diff --git a/.gitbook/includes/feature-request.md b/.gitbook/includes/feature-request.md index f9a2c101f7..2225bc3eb9 100644 --- a/.gitbook/includes/feature-request.md +++ b/.gitbook/includes/feature-request.md @@ -1,7 +1,9 @@ --- -title: Feature Requests / Ideas +title: Feature Requests/Ideas --- -# Feature Requests/Ideas +*** + +# Feature Requests / Ideas We value your feedback and ideas. Please raise any [feature requests](https://github.com/CyberDrain/CIPP/issues/new?template=feature.yml) on GitHub. diff --git a/backend/Config/BaselineStandards/SharePoint Standards/SPDisableCustomScripts.json b/backend/Config/BaselineStandards/SharePoint Standards/SPDisableCustomScripts.json index fb5a8e4792..e2de4c364f 100644 --- a/backend/Config/BaselineStandards/SharePoint Standards/SPDisableCustomScripts.json +++ b/backend/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/backend/Config/ConversionTable.csv b/backend/Config/ConversionTable.csv index 2d0f3cadd3..cdd72dd19b 100644 --- a/backend/Config/ConversionTable.csv +++ b/backend/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/backend/Config/openapi.json b/backend/Config/openapi.json index 93b7120c51..2f995bd299 100644 --- a/backend/Config/openapi.json +++ b/backend/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/backend/Config/standards.json b/backend/Config/standards.json index f5571101c8..62a8932f62 100644 --- a/backend/Config/standards.json +++ b/backend/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/backend/Modules/AzBobbyTables/3.6.2/AzBobbyTables.PS.dll b/backend/Modules/AzBobbyTables/3.6.2/AzBobbyTables.PS.dll index 7fa6ef290c..9bcca4d40c 100644 Binary files a/backend/Modules/AzBobbyTables/3.6.2/AzBobbyTables.PS.dll and b/backend/Modules/AzBobbyTables/3.6.2/AzBobbyTables.PS.dll differ diff --git a/backend/Modules/AzBobbyTables/3.6.2/dependencies/AzBobbyTables.Core.dll b/backend/Modules/AzBobbyTables/3.6.2/dependencies/AzBobbyTables.Core.dll index 6ff38bce3e..61d7298961 100644 Binary files a/backend/Modules/AzBobbyTables/3.6.2/dependencies/AzBobbyTables.Core.dll and b/backend/Modules/AzBobbyTables/3.6.2/dependencies/AzBobbyTables.Core.dll differ diff --git a/backend/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/BEC/Push-BECRun.ps1 b/backend/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/BEC/Push-BECRun.ps1 index 1708de199a..d4f9a8b2f8 100644 --- a/backend/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/BEC/Push-BECRun.ps1 +++ b/backend/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/backend/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Mailbox Permissions/Push-GetCalendarPermissionsBatch.ps1 b/backend/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Mailbox Permissions/Push-GetCalendarPermissionsBatch.ps1 index ab0b934994..66424361f4 100644 --- a/backend/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Mailbox Permissions/Push-GetCalendarPermissionsBatch.ps1 +++ b/backend/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/backend/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Mailbox Permissions/Push-StoreMailboxPermissions.ps1 b/backend/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Mailbox Permissions/Push-StoreMailboxPermissions.ps1 index c05728a208..3acec7b68d 100644 --- a/backend/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Mailbox Permissions/Push-StoreMailboxPermissions.ps1 +++ b/backend/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/backend/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Maintenance/Push-TableCleanupTask.ps1 b/backend/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Maintenance/Push-TableCleanupTask.ps1 index 6134363987..3d610ace04 100644 --- a/backend/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Maintenance/Push-TableCleanupTask.ps1 +++ b/backend/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/backend/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/SharePoint Sharing/Push-DBCacheSharePointSiteSharingLinks.ps1 b/backend/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/SharePoint Sharing/Push-DBCacheSharePointSiteSharingLinks.ps1 index 42bd5b3e68..d77f6d5119 100644 --- a/backend/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/SharePoint Sharing/Push-DBCacheSharePointSiteSharingLinks.ps1 +++ b/backend/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/SharePoint Sharing/Push-DBCacheSharePointSiteSharingLinks.ps1 @@ -1,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/backend/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/SharePoint Sharing/Push-StoreSharePointSharingLinks.ps1 b/backend/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/SharePoint Sharing/Push-StoreSharePointSharingLinks.ps1 index 40532b7f4b..9e7f239965 100644 --- a/backend/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/SharePoint Sharing/Push-StoreSharePointSharingLinks.ps1 +++ b/backend/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/SharePoint Sharing/Push-StoreSharePointSharingLinks.ps1 @@ -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/backend/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertHuntressRogueApps.ps1 b/backend/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertHuntressRogueApps.ps1 index ec58292bba..a66e081963 100644 --- a/backend/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertHuntressRogueApps.ps1 +++ b/backend/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/backend/Modules/CIPPCore/Public/Add-CIPPDbItem.ps1 b/backend/Modules/CIPPCore/Public/Add-CIPPDbItem.ps1 index 029fd94972..963619ad3b 100644 --- a/backend/Modules/CIPPCore/Public/Add-CIPPDbItem.ps1 +++ b/backend/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/backend/Modules/CIPPCore/Public/Clear-CippDurables.ps1 b/backend/Modules/CIPPCore/Public/Clear-CippDurables.ps1 index 972f3d8fc8..16c3911c67 100644 --- a/backend/Modules/CIPPCore/Public/Clear-CippDurables.ps1 +++ b/backend/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/backend/Modules/CIPPCore/Public/Compare-CIPPIntuneObject.ps1 b/backend/Modules/CIPPCore/Public/Compare-CIPPIntuneObject.ps1 index 809df07d75..33c61b4575 100644 --- a/backend/Modules/CIPPCore/Public/Compare-CIPPIntuneObject.ps1 +++ b/backend/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/backend/Modules/CIPPCore/Public/Functions/Format-CIPPCAPolicy.ps1 b/backend/Modules/CIPPCore/Public/Functions/Format-CIPPCAPolicy.ps1 new file mode 100644 index 0000000000..f971898199 --- /dev/null +++ b/backend/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/backend/Modules/CIPPCore/Public/Functions/Remove-EmptyArrays.ps1 b/backend/Modules/CIPPCore/Public/Functions/Remove-EmptyArrays.ps1 deleted file mode 100644 index fd46b76e72..0000000000 --- a/backend/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/backend/Modules/CIPPCore/Public/Get-CIPPAzDatatableEntity.ps1 b/backend/Modules/CIPPCore/Public/Get-CIPPAzDatatableEntity.ps1 index 2adbd7f880..f932f73db4 100644 --- a/backend/Modules/CIPPCore/Public/Get-CIPPAzDatatableEntity.ps1 +++ b/backend/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/backend/Modules/CIPPCore/Public/Get-CIPPCVEReport.ps1 b/backend/Modules/CIPPCore/Public/Get-CIPPCVEReport.ps1 index 5cc40daccb..f995a1f470 100644 --- a/backend/Modules/CIPPCore/Public/Get-CIPPCVEReport.ps1 +++ b/backend/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/backend/Modules/CIPPCore/Public/Get-CIPPIntuneDefinitionIndex.ps1 b/backend/Modules/CIPPCore/Public/Get-CIPPIntuneDefinitionIndex.ps1 index d2d1b84dcd..9800e063d0 100644 --- a/backend/Modules/CIPPCore/Public/Get-CIPPIntuneDefinitionIndex.ps1 +++ b/backend/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/backend/Modules/CIPPCore/Public/Get-CIPPSPOTenant.ps1 b/backend/Modules/CIPPCore/Public/Get-CIPPSPOTenant.ps1 index c31d0457fe..bc42246db9 100644 --- a/backend/Modules/CIPPCore/Public/Get-CIPPSPOTenant.ps1 +++ b/backend/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/backend/Modules/CIPPCore/Public/Get-CIPPSecureScoreReport.ps1 b/backend/Modules/CIPPCore/Public/Get-CIPPSecureScoreReport.ps1 index fc212c145a..27e102d129 100644 --- a/backend/Modules/CIPPCore/Public/Get-CIPPSecureScoreReport.ps1 +++ b/backend/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/backend/Modules/CIPPCore/Public/Get-CIPPTestResultsTenants.ps1 b/backend/Modules/CIPPCore/Public/Get-CIPPTestResultsTenants.ps1 index 98823f972d..9c1c267925 100644 --- a/backend/Modules/CIPPCore/Public/Get-CIPPTestResultsTenants.ps1 +++ b/backend/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/backend/Modules/CIPPCore/Public/Get-DefenderCves.ps1 b/backend/Modules/CIPPCore/Public/Get-DefenderCves.ps1 index cf95befa60..50b42f0e09 100644 --- a/backend/Modules/CIPPCore/Public/Get-DefenderCves.ps1 +++ b/backend/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/backend/Modules/CIPPCore/Public/Get-DefenderTvmRaw.ps1 b/backend/Modules/CIPPCore/Public/Get-DefenderTvmRaw.ps1 index d16a51bb1e..01ae9151fb 100644 --- a/backend/Modules/CIPPCore/Public/Get-DefenderTvmRaw.ps1 +++ b/backend/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/backend/Modules/CIPPCore/Public/GraphHelper/Get-CIPPSharePointDomain.ps1 b/backend/Modules/CIPPCore/Public/GraphHelper/Get-CIPPSharePointDomain.ps1 new file mode 100644 index 0000000000..779d57a461 --- /dev/null +++ b/backend/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/backend/Modules/CIPPCore/Public/GraphHelper/Get-CIPPTable.ps1 b/backend/Modules/CIPPCore/Public/GraphHelper/Get-CIPPTable.ps1 index 122d0a82d8..992b3aaee1 100644 --- a/backend/Modules/CIPPCore/Public/GraphHelper/Get-CIPPTable.ps1 +++ b/backend/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/backend/Modules/CIPPCore/Public/GraphHelper/Get-CippExoErrorText.ps1 b/backend/Modules/CIPPCore/Public/GraphHelper/Get-CippExoErrorText.ps1 new file mode 100644 index 0000000000..5f895153ef --- /dev/null +++ b/backend/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/backend/Modules/CIPPCore/Public/GraphHelper/Get-SharePointAdminLink.ps1 b/backend/Modules/CIPPCore/Public/GraphHelper/Get-SharePointAdminLink.ps1 index 5186d09eae..472bb1c4d6 100644 --- a/backend/Modules/CIPPCore/Public/GraphHelper/Get-SharePointAdminLink.ps1 +++ b/backend/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/backend/Modules/CIPPCore/Public/GraphHelper/Initialize-CIPPTables.ps1 b/backend/Modules/CIPPCore/Public/GraphHelper/Initialize-CIPPTables.ps1 new file mode 100644 index 0000000000..a3cd0f708f --- /dev/null +++ b/backend/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/backend/Modules/CIPPCore/Public/GraphHelper/Remove-CIPPCache.ps1 b/backend/Modules/CIPPCore/Public/GraphHelper/Remove-CIPPCache.ps1 index a3756df1a4..1fb95d477f 100644 --- a/backend/Modules/CIPPCore/Public/GraphHelper/Remove-CIPPCache.ps1 +++ b/backend/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/backend/Modules/CIPPCore/Public/GraphHelper/Resolve-CippExoBulkResult.ps1 b/backend/Modules/CIPPCore/Public/GraphHelper/Resolve-CippExoBulkResult.ps1 index c16d86a95a..d447f22939 100644 --- a/backend/Modules/CIPPCore/Public/GraphHelper/Resolve-CippExoBulkResult.ps1 +++ b/backend/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/backend/Modules/CIPPCore/Public/GraphHelper/Unregister-CIPPTable.ps1 b/backend/Modules/CIPPCore/Public/GraphHelper/Unregister-CIPPTable.ps1 new file mode 100644 index 0000000000..fdc3795501 --- /dev/null +++ b/backend/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/backend/Modules/CIPPCore/Public/Invoke-CIPPSharePointTemplateDeploy.ps1 b/backend/Modules/CIPPCore/Public/Invoke-CIPPSharePointTemplateDeploy.ps1 index d21d1a67a1..706a758569 100644 --- a/backend/Modules/CIPPCore/Public/Invoke-CIPPSharePointTemplateDeploy.ps1 +++ b/backend/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/backend/Modules/CIPPCore/Public/New-CIPPCAPolicy.ps1 b/backend/Modules/CIPPCore/Public/New-CIPPCAPolicy.ps1 index 8b1be67c6e..3a151d084e 100644 --- a/backend/Modules/CIPPCore/Public/New-CIPPCAPolicy.ps1 +++ b/backend/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/backend/Modules/CIPPCore/Public/New-CIPPSharepointSite.ps1 b/backend/Modules/CIPPCore/Public/New-CIPPSharepointSite.ps1 index 23b9be5142..d8be8d9368 100644 --- a/backend/Modules/CIPPCore/Public/New-CIPPSharepointSite.ps1 +++ b/backend/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/backend/Modules/CIPPCore/Public/Remove-CIPPCalendarPermissions.ps1 b/backend/Modules/CIPPCore/Public/Remove-CIPPCalendarPermissions.ps1 index 7703e88cc7..afb54a02e8 100644 --- a/backend/Modules/CIPPCore/Public/Remove-CIPPCalendarPermissions.ps1 +++ b/backend/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/backend/Modules/CIPPCore/Public/Remove-CIPPFolderPermission.ps1 b/backend/Modules/CIPPCore/Public/Remove-CIPPFolderPermission.ps1 new file mode 100644 index 0000000000..26f799dfc2 --- /dev/null +++ b/backend/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/backend/Modules/CIPPCore/Public/Resolve-CIPPFolderPermissionUser.ps1 b/backend/Modules/CIPPCore/Public/Resolve-CIPPFolderPermissionUser.ps1 new file mode 100644 index 0000000000..472e611d73 --- /dev/null +++ b/backend/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/backend/Modules/CIPPCore/Public/Set-CIPPCalendarPermission.ps1 b/backend/Modules/CIPPCore/Public/Set-CIPPCalendarPermission.ps1 index 8435ecf7db..2e4075535c 100644 --- a/backend/Modules/CIPPCore/Public/Set-CIPPCalendarPermission.ps1 +++ b/backend/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/backend/Modules/CIPPCore/Public/Set-CIPPContactPermission.ps1 b/backend/Modules/CIPPCore/Public/Set-CIPPContactPermission.ps1 index ae61cc3b34..9982404e9d 100644 --- a/backend/Modules/CIPPCore/Public/Set-CIPPContactPermission.ps1 +++ b/backend/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/backend/Modules/CIPPCore/Public/Set-CIPPSPOTenant.ps1 b/backend/Modules/CIPPCore/Public/Set-CIPPSPOTenant.ps1 index a3a870aacc..fe1188e1ca 100644 --- a/backend/Modules/CIPPCore/Public/Set-CIPPSPOTenant.ps1 +++ b/backend/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/backend/Modules/CIPPCore/Public/Test-CIPPAccessPermissions.ps1 b/backend/Modules/CIPPCore/Public/Test-CIPPAccessPermissions.ps1 index 6fa6250206..99845e0216 100644 --- a/backend/Modules/CIPPCore/Public/Test-CIPPAccessPermissions.ps1 +++ b/backend/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/backend/Modules/CIPPDB/Public/DBCache/ConvertTo-CIPPSharingLinksKeySegment.ps1 b/backend/Modules/CIPPDB/Public/DBCache/ConvertTo-CIPPSharingLinksKeySegment.ps1 new file mode 100644 index 0000000000..e08d65f9e2 --- /dev/null +++ b/backend/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/backend/Modules/CIPPDB/Public/DBCache/Get-CIPPSharingLinksDriveState.ps1 b/backend/Modules/CIPPDB/Public/DBCache/Get-CIPPSharingLinksDriveState.ps1 new file mode 100644 index 0000000000..ce4c5e9c27 --- /dev/null +++ b/backend/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/backend/Modules/CIPPDB/Public/DBCache/Get-CIPPSharingLinksRowKeysByPrefix.ps1 b/backend/Modules/CIPPDB/Public/DBCache/Get-CIPPSharingLinksRowKeysByPrefix.ps1 new file mode 100644 index 0000000000..7acc5b8bab --- /dev/null +++ b/backend/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/backend/Modules/CIPPDB/Public/DBCache/Remove-CIPPSharingLinksRowsByPrefix.ps1 b/backend/Modules/CIPPDB/Public/DBCache/Remove-CIPPSharingLinksRowsByPrefix.ps1 new file mode 100644 index 0000000000..851bc20002 --- /dev/null +++ b/backend/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/backend/Modules/CIPPDB/Public/DBCache/Resolve-CIPPSharingLinksTenantFilter.ps1 b/backend/Modules/CIPPDB/Public/DBCache/Resolve-CIPPSharingLinksTenantFilter.ps1 new file mode 100644 index 0000000000..67e570b495 --- /dev/null +++ b/backend/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/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheDefenderCVEs.ps1 b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheDefenderCVEs.ps1 index 8a31574e3d..5434c6c6a2 100644 --- a/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheDefenderCVEs.ps1 +++ b/backend/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/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheSharePointSharingLinks.ps1 b/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheSharePointSharingLinks.ps1 index 0516291c1b..49e9bca51f 100644 --- a/backend/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheSharePointSharingLinks.ps1 +++ b/backend/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/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecAzBobbyTables.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecAzBobbyTables.ps1 index aaeae20caf..4c7e5a7a4a 100644 --- a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecAzBobbyTables.ps1 +++ b/backend/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/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecDurableFunctions.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecDurableFunctions.ps1 index f8655fbec7..274496c2fb 100644 --- a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecDurableFunctions.ps1 +++ b/backend/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/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/MCP/Invoke-PublicMcpRegister.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/MCP/Invoke-PublicMcpRegister.ps1 index 7f01524715..d98869d4b6 100644 --- a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/MCP/Invoke-PublicMcpRegister.ps1 +++ b/backend/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/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecAddTrustedIP.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecAddTrustedIP.ps1 index b44a445229..3aef21a287 100644 --- a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecAddTrustedIP.ps1 +++ b/backend/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/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecBrandingSettings.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecBrandingSettings.ps1 index cfd3622676..886a547bcd 100644 --- a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecBrandingSettings.ps1 +++ b/backend/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/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ExecModifyCalPerms.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ExecModifyCalPerms.ps1 index 0325b100c5..e584b3457e 100644 --- a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ExecModifyCalPerms.ps1 +++ b/backend/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/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ExecModifyContactPerms.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ExecModifyContactPerms.ps1 index badb034c48..ca5cd0247d 100644 --- a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ExecModifyContactPerms.ps1 +++ b/backend/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/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ListCalendarPermissions.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ListCalendarPermissions.ps1 index 803a7665e2..1e96c919ce 100644 --- a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ListCalendarPermissions.ps1 +++ b/backend/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/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ListContactPermissions.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ListContactPermissions.ps1 index ea70f90b71..bee47059e2 100644 --- a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ListContactPermissions.ps1 +++ b/backend/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/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecAddCippCveException.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecAddCippCveException.ps1 index 060ac28b2d..e0433cc56f 100644 --- a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecAddCippCveException.ps1 +++ b/backend/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/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecBECCheck.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecBECCheck.ps1 index dbc08b1923..00058a5319 100644 --- a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecBECCheck.ps1 +++ b/backend/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/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSharepointAdminUrl.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSharepointAdminUrl.ps1 index a630624ad9..c0e3d31bd1 100644 --- a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSharepointAdminUrl.ps1 +++ b/backend/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/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Tenant/Invoke-ListTenants.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Tenant/Invoke-ListTenants.ps1 index 5600b16154..70a461acb5 100644 --- a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Tenant/Invoke-ListTenants.ps1 +++ b/backend/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/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Conditional/Invoke-ExecEditCAPolicyFull.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Conditional/Invoke-ExecEditCAPolicyFull.ps1 index 40c347946f..73dcab5cea 100644 --- a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Conditional/Invoke-ExecEditCAPolicyFull.ps1 +++ b/backend/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/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecCopilotSettings.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecCopilotSettings.ps1 index 7ba11d2e1c..229aff09dc 100644 --- a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecCopilotSettings.ps1 +++ b/backend/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/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ListCopilotSettings.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ListCopilotSettings.ps1 index f9dd2608a8..05456775b0 100644 --- a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ListCopilotSettings.ps1 +++ b/backend/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/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ListTenantAlignment.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ListTenantAlignment.ps1 index 9dd728b314..d74b06b336 100644 --- a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ListTenantAlignment.ps1 +++ b/backend/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/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Tests/Invoke-ListTestResultsTenants.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Tests/Invoke-ListTestResultsTenants.ps1 index 95deffb9ba..0c6523350c 100644 --- a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Tests/Invoke-ListTestResultsTenants.ps1 +++ b/backend/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/backend/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardCopilotSettings.ps1 b/backend/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardCopilotSettings.ps1 index 477a006931..9b4795886e 100644 --- a/backend/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardCopilotSettings.ps1 +++ b/backend/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/backend/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardDisableExchangeOnlinePowerShell.ps1 b/backend/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardDisableExchangeOnlinePowerShell.ps1 index bd04a38fc7..290032a1ff 100644 --- a/backend/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardDisableExchangeOnlinePowerShell.ps1 +++ b/backend/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/backend/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardDisableM365GroupUsers.ps1 b/backend/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardDisableM365GroupUsers.ps1 index 07346db213..0ddfa0f568 100644 --- a/backend/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardDisableM365GroupUsers.ps1 +++ b/backend/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/backend/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardReusableSettingsTemplate.ps1 b/backend/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardReusableSettingsTemplate.ps1 index 99f4b13fa6..6391728f00 100644 --- a/backend/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardReusableSettingsTemplate.ps1 +++ b/backend/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/backend/Modules/CippExtensions/Public/Hudu/Invoke-HuduExtensionSync.ps1 b/backend/Modules/CippExtensions/Public/Hudu/Invoke-HuduExtensionSync.ps1 index 5b33e81a4f..b2ff5e4133 100644 --- a/backend/Modules/CippExtensions/Public/Hudu/Invoke-HuduExtensionSync.ps1 +++ b/backend/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/backend/Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneTenantSync.ps1 b/backend/Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneTenantSync.ps1 index ab3d64cecf..b6bdf1b909 100644 --- a/backend/Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneTenantSync.ps1 +++ b/backend/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/backend/Tests/Api/BrandingImageDelete.Tests.ps1 b/backend/Tests/Api/BrandingImageDelete.Tests.ps1 new file mode 100644 index 0000000000..28099b6393 --- /dev/null +++ b/backend/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/backend/Tests/DBCache/Push-StoreMailboxPermissions.Tests.ps1 b/backend/Tests/DBCache/Push-StoreMailboxPermissions.Tests.ps1 new file mode 100644 index 0000000000..b721381468 --- /dev/null +++ b/backend/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/backend/Tests/DBCache/Set-CIPPDBCacheDefenderCVEs.Flush.Tests.ps1 b/backend/Tests/DBCache/Set-CIPPDBCacheDefenderCVEs.Flush.Tests.ps1 index 2a45e14b64..137fd91f04 100644 --- a/backend/Tests/DBCache/Set-CIPPDBCacheDefenderCVEs.Flush.Tests.ps1 +++ b/backend/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/backend/Tests/DBCache/Set-CIPPDBCacheDefenderCVEs.Tests.ps1 b/backend/Tests/DBCache/Set-CIPPDBCacheDefenderCVEs.Tests.ps1 index c0a7944ce1..d04d4025a0 100644 --- a/backend/Tests/DBCache/Set-CIPPDBCacheDefenderCVEs.Tests.ps1 +++ b/backend/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/backend/Tests/DBCache/SharePointSharingLinks.Resume.Tests.ps1 b/backend/Tests/DBCache/SharePointSharingLinks.Resume.Tests.ps1 new file mode 100644 index 0000000000..f2a70e567d --- /dev/null +++ b/backend/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/backend/Tests/Endpoint/Invoke-EditGroup.Tests.ps1 b/backend/Tests/Endpoint/Invoke-EditGroup.Tests.ps1 index db7acbbeae..cfdd3d265e 100644 --- a/backend/Tests/Endpoint/Invoke-EditGroup.Tests.ps1 +++ b/backend/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/backend/Tests/Endpoint/Invoke-ExecAddCippCveException.Tests.ps1 b/backend/Tests/Endpoint/Invoke-ExecAddCippCveException.Tests.ps1 new file mode 100644 index 0000000000..15217ccba0 --- /dev/null +++ b/backend/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/backend/Tests/Endpoint/Invoke-ExecEditCAPolicyFull.Tests.ps1 b/backend/Tests/Endpoint/Invoke-ExecEditCAPolicyFull.Tests.ps1 new file mode 100644 index 0000000000..c46d26d876 --- /dev/null +++ b/backend/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/backend/Tests/Endpoint/Invoke-ListTenantAlignment.Summary.Tests.ps1 b/backend/Tests/Endpoint/Invoke-ListTenantAlignment.Summary.Tests.ps1 new file mode 100644 index 0000000000..77f0ccdcbf --- /dev/null +++ b/backend/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/backend/Tests/Private/Add-CIPPDbItem.Tests.ps1 b/backend/Tests/Private/Add-CIPPDbItem.Tests.ps1 index c7c885ab6b..1a982fdbf1 100644 --- a/backend/Tests/Private/Add-CIPPDbItem.Tests.ps1 +++ b/backend/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/backend/Tests/Private/Add-CIPPGroupMember.Tests.ps1 b/backend/Tests/Private/Add-CIPPGroupMember.Tests.ps1 index f5311aa555..efc29d4d0d 100644 --- a/backend/Tests/Private/Add-CIPPGroupMember.Tests.ps1 +++ b/backend/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/backend/Tests/Private/Format-CIPPCAPolicy.Tests.ps1 b/backend/Tests/Private/Format-CIPPCAPolicy.Tests.ps1 new file mode 100644 index 0000000000..0401056ac5 --- /dev/null +++ b/backend/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/backend/Tests/Private/Get-CIPPCVEReport.Tests.ps1 b/backend/Tests/Private/Get-CIPPCVEReport.Tests.ps1 new file mode 100644 index 0000000000..a17aa0c425 --- /dev/null +++ b/backend/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/backend/Tests/Private/Get-CIPPTable.Tests.ps1 b/backend/Tests/Private/Get-CIPPTable.Tests.ps1 new file mode 100644 index 0000000000..ace3d9e5c8 --- /dev/null +++ b/backend/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/backend/Tests/Private/Remove-CIPPGroupMember.Tests.ps1 b/backend/Tests/Private/Remove-CIPPGroupMember.Tests.ps1 index cb0a8de141..50d1d86f05 100644 --- a/backend/Tests/Private/Remove-CIPPGroupMember.Tests.ps1 +++ b/backend/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/backend/Tests/Private/Remove-CIPPGroups.Tests.ps1 b/backend/Tests/Private/Remove-CIPPGroups.Tests.ps1 index fa07929a82..cecad9ad80 100644 --- a/backend/Tests/Private/Remove-CIPPGroups.Tests.ps1 +++ b/backend/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/backend/Tests/Private/Resolve-CippExoBulkResult.Tests.ps1 b/backend/Tests/Private/Resolve-CippExoBulkResult.Tests.ps1 index 12cef2ec7b..c9a87605db 100644 --- a/backend/Tests/Private/Resolve-CippExoBulkResult.Tests.ps1 +++ b/backend/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/backend/Tests/Reports/Get-CIPPSecureScoreReport.Tests.ps1 b/backend/Tests/Reports/Get-CIPPSecureScoreReport.Tests.ps1 new file mode 100644 index 0000000000..8026ff340f --- /dev/null +++ b/backend/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/backend/Tests/Reports/Get-CIPPTestResultsTenants.CountsOnly.Tests.ps1 b/backend/Tests/Reports/Get-CIPPTestResultsTenants.CountsOnly.Tests.ps1 new file mode 100644 index 0000000000..193ca8a060 --- /dev/null +++ b/backend/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/backend/Tests/Standards/Compare-CIPPIntuneObject.Catalog.Tests.ps1 b/backend/Tests/Standards/Compare-CIPPIntuneObject.Catalog.Tests.ps1 index 33a5fd4667..7483bfc84e 100644 --- a/backend/Tests/Standards/Compare-CIPPIntuneObject.Catalog.Tests.ps1 +++ b/backend/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/backend/Tests/Standards/Compare-CIPPIntuneObject.ReusablePolicySetting.Tests.ps1 b/backend/Tests/Standards/Compare-CIPPIntuneObject.ReusablePolicySetting.Tests.ps1 new file mode 100644 index 0000000000..223b518ac1 --- /dev/null +++ b/backend/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/backend/Tests/Standards/Invoke-CIPPStandardReusableSettingsTemplate.Tests.ps1 b/backend/Tests/Standards/Invoke-CIPPStandardReusableSettingsTemplate.Tests.ps1 index 82f3fbf7b1..ce0f57f681 100644 --- a/backend/Tests/Standards/Invoke-CIPPStandardReusableSettingsTemplate.Tests.ps1 +++ b/backend/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/backend/version_latest.txt b/backend/version_latest.txt index f60f38dc9b..0b04f50e23 100644 --- a/backend/version_latest.txt +++ b/backend/version_latest.txt @@ -1 +1 @@ -10.8.3 \ No newline at end of file +10.8.4 \ No newline at end of file diff --git a/build/config/appsettings.Development.json b/build/config/appsettings.Development.json index 5ce40bdaf1..9dacca6c46 100644 --- a/build/config/appsettings.Development.json +++ b/build/config/appsettings.Development.json @@ -58,11 +58,41 @@ "HttpPoolSize": 6, "BgPoolSize": 8, "SkuProfiles": [ - { "SkuEnv": "WEBSITE_SKU", "Sku": "Basic", "Cpu": 1, "HttpPoolSize": 2, "BgPoolSize": 2 }, - { "SkuEnv": "WEBSITE_SKU", "Sku": "Basic", "Cpu": 2, "HttpPoolSize": 6, "BgPoolSize": 8 }, - { "SkuEnv": "WEBSITE_SKU", "Sku": "Basic", "Cpu": 4, "HttpPoolSize": 8, "BgPoolSize": 12 }, - { "SkuEnv": "WEBSITE_SKU", "Sku": "PremiumV3", "Cpu": 1, "HttpPoolSize": 6, "BgPoolSize": 8 }, - { "SkuEnv": "WEBSITE_SKU", "Sku": "PremiumV3", "Cpu": 2, "HttpPoolSize": 8, "BgPoolSize": 12 } + { + "SkuEnv": "WEBSITE_SKU", + "Sku": "Basic", + "Cpu": 1, + "HttpPoolSize": 2, + "BgPoolSize": 2 + }, + { + "SkuEnv": "WEBSITE_SKU", + "Sku": "Basic", + "Cpu": 2, + "HttpPoolSize": 6, + "BgPoolSize": 8 + }, + { + "SkuEnv": "WEBSITE_SKU", + "Sku": "Basic", + "Cpu": 4, + "HttpPoolSize": 8, + "BgPoolSize": 12 + }, + { + "SkuEnv": "WEBSITE_SKU", + "Sku": "PremiumV3", + "Cpu": 1, + "HttpPoolSize": 6, + "BgPoolSize": 8 + }, + { + "SkuEnv": "WEBSITE_SKU", + "Sku": "PremiumV3", + "Cpu": 2, + "HttpPoolSize": 8, + "BgPoolSize": 12 + } ], "HttpTimeoutSeconds": 600, "BgTimeoutSeconds": 1200, @@ -77,6 +107,7 @@ }, "WarmupMode": "AfterReady", "WarmupScripts": [ + "Initialize-CIPPTables", "Initialize-CIPPAuth", "Initialize-CIPPTimezone", "Update-CIPPInstanceHostname", @@ -89,6 +120,11 @@ "Module": "CIPPCore", "Variable": "classictoken", "CacheKey": "ClassicTokenCache" + }, + { + "Module": "CIPPCore", + "Variable": "CIPPEnsuredTables", + "CacheKey": "CIPPTableCache" } ], "PostInitScripts": [], @@ -140,7 +176,9 @@ "EndpointTtl": {}, "ExcludedEndpoints": [], "RequiredParam": "tenantFilter", - "ExcludedParamValues": ["AllTenants"] + "ExcludedParamValues": [ + "AllTenants" + ] }, "Scripts": { "HttpModules": [ @@ -163,4 +201,4 @@ "PermitPerWindow": 100 } } -} \ No newline at end of file +} diff --git a/build/config/appsettings.Local.Development.json b/build/config/appsettings.Local.Development.json index e024e1c294..b16b4c68b9 100644 --- a/build/config/appsettings.Local.Development.json +++ b/build/config/appsettings.Local.Development.json @@ -71,6 +71,7 @@ }, "WarmupMode": "AfterReady", "WarmupScripts": [ + "Initialize-CIPPTables", "Initialize-CIPPAuth", "Initialize-CIPPTimezone", "Update-CIPPInstanceHostname", @@ -83,6 +84,11 @@ "Module": "CIPPCore", "Variable": "classictoken", "CacheKey": "ClassicTokenCache" + }, + { + "Module": "CIPPCore", + "Variable": "CIPPEnsuredTables", + "CacheKey": "CIPPTableCache" } ], "PostInitScripts": [], @@ -137,7 +143,9 @@ "EndpointTtl": {}, "ExcludedEndpoints": [], "RequiredParam": "tenantFilter", - "ExcludedParamValues": ["AllTenants"] + "ExcludedParamValues": [ + "AllTenants" + ] }, "Scripts": { "HttpModules": [ @@ -157,4 +165,4 @@ } } } -} \ No newline at end of file +} diff --git a/build/config/appsettings.Production.json b/build/config/appsettings.Production.json index 7abe42fef3..a7759eaad2 100644 --- a/build/config/appsettings.Production.json +++ b/build/config/appsettings.Production.json @@ -102,6 +102,7 @@ }, "WarmupMode": "AfterReady", "WarmupScripts": [ + "Initialize-CIPPTables", "Initialize-CIPPAuth", "Initialize-CIPPTimezone", "Update-CIPPInstanceHostname", @@ -114,6 +115,11 @@ "Module": "CIPPCore", "Variable": "classictoken", "CacheKey": "ClassicTokenCache" + }, + { + "Module": "CIPPCore", + "Variable": "CIPPEnsuredTables", + "CacheKey": "CIPPTableCache" } ], "PostInitScripts": [], @@ -190,4 +196,4 @@ "PermitPerWindow": 100 } } -} \ No newline at end of file +} diff --git a/docs/api-documentation/endpoints.md b/docs/api-documentation/endpoints.md index 77275b3889..c7b6025ac8 100644 --- a/docs/api-documentation/endpoints.md +++ b/docs/api-documentation/endpoints.md @@ -10,6 +10,4 @@ Everything CIPP does in the frontend is represented by an API endpoint. For furt {% @cipp-external-webpage-block/cyberdrain url="https://cipp-ashe.github.io/cipp-oas-generator/" fullWidth="true" %} -*** - {% include "../../.gitbook/includes/feature-request.md" %} diff --git a/docs/api-documentation/setup-and-authentication.md b/docs/api-documentation/setup-and-authentication.md index 5cdada08a4..95ccb8afcd 100644 --- a/docs/api-documentation/setup-and-authentication.md +++ b/docs/api-documentation/setup-and-authentication.md @@ -170,6 +170,4 @@ This module is created and maintained by a community member. With CIPP's rapid d After making changes, wait a minute or two before retrying authentication since propagation isn't always instant. If the error persists, try re-consenting to the app permissions. -*** - {% include "../../.gitbook/includes/feature-request.md" %} diff --git a/docs/dev-documentation/contributing-to-the-documentation.md b/docs/dev-documentation/contributing-to-the-documentation.md index 88a4fc280c..4025069982 100644 --- a/docs/dev-documentation/contributing-to-the-documentation.md +++ b/docs/dev-documentation/contributing-to-the-documentation.md @@ -1,13 +1,247 @@ +--- +description: How to write, structure and submit changes to the CIPP documentation. +--- + # Contributing to the Documentation -### **Looking for documentation contributors** +FOSS ([Free and Open-Source Software](https://en.wikipedia.org/wiki/Free_and_open-source_software)) lives and dies by the contributions of its community, and documentation is the part that most often goes unwritten. If you have spotted a gap, a page that is out of date, or a feature you understand well enough to explain, this page tells you where the docs live, how to write a page that matches the rest of the set, and how to get your change published. + +## Where the Documentation Lives + +Everything published at [docs.cipp.app](https://docs.cipp.app) is built from the `docs/` folder of the [CyberDrain/CIPP](https://github.com/CyberDrain/CIPP) repository on the **`dev`** branch. GitBook is two-way synced with that branch, so a change merged into `dev` appears on the site, and an edit made in GitBook is committed back to `dev`. + +A few consequences worth knowing before you start: + +* `dev` is the only branch that matters for documentation. Never open a documentation pull request against `main`. +* Always `git fetch` and start from the current `dev`. GitBook commits to the branch on its own, so your local copy goes stale faster than a normal code branch. +* Images and other assets live in `.gitbook/assets/`, and reusable snippets live in `.gitbook/includes/`, both at the root of the repository rather than under `docs/`. + +{% hint style="info" %} +`docs/SUMMARY.md` is the table of contents. A page that is not listed in it is orphaned, so it will not appear in the navigation even though the file exists. +{% endhint %} + +## How to Contribute a Change + +Contributions come in through GitHub, whether you are fixing a typo or writing a page from scratch. The **Edit on GitHub** option on every published page takes you straight to that page's source file in the repository, which is the fastest way to start. Editing in GitBook itself is reserved for maintainers and for collaborators who have been invited to the space. + +For a small fix you never have to leave the browser. Follow the edit option, use the pencil icon on GitHub, and GitHub forks the repository and opens the pull request for you. For a new page, a set of related pages, anything that moves or renames files, or a change that touches `docs/SUMMARY.md`, work locally instead: + +{% stepper %} +{% step %} +### Fork and branch + +Fork [CyberDrain/CIPP](https://github.com/CyberDrain/CIPP), then create a branch from `dev`. +{% endstep %} + +{% step %} +### Write the page + +Follow the structure and style rules below. Keep the change to documentation only, as mixing `docs/` changes into a code pull request makes both harder to review. +{% endstep %} + +{% step %} +### Add it to the navigation + +Add or update the entry in `docs/SUMMARY.md`, in the position the page should appear in the sidebar. +{% endstep %} + +{% step %} +### Open the pull request against `dev` + +Use a [Conventional Commits](https://www.conventionalcommits.org/) title, for example `docs(identity): document the Users page`. Say in the description which pages were added, moved or renamed. +{% endstep %} +{% endstepper %} + +{% hint style="warning" %} +Renaming or moving a page changes its published URL, which breaks existing links and bookmarks. If you do not search the remaining documentation for references, call it out in the pull request so maintainers can decide whether a redirect is needed. +{% endhint %} + +## Choosing Where a Page Goes + +The documentation is split into a small number of top-level sections: + +| Section | What belongs there | +| --------------------- | ----------------------------------------------------------------------------------------- | +| `setup/` | Installing, configuring, maintaining and implementing CIPP. | +| `user-documentation/` | One page per screen in the CIPP interface, plus shared behaviour under `shared-features/`. | +| `api-documentation/` | Working with the CIPP API. | +| `troubleshooting/` | Diagnosing and fixing problems. | +| `dev-documentation/` | Developing CIPP itself, including this page. | + +### The path of a user documentation page must mirror the UI route + +This is functional, not cosmetic. The **Check the Documentation** entry in the in-app speed dial builds its link by appending the current router path to `https://docs.cipp.app/user-documentation`, so a page filed anywhere else is a broken link from inside the product. + +Derive the path from `frontend/src/pages/**`, never from a tidier-looking grouping: + +* A route directory's `index.js` becomes `.md`, or `/README.md` when the route also has sibling pages. +* Sibling route files such as `add.jsx`, `edit.jsx` or `policy.jsx` become `add.md`, `edit.md` and `policy.md` in that folder. + +### Pages versus drawers + +A sub-page is only warranted when the button in the interface **navigates to a routed page**. Buttons that open a drawer or off-canvas panel never leave the list page, so their fields are documented inside the parent page rather than as a page of their own. Decide this from the frontend: a routed page has a file under `frontend/src/pages/**` and the parent triggers it with `link:` or `href=`, while a drawer is a `Cipp*Drawer` component imported into the parent with no matching route file. + +When a routed page does exist, reference it from the parent: + +``` +{% content-ref url="add.md" %} +[add.md](add.md) +{% endcontent-ref %} +``` + +### Moving a page breaks four other things + +If you move or rename a file, check all of these before opening the pull request: + +1. The `{% include %}` path, whose depth is counted in directories from the file's folder up to the repository root. +2. Relative links inside the moved file, especially cross-section ones such as `../../identity/...`. +3. Inbound links from other pages. Grep the whole `docs/` tree for the old filename. +4. The entry in `docs/SUMMARY.md`. + +## Finding Out What a Page Actually Does + +Documentation that guesses is worse than no documentation. Before describing a screen, read the code behind it: + +* The screen itself is at `frontend/src/pages//index.js`. It is often only wiring, so follow the components it imports for the real logic. +* On a list page, searching that file for `simpleColumns`, `label:`, `link:`, `hideBulk`, `offCanvas`, `cardButton`, `filterName` and `condition` surfaces the columns, actions, bulk behaviour and drawers in a single pass. +* For what an action does once it is clicked, read the matching `Invoke-.ps1` in the API. + +Never guess what a field means. If you cannot ground a statement in the code or in vendor documentation, leave it out and flag it in the pull request instead. + +## Page Anatomy + +A user documentation page is laid out like this: + +* An H1 with the page title, matching the name of the screen. +* An unheaded introduction paragraph saying what the page is for and when someone would use it. +* H2 headings for each section. +* The feature request include as the very last line, with no `***` before it, because the include brings its own divider. + +``` +{% include "../../../.gitbook/includes/feature-request.md" %} +``` + +Pages under `dev-documentation/` do not carry the include. + +### List and table pages + +Use this order: introduction, `## Action Buttons`, `## Filters`, `## Table Details`, `## Table Actions`. Action buttons are documented in a `
` block each, with the button name as the `` with the exception of actions that lead to a separate page. Those use the page link reference: + +``` +{% content-ref url="add.md" %} +[add.md](add.md) +{% endcontent-ref %} +``` + +### Form and settings pages + +Use one H2 per card on screen, with the card names reproduced exactly as they appear. Under each, describe the fields in a `| Setting | Description |` or `| Field | Description |` table, grouped under `**Bold**` mini-headings where the form itself is divided into sections. + +### Multi-step processes + +Use `{% stepper %}` and `{% step %}` GitBook blocks rather than a numbered list. + +## Writing Style + +**Write about outcomes, not implementation.** Describe what happens for the person clicking the button. "Blocks the user from signing in", not "calls the sign-in state endpoint". Backend mechanics stay out of user documentation: internal function names, cmdlet sequences, cache windows, queue and orchestrator detail, merge logic and role strings. + +**Be direct.** Present tense, third person, and "you" when the reader is doing something. No hedging, no filler, and do not open a section by restating its heading. + +**Use British English in prose:** licence as a noun and license as a verb, organisation, authorise, behaviour, customise, catalogue, analyse, synchronise. Vendor product names and on-screen labels are reproduced **verbatim**, including American spelling, so it is "Microsoft 365 admin center" and "Partner Center". Mirror the interface, do not tidy it up. + +**Other conventions:** + +* Oxford comma. +* Write "for example", never "e.g." or "i.e.". +* No em dashes anywhere. Restructure with commas, colons, brackets or two sentences. +* **Bold** for button and UI labels, `backticks` for API values, cmdlets and literal settings. + +### What to leave off a page + +The following are covered centrally in [table-features.md](../user-documentation/shared-features/table-features.md "mention") and should not be repeated: + +* Per-page All Tenants notes, unless the page genuinely behaves differently, such as queueing a background job. +* Explanations of Live and Cached data. +* General filtering, searching, exporting and column-picker behaviour. Filter _presets_ defined by a page are documented, the mechanism is not. +* Sentences describing where the page sits in the navigation. + +### Bugs do not go on pages + +If you find a defect, a dead route or behaviour that looks wrong, raise a [bug report](https://github.com/CyberDrain/CIPP/issues/new?template=bug.yml) rather than documenting the fault on the page. Mention the issue number in your pull request so a maintainer can decide whether the page should describe the current behaviour or the intended behaviour. + +## Tables + +### Table Details + +If the grid is essentially the raw return of a documented upstream object, do not hand-write a column table. Point at the upstream documentation instead, which stays correct as that object changes: + +* Graph: The properties returned are for the Graph resource type `user`. For more information on the properties please see the Graph documentation. +* PowerShell: The properties returned are for the Exchange Online PowerShell command `Get-Mailbox`. For more information on the command please see the Microsoft documentation. + +Write a descriptive `| Column | Description |` table only when there is no documented upstream object, or when CIPP joins, computes, renames or derives any of the columns. The test: would a reader following the link find every column explained? If not, write the table. + +Column names in the first cell are the friendly display name shown in the interface, in Title Case with spaces, never raw camelCase and never in backticks. Do not document the Tenant or Cache Timestamp columns, as they are added automatically in All Tenants and cached modes. + +### Table Actions + +Table Actions is always an HTML table, because the third column has to render as checkboxes: + +```html +
ActionDescriptionBulk Action Available
Edit UserOpens the edit form for the selected user.false
+``` + +Values are a bare `true` or `false`, never a tick glyph. Inside cells use `` for literal values and `` for labels, as markdown formatting does not render there, and escape ampersands as `&`. + +An action offers a bulk equivalent if, and only if, it has no `link` and no `hideBulk: true`. Neither `multiPost` nor `showInActionsMenu` affects this, and an action with a `condition` still counts as available. + +Conditions **disable** an action, they do not hide it, so describe a conditional action as **greyed out** and put the condition in the description rather than in the checkbox column. + +Where the page has a row flyout, the last row of the table is always, word for word: + +```html +More InfoOpens the Extended Info flyout with the full details for the selected row.false +``` + +Leave the row out entirely if the page has no flyout. + +## GitBook Blocks + +| Block | When to use it | +| ------------------------------ | ---------------------------------------------------------- | +| `{% hint style="info" %}` | A clarification. | +| `{% hint style="warning" %}` | A gotcha, or a caveat about what something really means. | +| `{% hint style="danger" %}` | Destructive or high-risk actions. | +| `{% hint style="success" %}` | A tip for doing something at scale, usually via Standards. | +| `
` / `` | Action buttons and drawers. | +| `{% stepper %}` / `{% step %}` | Ordered processes and wizards. | +| `{% content-ref %}` | Linking to a documented sub-page. | +| `{% code %}` | Code and configuration samples. | + +Cross-link other pages with GitBook mention syntax, using the filename as the link text: + +``` +[table-features.md](../../../shared-features/table-features.md "mention") +``` + +Inside an HTML cell, use `table-features.md` instead. + +## Reusable Content -FOSS ([Free and Open-Source Software](https://en.wikipedia.org/wiki/Free_and_open-source_software)) lives and dies by the contributions of their communities. Pages like this go unfinished because no one has been able to spare the time it takes to write something up for this. +Shared snippets live in `.gitbook/includes/` at the root of the repository. Only three are in use: `feature-request.md`, which closes every user documentation page, `intune-actions.md` and `live-cached-page-action.md`. -If you are reading this and have the time to contribute, please consider doing so! We have an edit button at the top of this page and others in order to make it easy for users to contribute. Simply click on the three-dot menu for additional options, including Edit. +* Never reword the text of an include on a consuming page. +* Never put a `***` divider before an include that already carries one. +* Never start a reusable content file with an H1. GitBook's importer treats a leading H1 as the snippet's title and strips it from the body. Lead with a `***` divider if the snippet needs a heading. +* `live-cached-page-action.md` is retired in favour of `table-features.md`. Do not add it to new pages. -*** +## Before You Submit -### Feature Requests / Ideas +* The page is listed in `docs/SUMMARY.md`. +* For user documentation, the file path matches the UI route exactly. +* Every claim is grounded in the code or in vendor documentation. +* Prose is British English, product names and UI labels are verbatim, and there are no em dashes. +* Relative links and the `{% include %}` path resolve from the file's actual location. +* The page ends with the feature request include, with no divider before it. +* The pull request targets `dev` and has a Conventional Commits title. -We value your feedback and ideas. Please raise any [feature requests](https://github.com/KelvinTegelaar/CIPP/issues/new?assignees=\&labels=enhancement%2Cno-priority\&projects=\&template=feature.yml\&title=%5BFeature+Request%5D%3A+) on GitHub. +{% include "../../.gitbook/includes/feature-request.md" %} diff --git a/docs/legacy-setup-hidden-from-nav/installation/owntenant.md b/docs/legacy-setup-hidden-from-nav/installation/owntenant.md index 4d1b00b838..20ebed1b6f 100644 --- a/docs/legacy-setup-hidden-from-nav/installation/owntenant.md +++ b/docs/legacy-setup-hidden-from-nav/installation/owntenant.md @@ -39,6 +39,4 @@ GDAP permissions will not apply, and you must directly assign roles to the servi 6. Select one of the three modes. The default mode is "Multi Tenant - GDAP Mode" 7. Clear the tenant cache. Users of CIPP now have access to the CSP Partner tenant, or to the single tenant it's been configured for. -*** - {% include "../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/legacy-setup-hidden-from-nav/resources/how-cipp-evaluates-roles.md b/docs/legacy-setup-hidden-from-nav/resources/how-cipp-evaluates-roles.md index 57b380f0ed..28749575db 100644 --- a/docs/legacy-setup-hidden-from-nav/resources/how-cipp-evaluates-roles.md +++ b/docs/legacy-setup-hidden-from-nav/resources/how-cipp-evaluates-roles.md @@ -127,6 +127,4 @@ If Priya were also added to a second custom role granting **Endpoint: Read**, he Because `admin`/`superadmin` bypass custom roles, the most common pattern for scoped access is to map a base role (`editor` or `readonly`) to one Entra group and a custom role to another, then add users to **both** — the base role provides a safe ceiling and the custom role tailors it. Custom-roles-only assignments also work, but without a base-role ceiling they grant exactly what is defined, so review them carefully. See [Custom Roles](../../setup/setting-up-cipp/roles.md#custom-roles) for the full setup steps. {% endhint %} -*** - {% include "../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/setup/installation/owntenant.md b/docs/setup/installation/owntenant.md index eddc7df81a..020f12ad66 100644 --- a/docs/setup/installation/owntenant.md +++ b/docs/setup/installation/owntenant.md @@ -39,6 +39,4 @@ GDAP permissions will not apply, and you must directly assign roles to the servi 6. Select one of the three modes. The default mode is "Multi Tenant - GDAP Mode" 7. Clear the tenant cache. Users of CIPP now have access to the CSP Partner tenant, or to the single tenant it's been configured for. -*** - {% include "../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/setup/resources/how-cipp-evaluates-roles.md b/docs/setup/resources/how-cipp-evaluates-roles.md index 8edb320021..cbe78eb99d 100644 --- a/docs/setup/resources/how-cipp-evaluates-roles.md +++ b/docs/setup/resources/how-cipp-evaluates-roles.md @@ -127,6 +127,4 @@ If Priya were also added to a second custom role granting **Endpoint: Read**, he Because `admin`/`superadmin` bypass custom roles, the most common pattern for scoped access is to map a base role (`editor` or `readonly`) to one Entra group and a custom role to another, then add users to **both** — the base role provides a safe ceiling and the custom role tailors it. Custom-roles-only assignments also work, but without a base-role ceiling they grant exactly what is defined, so review them carefully. See [Custom Roles](../setting-up-cipp/roles.md#custom-roles) for the full setup steps. {% endhint %} -*** - {% include "../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/troubleshooting/frequently-asked-questions/i-got-a-potential-phishing-page-detected-alert.-what-do-i-do-with-that.md b/docs/troubleshooting/frequently-asked-questions/i-got-a-potential-phishing-page-detected-alert.-what-do-i-do-with-that.md index a396c97c67..b40cf3485b 100644 --- a/docs/troubleshooting/frequently-asked-questions/i-got-a-potential-phishing-page-detected-alert.-what-do-i-do-with-that.md +++ b/docs/troubleshooting/frequently-asked-questions/i-got-a-potential-phishing-page-detected-alert.-what-do-i-do-with-that.md @@ -47,6 +47,4 @@ Since the standard is based on detecting the site's referrer, there are a number * Browser Extensions: Any browser extension that modifies the page's CSS could modify the site's referrer. A common extension that modifies the page's CSS is [Dark Reader](https://darkreader.org/). -*** - {% include "../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/troubleshooting/frequently-asked-questions/standards-v-drift.md b/docs/troubleshooting/frequently-asked-questions/standards-v-drift.md index d3a5b8924f..35b994e4c3 100644 --- a/docs/troubleshooting/frequently-asked-questions/standards-v-drift.md +++ b/docs/troubleshooting/frequently-asked-questions/standards-v-drift.md @@ -55,6 +55,4 @@ This pattern keeps your group-level templates clean and your exceptions visible Because licence-incompatible settings are automatically skipped, you _can_ lump everything into one big template — but the group-based pattern above gives you cleaner alignment reporting and makes "why is this tenant non-compliant?" easier to answer. {% endhint %} -*** - {% include "../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/cipp/advanced/authentication/README.md b/docs/user-documentation/cipp/advanced/authentication/README.md index bc8f01adcb..a1cb513b33 100644 --- a/docs/user-documentation/cipp/advanced/authentication/README.md +++ b/docs/user-documentation/cipp/advanced/authentication/README.md @@ -12,6 +12,4 @@ As of version 8.0, users only need the \`superadmin\` role in order to access th Note that it may take some time for the role change to take effect. {% endhint %} -*** - {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/cipp/advanced/authentication/cipp-roles/README.md b/docs/user-documentation/cipp/advanced/authentication/cipp-roles/README.md index 8fe602ac04..f8f92e6266 100644 --- a/docs/user-documentation/cipp/advanced/authentication/cipp-roles/README.md +++ b/docs/user-documentation/cipp/advanced/authentication/cipp-roles/README.md @@ -16,6 +16,4 @@ A page for super admins to manage the custom roles deployed to their CIPP instan
ActionDescriptionBulk Action Available
EditAllows you to edit the custom role.false
CloneAllows you to use an existing custom role to use as a starting point for a new roletrue
DeleteDeletes the selected role(s)true
More InfoOpens the Extended Info flyout with the full details for the selected row.false
-*** - {% include "../../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/cipp/advanced/authentication/cipp-roles/add.md b/docs/user-documentation/cipp/advanced/authentication/cipp-roles/add.md index a2b9d34346..cc9b55bfc6 100644 --- a/docs/user-documentation/cipp/advanced/authentication/cipp-roles/add.md +++ b/docs/user-documentation/cipp/advanced/authentication/cipp-roles/add.md @@ -42,6 +42,4 @@ Note that when creating a custom role to layer with the base role, any permissio The `i` icon next to each API permissions category will open a flyout listing the CIPP API endpoints included in each category. This flyout will now also contain the `i` icon next to API endpoints where developers can add details regarding the function of the API. This will help clarify the endpoint's purpose if the name of the API endpoint is not clear. -*** - {% include "../../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/cipp/advanced/authentication/cipp-users.md b/docs/user-documentation/cipp/advanced/authentication/cipp-users.md index ac9822ba9b..fe1a71cc8d 100644 --- a/docs/user-documentation/cipp/advanced/authentication/cipp-users.md +++ b/docs/user-documentation/cipp/advanced/authentication/cipp-users.md @@ -30,6 +30,4 @@ A few rules govern roles on this page: * There must always be at least one superadmin. You cannot remove the superadmin role from a user if they are the only superadmin. To hand the role over, assign superadmin to another user first, then remove it from the original. * To grant access to users outside your partner tenant, either add them as guest users in your partner tenant and assign their roles here or enable multi-tenant mode on the CIPP SSO tab and add them to the list directly, without inviting them as guests. -*** - {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/cipp/advanced/authentication/sam-app-permissions.md b/docs/user-documentation/cipp/advanced/authentication/sam-app-permissions.md index 978bf51100..dc84012069 100644 --- a/docs/user-documentation/cipp/advanced/authentication/sam-app-permissions.md +++ b/docs/user-documentation/cipp/advanced/authentication/sam-app-permissions.md @@ -60,6 +60,4 @@ Consent already granted in a customer tenant is not withdrawn by resetting here. Directory role assignments for the same application are configured separately on [sam-app-roles.md](sam-app-roles.md "mention"). -*** - {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/cipp/advanced/authentication/sam-app-roles.md b/docs/user-documentation/cipp/advanced/authentication/sam-app-roles.md index 5ecef178e0..1353255303 100644 --- a/docs/user-documentation/cipp/advanced/authentication/sam-app-roles.md +++ b/docs/user-documentation/cipp/advanced/authentication/sam-app-roles.md @@ -48,6 +48,4 @@ Removing a role from this page stops it being granted in future, but does not re To confirm which Exchange cmdlets the application context actually gains, use [exchange-cmdlets.md](../exchange-cmdlets.md "mention") with **As App** enabled and compare it against the delegated result. -*** - {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/cipp/advanced/authentication/sso.md b/docs/user-documentation/cipp/advanced/authentication/sso.md index 76ac419c45..065beb9316 100644 --- a/docs/user-documentation/cipp/advanced/authentication/sso.md +++ b/docs/user-documentation/cipp/advanced/authentication/sso.md @@ -248,6 +248,4 @@ If you can still reach this page, use **Rotate Secret** to issue and store a new If you cannot sign in at all, use the reset described in [#resetting-sso-when-you-cannot-sign-in](sso.md#resetting-sso-when-you-cannot-sign-in "mention"). On a self-hosted instance, an administrator with access to the underlying Azure resources can alternatively retrieve or replace the stored SSO values directly in the instance's Key Vault and restart the container to restore access. -*** - {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/cipp/advanced/container-management/README.md b/docs/user-documentation/cipp/advanced/container-management/README.md index f7e94f0b77..e03015a897 100644 --- a/docs/user-documentation/cipp/advanced/container-management/README.md +++ b/docs/user-documentation/cipp/advanced/container-management/README.md @@ -8,6 +8,4 @@ These pages are limited to users with the `superadmin` role. Note that it may take some time for a role change to take effect. {% endhint %} -*** - {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/cipp/advanced/container-management/custom-domains.md b/docs/user-documentation/cipp/advanced/container-management/custom-domains.md index 7e7c2b7e8f..35d2b327c8 100644 --- a/docs/user-documentation/cipp/advanced/container-management/custom-domains.md +++ b/docs/user-documentation/cipp/advanced/container-management/custom-domains.md @@ -65,6 +65,4 @@ Wildcard domains are the exception: App Service Managed Certificates do not supp {% endstep %} {% endstepper %} -*** - {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/cipp/advanced/container-management/logs.md b/docs/user-documentation/cipp/advanced/container-management/logs.md index 2955482f0a..5fee3f83a9 100644 --- a/docs/user-documentation/cipp/advanced/container-management/logs.md +++ b/docs/user-documentation/cipp/advanced/container-management/logs.md @@ -78,6 +78,4 @@ Log entries are categorised by severity level: | Error | ERR | | Critical | CRT | -*** - {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/cipp/advanced/container-management/status.md b/docs/user-documentation/cipp/advanced/container-management/status.md index fb06897cfe..4c1b275f0e 100644 --- a/docs/user-documentation/cipp/advanced/container-management/status.md +++ b/docs/user-documentation/cipp/advanced/container-management/status.md @@ -103,6 +103,4 @@ Select **Save Settings** to store these options. Setting Check Interval to **Dis Note that if the container restarts for any reason, the latest image for the current release channel is pulled regardless of these settings. -*** - {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/cipp/advanced/container-management/worker-health.md b/docs/user-documentation/cipp/advanced/container-management/worker-health.md index d768601f65..7e440332ad 100644 --- a/docs/user-documentation/cipp/advanced/container-management/worker-health.md +++ b/docs/user-documentation/cipp/advanced/container-management/worker-health.md @@ -47,7 +47,7 @@ Two tables list every worker in the container: one for the HTTP pool, which hand ## Job Queue -The Job Queue lists the background jobs known to the worker system, newest first. Two toggles above the table control what is loaded: a status toggle (All, Queued, Running, Completed, Failed, or Cancelled) and a load limit (500, 2k, 5k, or 10k). +The Job Queue lists the background jobs known to the worker system, newest first. Queued jobs come from the durable job queue in table storage, so the list shows the full backlog of a large run — not just the handful of tasks the container has buffered for execution — and cancelling or reprioritizing a queued job takes effect even for work no container has picked up yet. Two toggles above the table control what is loaded: a status toggle (All, Queued, Running, Completed, Failed, or Cancelled) and a load limit (500, 2k, 5k, or 10k). The status toggle filters on the server, before the load limit is applied, so the limit applies to the selected status rather than to all jobs. This matters on a busy instance: with a large backlog of completed jobs, loading All would fill the entire limit with completed work and show no queued jobs at all. Select Queued to see the jobs still waiting to run, regardless of how much history sits behind them. @@ -114,6 +114,4 @@ At the bottom of the page, a stacked bar shows how long each phase of the contai | BG Ready | Reaching the point where the background pool can run jobs. | | Fully Ready | The container becoming fully ready. | -*** - {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/cipp/advanced/diagnostics.md b/docs/user-documentation/cipp/advanced/diagnostics.md index 69f4f8a739..e9dacb63bb 100644 --- a/docs/user-documentation/cipp/advanced/diagnostics.md +++ b/docs/user-documentation/cipp/advanced/diagnostics.md @@ -63,6 +63,4 @@ The flyout adapts to the type of event in the row. | Task and standard completions | A summary card with the task or standard name, the command, the tenant, and the run count and duration figures where present. | | Anything else | A property list of every field returned by the query, with the custom dimensions listed separately and available to copy. | -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/cipp/advanced/exchange-cmdlets.md b/docs/user-documentation/cipp/advanced/exchange-cmdlets.md index f64e762e1a..e11a6b7404 100644 --- a/docs/user-documentation/cipp/advanced/exchange-cmdlets.md +++ b/docs/user-documentation/cipp/advanced/exchange-cmdlets.md @@ -42,6 +42,4 @@ The **Check Roles** action opens a dialog listing every management role that inc This is the practical answer to "which role does CIPP need in order to call this". Where a cmdlet CIPP relies on is unavailable, the roles listed here are what needs granting to the service account or the application, depending on which context you searched in. -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/cipp/advanced/super-admin/README.md b/docs/user-documentation/cipp/advanced/super-admin/README.md index 27ecb997cc..9c404b7014 100644 --- a/docs/user-documentation/cipp/advanced/super-admin/README.md +++ b/docs/user-documentation/cipp/advanced/super-admin/README.md @@ -10,6 +10,4 @@ As of version 8.0, users only need the \`superadmin\` role in order to access th Note that it may take some time for the role change to take effect. {% endhint %} -*** - {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/cipp/advanced/super-admin/function-offloading.md b/docs/user-documentation/cipp/advanced/super-admin/function-offloading.md index 1619dda371..d96b46d652 100644 --- a/docs/user-documentation/cipp/advanced/super-admin/function-offloading.md +++ b/docs/user-documentation/cipp/advanced/super-admin/function-offloading.md @@ -391,6 +391,4 @@ After setting up your additional function apps, make sure to enable CI/CD from t Using function offloading introduces a delay in starting background tasks. Background tasks are queued and run in 15 minute intervals (0,15,30,45). -*** - {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/cipp/advanced/super-admin/tenant-mode.md b/docs/user-documentation/cipp/advanced/super-admin/tenant-mode.md index 824813a67f..388d02ede3 100644 --- a/docs/user-documentation/cipp/advanced/super-admin/tenant-mode.md +++ b/docs/user-documentation/cipp/advanced/super-admin/tenant-mode.md @@ -16,6 +16,4 @@ For further information on the limitations of each mode, please review [owntenan After changing the tenant mode, clear your tenant cache by clicking Force Refresh on the [tenants.md](../../settings/tenants.md "mention") settings page. {% endhint %} -*** - {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/cipp/advanced/super-admin/time-settings.md b/docs/user-documentation/cipp/advanced/super-admin/time-settings.md index 14269ac22b..eca2809781 100644 --- a/docs/user-documentation/cipp/advanced/super-admin/time-settings.md +++ b/docs/user-documentation/cipp/advanced/super-admin/time-settings.md @@ -20,6 +20,4 @@ This affects CIPP's own background jobs that run at a fixed time of day — Stan It does **not** change Scheduled Tasks that you create yourself. Those run at the absolute time you picked when you created them. Background jobs that run on a short interval, such as every 15 minutes, are also unaffected. {% endhint %} -*** - {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/cipp/advanced/table-maintenance.md b/docs/user-documentation/cipp/advanced/table-maintenance.md index a9e1f800db..1012c04f50 100644 --- a/docs/user-documentation/cipp/advanced/table-maintenance.md +++ b/docs/user-documentation/cipp/advanced/table-maintenance.md @@ -68,6 +68,4 @@ Saving writes the row using its `PartitionKey` and `RowKey`, replacing any exist The `ETag` and `Timestamp` properties are managed by Azure Storage and are deliberately excluded from the editor. They cannot be set by hand. {% endhint %} -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/cipp/advanced/timers.md b/docs/user-documentation/cipp/advanced/timers.md index 2f7403098d..e03c81e176 100644 --- a/docs/user-documentation/cipp/advanced/timers.md +++ b/docs/user-documentation/cipp/advanced/timers.md @@ -38,6 +38,4 @@ This page lists every timer CIPP defines, including those tied to features that This applies to every timer at once. There is no way to reset an individual schedule, and no record is kept of what the customised values were, so note anything you want to reinstate before resetting. {% endhint %} -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/cipp/custom-data/README.md b/docs/user-documentation/cipp/custom-data/README.md index 8eab76e2bd..8c7ff38895 100644 --- a/docs/user-documentation/cipp/custom-data/README.md +++ b/docs/user-documentation/cipp/custom-data/README.md @@ -83,6 +83,4 @@ Custom Data is most valuable when the attribute feeds something downstream rathe Plan the attribute name before you create it. Directory extensions are given a fixed prefix and schema properties can never be deleted, so renaming later means creating a replacement and migrating values. {% endhint %} -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/cipp/custom-data/directory-extensions/README.md b/docs/user-documentation/cipp/custom-data/directory-extensions/README.md index 0dcf830aa9..266839b301 100644 --- a/docs/user-documentation/cipp/custom-data/directory-extensions/README.md +++ b/docs/user-documentation/cipp/custom-data/directory-extensions/README.md @@ -29,6 +29,4 @@ Deleting a directory extension is permanent and affects every tenant, not just the one you are currently viewing. Check for mappings that reference the extension before removing it. {% endhint %} -*** - {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/cipp/custom-data/directory-extensions/add.md b/docs/user-documentation/cipp/custom-data/directory-extensions/add.md index 95ef0ac1d3..c3a232866a 100644 --- a/docs/user-documentation/cipp/custom-data/directory-extensions/add.md +++ b/docs/user-documentation/cipp/custom-data/directory-extensions/add.md @@ -19,6 +19,4 @@ The data type and the multi-valued setting cannot be changed after creation. If Only select the target objects you genuinely need. Directory objects are limited to 100 extension values per instance across all extensions. {% endhint %} -*** - {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/cipp/custom-data/mappings/README.md b/docs/user-documentation/cipp/custom-data/mappings/README.md index 83e42913ff..f95ea04804 100644 --- a/docs/user-documentation/cipp/custom-data/mappings/README.md +++ b/docs/user-documentation/cipp/custom-data/mappings/README.md @@ -29,6 +29,4 @@ You need at least one directory extension or schema extension before a mapping c Adding or deleting a Reporting DB mapping re-registers the scheduled synchronisation tasks, so no further action is needed to start or stop the daily sync. {% endhint %} -*** - {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/cipp/custom-data/mappings/add.md b/docs/user-documentation/cipp/custom-data/mappings/add.md index 2dd5c01f52..59c352b8ea 100644 --- a/docs/user-documentation/cipp/custom-data/mappings/add.md +++ b/docs/user-documentation/cipp/custom-data/mappings/add.md @@ -59,6 +59,4 @@ The form control CIPP renders follows the attribute's data type. Boolean attribu Reporting DB mappings synchronise on a daily schedule per tenant and depend on CIPP having cached data for that tenant. Values will not appear immediately after saving the mapping. {% endhint %} -*** - {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/cipp/custom-data/mappings/edit.md b/docs/user-documentation/cipp/custom-data/mappings/edit.md index b42db61f9a..d9c111172f 100644 --- a/docs/user-documentation/cipp/custom-data/mappings/edit.md +++ b/docs/user-documentation/cipp/custom-data/mappings/edit.md @@ -8,6 +8,4 @@ Changing the destination attribute does not move or remove values already writte Saving a Reporting DB mapping re-registers the scheduled synchronisation tasks for the selected tenants. -*** - {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/cipp/custom-data/schema-extensions/README.md b/docs/user-documentation/cipp/custom-data/schema-extensions/README.md index 4dbf13f92f..7704ff7b2a 100644 --- a/docs/user-documentation/cipp/custom-data/schema-extensions/README.md +++ b/docs/user-documentation/cipp/custom-data/schema-extensions/README.md @@ -35,6 +35,4 @@ Plan your properties while the schema is still In Development. That is the only CIPP maintains its own schema, `cippUser`, which stores JIT administrator state, mailbox type, archive settings, and per-user MFA state on the user object. It counts towards the limit of five schema extensions and should be left in place. {% endhint %} -*** - {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/cipp/custom-data/schema-extensions/add.md b/docs/user-documentation/cipp/custom-data/schema-extensions/add.md index 3371452397..5ef8c539c3 100644 --- a/docs/user-documentation/cipp/custom-data/schema-extensions/add.md +++ b/docs/user-documentation/cipp/custom-data/schema-extensions/add.md @@ -28,6 +28,4 @@ Properties cannot be deleted once the schema is created, regardless of status. R Schema extension properties are always single-valued. If you need to store a collection, use a multi-valued directory extension instead. {% endhint %} -*** - {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/cipp/integrations/cloudflare.md b/docs/user-documentation/cipp/integrations/cloudflare.md index a7fa9c0a33..206fc4c163 100644 --- a/docs/user-documentation/cipp/integrations/cloudflare.md +++ b/docs/user-documentation/cipp/integrations/cloudflare.md @@ -59,6 +59,4 @@ The per-integration toggles only appear once this integration is enabled and sav The same credentials are used everywhere. If your Hudu instance and your webhook endpoint sit behind different Cloudflare Access applications, the one service token needs to be accepted by both. {% endhint %} -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/cipp/integrations/github.md b/docs/user-documentation/cipp/integrations/github.md index 0ca816cf17..ae717e4f2d 100644 --- a/docs/user-documentation/cipp/integrations/github.md +++ b/docs/user-documentation/cipp/integrations/github.md @@ -77,6 +77,4 @@ Results from **Test** appear in a banner at the top of the page, with two contro | View Results | Opens a table showing the results of the most recent attempt in full. | | Download Results | Downloads those results as a CSV file. | -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/cipp/integrations/gradient.md b/docs/user-documentation/cipp/integrations/gradient.md index 0d69355f3f..b87ec1a8ad 100644 --- a/docs/user-documentation/cipp/integrations/gradient.md +++ b/docs/user-documentation/cipp/integrations/gradient.md @@ -87,6 +87,4 @@ When Gradient is enabled, CIPP alerts configured to deliver to a PSA are also se CIPP checks the delivery status of each alert after sending it, and records a failure in the CIPP logs where Gradient reports that the alert could not be delivered. If tickets are not appearing, the logs are the first place to look. -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/cipp/integrations/halopsa.md b/docs/user-documentation/cipp/integrations/halopsa.md index db27bdf2d3..f4e1045a68 100644 --- a/docs/user-documentation/cipp/integrations/halopsa.md +++ b/docs/user-documentation/cipp/integrations/halopsa.md @@ -164,6 +164,4 @@ When **Consolidate Tickets** is enabled, CIPP records the ticket it raised for e When **Link Tickets to affected Users** is enabled, CIPP raises a separate ticket per affected user and matches them to a Halo contact within the mapped client, first on Microsoft Entra Object ID and then on email address or network login. Where no contact matches, the ticket is assigned to the client's General User and the affected user's UPN is included in the ticket body. -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/cipp/integrations/have-i-been-pwned.md b/docs/user-documentation/cipp/integrations/have-i-been-pwned.md index 3b7e3acfc9..71e1845eee 100644 --- a/docs/user-documentation/cipp/integrations/have-i-been-pwned.md +++ b/docs/user-documentation/cipp/integrations/have-i-been-pwned.md @@ -69,6 +69,4 @@ A tenant breach search runs as a background job and can take up to 24 hours to c Have I Been Pwned rate-limits API requests according to your subscription tier. Where a lookup is rate-limited, CIPP reports this rather than failing outright, and the request needs retrying after the wait period. Accounts with no breach history return no results rather than an error, so an empty result is a genuine answer. {% endhint %} -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/cipp/integrations/hudu.md b/docs/user-documentation/cipp/integrations/hudu.md index 7d110796ca..b3b90fd4c4 100644 --- a/docs/user-documentation/cipp/integrations/hudu.md +++ b/docs/user-documentation/cipp/integrations/hudu.md @@ -286,6 +286,4 @@ The Microsoft 365 rich text field can render with cut-off tables or cramped form Special thanks to Luke Whitelock and his [HuduM365Automation](https://github.com/lwhitelock/HuduM365Automation) function app code. -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/cipp/integrations/integration-sync.md b/docs/user-documentation/cipp/integrations/integration-sync.md index 2e929acb22..aed6997387 100644 --- a/docs/user-documentation/cipp/integrations/integration-sync.md +++ b/docs/user-documentation/cipp/integrations/integration-sync.md @@ -25,6 +25,4 @@ Scheduled tasks are picked up on the quarter hour (:00, :15, :30, :45). Anything Tasks are created and removed automatically when you change an integration's tenant mappings, so there is nothing to add or delete here. A tenant disappearing from this table usually means its mapping was removed rather than that the sync has broken. {% endhint %} -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/cipp/integrations/ninjaone.md b/docs/user-documentation/cipp/integrations/ninjaone.md index cb29ab7419..47b76439d3 100644 --- a/docs/user-documentation/cipp/integrations/ninjaone.md +++ b/docs/user-documentation/cipp/integrations/ninjaone.md @@ -172,6 +172,4 @@ In NinjaOne go to **Administration** > **Apps** > **Microsoft Defender**, open t CVE exceptions recorded in CIPP are applied before upload, both tenant-specific exceptions and those set for all tenants, so a suppressed CVE does not reappear in NinjaOne. If the expected scan group does not exist, that tenant's CVE upload is skipped and a warning is written to the CIPP logbook. {% endhint %} -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/cipp/integrations/passwordpusher.md b/docs/user-documentation/cipp/integrations/passwordpusher.md index 5611be561c..4f6cdb62ae 100644 --- a/docs/user-documentation/cipp/integrations/passwordpusher.md +++ b/docs/user-documentation/cipp/integrations/passwordpusher.md @@ -89,6 +89,4 @@ Do not enable the **Force the default value?** option on a Password Pusher websi Password Pusher's own password generator policy applies only to the generator on its website, which is a convenience tool. It has no bearing on the passwords CIPP generates — those are controlled by CIPP's own password configuration. {% endhint %} -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/cipp/integrations/sherweb.md b/docs/user-documentation/cipp/integrations/sherweb.md index 3b1bd4cb77..120239adc7 100644 --- a/docs/user-documentation/cipp/integrations/sherweb.md +++ b/docs/user-documentation/cipp/integrations/sherweb.md @@ -188,6 +188,4 @@ Alerts are delivered by email, to your PSA, and by webhook, following your alert Matching relies on subscription and SKU IDs, and will improve as Sherweb exposes more SKU detail. Review the notifications from a notify-only period before trusting automated purchasing on your own catalogue. {% endhint %} -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/cipp/logs/README.md b/docs/user-documentation/cipp/logs/README.md index b2b013b5fb..b33a86d88b 100644 --- a/docs/user-documentation/cipp/logs/README.md +++ b/docs/user-documentation/cipp/logs/README.md @@ -69,6 +69,4 @@ Debug entries are excluded unless you explicitly select Debug in **Filter by Sev | Critical | A platform-level failure occurred. Reserved for situations where CIPP's own infrastructure is impacted, such as failures retrieving tenant lists or GDAP relationships. These indicate a problem with CIPP itself, not a specific tenant action. | | Debug | Diagnostic information for troubleshooting. Only recorded when Debug Mode is enabled. Not included in notifications by default, and not returned unless Debug is selected in the severity filter. | -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/cipp/logs/logentry.md b/docs/user-documentation/cipp/logs/logentry.md index 03b6a58267..dca692940f 100644 --- a/docs/user-documentation/cipp/logs/logentry.md +++ b/docs/user-documentation/cipp/logs/logentry.md @@ -2,6 +2,4 @@ This page will show you an expanded view of the log entry details, including information on the standard if the log entry is from a standards run. -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/cipp/sam-setup-wizard.md b/docs/user-documentation/cipp/sam-setup-wizard.md index 8f559ee5b0..bce9964068 100644 --- a/docs/user-documentation/cipp/sam-setup-wizard.md +++ b/docs/user-documentation/cipp/sam-setup-wizard.md @@ -98,6 +98,4 @@ This is how links elsewhere in CIPP drop you straight into the right place, such [gdap-invite-wizard.md](../../setup/installation/gdap-invite-wizard.md) {% endcontent-ref %} -*** - {% include "../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/cipp/settings/README.md b/docs/user-documentation/cipp/settings/README.md index e46f98d2d2..63c09b5c58 100644 --- a/docs/user-documentation/cipp/settings/README.md +++ b/docs/user-documentation/cipp/settings/README.md @@ -116,6 +116,4 @@ The remaining application settings are grouped on their own tabs. | [features.md](features.md "mention") | Enables and disables optional CIPP features. | | [siem.md](siem.md "mention") | Configures log forwarding to an external SIEM. | -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/cipp/settings/backend.md b/docs/user-documentation/cipp/settings/backend.md index 3530789da2..db4a10d83c 100644 --- a/docs/user-documentation/cipp/settings/backend.md +++ b/docs/user-documentation/cipp/settings/backend.md @@ -50,6 +50,4 @@ The commands in the flyout are generated with your own resource group, function The **Command Reference** button is unavailable on hosted instances, since the commands operate on Azure resources a hosted client does not own. {% endhint %} -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/cipp/settings/backup.md b/docs/user-documentation/cipp/settings/backup.md index de182d56b3..ea60fa6fec 100644 --- a/docs/user-documentation/cipp/settings/backup.md +++ b/docs/user-documentation/cipp/settings/backup.md @@ -116,6 +116,4 @@ Each scope has the same two settings. Replication applies to new backups only. Existing backups are not copied across when you enable it, and CIPP does not manage or prune what it writes to the external container, so keep an eye on the storage costs there yourself. {% endhint %} -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/cipp/settings/branding.md b/docs/user-documentation/cipp/settings/branding.md index a02fcb9477..e749dbd967 100644 --- a/docs/user-documentation/cipp/settings/branding.md +++ b/docs/user-documentation/cipp/settings/branding.md @@ -84,7 +84,7 @@ Renders a sample cover page using your current logo, cover image, colours, cover **Report Type Preview** switches the sample between the report types CIPP produces, each of which uses a different title and subtitle layout. **Preview full report** replaces the cover mock-up with a rendering of the real report document against sample data, which you can page through to see how colours, tables, charts and the footer land on content pages. **Show cover only** returns to the lighter cover preview. {% hint style="info" %} -The preview reflects the unsaved state of the editor, so a colour or footer can be judged before it is committed. The sample figures exist only in the preview. A report run against a tenant with no data still shows that it has none. +The preview reflects the unsaved state of the editor, so a colour or footer can be judged before it is committed. Variables are shown resolved rather than as tokens, using a sample tenant name and the report type currently selected, so `%tenantname%` reads the way it will on a real report. The sample figures exist only in the preview. A report run against a tenant with no data still shows that it has none. {% endhint %} ## Colors @@ -134,13 +134,19 @@ Controls the strip at the bottom of every report page. Both text fields accept replacement variables. Typing `%` offers CIPP's variable list, and reports add `%reportname%` and `%reportdate%` on top of it. Matching is not case sensitive, and a variable CIPP cannot resolve is left written as typed rather than blanked, which is what tells you it was mistyped. +The 200 character limit applies to the finished text as well as to the wording you type. Text that grows past it once the variables are filled in, typically because of a long tenant name, is trimmed to fit rather than printed past the edge of the page. + {% hint style="info" %} Report templates can override the footer or switch it off for an individual report. {% endhint %} ## Watermark -Draws text diagonally across every page of a report, the cover included, at low opacity. Typical wording is DRAFT or CONFIDENTIAL. Text is limited to 40 characters, and **Show watermark** switches it off without losing the wording. +Draws text diagonally across every page of a report, the cover included, at low opacity. A fixed mark such as DRAFT or CONFIDENTIAL is typical. **Show watermark** switches it off without losing the wording, so entering text is enough to make it appear. + +The field takes replacement variables in the same way the footer does. Typing `%` offers CIPP's variable list plus `%reportname%` and `%reportdate%`, so `%tenantname%` stamps each report with the client it was run for. + +Text is limited to 40 characters. The limit applies to the finished mark as well as to the wording you type, so a tenant name that resolves longer than that is trimmed rather than stretched across the page. ## Saving and Resetting @@ -156,6 +162,4 @@ Leaving the page after uploading an image but before saving keeps the uploaded f Reset permanently deletes every logo and cover image you have uploaded, not just the active selection. There is no confirmation prompt and the files cannot be recovered. Presets survive a reset, but any preset pointing at a deleted image loses it. Keep copies of any custom artwork outside CIPP. {% endhint %} -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/cipp/settings/features.md b/docs/user-documentation/cipp/settings/features.md index f46374848a..67d745eed7 100644 --- a/docs/user-documentation/cipp/settings/features.md +++ b/docs/user-documentation/cipp/settings/features.md @@ -31,6 +31,4 @@ CIPP maintains further internal flags that are not shown on this page. Those gov The Extended Info flyout lists exactly what a flag governs, namely the pages it shows, the API endpoints it enables, and any scheduled timers it controls. Check it before disabling a feature, so you know what stops working. {% endhint %} -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/cipp/settings/licenses.md b/docs/user-documentation/cipp/settings/licenses.md index 5f43fe7426..0396b8b53e 100644 --- a/docs/user-documentation/cipp/settings/licenses.md +++ b/docs/user-documentation/cipp/settings/licenses.md @@ -64,6 +64,4 @@ A full reset deletes every entry in the list, including licences you added yours
ActionDescriptionBulk Action Available
Only Exclude from AlertsNarrows the exclusion so the licence is ignored by alerts but still appears in reports and integration data.true
Show in License DropdownsMakes the licence selectable in CIPP's licence pickers again. Only offered for licences currently hidden from them.true
Hide from License DropdownsRemoves the licence from CIPP's licence pickers. Only offered for licences currently shown in them.true
Delete ExclusionRemoves the licence from the exclusion list, so it is counted and reported on again.true
More InfoOpens the Extended Info flyout with the full details for the selected row.false
-*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/cipp/settings/notifications.md b/docs/user-documentation/cipp/settings/notifications.md index 6cadf57564..e261760dfb 100644 --- a/docs/user-documentation/cipp/settings/notifications.md +++ b/docs/user-documentation/cipp/settings/notifications.md @@ -139,6 +139,4 @@ You are able to select to send a test alert. Select which options you want to in
-*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/cipp/settings/partner-webhooks.md b/docs/user-documentation/cipp/settings/partner-webhooks.md index f461761fcd..9b79f4e661 100644 --- a/docs/user-documentation/cipp/settings/partner-webhooks.md +++ b/docs/user-documentation/cipp/settings/partner-webhooks.md @@ -85,6 +85,4 @@ The **Test Webhook** button asks Partner Center to deliver a test event and repo A failing test after a migration is the clearest symptom of the URL mismatch described above. -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/cipp/settings/password-config.md b/docs/user-documentation/cipp/settings/password-config.md index 9f23a27a00..56eae07ee5 100644 --- a/docs/user-documentation/cipp/settings/password-config.md +++ b/docs/user-documentation/cipp/settings/password-config.md @@ -55,6 +55,4 @@ The separator counts towards complexity. A passphrase using the default hyphen s Avoid setting the separator to a single space. Although the configuration will save, password generation rejects a whitespace-only separator, so credential creation will fail afterwards. {% endhint %} -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/cipp/settings/permissions.md b/docs/user-documentation/cipp/settings/permissions.md index 6ce5930a47..27a26b92d5 100644 --- a/docs/user-documentation/cipp/settings/permissions.md +++ b/docs/user-documentation/cipp/settings/permissions.md @@ -52,6 +52,4 @@ Selecting a row opens a flyout with further detail, including the tenant ID, def
ActionDescriptionBulk Action Available
Check TenantRe-runs the access check for the selected tenant.true
Repair Exchange RolesRestores the Exchange roles CIPP requires in the selected tenant. Available only where the check has found that a repair is needed.true
More InfoOpens the Extended Info flyout with the full details for the selected row.false
-*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/cipp/settings/siem.md b/docs/user-documentation/cipp/settings/siem.md index bb9cb6eb69..bea3204a89 100644 --- a/docs/user-documentation/cipp/settings/siem.md +++ b/docs/user-documentation/cipp/settings/siem.md @@ -96,6 +96,4 @@ $filter=PartitionKey ge '20260305' and PartitionKey le '20260312' * [Querying Tables and Entities](https://learn.microsoft.com/en-us/rest/api/storageservices/querying-tables-and-entities) covers filter syntax, operators, and supported data types. * [Query Timeout and Pagination](https://learn.microsoft.com/en-us/rest/api/storageservices/query-timeout-and-pagination) covers continuation tokens for large result sets. -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/cipp/settings/tenants.md b/docs/user-documentation/cipp/settings/tenants.md index ce8db9a4a7..c0c9035222 100644 --- a/docs/user-documentation/cipp/settings/tenants.md +++ b/docs/user-documentation/cipp/settings/tenants.md @@ -31,6 +31,4 @@ Several actions apply only to one kind of tenant, and none of them apply to your The **Force Refresh** button above the table re-reads tenant details and updates the list. Enter a default domain name or tenant ID to refresh a single tenant, or leave the field empty to refresh them all. This is also the way to make a tenant that is missing from the list reappear. {% endhint %} -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/copilot/agent365/packages.md b/docs/user-documentation/copilot/agent365/packages.md index ed5a184fac..f067f5fb8b 100644 --- a/docs/user-documentation/copilot/agent365/packages.md +++ b/docs/user-documentation/copilot/agent365/packages.md @@ -29,6 +29,4 @@ The Package Management API this page relies on requires a Microsoft Agent 365 li The flyout also fetches the package's full detail record live from the tenant, adding the allowed and acquired users and groups and the package's element details, which the list view does not include. -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/copilot/reports/copilot-adoption.md b/docs/user-documentation/copilot/reports/copilot-adoption.md index 27fc5c591b..268bf4c1b8 100644 --- a/docs/user-documentation/copilot/reports/copilot-adoption.md +++ b/docs/user-documentation/copilot/reports/copilot-adoption.md @@ -12,6 +12,4 @@ The figures come from the Microsoft 365 Copilot usage reports in Microsoft Graph | Enabled Users | The number of people who have Copilot available to them for that product, essentially the potential audience that could be using it. | | Active Users | The number of people actually using Copilot in that product, showing real-world uptake rather than just availability. Comparing this against Enabled Users highlights the adoption gap. | -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/copilot/reports/copilot-trend.md b/docs/user-documentation/copilot/reports/copilot-trend.md index 6022c62632..64ebe5f748 100644 --- a/docs/user-documentation/copilot/reports/copilot-trend.md +++ b/docs/user-documentation/copilot/reports/copilot-trend.md @@ -20,6 +20,4 @@ The figures come from the Microsoft 365 Copilot usage reports in Microsoft Graph | Loop Active | Number of people who actively used Copilot in Loop that day. | | Copilot Chat Active | Number of people who actively used Copilot Chat that day. | -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/copilot/reports/copilot-usage.md b/docs/user-documentation/copilot/reports/copilot-usage.md index 706aa34e36..f9f6c21add 100644 --- a/docs/user-documentation/copilot/reports/copilot-usage.md +++ b/docs/user-documentation/copilot/reports/copilot-usage.md @@ -26,6 +26,4 @@ Microsoft 365 has a tenant-wide setting that conceals user names in usage report The fix is on the tenant, not in CIPP. Enabling the **Enable Usernames instead of pseudo anonymised names in reports** standard turns the setting off, after which the report needs running again to pick up real names. -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/copilot/settings.md b/docs/user-documentation/copilot/settings.md index f0d7e9ec04..90379200de 100644 --- a/docs/user-documentation/copilot/settings.md +++ b/docs/user-documentation/copilot/settings.md @@ -19,6 +19,4 @@ This is a simple table view of Microsoft 365 Copilot tenant settings for the sel
ActionDescriptionBulk Action Available
Set StatusChanges the selected setting's state. For most settings, prompts for a desired state: Enabled, Disabled, or Not configured. The web search setting is a three-state policy, so it instead offers the same options as the Microsoft 365 admin center: enabled in both Microsoft 365 Copilot and Microsoft 365 Copilot Chat, disabled in both, or disabled in Microsoft 365 Copilot Work mode while enabled in Microsoft 365 Copilot Chat, plus Not configured. Refetches the list so the updated state is reflected.true
-*** - {% include "../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/copilot/shadow-ai.md b/docs/user-documentation/copilot/shadow-ai.md index 9fcab7c477..e5683e4e7f 100644 --- a/docs/user-documentation/copilot/shadow-ai.md +++ b/docs/user-documentation/copilot/shadow-ai.md @@ -130,6 +130,4 @@ Preset filters are available from the **Filters** button for **Sanctioned**, **U
ActionDescriptionBulk Action Available
Mark as Company SanctionedGreyed out on a tool that is already sanctioned. Marks the tool sanctioned for the tenant so Risk reports as Informational and Status becomes Sanctioned. Refetches the report so cards, charts, and both tables update.true
Remove Company Sanctioned StatusGreyed out on a tool that is not sanctioned. Removes the sanction so the catalogue risk level applies again and Status returns to Unsanctioned. Refetches the report.true
Application UsersOpens a side drawer listing the application's per-user sign-in activity over the last 7 days: user principal name, display name, sign-in count, and last sign-in time. The data depends on the same Entra ID P1 sign-in enrichment.true
More InfoOpens the Extended Info flyout with the full details for the selected row.false
-*** - {% include "../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/dashboard/README.md b/docs/user-documentation/dashboard/README.md index ced67de837..eb5df7335f 100644 --- a/docs/user-documentation/dashboard/README.md +++ b/docs/user-documentation/dashboard/README.md @@ -284,6 +284,4 @@ The [identity.md](identity.md "mention"), [devices.md](devices.md "mention"), an **Previous Dashboard Experience** returns you to the [dashboard.md](dashboard.md "mention"). -*** - {% include "../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/dashboard/custom.md b/docs/user-documentation/dashboard/custom.md index 3f8bcaaba4..c8bb567e01 100644 --- a/docs/user-documentation/dashboard/custom.md +++ b/docs/user-documentation/dashboard/custom.md @@ -99,6 +99,4 @@ Clicking a row opens the same detail flyout as the per-tenant view. The full det
ActionDescriptionBulk Action Available
View tenant dashboardOpens this tab for the tenant the selected result belongs to.false
More InfoOpens the Extended Info flyout with the full details for the selected row.false
-*** - {% include "../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/dashboard/dashboard.md b/docs/user-documentation/dashboard/dashboard.md index da8612725f..8740ad8ac1 100644 --- a/docs/user-documentation/dashboard/dashboard.md +++ b/docs/user-documentation/dashboard/dashboard.md @@ -119,6 +119,4 @@ The enabled services on the tenant, drawn from its assigned plans. Only Exchange
-*** - {% include "../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/dashboard/devices.md b/docs/user-documentation/dashboard/devices.md index b3e984624b..a5818df0e8 100644 --- a/docs/user-documentation/dashboard/devices.md +++ b/docs/user-documentation/dashboard/devices.md @@ -94,6 +94,4 @@ Clicking a row opens the same detail flyout as the per-tenant view. The full det
ActionDescriptionBulk Action Available
View tenant dashboardOpens this tab for the tenant the selected result belongs to.false
More InfoOpens the Extended Info flyout with the full details for the selected row.false
-*** - {% include "../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/dashboard/identity.md b/docs/user-documentation/dashboard/identity.md index 645ce6f6c8..d4e015fcbb 100644 --- a/docs/user-documentation/dashboard/identity.md +++ b/docs/user-documentation/dashboard/identity.md @@ -90,6 +90,4 @@ Clicking a row opens the same detail flyout as the per-tenant view. The full det
ActionDescriptionBulk Action Available
View tenant dashboardOpens this tab for the tenant the selected result belongs to.false
More InfoOpens the Extended Info flyout with the full details for the selected row.false
-*** - {% include "../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/email/administration/contacts-template/README.md b/docs/user-documentation/email/administration/contacts-template/README.md index 0fba5f2f13..d3080d88ba 100644 --- a/docs/user-documentation/email/administration/contacts-template/README.md +++ b/docs/user-documentation/email/administration/contacts-template/README.md @@ -48,6 +48,4 @@ A contact that already exists is skipped, not updated. Changing a template and d
ActionDescriptionBulk Action Available
Save to GitHubSaves the selected template(s) to a GitHub repository you have write access to, under a commit message you supply. Greyed out unless the GitHub integration is enabled.true
Delete TemplateDeletes the selected contact template(s). Contacts already created from the template are left in place in their tenants.true
Edit Contact TemplateOpens the edit.md page to allow you to adjust the template settings.false
-*** - {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/email/administration/contacts-template/add.md b/docs/user-documentation/email/administration/contacts-template/add.md index a93e23dbc4..97a45a9cb7 100644 --- a/docs/user-documentation/email/administration/contacts-template/add.md +++ b/docs/user-documentation/email/administration/contacts-template/add.md @@ -27,6 +27,4 @@ This page creates a new contact template. Only the display name and email addres Note that the email box does not support Custom Variable inclusion at this time. {% endhint %} -*** - {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/email/administration/contacts-template/edit.md b/docs/user-documentation/email/administration/contacts-template/edit.md index f9d813900e..5d0dec3288 100644 --- a/docs/user-documentation/email/administration/contacts-template/edit.md +++ b/docs/user-documentation/email/administration/contacts-template/edit.md @@ -8,6 +8,4 @@ The **Edit Contact Template** action on [README.md](README.md "mention") opens t Editing a template changes only the template. Contacts already created from it in a tenant are not updated, and the change is picked up the next time the template is deployed or the **Deploy Mail Contact Template** standard runs. {% endhint %} -*** - {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/email/administration/contacts/README.md b/docs/user-documentation/email/administration/contacts/README.md index 34e7adcc00..ee22bf5d73 100644 --- a/docs/user-documentation/email/administration/contacts/README.md +++ b/docs/user-documentation/email/administration/contacts/README.md @@ -65,6 +65,4 @@ The properties returned are for the Exchange Online PowerShell command `Get-Cont
ActionDescriptionBulk Action Available
Edit ContactOpens edit.md for the selected contact. Greyed out on a contact that is directory synced, which has to be changed at the source instead.false
Set Source of AuthoritySwitches the contact between Cloud Managed and On-Premises Managed, so you can take over a synced contact in the cloud or hand it back to on-premises. The current setting is preselected, and handing a contact back does not show until the next directory sync cycle. Greyed out on a cloud-only contact, which has no on-premises counterpart to switch between.true
Remove ContactDeletes the mail contact from the tenant. Greyed out on a contact that is directory synced, which has to be removed at the source instead.true
-*** - {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/email/administration/contacts/edit.md b/docs/user-documentation/email/administration/contacts/edit.md index d4e491c77d..4a192e03d4 100644 --- a/docs/user-documentation/email/administration/contacts/edit.md +++ b/docs/user-documentation/email/administration/contacts/edit.md @@ -22,6 +22,4 @@ Contacts synchronised from on-premises Active Directory cannot be edited here an | Mobile Phone | The mobile phone for the contact. Clearing the box removes the number from the contact. | | Business Phone | The business phone for the contact. Clearing the box removes the number from the contact. | -*** - {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/email/administration/deleted-mailboxes.md b/docs/user-documentation/email/administration/deleted-mailboxes.md index 98613daee3..7408f0ffba 100644 --- a/docs/user-documentation/email/administration/deleted-mailboxes.md +++ b/docs/user-documentation/email/administration/deleted-mailboxes.md @@ -17,6 +17,4 @@ This page lists the soft-deleted mailboxes in the selected tenant, which are mai The page is read only and has no row actions. To bring a mailbox back, use the mailbox restore tools. -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/email/administration/exchange-retention/policies/README.md b/docs/user-documentation/email/administration/exchange-retention/policies/README.md index ff4c321690..90e08b2d39 100644 --- a/docs/user-documentation/email/administration/exchange-retention/policies/README.md +++ b/docs/user-documentation/email/administration/exchange-retention/policies/README.md @@ -16,6 +16,4 @@ The properties returned are for the Exchange Online PowerShell command `Get-Rete
ActionDescriptionBulk Action Available
Edit PolicyOpens policy.mdfalse
Delete PolicyOpens a modal to confirm you want to delete the policy. A policy that is still applied to at least one mailbox cannot be deleted, and the results panel tells you so, so move those mailboxes onto another policy first.true
-*** - {% include "../../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/email/administration/exchange-retention/policies/policy.md b/docs/user-documentation/email/administration/exchange-retention/policies/policy.md index 92744dd146..c1987b9041 100644 --- a/docs/user-documentation/email/administration/exchange-retention/policies/policy.md +++ b/docs/user-documentation/email/administration/exchange-retention/policies/policy.md @@ -11,6 +11,4 @@ This page creates a retention policy, or opens an existing one for editing when The tag list you submit replaces the policy's existing links rather than adding to them, so removing a tag from the selection removes it from the policy. {% endhint %} -*** - {% include "../../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/email/administration/exchange-retention/tags/README.md b/docs/user-documentation/email/administration/exchange-retention/tags/README.md index ec04586587..4364fbcd59 100644 --- a/docs/user-documentation/email/administration/exchange-retention/tags/README.md +++ b/docs/user-documentation/email/administration/exchange-retention/tags/README.md @@ -18,6 +18,4 @@ For an explanation of what each tag type covers and what each retention action d
ActionDescriptionBulk Action Available
Edit TagOpens tag.md for the selected tag.false
Delete TagOpens a modal to confirm you want to delete the tag. A tag that is still linked to a retention policy cannot be deleted, and the results panel names the policies using it, so remove it from those policies first.true
-*** - {% include "../../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/email/administration/exchange-retention/tags/tag.md b/docs/user-documentation/email/administration/exchange-retention/tags/tag.md index 6d31b00b9f..93244dbd58 100644 --- a/docs/user-documentation/email/administration/exchange-retention/tags/tag.md +++ b/docs/user-documentation/email/administration/exchange-retention/tags/tag.md @@ -17,6 +17,4 @@ This page creates a retention tag, or opens an existing one for editing when you A tag on its own does nothing. It has to be added to a retention policy on [policy.md](../policies/policy.md "mention"), and that policy applied to a mailbox, before it affects any mail. {% endhint %} -*** - {% include "../../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/email/administration/hve-accounts.md b/docs/user-documentation/email/administration/hve-accounts.md index bfa0fd2000..c53be5a868 100644 --- a/docs/user-documentation/email/administration/hve-accounts.md +++ b/docs/user-documentation/email/administration/hve-accounts.md @@ -40,6 +40,4 @@ The row flyout adds the account's billing policy, its Microsoft Entra object ID, Every action other than **Delete HVE Account** works on a single account at a time and is not offered when several rows are selected. -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/email/administration/mailbox-rules.md b/docs/user-documentation/email/administration/mailbox-rules.md index dfe21aeaef..6bd33f65c4 100644 --- a/docs/user-documentation/email/administration/mailbox-rules.md +++ b/docs/user-documentation/email/administration/mailbox-rules.md @@ -20,6 +20,4 @@ The row flyout shows the rule's full definition, which is where its actual condi All three actions are also available from inside the flyout. -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/email/administration/mailboxes.md b/docs/user-documentation/email/administration/mailboxes.md index bc680943aa..d1430827f5 100644 --- a/docs/user-documentation/email/administration/mailboxes.md +++ b/docs/user-documentation/email/administration/mailboxes.md @@ -62,6 +62,4 @@ If every mailbox reports zero storage used, an alert appears above the table. Th
ActionDescriptionBulk Action Available
Bulk Add Mailbox PermissionsGrants other users access to the selected mailbox(es). Full Access, Send As, and Send On Behalf are each granted to their own list of users, and automapping (which makes Outlook add the mailbox on its own) is on by default for Full Access.true
Edit permissionsOpens the exchange.md for the mailbox's user, where existing permissions can be changed or removed.false
Research Compromised Account

Opens the bec.md investigation for the account, which analyses indicators of compromise:

  • Sign-in patterns
  • Mail rules
  • Suspicious activities
false
Send MFA PushSends a test MFA prompt to the user's registered devices.true
Convert MailboxConverts the mailbox to a different type: User Mailbox, Shared Mailbox, Room Mailbox or Equipment Mailbox.true
Enable Online ArchiveCreates the online archive for the mailbox. Greyed out on a mailbox that already has one.true
Set Retention PolicyApplies one of the tenant's messaging records management retention policies to the selected mailbox(es).true
Enable Auto-Expanding ArchiveSwitches on auto-expanding archiving for the mailbox, letting the archive grow beyond its initial quota. Greyed out on a mailbox with no online archive, and cannot be undone once enabled.true
Set Global Address List visibilityHides or shows the mailbox in the Global Address List. Changes can take up to 72 hours to appear.true
Start Managed Folder AssistantRuns the managed folder assistant against the mailbox, which applies its retention settings immediately rather than waiting for the next scheduled run.true
Delete MailboxDeletes the mailbox and the account it belongs to.true
Set Copy Sent Items for Delegated MailboxesControls whether mail sent as, or on behalf of, the mailbox is also saved to its Sent Items. Greyed out on anything other than a user or shared mailbox.true
Set Litigation HoldPlaces the mailbox on litigation hold for a number of days, or indefinitely if left blank, and can disable an existing hold. Greyed out on a mailbox whose licence does not include litigation hold.true
Set Retention HoldSuspends the processing of retention policies for the mailbox, or lifts an existing retention hold.true
Set Mailbox LocaleSets the mailbox language and regional format, for example en-US, which also names the default folders.true
Set Max Send/Receive SizeSets the largest message the mailbox can send and receive, from 1 to 150MB. Either can be left blank to leave it unchanged.true
Set Send QuotaSets the size at which the mailbox is blocked from sending, for example 10GB.true
Set Send and Receive QuotaSets the size at which the mailbox is blocked from both sending and receiving.true
Set Quota Warning LevelSets the size at which the user is warned that the mailbox is filling up.true
Set Calendar ProcessingConfigures how a resource handles booking requests: automatic processing and acceptance, conflict and recurrence rules, booking window and duration limits, what is kept of the original request, and the response text sent back. Greyed out on anything other than a room or equipment mailbox.true
More InfoOpens the Extended Info flyout with the full details for the selected row.false
-*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/email/administration/quarantine.md b/docs/user-documentation/email/administration/quarantine.md index 5eec329791..1f5d08b5f4 100644 --- a/docs/user-documentation/email/administration/quarantine.md +++ b/docs/user-documentation/email/administration/quarantine.md @@ -26,6 +26,4 @@ The flyout carries the same actions and highlights the message ID, recipient add **Release & Allow Sender** adds a standing allow entry to the anti-spam policy, and that entry stays until it is removed by hand. Use it for a sender that is genuinely being caught wrongly, and prefer a plain **Release** otherwise. {% endhint %} -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/email/administration/restricted-users.md b/docs/user-documentation/email/administration/restricted-users.md index 06407eccb4..fc02e23213 100644 --- a/docs/user-documentation/email/administration/restricted-users.md +++ b/docs/user-documentation/email/administration/restricted-users.md @@ -29,6 +29,4 @@ This typically indicates a compromised account. [Before unblocking, ensure you h
ActionDescriptionBulk Action Available
Unblock UserRemoves the sending restriction from the selected user so they can send email again. Unblocking can take up to an hour to take effect, and the account should be secured before it is unblocked.true
-*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/email/administration/tenant-allow-block-list-templates.md b/docs/user-documentation/email/administration/tenant-allow-block-list-templates.md index bee5f616ef..2d6d846ca7 100644 --- a/docs/user-documentation/email/administration/tenant-allow-block-list-templates.md +++ b/docs/user-documentation/email/administration/tenant-allow-block-list-templates.md @@ -76,6 +76,4 @@ When the standard remediates, it reads the tenant's current allow/block list for The standard adds entries and never removes them. An entry deleted from a template is not withdrawn from the tenants it was already deployed to, and an entry added by hand in the tenant is left alone. {% endhint %} -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/email/administration/tenant-allow-block-lists.md b/docs/user-documentation/email/administration/tenant-allow-block-lists.md index ea5d94d4d4..c4bac16047 100644 --- a/docs/user-documentation/email/administration/tenant-allow-block-lists.md +++ b/docs/user-documentation/email/administration/tenant-allow-block-lists.md @@ -73,6 +73,4 @@ If a tenant does not support one of the four list types, that type is skipped an
ActionDescriptionBulk Action Available
RemoveRemoves the entry from the tenant's allow/block list. Removal is immediate and the entry cannot be recovered, so a block that is still needed has to be added again.true
-*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/email/reports/activesync-devices.md b/docs/user-documentation/email/reports/activesync-devices.md index a87043f2f5..840942a214 100644 --- a/docs/user-documentation/email/reports/activesync-devices.md +++ b/docs/user-documentation/email/reports/activesync-devices.md @@ -14,6 +14,4 @@ The properties returned are for the Exchange Online PowerShell command `Get-Mobi Each action confirms first, naming the device and the mailbox it belongs to. Allowing and blocking are settings on the mailbox rather than on the device, so a device that synchronises several mailboxes needs the action running against each of its rows. -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/email/reports/antiphishing-filters.md b/docs/user-documentation/email/reports/antiphishing-filters.md index 0ee4f9a750..26e65d2a4d 100644 --- a/docs/user-documentation/email/reports/antiphishing-filters.md +++ b/docs/user-documentation/email/reports/antiphishing-filters.md @@ -21,6 +21,4 @@ Spoof intelligence, unauthenticated sender indicators, and the first contact saf
ActionDescriptionBulk Action Available
Enable RuleBrings the policy into force so it starts applying to the recipients its rule covers. Greyed out where State is already Enabled.true
Disable RuleStops the policy applying while keeping all of its settings, so it can be brought back later. Greyed out where State is already Disabled.true
More InfoOpens the Extended Info flyout with the full details for the selected row.false
-*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/email/reports/calendar-permissions.md b/docs/user-documentation/email/reports/calendar-permissions.md index 5ccc2591f1..99661376df 100644 --- a/docs/user-documentation/email/reports/calendar-permissions.md +++ b/docs/user-documentation/email/reports/calendar-permissions.md @@ -35,6 +35,4 @@ In the **By Calendar** grouping: The `Default` and `Anonymous` entries that every calendar carries are left out, so a calendar with no explicit delegate does not appear at all. The folder name is worth reading where a mailbox was created in another language, because the calendar folder is named in that language rather than being called Calendar. -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/email/reports/global-address-list.md b/docs/user-documentation/email/reports/global-address-list.md index d8f6747f09..aa0de05c43 100644 --- a/docs/user-documentation/email/reports/global-address-list.md +++ b/docs/user-documentation/email/reports/global-address-list.md @@ -30,6 +30,4 @@ Both actions confirm first, and both apply to mailbox backed recipients only. Ru Where **Is Dir Synced** is `Yes`, the entry is owned by on-premises Active Directory. Make the change on the on-premises object instead, or directory synchronisation will put the old value back. {% endhint %} -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/email/reports/mailbox-activity.md b/docs/user-documentation/email/reports/mailbox-activity.md index 3a94d732d6..f06b90a0b6 100644 --- a/docs/user-documentation/email/reports/mailbox-activity.md +++ b/docs/user-documentation/email/reports/mailbox-activity.md @@ -22,6 +22,4 @@ Microsoft 365 has a tenant-wide setting that conceals user names in usage report The fix is on the tenant, not in CIPP. Enabling the **Enable Usernames instead of pseudo anonymised names in reports** standard turns the setting off, after which the report needs running again to pick up real names. -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/email/reports/mailbox-cas-settings.md b/docs/user-documentation/email/reports/mailbox-cas-settings.md index 60ed22f50f..ff988afe1b 100644 --- a/docs/user-documentation/email/reports/mailbox-cas-settings.md +++ b/docs/user-documentation/email/reports/mailbox-cas-settings.md @@ -22,6 +22,4 @@ The properties returned are for the Exchange Online PowerShell command `Get-CASM Choosing **Disable** with **SMTP Client Authentication** selected does turn SMTP AUTH off, despite the inverted column name. The reverse is not possible: SMTP client authentication can only be turned off from here, and an attempt to turn it back on comes back as a warning, though any other protocols chosen at the same time are still applied. -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/email/reports/mailbox-forwarding.md b/docs/user-documentation/email/reports/mailbox-forwarding.md index 9b37509625..f9fa31f61c 100644 --- a/docs/user-documentation/email/reports/mailbox-forwarding.md +++ b/docs/user-documentation/email/reports/mailbox-forwarding.md @@ -28,6 +28,4 @@ Where a mailbox has both an external and an internal forwarding address configur This report covers forwarding set on the mailbox itself. Forwarding created by an inbox rule is not shown here, so a mailbox that looks clean on this page can still be forwarding mail through a rule. {% endhint %} -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/email/reports/mailbox-permissions.md b/docs/user-documentation/email/reports/mailbox-permissions.md index a90cf58868..5d281560e4 100644 --- a/docs/user-documentation/email/reports/mailbox-permissions.md +++ b/docs/user-documentation/email/reports/mailbox-permissions.md @@ -41,6 +41,4 @@ The `NT AUTHORITY\SELF` grant that every mailbox carries is left out, as are inh Both groupings are refreshed once the removal completes, so the report reflects the change straight away. -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/email/reports/mailbox-statistics.md b/docs/user-documentation/email/reports/mailbox-statistics.md index f812bb09d7..bae07b42e5 100644 --- a/docs/user-documentation/email/reports/mailbox-statistics.md +++ b/docs/user-documentation/email/reports/mailbox-statistics.md @@ -20,6 +20,4 @@ Microsoft 365 has a tenant-wide setting that conceals user names in usage report The fix is on the tenant, not in CIPP. Enabling the **Enable Usernames instead of pseudo anonymised names in reports** standard turns the setting off, after which the report needs running again to pick up real names. -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/email/reports/malware-filters.md b/docs/user-documentation/email/reports/malware-filters.md index 91666fb277..24e9414ac1 100644 --- a/docs/user-documentation/email/reports/malware-filters.md +++ b/docs/user-documentation/email/reports/malware-filters.md @@ -19,6 +19,4 @@ The built-in default policy shows no **Rule Name**, **Priority**, **Recipient Do
ActionDescriptionBulk Action Available
Enable RuleBrings the policy into force so it starts applying to the recipients its rule covers. Greyed out where State is already Enabled.true
Disable RuleStops the policy applying while keeping all of its settings, so it can be brought back later. Greyed out where State is already Disabled.true
More InfoOpens the Extended Info flyout with the full details for the selected row.false
-*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/email/reports/safeattachments-filters.md b/docs/user-documentation/email/reports/safeattachments-filters.md index 5563df793d..31bf9d5eb5 100644 --- a/docs/user-documentation/email/reports/safeattachments-filters.md +++ b/docs/user-documentation/email/reports/safeattachments-filters.md @@ -23,6 +23,4 @@ The built-in default policy shows no **Rule Name**, **Priority**, **Recipient Do
ActionDescriptionBulk Action Available
Enable RuleBrings the policy into force so it starts applying to the recipients its rule covers. Greyed out where State is already Enabled.true
Disable RuleStops the policy applying while keeping all of its settings, so it can be brought back later. Greyed out where State is already Disabled.true
More InfoOpens the Extended Info flyout with the full details for the selected row.false
-*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/email/reports/sharedmailboxenabledaccount.md b/docs/user-documentation/email/reports/sharedmailboxenabledaccount.md index a09b5255a0..fa29859dc4 100644 --- a/docs/user-documentation/email/reports/sharedmailboxenabledaccount.md +++ b/docs/user-documentation/email/reports/sharedmailboxenabledaccount.md @@ -22,6 +22,4 @@ The report pairs each of the tenant's shared mailboxes with its user account and
ActionDescriptionBulk Action Available
Block Sign InBlocks the account behind the shared mailbox from signing in, leaving the mailbox itself and everyone's delegated access to it working normally. Greyed out where On Premises Sync Enabled is Yes, because a synchronised account has to be disabled in on-premises Active Directory instead.true
More InfoOpens the Extended Info flyout with the full details for the selected row.false
-*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/email/resources/management/equipment/README.md b/docs/user-documentation/email/resources/management/equipment/README.md index 113ccf08fe..37636bb53e 100644 --- a/docs/user-documentation/email/resources/management/equipment/README.md +++ b/docs/user-documentation/email/resources/management/equipment/README.md @@ -32,6 +32,4 @@ The properties returned are for the Exchange Online PowerShell command `Get-Mail
ActionDescriptionBulk Action Available
Edit EquipmentOpens the edit.md page for the selected row, where the name, address list visibility, contact and location details, booking behaviour, and working hours can be changed.false
Edit permissionsOpens the exchange.md page for the account behind the mailbox, where mailbox and calendar permissions are managed.false
Block Sign InBlocks sign-in for the selected equipment mailbox, so nobody can sign in as the resource. New equipment mailboxes are already blocked when they are created.true
Unblock Sign InRestores sign-in for the selected equipment mailbox.true
Delete EquipmentDeletes the account behind the selected equipment mailbox, which removes the mailbox with it. Asks for confirmation first.true
-*** - {% include "../../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/email/resources/management/equipment/edit.md b/docs/user-documentation/email/resources/management/equipment/edit.md index ea234396b7..4508705b9a 100644 --- a/docs/user-documentation/email/resources/management/equipment/edit.md +++ b/docs/user-documentation/email/resources/management/equipment/edit.md @@ -47,6 +47,4 @@ This page changes the details and booking behaviour of an equipment mailbox. It **Save** applies the changes and returns you to the equipment list. -*** - {% include "../../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/email/resources/management/list-rooms/README.md b/docs/user-documentation/email/resources/management/list-rooms/README.md index b50f564172..35c842432a 100644 --- a/docs/user-documentation/email/resources/management/list-rooms/README.md +++ b/docs/user-documentation/email/resources/management/list-rooms/README.md @@ -45,6 +45,4 @@ Each row combines the room's mailbox with its location and facility details, so
ActionDescriptionBulk Action Available
Edit RoomOpens the edit.md page with the selected row's room pre-populated.false
Edit permissionsOpens the exchange.md page for the account behind the mailbox, where mailbox and calendar permissions are managed.false
Block Sign InBlocks sign-in for the selected room mailbox, so nobody can sign in as the room. Greyed out when sign-in is already blocked.true
Unblock Sign InRestores sign-in for the selected room mailbox. Greyed out unless sign-in is currently blocked.true
Delete RoomDeletes the account behind the selected room mailbox, which removes the mailbox with it. Asks for confirmation first.true
-*** - {% include "../../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/email/resources/management/list-rooms/edit.md b/docs/user-documentation/email/resources/management/list-rooms/edit.md index 9a28a500dc..5498881aa7 100644 --- a/docs/user-documentation/email/resources/management/list-rooms/edit.md +++ b/docs/user-documentation/email/resources/management/list-rooms/edit.md @@ -76,6 +76,4 @@ Turning on **Add Organizer to Subject** while **Default Calendar Permission** is | Postal Code | The postal code of the building. | | Country/Region | The country or region the building is in, chosen from a list. | -*** - {% include "../../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/email/resources/management/room-lists/README.md b/docs/user-documentation/email/resources/management/room-lists/README.md index 82d5e1b055..b6dd865536 100644 --- a/docs/user-documentation/email/resources/management/room-lists/README.md +++ b/docs/user-documentation/email/resources/management/room-lists/README.md @@ -30,6 +30,4 @@ The properties returned are for the Exchange Online PowerShell command `Get-Dist
ActionDescriptionBulk Action Available
Edit Room ListOpens the edit.md page with the selected row's room list pre-populated.false
Delete Room ListDeletes the selected room list. The rooms it contained are left in place. Asks for confirmation first.true
More InfoOpens the Extended Info flyout with the full details for the selected row.false
-*** - {% include "../../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/email/resources/management/room-lists/edit.md b/docs/user-documentation/email/resources/management/room-lists/edit.md index beb18f75b9..babb4b8691 100644 --- a/docs/user-documentation/email/resources/management/room-lists/edit.md +++ b/docs/user-documentation/email/resources/management/room-lists/edit.md @@ -36,6 +36,4 @@ Emptying **Display Name**, **Description**, or **Mail Nickname** leaves the prev | ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | | Let people outside the organization email the room list | When on, senders outside the organisation can email the room list. When off, only internal senders can. | -*** - {% include "../../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/email/spamfilter/list-connectionfilter-templates.md b/docs/user-documentation/email/spamfilter/list-connectionfilter-templates.md index 2dc1fe8875..d359bdabb0 100644 --- a/docs/user-documentation/email/spamfilter/list-connectionfilter-templates.md +++ b/docs/user-documentation/email/spamfilter/list-connectionfilter-templates.md @@ -21,6 +21,4 @@ The columns come from the saved template, so a column is empty where the templat
ActionDescriptionBulk Action Available
Save to GitHubUploads the selected template to a community repository. You are asked for a Repository, listing only repositories CIPP can write to, and a Commit Message. Greyed out unless the GitHub integration is enabled.true
Delete TemplateDeletes the template from CIPP. Connection filter settings already applied from it in a tenant are not affected.true
More InfoOpens the Extended Info flyout with the full details for the selected row.false
-*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/email/spamfilter/list-connectionfilter/README.md b/docs/user-documentation/email/spamfilter/list-connectionfilter/README.md index 9b25501651..99de702a7b 100644 --- a/docs/user-documentation/email/spamfilter/list-connectionfilter/README.md +++ b/docs/user-documentation/email/spamfilter/list-connectionfilter/README.md @@ -18,6 +18,4 @@ The properties returned are for the Exchange Online PowerShell command `Get-Host
ActionDescriptionBulk Action Available
Create template based on filterSaves the selected policy as a connection filter template, which then appears on list-connectionfilter-templates.md.true
More InfoOpens the Extended Info flyout with the full details for the selected row.false
-*** - {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/email/spamfilter/list-connectionfilter/add.md b/docs/user-documentation/email/spamfilter/list-connectionfilter/add.md index 1693339fc7..f59ed4f370 100644 --- a/docs/user-documentation/email/spamfilter/list-connectionfilter/add.md +++ b/docs/user-documentation/email/spamfilter/list-connectionfilter/add.md @@ -16,6 +16,4 @@ Deploying updates the tenant's existing connection filter policy rather than add Templates are managed on [list-connectionfilter-templates.md](../list-connectionfilter-templates.md "mention"). -*** - {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/email/spamfilter/list-quarantine-policies/README.md b/docs/user-documentation/email/spamfilter/list-quarantine-policies/README.md index 3be05c5cd3..65d909c26c 100644 --- a/docs/user-documentation/email/spamfilter/list-quarantine-policies/README.md +++ b/docs/user-documentation/email/spamfilter/list-quarantine-policies/README.md @@ -62,6 +62,4 @@ Microsoft supplies a set of built-in policies alongside any you create yourself.
ActionDescriptionBulk Action Available
Edit PolicyOpens a form for the end-user permissions and notification settings on the policy. Policy Name is shown but cannot be changed. Greyed out on built-in policies.true
Delete PolicyDeletes the policy from the tenant. It is removed even where it is still in use, so check first that it is not referenced by an anti-phishing, anti-spam, anti-malware, or Safe Attachments policy. Greyed out on built-in policies.true
More InfoOpens the Extended Info flyout with the full details for the selected row.false
-*** - {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/email/spamfilter/list-quarantine-policies/add.md b/docs/user-documentation/email/spamfilter/list-quarantine-policies/add.md index 6f65f7b280..212d889817 100644 --- a/docs/user-documentation/email/spamfilter/list-quarantine-policies/add.md +++ b/docs/user-documentation/email/spamfilter/list-quarantine-policies/add.md @@ -22,6 +22,4 @@ Releasing and requesting release are mutually exclusive in Exchange, which is wh Once created, the policy appears on [README.md](README.md "mention"), where its permissions can be changed with the **Edit Policy** action. The global notification frequency, sender address, and branding are set separately from the **Edit Settings** button on that page. -*** - {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/email/spamfilter/list-spamfilter/README.md b/docs/user-documentation/email/spamfilter/list-spamfilter/README.md index 497b74d677..6a0d076408 100644 --- a/docs/user-documentation/email/spamfilter/list-spamfilter/README.md +++ b/docs/user-documentation/email/spamfilter/list-spamfilter/README.md @@ -30,6 +30,4 @@ This page lists the hosted content filter (anti-spam) policies in the selected t
ActionDescriptionBulk Action Available
Create template based on ruleSaves the selected policy as a spam filter template, which then appears on list-templates.md.true
Enable RuleEnables the content filter rule that applies the policy. Greyed out unless Rule State is Disabled.true
Disable RuleDisables the content filter rule that applies the policy, leaving the policy itself in place. Greyed out unless Rule State is Enabled.true
Delete RuleDeletes the content filter rule and the policy it applies. Both are removed, and this cannot be undone.true
More InfoOpens the Extended Info flyout with the full details for the selected row.false
-*** - {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/email/spamfilter/list-spamfilter/add.md b/docs/user-documentation/email/spamfilter/list-spamfilter/add.md index 8aa36892a9..800ea65ab5 100644 --- a/docs/user-documentation/email/spamfilter/list-spamfilter/add.md +++ b/docs/user-documentation/email/spamfilter/list-spamfilter/add.md @@ -16,6 +16,4 @@ The policy and the rule are named after each other, so the new rule appears agai Templates are managed on [list-templates.md](../list-templates.md "mention"). -*** - {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/email/spamfilter/list-templates.md b/docs/user-documentation/email/spamfilter/list-templates.md index c82f4c4c86..af034fc025 100644 --- a/docs/user-documentation/email/spamfilter/list-templates.md +++ b/docs/user-documentation/email/spamfilter/list-templates.md @@ -20,6 +20,4 @@ The columns come from the saved template, so a column is empty where the templat
ActionDescriptionBulk Action Available
Save to GitHubUploads the selected template to a community repository. You are asked for a Repository, listing only repositories CIPP can write to, and a Commit Message. Greyed out unless the GitHub integration is enabled.true
Delete TemplateDeletes the template from CIPP. Policies already deployed from it in a tenant are not affected.true
More InfoOpens the Extended Info flyout with the full details for the selected row.false
-*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/email/transport/list-connector-templates.md b/docs/user-documentation/email/transport/list-connector-templates.md index 7334105c5f..cedafdefdd 100644 --- a/docs/user-documentation/email/transport/list-connector-templates.md +++ b/docs/user-documentation/email/transport/list-connector-templates.md @@ -26,6 +26,4 @@ The Extended Info flyout on this page is a **Connector Template Details** card s
ActionDescriptionBulk Action Available
Save to GitHubCommits the selected template to a GitHub repository you choose, using a commit message you supply. Greyed out unless the GitHub integration is set up and you have write access to at least one repository.true
Delete TemplateRemoves the selected template from CIPP. Connectors already deployed from the template keep working and are not changed.true
More InfoOpens the Extended Info flyout with the full details for the selected row.false
-*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/email/transport/list-connectors.md b/docs/user-documentation/email/transport/list-connectors.md index eb9a4ac0d8..ede4cf9ad3 100644 --- a/docs/user-documentation/email/transport/list-connectors.md +++ b/docs/user-documentation/email/transport/list-connectors.md @@ -37,6 +37,4 @@ Several columns only apply in one direction, so an inbound connector leaves the
ActionDescriptionBulk Action Available
Create template based on connectorSaves the selected connector as a connector template on the list-connector-templates.md page, so its settings can be redeployed to other tenants.true
Enable ConnectorSets the selected connector to Enabled so mail starts flowing through it. Greyed out for a connector that is already enabled, and for a bulk selection unless every selected connector is disabled.true
Disable ConnectorSets the selected connector to Disabled, which stops mail flowing through it while leaving its configuration in place. Greyed out for a connector that is already disabled, and for a bulk selection unless every selected connector is enabled.true
Delete ConnectorPermanently removes the selected connector from the tenant. Consider using Create template based on connector first if you might need to recreate it.true
More InfoOpens the Extended Info flyout with the full details for the selected row.false
-*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/email/transport/list-rules.md b/docs/user-documentation/email/transport/list-rules.md index 508389bbbf..4ec799906a 100644 --- a/docs/user-documentation/email/transport/list-rules.md +++ b/docs/user-documentation/email/transport/list-rules.md @@ -36,6 +36,4 @@ The properties returned are for the Exchange Online PowerShell command `Get-Tran
ActionDescriptionBulk Action Available
Create template based on ruleSaves the selected rule as a transport rule template on the list-templates.md page, so it can be redeployed to other tenants.true
Enable RuleSets the rule's State to Enabled so it starts acting on mail. Greyed out for a rule that is already enabled, and for a bulk selection unless every selected rule is disabled.true
Edit RuleOpens the same guided editor as New Transport Rule, pre-filled with the selected rule's conditions, actions, exceptions, and advanced settings. Save your changes with Update Rule.true
Disable RuleSets the rule's State to Disabled, which leaves the rule in place but stops it acting on mail. Greyed out for a rule that is already disabled, and for a bulk selection unless every selected rule is enabled.true
Delete RulePermanently removes the rule from the tenant. Consider using Create template based on rule first if you might need to recreate it.true
More InfoOpens the Extended Info flyout with the full details for the selected row.false
-*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/email/transport/list-templates.md b/docs/user-documentation/email/transport/list-templates.md index 7542e6a987..895f2847d5 100644 --- a/docs/user-documentation/email/transport/list-templates.md +++ b/docs/user-documentation/email/transport/list-templates.md @@ -30,6 +30,4 @@ The flyout shows a template's name, comments, and identifier, but not the rule i
ActionDescriptionBulk Action Available
Save to GitHubCommits the selected template to a GitHub repository you choose, using a commit message you supply. Greyed out unless the GitHub integration is set up and you have write access to at least one repository.true
Delete TemplateRemoves the selected template from CIPP. Rules already deployed from the template keep working and are not changed.true
More InfoOpens the Extended Info flyout with the full details for the selected row.false
-*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/endpoint/applications/application-templates.md b/docs/user-documentation/endpoint/applications/application-templates.md index 3c2f14b95c..c331d9cd65 100644 --- a/docs/user-documentation/endpoint/applications/application-templates.md +++ b/docs/user-documentation/endpoint/applications/application-templates.md @@ -44,6 +44,4 @@ After saving, the drawer stays open so a variation can be adjusted and saved as Deployed templates go into the queue alongside anything added from the Applications page, so nothing reaches Intune until the queue is processed. Use **Run Queue now** on the [queue.md](queue.md "mention") page if you would rather not wait for the scheduled run. {% endhint %} -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/endpoint/applications/list.md b/docs/user-documentation/endpoint/applications/list.md index 154ebd2bf7..8879e700f1 100644 --- a/docs/user-documentation/endpoint/applications/list.md +++ b/docs/user-documentation/endpoint/applications/list.md @@ -191,6 +191,4 @@ Every assignment action offers an assignment mode. **Append** keeps the existing Choosing the Uninstall intent creates an uninstall assignment, which will remove the application from the targeted devices rather than simply unassigning it. {% endhint %} -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/endpoint/applications/queue.md b/docs/user-documentation/endpoint/applications/queue.md index e558a76857..ce3cabaf01 100644 --- a/docs/user-documentation/endpoint/applications/queue.md +++ b/docs/user-documentation/endpoint/applications/queue.md @@ -43,6 +43,4 @@ The queue is processed automatically every twelve hours, so items left alone wil A deployment queued for a single tenant is removed from this list once it has been processed. A deployment queued for All Tenants stays in the list permanently with a status of Deployed, and is reprocessed on every run so that tenants added later also receive the application. Deleting it is the only way to stop that. {% endhint %} -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/endpoint/autopilot/add-device.md b/docs/user-documentation/endpoint/autopilot/add-device.md index 6658c0d273..9b330659eb 100644 --- a/docs/user-documentation/endpoint/autopilot/add-device.md +++ b/docs/user-documentation/endpoint/autopilot/add-device.md @@ -68,6 +68,4 @@ Review the tenant, the device list and the batch name, then submit. CIPP waits b * Group tags are only submitted when a new device batch is created. Adding devices to a batch name that already exists drops the group tag, and it will need setting afterwards with **Edit Group Tag** on the Autopilot Devices page. * Validation runs on manually entered rows only. A CSV is loaded without the duplicate, product key length or serial number companion checks being applied, so errors in a file surface as failures from Partner Center at the end rather than at import time. -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/endpoint/autopilot/enrollment-profiles/README.md b/docs/user-documentation/endpoint/autopilot/enrollment-profiles/README.md index a7ea727711..fc535daf75 100644 --- a/docs/user-documentation/endpoint/autopilot/enrollment-profiles/README.md +++ b/docs/user-documentation/endpoint/autopilot/enrollment-profiles/README.md @@ -39,6 +39,4 @@ The properties returned are for the Graph resource type `windowsAutopilotDeploym
ActionDescriptionBulk Action Available
Delete ProfileDeletes the profile from the tenant along with its assignments. Devices already deployed with it are unaffected, but devices reset afterwards will no longer receive it.true
More InfoOpens the Extended Info flyout with the full details for the selected row.false
-*** - {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/endpoint/autopilot/enrollment-profiles/android-enterprise.md b/docs/user-documentation/endpoint/autopilot/enrollment-profiles/android-enterprise.md index 6d87578e9c..ef8fe63cfa 100644 --- a/docs/user-documentation/endpoint/autopilot/enrollment-profiles/android-enterprise.md +++ b/docs/user-documentation/endpoint/autopilot/enrollment-profiles/android-enterprise.md @@ -10,6 +10,4 @@ The properties returned are for the Graph resource type `androidDeviceOwnerEnrol
ActionDescriptionBulk Action Available
Show QRDisplays the enrolment QR code for the profile, with the token value shown underneath and a button to copy it. Only offered on profiles that have an enrolment token.false
Delete ProfileDeletes the enrolment profile from the tenant. Devices already enrolled through it stay enrolled, but the QR code and token stop working.true
More InfoOpens the Extended Info flyout with the full details for the selected row.false
-*** - {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/endpoint/autopilot/enrollment-profiles/apple-ade.md b/docs/user-documentation/endpoint/autopilot/enrollment-profiles/apple-ade.md index ef507df5f5..8f4cc5ae47 100644 --- a/docs/user-documentation/endpoint/autopilot/enrollment-profiles/apple-ade.md +++ b/docs/user-documentation/endpoint/autopilot/enrollment-profiles/apple-ade.md @@ -42,6 +42,4 @@ The Apple ID, token expiry and token type of the owning token are also carried o
ActionDescriptionBulk Action Available
Delete ProfileDeletes the enrolment profile from its ADE token. Devices already assigned to it fall back to the token's default profile at next enrolment.true
More InfoOpens the Extended Info flyout with the full details for the selected row.false
-*** - {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/endpoint/autopilot/list-devices.md b/docs/user-documentation/endpoint/autopilot/list-devices.md index 252c00d722..8229b50be8 100644 --- a/docs/user-documentation/endpoint/autopilot/list-devices.md +++ b/docs/user-documentation/endpoint/autopilot/list-devices.md @@ -32,6 +32,4 @@ The properties returned are for the Graph resource type `windowsAutopilotDeviceI Display names entered for **Rename Device** must be 15 characters or fewer, may contain only letters, numbers and hyphens, cannot contain spaces, and cannot be made up entirely of numbers. Group tags are limited to 128 characters. Both are validated in the browser and again by CIPP, so an invalid value is rejected rather than silently applied. {% endhint %} -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/endpoint/autopilot/list-status-pages.md b/docs/user-documentation/endpoint/autopilot/list-status-pages.md index 32a3ca416c..35026c0f30 100644 --- a/docs/user-documentation/endpoint/autopilot/list-status-pages.md +++ b/docs/user-documentation/endpoint/autopilot/list-status-pages.md @@ -43,6 +43,4 @@ After a successful submission the drawer stays open so the same settings can be The properties returned are for the Graph resource type `deviceEnrollmentConfiguration`. For more information on the properties please see the [Graph documentation](https://learn.microsoft.com/en-us/graph/api/resources/intune-shared-deviceenrollmentconfiguration?view=graph-rest-beta#properties). The status page settings themselves come from the `windows10EnrollmentCompletionPageConfiguration` subtype, documented [here](https://learn.microsoft.com/en-us/graph/api/resources/intune-onboarding-windows10enrollmentcompletionpageconfiguration?view=graph-rest-beta#properties). -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/endpoint/mem/approval-requests.md b/docs/user-documentation/endpoint/mem/approval-requests.md index 7608391960..1bc183d402 100644 --- a/docs/user-documentation/endpoint/mem/approval-requests.md +++ b/docs/user-documentation/endpoint/mem/approval-requests.md @@ -42,6 +42,4 @@ The table deliberately offers nothing else. Approving and rejecting are refused Once a request is approved, a change CIPP raised is reapplied automatically. There is nothing to resubmit from this page. {% endhint %} -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/endpoint/mem/assignment-filter-templates/README.md b/docs/user-documentation/endpoint/mem/assignment-filter-templates/README.md index f6d6e7ba6e..823062de42 100644 --- a/docs/user-documentation/endpoint/mem/assignment-filter-templates/README.md +++ b/docs/user-documentation/endpoint/mem/assignment-filter-templates/README.md @@ -27,6 +27,4 @@ The template's rule and filter type are not shown as columns, but appear in the
ActionDescriptionBulk Action Available
Edit TemplateOpens the template for editing in edit-assignment-filter-template.md.false
Save to GitHubUploads the template to one of your GitHub repositories, prompting for the repository and a commit message. Only repositories you have write access to are offered. Hidden unless the GitHub integration is enabled.true
Delete TemplateDeletes the template from CIPP. Filters already created in a tenant from it are unaffected.true
More InfoOpens the Extended Info flyout with the full details for the selected row.false
-*** - {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/endpoint/mem/assignment-filter-templates/add.md b/docs/user-documentation/endpoint/mem/assignment-filter-templates/add.md index f00b7df989..22cde0e52d 100644 --- a/docs/user-documentation/endpoint/mem/assignment-filter-templates/add.md +++ b/docs/user-documentation/endpoint/mem/assignment-filter-templates/add.md @@ -16,6 +16,4 @@ Creates an assignment filter template in CIPP. The template can then be deployed See Microsoft's documentation on [filter device properties](https://learn.microsoft.com/en-us/mem/intune/fundamentals/filters-device-properties) for the properties and operators the rule syntax supports. {% endhint %} -*** - {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/endpoint/mem/assignment-filter-templates/deploy.md b/docs/user-documentation/endpoint/mem/assignment-filter-templates/deploy.md index f8195aa186..2851df1c15 100644 --- a/docs/user-documentation/endpoint/mem/assignment-filter-templates/deploy.md +++ b/docs/user-documentation/endpoint/mem/assignment-filter-templates/deploy.md @@ -30,6 +30,4 @@ Review the tenants and the filter, then submit. {% endstep %} {% endstepper %} -*** - {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/endpoint/mem/assignment-filters/README.md b/docs/user-documentation/endpoint/mem/assignment-filters/README.md index a3c54bef39..e1eaf530f4 100644 --- a/docs/user-documentation/endpoint/mem/assignment-filters/README.md +++ b/docs/user-documentation/endpoint/mem/assignment-filters/README.md @@ -16,6 +16,4 @@ The properties returned are for the Graph resource type `deviceAndAppManagementA
ActionDescriptionBulk Action Available
Create template based on filterSaves the filter as an assignment-filter-templates entry, so the same filter can be deployed to other tenants.true
Edit FilterOpens the filter for editing in edit.md.false
Delete FilterDeletes the filter from the tenant. Assignments using it lose the filter, so they apply to their full target group instead.true
More InfoOpens the Extended Info flyout with the full details for the selected row.false
-*** - {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/endpoint/mem/assignment-filters/add.md b/docs/user-documentation/endpoint/mem/assignment-filters/add.md index 661d02e975..9f185b7dcf 100644 --- a/docs/user-documentation/endpoint/mem/assignment-filters/add.md +++ b/docs/user-documentation/endpoint/mem/assignment-filters/add.md @@ -20,6 +20,4 @@ See Microsoft's documentation on [filter device properties](https://learn.micros Filter Type and Platform are fixed at creation. Getting either wrong means deleting the filter and creating it again, so check both before saving. {% endhint %} -*** - {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/endpoint/mem/assignment-filters/edit.md b/docs/user-documentation/endpoint/mem/assignment-filters/edit.md index b878ae181f..fece0fd627 100644 --- a/docs/user-documentation/endpoint/mem/assignment-filters/edit.md +++ b/docs/user-documentation/endpoint/mem/assignment-filters/edit.md @@ -16,6 +16,4 @@ The form is the same as [add.md](add.md "mention"), populated with the filter's Editing the rule changes what every assignment using this filter reaches, so a device matching the old rule but not the new one loses the policies and applications the filter was narrowing. {% endhint %} -*** - {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/endpoint/mem/bitlocker-search.md b/docs/user-documentation/endpoint/mem/bitlocker-search.md index 29f81b90b3..b7291da1fa 100644 --- a/docs/user-documentation/endpoint/mem/bitlocker-search.md +++ b/docs/user-documentation/endpoint/mem/bitlocker-search.md @@ -53,6 +53,4 @@ The search runs against CIPP's cached copy of each tenant's escrowed key records The recovery key itself is not held in the cache. **Retrieve Key** fetches it live and shows it in plain text, and every retrieval is written to the CIPP audit log against the account that performed it. {% endhint %} -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/endpoint/mem/devices/README.md b/docs/user-documentation/endpoint/mem/devices/README.md index 62bc85b033..a27a8b7379 100644 --- a/docs/user-documentation/endpoint/mem/devices/README.md +++ b/docs/user-documentation/endpoint/mem/devices/README.md @@ -26,6 +26,4 @@ Selecting a row opens a flyout showing the device name and its assigned user, fr
ActionDescriptionBulk Action Available
View DeviceOpens the device's device.md page in CIPP, with its full details, applications, and users.false
View in IntuneOpens the device in the Microsoft Intune admin center in a new tab.false
Change Primary UserSets a different user as the device's primary user.true
Add to GroupAdds the device to one or more Entra ID groups. Groups are listed with their name and type, and several can be selected at once, with the device added to each. Devices cannot be added to Distribution List or Mail-Enabled Security groups, and selecting one returns an error for that group rather than failing the whole action.true
Rename DeviceChanges the device's name to one you specify.true
Sync DeviceAsks the device to check in with Intune, so that pending policies and applications are applied sooner than the next scheduled sync.true
Reboot DeviceRestarts the device.true
Locate DeviceRequests the device's current location.true
Retrieve LAPS passwordRetrieves the local administrator password held for the device by Windows LAPS. Windows devices only.true
Rotate Local Admin PasswordForces the local administrator password to be changed and a new one stored. Windows devices only.true
Retrieve BIOS PasswordRetrieves the BIOS password Intune holds for the device. A password only exists where the device is targeted by a BIOS configuration profile that manages per-device passwords, otherwise the action reports that none was found. Windows devices only.true
Retrieve BitLocker KeysRetrieves the BitLocker recovery keys escrowed for the device. Windows devices only.true
Retrieve FileVault KeyRetrieves the FileVault recovery key escrowed for the device. macOS devices only.true
Reset PasscodeResets the device's passcode. Android devices only.true
Remove PasscodeRemoves the device's passcode. iOS devices only.true
Windows Defender Full ScanStarts a full Microsoft Defender scan on the device.true
Windows Defender Quick ScanStarts a quick Microsoft Defender scan on the device.true
Update Windows DefenderUpdates the Microsoft Defender signatures on the device.true
Fresh Start (Remove user data)Reinstalls Windows on the device and removes the user's data. Windows devices only.true
Fresh Start (Do not remove user data)Reinstalls Windows on the device while retaining the user's data. Windows devices only.true
Wipe Device, keep enrollment dataWipes the device but retains its enrolment data, so it remains managed. Windows devices only.true
Wipe Device, remove enrollment dataWipes the device and removes its enrolment data, so it is no longer managed. Windows devices only.true
Wipe Device, keep enrollment data, and continue at powerlossAs above, retaining enrolment data, but the wipe resumes if the device loses power part-way through. Windows devices only.true
Wipe Device, remove enrollment data, and continue at powerlossAs above, removing enrolment data, but the wipe resumes if the device loses power part-way through. Windows devices only.true
Autopilot ResetResets the device and re-runs the Autopilot provisioning process. Windows devices only.true
Delete deviceDeletes the device record from Intune.true
Retire deviceRemoves company data and management from the device while leaving the user's personal data in place.true
More InfoOpens the Extended Info flyout with the full details for the selected row.false
-*** - {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/endpoint/mem/devices/device.md b/docs/user-documentation/endpoint/mem/devices/device.md index a10737ce85..64e6f40724 100644 --- a/docs/user-documentation/endpoint/mem/devices/device.md +++ b/docs/user-documentation/endpoint/mem/devices/device.md @@ -73,6 +73,4 @@ Lists the groups the device belongs to, including those it inherits through nest Group membership is read against the device's Entra ID object rather than its Intune record. A device that is not joined to Entra ID, such as one enrolled through a co-management or MDM-only path, reports no group memberships here even where it is managed by Intune. Only groups are listed, so directory roles and administrative units the device object belongs to are left out. {% endhint %} -*** - {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/endpoint/mem/list-appprotection-policies.md b/docs/user-documentation/endpoint/mem/list-appprotection-policies.md index abbae95858..272d7f4e09 100644 --- a/docs/user-documentation/endpoint/mem/list-appprotection-policies.md +++ b/docs/user-documentation/endpoint/mem/list-appprotection-policies.md @@ -22,6 +22,4 @@ A Policy Source column is also available from the column chooser, and appears in {% include "../../../../.gitbook/includes/intune-actions.md" %} -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/endpoint/mem/list-compliance-policies.md b/docs/user-documentation/endpoint/mem/list-compliance-policies.md index 416332071c..f7f019ed12 100644 --- a/docs/user-documentation/endpoint/mem/list-compliance-policies.md +++ b/docs/user-documentation/endpoint/mem/list-compliance-policies.md @@ -23,6 +23,4 @@ The remaining properties, available from the column chooser and in the row flyou {% include "../../../../.gitbook/includes/intune-actions.md" %} -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/endpoint/mem/list-policies.md b/docs/user-documentation/endpoint/mem/list-policies.md index 0fbde430f5..c389133f74 100644 --- a/docs/user-documentation/endpoint/mem/list-policies.md +++ b/docs/user-documentation/endpoint/mem/list-policies.md @@ -25,6 +25,4 @@ The flyout for a Settings Catalog or Administrative Templates policy fetches the {% include "../../../../.gitbook/includes/intune-actions.md" %} -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/endpoint/mem/list-scripts.md b/docs/user-documentation/endpoint/mem/list-scripts.md index 42b2ba84fd..18de99a190 100644 --- a/docs/user-documentation/endpoint/mem/list-scripts.md +++ b/docs/user-documentation/endpoint/mem/list-scripts.md @@ -51,6 +51,4 @@ Built-in Microsoft remediation scripts are read-only. The editor opens and the s Some script types hold no editable content, in which case the dialogue says so rather than opening an editor. {% endhint %} -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/endpoint/mem/list-templates/README.md b/docs/user-documentation/endpoint/mem/list-templates/README.md index 04c2404cd4..9ace1f25de 100644 --- a/docs/user-documentation/endpoint/mem/list-templates/README.md +++ b/docs/user-documentation/endpoint/mem/list-templates/README.md @@ -37,6 +37,4 @@ Selecting a row opens a flyout listing the standards templates the template is u
ActionDescriptionBulk Action Available
Edit TemplateOpens the template for editing in edit.md. Only offered for templates that are not synced to a community repository.false
Edit Template Name and DescriptionChanges the template's name and description. Applying this disconnects the template from the community repository it came from, so it will no longer receive updates.true
Clone TemplateCreates a copy of the template in CIPP. The copy is not linked to a community repository, so it can be edited freely.true
Add to packageTags the template with a package name, so that standards templates referencing that package pick it up. Enter the package name to assign.true
Remove from packageRemoves the package tag from the template.true
Save to GitHubUploads the template to one of your GitHub repositories, prompting for the repository and a commit message. Only repositories you have write access to are offered. Hidden unless the GitHub integration is enabled.true
Delete TemplateDeletes the template from CIPP. Policies already deployed from it are unaffected.true
More InfoOpens the Extended Info flyout with the full details for the selected row.false
-*** - {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/endpoint/mem/list-templates/edit.md b/docs/user-documentation/endpoint/mem/list-templates/edit.md index 009a6e6c6f..ae824fed48 100644 --- a/docs/user-documentation/endpoint/mem/list-templates/edit.md +++ b/docs/user-documentation/endpoint/mem/list-templates/edit.md @@ -39,6 +39,4 @@ Some templates cannot have their settings edited here: Settings the editor does not present as fields are left exactly as they were stored. Saving a template only changes the values bound to a field, so nothing else in the underlying policy is rewritten. {% endhint %} -*** - {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/endpoint/mem/reusable-settings-templates/README.md b/docs/user-documentation/endpoint/mem/reusable-settings-templates/README.md index 19c94e61b2..91168f271b 100644 --- a/docs/user-documentation/endpoint/mem/reusable-settings-templates/README.md +++ b/docs/user-documentation/endpoint/mem/reusable-settings-templates/README.md @@ -20,6 +20,4 @@ Lists the reusable settings templates saved in CIPP. A template holds the config
ActionDescriptionBulk Action Available
Edit TemplateOpens the template for editing in edit-reusable-settings-template.md.false
Save to GitHubUploads the template to one of your GitHub repositories, prompting for the repository and a commit message. Only repositories you have write access to are offered. Hidden unless the GitHub integration is enabled.true
Delete TemplateDeletes the template from CIPP. Reusable settings already created in a tenant from it are unaffected.true
More InfoOpens the Extended Info flyout with the full details for the selected row.false
-*** - {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/endpoint/mem/reusable-settings-templates/add.md b/docs/user-documentation/endpoint/mem/reusable-settings-templates/add.md index 3db267a7bc..fe4233ff95 100644 --- a/docs/user-documentation/endpoint/mem/reusable-settings-templates/add.md +++ b/docs/user-documentation/endpoint/mem/reusable-settings-templates/add.md @@ -22,6 +22,4 @@ Each row in this table is one entry in the address list. **Add row** appends ano Each entry also carries an identifier, which CIPP generates automatically and does not show. Nothing needs entering for it. {% endhint %} -*** - {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/endpoint/mem/reusable-settings-templates/edit-reusable-settings-template.md b/docs/user-documentation/endpoint/mem/reusable-settings-templates/edit-reusable-settings-template.md index 45a2178d60..c51ebaa46e 100644 --- a/docs/user-documentation/endpoint/mem/reusable-settings-templates/edit-reusable-settings-template.md +++ b/docs/user-documentation/endpoint/mem/reusable-settings-templates/edit-reusable-settings-template.md @@ -20,6 +20,4 @@ Each row is one entry in the address list. **Add row** appends another and **Rem | Autoresolve | Whether the keyword is resolved to the addresses it currently points at. | | Keyword | The domain, IP address or address range the entry covers. Wildcards are allowed using the `*` character, and CIPP variables can be inserted so the value differs per tenant. | -*** - {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/endpoint/mem/reusable-settings/README.md b/docs/user-documentation/endpoint/mem/reusable-settings/README.md index cb991b229e..81de624499 100644 --- a/docs/user-documentation/endpoint/mem/reusable-settings/README.md +++ b/docs/user-documentation/endpoint/mem/reusable-settings/README.md @@ -40,6 +40,4 @@ A Referencing Configuration Policy Count column is available from the column cho
ActionDescriptionBulk Action Available
Edit Reusable SettingOpens the setting for editing in edit.md.false
Delete Reusable SettingDeletes the reusable setting from the tenant. Configuration policies referencing it are left pointing at a setting that no longer exists.true
Create Template from SettingSaves the setting as a reusable settings template in CIPP, so the same setting can be deployed to other tenants.true
More InfoOpens the Extended Info flyout with the full details for the selected row.false
-*** - {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/endpoint/mem/reusable-settings/edit.md b/docs/user-documentation/endpoint/mem/reusable-settings/edit.md index 0b6614e713..0eec2036f2 100644 --- a/docs/user-documentation/endpoint/mem/reusable-settings/edit.md +++ b/docs/user-documentation/endpoint/mem/reusable-settings/edit.md @@ -16,6 +16,4 @@ The configuration is rendered below the fields so it can be read without working Configuration policies that reference this setting take its new value as soon as it is saved. A reusable setting exists precisely so that one change reaches every policy using it, so check how many policies reference it before changing the configuration. {% endhint %} -*** - {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/endpoint/reports/analyticsdevicescore.md b/docs/user-documentation/endpoint/reports/analyticsdevicescore.md index d0acaafa1c..515569c157 100644 --- a/docs/user-documentation/endpoint/reports/analyticsdevicescore.md +++ b/docs/user-documentation/endpoint/reports/analyticsdevicescore.md @@ -18,6 +18,4 @@ Every score runs from 0 to 100, where a higher score is healthier. A score of -1 Devices only appear here once Endpoint Analytics has been enabled in the tenant and has collected enough data. An empty report usually means Endpoint Analytics has not been turned on rather than that no devices qualify. {% endhint %} -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/endpoint/reports/autopilot-deployment.md b/docs/user-documentation/endpoint/reports/autopilot-deployment.md index ec8c61d7e1..af968abe25 100644 --- a/docs/user-documentation/endpoint/reports/autopilot-deployment.md +++ b/docs/user-documentation/endpoint/reports/autopilot-deployment.md @@ -18,6 +18,4 @@ Deployment State is the overall outcome. Where a deployment failed, Enrollment F Intune retains Autopilot deployment events for a limited period, so a deployment that ran some time ago may no longer appear here even though the device is still enrolled. {% endhint %} -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/endpoint/reports/detected-apps.md b/docs/user-documentation/endpoint/reports/detected-apps.md index 7790bd686c..0f7219d52d 100644 --- a/docs/user-documentation/endpoint/reports/detected-apps.md +++ b/docs/user-documentation/endpoint/reports/detected-apps.md @@ -20,6 +20,4 @@ Intune refreshes this inventory roughly every seven days per device, counted fro To see which applications a particular device reports, use the Detected Applications section on [device.md](../mem/devices/device.md "mention"). -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/endpoint/reports/work-from-anywhere.md b/docs/user-documentation/endpoint/reports/work-from-anywhere.md index fb24770a52..078d2a93c0 100644 --- a/docs/user-documentation/endpoint/reports/work-from-anywhere.md +++ b/docs/user-documentation/endpoint/reports/work-from-anywhere.md @@ -18,6 +18,4 @@ Upgrade Eligibility summarises the outcome as Capable, Not Capable, Upgraded or Devices only appear here once Endpoint Analytics has been enabled in the tenant and has collected enough data from them. {% endhint %} -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/identity/administration/deleted-items.md b/docs/user-documentation/identity/administration/deleted-items.md index e9f5579603..8d13b88a95 100644 --- a/docs/user-documentation/identity/administration/deleted-items.md +++ b/docs/user-documentation/identity/administration/deleted-items.md @@ -34,6 +34,4 @@ Entra ID keeps soft-deleted objects for 30 days, after which they are removed au Restoring a user does not restore their mailbox content by itself. Exchange Online reconnects the mailbox when the account is restored within the retention window and still holds a licence, so check the mailbox afterwards rather than assuming it came back with the account. {% endhint %} -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/identity/administration/devices.md b/docs/user-documentation/identity/administration/devices.md index b4e8f2d78a..6361adcf56 100644 --- a/docs/user-documentation/identity/administration/devices.md +++ b/docs/user-documentation/identity/administration/devices.md @@ -18,6 +18,4 @@ Retrieving a BitLocker key returns a live recovery key in plain text, so treat t Disabling a device stops it authenticating but leaves the object in place, so the action can be reversed and the device's recovery keys stay available. Deleting is the destructive option, and a device that is still in use will simply register itself again the next time it is joined. {% endhint %} -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/identity/administration/group-templates/README.md b/docs/user-documentation/identity/administration/group-templates/README.md index 6b1b04772d..0797df0ec2 100644 --- a/docs/user-documentation/identity/administration/group-templates/README.md +++ b/docs/user-documentation/identity/administration/group-templates/README.md @@ -31,6 +31,4 @@ The Group Type column shows the stored value rather than the friendly name used
ActionDescriptionBulk Action Available
Edit TemplateOpens the edit.md page for the selected template.false
Save to GitHubUploads the template to one of your GitHub repositories, prompting for the repository and a commit message. Only repositories you have write access to are offered. Greyed out unless the GitHub integration is enabled.true
Delete TemplateDeletes the template from CIPP. Groups already created from it are unaffected.true
More InfoOpens the Extended Info flyout with the full details for the selected row.false
-*** - {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/identity/administration/group-templates/add.md b/docs/user-documentation/identity/administration/group-templates/add.md index 6a26f6abc9..9a90144549 100644 --- a/docs/user-documentation/identity/administration/group-templates/add.md +++ b/docs/user-documentation/identity/administration/group-templates/add.md @@ -52,6 +52,4 @@ An example membership rule for a dynamic group, excluding guests and external us `(user.userPrincipalName -notContains "#EXT#@") -and (user.userType -ne "Guest")` {% endhint %} -*** - {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/identity/administration/group-templates/deploy.md b/docs/user-documentation/identity/administration/group-templates/deploy.md index 8e6a3aa676..3f7d0c2adb 100644 --- a/docs/user-documentation/identity/administration/group-templates/deploy.md +++ b/docs/user-documentation/identity/administration/group-templates/deploy.md @@ -49,6 +49,4 @@ Licences held on a template are carried into the deployment even though this wiz The Group Type list here does not include Microsoft 365 Group, so a Microsoft 365 template cannot be deployed from this wizard. Selecting one leaves the group type unset and the wizard will not continue. Create Microsoft 365 groups from the [add.md](../groups/add.md "mention") page in the meantime. {% endhint %} -*** - {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/identity/administration/group-templates/edit.md b/docs/user-documentation/identity/administration/group-templates/edit.md index 844a51b930..eba2745568 100644 --- a/docs/user-documentation/identity/administration/group-templates/edit.md +++ b/docs/user-documentation/identity/administration/group-templates/edit.md @@ -12,6 +12,4 @@ Groups already created from a template are not affected by editing it. A templat Changing the group type on an existing template changes which settings apply to it. Values belonging to the previous type stay on the record but stop being offered, so a template switched from a Distribution List to a Security Group keeps its aliases without any way to see or clear them from this page. Where the type is wrong it is usually cleaner to create a new template and delete the old one. {% endhint %} -*** - {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/identity/administration/groups/README.md b/docs/user-documentation/identity/administration/groups/README.md index eccb5d0bf5..e18c599106 100644 --- a/docs/user-documentation/identity/administration/groups/README.md +++ b/docs/user-documentation/identity/administration/groups/README.md @@ -65,6 +65,4 @@ Group Type is composed by CIPP rather than returned by Graph, which reports the A group has to be at least fifteen minutes old before **Create Team from Group** will work, as Microsoft needs the group to have finished provisioning first. {% endhint %} -*** - {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/identity/administration/groups/add.md b/docs/user-documentation/identity/administration/groups/add.md index cd20602a2e..c141af5636 100644 --- a/docs/user-documentation/identity/administration/groups/add.md +++ b/docs/user-documentation/identity/administration/groups/add.md @@ -63,6 +63,4 @@ A **Dynamic Distribution Group** goes further and ignores owners, members and th Group-based licensing requires the tenant to be licensed for Entra ID P1 or higher. Assigning licences through a group without the appropriate licensing is not compliant with Microsoft's licensing terms. {% endhint %} -*** - {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/identity/administration/groups/edit.md b/docs/user-documentation/identity/administration/groups/edit.md index 6b9c280ba0..dba8722851 100644 --- a/docs/user-documentation/identity/administration/groups/edit.md +++ b/docs/user-documentation/identity/administration/groups/edit.md @@ -79,6 +79,4 @@ Licences assigned to a group are applied automatically to everyone in it, which The five settings under Group Settings are only sent when you have actually changed them, so a toggle you leave alone is not written back. The properties under Group Properties behave differently and are submitted every time, whatever their current value. {% endhint %} -*** - {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/identity/administration/groups/group.md b/docs/user-documentation/identity/administration/groups/group.md index 29f1ab99f3..580e0a47d2 100644 --- a/docs/user-documentation/identity/administration/groups/group.md +++ b/docs/user-documentation/identity/administration/groups/group.md @@ -57,6 +57,4 @@ The group's owners listed the same way, with the same row action through to the The groups this group belongs to, listed with the group name, its types, and whether it is security enabled and mail enabled. Row actions open the group's own page or its edit page. -*** - {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/identity/administration/jit-admin-templates/README.md b/docs/user-documentation/identity/administration/jit-admin-templates/README.md index 561a9174a7..0e020e7250 100644 --- a/docs/user-documentation/identity/administration/jit-admin-templates/README.md +++ b/docs/user-documentation/identity/administration/jit-admin-templates/README.md @@ -38,6 +38,4 @@ This list is scoped to the tenant selected in the tenant selector, and shows tha Only one template should be marked as the default for a given tenant. Where both a tenant-specific default and an All Tenants default exist, the tenant-specific one is applied. {% endhint %} -*** - {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/identity/administration/jit-admin-templates/add-jit-admin-template.md b/docs/user-documentation/identity/administration/jit-admin-templates/add-jit-admin-template.md index 2a642d5fde..1948643f50 100644 --- a/docs/user-documentation/identity/administration/jit-admin-templates/add-jit-admin-template.md +++ b/docs/user-documentation/identity/administration/jit-admin-templates/add-jit-admin-template.md @@ -47,6 +47,4 @@ The expiration action offers **Delete User** and **Disable User** whatever the s An All Tenants template can only create a new user. The Existing User option is not offered, because a specific account exists in one tenant and means nothing in the others. Domain and group selection are withdrawn for the same reason, so an All Tenants template covers the roles, timing and expiry behaviour while the tenant-specific details are supplied when the grant is made. {% endhint %} -*** - {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/identity/administration/jit-admin-templates/edit-jit-admin-template.md b/docs/user-documentation/identity/administration/jit-admin-templates/edit-jit-admin-template.md index 5484ac62fd..304cbf15b2 100644 --- a/docs/user-documentation/identity/administration/jit-admin-templates/edit-jit-admin-template.md +++ b/docs/user-documentation/identity/administration/jit-admin-templates/edit-jit-admin-template.md @@ -20,6 +20,4 @@ Template names have to be unique within a tenant. Saving a name already used by Only the fields belonging to the selected **Default User Action** are kept. Switching a template from creating a new user to using an existing one discards the stored name, username, domain and usage location, and switching the other way discards the stored user. Change this setting only when you intend to rebuild that part of the template. {% endhint %} -*** - {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/identity/administration/jit-admin/README.md b/docs/user-documentation/identity/administration/jit-admin/README.md index dde3a58f52..7d39f30bd4 100644 --- a/docs/user-documentation/identity/administration/jit-admin/README.md +++ b/docs/user-documentation/identity/administration/jit-admin/README.md @@ -41,6 +41,4 @@ This table has no per-row actions. Elevation is granted from the Add JIT Admin p Under All Tenants the list is served from a cache rather than queried live. The first time it is opened, CIPP queues a background job to collect the data from every tenant and reports that it is still loading, so come back after a few minutes. Once built, the cache is reused for an hour before a fresh collection runs. Single-tenant views are always live. {% endhint %} -*** - {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/identity/administration/jit-admin/add.md b/docs/user-documentation/identity/administration/jit-admin/add.md index ae67aa40b7..21f9512057 100644 --- a/docs/user-documentation/identity/administration/jit-admin/add.md +++ b/docs/user-documentation/identity/administration/jit-admin/add.md @@ -69,6 +69,4 @@ The expiration action offers **Delete User** and **Disable User** for any grant. Notification channels only deliver if they are configured in CIPP's [notifications.md](../../../cipp/settings/notifications.md "mention") settings first. Selecting one that is not set up produces no notification rather than an error. {% endhint %} -*** - {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/identity/administration/offboarding-wizard.md b/docs/user-documentation/identity/administration/offboarding-wizard.md index 3d832a2c36..8c9af4d17d 100644 --- a/docs/user-documentation/identity/administration/offboarding-wizard.md +++ b/docs/user-documentation/identity/administration/offboarding-wizard.md @@ -120,6 +120,4 @@ Selecting three or more users turns scheduling on by itself, since a large offbo
ActionDescriptionBulk Action Available
View Task DetailsOpens the task.md page for the selected job, showing its full parameters and results. Requires scheduler read permissions.false
Run NowRuns the selected job immediately rather than waiting for its scheduled date. Requires scheduler write permissions.true
Delete JobRemoves the job so it never runs. Requires scheduler write permissions.true
More InfoOpens the Extended Info flyout with the full details for the selected row.false
-*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/identity/administration/risky-users.md b/docs/user-documentation/identity/administration/risky-users.md index c34487f084..bb3045dc12 100644 --- a/docs/user-documentation/identity/administration/risky-users.md +++ b/docs/user-documentation/identity/administration/risky-users.md @@ -26,6 +26,4 @@ Dismissing a risk closes it without changing anything about the account. It does This page depends on Microsoft Entra ID Protection, which needs Entra ID P2 licensing. Tenants without it return no risk data, so an empty table means the feature is unavailable rather than that no user is at risk. {% endhint %} -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/identity/administration/roles.md b/docs/user-documentation/identity/administration/roles.md index 840b385540..bcf619883f 100644 --- a/docs/user-documentation/identity/administration/roles.md +++ b/docs/user-documentation/identity/administration/roles.md @@ -33,6 +33,4 @@ Custom roles cannot have their members removed from this page, because the remov This page covers Entra ID directory roles. Permissions granted through Exchange Online role groups, Azure resource roles, or Microsoft Purview are held elsewhere and do not appear here, so a review of who holds administrative access needs to take in those as well. {% endhint %} -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/identity/administration/users/README.md b/docs/user-documentation/identity/administration/users/README.md index 165cf85c6d..80a0e21cbd 100644 --- a/docs/user-documentation/identity/administration/users/README.md +++ b/docs/user-documentation/identity/administration/users/README.md @@ -210,6 +210,4 @@ The query string below can be used as the basis of an AutoTask LiveLink, substit ``` {% endcode %} -*** - {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/identity/administration/users/patch-wizard.md b/docs/user-documentation/identity/administration/users/patch-wizard.md index 916c108330..d570b3ff9f 100644 --- a/docs/user-documentation/identity/administration/users/patch-wizard.md +++ b/docs/user-documentation/identity/administration/users/patch-wizard.md @@ -71,6 +71,4 @@ Changing the UPN domain suffix signs every affected user out, and they have to s When the selection spans tenants, the users are grouped by tenant and updated tenant by tenant. Two limits follow from that. UPN Domain Suffix is withdrawn from the property list entirely, since a domain from one tenant means nothing in another. Manager and Sponsor stay available, but their picker only lists users from the first tenant in the selection, and the assignment will only succeed where an account with that name also exists in the other tenants. The wizard warns about both on screen. {% endhint %} -*** - {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/identity/administration/users/user/README.md b/docs/user-documentation/identity/administration/users/user/README.md index 7263e0e167..7a0e264b28 100644 --- a/docs/user-documentation/identity/administration/users/user/README.md +++ b/docs/user-documentation/identity/administration/users/user/README.md @@ -119,6 +119,4 @@ Two cards list what the account belongs to, each showing a count in its header. The Intune managed devices registered to this user, matched on their user principal name. Each row shows the device name, operating system, OS version and management type, and the row action opens the device.md page. The card reports separately when the user has no managed devices and when the device lookup failed. -*** - {% include "../../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/identity/administration/users/user/bec.md b/docs/user-documentation/identity/administration/users/user/bec.md index c51698979e..1a68d8bed7 100644 --- a/docs/user-documentation/identity/administration/users/user/bec.md +++ b/docs/user-documentation/identity/administration/users/user/bec.md @@ -10,7 +10,7 @@ This page gathers the signals worth checking when a mailbox is suspected of bein Nothing on this page is proof of a compromise. The checks surface the information that usually matters during an investigation, and several of them return results on perfectly healthy accounts. Read the findings alongside what you already know about the user and the tenant. {% endhint %} -## Running the analysis +## Running the Analysis The analysis runs as a background job. The first visit queues it and the page polls until it finishes, which can take up to ten minutes on a tenant with a lot of log data. The result is then cached against the user, so returning to the page shows the earlier run rather than starting a new one. @@ -22,29 +22,46 @@ Most checks depend on the unified audit log. When it is disabled for the tenant, ## Checks -Every check covers the seven days before the analysis ran, apart from the MFA device list and the Intune device list, which show the account's current registrations and devices regardless of age. - -| Check | What it looks for | -| ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Check 1: Mailbox Rules | The inbox rules currently on the mailbox, and any rule created, changed, or removed in the last seven days. A rule that moves mail into an `RSS` folder raises a potential breach message, as it is a long-standing trick for hiding replies. Rules whose names match a recent audit event are marked as changed in the last seven days and sorted to the top. | -| Check 2: Recently added users | Accounts created in the tenant during the window, listed with their creation date. | -| Check 3: New Applications | Service principals registered during the window, listed with their application ID and creation date. | -| Check 4: Mailbox permission changes | Mailbox permission and delegation changes across the tenant, listed with who made the change, the operation, and the rights involved. Covers permissions being added or removed, calendar delegation updates, and folder permission grants. | -| Check 5: Sent Messages | Messages sent by the mailbox during the window, from the message trace, with the subject, recipient, delivery status, time received, and originating IP address. | -| Check 6: MFA Devices | The authentication methods registered on the account, other than its password, listed with the method type, name, and registration date. | -| Check 7: Password Changes | Accounts across the tenant whose password changed during the window, listed with the change time. | -| Check 8: Trusted & Blocked Senders | The mailbox's own trusted and blocked sender and domain lists, along with any changes to them in the last seven days. | -| Check 9: Intune Devices | Every Intune-managed device enrolled under the account, newest enrolment first. The card's count is the number enrolled in the last seven days rather than the total, so a zero here still leaves a device list worth reading. A device standing up during the window can mean an intruder enrolling a virtual machine or personal endpoint under the identity, which is also a route to registering Windows Hello for Business as a persistence mechanism. | +Every check covers the seven days before the analysis ran, apart from the MFA device list, the Intune device list, and the trusted and blocked sender lists, which show the account's current state regardless of age, and the sign-in list, which is simply the last fifty sign-ins however old they are. + +| Check | What it looks for | +| ----------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Check 1: Mailbox Rules | The inbox rules currently on the mailbox, and any rule created, changed, or removed in the last seven days. A rule that moves mail into an `RSS` folder raises a potential breach message, as it is a long-standing trick for hiding replies. Rules whose names match a recent audit event are marked as changed in the last seven days and sorted to the top. Each change lists the IP address it was made from and, where the IP can be located, the country. | +| Check 2: Recently added users | Accounts created in the tenant during the window, listed with their creation date. | +| Check 3: New Applications | Service principals registered during the window, plus every application in the tenant, of any age, that matches CIPP's catalog of known-malicious applications. A catalog match is sorted to the top, named with its catalog entry, and raises a potential breach message, because consent-based access survives a password reset. | +| Check 4: Mailbox permission changes | Mailbox permission and delegation changes across the tenant, listed with who made the change, the operation, and the rights involved. Covers permissions being added or removed, calendar delegation updates, and folder permission grants. Changes that target the investigated mailbox are flagged and sorted to the top. | +| Check 5: Sent Messages | Messages sent by the mailbox during the window, from the message trace, with the subject, recipient, delivery status, time received, the originating IP address, and the country that IP locates to. The check also looks for mass-mail patterns: a subject sent as five or more separate messages or reaching twenty or more recipients, and bursts of ten or more messages or thirty or more recipients inside ten minutes. Both are how a compromised mailbox spreads phishing. | +| Check 6: MFA Devices | The authentication methods registered on the account, other than its password, listed with the method type, name, and registration date. Methods registered in the last seven days are flagged and sorted to the top, and an account with no methods at all is called out rather than shown as an empty list. | +| Check 7: Password Changes | Accounts across the tenant whose password changed during the window, listed with the change time. | +| Check 8: Trusted & Blocked Senders | The mailbox's own trusted and blocked sender and domain lists, along with any changes to them in the last seven days. Each change lists the IP address it was made from and its country. If the lists cannot be read, the card says so in red instead of presenting an empty list as clean. | +| Check 9: Intune Devices | Every Intune-managed device enrolled under the account, newest enrolment first. The card's count is the number enrolled in the last seven days rather than the total, so a zero here still leaves a device list worth reading. A device standing up during the window can mean an intruder enrolling a virtual machine or personal endpoint under the identity, which is also a route to registering Windows Hello for Business as a persistence mechanism. | +| Check 10: Sign-in Locations | The user's last fifty sign-ins with the application, result, IP address, country, and city, compared against the account's assigned usage location. The card's count is the number of foreign data points found across sign-ins, rule changes, safelist changes, sharing changes, and sent mail. See [#location-analysis](bec.md#location-analysis "mention") below. | +| Check 11: Sharing Links | Every OneDrive and SharePoint sharing link the account created or changed during the window, with the file, who it was shared with, and the IP address it was done from. Anonymous links are called out separately, because anyone holding the URL can open them and they give an intruder a data feed that survives a password reset. | {% hint style="info" %} -Checks 2, 3, 4, and 7 are tenant-wide rather than scoped to this user. That is deliberate: an intruder who has taken one mailbox often leaves traces elsewhere, so a new account or an unfamiliar application appearing in the same window is worth knowing about even though it has nothing to do with the mailbox in front of you. +Checks 2, 4, and 7 are tenant-wide rather than scoped to this user, and Check 3 sweeps the whole tenant for catalog matches. That is deliberate: an intruder who has taken one mailbox often leaves traces elsewhere, so a new account or an unfamiliar application appearing in the same window is worth knowing about even though it has nothing to do with the mailbox in front of you. {% endhint %} {% hint style="info" %} Inbox rules carry no timestamp of their own, so a rule is marked as recently changed by matching its name against audit events from the last seven days. Rules changed from the Outlook client are recorded without a rule name, so a rule altered that way stays unmarked even though the change appears under the rule change entries. {% endhint %} -### Intune device actions +### Location Analysis + +Check 10 and the flags scattered through the other checks come from one comparison: the account's **usage location** (the two-letter country code assigned in Entra ID, usually for licensing) held against where activity actually came from. + +* Sign-ins carry their own location in the sign-in log, so those need no lookup. +* The client IPs behind inbox rule changes, safelist changes, sharing changes, and sent messages are geo-located through CIPP's GeoIP service, which caches results, so repeated runs do not repeat lookups. +* A row only counts as foreign when both sides are known. No assigned usage location, an IP that cannot be located, or a private address means the row is left unflagged, not counted against the user. +* Foreign sign-ins are split into successful and failed. Failed attempts from other countries are the constant background of password spray and are listed for context only; a successful foreign sign-in is the one that proves access and feeds the threat score. + +When the account has no usage location assigned, the card says the comparison is unavailable and still lists the countries seen, for manual review. + +{% hint style="warning" %} +Usage location is an administrative setting, not a statement of where the user works. Travel, VPN egress points, and mobile carrier routing all produce foreign rows on healthy accounts, and a usage location that was never set correctly produces them permanently. A foreign sign-in is a prompt to check with the user; a rule or safelist change from a foreign IP is much harder to explain innocently. +{% endhint %} + +### Intune Device Actions Each row in Check 9 carries its own actions, so a suspect device can be dealt with without leaving the investigation. @@ -59,19 +76,19 @@ Each row in Check 9 carries its own actions, so a suspect device can be dealt wi **Wipe device (remove enrollment)** is a full factory wipe, not the lighter wipe that keeps user or enrolment data. It cannot be undone, and it will take the device out of service for whoever is holding it. Confirm the device is genuinely the intruder's before running it. {% endhint %} -Both actions need write permission for device management, and the list does not update on its own afterwards. Use **Refresh Data** to see the result. +**Retire device** and **Wipe device (remove enrollment)** both ask for confirmation first and need write permission for device management. Neither updates the list afterwards, so use **Refresh Data** to see the result. {% hint style="warning" %} -If CIPP cannot read the tenant's Intune devices, the card says so in red and shows no count. That is not the same as the user having no devices, and it usually points at missing permissions or licensing rather than a clean result. Fix the underlying problem and refresh rather than reading the empty card as an all-clear. +If CIPP cannot read the tenant's Intune devices, the card says so in red and shows no count. That is not the same as the user having no devices, and it usually points at missing permissions or licensing rather than a clean result. Fix the underlying problem and refresh rather than reading the empty card as an all-clear. The sign-in and sender-list checks behave the same way when their sources cannot be read. {% endhint %} ## Actions | Action | Description | -| ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Refresh Data | Discards the cached result and runs the analysis again. Use it when the cached data predates something you need to see, such as a rule created in the last few minutes or a device you have just retired. The page returns to its waiting state while the new run completes. | | Remediate User | Runs the containment steps listed on the overview card in one go: blocks sign-in, resets the password, disconnects all current sessions, removes every MFA method, disables all inbox rules, and disables OneDrive sharing. A confirmation dialog appears first. | -| Generate PDF Report | Opens a preview of a formatted report covering the findings, written to be readable by managers and end users as well as technicians, and suitable for attaching to a compliance record. **Download PDF** saves it. Long result sets are truncated in the PDF, which points to the JSON export for the full list. | +| Generate PDF Report | Opens a preview of a formatted report covering the findings, written to be readable by managers and end users as well as technicians, and suitable for attaching to a compliance record. **Download PDF** saves it. What the report contains is covered under [#pdf-report](bec.md#pdf-report "mention") below. | | Download JSON | Saves the complete analysis as a JSON file, including data the cards do not display. | {% hint style="warning" %} @@ -79,13 +96,82 @@ Removing every MFA method leaves the account with no second factor registered. O {% endhint %} {% hint style="info" %} -**Remediate User** does not touch the user's devices. If Check 9 has turned up an enrolment you do not recognise, retiring or wiping it is a separate decision and a separate action. +**Remediate User** does not touch the user's devices or remove applications, and while it disables OneDrive sharing it does not review links that were already created. If Check 9 has turned up an enrolment you do not recognise, Check 3 a malicious application, or Check 11 a sharing link you cannot explain, dealing with those is a separate decision and a separate action. {% endhint %} {% hint style="info" %} -The JSON export carries three data sets that no card displays: the last fifty sign-ins for the tenant, the user's most recent sign-in, and the mobile devices attached to the mailbox. If the investigation turns on sign-in origin or an unrecognised device, that is where to look. The Intune device list in the export also holds the manufacturer, model, owner type, and assigned user, none of which the card shows. +The JSON export carries three data sets that no card displays: the last fifty sign-ins for the tenant as a whole (`TenantLastSignIns`), the user's single most recent sign-in, and the mobile devices attached to the mailbox. If the investigation turns on tenant-wide sign-in activity or an unrecognised mobile device, that is where to look. The Intune device list in the export also holds the manufacturer, model, owner type, and assigned user, none of which the card shows. +{% endhint %} + +## PDF Report + +The report is built from the analysis already on screen, so it never starts a fresh run and always reflects the same cached result the cards are showing. Its cover names the user rather than the tenant, and the logo, cover image, colours, footer and watermark come from your instance branding, described in [branding.md](../../../../cipp/settings/branding.md "mention"). Its detailed findings use the same check numbers as the page, 1 through 11. + +| Page | What it contains | +| --------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Executive Summary | A narrative introduction naming the user and the tenant, four headline counts (mailbox rules, permission changes, foreign sign-ins, known-malicious applications), the threat assessment, and the audit log status, analysis period, and assigned usage location. | +| Understanding Business Email Compromise | A plain-language explanation of what a compromise is, how accounts are usually taken, and why the investigation was run. Written for the user or their manager rather than the technician. | +| Detailed Findings | The page's checks 1 through 11 (rules, users, applications, permission changes, sent messages, authentication methods, password changes, trusted and blocked senders, Intune devices, sign-in locations, and sharing links), each under a short explanation of why that check matters. | +| Recommendations | Six immediate containment steps, six longer-term prevention measures, and five points to pass to the user. | +| Compliance & Documentation | How the investigation maps onto ISO 27001, CMMC Level 2, SOC 2 Type II, NIST CSF and GDPR, an audit trail block with the investigation details, a **Findings Summary** listing every count, and retention guidance. | + +### Threat Assessment + +The **Threat Assessment** banner on the executive summary is a total of fixed points, one contribution per finding, regardless of how many results that finding returned. + +| Finding | Points | +| ---------------------------------------------------------------- | ------ | +| A rule that moves mail to an RSS folder | 5 | +| An application matching the known-malicious catalog | 5 | +| One or more inbox rules on the mailbox | 3 | +| One or more inbox rule changes in the window | 3 | +| A successful sign-in from outside the usage location | 3 | +| A rule, safelist, sharing, or sent-mail action from a foreign IP | 3 | +| An anonymous sharing link created or changed in the window | 3 | +| A mass-mail pattern (repeated subjects or send bursts) | 3 | +| A permission change targeting the investigated mailbox | 2 | +| One or more changes to the trusted or blocked senders list | 2 | +| An MFA method registered in the window | 2 | +| An Intune device enrolled in the window | 2 | +| Permission changes elsewhere in the tenant only | 1 | +| One or more new applications | 1 | +| More than five new users | 1 | + +Seven points or more reads as **High**, four to six as **Medium**, and anything below that as **Low**. + +{% hint style="warning" %} +Scoring counts findings, not volume. A mailbox holding a single ordinary inbox rule already scores three, one point short of Medium, so a single unrelated finding tips it over. Forty rules score the same three points as one. +{% endhint %} + +{% hint style="warning" %} +New users, new applications, and permission changes are tenant-wide checks, but each carries a single point unless a permission change targets the investigated mailbox. Tenant churn nudges the score rather than driving it. A wrongly-set usage location, on the other hand, can add three points through a perfectly normal successful sign-in, so check the assigned location before trusting a foreign-sign-in score. +{% endhint %} + +{% hint style="danger" %} +Password changes carry no weight, and the MFA and Intune lists only score for registrations and enrolments inside the window; long-standing methods and devices do not move the banner however unfamiliar they look. Sent messages score only for a foreign-IP send or a mass-mail pattern. Failed sign-ins from foreign countries score nothing either: password spray hits every internet-facing tenant, so only a successful foreign sign-in counts, though the failures still show in Check 10. A Low is a summary of what scored, not an all-clear; read the checks. {% endhint %} -*** +### What the Report Leaves Out + +Each section stops at a fixed number of rows and says how many were left off, so a truncated section is visible as truncated. The counts in **Findings Summary** on the last page always carry the full totals, and **Download JSON** has the complete set. + +| Section | Rows shown | +| ------------------------------------------ | ------------------------- | +| Mailbox rules | 10 | +| Rule changes | 10 | +| Recently created users | 8 | +| New applications | 6 | +| Known-malicious applications in the tenant | 6 | +| Mailbox permission changes | 5 | +| Sent messages | 10 | +| Repeated subjects | 5 (analysis keeps 10) | +| Send bursts | 5 (analysis keeps 10) | +| MFA devices | 5, newest first | +| Password changes | 5 | +| Trusted and blocked senders | 15 of each | +| Safelist changes | 10 | +| Sharing changes | 10 | +| Intune devices | 5, newest enrolment first | +| Foreign sign-ins | 10 | {% include "../../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/identity/administration/users/user/conditional-access.md b/docs/user-documentation/identity/administration/users/user/conditional-access.md index 11974d227c..e878016969 100644 --- a/docs/user-documentation/identity/administration/users/user/conditional-access.md +++ b/docs/user-documentation/identity/administration/users/user/conditional-access.md @@ -65,6 +65,4 @@ Each of the tenant's Conditional Access policies is listed with what the evaluat Analysis Reasons is the column that earns its keep. A policy showing as not applying will usually name the single condition responsible, so it points straight at the assignment or condition to change rather than leaving you to compare the policy against your test settings by hand. {% endhint %} -*** - {% include "../../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/identity/administration/users/user/edit.md b/docs/user-documentation/identity/administration/users/user/edit.md index 31e6e2e2e9..ab566542f9 100644 --- a/docs/user-documentation/identity/administration/users/user/edit.md +++ b/docs/user-documentation/identity/administration/users/user/edit.md @@ -83,6 +83,4 @@ Any directory attributes added under [user-settings.md](../../../../shared-featu | Send results to Webhook / E-mail / PSA | Delivers the outcome of the scheduled edit to the notification channels configured for the tenant. | | Reference | Free text added to the notification title so the task can be recognised later. | -*** - {% include "../../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/identity/administration/users/user/exchange.md b/docs/user-documentation/identity/administration/users/user/exchange.md index f54c67431f..3b90e76c9d 100644 --- a/docs/user-documentation/identity/administration/users/user/exchange.md +++ b/docs/user-documentation/identity/administration/users/user/exchange.md @@ -131,6 +131,4 @@ Sets the mailbox's automatic replies. Sets the largest number of recipients the mailbox may address in a single message, which is a practical brake on a compromised account being used to send in bulk. -*** - {% include "../../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/identity/administration/vacation-mode/README.md b/docs/user-documentation/identity/administration/vacation-mode/README.md index f48d755ccc..9548a841a3 100644 --- a/docs/user-documentation/identity/administration/vacation-mode/README.md +++ b/docs/user-documentation/identity/administration/vacation-mode/README.md @@ -54,6 +54,4 @@ Cancelling is per task, not per vacation. Cancelling the task that reverses a ch Because a vacation is several independent tasks rather than one object, use the **Reference** column to find every task belonging to the same schedule before cancelling anything. {% endhint %} -*** - {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/identity/administration/vacation-mode/add-vacation-schedule.md b/docs/user-documentation/identity/administration/vacation-mode/add-vacation-schedule.md index 85ed2e567a..6b24b14daf 100644 --- a/docs/user-documentation/identity/administration/vacation-mode/add-vacation-schedule.md +++ b/docs/user-documentation/identity/administration/vacation-mode/add-vacation-schedule.md @@ -97,6 +97,4 @@ A summary of everything selected. Submitting creates the scheduled tasks. {% endstep %} {% endstepper %} -*** - {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/identity/reports/azure-ad-connect-report.md b/docs/user-documentation/identity/reports/azure-ad-connect-report.md index 670c034dc4..bc058b67c6 100644 --- a/docs/user-documentation/identity/reports/azure-ad-connect-report.md +++ b/docs/user-documentation/identity/reports/azure-ad-connect-report.md @@ -17,6 +17,4 @@ CIPP queries users, contacts and groups separately and combines the results, add Sort or filter on **On Premises Provisioning Errors** to bring the objects that need attention to the top, since the table lists every user, contact and group rather than only the ones in error. {% endhint %} -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/identity/reports/inactive-users-report.md b/docs/user-documentation/identity/reports/inactive-users-report.md index e09d0169b5..2beb6389a0 100644 --- a/docs/user-documentation/identity/reports/inactive-users-report.md +++ b/docs/user-documentation/identity/reports/inactive-users-report.md @@ -32,6 +32,4 @@ Disabled accounts and guests are left out. A disabled account is already handled Removing a licence from a dormant account starts a clock on the data attached to it. A mailbox left unlicensed stops receiving mail and is eventually removed, and OneDrive content follows its own retention schedule. Where the data still matters, convert the mailbox to shared or run the account through the offboarding-wizard.md rather than simply stripping the licence. {% endhint %} -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/identity/reports/mfa-report.md b/docs/user-documentation/identity/reports/mfa-report.md index 1fb7689799..40af8a1401 100644 --- a/docs/user-documentation/identity/reports/mfa-report.md +++ b/docs/user-documentation/identity/reports/mfa-report.md @@ -50,6 +50,4 @@ A user is protected when at least one of the following applies. Reading the thre
ActionDescriptionBulk Action Available
Set Per-User MFASets the legacy per-user MFA state to Enforced, Enabled or Disabled, independently of any Conditional Access policy.true
-*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/identity/reports/risk-detections.md b/docs/user-documentation/identity/reports/risk-detections.md index 155dd53f5d..c4a7af98db 100644 --- a/docs/user-documentation/identity/reports/risk-detections.md +++ b/docs/user-documentation/identity/reports/risk-detections.md @@ -31,6 +31,4 @@ Risk state is held against the user rather than the individual detection, so mar Entra ID Protection needs Entra ID P2 licensing to report detections in full. Tenants without it see limited or no detection data, so an empty table means the feature is unavailable rather than that no risk was detected. {% endhint %} -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/identity/reports/signin-report.md b/docs/user-documentation/identity/reports/signin-report.md index 53e58646dd..4f102fb55c 100644 --- a/docs/user-documentation/identity/reports/signin-report.md +++ b/docs/user-documentation/identity/reports/signin-report.md @@ -39,6 +39,4 @@ The properties returned are for the Graph resource type `signIn`. For more infor The flyout on this page shows the complete sign-in record as raw JSON rather than a summarised list of fields. That is where the detail an investigation needs sits, including the Conditional Access policies evaluated and their individual results, the device and client details, and the full authentication method breakdown. {% endhint %} -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/security/compliance/dlp-templates.md b/docs/user-documentation/security/compliance/dlp-templates.md index 8b6bc25759..aee2aa4429 100644 --- a/docs/user-documentation/security/compliance/dlp-templates.md +++ b/docs/user-documentation/security/compliance/dlp-templates.md @@ -62,6 +62,4 @@ A DLP policy that is pending deletion cannot be saved as a template, because the
ActionDescriptionBulk Action Available
Save to GitHubPublishes the selected template to a repository you have write access to, prompting for the repository and a commit message. Greyed out unless the GitHub integration is enabled.true
Delete TemplateRemoves the selected template from CIPP. Policies already deployed from it are unaffected.true
More InfoOpens the Extended Info flyout with the full details for the selected row.false
-*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/security/compliance/dlp.md b/docs/user-documentation/security/compliance/dlp.md index 2f74b797c2..c3bdea30a6 100644 --- a/docs/user-documentation/security/compliance/dlp.md +++ b/docs/user-documentation/security/compliance/dlp.md @@ -51,6 +51,4 @@ The Extended Info flyout adds the policy comment and the individual workload loc
ActionDescriptionBulk Action Available
Create template based on policySaves the selected policy as a DLP policy template so it can be redeployed to other tenants.false
Enable PolicySwitches the selected policy on. Greyed out when the policy is already enabled.true
Disable PolicySwitches the selected policy off, leaving it in place but not enforcing. Greyed out when the policy is already disabled.true
Delete PolicyPermanently removes the selected policy and the rules belonging to it.true
More InfoOpens the Extended Info flyout with the full details for the selected row.false
-*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/security/compliance/labels-templates.md b/docs/user-documentation/security/compliance/labels-templates.md index 7b0bb13a08..e4c1ee1065 100644 --- a/docs/user-documentation/security/compliance/labels-templates.md +++ b/docs/user-documentation/security/compliance/labels-templates.md @@ -63,6 +63,4 @@ A template captured from a live label has its rights management template identif
ActionDescriptionBulk Action Available
Save to GitHubPublishes the selected template to a repository you have write access to, prompting for the repository and a commit message. Greyed out unless the GitHub integration is enabled.true
Delete TemplateRemoves the selected template from CIPP. Labels already deployed from it are unaffected.true
More InfoOpens the Extended Info flyout with the full details for the selected row.false
-*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/security/compliance/labels.md b/docs/user-documentation/security/compliance/labels.md index 8a88038a16..0c02daedf2 100644 --- a/docs/user-documentation/security/compliance/labels.md +++ b/docs/user-documentation/security/compliance/labels.md @@ -40,6 +40,4 @@ The Extended Info flyout adds the label's comment, tooltip, parent label, footer
ActionDescriptionBulk Action Available
Create template based on labelSaves the selected label as a sensitivity label template so it can be redeployed to other tenants.false
Set Label ColorOpens a colour picker and applies the chosen colour to the selected label. Any hex colour is accepted, beyond the preset palette the Purview portal offers. Submitting an empty value clears a colour that was set previously.false
Delete LabelPermanently removes the selected label. A label that is published to users is also removed from the policies publishing it.true
More InfoOpens the Extended Info flyout with the full details for the selected row.false
-*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/security/compliance/retention-templates.md b/docs/user-documentation/security/compliance/retention-templates.md index cb0bd4f6e1..05a5cc2edc 100644 --- a/docs/user-documentation/security/compliance/retention-templates.md +++ b/docs/user-documentation/security/compliance/retention-templates.md @@ -63,6 +63,4 @@ Microsoft does not offer this setting in the Purview portal at all, specifically
ActionDescriptionBulk Action Available
Save to GitHubPublishes the selected template to a repository you have write access to, prompting for the repository and a commit message. Greyed out unless the GitHub integration is enabled.true
Delete TemplateRemoves the selected template from CIPP. Policies already deployed from it are unaffected.true
More InfoOpens the Extended Info flyout with the full details for the selected row.false
-*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/security/compliance/retention.md b/docs/user-documentation/security/compliance/retention.md index aad38593ca..fdf9419670 100644 --- a/docs/user-documentation/security/compliance/retention.md +++ b/docs/user-documentation/security/compliance/retention.md @@ -45,6 +45,4 @@ Microsoft returns the individual location lists on a retention policy empty, wit
ActionDescriptionBulk Action Available
Create template based on policySaves the selected policy, and the first retention rule attached to it, as a retention policy template so it can be redeployed to other tenants.false
Enable PolicySwitches the selected policy on. Greyed out when the policy is already enabled.true
Disable PolicySwitches the selected policy off, leaving it in place but not retaining. Greyed out when the policy is already disabled.true
Delete PolicyPermanently removes the selected policy and the rules belonging to it.true
More InfoOpens the Extended Info flyout with the full details for the selected row.false
-*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/security/compliance/sit-templates.md b/docs/user-documentation/security/compliance/sit-templates.md index 6789db3808..6d21a04e02 100644 --- a/docs/user-documentation/security/compliance/sit-templates.md +++ b/docs/user-documentation/security/compliance/sit-templates.md @@ -58,6 +58,4 @@ The Extended Info flyout tells you which of the two kinds of template you are lo
ActionDescriptionBulk Action Available
Save to GitHubPublishes the selected template to a repository you have write access to, prompting for the repository and a commit message. Greyed out unless the GitHub integration is enabled.true
Delete TemplateRemoves the selected template from CIPP. Types already deployed from it are unaffected.true
More InfoOpens the Extended Info flyout with the full details for the selected row.false
-*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/security/compliance/sit.md b/docs/user-documentation/security/compliance/sit.md index fcf7476340..dabe63f522 100644 --- a/docs/user-documentation/security/compliance/sit.md +++ b/docs/user-documentation/security/compliance/sit.md @@ -32,6 +32,4 @@ Opening a row's Extended Info flyout also looks up the rule pack behind the type
ActionDescriptionBulk Action Available
Create template based on SITSaves the selected Sensitive Information Type as a template so it can be redeployed to other tenants.false
Delete SITPermanently removes the selected Sensitive Information Type from the tenant.true
More InfoOpens the Extended Info flyout with the full details for the selected row.false
-*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/security/defender/defender-cve-exceptions.md b/docs/user-documentation/security/defender/defender-cve-exceptions.md index 27ff1da329..b8a04a0d64 100644 --- a/docs/user-documentation/security/defender/defender-cve-exceptions.md +++ b/docs/user-documentation/security/defender/defender-cve-exceptions.md @@ -41,6 +41,4 @@ An exception is recorded in CIPP only. Nothing is written back to Microsoft Defe The `All Affected Tenants` scope currently fails when recording an exception and reports an error instead of writing one. Until this is resolved, use `All Tenants (Global)` to cover every tenant, or record the exception one tenant at a time with `Current Tenant Only`. Removing an exception with `All Affected Tenants` is unaffected. {% endhint %} -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/security/defender/deployment.md b/docs/user-documentation/security/defender/deployment.md index 242e57fa20..ce32779614 100644 --- a/docs/user-documentation/security/defender/deployment.md +++ b/docs/user-documentation/security/defender/deployment.md @@ -198,6 +198,4 @@ The mode applies to the whole policy rather than to individual rules. Deploying For more details on each setting, refer to the [Microsoft Defender for Endpoint documentation](https://learn.microsoft.com/en-us/microsoft-365/security/defender-endpoint/microsoft-defender-endpoint?view=o365-worldwide). -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/security/defender/list-defender-tvm.md b/docs/user-documentation/security/defender/list-defender-tvm.md index 804997a3cc..3e5f62817e 100644 --- a/docs/user-documentation/security/defender/list-defender-tvm.md +++ b/docs/user-documentation/security/defender/list-defender-tvm.md @@ -21,6 +21,4 @@ Software vulnerabilities found by Microsoft Defender across the selected tenant' Only **Affected Devices Count** and **Affected Devices** reflect every device behind a row. The remaining columns are taken from a single one of the grouped findings, so where a CVE spans more than one platform, vendor or software version, this table shows one of them rather than all of them. Treat those columns as an example of what the CVE was found on, not a complete picture. {% endhint %} -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/security/defender/list-defender.md b/docs/user-documentation/security/defender/list-defender.md index dfb3562ff2..9a5607eaf4 100644 --- a/docs/user-documentation/security/defender/list-defender.md +++ b/docs/user-documentation/security/defender/list-defender.md @@ -10,6 +10,4 @@ The properties returned are for the Graph resource type `managedDevice`. For mor Every managed device in the tenant is listed, not just Windows ones. The protection state is a Windows specific object, so devices on other platforms appear in the list with their Defender columns empty. {% endhint %} -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/security/incidents/list-alerts.md b/docs/user-documentation/security/incidents/list-alerts.md index 577b8e7607..c70db179e7 100644 --- a/docs/user-documentation/security/incidents/list-alerts.md +++ b/docs/user-documentation/security/incidents/list-alerts.md @@ -26,6 +26,4 @@ Selecting All Tenants queues a background job that collects alerts from every te
ActionDescriptionBulk Action Available
Set status to in progressMarks the alert as being worked on.true
Set status to resolvedCloses the alert.true
More InfoOpens the Extended Info flyout with the full details for the selected row.false
-*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/security/incidents/list-check-alerts.md b/docs/user-documentation/security/incidents/list-check-alerts.md index 61419ac918..8bc5f65a21 100644 --- a/docs/user-documentation/security/incidents/list-check-alerts.md +++ b/docs/user-documentation/security/incidents/list-check-alerts.md @@ -22,6 +22,4 @@ Each row is one page the plugin flagged on a user's machine, so the table tells The user columns are named "potential" for a reason. They carry whatever identity the browser plugin believed it was seeing at the time, which is not an authenticated claim, so treat them as a lead to investigate rather than a confirmed identification. {% endhint %} -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/security/incidents/list-incidents.md b/docs/user-documentation/security/incidents/list-incidents.md index ce8b6f75d6..4e3fadd2e4 100644 --- a/docs/user-documentation/security/incidents/list-incidents.md +++ b/docs/user-documentation/security/incidents/list-incidents.md @@ -35,6 +35,4 @@ Selecting All Tenants queues a background job that collects incidents from every
ActionDescriptionBulk Action Available
Assign to selfPuts your name on the incident as its owner.true
Set status to activeMoves the incident back into the active queue.true
Set status to in progressMarks the incident as being worked on.true
Set status to resolvedCloses the incident, with an optional Resolving comment recorded against it.true
Set severityChanges the incident's severity, prompting you to choose Informational, Low, Medium or High.true
More InfoOpens the Extended Info flyout with the full details for the selected row.false
-*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/security/incidents/list-mdo-alerts.md b/docs/user-documentation/security/incidents/list-mdo-alerts.md index e044e371d6..6de2e759ce 100644 --- a/docs/user-documentation/security/incidents/list-mdo-alerts.md +++ b/docs/user-documentation/security/incidents/list-mdo-alerts.md @@ -16,6 +16,4 @@ Selecting All Tenants queues a background job that collects alerts from every te
ActionDescriptionBulk Action Available
Assign to selfPuts your name on the alert as its owner.true
Set status to activeMoves the alert back into the active queue.true
Set status to in progressMarks the alert as being worked on.true
Set status to resolvedCloses the alert.true
More InfoOpens the Extended Info flyout with the full details for the selected row.false
-*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/security/reports/cve-report.md b/docs/user-documentation/security/reports/cve-report.md index 8d953ceca3..3635f20d6c 100644 --- a/docs/user-documentation/security/reports/cve-report.md +++ b/docs/user-documentation/security/reports/cve-report.md @@ -24,6 +24,4 @@ A read only view of the CVEs Microsoft Defender has found across your devices, o The exception columns hold one entry per tenant, so a CVE excepted in several tenants shows several values in a single cell. Expand the cell to see which tenant each value belongs to. {% endhint %} -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/security/reports/list-device-compliance.md b/docs/user-documentation/security/reports/list-device-compliance.md index 393a9f3b19..4d224d651a 100644 --- a/docs/user-documentation/security/reports/list-device-compliance.md +++ b/docs/user-documentation/security/reports/list-device-compliance.md @@ -10,6 +10,4 @@ The properties returned are for the Graph resource type `device`. For more infor This is the directory's view of a device, not Intune's. A device shows as compliant here based on what the directory holds, so a device that has not checked in recently can keep reporting its last known state. Read **Approximate Last Sign In Date Time** and **Last Sync Date Time** alongside the compliance columns before treating a row as current. {% endhint %} -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/security/reports/mde-onboarding.md b/docs/user-documentation/security/reports/mde-onboarding.md index abab92c886..47e2efcabb 100644 --- a/docs/user-documentation/security/reports/mde-onboarding.md +++ b/docs/user-documentation/security/reports/mde-onboarding.md @@ -86,6 +86,4 @@ Under All Tenants the page becomes a table, one row per tenant. The connector being configured is a prerequisite, not a confirmation of full deployment. See [deployment.md](../defender/deployment.md "mention") for how to complete a full deployment. {% endhint %} -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/security/safelinks/safelinks-template/README.md b/docs/user-documentation/security/safelinks/safelinks-template/README.md index 4d7836bfe7..ba43178d96 100644 --- a/docs/user-documentation/security/safelinks/safelinks-template/README.md +++ b/docs/user-documentation/security/safelinks/safelinks-template/README.md @@ -24,6 +24,4 @@ Safe Links policy templates are saved Safe Links configurations held in CIPP rat
ActionDescriptionBulk Action Available
Edit TemplateOpens the edit.md page for the selected template.false
Save to GitHubPublishes the selected template to a repository you have write access to, prompting for the repository and a commit message. Greyed out unless the GitHub integration is enabled.true
Delete TemplateRemoves the selected template from CIPP. Policies already deployed from it are unaffected.true
More InfoOpens the Extended Info flyout with the full details for the selected row.false
-*** - {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/security/safelinks/safelinks-template/add.md b/docs/user-documentation/security/safelinks/safelinks-template/add.md index 33e270f6c0..183805e3f4 100644 --- a/docs/user-documentation/security/safelinks/safelinks-template/add.md +++ b/docs/user-documentation/security/safelinks/safelinks-template/add.md @@ -17,6 +17,4 @@ A template deploys under the policy name it was saved with. Where a tenant alrea Changes to Safe Links policies and rules may take up to 6 hours to propagate throughout your organization. {% endhint %} -*** - {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/security/safelinks/safelinks-template/create.md b/docs/user-documentation/security/safelinks/safelinks-template/create.md index 0524b4c769..22faa308f5 100644 --- a/docs/user-documentation/security/safelinks/safelinks-template/create.md +++ b/docs/user-documentation/security/safelinks/safelinks-template/create.md @@ -61,6 +61,4 @@ Builds a new Safe Links policy template from scratch. The form is the same one u The domain, group and recipient pickers read from the tenant you currently have selected, so a template scoped with them carries that tenant's objects. Where a template is meant for several customers, scope it after deployment rather than here, or keep the scoping to values that exist in every tenant you will deploy to. {% endhint %} -*** - {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/security/safelinks/safelinks-template/edit.md b/docs/user-documentation/security/safelinks/safelinks-template/edit.md index cfbe9c177a..e6043d9b2c 100644 --- a/docs/user-documentation/security/safelinks/safelinks-template/edit.md +++ b/docs/user-documentation/security/safelinks/safelinks-template/edit.md @@ -63,6 +63,4 @@ Editing a template changes only what future deployments will create. Policies al The domain, group and recipient pickers read from the tenant you currently have selected, so a template scoped with them carries that tenant's objects. Where a template is meant for several customers, scope it after deployment rather than here, or keep the scoping to values that exist in every tenant you will deploy to. {% endhint %} -*** - {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/security/safelinks/safelinks/README.md b/docs/user-documentation/security/safelinks/safelinks/README.md index 199ccf074b..9c74bb4baf 100644 --- a/docs/user-documentation/security/safelinks/safelinks/README.md +++ b/docs/user-documentation/security/safelinks/safelinks/README.md @@ -68,6 +68,4 @@ Microsoft's preset security policies and the built-in protection policy are mana
ActionDescriptionBulk Action Available
Edit Safe Links PolicyOpens the edit.md page for the selected policy.false
Enable RuleSwitches the configuration on. Greyed out when it is already enabled.true
Disable RuleSwitches the configuration off, leaving it in place but not applying. Greyed out when it is already disabled.true
Set PriorityChanges the order the rule is evaluated in, prompting for the new priority number. Lower numbers are evaluated first, and the value must be at least 0.false
Create template based on policySaves the selected policy and its rule as a Safe Links policy template, so it can be redeployed to other tenants.false
Delete RulePermanently removes both the policy and the rule.true
More InfoOpens the Extended Info flyout with the full details for the selected row.false
-*** - {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/security/safelinks/safelinks/add.md b/docs/user-documentation/security/safelinks/safelinks/add.md index 6720ab2b93..8661db7272 100644 --- a/docs/user-documentation/security/safelinks/safelinks/add.md +++ b/docs/user-documentation/security/safelinks/safelinks/add.md @@ -54,6 +54,4 @@ Creates a new Safe Links policy and the rule that scopes it, in the tenant you c Changes to Safe Links policies and rules may take up to 6 hours to propagate throughout your organization. {% endhint %} -*** - {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/security/safelinks/safelinks/edit.md b/docs/user-documentation/security/safelinks/safelinks/edit.md index a25984a20c..ce9e04250d 100644 --- a/docs/user-documentation/security/safelinks/safelinks/edit.md +++ b/docs/user-documentation/security/safelinks/safelinks/edit.md @@ -54,6 +54,4 @@ Opens an existing Safe Links policy and its rule together, so a change to the pr Changes to Safe Links policies and rules may take up to 6 hours to propagate throughout your organization. {% endhint %} -*** - {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/shared-features/breadcrumb-navigation.md b/docs/user-documentation/shared-features/breadcrumb-navigation.md index 21adfe010d..88cff160a5 100644 --- a/docs/user-documentation/shared-features/breadcrumb-navigation.md +++ b/docs/user-documentation/shared-features/breadcrumb-navigation.md @@ -34,6 +34,4 @@ The button is hidden on pages whose address identifies a specific record, such a For managing your saved bookmarks, see [bookmarks.md](menu-bar/bookmarks.md "mention"). -*** - {% include "../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/shared-features/get-help.md b/docs/user-documentation/shared-features/get-help.md index da76ec5905..19660d9a79 100644 --- a/docs/user-documentation/shared-features/get-help.md +++ b/docs/user-documentation/shared-features/get-help.md @@ -6,6 +6,4 @@ Have an error that you're unsure how to handle? Errors in most pages of CIPP wil Note that not every Microsoft returned error will be included in the docs site. These can also have additional information available with a search of the internet/Microsoft documentation. {% endhint %} -*** - {% include "../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/shared-features/global-page-icon.md b/docs/user-documentation/shared-features/global-page-icon.md index 2e7608d716..9e6becb0dd 100644 --- a/docs/user-documentation/shared-features/global-page-icon.md +++ b/docs/user-documentation/shared-features/global-page-icon.md @@ -2,6 +2,4 @@ Pages marked with this icon are not specific to any particular tenant. Settings, etc. managed on this page apply globally. -*** - {% include "../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/shared-features/keyboard-shortcuts.md b/docs/user-documentation/shared-features/keyboard-shortcuts.md index 21288fc5f8..f1cdbf229c 100644 --- a/docs/user-documentation/shared-features/keyboard-shortcuts.md +++ b/docs/user-documentation/shared-features/keyboard-shortcuts.md @@ -30,6 +30,4 @@ These shortcuts are active even while your cursor is in a text field, so pressin Breadcrumb entries can be reached with the Tab key and opened with either Enter or the space bar, so the trail can be navigated without a mouse. -*** - {% include "../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/shared-features/menu-bar/bookmarks.md b/docs/user-documentation/shared-features/menu-bar/bookmarks.md index 2c5c31bb0a..96cad75f72 100644 --- a/docs/user-documentation/shared-features/menu-bar/bookmarks.md +++ b/docs/user-documentation/shared-features/menu-bar/bookmarks.md @@ -45,6 +45,4 @@ The list is locked by default. If you attempt to move or remove a bookmark while Your custom order, sort choice and lock state are all remembered between sessions. -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/shared-features/menu-bar/display-mode.md b/docs/user-documentation/shared-features/menu-bar/display-mode.md index 47688e123b..179a6b2f4a 100644 --- a/docs/user-documentation/shared-features/menu-bar/display-mode.md +++ b/docs/user-documentation/shared-features/menu-bar/display-mode.md @@ -13,6 +13,4 @@ Your choice applies immediately and is remembered on that device. Because it is On narrow screens the icon is not shown in the menu bar. Use the **Light Mode** or **Dark Mode** entry in the account menu instead, reached from your avatar at the right of the menu bar. {% endhint %} -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/shared-features/menu-bar/search.md b/docs/user-documentation/shared-features/menu-bar/search.md index 08d46dd62a..325d80b728 100644 --- a/docs/user-documentation/shared-features/menu-bar/search.md +++ b/docs/user-documentation/shared-features/menu-bar/search.md @@ -4,6 +4,4 @@ This search is a duplication of [universal-search.md](universal-search.md "mention") but clicking the icon will preselect the Pages option. {% endhint %} -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/shared-features/menu-bar/tenant-select.md b/docs/user-documentation/shared-features/menu-bar/tenant-select.md index 18812fdf46..a2a1d6530b 100644 --- a/docs/user-documentation/shared-features/menu-bar/tenant-select.md +++ b/docs/user-documentation/shared-features/menu-bar/tenant-select.md @@ -72,6 +72,4 @@ The same flyout lists shortcuts for jumping straight to the tenant's Microsoft p Every portal other than **Manage Tenant** can be hidden from this list using the **Portal Links Configuration** settings described in [user-settings.md](user-settings.md "mention"), so you can shorten it to the portals you actually use. -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/shared-features/menu-bar/universal-search.md b/docs/user-documentation/shared-features/menu-bar/universal-search.md index a5e2a893b9..d335d93823 100644 --- a/docs/user-documentation/shared-features/menu-bar/universal-search.md +++ b/docs/user-documentation/shared-features/menu-bar/universal-search.md @@ -41,6 +41,4 @@ In the results list, matching text is shown in bold, and each result is labelled Results for users, groups, applications and licences come from the CIPP reporting database, so they are only as current as the last cache run. A record created moments ago will not appear until the cache next refreshes. Licence searches are matched first against the Microsoft SKU catalogue built into CIPP, and fall back to cached data only when the catalogue has no match. {% endhint %} -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/shared-features/menu-bar/user-settings.md b/docs/user-documentation/shared-features/menu-bar/user-settings.md index 6b7d2900ab..cc41fa3ad2 100644 --- a/docs/user-documentation/shared-features/menu-bar/user-settings.md +++ b/docs/user-documentation/shared-features/menu-bar/user-settings.md @@ -88,6 +88,4 @@ The Actions card controls who your changes apply to and commits them. | User selector | Chooses whether the settings are saved for Current User or for All Users. Selecting a different option reloads the page's values to show the settings that apply to that scope. | | Save Changes | Saves the settings for the selected scope. The button is unavailable while any required field is empty or invalid, and a message confirms the save or reports an error. | -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/shared-features/release-notes-notification.md b/docs/user-documentation/shared-features/release-notes-notification.md index 925e0305b5..05534abf78 100644 --- a/docs/user-documentation/shared-features/release-notes-notification.md +++ b/docs/user-documentation/shared-features/release-notes-notification.md @@ -31,6 +31,4 @@ These choices are stored in the browser you are using, so they apply to that bro Choosing **Remind me next time** or **Don't show until next release** also clears a previous **Don't show again**, so the notification is easy to reinstate without hunting through browser settings. {% endhint %} -*** - {% include "../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/shared-features/speed-dial.md b/docs/user-documentation/shared-features/speed-dial.md index 0e92f0d476..ca353d5789 100644 --- a/docs/user-documentation/shared-features/speed-dial.md +++ b/docs/user-documentation/shared-features/speed-dial.md @@ -24,6 +24,4 @@ The **Tutorials** option opens a list of guided walkthroughs that highlight part Your progress is tracked, with a count of how many tutorials you have completed shown at the foot of the list and completed entries marked. A reset control at the top of the dialog clears that progress so the tutorials can be taken again. -*** - {% include "../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/shared-features/table-features.md b/docs/user-documentation/shared-features/table-features.md index e4fee63fbf..35240b9d2c 100644 --- a/docs/user-documentation/shared-features/table-features.md +++ b/docs/user-documentation/shared-features/table-features.md @@ -165,6 +165,4 @@ Tables are paged, with a control at the foot of the table for moving between pag Only the rows and columns currently in view are rendered, which keeps large result sets responsive while scrolling. -*** - {% include "../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/shared-features/variable-auto-complete.md b/docs/user-documentation/shared-features/variable-auto-complete.md index bc971cf572..b666c7acaa 100644 --- a/docs/user-documentation/shared-features/variable-auto-complete.md +++ b/docs/user-documentation/shared-features/variable-auto-complete.md @@ -36,6 +36,4 @@ You can also click an entry to insert it. The list closes on its own if what you type after the `%` stops looking like a variable name, for example when you type a space or punctuation. Type `%` again to bring it back. {% endhint %} -*** - {% include "../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/teams-share/deleted-sites.md b/docs/user-documentation/teams-share/deleted-sites.md index 69670e6882..162ca3e475 100644 --- a/docs/user-documentation/teams-share/deleted-sites.md +++ b/docs/user-documentation/teams-share/deleted-sites.md @@ -17,6 +17,4 @@ This page lists the SharePoint sites that have been deleted in the selected tena
ActionDescriptionBulk Action Available
Restore SiteRestores the selected site from the tenant recycle bin back to active use, at the address shown in the Url column. You are asked to confirm first, and large sites can take a while to finish restoring. Greyed out unless you have SharePoint site write access.true
-*** - {% include "../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/teams-share/external-users.md b/docs/user-documentation/teams-share/external-users.md index 5a1633e25b..55d8a189db 100644 --- a/docs/user-documentation/teams-share/external-users.md +++ b/docs/user-documentation/teams-share/external-users.md @@ -31,6 +31,4 @@ The **Guest Type** column tells you what each entry really is. **Entra B2B** is
ActionDescriptionBulk Action Available
Remove Guest AccessFully removes external access for the selected guest. Deletes their Entra guest account, if one exists, and removes them from every site listed in the Sites column, so nothing is left orphaned. You are asked to confirm first. Any sharing links they hold are revoked separately from sharing-report.md, and the leftover SharePoint store entry ages out on its own. Greyed out unless you have SharePoint site write access and the guest either has an Entra account or holds membership of at least one site.true
-*** - {% include "../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/teams-share/onedrive.md b/docs/user-documentation/teams-share/onedrive.md index f68f2e1e83..6889bb437a 100644 --- a/docs/user-documentation/teams-share/onedrive.md +++ b/docs/user-documentation/teams-share/onedrive.md @@ -28,6 +28,4 @@ The activity, file count and storage figures come from Microsoft's usage reporti
ActionDescriptionBulk Action Available
Add permissions to OneDriveGrants another user access to the selected user's OneDrive. You pick the user to grant access to from a list of everyone in the tenant.true
Remove permissions from OneDriveRemoves another user's access to the selected user's OneDrive. You pick the user to remove from a list of everyone in the tenant.true
-*** - {% include "../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/teams-share/permissions-report.md b/docs/user-documentation/teams-share/permissions-report.md index 29483562f4..7b32115649 100644 --- a/docs/user-documentation/teams-share/permissions-report.md +++ b/docs/user-documentation/teams-share/permissions-report.md @@ -67,6 +67,4 @@ The Library Permissions table lists the individual permission assignments. Libra | Is Guest | Whether the principal is an external (guest) user. | | Email | The email address of the principal, where available. | -*** - {% include "../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/teams-share/sharepoint-templates/README.md b/docs/user-documentation/teams-share/sharepoint-templates/README.md index 2fb757c3a9..8fc22702ac 100644 --- a/docs/user-documentation/teams-share/sharepoint-templates/README.md +++ b/docs/user-documentation/teams-share/sharepoint-templates/README.md @@ -38,6 +38,4 @@ The **Deploy Template** button in the panel queues the deployment, and only beco
ActionDescriptionBulk Action Available
Edit TemplateOpens the selected template in the same editor as add.md so you can change its settings. Greyed out unless you have SharePoint admin write access.false
Copy TemplateOpens the editor pre-filled from the selected template so you can save it as a new template under a different name. Greyed out unless you have SharePoint admin write access.false
Delete TemplatePermanently removes the selected template. Greyed out unless you have SharePoint admin write access.true
More InfoOpens the Extended Info flyout with the full details for the selected row.false
-*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/teams-share/sharepoint-templates/add.md b/docs/user-documentation/teams-share/sharepoint-templates/add.md index a843a21cdb..57b64d942a 100644 --- a/docs/user-documentation/teams-share/sharepoint-templates/add.md +++ b/docs/user-documentation/teams-share/sharepoint-templates/add.md @@ -86,6 +86,4 @@ The SharePoint and Teams counts respect the site-type override, so they always r Select **Save Template** to store the template. Save only becomes available once the template is valid: a template name is set and every site template has a name, at least one root-level permission, and a name for every library. When something is missing, an information icon next to the Save button lists exactly what needs fixing. Saving a new template or a copy creates a new template, while saving an edit updates the existing template in place. You are then returned to the SharePoint Templates list. -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/teams-share/sharepoint/README.md b/docs/user-documentation/teams-share/sharepoint/README.md index 9223ef6099..af2d72259c 100644 --- a/docs/user-documentation/teams-share/sharepoint/README.md +++ b/docs/user-documentation/teams-share/sharepoint/README.md @@ -40,6 +40,4 @@ The Extended Info flyout also lists the site's members, showing each person's na
ActionDescriptionBulk Action Available
Add MemberAdds a user to the site and puts them in the site role you choose: Members, Owners or Visitors. Greyed out unless you have SharePoint site write access.true
Remove MemberRemoves a user from one of the site's roles. The picker lists the site's current owners, members and visitors, so you can see which role each person holds before removing them. Greyed out unless you have SharePoint site write access.true
Remove User From SiteRemoves a user from the entire site at once, covering every site group and every direct permission grant they hold. Sharing links they have already received are not revoked by this. Greyed out unless you have SharePoint site write access.true
Revoke Sharing LinksRevokes sharing links across the whole site in one go. You choose the scope: anonymous links only, anonymous links plus external user shares, or all sharing links including internal ones. This works from the sharing report data, so links created since the last sharing sync are not covered; run a sync from sharing-report.md first for full coverage. Greyed out unless you have SharePoint site write access.true
Edit SiteOpens the site's properties, prefilled with their current values, so you can change the site name, its external sharing capability, the default sharing link type and permission, any domain restrictions, anonymous link expiry, the lock state, storage limit and warning level, and the file version retention policy. Some settings are not editable on a site connected to a Microsoft 365 group. Greyed out unless you have SharePoint site write access.true
Add Site AdminMakes a user a site collection administrator, giving them full control of the site. Greyed out unless you have SharePoint site write access.true
Remove Site AdminRemoves a user's site collection administrator rights. Greyed out unless you have SharePoint site write access.true
Manage PermissionsShows who has access to the site, or to one of its document libraries, and the permission level each user or group holds. From here you can grant a user or group a permission level, change or remove an existing assignment, stop a library inheriting the site's permissions so that it can hold its own, or restore inheritance. Viewing requires SharePoint site read access; making changes requires write access.false
Check User AccessAnswers whether a specific person can reach the site or one of its libraries, and by which routes. Group memberships are resolved, including nested groups, and each route is listed with what it grants. Because access can come from several routes at once, removing one does not necessarily remove access. Greyed out unless you have SharePoint site read access.false
Delete SiteDeletes the selected site. Deleted sites can be restored from deleted-sites.md for 93 days, after which they are permanently removed. For a site connected to a Microsoft 365 group, including any Teams site, the group's other resources are kept for only 30 days, so restoring after that returns the site but not the Team, its mailbox or its Planner. Greyed out unless you have SharePoint site write access, and always greyed out for sites SharePoint will not let you delete this way: the tenant admin site, the My Site host, search and compliance centres, the app catalogue, the root site, the content type hub, and Team channel sites, which are removed by deleting the channel in Teams.true
Start Version Cleanup JobStarts a background job that trims old file versions to reclaim storage. You choose the mode: Sync Policy applies the site's version policy to versions that already exist, Delete Older Than Days removes versions older than a set number of days (SharePoint requires at least 30), and Count Limits keeps a maximum number of major versions. Greyed out unless you have SharePoint site write access.true
Recycle BinLists the items in the site's recycle bin, showing each item's name, location, type, state, size, who deleted it and when. Individual items can be restored from here. Greyed out unless you have SharePoint site recycle bin read access, and restoring requires recycle bin write access.false
Check Cleanup Job StatusShows the progress of the site's file version cleanup job, including how many lists, files and versions have been processed, how many versions were deleted, and how much storage was released. Tells you when no job has been run for the site. Greyed out unless you have SharePoint site read access.false
More InfoOpens the Extended Info flyout with the full details for the selected row.false
-*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/teams-share/sharepoint/add-site.md b/docs/user-documentation/teams-share/sharepoint/add-site.md index d82b341885..084711b832 100644 --- a/docs/user-documentation/teams-share/sharepoint/add-site.md +++ b/docs/user-documentation/teams-share/sharepoint/add-site.md @@ -12,6 +12,4 @@ Creates a single new SharePoint site in the selected tenant. Every field on this | Template Name | The kind of site to create: **Team (No Microsoft365 Group)** or **Communication**. | | Site Design Template | The site design to apply when the site is created: **Blank**, **Showcase** or **Topic**. | -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/teams-share/sharepoint/bulk-add-site.md b/docs/user-documentation/teams-share/sharepoint/bulk-add-site.md index e541b71378..1e7ac7bf71 100644 --- a/docs/user-documentation/teams-share/sharepoint/bulk-add-site.md +++ b/docs/user-documentation/teams-share/sharepoint/bulk-add-site.md @@ -28,6 +28,4 @@ Review the information for accuracy and select **Submit**. {% endstep %} {% endstepper %} -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/teams-share/sharing-report.md b/docs/user-documentation/teams-share/sharing-report.md index 231afe2c88..3cee5560f7 100644 --- a/docs/user-documentation/teams-share/sharing-report.md +++ b/docs/user-documentation/teams-share/sharing-report.md @@ -8,11 +8,17 @@ The report reads from cached scans rather than querying SharePoint and OneDrive | Control | Description | | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Sync data | Queues scans of the tenant's SharePoint sites and OneDrive accounts for sharing links, together with SharePoint and OneDrive usage data. Scanning every drive can take a while on large tenants. Progress is shown next to the button, and the report refreshes itself once the scans finish. | +| Sync data | Queues scans of the tenant's SharePoint sites and OneDrive accounts for sharing links, together with SharePoint and OneDrive usage data. Scanning every drive can take a while on large tenants, particularly the first time. Progress is shown next to the button, and the report refreshes itself once the scans finish. | | Refresh | The refresh icon reloads the report from the cached data without running a new scan. Use it when a scan has finished elsewhere, or when someone else has refreshed the cache. | | Export Report | Opens a preview of the report as a PDF, which you can then download. Greyed out until the report has data. | -The time of the last scan is shown as "Last data refresh." +The time of the last completed scan is shown as "Last data refresh." + +Results are written as the scan works through the tenant, so the report fills in site by site rather than staying empty until every drive has been read. Refreshing part-way through shows what has been scanned so far, and the totals settle once the scan finishes. + +The first scan of a tenant reads every drive in full. Later syncs collect only what has changed since the previous one, so they finish considerably faster, and a full rescan is run periodically to catch anything a change-only pass would miss. A scan interrupted before it finishes, by a timeout on a large tenant for example, picks up where it stopped rather than starting the tenant over. + +Sites that could not be read keep the links an earlier scan found for them rather than dropping out of the report, so those rows may be out of date. Links belonging to sites or libraries that no longer exist in the tenant are removed once the scan finishes. ## Summary @@ -95,6 +101,4 @@ The Sharing Links & External Shares table lists every sharing link and external
ActionDescriptionBulk Action Available
Revoke Sharing LinkRemoves the selected sharing link, so anyone using it loses access to the item.true
Open FileOpens the shared file in a new browser tab.false
More InfoOpens the Extended Info flyout with the full details for the selected row.false
-*** - {% include "../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/teams-share/teams/business-voice.md b/docs/user-documentation/teams-share/teams/business-voice.md index 2990f6ae65..cd99d26fa3 100644 --- a/docs/user-documentation/teams-share/teams/business-voice.md +++ b/docs/user-documentation/teams-share/teams/business-voice.md @@ -32,6 +32,4 @@ A user needs a Teams Phone licence before a number can be assigned to them. Phon
ActionDescriptionBulk Action Available
Assign UserAssigns a user or resource account to the phone number. The assignment is submitted for processing and may take a moment to complete; re-sync the report to see the updated status.true
Unassign UserRemoves the assignment from the phone number. The change is submitted for processing and may take a moment to complete; re-sync the report to see the updated status.true
Set Emergency LocationSets the emergency location for the number. Locations are listed by description, place name, or street address.true
More InfoOpens the Extended Info flyout with the full details for the selected row.false
-*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/teams-share/teams/list-team/README.md b/docs/user-documentation/teams-share/teams/list-team/README.md index 3423970ac5..11aca1aed9 100644 --- a/docs/user-documentation/teams-share/teams/list-team/README.md +++ b/docs/user-documentation/teams-share/teams/list-team/README.md @@ -16,6 +16,4 @@ The properties returned are for the Graph resource type `group`, filtered to gro
ActionDescriptionBulk Action Available
Edit GroupOpens the team's underlying Microsoft 365 group in the edit.md page, where its members, owners, and settings can be changed.false
Delete TeamDeletes the selected team by deleting its underlying Microsoft 365 group, which takes the team's channels, SharePoint site, and shared mailbox with it. You are asked to confirm first. The group and its content stay recoverable for 30 days before being permanently removed.true
-*** - {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/teams-share/teams/list-team/add.md b/docs/user-documentation/teams-share/teams/list-team/add.md index 1f507c4dc3..648ec330ad 100644 --- a/docs/user-documentation/teams-share/teams/list-team/add.md +++ b/docs/user-documentation/teams-share/teams/list-team/add.md @@ -11,6 +11,4 @@ Creates a new team in the selected tenant, using Microsoft's standard team templ | Select owner (must have a Teams license) | The user who will be added to the team as its owner. Required, and at least one owner has to be set for the team to be created. | | Team Visibility | **Private Team** means people can only join if an owner adds them. **Public Team** means anyone in the organisation can join it. | -*** - {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/teams-share/teams/teams-activity.md b/docs/user-documentation/teams-share/teams/teams-activity.md index f576d5a4dc..71547b7772 100644 --- a/docs/user-documentation/teams-share/teams/teams-activity.md +++ b/docs/user-documentation/teams-share/teams/teams-activity.md @@ -18,6 +18,4 @@ Microsoft 365 has a tenant-wide setting that conceals user names in usage report The fix is on the tenant, not in CIPP. Enabling the **Enable Usernames instead of pseudo anonymised names in reports** standard turns the setting off, after which the report needs running again to pick up real names. -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/tenant/administration/alert-configuration/README.md b/docs/user-documentation/tenant/administration/alert-configuration/README.md index 1d1c7fdeae..52f16c39c2 100644 --- a/docs/user-documentation/tenant/administration/alert-configuration/README.md +++ b/docs/user-documentation/tenant/administration/alert-configuration/README.md @@ -26,6 +26,4 @@ Excluded tenants only apply where the alert is scoped broadly, such as to all te
ActionDescriptionBulk Action Available
View Task DetailsOpens the underlying scheduled task, showing its run history and results. Only available for rows with an Event Type of Scheduled Task.false
Edit AlertOpens the alert for editing so its tenants, conditions, schedule and actions can be adjusted and saved back over the existing rule.false
Clone & Edit AlertOpens a copy of the alert for editing, saving it as a new rule and leaving the original untouched. Useful for applying the same alert to a different set of tenants.false
Delete AlertRemoves the alert rule after confirmation. The alert stops firing immediately and cannot be recovered.true
-*** - {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/tenant/administration/alert-configuration/alert.md b/docs/user-documentation/tenant/administration/alert-configuration/alert.md index c7f7c99a4f..d52c96325d 100644 --- a/docs/user-documentation/tenant/administration/alert-configuration/alert.md +++ b/docs/user-documentation/tenant/administration/alert-configuration/alert.md @@ -119,6 +119,4 @@ You can review the available alerts embedded below or navigate to [https://resou {% @cipp-external-webpage-block/cyberdrain url="https://resources.cipp.app/?tab=alerts" fullWidth="true" %} -*** - {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/tenant/administration/alert-configuration/snoozed-alerts.md b/docs/user-documentation/tenant/administration/alert-configuration/snoozed-alerts.md index e05bb9a873..64155dcc78 100644 --- a/docs/user-documentation/tenant/administration/alert-configuration/snoozed-alerts.md +++ b/docs/user-documentation/tenant/administration/alert-configuration/snoozed-alerts.md @@ -33,6 +33,4 @@ Expired snoozes stay listed until they are removed. They no longer suppress anyt
ActionDescriptionBulk Action Available
Remove SnoozeRemoves the snooze after confirmation, so the alert fires again for that item on its next run.true
-*** - {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/tenant/administration/app-consent-requests.md b/docs/user-documentation/tenant/administration/app-consent-requests.md index 940287095c..a9d1bbb572 100644 --- a/docs/user-documentation/tenant/administration/app-consent-requests.md +++ b/docs/user-documentation/tenant/administration/app-consent-requests.md @@ -42,6 +42,4 @@ The table's own filter menu additionally offers Pending requests, Expired reques The permissions being requested are not shown in the table or the flyout, and completing the prompt that **Approve in Entra** opens grants those permissions on behalf of the entire organisation, not only the user who asked. Review the request in Entra, or the application itself, before approving. {% endhint %} -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/tenant/administration/applications/app-registrations/README.md b/docs/user-documentation/tenant/administration/applications/app-registrations/README.md index 185cc8affa..926a1b22c8 100644 --- a/docs/user-documentation/tenant/administration/applications/app-registrations/README.md +++ b/docs/user-documentation/tenant/administration/applications/app-registrations/README.md @@ -29,6 +29,4 @@ Alongside the usual row details, the flyout for this table renders an **Applicat These are the permissions the application asks for in its manifest, which is not the same as what has been consented to in a tenant. Granted consent is shown on the Permissions tab of ... -*** - {% include "../../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/tenant/administration/applications/app-registrations/appid.md b/docs/user-documentation/tenant/administration/applications/app-registrations/appid.md index d52cc1ef7b..20fa403c37 100644 --- a/docs/user-documentation/tenant/administration/applications/app-registrations/appid.md +++ b/docs/user-documentation/tenant/administration/applications/app-registrations/appid.md @@ -89,6 +89,4 @@ Permissions that appear in CIPP's curated set of risky permissions are marked wi Permission names and descriptions are resolved by looking up each resource API's service principal in the current tenant. Where an API has no service principal present, the group shows a warning and its permissions are listed as raw GUIDs until the API is provisioned in the tenant. {% endhint %} -*** - {% include "../../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/tenant/administration/applications/enterprise-apps/README.md b/docs/user-documentation/tenant/administration/applications/enterprise-apps/README.md index 60aa404885..3be04de703 100644 --- a/docs/user-documentation/tenant/administration/applications/enterprise-apps/README.md +++ b/docs/user-documentation/tenant/administration/applications/enterprise-apps/README.md @@ -26,6 +26,4 @@ The password and certificate credential columns are included so that application Removing credentials, disabling, or deleting a service principal takes effect immediately and will break any integration currently authenticating as that application. Confirm what an application is used for before acting on it, particularly for applications published by your own or another partner organisation. {% endhint %} -*** - {% include "../../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/tenant/administration/applications/enterprise-apps/spid.md b/docs/user-documentation/tenant/administration/applications/enterprise-apps/spid.md index 27e96b9c37..bc1fa425b3 100644 --- a/docs/user-documentation/tenant/administration/applications/enterprise-apps/spid.md +++ b/docs/user-documentation/tenant/administration/applications/enterprise-apps/spid.md @@ -77,6 +77,4 @@ Permissions that appear in CIPP's curated set of risky permissions are marked wi The risky-permissions set is a deliberately short list of the permissions most useful to an attacker, and it concentrates on application permissions, so delegated scopes are rarely flagged. A permission without a chip has not been assessed rather than judged safe. {% endhint %} -*** - {% include "../../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/tenant/administration/applications/permission-sets.md b/docs/user-documentation/tenant/administration/applications/permission-sets.md index f00f6d532c..8c26a03d42 100644 --- a/docs/user-documentation/tenant/administration/applications/permission-sets.md +++ b/docs/user-documentation/tenant/administration/applications/permission-sets.md @@ -25,6 +25,4 @@ Sets are created by hand from this page, and also automatically by the **Create Editing a permission set changes it for every application template that references it. The change does not reach tenants on its own: applications already deployed keep the permissions they were granted until the template is deployed again. Deleting a set leaves any template referencing it without its permissions. {% endhint %} -*** - {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/tenant/administration/applications/templates/README.md b/docs/user-documentation/tenant/administration/applications/templates/README.md index c2c7eb0243..b5a62bb5db 100644 --- a/docs/user-documentation/tenant/administration/applications/templates/README.md +++ b/docs/user-documentation/tenant/administration/applications/templates/README.md @@ -42,6 +42,4 @@ Templates are held at partner level rather than per tenant, so this table shows An enterprise app template references a permission set by ID rather than copying it, so editing that set changes what the template grants on its next deployment. Manifest templates cannot be saved while the captured manifest still contains secrets or certificates; remove those sections and save again. {% endhint %} -*** - {% include "../../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/tenant/administration/applications/templates/add.md b/docs/user-documentation/tenant/administration/applications/templates/add.md index 965de64846..cc61c205ef 100644 --- a/docs/user-documentation/tenant/administration/applications/templates/add.md +++ b/docs/user-documentation/tenant/administration/applications/templates/add.md @@ -60,6 +60,4 @@ The panel beside the form shows what has been defined so far, and changes with t Saved templates can be deployed from [appapproval.md](../../../../tools/tenant-tools/appapproval.md "mention"), or applied continuously through the Deploy Application standard in [standards](../../../standards/ "mention"). {% endhint %} -*** - {% include "../../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/tenant/administration/applications/templates/edit.md b/docs/user-documentation/tenant/administration/applications/templates/edit.md index 6691285005..29aaa255fd 100644 --- a/docs/user-documentation/tenant/administration/applications/templates/edit.md +++ b/docs/user-documentation/tenant/administration/applications/templates/edit.md @@ -2,6 +2,4 @@ This page will allow you to edit the settings for your app approval template. As this page is similar in form, see [add.md](add.md "mention") for more detail. -*** - {% include "../../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/tenant/administration/audit-logs/README.md b/docs/user-documentation/tenant/administration/audit-logs/README.md index 5a3c0c6853..0d9f7850d1 100644 --- a/docs/user-documentation/tenant/administration/audit-logs/README.md +++ b/docs/user-documentation/tenant/administration/audit-logs/README.md @@ -36,6 +36,4 @@ The table respects the tenant selected at the top of CIPP. Choose All Tenants to
ActionDescriptionBulk Action Available
View LogOpens the full structured view of the selected entry, including the raw audit record, any actions CIPP took, and geolocation for the originating IP address where one is available.false
-*** - {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/tenant/administration/audit-logs/directory-audits.md b/docs/user-documentation/tenant/administration/audit-logs/directory-audits.md index d99cb9b46e..14fe791a59 100644 --- a/docs/user-documentation/tenant/administration/audit-logs/directory-audits.md +++ b/docs/user-documentation/tenant/administration/audit-logs/directory-audits.md @@ -14,6 +14,4 @@ The properties returned are for the Graph resource type `directoryAudit`. For mo
ActionDescriptionBulk Action Available
More InfoOpens the Extended Info flyout with the full details for the selected row.false
-*** - {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/tenant/administration/audit-logs/log.md b/docs/user-documentation/tenant/administration/audit-logs/log.md index 2367464712..5177341428 100644 --- a/docs/user-documentation/tenant/administration/audit-logs/log.md +++ b/docs/user-documentation/tenant/administration/audit-logs/log.md @@ -33,6 +33,4 @@ Everything else from the raw audit record, laid out as a property list. The exac Values are translated into readable text wherever CIPP has a mapping for them, so numeric result codes and internal identifiers appear as their meanings rather than their raw values. Properties that CIPP added while processing the alert are left out here, as they are already presented above. -*** - {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/tenant/administration/audit-logs/manual-searches.md b/docs/user-documentation/tenant/administration/audit-logs/manual-searches.md index 631bb0ae7f..90e6b54d5f 100644 --- a/docs/user-documentation/tenant/administration/audit-logs/manual-searches.md +++ b/docs/user-documentation/tenant/administration/audit-logs/manual-searches.md @@ -39,6 +39,4 @@ The properties returned are for the Graph resource type `microsoft.graph.securit
ActionDescriptionBulk Action Available
View ResultsOpens the search-results.md for the selected search. Only useful once the search has reached a status of succeeded.false
Process LogsRuns the search results through your alert rules after confirmation, generating alerts for anything that matches. Nothing happens for records that match no rule.true
-*** - {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/tenant/administration/audit-logs/search-results.md b/docs/user-documentation/tenant/administration/audit-logs/search-results.md index 8c2ce48639..2ccfce7ee6 100644 --- a/docs/user-documentation/tenant/administration/audit-logs/search-results.md +++ b/docs/user-documentation/tenant/administration/audit-logs/search-results.md @@ -27,6 +27,4 @@ CIPP does some work to make the record readable. Object IDs are resolved to the
ActionDescriptionBulk Action Available
More InfoOpens the Extended Info flyout with the full details for the selected row.false
-*** - {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/tenant/administration/audit-logs/searches.md b/docs/user-documentation/tenant/administration/audit-logs/searches.md index 043dcfa531..efa7e78528 100644 --- a/docs/user-documentation/tenant/administration/audit-logs/searches.md +++ b/docs/user-documentation/tenant/administration/audit-logs/searches.md @@ -48,6 +48,4 @@ The ledger honours the tenant selector at the top of CIPP. Choose All Tenants to To queue a search of your own rather than wait for the scheduled windows, use the Manual Searches tab. -*** - {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/tenant/administration/authentication-methods/README.md b/docs/user-documentation/tenant/administration/authentication-methods/README.md index 24358f61af..8842fd1360 100644 --- a/docs/user-documentation/tenant/administration/authentication-methods/README.md +++ b/docs/user-documentation/tenant/administration/authentication-methods/README.md @@ -49,6 +49,4 @@ Both targeting actions replace the method's include targets rather than adding t Enabling FIDO2 with **Enable Policy** rather than **Configure** turns on attestation enforcement and self-service registration, as those are the defaults CIPP applies. Use **Configure** afterwards if either should be off. {% endhint %} -*** - {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/tenant/administration/authentication-methods/registration-campaign.md b/docs/user-documentation/tenant/administration/authentication-methods/registration-campaign.md index 194d7cb962..68e69a3188 100644 --- a/docs/user-documentation/tenant/administration/authentication-methods/registration-campaign.md +++ b/docs/user-documentation/tenant/administration/authentication-methods/registration-campaign.md @@ -33,6 +33,4 @@ Groups and users already targeted are shown by their object ID rather than their The **Sets the state for the request to setup Authenticator** standard writes the same campaign settings. Where that standard is applied to a tenant, changes made here are overwritten the next time it runs, so use one or the other rather than both. {% endhint %} -*** - {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/tenant/administration/domains.md b/docs/user-documentation/tenant/administration/domains.md index 4a4a051331..7c5d784845 100644 --- a/docs/user-documentation/tenant/administration/domains.md +++ b/docs/user-documentation/tenant/administration/domains.md @@ -64,6 +64,4 @@ The flyout for this table is built specifically for domains and shows three sect Both record sets are read live from Microsoft when the flyout is opened, so they reflect what Microsoft is currently asking for rather than a cached copy. -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/tenant/administration/partner-relationships.md b/docs/user-documentation/tenant/administration/partner-relationships.md index 8da0bbb0cf..8994ef0bba 100644 --- a/docs/user-documentation/tenant/administration/partner-relationships.md +++ b/docs/user-documentation/tenant/administration/partner-relationships.md @@ -24,6 +24,4 @@ This table is view only. Cross-tenant access settings are changed in the Microso The partner name is not stored in the policy itself. CIPP looks up each partner's tenant ID against Microsoft to resolve it, so a partner whose tenant cannot be resolved shows a blank name with its details still available in the Tenant Info column. {% endhint %} -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/tenant/administration/securescore/README.md b/docs/user-documentation/tenant/administration/securescore/README.md index 395345f26c..26d1f774c7 100644 --- a/docs/user-documentation/tenant/administration/securescore/README.md +++ b/docs/user-documentation/tenant/administration/securescore/README.md @@ -83,6 +83,4 @@ Marking a recommendation as resolved by a third party or as risk accepted change Defender controls cannot be updated from here at all. Microsoft only accepts status changes for those through the Microsoft Defender portal, so **Change Status** is disabled on those cards and the API rejects the change if it is attempted another way. {% endhint %} -*** - {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/tenant/administration/securescore/table.md b/docs/user-documentation/tenant/administration/securescore/table.md index edb56e1c46..d95853699e 100644 --- a/docs/user-documentation/tenant/administration/securescore/table.md +++ b/docs/user-documentation/tenant/administration/securescore/table.md @@ -52,6 +52,4 @@ These columns are drawn from the Graph resource type `secureScoreControlProfile` The single-tenant view is read-only and does not show how complete each recommendation is. To see completion, change a recommendation's status, or read its full remediation guidance, use .. {% endhint %} -*** - {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/tenant/administration/tenants/README.md b/docs/user-documentation/tenant/administration/tenants/README.md index 151d0d126f..480e4b7421 100644 --- a/docs/user-documentation/tenant/administration/tenants/README.md +++ b/docs/user-documentation/tenant/administration/tenants/README.md @@ -8,7 +8,7 @@ description: View and manage your Microsoft 365 CSP tenants. When you select one of the portal links, the permissions of the currently logged in user are the ones that matter. The user's GDAP permissions will apply, not the CIPP service account. {% endhint %} -Lists every tenant CIPP manages and gives you a one click jump into each Microsoft administration centre for that customer. The portal links open in the context of the selected tenant using your own partner credentials, so you land in the target administration centre already scoped to that customer rather than having to switch context manually. Alongside the links, the row actions cover the tenant level maintenance tasks: editing the tenant's alias and group membership, managing its configuration backup schedule, and clearing its cached capability data. +Lists every tenant CIPP manages and gives you a one click jump into each Microsoft administration center for that customer. The portal links open in the context of the selected tenant using your own partner credentials, so you land in the target administration center already scoped to that customer rather than having to switch context manually. Alongside the links, the row actions cover the tenant level maintenance tasks: editing the tenant's alias and group membership, managing its configuration backup schedule, and clearing its cached capability data. Tenants are served from CIPP's own cache rather than being read from Partner Center on every page load. If a newly added tenant is missing, or the display name or default domain looks out of date, clear the tenant cache from the Cache card in [settings](../../../cipp/settings/ "mention") using the **Clear Cache** button with **Only Clear the Tenant Cache** enabled. That queues a tenant refresh in the background. Refreshing your browser afterwards is worth doing so the page picks up the new data. @@ -18,6 +18,7 @@ Tenants are served from CIPP's own cache rather than being read from Partner Cen | ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Display Name | The tenant's name as it appears in Microsoft 365. If you have set a tenant alias, that alias is shown instead. | | Default Domain Name | The tenant's default domain, used throughout CIPP as the tenant identifier. | +| Tenant Groups | The tenant groups this tenant belongs to, with each group's `static` or `dynamic` type. Only groups you have access to are listed. Groups are created and maintained on [groups](groups/ "mention"). | | M365 | Opens the Microsoft 365 admin center for the tenant. | | Exchange | Opens the Exchange admin center for the tenant. | | Entra | Opens the Microsoft Entra admin center for the tenant. | @@ -38,6 +39,4 @@ A tenant that repeatedly fails to return data from Graph accumulates errors agai
ActionDescriptionBulk Action Available
Edit TenantOpens the edit.md page, where you can set a tenant alias, manage tenant group membership, define custom variables, and configure offboarding defaults.false
Configure BackupOpens the backup.md page for the tenant, where you can review the backup schedule, choose which components are included, and trigger a backup.false
Delete Capabilities CacheClears the cached licence capability data CIPP holds for the tenant, so the next request re-evaluates what the tenant is licensed for. Useful after a licence change has not yet been reflected in CIPP. You are asked to confirm before the cache is removed.true
-*** - {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/tenant/administration/tenants/global-variables.md b/docs/user-documentation/tenant/administration/tenants/global-variables.md index 80c5c3282f..7ffd5d27a3 100644 --- a/docs/user-documentation/tenant/administration/tenants/global-variables.md +++ b/docs/user-documentation/tenant/administration/tenants/global-variables.md @@ -56,6 +56,4 @@ The reserved variables listed above behave differently: they are deliberately pa If you want to see how to combine Custom Variables and Tenant Groups to provide a way to "graduate" tenants through standards, see Using Custom Variables to Manage Standards Templates. {% endhint %} -*** - {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/tenant/administration/tenants/groups/README.md b/docs/user-documentation/tenant/administration/tenants/groups/README.md index 959f2e70d6..5ca89a0956 100644 --- a/docs/user-documentation/tenant/administration/tenants/groups/README.md +++ b/docs/user-documentation/tenant/administration/tenants/groups/README.md @@ -66,6 +66,4 @@ Check the Usage column before deleting a group. A group that is still referenced
ActionDescriptionBulk Action Available
Edit GroupOpens the edit.md page for the selected group, where you can change its name, description, and membership or rules.false
Run Dynamic RulesForces an immediate re-evaluation of the group's membership rules rather than waiting for the next scheduled run. Only offered on groups with a dynamic group type. You are asked to confirm before the rules are run.true
Delete GroupPermanently removes the selected group. You are asked to confirm before the group is deleted.true
More InfoOpens the Extended Info flyout with the full details for the selected row.false
-*** - {% include "../../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/tenant/administration/tenants/groups/edit.md b/docs/user-documentation/tenant/administration/tenants/groups/edit.md index 1fe70e997e..cb33d2271c 100644 --- a/docs/user-documentation/tenant/administration/tenants/groups/edit.md +++ b/docs/user-documentation/tenant/administration/tenants/groups/edit.md @@ -53,6 +53,4 @@ CIPP re-evaluates dynamic groups on a schedule and updates their membership auto Select Save to write your changes. For a static group this stores the member list you selected; for a dynamic group it stores the rule set and logic, and membership is resolved automatically from that point forward. -*** - {% include "../../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/tenant/conditional/list-named-locations/README.md b/docs/user-documentation/tenant/conditional/list-named-locations/README.md index c5f736f000..8bd2f03554 100644 --- a/docs/user-documentation/tenant/conditional/list-named-locations/README.md +++ b/docs/user-documentation/tenant/conditional/list-named-locations/README.md @@ -41,6 +41,4 @@ Deleting a named location does not update the Conditional Access policies that r | IPv4 | `1.1.1.1/32` | /9 to /32 | | IPv6 | `2001:db8::/32` | /9 to /128 | -*** - {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/tenant/conditional/list-named-locations/add.md b/docs/user-documentation/tenant/conditional/list-named-locations/add.md index fa88429769..2771f5e4a4 100644 --- a/docs/user-documentation/tenant/conditional/list-named-locations/add.md +++ b/docs/user-documentation/tenant/conditional/list-named-locations/add.md @@ -32,6 +32,4 @@ A named location is either country-based or IP-based, never both. If you need to Creating the same named location across several tenants gives you locations that share a name but are separate objects, each with its own identifier. Later edits are per tenant, so if the underlying ranges change you will need to update each one, either from the named locations list or by creating a replacement here. {% endhint %} -*** - {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/tenant/conditional/list-policies/README.md b/docs/user-documentation/tenant/conditional/list-policies/README.md index 6c1b4e2638..5f66dfad3a 100644 --- a/docs/user-documentation/tenant/conditional/list-policies/README.md +++ b/docs/user-documentation/tenant/conditional/list-policies/README.md @@ -37,7 +37,7 @@ Conditional Access policies reference users, groups, and named locations by GUID | Conditional Access Parameters | The raw policy JSON, populated from the selected template and editable before deployment. | | How should groups and users be handled? | Controls how user and group references are translated. See below. | | Policy State | Deploy the policy as enabled, disabled, or report only, or leave the template's own state unchanged. | -| Overwrite Existing Policy | Replaces a policy of the same name in the target tenant rather than failing. | +| Overwrite Existing Policy | Replaces a policy of the same name in the target tenant rather than failing. The policy is returned to the template in full, so anything changed in the tenant since the last deployment, such as an extra excluded group or a platform condition, is removed. | | Disable Security Defaults if enabled when creating policy | Turns off Security Defaults in the target tenant, which otherwise blocks Conditional Access from taking effect. | | Create groups if they do not exist | Creates any group named in the template that is missing from the target tenant. Only available when translating by display name. | @@ -54,7 +54,7 @@ Conditional Access policies reference users, groups, and named locations by GUID {% endhint %} {% hint style="info" %} -Standards deploy Conditional Access templates using the display-name translation, which is why standards-driven deployments are portable across tenants without any extra configuration. Use a standard rather than this drawer when you want the policy re-applied automatically on drift. +Standards deploy Conditional Access templates using the display-name translation, which is why standards-driven deployments are portable across tenants without any extra configuration. Use a standard rather than this drawer when you want the policy re-applied automatically on drift. A standard reapplies the template the same way an overwrite does, so a policy edited in the tenant is put back the way the template describes it rather than keeping both sets of changes. {% endhint %} ## Table Details @@ -99,6 +99,4 @@ The Extended Info flyout shows the policy's complete raw JSON, including conditi Every property of the policy is captured. Inclusions and exclusions are stored in a form CIPP can translate on redeployment, and everything else carries across, including named locations, authentication strengths, and session controls. -*** - {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/tenant/conditional/list-policies/edit-ca-policy.md b/docs/user-documentation/tenant/conditional/list-policies/edit-ca-policy.md index b73506134d..86da5377c8 100644 --- a/docs/user-documentation/tenant/conditional/list-policies/edit-ca-policy.md +++ b/docs/user-documentation/tenant/conditional/list-policies/edit-ca-policy.md @@ -24,18 +24,24 @@ Defines who the policy applies to. | Include Directory Roles | Directory roles the policy targets, for scoping to administrators. | | Exclude Directory Roles | Directory roles exempt from the policy. | -### Exclude Guests or External Users +### Include or Exclude Guests or External Users -| Field | Description | -| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| External User Types to Exclude | The categories of external user to exempt, such as service providers or B2B collaboration guests. | -| Tenant Scope | Whether the exclusion covers all external tenants or only named ones. Appears once an external user type is selected, and is only meaningful for genuinely external users rather than internal guests. | -| External Tenant IDs | The tenant GUIDs the exclusion applies to. Appears only when the scope is set to specific tenants. | +Two matching blocks, **Include Guests or External Users** and **Exclude Guests or External Users**, targeting people from outside the tenant by category rather than by naming individual accounts. The fields are the same on both sides. + +| Field | Description | +| ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| External User Types to Include / Exclude | The categories of external user the policy targets or exempts, such as service providers or B2B collaboration guests. | +| Tenant Scope | Whether the block covers all external tenants or only named ones. Appears once an external user type is selected, and is only meaningful for genuinely external users rather than internal guests. | +| External Tenant IDs | The tenant GUIDs to scope to. Appears only when the scope is set to specific tenants. | {% hint style="info" %} Excluding your own partner tenant here is what the **Add service provider exception to policy** action on the policy list does for you. Use that action rather than setting this by hand unless you need something more specific. {% endhint %} +{% hint style="warning" %} +Entra ID rejects an include block that is combined with `All`, `None` or `GuestsOrExternalUsers` under **Include Users**. The editor warns you as soon as the combination is set, but does not stop you saving it, so clear **Include Users** or drop the external user types first rather than waiting for Entra to refuse the save. +{% endhint %} + ## Cloud Apps or Actions | Field | Description | @@ -43,6 +49,16 @@ Excluding your own partner tenant here is what the **Add service provider except | Include Applications | The applications the policy targets. | | Exclude Applications | Applications exempt from the policy. | | User Actions (instead of cloud apps) | Targets a user action, such as registering security information, in place of applications. | +| Authentication Context | Authentication context references the policy protects, used in place of cloud apps. | + +### Application Filter + +Targets applications by attribute rather than by name, which keeps the policy current as applications are added. + +| Field | Description | +| ----------------------- | --------------------------------------------------------------------------------------------- | +| Application Filter Mode | Whether applications matching the rule are included or excluded. | +| Application Filter Rule | The filter expression, for example `application.customSecurityAttributes.App.Sensitivity -eq "High"`. | ## Conditions @@ -78,6 +94,17 @@ Marked in the interface as requiring **Entra ID P2**. | ------------------------------------ | ------------------------------------------------------------------------------------ | | Authentication Flow Transfer Methods | The authentication transfer methods the policy applies to, such as device code flow. | +### Workload Identities + +Marked in the interface as requiring **Workload Identities Premium**. Scopes the policy to service principals instead of users, which is what turns it into a workload identity policy. Leave these empty for an ordinary user policy. + +| Field | Description | +| ----------------------------- | ------------------------------------------------------------------------------------------------ | +| Include Service Principals | The service principals the policy targets, either all of them or specific object IDs. | +| Exclude Service Principals | Service principals exempt from the policy, by object ID. | +| Service Principal Filter Mode | Whether service principals matching the rule are included or excluded. | +| Service Principal Filter Rule | The filter expression, for example `servicePrincipal.customSecurityAttributes.App.Tier -eq "1"`. | + ## Grant Controls What the policy requires before granting access. @@ -88,6 +115,9 @@ What the policy requires before granting access. | Built-in Controls | The requirements to grant access, such as multifactor authentication or a compliant device. | | Authentication Strength Policy | An authentication strength policy to require in place of plain MFA. | | Terms of Use | Terms of use the user must accept. | +| Custom Controls | Legacy custom controls from an external identity provider, referenced by ID. | + +A grant operator is required as soon as any of these carry a value, custom controls included. ## Session Controls @@ -107,10 +137,12 @@ Collapsed by default, since most policies do not use these. | Persistence Mode | Whether sessions are always persistent or never persistent. | | Disable Resilience Defaults | Stops Entra extending existing sessions during an outage. Leaving resilience defaults on is the safer choice for most tenants. | +{% hint style="info" %} +Saving writes the policy as the editor shows it rather than merging with what the tenant already had. Anything you clear here, whether an exclusion, a platform condition or a session control, is cleared on the policy itself. +{% endhint %} + {% hint style="warning" %} Test changes with the policy set to report only before enabling it, particularly when adding grant controls or narrowing the users the policy applies to. A policy that excludes no break-glass account can remove your own access to the tenant. {% endhint %} -*** - {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/tenant/conditional/list-template/README.md b/docs/user-documentation/tenant/conditional/list-template/README.md index a123d18805..791e63d7f6 100644 --- a/docs/user-documentation/tenant/conditional/list-template/README.md +++ b/docs/user-documentation/tenant/conditional/list-template/README.md @@ -51,6 +51,4 @@ Either source lets you preview a policy's full JSON before importing. Imported t Package tags are free text. Assigning the same package name to several templates groups them, which is useful for keeping a baseline set together when deploying to a new tenant. {% endhint %} -*** - {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/tenant/conditional/list-template/create-ca-template.md b/docs/user-documentation/tenant/conditional/list-template/create-ca-template.md index da13e5ab76..7237ccd572 100644 --- a/docs/user-documentation/tenant/conditional/list-template/create-ca-template.md +++ b/docs/user-documentation/tenant/conditional/list-template/create-ca-template.md @@ -24,13 +24,19 @@ New templates default to a state of **Report-only**, so a template deployed with | Include Directory Roles | Directory roles the policy targets, for scoping to administrators. | | Exclude Directory Roles | Directory roles exempt from the policy. | -### Exclude Guests or External Users +### Include or Exclude Guests or External Users -| Field | Description | -| ------------------------------ | --------------------------------------------------------------------------------------------------------------------- | -| External User Types to Exclude | The categories of external user to exempt, such as service providers or B2B collaboration guests. | -| Tenant Scope | Whether the exclusion covers all external tenants or only named ones. Appears once an external user type is selected. | -| External Tenant IDs | The tenant GUIDs the exclusion applies to. Appears only when the scope is set to specific tenants. | +Two matching blocks, **Include Guests or External Users** and **Exclude Guests or External Users**, targeting people from outside the tenant by category rather than by naming individual accounts. The fields are the same on both sides. + +| Field | Description | +| ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | +| External User Types to Include / Exclude | The categories of external user the policy targets or exempts, such as service providers or B2B collaboration guests. | +| Tenant Scope | Whether the block covers all external tenants or only named ones. Appears once an external user type is selected. | +| External Tenant IDs | The tenant GUIDs to scope to. Appears only when the scope is set to specific tenants. | + +{% hint style="warning" %} +Entra ID rejects an include block that is combined with `All`, `None` or `GuestsOrExternalUsers` under **Include Users**. The editor warns you as soon as the combination is set, but does not stop you saving the template, so the deployment is what fails. +{% endhint %} ## Cloud Apps or Actions @@ -39,6 +45,18 @@ New templates default to a state of **Report-only**, so a template deployed with | Include Applications | The applications the policy targets. | | Exclude Applications | Applications exempt from the policy. | | User Actions (instead of cloud apps) | Targets a user action, such as registering security information, in place of applications. | +| Authentication Context | Authentication context references the policy protects, used in place of cloud apps. | + +Authentication contexts are matched in the target tenant by display name when the template is deployed, and one that does not exist yet is created there, so a context can be referenced without existing in every tenant first. + +### Application Filter + +Targets applications by attribute rather than by name, which keeps the policy current as applications are added. + +| Field | Description | +| ----------------------- | ------------------------------------------------------------------------------------------------------- | +| Application Filter Mode | Whether applications matching the rule are included or excluded. | +| Application Filter Rule | The filter expression, for example `application.customSecurityAttributes.App.Sensitivity -eq "High"`. | ## Conditions @@ -76,6 +94,21 @@ These require **Entra ID P2**. Usage of these without proper licensing could ris | ------------------------------------ | ------------------------------------------------------------------------------------ | | Authentication Flow Transfer Methods | The authentication transfer methods the policy applies to, such as device code flow. | +### Workload Identities + +{% hint style="warning" %} +These require **Workload Identities Premium** in the target tenant. Usage of these without proper licensing could risk your client's tenant and your partner status. +{% endhint %} + +Scopes the policy to service principals instead of users, which is what turns it into a workload identity policy. Leave these empty for an ordinary user policy. Service principals are referenced by object ID, which is tenant-specific, so a template using them is not portable between tenants in the way a user or group reference is. + +| Field | Description | +| ----------------------------- | -------------------------------------------------------------------------------------------------- | +| Include Service Principals | The service principals the policy targets, either all of them or specific object IDs. | +| Exclude Service Principals | Service principals exempt from the policy, by object ID. | +| Service Principal Filter Mode | Whether service principals matching the rule are included or excluded. | +| Service Principal Filter Rule | The filter expression, for example `servicePrincipal.customSecurityAttributes.App.Tier -eq "1"`. | + ## Grant Controls | Field | Description | @@ -84,6 +117,9 @@ These require **Entra ID P2**. Usage of these without proper licensing could ris | Built-in Controls | The requirements to grant access, such as multifactor authentication or a compliant device. | | Authentication Strength Policy | An authentication strength policy to require in place of plain MFA. | | Terms of Use | Terms of use the user must accept. | +| Custom Controls | Legacy custom controls from an external identity provider, referenced by ID. | + +A grant operator is required as soon as any of these carry a value, custom controls included. ## Session Controls @@ -133,6 +169,4 @@ Use **Add Named Location** to add an entry, and the delete icon on any entry to A template deployed to a tenant is not linked to it afterwards. Editing the template later does not change policies already deployed from it, unless the template is applied through a standard, which redeploys on drift. {% endhint %} -*** - {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/tenant/conditional/list-template/edit.md b/docs/user-documentation/tenant/conditional/list-template/edit.md index ec707d400e..64a348886a 100644 --- a/docs/user-documentation/tenant/conditional/list-template/edit.md +++ b/docs/user-documentation/tenant/conditional/list-template/edit.md @@ -2,6 +2,4 @@ This page will allow you to review the template in an easy-to-understand layout making it easy to make any edits you desire. See [create-ca-template.md](create-ca-template.md "mention") for more on the fields and their descriptions. -*** - {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/tenant/gdap-management/README.md b/docs/user-documentation/tenant/gdap-management/README.md index 15fc30a18d..35ab904b12 100644 --- a/docs/user-documentation/tenant/gdap-management/README.md +++ b/docs/user-documentation/tenant/gdap-management/README.md @@ -36,6 +36,4 @@ Runs a set of diagnostic checks against your GDAP configuration and reports the The **GDAP Setup** and **GDAP Check** cards are only shown to users who hold the `CIPP.AppSettings.Read` permission. The statistics bar and the **Add a Tenant** button are visible to everyone who can reach the page. {% endhint %} -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/tenant/gdap-management/invites/README.md b/docs/user-documentation/tenant/gdap-management/invites/README.md index 6aeac618e6..c1aa864f2d 100644 --- a/docs/user-documentation/tenant/gdap-management/invites/README.md +++ b/docs/user-documentation/tenant/gdap-management/invites/README.md @@ -28,6 +28,4 @@ Use the [add.md](add.md "mention") button to generate more. GDAP relationships cannot be terminated once they have reached approval pending status, so deleting an invite here removes only CIPP's record of it. The pending relationship remains in Partner Center until the customer approves it or it lapses. {% endhint %} -*** - {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/tenant/gdap-management/invites/add.md b/docs/user-documentation/tenant/gdap-management/invites/add.md index 3aacb32db7..42c9d0b0e7 100644 --- a/docs/user-documentation/tenant/gdap-management/invites/add.md +++ b/docs/user-documentation/tenant/gdap-management/invites/add.md @@ -36,6 +36,4 @@ To easily add users to these groups, you can do the following If multiple invites are generated but not used, the unused ones can be found on the . page, where onboarding can be started again later. -*** - {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/tenant/gdap-management/offboarding.md b/docs/user-documentation/tenant/gdap-management/offboarding.md index 2e7a24263c..f8523e131d 100644 --- a/docs/user-documentation/tenant/gdap-management/offboarding.md +++ b/docs/user-documentation/tenant/gdap-management/offboarding.md @@ -40,6 +40,4 @@ The following actions will terminate all delegated access to the customer tenant Selecting **Terminate all active GDAP relationships** also removes the tenant from CIPP's tenant list as part of the same run. The other options leave the tenant in place, so if you need it gone without terminating relationships, use the **Delete Tenant** action on [tenants.md](../../cipp/settings/tenants.md "mention"). {% endhint %} -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/tenant/gdap-management/onboarding/README.md b/docs/user-documentation/tenant/gdap-management/onboarding/README.md index 7c441257b0..f67008f5f1 100644 --- a/docs/user-documentation/tenant/gdap-management/onboarding/README.md +++ b/docs/user-documentation/tenant/gdap-management/onboarding/README.md @@ -34,6 +34,4 @@ Each onboarding runs through the same five steps in order. Onboarding also runs on a nightly schedule, so an approved invite is picked up automatically even if you never start the process by hand. {% endhint %} -*** - {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/tenant/gdap-management/onboarding/start.md b/docs/user-documentation/tenant/gdap-management/onboarding/start.md index 238c5e94e9..e03758a0fb 100644 --- a/docs/user-documentation/tenant/gdap-management/onboarding/start.md +++ b/docs/user-documentation/tenant/gdap-management/onboarding/start.md @@ -59,6 +59,4 @@ The status panel polls every few seconds while a run is active, so it updates wi The Global Administrator role is a highly privileged role that should be used with caution. GDAP Relationships with this role will not be eligible for auto-extend. {% endhint %} -*** - {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/tenant/gdap-management/relationships/README.md b/docs/user-documentation/tenant/gdap-management/relationships/README.md index 679359fafb..a8cc9dada6 100644 --- a/docs/user-documentation/tenant/gdap-management/relationships/README.md +++ b/docs/user-documentation/tenant/gdap-management/relationships/README.md @@ -10,6 +10,4 @@ The properties returned are for the Graph resource type `delegatedAdminRelations
ActionDescriptionBulk Action Available
View RelationshipOpens the relationship for the selected relationship, showing its full details and role mappings.false
Start OnboardingOpens the gdap-invite-wizard.md page with the selected relationship already chosen.false
Open Relationship in Partner CenterOpens the relationship in Microsoft Partner Center in a new tab.false
Enable automatic extensionEnables automatic extension on the relationship, so it renews rather than expiring. Relationships that include the Global Administrator role are not eligible.true
Remove Global Administrator from RelationshipRemoves the Global Administrator role from the relationship. This is the only role change that can be made to an existing relationship.true
Reset Role MappingApplies a role template of your choosing to the relationship. Group assignments that are not part of the template are removed, existing assignments are updated to match, and any missing assignments are created. Use this to repair relationships with overlapping roles or incorrect group assignments.true
Terminate RelationshipEnds the relationship and removes all delegated access it granted to the customer tenant.true
More InfoOpens the Extended Info flyout with the full details for the selected row.false
-*** - {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/tenant/gdap-management/relationships/relationship/README.md b/docs/user-documentation/tenant/gdap-management/relationships/relationship/README.md index 8c6a9d3dae..c528d7b303 100644 --- a/docs/user-documentation/tenant/gdap-management/relationships/relationship/README.md +++ b/docs/user-documentation/tenant/gdap-management/relationships/relationship/README.md @@ -42,6 +42,4 @@ CIPP works out the relationship type from the relationship name. Lists the admin roles the customer has approved for this relationship. These are the roles the relationship is permitted to grant, which is not the same as the roles that have actually been assigned to your technicians. -*** - {% include "../../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/tenant/gdap-management/relationships/relationship/mappings.md b/docs/user-documentation/tenant/gdap-management/relationships/relationship/mappings.md index 37136f803a..ce7f50053a 100644 --- a/docs/user-documentation/tenant/gdap-management/relationships/relationship/mappings.md +++ b/docs/user-documentation/tenant/gdap-management/relationships/relationship/mappings.md @@ -16,6 +16,4 @@ This tab shows how the roles approved on a GDAP relationship are actually assign An empty table on an active relationship means the roles have been approved but never assigned to a group, so nobody holds the access yet. Use the **Reset Role Mapping** action on the relationships list to apply a role template and create the assignments. {% endhint %} -*** - {% include "../../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/tenant/gdap-management/role-templates/README.md b/docs/user-documentation/tenant/gdap-management/role-templates/README.md index ec78c2a45e..f0599eb5c0 100644 --- a/docs/user-documentation/tenant/gdap-management/role-templates/README.md +++ b/docs/user-documentation/tenant/gdap-management/role-templates/README.md @@ -17,6 +17,4 @@ Use the **Add Template** button to create a new template via [add.md](add.md "me
ActionDescriptionBulk Action Available
Edit TemplateOpens edit.md so you can rename it or change which role mappings it contains.false
Delete TemplateRemoves the template from CIPP. The underlying role mappings and security groups are not deleted, and existing relationships built from the template are not changed.true
-*** - {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/tenant/gdap-management/role-templates/add.md b/docs/user-documentation/tenant/gdap-management/role-templates/add.md index dd51be42a1..b8b7ec5cec 100644 --- a/docs/user-documentation/tenant/gdap-management/role-templates/add.md +++ b/docs/user-documentation/tenant/gdap-management/role-templates/add.md @@ -15,6 +15,4 @@ Role templates are used to create new GDAP invites for your customer tenants. Ma Only mappings that already exist on the [roles](../roles/ "mention") page can be selected. If a role you need is missing, create the mapping first and then return here. {% endhint %} -*** - {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/tenant/gdap-management/role-templates/edit.md b/docs/user-documentation/tenant/gdap-management/role-templates/edit.md index d83a1d8daf..7e19247dff 100644 --- a/docs/user-documentation/tenant/gdap-management/role-templates/edit.md +++ b/docs/user-documentation/tenant/gdap-management/role-templates/edit.md @@ -32,6 +32,4 @@ Editing a template does not change GDAP relationships that were created from it. Renaming the template named `CIPP Defaults` causes CIPP to prompt you to recreate it, since it checks for that exact name. {% endhint %} -*** - {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/tenant/gdap-management/roles/README.md b/docs/user-documentation/tenant/gdap-management/roles/README.md index 351f4389f7..720c8f6533 100644 --- a/docs/user-documentation/tenant/gdap-management/roles/README.md +++ b/docs/user-documentation/tenant/gdap-management/roles/README.md @@ -15,6 +15,4 @@ Use the **Map GDAP Roles** button to create new mappings via [add.md](add.md "me
ActionDescriptionBulk Action Available
Add to TemplateAdds the selected mapping to a role template. You can pick an existing template or type a new name to create one.true
Delete MappingRemoves the mapping from CIPP. The security group itself is not deleted and existing GDAP relationships are not changed.true
-*** - {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/tenant/gdap-management/roles/add.md b/docs/user-documentation/tenant/gdap-management/roles/add.md index 6755ae8971..5be3b24e87 100644 --- a/docs/user-documentation/tenant/gdap-management/roles/add.md +++ b/docs/user-documentation/tenant/gdap-management/roles/add.md @@ -40,6 +40,4 @@ Use extreme caution in this mode. The following limitations apply: * Certain roles may not be compatible with GDAP. See the [Microsoft documentation](https://learn.microsoft.com/en-us/partner-center/customers/gdap-least-privileged-roles-by-task) on GDAP role guidance. {% endhint %} -*** - {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/tenant/manage/applied-standards.md b/docs/user-documentation/tenant/manage/applied-standards.md index aea46d3bcb..fb0304b620 100644 --- a/docs/user-documentation/tenant/manage/applied-standards.md +++ b/docs/user-documentation/tenant/manage/applied-standards.md @@ -60,6 +60,4 @@ A **Run Standard Report** option regenerates the comparison data for the selecte * There is currently a limitation with Conditional Access standards due to the complexity of the comparison the standard settings and the Conditional Access response object. We hope to resolve this in a future update. -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/tenant/manage/backup.md b/docs/user-documentation/tenant/manage/backup.md index e2ef9d4e7a..962deaefdd 100644 --- a/docs/user-documentation/tenant/manage/backup.md +++ b/docs/user-documentation/tenant/manage/backup.md @@ -81,6 +81,4 @@ Restoring writes the selected components from a backup back into the tenant. The Overwriting replaces current settings with those in the backup rather than merging them. Where users are included in the restore, every property on the account is overwritten with the backed-up values. To protect a component, switch it off in Restore Settings or leave Overwrite existing entries off. {% endhint %} -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/tenant/manage/drift.md b/docs/user-documentation/tenant/manage/drift.md index 7e1dc66a63..f79bb0fde5 100644 --- a/docs/user-documentation/tenant/manage/drift.md +++ b/docs/user-documentation/tenant/manage/drift.md @@ -86,6 +86,4 @@ For deviations that come from an Intune template standard, a **Compare** button | Differences table | Lists each differing property with its Baseline and Tenant values, and whether the values differ or the setting exists on only one side. | | Full settings | The complete configuration of both the baseline and the tenant policy, for reviewing settings the comparison treated as matching. | -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/tenant/manage/edit.md b/docs/user-documentation/tenant/manage/edit.md index 9f7c74055b..c496f1d058 100644 --- a/docs/user-documentation/tenant/manage/edit.md +++ b/docs/user-documentation/tenant/manage/edit.md @@ -89,6 +89,4 @@ Use **Add Variable** to create one. The name picker offers variables already def If you want to see how to combine Custom Variables and Tenant Groups to provide a way to "graduate" tenants through standards, see [#using-custom-variables-to-manage-standards-templates](../../../demos/tutorials.md#using-custom-variables-to-manage-standards-templates "mention"). {% endhint %} -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/tenant/manage/history.md b/docs/user-documentation/tenant/manage/history.md index 7000669d80..63d7f4d7fc 100644 --- a/docs/user-documentation/tenant/manage/history.md +++ b/docs/user-documentation/tenant/manage/history.md @@ -43,6 +43,4 @@ The Load More button appears below the timeline only once there is at least one | ------------ | ---------------------------------------------------- | | Refresh Data | Re-reads the timeline for the range currently shown. | -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/tenant/manage/policies-deployed.md b/docs/user-documentation/tenant/manage/policies-deployed.md index d72d70eb75..abfecf7ed9 100644 --- a/docs/user-documentation/tenant/manage/policies-deployed.md +++ b/docs/user-documentation/tenant/manage/policies-deployed.md @@ -65,6 +65,4 @@ The Conditional Access templates included in the standards template, with the sa | Controls | Always Access Control in this section. | | Last Modified | The date the comparison data for this template was last refreshed, or N/A where none exists. | -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/tenant/manage/user-defaults.md b/docs/user-documentation/tenant/manage/user-defaults.md index 28705e36f1..54c5f4da87 100644 --- a/docs/user-documentation/tenant/manage/user-defaults.md +++ b/docs/user-documentation/tenant/manage/user-defaults.md @@ -68,6 +68,4 @@ Each permission level is granted by its own task, so a mailbox with both `Full A
ActionDescriptionBulk Action Available
Edit TemplateOpens the selected template to allow you to change the attributes that were previously set.false
Delete TemplateOpens a modal to confirm deletion of the selected template(s)true
More InfoOpens the Extended Info flyout with the full details for the selected row.false
-*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/tenant/reports/application-consent.md b/docs/user-documentation/tenant/reports/application-consent.md index 724f3d64c7..06171b26b4 100644 --- a/docs/user-documentation/tenant/reports/application-consent.md +++ b/docs/user-documentation/tenant/reports/application-consent.md @@ -16,6 +16,4 @@ Each row combines the details of a single consent with information about the app | Start Time | When the consent was granted. | | Cache Timestamp | When the cached record was last refreshed. Shown in cached mode only. | -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/tenant/reports/custom-test-report.md b/docs/user-documentation/tenant/reports/custom-test-report.md index aae044d9c1..47c324a08e 100644 --- a/docs/user-documentation/tenant/reports/custom-test-report.md +++ b/docs/user-documentation/tenant/reports/custom-test-report.md @@ -94,6 +94,4 @@ The output the test itself produced for this tenant. Custom tests can return eit The test's category and its description, rendered from the author's Markdown. Links here open in a new tab, so a well-written test can point directly at vendor documentation or an internal runbook. -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/tenant/reports/graph-office-reports.md b/docs/user-documentation/tenant/reports/graph-office-reports.md index b6d30b6188..3ffd14fd83 100644 --- a/docs/user-documentation/tenant/reports/graph-office-reports.md +++ b/docs/user-documentation/tenant/reports/graph-office-reports.md @@ -46,6 +46,4 @@ The fix is on the tenant, not in CIPP. Enabling the **Enable Usernames instead o This setting also affects reports elsewhere in CIPP, and in the Microsoft 365 admin center itself, so it is worth resolving for any tenant where you rely on usage reporting. {% endhint %} -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/tenant/reports/list-csp-licenses/README.md b/docs/user-documentation/tenant/reports/list-csp-licenses/README.md index 45e5746571..7743abd89d 100644 --- a/docs/user-documentation/tenant/reports/list-csp-licenses/README.md +++ b/docs/user-documentation/tenant/reports/list-csp-licenses/README.md @@ -33,6 +33,4 @@ This page requires the Sherweb integration to be enabled, and the tenant to be m These actions change what you are billed for and take effect against Sherweb immediately. There is no confirmation step beyond the prompt, and increases are purchases. Check the tenant's actual assignment counts on the list-licenses.md before reducing a subscription, since Sherweb does not know which licences are assigned in Microsoft 365. {% endhint %} -*** - {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/tenant/reports/list-csp-licenses/add-subscription.md b/docs/user-documentation/tenant/reports/list-csp-licenses/add-subscription.md index 0dff0c440d..d9df340adc 100644 --- a/docs/user-documentation/tenant/reports/list-csp-licenses/add-subscription.md +++ b/docs/user-documentation/tenant/reports/list-csp-licenses/add-subscription.md @@ -29,6 +29,4 @@ Once a SKU is chosen, a card appears summarising it. Check this before submittin Where the tenant has no Sherweb mapping, the SKU list returns empty rather than raising an error, so the selector will simply have nothing in it. If you see no SKUs at all, check that the Sherweb integration is enabled and that the tenant is mapped to a Sherweb customer in the integration settings, rather than assuming the catalogue is empty. {% endhint %} -*** - {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/tenant/reports/list-licenses.md b/docs/user-documentation/tenant/reports/list-licenses.md index 86d7915b06..60ff37f361 100644 --- a/docs/user-documentation/tenant/reports/list-licenses.md +++ b/docs/user-documentation/tenant/reports/list-licenses.md @@ -49,6 +49,4 @@ This report tries to make an estimate of the NCE term by calculating the dates. This page accepts filters passed in the URL, so links from dashboards and other reports can open it pre-filtered to a particular SKU or tenant. {% endhint %} -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/tenant/standards/README.md b/docs/user-documentation/tenant/standards/README.md index 1c15965f06..c7747c041e 100644 --- a/docs/user-documentation/tenant/standards/README.md +++ b/docs/user-documentation/tenant/standards/README.md @@ -118,6 +118,4 @@ Each standard is labelled with the level of change it introduces and its effect Plans exist to implement more standardised options and settings. If there is a standard you want, see the Feature Requests section below. {% endhint %} -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/tenant/standards/alignment/README.md b/docs/user-documentation/tenant/standards/alignment/README.md index 162a96c2de..b22ba6a533 100644 --- a/docs/user-documentation/tenant/standards/alignment/README.md +++ b/docs/user-documentation/tenant/standards/alignment/README.md @@ -102,6 +102,4 @@ Selecting a row opens the Standard Tenant Summary flyout, which lists the tenant * There is currently a limitation with Conditional Access classic standards due to the complexity of comparing the standard settings against the Conditional Access response object. We hope to resolve this in a future update. -*** - {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/tenant/standards/alignment/templates/README.md b/docs/user-documentation/tenant/standards/alignment/templates/README.md index 43d1e6d37d..3458671f52 100644 --- a/docs/user-documentation/tenant/standards/alignment/templates/README.md +++ b/docs/user-documentation/tenant/standards/alignment/templates/README.md @@ -43,6 +43,4 @@ If you are upgrading from a version of CIPP that predates templates, a **Convert
ActionDescriptionBulk Action Available
View Tenant ReportOpens the applied standards report, comparing the template against the settings discovered for the selected tenant.false
Edit TemplateOpens the template configuration page.false
Clone & Edit TemplateCopies the template and opens the copy for editing, so you can adjust it before saving it as a new template.false
Create Drift CloneCreates a new Drift template based on this template, so an existing Standards template can be reused for monitoring.true
Run Template NowForces a run outside the schedule. You are asked which tenants to run against, and only tenants and groups assigned to the template are offered.true
Set ScheduleSwitches the template between running on the schedule and running manually only. Not available on Drift templates, which always run on the schedule.true
Save to GitHubCommits the template to a repository, with a commit message of your choosing. Only shown when the GitHub integration is enabled.true
Delete TemplateDeletes the template.true
-*** - {% include "../../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/tenant/standards/alignment/templates/available-standards.md b/docs/user-documentation/tenant/standards/alignment/templates/available-standards.md index 4e1d7d3d20..cf6d92ee5c 100644 --- a/docs/user-documentation/tenant/standards/alignment/templates/available-standards.md +++ b/docs/user-documentation/tenant/standards/alignment/templates/available-standards.md @@ -4,6 +4,4 @@ Explore CIPP's standards in an easy to digest format using [https://standards.ci {% @cipp-external-webpage-block/cyberdrain url="https://standards.cipp.app/" fullWidth="true" %} -*** - {% include "../../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/tenant/standards/bpa-report/README.md b/docs/user-documentation/tenant/standards/bpa-report/README.md index aa9a68f5c3..bd4cebff0c 100644 --- a/docs/user-documentation/tenant/standards/bpa-report/README.md +++ b/docs/user-documentation/tenant/standards/bpa-report/README.md @@ -78,6 +78,4 @@ The BPA uses a traffic-light system for quick, visual feedback: **Permissions:** Validate required permissions with `Run Permission Check` and troubleshoot invalid refresh tokens with `Tenant Access Check`. {% endhint %} -*** - {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/tenant/standards/bpa-report/best-practice-templates.md b/docs/user-documentation/tenant/standards/bpa-report/best-practice-templates.md index 75a2b841eb..95a6927838 100644 --- a/docs/user-documentation/tenant/standards/bpa-report/best-practice-templates.md +++ b/docs/user-documentation/tenant/standards/bpa-report/best-practice-templates.md @@ -143,6 +143,4 @@ _Unused licences trigger an orange warning status. You can click the badge in th * **Purpose:** Extracts metrics like licence usage and Secure Scores using tailored commands (e.g., `Get-CIPPLicenseOverview`). * **Benefit:** Provides actionable insights for tenant optimisation. -*** - {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/tenant/standards/bpa-report/builder/README.md b/docs/user-documentation/tenant/standards/bpa-report/builder/README.md index 7a50c342e6..74c408807f 100644 --- a/docs/user-documentation/tenant/standards/bpa-report/builder/README.md +++ b/docs/user-documentation/tenant/standards/bpa-report/builder/README.md @@ -78,6 +78,4 @@ Regardless of the type of report layout you choose, the fields will have the fol 2. JSON: used for storing more complex objects 3. Boolean: used for true/false reporting -*** - {% include "../../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/tenant/standards/bpa-report/builder/custom-report-options.md b/docs/user-documentation/tenant/standards/bpa-report/builder/custom-report-options.md index efd902dd0b..b7569693fa 100644 --- a/docs/user-documentation/tenant/standards/bpa-report/builder/custom-report-options.md +++ b/docs/user-documentation/tenant/standards/bpa-report/builder/custom-report-options.md @@ -179,6 +179,4 @@ Here’s a complete example combining all components: This structure ensures clear, actionable insights while offering flexibility for customisation. Let me know if you'd like further explanation or a specific example tailored to your needs! -*** - {% include "../../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/tenant/standards/domains-analyser/README.md b/docs/user-documentation/tenant/standards/domains-analyser/README.md index eac54a9406..5daedaa4d2 100644 --- a/docs/user-documentation/tenant/standards/domains-analyser/README.md +++ b/docs/user-documentation/tenant/standards/domains-analyser/README.md @@ -120,6 +120,4 @@ The settings icon reveals additional options for the check. You can supply a spe * Check your permissions under **CIPP > Application Settings > Permissions** and review the Permissions Check results. * Make sure both CIPP-API and CIPP are fully up to date. There is extensive logging in the CIPP-API Function App. -*** - {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/tenant/standards/domains-analyser/domain-analyser-updates-and-data-refreshing.md b/docs/user-documentation/tenant/standards/domains-analyser/domain-analyser-updates-and-data-refreshing.md index 1b5e5b9471..10c078e52b 100644 --- a/docs/user-documentation/tenant/standards/domains-analyser/domain-analyser-updates-and-data-refreshing.md +++ b/docs/user-documentation/tenant/standards/domains-analyser/domain-analyser-updates-and-data-refreshing.md @@ -80,6 +80,4 @@ For most tenants this is rarely needed. The daily job keeps results current on i | A tenant is never analysed | Check the tenant for repeated Graph failures. Tenants with a high error count are skipped until access is fixed. | | Results not updating at all | Confirm CIPP and CIPP-API are up to date, and review the Domain Analyser entries in the CIPP logs. | -*** - {% include "../../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/tenant/standards/template.md b/docs/user-documentation/tenant/standards/template.md index b3ef97449a..5603d226c4 100644 --- a/docs/user-documentation/tenant/standards/template.md +++ b/docs/user-documentation/tenant/standards/template.md @@ -174,6 +174,4 @@ A **Clear** button appears once any filter is active. To see how Custom Variables and Tenant Groups can be combined to graduate tenants through standards, see [#using-custom-variables-to-manage-standards-templates](../../../demos/tutorials.md#using-custom-variables-to-manage-standards-templates "mention"). {% endhint %} -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/tools/community-repos/README.md b/docs/user-documentation/tools/community-repos/README.md index 5fff5f07bc..b8b87d3982 100644 --- a/docs/user-documentation/tools/community-repos/README.md +++ b/docs/user-documentation/tools/community-repos/README.md @@ -75,6 +75,4 @@ The menu on each card carries the actions for that source. Removing a source only stops CIPP reading from it. Templates you have already imported are unaffected, and nothing is deleted on GitHub, including private repositories you own. {% endhint %} -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/tools/community-repos/browse-all-templates.md b/docs/user-documentation/tools/community-repos/browse-all-templates.md index 925db25ee5..12ee760408 100644 --- a/docs/user-documentation/tools/community-repos/browse-all-templates.md +++ b/docs/user-documentation/tools/community-repos/browse-all-templates.md @@ -53,6 +53,4 @@ To import in bulk, tick the templates you want, or use the select-all checkbox t Where CIPP could not read one of your sources, a warning is shown above the results. The catalogue still lists everything it could reach, so a shorter than expected list is worth checking against those warnings before concluding a template is missing. {% endhint %} -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/tools/custom-tests/README.md b/docs/user-documentation/tools/custom-tests/README.md index 832cf45720..1c76a8cf9c 100644 --- a/docs/user-documentation/tools/custom-tests/README.md +++ b/docs/user-documentation/tools/custom-tests/README.md @@ -55,6 +55,4 @@ Deleting a test removes all of its versions and cannot be undone. Where you only Only repositories you have write access to are offered when saving to GitHub. Where the list is empty, the repositories registered with your instance are all read-only, which includes the built-in CyberDrain ones. {% endhint %} -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/tools/custom-tests/add.md b/docs/user-documentation/tools/custom-tests/add.md index a63a9da478..04a71d060d 100644 --- a/docs/user-documentation/tools/custom-tests/add.md +++ b/docs/user-documentation/tools/custom-tests/add.md @@ -69,6 +69,4 @@ Runs the test against the tenant currently chosen in tenant-select.md and render Runs from this page are preview only. Results are stored only when a scheduled tenant test run executes the test with **Enable Script** turned on. {% endhint %} -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/tools/custom-tests/versions.md b/docs/user-documentation/tools/custom-tests/versions.md index c8f3715a75..074358dba1 100644 --- a/docs/user-documentation/tools/custom-tests/versions.md +++ b/docs/user-documentation/tools/custom-tests/versions.md @@ -35,6 +35,4 @@ The **Compare Test Versions** dialog shows what changed between the version you **Markdown Result Template Diff** appears below it whenever either version carries a markdown template, and compares those in the same way. A test using the default output has no template and no second comparison. -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/tools/dark-web-tools/breach-lookup.md b/docs/user-documentation/tools/dark-web-tools/breach-lookup.md index 874d7f27c9..fa44572d4d 100644 --- a/docs/user-documentation/tools/dark-web-tools/breach-lookup.md +++ b/docs/user-documentation/tools/dark-web-tools/breach-lookup.md @@ -38,6 +38,4 @@ Where the lookup could not be completed, an **Error** card reports that the conn Password data is generally only returned for domain lookups. A breach record for an individual account normally carries the breach's details rather than the credentials themselves, so **Partial Password Available** will often read No even where the account is genuinely affected. {% endhint %} -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/tools/dark-web-tools/tenant-breach-lookup.md b/docs/user-documentation/tools/dark-web-tools/tenant-breach-lookup.md index 35be4c5d40..a7141807b9 100644 --- a/docs/user-documentation/tools/dark-web-tools/tenant-breach-lookup.md +++ b/docs/user-documentation/tools/dark-web-tools/tenant-breach-lookup.md @@ -40,6 +40,4 @@ An account exposed in several breaches appears once per breached credential, so
ActionDescriptionBulk Action Available
View UserOpens the breach-lookup.md page with the selected address already filled in, showing everything known about that individual account.false
-*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/tools/email-tools/mailbox-restores.md b/docs/user-documentation/tools/email-tools/mailbox-restores.md index 7a42ce41bc..e3f6522439 100644 --- a/docs/user-documentation/tools/email-tools/mailbox-restores.md +++ b/docs/user-documentation/tools/email-tools/mailbox-restores.md @@ -68,6 +68,4 @@ The properties returned are for the Exchange Online PowerShell command `Get-Mail **View Report** opens the detailed request report, a verbose log of everything Exchange Online did while processing the request. This is the most useful artefact to attach to a support case for a failed restore. -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/tools/email-tools/message-trace.md b/docs/user-documentation/tools/email-tools/message-trace.md index 9c5e4e60c8..12bcd4d401 100644 --- a/docs/user-documentation/tools/email-tools/message-trace.md +++ b/docs/user-documentation/tools/email-tools/message-trace.md @@ -54,6 +54,4 @@ The details dialog traces one message to one recipient, so a message delivered t | Action | What Exchange Online did to the message at that stage. | | Detail | The supporting detail for the event, including the reason for any failure. | -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/tools/email-tools/message-viewer.md b/docs/user-documentation/tools/email-tools/message-viewer.md index d10cb73253..6724504f11 100644 --- a/docs/user-documentation/tools/email-tools/message-viewer.md +++ b/docs/user-documentation/tools/email-tools/message-viewer.md @@ -66,6 +66,4 @@ The buttons alongside the message subject open the raw data behind the rendered | Anti-Spam Report | Decodes the `X-Forefront-Antispam-Report` header into readable fields, covering the spam confidence level, the filtering verdict, the connecting IP address and its country, the reverse DNS of the sender, the bulk mail rating, and the protection policy category that applied. Values that map to a known meaning are expanded into a description rather than left as their raw code. Shown only on messages that passed through Exchange Online Protection, since Microsoft adds the header. | | View Source | Opens a dialog containing the complete raw message exactly as it was loaded, headers and body together. | -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/tools/intune-tools/compare-policies.md b/docs/user-documentation/tools/intune-tools/compare-policies.md index c1697c48d8..7a0a27f94c 100644 --- a/docs/user-documentation/tools/intune-tools/compare-policies.md +++ b/docs/user-documentation/tools/intune-tools/compare-policies.md @@ -88,6 +88,4 @@ This handling applies only when both sides are settings catalog policies. Compar Beneath the differences, the complete configuration of each policy is shown in its own panel, headed **Source A Settings** and **Source B Settings** with the policy's name. These show everything each policy contains, not only the settings that differ, which is useful for reviewing a policy in full or confirming a setting that the comparison treated as matching. -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/tools/report-builder/README.md b/docs/user-documentation/tools/report-builder/README.md index ff86e0a544..fc32fd438e 100644 --- a/docs/user-documentation/tools/report-builder/README.md +++ b/docs/user-documentation/tools/report-builder/README.md @@ -14,6 +14,4 @@ This is CIPP's custom report builder introduced with the deprecation of the Best {% @storylane/embed subdomain="app" linkValue="vcrohqu0snfg" url="https://app.storylane.io/share/vcrohqu0snfg" %} -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/tools/report-builder/builder.md b/docs/user-documentation/tools/report-builder/builder.md index f65493cdc1..03d9efa2af 100644 --- a/docs/user-documentation/tools/report-builder/builder.md +++ b/docs/user-documentation/tools/report-builder/builder.md @@ -118,6 +118,4 @@ The chip beside the title switches the display between **Table (Text)**, **CSV** Chart, Score Cards and Progress Bars blocks are edited as small tables of values. Add a row for each data point, giving it a label and a value, with an optional colour on chart data points. Charts also take a caption, and a donut chart takes a centre label and an optional maximum. -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/tools/report-builder/generated.md b/docs/user-documentation/tools/report-builder/generated.md index d5259de9e7..97c52856f1 100644 --- a/docs/user-documentation/tools/report-builder/generated.md +++ b/docs/user-documentation/tools/report-builder/generated.md @@ -32,6 +32,4 @@ The report opens rendered in full, using the page setup and branding that were i **Download PDF** saves the report locally. The filename is built from the tenant and the current date. -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/tools/report-builder/templates.md b/docs/user-documentation/tools/report-builder/templates.md index e46308a19b..eada2f9ca5 100644 --- a/docs/user-documentation/tools/report-builder/templates.md +++ b/docs/user-documentation/tools/report-builder/templates.md @@ -41,6 +41,4 @@ Sections will usually be larger than Test Count and Custom Count added together. Only repositories you have write access to are offered when uploading. Where the list is empty, the repositories registered with your instance are all read-only, which includes the built-in CyberDrain ones. {% endhint %} -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/tools/scheduler/README.md b/docs/user-documentation/tools/scheduler/README.md index 9b62ce9e9c..73c0d50a70 100644 --- a/docs/user-documentation/tools/scheduler/README.md +++ b/docs/user-documentation/tools/scheduler/README.md @@ -131,6 +131,4 @@ The same set of actions available on the table row: Run Now, Edit Job, Clone Job | Task Parameters | The parameters passed to the task's command. | | Execution Results | The history of the task's executions, with a count of how many entries are shown against the total and a search box for narrowing a long history. | -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/tools/scheduler/task.md b/docs/user-documentation/tools/scheduler/task.md index dd5b097d2c..633a54848e 100644 --- a/docs/user-documentation/tools/scheduler/task.md +++ b/docs/user-documentation/tools/scheduler/task.md @@ -61,6 +61,4 @@ How the result is displayed depends on what the command returned: A search box filters the history by tenant or by anything within the results themselves, and the heading shows how many entries match against the total. Where nothing matches, the section says so rather than appearing empty. -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/tools/templatelib.md b/docs/user-documentation/tools/templatelib.md index d65231695f..d857793307 100644 --- a/docs/user-documentation/tools/templatelib.md +++ b/docs/user-documentation/tools/templatelib.md @@ -88,6 +88,4 @@ Saving creates a scheduled task named after the tenant or repository, which perf Templates are compared before being written. Where a template already exists, carries the same source, and its content is unchanged, CIPP skips it rather than rewriting it, so a sync that reports no changes is working correctly. {% endhint %} -*** - {% include "../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/tools/tenant-tools/appapproval.md b/docs/user-documentation/tools/tenant-tools/appapproval.md index f3920a4578..7cd25ddc96 100644 --- a/docs/user-documentation/tools/tenant-tools/appapproval.md +++ b/docs/user-documentation/tools/tenant-tools/appapproval.md @@ -71,6 +71,4 @@ Progress and any per-tenant failures are recorded in the logbook, which is where [logs](../../cipp/logs/) {% endcontent-ref %} -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/tools/tenant-tools/geoiplookup.md b/docs/user-documentation/tools/tenant-tools/geoiplookup.md index 3ddd6dd036..f85db9be8b 100644 --- a/docs/user-documentation/tools/tenant-tools/geoiplookup.md +++ b/docs/user-documentation/tools/tenant-tools/geoiplookup.md @@ -49,6 +49,4 @@ The table lists every address recorded across all of your tenants, not just the The whitelist actions write against the tenant in the tenant selector, not the tenant shown in the entry's **Partition Key**. Acting on another tenant's row while a different tenant is selected creates a second entry rather than changing the existing one. {% endhint %} -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/tools/tenant-tools/graph-explorer.md b/docs/user-documentation/tools/tenant-tools/graph-explorer.md index d76bfaaf7c..f8535f0ca7 100644 --- a/docs/user-documentation/tools/tenant-tools/graph-explorer.md +++ b/docs/user-documentation/tools/tenant-tools/graph-explorer.md @@ -84,6 +84,4 @@ Results render as a table with all the standard [table-features.md](../../shared A warning is displayed in place of the results if no tenant is selected. Choose a tenant and run the query again. {% endhint %} -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/tools/tenant-tools/individual-domains.md b/docs/user-documentation/tools/tenant-tools/individual-domains.md index c960643f26..b5ab1d7b06 100644 --- a/docs/user-documentation/tools/tenant-tools/individual-domains.md +++ b/docs/user-documentation/tools/tenant-tools/individual-domains.md @@ -55,6 +55,4 @@ Two actions sit at the bottom of each card: A domain that fails validation here is not necessarily misconfigured for its own purposes. Read the individual findings rather than treating the header icon as a verdict, particularly on domains that do not send mail. {% endhint %} -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/docs/user-documentation/tools/tenant-tools/tenantlookup.md b/docs/user-documentation/tools/tenant-tools/tenantlookup.md index 8f0f4749f4..7e3f7cd5fd 100644 --- a/docs/user-documentation/tools/tenant-tools/tenantlookup.md +++ b/docs/user-documentation/tools/tenant-tools/tenantlookup.md @@ -34,6 +34,4 @@ Where the tenant has customised its sign-in experience, that branding is reprodu Branding is retrieved from the tenant's own sign-in service and is a helpful signal, not proof of identity. Treat a familiar-looking logo as a starting point for verification rather than confirmation that a domain belongs to who you expect. {% endhint %} -*** - {% include "../../../../.gitbook/includes/feature-request.md" %} diff --git a/frontend/package.json b/frontend/package.json index f5e3782ce8..df7833c3db 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "cipp", - "version": "10.8.3", + "version": "10.8.4", "author": "CIPP Contributors", "homepage": "https://cipp.app/", "bugs": { diff --git a/frontend/public/version.json b/frontend/public/version.json index 682cb83714..bfb273510e 100644 --- a/frontend/public/version.json +++ b/frontend/public/version.json @@ -1,3 +1,3 @@ { - "version": "10.8.3" + "version": "10.8.4" } \ No newline at end of file diff --git a/frontend/src/components/BECRemediationReportButton.js b/frontend/src/components/BECRemediationReportButton.js index bd0255fdb1..dd67d546cb 100644 --- a/frontend/src/components/BECRemediationReportButton.js +++ b/frontend/src/components/BECRemediationReportButton.js @@ -77,18 +77,46 @@ export const BECRemediationReportDocument = ({ newUsers: becData?.NewUsers?.length || 0, newApps: becData?.AddedApps?.length || 0, permissionChanges: becData?.MailboxPermissionChanges?.length || 0, + permissionChangesTargetingUser: (becData?.MailboxPermissionChanges || []).filter( + (change) => change?.TargetsSuspect === true + ).length, mfaDevices: becData?.MFADevices?.length || 0, passwordChanges: becData?.ChangedPasswords?.length || 0, + sentMessages: becData?.SentMessages?.length || 0, trustedSenders: becData?.TrustedSenders?.length || 0, blockedSenders: becData?.BlockedSenders?.length || 0, safelistChanges: becData?.SafelistChanges?.length || 0, + sharingChanges: becData?.SharingChanges?.length || 0, + anonymousLinks: (becData?.SharingChanges || []).filter((c) => + c?.Operation?.startsWith('AnonymousLink') + ).length, intuneDevices: becData?.IntuneDevices?.length || 0, + signIns: becData?.SuspectUserSignIns?.length || 0, + sentTotalMessages: becData?.SentMessageAnalysis?.TotalMessages ?? 0, + sentTotalRecipients: becData?.SentMessageAnalysis?.TotalRecipients ?? 0, + repeatedSubjects: becData?.SentMessageAnalysis?.FlaggedSubjectCount || 0, + sendBursts: becData?.SentMessageAnalysis?.Bursts?.length || 0, + massMailFlagged: becData?.SentMessageAnalysis?.Flagged === true, + maliciousApps: + (becData?.AddedApps || []).filter((app) => app?.MaliciousMatch).length + + (becData?.MaliciousSPs?.length || 0), } - const intuneWindowStart = (() => { + const locationAnalysis = becData?.LocationAnalysis + stats.foreignSignIns = locationAnalysis?.ForeignSignInCount || 0 + stats.foreignSuccessfulSignIns = locationAnalysis?.ForeignSuccessfulSignInCount || 0 + stats.foreignSentMessages = locationAnalysis?.ForeignSentMessageCount || 0 + stats.foreignActivity = + (locationAnalysis?.ForeignRuleChangeCount || 0) + + (locationAnalysis?.ForeignSafelistChangeCount || 0) + + (locationAnalysis?.ForeignSharingChangeCount || 0) + + (locationAnalysis?.ForeignSentMessageCount || 0) + + // the analysis window: 7 days before the data was extracted + const analysisWindowStart = (() => { const extractedAt = becData?.ExtractedAt ? new Date(becData.ExtractedAt) : new Date() if (Number.isNaN(extractedAt.getTime())) { - return new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) + return new Date(new Date().getTime() - 7 * 24 * 60 * 60 * 1000) } return new Date(extractedAt.getTime() - 7 * 24 * 60 * 60 * 1000) })() @@ -97,10 +125,23 @@ export const BECRemediationReportDocument = ({ if (!device?.enrolledDateTime) return false const enrolled = new Date(device.enrolledDateTime) if (Number.isNaN(enrolled.getTime())) return false - return enrolled >= intuneWindowStart + return enrolled >= analysisWindowStart }) stats.recentIntuneDevices = recentIntuneDevices.length + const isRecentMfaDevice = (method) => { + if (!method?.createdDateTime) return false + const created = new Date(method.createdDateTime) + if (Number.isNaN(created.getTime())) return false + return created >= analysisWindowStart + } + stats.recentMfaDevices = (becData?.MFADevices || []).filter(isRecentMfaDevice).length + + // successful foreign sign-ins first - they prove access, failed ones are mostly spray noise + const foreignSignIns = (becData?.SuspectUserSignIns || []) + .filter((signIn) => signIn?.ForeignLocation === true) + .sort((a, b) => (b?.Status === 'Success') - (a?.Status === 'Success')) + const sortedIntuneDevices = [...(becData?.IntuneDevices || [])].sort((a, b) => { const aTime = a?.enrolledDateTime ? new Date(a.enrolledDateTime).getTime() : 0 const bTime = b?.enrolledDateTime ? new Date(b.enrolledDateTime).getTime() : 0 @@ -112,8 +153,12 @@ export const BECRemediationReportDocument = ({ let threatScore = 0 if (stats.newRules > 0) threatScore += 3 if (stats.ruleChanges > 0) threatScore += 3 - if (stats.permissionChanges > 0) threatScore += 2 - if (stats.newApps > 0) threatScore += 2 + // A change to this mailbox's permissions outweighs unrelated tenant churn, which the + // tenant-wide search also surfaces + if (stats.permissionChangesTargetingUser > 0) threatScore += 2 + else if (stats.permissionChanges > 0) threatScore += 1 + // Generic new service principals appear constantly; the actually-bad ones score +5 below + if (stats.newApps > 0) threatScore += 1 if (stats.newUsers > 5) threatScore += 1 if (stats.safelistChanges > 0) threatScore += 2 @@ -121,6 +166,20 @@ export const BECRemediationReportDocument = ({ const hasSuspiciousRules = becData?.NewRules?.some((rule) => rule.MoveToFolder?.includes('RSS')) if (hasSuspiciousRules) threatScore += 5 + // A catalog-matched application is a confirmed bad indicator, not a heuristic + if (stats.maliciousApps > 0) threatScore += 5 + // Only a successful foreign sign-in proves access - failed foreign attempts are + // password-spray background noise present on almost every tenant + if (stats.foreignSuccessfulSignIns > 0) threatScore += 3 + if (stats.foreignActivity > 0) threatScore += 3 + // An anonymous link exposes data to anyone holding the URL, past any later reset + if (stats.anonymousLinks > 0) threatScore += 3 + // Repeated-subject campaigns and send bursts are how a compromised mailbox spreads + if (stats.massMailFlagged) threatScore += 3 + // Persistence moves during the window: a fresh MFA method or device enrollment + if (stats.recentMfaDevices > 0) threatScore += 2 + if (stats.recentIntuneDevices > 0) threatScore += 2 + if (threatScore >= 7) return { level: 'High', color: '#742A2A' } if (threatScore >= 4) return { level: 'Medium', color: '#744210' } return { level: 'Low', color: '#22543D' } @@ -164,7 +223,7 @@ export const BECRemediationReportDocument = ({ {userData?.userPrincipalName} within{' '} {tenantName}. The investigation analyzed suspicious activity indicators including mailbox rules, permission changes, new - applications, and authentication patterns over a 7-day period. + applications, authentication patterns, and sign-in locations over a 7-day period. @@ -182,8 +241,8 @@ export const BECRemediationReportDocument = ({ stats={[ { value: stats.newRules, label: 'Mailbox Rules' }, { value: stats.permissionChanges, label: 'Permission Changes' }, - { value: stats.newApps, label: 'New Applications' }, - { value: stats.newUsers, label: 'New Users' }, + { value: stats.foreignSignIns, label: 'Foreign Sign-ins' }, + { value: stats.maliciousApps, label: 'Malicious Apps' }, ]} /> @@ -202,6 +261,10 @@ export const BECRemediationReportDocument = ({ Last 7 days ending {becData?.ExtractedAt ? formatDate(becData.ExtractedAt) : 'N/A'} + + {locationAnalysis?.UsageLocation || + 'Not assigned - sign-ins and activity could not be compared against an expected country'} + @@ -279,7 +342,7 @@ export const BECRemediationReportDocument = ({ {stats.newRules > 0 && ( <> - + The following mailbox rules were detected. Review each rule carefully to determine if it was created by the user or by an attacker. Rules that forward emails or move them to unusual folders are particularly suspicious. @@ -304,7 +367,7 @@ export const BECRemediationReportDocument = ({ )} {stats.ruleChanges > 0 && ( <> - + The audit log recorded inbox rules being created, changed or removed on this mailbox. Rules that were removed after use are a common way for attackers to cover their tracks. @@ -315,6 +378,10 @@ export const BECRemediationReportDocument = ({ Date: {change.Date || 'Unknown'} {'\n'} By: {change.UserKey || 'Unknown'} + {change.ClientIP && + `\nFrom: ${change.ClientIP}${change.Country ? ` (${change.Country})` : ''}`} + {change.ForeignLocation === true && + '\n⚠️ Originated outside the assigned usage location'} {change.Parameters && `\nParameters: ${change.Parameters}`} ))} @@ -327,7 +394,7 @@ export const BECRemediationReportDocument = ({ )} {stats.newRules === 0 && stats.ruleChanges === 0 && ( - + No mailbox rules were detected that match suspicious patterns. This is a positive indicator. @@ -347,7 +414,7 @@ export const BECRemediationReportDocument = ({ {stats.newUsers > 0 ? ( <> - + The following users were created in the last 7 days. Verify that each account creation was authorized and legitimate. @@ -366,7 +433,7 @@ export const BECRemediationReportDocument = ({ )} ) : ( - + No new user accounts were created during the analysis period. )} @@ -380,9 +447,17 @@ export const BECRemediationReportDocument = ({ files without the user's explicit knowledge. + {stats.maliciousApps > 0 && ( + + One or more applications in this tenant match the CIPP known-malicious application + catalog. Consent-based access survives a password reset, so these applications + should be removed unless their presence is explained. + + )} + {stats.newApps > 0 ? ( <> - + New applications were granted access during the analysis period. Review each application to ensure it was authorized and is from a trusted publisher. @@ -394,6 +469,12 @@ export const BECRemediationReportDocument = ({ App ID: {app.appId || 'N/A'} {'\n'} Created: {formatDate(app.createdDateTime)} + {app.MaliciousMatch && + `\n⚠️ Matches known-malicious catalog entry "${app.MaliciousMatch.Name}"${ + app.MaliciousMatch.Categories?.length + ? ` (${app.MaliciousMatch.Categories.join(', ')})` + : '' + }`} ))} {becData.AddedApps.length > 6 && ( @@ -403,15 +484,41 @@ export const BECRemediationReportDocument = ({ )} ) : ( - - No new applications were authorized during the analysis period. - + (becData?.MaliciousSPs?.length || 0) === 0 && ( + + No new applications were authorized during the analysis period, and no known + malicious applications are present in the tenant. + + ) + )} + + {(becData?.MaliciousSPs?.length || 0) > 0 && ( + <> + {becData.MaliciousSPs.slice(0, 6).map((app, index) => ( + + Catalog entry: {app.CatalogName || 'Unknown'} + {'\n'} + App ID: {app.appId || 'N/A'} + {'\n'} + Categories: {app.Categories?.length ? app.Categories.join(', ') : 'N/A'} + {'\n'} + Enabled: {String(app.accountEnabled ?? 'Unknown')} + {'\n'} + First seen: {formatDate(app.createdDateTime)} + + ))} + {becData.MaliciousSPs.length > 6 && ( + + ... and {becData.MaliciousSPs.length - 6} more (see JSON export for full list) + + )} + )} - {/* CHECK 4, 5, 6: PERMISSIONS, MFA, PASSWORDS */} - + {/* CHECK 4, 5, 6, 7: PERMISSIONS, SENT MAIL, MFA, PASSWORDS */} + {/* Check 4: Mailbox Permission Changes */}
@@ -423,7 +530,7 @@ export const BECRemediationReportDocument = ({ {stats.permissionChanges > 0 ? ( <> - + Mailbox permission changes were detected. Verify that each change was authorized and necessary for legitimate business purposes. @@ -435,6 +542,8 @@ export const BECRemediationReportDocument = ({ Target: {change.ObjectId || 'N/A'} {'\n'} Permissions: {change.Permissions || 'Unknown'} + {change.TargetsSuspect === true && + '\n⚠️ Targets the investigated mailbox'} ))} {becData.MailboxPermissionChanges.length > 5 && ( @@ -444,14 +553,107 @@ export const BECRemediationReportDocument = ({ )} ) : ( - + No mailbox permission changes were detected during the analysis period. )}
- {/* Check 5: MFA Devices */} -
+ {/* Check 5: Sent Messages */} +
+ + Attackers use a compromised mailbox to send fraudulent invoices, phishing, or + internal impersonation mail. The message trace shows what actually left the mailbox + during the analysis period, including the IP address it was sent from. + + + {stats.sentMessages > 0 ? ( + <> + + ℹ️ {stats.sentTotalMessages || stats.sentMessages} message(s) to{' '} + {stats.sentTotalRecipients || stats.sentMessages} recipient(s) were sent by this + mailbox during the analysis period + {stats.foreignSentMessages > 0 + ? `, including ${stats.foreignSentMessages} from an IP outside the user's assigned usage location.` + : '.'} + + + {stats.massMailFlagged && ( + + {stats.repeatedSubjects > 0 + ? `${stats.repeatedSubjects} subject(s) were sent as many separate messages or to many recipients. ` + : ''} + {stats.sendBursts > 0 + ? `${stats.sendBursts} short burst(s) of high-volume sending were detected. ` + : ''} + Identical-subject mass mail and send bursts are how a compromised mailbox + spreads phishing or fraudulent invoices. Review the campaigns below and warn + the recipients if the content was malicious. + + )} + + {(becData?.SentMessageAnalysis?.RepeatedSubjects || []) + .slice(0, 5) + .map((group, index) => ( + + Messages: {group.MessageCount} + {'\n'} + Recipients: {group.RecipientCount} + {'\n'} + First sent: {group.FirstSent || 'N/A'} + {'\n'} + Last sent: {group.LastSent || 'N/A'} + + ))} + {(becData?.SentMessageAnalysis?.RepeatedSubjects?.length || 0) > 5 && ( + + ... and {becData.SentMessageAnalysis.RepeatedSubjects.length - 5} more repeated + subjects (see JSON export for full list) + + )} + + {(becData?.SentMessageAnalysis?.Bursts || []).slice(0, 5).map((burst, index) => ( + + Starting: {burst.WindowStart || 'N/A'} + {burst.TopSubject && `\nMost common subject: ${burst.TopSubject}`} + + ))} + {(becData?.SentMessageAnalysis?.Bursts?.length || 0) > 5 && ( + + ... and {becData.SentMessageAnalysis.Bursts.length - 5} more bursts (see JSON + export for full list) + + )} + + {becData.SentMessages.slice(0, 10).map((msg, index) => ( + + To: {msg.RecipientAddress || 'N/A'} + {'\n'} + Status: {msg.Status || 'N/A'} + {'\n'} + Received: {msg.Received || 'N/A'} + {msg.FromIP && + `\nFrom IP: ${msg.FromIP}${msg.Country ? ` (${msg.Country})` : ''}`} + {msg.ForeignLocation === true && + '\n⚠️ Sent from outside the assigned usage location'} + + ))} + {becData.SentMessages.length > 10 && ( + + ... and {becData.SentMessages.length - 10} more messages (see JSON export for + full list) + + )} + + ) : ( + + No messages were sent by this mailbox during the analysis period. + + )} +
+ + {/* Check 6: MFA Devices */} +
Multi-factor authentication (MFA) devices provide an additional layer of security. Reviewing registered MFA methods helps identify if attackers have added unauthorized @@ -461,28 +663,42 @@ export const BECRemediationReportDocument = ({ {stats.mfaDevices > 0 ? ( <> - ℹ {stats.mfaDevices} MFA device(s) registered. Verify each device belongs to the - user. + ℹ️ {stats.mfaDevices} MFA device(s) registered + {stats.recentMfaDevices > 0 + ? `, including ${stats.recentMfaDevices} registered in the last 7 days. Verify the recent registrations were made by the user — attackers register their own method to keep access after a password reset.` + : '. Verify each device belongs to the user.'} - {becData.MFADevices.slice(0, 5).map((device, index) => ( - - Display Name: {device.displayName || 'N/A'} - {'\n'} - Registered: {formatDate(device.createdDateTime)} - - ))} + {[...becData.MFADevices] + .sort( + (a, b) => new Date(b?.createdDateTime || 0) - new Date(a?.createdDateTime || 0) + ) + .slice(0, 5) + .map((device, index) => ( + + Display Name: {device.displayName || 'N/A'} + {'\n'} + Registered: {formatDate(device.createdDateTime)} + {isRecentMfaDevice(device) && '\n⚠️ Registered in the last 7 days'} + + ))} + {becData.MFADevices.length > 5 && ( + + ... and {becData.MFADevices.length - 5} more methods (see JSON export for full + list) + + )} ) : ( - + No multi-factor authentication devices are registered. MFA is highly recommended to prevent unauthorized access. )}
- {/* Check 6: Password Changes */} -
+ {/* Check 7: Password Changes */} +
Attackers often change passwords to lock out legitimate users. Reviewing recent password changes in the tenant helps identify if the compromised account's password @@ -492,7 +708,7 @@ export const BECRemediationReportDocument = ({ {stats.passwordChanges > 0 ? ( <> - ℹ {stats.passwordChanges} password change(s) detected in the tenant during the + ℹ️ {stats.passwordChanges} password change(s) detected in the tenant during the analysis period. @@ -503,16 +719,26 @@ export const BECRemediationReportDocument = ({ Last Password Change: {formatDate(user.lastPasswordChangeDateTime)} ))} + {becData.ChangedPasswords.length > 5 && ( + + ... and {becData.ChangedPasswords.length - 5} more (see JSON export for full + list) + + )} ) : ( - ℹ No password changes detected during the analysis period. + ℹ️ No password changes detected during the analysis period. )}
+ + + {/* CHECK 8, 9, 10: SENDER LISTS, DEVICES, LOCATIONS */} + - {/* Check 7: Trusted & Blocked Senders */} -
+ {/* Check 8: Trusted & Blocked Senders */} +
Attackers may add their own domain to the Trusted Senders list so their fraudulent messages bypass spam filtering, or add finance/security domains to the Blocked @@ -520,9 +746,17 @@ export const BECRemediationReportDocument = ({ folder. + {becData?.SafelistError && ( + + {becData.SafelistError} + {'\n'} + An empty list here does not mean the mailbox has no trusted or blocked senders. + + )} + {stats.safelistChanges > 0 && ( <> - + The audit log recorded changes to the Trusted/Blocked Senders and Domains list on this mailbox. Review each change carefully. @@ -530,32 +764,57 @@ export const BECRemediationReportDocument = ({ {becData.SafelistChanges.slice(0, 10).map((change, index) => ( Date: {formatDate(change.Date)} + {change.ClientIP && + `\nFrom: ${change.ClientIP}${change.Country ? ` (${change.Country})` : ''}`} + {change.ForeignLocation === true && + '\n⚠️ Originated outside the assigned usage location'} {'\n'} Trusted: {formatSafelistValue(change.Trusted)} {'\n'} Blocked: {formatSafelistValue(change.Blocked)} ))} + {becData.SafelistChanges.length > 10 && ( + + ... and {becData.SafelistChanges.length - 10} more changes (see JSON export for + full list) + + )} )} {stats.trustedSenders > 0 && ( {becData.TrustedSenders.slice(0, 15).join(', ')} )} + {stats.trustedSenders > 15 && ( + + ... and {stats.trustedSenders - 15} more trusted entries (see JSON export for full + list) + + )} {stats.blockedSenders > 0 && ( {becData.BlockedSenders.slice(0, 15).join(', ')} )} - - {stats.trustedSenders === 0 && stats.blockedSenders === 0 && stats.safelistChanges === 0 && ( - - No trusted or blocked sender/domain entries were found on this mailbox. - + {stats.blockedSenders > 15 && ( + + ... and {stats.blockedSenders - 15} more blocked entries (see JSON export for full + list) + )} + + {!becData?.SafelistError && + stats.trustedSenders === 0 && + stats.blockedSenders === 0 && + stats.safelistChanges === 0 && ( + + No trusted or blocked sender/domain entries were found on this mailbox. + + )}
- {/* Check 8: Intune Devices */} -
+ {/* Check 9: Intune Devices */} +
Newly enrolled Intune devices can indicate an attacker standing up a VM or BYOD endpoint under the compromised identity, including paths that re-register Windows @@ -563,7 +822,7 @@ export const BECRemediationReportDocument = ({ {becData?.IntuneDevicesError ? ( - + {becData.IntuneDevicesError} {'\n'} An empty device list here does not mean the user has no Intune devices. @@ -571,7 +830,7 @@ export const BECRemediationReportDocument = ({ ) : stats.intuneDevices > 0 ? ( <> - ℹ {stats.intuneDevices} Intune-managed device(s) associated with this user + ℹ️ {stats.intuneDevices} Intune-managed device(s) associated with this user {stats.recentIntuneDevices > 0 ? `, including ${stats.recentIntuneDevices} enrolled in the last 7 days.` : '. None were enrolled in the last 7 days.'} @@ -590,13 +849,142 @@ export const BECRemediationReportDocument = ({ {device.serialNumber ? `\nSerial: ${device.serialNumber}` : ''} ))} + {sortedIntuneDevices.length > 5 && ( + + ... and {sortedIntuneDevices.length - 5} more devices (see JSON export for full + list) + + )} ) : ( - + No Intune-managed devices were found for this user. )}
+ + {/* Check 10: Sign-in Locations */} +
+ + Sign-ins from countries the user does not work from are one of the strongest + compromise indicators. Each sign-in is compared against the user's assigned usage + location in Entra ID + {locationAnalysis?.UsageLocation ? ` (${locationAnalysis.UsageLocation})` : ''}, and + the client IPs behind rule changes, safelist changes, sharing changes, and sent mail + are geo-located and compared the same way. + + + {becData?.SuspectUserSignInsError ? ( + + {becData.SuspectUserSignInsError} + {'\n'} + An empty list here does not mean the user has not signed in. + + ) : ( + <> + {!locationAnalysis?.UsageLocation && ( + + {locationAnalysis?.Note || + 'The user has no usage location assigned in Entra ID, so activity cannot be compared against an expected country.'} + + )} + + {(locationAnalysis?.SignInCountries?.length || 0) > 0 && ( + + {locationAnalysis.SignInCountries.map( + (c) => `${c.Country}: ${c.Count} sign-in(s)` + ).join('\n')} + + )} + + {stats.foreignSignIns > 0 || stats.foreignActivity > 0 ? ( + <> + + {stats.foreignSignIns} sign-in(s) (of which {stats.foreignSuccessfulSignIns}{' '} + succeeded), {locationAnalysis?.ForeignRuleChangeCount || 0} inbox rule + change(s), {locationAnalysis?.ForeignSafelistChangeCount || 0} safelist + change(s), {locationAnalysis?.ForeignSharingChangeCount || 0} sharing + change(s), and {locationAnalysis?.ForeignSentMessageCount || 0} sent + message(s) originated outside {locationAnalysis?.UsageLocation}. Failed + foreign sign-ins are mostly password-spray noise; the successful ones prove + access. Review each carefully — a single legitimate trip can explain some of + this, but rule, safelist, or sharing changes from a foreign IP rarely have an + innocent explanation. + + + {foreignSignIns.slice(0, 10).map((signIn, index) => ( + + Application: {signIn.AppDisplayName || 'N/A'} + {'\n'} + IP Address: {signIn.IPAddress || 'N/A'} + {'\n'} + City: {signIn.City || 'N/A'} + {'\n'} + Result: {signIn.Status || 'N/A'} + + ))} + {foreignSignIns.length > 10 && ( + + ... and {foreignSignIns.length - 10} more foreign sign-ins (see JSON export + for full list) + + )} + + ) : locationAnalysis?.UsageLocation ? ( + + All located sign-ins and activity match the user's assigned usage location ( + {locationAnalysis.UsageLocation}). + + ) : null} + + )} +
+ + {/* Check 11: Sharing Links */} +
+ + Attackers share OneDrive and SharePoint folders to give themselves a data feed that + survives a password reset, and anonymous links expose the content to anyone holding + the URL. This check lists every sharing link the account created or changed during + the analysis period, including the IP address it was done from. + + + {stats.sharingChanges > 0 ? ( + <> + + {stats.anonymousLinks > 0 + ? `${stats.anonymousLinks} of these involve anonymous links, which anyone with the URL can open. ` + : ''} + Review each link and remove any that are not explained, even if the account has + since been remediated. + + + {becData.SharingChanges.slice(0, 10).map((change, index) => ( + + Date: {formatDate(change.Date)} + {'\n'} + Workload: {change.Workload || 'N/A'} + {change.Target && `\nShared with: ${change.Target}`} + {change.ClientIP && + `\nFrom: ${change.ClientIP}${change.Country ? ` (${change.Country})` : ''}`} + {change.ForeignLocation === true && + '\n⚠️ Originated outside the assigned usage location'} + + ))} + {becData.SharingChanges.length > 10 && ( + + ... and {becData.SharingChanges.length - 10} more changes (see JSON export for + full list) + + )} + + ) : ( + + No sharing links were created or changed by this account during the analysis + period. + + )} +
{/* RECOMMENDATIONS PAGE */} @@ -736,6 +1124,8 @@ export const BECRemediationReportDocument = ({ {'\n'} Analysis Period: 7 days {'\n'} + Assigned Usage Location: {locationAnalysis?.UsageLocation || 'Not assigned'} + {'\n'} Audit Log Status: {becData?.ExtractResult || 'Unknown'} @@ -746,14 +1136,25 @@ export const BECRemediationReportDocument = ({ {'\n'} Rule Changes: {stats.ruleChanges} {'\n'} - Permission Changes: {stats.permissionChanges} + Permission Changes: {stats.permissionChanges} ({stats.permissionChangesTargetingUser}{' '} + targeting this mailbox) {'\n'} New Applications: {stats.newApps} {'\n'} + Known-Malicious Applications: {stats.maliciousApps} + {'\n'} New Users: {stats.newUsers} {'\n'} + Sent Messages: {stats.sentTotalMessages || stats.sentMessages} + {'\n'} + Repeated Subject Campaigns: {stats.repeatedSubjects} + {'\n'} + Send Bursts: {stats.sendBursts} + {'\n'} MFA Devices: {stats.mfaDevices} {'\n'} + Recent MFA Registrations (7d): {stats.recentMfaDevices} + {'\n'} Password Changes: {stats.passwordChanges} {'\n'} Trusted Senders: {stats.trustedSenders} @@ -762,9 +1163,17 @@ export const BECRemediationReportDocument = ({ {'\n'} Safelist Changes: {stats.safelistChanges} {'\n'} + Sharing Changes: {stats.sharingChanges} + {'\n'} + Anonymous Links: {stats.anonymousLinks} + {'\n'} Intune Devices: {stats.intuneDevices} {'\n'} Recent Intune Enrollments (7d): {stats.recentIntuneDevices} + {'\n'} + Foreign Sign-ins: {stats.foreignSignIns} ({stats.foreignSuccessfulSignIns} successful) + {'\n'} + Foreign Rule/Safelist/Sharing/Mail Activity: {stats.foreignActivity}
diff --git a/frontend/src/components/CippAllTenants/useAllTenantsDashboard.js b/frontend/src/components/CippAllTenants/useAllTenantsDashboard.js index 5d38821021..09426e63bb 100644 --- a/frontend/src/components/CippAllTenants/useAllTenantsDashboard.js +++ b/frontend/src/components/CippAllTenants/useAllTenantsDashboard.js @@ -118,17 +118,20 @@ export const useAllTenantsDashboard = () => { waiting: true, }) + // summary returns the estate roll-up only. The row list is one entry per tenant per standard, and + // this card renders four bucket counts, an average, four low scorers and two pending totals. const alignmentApi = ApiGetCall({ url: '/api/ListTenantAlignment', + data: { summary: true }, queryKey: 'AllTenantsDashboard-Alignment', waiting: true, }) - // summaryOnly projects away ResultMarkdown/ResultDataJson server-side — this card only counts - // rows, and those two columns are unbounded blobs that otherwise dominate the payload. + // countsOnly returns the aggregates with no rows. Deriving them here pulled the estate's whole + // failed-test set over the wire, growing linearly with tenant count. const failedTestsApi = ApiGetCall({ url: '/api/ListTestResultsTenants', - data: { status: 'Failed', summaryOnly: 'true' }, + data: { status: 'Failed', countsOnly: 'true' }, queryKey: 'AllTenantsDashboard-FailedTests', waiting: true, }) @@ -226,104 +229,45 @@ export const useAllTenantsDashboard = () => { /* ---------------------------------------------------------------- alignment */ const alignment = useMemo(() => { - const rows = asArray(alignmentApi.data) - const byTenant = new Map() - let pendingDeviations = 0 - const pendingByTenant = new Map() - - // ListTenantAlignment serialises camelCase on the wire even though the PowerShell object that - // builds it is PascalCase. Accept both so this keeps working if that ever normalises. - rows.forEach((row) => { - const key = row?.tenantFilter ?? row?.TenantFilter - if (!key) return - const score = Number( - row?.combinedAlignmentScore ?? - row?.CombinedScore ?? - row?.alignmentScore ?? - row?.AlignmentScore ?? - 0 - ) - const existing = byTenant.get(key) ?? { total: 0, count: 0 } - byTenant.set(key, { - total: existing.total + score, - count: existing.count + 1, - }) - - const pending = Number(row?.pendingDeviationsCount ?? row?.PendingDeviationsCount ?? 0) - if (pending > 0) { - pendingDeviations += pending - pendingByTenant.set(key, (pendingByTenant.get(key) ?? 0) + pending) - } - }) - - const scores = [] - byTenant.forEach((value, key) => { - scores.push({ - tenant: key, - name: displayNameByDomain.get(key) ?? key, - score: value.count ? Math.round(value.total / value.count) : 0, - }) - }) - - const buckets = { strong: 0, good: 0, weak: 0, poor: 0 } - scores.forEach(({ score }) => { - if (score >= 90) buckets.strong += 1 - else if (score >= 75) buckets.good += 1 - else if (score >= 50) buckets.weak += 1 - else buckets.poor += 1 - }) - - const average = scores.length - ? Math.round(scores.reduce((sum, item) => sum + item.score, 0) / scores.length) - : 0 + const summary = alignmentApi.data ?? {} + const buckets = summary.Buckets ?? {} return { - scores, - buckets, - average, - lowest: [...scores].sort((a, b) => a.score - b.score).slice(0, 4), - pendingDeviations, - pendingTenantCount: pendingByTenant.size, + // Only ever read for its length — the endpoint returns the count directly. + scores: { length: summary.ScoredTenantCount ?? 0 }, + buckets: { + strong: buckets.Strong ?? 0, + good: buckets.Good ?? 0, + weak: buckets.Weak ?? 0, + poor: buckets.Poor ?? 0, + }, + average: summary.Average ?? 0, + lowest: (summary.Lowest ?? []).map((item) => ({ + tenant: item.Tenant, + name: item.Name ?? item.Tenant, + score: item.Score ?? 0, + })), + pendingDeviations: summary.PendingDeviations ?? 0, + pendingTenantCount: summary.PendingTenantCount ?? 0, } - }, [alignmentApi.data, displayNameByDomain]) + }, [alignmentApi.data]) /* ------------------------------------------------------------- test results */ const tests = useMemo(() => { - const rows = asArray(failedTestsApi.data) - const identityChecks = new Map() - const highRiskTenants = new Set() - let high = 0 - - rows.forEach((row) => { - if (String(row?.Risk ?? '').toLowerCase() === 'high') { - high += 1 - if (row?.Tenant) highRiskTenants.add(row.Tenant) - } - - if (String(row?.TestType ?? '').toLowerCase() === 'identity' && row?.Name) { - const tenantSet = identityChecks.get(row.Name) ?? new Set() - if (row?.Tenant) tenantSet.add(row.Tenant) - identityChecks.set(row.Name, tenantSet) - } - }) - - const identityRows = [...identityChecks.entries()] - .map(([label, tenantSet]) => ({ label, tenantCount: tenantSet.size })) - .sort((a, b) => b.tenantCount - a.tenantCount) - .slice(0, 4) - - const identityTenantCount = new Set( - rows - .filter((row) => String(row?.TestType ?? '').toLowerCase() === 'identity' && row?.Tenant) - .map((row) => row.Tenant) - ).size + const counts = failedTestsApi.data?.Counts ?? {} + const byTestType = counts.ByTestType ?? {} + // The facet is keyed by the TestType as stored ('Identity'); match without assuming casing. + const identityKey = Object.keys(byTestType).find((key) => key.toLowerCase() === 'identity') + const identity = (identityKey ? byTestType[identityKey] : null) ?? {} return { - identityRows, - identityTenantCount, - high, - highRiskTenantCount: highRiskTenants.size, + identityRows: (identity.TopChecks ?? []) + .slice(0, 4) + .map((check) => ({ label: check.Name, tenantCount: check.TenantCount })), + identityTenantCount: identity.Tenants ?? 0, + high: counts.HighRiskFailed ?? 0, + highRiskTenantCount: counts.HighRiskTenants ?? 0, } }, [failedTestsApi.data]) diff --git a/frontend/src/components/CippComponents/CippCAPolicyBuilder.jsx b/frontend/src/components/CippComponents/CippCAPolicyBuilder.jsx index 39e4bc9681..ddbc9b4de2 100644 --- a/frontend/src/components/CippComponents/CippCAPolicyBuilder.jsx +++ b/frontend/src/components/CippComponents/CippCAPolicyBuilder.jsx @@ -108,6 +108,114 @@ function SectionHeader({ title, description, requiresLicense, icon }) { ); } +/** + * The guest / external user block, which Graph models identically on the include and the + * exclude side. Rendered twice from UsersSection rather than duplicated. + */ +function GuestsOrExternalUsersFields({ formControl, disabled, prefix, direction, typeOptions }) { + const base = `${prefix}.${direction}GuestsOrExternalUsers`; + const Verb = direction === "include" ? "Include" : "Exclude"; + const scopeHelp = + direction === "include" + ? "Choose whether the policy applies to all external tenants or specific ones. Only relevant for external user types (not internal guests)." + : "Choose whether the exclusion applies to all external tenants or specific ones. Only relevant for external user types (not internal guests)."; + + // Entra rejects an include-guests assignment (error 1119) when Include Users also carries one of + // its special values. The exclude side has no such constraint, so only watch on the include side. + const includeUsers = useWatch({ control: formControl.control, name: `${prefix}.includeUsers` }); + const guestTypes = useWatch({ control: formControl.control, name: `${base}.guestOrExternalUserTypes` }); + const conflictsWithIncludeUsers = useMemo(() => { + if (direction !== "include") return false; + const hasGuestTypes = Array.isArray(guestTypes) ? guestTypes.length > 0 : Boolean(guestTypes); + if (!hasGuestTypes) return false; + const users = Array.isArray(includeUsers) ? includeUsers : [includeUsers]; + return users.some((u) => ["All", "None", "GuestsOrExternalUsers"].includes(u?.value ?? u)); + }, [direction, guestTypes, includeUsers]); + + return ( + <> + + + + {Verb} Guests or External Users + + + + + + + Select one or more external user types to {direction} {direction === "include" ? "in" : "from"} this + policy. + + {conflictsWithIncludeUsers && ( + + Entra ID rejects this combination. Clear "Include Users" — an include-guests + assignment cannot be combined with All, None or GuestsOrExternalUsers. + + )} + + + + + + {scopeHelp} + + + + + + + Enter the tenant IDs to scope this to (e.g. your partner tenant ID for a service + provider {direction === "include" ? "inclusion" : "exclusion"}). + + + + + + ); +} + // --------------------------------------------------------------------------- // Users & Groups section // --------------------------------------------------------------------------- @@ -215,79 +323,21 @@ function UsersSection({ formControl, disabled, prefix = "conditions.users" }) { /> - {/* Guest / External User Exclusions */} - - - - Exclude Guests or External Users - - - - - - - Select one or more external user types to exclude from this policy. - - - - - - - Choose whether the exclusion applies to all external tenants or specific ones. Only - relevant for external user types (not internal guests). - - - - - - - Enter the tenant IDs to scope this exclusion to (e.g. your partner tenant ID for - service provider exclusion). - - - - + disabled={disabled} + prefix={prefix} + direction="include" + typeOptions={guestTypeOpts} + /> + ); } @@ -305,6 +355,11 @@ function ApplicationsSection({ formControl, disabled, prefix = "conditions.appli () => enumToOptions(schemaDef?.properties?.includeUserActions), [schemaDef] ); + const filterSchema = resolveRef("#/$defs/conditionalAccessFilter"); + const filterModeOpts = useMemo( + () => enumToOptions(filterSchema?.properties?.mode), + [filterSchema] + ); return ( @@ -344,6 +399,52 @@ function ApplicationsSection({ formControl, disabled, prefix = "conditions.appli options={userActionOpts} /> + + + + Used instead of cloud apps. In a template, deployment matches these by display name and + creates the authentication context in the tenant if it is missing. + + + + {/* Application filter */} + + + + Application Filter + + + + + + + + + ); } @@ -399,6 +500,17 @@ function ConditionsSection({ formControl, disabled }) { [locationSchema] ); + const clientAppsSchema = resolveRef("#/$defs/conditionalAccessClientApplications"); + const includeSpOpts = useMemo( + () => specialValueOptions(clientAppsSchema?.properties?.includeServicePrincipals), + [clientAppsSchema] + ); + const filterSchema = resolveRef("#/$defs/conditionalAccessFilter"); + const filterModeOpts = useMemo( + () => enumToOptions(filterSchema?.properties?.mode), + [filterSchema] + ); + return ( {/* Client app types */} @@ -570,6 +682,67 @@ function ConditionsSection({ formControl, disabled }) { options={authFlowOpts} /> + + {/* Workload identities */} + + + + + Workload Identities + + + + + + + + + Scopes the policy to workload identities instead of users. Leave empty for a user policy. + + + + + + + + + + + ); } @@ -625,7 +798,10 @@ function GrantControlsSection({ formControl, disabled }) { ? gc.builtInControls.length : gc.builtInControls) || gc.authenticationStrength?.id || - (Array.isArray(gc.termsOfUse) ? gc.termsOfUse.length : gc.termsOfUse); + (Array.isArray(gc.termsOfUse) ? gc.termsOfUse.length : gc.termsOfUse) || + (Array.isArray(gc.customAuthenticationFactors) + ? gc.customAuthenticationFactors.length + : gc.customAuthenticationFactors); if (hasControls && !(value?.value ?? value)) { return "Grant operator is required when grant controls are set"; } @@ -676,6 +852,21 @@ function GrantControlsSection({ formControl, disabled }) { placeholder="Terms of use agreement IDs" /> + + + + Legacy custom controls from an external identity provider, referenced by ID. + + ); } @@ -1356,6 +1547,9 @@ export default CippCAPolicyBuilder; * Call this in your form's submit handler to strip out { label, value } * wrapper objects from autoComplete fields, remove empty/null branches, * and ensure the JSON is ready to send to AddCAPolicy / AddCATemplate. + * + * Absent keys are fine: the backend canonicalizer (Format-CIPPCAPolicy) restores every managed + * key it needs as its cleared form at deploy/edit time, so this stays a plain payload cleanup. */ export function extractCAPolicyJSON(formValues) { const clean = (obj) => { @@ -1449,7 +1643,8 @@ export function extractCAPolicyJSON(formValues) { } // Post-process: strip session control sub-objects where isEnabled is false. - // Graph validates fields like `mode` even when disabled — safest to omit entirely. + // Graph validates fields like `mode` even when disabled — safest to omit entirely; the backend + // canonicalizer turns the resulting absence into the null that clears it on the policy. if (cleaned.sessionControls) { const sessionKeys = [ "applicationEnforcedRestrictions", diff --git a/frontend/src/components/CippComponents/CippMultiQueueTracker.jsx b/frontend/src/components/CippComponents/CippMultiQueueTracker.jsx index 4be060760e..d1c2944390 100644 --- a/frontend/src/components/CippComponents/CippMultiQueueTracker.jsx +++ b/frontend/src/components/CippComponents/CippMultiQueueTracker.jsx @@ -48,7 +48,10 @@ export const CippMultiQueueTracker = ({ queueIds = [], relatedQueryKeys = [], la data: { QueueIds: idKey }, queryKey: `CippQueues-${idKey || 'none'}`, waiting: ids.length > 0, - refetchInterval: (data) => (isFinished(data?.Summary?.Status) ? false : 3000), + // TanStack Query v5 hands this callback the Query object, not the data. Reading the data + // off query.state is what makes the interval actually return false on completion - with + // the v4 (data) signature the status is never found and the poll runs forever. + refetchInterval: (query) => (isFinished(query?.state?.data?.Summary?.Status) ? false : 3000), refetchOnWindowFocus: false, staleTime: 0, }) diff --git a/frontend/src/components/CippPdf/PermissionsReportButton.jsx b/frontend/src/components/CippPdf/PermissionsReportButton.jsx index 6be6907658..e5babd008d 100644 --- a/frontend/src/components/CippPdf/PermissionsReportButton.jsx +++ b/frontend/src/components/CippPdf/PermissionsReportButton.jsx @@ -226,7 +226,7 @@ export const PermissionsReportDocument = ({ /> ) : ( - + No site or library grants access to Everyone, Everyone except external users, or All Users. @@ -261,7 +261,7 @@ export const PermissionsReportDocument = ({ /> ) : ( - + No guest or external identity holds a permission on a scanned site or library. )} @@ -301,7 +301,7 @@ export const PermissionsReportDocument = ({ /> ) : ( - + No user or directory group holds Full Control outside a site's Owners group. )} @@ -321,7 +321,7 @@ export const PermissionsReportDocument = ({ whether each detachment was intentional and is still needed. ) : ( - + Every scanned library takes its permissions from its site, so site-level access management covers them all. diff --git a/frontend/src/components/CippPdf/ReportDocument.jsx b/frontend/src/components/CippPdf/ReportDocument.jsx index 5ea627587a..f5810cd1ea 100644 --- a/frontend/src/components/CippPdf/ReportDocument.jsx +++ b/frontend/src/components/CippPdf/ReportDocument.jsx @@ -1,6 +1,6 @@ import { Document } from '@react-pdf/renderer' import { ReportProvider } from './reportContext' -import { createReportTheme } from './reportTheme' +import { applyFooterText, createReportTheme } from './reportTheme' import { createReportStyles, DEFAULT_PAGE_SETUP } from './reportPdfStyles' import { CoverPage } from './reportPdfPrimitives' import { resolveCoverImage } from './resolveCoverImage' @@ -83,6 +83,14 @@ export const ReportDocument = ({ const context = { theme, styles, variables, logo, footerLabel, size, orientation, date } + // Branding's cover note wins; a report's own wording is the fallback. Leave the prop undefined + // when neither is set so CoverPage's default confidentiality line still appears. Variables are + // filled here so a configured `%tenantname%` note resolves the same way the page footer does. + const coverNoteTemplate = theme.coverFooterText || coverFooterNote + const coverNote = coverNoteTemplate + ? applyFooterText(coverNoteTemplate, variables) + : undefined + return ( @@ -103,8 +111,7 @@ export const ReportDocument = ({ // Naming the client on the cover is what makes it a client report. Every report wanted // it and each one printed it slightly differently; `coverTenant={false}` opts out. tenantName={coverTenant === false ? null : coverTenant || tenantName} - // Branding's cover note wins; a report's own wording is the fallback. - footerNote={theme.coverFooterText || coverFooterNote} + footerNote={coverNote} > {coverMeta} diff --git a/frontend/src/components/CippPdf/SharingReportButton.jsx b/frontend/src/components/CippPdf/SharingReportButton.jsx index 3ff51601b3..136a515f0c 100644 --- a/frontend/src/components/CippPdf/SharingReportButton.jsx +++ b/frontend/src/components/CippPdf/SharingReportButton.jsx @@ -211,7 +211,7 @@ export const SharingReportDocument = ({ /> ) : ( - + No anonymous link grants write access. )} @@ -246,7 +246,7 @@ export const SharingReportDocument = ({ /> ) : ( - + Every anonymous link has an expiry date set. )} @@ -284,7 +284,7 @@ export const SharingReportDocument = ({ /> ) : ( - + External and anonymous shares point at individual files rather than folders. )} @@ -316,7 +316,7 @@ export const SharingReportDocument = ({ /> ) : ( - + Nothing has been shared with an identity outside the organisation. )} diff --git a/frontend/src/components/CippPdf/index.js b/frontend/src/components/CippPdf/index.js index 71468d9693..fbd7dba319 100644 --- a/frontend/src/components/CippPdf/index.js +++ b/frontend/src/components/CippPdf/index.js @@ -14,6 +14,10 @@ export { REPORT_COLOURS, REPORT_SERIES_SEMANTIC, applyReportVariables, + applyFooterText, + applyWatermarkText, + FOOTER_MAX_LENGTH, + WATERMARK_MAX_LENGTH, REPORT_COLOUR_ROLES, asReportTheme, buildPalette, diff --git a/frontend/src/components/CippPdf/previewSampleData.js b/frontend/src/components/CippPdf/previewSampleData.js index 146556bba9..f3a607f508 100644 --- a/frontend/src/components/CippPdf/previewSampleData.js +++ b/frontend/src/components/CippPdf/previewSampleData.js @@ -350,22 +350,204 @@ export const SAMPLE_SHADOW_AI = { ], } -/** BEC remediation report. */ +/** BEC remediation report. Field shapes mirror the real Push-BECRun payload so the preview + * renders every report section with plausible values rather than 'Unknown' placeholders. */ export const SAMPLE_BEC = { userData: { displayName: 'Sample User', userPrincipalName: 'sample.user@example.com' }, becData: { ExtractedAt: '2026-08-05T09:00:00Z', - ExtractResult: 'Completed', - NewRules: [{ Name: 'Sample forwarding rule', MoveToFolder: 'RSS Feeds' }], - InboxRuleChanges: [{ Name: 'Sample rule change' }], - NewUsers: [], - AddedApps: [{ DisplayName: 'Sample OAuth app' }], - MailboxPermissionChanges: [{ Grantee: 'sample.other@example.com' }], - MFADevices: [{ Device: 'Sample phone' }], - ChangedPasswords: [{ User: 'sample.user@example.com' }], - TrustedSenders: [], - BlockedSenders: [], - SafelistChanges: [], + ExtractResult: 'Successfully extracted logs from auditlog', + AnalysisWindowDays: 7, + NewRules: [ + { + Name: 'Sample forwarding rule', + Description: 'Move messages from billing@example.com to folder RSS Feeds', + MoveToFolder: 'RSS Feeds', + RecentlyChanged: true, + }, + ], + InboxRuleChanges: [ + { + Operation: 'New-InboxRule', + UserKey: 'sample.user@example.com', + RuleName: 'Sample forwarding rule', + Parameters: 'MoveToFolder=RSS Feeds; MarkAsRead=True', + Date: '2026-08-03T11:24:00Z', + ClientIP: '203.0.113.10', + Country: 'NG', + City: 'Lagos', + ForeignLocation: true, + }, + ], + NewUsers: [ + { + displayName: 'Sample Contractor', + userPrincipalName: 'sample.contractor@example.com', + createdDateTime: '2026-08-02T08:00:00Z', + }, + ], + AddedApps: [ + { + displayName: 'Sample OAuth app', + appId: '00000000-0000-0000-0000-000000000001', + publisher: 'Sample Publisher', + createdDateTime: '2026-08-01T10:00:00Z', + MaliciousMatch: null, + }, + ], + MaliciousSPs: [ + { + displayName: 'Sample Mail Sync Tool', + appId: '00000000-0000-0000-0000-000000000002', + accountEnabled: true, + createdDateTime: '2026-07-30T09:30:00Z', + CatalogName: 'Sample Mail Sync Tool', + Categories: ['Mailbox exfiltration', 'Business Email Compromise'], + Description: 'Sample catalog entry used for preview data.', + }, + ], + MailboxPermissionChanges: [ + { + Operation: 'Add-MailboxPermission', + UserKey: 'admin@example.com', + ObjectId: 'sample.user@example.com', + Permissions: 'FullAccess', + TargetsSuspect: true, + }, + ], + SentMessages: [ + { + MessageTraceId: '00000000-0000-0000-0000-000000000003', + Status: 'Delivered', + Subject: 'Sample invoice', + RecipientAddress: 'supplier@example.net', + Received: '2026-08-04 15:02:11Z', + FromIP: '203.0.113.10', + Country: 'NG', + City: 'Lagos', + ForeignLocation: true, + }, + ], + SentMessageAnalysis: { + TotalMessages: 47, + TotalRecipients: 212, + RepeatedSubjects: [ + { + Subject: 'Sample invoice', + MessageCount: 38, + RecipientCount: 190, + FirstSent: '2026-08-04 14:55:00Z', + LastSent: '2026-08-04 15:20:00Z', + Flagged: true, + }, + ], + FlaggedSubjectCount: 1, + Bursts: [ + { + WindowStart: '2026-08-04 15:00:00Z', + WindowMinutes: 10, + MessageCount: 31, + RecipientCount: 160, + TopSubject: 'Sample invoice', + }, + ], + Flagged: true, + }, + MFADevices: [ + { + '@odata.type': '#microsoft.graph.microsoftAuthenticatorAuthenticationMethod', + displayName: 'Sample phone', + createdDateTime: '2026-08-03T12:00:00Z', + }, + ], + ChangedPasswords: [ + { + displayName: 'Sample User', + userPrincipalName: 'sample.user@example.com', + lastPasswordChangeDateTime: '2026-08-03T12:05:00Z', + }, + ], + TrustedSenders: ['trusted@example.net', 'example-partner.com'], + BlockedSenders: ['security-alerts@example.org'], + SafelistChanges: [ + { + Operation: 'Set-MailboxJunkEmailConfiguration', + UserKey: 'sample.user@example.com', + Date: '2026-08-03T11:30:00Z', + ClientIP: '203.0.113.10', + Country: 'NG', + City: 'Lagos', + ForeignLocation: true, + Trusted: ['attacker-domain.example'], + Blocked: null, + }, + ], + SharingChanges: [ + { + Operation: 'AnonymousLinkCreated', + UserKey: 'sample.user@example.com', + Date: '2026-08-04T10:15:00Z', + Workload: 'OneDrive', + FileName: 'Payroll Q3.xlsx', + ItemUrl: 'https://example-my.sharepoint.com/personal/sample_user/Documents/Payroll Q3.xlsx', + Target: null, + TargetType: null, + ClientIP: '203.0.113.10', + Country: 'NG', + City: 'Lagos', + ForeignLocation: true, + }, + ], + IntuneDevices: [ + { + id: '00000000-0000-0000-0000-000000000004', + deviceName: 'SAMPLE-VM01', + operatingSystem: 'Windows', + osVersion: '10.0.26100', + complianceState: 'noncompliant', + enrolledDateTime: '2026-08-03T13:00:00Z', + lastSyncDateTime: '2026-08-05T08:00:00Z', + deviceEnrollmentType: 'windowsAzureADJoin', + serialNumber: 'SAMPLE1234', + }, + ], + SuspectUserSignIns: [ + { + CreatedDateTime: '2026-08-04T22:14:00Z', + AppDisplayName: 'Office 365 Exchange Online', + ClientAppUsed: 'Browser', + Status: 'Success', + IPAddress: '203.0.113.10', + Country: 'NG', + City: 'Lagos', + ForeignLocation: true, + }, + { + CreatedDateTime: '2026-08-04T09:02:00Z', + AppDisplayName: 'Microsoft Teams', + ClientAppUsed: 'Mobile Apps and Desktop clients', + Status: 'Success', + IPAddress: '198.51.100.24', + Country: 'US', + City: 'Seattle', + ForeignLocation: false, + }, + ], + LocationAnalysis: { + UsageLocation: 'US', + UserRegisteredCountry: 'United States', + SignInCountries: [ + { Country: 'US', Count: 41 }, + { Country: 'NG', Count: 9 }, + ], + ForeignSignInCount: 9, + ForeignSuccessfulSignInCount: 8, + ForeignRuleChangeCount: 1, + ForeignSafelistChangeCount: 1, + ForeignSharingChangeCount: 1, + ForeignSentMessageCount: 1, + Note: null, + }, }, } diff --git a/frontend/src/components/CippPdf/reportPdfPrimitives.jsx b/frontend/src/components/CippPdf/reportPdfPrimitives.jsx index c671983dba..9404989753 100644 --- a/frontend/src/components/CippPdf/reportPdfPrimitives.jsx +++ b/frontend/src/components/CippPdf/reportPdfPrimitives.jsx @@ -1,6 +1,6 @@ import { Children } from 'react' import { Text, View, Image, Page } from '@react-pdf/renderer' -import { REPORT_COLOURS, applyReportVariables } from './reportTheme' +import { REPORT_COLOURS, applyFooterText, applyWatermarkText } from './reportTheme' import { useReport, useReportStyles } from './reportContext' import { DEFAULT_PAGE_SETUP, TABLE_ROW_PADDING, contentWidth } from './reportPdfStyles' import { wrapLongTokens } from './measureText' @@ -124,7 +124,7 @@ export const ContentPage = ({ title, subtitle, children, ...props }) => { */ export const PageFooter = ({ styles, label, theme, variables }) => { const templated = theme?.footer?.enabled - ? applyReportVariables(theme.footer.template, variables) + ? applyFooterText(theme.footer.template, variables) : '' // Configured branding wins over the report's own label. The reverse — which this did at first — // meant every report that passed a label silently ignored the footer text an MSP had set, which @@ -178,8 +178,16 @@ export const ReportPage = ({ ) -export const Watermark = ({ styles, theme, text, onDark = false }) => { - const value = text ?? (theme?.watermark?.enabled ? theme.watermark.text : '') +/** + * Diagonal mark drawn over every page. Same `%variable%` substitution as the footer — branding + * stores a template (e.g. `%tenantname%`), and the report fills it from the surrounding context. + * The 40-character ceiling is applied to the *resolved* string, after variables expand. + */ +export const Watermark = ({ styles, theme, text, variables: variablesProp, onDark = false }) => { + const report = useReport() + const variables = variablesProp ?? report.variables + const template = text ?? (theme?.watermark?.enabled ? theme.watermark.text : '') + const value = template ? applyWatermarkText(template, variables) : '' if (!value) return null return ( diff --git a/frontend/src/components/CippPdf/reportTheme.js b/frontend/src/components/CippPdf/reportTheme.js index 6e288ae766..c2443b55d2 100644 --- a/frontend/src/components/CippPdf/reportTheme.js +++ b/frontend/src/components/CippPdf/reportTheme.js @@ -169,6 +169,12 @@ const buildSeries = (primary, secondary) => { const DEFAULT_FOOTER_TEMPLATE = '' const DEFAULT_WATERMARK_TEXT = '' +/** Hard ceiling for page/cover footer text — applied after `%variable%` substitution. */ +export const FOOTER_MAX_LENGTH = 200 + +/** Hard ceiling for the mark drawn on the page — applied after `%variable%` substitution. */ +export const WATERMARK_MAX_LENGTH = 40 + /** * The parts of a report that can be coloured independently. * @@ -333,6 +339,23 @@ export const applyReportVariables = (template, variables = {}) => { }) } +/** + * Resolve a watermark template and enforce the on-page length ceiling. + * + * The branding field stores a template (and rejects templates over the same limit). Tenant names + * and other variables can still expand past it at render time — that is when the ceiling is + * applied, so a long `%tenantname%` cannot spill a mark across the whole page. + */ +export const applyWatermarkText = (template, variables = {}) => + applyReportVariables(template, variables).slice(0, WATERMARK_MAX_LENGTH) + +/** + * Resolve page-footer / cover-note text and enforce the length ceiling after substitution. + * Same reason as the watermark: a long `%tenantname%` must not blow past the stored limit. + */ +export const applyFooterText = (template, variables = {}) => + applyReportVariables(template, variables).slice(0, FOOTER_MAX_LENGTH) + /** * Build the theme a report renders against. * diff --git a/frontend/src/components/CippSettings/CippBrandingCoverPreview.jsx b/frontend/src/components/CippSettings/CippBrandingCoverPreview.jsx index a4c016ad2b..3dc4b86c4a 100644 --- a/frontend/src/components/CippSettings/CippBrandingCoverPreview.jsx +++ b/frontend/src/components/CippSettings/CippBrandingCoverPreview.jsx @@ -1,7 +1,7 @@ import { Box, Typography } from "@mui/material"; import { resolveCoverImage } from "../CippPdf/resolveCoverImage"; import { createReportStyles } from "../CippPdf/reportPdfStyles"; -import { createReportTheme } from "../CippPdf/reportTheme"; +import { applyFooterText, applyWatermarkText, createReportTheme } from "../CippPdf/reportTheme"; import { SAMPLE_BEC, SAMPLE_PERMISSIONS, @@ -37,6 +37,8 @@ export const REPORT_COVER_PRESETS = [ { id: "executive", label: "Executive Report", + // Must match `reportName` on ExecutiveReportDocument — cover-mock `%reportname%` uses this. + reportName: "Executive Summary", coverLabel: "Security Assessment", title: "Executive", accent: "Summary", @@ -48,6 +50,7 @@ export const REPORT_COVER_PRESETS = [ { id: "shadowAI", label: "Shadow AI Report", + reportName: "Shadow AI Report", coverLabel: "AI Risk Assessment", title: "Shadow AI", accent: "Report", @@ -60,6 +63,7 @@ export const REPORT_COVER_PRESETS = [ { id: "bec", label: "BEC Remediation", + reportName: "BEC Analysis Report", coverLabel: "Security Incident Report", title: "BEC Compromise", accent: "Analysis", @@ -74,6 +78,7 @@ export const REPORT_COVER_PRESETS = [ { id: "sharing", label: "Sharing Report", + reportName: "Sharing Report", coverLabel: "Data Sharing Review", title: "Sharing", accent: "Report", @@ -88,6 +93,7 @@ export const REPORT_COVER_PRESETS = [ { id: "permissions", label: "Permissions Report", + reportName: "Permissions Report", coverLabel: "Access Review", title: "Permissions", accent: "Report", @@ -102,6 +108,8 @@ export const REPORT_COVER_PRESETS = [ // the report builder, so it belongs after the reports that are the same every time. id: "reportBuilder", label: "Report Builder", + // Matches the sample template name used by CippBrandingReportPreview for this report type. + reportName: "Quarterly Security Review", coverLabel: "Assessment Report", title: "Custom", accent: "Report", @@ -152,6 +160,18 @@ const CippBrandingCoverPreview = ({ month: "long", day: "numeric", }); + // Same substitution + length ceiling the PDF applies — without it, typing %tenantname% in + // branding shows the token literally in this mock while real reports resolve it. + const previewVariables = { + tenantname: SAMPLE_TENANT_NAME, + reportname: preset.reportName, + reportdate: currentDate, + }; + const watermarkLabel = applyWatermarkText(theme.watermark.text, previewVariables); + const coverFooterLabel = applyFooterText( + theme.coverFooterText || preset.footer, + previewVariables + ); return ( - {theme.watermark.text} + {watermarkLabel} )} @@ -339,7 +359,7 @@ const CippBrandingCoverPreview = ({ }} > {/* A configured cover note replaces the report's own wording, exactly as the PDF does. */} - {theme.coverFooterText || preset.footer} + {coverFooterLabel} diff --git a/frontend/src/components/CippSettings/CippBrandingSettings.jsx b/frontend/src/components/CippSettings/CippBrandingSettings.jsx index c798c9afbc..6a01076a5a 100644 --- a/frontend/src/components/CippSettings/CippBrandingSettings.jsx +++ b/frontend/src/components/CippSettings/CippBrandingSettings.jsx @@ -33,7 +33,7 @@ import { normalizeLogoImageIds, normalizeLogoUploads, } from "../CippPdf/resolveCoverImage"; -import { REPORT_COLOUR_ROLES } from "../CippPdf/reportTheme"; +import { FOOTER_MAX_LENGTH, REPORT_COLOUR_ROLES, WATERMARK_MAX_LENGTH } from "../CippPdf/reportTheme"; import { BRANDING_GALLERY_QUERY_KEY } from "../CippPdf/useBrandingSettings"; import { useForm } from "react-hook-form"; @@ -78,7 +78,7 @@ const FOOTER_TOOLTIP = "Text shown at the bottom of every report page. Type % for CIPP's variables, plus %reportname% and %reportdate% which reports add. Report templates can override this or switch it off individually."; const WATERMARK_TOOLTIP = - "Diagonal text drawn faintly across every page of a report, cover included — e.g. DRAFT or CONFIDENTIAL. Typing text is enough to show it; the toggle only exists to switch it off without losing the wording."; + "Diagonal text drawn faintly across every page of a report, cover included. Type % for CIPP's variables (e.g. %tenantname%), or a static mark such as DRAFT. Typing text is enough to show it; the toggle only exists to switch it off without losing the wording."; const REPORT_DEFAULTS_TOOLTIP = "Which preset each report reaches for when nothing else says otherwise. A report template with its own preset still wins over this, and this still wins over the default branding above."; @@ -1412,12 +1412,12 @@ const CippBrandingSettings = () => { name="footerText" formControl={formControl} placeholder="%tenantname% — prepared by Contoso IT — %reportdate%" - helperText="Type % for variables. Reports add %reportname% and %reportdate%." + helperText={`Type % for variables. Reports add %reportname% and %reportdate%. After substitution, text is capped at ${FOOTER_MAX_LENGTH} characters.`} includeSystemVariables={true} validators={{ maxLength: { - value: 200, - message: "Footer text must be 200 characters or fewer", + value: FOOTER_MAX_LENGTH, + message: `Footer text must be ${FOOTER_MAX_LENGTH} characters or fewer`, }, }} /> @@ -1426,13 +1426,13 @@ const CippBrandingSettings = () => { name="coverFooterText" label="Cover Note" placeholder="Blank = each report's own wording" - helperText="Replaces the confidentiality note on cover pages" + helperText={`Replaces the confidentiality note on cover pages. After substitution, text is capped at ${FOOTER_MAX_LENGTH} characters.`} includeSystemVariables={true} formControl={formControl} validators={{ maxLength: { - value: 200, - message: "Cover note must be 200 characters or fewer", + value: FOOTER_MAX_LENGTH, + message: `Cover note must be ${FOOTER_MAX_LENGTH} characters or fewer`, }, }} /> @@ -1462,14 +1462,16 @@ const CippBrandingSettings = () => { diff --git a/frontend/src/components/CippTable/CippQueueTracker.js b/frontend/src/components/CippTable/CippQueueTracker.js index 20a4fd6d62..ac35062aa3 100644 --- a/frontend/src/components/CippTable/CippQueueTracker.js +++ b/frontend/src/components/CippTable/CippQueueTracker.js @@ -34,9 +34,10 @@ export const CippQueueTracker = ({ queueId, queryKey, title, onQueueComplete }) data: { QueueId: effectiveQueueId }, queryKey: `CippQueue-${effectiveQueueId || "unknown"}`, waiting: shouldShowQueue && !!effectiveQueueId && !isQueueCompleted, - refetchInterval: (data) => { - // Check if the current data shows completion - const currentData = data?.[0]; + refetchInterval: (query) => { + // TanStack Query v5 hands this callback the Query object, not the data - the response + // has to be read off query.state or the completion check below never matches. + const currentData = query?.state?.data?.[0]; const isCurrentCompleted = currentData?.Status === "Completed" || currentData?.Status === "Failed" || diff --git a/frontend/src/data/M365Licenses.json b/frontend/src/data/M365Licenses.json index a5075fa9e6..61bd872fbf 100644 --- a/frontend/src/data/M365Licenses.json +++ b/frontend/src/data/M365Licenses.json @@ -38287,6 +38287,14 @@ "Service_Plan_Id": "65cc641f-cccd-4643-97e0-a17e3045e541", "Service_Plans_Included_Friendly_Names": "Microsoft Records Management" }, + { + "Product_Display_Name": "Office 365 E5", + "String_Id": "ENTERPRISEPREMIUM", + "GUID": "c7df2760-2c81-4ef7-b578-5b5392b571df", + "Service_Plan_Name": "MICROSOFT_TEAMS_EVENTS", + "Service_Plan_Id": "29c62f1c-8ffc-4304-9cb9-398a6aa1852b", + "Service_Plans_Included_Friendly_Names": "Microsoft Teams Events" + }, { "Product_Display_Name": "Office 365 E5 EEA (no Teams)", "String_Id": "Office_365_w/o_Teams_Bundle_E5", @@ -47918,5 +47926,61 @@ "Service_Plan_Name": "INSIDER_RISK_MANAGEMENT_FOR_AGENTS", "Service_Plan_Id": "004ddfc0-c92f-4b0a-90c5-c60646299d71", "Service_Plans_Included_Friendly_Names": "Microsoft Purview Insider Risk Management for Agents" + }, + { + "Product_Display_Name": "Microsoft Teams Premium", + "String_Id": "M365_TEAMS_PREMIUM", + "GUID": "6432c818-bcef-43b6-9290-aec052964950", + "Service_Plan_Name": "TEAMSPRO_MGMT", + "Service_Plan_Id": "0504111f-feb8-4a3c-992a-70280f9a2869", + "Service_Plans_Included_Friendly_Names": "Microsoft Teams Premium Intelligent" + }, + { + "Product_Display_Name": "Microsoft Teams Premium", + "String_Id": "M365_TEAMS_PREMIUM", + "GUID": "6432c818-bcef-43b6-9290-aec052964950", + "Service_Plan_Name": "TEAMSPRO_CUST", + "Service_Plan_Id": "cc8c0802-a325-43df-8cba-995d0c6cb373", + "Service_Plans_Included_Friendly_Names": "Microsoft Teams Premium Personalized" + }, + { + "Product_Display_Name": "Microsoft Teams Premium", + "String_Id": "M365_TEAMS_PREMIUM", + "GUID": "6432c818-bcef-43b6-9290-aec052964950", + "Service_Plan_Name": "TEAMSPRO_PROTECTION", + "Service_Plan_Id": "f8b44f54-18bb-46a3-9658-44ab58712968", + "Service_Plans_Included_Friendly_Names": "Microsoft Teams Premium Secure" + }, + { + "Product_Display_Name": "Microsoft Teams Premium", + "String_Id": "M365_TEAMS_PREMIUM", + "GUID": "6432c818-bcef-43b6-9290-aec052964950", + "Service_Plan_Name": "TEAMSPRO_VIRTUALAPPT", + "Service_Plan_Id": "9104f592-f2a7-4f77-904c-ca5a5715883f", + "Service_Plans_Included_Friendly_Names": "Microsoft Teams Premium Virtual Appointment" + }, + { + "Product_Display_Name": "Microsoft Teams Premium", + "String_Id": "M365_TEAMS_PREMIUM", + "GUID": "6432c818-bcef-43b6-9290-aec052964950", + "Service_Plan_Name": "MCO_VIRTUAL_APPT", + "Service_Plan_Id": "711413d0-b36e-4cd4-93db-0a50a4ab7ea3", + "Service_Plans_Included_Friendly_Names": "Microsoft Teams Premium Virtual Appointments" + }, + { + "Product_Display_Name": "Microsoft Teams Premium", + "String_Id": "M365_TEAMS_PREMIUM", + "GUID": "6432c818-bcef-43b6-9290-aec052964950", + "Service_Plan_Name": "QUEUES_APP", + "Service_Plan_Id": "ab2d4fb5-f80a-4bf1-a11d-7f1da254041b", + "Service_Plans_Included_Friendly_Names": "Queues app for Microsoft Teams" + }, + { + "Product_Display_Name": "Skype for Business PSTN Domestic and International Calling", + "String_Id": "MCOSMS2", + "GUID": "d4009785-b899-4cab-97b6-d06a7c799507", + "Service_Plan_Name": "MCOSMS2", + "Service_Plan_Id": "d4009785-b899-4cab-97b6-d06a7c799507", + "Service_Plans_Included_Friendly_Names": "DOMESTIC AND INTERNATIONAL CALLING PLAN" } ] diff --git a/frontend/src/data/standards.json b/frontend/src/data/standards.json index 560a9eceeb..62a8932f62 100644 --- a/frontend/src/data/standards.json +++ b/frontend/src/data/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": [ { @@ -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/frontend/src/pages/copilot/settings/index.js b/frontend/src/pages/copilot/settings/index.js index 0f8c018b2e..67cf5c2465 100644 --- a/frontend/src/pages/copilot/settings/index.js +++ b/frontend/src/pages/copilot/settings/index.js @@ -14,7 +14,11 @@ const Page = () => { url: '/api/ExecCopilotSettings', icon: , data: { settingId: 'settingId' }, - condition: (row) => row.settingId !== 'microsoft.copilot.allowwebsearch', + condition: (row) => + ![ + 'microsoft.copilot.allowwebsearch', + 'microsoft.copilot.imagegeneration', + ].includes(row.settingId), fields: [ { type: 'autoComplete', @@ -32,6 +36,31 @@ const Page = () => { confirmText: "Set '[setting]' to the selected state?", relatedQueryKeys: [queryKey], }, + { + // Designer image generation inverts the usual toggle: '1' disables, '0' enables. + label: 'Set Status', + type: 'POST', + url: '/api/ExecCopilotSettings', + icon: , + data: { settingId: 'settingId' }, + condition: (row) => row.settingId === 'microsoft.copilot.imagegeneration', + fields: [ + { + type: 'autoComplete', + name: 'value', + label: 'Desired state', + multiple: false, + creatable: false, + options: [ + { label: 'Enabled', value: '0' }, + { label: 'Disabled', value: '1' }, + { label: 'Not configured', value: 'clear' }, + ], + }, + ], + confirmText: "Set '[setting]' to the selected state?", + relatedQueryKeys: [queryKey], + }, { // Web search is a three-state policy; its values match the config.office.com options label: 'Set Status', @@ -51,7 +80,7 @@ const Page = () => { { label: 'Enabled in Microsoft 365 Copilot and Microsoft 365 Copilot Chat', - value: '2', + value: '0', }, { label: @@ -61,7 +90,7 @@ const Page = () => { { label: 'Disabled in Microsoft 365 Copilot Work mode, Enabled in Microsoft 365 Copilot Chat', - value: '0', + value: '2', }, { label: 'Not configured', value: 'clear' }, ], diff --git a/frontend/src/pages/identity/administration/users/user/bec.jsx b/frontend/src/pages/identity/administration/users/user/bec.jsx index d63a7fcac2..faac9f2e1d 100644 --- a/frontend/src/pages/identity/administration/users/user/bec.jsx +++ b/frontend/src/pages/identity/administration/users/user/bec.jsx @@ -106,17 +106,26 @@ const Page = () => { } } - if (becPollingCall.isSuccess && becPollingCall.data && !becPollingCall.data?.Waiting) { + // The !restart guard keeps a refresh from being cancelled: between clicking Refresh Data + // and the overwrite call resolving, the polling cache still holds the previous run, which + // would otherwise read as "done" and stop the loading state. + if (!restart && becPollingCall.isSuccess && becPollingCall.data && !becPollingCall.data?.Waiting) { setIsLoading(false) } }, [becPollingCall.dataUpdatedAt, becInitialCall]) const restartProcess = () => { setRestart(true) - becPollingCall.refetch() + setIsLoading(true) + // The 500ms lets the re-render register Overwrite on the initial call's params. Poll only + // after the initial call resolves: the backend resets the cache row to Waiting before it + // responds, so a poll issued after that cannot race the reset and resurface the old run. setTimeout(() => { - becInitialCall.refetch() - becPollingCall.refetch() + becInitialCall.refetch().finally(() => { + // one-shot: without this every later refetch would force a fresh run + setRestart(false) + becPollingCall.refetch() + }) }, 500) } @@ -150,21 +159,23 @@ const Page = () => { const getUserMessage = () => { if (!becPollingCall.data) return null if (becPollingCall.data.NewUsers && becPollingCall.data.NewUsers.length > 0) { - return 'New users have been found in the last 14 days. Please review the list below and take action as needed.' + return 'New users have been found in the last 7 days. Please review the list below and take action as needed.' } return 'No new users found.' } const getAppMessage = () => { if (!becPollingCall.data) return null + const maliciousAddedCount = (becPollingCall.data.AddedApps || []).filter( + (app) => app?.MaliciousMatch + ).length + const maliciousPresentCount = becPollingCall.data.MaliciousSPs?.length || 0 + if (maliciousAddedCount > 0 || maliciousPresentCount > 0) { + return `Potential Breach found: ${ + maliciousAddedCount + maliciousPresentCount + } application(s) in this tenant match the CIPP known-malicious application catalog. Consent-based access survives a password reset, so remove these applications unless their presence is explained.` + } if (becPollingCall.data.AddedApps && becPollingCall.data.AddedApps.length > 0) { - // Example condition to check for potential breach - const hasPotentialBreach = becPollingCall.data.AddedApps.some( - (app) => /* your condition here */ false - ) - if (hasPotentialBreach) { - return 'Potential Breach found.' - } return 'New applications have been found. Please review the list below and take action as needed.' } return 'No new applications found.' @@ -172,11 +183,13 @@ const Page = () => { const getMailboxPermissionMessage = () => { if (!becPollingCall.data) return null - if ( - becPollingCall.data.MailboxPermissionChanges && - becPollingCall.data.MailboxPermissionChanges.length > 0 - ) { - return 'Mailbox permission changes have been found.' + const changes = becPollingCall.data.MailboxPermissionChanges || [] + if (changes.length > 0) { + const targeting = changes.filter((c) => c?.TargetsSuspect === true).length + if (targeting > 0) { + return `${changes.length} mailbox permission change(s) found across the tenant in the last 7 days, ${targeting} of which target this mailbox. Review those first.` + } + return `${changes.length} mailbox permission change(s) found across the tenant in the last 7 days. None appear to target this mailbox, but verify the list below.` } return 'No mailbox permission changes found.' } @@ -184,13 +197,38 @@ const Page = () => { const getSentMessagesMessage = () => { if (!becPollingCall.data) return null if (becPollingCall.data.SentMessages && becPollingCall.data.SentMessages.length > 0) { - return 'Sent messages have been found. Please review the list below for any suspicious activity.' + const analysis = becPollingCall.data.SentMessageAnalysis + const parts = [ + `${analysis?.TotalMessages ?? becPollingCall.data.SentMessages.length} message(s) to ${ + analysis?.TotalRecipients ?? becPollingCall.data.SentMessages.length + } recipient(s) were sent in the last 7 days`, + ] + if (analysis?.FlaggedSubjectCount > 0) { + parts.push( + `${analysis.FlaggedSubjectCount} subject(s) were sent as many separate messages or to many recipients — identical-subject mass mail is a classic sign of a compromised mailbox running a campaign` + ) + } + if (analysis?.Bursts?.length > 0) { + parts.push( + `${analysis.Bursts.length} short burst(s) of high-volume sending were detected` + ) + } + const foreignCount = becPollingCall.data.LocationAnalysis?.ForeignSentMessageCount || 0 + if (foreignCount > 0) { + parts.push( + `${foreignCount} message(s) were sent from an IP outside the user's assigned usage location` + ) + } + return `${parts.join('. ')}. Please review the list below for any suspicious activity.` } return 'No sent messages found in the specified time range.' } const getSafelistMessage = () => { if (!becPollingCall.data) return null + if (becPollingCall.data.SafelistError) { + return `${becPollingCall.data.SafelistError} An empty list here is not proof the mailbox has none — refresh after fixing the underlying problem.` + } const trustedCount = becPollingCall.data.TrustedSenders?.length || 0 const blockedCount = becPollingCall.data.BlockedSenders?.length || 0 const changeCount = becPollingCall.data.SafelistChanges?.length || 0 @@ -217,7 +255,9 @@ const Page = () => { [becPollingCall.data] ) - const intuneDevicesWindowStart = useMemo(() => { + // the analysis window: 7 days before the data was extracted. Shared by the Intune + // enrollment and MFA registration recency checks. + const analysisWindowStart = useMemo(() => { const extractedAt = becPollingCall.data?.ExtractedAt ? new Date(becPollingCall.data.ExtractedAt) : new Date() @@ -227,6 +267,29 @@ const Page = () => { return new Date(extractedAt.getTime() - 7 * 24 * 60 * 60 * 1000) }, [becPollingCall.data?.ExtractedAt]) + const recentMfaDeviceCount = useMemo( + () => + (becPollingCall.data?.MFADevices || []).filter((method) => { + if (!method?.createdDateTime) return false + const created = new Date(method.createdDateTime) + if (Number.isNaN(created.getTime())) return false + return created >= analysisWindowStart + }).length, + [becPollingCall.data?.MFADevices, analysisWindowStart] + ) + + const foreignActivityCount = useMemo(() => { + const analysis = becPollingCall.data?.LocationAnalysis + if (!analysis) return 0 + return ( + (analysis.ForeignSignInCount || 0) + + (analysis.ForeignRuleChangeCount || 0) + + (analysis.ForeignSafelistChangeCount || 0) + + (analysis.ForeignSharingChangeCount || 0) + + (analysis.ForeignSentMessageCount || 0) + ) + }, [becPollingCall.data?.LocationAnalysis]) + const intuneDevices = useMemo(() => { const devices = [...(becPollingCall.data?.IntuneDevices || [])] devices.sort((a, b) => { @@ -243,9 +306,9 @@ const Page = () => { if (!device?.enrolledDateTime) return false const enrolled = new Date(device.enrolledDateTime) if (Number.isNaN(enrolled.getTime())) return false - return enrolled >= intuneDevicesWindowStart + return enrolled >= analysisWindowStart }).length, - [intuneDevices, intuneDevicesWindowStart] + [intuneDevices, analysisWindowStart] ) const intuneDeviceActions = useMemo( @@ -253,6 +316,91 @@ const Page = () => { [userSettingsDefaults.currentTenant] ) + const getMfaMessage = () => { + if (!becPollingCall.data) return null + const count = becPollingCall.data.MFADevices?.length || 0 + if (count === 0) { + return 'No MFA methods are registered for this user. If MFA was expected, an attacker may have removed it; either way the account currently has no second factor.' + } + if (recentMfaDeviceCount > 0) { + return `${count} MFA method(s) registered, ${recentMfaDeviceCount} in the last 7 days. Verify the recent registrations were made by the user — attackers register their own method to keep access after a password reset.` + } + return `${count} MFA method(s) registered. Please review the list below and take action as required.` + } + + const getSignInLocationMessage = () => { + if (!becPollingCall.data) return null + if (becPollingCall.data.SuspectUserSignInsError) { + return `${becPollingCall.data.SuspectUserSignInsError} This is not proof the user has no sign-ins — fix the underlying permission or licensing problem and refresh.` + } + const analysis = becPollingCall.data.LocationAnalysis + const signInCount = becPollingCall.data.SuspectUserSignIns?.length || 0 + if (signInCount === 0) { + return 'No sign-ins were found for this user in the sign-in logs.' + } + const countries = (analysis?.SignInCountries || []) + .map((c) => `${c.Country} (${c.Count})`) + .join(', ') + if (!analysis?.UsageLocation) { + return `${ + analysis?.Note || + 'The user has no usage location assigned in Entra ID, so activity cannot be compared against an expected country.' + } Sign-in countries seen: ${countries || 'none recorded'}.` + } + const foreignParts = [] + if (analysis.ForeignSignInCount > 0) { + foreignParts.push( + `${analysis.ForeignSignInCount} sign-in(s), of which ${ + analysis.ForeignSuccessfulSignInCount || 0 + } succeeded (failed foreign attempts are mostly password-spray noise)` + ) + } + if (analysis.ForeignRuleChangeCount > 0) { + foreignParts.push(`${analysis.ForeignRuleChangeCount} inbox rule change(s)`) + } + if (analysis.ForeignSafelistChangeCount > 0) { + foreignParts.push(`${analysis.ForeignSafelistChangeCount} safelist change(s)`) + } + if (analysis.ForeignSharingChangeCount > 0) { + foreignParts.push(`${analysis.ForeignSharingChangeCount} sharing change(s)`) + } + if (analysis.ForeignSentMessageCount > 0) { + foreignParts.push(`${analysis.ForeignSentMessageCount} sent message(s)`) + } + if (foreignParts.length > 0) { + return `The user's assigned usage location is ${ + analysis.UsageLocation + }, but activity originated outside it: ${foreignParts.join( + ', ' + )}. Sign-in countries seen: ${countries}. Review the sign-ins below and the flagged rows in the checks above.` + } + return `All located activity matches the user's assigned usage location (${ + analysis.UsageLocation + }). Sign-in countries seen: ${countries || 'none recorded'}.` + } + + const getSharingMessage = () => { + if (!becPollingCall.data) return null + const changes = becPollingCall.data.SharingChanges || [] + if (changes.length === 0) { + return 'No sharing links were created or changed by this account in the last 7 days.' + } + const anonymousCount = changes.filter((c) => c?.Operation?.startsWith('AnonymousLink')).length + const foreignCount = becPollingCall.data.LocationAnalysis?.ForeignSharingChangeCount || 0 + const parts = [ + `${changes.length} OneDrive/SharePoint sharing change(s) found in the last 7 days`, + ] + if (anonymousCount > 0) { + parts.push(`${anonymousCount} involve anonymous links, which anyone with the URL can open`) + } + if (foreignCount > 0) { + parts.push(`${foreignCount} were made from outside the user's usage location`) + } + return `${parts.join( + '. ' + )}. Attackers share folders to keep pulling data after a password reset — review each link and remove any that are not explained.` + } + const getIntuneDevicesMessage = () => { if (!becPollingCall.data) return null if (becPollingCall.data.IntuneDevicesError) { @@ -425,10 +573,16 @@ const Page = () => { ))} @@ -465,7 +619,10 @@ const Page = () => { {/* Check 3: New Applications */} {getAppMessage()} @@ -473,17 +630,48 @@ const Page = () => { {becPollingCall.data?.AddedApps?.length > 0 && ( - {becPollingCall.data.AddedApps.map((app, index) => ( - - ))} + {[...becPollingCall.data.AddedApps] + .sort((a, b) => !!b?.MaliciousMatch - !!a?.MaliciousMatch) + .map((app, index) => ( + + ))} )} + {becPollingCall.data?.MaliciousSPs?.length > 0 && ( + + + Known-malicious applications present in the tenant (any age) + + + + {becPollingCall.data.MaliciousSPs.map((app, index) => ( + + ))} + + + + )} {/* Check 4: Mailbox permission changes */} @@ -497,14 +685,20 @@ const Page = () => { {becPollingCall.data?.MailboxPermissionChanges?.length > 0 && ( - {becPollingCall.data.MailboxPermissionChanges.map((permission, index) => ( - - ))} + {[...becPollingCall.data.MailboxPermissionChanges] + .sort((a, b) => (b?.TargetsSuspect === true) - (a?.TargetsSuspect === true)) + .map((permission, index) => ( + + ))} )} @@ -518,6 +712,52 @@ const Page = () => { {getSentMessagesMessage()} + {becPollingCall.data?.SentMessageAnalysis?.RepeatedSubjects?.length > 0 && ( + + + Repeated subjects + + + + {becPollingCall.data.SentMessageAnalysis.RepeatedSubjects.map( + (group, index) => ( + + ) + )} + + + + )} + {becPollingCall.data?.SentMessageAnalysis?.Bursts?.length > 0 && ( + + + Send bursts + + + + {becPollingCall.data.SentMessageAnalysis.Bursts.map((burst, index) => ( + + ))} + + + + )} {becPollingCall.data?.SentMessages?.length > 0 && ( { hideTitle={true} title="Sent Messages" data={becPollingCall.data.SentMessages} - simpleColumns={['Subject', 'RecipientAddress', 'Status', 'Received', 'FromIP']} + simpleColumns={[ + 'Subject', + 'RecipientAddress', + 'Status', + 'Received', + 'FromIP', + 'Country', + ]} /> )} @@ -536,21 +783,34 @@ const Page = () => { count={becPollingCall.data?.MFADevices?.length || 0} > - MFA Devices have been found. Please review the list below and take action as - required + {getMfaMessage()} {becPollingCall.data?.MFADevices?.length > 0 && ( - {becPollingCall.data.MFADevices.map((permission, index) => ( - - ))} + {[...becPollingCall.data.MFADevices] + .sort( + (a, b) => + new Date(b?.createdDateTime || 0) - new Date(a?.createdDateTime || 0) + ) + .map((method, index) => { + const isRecent = + method?.createdDateTime && + new Date(method.createdDateTime) >= analysisWindowStart + return ( + + ) + })} )} @@ -584,12 +844,18 @@ const Page = () => { - + {getSafelistMessage()} {senderRows.length > 0 && ( @@ -614,8 +880,16 @@ const Page = () => { @@ -662,6 +936,71 @@ const Page = () => { )} + {/* Check 10: Sign-in Locations */} + + + {getSignInLocationMessage()} + + {becPollingCall.data?.SuspectUserSignIns?.length > 0 && ( + + + + )} + + + {/* Check 11: Sharing Links */} + + + {getSharingMessage()} + + {becPollingCall.data?.SharingChanges?.length > 0 && ( + + + + )} + + {/* Report Data */} diff --git a/frontend/src/pages/identity/administration/users/user/exchange.jsx b/frontend/src/pages/identity/administration/users/user/exchange.jsx index a871244661..f2ef68719b 100644 --- a/frontend/src/pages/identity/administration/users/user/exchange.jsx +++ b/frontend/src/pages/identity/administration/users/user/exchange.jsx @@ -801,26 +801,16 @@ const Page = () => { icon: , url: '/api/ExecModifyCalPerms', customDataformatter: (row, action, formData) => { - var permissions = [] - if (Array.isArray(row)) { - row.forEach((item) => { - const originalUser = item._raw ? item._raw.User : item.User - permissions.push({ - UserID: originalUser, // Use original identifier for API calls - PermissionLevel: item.AccessRights, - FolderName: item.FolderName, - Modification: 'Remove', - }) - }) - } else { - const originalUser = row._raw ? row._raw.User : row.User - permissions.push({ - UserID: originalUser, // Use original identifier for API calls - PermissionLevel: row.AccessRights, - FolderName: row.FolderName, - Modification: 'Remove', - }) - } + const rows = Array.isArray(row) ? row : [row] + // UserId is the resolved recipient; User is only a display + // name, which Exchange cannot resolve when two share it. + const permissions = rows.map((item) => ({ + UserID: item._raw?.UserId || item._raw?.User || item.User, + DisplayName: item._raw?.User || item.User, + PermissionLevel: item.AccessRights, + FolderName: item.FolderName, + Modification: 'Remove', + })) return { userID: graphUserRequest.data?.[0]?.userPrincipalName, tenantFilter: userSettingsDefaults.currentTenant, @@ -870,7 +860,8 @@ const Page = () => { tenantFilter: userSettingsDefaults.currentTenant, permissions: [ { - UserID: originalUser, // Use original identifier for API calls + UserID: data._raw?.UserId || originalUser, + DisplayName: originalUser, PermissionLevel: data.AccessRights, FolderName: data.FolderName, Modification: 'Remove', @@ -944,26 +935,16 @@ const Page = () => { icon: , url: '/api/ExecModifyContactPerms', customDataformatter: (row, action, formData) => { - var permissions = [] - if (Array.isArray(row)) { - row.forEach((item) => { - const originalUser = item._raw ? item._raw.User : item.User - permissions.push({ - UserID: originalUser, // Use original identifier for API calls - PermissionLevel: item.AccessRights, - FolderName: item.FolderName, - Modification: 'Remove', - }) - }) - } else { - const originalUser = row._raw ? row._raw.User : row.User - permissions.push({ - UserID: originalUser, // Use original identifier for API calls - PermissionLevel: row.AccessRights, - FolderName: row.FolderName, - Modification: 'Remove', - }) - } + const rows = Array.isArray(row) ? row : [row] + // UserId is the resolved recipient; User is only a display + // name, which Exchange cannot resolve when two share it. + const permissions = rows.map((item) => ({ + UserID: item._raw?.UserId || item._raw?.User || item.User, + DisplayName: item._raw?.User || item.User, + PermissionLevel: item.AccessRights, + FolderName: item.FolderName, + Modification: 'Remove', + })) return { userID: graphUserRequest.data?.[0]?.userPrincipalName, tenantFilter: userSettingsDefaults.currentTenant, @@ -1013,7 +994,8 @@ const Page = () => { tenantFilter: userSettingsDefaults.currentTenant, permissions: [ { - UserID: originalUser, // Use original identifier for API calls + UserID: data._raw?.UserId || originalUser, + DisplayName: originalUser, PermissionLevel: data.AccessRights, FolderName: data.FolderName, Modification: 'Remove',