From b563b25dc2c1d99db12a4851512b43d48fb0484f Mon Sep 17 00:00:00 2001 From: Jacob Newman Date: Wed, 13 May 2026 13:49:00 +0100 Subject: [PATCH 001/170] feat(halo): add Get-HaloUser lookup function Adds a HaloPSA user lookup helper that resolves a Microsoft 365 end-user to their HaloPSA contact id, scoped to a specific client. Matches by Azure Object ID first (azureoid field on the contact), then falls back to email address. Returns $null when no match is found so callers can decide how to handle unmatched users. Foundation for linking CIPP-generated alert tickets to the affected end-user instead of the client's General User contact. Co-Authored-By: Claude Opus 4.7 --- .../Public/Halo/Get-HaloUser.ps1 | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 Modules/CippExtensions/Public/Halo/Get-HaloUser.ps1 diff --git a/Modules/CippExtensions/Public/Halo/Get-HaloUser.ps1 b/Modules/CippExtensions/Public/Halo/Get-HaloUser.ps1 new file mode 100644 index 0000000000000..7ac85349ff1a0 --- /dev/null +++ b/Modules/CippExtensions/Public/Halo/Get-HaloUser.ps1 @@ -0,0 +1,63 @@ +function Get-HaloUser { + <# + .SYNOPSIS + Look up a HaloPSA user/contact for a Microsoft 365 end-user. + .DESCRIPTION + Searches the HaloPSA /Users endpoint scoped to a specific client. Matches first by Azure + Object ID (HaloPSA contact field 'azureoid'), then falls back to email address. Returns the + matched HaloPSA user object's id, or $null when no match is found. + .PARAMETER AzureOID + The Microsoft Entra (Azure AD) Object ID of the user to match. Preferred when present. + .PARAMETER Email + The user's email address (typically the UPN). Used as a fallback when AzureOID is missing + or returns no match. + .PARAMETER ClientId + The HaloPSA client id to scope the search to. + .PARAMETER Configuration + The HaloPSA extension configuration object (already extracted from Extensionsconfig). + .PARAMETER Token + A valid Halo OAuth token object as returned by Get-HaloToken. + #> + [CmdletBinding()] + param ( + [string]$AzureOID, + [string]$Email, + [Parameter(Mandatory = $true)] + $ClientId, + [Parameter(Mandatory = $true)] + $Configuration, + [Parameter(Mandatory = $true)] + $Token + ) + + $Headers = @{ Authorization = "Bearer $($Token.access_token)" } + $BaseUri = "$($Configuration.ResourceURL)/Users?client_id=$ClientId&includeinactive=false&pageinate=false" + + $TrySearch = { + param($Term) + try { + $EncodedTerm = [System.Uri]::EscapeDataString($Term) + $Response = Invoke-RestMethod -Uri "$BaseUri&search=$EncodedTerm" -ContentType 'application/json' -Method GET -Headers $Headers + if ($Response.users) { return $Response.users } + return $Response + } catch { + $Message = if ($_.ErrorDetails.Message) { Get-NormalizedError -Message $_.ErrorDetails.Message } else { $_.Exception.Message } + Write-LogMessage -API 'HaloPSATicket' -message "Halo user search failed for term '$Term' in client $ClientId: $Message" -sev Warning + return @() + } + } + + if ($AzureOID) { + $Results = & $TrySearch $AzureOID + $Match = $Results | Where-Object { $_.azureoid -and ($_.azureoid -eq $AzureOID) } | Select-Object -First 1 + if ($Match) { return $Match.id } + } + + if ($Email) { + $Results = & $TrySearch $Email + $Match = $Results | Where-Object { $_.emailaddress -and ($_.emailaddress -ieq $Email) } | Select-Object -First 1 + if ($Match) { return $Match.id } + } + + return $null +} From e3b2a61d7c82e7be580cfe9455919f04011a3fa6 Mon Sep 17 00:00:00 2001 From: Jacob Newman Date: Wed, 13 May 2026 13:50:10 +0100 Subject: [PATCH 002/170] feat(halo): link tickets to affected user contacts when configured Adds optional UserUPN/AzureOID/DisplayName parameters to New-HaloPSATicket. When the HaloPSA.LinkTicketsToUsers setting is enabled and a user identifier is supplied, looks up the matching HaloPSA contact (via Get-HaloUser) and populates userlookup.id and user_name on the ticket payload so the ticket lands directly on the end-user's contact record rather than the client's General User. Unmatched users fall back to the existing userlookup.id = -1 (General User) behaviour, with the affected UPN appended to the ticket description and a warning logged to the CIPP logbook for follow-up. Consolidation hash now includes the UPN when user-linking is active so that per-user tickets for the same alert title don't collapse onto each other. Co-Authored-By: Claude Opus 4.7 --- .../Public/Halo/New-HaloPSATicket.ps1 | 43 ++++++++++++++++--- 1 file changed, 37 insertions(+), 6 deletions(-) diff --git a/Modules/CippExtensions/Public/Halo/New-HaloPSATicket.ps1 b/Modules/CippExtensions/Public/Halo/New-HaloPSATicket.ps1 index 0249248c88ec6..35991feef2a31 100644 --- a/Modules/CippExtensions/Public/Halo/New-HaloPSATicket.ps1 +++ b/Modules/CippExtensions/Public/Halo/New-HaloPSATicket.ps1 @@ -3,15 +3,34 @@ function New-HaloPSATicket { param ( $title, $description, - $client + $client, + [string]$UserUPN, + [string]$AzureOID, + [string]$DisplayName ) #Get HaloPSA Token based on the config we have. $Table = Get-CIPPTable -TableName Extensionsconfig $Configuration = ((Get-CIPPAzDataTableEntity @Table).config | ConvertFrom-Json).HaloPSA $TicketTable = Get-CIPPTable -TableName 'PSATickets' $token = Get-HaloToken -configuration $Configuration - # sha hash title - $TitleHash = Get-StringHash -String $title + + # Resolve affected user to a HaloPSA contact when the integration is configured for it. + # Unmatched users fall through to userlookup.id = -1 (the client's General User contact). + $MatchedUserId = $null + $UserLinkActive = $Configuration.LinkTicketsToUsers -and ($UserUPN -or $AzureOID) + if ($UserLinkActive) { + $MatchedUserId = Get-HaloUser -AzureOID $AzureOID -Email $UserUPN -ClientId $client -Configuration $Configuration -Token $token + if (-not $MatchedUserId) { + $UnmatchedLabel = if ($DisplayName) { "$DisplayName ($UserUPN)" } else { $UserUPN } + Write-LogMessage -API 'HaloPSATicket' -message "No HaloPSA contact match for $UserUPN in client $client - falling back to General User" -sev Warning + $description = "$description

Affected user: $UnmatchedLabel - no matching HaloPSA contact found, ticket assigned to General User.

" + } + } + + # When linking is active, include UPN in the consolidation key so per-user tickets don't + # collapse onto each other when the same alert title fires for multiple users. + $HashInput = if ($UserLinkActive -and $UserUPN) { "$title|$UserUPN" } else { $title } + $TitleHash = Get-StringHash -String $HashInput if ($Configuration.ConsolidateTickets) { $ExistingTicket = Get-CIPPAzDataTableEntity @TicketTable -Filter "PartitionKey eq 'HaloPSA' and RowKey eq '$($client)-$($TitleHash)'" @@ -62,17 +81,29 @@ function New-HaloPSATicket { } } + $UserLookupId = if ($MatchedUserId) { $MatchedUserId } else { -1 } + $UserLookupDisplay = if ($MatchedUserId) { + if ($DisplayName) { $DisplayName } else { $UserUPN } + } else { + 'Enter Details Manually' + } + $UserNameValue = if ($MatchedUserId) { + if ($DisplayName) { $DisplayName } else { $UserUPN } + } else { + $null + } + $Object = [PSCustomObject]@{ files = $null usertype = 1 userlookup = @{ - id = -1 - lookupdisplay = 'Enter Details Manually' + id = $UserLookupId + lookupdisplay = $UserLookupDisplay } client_id = ($client | Select-Object -Last 1) _forcereassign = $true site_id = $null - user_name = $null + user_name = $UserNameValue reportedby = $null summary = $title details_html = $description From 5f470b1ed4a507054d4e95a0a53cc610d1763adc Mon Sep 17 00:00:00 2001 From: Jacob Newman Date: Wed, 13 May 2026 13:50:58 +0100 Subject: [PATCH 003/170] feat(halo): thread AffectedUser from alerts to HaloPSA ticket creation When an alert payload includes an AffectedUser (UPN, optional AzureOID, optional DisplayName) and HaloPSA.LinkTicketsToUsers is enabled, pass it through to New-HaloPSATicket. Best-effort resolves the user's Azure Object ID via Graph when only the UPN was supplied, so the HaloPSA contact lookup can prefer the more reliable azureoid match. Existing behaviour is unchanged when AffectedUser is absent or the toggle is off. Co-Authored-By: Claude Opus 4.7 --- .../Public/New-CippExtAlert.ps1 | 32 ++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/Modules/CippExtensions/Public/New-CippExtAlert.ps1 b/Modules/CippExtensions/Public/New-CippExtAlert.ps1 index bae549bf07196..aec62ffae8fb4 100644 --- a/Modules/CippExtensions/Public/New-CippExtAlert.ps1 +++ b/Modules/CippExtensions/Public/New-CippExtAlert.ps1 @@ -20,7 +20,37 @@ function New-CippExtAlert { Write-Host "MappedId: $MappedId" if (!$mappedId) { $MappedId = 1 } Write-Host "MappedId: $MappedId" - New-HaloPSATicket -Title $Alert.AlertTitle -Description $Alert.AlertText -Client $mappedId + + $TicketParams = @{ + Title = $Alert.AlertTitle + Description = $Alert.AlertText + Client = $MappedId + } + + if ($Alert.AffectedUser -and $Configuration.HaloPSA.LinkTicketsToUsers) { + $UPN = $Alert.AffectedUser.UPN + $OID = $Alert.AffectedUser.AzureOID + $Display = $Alert.AffectedUser.DisplayName + + # Best-effort: resolve UPN -> Azure Object ID via Graph if we don't already have it. + # Failure here is non-fatal; Get-HaloUser will still try the email-based lookup. + if (-not $OID -and $UPN -and $Alert.TenantId) { + try { + $EncodedUPN = [System.Uri]::EscapeDataString($UPN) + $GraphUser = New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/users/$EncodedUPN`?`$select=id,displayName,userPrincipalName" -tenantid $Alert.TenantId -AsApp $true + if ($GraphUser.id) { $OID = $GraphUser.id } + if (-not $Display -and $GraphUser.displayName) { $Display = $GraphUser.displayName } + } catch { + Write-Information "Could not resolve Graph user for $UPN in tenant $($Alert.TenantId): $($_.Exception.Message)" + } + } + + if ($UPN) { $TicketParams.UserUPN = $UPN } + if ($OID) { $TicketParams.AzureOID = $OID } + if ($Display) { $TicketParams.DisplayName = $Display } + } + + New-HaloPSATicket @TicketParams } } 'Gradient' { From 21d7a2fba3e55445cbca16b1ccd9e22dc1440eb7 Mon Sep 17 00:00:00 2001 From: Jacob Newman Date: Wed, 13 May 2026 13:51:42 +0100 Subject: [PATCH 004/170] feat(alerts): add AffectedUser parameter to Send-CIPPAlert Lets callers attach an affected-user object (UPN, AzureOID, DisplayName) to PSA alerts. The user is added to the Alert hashtable handed to New-CippExtAlert, which uses it to link the resulting HaloPSA ticket to the matching contact. Co-Authored-By: Claude Opus 4.7 --- Modules/CIPPCore/Public/Send-CIPPAlert.ps1 | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Modules/CIPPCore/Public/Send-CIPPAlert.ps1 b/Modules/CIPPCore/Public/Send-CIPPAlert.ps1 index d069a2e2eb84e..18ef1d8f75c05 100644 --- a/Modules/CIPPCore/Public/Send-CIPPAlert.ps1 +++ b/Modules/CIPPCore/Public/Send-CIPPAlert.ps1 @@ -16,6 +16,7 @@ function Send-CIPPAlert { $TableName, $RowKey = [string][guid]::NewGuid(), $Attachments, + $AffectedUser, [switch]$UseStandardizedSchema ) Write-Information 'Shipping Alert' @@ -334,6 +335,9 @@ function Send-CIPPAlert { AlertText = "$HTMLContent" AlertTitle = "$($Title)" } + if ($AffectedUser) { + $Alert.AffectedUser = $AffectedUser + } New-CippExtAlert -Alert $Alert Write-LogMessage -API 'Webhook Alerts' -tenant $TenantFilter -message "Sent PSA alert $title" -sev info } catch { From 71a208f3b2dd3933bbbcdee1c7909aaa2450ee24 Mon Sep 17 00:00:00 2001 From: Jacob Newman Date: Wed, 13 May 2026 13:52:29 +0100 Subject: [PATCH 005/170] feat(alerts): split per-user PSA tickets when HaloPSA user-linking is on When the HaloPSA integration has LinkTicketsToUsers enabled and a scheduled alert returns rows containing a UPN-like field (UserPrincipalName, UPN, userId, Userkey), Send-CIPPScheduledTaskAlert now groups the result rows by that field and emits one PSA call per affected user. Each call carries an AffectedUser payload so the resulting HaloPSA ticket lands on the correct contact. Rows with no UPN value still produce a single tenant-scoped ticket, and any unexpected error in the split path falls back to today's consolidated-ticket behaviour. Co-Authored-By: Claude Opus 4.7 --- .../Public/Send-CIPPScheduledTaskAlert.ps1 | 49 ++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/Modules/CIPPCore/Public/Send-CIPPScheduledTaskAlert.ps1 b/Modules/CIPPCore/Public/Send-CIPPScheduledTaskAlert.ps1 index 698be2e0e2440..a79af2d6302be 100644 --- a/Modules/CIPPCore/Public/Send-CIPPScheduledTaskAlert.ps1 +++ b/Modules/CIPPCore/Public/Send-CIPPScheduledTaskAlert.ps1 @@ -137,7 +137,54 @@ function Send-CIPPScheduledTaskAlert { # Send to configured alert targets switch -wildcard ($TaskInfo.PostExecution) { '*psa*' { - Send-CIPPAlert -Type 'psa' -Title $title -HTMLContent $HTML -TenantFilter $TenantFilter + $PsaSplitSent = $false + if ($TaskType -eq 'Alert' -and $Results -is [array] -and $Results.Count -gt 0 -and $Results[0] -isnot [string]) { + try { + $ExtConfigTable = Get-CIPPTable -TableName Extensionsconfig + $ExtConfig = (Get-CIPPAzDataTableEntity @ExtConfigTable).config | ConvertFrom-Json -ErrorAction SilentlyContinue + $HaloConfig = $ExtConfig.HaloPSA + + if ($HaloConfig -and $HaloConfig.Enabled -and $HaloConfig.LinkTicketsToUsers) { + $UpnFieldCandidates = @('UserPrincipalName', 'userPrincipalName', 'UPN', 'userId', 'Userkey') + $RowProperties = $Results[0].PSObject.Properties.Name + $UpnField = $UpnFieldCandidates | Where-Object { $_ -in $RowProperties } | Select-Object -First 1 + + if ($UpnField) { + $DisplayFieldCandidates = @('DisplayName', 'displayName', 'userDisplayName') + $DisplayField = $DisplayFieldCandidates | Where-Object { $_ -in $RowProperties } | Select-Object -First 1 + + $Groups = $Results | Group-Object -Property $UpnField + + foreach ($Group in $Groups) { + $GroupKey = $Group.Name + $GroupHTMLFragment = $Group.Group | ConvertTo-Html -Fragment + $GroupHTML = $GroupHTMLFragment -replace '', "This alert is for tenant $TenantFilter.

$TableDesign
" | Out-String + + if ([string]::IsNullOrWhiteSpace($GroupKey)) { + # Rows without a usable user identifier - send as a single tenant-scoped ticket. + Send-CIPPAlert -Type 'psa' -Title $title -HTMLContent $GroupHTML -TenantFilter $TenantFilter + } else { + $GroupDisplayName = if ($DisplayField) { $Group.Group[0].$DisplayField } else { $null } + $UserLabel = if ($GroupDisplayName) { "$GroupDisplayName ($GroupKey)" } else { $GroupKey } + $UserTitle = "$title - $UserLabel" + $AffectedUser = [pscustomobject]@{ + UPN = $GroupKey + DisplayName = $GroupDisplayName + } + Send-CIPPAlert -Type 'psa' -Title $UserTitle -HTMLContent $GroupHTML -TenantFilter $TenantFilter -AffectedUser $AffectedUser + } + } + $PsaSplitSent = $true + } + } + } catch { + Write-Information "Failed to split PSA alert by user, falling back to consolidated ticket: $($_.Exception.Message)" + } + } + + if (-not $PsaSplitSent) { + Send-CIPPAlert -Type 'psa' -Title $title -HTMLContent $HTML -TenantFilter $TenantFilter + } } '*email*' { $EmailParams = @{ From 95386fc3c766373d22830dd06783086e746eebfe Mon Sep 17 00:00:00 2001 From: Jacob Newman Date: Wed, 13 May 2026 13:55:01 +0100 Subject: [PATCH 006/170] fix(halo): escape ClientId variable in Get-HaloUser warning message PowerShell parses '$ClientId:' as a scope-qualified variable reference, breaking the script's parser. Wrap the variable in ${} so it's interpolated correctly inside the warning message. Co-Authored-By: Claude Opus 4.7 --- Modules/CippExtensions/Public/Halo/Get-HaloUser.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Modules/CippExtensions/Public/Halo/Get-HaloUser.ps1 b/Modules/CippExtensions/Public/Halo/Get-HaloUser.ps1 index 7ac85349ff1a0..d2f0fa50fa9c1 100644 --- a/Modules/CippExtensions/Public/Halo/Get-HaloUser.ps1 +++ b/Modules/CippExtensions/Public/Halo/Get-HaloUser.ps1 @@ -42,7 +42,7 @@ function Get-HaloUser { return $Response } catch { $Message = if ($_.ErrorDetails.Message) { Get-NormalizedError -Message $_.ErrorDetails.Message } else { $_.Exception.Message } - Write-LogMessage -API 'HaloPSATicket' -message "Halo user search failed for term '$Term' in client $ClientId: $Message" -sev Warning + Write-LogMessage -API 'HaloPSATicket' -message "Halo user search failed for term '$Term' in client ${ClientId}: $Message" -sev Warning return @() } } From b190cf0bd874bc6da24f7e281150a1682c3e0350 Mon Sep 17 00:00:00 2001 From: Jacob Newman Date: Wed, 13 May 2026 15:28:39 +0100 Subject: [PATCH 007/170] fix(halo): populate site_id from matched user record HaloPSA rejects tickets with a specific user (userlookup.id != -1) when site_id is null - "Please select a valid Client/Site/User". The General User fallback (id = -1) auto-resolves the site, but a real contact does not. Get-HaloUser now returns both the matched user's id and site_id. New- HaloPSATicket pulls the site_id from that record onto the payload so the ticket can be created against the correct site under the client. Unmatched users still produce site_id = null, preserving today's General User behaviour. Co-Authored-By: Claude Opus 4.7 --- .../Public/Halo/Get-HaloUser.ps1 | 17 +++++++++++++---- .../Public/Halo/New-HaloPSATicket.ps1 | 19 ++++++++++++------- 2 files changed, 25 insertions(+), 11 deletions(-) diff --git a/Modules/CippExtensions/Public/Halo/Get-HaloUser.ps1 b/Modules/CippExtensions/Public/Halo/Get-HaloUser.ps1 index d2f0fa50fa9c1..5b25112ab2193 100644 --- a/Modules/CippExtensions/Public/Halo/Get-HaloUser.ps1 +++ b/Modules/CippExtensions/Public/Halo/Get-HaloUser.ps1 @@ -4,8 +4,9 @@ function Get-HaloUser { Look up a HaloPSA user/contact for a Microsoft 365 end-user. .DESCRIPTION Searches the HaloPSA /Users endpoint scoped to a specific client. Matches first by Azure - Object ID (HaloPSA contact field 'azureoid'), then falls back to email address. Returns the - matched HaloPSA user object's id, or $null when no match is found. + Object ID (HaloPSA contact field 'azureoid'), then falls back to email address. Returns a + small object containing the matched user's id and site_id (Halo requires both when a + specific user is set on a ticket), or $null when no match is found. .PARAMETER AzureOID The Microsoft Entra (Azure AD) Object ID of the user to match. Preferred when present. .PARAMETER Email @@ -47,16 +48,24 @@ function Get-HaloUser { } } + $BuildResult = { + param($MatchedUser) + [pscustomobject]@{ + id = $MatchedUser.id + site_id = $MatchedUser.site_id + } + } + if ($AzureOID) { $Results = & $TrySearch $AzureOID $Match = $Results | Where-Object { $_.azureoid -and ($_.azureoid -eq $AzureOID) } | Select-Object -First 1 - if ($Match) { return $Match.id } + if ($Match) { return & $BuildResult $Match } } if ($Email) { $Results = & $TrySearch $Email $Match = $Results | Where-Object { $_.emailaddress -and ($_.emailaddress -ieq $Email) } | Select-Object -First 1 - if ($Match) { return $Match.id } + if ($Match) { return & $BuildResult $Match } } return $null diff --git a/Modules/CippExtensions/Public/Halo/New-HaloPSATicket.ps1 b/Modules/CippExtensions/Public/Halo/New-HaloPSATicket.ps1 index 35991feef2a31..14f8d2c524676 100644 --- a/Modules/CippExtensions/Public/Halo/New-HaloPSATicket.ps1 +++ b/Modules/CippExtensions/Public/Halo/New-HaloPSATicket.ps1 @@ -16,11 +16,11 @@ function New-HaloPSATicket { # Resolve affected user to a HaloPSA contact when the integration is configured for it. # Unmatched users fall through to userlookup.id = -1 (the client's General User contact). - $MatchedUserId = $null + $MatchedUser = $null $UserLinkActive = $Configuration.LinkTicketsToUsers -and ($UserUPN -or $AzureOID) if ($UserLinkActive) { - $MatchedUserId = Get-HaloUser -AzureOID $AzureOID -Email $UserUPN -ClientId $client -Configuration $Configuration -Token $token - if (-not $MatchedUserId) { + $MatchedUser = Get-HaloUser -AzureOID $AzureOID -Email $UserUPN -ClientId $client -Configuration $Configuration -Token $token + if (-not $MatchedUser) { $UnmatchedLabel = if ($DisplayName) { "$DisplayName ($UserUPN)" } else { $UserUPN } Write-LogMessage -API 'HaloPSATicket' -message "No HaloPSA contact match for $UserUPN in client $client - falling back to General User" -sev Warning $description = "$description

Affected user: $UnmatchedLabel - no matching HaloPSA contact found, ticket assigned to General User.

" @@ -32,6 +32,11 @@ function New-HaloPSATicket { $HashInput = if ($UserLinkActive -and $UserUPN) { "$title|$UserUPN" } else { $title } $TitleHash = Get-StringHash -String $HashInput + # Halo requires a site_id whenever a specific user is set on the ticket; pull it from the + # matched user record. When no user is matched, leave site_id null and let Halo resolve it + # from the General User (id = -1). + $SiteId = if ($MatchedUser) { $MatchedUser.site_id } else { $null } + if ($Configuration.ConsolidateTickets) { $ExistingTicket = Get-CIPPAzDataTableEntity @TicketTable -Filter "PartitionKey eq 'HaloPSA' and RowKey eq '$($client)-$($TitleHash)'" if ($ExistingTicket) { @@ -81,13 +86,13 @@ function New-HaloPSATicket { } } - $UserLookupId = if ($MatchedUserId) { $MatchedUserId } else { -1 } - $UserLookupDisplay = if ($MatchedUserId) { + $UserLookupId = if ($MatchedUser) { $MatchedUser.id } else { -1 } + $UserLookupDisplay = if ($MatchedUser) { if ($DisplayName) { $DisplayName } else { $UserUPN } } else { 'Enter Details Manually' } - $UserNameValue = if ($MatchedUserId) { + $UserNameValue = if ($MatchedUser) { if ($DisplayName) { $DisplayName } else { $UserUPN } } else { $null @@ -102,7 +107,7 @@ function New-HaloPSATicket { } client_id = ($client | Select-Object -Last 1) _forcereassign = $true - site_id = $null + site_id = $SiteId user_name = $UserNameValue reportedby = $null summary = $title From a74f113a1c5d254138041a24f12d95673b345964 Mon Sep 17 00:00:00 2001 From: Jacob Newman Date: Wed, 13 May 2026 15:31:58 +0100 Subject: [PATCH 008/170] fix(halo): cast user id and site_id to [int] in Get-HaloUser Invoke-RestMethod deserialises JSON numbers as [double] by default, so a site_id of 95 round-trips as "95.0" - which Halo's ticket endpoint rejects ("Input string '95.0' is not a valid integer"). Cast both id and site_id to [int] when building the result so ConvertTo-Json emits them as plain integers. Co-Authored-By: Claude Opus 4.7 --- Modules/CippExtensions/Public/Halo/Get-HaloUser.ps1 | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Modules/CippExtensions/Public/Halo/Get-HaloUser.ps1 b/Modules/CippExtensions/Public/Halo/Get-HaloUser.ps1 index 5b25112ab2193..f957a7a4ce04c 100644 --- a/Modules/CippExtensions/Public/Halo/Get-HaloUser.ps1 +++ b/Modules/CippExtensions/Public/Halo/Get-HaloUser.ps1 @@ -50,9 +50,11 @@ function Get-HaloUser { $BuildResult = { param($MatchedUser) + # Cast to [int] so PowerShell's default [double] deserialisation of JSON numbers + # doesn't serialise back as e.g. "95.0", which Halo rejects. [pscustomobject]@{ - id = $MatchedUser.id - site_id = $MatchedUser.site_id + id = [int]$MatchedUser.id + site_id = [int]$MatchedUser.site_id } } From 0582c232c8306c08e4291d6c91d7909cf4e8ff8f Mon Sep 17 00:00:00 2001 From: Jacob Newman Date: Wed, 13 May 2026 17:04:59 +0100 Subject: [PATCH 009/170] feat(halo): link audit-log PSA tickets to the affected user Audit-log driven alerts (new inbox rule, role change, MFA disabled, sessions revoked, etc.) now extract the affected user from the audit record and pass it through to the PSA pipeline as AffectedUser. When HaloPSA.LinkTicketsToUsers is enabled, the resulting Halo ticket lands on the matching contact instead of the client's General User. Detection prefers ObjectId (target of the action) over UserId/Userkey, taking the resolved UPN from the upstream GUID-mapped CIPP* property when available and using the raw GUID directly as the AzureOID for the Halo lookup. Service principal events naturally fall through with no AffectedUser (no @ in any candidate), preserving the original General User behaviour. Co-Authored-By: Claude Opus 4.7 --- .../Webhooks/Invoke-CIPPWebhookProcessing.ps1 | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/Modules/CIPPCore/Public/Webhooks/Invoke-CIPPWebhookProcessing.ps1 b/Modules/CIPPCore/Public/Webhooks/Invoke-CIPPWebhookProcessing.ps1 index 6ca3b39290fa4..1e478b6a683b6 100644 --- a/Modules/CIPPCore/Public/Webhooks/Invoke-CIPPWebhookProcessing.ps1 +++ b/Modules/CIPPCore/Public/Webhooks/Invoke-CIPPWebhookProcessing.ps1 @@ -121,6 +121,38 @@ function Invoke-CippWebhookProcessing { $AuditLogLink = '{0}/tenant/administration/audit-logs/log?logId={1}&tenantFilter={2}' -f $CIPPURL, $LogId, $Tenant.defaultDomainName $GenerateEmail = New-CIPPAlertTemplate -format 'html' -data $Data -ActionResults $ActionResults -CIPPURL $CIPPURL -Tenant $Tenant.defaultDomainName -AuditLogLink $AuditLogLink -AlertComment $AlertComment + # Derive the affected end-user from the audit record so PSA tickets can be linked to the + # right HaloPSA contact when HaloPSA.LinkTicketsToUsers is enabled. The upstream GUID mapper + # has already attached CIPP-prefixed properties (e.g. CIPPObjectId) holding the resolved UPN + # for any property that contained a user's Object ID; the raw property usually still holds + # the original GUID, which we can use directly as the AzureOID for the Halo lookup. + $AffectedUser = $null + $GuidRegex = '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$' + $UserCandidates = @( + @{ Raw = 'ObjectId'; Mapped = 'CIPPObjectId' } + @{ Raw = 'UserId'; Mapped = 'CIPPUserId' } + @{ Raw = 'Userkey'; Mapped = 'CIPPUserkey' } + ) + foreach ($Candidate in $UserCandidates) { + $RawValue = $Data.$($Candidate.Raw) + $MappedValue = $Data.$($Candidate.Mapped) + if (-not $RawValue -and -not $MappedValue) { continue } + + $UPN = $null; $OID = $null + if ($MappedValue -is [string] -and $MappedValue -match '@') { $UPN = $MappedValue } + elseif ($RawValue -is [string] -and $RawValue -match '@') { $UPN = $RawValue } + + if ($RawValue -is [string] -and $RawValue -match $GuidRegex) { $OID = $RawValue } + + if ($UPN -or $OID) { + $AffectedUser = [pscustomobject]@{ + UPN = $UPN + AzureOID = $OID + } + break + } + } + Write-Host 'Going to create the content' foreach ($action in $ActionList ) { switch ($action) { @@ -142,6 +174,9 @@ function Invoke-CippWebhookProcessing { HTMLContent = $GenerateEmail.htmlcontent TenantFilter = $TenantFilter } + if ($AffectedUser) { + $CIPPAlert.AffectedUser = $AffectedUser + } Send-CIPPAlert @CIPPAlert } 'generateWebhook' { From e7657c7f23c3c9ca36b867c93a349aa3ff552f55 Mon Sep 17 00:00:00 2001 From: Jacob Newman Date: Wed, 13 May 2026 17:13:53 +0100 Subject: [PATCH 010/170] chore(alerts): surface PSA delivery outcome to Information stream Adds visibility to the PSA branch of Send-CIPPAlert so callers can see what actually happened: - When sendtoIntegration is disabled in CippNotifications config, log a clear skip reason instead of silently doing nothing. - When an AffectedUser is attached, log which UPN/OID is being targeted. - Capture and surface the result string returned by New-CippExtAlert (which carries through "Ticket created in HaloPSA: " or the failure message). No behavioural change to delivery - just diagnostics. Co-Authored-By: Claude Opus 4.7 --- Modules/CIPPCore/Public/Send-CIPPAlert.ps1 | 41 +++++++++++++--------- 1 file changed, 24 insertions(+), 17 deletions(-) diff --git a/Modules/CIPPCore/Public/Send-CIPPAlert.ps1 b/Modules/CIPPCore/Public/Send-CIPPAlert.ps1 index cfeebf4b95cbf..edd6a2af17edf 100644 --- a/Modules/CIPPCore/Public/Send-CIPPAlert.ps1 +++ b/Modules/CIPPCore/Public/Send-CIPPAlert.ps1 @@ -327,24 +327,31 @@ function Send-CIPPAlert { if ($Type -eq 'psa') { Write-Information 'Trying to send to PSA' - if ($config.sendtoIntegration) { - if ($PSCmdlet.ShouldProcess('PSA', 'Sending alert')) { - try { - $Alert = @{ - TenantId = $TenantFilter - AlertText = "$HTMLContent" - AlertTitle = "$($Title)" - } - if ($AffectedUser) { - $Alert.AffectedUser = $AffectedUser - } - New-CippExtAlert -Alert $Alert - Write-LogMessage -API 'Webhook Alerts' -tenant $TenantFilter -message "Sent PSA alert $title" -sev info - } catch { - $ErrorMessage = Get-CippException -Exception $_ - Write-Information "Could not send alerts to ticketing system: $($ErrorMessage.NormalizedError)" - Write-LogMessage -API 'Webhook Alerts' -tenant $TenantFilter -message "Could not send alerts to ticketing system: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + if (-not $config.sendtoIntegration) { + Write-Information 'PSA delivery skipped: sendtoIntegration is disabled in CippNotifications config. Enable it under Settings -> Notifications to route alerts to your PSA.' + return + } + if ($PSCmdlet.ShouldProcess('PSA', 'Sending alert')) { + try { + $Alert = @{ + TenantId = $TenantFilter + AlertText = "$HTMLContent" + AlertTitle = "$($Title)" + } + if ($AffectedUser) { + $Alert.AffectedUser = $AffectedUser + $UserLabel = if ($AffectedUser.UPN) { $AffectedUser.UPN } elseif ($AffectedUser.AzureOID) { "OID:$($AffectedUser.AzureOID)" } else { 'unknown' } + Write-Information "PSA alert AffectedUser: $UserLabel" + } + $PsaResult = New-CippExtAlert -Alert $Alert + if ($PsaResult) { + Write-Information "PSA result: $PsaResult" } + Write-LogMessage -API 'Webhook Alerts' -tenant $TenantFilter -message "Sent PSA alert $title" -sev info + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-Information "Could not send alerts to ticketing system: $($ErrorMessage.NormalizedError)" + Write-LogMessage -API 'Webhook Alerts' -tenant $TenantFilter -message "Could not send alerts to ticketing system: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage } } } From d83a6fb09e82868b9b8fcb3cb13435cf0b76a510 Mon Sep 17 00:00:00 2001 From: Jacob Newman Date: Thu, 14 May 2026 08:44:57 +0100 Subject: [PATCH 011/170] fix(halo): cast client_id to [int] on outbound ticket payload The HaloPSA mapping table stores IntegrationId as a string, which then threads through New-CippExtAlert -> New-HaloPSATicket and lands in the Tickets payload as e.g. "client_id":"57". Halo rejects this with the generic "Please select a valid Client/Site/User" error - the same class of bug we already fixed for site_id. Direct PowerShell test calls used integer literals so they didn't hit this; only the audit-log and scheduled-alert paths (which read the mapping from storage) tripped it. Co-Authored-By: Claude Opus 4.7 --- Modules/CippExtensions/Public/Halo/New-HaloPSATicket.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Modules/CippExtensions/Public/Halo/New-HaloPSATicket.ps1 b/Modules/CippExtensions/Public/Halo/New-HaloPSATicket.ps1 index 14f8d2c524676..5a40005888337 100644 --- a/Modules/CippExtensions/Public/Halo/New-HaloPSATicket.ps1 +++ b/Modules/CippExtensions/Public/Halo/New-HaloPSATicket.ps1 @@ -105,7 +105,7 @@ function New-HaloPSATicket { id = $UserLookupId lookupdisplay = $UserLookupDisplay } - client_id = ($client | Select-Object -Last 1) + client_id = [int]($client | Select-Object -Last 1) _forcereassign = $true site_id = $SiteId user_name = $UserNameValue From ec152c637b2205ab6b58b3c56f44344bb94972b7 Mon Sep 17 00:00:00 2001 From: Jacob Newman Date: Thu, 14 May 2026 09:03:54 +0100 Subject: [PATCH 012/170] fix(halo): match user lookup against all common identity fields Real-world HaloPSA contacts populated by AD/Azure AD sync often store the user's UPN in 'networklogin' or 'aaduserid' rather than (or in addition to) 'emailaddress'. The previous lookup only checked 'emailaddress', so a contact like Bob whose UPN was synced into 'networklogin' wouldn't match even though the data was right there. Now matches against: AzureOID -> azureoid, aaduserid Email -> emailaddress, networklogin, aaduserid Field lists are defined once at the top so adding more is a one-line change. Co-Authored-By: Claude Opus 4.7 --- .../Public/Halo/Get-HaloUser.ps1 | 28 +++++++++++++++---- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/Modules/CippExtensions/Public/Halo/Get-HaloUser.ps1 b/Modules/CippExtensions/Public/Halo/Get-HaloUser.ps1 index f957a7a4ce04c..8786d4f826abe 100644 --- a/Modules/CippExtensions/Public/Halo/Get-HaloUser.ps1 +++ b/Modules/CippExtensions/Public/Halo/Get-HaloUser.ps1 @@ -4,9 +4,11 @@ function Get-HaloUser { Look up a HaloPSA user/contact for a Microsoft 365 end-user. .DESCRIPTION Searches the HaloPSA /Users endpoint scoped to a specific client. Matches first by Azure - Object ID (HaloPSA contact field 'azureoid'), then falls back to email address. Returns a - small object containing the matched user's id and site_id (Halo requires both when a - specific user is set on a ticket), or $null when no match is found. + Object ID (against the HaloPSA contact fields 'azureoid' and 'aaduserid'), then falls back + to the user's email/UPN (against 'emailaddress', 'networklogin' and 'aaduserid' - Halo's + AD-sync contacts often store the UPN in any of these). Returns a small object containing + the matched user's id and site_id (Halo requires both when a specific user is set on a + ticket), or $null when no match is found. .PARAMETER AzureOID The Microsoft Entra (Azure AD) Object ID of the user to match. Preferred when present. .PARAMETER Email @@ -58,15 +60,31 @@ function Get-HaloUser { } } + # HaloPSA contacts can carry the user identity in several fields depending on how AD/Azure AD + # sync is set up. Match against all known candidates so partial integrations still resolve. + $AzureIdFields = @('azureoid', 'aaduserid') + $EmailFields = @('emailaddress', 'networklogin', 'aaduserid') + + $MatchAny = { + param($Results, $Term, $Fields) + foreach ($Result in $Results) { + foreach ($Field in $Fields) { + $Value = $Result.$Field + if ($Value -and ($Value -ieq $Term)) { return $Result } + } + } + return $null + } + if ($AzureOID) { $Results = & $TrySearch $AzureOID - $Match = $Results | Where-Object { $_.azureoid -and ($_.azureoid -eq $AzureOID) } | Select-Object -First 1 + $Match = & $MatchAny $Results $AzureOID $AzureIdFields if ($Match) { return & $BuildResult $Match } } if ($Email) { $Results = & $TrySearch $Email - $Match = $Results | Where-Object { $_.emailaddress -and ($_.emailaddress -ieq $Email) } | Select-Object -First 1 + $Match = & $MatchAny $Results $Email $EmailFields if ($Match) { return & $BuildResult $Match } } From 9947afcb25f22ef0942d6496beec445086f951cd Mon Sep 17 00:00:00 2001 From: Jacob Newman Date: Thu, 14 May 2026 09:08:54 +0100 Subject: [PATCH 013/170] fix(halo): combine search results before filtering on identity fields HaloPSA's /Users?search= parameter does not search the azureoid field, so an OID-only search returns zero rows even when a contact has that exact OID populated. Bob's contact in the test sandbox proved this: azureoid was set correctly but emailaddress/networklogin/aaduserid were all blank, so neither the OID search nor the email-field filter found him. Restructured to: 1. Run a search for each supplied term (AzureOID, Email) and merge the results into a deduped candidate pool keyed by user id. 2. Filter the pool preferring AzureOID matches against azureoid/aaduserid, then fall back to email matches against emailaddress/networklogin/ aaduserid. This way the email search brings the contact into scope and the OID filter catches it, even when none of the email-shaped fields are populated on the Halo record. Co-Authored-By: Claude Opus 4.7 --- .../Public/Halo/Get-HaloUser.ps1 | 41 +++++++++++++------ 1 file changed, 28 insertions(+), 13 deletions(-) diff --git a/Modules/CippExtensions/Public/Halo/Get-HaloUser.ps1 b/Modules/CippExtensions/Public/Halo/Get-HaloUser.ps1 index 8786d4f826abe..561ef19ac6e54 100644 --- a/Modules/CippExtensions/Public/Halo/Get-HaloUser.ps1 +++ b/Modules/CippExtensions/Public/Halo/Get-HaloUser.ps1 @@ -65,27 +65,42 @@ function Get-HaloUser { $AzureIdFields = @('azureoid', 'aaduserid') $EmailFields = @('emailaddress', 'networklogin', 'aaduserid') - $MatchAny = { - param($Results, $Term, $Fields) - foreach ($Result in $Results) { - foreach ($Field in $Fields) { - $Value = $Result.$Field - if ($Value -and ($Value -ieq $Term)) { return $Result } + # Halo's /Users?search= parameter doesn't search the azureoid field, so a search by Object ID + # alone returns zero rows even when a contact has that exact OID set. Strategy: + # 1. Run a search per supplied term and combine the results into a deduped candidate pool. + # 2. Filter the pool client-side, preferring AzureOID matches (most reliable) over email. + $Candidates = @{} + foreach ($Term in @($AzureOID, $Email) | Where-Object { $_ }) { + foreach ($Result in (& $TrySearch $Term)) { + if ($Result.id -and -not $Candidates.ContainsKey([string]$Result.id)) { + $Candidates[[string]$Result.id] = $Result } } - return $null + } + + $MatchOnAnyField = { + param($User, $Term, $Fields) + foreach ($Field in $Fields) { + $Value = $User.$Field + if ($Value -and ($Value -ieq $Term)) { return $true } + } + return $false } if ($AzureOID) { - $Results = & $TrySearch $AzureOID - $Match = & $MatchAny $Results $AzureOID $AzureIdFields - if ($Match) { return & $BuildResult $Match } + foreach ($User in $Candidates.Values) { + if (& $MatchOnAnyField $User $AzureOID $AzureIdFields) { + return & $BuildResult $User + } + } } if ($Email) { - $Results = & $TrySearch $Email - $Match = & $MatchAny $Results $Email $EmailFields - if ($Match) { return & $BuildResult $Match } + foreach ($User in $Candidates.Values) { + if (& $MatchOnAnyField $User $Email $EmailFields) { + return & $BuildResult $User + } + } } return $null From aa681a17c90f52d3593c0a4be8b6cc1a33090a68 Mon Sep 17 00:00:00 2001 From: Jacob Newman Date: Thu, 14 May 2026 09:14:31 +0100 Subject: [PATCH 014/170] fix(halo): use advanced_search for exact field-level user lookup Halo's basic ?search= parameter only searches a fixed set of indexed fields (name, email, logins...) and notably NOT azureoid. So an OID-only lookup returned zero results even when a contact had that exact OID populated. Switch the AzureOID path to ?advanced_search= with filter_type=2 (equality) against azureoid and aaduserid in turn. This queries the underlying field directly and short-circuits as soon as we find a match, avoiding the previous fetch-and-filter dance for the common case. The email path still uses the basic search but now also checks the azureoid field on returned records, so contacts with azureoid set and blank email fields still match when both terms are supplied. Co-Authored-By: Claude Opus 4.7 --- .../Public/Halo/Get-HaloUser.ps1 | 91 +++++++++++-------- 1 file changed, 52 insertions(+), 39 deletions(-) diff --git a/Modules/CippExtensions/Public/Halo/Get-HaloUser.ps1 b/Modules/CippExtensions/Public/Halo/Get-HaloUser.ps1 index 561ef19ac6e54..9ed145df4b5c1 100644 --- a/Modules/CippExtensions/Public/Halo/Get-HaloUser.ps1 +++ b/Modules/CippExtensions/Public/Halo/Get-HaloUser.ps1 @@ -36,6 +36,38 @@ function Get-HaloUser { $Headers = @{ Authorization = "Bearer $($Token.access_token)" } $BaseUri = "$($Configuration.ResourceURL)/Users?client_id=$ClientId&includeinactive=false&pageinate=false" + $BuildResult = { + param($MatchedUser) + # Cast to [int] so PowerShell's default [double] deserialisation of JSON numbers + # doesn't serialise back as e.g. "95.0", which Halo rejects. + [pscustomobject]@{ + id = [int]$MatchedUser.id + site_id = [int]$MatchedUser.site_id + } + } + + # Halo's basic ?search= parameter only searches a fixed set of indexed fields (name, email, + # logins...) and notably NOT azureoid. Use ?advanced_search= with filter_type=2 (=) for an + # exact-match query against a specific field. + $TryAdvancedSearch = { + param($FilterName, $FilterValue) + try { + $Filter = ConvertTo-Json -Compress -InputObject @(@{ + filter_name = $FilterName + filter_type = 2 # 2 = exact equality + filter_value = $FilterValue + }) + $EncodedFilter = [System.Uri]::EscapeDataString($Filter) + $Response = Invoke-RestMethod -Uri "$BaseUri&advanced_search=$EncodedFilter" -ContentType 'application/json' -Method GET -Headers $Headers + if ($Response.users) { return $Response.users } + return $Response + } catch { + $Message = if ($_.ErrorDetails.Message) { Get-NormalizedError -Message $_.ErrorDetails.Message } else { $_.Exception.Message } + Write-LogMessage -API 'HaloPSATicket' -message "Halo advanced_search failed for $FilterName='$FilterValue' in client ${ClientId}: $Message" -sev Warning + return @() + } + } + $TrySearch = { param($Term) try { @@ -50,55 +82,36 @@ function Get-HaloUser { } } - $BuildResult = { - param($MatchedUser) - # Cast to [int] so PowerShell's default [double] deserialisation of JSON numbers - # doesn't serialise back as e.g. "95.0", which Halo rejects. - [pscustomobject]@{ - id = [int]$MatchedUser.id - site_id = [int]$MatchedUser.site_id - } - } - # HaloPSA contacts can carry the user identity in several fields depending on how AD/Azure AD # sync is set up. Match against all known candidates so partial integrations still resolve. $AzureIdFields = @('azureoid', 'aaduserid') $EmailFields = @('emailaddress', 'networklogin', 'aaduserid') - # Halo's /Users?search= parameter doesn't search the azureoid field, so a search by Object ID - # alone returns zero rows even when a contact has that exact OID set. Strategy: - # 1. Run a search per supplied term and combine the results into a deduped candidate pool. - # 2. Filter the pool client-side, preferring AzureOID matches (most reliable) over email. - $Candidates = @{} - foreach ($Term in @($AzureOID, $Email) | Where-Object { $_ }) { - foreach ($Result in (& $TrySearch $Term)) { - if ($Result.id -and -not $Candidates.ContainsKey([string]$Result.id)) { - $Candidates[[string]$Result.id] = $Result - } - } - } - - $MatchOnAnyField = { - param($User, $Term, $Fields) - foreach ($Field in $Fields) { - $Value = $User.$Field - if ($Value -and ($Value -ieq $Term)) { return $true } - } - return $false - } - + # Try AzureOID first via advanced_search - exact-match on each AD identifier field, returning + # the first hit. This is the most reliable path because Halo's azureoid field is the cleanest + # link back to the Entra user. if ($AzureOID) { - foreach ($User in $Candidates.Values) { - if (& $MatchOnAnyField $User $AzureOID $AzureIdFields) { - return & $BuildResult $User - } + foreach ($Field in $AzureIdFields) { + $Match = (& $TryAdvancedSearch $Field $AzureOID) | Where-Object { $_.id } | Select-Object -First 1 + if ($Match) { return & $BuildResult $Match } } } + # Fall back to email: the basic search indexes email-shaped fields and returns candidates; + # filter client-side against any of the email-bearing fields, and also re-check the AzureOID + # against returned records (handy when a contact has azureoid set but blank email fields). if ($Email) { - foreach ($User in $Candidates.Values) { - if (& $MatchOnAnyField $User $Email $EmailFields) { - return & $BuildResult $User + $Results = & $TrySearch $Email + foreach ($User in $Results) { + if ($AzureOID) { + foreach ($Field in $AzureIdFields) { + $Value = $User.$Field + if ($Value -and ($Value -ieq $AzureOID)) { return & $BuildResult $User } + } + } + foreach ($Field in $EmailFields) { + $Value = $User.$Field + if ($Value -and ($Value -ieq $Email)) { return & $BuildResult $User } } } } From 7283ce2de8b9dcc5eca751435b10cc8952f781d1 Mon Sep 17 00:00:00 2001 From: Jacob Newman Date: Thu, 14 May 2026 11:05:12 +0100 Subject: [PATCH 015/170] fix(halo): silence advanced_search noise and recover from note-add failures Two fixes for issues surfaced by real audit-log alerts: 1. Get-HaloUser: some Halo instances don't whitelist azureoid/aaduserid as filterable advanced_search fields, returning "Invalid advanced search parameter(s)". The email-search fallback already handles this case correctly, but every alert was emitting two Warning-level logbook entries. Now only log when the failure is something other than the expected "field not whitelisted" rejection. 2. New-HaloPSATicket: when ConsolidateTickets is on and a note-add to an existing ticket fails (permission error on the configured outcome, ticket type that doesn't accept the action, etc.), the function used to return the error and never create a new ticket. The alert was effectively lost. Now log the failure and fall through to creating a new ticket so the alert reaches the technician one way or another. Co-Authored-By: Claude Opus 4.7 --- .../CippExtensions/Public/Halo/Get-HaloUser.ps1 | 7 ++++++- .../Public/Halo/New-HaloPSATicket.ps1 | 14 ++++++++++---- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/Modules/CippExtensions/Public/Halo/Get-HaloUser.ps1 b/Modules/CippExtensions/Public/Halo/Get-HaloUser.ps1 index 9ed145df4b5c1..cc2df9f62cb99 100644 --- a/Modules/CippExtensions/Public/Halo/Get-HaloUser.ps1 +++ b/Modules/CippExtensions/Public/Halo/Get-HaloUser.ps1 @@ -63,7 +63,12 @@ function Get-HaloUser { return $Response } catch { $Message = if ($_.ErrorDetails.Message) { Get-NormalizedError -Message $_.ErrorDetails.Message } else { $_.Exception.Message } - Write-LogMessage -API 'HaloPSATicket' -message "Halo advanced_search failed for $FilterName='$FilterValue' in client ${ClientId}: $Message" -sev Warning + # Some Halo instances don't whitelist these fields for advanced_search even though they + # exist on the user record. That's expected - the email-search fallback handles it. Only + # log unexpected failures. + if ($Message -notmatch 'Invalid advanced search parameter') { + Write-LogMessage -API 'HaloPSATicket' -message "Halo advanced_search failed for $FilterName='$FilterValue' in client ${ClientId}: $Message" -sev Warning + } return @() } } diff --git a/Modules/CippExtensions/Public/Halo/New-HaloPSATicket.ps1 b/Modules/CippExtensions/Public/Halo/New-HaloPSATicket.ps1 index 5a40005888337..e2eb1eaf60743 100644 --- a/Modules/CippExtensions/Public/Halo/New-HaloPSATicket.ps1 +++ b/Modules/CippExtensions/Public/Halo/New-HaloPSATicket.ps1 @@ -59,12 +59,13 @@ function New-HaloPSATicket { } $body = ConvertTo-Json -Compress -Depth 10 -InputObject @($Object) + $NoteAdded = $false try { if ($PSCmdlet.ShouldProcess('Add note to HaloPSA ticket', 'Add note')) { $Action = Invoke-RestMethod -Uri "$($Configuration.ResourceURL)/actions" -ContentType 'application/json; charset=utf-8' -Method Post -Body $body -Headers @{Authorization = "Bearer $($token.access_token)" } Write-Information "Note added to ticket in HaloPSA: $($ExistingTicket.TicketID)" + $NoteAdded = $true } - return "Note added to ticket in HaloPSA: $($ExistingTicket.TicketID)" } catch { $Message = if ($_.ErrorDetails.Message) { @@ -73,10 +74,15 @@ function New-HaloPSATicket { else { $_.Exception.message } - Write-LogMessage -message "Failed to add note to HaloPSA ticket: $Message" -API 'HaloPSATicket' -sev Error -LogData (Get-CippException -Exception $_) - Write-Information "Failed to add note to HaloPSA ticket: $Message" + # Don't return here - if appending a note failed (e.g. permissions on the action, + # invalid outcome_id) we still want to create a fresh ticket so the alert isn't lost. + Write-LogMessage -message "Failed to add note to HaloPSA ticket $($ExistingTicket.TicketID): $Message - falling back to creating a new ticket" -API 'HaloPSATicket' -sev Warning -LogData (Get-CippException -Exception $_) + Write-Information "Failed to add note to HaloPSA ticket: $Message; creating a new ticket instead" Write-Information "Body we tried to ship: $body" - return "Failed to add note to HaloPSA ticket: $Message" + } + + if ($NoteAdded) { + return "Note added to ticket in HaloPSA: $($ExistingTicket.TicketID)" } } } From 1e83607197caa28569ddcb0b058d7d11aba71f0c Mon Sep 17 00:00:00 2001 From: Jacob Newman Date: Fri, 15 May 2026 10:03:29 +0100 Subject: [PATCH 016/170] feat(alerts): per-task PsaTicketStrategy override for the HaloPSA splitter Adds a per-scheduled-alert setting that can override the global HaloPSA.LinkTicketsToUsers toggle on a single alert basis. Lets MSPs keep wide alerts like 'users without MFA' as one consolidated ticket per tenant when individual tickets would flood their PSA, while still splitting per-user for the alerts where granularity matters. Stored as a new column 'PsaTicketStrategy' on the ScheduledTasks row with values: - 'split' - always one ticket per affected user - 'consolidated' - always one ticket per tenant - '' / null - inherit the integration's global setting (default) Send-CIPPScheduledTaskAlert resolves the per-task strategy first and only falls back to the global toggle when the task hasn't expressed a preference, so existing alerts created before this change keep working exactly as before. --- Modules/CIPPCore/Public/Add-CIPPScheduledTask.ps1 | 1 + .../CIPPCore/Public/Send-CIPPScheduledTaskAlert.ps1 | 13 ++++++++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/Modules/CIPPCore/Public/Add-CIPPScheduledTask.ps1 b/Modules/CIPPCore/Public/Add-CIPPScheduledTask.ps1 index f9a5c01599239..e87479a95e857 100644 --- a/Modules/CIPPCore/Public/Add-CIPPScheduledTask.ps1 +++ b/Modules/CIPPCore/Public/Add-CIPPScheduledTask.ps1 @@ -201,6 +201,7 @@ function Add-CIPPScheduledTask { Results = 'Planned' AlertComment = [string]$task.AlertComment CustomSubject = [string]$task.CustomSubject + PsaTicketStrategy = [string]($task.PsaTicketStrategy.value ?? $task.PsaTicketStrategy) } diff --git a/Modules/CIPPCore/Public/Send-CIPPScheduledTaskAlert.ps1 b/Modules/CIPPCore/Public/Send-CIPPScheduledTaskAlert.ps1 index df99f76f01bc9..543588c90fc75 100644 --- a/Modules/CIPPCore/Public/Send-CIPPScheduledTaskAlert.ps1 +++ b/Modules/CIPPCore/Public/Send-CIPPScheduledTaskAlert.ps1 @@ -148,7 +148,18 @@ function Send-CIPPScheduledTaskAlert { $ExtConfig = (Get-CIPPAzDataTableEntity @ExtConfigTable).config | ConvertFrom-Json -ErrorAction SilentlyContinue $HaloConfig = $ExtConfig.HaloPSA - if ($HaloConfig -and $HaloConfig.Enabled -and $HaloConfig.LinkTicketsToUsers) { + # Per-task PsaTicketStrategy (configured on the alert) overrides the global + # HaloPSA.LinkTicketsToUsers toggle. Lets MSPs decide on a per-alert basis + # whether a wide alert (e.g. "users without MFA") should produce one ticket + # per user or one consolidated ticket per tenant. + $TaskStrategy = $TaskInfo.PsaTicketStrategy + $ShouldSplit = switch ($TaskStrategy) { + 'split' { $true } + 'consolidated' { $false } + default { [bool]$HaloConfig.LinkTicketsToUsers } + } + + if ($HaloConfig -and $HaloConfig.Enabled -and $ShouldSplit) { $UpnFieldCandidates = @('UserPrincipalName', 'userPrincipalName', 'UPN', 'userId', 'Userkey') $RowProperties = $Results[0].PSObject.Properties.Name $UpnField = $UpnFieldCandidates | Where-Object { $_ -in $RowProperties } | Select-Object -First 1 From 7d02b0b141d7b19e5f618d014f97b744f12ddf9b Mon Sep 17 00:00:00 2001 From: computersunplugged Date: Mon, 8 Jun 2026 11:19:55 +1000 Subject: [PATCH 017/170] Major Update Updated to write to and read from cetralised CIPP database --- Config/CIPPDBCacheTypes.json | 5 + Modules/CIPPCore/Public/Get-CIPPCVEReport.ps1 | 164 ++++++++++++++++++ Modules/CIPPCore/Public/Get-DefenderCves.ps1 | 127 ++++++++++++++ .../CIPPCore/Public/Get-DefenderTvmRaw.ps1 | 65 +++++++ .../Public/Invoke-CIPPDBCacheCollection.ps1 | 6 +- Modules/CIPPCore/Public/New-VulnCsvBytes.ps1 | 31 ++++ .../DBCache/Set-CIPPDBCacheDefenderCVEs.ps1 | 135 ++++++++++++++ .../MEM/Invoke-ExecAddCippCveException.ps1 | 113 ++++++++++++ .../MEM/Invoke-ExecRemoveCippCveException.ps1 | 77 ++++++++ .../Endpoint/MEM/Invoke-ListCVEManagement.ps1 | 162 +++++++++++++++++ .../NinjaOne/Invoke-NinjaOneCveSyncTenant.ps1 | 133 ++++++++++++++ .../Invoke-NinjaOneExtensionScheduler.ps1 | 22 ++- .../NinjaOne/Invoke-NinjaOneTenantSync.ps1 | 97 +++++++++++ .../NinjaOne/Invoke-NinjaOneVulnCsvUpload.ps1 | 126 ++++++++++++++ .../Public/NinjaOne/Push-NinjaOneQueue.ps1 | 7 +- 15 files changed, 1264 insertions(+), 6 deletions(-) create mode 100644 Modules/CIPPCore/Public/Get-CIPPCVEReport.ps1 create mode 100644 Modules/CIPPCore/Public/Get-DefenderCves.ps1 create mode 100644 Modules/CIPPCore/Public/Get-DefenderTvmRaw.ps1 create mode 100644 Modules/CIPPCore/Public/New-VulnCsvBytes.ps1 create mode 100644 Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheDefenderCVEs.ps1 create mode 100644 Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecAddCippCveException.ps1 create mode 100644 Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecRemoveCippCveException.ps1 create mode 100644 Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListCVEManagement.ps1 create mode 100644 Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneCveSyncTenant.ps1 create mode 100644 Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneVulnCsvUpload.ps1 diff --git a/Config/CIPPDBCacheTypes.json b/Config/CIPPDBCacheTypes.json index eb833092befd3..c85e07fbba9aa 100644 --- a/Config/CIPPDBCacheTypes.json +++ b/Config/CIPPDBCacheTypes.json @@ -368,5 +368,10 @@ "type": "ExoTransportConfig", "friendlyName": "Exchange Transport Config", "description": "Exchange Online transport configuration including SMTP authentication settings" + }, + { + "type": "DefenderCVEs", + "friendlyName": "Defender CVEs", + "description": "All Defender CVEs for Devices" } ] diff --git a/Modules/CIPPCore/Public/Get-CIPPCVEReport.ps1 b/Modules/CIPPCore/Public/Get-CIPPCVEReport.ps1 new file mode 100644 index 0000000000000..6023e78108da3 --- /dev/null +++ b/Modules/CIPPCore/Public/Get-CIPPCVEReport.ps1 @@ -0,0 +1,164 @@ +function Get-CIPPCVEReport { + <# + .SYNOPSIS + 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. + + .PARAMETER TenantFilter + The tenant to generate the report for, or 'AllTenants' + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter + ) + + try { + # Retrieve Exceptions from Exception database + $CveExceptionsTable = Get-CIPPTable -TableName 'CveExceptions' + $AllExceptions = Get-CIPPAzDataTableEntity @CveExceptionsTable + $ExceptionsByCve = @{} + + # Retrieve CVEs from database + $RawCveData = Get-CIPPDbItem -TenantFilter 'allTenants' -Type 'DefenderCVEs' | Where-Object { $_.RowKey -ne 'DefenderCVEs-Count' } + $AllCachedCves = $RawCveData.Data | ConvertFrom-Json + + # Filter results by Tenant + $RawCveItems = [System.Collections.Generic.List[object]]::new() + + 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 { + $TenantList = Get-Tenants | Where-Object defaultDomainName -eq $TenantFilter + foreach ($Item in $AllCachedCves) { + if ($Item.customerId -eq $TenantFilter) { + [void]$RawCveItems.Add($Item) + } + } + } + + 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 = @{} + + foreach ($Item in $RawCveItems) { + $CveId = $Item.PartitionKey + + if (-not $CveMasterTable.ContainsKey($CveId)) { + $CveMasterTable[$CveId] = @{ + cveId = $CveId + vulnerabilitySeverityLevel = $Item.vulnerabilitySeverityLevel + exploitabilityLevel = $Item.exploitabilityLevel + softwareName = $Item.softwareName + softwareVendor = $Item.softwareVendor + softwareVersion = $Item.softwareVersion + TotalDeviceCount = 0 + AffectedTenantsList = [System.Collections.Generic.List[object]]::new() + AffectedDevicesList = [System.Collections.Generic.List[object]]::new() + ExceptionMatchCount = 0 + TotalTenantGroupCount = 0 + ExceptionSources = [System.Collections.Generic.HashSet[string]]::new() + } + } + + $CveGroup = $CveMasterTable[$CveId] + $CveGroup.TotalTenantGroupCount++ + + [void]$CveGroup.AffectedTenantsList.Add(@{ customerId = $Item.customerId }) + + # Unpack the device JSON details from the row + if ($Item.deviceDetailsJson) { + $Devices = ConvertFrom-Json $Item.deviceDetailsJson | Sort-Object -Property deviceName -Unique + foreach ($Dev in $Devices) { + [void]$CveGroup.AffectedDevicesList.Add(@{ deviceName = $Dev.deviceName }) + $CveGroup.TotalDeviceCount ++ + } + } + } + + # Combine filtered results + $SortedCves = [System.Collections.Generic.List[PSCustomObject]]::new() + + foreach ($CveKey in $CveMasterTable.Keys) { + $Target = $CveMasterTable[$CveKey] + $ExceptionStatus = 'None' + $HasException = $false + $Exceptions = @{} + + if ($ExceptionsByCve.ContainsKey($CveKey)){ + $Exceptions = @($ExceptionsByCve[$CveKey]) + $HasException = $true + $ExceptionStatus = if ($Exceptions.customerId -contains "ALL") { "All" } else { "Partial" } + } + + [void]$SortedCves.Add([PSCustomObject]@{ + cveId = $Target.cveId + vulnerabilitySeverityLevel = $Target.vulnerabilitySeverityLevel + exploitabilityLevel = $Target.exploitabilityLevel + softwareName = $Target.softwareName + softwareVendor = $Target.softwareVendor + softwareVersion = $Target.softwareVersion + deviceCount = $Target.TotalDeviceCount + tenantCount = $Target.TotalTenantGroupCount + exceptionStatus = $ExceptionStatus + hasException = $HasException + affectedTenants = $Target.AffectedTenantsList + affectedDevices = $Target.AffectedDevicesList + exceptionType = if ($HasException){$Exceptions | ForEach-Object { + @{ customerId = $_.customerId + exceptionType = $_.exceptionType } } }else{''} + exceptionComment = if ($HasException){$Exceptions | ForEach-Object { + @{ customerId = $_.customerId + exceptionComment = $_.exceptionComment } } }else{''} + exceptionCreatedBy = if ($HasException){$Exceptions | ForEach-Object { + @{ customerId = $_.customerId + exceptionCreatedBy = $_.exceptionCreatedBy } } }else{''} + exceptionDate = if ($HasException){$Exceptions | ForEach-Object { + @{ customerId = $_.customerId + exceptionDate = $_.exceptionDate } } }else{''} + exceptionExpiry = if ($HasException){$Exceptions | ForEach-Object { + @{ customerId = $_.customerId + exceptionExpiry = $_.exceptionExpiry } } }else{''} + cacheTimeStamp = $Target.lastUpdated + }) + } + + return $SortedCves | Sort-Object -Property cveId + + } catch { + Write-LogMessage -API 'CVEReport' -tenant $TenantFilter -message "Failed to generate CVE report: $($_.Exception.Message)" -sev Error + throw + } +} diff --git a/Modules/CIPPCore/Public/Get-DefenderCves.ps1 b/Modules/CIPPCore/Public/Get-DefenderCves.ps1 new file mode 100644 index 0000000000000..0a36aa2930663 --- /dev/null +++ b/Modules/CIPPCore/Public/Get-DefenderCves.ps1 @@ -0,0 +1,127 @@ +function get-DefenderCVEs { + <# + .SYNOPSIS + Caches all vulnerabilities devices for a tenant + + .PARAMETER TenantFilter + The tenant to cache vulnerabilities for + + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter + ) + + try { + $AllVulns = Get-DefenderTvmRaw -TenantId $TenantFilter -MaxPages 0 + + if (-not $AllVulns) { + Write-LogMessage -API 'DefenderCVEs' -tenant $TenantFilter -message "No vulnerability data returned from Defender TVM" -sev 'Warning' + return + } + + Write-LogMessage -API 'DefenderCVEs' -tenant $TenantFilter -message "Retrieved $($AllVulns.Count) CVE records from Defender TVM" -sev 'Info' + try{ + # Initialize a tracker for this tenant session + $CveAggregator = @{} + }catch{ + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'DefenderCVEs' -tenant $TenantFilter -message "Aggregator Failed: $($ErrorMessage.NormalizedError)" -sev 'Error' -LogData $ErrorMessage + } + try{ + # Group the raw TVM records into unified CVE buckets + foreach ($Vuln in $AllVulns) { + $CveId = $Vuln.cveId + try{ + if (-not $CveAggregator.ContainsKey($CveId)) { + # Establish global CVE & software properties for this specific tenant + $CveAggregator[$CveId] = @{ + cveId = $CveId + customerId = $TenantFilter + softwareVendor = $Vuln.softwareVendor ?? '' + softwareName = $Vuln.softwareName ?? '' + vulnerabilitySeverityLevel = $Vuln.vulnerabilitySeverityLevel ?? '' + recommendedSecurityUpdate = $Vuln.recommendedSecurityUpdate ?? '' + recommendedSecurityUpdateUrl = $Vuln.recommendedSecurityUpdateUrl ?? '' + exploitabilityLevel = $Vuln.exploitabilityLevel ?? '' + + # Arrays to collect device metadata efficiently + AffectedDevices = [System.Collections.Generic.List[object]]::new() + } + } + }catch{ + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'DefenderCVEs' -tenant $TenantFilter -message "Failed to establish global: $($ErrorMessage.NormalizedError)" -sev 'Error' -LogData $ErrorMessage + } + try{ + # Extract properties specific to this device instance + $DevicePayload = @{ + 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 { '' } + } + }catch{ + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'DefenderCVEs' -tenant $TenantFilter -message "Failed to extract: $($ErrorMessage.NormalizedError)" -sev 'Error' -LogData $ErrorMessage + } + # Append to our tracking list + [void]$CveAggregator[$CveId].AffectedDevices.Add($DevicePayload) + } + }catch{ + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'DefenderCVEs' -tenant $TenantFilter -message "Allover Build: $($ErrorMessage.NormalizedError)" -sev 'Error' -LogData $ErrorMessage + } + + $Entities = [System.Collections.Generic.List[object]]::new() + + foreach ($CveKey in $CveAggregator.Keys) { + $CveData = $CveAggregator[$CveKey] + + # Flatten or convert device info arrays into a compact, compressed JSON string + $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 = [string]$(Get-Date (Get-Date).ToUniversalTime() -UFormat '+%Y-%m-%dT%H:%M:%S.000Z') + }) + } + + if ($Entities.Count -eq 0) { + Write-LogMessage -API 'DefenderCVEs' -tenant $TenantFilter -message "No valid CVE records to cache" -sev 'Warning' + return + } + + $SuccessCount = 0 + $FailCount = 0 + + $UniqueCves = ($Entities | Select-Object -ExpandProperty cveId -Unique).Count + Write-LogMessage -API 'DefenderCVEs' -tenant $TenantFilter -message "Retrieved $UniqueCves Unique CVEs" -sev 'Info' + + return $Entities + + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'DefenderCVEs' -tenant $TenantFilter -message "CVE Cache Refresh failed: $($ErrorMessage.NormalizedError)" -sev 'Error' -LogData $ErrorMessage + throw + } +} diff --git a/Modules/CIPPCore/Public/Get-DefenderTvmRaw.ps1 b/Modules/CIPPCore/Public/Get-DefenderTvmRaw.ps1 new file mode 100644 index 0000000000000..7e1b567738dba --- /dev/null +++ b/Modules/CIPPCore/Public/Get-DefenderTvmRaw.ps1 @@ -0,0 +1,65 @@ +function Get-DefenderTvmRaw { + <# + .SYNOPSIS + Fetch Defender TVM SoftwareVulnerabilitiesByMachine with paging. + .PARAMETER TenantId + Microsoft Entra tenant id to query. + .PARAMETER MaxPages + Optional page cap (0 = no cap). + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$TenantId, + [int]$MaxPages = 0 + ) + + $scope = 'https://api.securitycenter.microsoft.com/.default' + $uri = 'https://api.securitycenter.microsoft.com/api/machines/SoftwareVulnerabilitiesByMachine' + $all = New-Object System.Collections.Generic.List[object] + $page = 0 + + try { + do { + Write-LogMessage -API 'DefenderTVM' -tenant $TenantId -message "Fetching page $($page + 1)" -Sev 'Debug' + + $resp = New-GraphGetRequest -tenantid $TenantId -uri $uri -scope $scope + + if ($resp -is [System.Collections.IDictionary]) { + if ($resp.ContainsKey('value')) { + $rows = $resp.value + $nextLink = $resp.'@odata.nextLink' + if ($rows) { $all.AddRange($rows) } + $uri = $nextLink + Write-LogMessage -API 'DefenderTVM' -tenant $TenantId -message "Page $($page + 1): $($rows.Count) records" -Sev 'Debug' + } + else { + $all.Add($resp) + $uri = $null + } + } + elseif ($resp -is [System.Collections.IEnumerable] -and $resp -isnot [string]) { + $all.AddRange($resp) + $uri = $null + } + else { + $all.Add($resp) + $uri = $null + } + + $page++ + + if ($page -gt 100) { + Write-LogMessage -API 'DefenderTVM' -tenant $TenantId -message "Reached 100 page safety limit — stopping" -Sev 'Warning' + break + } + + } while ($uri -and ($MaxPages -eq 0 -or $page -lt $MaxPages)) + + Write-LogMessage -API 'DefenderTVM' -tenant $TenantId -message "Defender TVM fetch complete: $($all.Count) records across $page page(s)" -Sev 'Info' + return $all + } + catch { + Write-LogMessage -API 'DefenderTVM' -tenant $TenantId -message "Error on page $page`: $($_.Exception.Message)" -Sev 'Error' + throw + } +} diff --git a/Modules/CIPPCore/Public/Invoke-CIPPDBCacheCollection.ps1 b/Modules/CIPPCore/Public/Invoke-CIPPDBCacheCollection.ps1 index 2538fc8493fb8..579c38c4da9e1 100644 --- a/Modules/CIPPCore/Public/Invoke-CIPPDBCacheCollection.ps1 +++ b/Modules/CIPPCore/Public/Invoke-CIPPDBCacheCollection.ps1 @@ -15,6 +15,7 @@ function Invoke-CIPPDBCacheCollection { - ConditionalAccess: CA policies and registration details - IdentityProtection: Risky users/SPs, risk detections, PIM - Intune: Managed devices, policies, app protection + - Defender: Defender Vulnerabilities .PARAMETER CollectionType The group of cache functions to execute @@ -31,7 +32,7 @@ function Invoke-CIPPDBCacheCollection { [CmdletBinding()] param( [Parameter(Mandatory = $true)] - [ValidateSet('Graph', 'ExchangeConfig', 'ExchangeData', 'ConditionalAccess', 'IdentityProtection', 'Intune', 'Compliance', 'CopilotUsage', 'SharePoint', 'Teams')] + [ValidateSet('Graph', 'ExchangeConfig', 'ExchangeData', 'ConditionalAccess', 'IdentityProtection', 'Intune', 'Compliance', 'CopilotUsage', 'SharePoint', 'Teams', 'Defender')] [string]$CollectionType, [Parameter(Mandatory = $true)] @@ -151,6 +152,9 @@ function Invoke-CIPPDBCacheCollection { 'TeamsActivity' 'TeamsVoice' ) + Defender = @( + 'DefenderCVEs' + ) } $CacheTypes = $Collections[$CollectionType] diff --git a/Modules/CIPPCore/Public/New-VulnCsvBytes.ps1 b/Modules/CIPPCore/Public/New-VulnCsvBytes.ps1 new file mode 100644 index 0000000000000..be860ac91a379 --- /dev/null +++ b/Modules/CIPPCore/Public/New-VulnCsvBytes.ps1 @@ -0,0 +1,31 @@ +function New-VulnCsvBytes { + <# + .SYNOPSIS + Build a CSV payload (UTF-8 bytes) from objects with explicit headers. + .PARAMETER Rows + Array of PSCustomObject where property names match the provided headers. + .PARAMETER Headers + Ordered list of column headers (and property names). + #> + [CmdletBinding()] + param( + [Parameter()][object[]]$Rows = @(), + [Parameter(Mandatory)][string[]]$Headers + ) + + $Sb = [System.Text.StringBuilder]::new() + [void]$Sb.AppendLine(($Headers -join ',')) + + foreach ($Row in $Rows) { + $Cells = foreach ($Header in $Headers) { + $Val = $Row.$Header + if ($null -ne $Val) { + $S = [string]$Val + if ($S -match '[,"\r\n]') { '"' + ($S -replace '"', '""') + '"' } else { $S } + } else { '' } + } + [void]$Sb.AppendLine(($Cells -join ',')) + } + + return [System.Text.Encoding]::UTF8.GetBytes($Sb.ToString()) +} diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheDefenderCVEs.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheDefenderCVEs.ps1 new file mode 100644 index 0000000000000..cc7f898ef4825 --- /dev/null +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheDefenderCVEs.ps1 @@ -0,0 +1,135 @@ +function Set-CIPPDBCacheDefenderCVEs { + <# + .SYNOPSIS + Caches all vulnerabilities devices for a tenant + + .PARAMETER TenantFilter + The tenant to cache vulnerabilities for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + [string]$QueueId + ) + + try { + $AllVulns = Get-DefenderTvmRaw -TenantId $TenantFilter -MaxPages 0 + + if (-not $AllVulns) { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "No vulnerability data returned from Defender TVM" -sev 'Warning' + return + } + + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Retrieved $($AllVulns.Count) CVE records from Defender TVM" -sev 'Info' + try{ + # Initialize a tracker for this tenant session + $CveAggregator = @{} + }catch{ + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Aggregator Failed: $($ErrorMessage.NormalizedError)" -sev 'Error' -LogData $ErrorMessage + } + try{ + # Group the raw TVM records into unified CVE buckets + foreach ($Vuln in $AllVulns) { + $CveId = $Vuln.cveId + try{ + if (-not $CveAggregator.ContainsKey($CveId)) { + # Establish global CVE & software properties for this specific tenant + $CveAggregator[$CveId] = @{ + cveId = $CveId + customerId = $TenantFilter + softwareVendor = $Vuln.softwareVendor ?? '' + softwareName = $Vuln.softwareName ?? '' + vulnerabilitySeverityLevel = $Vuln.vulnerabilitySeverityLevel ?? '' + recommendedSecurityUpdate = $Vuln.recommendedSecurityUpdate ?? '' + recommendedSecurityUpdateUrl = $Vuln.recommendedSecurityUpdateUrl ?? '' + exploitabilityLevel = $Vuln.exploitabilityLevel ?? '' + + # Arrays to collect device metadata efficiently + AffectedDevices = [System.Collections.Generic.List[object]]::new() + } + } + }catch{ + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to establish global: $($ErrorMessage.NormalizedError)" -sev 'Error' -LogData $ErrorMessage + } + try{ + # Extract properties specific to this device instance + $DevicePayload = @{ + 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 { '' } + } + }catch{ + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to extract: $($ErrorMessage.NormalizedError)" -sev 'Error' -LogData $ErrorMessage + } + # Append to our tracking list + [void]$CveAggregator[$CveId].AffectedDevices.Add($DevicePayload) + } + }catch{ + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Allover Build: $($ErrorMessage.NormalizedError)" -sev 'Error' -LogData $ErrorMessage + } + + $Entities = [System.Collections.Generic.List[object]]::new() + + foreach ($CveKey in $CveAggregator.Keys) { + $CveData = $CveAggregator[$CveKey] + + # Flatten or convert device info arrays into a compact, compressed JSON string + $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 = [string]$(Get-Date (Get-Date).ToUniversalTime() -UFormat '+%Y-%m-%dT%H:%M:%S.000Z') + }) + } + + if ($Entities.Count -eq 0) { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "No valid CVE records to cache" -sev 'Warning' + return + } + + $SuccessCount = 0 + $FailCount = 0 + + try { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Entities: ($Entities | Out-String)" -sev 'Info' + $Entities | Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'DefenderCVEs' -AddCount + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "CVE Cache failed: $($ErrorMessage.NormalizedError)" -sev 'Error' -LogData $ErrorMessage + } + + $UniqueCves = ($Entities | Select-Object -ExpandProperty cveId -Unique).Count + + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "CVE Cache Refresh failed: $($ErrorMessage.NormalizedError)" -sev 'Error' -LogData $ErrorMessage + throw + } +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecAddCippCveException.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecAddCippCveException.ps1 new file mode 100644 index 0000000000000..060ac28b2d690 --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecAddCippCveException.ps1 @@ -0,0 +1,113 @@ +function Invoke-ExecAddCippCveException { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Security.Alert.ReadWrite + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + $TenantFilter = $Request.Query.tenantFilter ?? $Request.Body.tenantFilter + + try { + $CveId = [string]$Request.Body.cveId + $ExceptionType = [string]$Request.Body.exceptionType + $ApplyTo = [string]$Request.Body.applyTo + $Justification = [string]$Request.Body.justification + $ExpiryDate = if ($Request.Body.expiryDate) { [string]$Request.Body.expiryDate } else { $null } + + if (-not $CveId -or -not $ExceptionType -or -not $ApplyTo -or -not $Justification) { + return [HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::BadRequest + Body = @{ Results = 'Error: cveId, exceptionType, applyTo, and justification are required' } + } + } + + $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'" + + $TenantsToUpdate = switch ($ApplyTo) { + 'CurrentTenant' { + if (-not $TenantFilter -or $TenantFilter -eq 'AllTenants') { + throw "Current tenant must be selected to use 'Current Tenant Only' option" + } + @($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) + } + 'Global' { + @('ALL') + } + default { + throw "Invalid applyTo value: $ApplyTo" + } + } + + $Username = $Headers.'x-ms-client-principal-name' + $CurrentDate = (Get-Date).ToUniversalTime().ToString('o') + $ReadableDate = (Get-Date).ToString() + + $ExceptionsAdded = [System.Collections.Generic.List[string]]::new() + $ExceptionsUpdated = [System.Collections.Generic.List[string]]::new() + + # Build all exception entities in memory, track add vs update + $ExceptionEntities = foreach ($TenantId in $TenantsToUpdate) { + $ExistingException = $AllCveExceptions | Where-Object { $_.RowKey -eq $TenantId } + + if ($ExistingException) { + [void]$ExceptionsUpdated.Add($TenantId) + } else { + [void]$ExceptionsAdded.Add($TenantId) + } + + @{ + PartitionKey = [string]$CveId + RowKey = [string]$TenantId + cveId = [string]$CveId + customerId = [string]$TenantId + exceptionType = [string]$ExceptionType + exceptionComment = [string]$Justification + exceptionCreatedBy = [string]$Username + exceptionCreatedDate = [string]$CurrentDate + exceptionReadableDate = [string]$ReadableDate + exceptionExpiry = $ExpiryDate ?? '' + source = 'CIPP' + } + } + + # Write all exception entities in one batch + if (@($ExceptionEntities).Count -gt 0) { + Add-CIPPAzDataTableEntity @CveExceptionsTable -Entity @($ExceptionEntities) -Force + } + + Write-LogMessage -API $APIName -tenant $TenantFilter -headers $Headers -message "Added/updated CVE exception for $CveId across $($TenantsToUpdate.Count) tenant(s)" -sev 'Info' + + return [HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::OK + Body = @{ + Results = "Successfully applied exception to CVE $CveId" + TenantsAffected = $TenantsToUpdate.Count + ExceptionsAdded = $ExceptionsAdded.Count + ExceptionsUpdated = $ExceptionsUpdated.Count + Details = "Added: $($ExceptionsAdded -join ', '), Updated: $($ExceptionsUpdated -join ', ')" + } + } + + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API $APIName -tenant $TenantFilter -headers $Headers -message "Failed to add CVE exception: $($ErrorMessage.NormalizedError)" -sev 'Error' -LogData $ErrorMessage + return [HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::InternalServerError + Body = @{ Results = "Failed to add exception: $($ErrorMessage.NormalizedError)" } + } + } +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecRemoveCippCveException.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecRemoveCippCveException.ps1 new file mode 100644 index 0000000000000..a263d81176ac0 --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecRemoveCippCveException.ps1 @@ -0,0 +1,77 @@ +function Invoke-ExecRemoveCippCveException { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Security.Alert.ReadWrite + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + $TenantFilter = $Request.Query.tenantFilter ?? $Request.Body.tenantFilter + + try { + $CveId = $Request.Query.cveId ?? $Request.Body.cveId + $RemoveScope = $Request.Query.removeScope ?? $Request.Body.removeScope + + if (-not $CveId) { + return [HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::BadRequest + Body = @{ Results = 'Error: cveId is required' } + } + } + + $CveExceptionsTable = Get-CIPPTable -TableName 'CveExceptions' + + # Load all exceptions for this CVE + $AllCveExceptions = Get-CIPPAzDataTableEntity @CveExceptionsTable -Filter "PartitionKey eq '$CveId'" + + $ExceptionsToRemove = switch ($RemoveScope) { + 'CurrentTenant' { + if (-not $TenantFilter -or $TenantFilter -eq 'AllTenants') { + throw 'Current tenant must be selected' + } + @($TenantFilter) + } + 'AllAffected' { + @($AllCveExceptions | Where-Object { $_.RowKey -ne 'ALL' } | Select-Object -ExpandProperty RowKey) + } + 'Global' { + @('ALL') + } + default { + if ($TenantFilter -and $TenantFilter -ne 'AllTenants') { + @($TenantFilter) + } else { + throw 'removeScope must be specified when no tenant is selected' + } + } + } + + # Remove matched exception entities + $EntitiesToRemove = $AllCveExceptions | Where-Object { $_.RowKey -in $ExceptionsToRemove } + $RemovedCount = 0 + + foreach ($Entity in $EntitiesToRemove) { + Remove-AzDataTableEntity @CveExceptionsTable -Entity $Entity -Force + $RemovedCount++ + } + + Write-LogMessage -API $APIName -tenant $TenantFilter -headers $Headers -message "Removed $RemovedCount CVE exception(s) for $CveId" -sev 'Info' + + return [HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::OK + Body = @{ Results = "Successfully removed $RemovedCount exception(s) for CVE $CveId" } + } + + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API $APIName -tenant $TenantFilter -headers $Headers -message "Failed to remove CVE exception: $($ErrorMessage.NormalizedError)" -sev 'Error' -LogData $ErrorMessage + return [HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::InternalServerError + Body = @{ Results = "Failed to remove exception: $($ErrorMessage.NormalizedError)" } + } + } +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListCVEManagement.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListCVEManagement.ps1 new file mode 100644 index 0000000000000..33c5a561001db --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListCVEManagement.ps1 @@ -0,0 +1,162 @@ +function Invoke-ListCVEManagement { + <# + .FUNCTIONALITY + Entrypoint,AnyTenant + .ROLE + Endpoint.Security.Read + #> + + [CmdletBinding()] + param($Request, $TriggerMetadata) + # Interact with query parameters or the body of the request. + $TenantFilter = $Request.Query.tenantFilter + $UseReportDB = $Request.Query.UseReportDB + + if ($UseReportDB -eq 'true'){ + try { + $GraphRequest = Get-CIPPCVEReport -TenantFilter $TenantFilter -ErrorAction Stop + $StatusCode = [HttpStatusCode]::OK + $SortedCves = $GraphRequest + Write-LogMessage -API 'ListCVEManagement' -tenant $TenantFilter -message "running cve report" -sev 'info' + } catch { + Write-Host "Error retrieving CVEs from report database: $($_.Exception.Message)" + $StatusCode = [HttpStatusCode]::InternalServerError + $GraphRequest = $_.Exception.Message + Write-LogMessage -API 'ListCVEManagement' -tenant $TenantFilter -message "Error retrieving" -sev 'info' + } + }else{ + try { + Write-LogMessage -API 'ListCVEManagement' -tenant $TenantFilter -message "retrieving CVEs" -sev 'info' + $GraphRequest = get-DefenderCVEs -TenantFilter $TenantFilter + + # Retrieve Exceptions from Exception database + $CveExceptionsTable = Get-CIPPTable -TableName 'CveExceptions' + $AllExceptions = Get-CIPPAzDataTableEntity @CveExceptionsTable + $ExceptionsByCve = @{} + + # Retrieve CVEs from database + $RawCveItems = $GraphRequest + $AllCachedCves = $RawCveData + + $TenantList = Get-Tenants | Where-Object defaultDomainName -eq $TenantFilter + + if ($RawCveItems.Count -eq 0) { + return @() + } + + 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 + }) + } + } + + # Merge all results + $CveMasterTable = @{} + + foreach ($Item in $RawCveItems) { + $CveId = $Item.PartitionKey + + if (-not $CveMasterTable.ContainsKey($CveId)) { + $CveMasterTable[$CveId] = @{ + cveId = $CveId + vulnerabilitySeverityLevel = $Item.vulnerabilitySeverityLevel + exploitabilityLevel = $Item.exploitabilityLevel + softwareName = $Item.softwareName + softwareVendor = $Item.softwareVendor + softwareVersion = $Item.softwareVersion + TotalDeviceCount = 0 + AffectedTenantsList = [System.Collections.Generic.List[object]]::new() + AffectedDevicesList = [System.Collections.Generic.List[object]]::new() + ExceptionMatchCount = 0 + TotalTenantGroupCount = 0 + ExceptionSources = [System.Collections.Generic.HashSet[string]]::new() + } + } + + $CveGroup = $CveMasterTable[$CveId] + $CveGroup.TotalTenantGroupCount++ + + [void]$CveGroup.AffectedTenantsList.Add(@{ customerId = $Item.customerId }) + + # Unpack the device JSON details from the row + if ($Item.deviceDetailsJson) { + $Devices = ConvertFrom-Json $Item.deviceDetailsJson | Sort-Object -Property deviceName -Unique + foreach ($Dev in $Devices) { + [void]$CveGroup.AffectedDevicesList.Add(@{ deviceName = $Dev.deviceName }) + $CveGroup.TotalDeviceCount ++ + } + } + } + + # Combine filtered results + $SortedCves = [System.Collections.Generic.List[PSCustomObject]]::new() + + foreach ($CveKey in $CveMasterTable.Keys) { + $Target = $CveMasterTable[$CveKey] + $ExceptionStatus = 'None' + $HasException = $false + $Exceptions = @{} + + if ($ExceptionsByCve.ContainsKey($CveKey)){ + $Exceptions = @($ExceptionsByCve[$CveKey]) + $HasException = $true + $ExceptionStatus = if ($Exceptions.customerId -contains "ALL") { "All" } else { "Partial" } + } + + [void]$SortedCves.Add([PSCustomObject]@{ + cveId = $Target.cveId + vulnerabilitySeverityLevel = $Target.vulnerabilitySeverityLevel + exploitabilityLevel = $Target.exploitabilityLevel + softwareName = $Target.softwareName + softwareVendor = $Target.softwareVendor + softwareVersion = $Target.softwareVersion + deviceCount = $Target.TotalDeviceCount + tenantCount = $Target.TotalTenantGroupCount + exceptionStatus = $ExceptionStatus + hasException = $HasException + affectedTenants = $Target.AffectedTenantsList + affectedDevices = $Target.AffectedDevicesList + exceptionType = if ($HasException){$Exceptions | ForEach-Object { + @{ customerId = $_.customerId + exceptionType = $_.exceptionType } } }else{''} + exceptionComment = if ($HasException){$Exceptions | ForEach-Object { + @{ customerId = $_.customerId + exceptionComment = $_.exceptionComment } } }else{''} + exceptionCreatedBy = if ($HasException){$Exceptions | ForEach-Object { + @{ customerId = $_.customerId + exceptionCreatedBy = $_.exceptionCreatedBy } } }else{''} + exceptionDate = if ($HasException){$Exceptions | ForEach-Object { + @{ customerId = $_.customerId + exceptionDate = $_.exceptionDate } } }else{''} + exceptionExpiry = if ($HasException){$Exceptions | ForEach-Object { + @{ customerId = $_.customerId + exceptionExpiry = $_.exceptionExpiry } } }else{''} + cacheTimeStamp = $Target.lastUpdated + }) + $StatusCode = [HttpStatusCode]::OK + } + + } catch { + Write-Host "Error retrieving CVEs: $($_.Exception.Message)" + $StatusCode = [HttpStatusCode]::InternalServerError + $GraphRequest = $_.Exception.Message + } + } + Return [HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @($SortedCves | Sort-Object -Property cveId) + } +} diff --git a/Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneCveSyncTenant.ps1 b/Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneCveSyncTenant.ps1 new file mode 100644 index 0000000000000..2cb023761abf9 --- /dev/null +++ b/Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneCveSyncTenant.ps1 @@ -0,0 +1,133 @@ +function Invoke-NinjaOneCveSyncTenant { + [CmdletBinding()] + param ( + $QueueItem + ) + + try { + $MappedTenant = $QueueItem.MappedTenant + + $Customer = Get-Tenants -IncludeErrors | Where-Object { $_.customerId -eq $MappedTenant.RowKey } + + if (($Customer | Measure-Object).count -ne 1) { + throw "Unable to match the received ID to a tenant. QueueItem: $($QueueItem | ConvertTo-Json -Depth 10 | Out-String)" + } + + $TenantFilter = $Customer.defaultDomainName + + Write-LogMessage -API 'NinjaCveSync' -tenant $TenantFilter -message "Starting CVE sync for $($Customer.displayName)" -sev 'Info' + + # Load NinjaOne config + $Table = Get-CIPPTable -TableName Extensionsconfig + $Configuration = ((Get-CIPPAzDataTableEntity @Table).config | ConvertFrom-Json).NinjaOne + + if (-not $Configuration -or -not $Configuration.Instance) { + throw 'NinjaOne configuration is missing or incomplete' + } + + $ScanGroupPrefix = $Configuration.CveSyncPrefix ?? '' + $ScanGroupName = "$ScanGroupPrefix$TenantFilter" + $NinjaBaseUrl = "https://$($Configuration.Instance)/api/v2" + + # Get token + $Token = Get-NinjaOneToken -configuration $Configuration + + if (-not $Token -or -not $Token.access_token) { + throw 'Failed to retrieve NinjaOne access token' + } + + $Headers = @{ Authorization = "Bearer $($Token.access_token)" } + + # Get scan groups + $ScanGroups = Invoke-RestMethod -Method Get -Uri "$NinjaBaseUrl/vulnerability/scan-groups" -Headers $Headers -TimeoutSec 30 -ErrorAction Stop + + if (-not $ScanGroups) { + throw 'Scan groups response was empty' + } + + # Resolve scan group for this tenant + $ResolvedScanGroup = $ScanGroups | Where-Object { $_.groupName -eq $ScanGroupName } + + if (-not $ResolvedScanGroup) { + $Available = ($ScanGroups | Select-Object -First 10 | ForEach-Object { "ID $($_.id): $($_.groupName)" }) -join ', ' + throw "Scan group '$ScanGroupName' not found. Available: $Available" + } + + $ResolvedScanGroupId = $ResolvedScanGroup.id + $DeviceIdHeader = $ResolvedScanGroup.deviceIdHeader + $CveIdHeader = $ResolvedScanGroup.cveIdHeader + + if ([string]::IsNullOrWhiteSpace($DeviceIdHeader) -or [string]::IsNullOrWhiteSpace($CveIdHeader)) { + throw 'Scan group missing required header config' + } + + # Pull Defender TVM data + $AllVulns = Get-CIPPCVEReport -TenantId $TenantFilter -MaxPages 0 + + if (-not $AllVulns) { + Write-LogMessage -API 'NinjaCveSync' -tenant $TenantFilter -message 'No vulnerability data returned — skipping' -sev 'Warning' + return $true + } + + Write-LogMessage -API 'NinjaCveSync' -tenant $TenantFilter -message "Retrieved $($AllVulns.Count) vulnerabilities" -sev 'Info' + + # Filter CIPP exceptions + $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 'NinjaCveSync' -tenant $TenantFilter -message "Filtered $($BeforeCount - $AllVulns.Count) excepted CVEs — $($AllVulns.Count) remaining" -sev 'Info' + } + + # Build CSV rows + $CsvRows = [System.Collections.Generic.List[object]]::new() + $SkippedCount = 0 + + foreach ($Item in $AllVulns) { + if ([string]::IsNullOrWhiteSpace($Item.cveId) -or [string]::IsNullOrWhiteSpace($Item.deviceName)) { + $SkippedCount++ + continue + } + [void]$CsvRows.Add([PSCustomObject]@{ + $DeviceIdHeader = $Item.deviceName.Trim() + $CveIdHeader = $Item.cveId.Trim() + }) + } + + if ($SkippedCount -gt 0) { + Write-LogMessage -API 'NinjaCveSync' -tenant $TenantFilter -message "Skipped $SkippedCount rows (missing deviceName or cveId)" -sev 'Warning' + } + + $CsvBytes = New-VulnCsvBytes -Rows $CsvRows -Headers @($DeviceIdHeader, $CveIdHeader) + + if (-not $CsvBytes -or $CsvBytes.Length -eq 0) { + throw 'Failed to generate CSV bytes' + } + + # Upload and poll for completion + $UploadUri = "$NinjaBaseUrl/vulnerability/scan-groups/$ResolvedScanGroupId/upload" + $PollUri = "$NinjaBaseUrl/vulnerability/scan-groups/$ResolvedScanGroupId" + $Response = Invoke-NinjaOneVulnCsvUpload -Uri $UploadUri -PollUri $PollUri -CsvBytes $CsvBytes -Headers $Headers + + $FinalStatus = $Response.status ?? 'unknown' + $ProcessedCount = $Response.recordsProcessed ?? '?' + + if ($FinalStatus -eq 'COMPLETE') { + Write-LogMessage -API 'NinjaCveSync' -tenant $TenantFilter -message "Complete — $($CsvRows.Count) CVEs sent to '$ScanGroupName', $ProcessedCount processed by NinjaOne" -sev 'Info' + } elseif ($FinalStatus -eq 'IN_PROGRESS') { + Write-LogMessage -API 'NinjaCveSync' -tenant $TenantFilter -message "Upload accepted — $($CsvRows.Count) CVEs sent to '$ScanGroupName', still processing (timed out polling)" -sev 'Warning' + } else { + Write-LogMessage -API 'NinjaCveSync' -tenant $TenantFilter -message "Upload finished with status '$FinalStatus' for '$ScanGroupName', $ProcessedCount processed by NinjaOne" -sev 'Warning' + } + + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'NinjaCveSync' -tenant $TenantFilter -message "Failed CVE sync: $($ErrorMessage.NormalizedError)" -sev 'Error' -LogData $ErrorMessage + } + + return $true +} diff --git a/Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneExtensionScheduler.ps1 b/Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneExtensionScheduler.ps1 index 7dd47505a39c7..0c160259c92ee 100644 --- a/Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneExtensionScheduler.ps1 +++ b/Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneExtensionScheduler.ps1 @@ -42,6 +42,15 @@ function Invoke-NinjaOneExtensionScheduler { 'FunctionName' = 'NinjaOneQueue' } } + + $CveBatch = foreach ($Tenant in $TenantsToProcess) { + [PSCustomObject]@{ + 'NinjaAction' = 'CveSyncTenant' + 'MappedTenant' = $Tenant + 'FunctionName' = 'NinjaOneQueue' + } + } + if (($Batch | Measure-Object).Count -gt 0) { $InputObject = [PSCustomObject]@{ OrchestratorName = 'NinjaOneOrchestrator' @@ -52,6 +61,15 @@ function Invoke-NinjaOneExtensionScheduler { Write-Host "Started permissions orchestration with ID = '$InstanceId'" } + if (($CveBatch | Measure-Object).Count -gt 0) { + $CveInputObject = [PSCustomObject]@{ + OrchestratorName = 'NinjaOneOrchestrator' + Batch = @($CveBatch) + } + $CveInstanceId = Start-CIPPOrchestrator -InputObject $CveInputObject + Write-Host "Started CVE sync orchestration with ID = '$CveInstanceId'" + } + $AddObject = @{ PartitionKey = 'NinjaConfig' RowKey = 'NinjaLastRunTime' @@ -59,7 +77,7 @@ function Invoke-NinjaOneExtensionScheduler { } Add-AzDataTableEntity @Table -Entity $AddObject -Force - Write-LogMessage -API 'NinjaOneSync' -message "NinjaOne Daily Synchronization Queued for $(($TenantsToProcess | Measure-Object).count) Tenants" -Sev 'Info' + Write-LogMessage -API 'NinjaOneSync' -message "NinjaOne Daily Synchronization Queued for $(($TenantsToProcess | Measure-Object).count) Tenants" -Sev 'Info' } else { if ($LastRunTime -lt (Get-Date).AddMinutes(-90)) { @@ -95,7 +113,7 @@ function Invoke-NinjaOneExtensionScheduler { } if (($CatchupTenants | Measure-Object).count -gt 0) { - Write-LogMessage -API 'NinjaOneSync' -message "NinjaOne Synchronization Catchup Queued for $(($CatchupTenants | Measure-Object).count) Tenants" -Sev 'Info' + Write-LogMessage -API 'NinjaOneSync' -message "NinjaOne Synchronization Catchup Queued for $(($CatchupTenants | Measure-Object).count) Tenants" -Sev 'Info' } } diff --git a/Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneTenantSync.ps1 b/Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneTenantSync.ps1 index 5c6753fc6de9e..6ceb616c16fbc 100644 --- a/Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneTenantSync.ps1 +++ b/Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneTenantSync.ps1 @@ -1846,6 +1846,10 @@ function Invoke-NinjaOneTenantSync { ### CIPP Applied Standards Cards + $ModuleBase = Get-Module CIPPExtensions | Select-Object -ExpandProperty ModuleBase + $CIPPRoot = (Get-Item $ModuleBase).Parent.Parent.FullName + Set-Location $CIPPRoot + try { $StandardsDefinitions = Invoke-RestMethod -Uri 'https://raw.githubusercontent.com/KelvinTegelaar/CIPP/refs/heads/main/src/data/standards.json' $AppliedStandards = Get-CIPPStandards -TenantFilter $Customer.defaultDomainName @@ -2195,6 +2199,99 @@ function Invoke-NinjaOneTenantSync { $Result = Invoke-WebRequest -Uri "https://$($Configuration.Instance)/api/v2/organization/$($MappedTenant.IntegrationId)/custom-fields" -Method PATCH -Headers @{Authorization = "Bearer $($token.access_token)" } -ContentType 'application/json; charset=utf-8' -Body ($NinjaOrgUpdate | ConvertTo-Json -Depth 100) + + # CVE Sync — runs as part of tenant sync if enabled + if ($Configuration.CveSyncEnabled -eq $true) { + try { + $ScanGroupPrefix = $Configuration.CveSyncPrefix ?? '' + $ScanGroupName = "$ScanGroupPrefix$TenantFilter" + $NinjaBaseUrl = "https://$($Configuration.Instance)/api/v2" + + $CveScanGroups = Invoke-RestMethod -Method Get -Uri "$NinjaBaseUrl/vulnerability/scan-groups" -Headers @{ Authorization = "Bearer $($Token.access_token)" } -TimeoutSec 30 -ErrorAction Stop + $ResolvedScanGroup = $CveScanGroups | Where-Object { $_.groupName -eq $ScanGroupName } + + if (-not $ResolvedScanGroup) { + Write-LogMessage -API 'NinjaOneSync' -tenant $TenantFilter -message "CVE sync skipped — scan group '$ScanGroupName' not found" -sev 'Warning' + } else { + $ResolvedScanGroupId = $ResolvedScanGroup.id + $DeviceIdHeader = $ResolvedScanGroup.deviceIdHeader + $CveIdHeader = $ResolvedScanGroup.cveIdHeader + + if ([string]::IsNullOrWhiteSpace($DeviceIdHeader) -or [string]::IsNullOrWhiteSpace($CveIdHeader)) { + Write-LogMessage -API 'NinjaOneSync' -tenant $TenantFilter -message "CVE sync skipped — scan group missing required header config" -sev 'Warning' + } else { + $RawVulns = Get-CIPPDbItem -TenantFilter $TenantFilter -Type 'DefenderCVEs' | Where-Object { $_.RowKey -ne 'DefenderCVEs-Count' } + $AllVulns = $RawVulns.Data | ConvertFrom-Json + + if (-not $AllVulns) { + Write-LogMessage -API 'NinjaOneSync' -tenant $TenantFilter -message 'CVE sync — no vulnerability data returned' -sev 'Warning' + } 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' + } + + $CsvRows = [System.Collections.Generic.List[object]]::new() + $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 + $AffectedDevices = [System.Collections.Generic.List[PSCustomObject]]::new() + foreach ($Dev in $Devices) { + [void]$AffectedDevices.Add(@{ deviceName = $Dev.deviceName }) + } + } + [void]$CsvRows.Add([PSCustomObject]@{ + $DeviceIdHeader = $Dev.deviceName.Trim() + $CveIdHeader = $Item.cveId.Trim() + }) + } + + if ($SkippedCount -gt 0) { + Write-LogMessage -API 'NinjaOneSync' -tenant $TenantFilter -message "CVE sync — skipped $SkippedCount rows (missing deviceName or cveId)" -sev 'Warning' + } + + $CsvBytes = New-VulnCsvBytes -Rows $CsvRows -Headers @($DeviceIdHeader, $CveIdHeader) + + if ($CsvBytes -and $CsvBytes.Length -gt 0) { + $UploadUri = "$NinjaBaseUrl/vulnerability/scan-groups/$ResolvedScanGroupId/upload" + $PollUri = "$NinjaBaseUrl/vulnerability/scan-groups/$ResolvedScanGroupId" + $CveResp = Invoke-NinjaOneVulnCsvUpload -Uri $UploadUri -PollUri $PollUri -CsvBytes $CsvBytes -Headers @{ Authorization = "Bearer $($Token.access_token)" } + + $FinalStatus = $CveResp.status ?? 'unknown' + $ProcessedCount = $CveResp.recordsProcessed ?? '?' + + if ($FinalStatus -eq 'COMPLETE') { + Write-LogMessage -API 'NinjaOneSync' -tenant $TenantFilter -message "CVE sync complete — $($CsvRows.Count) CVEs sent to '$ScanGroupName', $ProcessedCount processed" -sev 'Info' + } elseif ($FinalStatus -eq 'IN_PROGRESS') { + Write-LogMessage -API 'NinjaOneSync' -tenant $TenantFilter -message "CVE sync upload accepted — $($CsvRows.Count) CVEs sent to '$ScanGroupName', still processing (timed out polling)" -sev 'Warning' + } else { + Write-LogMessage -API 'NinjaOneSync' -tenant $TenantFilter -message "CVE sync finished with status '$FinalStatus' for '$ScanGroupName', $ProcessedCount processed" -sev 'Warning' + } + } else { + Write-LogMessage -API 'NinjaOneSync' -tenant $TenantFilter -message 'CVE sync — failed to generate CSV bytes' -sev 'Warning' + } + } + } + } + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'NinjaOneSync' -tenant $TenantFilter -message "CVE sync failed: $($ErrorMessage.NormalizedError)" -sev 'Error' -LogData $ErrorMessage + # Do not rethrow — CVE sync failure should not fail the whole tenant sync + } + } + Write-Information 'Cleaning Users Cache' if (($ParsedUsers | Measure-Object).count -gt 0) { Remove-AzDataTableEntity -Force @UsersTable -Entity ($ParsedUsers | Select-Object PartitionKey, RowKey) diff --git a/Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneVulnCsvUpload.ps1 b/Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneVulnCsvUpload.ps1 new file mode 100644 index 0000000000000..f6c65c9788d67 --- /dev/null +++ b/Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneVulnCsvUpload.ps1 @@ -0,0 +1,126 @@ +function Invoke-NinjaOneVulnCsvUpload { + <# + .SYNOPSIS + Upload CVE CSV to NinjaOne vulnerability scan group via multipart POST, + then poll until processing completes. Retries the full upload+poll cycle + on transient failures or a FAILED processing status. + .PARAMETER Uri + Full NinjaOne API upload URI including scan group ID. + .PARAMETER PollUri + NinjaOne API URI for the scan group (GET) used to poll processing status. + .PARAMETER CsvBytes + UTF-8 encoded CSV payload as a byte array. + .PARAMETER Headers + Hashtable of HTTP headers including Authorization bearer token. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$Uri, + [Parameter(Mandatory)][string]$PollUri, + [Parameter(Mandatory)][byte[]]$CsvBytes, + [Parameter(Mandatory)][hashtable]$Headers + ) + + $Boundary = [System.Guid]::NewGuid().ToString() + $LF = "`r`n" + $MaxRetries = 5 + $RetryDelay = 5 + $PollDelay = 10 + $MaxPolls = 18 + $Attempt = 0 + + $BodyLines = @( + "--$Boundary" + 'Content-Disposition: form-data; name="csv"; filename="cve.csv"' + 'Content-Type: text/csv' + '' + ) + + $HeaderText = $BodyLines -join $LF + $HeaderBytes = [System.Text.Encoding]::UTF8.GetBytes($HeaderText + $LF) + + $TrailerText = "$LF--$Boundary--$LF" + $TrailerBytes = [System.Text.Encoding]::UTF8.GetBytes($TrailerText) + + while ($Attempt -le $MaxRetries) { + $Mem = [System.IO.MemoryStream]::new() + try { + $Mem.Write($HeaderBytes, 0, $HeaderBytes.Length) + $Mem.Write($CsvBytes, 0, $CsvBytes.Length) + $Mem.Write($TrailerBytes, 0, $TrailerBytes.Length) + $Mem.Position = 0 + + if ($Attempt -eq 0) { + Write-LogMessage -API 'NinjaOne' -message "Uploading CVE CSV to NinjaOne ($($CsvBytes.Length) bytes)" -sev 'Debug' + } else { + Write-LogMessage -API 'NinjaOne' -message "Retrying CVE CSV upload (attempt $Attempt of $MaxRetries)" -sev 'Warning' + } + + $Resp = Invoke-RestMethod -Method POST -Uri $Uri ` + -Headers $Headers ` + -ContentType "multipart/form-data; boundary=$Boundary" ` + -Body $Mem ` + -ErrorAction Stop + + } catch { + $ErrorMessage = Get-CippException -Exception $_ + + # Do not retry on 404 — scan group not found is a config issue, not transient + if ($_.Exception.Response.StatusCode.value__ -eq 404) { + Write-LogMessage -API 'NinjaOne' -message "CSV upload failed (404 — scan group not found, not retrying): $($ErrorMessage.NormalizedError)" -sev 'Error' -LogData $ErrorMessage + throw + } + + if ($Attempt -lt $MaxRetries) { + Write-LogMessage -API 'NinjaOne' -message "CSV upload failed (attempt $Attempt of $MaxRetries), retrying in ${RetryDelay}s: $($ErrorMessage.NormalizedError)" -sev 'Warning' -LogData $ErrorMessage + Start-Sleep -Seconds $RetryDelay + $Attempt++ + continue + } else { + Write-LogMessage -API 'NinjaOne' -message "CSV upload failed after $MaxRetries retries: $($ErrorMessage.NormalizedError)" -sev 'Error' -LogData $ErrorMessage + throw + } + } finally { + $Mem.Dispose() + } + + # Upload accepted — poll until no longer IN_PROGRESS + if ($Resp.status -eq 'IN_PROGRESS') { + Write-LogMessage -API 'NinjaOne' -message "Upload accepted, polling for completion (max $($MaxPolls * $PollDelay)s)" -sev 'Debug' + + $PollCount = 0 + while ($Resp.status -eq 'IN_PROGRESS' -and $PollCount -lt $MaxPolls) { + Start-Sleep -Seconds $PollDelay + $PollCount++ + try { + $Resp = Invoke-RestMethod -Method Get -Uri $PollUri -Headers $Headers -ErrorAction Stop + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'NinjaOne' -message "Poll failed on attempt $PollCount — will retry: $($ErrorMessage.NormalizedError)" -sev 'Warning' -LogData $ErrorMessage + } + } + + if ($Resp.status -eq 'IN_PROGRESS') { + # Timed out waiting — upload succeeded but processing is still running + Write-LogMessage -API 'NinjaOne' -message "Polling timed out after $($MaxPolls * $PollDelay)s — upload accepted by NinjaOne but processing status unknown" -sev 'Warning' + return $Resp + } + } + + # FAILED status — treat as retryable + if ($Resp.status -eq 'FAILED') { + if ($Attempt -lt $MaxRetries) { + Write-LogMessage -API 'NinjaOne' -message "NinjaOne returned FAILED status (attempt $Attempt of $MaxRetries), retrying in ${RetryDelay}s" -sev 'Warning' + Start-Sleep -Seconds $RetryDelay + $Attempt++ + continue + } else { + Write-LogMessage -API 'NinjaOne' -message "NinjaOne returned FAILED status after $MaxRetries retries — giving up" -sev 'Error' + return $Resp + } + } + + # COMPLETE or any other terminal status — return + return $Resp + } +} diff --git a/Modules/CippExtensions/Public/NinjaOne/Push-NinjaOneQueue.ps1 b/Modules/CippExtensions/Public/NinjaOne/Push-NinjaOneQueue.ps1 index 470220016842b..d5b85d58e372c 100644 --- a/Modules/CippExtensions/Public/NinjaOne/Push-NinjaOneQueue.ps1 +++ b/Modules/CippExtensions/Public/NinjaOne/Push-NinjaOneQueue.ps1 @@ -7,9 +7,10 @@ function Push-NinjaOneQueue { Switch ($Item.NinjaAction) { 'StartAutoMapping' { Invoke-NinjaOneOrgMapping } - 'AutoMapTenant' { Invoke-NinjaOneOrgMappingTenant -QueueItem $Item } - 'SyncTenant' { Invoke-NinjaOneTenantSync -QueueItem $Item } - 'SyncTenants' { Invoke-NinjaOneSync } + 'AutoMapTenant' { Invoke-NinjaOneOrgMappingTenant -QueueItem $Item } + 'SyncTenant' { Invoke-NinjaOneTenantSync -QueueItem $Item } + 'SyncTenants' { Invoke-NinjaOneSync } + 'CveSyncTenant' { Invoke-NinjaOneCveSyncTenant -QueueItem $Item } } return $true } From b68c93fd940c96812b6266e75f3bdcc6ac4c4fe8 Mon Sep 17 00:00:00 2001 From: DamienMatthys Date: Mon, 8 Jun 2026 22:14:00 +1000 Subject: [PATCH 018/170] Update Push-CIPPDBCacheData.ps1 Added license check for new Defender category Signed-off-by: DamienMatthys --- .../Push-CIPPDBCacheData.ps1 | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-CIPPDBCacheData.ps1 b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-CIPPDBCacheData.ps1 index 074278d77cbb3..24f93b25fd47e 100644 --- a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-CIPPDBCacheData.ps1 +++ b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-CIPPDBCacheData.ps1 @@ -65,6 +65,14 @@ function Push-CIPPDBCacheData { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Compliance license check failed: $($_.Exception.Message)" -sev Warning -LogData $ErrorMessage } + $DefenderCapable = $false + try { + $DefenderCapable = Test-CIPPStandardLicense -StandardName Compliance'DefenderLicenseCheck' -TenantFilter $TenantFilter -RequiredCapabilities @('MDE_SMB', 'WIN_DEF_ATP', 'DEFENDER_ENDPOINT_P1') -SkipLog + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Compliance license check failed: $($_.Exception.Message)" -sev Warning -LogData $ErrorMessage + } + $SharePointCapable = $false try { $SharePointCapable = Test-CIPPStandardLicense -StandardName 'SharePointLicenseCheck' -TenantFilter $TenantFilter -Preset SharePoint -SkipLog @@ -190,6 +198,18 @@ function Push-CIPPDBCacheData { } else { Write-Host "Skipping Compliance data collection for $TenantFilter - no required license" } + + if ($DefenderCapable) { + $Tasks.Add(@{ + FunctionName = 'ExecCIPPDBCache' + CollectionType = 'Defender' + TenantFilter = $TenantFilter + QueueId = $QueueId + QueueName = "DB Cache Defender - $TenantFilter" + }) + } else { + Write-Host "Skipping Defender data collection for $TenantFilter - no required license" + } if ($SharePointCapable) { $Tasks.Add(@{ From 4c020dd9eac4544f1dadbf445b381fd61046e256 Mon Sep 17 00:00:00 2001 From: DamienMatthys Date: Mon, 8 Jun 2026 22:16:03 +1000 Subject: [PATCH 019/170] Update Set-CIPPDBCacheDefenderCVEs.ps1 Cleaned up logging Signed-off-by: DamienMatthys --- .../CIPPDB/Public/DBCache/Set-CIPPDBCacheDefenderCVEs.ps1 | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheDefenderCVEs.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheDefenderCVEs.ps1 index cc7f898ef4825..f6cf4850f65fb 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheDefenderCVEs.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheDefenderCVEs.ps1 @@ -24,7 +24,7 @@ function Set-CIPPDBCacheDefenderCVEs { return } - Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Retrieved $($AllVulns.Count) CVE records from Defender TVM" -sev 'Info' + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Retrieved $($AllVulns.Count) CVE records from Defender TVM" -sev 'Debug' try{ # Initialize a tracker for this tenant session $CveAggregator = @{} @@ -116,17 +116,17 @@ function Set-CIPPDBCacheDefenderCVEs { $SuccessCount = 0 $FailCount = 0 + + $UniqueCves = ($Entities | Select-Object -ExpandProperty cveId -Unique).Count try { - Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Entities: ($Entities | Out-String)" -sev 'Info' + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Cached $UniqueCves CVEs" -sev 'Info' $Entities | Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'DefenderCVEs' -AddCount } catch { $ErrorMessage = Get-CippException -Exception $_ Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "CVE Cache failed: $($ErrorMessage.NormalizedError)" -sev 'Error' -LogData $ErrorMessage } - $UniqueCves = ($Entities | Select-Object -ExpandProperty cveId -Unique).Count - } catch { $ErrorMessage = Get-CippException -Exception $_ Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "CVE Cache Refresh failed: $($ErrorMessage.NormalizedError)" -sev 'Error' -LogData $ErrorMessage From 85ef802da7d0f7144786fa045625561d617d93c0 Mon Sep 17 00:00:00 2001 From: DamienMatthys Date: Wed, 10 Jun 2026 20:14:02 +1000 Subject: [PATCH 020/170] Delete Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneCveSyncTenant.ps1 Remove old sync function Signed-off-by: DamienMatthys --- .../NinjaOne/Invoke-NinjaOneCveSyncTenant.ps1 | 133 ------------------ 1 file changed, 133 deletions(-) delete mode 100644 Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneCveSyncTenant.ps1 diff --git a/Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneCveSyncTenant.ps1 b/Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneCveSyncTenant.ps1 deleted file mode 100644 index 2cb023761abf9..0000000000000 --- a/Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneCveSyncTenant.ps1 +++ /dev/null @@ -1,133 +0,0 @@ -function Invoke-NinjaOneCveSyncTenant { - [CmdletBinding()] - param ( - $QueueItem - ) - - try { - $MappedTenant = $QueueItem.MappedTenant - - $Customer = Get-Tenants -IncludeErrors | Where-Object { $_.customerId -eq $MappedTenant.RowKey } - - if (($Customer | Measure-Object).count -ne 1) { - throw "Unable to match the received ID to a tenant. QueueItem: $($QueueItem | ConvertTo-Json -Depth 10 | Out-String)" - } - - $TenantFilter = $Customer.defaultDomainName - - Write-LogMessage -API 'NinjaCveSync' -tenant $TenantFilter -message "Starting CVE sync for $($Customer.displayName)" -sev 'Info' - - # Load NinjaOne config - $Table = Get-CIPPTable -TableName Extensionsconfig - $Configuration = ((Get-CIPPAzDataTableEntity @Table).config | ConvertFrom-Json).NinjaOne - - if (-not $Configuration -or -not $Configuration.Instance) { - throw 'NinjaOne configuration is missing or incomplete' - } - - $ScanGroupPrefix = $Configuration.CveSyncPrefix ?? '' - $ScanGroupName = "$ScanGroupPrefix$TenantFilter" - $NinjaBaseUrl = "https://$($Configuration.Instance)/api/v2" - - # Get token - $Token = Get-NinjaOneToken -configuration $Configuration - - if (-not $Token -or -not $Token.access_token) { - throw 'Failed to retrieve NinjaOne access token' - } - - $Headers = @{ Authorization = "Bearer $($Token.access_token)" } - - # Get scan groups - $ScanGroups = Invoke-RestMethod -Method Get -Uri "$NinjaBaseUrl/vulnerability/scan-groups" -Headers $Headers -TimeoutSec 30 -ErrorAction Stop - - if (-not $ScanGroups) { - throw 'Scan groups response was empty' - } - - # Resolve scan group for this tenant - $ResolvedScanGroup = $ScanGroups | Where-Object { $_.groupName -eq $ScanGroupName } - - if (-not $ResolvedScanGroup) { - $Available = ($ScanGroups | Select-Object -First 10 | ForEach-Object { "ID $($_.id): $($_.groupName)" }) -join ', ' - throw "Scan group '$ScanGroupName' not found. Available: $Available" - } - - $ResolvedScanGroupId = $ResolvedScanGroup.id - $DeviceIdHeader = $ResolvedScanGroup.deviceIdHeader - $CveIdHeader = $ResolvedScanGroup.cveIdHeader - - if ([string]::IsNullOrWhiteSpace($DeviceIdHeader) -or [string]::IsNullOrWhiteSpace($CveIdHeader)) { - throw 'Scan group missing required header config' - } - - # Pull Defender TVM data - $AllVulns = Get-CIPPCVEReport -TenantId $TenantFilter -MaxPages 0 - - if (-not $AllVulns) { - Write-LogMessage -API 'NinjaCveSync' -tenant $TenantFilter -message 'No vulnerability data returned — skipping' -sev 'Warning' - return $true - } - - Write-LogMessage -API 'NinjaCveSync' -tenant $TenantFilter -message "Retrieved $($AllVulns.Count) vulnerabilities" -sev 'Info' - - # Filter CIPP exceptions - $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 'NinjaCveSync' -tenant $TenantFilter -message "Filtered $($BeforeCount - $AllVulns.Count) excepted CVEs — $($AllVulns.Count) remaining" -sev 'Info' - } - - # Build CSV rows - $CsvRows = [System.Collections.Generic.List[object]]::new() - $SkippedCount = 0 - - foreach ($Item in $AllVulns) { - if ([string]::IsNullOrWhiteSpace($Item.cveId) -or [string]::IsNullOrWhiteSpace($Item.deviceName)) { - $SkippedCount++ - continue - } - [void]$CsvRows.Add([PSCustomObject]@{ - $DeviceIdHeader = $Item.deviceName.Trim() - $CveIdHeader = $Item.cveId.Trim() - }) - } - - if ($SkippedCount -gt 0) { - Write-LogMessage -API 'NinjaCveSync' -tenant $TenantFilter -message "Skipped $SkippedCount rows (missing deviceName or cveId)" -sev 'Warning' - } - - $CsvBytes = New-VulnCsvBytes -Rows $CsvRows -Headers @($DeviceIdHeader, $CveIdHeader) - - if (-not $CsvBytes -or $CsvBytes.Length -eq 0) { - throw 'Failed to generate CSV bytes' - } - - # Upload and poll for completion - $UploadUri = "$NinjaBaseUrl/vulnerability/scan-groups/$ResolvedScanGroupId/upload" - $PollUri = "$NinjaBaseUrl/vulnerability/scan-groups/$ResolvedScanGroupId" - $Response = Invoke-NinjaOneVulnCsvUpload -Uri $UploadUri -PollUri $PollUri -CsvBytes $CsvBytes -Headers $Headers - - $FinalStatus = $Response.status ?? 'unknown' - $ProcessedCount = $Response.recordsProcessed ?? '?' - - if ($FinalStatus -eq 'COMPLETE') { - Write-LogMessage -API 'NinjaCveSync' -tenant $TenantFilter -message "Complete — $($CsvRows.Count) CVEs sent to '$ScanGroupName', $ProcessedCount processed by NinjaOne" -sev 'Info' - } elseif ($FinalStatus -eq 'IN_PROGRESS') { - Write-LogMessage -API 'NinjaCveSync' -tenant $TenantFilter -message "Upload accepted — $($CsvRows.Count) CVEs sent to '$ScanGroupName', still processing (timed out polling)" -sev 'Warning' - } else { - Write-LogMessage -API 'NinjaCveSync' -tenant $TenantFilter -message "Upload finished with status '$FinalStatus' for '$ScanGroupName', $ProcessedCount processed by NinjaOne" -sev 'Warning' - } - - } catch { - $ErrorMessage = Get-CippException -Exception $_ - Write-LogMessage -API 'NinjaCveSync' -tenant $TenantFilter -message "Failed CVE sync: $($ErrorMessage.NormalizedError)" -sev 'Error' -LogData $ErrorMessage - } - - return $true -} From 7ae8fcdbda8f6fa30058cd704c6667f1f6e3739c Mon Sep 17 00:00:00 2001 From: DamienMatthys Date: Wed, 10 Jun 2026 20:14:35 +1000 Subject: [PATCH 021/170] Update Push-NinjaOneQueue.ps1 Remove old sync schedule Signed-off-by: DamienMatthys --- Modules/CippExtensions/Public/NinjaOne/Push-NinjaOneQueue.ps1 | 1 - 1 file changed, 1 deletion(-) diff --git a/Modules/CippExtensions/Public/NinjaOne/Push-NinjaOneQueue.ps1 b/Modules/CippExtensions/Public/NinjaOne/Push-NinjaOneQueue.ps1 index d5b85d58e372c..feff03bc7a207 100644 --- a/Modules/CippExtensions/Public/NinjaOne/Push-NinjaOneQueue.ps1 +++ b/Modules/CippExtensions/Public/NinjaOne/Push-NinjaOneQueue.ps1 @@ -10,7 +10,6 @@ function Push-NinjaOneQueue { 'AutoMapTenant' { Invoke-NinjaOneOrgMappingTenant -QueueItem $Item } 'SyncTenant' { Invoke-NinjaOneTenantSync -QueueItem $Item } 'SyncTenants' { Invoke-NinjaOneSync } - 'CveSyncTenant' { Invoke-NinjaOneCveSyncTenant -QueueItem $Item } } return $true } From 7420db61543e2f8a255e72aab01e00dd0ed805d1 Mon Sep 17 00:00:00 2001 From: DamienMatthys Date: Wed, 10 Jun 2026 20:18:24 +1000 Subject: [PATCH 022/170] Update Invoke-NinjaOneTenantSync.ps1 Fixed sync to add one CVE per Device Signed-off-by: DamienMatthys --- .../Public/NinjaOne/Invoke-NinjaOneTenantSync.ps1 | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneTenantSync.ps1 b/Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneTenantSync.ps1 index 6ceb616c16fbc..f7880cd8d8c34 100644 --- a/Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneTenantSync.ps1 +++ b/Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneTenantSync.ps1 @@ -2247,15 +2247,13 @@ function Invoke-NinjaOneTenantSync { } if ($Item.deviceDetailsJson) { $Devices = ConvertFrom-Json $Item.deviceDetailsJson | Sort-Object -Property deviceName -Unique - $AffectedDevices = [System.Collections.Generic.List[PSCustomObject]]::new() foreach ($Dev in $Devices) { - [void]$AffectedDevices.Add(@{ deviceName = $Dev.deviceName }) - } + [void]$CsvRows.Add([PSCustomObject]@{ + $DeviceIdHeader = $Dev.deviceName.Trim() + $CveIdHeader = $Item.cveId.Trim() + }) + } } - [void]$CsvRows.Add([PSCustomObject]@{ - $DeviceIdHeader = $Dev.deviceName.Trim() - $CveIdHeader = $Item.cveId.Trim() - }) } if ($SkippedCount -gt 0) { From 4fb1aded1b397a634413175b0f01e2b6f1ea8ca7 Mon Sep 17 00:00:00 2001 From: Bobby <31723128+kris6673@users.noreply.github.com> Date: Mon, 15 Jun 2026 22:17:51 +0200 Subject: [PATCH 023/170] feat(mailbox): return SMTP client auth state --- .../Users/Invoke-ListUserMailboxDetails.ps1 | 63 ++++++++++--------- 1 file changed, 32 insertions(+), 31 deletions(-) diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListUserMailboxDetails.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListUserMailboxDetails.ps1 index 97cebd8840b99..9a59cf7bbd983 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListUserMailboxDetails.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListUserMailboxDetails.ps1 @@ -241,37 +241,38 @@ function Invoke-ListUserMailboxDetails { # Build the GraphRequest object $GraphRequest = [ordered]@{ - ForwardAndDeliver = $MailboxDetailedRequest.DeliverToMailboxAndForward - ForwardingAddress = $ForwardingAddress - LitigationHold = $MailboxDetailedRequest.LitigationHoldEnabled - RetentionHold = $MailboxDetailedRequest.RetentionHoldEnabled - ComplianceTagHold = $MailboxDetailedRequest.ComplianceTagHoldApplied - InPlaceHold = $InPlaceHold - EDiscoveryHold = $EDiscoveryHold - PurviewRetentionHold = $PurviewRetentionHold - ExcludedFromOrgWideHold = $ExcludedFromOrgWideHold - HiddenFromAddressLists = $MailboxDetailedRequest.HiddenFromAddressListsEnabled - EWSEnabled = $CASRequest.EwsEnabled - MailboxMAPIEnabled = $CASRequest.MAPIEnabled - MailboxOWAEnabled = $CASRequest.OWAEnabled - MailboxImapEnabled = $CASRequest.ImapEnabled - MailboxPopEnabled = $CASRequest.PopEnabled - MailboxActiveSyncEnabled = $CASRequest.ActiveSyncEnabled - Permissions = @($ParsedPerms) - ProhibitSendQuota = $ProhibitSendQuota - ProhibitSendReceiveQuota = $ProhibitSendReceiveQuota - ItemCount = [math]::Round($StatsRequest.ItemCount, 2) - TotalItemSize = $TotalItemSize - TotalArchiveItemSize = $TotalArchiveItemSize - TotalArchiveItemCount = $TotalArchiveItemCount - BlockedForSpam = $BlockedForSpam - ArchiveMailBox = $ArchiveEnabled - AutoExpandingArchive = $AutoExpandingArchiveEnabled - AutoExpandingArchiveScope = $AutoExpandingArchiveScope - RecipientTypeDetails = $MailboxDetailedRequest.RecipientTypeDetails - Mailbox = $MailboxDetailedRequest - RetentionPolicy = $MailboxDetailedRequest.RetentionPolicy - MailboxActionsData = ($MailboxDetailedRequest | Select-Object id, ExchangeGuid, ArchiveGuid, WhenSoftDeleted, + ForwardAndDeliver = $MailboxDetailedRequest.DeliverToMailboxAndForward + ForwardingAddress = $ForwardingAddress + LitigationHold = $MailboxDetailedRequest.LitigationHoldEnabled + RetentionHold = $MailboxDetailedRequest.RetentionHoldEnabled + ComplianceTagHold = $MailboxDetailedRequest.ComplianceTagHoldApplied + InPlaceHold = $InPlaceHold + EDiscoveryHold = $EDiscoveryHold + PurviewRetentionHold = $PurviewRetentionHold + ExcludedFromOrgWideHold = $ExcludedFromOrgWideHold + HiddenFromAddressLists = $MailboxDetailedRequest.HiddenFromAddressListsEnabled + EWSEnabled = $CASRequest.EwsEnabled + MailboxMAPIEnabled = $CASRequest.MAPIEnabled + MailboxOWAEnabled = $CASRequest.OWAEnabled + MailboxImapEnabled = $CASRequest.ImapEnabled + MailboxPopEnabled = $CASRequest.PopEnabled + MailboxActiveSyncEnabled = $CASRequest.ActiveSyncEnabled + SmtpClientAuthenticationDisabled = $CASRequest.SmtpClientAuthenticationDisabled + Permissions = @($ParsedPerms) + ProhibitSendQuota = $ProhibitSendQuota + ProhibitSendReceiveQuota = $ProhibitSendReceiveQuota + ItemCount = [math]::Round($StatsRequest.ItemCount, 2) + TotalItemSize = $TotalItemSize + TotalArchiveItemSize = $TotalArchiveItemSize + TotalArchiveItemCount = $TotalArchiveItemCount + BlockedForSpam = $BlockedForSpam + ArchiveMailBox = $ArchiveEnabled + AutoExpandingArchive = $AutoExpandingArchiveEnabled + AutoExpandingArchiveScope = $AutoExpandingArchiveScope + RecipientTypeDetails = $MailboxDetailedRequest.RecipientTypeDetails + Mailbox = $MailboxDetailedRequest + RetentionPolicy = $MailboxDetailedRequest.RetentionPolicy + MailboxActionsData = ($MailboxDetailedRequest | Select-Object id, ExchangeGuid, ArchiveGuid, WhenSoftDeleted, @{ Name = 'UPN'; Expression = { $_.'UserPrincipalName' } }, @{ Name = 'displayName'; Expression = { $_.'DisplayName' } }, @{ Name = 'primarySmtpAddress'; Expression = { $_.'PrimarySMTPAddress' } }, From f44ff88ade8f608415930c37271f457959df8e11 Mon Sep 17 00:00:00 2001 From: Bobby <31723128+kris6673@users.noreply.github.com> Date: Sat, 20 Jun 2026 01:08:16 +0200 Subject: [PATCH 024/170] feat: add allTenants support for shared mailbox enabled report --- ...-CIPPSharedMailboxAccountEnabledReport.ps1 | 100 +++++++++++++ ...Invoke-ListSharedMailboxAccountEnabled.ps1 | 18 +++ ...haredMailboxAccountEnabledReport.Tests.ps1 | 141 ++++++++++++++++++ 3 files changed, 259 insertions(+) create mode 100644 Modules/CIPPCore/Public/Get-CIPPSharedMailboxAccountEnabledReport.ps1 create mode 100644 Tests/Reports/Get-CIPPSharedMailboxAccountEnabledReport.Tests.ps1 diff --git a/Modules/CIPPCore/Public/Get-CIPPSharedMailboxAccountEnabledReport.ps1 b/Modules/CIPPCore/Public/Get-CIPPSharedMailboxAccountEnabledReport.ps1 new file mode 100644 index 0000000000000..72c961f2a8cb5 --- /dev/null +++ b/Modules/CIPPCore/Public/Get-CIPPSharedMailboxAccountEnabledReport.ps1 @@ -0,0 +1,100 @@ +function Get-CIPPSharedMailboxAccountEnabledReport { + <# + .SYNOPSIS + Generates the "shared mailbox with enabled account" report from the CIPP Reporting database + + .DESCRIPTION + Reproduces the live Invoke-ListSharedMailboxAccountEnabled payload entirely from cached data, + joining the cached 'Mailboxes' dataset (to identify SharedMailbox recipients) with the cached + 'Users' dataset (for accountEnabled / assignedLicenses / onPremisesSyncEnabled) by UPN. Only + shared mailboxes whose user account is enabled are returned. No dedicated cache writer is needed — + both source datasets are already populated on the scheduled cache cycle. + + .PARAMETER TenantFilter + The tenant to generate the report for. 'AllTenants' fans out across every tenant present in the + Mailboxes cache. + + .EXAMPLE + Get-CIPPSharedMailboxAccountEnabledReport -TenantFilter 'contoso.onmicrosoft.com' + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter + ) + + try { + # Handle AllTenants by recursing per tenant present in the Mailboxes cache + if ($TenantFilter -eq 'AllTenants') { + $AllMailboxItems = Get-CIPPDbItem -TenantFilter 'allTenants' -Type 'Mailboxes' + $Tenants = @($AllMailboxItems | Where-Object { $_.RowKey -ne 'Mailboxes-Count' } | Select-Object -ExpandProperty PartitionKey -Unique) + + $TenantList = Get-Tenants -IncludeErrors + $Tenants = $Tenants | Where-Object { $TenantList.defaultDomainName -contains $_ } + + $AllResults = [System.Collections.Generic.List[PSCustomObject]]::new() + foreach ($Tenant in $Tenants) { + try { + $TenantResults = Get-CIPPSharedMailboxAccountEnabledReport -TenantFilter $Tenant + foreach ($Result in $TenantResults) { + $Result | Add-Member -NotePropertyName 'Tenant' -NotePropertyValue $Tenant -Force + $AllResults.Add($Result) + } + } catch { + Write-LogMessage -API 'SharedMailboxAccountEnabledReport' -tenant $Tenant -message "Failed to get report for tenant: $($_.Exception.Message)" -sev Warning + } + } + return $AllResults + } + + # Mailboxes cache identifies which mailboxes are shared (recipientTypeDetails) and the join key (UPN) + $MailboxItems = Get-CIPPDbItem -TenantFilter $TenantFilter -Type 'Mailboxes' | Where-Object { $_.RowKey -ne 'Mailboxes-Count' } + if (-not $MailboxItems) { + throw 'No mailbox data found in reporting database. Sync the report data first.' + } + + # Users cache carries the account/license fields the live endpoint pulls from Graph /users + $UserItems = Get-CIPPDbItem -TenantFilter $TenantFilter -Type 'Users' | Where-Object { $_.RowKey -ne 'Users-Count' } + + # Most-recent cache timestamp across both source datasets + $CacheTimestamp = (@($MailboxItems) + @($UserItems) | Where-Object { $_.Timestamp } | Sort-Object Timestamp -Descending | Select-Object -First 1).Timestamp + + # Build a UPN -> user lookup (hashtable string keys are case-insensitive, matching UPN semantics) + $UserByUPN = @{} + foreach ($Item in $UserItems) { + $User = $Item.Data | ConvertFrom-Json + if ($User.userPrincipalName) { + $UserByUPN[$User.userPrincipalName] = $User + } + } + + $Results = [System.Collections.Generic.List[PSCustomObject]]::new() + foreach ($Item in $MailboxItems) { + $Mailbox = $Item.Data | ConvertFrom-Json + if ($Mailbox.recipientTypeDetails -ne 'SharedMailbox') { continue } + + $User = $UserByUPN[$Mailbox.UPN] + if (-not $User -or -not $User.accountEnabled) { continue } + + # Match the live Invoke-ListSharedMailboxAccountEnabled shape exactly. 'id' must be the user's + # object id — the page's "Block Sign In" action posts it to ExecDisableUser. + $Results.Add([PSCustomObject]@{ + UserPrincipalName = $User.userPrincipalName + displayName = $User.displayName + givenName = $User.givenName + surname = $User.surname + accountEnabled = $User.accountEnabled + assignedLicenses = $User.assignedLicenses + id = $User.id + onPremisesSyncEnabled = $User.onPremisesSyncEnabled + CacheTimestamp = $CacheTimestamp + }) + } + + return $Results + + } catch { + Write-LogMessage -API 'SharedMailboxAccountEnabledReport' -tenant $TenantFilter -message "Failed to generate shared mailbox account enabled report: $($_.Exception.Message)" -sev Error + throw + } +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Reports/Invoke-ListSharedMailboxAccountEnabled.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Reports/Invoke-ListSharedMailboxAccountEnabled.ps1 index 6daf756987619..55d8f547ed001 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Reports/Invoke-ListSharedMailboxAccountEnabled.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Reports/Invoke-ListSharedMailboxAccountEnabled.ps1 @@ -6,15 +6,33 @@ function Invoke-ListSharedMailboxAccountEnabled { Exchange.Mailbox.Read .DESCRIPTION Lists shared mailboxes that have direct sign-in enabled (account not disabled), which is a security concern. + Supports UseReportDB=true to read cached data from the reporting database (required for AllTenants). #> [CmdletBinding()] param($Request, $TriggerMetadata) $APIName = $Request.Params.CIPPEndpoint $TenantFilter = $Request.Query.tenantFilter + $UseReportDB = $Request.Query.UseReportDB # Get Shared Mailbox Stuff try { + # If UseReportDB is specified, retrieve from the report database (cached Mailboxes + Users join) + if ($UseReportDB -eq 'true') { + try { + $GraphRequest = Get-CIPPSharedMailboxAccountEnabledReport -TenantFilter $TenantFilter -ErrorAction Stop + $StatusCode = [HttpStatusCode]::OK + } catch { + $StatusCode = [HttpStatusCode]::InternalServerError + $GraphRequest = $_.Exception.Message + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @($GraphRequest) + }) + } + $SharedMailboxList = (New-GraphGetRequest -uri "https://outlook.office365.com/adminapi/beta/$($TenantFilter)/Mailbox?`$filter=RecipientTypeDetails eq 'SharedMailbox'" -Tenantid $TenantFilter -scope ExchangeOnline) $AllUsersInfo = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/users?$select=id,userPrincipalName,accountEnabled,displayName,givenName,surname,onPremisesSyncEnabled,assignedLicenses' -tenantid $TenantFilter $SharedMailboxDetails = foreach ($SharedMailbox in $SharedMailboxList) { diff --git a/Tests/Reports/Get-CIPPSharedMailboxAccountEnabledReport.Tests.ps1 b/Tests/Reports/Get-CIPPSharedMailboxAccountEnabledReport.Tests.ps1 new file mode 100644 index 0000000000000..991df44fd8206 --- /dev/null +++ b/Tests/Reports/Get-CIPPSharedMailboxAccountEnabledReport.Tests.ps1 @@ -0,0 +1,141 @@ +# Pester tests for Get-CIPPSharedMailboxAccountEnabledReport +# Verifies the cached Mailboxes + Users join, accountEnabled filtering, payload shape, and AllTenants fan-out + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $ReportPath = Join-Path $RepoRoot 'Modules/CIPPCore/Public/Get-CIPPSharedMailboxAccountEnabledReport.ps1' + + # Minimal stubs so Mock has commands to replace during tests + function Get-CIPPDbItem { param($TenantFilter, $Type) } + function Get-Tenants { param([switch]$IncludeErrors) } + function Write-LogMessage { param($API, $tenant, $message, $sev) } + + . $ReportPath + + function New-DbItem { + param($PartitionKey, $RowKey, $Data, $Timestamp) + [pscustomobject]@{ + PartitionKey = $PartitionKey + RowKey = $RowKey + Timestamp = $Timestamp + Data = ($Data | ConvertTo-Json -Depth 5 -Compress) + } + } +} + +Describe 'Get-CIPPSharedMailboxAccountEnabledReport' { + BeforeEach { + $script:Tenant = 'contoso.onmicrosoft.com' + + $script:SharedMailbox = @{ UPN = 'shared@contoso.com'; recipientTypeDetails = 'SharedMailbox' } + $script:RegularMailbox = @{ UPN = 'user@contoso.com'; recipientTypeDetails = 'UserMailbox' } + + $script:EnabledUser = @{ + userPrincipalName = 'shared@contoso.com' + displayName = 'Shared Mailbox' + givenName = 'Shared' + surname = 'Mailbox' + accountEnabled = $true + assignedLicenses = @(@{ skuId = 'sku-1' }) + id = 'user-id-shared' + onPremisesSyncEnabled = $false + } + $script:RegularUser = @{ + userPrincipalName = 'user@contoso.com' + displayName = 'Regular User' + accountEnabled = $true + id = 'user-id-regular' + onPremisesSyncEnabled = $false + } + + $script:Now = Get-Date + + Mock -CommandName Write-LogMessage -MockWith { } + Mock -CommandName Get-Tenants -MockWith { @([pscustomobject]@{ defaultDomainName = 'contoso.onmicrosoft.com' }) } + } + + It 'joins a shared mailbox to its user and returns the live payload shape' { + Mock -CommandName Get-CIPPDbItem -ParameterFilter { $Type -eq 'Mailboxes' } -MockWith { + @( + New-DbItem -PartitionKey $script:Tenant -RowKey 'Mailboxes-Count' -Data @{ Count = 2 } -Timestamp $script:Now + New-DbItem -PartitionKey $script:Tenant -RowKey '1' -Data $script:SharedMailbox -Timestamp $script:Now + New-DbItem -PartitionKey $script:Tenant -RowKey '2' -Data $script:RegularMailbox -Timestamp $script:Now + ) + } + Mock -CommandName Get-CIPPDbItem -ParameterFilter { $Type -eq 'Users' } -MockWith { + @( + New-DbItem -PartitionKey $script:Tenant -RowKey 'Users-Count' -Data @{ Count = 2 } -Timestamp $script:Now + New-DbItem -PartitionKey $script:Tenant -RowKey 'u1' -Data $script:EnabledUser -Timestamp $script:Now + New-DbItem -PartitionKey $script:Tenant -RowKey 'u2' -Data $script:RegularUser -Timestamp $script:Now + ) + } + + $Result = Get-CIPPSharedMailboxAccountEnabledReport -TenantFilter $script:Tenant + + @($Result).Count | Should -Be 1 + $Result[0].UserPrincipalName | Should -Be 'shared@contoso.com' + $Result[0].id | Should -Be 'user-id-shared' + $Result[0].accountEnabled | Should -BeTrue + $Result[0].onPremisesSyncEnabled | Should -BeFalse + $Result[0].CacheTimestamp | Should -Not -BeNullOrEmpty + # Must not leak the regular (non-shared) mailbox + $Result.UserPrincipalName | Should -Not -Contain 'user@contoso.com' + } + + It 'excludes shared mailboxes whose user account is disabled' { + $script:EnabledUser.accountEnabled = $false + Mock -CommandName Get-CIPPDbItem -ParameterFilter { $Type -eq 'Mailboxes' } -MockWith { + @( + New-DbItem -PartitionKey $script:Tenant -RowKey 'Mailboxes-Count' -Data @{ Count = 1 } -Timestamp $script:Now + New-DbItem -PartitionKey $script:Tenant -RowKey '1' -Data $script:SharedMailbox -Timestamp $script:Now + ) + } + Mock -CommandName Get-CIPPDbItem -ParameterFilter { $Type -eq 'Users' } -MockWith { + @(New-DbItem -PartitionKey $script:Tenant -RowKey 'u1' -Data $script:EnabledUser -Timestamp $script:Now) + } + + $Result = Get-CIPPSharedMailboxAccountEnabledReport -TenantFilter $script:Tenant + + @($Result).Count | Should -Be 0 + } + + It 'returns an empty result (no throw) when the cache holds no enabled shared mailboxes' { + Mock -CommandName Get-CIPPDbItem -ParameterFilter { $Type -eq 'Mailboxes' } -MockWith { + @( + New-DbItem -PartitionKey $script:Tenant -RowKey 'Mailboxes-Count' -Data @{ Count = 1 } -Timestamp $script:Now + New-DbItem -PartitionKey $script:Tenant -RowKey '1' -Data $script:RegularMailbox -Timestamp $script:Now + ) + } + Mock -CommandName Get-CIPPDbItem -ParameterFilter { $Type -eq 'Users' } -MockWith { + @(New-DbItem -PartitionKey $script:Tenant -RowKey 'u2' -Data $script:RegularUser -Timestamp $script:Now) + } + + { Get-CIPPSharedMailboxAccountEnabledReport -TenantFilter $script:Tenant } | Should -Not -Throw + @(Get-CIPPSharedMailboxAccountEnabledReport -TenantFilter $script:Tenant).Count | Should -Be 0 + } + + It 'throws when no mailbox data is cached' { + Mock -CommandName Get-CIPPDbItem -ParameterFilter { $Type -eq 'Mailboxes' } -MockWith { @() } + Mock -CommandName Get-CIPPDbItem -ParameterFilter { $Type -eq 'Users' } -MockWith { @() } + + { Get-CIPPSharedMailboxAccountEnabledReport -TenantFilter $script:Tenant } | Should -Throw '*Sync the report data first*' + } + + It 'adds a Tenant column for AllTenants' { + Mock -CommandName Get-CIPPDbItem -ParameterFilter { $Type -eq 'Mailboxes' } -MockWith { + @( + New-DbItem -PartitionKey $script:Tenant -RowKey 'Mailboxes-Count' -Data @{ Count = 1 } -Timestamp $script:Now + New-DbItem -PartitionKey $script:Tenant -RowKey '1' -Data $script:SharedMailbox -Timestamp $script:Now + ) + } + Mock -CommandName Get-CIPPDbItem -ParameterFilter { $Type -eq 'Users' } -MockWith { + @(New-DbItem -PartitionKey $script:Tenant -RowKey 'u1' -Data $script:EnabledUser -Timestamp $script:Now) + } + + $Result = Get-CIPPSharedMailboxAccountEnabledReport -TenantFilter 'AllTenants' + + @($Result).Count | Should -Be 1 + $Result[0].Tenant | Should -Be 'contoso.onmicrosoft.com' + $Result[0].UserPrincipalName | Should -Be 'shared@contoso.com' + } +} From 69394ec1f0fe080f7b4ae3f9311374bb4f774b60 Mon Sep 17 00:00:00 2001 From: Bobby <31723128+kris6673@users.noreply.github.com> Date: Sat, 20 Jun 2026 01:31:41 +0200 Subject: [PATCH 025/170] feat: Support room calendar processing options --- .../Email-Exchange/Resources/Invoke-EditRoomMailbox.ps1 | 5 +++-- .../Email-Exchange/Resources/Invoke-ListRooms.ps1 | 3 +++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Resources/Invoke-EditRoomMailbox.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Resources/Invoke-EditRoomMailbox.ps1 index 9ccfa72a5efb1..0d002433ffd6e 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Resources/Invoke-EditRoomMailbox.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Resources/Invoke-EditRoomMailbox.ps1 @@ -59,8 +59,9 @@ Function Invoke-EditRoomMailbox { $CalendarProperties = @( 'AllowConflicts', 'AllowRecurringMeetings', 'BookingWindowInDays', 'MaximumDurationInMinutes', 'ProcessExternalMeetingMessages', 'EnforceCapacity', - 'ForwardRequestsToDelegates', 'ScheduleOnlyDuringWorkHours ', 'AutomateProcessing', - 'AddOrganizerToSubject', 'DeleteSubject', 'RemoveCanceledMeetings' + 'ForwardRequestsToDelegates', 'ScheduleOnlyDuringWorkHours', 'AutomateProcessing', + 'AddOrganizerToSubject', 'DeleteComments', 'DeleteSubject', 'RemovePrivateProperty', + 'RemoveCanceledMeetings', 'RemoveOldMeetingMessages' ) foreach ($prop in $CalendarProperties) { diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Resources/Invoke-ListRooms.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Resources/Invoke-ListRooms.ps1 index 127b5d4006fc1..86943bf85efac 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Resources/Invoke-ListRooms.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Resources/Invoke-ListRooms.ps1 @@ -81,8 +81,11 @@ function Invoke-ListRooms { ScheduleOnlyDuringWorkHours = $CalendarProperties.ScheduleOnlyDuringWorkHours AutomateProcessing = $CalendarProperties.AutomateProcessing AddOrganizerToSubject = $CalendarProperties.AddOrganizerToSubject + DeleteComments = $CalendarProperties.DeleteComments DeleteSubject = $CalendarProperties.DeleteSubject + RemovePrivateProperty = $CalendarProperties.RemovePrivateProperty RemoveCanceledMeetings = $CalendarProperties.RemoveCanceledMeetings + RemoveOldMeetingMessages = $CalendarProperties.RemoveOldMeetingMessages # Calendar Configuration Properties WorkDays = if ([string]::IsNullOrWhiteSpace($CalendarConfigurationProperties.WorkDays)) { $null } else { $CalendarConfigurationProperties.WorkDays } From 05096dcf6b23b506d17f4fa12d894f130ca94de7 Mon Sep 17 00:00:00 2001 From: Bobby <31723128+kris6673@users.noreply.github.com> Date: Sat, 20 Jun 2026 02:15:49 +0200 Subject: [PATCH 026/170] feat: Add default calendar permission handling for rooms --- .../Resources/Invoke-EditRoomMailbox.ps1 | 25 +++++++++++++++++ .../Resources/Invoke-ListRooms.ps1 | 27 +++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Resources/Invoke-EditRoomMailbox.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Resources/Invoke-EditRoomMailbox.ps1 index 0d002433ffd6e..8c638985853e1 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Resources/Invoke-EditRoomMailbox.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Resources/Invoke-EditRoomMailbox.ps1 @@ -14,6 +14,7 @@ Function Invoke-EditRoomMailbox { $Results = [System.Collections.Generic.List[Object]]::new() $MailboxObject = $Request.Body + $DefaultCalendarPermission = $MailboxObject.DefaultCalendarPermission.value ?? $MailboxObject.DefaultCalendarPermission # First update the mailbox properties $UpdateMailboxParams = @{ @@ -99,6 +100,30 @@ Function Invoke-EditRoomMailbox { $null = New-ExoRequest -tenantid $Tenant -cmdlet 'Set-MailboxCalendarConfiguration' -cmdParams $UpdateCalendarConfigParams -Anchor $MailboxObject.roomId $Results.Add("Successfully updated room: $($MailboxObject.DisplayName) (Calendar Configuration)") + if (![string]::IsNullOrWhiteSpace($DefaultCalendarPermission)) { + $CalendarFolder = New-ExoRequest -tenantid $Tenant -cmdlet 'Get-MailboxFolderStatistics' -cmdParams @{ + Identity = $MailboxObject.roomId + FolderScope = 'Calendar' + } -Anchor $MailboxObject.roomId | Where-Object { $_.FolderType -eq 'Calendar' } | Select-Object -First 1 -ExcludeProperty *@odata.type* + + $CalendarFolderIdentity = if ($CalendarFolder -and $CalendarFolder.FolderId) { + "$($MailboxObject.roomId):$($CalendarFolder.FolderId)" + } elseif ($CalendarFolder -and $CalendarFolder.Name) { + "$($MailboxObject.roomId):\$($CalendarFolder.Name)" + } else { + "$($MailboxObject.roomId):\Calendar" + } + + $null = New-ExoRequest -tenantid $Tenant -cmdlet 'Set-MailboxFolderPermission' -cmdParams @{ + Identity = $CalendarFolderIdentity + User = 'Default' + AccessRights = $DefaultCalendarPermission + } -Anchor $MailboxObject.roomId + + Sync-CIPPCalendarPermissionCache -TenantFilter $Tenant -MailboxIdentity $MailboxObject.roomId -FolderName 'Calendar' -User 'Default' -Permissions $DefaultCalendarPermission -Action 'Add' + $Results.Add("Successfully updated room: $($MailboxObject.DisplayName) (Default Calendar Permission)") + } + Write-LogMessage -headers $Request.Headers -API $APIName -tenant $Tenant -message "Updated room $($MailboxObject.DisplayName)" -Sev 'Info' $StatusCode = [HttpStatusCode]::OK diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Resources/Invoke-ListRooms.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Resources/Invoke-ListRooms.ps1 index 86943bf85efac..b7ee98fb8848e 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Resources/Invoke-ListRooms.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Resources/Invoke-ListRooms.ps1 @@ -30,6 +30,32 @@ function Invoke-ListRooms { # Get-MailboxCalendarConfiguration requires anchor to the room mailbox $CalendarConfigurationProperties = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-MailboxCalendarConfiguration' -cmdParams @{ Identity = $RoomId } -Anchor $RoomId | Select-Object -ExcludeProperty *@odata.type* + $DefaultCalendarPermission = $null + + try { + $CalendarFolder = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-MailboxFolderStatistics' -cmdParams @{ + Identity = $RoomId + FolderScope = 'Calendar' + } -Anchor $RoomId | Where-Object { $_.FolderType -eq 'Calendar' } | Select-Object -First 1 -ExcludeProperty *@odata.type* + + if ($CalendarFolder) { + $CalendarFolderIdentity = if ($CalendarFolder.FolderId) { "$($RoomId):$($CalendarFolder.FolderId)" } else { "$($RoomId):\$($CalendarFolder.Name)" } + $CalendarFolderPermissions = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-MailboxFolderPermission' -cmdParams @{ + Identity = $CalendarFolderIdentity + } -Anchor $RoomId -UseSystemMailbox $true | Select-Object -ExcludeProperty *@odata.type* + $DefaultCalendarPermissionEntry = $CalendarFolderPermissions | Where-Object { $_.User -eq 'Default' } | Select-Object -First 1 + + if ($DefaultCalendarPermissionEntry) { + $DefaultCalendarPermission = if ($DefaultCalendarPermissionEntry.AccessRights -is [array]) { + $DefaultCalendarPermissionEntry.AccessRights -join ',' + } else { + $DefaultCalendarPermissionEntry.AccessRights + } + } + } + } catch { + Write-Warning "Could not retrieve default calendar permission for room $RoomId. $($_.Exception.Message)" + } if ($RoomMailbox -and $PlaceDetails -and $CalendarProperties -and $CalendarConfigurationProperties) { $GraphRequest = @( @@ -86,6 +112,7 @@ function Invoke-ListRooms { RemovePrivateProperty = $CalendarProperties.RemovePrivateProperty RemoveCanceledMeetings = $CalendarProperties.RemoveCanceledMeetings RemoveOldMeetingMessages = $CalendarProperties.RemoveOldMeetingMessages + DefaultCalendarPermission = $DefaultCalendarPermission # Calendar Configuration Properties WorkDays = if ([string]::IsNullOrWhiteSpace($CalendarConfigurationProperties.WorkDays)) { $null } else { $CalendarConfigurationProperties.WorkDays } From 68595ccdf281f68dba719c1d556ae1140575ccb2 Mon Sep 17 00:00:00 2001 From: Bobby <31723128+kris6673@users.noreply.github.com> Date: Thu, 25 Jun 2026 14:28:29 +0200 Subject: [PATCH 027/170] feat: incident severity + resolving comment Add severity and resolvingComment to the incident PATCH so users can reprioritise and document why an incident was resolved. Build the body as a hashtable/JSON so free-text comments with quotes are escaped, and gate assignee changes behind an explicit AssignToSelf flag so status and severity and status edits no longer clobber the existing owner. --- .../Invoke-ExecSetSecurityIncident.ps1 | 75 ++++++---- .../Invoke-ExecSetSecurityIncident.Tests.ps1 | 129 ++++++++++++++++++ 2 files changed, 175 insertions(+), 29 deletions(-) create mode 100644 Tests/Security/Invoke-ExecSetSecurityIncident.Tests.ps1 diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Invoke-ExecSetSecurityIncident.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Invoke-ExecSetSecurityIncident.ps1 index 673e7404d8569..d6f01ab1458c2 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Invoke-ExecSetSecurityIncident.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Invoke-ExecSetSecurityIncident.ps1 @@ -1,4 +1,4 @@ -Function Invoke-ExecSetSecurityIncident { +function Invoke-ExecSetSecurityIncident { <# .FUNCTIONALITY Entrypoint @@ -12,27 +12,40 @@ Function Invoke-ExecSetSecurityIncident { $Headers = $Request.Headers - $first = '' # Interact with query parameters or the body of the request. - $TenantFilter = $Request.Query.tenantFilter ?? $Request.Body.tenantFilter - $IncidentFilter = $Request.Query.GUID ?? $Request.Body.GUID - $Status = $Request.Query.Status ?? $Request.Body.Status - # $Assigned = $Request.Query.Assigned ?? $Request.Body.Assigned ?? $Headers.'x-ms-client-principal' - $Assigned = $Request.Query.Assigned ?? $Request.Body.Assigned ?? ([System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($Headers.'x-ms-client-principal')) | ConvertFrom-Json).userDetails - $Classification = $Request.Query.Classification ?? $Request.Body.Classification - $Determination = $Request.Query.Determination ?? $Request.Body.Determination - $Redirected = $Request.Query.Redirected -as [int] ?? $Request.Body.Redirected -as [int] - $BodyBuild - $AssignBody = '{' + $TenantFilter = $Request.Body.tenantFilter + $IncidentFilter = $Request.Body.GUID + $Status = $Request.Body.Status + $Classification = $Request.Body.Classification + $Determination = $Request.Body.Determination + # Severity autoComplete submits {label, value} + $Severity = $Request.Body.Severity.value + $Comment = $Request.Body.Comment + $Redirected = $Request.Body.Redirected -as [int] + + $AssignToSelf = [System.Convert]::ToBoolean($Request.Body.AssignToSelf) + # Assign-to-self resolves to the caller; other actions omit the assignee so it's preserved. + if ($AssignToSelf -eq $true) { + $Assigned = ([System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($Headers.'x-ms-client-principal')) | ConvertFrom-Json).userDetails + } + + # Hashtable + ConvertTo-Json so free-text fields (resolvingComment) are escaped correctly. + $BodyObject = [ordered]@{} + $BodyParts = [System.Collections.Generic.List[string]]::new() try { # We won't update redirected incidents because the incident it is redirected to should instead be updated if ($Redirected -lt 1) { # Set received status if ($null -ne $Status) { - $AssignBody += $first + '"status":"' + $Status + '"' - $BodyBuild += $first + 'Set status for incident ' + $IncidentFilter + ' to ' + $Status - $first = ', ' + $BodyObject['status'] = $Status + $BodyParts.Add("status to $Status") + } + + # Set received severity + if ($null -ne $Severity) { + $BodyObject['severity'] = $Severity + $BodyParts.Add("severity to $Severity") } # Set received classification and determination @@ -42,43 +55,47 @@ Function Invoke-ExecSetSecurityIncident { throw } - $AssignBody += $first + '"classification":"' + $Classification + '", "determination":"' + $Determination + '"' - $BodyBuild += $first + 'Set classification & determination for incident ' + $IncidentFilter + ' to ' + $Classification + ' ' + $Determination - $first = ', ' + $BodyObject['classification'] = $Classification + $BodyObject['determination'] = $Determination + $BodyParts.Add("classification & determination to $Classification $Determination") + } + + # Set received resolving comment + if ($null -ne $Comment) { + $BodyObject['resolvingComment'] = $Comment + $BodyParts.Add('resolving comment') } # Set received assignee if ($null -ne $Assigned) { - $AssignBody += $first + '"assignedTo":"' + $Assigned + '"' + $BodyObject['assignedTo'] = $Assigned if ($null -eq $Status) { - $BodyBuild += $first + 'Set assigned for incident ' + $IncidentFilter + ' to ' + $Assigned + $BodyParts.Add("assigned to $Assigned") } - $first = ', ' } - $AssignBody += '}' + $AssignBody = ConvertTo-Json -InputObject $BodyObject -Compress + $BodyBuild = "Set $($BodyParts -join ', ') for incident $IncidentFilter" - $ResponseBody = [pscustomobject]@{'Results' = $BodyBuild } - New-GraphPOSTRequest -uri "https://graph.microsoft.com/beta/security/incidents/$IncidentFilter" -type PATCH -tenantid $TenantFilter -body $AssignBody -asApp $true + $Result = $BodyBuild + $null = New-GraphPOSTRequest -uri "https://graph.microsoft.com/beta/security/incidents/$IncidentFilter" -type PATCH -tenantid $TenantFilter -body $AssignBody -asApp $true Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Update incident $IncidentFilter with values $AssignBody" -Sev 'Info' } else { - $ResponseBody = [pscustomobject]@{'Results' = "Refused to update incident $IncidentFilter with values $AssignBody because it is redirected to another incident" } - Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Refused to update incident $IncidentFilter with values $AssignBody because it is redirected to another incident" -Sev 'Info' + $Result = "Refused to update incident $IncidentFilter because it is redirected to another incident" + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $Result -Sev 'Info' } - $body = $ResponseBody $StatusCode = [HttpStatusCode]::OK } catch { $ErrorMessage = Get-CippException -Exception $_ $Result = "Failed to update incident $IncidentFilter : $($ErrorMessage.NormalizedError)" Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $Result -Sev 'Error' -LogData $ErrorMessage - $body = [pscustomobject]@{'Results' = $Result } $StatusCode = [HttpStatusCode]::InternalServerError } return ([HttpResponseContext]@{ StatusCode = $StatusCode - Body = $body + Body = @{ 'Results' = $Result } }) } diff --git a/Tests/Security/Invoke-ExecSetSecurityIncident.Tests.ps1 b/Tests/Security/Invoke-ExecSetSecurityIncident.Tests.ps1 new file mode 100644 index 0000000000000..8c12a28ca6ff6 --- /dev/null +++ b/Tests/Security/Invoke-ExecSetSecurityIncident.Tests.ps1 @@ -0,0 +1,129 @@ +# Pester tests for Invoke-ExecSetSecurityIncident +# Validates the PATCH body built for severity changes and resolving comments, +# and that free-text comments produce valid JSON (regression for the body builder). + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Join-Path $RepoRoot 'Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Invoke-ExecSetSecurityIncident.ps1' + + # 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 { + [int]$StatusCode + [object]$Body + } + + function New-GraphPOSTRequest { param($uri, $type, $tenantid, $body, $asApp) $script:lastPatch = @{ Uri = $uri; Type = $type; Tenant = $tenantid; Body = $body } } + function Write-LogMessage { param($headers, $API, $tenant, $message, $Sev, $LogData) $script:logs += $message } + function Get-CippException { param($Exception) @{ NormalizedError = $Exception.Exception.Message } } + + . $FunctionPath +} + +Describe 'Invoke-ExecSetSecurityIncident' { + BeforeEach { + $script:lastPatch = $null + $script:logs = @() + } + + It 'sends only the chosen severity, leaving the assignee untouched' { + # The severity action omits Assigned, so no assignedTo should be written. + $request = [pscustomobject]@{ + Params = @{ CIPPEndpoint = 'ExecSetSecurityIncident' } + Headers = @{} + Body = [pscustomobject]@{ + tenantFilter = 'contoso.onmicrosoft.com' + GUID = 'incident-1' + Severity = [pscustomobject]@{ value = 'high'; label = 'High' } + } + } + + $response = Invoke-ExecSetSecurityIncident -Request $request -TriggerMetadata $null + + $response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK) + $lastPatch.Type | Should -Be 'PATCH' + $lastPatch.Uri | Should -Match '/security/incidents/incident-1' + $parsed = $lastPatch.Body | ConvertFrom-Json + $parsed.severity | Should -Be 'high' + $parsed.PSObject.Properties.Name | Should -Not -Contain 'assignedTo' + } + + It 'assigns to the calling user when the AssignToSelf flag is set' { + $principal = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes('{"userDetails":"caller@contoso.com"}')) + $request = [pscustomobject]@{ + Params = @{ CIPPEndpoint = 'ExecSetSecurityIncident' } + Headers = @{ 'x-ms-client-principal' = $principal } + Body = [pscustomobject]@{ + tenantFilter = 'contoso.onmicrosoft.com' + GUID = 'incident-1' + AssignToSelf = $true + } + } + + $response = Invoke-ExecSetSecurityIncident -Request $request -TriggerMetadata $null + + $response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK) + ($lastPatch.Body | ConvertFrom-Json).assignedTo | Should -Be 'caller@contoso.com' + } + + It 'sends status resolved together with the resolving comment' { + $request = [pscustomobject]@{ + Params = @{ CIPPEndpoint = 'ExecSetSecurityIncident' } + Headers = @{} + Body = [pscustomobject]@{ + tenantFilter = 'contoso.onmicrosoft.com' + GUID = 'incident-2' + Status = 'resolved' + Comment = 'Closed after review' + } + } + + $response = Invoke-ExecSetSecurityIncident -Request $request -TriggerMetadata $null + + $response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK) + $parsed = $lastPatch.Body | ConvertFrom-Json + $parsed.status | Should -Be 'resolved' + $parsed.resolvingComment | Should -Be 'Closed after review' + } + + It 'produces valid JSON when the comment contains quotes and newlines' { + $comment = "Line one with a `"quote`"`nLine two with a backslash \" + $request = [pscustomobject]@{ + Params = @{ CIPPEndpoint = 'ExecSetSecurityIncident' } + Headers = @{} + Body = [pscustomobject]@{ + tenantFilter = 'contoso.onmicrosoft.com' + GUID = 'incident-3' + Status = 'resolved' + Comment = $comment + } + } + + $response = Invoke-ExecSetSecurityIncident -Request $request -TriggerMetadata $null + + $response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK) + { $lastPatch.Body | ConvertFrom-Json } | Should -Not -Throw + ($lastPatch.Body | ConvertFrom-Json).resolvingComment | Should -Be $comment + } + + It 'refuses to update a redirected incident' { + $request = [pscustomobject]@{ + Params = @{ CIPPEndpoint = 'ExecSetSecurityIncident' } + Headers = @{} + Body = [pscustomobject]@{ + tenantFilter = 'contoso.onmicrosoft.com' + GUID = 'incident-4' + Status = 'resolved' + Redirected = 1 + } + } + + $response = Invoke-ExecSetSecurityIncident -Request $request -TriggerMetadata $null + + $response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK) + $response.Body.Results | Should -Match 'Refused to update' + $lastPatch | Should -BeNullOrEmpty + } +} From 42087aefc622c764c0afcb01203bdda9cc19d1cb Mon Sep 17 00:00:00 2001 From: Logan Date: Mon, 15 Jun 2026 14:25:39 -0400 Subject: [PATCH 028/170] feat: Add message trace retrieval to Push-BECRun function --- .../Activity Triggers/BEC/Push-BECRun.ps1 | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/BEC/Push-BECRun.ps1 b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/BEC/Push-BECRun.ps1 index f4cc20ab450b1..b933239db4136 100644 --- a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/BEC/Push-BECRun.ps1 +++ b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/BEC/Push-BECRun.ps1 @@ -103,6 +103,21 @@ function Push-BECRun { $RulesLog = @() } + Write-Information 'Getting sent message trace' + try { + $MessageTraceParams = @{ + SenderAddress = $UserName + 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) + } catch { + $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' 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') } }, @@ -155,6 +170,7 @@ function Push-BECRun { LastSuspectUserLogon = @($LastSignIn) SuspectUserDevices = @($Devices) NewRules = @($RulesLog) + SentMessages = @($SentMessages) MailboxPermissionChanges = @($PermissionsLog) NewUsers = @($NewUsers) MFADevices = @($MFADevices | Where-Object { $_.'@odata.type' -ne '#microsoft.graph.passwordAuthenticationMethod' }) From f18525b6bfdda946a30ade0521cf39633b377a76 Mon Sep 17 00:00:00 2001 From: Bobby <31723128+kris6673@users.noreply.github.com> Date: Fri, 26 Jun 2026 00:46:20 +0200 Subject: [PATCH 029/170] feat: configure auth method sub-settings The SetAuthMethod endpoint only toggled state and group targeting, so method-specific options (TAP lifetimes/usable-once, Authenticator feature settings, Email OTP external/exclude, QR code, FIDO2 attestation and self-service, Voice office phone, SMS sign-in) could not be managed from the portal. Forward those settings through the handler, honor them in Set-CIPPAuthenticationPolicy, treat an explicit empty Email exclude list as a clear, and add Pester coverage. --- .../Public/Set-CIPPAuthenticationPolicy.ps1 | 44 ++++-- .../Administration/Invoke-SetAuthMethod.ps1 | 14 ++ .../Set-CIPPAuthenticationPolicy.Tests.ps1 | 129 ++++++++++++++++++ 3 files changed, 179 insertions(+), 8 deletions(-) create mode 100644 Tests/Private/Set-CIPPAuthenticationPolicy.Tests.ps1 diff --git a/Modules/CIPPCore/Public/Set-CIPPAuthenticationPolicy.ps1 b/Modules/CIPPCore/Public/Set-CIPPAuthenticationPolicy.ps1 index cd6d7d7d8c185..e21035825f43a 100644 --- a/Modules/CIPPCore/Public/Set-CIPPAuthenticationPolicy.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPAuthenticationPolicy.ps1 @@ -18,6 +18,10 @@ function Set-CIPPAuthenticationPolicy { [Parameter()][ValidateRange(8, 20)]$QRCodePinLength = 8, [Parameter()][ValidateSet('default', 'enabled', 'disabled')]$EmailAllowExternalIdToUseEmailOtp, [Parameter()][string[]]$EmailExcludeGroupIds, + [Parameter()][bool]$FIDO2AttestationEnforced, + [Parameter()][bool]$FIDO2SelfServiceRegistration, + [Parameter()][bool]$VoiceIsOfficePhoneAllowed, + [Parameter()][bool]$SmsIsUsableForSignIn, $APIName = 'Set Authentication Policy', $Headers ) @@ -39,37 +43,52 @@ function Set-CIPPAuthenticationPolicy { # FIDO2 'FIDO2' { if ($State -eq 'enabled') { - $CurrentInfo.isAttestationEnforced = $true - $CurrentInfo.isSelfServiceRegistrationAllowed = $true + # Honor passed values; otherwise default to enforced/allowed to preserve previous enable behavior + $CurrentInfo.isAttestationEnforced = if ($PSBoundParameters.ContainsKey('FIDO2AttestationEnforced')) { $FIDO2AttestationEnforced } else { $true } + $CurrentInfo.isSelfServiceRegistrationAllowed = if ($PSBoundParameters.ContainsKey('FIDO2SelfServiceRegistration')) { $FIDO2SelfServiceRegistration } else { $true } + $OptionalLogMessage = "with attestation enforced set to $($CurrentInfo.isAttestationEnforced) and self-service registration set to $($CurrentInfo.isSelfServiceRegistrationAllowed)" } } # Microsoft Authenticator 'MicrosoftAuthenticator' { if ($State -eq 'enabled') { + $AuthChanges = [System.Collections.Generic.List[string]]::new() # Set MS authenticator OTP state if parameter is passed in if ($null -ne $MicrosoftAuthenticatorSoftwareOathEnabled) { $CurrentInfo.isSoftwareOathEnabled = $MicrosoftAuthenticatorSoftwareOathEnabled - $OptionalLogMessage = "and MS Authenticator software OTP to $MicrosoftAuthenticatorSoftwareOathEnabled" + $AuthChanges.Add("software OTP set to $MicrosoftAuthenticatorSoftwareOathEnabled") } # Feature settings if ($MicrosoftAuthenticatorDisplayAppInfo) { $CurrentInfo.featureSettings.displayAppInformationRequiredState.state = $MicrosoftAuthenticatorDisplayAppInfo + $AuthChanges.Add("display app information set to $MicrosoftAuthenticatorDisplayAppInfo") } if ($MicrosoftAuthenticatorDisplayLocation) { $CurrentInfo.featureSettings.displayLocationInformationRequiredState.state = $MicrosoftAuthenticatorDisplayLocation + $AuthChanges.Add("display location set to $MicrosoftAuthenticatorDisplayLocation") } if ($MicrosoftAuthenticatorCompanionApp) { $CurrentInfo.featureSettings.companionAppAllowedState.state = $MicrosoftAuthenticatorCompanionApp + $AuthChanges.Add("companion app set to $MicrosoftAuthenticatorCompanionApp") } # numberMatchingRequiredState is permanently enabled by Microsoft and can no longer be toggled $CurrentInfo.featureSettings.PSObject.Properties.Remove('numberMatchingRequiredState') + if ($AuthChanges.Count -gt 0) { + $OptionalLogMessage = "with $($AuthChanges -join ', ')" + } } } # SMS 'SMS' { - # No special configuration needed + # SMS sign-in is set per include-target (smsAuthenticationMethodTarget.isUsableForSignIn) + if ($State -eq 'enabled' -and $PSBoundParameters.ContainsKey('SmsIsUsableForSignIn')) { + foreach ($Target in $CurrentInfo.includeTargets) { + $Target | Add-Member -NotePropertyName 'isUsableForSignIn' -NotePropertyValue $SmsIsUsableForSignIn -Force + } + $OptionalLogMessage = "with SMS sign-in set to $SmsIsUsableForSignIn" + } } # Temporary Access Pass @@ -80,7 +99,7 @@ function Set-CIPPAuthenticationPolicy { $CurrentInfo.maximumLifetimeInMinutes = $TAPMaximumLifetime $CurrentInfo.defaultLifetimeInMinutes = $TAPDefaultLifeTime $CurrentInfo.defaultLength = $TAPDefaultLength - $OptionalLogMessage = "with TAP isUsableOnce set to $TAPisUsableOnce" + $OptionalLogMessage = "with TAP isUsableOnce set to $TAPisUsableOnce, minimum lifetime $TAPMinimumLifetime min, maximum lifetime $TAPMaximumLifetime min, default lifetime $TAPDefaultLifeTime min, and default length $TAPDefaultLength" } } @@ -96,7 +115,10 @@ function Set-CIPPAuthenticationPolicy { # Voice call 'Voice' { - # No special configuration needed + if ($State -eq 'enabled' -and $PSBoundParameters.ContainsKey('VoiceIsOfficePhoneAllowed')) { + $CurrentInfo.isOfficePhoneAllowed = $VoiceIsOfficePhoneAllowed + $OptionalLogMessage = "with isOfficePhoneAllowed set to $VoiceIsOfficePhoneAllowed" + } } # Email OTP @@ -106,7 +128,8 @@ function Set-CIPPAuthenticationPolicy { $CurrentInfo.allowExternalIdToUseEmailOtp = $EmailAllowExternalIdToUseEmailOtp $OptionalLogMessage = "with allowExternalIdToUseEmailOtp set to $EmailAllowExternalIdToUseEmailOtp" } - if ($EmailExcludeGroupIds) { + # Present (even empty) means the caller is setting the exclude list; an empty array clears it + if ($PSBoundParameters.ContainsKey('EmailExcludeGroupIds')) { $CurrentInfo.excludeTargets = @( foreach ($id in $EmailExcludeGroupIds) { [pscustomobject]@{ @@ -115,7 +138,11 @@ function Set-CIPPAuthenticationPolicy { } } ) - $OptionalLogMessage += " and excluded groups set to $($EmailExcludeGroupIds -join ', ')" + if ($EmailExcludeGroupIds) { + $OptionalLogMessage += " and excluded groups set to $($EmailExcludeGroupIds -join ', ')" + } else { + $OptionalLogMessage += ' and excluded groups cleared' + } } } } @@ -130,6 +157,7 @@ function Set-CIPPAuthenticationPolicy { if ($State -eq 'enabled') { $CurrentInfo.standardQRCodeLifetimeInDays = $QRCodeLifetimeInDays $CurrentInfo.pinLength = $QRCodePinLength + $OptionalLogMessage = "with QR code lifetime $QRCodeLifetimeInDays days and PIN length $QRCodePinLength" } } default { diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Invoke-SetAuthMethod.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Invoke-SetAuthMethod.ps1 index 71b5adf5984dc..d179814f4f3d0 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Invoke-SetAuthMethod.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Invoke-SetAuthMethod.ps1 @@ -28,6 +28,20 @@ function Invoke-SetAuthMethod { if ($GroupIds) { $Params.GroupIds = @($GroupIds) } + # Forward any method-specific sub-settings present in the body (omitted ones keep current/default values) + $OptionalSettings = @( + 'TAPisUsableOnce', 'TAPMinimumLifetime', 'TAPMaximumLifetime', 'TAPDefaultLifeTime', 'TAPDefaultLength' + 'MicrosoftAuthenticatorSoftwareOathEnabled', 'MicrosoftAuthenticatorDisplayAppInfo', 'MicrosoftAuthenticatorDisplayLocation', 'MicrosoftAuthenticatorCompanionApp' + 'EmailAllowExternalIdToUseEmailOtp', 'EmailExcludeGroupIds' + 'QRCodeLifetimeInDays', 'QRCodePinLength' + 'FIDO2AttestationEnforced', 'FIDO2SelfServiceRegistration', 'VoiceIsOfficePhoneAllowed' + 'SmsIsUsableForSignIn' + ) + foreach ($Setting in $OptionalSettings) { + if ($null -ne $Request.Body.$Setting) { + $Params.$Setting = $Request.Body.$Setting + } + } $Result = Set-CIPPAuthenticationPolicy @Params $StatusCode = [HttpStatusCode]::OK } catch { diff --git a/Tests/Private/Set-CIPPAuthenticationPolicy.Tests.ps1 b/Tests/Private/Set-CIPPAuthenticationPolicy.Tests.ps1 new file mode 100644 index 0000000000000..c5402c179623e --- /dev/null +++ b/Tests/Private/Set-CIPPAuthenticationPolicy.Tests.ps1 @@ -0,0 +1,129 @@ +# Pester tests for Set-CIPPAuthenticationPolicy +# Validates that method-specific sub-settings are written to the Graph PATCH body + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Join-Path $RepoRoot 'Modules/CIPPCore/Public/Set-CIPPAuthenticationPolicy.ps1' + + # Mock returns whatever the current test stored, simulating the existing Graph config + function New-GraphGetRequest { param($Uri, $tenantid, $AsApp) return $script:mockCurrentInfo } + function New-GraphPostRequest { + param($tenantid, $Uri, $Type, $Body, $ContentType, $AsApp) + $script:lastBody = $Body + } + function Write-LogMessage { param($headers, $API, $tenant, $message, $sev, $LogData) } + function Get-CippException { param($Exception) $Exception } + + . $FunctionPath +} + +Describe 'Set-CIPPAuthenticationPolicy' { + BeforeEach { + $script:lastBody = $null + $script:mockCurrentInfo = $null + } + + It 'writes Temporary Access Pass sub-settings to the PATCH body' { + $script:mockCurrentInfo = [pscustomobject]@{ + state = 'disabled' + isUsableOnce = $true + minimumLifetimeInMinutes = 10 + maximumLifetimeInMinutes = 20 + defaultLifetimeInMinutes = 15 + defaultLength = 8 + } + + Set-CIPPAuthenticationPolicy -Tenant 'contoso.onmicrosoft.com' -AuthenticationMethodId 'TemporaryAccessPass' ` + -Enabled $true -TAPisUsableOnce $false -TAPDefaultLength 12 -TAPDefaultLifeTime 90 + + $body = $script:lastBody | ConvertFrom-Json + $body.state | Should -Be 'enabled' + $body.isUsableOnce | Should -Be $false + $body.defaultLength | Should -Be 12 + $body.defaultLifetimeInMinutes | Should -Be 90 + } + + It 'honors the FIDO2 attestation/self-service parameters instead of forcing true' { + $script:mockCurrentInfo = [pscustomobject]@{ + state = 'disabled' + isAttestationEnforced = $true + isSelfServiceRegistrationAllowed = $true + } + + Set-CIPPAuthenticationPolicy -Tenant 'contoso.onmicrosoft.com' -AuthenticationMethodId 'FIDO2' ` + -Enabled $true -FIDO2AttestationEnforced $false -FIDO2SelfServiceRegistration $false + + $body = $script:lastBody | ConvertFrom-Json + $body.isAttestationEnforced | Should -Be $false + $body.isSelfServiceRegistrationAllowed | Should -Be $false + } + + It 'defaults FIDO2 to enforced/allowed when no parameters are passed' { + $script:mockCurrentInfo = [pscustomobject]@{ + state = 'disabled' + isAttestationEnforced = $false + isSelfServiceRegistrationAllowed = $false + } + + Set-CIPPAuthenticationPolicy -Tenant 'contoso.onmicrosoft.com' -AuthenticationMethodId 'FIDO2' -Enabled $true + + $body = $script:lastBody | ConvertFrom-Json + $body.isAttestationEnforced | Should -Be $true + $body.isSelfServiceRegistrationAllowed | Should -Be $true + } + + It 'scopes the method to all users when GroupIds contains all_users' { + $script:mockCurrentInfo = [pscustomobject]@{ + state = 'disabled' + includeTargets = @() + } + + Set-CIPPAuthenticationPolicy -Tenant 'contoso.onmicrosoft.com' -AuthenticationMethodId 'softwareOath' ` + -Enabled $true -GroupIds @('all_users') + + $body = $script:lastBody | ConvertFrom-Json + $body.includeTargets[0].targetType | Should -Be 'group' + $body.includeTargets[0].id | Should -Be 'all_users' + } + + It 'stamps SMS isUsableForSignIn onto every include-target' { + $script:mockCurrentInfo = [pscustomobject]@{ + state = 'disabled' + includeTargets = @( + [pscustomobject]@{ targetType = 'group'; id = 'all_users' } + ) + } + + Set-CIPPAuthenticationPolicy -Tenant 'contoso.onmicrosoft.com' -AuthenticationMethodId 'SMS' ` + -Enabled $true -SmsIsUsableForSignIn $true + + $body = $script:lastBody | ConvertFrom-Json + $body.includeTargets[0].isUsableForSignIn | Should -Be $true + } + + It 'clears Email exclude targets when an empty group list is supplied' { + $script:mockCurrentInfo = [pscustomobject]@{ + state = 'disabled' + excludeTargets = @([pscustomobject]@{ targetType = 'group'; id = 'old-group' }) + } + + Set-CIPPAuthenticationPolicy -Tenant 'contoso.onmicrosoft.com' -AuthenticationMethodId 'Email' ` + -Enabled $true -EmailExcludeGroupIds @() + + $body = $script:lastBody | ConvertFrom-Json + @($body.excludeTargets).Count | Should -Be 0 + } + + It 'writes the Voice isOfficePhoneAllowed setting to the PATCH body' { + $script:mockCurrentInfo = [pscustomobject]@{ + state = 'disabled' + isOfficePhoneAllowed = $false + } + + Set-CIPPAuthenticationPolicy -Tenant 'contoso.onmicrosoft.com' -AuthenticationMethodId 'Voice' ` + -Enabled $true -VoiceIsOfficePhoneAllowed $true + + $body = $script:lastBody | ConvertFrom-Json + $body.isOfficePhoneAllowed | Should -Be $true + } +} From ee5c30aa1ff4361e43a73655278cdf1fe70ae00d Mon Sep 17 00:00:00 2001 From: Bobby <31723128+kris6673@users.noreply.github.com> Date: Fri, 26 Jun 2026 18:16:39 +0200 Subject: [PATCH 030/170] feat: enhance device import status handling and logging --- .../Endpoint/Autopilot/Invoke-AddAPDevice.ps1 | 45 +++++++++++++++---- 1 file changed, 36 insertions(+), 9 deletions(-) diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/Autopilot/Invoke-AddAPDevice.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/Autopilot/Invoke-AddAPDevice.ps1 index 56dc7c0bda121..ee7a42396de3a 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/Autopilot/Invoke-AddAPDevice.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/Autopilot/Invoke-AddAPDevice.ps1 @@ -47,27 +47,54 @@ function Invoke-AddAPDevice { $Amount++ Start-Sleep 1 $NewStatus = New-GraphGetRequest -uri "https://api.partnercenter.microsoft.com/v1/$($GraphRequest.Location)" -scope 'https://api.partnercenter.microsoft.com/user_impersonation' - } until ($NewStatus.status -eq 'finished' -or $Amount -eq 4) - if ($NewStatus.status -ne 'finished') { throw 'Could not retrieve status of import - This job might still be running. Check the autopilot device list in 10 minutes for the latest status.' } - Write-LogMessage -headers $Request.Headers -API $APIName -tenant $($Request.body.TenantFilter.value) -message "Created Autopilot devices group. Group ID is $GroupName" -Sev 'Info' + } until ($NewStatus.status -in @('finished', 'finished_with_errors') -or $Amount -eq 4) + if ($NewStatus.status -notin @('finished', 'finished_with_errors')) { throw 'Could not retrieve status of import - This job might still be running. Check the autopilot device list in 10 minutes for the latest status.' } + Write-LogMessage -headers $Headers -API $APIName -tenant $($Request.body.TenantFilter.value) -message "Created Autopilot devices group. Group ID is $GroupName" -Sev 'Info' - [PSCustomObject]@{ - Status = 'Import Job Completed' - Devices = @($NewStatus.devicesStatus) + # DEBUG: dump the raw status so we can inspect what Partner Center returns per device. + Write-Host "RAW NewStatus: $($NewStatus | ConvertTo-Json -Depth 10)" + + # Build one result per device (DeviceUploadDetails) so the frontend renders a + # single bar each, instead of flattening raw device fields into many stray bars. + $Index = 0 + $DeviceResults = foreach ($Device in @($NewStatus.devicesStatus)) { + $Index++ + # Hash-only uploads return no serial/productKey/deviceId; fall back to a number. + $DeviceId = $Device.serialNumber ?? $Device.productKey ?? $Device.deviceId + $Label = $DeviceId ?? "Device $Index" + $IsError = $Device.status -match 'error' + $Text = "$($Label): $($Device.status)" + if ($IsError -and $Device.errorDescription) { + $Text += " - $($Device.errorCode) $($Device.errorDescription)" + } + # Log each device with the input data that was submitted for it (matched by position). + $InputDevice = @($rawDevices)[$Index - 1] + Write-LogMessage -headers $Headers -API $APIName -tenant $($Request.Body.TenantFilter.value) -message "Autopilot import - $Text" -Sev $(if ($IsError) { 'Error' } else { 'Info' }) -LogData $InputDevice + [PSCustomObject]@{ + resultText = $Text + state = if ($IsError) { 'error' } else { 'success' } + copyField = $DeviceId + details = $Device + } + } + if (-not $DeviceResults) { + $DeviceResults = [PSCustomObject]@{ resultText = "Import job '$($NewStatus.status)' for group $GroupName"; state = 'success' } } $StatusCode = [HttpStatusCode]::OK + # Emit as the try block's value so the outer `$Result = try {...}` captures it. + $DeviceResults } catch { $ErrorMessage = Get-CippException -Exception $_ $StatusCode = [HttpStatusCode]::InternalServerError [PSCustomObject]@{ - Status = "$($Request.Body.TenantFilter.value): Failed to create autopilot devices. $($ErrorMessage.NormalizedError)" - Devices = @() + resultText = "$($Request.Body.TenantFilter.value): Failed to create autopilot devices. $($ErrorMessage.NormalizedError)" + state = 'error' } Write-LogMessage -headers $Headers -API $APIName -tenant $($Request.Body.TenantFilter.value) -message "Failed to create autopilot devices. $($ErrorMessage.NormalizedError)" -Sev 'Error' -LogData $ErrorMessage } return ([HttpResponseContext]@{ StatusCode = $StatusCode - Body = @{'Results' = $Result } + Body = @{'Results' = @($Result) } }) } From 03ae94ec934f943fbb4b632d742d64232e0bacf3 Mon Sep 17 00:00:00 2001 From: Tim Fournet Date: Fri, 26 Jun 2026 13:52:32 -0500 Subject: [PATCH 031/170] Add typed-response + operationId enrichment for openapi.json The generated openapi.json types every request body but leaves each 200 response as the generic StandardResults envelope, and carries no operationId on any operation. That makes the spec hard to consume: OpenAPI importers that key on operationId skip every operation, and downstream tools get no typed output fields to map against. This adds a deterministic post-processing stage that enriches the generated spec without replacing the upstream generator: - operationId injection: bare endpoint name per operation, method-prefixed only where a single path carries multiple methods. Existing operationIds are preserved; collisions are a hard failure. - typed 200 responses: derived from two checked-in sources, the frontend shape baselines (Tests/Shapes/*.json) and page simpleColumns declarations. The { Results, Metadata } envelope is preserved as the API actually returns it. The enriched spec is published as the openapi.enriched.json asset on each GitHub Release. A PR check workflow runs the test suite and strictly lints the enriched spec against a committed ignore-baseline that pins pre-existing upstream findings, so any new finding fails CI. Pure function of the two repositories; same input produces byte-identical output. Stage and runner are PowerShell with a Pester suite (50 tests). --- .build/Add-OpenApiResponseSchemas.ps1 | 349 +++++++++++++ .build/README.md | 38 ++ .github/workflows/openapi-enriched-check.yml | 83 +++ .../workflows/openapi-enriched-release.yml | 86 +++ .redocly.lint-ignore.yaml | 12 + .../Add-OpenApiResponseSchemas.Tests.ps1 | 488 ++++++++++++++++++ Tests/Build/Invoke-BuildTests.ps1 | 46 ++ redocly.yaml | 5 + 8 files changed, 1107 insertions(+) create mode 100644 .build/Add-OpenApiResponseSchemas.ps1 create mode 100644 .build/README.md create mode 100644 .github/workflows/openapi-enriched-check.yml create mode 100644 .github/workflows/openapi-enriched-release.yml create mode 100644 .redocly.lint-ignore.yaml create mode 100644 Tests/Build/Add-OpenApiResponseSchemas.Tests.ps1 create mode 100644 Tests/Build/Invoke-BuildTests.ps1 create mode 100644 redocly.yaml diff --git a/.build/Add-OpenApiResponseSchemas.ps1 b/.build/Add-OpenApiResponseSchemas.ps1 new file mode 100644 index 0000000000000..3e90a214a1b13 --- /dev/null +++ b/.build/Add-OpenApiResponseSchemas.ps1 @@ -0,0 +1,349 @@ +#Requires -Version 7.0 +<# +.SYNOPSIS + Enriches a CIPP openapi.json with typed 200 response schemas derived by static + analysis of the API and frontend repositories. + +.DESCRIPTION + The generated CIPP spec types every request body but leaves every 200 response + as the generic StandardResults envelope. This stage fills typed per-endpoint + response schemas for the read surface, using two deterministic sources that are + already checked into the repositories (no live API calls): + + 1. Captured response shape baselines (CIPP/Tests/Shapes/*.json) - carry real + field types and nesting. Preferred when present. + 2. Frontend table column declarations (simpleColumns in CIPP/src pages) - + carry field names only. Used when no baseline exists; fields are typed as + string and marked x-cipp-field-source: frontend so consumers know the type + is a name-only inference, not a verified type. + + Endpoints with neither source keep the StandardResults envelope, which is the + correct shape for write/exec operations. Output is deterministic: the same input + repositories always produce a byte-identical spec. + +.PARAMETER InputSpec + Path to the source openapi.json. Defaults to the repo-root spec relative to this + script (.build/.. ). + +.PARAMETER OutputSpec + Path to write the enriched spec. Defaults to InputSpec (in-place rewrite). + +.PARAMETER FrontendRepoPath + Path to a checkout of the CIPP frontend repository. Provides both the shape + baselines (Tests/Shapes) and the page column declarations (src). + +.PARAMETER PassThru + Return the enriched spec object instead of only writing it. Used by tests. + +.EXAMPLE + ./Add-OpenApiResponseSchemas.ps1 -FrontendRepoPath ../CIPP + + Rewrites the repo-root openapi.json in place with typed response schemas. +#> +[CmdletBinding()] +param( + [string]$InputSpec = (Join-Path $PSScriptRoot '..' 'openapi.json'), + [string]$OutputSpec, + [string]$FrontendRepoPath, + [switch]$PassThru +) + +$ErrorActionPreference = 'Stop' + +$script:CippHttpMethods = @('get', 'post', 'put', 'patch', 'delete') + +function ConvertFrom-ShapeNode { + <# + .SYNOPSIS + Converts one node of a captured shape tree into an OpenAPI schema fragment. + #> + param($Node) + + if ($Node -is [string]) { + switch ($Node) { + 'string' { return @{ type = 'string' } } + 'number' { return @{ type = 'number' } } + 'bool' { return @{ type = 'boolean' } } + 'datetime' { return [ordered]@{ type = 'string'; format = 'date-time' } } + # 'null' (captured as null at sample time) and 'truncated' (below the + # capture depth limit) carry no reliable type, so stay permissive. + default { return @{} } + } + } + + if ($Node -is [System.Collections.IDictionary]) { + if ($Node['_type'] -eq 'array') { + return [ordered]@{ type = 'array'; items = (ConvertFrom-ShapeNode -Node $Node['_element']) } + } + $properties = [ordered]@{} + foreach ($key in ($Node.Keys | Sort-Object)) { + $properties[[string]$key] = ConvertFrom-ShapeNode -Node $Node[$key] + } + return [ordered]@{ type = 'object'; properties = $properties } + } + + return @{} +} + +function Get-ShapeBaselineMap { + <# + .SYNOPSIS + Maps endpoint name -> per-record OpenAPI schema, from captured shape baselines. + .DESCRIPTION + Reads only files carrying both _metadata and shape; the sibling + test-results.json and any non-baseline file is skipped. The per-record schema + is the baseline shape itself (the CIPP envelope's Results[] element). + #> + param([string]$ShapesDir) + + $map = @{} + if (-not (Test-Path $ShapesDir)) { + Write-Warning "Shapes directory not found: $ShapesDir" + return $map + } + + foreach ($file in (Get-ChildItem -Path $ShapesDir -Filter '*.json' | Sort-Object -Property FullName)) { + $doc = Get-Content -LiteralPath $file.FullName -Raw | ConvertFrom-Json -AsHashtable -Depth 100 + if (-not ($doc -is [System.Collections.IDictionary] -and $doc.ContainsKey('_metadata') -and $doc.ContainsKey('shape'))) { + continue + } + $endpoint = $doc['_metadata']['endpoint'] + if (-not $endpoint) { continue } + $map[$endpoint] = ConvertFrom-ShapeNode -Node $doc['shape'] + } + return $map +} + +function Get-FrontendColumnMap { + <# + .SYNOPSIS + Maps endpoint name -> sorted unique field names, from page simpleColumns. + .DESCRIPTION + Intent: skips conditional simpleColumns arrays to avoid non-column branch strings; false negatives beat junk fields. + Scans frontend page sources for files that pair an /api/ reference + with a simpleColumns array, and unions the declared column names per endpoint. + Field names are deterministic; their types are not, so callers type them as + string with a provenance marker. + #> + param([string]$SrcDir) + + $map = @{} + if (-not (Test-Path $SrcDir)) { + Write-Warning "Frontend src directory not found: $SrcDir" + return $map + } + + $endpointPattern = [regex]'/api/([A-Za-z0-9_]+)' + $columnsPattern = [regex]'(?s)\bsimpleColumns\s*(?:=|:)\s*(?:\{\s*)?\[(?[^\]]*)\]' + $stringPattern = [regex]'"([^"]+)"|''([^'']+)''' + + $files = Get-ChildItem -Path $SrcDir -Recurse -File -Include '*.js', '*.jsx' + foreach ($file in $files) { + $text = Get-Content -LiteralPath $file.FullName -Raw + if ([string]::IsNullOrEmpty($text) -or $text -notmatch 'simpleColumns') { continue } + + $endpoints = $endpointPattern.Matches($text) | ForEach-Object { $_.Groups[1].Value } | Sort-Object -Unique + if (-not $endpoints) { continue } + + $columns = foreach ($colMatch in $columnsPattern.Matches($text)) { + foreach ($strMatch in $stringPattern.Matches($colMatch.Groups['columns'].Value)) { + $value = if ($strMatch.Groups[1].Success) { $strMatch.Groups[1].Value } else { $strMatch.Groups[2].Value } + if ($value) { $value } + } + } + if (-not $columns) { continue } + + foreach ($endpoint in $endpoints) { + if (-not $map.ContainsKey($endpoint)) { $map[$endpoint] = [System.Collections.Generic.HashSet[string]]::new() } + foreach ($column in $columns) { [void]$map[$endpoint].Add($column) } + } + } + return $map +} + +function ConvertTo-ColumnRecordSchema { + <# + .SYNOPSIS + Builds a per-record object schema from a set of frontend column names. + #> + param([System.Collections.Generic.HashSet[string]]$Columns) + + $properties = [ordered]@{} + foreach ($column in ($Columns | Sort-Object)) { + $properties[$column] = [ordered]@{ type = 'string'; 'x-cipp-field-source' = 'frontend' } + } + return [ordered]@{ type = 'object'; properties = $properties } +} + +function ConvertTo-ResponseEnvelopeSchema { + <# + .SYNOPSIS + Wraps a per-record schema in the CIPP { Results: [...], Metadata: {...} } envelope. + #> + param($RecordSchema) + + return [ordered]@{ + type = 'object' + properties = [ordered]@{ + Results = [ordered]@{ type = 'array'; items = $RecordSchema } + Metadata = [ordered]@{ type = 'object' } + } + } +} + + +function Get-CippOperationId { + <# + .SYNOPSIS + Builds the deterministic operationId for one CIPP path and method. + .DESCRIPTION + Riftwing imports OpenAPI operations by operationId. CIPP upstream does not + currently emit operationIds, so this keeps importer keys stable without + depending on display labels or external data. + #> + param( + [Parameter(Mandatory)][string]$Path, + [Parameter(Mandatory)][string]$Method, + [Parameter(Mandatory)][string[]]$PathMethods + ) + + $endpointName = $Path -replace '^/api/', '' + if ($PathMethods.Count -eq 1) { + return $endpointName + } + + $methodName = [System.Globalization.CultureInfo]::InvariantCulture.TextInfo.ToTitleCase($Method.ToLowerInvariant()) + return "$methodName$endpointName" +} + +function Add-CippOperationId { + <# + .SYNOPSIS + Injects missing operationIds and fails on duplicate operationIds. + .DESCRIPTION + Existing non-empty operationIds are preserved so this pass can retire itself + when upstream starts emitting operationIds. Duplicate operationIds are fatal + because importers commonly key operations by operationId. + #> + param([Parameter(Mandatory)][System.Collections.IDictionary]$Spec) + + if (-not $Spec['paths']) { throw 'Spec has no paths.' } + + $operationCount = 0 + $injectedCount = 0 + $operationIds = @{} + + foreach ($pathEntry in $Spec['paths'].GetEnumerator()) { + $pathMethods = @($pathEntry.Value.Keys | Where-Object { $_ -in $script:CippHttpMethods }) + foreach ($methodEntry in $pathEntry.Value.GetEnumerator()) { + if ($methodEntry.Key -notin $script:CippHttpMethods) { continue } + + $operationCount++ + $operation = $methodEntry.Value + $operationId = $operation['operationId'] + if ([string]::IsNullOrWhiteSpace([string]$operationId)) { + $operationId = Get-CippOperationId -Path $pathEntry.Key -Method $methodEntry.Key -PathMethods $pathMethods + $operation['operationId'] = $operationId + $injectedCount++ + } + + if ($operationIds.ContainsKey($operationId)) { + throw "Duplicate operationId found: $operationId" + } + $operationIds[$operationId] = $true + } + } + + return [pscustomobject]@{ Operations = $operationCount; Injected = $injectedCount; Unique = $operationIds.Count } +} + +function Resolve-SpecResponse { + <# + .SYNOPSIS + Adds typed 200 response schemas to a parsed spec, in place, and returns counts. + .DESCRIPTION + The pure core of this stage: operates on an already-parsed spec hashtable and + the two endpoint maps, with no file or repository access, so it is unit + testable. Only existing 200 responses on get/post/put/patch/delete operations + are touched; everything else (including operations with no matching source) is + left exactly as found. + #> + param( + [Parameter(Mandatory)][System.Collections.IDictionary]$Spec, + [Parameter(Mandatory)][hashtable]$BaselineMap, + [Parameter(Mandatory)][hashtable]$ColumnMap + ) + + if (-not $Spec['paths']) { throw 'Spec has no paths.' } + + $operationCount = 0 + $typedCount = 0 + + foreach ($pathEntry in $Spec['paths'].GetEnumerator()) { + $endpoint = $pathEntry.Key -replace '^/api/', '' + + $recordSchema = $null + if ($BaselineMap.ContainsKey($endpoint)) { + $recordSchema = $BaselineMap[$endpoint] + } elseif ($ColumnMap.ContainsKey($endpoint)) { + $recordSchema = ConvertTo-ColumnRecordSchema -Columns $ColumnMap[$endpoint] + } + + foreach ($methodEntry in $pathEntry.Value.GetEnumerator()) { + if ($methodEntry.Key -notin $script:CippHttpMethods) { continue } + $operationCount++ + if ($null -eq $recordSchema) { continue } + + $responses = $methodEntry.Value['responses'] + if ($null -eq $responses) { continue } + + $okResponse = $responses['200'] + if (-not $okResponse) { continue } + + $okResponse['content'] = [ordered]@{ + 'application/json' = [ordered]@{ schema = (ConvertTo-ResponseEnvelopeSchema -RecordSchema $recordSchema) } + } + $typedCount++ + } + } + + return [pscustomobject]@{ + Operations = $operationCount + Typed = $typedCount + } +} + +function Add-CippResponseSchema { + <# + .SYNOPSIS + File-level orchestration: read spec + repo sources, enrich, write output. + #> + param( + [Parameter(Mandatory)][string]$InputSpec, + [Parameter(Mandatory)][string]$OutputSpec, + [Parameter(Mandatory)][string]$FrontendRepoPath, + [switch]$PassThru + ) + + if (-not (Test-Path $InputSpec)) { throw "Input spec not found: $InputSpec" } + + $spec = Get-Content -LiteralPath $InputSpec -Raw | ConvertFrom-Json -AsHashtable -Depth 100 + $baselineMap = Get-ShapeBaselineMap -ShapesDir (Join-Path $FrontendRepoPath 'Tests' 'Shapes') + $columnMap = Get-FrontendColumnMap -SrcDir (Join-Path $FrontendRepoPath 'src') + + $operationIdResult = Add-CippOperationId -Spec $spec + $result = Resolve-SpecResponse -Spec $spec -BaselineMap $baselineMap -ColumnMap $columnMap + Write-Information "Operations: $($result.Operations) | typed responses added: $($result.Typed) | operationIds injected: $($operationIdResult.Injected) | unique operationIds: $($operationIdResult.Unique)" -InformationAction Continue + + # Serialization is deterministic for the object this stage builds, but it does not globally canonicalize pre-existing spec keys. + [System.IO.File]::WriteAllText($OutputSpec, ($spec | ConvertTo-Json -Depth 100)) + + if ($PassThru) { return $spec } +} + +# Run orchestration only when invoked as a script, not when dot-sourced for testing. +if ($MyInvocation.InvocationName -ne '.') { + if (-not $FrontendRepoPath) { throw 'FrontendRepoPath is required when running the script.' } + if (-not $OutputSpec) { $OutputSpec = $InputSpec } + Add-CippResponseSchema -InputSpec $InputSpec -OutputSpec $OutputSpec -FrontendRepoPath $FrontendRepoPath -PassThru:$PassThru +} diff --git a/.build/README.md b/.build/README.md new file mode 100644 index 0000000000000..81d8ce08a6dd6 --- /dev/null +++ b/.build/README.md @@ -0,0 +1,38 @@ +# OpenAPI enrichment + +`Add-OpenApiResponseSchemas.ps1` post-processes the generated CIPP `openapi.json`. It adds deterministic operationIds and typed `200` response schemas where response shape data can be derived from the CIPP frontend repository. It does not replace the upstream OpenAPI generator. + +The enriched spec is published on each GitHub Release as the `openapi.enriched.json` release asset. + +The PR check and release workflow strictly lint the CI-generated `openapi.enriched.json` with Redocly. The committed `.redocly.lint-ignore.yaml` baseline pins findings that already exist in the generated enriched spec because of upstream `openapi.json` issues. Any new Redocly error or warning that is not in the baseline fails CI. + +To regenerate locally, check out the CIPP frontend repository and run: + +```powershell +pwsh -NoProfile -File .build/Add-OpenApiResponseSchemas.ps1 ` + -FrontendRepoPath ` + -InputSpec ./openapi.json -OutputSpec ./openapi.enriched.json +``` + +If upstream `openapi.json` legitimately changes and the pinned Redocly findings must be refreshed, regenerate the enriched spec first, then regenerate the ignore baseline from that enriched output: + +```powershell +pwsh -NoProfile -File .build/Add-OpenApiResponseSchemas.ps1 ` + -FrontendRepoPath ` + -InputSpec ./openapi.json -OutputSpec ./openapi.enriched.json +npx --yes @redocly/cli@2.35.1 lint ./openapi.enriched.json --generate-ignore-file +``` + +Do not generate the baseline from the base `openapi.json`. The lint subject is always the generated `openapi.enriched.json`. + +## Known limitations + +- Only `get`, `post`, `put`, `patch`, and `delete` operations are processed. `head`, `options`, and `trace` are not present in the current spec. +- Paths are assumed to start with `/api/`. All 580 current paths do. +- When a typed `200` response is added, it replaces the existing `200.content`. Today that content is only the generic `StandardResults` envelope. +- Conditional/ternary `simpleColumns` expressions are intentionally not parsed. + +## Release workflow notes + +- `openapi-enriched-release.yml` builds and uploads from the same tag. On `workflow_dispatch`, the `tag` input is checked out and used as the upload target. On `release: published`, the release tag is checked out and used as the upload target. +- `.github/workflows/` is gitignored in this repository, so the OpenAPI workflow files require `git add -f` when they are intentionally added or updated. diff --git a/.github/workflows/openapi-enriched-check.yml b/.github/workflows/openapi-enriched-check.yml new file mode 100644 index 0000000000000..cb93afbf9aba2 --- /dev/null +++ b/.github/workflows/openapi-enriched-check.yml @@ -0,0 +1,83 @@ +name: OpenAPI Enriched Spec Check + +on: + pull_request: + paths: + - '.build/**' + - '.github/workflows/openapi-enriched-check.yml' + - '.github/workflows/openapi-enriched-release.yml' + - '.redocly.lint-ignore.yaml' + - 'redocly.yaml' + - 'Tests/Build/**' + workflow_dispatch: + +permissions: + contents: read + +jobs: + openapi-enriched-check: + if: github.repository_owner == 'KelvinTegelaar' + runs-on: ubuntu-latest + + steps: + - name: Checkout CIPP-API + uses: actions/checkout@v4 + + - name: Checkout CIPP frontend + uses: actions/checkout@v4 + with: + repository: KelvinTegelaar/CIPP + path: _frontend + + - name: Install PowerShell test modules + shell: pwsh + run: | + Install-Module Pester -RequiredVersion 5.7.1 -Scope CurrentUser -Force + Install-Module PSScriptAnalyzer -RequiredVersion 1.25.0 -Scope CurrentUser -Force + + - name: Run build tests + shell: pwsh + run: ./Tests/Build/Invoke-BuildTests.ps1 + + - name: Generate enriched OpenAPI spec + shell: pwsh + run: | + pwsh -NoProfile -File .build/Add-OpenApiResponseSchemas.ps1 ` + -FrontendRepoPath ./_frontend ` + -InputSpec ./openapi.json ` + -OutputSpec ./openapi.enriched.json + + - name: Strict lint enriched OpenAPI spec + run: | + set +e + npx --yes @redocly/cli@2.35.1 lint ./openapi.enriched.json --format=json > redocly-results.json + REDOCLY_STATUS=$? + set -e + + node - redocly-results.json "$REDOCLY_STATUS" <<'NODE' + const fs = require('fs'); + + const resultsPath = process.argv[2]; + const redoclyStatus = Number(process.argv[3]); + const raw = fs.readFileSync(resultsPath, 'utf8').trim(); + if (!raw) { + throw new Error(`${resultsPath} did not contain Redocly JSON output.`); + } + + const doc = JSON.parse(raw); + const totals = doc.totals || {}; + const errors = totals.errors || 0; + const warnings = totals.warnings || 0; + const ignored = totals.ignored || 0; + console.log(`Redocly new findings: errors=${errors}, warnings=${warnings}, ignored=${ignored}`); + + if (errors + warnings > 0) { + console.error('Enriched spec introduced new Redocly finding(s). Update the enrichment or regenerate the baseline only for legitimate upstream changes.'); + process.exit(1); + } + + if (redoclyStatus !== 0) { + console.error(`Redocly exited with status ${redoclyStatus}.`); + process.exit(redoclyStatus); + } + NODE diff --git a/.github/workflows/openapi-enriched-release.yml b/.github/workflows/openapi-enriched-release.yml new file mode 100644 index 0000000000000..853e5542bc1a9 --- /dev/null +++ b/.github/workflows/openapi-enriched-release.yml @@ -0,0 +1,86 @@ +name: Publish Enriched OpenAPI Spec + +on: + release: + types: [published] + workflow_dispatch: + inputs: + tag: + description: Release tag to upload the enriched spec to + required: true + type: string + +permissions: + contents: write + +jobs: + openapi-enriched-release: + if: github.repository_owner == 'KelvinTegelaar' + runs-on: ubuntu-latest + + steps: + - name: Select release tag + id: release_tag + env: + RESOLVED_TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.event.release.tag_name }} + run: printf 'tag=%s\n' "$RESOLVED_TAG" >> "$GITHUB_OUTPUT" + + - name: Checkout CIPP-API + uses: actions/checkout@v4 + with: + ref: ${{ steps.release_tag.outputs.tag }} + + - name: Checkout CIPP frontend + uses: actions/checkout@v4 + with: + repository: KelvinTegelaar/CIPP + path: _frontend + + - name: Generate enriched OpenAPI spec + shell: pwsh + run: | + pwsh -NoProfile -File .build/Add-OpenApiResponseSchemas.ps1 ` + -FrontendRepoPath ./_frontend ` + -InputSpec ./openapi.json ` + -OutputSpec ./openapi.enriched.json + + - name: Strict lint enriched OpenAPI spec + run: | + set +e + npx --yes @redocly/cli@2.35.1 lint ./openapi.enriched.json --format=json > redocly-results.json + REDOCLY_STATUS=$? + set -e + + node - redocly-results.json "$REDOCLY_STATUS" <<'NODE' + const fs = require('fs'); + + const resultsPath = process.argv[2]; + const redoclyStatus = Number(process.argv[3]); + const raw = fs.readFileSync(resultsPath, 'utf8').trim(); + if (!raw) { + throw new Error(`${resultsPath} did not contain Redocly JSON output.`); + } + + const doc = JSON.parse(raw); + const totals = doc.totals || {}; + const errors = totals.errors || 0; + const warnings = totals.warnings || 0; + const ignored = totals.ignored || 0; + console.log(`Redocly new findings: errors=${errors}, warnings=${warnings}, ignored=${ignored}`); + + if (errors + warnings > 0) { + console.error('Enriched spec introduced new Redocly finding(s). Update the enrichment or regenerate the baseline only for legitimate upstream changes.'); + process.exit(1); + } + + if (redoclyStatus !== 0) { + console.error(`Redocly exited with status ${redoclyStatus}.`); + process.exit(redoclyStatus); + } + NODE + + - name: Upload enriched OpenAPI spec to release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_TAG: ${{ steps.release_tag.outputs.tag }} + run: gh release upload "$RELEASE_TAG" openapi.enriched.json --clobber diff --git a/.redocly.lint-ignore.yaml b/.redocly.lint-ignore.yaml new file mode 100644 index 0000000000000..2aaa3434d8bf2 --- /dev/null +++ b/.redocly.lint-ignore.yaml @@ -0,0 +1,12 @@ +# This file instructs Redocly's linter to ignore the rules contained for specific parts of your API. +# See https://redocly.com/docs/cli/ for more information. +openapi.enriched.json: + info-license: + - '#/info' + no-schema-type-mismatch: + - >- + #/paths/~1api~1DeployContactTemplates/post/requestBody/content/application~1json/schema/properties/TemplateList/items + no-unused-components: + - '#/components/parameters/selectedTenants' + - '#/components/schemas/LabelValueNumber' + - '#/components/schemas/DynamicExtensionFields' diff --git a/Tests/Build/Add-OpenApiResponseSchemas.Tests.ps1 b/Tests/Build/Add-OpenApiResponseSchemas.Tests.ps1 new file mode 100644 index 0000000000000..ee1d7ebc8fdfd --- /dev/null +++ b/Tests/Build/Add-OpenApiResponseSchemas.Tests.ps1 @@ -0,0 +1,488 @@ +#Requires -Version 7.0 +<# + Pester tests for Add-OpenApiResponseSchemas.ps1. + + Covers the pure core (Resolve-SpecResponse + the schema/source converters) with + hand-built fixtures, plus the sad paths that matter for a generator stage: + malformed input, non-baseline files, missing sources, and operations the stage + must leave untouched. Dot-sources the script so only its functions load. +#> + +BeforeAll { + # Test lives at /Tests/Build/; the script lives at /.build/. + $RepoRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) + $ScriptPath = Join-Path $RepoRoot '.build' 'Add-OpenApiResponseSchemas.ps1' + . $ScriptPath + + # Minimal spec factory: one path with the given methods, each carrying a bare 200. + function Get-TestSpec { + param([hashtable]$Paths) + return [ordered]@{ openapi = '3.1.0'; paths = $Paths } + } + function Get-Operation { + param( + [switch]$NoOkResponse, + [string]$OperationId + ) + $responses = if ($NoOkResponse) { @{ '500' = @{ description = 'err' } } } else { @{ '200' = @{ description = 'ok' } } } + $operation = @{ responses = $responses } + if ($OperationId) { $operation['operationId'] = $OperationId } + return $operation + } +} + +Describe 'ConvertFrom-ShapeNode' { + Context 'Leaf tokens' { + It 'maps string' { (ConvertFrom-ShapeNode -Node 'string').type | Should -Be 'string' } + It 'maps number' { (ConvertFrom-ShapeNode -Node 'number').type | Should -Be 'number' } + It 'maps bool to boolean' { (ConvertFrom-ShapeNode -Node 'bool').type | Should -Be 'boolean' } + It 'maps datetime to string+format' { + $r = ConvertFrom-ShapeNode -Node 'datetime' + $r.type | Should -Be 'string' + $r.format | Should -Be 'date-time' + } + It 'leaves null permissive (no type)' { (ConvertFrom-ShapeNode -Node 'null').Keys.Count | Should -Be 0 } + It 'leaves truncated permissive (no type)' { (ConvertFrom-ShapeNode -Node 'truncated').Keys.Count | Should -Be 0 } + It 'leaves unknown tokens permissive' { (ConvertFrom-ShapeNode -Node 'mystery').Keys.Count | Should -Be 0 } + } + + Context 'Nested structures' { + It 'maps an array node to type array with typed items' { + $node = @{ _type = 'array'; _element = 'string' } + $r = ConvertFrom-ShapeNode -Node $node + $r.type | Should -Be 'array' + $r.items.type | Should -Be 'string' + } + It 'maps an object node and sorts its properties' { + $node = [ordered]@{ zeta = 'string'; alpha = 'number' } + $r = ConvertFrom-ShapeNode -Node $node + $r.type | Should -Be 'object' + @($r.properties.Keys) | Should -Be @('alpha', 'zeta') + } + It 'maps an array of objects' { + $node = @{ _type = 'array'; _element = @{ id = 'string'; count = 'number' } } + $r = ConvertFrom-ShapeNode -Node $node + $r.items.type | Should -Be 'object' + $r.items.properties.id.type | Should -Be 'string' + } + } +} + +Describe 'ConvertTo-ColumnRecordSchema' { + It 'types every column as string with frontend provenance' { + $cols = [System.Collections.Generic.HashSet[string]]::new() + [void]$cols.Add('mail'); [void]$cols.Add('displayName') + $r = ConvertTo-ColumnRecordSchema -Columns $cols + $r.type | Should -Be 'object' + $r.properties.mail.type | Should -Be 'string' + $r.properties.mail.'x-cipp-field-source' | Should -Be 'frontend' + } + It 'sorts columns for deterministic output' { + $cols = [System.Collections.Generic.HashSet[string]]::new() + [void]$cols.Add('zeta'); [void]$cols.Add('alpha') + $r = ConvertTo-ColumnRecordSchema -Columns $cols + @($r.properties.Keys) | Should -Be @('alpha', 'zeta') + } +} + +Describe 'ConvertTo-ResponseEnvelopeSchema' { + It 'wraps a record schema in the Results/Metadata envelope' { + $record = [ordered]@{ type = 'object'; properties = [ordered]@{ id = @{ type = 'string' } } } + $r = ConvertTo-ResponseEnvelopeSchema -RecordSchema $record + $r.type | Should -Be 'object' + $r.properties.Results.type | Should -Be 'array' + $r.properties.Results.items.properties.id.type | Should -Be 'string' + $r.properties.Metadata.type | Should -Be 'object' + } +} + + +Describe 'Add-CippOperationId' { + It 'injects the bare endpoint name for a single-method operation with no operationId' { + $spec = Get-TestSpec -Paths @{ '/api/ListMailboxes' = @{ get = (Get-Operation) } } + $r = Add-CippOperationId -Spec $spec + $spec['paths']['/api/ListMailboxes']['get']['operationId'] | Should -Be 'ListMailboxes' + $r.Injected | Should -Be 1 + $r.Unique | Should -Be 1 + } + + It 'keeps single-method endpoint names bare even when they start with their method word' { + $spec = Get-TestSpec -Paths @{ + '/api/PatchUser' = @{ patch = (Get-Operation) } + '/api/ListX' = @{ get = (Get-Operation) } + } + Add-CippOperationId -Spec $spec | Out-Null + $spec['paths']['/api/PatchUser']['patch']['operationId'] | Should -Be 'PatchUser' + $spec['paths']['/api/ListX']['get']['operationId'] | Should -Be 'ListX' + } + + It 'gives a multi-method path two distinct operationIds' { + $spec = Get-TestSpec -Paths @{ '/api/ExecCSPLicense' = @{ get = (Get-Operation); post = (Get-Operation) } } + $r = Add-CippOperationId -Spec $spec + $spec['paths']['/api/ExecCSPLicense']['get']['operationId'] | Should -Be 'GetExecCSPLicense' + $spec['paths']['/api/ExecCSPLicense']['post']['operationId'] | Should -Be 'PostExecCSPLicense' + $r.Unique | Should -Be 2 + } + + It 'preserves a pre-existing operationId' { + $spec = Get-TestSpec -Paths @{ '/api/ListMailboxes' = @{ get = (Get-Operation -OperationId 'AlreadyThere') } } + $r = Add-CippOperationId -Spec $spec + $spec['paths']['/api/ListMailboxes']['get']['operationId'] | Should -Be 'AlreadyThere' + $r.Injected | Should -Be 0 + } + + It 'throws when a synthetic duplicate operationId is present' { + $spec = Get-TestSpec -Paths @{ + '/api/DuplicateA' = @{ get = (Get-Operation -OperationId 'SameOperation') } + '/api/DuplicateB' = @{ post = (Get-Operation -OperationId 'SameOperation') } + } + { Add-CippOperationId -Spec $spec } | Should -Throw '*Duplicate operationId found: SameOperation*' + } + + It 'keeps visibly different endpoint names distinct without hidden normalization' { + $spec = Get-TestSpec -Paths @{ + '/api/User' = @{ get = (Get-Operation) } + '/api/ListUsers' = @{ get = (Get-Operation) } + '/api/List-Users' = @{ get = (Get-Operation) } + } + Add-CippOperationId -Spec $spec | Out-Null + $spec['paths']['/api/User']['get']['operationId'] | Should -Be 'User' + $spec['paths']['/api/ListUsers']['get']['operationId'] | Should -Be 'ListUsers' + $spec['paths']['/api/List-Users']['get']['operationId'] | Should -Be 'List-Users' + } + + It 'throws when disambiguated derivation creates an actual duplicate' { + $spec = Get-TestSpec -Paths @{ + '/api/PatchUser' = @{ patch = (Get-Operation) } + '/api/User' = @{ get = (Get-Operation); patch = (Get-Operation) } + } + { Add-CippOperationId -Spec $spec } | Should -Throw '*Duplicate operationId found: PatchUser*' + } + + It 'throws when two single-method paths have the same bare endpoint name' { + $spec = Get-TestSpec -Paths @{ + '/api/SameName' = @{ get = (Get-Operation) } + 'SameName' = @{ post = (Get-Operation) } + } + { Add-CippOperationId -Spec $spec } | Should -Throw '*Duplicate operationId found: SameName*' + } + + It 'ignores non-method path item keys when injecting operationIds' { + $spec = Get-TestSpec -Paths @{ + '/api/ListThings' = [ordered]@{ + get = (Get-Operation) + parameters = @(@{ name = 'tenant'; in = 'query' }) + summary = 'path summary' + '$ref' = '#/components/pathItems/ListThings' + description = 'path description' + } + } + Add-CippOperationId -Spec $spec | Out-Null + $spec['paths']['/api/ListThings']['get']['operationId'] | Should -Be 'ListThings' + $spec['paths']['/api/ListThings']['parameters'][0].ContainsKey('operationId') | Should -BeFalse + $spec['paths']['/api/ListThings']['summary'] | Should -Be 'path summary' + $spec['paths']['/api/ListThings']['$ref'] | Should -Be '#/components/pathItems/ListThings' + $spec['paths']['/api/ListThings']['description'] | Should -Be 'path description' + } + + It 'yields one unique operationId per operation on the full real spec' { + $specPath = Join-Path $RepoRoot 'openapi.json' + $spec = Get-Content -LiteralPath $specPath -Raw | ConvertFrom-Json -AsHashtable -Depth 100 + $operationTotal = 0 + foreach ($pathEntry in $spec['paths'].GetEnumerator()) { + foreach ($methodEntry in $pathEntry.Value.GetEnumerator()) { + if ($methodEntry.Key -in @('get', 'post', 'put', 'patch', 'delete')) { $operationTotal++ } + } + } + $r = Add-CippOperationId -Spec $spec + Write-Information "Full real spec operationIds: $($r.Unique)" -InformationAction Continue + $r.Operations | Should -Be $operationTotal + $r.Unique | Should -Be $r.Operations + } +} + +Describe 'Resolve-SpecResponse - happy paths' { + It 'types a baseline-backed endpoint and counts it' { + $spec = Get-TestSpec -Paths @{ '/api/ListThings' = @{ get = (Get-Operation) } } + $baseline = @{ ListThings = [ordered]@{ type = 'object'; properties = [ordered]@{ id = @{ type = 'string' } } } } + $r = Resolve-SpecResponse -Spec $spec -BaselineMap $baseline -ColumnMap @{} + $r.Operations | Should -Be 1 + $r.Typed | Should -Be 1 + $schema = $spec['paths']['/api/ListThings']['get']['responses']['200']['content']['application/json']['schema'] + $schema.properties.Results.items.properties.id.type | Should -Be 'string' + } + + It 'prefers the baseline when an endpoint is in both maps' { + $spec = Get-TestSpec -Paths @{ '/api/Both' = @{ get = (Get-Operation) } } + $baseline = @{ Both = [ordered]@{ type = 'object'; properties = [ordered]@{ fromBaseline = @{ type = 'string' } } } } + $cols = [System.Collections.Generic.HashSet[string]]::new(); [void]$cols.Add('fromColumns') + Resolve-SpecResponse -Spec $spec -BaselineMap $baseline -ColumnMap @{ Both = $cols } | Out-Null + $props = $spec['paths']['/api/Both']['get']['responses']['200']['content']['application/json']['schema'].properties.Results.items.properties + $props.Keys | Should -Contain 'fromBaseline' + $props.Keys | Should -Not -Contain 'fromColumns' + } + + + + It 'does not inject operationIds while typing responses' { + $spec = Get-TestSpec -Paths @{ '/api/ListThings' = @{ get = (Get-Operation) } } + $baseline = @{ ListThings = [ordered]@{ type = 'object'; properties = [ordered]@{ id = @{ type = 'string' } } } } + Resolve-SpecResponse -Spec $spec -BaselineMap $baseline -ColumnMap @{} | Out-Null + $spec['paths']['/api/ListThings']['get'].ContainsKey('operationId') | Should -BeFalse + } + + It 'types a columns-only endpoint with provenance markers' { + $spec = Get-TestSpec -Paths @{ '/api/ListCols' = @{ get = (Get-Operation) } } + $cols = [System.Collections.Generic.HashSet[string]]::new(); [void]$cols.Add('displayName') + Resolve-SpecResponse -Spec $spec -BaselineMap @{} -ColumnMap @{ ListCols = $cols } | Out-Null + $props = $spec['paths']['/api/ListCols']['get']['responses']['200']['content']['application/json']['schema'].properties.Results.items.properties + $props.displayName.'x-cipp-field-source' | Should -Be 'frontend' + } +} + +Describe 'Resolve-SpecResponse - sad paths and invariants' { + It 'leaves an endpoint with no matching source untouched' { + $spec = Get-TestSpec -Paths @{ '/api/AddUser' = @{ post = (Get-Operation) } } + $r = Resolve-SpecResponse -Spec $spec -BaselineMap @{} -ColumnMap @{} + $r.Operations | Should -Be 1 + $r.Typed | Should -Be 0 + $spec['paths']['/api/AddUser']['post']['responses']['200'].ContainsKey('content') | Should -BeFalse + } + + It 'does not type an operation that has no 200 response' { + $spec = Get-TestSpec -Paths @{ '/api/Weird' = @{ get = (Get-Operation -NoOkResponse) } } + $baseline = @{ Weird = [ordered]@{ type = 'object'; properties = [ordered]@{ id = @{ type = 'string' } } } } + $r = Resolve-SpecResponse -Spec $spec -BaselineMap $baseline -ColumnMap @{} + $r.Typed | Should -Be 0 + } + + It 'does not throw on an operation with no responses block' { + $spec = Get-TestSpec -Paths @{ '/api/Weird' = @{ get = @{} } } + $baseline = @{ Weird = [ordered]@{ type = 'object'; properties = [ordered]@{ id = @{ type = 'string' } } } } + $r = Resolve-SpecResponse -Spec $spec -BaselineMap $baseline -ColumnMap @{} + $r.Operations | Should -Be 1 + $r.Typed | Should -Be 0 + } + + It 'types a baseline-backed endpoint even when the record schema is permissive' { + $spec = Get-TestSpec -Paths @{ '/api/ListUnknown' = @{ get = (Get-Operation) } } + $baseline = @{ ListUnknown = @{} } + $r = Resolve-SpecResponse -Spec $spec -BaselineMap $baseline -ColumnMap @{} + $r.Typed | Should -Be 1 + $schema = $spec['paths']['/api/ListUnknown']['get']['responses']['200']['content']['application/json']['schema'] + $schema.properties.Results.items.Keys.Count | Should -Be 0 + } + + It 'counts only get/post/put/patch/delete, ignoring parameters/summary keys' { + $spec = Get-TestSpec -Paths @{ '/api/ListThings' = [ordered]@{ get = (Get-Operation); parameters = @(); summary = 'x' } } + $r = Resolve-SpecResponse -Spec $spec -BaselineMap @{} -ColumnMap @{} + $r.Operations | Should -Be 1 + } + + It 'preserves the operation set (adds nothing, removes nothing)' { + $spec = Get-TestSpec -Paths @{ + '/api/ListA' = @{ get = (Get-Operation) } + '/api/AddB' = @{ post = (Get-Operation) } + } + $before = $spec['paths'].Keys | Sort-Object + Resolve-SpecResponse -Spec $spec -BaselineMap @{} -ColumnMap @{} | Out-Null + ($spec['paths'].Keys | Sort-Object) | Should -Be $before + } + + It 'throws on a spec with no paths' { + { Resolve-SpecResponse -Spec ([ordered]@{ openapi = '3.1.0' }) -BaselineMap @{} -ColumnMap @{} } | + Should -Throw '*no paths*' + } + + It 'is stable on a second core pass (same count, same typed schema)' { + # Production never re-mutates an in-memory spec; it reads fresh each run. The + # guarantee that matters is that re-applying yields the same meaningful output, + # not that an unordered Hashtable serialises in identical key order. + $spec = Get-TestSpec -Paths @{ '/api/ListThings' = @{ get = (Get-Operation) } } + $baseline = @{ ListThings = [ordered]@{ type = 'object'; properties = [ordered]@{ id = @{ type = 'string' } } } } + $r1 = Resolve-SpecResponse -Spec $spec -BaselineMap $baseline -ColumnMap @{} + $schema1 = $spec['paths']['/api/ListThings']['get']['responses']['200']['content']['application/json']['schema'] | ConvertTo-Json -Depth 100 + $r2 = Resolve-SpecResponse -Spec $spec -BaselineMap $baseline -ColumnMap @{} + $schema2 = $spec['paths']['/api/ListThings']['get']['responses']['200']['content']['application/json']['schema'] | ConvertTo-Json -Depth 100 + $r2.Typed | Should -Be $r1.Typed + $schema2 | Should -Be $schema1 + } +} + +Describe 'Get-ShapeBaselineMap - file ingestion sad paths' { + BeforeEach { + $script:ShapesDir = Join-Path ([System.IO.Path]::GetTempPath()) ("hermes-shapes-" + [guid]::NewGuid()) + New-Item -ItemType Directory -Path $script:ShapesDir -Force | Out-Null + } + AfterEach { Remove-Item -Recurse -Force $script:ShapesDir -ErrorAction SilentlyContinue } + + It 'ingests a valid baseline' { + @{ _metadata = @{ endpoint = 'ListX' }; shape = @{ id = 'string' } } | ConvertTo-Json -Depth 10 | + Set-Content (Join-Path $script:ShapesDir 'ListX.json') + $map = Get-ShapeBaselineMap -ShapesDir $script:ShapesDir + $map.ContainsKey('ListX') | Should -BeTrue + } + + It 'skips a file that lacks _metadata/shape (e.g. test-results.json)' { + @{ results = @(@{ status = 'PASS' }) } | ConvertTo-Json -Depth 10 | + Set-Content (Join-Path $script:ShapesDir 'test-results.json') + $map = Get-ShapeBaselineMap -ShapesDir $script:ShapesDir + $map.Count | Should -Be 0 + } + + It 'returns empty for a missing directory without throwing' { + $map = Get-ShapeBaselineMap -ShapesDir (Join-Path $script:ShapesDir 'does-not-exist') + $map.Count | Should -Be 0 + } +} + +Describe 'Get-FrontendColumnMap - parsing sad paths' { + BeforeEach { + $script:SrcDir = Join-Path ([System.IO.Path]::GetTempPath()) ("hermes-src-" + [guid]::NewGuid()) + New-Item -ItemType Directory -Path $script:SrcDir -Force | Out-Null + } + AfterEach { Remove-Item -Recurse -Force $script:SrcDir -ErrorAction SilentlyContinue } + + It 'extracts columns paired with an api endpoint' { + Set-Content (Join-Path $script:SrcDir 'page.jsx') @' +const simpleColumns = ["displayName", "mail"]; + +'@ + $map = Get-FrontendColumnMap -SrcDir $script:SrcDir + $map['ListMailboxes'] | Should -Contain 'displayName' + $map['ListMailboxes'] | Should -Contain 'mail' + } + + It 'handles mixed single and double quotes in the column array' { + Set-Content (Join-Path $script:SrcDir 'page.jsx') @' +const simpleColumns = ['mail', "displayName"]; +apiUrl="/api/ListThings" +'@ + $map = Get-FrontendColumnMap -SrcDir $script:SrcDir + $map['ListThings'].Count | Should -Be 2 + } + + It 'handles JSX simpleColumns arrays split after the opening brace' { + Set-Content (Join-Path $script:SrcDir 'page.jsx') @' + +'@ + $map = Get-FrontendColumnMap -SrcDir $script:SrcDir + $map['ListSplit'] | Should -Contain 'displayName' + $map['ListSplit'] | Should -Contain 'mail' + } + + + + It 'does not leak scalar ternary branch strings near simpleColumns' { + Set-Content (Join-Path $script:SrcDir 'page.jsx') @' +const statusLabel = enabled ? "yes" : "no"; +const simpleColumns = ["displayName", "mail"]; +apiUrl="/api/ListTernarySafe" +'@ + $map = Get-FrontendColumnMap -SrcDir $script:SrcDir + $map['ListTernarySafe'] | Should -Contain 'displayName' + $map['ListTernarySafe'] | Should -Contain 'mail' + $map['ListTernarySafe'] | Should -Not -Contain 'yes' + $map['ListTernarySafe'] | Should -Not -Contain 'no' + } + + It 'does not capture branch strings when simpleColumns is a ternary of arrays' { + # This lightweight parser only accepts direct array literals. Conditional + # simpleColumns values are ignored rather than risking scalar branch leaks. + Set-Content (Join-Path $script:SrcDir 'page.jsx') @' +const simpleColumns = hasScope + ? ['RowKey', 'Value', 'Description'] + : ['RowKey', 'Value', 'Description', 'Scope']; +const label = hasScope ? "yes" : "no"; +apiUrl="/api/ListCustomVariables" +'@ + $map = Get-FrontendColumnMap -SrcDir $script:SrcDir + $map.ContainsKey('ListCustomVariables') | Should -BeFalse + foreach ($columns in $map.Values) { + $columns | Should -Not -Contain 'yes' + $columns | Should -Not -Contain 'no' + } + } + + It 'ignores a file with simpleColumns but no api endpoint' { + Set-Content (Join-Path $script:SrcDir 'page.jsx') 'const simpleColumns = ["x"];' + $map = Get-FrontendColumnMap -SrcDir $script:SrcDir + $map.Count | Should -Be 0 + } + + It 'does not crash on an empty file' { + Set-Content (Join-Path $script:SrcDir 'empty.jsx') '' + { Get-FrontendColumnMap -SrcDir $script:SrcDir } | Should -Not -Throw + } + + It 'returns empty for a missing src directory without throwing' { + $map = Get-FrontendColumnMap -SrcDir (Join-Path $script:SrcDir 'nope') + $map.Count | Should -Be 0 + } +} + +Describe 'Add-CippResponseSchema - end to end on a temp spec' { + It 'throws when the input spec does not exist' { + { Add-CippResponseSchema -InputSpec 'X:\nope.json' -OutputSpec 'X:\out.json' -FrontendRepoPath '.' } | + Should -Throw '*not found*' + } + + It 'throws when the input spec contains malformed JSON' { + $tmp = Join-Path ([System.IO.Path]::GetTempPath()) ("hermes-bad-json-" + [guid]::NewGuid()) + New-Item -ItemType Directory -Path (Join-Path $tmp 'Tests' 'Shapes') -Force | Out-Null + New-Item -ItemType Directory -Path (Join-Path $tmp 'src') -Force | Out-Null + $specPath = Join-Path $tmp 'openapi.json' + $outPath = Join-Path $tmp 'out.json' + Set-Content -LiteralPath $specPath -Value '{ "openapi": "3.1.0", "paths": ' + + { Add-CippResponseSchema -InputSpec $specPath -OutputSpec $outPath -FrontendRepoPath $tmp } | + Should -Throw + + Remove-Item -Recurse -Force $tmp -ErrorAction SilentlyContinue + } + + It 'reads, enriches, and writes a real file round-trip' { + $tmp = Join-Path ([System.IO.Path]::GetTempPath()) ("hermes-e2e-" + [guid]::NewGuid()) + New-Item -ItemType Directory -Path $tmp | Out-Null + New-Item -ItemType Directory -Path (Join-Path $tmp 'Tests' 'Shapes') -Force | Out-Null + New-Item -ItemType Directory -Path (Join-Path $tmp 'src') -Force | Out-Null + @{ _metadata = @{ endpoint = 'ListThings' }; shape = @{ id = 'string' } } | ConvertTo-Json -Depth 10 | + Set-Content (Join-Path $tmp 'Tests' 'Shapes' 'ListThings.json') + $specPath = Join-Path $tmp 'openapi.json' + $outPath = Join-Path $tmp 'out.json' + Get-TestSpec -Paths @{ '/api/ListThings' = @{ get = (Get-Operation) } } | ConvertTo-Json -Depth 100 | + Set-Content $specPath + + Add-CippResponseSchema -InputSpec $specPath -OutputSpec $outPath -FrontendRepoPath $tmp | Out-Null + $out = Get-Content $outPath -Raw | ConvertFrom-Json -AsHashtable -Depth 100 + $out['paths']['/api/ListThings']['get']['responses']['200']['content']['application/json']['schema'].properties.Results.items.properties.id.type | + Should -Be 'string' + $out['paths']['/api/ListThings']['get']['operationId'] | Should -Be 'ListThings' + + Remove-Item -Recurse -Force $tmp -ErrorAction SilentlyContinue + } + + It 'is byte-identical when run twice on the same sources (file-level idempotency)' { + $tmp = Join-Path ([System.IO.Path]::GetTempPath()) ("hermes-idem-" + [guid]::NewGuid()) + New-Item -ItemType Directory -Path (Join-Path $tmp 'Tests' 'Shapes') -Force | Out-Null + New-Item -ItemType Directory -Path (Join-Path $tmp 'src') -Force | Out-Null + @{ _metadata = @{ endpoint = 'ListThings' }; shape = @{ id = 'string'; when = 'datetime' } } | ConvertTo-Json -Depth 10 | + Set-Content (Join-Path $tmp 'Tests' 'Shapes' 'ListThings.json') + $specPath = Join-Path $tmp 'openapi.json' + Get-TestSpec -Paths @{ '/api/ListThings' = @{ get = (Get-Operation) } } | ConvertTo-Json -Depth 100 | + Set-Content $specPath + + $out1 = Join-Path $tmp 'o1.json' + $out2 = Join-Path $tmp 'o2.json' + Add-CippResponseSchema -InputSpec $specPath -OutputSpec $out1 -FrontendRepoPath $tmp | Out-Null + Add-CippResponseSchema -InputSpec $out1 -OutputSpec $out2 -FrontendRepoPath $tmp | Out-Null + (Get-Content $out2 -Raw) | Should -Be (Get-Content $out1 -Raw) + + Remove-Item -Recurse -Force $tmp -ErrorAction SilentlyContinue + } +} diff --git a/Tests/Build/Invoke-BuildTests.ps1 b/Tests/Build/Invoke-BuildTests.ps1 new file mode 100644 index 0000000000000..5c7b7f96b8fd4 --- /dev/null +++ b/Tests/Build/Invoke-BuildTests.ps1 @@ -0,0 +1,46 @@ +#Requires -Version 7.0 +<# +.SYNOPSIS + Runs static analysis and the OpenAPI response schema Pester suite. + +.DESCRIPTION + Conventional one-command verification for the response schema build stage. + Checks PSScriptAnalyzer warning and error findings for the stage script and + its tests, then runs the focused Pester 5 suite. Exits non-zero on failure. +#> +[CmdletBinding()] +param() + +$ErrorActionPreference = 'Stop' + +$repoRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) +$scriptPath = Join-Path $repoRoot '.build' 'Add-OpenApiResponseSchemas.ps1' +$testPath = Join-Path $PSScriptRoot 'Add-OpenApiResponseSchemas.Tests.ps1' + +Write-Information 'Running PSScriptAnalyzer...' -InformationAction Continue +$analysisFindings = @( + foreach ($path in @($scriptPath, $testPath)) { + Invoke-ScriptAnalyzer -Path $path -Severity Warning, Error + } +) + +if ($analysisFindings.Count -gt 0) { + $analysisFindings | Format-Table -AutoSize | Out-String | Write-Information -InformationAction Continue +} + +Write-Information "PSScriptAnalyzer Warning/Error findings: $($analysisFindings.Count)" -InformationAction Continue + +Write-Information 'Running Pester...' -InformationAction Continue +$pesterConfig = New-PesterConfiguration +$pesterConfig.Run.Path = $testPath +$pesterConfig.Run.PassThru = $true +$pesterConfig.Run.Exit = $false +$pesterConfig.Output.Verbosity = 'Detailed' + +$pesterResult = Invoke-Pester -Configuration $pesterConfig + +Write-Information "Pester: Passed=$($pesterResult.PassedCount) Failed=$($pesterResult.FailedCount) Skipped=$($pesterResult.SkippedCount)" -InformationAction Continue + +if ($analysisFindings.Count -gt 0 -or $pesterResult.FailedCount -gt 0) { + exit 1 +} diff --git a/redocly.yaml b/redocly.yaml new file mode 100644 index 0000000000000..481010ed7caed --- /dev/null +++ b/redocly.yaml @@ -0,0 +1,5 @@ +apis: + enriched: + root: ./openapi.enriched.json +extends: + - recommended From 2a7c7a784442405069645a1c979cc72df71c2285 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Sat, 27 Jun 2026 15:12:08 +0800 Subject: [PATCH 032/170] Ignore mail.onmicrosoft domains for DMARC enablement --- .../Public/Standards/Invoke-CIPPStandardAddDMARCToMOERA.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardAddDMARCToMOERA.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardAddDMARCToMOERA.ps1 index 7ed2feadb7563..8c3ef3c619c99 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardAddDMARCToMOERA.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardAddDMARCToMOERA.ps1 @@ -46,7 +46,7 @@ function Invoke-CIPPStandardAddDMARCToMOERA { try { $DomainsResponse = New-GraphGetRequest -TenantID $Tenant -Uri 'https://graph.microsoft.com/beta/domains' Write-Warning ($DomainsResponse | ConvertTo-Json -Depth 5) - $Domains = @($DomainsResponse | Where-Object { $_.id -like '*.onmicrosoft.com' } | ForEach-Object { $_.id }) + $Domains = @($DomainsResponse | Where-Object { $_.id -like '*.onmicrosoft.com' -and $_.id -notlike '*.mail.onmicrosoft.com' } | ForEach-Object { $_.id }) Write-Information "Detected $($Domains.Count) MOERA domains: $($Domains -join ', ')" $CurrentInfo = foreach ($Domain in $Domains) { From a2f29dd77c41d95050c9e156681a6f8b69d85b15 Mon Sep 17 00:00:00 2001 From: Bobby <31723128+kris6673@users.noreply.github.com> Date: Sat, 27 Jun 2026 02:07:49 +0200 Subject: [PATCH 033/170] feat: direction-scoped Intune group assignment Add ExcludeGroupIds and AssignmentDirection so exclude groups are targeted by ID and a Replace edits only one direction, preserving the other plus All Users/All Devices. Exclude-only requests no longer error or wipe assignments, and exclusion targets drop the settings block that Graph rejects. --- .../Get-CIPPIntuneApplicationReport.ps1 | 2 +- .../Public/Set-CIPPAssignedApplication.ps1 | 130 +++++++++++------- .../Public/Set-CIPPAssignedPolicy.ps1 | 112 ++++++++------- .../Applications/Invoke-ExecAssignApp.ps1 | 43 +++++- .../Endpoint/Applications/Invoke-ListApps.ps1 | 2 +- .../Endpoint/MEM/Invoke-ExecAssignPolicy.ps1 | 36 ++++- 6 files changed, 221 insertions(+), 104 deletions(-) diff --git a/Modules/CIPPCore/Public/Get-CIPPIntuneApplicationReport.ps1 b/Modules/CIPPCore/Public/Get-CIPPIntuneApplicationReport.ps1 index a53717c8bb595..d6f53a245e65e 100644 --- a/Modules/CIPPCore/Public/Get-CIPPIntuneApplicationReport.ps1 +++ b/Modules/CIPPCore/Public/Get-CIPPIntuneApplicationReport.ps1 @@ -65,7 +65,7 @@ function Get-CIPPIntuneApplicationReport { } '#microsoft.graph.exclusionGroupAssignmentTarget' { $groupName = ($Groups | Where-Object { $_.id -eq $target.groupId }).displayName - if ($groupName) { $AppExclude.Add($groupName) } + if ($groupName) { $AppExclude.Add("$groupName$intentSuffix") } } } } diff --git a/Modules/CIPPCore/Public/Set-CIPPAssignedApplication.ps1 b/Modules/CIPPCore/Public/Set-CIPPAssignedApplication.ps1 index fe55f61476386..007f52b8e11c8 100644 --- a/Modules/CIPPCore/Public/Set-CIPPAssignedApplication.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPAssignedApplication.ps1 @@ -3,12 +3,14 @@ function Set-CIPPAssignedApplication { param( $GroupName, $ExcludeGroup, + $ExcludeGroupIds, $Intent, $AppType, $ApplicationId, $TenantFilter, $GroupIds, $AssignmentMode = 'replace', + $AssignmentDirection, $APIName = 'Assign Application', $Headers, $AssignmentFilterName, @@ -106,7 +108,7 @@ function Set-CIPPAssignedApplication { $resolvedGroupIds = @() if ($PSBoundParameters.ContainsKey('GroupIds') -and $GroupIds) { $resolvedGroupIds = $GroupIds - } else { + } elseif ($GroupName) { $GroupNames = $GroupName.Split(',') $resolvedGroupIds = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/groups?$top=999&$select=id,displayName' -tenantid $TenantFilter | ForEach-Object { $Group = $_ @@ -119,8 +121,10 @@ function Set-CIPPAssignedApplication { Write-Information "found $($resolvedGroupIds) groups" } - # We ain't found nothing so we panic - if (-not $resolvedGroupIds) { + # Only panic when an include target was actually requested. Exclude-only + # assignments legitimately resolve to no include groups here. + $IncludeRequested = $GroupName -or ($GroupIds -and @($GroupIds).Count -gt 0) + if (-not $resolvedGroupIds -and $IncludeRequested) { throw 'No matching groups resolved for assignment request.' } @@ -138,20 +142,34 @@ function Set-CIPPAssignedApplication { } } + # Normalize to an array so appending exclusions appends an element rather than + # merging hashtable keys (a single include group makes the switch return a scalar). + # Filter nulls so an exclude-only assignment doesn't carry an empty placeholder. + $MobileAppAssignment = @($MobileAppAssignment | Where-Object { $_ }) + # Add exclusion group assignments - if ($ExcludeGroup) { - Write-Host "Excluding group(s) from application assignment: $ExcludeGroup" - $ExcludeGroupNames = $ExcludeGroup.Split(',').Trim() - $ExcludeGroupIds = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/groups?$top=999&$select=id,displayName' -tenantid $TenantFilter | ForEach-Object { - $Group = $_ - foreach ($SingleName in $ExcludeGroupNames) { - if ($Group.displayName -like $SingleName) { - $Group.id + if ($ExcludeGroup -or ($ExcludeGroupIds -and @($ExcludeGroupIds).Count -gt 0)) { + # Prefer explicit group IDs (from the picker); fall back to name resolution + # for templates/wizards/API callers that still send ExcludeGroup names. + if ($ExcludeGroupIds -and @($ExcludeGroupIds).Count -gt 0) { + Write-Host "Excluding group(s) by id from application assignment: $($ExcludeGroupIds -join ', ')" + $ResolvedExcludeIds = @($ExcludeGroupIds) + } else { + Write-Host "Excluding group(s) from application assignment: $ExcludeGroup" + $ExcludeGroupNames = $ExcludeGroup.Split(',').Trim() + $ResolvedExcludeIds = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/groups?$top=999&$select=id,displayName' -tenantid $TenantFilter | ForEach-Object { + $Group = $_ + foreach ($SingleName in $ExcludeGroupNames) { + if ($Group.displayName -like $SingleName) { + $Group.id + } } } } - foreach ($egid in $ExcludeGroupIds) { + foreach ($egid in $ResolvedExcludeIds) { + # Graph rejects 'settings' on exclusion targets: + # "Exclusion assignment does not support MobileAppAssignment Settings." $MobileAppAssignment += @{ '@odata.type' = '#microsoft.graph.mobileAppAssignment' target = @{ @@ -159,7 +177,6 @@ function Set-CIPPAssignedApplication { groupId = $egid } intent = $Intent - settings = $assignmentSettings } } } @@ -176,59 +193,70 @@ function Set-CIPPAssignedApplication { } } - # If we're appending, we need to get existing assignments - if ($AssignmentMode -eq 'append') { + # Determine which existing assignments (if any) must be preserved. + # append -> keep all existing (minus ones the new set overrides) + # replace + direction -> keep everything except the direction being edited + # (Custom Group action only; legacy replace overwrites everything) + $DirectionScoped = -not [string]::IsNullOrWhiteSpace($AssignmentDirection) + $EditedType = switch ($AssignmentDirection) { + 'exclude' { '#microsoft.graph.exclusionGroupAssignmentTarget' } + 'include' { '#microsoft.graph.groupAssignmentTarget' } + default { $null } + } + $PreserveExisting = ($AssignmentMode -eq 'append') -or ($AssignmentMode -eq 'replace' -and $DirectionScoped) + + $ExistingAssignments = @() + if ($PreserveExisting) { try { $ExistingAssignments = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/deviceAppManagement/mobileApps/$($ApplicationId)/assignments" -tenantid $TenantFilter } catch { - Write-Warning "Unable to retrieve existing assignments for $ApplicationId. Proceeding with new assignments only. Error: $($_.Exception.Message)" - $ExistingAssignments = @() + $ErrorMessage = "Unable to retrieve existing assignments for $ApplicationId. Existing assignments must be preserved for assignment mode '$AssignmentMode' and direction '$AssignmentDirection'. Aborting to avoid removing assignments. Error: $($_.Exception.Message)" + Write-Warning $ErrorMessage + throw $ErrorMessage } } - # Deduplicate current assignments so the new ones override existing ones + # Decide which existing assignments to carry forward. + $KeptAssignments = [System.Collections.Generic.List[object]]::new() if ($ExistingAssignments) { - $ExistingAssignments = $ExistingAssignments | ForEach-Object { - $ExistingAssignment = $_ - switch ($ExistingAssignment.target.'@odata.type') { - '#microsoft.graph.groupAssignmentTarget' { - if ($ExistingAssignment.target.groupId -notin $MobileAppAssignment.target.groupId) { - $ExistingAssignment - } - } - '#microsoft.graph.exclusionGroupAssignmentTarget' { - if ($ExistingAssignment.target.groupId -notin $MobileAppAssignment.target.groupId) { - $ExistingAssignment - } - } - default { - if ($ExistingAssignment.target.'@odata.type' -notin $MobileAppAssignment.target.'@odata.type') { - $ExistingAssignment - } + foreach ($ExistingAssignment in @($ExistingAssignments)) { + $ExistingType = $ExistingAssignment.target.'@odata.type' + $Keep = if ($AssignmentMode -eq 'replace' -and $DirectionScoped) { + # Direction-scoped replace: drop every target of the edited type, keep the rest + # (the other direction plus All Users / All Devices broad targets). + $ExistingType -ne $EditedType + } else { + # Append: keep existing unless the new set overrides the same group/target. + switch ($ExistingType) { + '#microsoft.graph.groupAssignmentTarget' { $ExistingAssignment.target.groupId -notin $MobileAppAssignment.target.groupId } + '#microsoft.graph.exclusionGroupAssignmentTarget' { $ExistingAssignment.target.groupId -notin $MobileAppAssignment.target.groupId } + default { $ExistingType -notin $MobileAppAssignment.target.'@odata.type' } } } + if ($Keep) { + $KeptAssignments.Add($ExistingAssignment) + } } } $FinalAssignments = [System.Collections.Generic.List[object]]::new() - if ($AssignmentMode -eq 'append' -and $ExistingAssignments) { - $ExistingAssignments | ForEach-Object { - $FinalAssignments.Add(@{ - '@odata.type' = '#microsoft.graph.mobileAppAssignment' - target = $_.target - intent = $_.intent - settings = $_.settings - }) + if ($PreserveExisting) { + # Rebuild each assignment, omitting 'settings' on exclusion targets (Graph rejects it). + $AddAssignment = { + param($a) + $entry = @{ + '@odata.type' = '#microsoft.graph.mobileAppAssignment' + target = $a.target + intent = $a.intent + } + if ($a.target.'@odata.type' -ne '#microsoft.graph.exclusionGroupAssignmentTarget' -and $null -ne $a.settings) { + $entry.settings = $a.settings + } + $FinalAssignments.Add($entry) } - $MobileAppAssignment | ForEach-Object { - $FinalAssignments.Add(@{ - '@odata.type' = '#microsoft.graph.mobileAppAssignment' - target = $_.target - intent = $_.intent - settings = $_.settings - }) - } + $KeptAssignments | ForEach-Object { & $AddAssignment $_ } + $MobileAppAssignment | ForEach-Object { & $AddAssignment $_ } } else { $FinalAssignments = $MobileAppAssignment } diff --git a/Modules/CIPPCore/Public/Set-CIPPAssignedPolicy.ps1 b/Modules/CIPPCore/Public/Set-CIPPAssignedPolicy.ps1 index c616658100f63..715e1a4844ba3 100644 --- a/Modules/CIPPCore/Public/Set-CIPPAssignedPolicy.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPAssignedPolicy.ps1 @@ -3,6 +3,7 @@ function Set-CIPPAssignedPolicy { param( $GroupName, $ExcludeGroup, + $ExcludeGroupIds, $PolicyId, $Type, $TenantFilter, @@ -13,7 +14,8 @@ function Set-CIPPAssignedPolicy { $AssignmentFilterType = 'include', $GroupIds, $GroupNames, - $AssignmentMode = 'replace' + $AssignmentMode = 'replace', + $AssignmentDirection ) Write-Host "Assigning policy $PolicyId ($PlatformType/$Type) to $GroupName" @@ -93,7 +95,10 @@ function Set-CIPPAssignedPolicy { } } - if (-not $resolvedGroupIds -or $resolvedGroupIds.Count -eq 0) { + # Only error when an include target was actually requested. Exclude-only + # assignments legitimately resolve to no include groups here. + $IncludeRequested = $GroupName -or ($GroupIds -and @($GroupIds).Count -gt 0) + if ((-not $resolvedGroupIds -or $resolvedGroupIds.Count -eq 0) -and $IncludeRequested) { $ErrorMessage = "No groups found matching the specified name(s): $GroupName. Policy not assigned." Write-LogMessage -headers $Headers -API $APIName -message $ErrorMessage -sev 'Warning' -tenant $TenantFilter throw $ErrorMessage @@ -111,19 +116,26 @@ function Set-CIPPAssignedPolicy { } } } - if ($ExcludeGroup) { - Write-Host "We're supposed to exclude a custom group. The group is $ExcludeGroup" - $ExcludeGroupNames = $ExcludeGroup.Split(',').Trim() - $ExcludeGroupIds = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/groups?$select=id,displayName&$top=999' -tenantid $TenantFilter | - ForEach-Object { - foreach ($SingleName in $ExcludeGroupNames) { - if ($_.displayName -like $SingleName) { - $_.id + if ($ExcludeGroup -or ($ExcludeGroupIds -and @($ExcludeGroupIds).Count -gt 0)) { + # Prefer explicit group IDs (from the picker); fall back to name resolution + # for templates/wizards/API callers that still send ExcludeGroup names. + if ($ExcludeGroupIds -and @($ExcludeGroupIds).Count -gt 0) { + Write-Host "Excluding custom group(s) by id: $($ExcludeGroupIds -join ', ')" + $ResolvedExcludeIds = @($ExcludeGroupIds) + } else { + Write-Host "We're supposed to exclude a custom group. The group is $ExcludeGroup" + $ExcludeGroupNames = $ExcludeGroup.Split(',').Trim() + $ResolvedExcludeIds = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/groups?$select=id,displayName&$top=999' -tenantid $TenantFilter | + ForEach-Object { + foreach ($SingleName in $ExcludeGroupNames) { + if ($_.displayName -like $SingleName) { + $_.id + } } } - } + } - foreach ($egid in $ExcludeGroupIds) { + foreach ($egid in $ResolvedExcludeIds) { $assignmentsList.Add( @{ target = @{ @@ -147,50 +159,51 @@ function Set-CIPPAssignedPolicy { } } - # If we're appending, we need to get existing assignments + # Determine which existing assignments (if any) must be preserved. + # append -> keep all existing (minus ones the new set overrides) + # replace + direction -> keep everything except the direction being edited + # (Custom Group action only; legacy replace overwrites everything) + $DirectionScoped = -not [string]::IsNullOrWhiteSpace($AssignmentDirection) + $EditedType = switch ($AssignmentDirection) { + 'exclude' { '#microsoft.graph.exclusionGroupAssignmentTarget' } + 'include' { '#microsoft.graph.groupAssignmentTarget' } + default { $null } + } + $PreserveExisting = ($AssignmentMode -eq 'append') -or ($AssignmentMode -eq 'replace' -and $DirectionScoped) + $ExistingAssignments = @() - if ($AssignmentMode -eq 'append') { + if ($PreserveExisting) { try { $uri = "https://graph.microsoft.com/beta/$($PlatformType)/$Type('$($PolicyId)')/assignments" $ExistingAssignments = New-GraphGetRequest -uri $uri -tenantid $TenantFilter Write-Host "Found $($ExistingAssignments.Count) existing assignments for policy $PolicyId" } catch { - Write-Warning "Unable to retrieve existing assignments for $PolicyId. Proceeding with new assignments only. Error: $($_.Exception.Message)" - $ExistingAssignments = @() + $ErrorMessage = "Unable to retrieve existing assignments for $PolicyId. Existing assignments must be preserved for assignment mode '$AssignmentMode' and direction '$AssignmentDirection'. Aborting to avoid removing assignments. Error: $($_.Exception.Message)" + Write-Warning $ErrorMessage + throw $ErrorMessage } } - # Deduplicate current assignments so the new ones override existing ones + # Decide which existing assignments to carry forward. + $FinalAssignments = [System.Collections.Generic.List[object]]::new() if ($ExistingAssignments -and $ExistingAssignments.Count -gt 0) { - $ExistingAssignments = $ExistingAssignments | ForEach-Object { - $ExistingAssignment = $_ - switch ($ExistingAssignment.target.'@odata.type') { - '#microsoft.graph.groupAssignmentTarget' { - if ($ExistingAssignment.target.groupId -notin $assignmentsList.target.groupId) { - $ExistingAssignment - } - } - '#microsoft.graph.exclusionGroupAssignmentTarget' { - if ($ExistingAssignment.target.groupId -notin $assignmentsList.target.groupId) { - $ExistingAssignment - } - } - default { - if ($ExistingAssignment.target.'@odata.type' -notin $assignmentsList.target.'@odata.type') { - $ExistingAssignment - } + foreach ($ExistingAssignment in $ExistingAssignments) { + $ExistingType = $ExistingAssignment.target.'@odata.type' + $Keep = if ($AssignmentMode -eq 'replace' -and $DirectionScoped) { + # Direction-scoped replace: drop every target of the edited type, keep the rest + # (the other direction plus All Users / All Devices broad targets). + $ExistingType -ne $EditedType + } else { + # Append: keep existing unless the new set overrides the same group/target. + switch ($ExistingType) { + '#microsoft.graph.groupAssignmentTarget' { $ExistingAssignment.target.groupId -notin $assignmentsList.target.groupId } + '#microsoft.graph.exclusionGroupAssignmentTarget' { $ExistingAssignment.target.groupId -notin $assignmentsList.target.groupId } + default { $ExistingType -notin $assignmentsList.target.'@odata.type' } } } - } - } - - # Build final assignments list - $FinalAssignments = [System.Collections.Generic.List[object]]::new() - if ($AssignmentMode -eq 'append' -and $ExistingAssignments) { - foreach ($existing in $ExistingAssignments) { - $FinalAssignments.Add(@{ - target = $existing.target - }) + if ($Keep) { + $FinalAssignments.Add(@{ target = $ExistingAssignment.target }) + } } } @@ -222,9 +235,14 @@ function Set-CIPPAssignedPolicy { 'specified groups' } - if ($ExcludeGroup) { - Write-LogMessage -headers $Headers -API $APIName -message "Assigned group '$AssignedGroupsDisplay' and excluded group '$ExcludeGroup' on Policy $PolicyId" -Sev 'Info' -tenant $TenantFilter - return "Successfully assigned group '$AssignedGroupsDisplay' and excluded group '$ExcludeGroup' on Policy $PolicyId" + $ExcludedGroupsDisplay = if ($ExcludeGroupIds -and @($ExcludeGroupIds).Count -gt 0) { + ($ExcludeGroupIds -join ', ') + } else { + $ExcludeGroup + } + if ($ExcludedGroupsDisplay) { + Write-LogMessage -headers $Headers -API $APIName -message "Assigned group '$AssignedGroupsDisplay' and excluded group '$ExcludedGroupsDisplay' on Policy $PolicyId" -Sev 'Info' -tenant $TenantFilter + return "Successfully assigned group '$AssignedGroupsDisplay' and excluded group '$ExcludedGroupsDisplay' on Policy $PolicyId" } else { Write-LogMessage -headers $Headers -API $APIName -message "Assigned group '$AssignedGroupsDisplay' on Policy $PolicyId" -Sev 'Info' -tenant $TenantFilter return "Successfully assigned group '$AssignedGroupsDisplay' on Policy $PolicyId" diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-ExecAssignApp.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-ExecAssignApp.ps1 index 84b12b647b16a..58d543e45bfa8 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-ExecAssignApp.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-ExecAssignApp.ps1 @@ -24,6 +24,8 @@ function Invoke-ExecAssignApp { $AssignmentFilterName = $Request.Body.AssignmentFilterName $AssignmentFilterType = $Request.Body.AssignmentFilterType $ExcludeGroup = $Request.Body.excludeGroup + $ExcludeGroupIdsRaw = $Request.Body.ExcludeGroupIds + $AssignmentDirection = $Request.Body.assignmentDirection $Intent = if ([string]::IsNullOrWhiteSpace($Intent)) { 'Required' } else { $Intent } @@ -36,6 +38,17 @@ function Invoke-ExecAssignApp { } } + # assignmentDirection is sent only by the Custom Group action and switches that request to + # direction-scoped Replace (preserve the other direction + All Users/All Devices broad targets). + if (-not [string]::IsNullOrWhiteSpace($AssignmentDirection)) { + $AssignmentDirection = $AssignmentDirection.ToLower() + if ($AssignmentDirection -notin @('include', 'exclude')) { + $AssignmentDirection = $null + } + } else { + $AssignmentDirection = $null + } + function Get-StandardizedAssignmentList { param($InputObject) @@ -53,9 +66,24 @@ function Invoke-ExecAssignApp { $GroupNames = Get-StandardizedAssignmentList -InputObject $GroupNamesRaw $GroupIds = Get-StandardizedAssignmentList -InputObject $GroupIdsRaw + $ExcludeGroupIds = Get-StandardizedAssignmentList -InputObject $ExcludeGroupIdsRaw + + # 'Clear all exclusions' is a Custom Group Exclude + Replace request with no groups selected. + $IsClearExclusions = ($AssignmentDirection -eq 'exclude') -and ($AssignmentMode -eq 'replace') - if (-not $AssignTo -and $GroupIds.Count -eq 0 -and $GroupNames.Count -eq 0) { - throw 'No assignment target provided. Supply AssignTo, GroupNames, or GroupIds.' + if (-not $AssignTo -and $GroupIds.Count -eq 0 -and $GroupNames.Count -eq 0 -and $ExcludeGroupIds.Count -eq 0 -and [string]::IsNullOrWhiteSpace($ExcludeGroup) -and -not $IsClearExclusions) { + throw 'No assignment target provided. Supply AssignTo, GroupNames, GroupIds, or an exclude group.' + } + + # Safety net for legacy/API callers (no assignmentDirection): an exclude-only request in + # 'replace' mode would post just the exclusion target and wipe every existing assignment, so + # force preserve. The Custom Group action sends assignmentDirection and uses direction-scoped + # Replace instead, so it is exempt. + $IsExcludeOnly = (-not $AssignTo -and $GroupIds.Count -eq 0 -and $GroupNames.Count -eq 0) -and + ($ExcludeGroupIds.Count -gt 0 -or -not [string]::IsNullOrWhiteSpace($ExcludeGroup)) + if ($IsExcludeOnly -and $AssignmentMode -eq 'replace' -and -not $AssignmentDirection) { + Write-Information 'Exclude-only assignment requested; forcing append mode to preserve existing assignments.' + $AssignmentMode = 'append' } # Try to get the application type if not provided. Mostly just useful for ppl using the API that dont know the application type. @@ -78,7 +106,8 @@ function Invoke-ExecAssignApp { } elseif ($GroupIds.Count -gt 0) { "GroupIds: $($GroupIds -join ',')" } else { - 'CustomGroupAssignment' + # Exclude-only assignment: no include target, so the helper must not try to resolve one + $null } $setParams = @{ @@ -110,6 +139,14 @@ function Invoke-ExecAssignApp { $setParams.ExcludeGroup = $ExcludeGroup } + if ($ExcludeGroupIds.Count -gt 0) { + $setParams.ExcludeGroupIds = $ExcludeGroupIds + } + + if ($AssignmentDirection) { + $setParams.AssignmentDirection = $AssignmentDirection + } + try { $Result = Set-CIPPAssignedApplication @setParams $StatusCode = [HttpStatusCode]::OK diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-ListApps.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-ListApps.ps1 index f2d0dc06d28cb..9a401a15a400f 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-ListApps.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-ListApps.ps1 @@ -67,7 +67,7 @@ function Invoke-ListApps { } '#microsoft.graph.exclusionGroupAssignmentTarget' { $groupName = ($Groups | Where-Object { $_.id -eq $target.groupId }).displayName - if ($groupName) { $AppExclude.Add($groupName) } + if ($groupName) { $AppExclude.Add("$groupName$intentSuffix") } } } } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecAssignPolicy.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecAssignPolicy.ps1 index ce22cb30d03f5..1b6973bf81827 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecAssignPolicy.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecAssignPolicy.ps1 @@ -18,9 +18,11 @@ function Invoke-ExecAssignPolicy { $AssignTo = $Request.Body.AssignTo $PlatformType = $Request.Body.platformType $ExcludeGroup = $Request.Body.excludeGroup + $ExcludeGroupIdsRaw = $Request.Body.ExcludeGroupIds $GroupIdsRaw = $Request.Body.GroupIds $GroupNamesRaw = $Request.Body.GroupNames $AssignmentMode = $Request.Body.assignmentMode + $AssignmentDirection = $Request.Body.assignmentDirection $AssignmentFilterName = $Request.Body.AssignmentFilterName $AssignmentFilterType = $Request.Body.AssignmentFilterType @@ -41,6 +43,7 @@ function Invoke-ExecAssignPolicy { $GroupIds = Get-StandardizedList -InputObject $GroupIdsRaw $GroupNames = Get-StandardizedList -InputObject $GroupNamesRaw + $ExcludeGroupIds = Get-StandardizedList -InputObject $ExcludeGroupIdsRaw # Validate and default AssignmentMode if ([string]::IsNullOrWhiteSpace($AssignmentMode)) { @@ -49,8 +52,31 @@ function Invoke-ExecAssignPolicy { $AssignTo = if ($AssignTo -ne 'on') { $AssignTo } + # assignmentDirection is sent only by the Custom Group action and switches that request to + # direction-scoped Replace (preserve the other direction + All Users/All Devices broad targets). + if (-not [string]::IsNullOrWhiteSpace($AssignmentDirection)) { + $AssignmentDirection = $AssignmentDirection.ToLower() + if ($AssignmentDirection -notin @('include', 'exclude')) { + $AssignmentDirection = $null + } + } else { + $AssignmentDirection = $null + } + + # 'Clear all exclusions' is a Custom Group Exclude + Replace request with no groups selected. + $IsClearExclusions = ($AssignmentDirection -eq 'exclude') -and ($AssignmentMode -eq 'replace') + + # Safety net for legacy/API callers (no assignmentDirection): an exclude-only request in + # 'replace' mode would post just the exclusion target and wipe every existing assignment. The + # Custom Group action sends assignmentDirection and uses direction-scoped Replace instead. + $IsExcludeOnly = (-not $AssignTo -and @($GroupIds).Count -eq 0 -and @($GroupNames).Count -eq 0) -and + (@($ExcludeGroupIds).Count -gt 0 -or -not [string]::IsNullOrWhiteSpace($ExcludeGroup)) + if ($IsExcludeOnly -and $AssignmentMode -eq 'replace' -and -not $AssignmentDirection) { + $AssignmentMode = 'append' + } + $Results = try { - if ($AssignTo -or @($GroupIds).Count -gt 0) { + if ($AssignTo -or @($GroupIds).Count -gt 0 -or @($ExcludeGroupIds).Count -gt 0 -or -not [string]::IsNullOrWhiteSpace($ExcludeGroup) -or $IsClearExclusions) { $params = @{ PolicyId = $ID TenantFilter = $TenantFilter @@ -76,6 +102,14 @@ function Invoke-ExecAssignPolicy { $params.ExcludeGroup = $ExcludeGroup } + if (@($ExcludeGroupIds).Count -gt 0) { + $params.ExcludeGroupIds = @($ExcludeGroupIds) + } + + if ($AssignmentDirection) { + $params.AssignmentDirection = $AssignmentDirection + } + if (-not [string]::IsNullOrWhiteSpace($AssignmentFilterName)) { $params.AssignmentFilterName = $AssignmentFilterName } From dae979bb5613acf6196553e8aee27b58b13635d7 Mon Sep 17 00:00:00 2001 From: MatStocks <20848373+matstocks@users.noreply.github.com> Date: Sat, 27 Jun 2026 08:57:01 +0100 Subject: [PATCH 034/170] Fix Check extension alerts repeating on every run (watermark never advanced) Get-CIPPAlertCheckExtension reads a per-tenant watermark from the AlertLastRun table to only fetch alerts newer than the previous run, but never wrote it back. So $Since always fell back to the default 24h window and the same alerts were re-sent on every scheduled run. Capture the run start, read/store an explicit LastRunTime watermark (falling back to the existing Timestamp for compatibility), and upsert it after processing so each run only sends alerts created since the last run. Fixes #6216 --- .../Alerts/Get-CIPPAlertCheckExtension.ps1 | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertCheckExtension.ps1 b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertCheckExtension.ps1 index 79ae2f3a8b41c..0d0fb4281d13d 100644 --- a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertCheckExtension.ps1 +++ b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertCheckExtension.ps1 @@ -16,9 +16,16 @@ function Get-CIPPAlertCheckExtension { $LastRunTable = Get-CippTable -tablename 'AlertLastRun' $LastRunKey = "$TenantFilter-Get-CIPPAlertCheckExtension" - # Get the last run timestamp for this tenant to only fetch new alerts + # Capture the start of this run. The watermark is advanced to this value + # after processing so the next run only fetches alerts from this point + # onward, including any that arrive while this run is still processing. + $RunStart = (Get-Date).ToUniversalTime() + + # Get the last run timestamp for this tenant to only fetch new alerts. $LastRun = Get-CIPPAzDataTableEntity @LastRunTable -Filter "PartitionKey eq 'AlertLastRun' and RowKey eq '$LastRunKey'" | Select-Object -First 1 - $Since = if ($LastRun.Timestamp) { + $Since = if ($LastRun.LastRunTime) { + [datetime]::Parse($LastRun.LastRunTime, [cultureinfo]::InvariantCulture, [System.Globalization.DateTimeStyles]::RoundtripKind) + } elseif ($LastRun.Timestamp) { $LastRun.Timestamp.UtcDateTime } else { (Get-Date).AddDays(-1).ToUniversalTime() @@ -45,6 +52,16 @@ function Get-CIPPAlertCheckExtension { if ($AlertData) { Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData } + + # Advance the watermark so the next run only picks up alerts newer than + # this run. Without this, $Since always fell back to the default window + # and previously sent alerts were re-sent on every run. + $LastRunEntity = @{ + PartitionKey = 'AlertLastRun' + RowKey = $LastRunKey + LastRunTime = $RunStart.ToString('o') + } + $null = Add-CIPPAzDataTableEntity @LastRunTable -Entity $LastRunEntity -Force } catch { $ErrorMessage = Get-CippException -Exception $_ Write-AlertMessage -message "Check Extension alert failed: $($ErrorMessage.NormalizedError)" -tenant $TenantFilter -LogData $ErrorMessage From 9a88ec07e955561ff656f12b960017e316e67e79 Mon Sep 17 00:00:00 2001 From: Bobby <31723128+kris6673@users.noreply.github.com> Date: Sat, 27 Jun 2026 10:07:19 +0200 Subject: [PATCH 035/170] feat: add support for ExcludeGroupNames in assignment functions --- .../Public/Set-CIPPAssignedApplication.ps1 | 35 +++++++++++++++++-- .../Public/Set-CIPPAssignedPolicy.ps1 | 30 +++++++++++----- .../Applications/Invoke-ExecAssignApp.ps1 | 6 ++++ .../Endpoint/MEM/Invoke-ExecAssignPolicy.ps1 | 6 ++++ 4 files changed, 66 insertions(+), 11 deletions(-) diff --git a/Modules/CIPPCore/Public/Set-CIPPAssignedApplication.ps1 b/Modules/CIPPCore/Public/Set-CIPPAssignedApplication.ps1 index 007f52b8e11c8..01cb05ec8ec77 100644 --- a/Modules/CIPPCore/Public/Set-CIPPAssignedApplication.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPAssignedApplication.ps1 @@ -4,6 +4,7 @@ function Set-CIPPAssignedApplication { $GroupName, $ExcludeGroup, $ExcludeGroupIds, + $ExcludeGroupNames, $Intent, $AppType, $ApplicationId, @@ -266,12 +267,40 @@ function Set-CIPPAssignedApplication { $FinalAssignments ) } - if ($PSCmdlet.ShouldProcess($GroupName, "Assigning Application $ApplicationId")) { + $ShouldProcess = $PSCmdlet.ShouldProcess($GroupName, "Assigning Application $ApplicationId") + if ($ShouldProcess) { Start-Sleep -Seconds 1 $null = New-GraphPOSTRequest -uri "https://graph.microsoft.com/beta/deviceAppManagement/mobileApps/$($ApplicationId)/assign" -tenantid $TenantFilter -type POST -body ($DefaultAssignmentObject | ConvertTo-Json -Compress -Depth 10) - Write-LogMessage -headers $Headers -API $APIName -message "Assigned Application $ApplicationId to $($GroupName)" -Sev 'Info' -tenant $TenantFilter } - return "Assigned Application $ApplicationId to $($GroupName)" + + $AssignedGroupsDisplay = if ($GroupName) { + $GroupName + } elseif ($GroupIds -and @($GroupIds).Count -gt 0) { + @($GroupIds) -join ', ' + } + + $ExcludedGroupsDisplay = if ($ExcludeGroupNames -and @($ExcludeGroupNames).Count -gt 0) { + @($ExcludeGroupNames) -join ', ' + } elseif ($ExcludeGroupIds -and @($ExcludeGroupIds).Count -gt 0) { + @($ExcludeGroupIds) -join ', ' + } else { + $ExcludeGroup + } + + $ResultMessage = if ($ExcludedGroupsDisplay -and $AssignedGroupsDisplay) { + "Assigned Application $ApplicationId to $AssignedGroupsDisplay excluding group '$ExcludedGroupsDisplay'" + } elseif ($ExcludedGroupsDisplay) { + "Updated exclusions for Application $ApplicationId to group '$ExcludedGroupsDisplay'" + } elseif ($AssignmentDirection -eq 'exclude' -and $AssignmentMode -eq 'replace') { + "Cleared exclusions for Application $ApplicationId" + } else { + "Assigned Application $ApplicationId to $AssignedGroupsDisplay" + } + + if ($ShouldProcess) { + Write-LogMessage -headers $Headers -API $APIName -message $ResultMessage -Sev 'Info' -tenant $TenantFilter + } + return $ResultMessage } catch { $ErrorMessage = Get-CippException -Exception $_ Write-LogMessage -headers $Headers -API $APIName -message "Could not assign application $ApplicationId to $GroupName. Error: $($ErrorMessage.NormalizedError)" -Sev 'Error' -tenant $TenantFilter -LogData $ErrorMessage diff --git a/Modules/CIPPCore/Public/Set-CIPPAssignedPolicy.ps1 b/Modules/CIPPCore/Public/Set-CIPPAssignedPolicy.ps1 index 715e1a4844ba3..3aa9d3530e2c3 100644 --- a/Modules/CIPPCore/Public/Set-CIPPAssignedPolicy.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPAssignedPolicy.ps1 @@ -4,6 +4,7 @@ function Set-CIPPAssignedPolicy { $GroupName, $ExcludeGroup, $ExcludeGroupIds, + $ExcludeGroupNames, $PolicyId, $Type, $TenantFilter, @@ -222,7 +223,8 @@ function Set-CIPPAssignedPolicy { $assignmentsObject = @{ $AssignmentPropertyName = @($FinalAssignments) } $AssignJSON = ConvertTo-Json -InputObject $assignmentsObject -Depth 10 -Compress - if ($PSCmdlet.ShouldProcess($GroupName, "Assigning policy $PolicyId")) { + $ShouldProcess = $PSCmdlet.ShouldProcess($GroupName, "Assigning policy $PolicyId") + if ($ShouldProcess) { $uri = "https://graph.microsoft.com/beta/$($PlatformType)/$Type('$($PolicyId)')/assign" $null = New-GraphPOSTRequest -uri $uri -tenantid $TenantFilter -type POST -body $AssignJSON @@ -231,22 +233,34 @@ function Set-CIPPAssignedPolicy { ($GroupNames -join ', ') } elseif ($GroupName) { $GroupName + } elseif ($GroupIds -and @($GroupIds).Count -gt 0) { + @($GroupIds) -join ', ' } else { - 'specified groups' + $null } - $ExcludedGroupsDisplay = if ($ExcludeGroupIds -and @($ExcludeGroupIds).Count -gt 0) { + $ExcludedGroupsDisplay = if ($ExcludeGroupNames -and @($ExcludeGroupNames).Count -gt 0) { + ($ExcludeGroupNames -join ', ') + } elseif ($ExcludeGroupIds -and @($ExcludeGroupIds).Count -gt 0) { ($ExcludeGroupIds -join ', ') } else { $ExcludeGroup } - if ($ExcludedGroupsDisplay) { - Write-LogMessage -headers $Headers -API $APIName -message "Assigned group '$AssignedGroupsDisplay' and excluded group '$ExcludedGroupsDisplay' on Policy $PolicyId" -Sev 'Info' -tenant $TenantFilter - return "Successfully assigned group '$AssignedGroupsDisplay' and excluded group '$ExcludedGroupsDisplay' on Policy $PolicyId" + + $ResultMessage = if ($ExcludedGroupsDisplay -and $AssignedGroupsDisplay) { + "Successfully assigned group '$AssignedGroupsDisplay' and excluded group '$ExcludedGroupsDisplay' on Policy $PolicyId" + } elseif ($ExcludedGroupsDisplay) { + "Successfully updated exclusions to group '$ExcludedGroupsDisplay' on Policy $PolicyId" + } elseif ($AssignmentDirection -eq 'exclude' -and $AssignmentMode -eq 'replace') { + "Successfully cleared exclusions on Policy $PolicyId" } else { - Write-LogMessage -headers $Headers -API $APIName -message "Assigned group '$AssignedGroupsDisplay' on Policy $PolicyId" -Sev 'Info' -tenant $TenantFilter - return "Successfully assigned group '$AssignedGroupsDisplay' on Policy $PolicyId" + "Successfully assigned group '$AssignedGroupsDisplay' on Policy $PolicyId" + } + + if ($ShouldProcess) { + Write-LogMessage -headers $Headers -API $APIName -message $ResultMessage -Sev 'Info' -tenant $TenantFilter } + return $ResultMessage } } catch { diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-ExecAssignApp.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-ExecAssignApp.ps1 index 58d543e45bfa8..1f6e1fcb6edef 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-ExecAssignApp.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-ExecAssignApp.ps1 @@ -25,6 +25,7 @@ function Invoke-ExecAssignApp { $AssignmentFilterType = $Request.Body.AssignmentFilterType $ExcludeGroup = $Request.Body.excludeGroup $ExcludeGroupIdsRaw = $Request.Body.ExcludeGroupIds + $ExcludeGroupNamesRaw = $Request.Body.ExcludeGroupNames $AssignmentDirection = $Request.Body.assignmentDirection $Intent = if ([string]::IsNullOrWhiteSpace($Intent)) { 'Required' } else { $Intent } @@ -67,6 +68,7 @@ function Invoke-ExecAssignApp { $GroupNames = Get-StandardizedAssignmentList -InputObject $GroupNamesRaw $GroupIds = Get-StandardizedAssignmentList -InputObject $GroupIdsRaw $ExcludeGroupIds = Get-StandardizedAssignmentList -InputObject $ExcludeGroupIdsRaw + $ExcludeGroupNames = Get-StandardizedAssignmentList -InputObject $ExcludeGroupNamesRaw # 'Clear all exclusions' is a Custom Group Exclude + Replace request with no groups selected. $IsClearExclusions = ($AssignmentDirection -eq 'exclude') -and ($AssignmentMode -eq 'replace') @@ -143,6 +145,10 @@ function Invoke-ExecAssignApp { $setParams.ExcludeGroupIds = $ExcludeGroupIds } + if ($ExcludeGroupNames.Count -gt 0) { + $setParams.ExcludeGroupNames = $ExcludeGroupNames + } + if ($AssignmentDirection) { $setParams.AssignmentDirection = $AssignmentDirection } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecAssignPolicy.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecAssignPolicy.ps1 index 1b6973bf81827..722a5f37cdc04 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecAssignPolicy.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecAssignPolicy.ps1 @@ -19,6 +19,7 @@ function Invoke-ExecAssignPolicy { $PlatformType = $Request.Body.platformType $ExcludeGroup = $Request.Body.excludeGroup $ExcludeGroupIdsRaw = $Request.Body.ExcludeGroupIds + $ExcludeGroupNamesRaw = $Request.Body.ExcludeGroupNames $GroupIdsRaw = $Request.Body.GroupIds $GroupNamesRaw = $Request.Body.GroupNames $AssignmentMode = $Request.Body.assignmentMode @@ -44,6 +45,7 @@ function Invoke-ExecAssignPolicy { $GroupIds = Get-StandardizedList -InputObject $GroupIdsRaw $GroupNames = Get-StandardizedList -InputObject $GroupNamesRaw $ExcludeGroupIds = Get-StandardizedList -InputObject $ExcludeGroupIdsRaw + $ExcludeGroupNames = Get-StandardizedList -InputObject $ExcludeGroupNamesRaw # Validate and default AssignmentMode if ([string]::IsNullOrWhiteSpace($AssignmentMode)) { @@ -106,6 +108,10 @@ function Invoke-ExecAssignPolicy { $params.ExcludeGroupIds = @($ExcludeGroupIds) } + if (@($ExcludeGroupNames).Count -gt 0) { + $params.ExcludeGroupNames = @($ExcludeGroupNames) + } + if ($AssignmentDirection) { $params.AssignmentDirection = $AssignmentDirection } From 2f162230ee431bdac6da082311f7fce2c95a53e1 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Sat, 27 Jun 2026 16:18:22 +0800 Subject: [PATCH 036/170] Sensitivity Rule Templating and standard overhaul --- .../Public/Compare-CIPPSensitiveInfoType.ps1 | 154 ++++++++++++++++++ .../Public/Get-CIPPSitSinglePackXml.ps1 | 92 +++++++++++ .../Public/New-CIPPSitRulePackXml.ps1 | 22 ++- .../Public/Set-CIPPSensitiveInfoType.ps1 | 56 ++++--- .../Invoke-AddSensitiveInfoTypeTemplate.ps1 | 93 +++++++---- ...nvoke-ListSensitiveInfoTypeRulePackage.ps1 | 47 ++++++ .../Invoke-RemoveSensitiveInfoType.ps1 | 19 ++- ...-CIPPStandardSensitiveInfoTypeTemplate.ps1 | 52 ++++-- 8 files changed, 458 insertions(+), 77 deletions(-) create mode 100644 Modules/CIPPCore/Public/Compare-CIPPSensitiveInfoType.ps1 create mode 100644 Modules/CIPPCore/Public/Get-CIPPSitSinglePackXml.ps1 create mode 100644 Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SIT/Invoke-ListSensitiveInfoTypeRulePackage.ps1 diff --git a/Modules/CIPPCore/Public/Compare-CIPPSensitiveInfoType.ps1 b/Modules/CIPPCore/Public/Compare-CIPPSensitiveInfoType.ps1 new file mode 100644 index 0000000000000..426bec77f51ef --- /dev/null +++ b/Modules/CIPPCore/Public/Compare-CIPPSensitiveInfoType.ps1 @@ -0,0 +1,154 @@ +function ConvertTo-CIPPSitComparable { + <# + .SYNOPSIS + Reduce a SIT rule pack XML to a semantic, comparable structure (ignoring volatile ids/versions). + .DESCRIPTION + A rule pack XML carries GUIDs (RulePack/Publisher/Entity/Regex ids) and a version that change + between deploys and are assigned by Microsoft, so a raw XML compare always looks like drift. This + extracts only the meaningful content per entity - name, description, recommended confidence, + patterns proximity, and each pattern's confidence level with its resolved regex/keyword content - + keyed by entity name. Parsing is namespace-agnostic (local-name()) so the 2011 and 2018 schemas + both work. + .OUTPUTS + Hashtable of entityName -> ordered hashtable { confidence, proximity, description, patterns }. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param([Parameter(Mandatory)][AllowNull()][string]$Xml) + + $result = @{} + if ([string]::IsNullOrWhiteSpace($Xml)) { return $result } + try { [xml]$doc = $Xml } catch { return $result } + + # Resolve referenced detection elements: regex id -> pattern text, keyword id -> sorted terms. + $regexMap = @{} + foreach ($r in $doc.SelectNodes("//*[local-name()='Regex']")) { + if ($r.id) { $regexMap[[string]$r.id] = ([string]$r.InnerText).Trim() } + } + $keywordMap = @{} + foreach ($k in $doc.SelectNodes("//*[local-name()='Keyword']")) { + $terms = @($k.SelectNodes(".//*[local-name()='Term']") | ForEach-Object { ([string]$_.InnerText).Trim() }) | Sort-Object + if ($k.id) { $keywordMap[[string]$k.id] = ($terms -join '|') } + } + + # entity id -> localized name/description + $resMap = @{} + foreach ($res in $doc.SelectNodes("//*[local-name()='Resource']")) { + if (-not $res.idRef) { continue } + $nameNode = $res.SelectSingleNode("*[local-name()='Name']") + $descNode = $res.SelectSingleNode("*[local-name()='Description']") + $resMap[[string]$res.idRef] = @{ + Name = if ($nameNode) { ([string]$nameNode.InnerText).Trim() } else { '' } + Description = if ($descNode) { ([string]$descNode.InnerText).Trim() } else { '' } + } + } + + foreach ($ent in $doc.SelectNodes("//*[local-name()='Entity']")) { + $eid = [string]$ent.id + $name = if ($resMap.ContainsKey($eid) -and $resMap[$eid].Name) { $resMap[$eid].Name } else { $eid } + + $patterns = @(foreach ($p in $ent.SelectNodes("*[local-name()='Pattern']")) { + $matches = @($p.SelectNodes(".//*[@idRef]") | ForEach-Object { + $ref = [string]$_.idRef + if ($regexMap.ContainsKey($ref)) { "regex:$($regexMap[$ref])" } + elseif ($keywordMap.ContainsKey($ref)) { "keyword:$($keywordMap[$ref])" } + else { "ref:$ref" } + }) | Sort-Object + [ordered]@{ level = [string]$p.confidenceLevel; matches = @($matches) } + }) + + $result[$name] = [ordered]@{ + confidence = [string]$ent.recommendedConfidence + proximity = [string]$ent.patternsProximity + description = if ($resMap.ContainsKey($eid)) { $resMap[$eid].Description } else { '' } + patterns = @($patterns) + } + } + return $result +} + +function Compare-CIPPSensitiveInfoType { + <# + .SYNOPSIS + Compare a stored SIT template against the live custom SIT in a tenant and report drift. + .DESCRIPTION + Resolves the template's intended rule pack XML (advanced FileDataBase64, or synthesized from a + simple Pattern), fetches the live SIT's rule pack XML, reduces both to a semantic structure via + ConvertTo-CIPPSitComparable, and diffs each templated entity (matched by name). Returns the state + and the specific differing fields with expected (template) vs current (tenant) values. + .OUTPUTS + PSCustomObject: Name, State ('Missing' | 'BuiltIn' | 'Invalid' | 'InSync' | 'Drift'), Differences. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string] $TenantFilter, + [Parameter(Mandatory)] $Template + ) + + $Name = $Template.Name ?? $Template.name + + # Resolve the template's intended rule pack XML. + $WantXml = $null + if ($Template.FileDataBase64) { + try { $Bytes = [System.Convert]::FromBase64String($Template.FileDataBase64) } catch { $Bytes = $null } + if ($Bytes) { + $WantXml = [System.Text.Encoding]::Unicode.GetString($Bytes) + if ($WantXml -notmatch ' wrapper. + Works for regex (Entity -> Pattern -> Regex/Keyword) and fingerprint (Affinity -> Evidence -> + Fingerprint -> AdvancedFingerprint) alike. Namespace-agnostic. + .PARAMETER PackXml + The full rule pack XML (e.g. from Get-DlpSensitiveInformationTypeRulePackage). + .PARAMETER EntityId + The SIT entity id (the SIT's Id). If blank, resolved from EntityName via the Resource. + .PARAMETER EntityName + The SIT name, used to resolve the entity id when EntityId is blank. + .OUTPUTS + A single-SIT rule pack XML string (utf-16 declared). + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)][string]$PackXml, + [string]$EntityId, + [string]$EntityName + ) + + [xml]$doc = $PackXml + + # Resolve the entity id from the SIT name if needed (Resource.Name -> Resource.idRef). + if ([string]::IsNullOrWhiteSpace($EntityId) -and $EntityName) { + foreach ($res in $doc.SelectNodes("//*[local-name()='Resource']")) { + $nm = $res.SelectSingleNode("*[local-name()='Name']") + if ($nm -and ([string]$nm.InnerText).Trim() -eq $EntityName) { $EntityId = [string]$res.idRef; break } + } + } + if ([string]::IsNullOrWhiteSpace($EntityId)) { throw 'Could not resolve the SIT entity id.' } + + # Map every element that carries an id (anywhere - entities can be nested in a Version wrapper). + $byId = @{} + foreach ($el in $doc.SelectNodes("//*[@id]")) { $byId[[string]$el.id] = $el } + if (-not $byId.ContainsKey($EntityId)) { throw "Entity '$EntityId' not found in the rule package." } + + # Transitive closure of ids the target entity needs. + $keep = New-Object 'System.Collections.Generic.HashSet[string]' + [void]$keep.Add($EntityId) + $stack = New-Object System.Collections.Stack + $stack.Push($byId[$EntityId]) + while ($stack.Count -gt 0) { + $el = $stack.Pop() + $walk = New-Object System.Collections.Stack + $walk.Push($el) + while ($walk.Count -gt 0) { + $n = $walk.Pop() + foreach ($a in @($n.Attributes)) { + if ($a.Name -in @('idRef', 'linkedProcessorIdRef')) { + $rid = [string]$a.Value + if ($byId.ContainsKey($rid) -and -not $keep.Contains($rid)) { + [void]$keep.Add($rid) + $stack.Push($byId[$rid]) + } + } + } + foreach ($ch in $n.ChildNodes) { if ($ch.NodeType -eq 'Element') { $walk.Push($ch) } } + } + } + + # Remove other entities, the detection elements they (and not the target) own, and other Resources. + $toRemove = New-Object System.Collections.ArrayList + foreach ($e in $doc.SelectNodes("//*[local-name()='Entity' or local-name()='Affinity']")) { + if ([string]$e.id -ne $EntityId) { [void]$toRemove.Add($e) } + } + foreach ($e in $doc.SelectNodes("//*[local-name()='Regex' or local-name()='Keyword' or local-name()='Fingerprint' or local-name()='AdvancedFingerprint']")) { + if (-not $keep.Contains([string]$e.id)) { [void]$toRemove.Add($e) } + } + foreach ($r in $doc.SelectNodes("//*[local-name()='Resource']")) { + if ([string]$r.idRef -ne $EntityId) { [void]$toRemove.Add($r) } + } + foreach ($node in $toRemove) { [void]$node.ParentNode.RemoveChild($node) } + + # Fresh RulePack id so this deploys as a new custom pack (not the fixed managed pack id). + $rulePack = $doc.SelectSingleNode("//*[local-name()='RulePack']") + if ($rulePack -and $rulePack.Attributes['id']) { $rulePack.id = (New-Guid).Guid } + + return '' + $doc.DocumentElement.OuterXml +} diff --git a/Modules/CIPPCore/Public/New-CIPPSitRulePackXml.ps1 b/Modules/CIPPCore/Public/New-CIPPSitRulePackXml.ps1 index a202c9b89511d..59ecf84ec9410 100644 --- a/Modules/CIPPCore/Public/New-CIPPSitRulePackXml.ps1 +++ b/Modules/CIPPCore/Public/New-CIPPSitRulePackXml.ps1 @@ -3,9 +3,17 @@ function New-CIPPSitRulePackXml { .SYNOPSIS Synthesize a Microsoft Purview Sensitive Information Type rule pack XML from simple inputs. .DESCRIPTION - New-DlpSensitiveInformationType only accepts a rule pack XML via -FileData (byte array). - For simple regex-based SITs, this helper builds a minimal valid rule pack with fresh GUIDs - so callers can pass it to the cmdlet without hand-authoring XML. + New-DlpSensitiveInformationTypeRulePackage imports a custom SIT *rule package* (regex/keyword + based, Type=Entity). It requires the 2011 'mce' schema namespace and UTF-16 encoded bytes - the + 2018 'search.external' namespace is rejected with a schema-validation error. + + For simple regex-based SITs this helper builds a minimal valid rule pack with fresh GUIDs so + callers can hand it to the cmdlet without authoring XML. (NOTE: the singular + New-DlpSensitiveInformationType cmdlet is a *document-fingerprint* primitive and must NOT be used + for regex SITs - it stores the FileData as a fingerprint and discards the regex.) + .NOTES + The returned string declares encoding="utf-16"; callers must encode it with + [System.Text.Encoding]::Unicode (UTF-16LE, no BOM) so the bytes match the declaration. .FUNCTIONALITY Internal #> @@ -16,7 +24,7 @@ function New-CIPPSitRulePackXml { [Parameter(Mandatory)][string]$Pattern, [int]$Confidence = 85, [int]$PatternsProximity = 300, - [string]$Locale = 'en-us', + [string]$Locale = 'en-US', [string]$PublisherName = 'CIPP' ) @@ -27,10 +35,10 @@ function New-CIPPSitRulePackXml { $esc = { param($s) [System.Security.SecurityElement]::Escape([string]$s) } return @" - - + + - +
diff --git a/Modules/CIPPCore/Public/Set-CIPPSensitiveInfoType.ps1 b/Modules/CIPPCore/Public/Set-CIPPSensitiveInfoType.ps1 index 5a86bc1674567..6860bb48a8c15 100644 --- a/Modules/CIPPCore/Public/Set-CIPPSensitiveInfoType.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPSensitiveInfoType.ps1 @@ -4,8 +4,14 @@ function Set-CIPPSensitiveInfoType { Deploy or update a single custom Sensitive Information Type in a tenant from a template object. .DESCRIPTION Single source of truth for SIT deployment, shared by the HTTP deploy endpoint and the standard. - Supports simple mode (Pattern → backend synthesizes rule pack XML) and advanced mode (caller- - supplied FileDataBase64 rule pack). Microsoft built-in SITs are skipped. + Imports a custom SIT *rule package* (regex/keyword based, Type=Entity) via + New-/Set-DlpSensitiveInformationTypeRulePackage. Supports simple mode (Pattern -> backend + synthesizes the rule pack XML) and advanced mode (caller-supplied FileDataBase64 rule pack, + e.g. captured from an existing SIT). Microsoft built-in SITs are skipped. + + IMPORTANT: this uses the rule-*package* cmdlets, NOT New-/Set-DlpSensitiveInformationType - the + latter is a document-fingerprint primitive that stores -FileData as a fingerprint and discards + the regex. The rule pack XML must use the 2011 'mce' schema and be UTF-16 encoded. .FUNCTIONALITY Internal #> @@ -19,53 +25,63 @@ function Set-CIPPSensitiveInfoType { $Name = $Template.Name - # Build FileData byte array from either advanced (FileDataBase64) or simple (Pattern) mode - $FileDataBytes = $null + # Resolve the rule pack XML from advanced (FileDataBase64) or simple (Pattern) mode. + $XmlString = $null if ($Template.FileDataBase64) { try { - $FileDataBytes = [System.Convert]::FromBase64String($Template.FileDataBase64) + $RawBytes = [System.Convert]::FromBase64String($Template.FileDataBase64) } catch { - $msg = "SIT '$Name' has invalid FileDataBase64 ($($_.Exception.Message)) — skipping in $TenantFilter." + $msg = "SIT '$Name' has invalid FileDataBase64 ($($_.Exception.Message)) - skipping in $TenantFilter." Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $msg -sev Error return $msg } + # Captured/synthesized packs are UTF-16; fall back to UTF-8 if that doesn't look like the XML. + $XmlString = [System.Text.Encoding]::Unicode.GetString($RawBytes) + if ($XmlString -notmatch ' + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $TenantFilter = $Request.Query.tenantFilter ?? $Request.Body.tenantFilter + $RulePackId = $Request.Query.RulePackId ?? $Request.Body.RulePackId + + try { + if ([string]::IsNullOrWhiteSpace($RulePackId)) { throw 'RulePackId is required.' } + + $Pack = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-DlpSensitiveInformationTypeRulePackage' -cmdParams @{ Identity = $RulePackId } -Compliance | + Select-Object * -ExcludeProperty *odata*, *data.type* | Select-Object -First 1 + $Xml = [string]$Pack.ClassificationRuleCollectionXml + + # Reuse the drift comparer's semantic parser to expose a friendly rule configuration. + $Configuration = if (-not [string]::IsNullOrWhiteSpace($Xml)) { ConvertTo-CIPPSitComparable -Xml $Xml } else { @{} } + + $Result = [ordered]@{ + RulePackId = $RulePackId + RuleCollectionName = $Pack.RuleCollectionName + Publisher = $Pack.Publisher + Version = $Pack.Version + Configuration = $Configuration + Xml = $Xml + } + $StatusCode = [HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message + $StatusCode = [HttpStatusCode]::Forbidden + $Result = $ErrorMessage + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = $Result + }) +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SIT/Invoke-RemoveSensitiveInfoType.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SIT/Invoke-RemoveSensitiveInfoType.ps1 index 3fa5502ee294d..88edcad8111a7 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SIT/Invoke-RemoveSensitiveInfoType.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SIT/Invoke-RemoveSensitiveInfoType.ps1 @@ -13,12 +13,25 @@ Function Invoke-RemoveSensitiveInfoType { $TenantFilter = $Request.Query.tenantFilter ?? $Request.Body.tenantFilter $Identity = $Request.Query.Identity ?? $Request.Body.Identity ?? $Request.Body.Name + $FingerprintPackId = '00000000-0000-0000-0001-000000000001' try { - $Params = @{ - Identity = $Identity + $Sit = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-DlpSensitiveInformationType' -Compliance | + Where-Object { $_.Name -eq $Identity -or $_.Id -eq $Identity -or $_.Identity -eq $Identity } | Select-Object -First 1 + if (-not $Sit) { + throw "Sensitive Information Type '$Identity' not found." + } + if ($Sit.Publisher -like 'Microsoft*') { + throw "SIT '$($Sit.Name)' is a Microsoft built-in and cannot be deleted." + } + + # Regex/keyword SITs are their own rule package and must be removed at the package level - the + # singular Remove-DlpSensitiveInformationType only removes a SIT from the shared fingerprint pack. + if ($Sit.RulePackId -and $Sit.RulePackId -ne $FingerprintPackId -and $Sit.Type -ne 'Fingerprint') { + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Remove-DlpSensitiveInformationTypeRulePackage' -cmdParams @{ Identity = $Sit.RulePackId } -Compliance -useSystemMailbox $true + } else { + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Remove-DlpSensitiveInformationType' -cmdParams @{ Identity = $Identity } -Compliance -useSystemMailbox $true } - $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Remove-DlpSensitiveInformationType' -cmdParams $Params -Compliance -useSystemMailbox $true $Result = "Deleted Sensitive Information Type $Identity" Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message $Result -Sev Info $StatusCode = [HttpStatusCode]::OK diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardSensitiveInfoTypeTemplate.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardSensitiveInfoTypeTemplate.ps1 index d61620b079365..b7bd969e0e982 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardSensitiveInfoTypeTemplate.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardSensitiveInfoTypeTemplate.ps1 @@ -50,36 +50,52 @@ function Invoke-CIPPStandardSensitiveInfoTypeTemplate { return } - if ($Settings.remediate -eq $true) { - foreach ($Template in @($Templates)) { - $null = Set-CIPPSensitiveInfoType -TenantFilter $Tenant -Template $Template -APIName 'Standards' + # Compare each template against the live SIT's rule pack and remediate only what drifts (or is + # missing). After a successful remediation, re-compare so the report/alert reflect the fixed state. + $Comparisons = foreach ($Template in @($Templates)) { + $Comparison = Compare-CIPPSensitiveInfoType -TenantFilter $Tenant -Template $Template + + if ($Settings.remediate -eq $true -and $Comparison.State -in @('Missing', 'Drift')) { + $DeployResult = Set-CIPPSensitiveInfoType -TenantFilter $Tenant -Template $Template -APIName 'Standards' + if ($DeployResult -match '^(Created|Updated)') { + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Remediated SIT '$($Comparison.Name)' ($($Comparison.State)): $DeployResult" -sev Info + $Comparison = Compare-CIPPSensitiveInfoType -TenantFilter $Tenant -Template $Template + } else { + Write-LogMessage -API 'Standards' -tenant $Tenant -message $DeployResult -sev Error + $Comparison | Add-Member -NotePropertyName DeployError -NotePropertyValue "$DeployResult" -Force + } } + $Comparison } - $ExistingSitNames = try { - @(New-ExoRequest -tenantid $Tenant -cmdlet 'Get-DlpSensitiveInformationType' -Compliance | Select-Object -ExpandProperty Name) - } catch { @() } - - $MissingSits = @(foreach ($Template in @($Templates)) { - $TemplateName = $Template.Name ?? $Template.name - if ($ExistingSitNames -notcontains $TemplateName) { $TemplateName } - }) + # Non-compliant when the SIT is missing, drifted, or the template is invalid. Built-in and in-sync + # SITs are compliant. + $NonCompliant = @($Comparisons | Where-Object { $_.State -in @('Missing', 'Drift', 'Invalid') }) if ($Settings.alert -eq $true) { - if ($MissingSits.Count -eq 0) { - Write-LogMessage -API 'Standards' -tenant $Tenant -message 'All selected Sensitive Information Type templates are deployed.' -sev Info + if ($NonCompliant.Count -eq 0) { + Write-LogMessage -API 'Standards' -tenant $Tenant -message 'All selected Sensitive Information Type templates are deployed and in sync.' -sev Info } else { - $AlertMessage = "Sensitive Information Types not deployed in tenant: $($MissingSits -join ', ')" - Write-StandardsAlert -message $AlertMessage -object @{ MissingSensitiveInfoTypes = $MissingSits } -tenant $Tenant -standardName 'SensitiveInfoTypeTemplate' -standardId $Settings.standardId + $Summary = $NonCompliant | ForEach-Object { + if ($_.State -eq 'Drift') { + $Fields = @($_.Differences | ForEach-Object { "$($_.Scope)/$($_.Field)" }) -join ', ' + "$($_.Name): drift in $Fields" + } else { + "$($_.Name): $($_.State)" + } + } + $AlertMessage = "Sensitive Information Type templates not in sync: $($Summary -join '; ')" + Write-StandardsAlert -message $AlertMessage -object @{ NonCompliantSensitiveInfoTypes = $NonCompliant } -tenant $Tenant -standardName 'SensitiveInfoTypeTemplate' -standardId $Settings.standardId Write-LogMessage -API 'Standards' -tenant $Tenant -message $AlertMessage -sev Info } } if ($Settings.report -eq $true) { - $CurrentValue = @{ MissingSensitiveInfoTypes = $MissingSits } - $ExpectedValue = @{ MissingSensitiveInfoTypes = @() } + # Expose the actual drift (per SIT: state + the differing fields with expected vs current values). + $CurrentValue = @{ NonCompliantSensitiveInfoTypes = $NonCompliant } + $ExpectedValue = @{ NonCompliantSensitiveInfoTypes = @() } Set-CIPPStandardsCompareField -FieldName 'standards.SensitiveInfoTypeTemplate' -CurrentValue $CurrentValue -ExpectedValue $ExpectedValue -TenantFilter $Tenant - Add-CIPPBPAField -FieldName 'SensitiveInfoTypeTemplate' -FieldValue ($MissingSits.Count -eq 0) -StoreAs bool -Tenant $Tenant + Add-CIPPBPAField -FieldName 'SensitiveInfoTypeTemplate' -FieldValue ($NonCompliant.Count -eq 0) -StoreAs bool -Tenant $Tenant } } From d52ec9b1af79b0230b6df622c47f48840d11b3de Mon Sep 17 00:00:00 2001 From: MatStocks <20848373+matstocks@users.noreply.github.com> Date: Sun, 28 Jun 2026 00:12:27 +0100 Subject: [PATCH 037/170] Resolve tenant variables in Win32 Custom App detection script The detection script of a Win32 Custom Application was base64-encoded from the raw input, so %tenantid% and other Get-CIPPTextReplacement tokens were not resolved (they reached Intune as literal text), unlike the install and uninstall scripts. Run the detection script through Get-CIPPTextReplacement first, matching the install/uninstall handling. Fixes KelvinTegelaar/CIPP#6226 --- Modules/CIPPCore/Public/Add-CIPPW32ScriptApplication.ps1 | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Modules/CIPPCore/Public/Add-CIPPW32ScriptApplication.ps1 b/Modules/CIPPCore/Public/Add-CIPPW32ScriptApplication.ps1 index 77518b24d11d7..62b40dc56f3ba 100644 --- a/Modules/CIPPCore/Public/Add-CIPPW32ScriptApplication.ps1 +++ b/Modules/CIPPCore/Public/Add-CIPPW32ScriptApplication.ps1 @@ -74,7 +74,9 @@ function Add-CIPPW32ScriptApplication { # Build detection rules — detection script takes priority, then file detection, then marker file fallback if ($Properties.detectionScript) { # PowerShell script detection: script should write to STDOUT and exit 0 when detected - $DetectionScriptContent = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($Properties.detectionScript)) + # Resolve %tenantid%/%tenantfilter%/etc. for consistency with the install/uninstall scripts below + $ReplacedDetectionScript = Get-CIPPTextReplacement -Text $Properties.detectionScript -TenantFilter $TenantFilter + $DetectionScriptContent = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($ReplacedDetectionScript)) $DetectionRules = @( @{ '@odata.type' = '#microsoft.graph.win32LobAppPowerShellScriptDetection' From 38b18bc091cabd594c9ee467a3bb3020e4fd01b2 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Mon, 29 Jun 2026 14:31:23 +0800 Subject: [PATCH 038/170] Audit log rework for better resilience --- Config/CIPPTimers.json | 24 +-- .../AuditLogs/Get-CippAuditLogNextAttempt.ps1 | 27 ++++ .../Get-CippAuditLogPlannedWindows.ps1 | 95 ++++++++++++ .../Get-CippAuditLogReconciliationWindows.ps1 | 68 +++++++++ .../AuditLogs/New-CippAuditLogSearchV2.ps1 | 86 +++++++++++ .../Start-AuditLogIngestionV2.ps1 | 82 ++++++++++ .../Start-AuditLogPlannerV2.ps1 | 38 +++++ .../Start-AuditLogSearchCreationV2.ps1 | 123 +++++++++++++++ .../GraphHelper/New-GraphPOSTRequest.ps1 | 11 +- .../CIPPCore/Public/Invoke-CIPPRestMethod.ps1 | 2 +- .../Webhooks/Push-AuditLogDownloadV2.ps1 | 125 ++++++++++++++++ .../Webhooks/Push-AuditLogProcessV2.ps1 | 53 +++++++ .../Push-AuditLogProcessingBatchV2.ps1 | 66 ++++++++ .../Push-AuditLogSearchCreationV2.ps1 | 141 ++++++++++++++++++ .../Webhooks/Push-AuditLogTenantProcessV2.ps1 | 104 +++++++++++++ .../Alerts/Invoke-ListAuditLogCoverage.ps1 | 135 +++++++++++++++++ Shared/CIPPSharp/CIPPRestClient.cs | 82 ++++++++++ Shared/CIPPSharp/bin/CIPPSharp.dll | Bin 43008 -> 44544 bytes 18 files changed, 1236 insertions(+), 26 deletions(-) create mode 100644 Modules/CIPPCore/Public/AuditLogs/Get-CippAuditLogNextAttempt.ps1 create mode 100644 Modules/CIPPCore/Public/AuditLogs/Get-CippAuditLogPlannedWindows.ps1 create mode 100644 Modules/CIPPCore/Public/AuditLogs/Get-CippAuditLogReconciliationWindows.ps1 create mode 100644 Modules/CIPPCore/Public/AuditLogs/New-CippAuditLogSearchV2.ps1 create mode 100644 Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-AuditLogIngestionV2.ps1 create mode 100644 Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-AuditLogPlannerV2.ps1 create mode 100644 Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-AuditLogSearchCreationV2.ps1 create mode 100644 Modules/CIPPCore/Public/Webhooks/Push-AuditLogDownloadV2.ps1 create mode 100644 Modules/CIPPCore/Public/Webhooks/Push-AuditLogProcessV2.ps1 create mode 100644 Modules/CIPPCore/Public/Webhooks/Push-AuditLogProcessingBatchV2.ps1 create mode 100644 Modules/CIPPCore/Public/Webhooks/Push-AuditLogSearchCreationV2.ps1 create mode 100644 Modules/CIPPCore/Public/Webhooks/Push-AuditLogTenantProcessV2.ps1 create mode 100644 Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-ListAuditLogCoverage.ps1 diff --git a/Config/CIPPTimers.json b/Config/CIPPTimers.json index 63203c303ab83..ac1ce7ba5b1b6 100644 --- a/Config/CIPPTimers.json +++ b/Config/CIPPTimers.json @@ -19,34 +19,14 @@ }, { "Id": "44a40668-ed71-403c-8c26-b32e320086ad", - "Command": "Start-AuditLogOrchestrator", - "Description": "Orchestrator to download audit logs", + "Command": "Start-AuditLogPlannerV2", + "Description": "Audit log V2 planner: create searches, download and process", "Cron": "0 */15 * * * *", "Priority": 2, "RunOnProcessor": true, "PreferredProcessor": "auditlog", "IsSystem": true }, - { - "Id": "01cd512a-15c4-44a9-b8cb-1e5d879cfd2d", - "Command": "Start-AuditLogProcessingOrchestrator", - "Description": "Orchestrator to process audit logs", - "Cron": "0 */15 * * * *", - "Priority": 3, - "RunOnProcessor": true, - "PreferredProcessor": "auditlog", - "IsSystem": true - }, - { - "Id": "03475c86-4314-4d7b-90f2-5a0639e3899b", - "Command": "Start-AuditLogSearchCreation", - "Description": "Timer to create audit log searches", - "Cron": "0 */30 * * * *", - "Priority": 4, - "RunOnProcessor": true, - "PreferredProcessor": "auditlog", - "IsSystem": true - }, { "Id": "5ff6c500-e420-4a3b-8532-ace2e4da4f7d", "Command": "Start-ApplicationOrchestrator", diff --git a/Modules/CIPPCore/Public/AuditLogs/Get-CippAuditLogNextAttempt.ps1 b/Modules/CIPPCore/Public/AuditLogs/Get-CippAuditLogNextAttempt.ps1 new file mode 100644 index 0000000000000..d7c6c49840733 --- /dev/null +++ b/Modules/CIPPCore/Public/AuditLogs/Get-CippAuditLogNextAttempt.ps1 @@ -0,0 +1,27 @@ +function Get-CippAuditLogNextAttempt { + <# + .SYNOPSIS + Compute the next-attempt UTC time for a retried audit-log coverage row (exponential backoff). + .DESCRIPTION + Used by the V2 audit-log pipeline to schedule retries of failed search creations and + downloads in the AuditLogCoverage ledger. Exponential backoff with jitter, capped. Because + the timers run every 15 minutes, small delays effectively mean "retry next run"; larger ones + defer a persistently failing window before it is eventually dead-lettered by the caller. + .PARAMETER Attempts + The attempt count that has just been consumed (1 = first failure). + .OUTPUTS + [datetime] (UTC) when the row becomes eligible to retry. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + [int]$Attempts, + [int]$BaseMinutes = 5, + [int]$CapMinutes = 240 + ) + $exp = [Math]::Max(0, $Attempts - 1) + $delay = [Math]::Min($BaseMinutes * [Math]::Pow(2, $exp), $CapMinutes) + $jitter = Get-Random -Minimum 0.8 -Maximum 1.2 + return (Get-Date).ToUniversalTime().AddMinutes($delay * $jitter) +} diff --git a/Modules/CIPPCore/Public/AuditLogs/Get-CippAuditLogPlannedWindows.ps1 b/Modules/CIPPCore/Public/AuditLogs/Get-CippAuditLogPlannedWindows.ps1 new file mode 100644 index 0000000000000..a33fffb19acdb --- /dev/null +++ b/Modules/CIPPCore/Public/AuditLogs/Get-CippAuditLogPlannedWindows.ps1 @@ -0,0 +1,95 @@ +function Get-CippAuditLogPlannedWindows { + <# + .SYNOPSIS + Compute the 35-minute audit-log search windows a tenant is missing (gaps + the newest settled). + .DESCRIPTION + Pure helper for the V2 audit-log pipeline. Windows are 35 minutes long on a 30-minute stride, + so consecutive windows overlap by 5 minutes (covers boundary stragglers; alerting dedups by + record id). Window ENDS sit on the 30-minute grid minus the settle (i.e. :25 / :55), which is + exactly `floor_to_30min(now) - settle`. With the planner timer firing at :00/:15/:30/:45 and a + 5-minute settle, a fresh window becomes creatable exactly at a :00/:30 tick - no tick delay - + and the :15/:45 ticks naturally have no new window (they do retries + download/process). + + Backfill of older gaps is bounded by -HorizonHours and capped at -MaxPerRun per call (oldest + first). A brand-new tenant is seeded with only the newest settled window. + .PARAMETER ExistingRows + The tenant's current AuditLogCoverage rows. Reconciliation rows (RowKey 'RECON-*') are ignored + here; only regular 14-digit window keys are considered. + .PARAMETER Now + Reference time (UTC). Defaults to now. + .OUTPUTS + Array of [pscustomobject]@{ RowKey; WindowStart; WindowEnd } sorted oldest-first. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + [object[]]$ExistingRows, + [datetime]$Now = (Get-Date).ToUniversalTime(), + [int]$SettleMinutes = 5, + [int]$WindowMinutes = 35, + [int]$StrideMinutes = 30, + [int]$HorizonHours = 24, + [int]$MaxPerRun = 6 + ) + + $Now = $Now.ToUniversalTime() + + # Newest window end: floor to the 30-min grid, minus the settle (lands on :25 / :55). + $FloorMinute = $Now.Minute - ($Now.Minute % $StrideMinutes) + $Floor = [datetime]::new($Now.Year, $Now.Month, $Now.Day, $Now.Hour, $FloorMinute, 0, [System.DateTimeKind]::Utc) + $NewestEnd = $Floor.AddMinutes(-$SettleMinutes) + + $HorizonStart = $Now.AddHours(-$HorizonHours) + + # Existing regular window keys (ignore reconciliation rows). + $ExistingKeys = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + $ExistingStarts = [System.Collections.Generic.List[datetime]]::new() + foreach ($Row in $ExistingRows) { + if ($Row.RowKey -notmatch '^\d{14}$') { continue } + [void]$ExistingKeys.Add([string]$Row.RowKey) + if ($null -ne $Row.WindowStart) { + try { $ExistingStarts.Add(([datetimeoffset]$Row.WindowStart).UtcDateTime) } catch {} + } + } + + # Brand-new tenant: seed only the newest settled window. + if ($ExistingStarts.Count -eq 0) { + $Start = $NewestEnd.AddMinutes(-$WindowMinutes) + if ($Start -lt $HorizonStart) { return @() } + return , ([pscustomobject]@{ + RowKey = $Start.ToString('yyyyMMddHHmmss') + WindowStart = $Start + WindowEnd = $NewestEnd + }) + } + + # Established tenant: backfill missing windows from the lower bound up to NewestEnd (oldest first). + $EarliestExisting = ($ExistingStarts | Measure-Object -Minimum).Minimum + $LowerEnd = if ($EarliestExisting -gt $HorizonStart) { $EarliestExisting.AddMinutes($WindowMinutes) } else { $HorizonStart.AddMinutes($WindowMinutes) } + + $Owed = [System.Collections.Generic.List[object]]::new() + $End = $NewestEnd + while ($End -ge $LowerEnd) { + $Start = $End.AddMinutes(-$WindowMinutes) + $Key = $Start.ToString('yyyyMMddHHmmss') + if (-not $ExistingKeys.Contains($Key)) { + $Owed.Add([pscustomobject]@{ RowKey = $Key; WindowStart = $Start; WindowEnd = $End }) + } + $End = $End.AddMinutes(-$StrideMinutes) + } + + # $Owed is newest-first from the loop; reorder to oldest-first ([0]=oldest, [-1]=newest). + $Owed.Reverse() + if ($Owed.Count -le $MaxPerRun) { + return @($Owed) + } + + # Backlog exceeds the per-run cap: always include the NEWEST window so the live period can + # be created promptly (current-first, see Push-AuditLogSearchCreationV2), plus the oldest + # (MaxPerRun-1) so historical gaps still drain - oldest first - before they age out of the + # horizon. Without seeding the newest here it would never be Planned during a backlog. + $Newest = $Owed[$Owed.Count - 1] + $Backfill = @($Owed[0..($MaxPerRun - 2)]) + return @($Backfill + $Newest) +} diff --git a/Modules/CIPPCore/Public/AuditLogs/Get-CippAuditLogReconciliationWindows.ps1 b/Modules/CIPPCore/Public/AuditLogs/Get-CippAuditLogReconciliationWindows.ps1 new file mode 100644 index 0000000000000..6ffa7f1c4c026 --- /dev/null +++ b/Modules/CIPPCore/Public/AuditLogs/Get-CippAuditLogReconciliationWindows.ps1 @@ -0,0 +1,68 @@ +function Get-CippAuditLogReconciliationWindows { + <# + .SYNOPSIS + Compute the 12-hour reconciliation audit-log windows a tenant is missing. + .DESCRIPTION + The fast 35-minute path searches each period soon after it closes, so late-landing / backfilled + audit events (Microsoft can publish them hours later) can be missed. This helper produces wide + catch-all windows aligned to 00:00-12:00 and 12:00-00:00 UTC, each created 3 hours after the + block closes (a generous settle so backfilled data has landed). They flow through the normal + download/process path; alerting dedups by record id, so overlap with the fast path is harmless. + .PARAMETER ExistingRows + The tenant's current AuditLogCoverage rows. Only reconciliation rows (RowKey 'RECON-*') are + considered when finding gaps. + .PARAMETER Now + Reference time (UTC). Defaults to now. + .OUTPUTS + Array of [pscustomobject]@{ RowKey; WindowStart; WindowEnd } sorted oldest-first. RowKey is + 'RECON-'. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + [object[]]$ExistingRows, + [datetime]$Now = (Get-Date).ToUniversalTime(), + [int]$SettleHours = 3, + [int]$HorizonHours = 24, + [int]$MaxPerRun = 6 + ) + + $Now = $Now.ToUniversalTime() + + # Newest 12h block end (00:00 / 12:00 UTC) whose close is at least SettleHours in the past. + $T = $Now.AddHours(-$SettleHours) + $BoundaryHour = if ($T.Hour -lt 12) { 0 } else { 12 } + $NewestEnd = [datetime]::new($T.Year, $T.Month, $T.Day, $BoundaryHour, 0, 0, [System.DateTimeKind]::Utc) + $HorizonStart = $Now.AddHours(-$HorizonHours) + + $ExistingKeys = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($Row in $ExistingRows) { + if ($Row.RowKey -like 'RECON-*') { [void]$ExistingKeys.Add([string]$Row.RowKey) } + } + + # No reconciliation history: seed only the newest settled block (avoid a first-run backfill spike; + # the fast 35-min path already covers recent history). Established tenants backfill gaps below. + if ($ExistingKeys.Count -eq 0) { + $Start = $NewestEnd.AddHours(-12) + if ($Start -lt $HorizonStart) { return @() } + return , ([pscustomobject]@{ RowKey = 'RECON-' + $Start.ToString('yyyyMMddHHmmss'); WindowStart = $Start; WindowEnd = $NewestEnd }) + } + + $Owed = [System.Collections.Generic.List[object]]::new() + $End = $NewestEnd + while ($End -ge $HorizonStart) { + $Start = $End.AddHours(-12) + $Key = 'RECON-' + $Start.ToString('yyyyMMddHHmmss') + if (-not $ExistingKeys.Contains($Key)) { + $Owed.Add([pscustomobject]@{ RowKey = $Key; WindowStart = $Start; WindowEnd = $End }) + } + $End = $End.AddHours(-12) + } + + $Owed.Reverse() + if ($Owed.Count -gt $MaxPerRun) { + return @($Owed[0..($MaxPerRun - 1)]) + } + return @($Owed) +} diff --git a/Modules/CIPPCore/Public/AuditLogs/New-CippAuditLogSearchV2.ps1 b/Modules/CIPPCore/Public/AuditLogs/New-CippAuditLogSearchV2.ps1 new file mode 100644 index 0000000000000..babdc345f9328 --- /dev/null +++ b/Modules/CIPPCore/Public/AuditLogs/New-CippAuditLogSearchV2.ps1 @@ -0,0 +1,86 @@ +function New-CippAuditLogSearchV2 { + <# + .SYNOPSIS + Create a Microsoft Graph audit-log search for the V2 pipeline and return a classified result. + .DESCRIPTION + Thin wrapper over New-GraphPOSTRequest (which now honours 429 backoff). Unlike the V1 + New-CippAuditLogSearch, this writes to NO table - the AuditLogCoverage ledger is updated by + the caller. Failures are classified so the caller can decide whether to retry (transient) or + stop (auditing disabled). + .PARAMETER TenantFilter + Tenant default domain or customerId. + .PARAMETER StartTime + Window start (inclusive). + .PARAMETER EndTime + Window end (exclusive). + .PARAMETER RecordTypeFilters + Record types to capture. Defaults to the four the V1 pipeline used. + .OUTPUTS + [pscustomobject]@{ Id; Status; Outcome; Message } Outcome in 'Created','AuditingDisabled','Transient'. + .FUNCTIONALITY + Internal + #> + [CmdletBinding(SupportsShouldProcess = $true)] + param( + [Parameter(Mandatory = $true)][string]$TenantFilter, + [Parameter(Mandatory = $true)][datetime]$StartTime, + [Parameter(Mandatory = $true)][datetime]$EndTime, + [string[]]$RecordTypeFilters = @('exchangeAdmin', 'azureActiveDirectory', 'azureActiveDirectoryAccountLogon', 'azureActiveDirectoryStsLogon'), + [int]$MaxAttempts = 3, + [string]$DisplayName = ('CIPP Audit Search V2 - ' + (Get-Date).ToString('yyyy-MM-dd HH:mm:ss')) + ) + + $Body = @{ + displayName = $DisplayName + filterStartDateTime = $StartTime.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss') + filterEndDateTime = $EndTime.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss') + recordTypeFilters = @($RecordTypeFilters) + } | ConvertTo-Json -Compress + + if (-not $PSCmdlet.ShouldProcess($TenantFilter, 'Create audit log search')) { + return [pscustomobject]@{ Id = $null; Status = 'WhatIf'; Outcome = 'Transient'; Message = 'WhatIf'; Throttled = $false } + } + + for ($Attempt = 1; $Attempt -le $MaxAttempts; $Attempt++) { + try { + # maxRetries 1 = no retry inside the Graph helper; this function owns retry/backoff. + $Query = New-GraphPOSTRequest -uri 'https://graph.microsoft.com/beta/security/auditLog/queries' -body $Body -tenantid $TenantFilter -AsApp $true -maxRetries 1 + return [pscustomobject]@{ Id = $Query.id; Status = $Query.status; Outcome = 'Created'; Message = $null; Throttled = $false } + } catch { + $Raw = $_.Exception.Data['RawErrorBody'] + if (-not $Raw) { $Raw = $_.ErrorDetails.Message } + if (-not $Raw) { $Raw = $_.Exception.Message } + $Parsed = $null + if ($Raw) { try { $Parsed = ([string]$Raw) | ConvertFrom-Json -ErrorAction Stop } catch {} } + + # AuditingDisabledTenant can be top-level Status or nested as JSON inside error.message. + $AuditStatus = $Parsed.Status + if (-not $AuditStatus) { + $Inner = $Parsed.error.message ?? $Parsed.message + if ($Inner -is [string]) { try { $AuditStatus = ($Inner | ConvertFrom-Json -ErrorAction Stop).Status } catch {} } + } + if ($AuditStatus -eq 'AuditingDisabledTenant') { + return [pscustomobject]@{ Id = $null; Status = 'AuditingDisabledTenant'; Outcome = 'AuditingDisabled'; Message = 'Unified auditing is disabled for this tenant.'; Throttled = $false } + } + + $Code = $Parsed.error.code ?? $Parsed.code + $Msg = $Parsed.error.message ?? $Parsed.message ?? $_.Exception.Message + $StatusCode = $null + try { $StatusCode = [int]$_.Exception.Response.StatusCode } catch {} + + # 429 = the tenant's ~10 concurrent-search cap is full. Retrying in-process won't clear it, + # so return immediately and let the planner defer this + remaining windows to next cycle. + if (($Code -eq 'TooManyRequests') -or ($StatusCode -eq 429)) { + return [pscustomobject]@{ Id = $null; Status = ([string]($Code ?? 'TooManyRequests')); Outcome = 'Transient'; Message = [string]$Msg; Throttled = $true } + } + + # Other transient (UnknownError, 5xx, gateway, timeout): usually a momentary EXO-backend + # blip that clears on a quick re-submit. Retry in-process with >1s jitter before giving up. + if ($Attempt -lt $MaxAttempts) { + Start-Sleep -Seconds (Get-Random -Minimum 1.5 -Maximum 4.0) + continue + } + return [pscustomobject]@{ Id = $null; Status = ([string]($Code ?? 'Error')); Outcome = 'Transient'; Message = [string]$Msg; Throttled = $false } + } + } +} diff --git a/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-AuditLogIngestionV2.ps1 b/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-AuditLogIngestionV2.ps1 new file mode 100644 index 0000000000000..9b47577ec4c87 --- /dev/null +++ b/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-AuditLogIngestionV2.ps1 @@ -0,0 +1,82 @@ +function Start-AuditLogIngestionV2 { + <# + .SYNOPSIS + V2 audit-log ingestion timer. Drives both download and processing, decoupled so that pending + logs are processed even when there is nothing new to download. + .DESCRIPTION + Runs offset 15 minutes from the creation timer and fans out two kinds of work: + + 1. Download tenants - AuditLogCoverage rows in state 'Created' (a search was created and is + awaiting download) and due (not in backoff). Each gets a per-tenant orchestrator: + Batch = AuditLogDownloadV2 (download succeeded searches -> CacheWebhooks) + PostExecution = AuditLogProcessV2 (enqueue processing if any cache rows are pending) + + 2. Process-only tenants - tenants that have rows sitting in CacheWebhooks (downloaded but + not yet processed, e.g. left behind by a worker crash mid-processing) but no pending + download. These get a processing orchestrator fanned out DIRECTLY, skipping the no-op + download orchestration. + + This makes processing self-healing: a crashed/partial processing run is retried on the next + cycle off the cache contents, not gated behind a fresh download. + .FUNCTIONALITY + Entrypoint + #> + [CmdletBinding(SupportsShouldProcess = $true)] + param() + try { + $Ledger = Get-CippTable -TableName 'AuditLogCoverage' + $Now = (Get-Date).ToUniversalTime() + + # --- Download tenants: searches awaiting download (State = Created, due) --- + $Created = @(Get-CIPPAzDataTableEntity @Ledger -Filter "State eq 'Created'") + $DueCreated = $Created | Where-Object { -not $_.NextAttemptUtc -or ([datetimeoffset]$_.NextAttemptUtc).UtcDateTime -le $Now } + $DownloadTenants = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($Name in @($DueCreated | Group-Object PartitionKey | ForEach-Object { $_.Name })) { + if ($Name) { [void]$DownloadTenants.Add([string]$Name) } + } + + # --- Process-only tenants: rows pending in the webhook cache (downloaded, not yet processed) --- + $CacheTable = Get-CippTable -TableName 'CacheWebhooks' + $CacheRows = @(Get-CIPPAzDataTableEntity @CacheTable -Property @('PartitionKey', 'RowKey')) + $CacheTenants = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($Name in @($CacheRows | Group-Object PartitionKey | ForEach-Object { $_.Name })) { + if ($Name) { [void]$CacheTenants.Add([string]$Name) } + } + + if ($DownloadTenants.Count -eq 0 -and $CacheTenants.Count -eq 0) { + Write-Information 'AuditLogV2: nothing to download or process' + return + } + + # 1) Download tenants -> download + post-exec processing in one orchestration. + foreach ($TenantFilter in $DownloadTenants) { + if ($PSCmdlet.ShouldProcess($TenantFilter, 'Download + process audit logs')) { + Start-CIPPOrchestrator -InputObject ([PSCustomObject]@{ + OrchestratorName = "AuditLogIngestV2-$TenantFilter" + Batch = @([PSCustomObject]@{ FunctionName = 'AuditLogDownloadV2'; TenantFilter = $TenantFilter }) + PostExecution = @{ FunctionName = 'AuditLogProcessV2'; Parameters = @{ TenantFilter = $TenantFilter } } + SkipLog = $true + }) + } + } + + # 2) Process-only tenants (pending cache, no pending download) -> process directly. + $ProcessOnlyCount = 0 + foreach ($TenantFilter in $CacheTenants) { + if ($DownloadTenants.Contains($TenantFilter)) { continue } + $ProcessOnlyCount++ + if ($PSCmdlet.ShouldProcess($TenantFilter, 'Process pending audit logs')) { + Start-CIPPOrchestrator -InputObject ([PSCustomObject]@{ + OrchestratorName = "AuditLogProcessV2-$TenantFilter" + QueueFunction = [PSCustomObject]@{ FunctionName = 'AuditLogProcessingBatchV2'; Parameters = @{ TenantFilter = $TenantFilter } } + SkipLog = $true + }) + } + } + + Write-Information "AuditLogV2: ingestion fan-out - $($DownloadTenants.Count) download tenant(s), $ProcessOnlyCount process-only tenant(s)" + } catch { + Write-LogMessage -API 'AuditLogV2' -message 'Error in audit log ingestion orchestrator (V2)' -sev Error -LogData (Get-CippException -Exception $_) + Write-Information ('AuditLogV2 ingestion error {0} line {1} - {2}' -f $_.InvocationInfo.ScriptName, $_.InvocationInfo.ScriptLineNumber, $_.Exception.Message) + } +} diff --git a/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-AuditLogPlannerV2.ps1 b/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-AuditLogPlannerV2.ps1 new file mode 100644 index 0000000000000..4e8ad995b155b --- /dev/null +++ b/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-AuditLogPlannerV2.ps1 @@ -0,0 +1,38 @@ +function Start-AuditLogPlannerV2 { + <# + .SYNOPSIS + Single timer entrypoint for the V2 audit-log pipeline. Runs every 15 minutes and drives both + stages: plan + create searches, then download + process. + .DESCRIPTION + Replaces the separate Start-AuditLogSearchCreationV2 and Start-AuditLogIngestionV2 timers with + one planner so the whole pipeline ticks together: + + Stage 1 (create) - Start-AuditLogSearchCreationV2: seeds owed 35-min windows (5-min settle, + ends on the :25/:55 grid so a fresh window is creatable exactly at :00/:30 with no tick + delay) plus 12-hour reconciliation windows, then creates the oldest <= 6 due windows per + tenant with auto-retry disabled and manual 429 back-off. + + Stage 2 (ingest) - Start-AuditLogIngestionV2: downloads searches created in PRIOR ticks that + are now ready, processes them, and re-processes any tenant with leftover cache rows. + + The two stages operate on different windows (stage 1 queues new searches; stage 2 consumes + searches from earlier ticks once Graph has finished them), so running them in one tick simply + pipelines the work. + .FUNCTIONALITY + Entrypoint + #> + [CmdletBinding()] + param() + try { + Start-AuditLogSearchCreationV2 + } catch { + Write-LogMessage -API 'AuditLogV2' -message 'Planner: search creation stage failed' -sev Error -LogData (Get-CippException -Exception $_) + Write-Information ('AuditLogV2 planner (create) error: {0}' -f $_.Exception.Message) + } + try { + Start-AuditLogIngestionV2 + } catch { + Write-LogMessage -API 'AuditLogV2' -message 'Planner: ingestion stage failed' -sev Error -LogData (Get-CippException -Exception $_) + Write-Information ('AuditLogV2 planner (ingest) error: {0}' -f $_.Exception.Message) + } +} diff --git a/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-AuditLogSearchCreationV2.ps1 b/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-AuditLogSearchCreationV2.ps1 new file mode 100644 index 0000000000000..7e89abe335f1c --- /dev/null +++ b/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-AuditLogSearchCreationV2.ps1 @@ -0,0 +1,123 @@ +function Start-AuditLogSearchCreationV2 { + <# + .SYNOPSIS + V2 audit-log search creation timer. Plans non-overlapping 60-minute windows per tenant in the + AuditLogCoverage ledger and fans out creation only to tenants that owe a window or have a + retry due. + .DESCRIPTION + Replaces Start-AuditLogSearchCreation. Tenant selection is unchanged (WebhookRules Webhookv2, + minus excluded, minus auditing-disabled). The key differences: + * Windows are clock-aligned, 60 minutes, NON-overlapping (tracked in AuditLogCoverage). + * Failed creations are recorded as Planned/Retry ledger rows, so they are retried (and gaps + backfilled) instead of being silently dropped. + * "First check what tenants need searches created" - the timer scans the ledger once and + only fans out per-tenant activities for tenants that owe a window or have a due retry. + .FUNCTIONALITY + Entrypoint + #> + [CmdletBinding(SupportsShouldProcess = $true)] + param() + try { + # --- Tenant selection (same source as V1) --- + $ConfigTable = Get-CippTable -TableName 'WebhookRules' + $ConfigEntries = Get-CIPPAzDataTableEntity @ConfigTable -Filter "PartitionKey eq 'Webhookv2'" | ForEach-Object { + $ConfigEntry = $_ + if (!$ConfigEntry.excludedTenants) { + $ConfigEntry | Add-Member -MemberType NoteProperty -Name 'excludedTenants' -Value @() -Force + } else { + $ConfigEntry.excludedTenants = $ConfigEntry.excludedTenants | ConvertFrom-Json + } + $ConfigEntry.Tenants = $ConfigEntry.Tenants | ConvertFrom-Json + $ConfigEntry | Add-Member -MemberType NoteProperty -Name 'ExpandedTenants' -Value (Expand-CIPPTenantGroups -TenantFilter ($ConfigEntry.Tenants)).value -Force + $ConfigEntry + } + if (($ConfigEntries | Measure-Object).Count -eq 0) { + Write-Information 'AuditLogV2: no webhook rules defined; nothing to create' + return + } + + $TenantList = Get-Tenants -IncludeErrors + + # Auditing-disabled skip set (reuse existing table + expiry semantics) + $AuditDisabledTable = Get-CIPPTable -TableName 'AuditLogDisabledTenants' + $NowUnix = [int64]([datetimeoffset]::UtcNow.ToUnixTimeSeconds()) + $AuditDisabledTenants = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($DisabledRow in @(Get-CIPPAzDataTableEntity @AuditDisabledTable -Filter "PartitionKey eq 'AuditDisabledTenant'")) { + [int64]$ExpiresAtUnix = 0 + if ([int64]::TryParse([string]$DisabledRow.ExpiresAtUnix, [ref]$ExpiresAtUnix) -and $ExpiresAtUnix -gt $NowUnix) { + [void]$AuditDisabledTenants.Add([string]$DisabledRow.RowKey) + } + } + + $InScope = foreach ($Tenant in $TenantList) { + if ($AuditDisabledTenants.Contains($Tenant.defaultDomainName) -or $AuditDisabledTenants.Contains([string]$Tenant.customerId)) { continue } + $Match = $false + foreach ($ConfigEntry in $ConfigEntries) { + if ($ConfigEntry.excludedTenants.value -contains $Tenant.defaultDomainName) { continue } + if ($ConfigEntry.ExpandedTenants -contains $Tenant.defaultDomainName -or $ConfigEntry.ExpandedTenants -contains 'AllTenants') { $Match = $true; break } + } + if ($Match) { $Tenant } + } + $InScope = @($InScope) + if ($InScope.Count -eq 0) { + Write-Information 'AuditLogV2: no in-scope tenants' + return + } + + # --- Scan ledger once, group by tenant --- + $Ledger = Get-CippTable -TableName 'AuditLogCoverage' + # Cover the reconciliation horizon (48h) plus slack so the fan-out check sees existing recon rows. + $HorizonIso = (Get-Date).AddHours(-50).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') + $AllRows = Get-CIPPAzDataTableEntity @Ledger -Filter "Timestamp ge datetime'$HorizonIso'" + $ByTenant = @{} + foreach ($Row in $AllRows) { + if (-not $ByTenant.ContainsKey($Row.PartitionKey)) { $ByTenant[$Row.PartitionKey] = [System.Collections.Generic.List[object]]::new() } + $ByTenant[$Row.PartitionKey].Add($Row) + } + + $Now = (Get-Date).ToUniversalTime() + $Batch = foreach ($Tenant in $InScope) { + $Rows = if ($ByTenant.ContainsKey($Tenant.defaultDomainName)) { $ByTenant[$Tenant.defaultDomainName] } else { @() } + $Owed = Get-CippAuditLogPlannedWindows -ExistingRows $Rows -Now $Now + $OwedRecon = Get-CippAuditLogReconciliationWindows -ExistingRows $Rows -Now $Now + $DuePlanned = @($Rows | Where-Object { $_.State -eq 'Planned' -and (-not $_.NextAttemptUtc -or ([datetimeoffset]$_.NextAttemptUtc).UtcDateTime -le $Now) }) + if (($Owed.Count -gt 0) -or ($OwedRecon.Count -gt 0) -or ($DuePlanned.Count -gt 0)) { + [PSCustomObject]@{ + FunctionName = 'AuditLogSearchCreationV2' + TenantFilter = $Tenant.defaultDomainName + TenantId = [string]$Tenant.customerId + } + } + } + $Batch = @($Batch) + + if ($Batch.Count -gt 0) { + Write-Information "AuditLogV2: $($Batch.Count) tenant(s) need search creation" + if ($PSCmdlet.ShouldProcess('Start-AuditLogSearchCreationV2', 'Create audit log searches')) { + $InputObject = [PSCustomObject]@{ + OrchestratorName = 'AuditLogSearchCreationV2' + Batch = @($Batch) + SkipLog = $true + } + Start-CIPPOrchestrator -InputObject $InputObject + } + } else { + Write-Information 'AuditLogV2: no tenants need searches this run' + } + + # --- Best-effort retention: drop ledger rows older than 7 days (all states; active windows are < 26h old) --- + try { + $CutoffIso = (Get-Date).AddDays(-7).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') + $Stale = @(Get-CIPPAzDataTableEntity @Ledger -Filter "Timestamp le datetime'$CutoffIso'" -Property PartitionKey, RowKey) + if ($Stale.Count -gt 0) { + Remove-AzDataTableEntity @Ledger -Entity $Stale -Force + Write-Information "AuditLogV2: cleaned $($Stale.Count) stale ledger row(s)" + } + } catch { + Write-Information "AuditLogV2: ledger cleanup skipped - $($_.Exception.Message)" + } + } catch { + Write-LogMessage -API 'AuditLogV2' -message 'Error creating audit log searches (V2)' -sev Error -LogData (Get-CippException -Exception $_) + Write-Information ('AuditLogV2 create error {0} line {1} - {2}' -f $_.InvocationInfo.ScriptName, $_.InvocationInfo.ScriptLineNumber, $_.Exception.Message) + } +} diff --git a/Modules/CIPPCore/Public/GraphHelper/New-GraphPOSTRequest.ps1 b/Modules/CIPPCore/Public/GraphHelper/New-GraphPOSTRequest.ps1 index c69c34636766c..904972d98fe65 100644 --- a/Modules/CIPPCore/Public/GraphHelper/New-GraphPOSTRequest.ps1 +++ b/Modules/CIPPCore/Public/GraphHelper/New-GraphPOSTRequest.ps1 @@ -55,6 +55,7 @@ function New-GraphPOSTRequest { } catch { $ShouldRetry = $false $WaitTime = 0 + $RetryReason = '' $RawErrorBody = $_.ErrorDetails.Message $Message = if ($_.ErrorDetails.Message) { Get-NormalizedError -Message $_.ErrorDetails.Message @@ -67,20 +68,24 @@ function New-GraphPOSTRequest { $RetryAfterHeader = $_.Exception.Response.Headers['Retry-After'] if ($RetryAfterHeader) { $WaitTime = [int]$RetryAfterHeader - Write-Warning "Rate limited (429). Waiting $WaitTime seconds before retry. Attempt $($RetryCount + 1) of $maxRetries" - $ShouldRetry = $true + $RetryReason = 'Rate limited (429).' + } else { + $WaitTime = Get-Random -Minimum 1.1 -Maximum 4.1 + $RetryReason = 'Rate limited (429) with no Retry-After header.' } + $ShouldRetry = $true } # Check for "Resource temporarily unavailable" elseif ($Message -like '*Resource temporarily unavailable*' -or $Message -like '*Too many requests*') { $WaitTime = Get-Random -Minimum 1.1 -Maximum 3.1 - Write-Warning "Resource temporarily unavailable. Waiting $WaitTime seconds before retry. Attempt $($RetryCount + 1) of $maxRetries" + $RetryReason = 'Resource temporarily unavailable.' $ShouldRetry = $true } if ($ShouldRetry) { $RetryCount++ if ($RetryCount -lt $maxRetries) { + Write-Warning "$RetryReason Waiting $WaitTime seconds before retry. Attempt $($RetryCount + 1) of $maxRetries" Start-Sleep -Seconds $WaitTime } } else { diff --git a/Modules/CIPPCore/Public/Invoke-CIPPRestMethod.ps1 b/Modules/CIPPCore/Public/Invoke-CIPPRestMethod.ps1 index 88d7e2bc5ba5e..1c3d7345c4177 100644 --- a/Modules/CIPPCore/Public/Invoke-CIPPRestMethod.ps1 +++ b/Modules/CIPPCore/Public/Invoke-CIPPRestMethod.ps1 @@ -243,7 +243,7 @@ function Invoke-CIPPRestMethod { # ------------------------------------------------------------------ if (-not $SkipHttpErrorCheck -and -not $Result.IsSuccess) { $ErrorMessage = "Response status code does not indicate success: $($Result.StatusCode)" - $Exception = [System.Net.Http.HttpRequestException]::new($ErrorMessage) + $Exception = [CIPP.CIPPHttpRequestException]::new($ErrorMessage, [int]$Result.StatusCode, $Result.ResponseHeaders, $Result.Content) $ErrorRecord = [System.Management.Automation.ErrorRecord]::new($Exception, 'WebCmdletWebResponseException', [System.Management.Automation.ErrorCategory]::InvalidOperation, $Uri) if (-not [string]::IsNullOrWhiteSpace($Result.Content)) { $ErrorRecord.ErrorDetails = [System.Management.Automation.ErrorDetails]::new($Result.Content) diff --git a/Modules/CIPPCore/Public/Webhooks/Push-AuditLogDownloadV2.ps1 b/Modules/CIPPCore/Public/Webhooks/Push-AuditLogDownloadV2.ps1 new file mode 100644 index 0000000000000..8a729938833ae --- /dev/null +++ b/Modules/CIPPCore/Public/Webhooks/Push-AuditLogDownloadV2.ps1 @@ -0,0 +1,125 @@ +function Push-AuditLogDownloadV2 { + <# + .SYNOPSIS + Per-tenant audit-log download activity (V2). Polls created searches, downloads succeeded ones + to CacheWebhooks, and advances the AuditLogCoverage ledger. + .DESCRIPTION + For the tenant's ledger rows in state 'Created' (and due): + * bulk-poll Graph search status + * succeeded -> download records to CacheWebhooks, mark Downloaded (+ RecordCount) + * failed -> re-plan a fresh search (State = Planned, clear SearchId); dead-letter at cap + * running/notStarted -> leave Created; if stuck > 4h, re-plan + * download error -> increment Attempts + backoff; dead-letter at cap (NOT terminal) + Returns a summary the PostExecution (AuditLogProcessV2) uses to decide whether to process. + .FUNCTIONALITY + Entrypoint + #> + [CmdletBinding()] + param($Item) + + $TenantFilter = $Item.TenantFilter + $MaxAttempts = 6 + $StuckHours = 4 + $Downloaded = 0 + + try { + $Ledger = Get-CippTable -TableName 'AuditLogCoverage' + $Now = (Get-Date).ToUniversalTime() + $Rows = @(Get-CIPPAzDataTableEntity @Ledger -Filter "PartitionKey eq '$TenantFilter' and State eq 'Created'") + $Rows = $Rows | Where-Object { -not $_.NextAttemptUtc -or ([datetimeoffset]$_.NextAttemptUtc).UtcDateTime -le $Now } + $Rows = @($Rows) + if ($Rows.Count -eq 0) { + return @{ TenantFilter = $TenantFilter; Success = $true; Downloaded = 0 } + } + + # Bulk-poll Graph search status for this tenant's created searches + $Requests = foreach ($Row in $Rows) { + if ($Row.SearchId) { @{ id = [string]$Row.SearchId; url = "security/auditLog/queries/$($Row.SearchId)"; method = 'GET' } } + } + $Requests = @($Requests) + $StatusById = @{} + if ($Requests.Count -gt 0) { + $Responses = New-GraphBulkRequest -Requests $Requests -AsApp $true -TenantId $TenantFilter + foreach ($Response in $Responses) { + if ($Response.body -and $Response.body.id) { $StatusById[[string]$Response.body.id] = [string]$Response.body.status } + } + } + + $CacheTable = Get-CippTable -TableName 'CacheWebhooks' + + foreach ($Row in $Rows) { + $SearchId = [string]$Row.SearchId + $Status = $StatusById[$SearchId] + $CreatedAgeHours = if ($Row.CreatedUtc) { ($Now - ([datetimeoffset]$Row.CreatedUtc).UtcDateTime).TotalHours } else { 999 } + + if ($Status -eq 'succeeded') { + try { + $Results = @(Get-CippAuditLogSearchResults -TenantFilter $TenantFilter -QueryId $SearchId) + foreach ($SearchResult in $Results) { + Add-CIPPAzDataTableEntity @CacheTable -Entity @{ + RowKey = [string]$SearchResult.id + PartitionKey = [string]$TenantFilter + SearchId = $SearchId + JSON = [string]($SearchResult | ConvertTo-Json -Depth 10 -Compress) + CippProcessing = $false + CippProcessingStarted = '' + } -Force + } + $Downloaded += $Results.Count + # Empty windows have nothing to process - mark them Processed directly so they + # don't sit at Downloaded forever. Windows with records go to Downloaded and are + # advanced to Processed by Push-AuditLogTenantProcessV2 once their rows are drained. + $DownloadState = if ($Results.Count -eq 0) { 'Processed' } else { 'Downloaded' } + $LedgerUpdate = @{ + PartitionKey = $TenantFilter; RowKey = $Row.RowKey; State = $DownloadState + RecordCount = [int]$Results.Count; DownloadedUtc = $Now; Attempts = 0 + SearchStatus = 'succeeded'; LastPolledUtc = $Now + } + if ($DownloadState -eq 'Processed') { + $LedgerUpdate.ProcessedUtc = $Now + $LedgerUpdate.MatchedCount = 0 + } + Add-CIPPAzDataTableEntity @Ledger -Entity $LedgerUpdate -OperationType UpsertMerge + Write-Information "AuditLogV2: downloaded $($Results.Count) record(s) for $TenantFilter window $($Row.RowKey)" + } catch { + $Attempts = [int]$Row.Attempts + 1 + $RetryTotal = [int]$Row.RetryCount + 1 + if ($Attempts -ge $MaxAttempts) { + Add-CIPPAzDataTableEntity @Ledger -Entity @{ PartitionKey = $TenantFilter; RowKey = $Row.RowKey; State = 'DeadLetter'; Attempts = $Attempts; RetryCount = $RetryTotal; LastError = [string]$_.Exception.Message; LastErrorUtc = $Now } -OperationType UpsertMerge + } else { + Add-CIPPAzDataTableEntity @Ledger -Entity @{ PartitionKey = $TenantFilter; RowKey = $Row.RowKey; Attempts = $Attempts; RetryCount = $RetryTotal; NextAttemptUtc = (Get-CippAuditLogNextAttempt -Attempts $Attempts); LastError = [string]$_.Exception.Message; LastErrorUtc = $Now } -OperationType UpsertMerge + } + Write-Information "AuditLogV2: download error for $TenantFilter window $($Row.RowKey): $($_.Exception.Message)" + } + } elseif ($Status -eq 'failed') { + $Attempts = [int]$Row.Attempts + 1 + $RetryTotal = [int]$Row.RetryCount + 1 + if ($Attempts -ge $MaxAttempts) { + Add-CIPPAzDataTableEntity @Ledger -Entity @{ PartitionKey = $TenantFilter; RowKey = $Row.RowKey; State = 'DeadLetter'; Attempts = $Attempts; RetryCount = $RetryTotal; SearchStatus = 'failed'; LastPolledUtc = $Now; LastError = 'Graph search failed'; LastErrorUtc = $Now } -OperationType UpsertMerge + } else { + Add-CIPPAzDataTableEntity @Ledger -Entity @{ PartitionKey = $TenantFilter; RowKey = $Row.RowKey; State = 'Planned'; SearchId = ''; Attempts = $Attempts; RetryCount = $RetryTotal; SearchStatus = ''; LastPolledUtc = $Now; NextAttemptUtc = (Get-CippAuditLogNextAttempt -Attempts $Attempts); LastError = 'Graph search failed; re-planning'; LastErrorUtc = $Now } -OperationType UpsertMerge + } + } elseif ($Status -in @('running', 'notStarted')) { + if ($CreatedAgeHours -ge $StuckHours) { + Add-CIPPAzDataTableEntity @Ledger -Entity @{ PartitionKey = $TenantFilter; RowKey = $Row.RowKey; State = 'Planned'; SearchId = ''; SearchStatus = ''; LastPolledUtc = $Now; RetryCount = ([int]$Row.RetryCount + 1); LastError = 'Search stuck; re-planning'; LastErrorUtc = $Now } -OperationType UpsertMerge + } else { + # Not ready yet: leave Created, but persist the live Graph search status so a pending + # window shows WHY it is still in-flight (e.g. 'running') rather than looking stuck. + Add-CIPPAzDataTableEntity @Ledger -Entity @{ PartitionKey = $TenantFilter; RowKey = $Row.RowKey; SearchStatus = [string]$Status; LastPolledUtc = $Now } -OperationType UpsertMerge + } + } else { + # Unknown / search no longer present on Graph + if ($CreatedAgeHours -ge $StuckHours) { + Add-CIPPAzDataTableEntity @Ledger -Entity @{ PartitionKey = $TenantFilter; RowKey = $Row.RowKey; State = 'Planned'; SearchId = ''; SearchStatus = ''; LastPolledUtc = $Now; RetryCount = ([int]$Row.RetryCount + 1); LastError = 'Search not found; re-planning'; LastErrorUtc = $Now } -OperationType UpsertMerge + } else { + Add-CIPPAzDataTableEntity @Ledger -Entity @{ PartitionKey = $TenantFilter; RowKey = $Row.RowKey; SearchStatus = $(if ($Status) { [string]$Status } else { 'unknown' }); LastPolledUtc = $Now } -OperationType UpsertMerge + } + } + } + + return @{ TenantFilter = $TenantFilter; Success = $true; Downloaded = $Downloaded } + } catch { + Write-Information ('Push-AuditLogDownloadV2 error for {0}: {1}' -f $TenantFilter, $_.Exception.Message) + return @{ TenantFilter = $TenantFilter; Success = $false; Downloaded = $Downloaded; Error = $_.Exception.Message } + } +} diff --git a/Modules/CIPPCore/Public/Webhooks/Push-AuditLogProcessV2.ps1 b/Modules/CIPPCore/Public/Webhooks/Push-AuditLogProcessV2.ps1 new file mode 100644 index 0000000000000..7c52ccf09c766 --- /dev/null +++ b/Modules/CIPPCore/Public/Webhooks/Push-AuditLogProcessV2.ps1 @@ -0,0 +1,53 @@ +function Push-AuditLogProcessV2 { + <# + .SYNOPSIS + PostExecution step of the V2 ingestion orchestrator. If the per-tenant download succeeded, + enqueues a per-tenant processing orchestrator (post-exec style). + .DESCRIPTION + Receives the download orchestrator's aggregated results ($Item.Results) and the tenant filter + ($Item.Parameters.TenantFilter). When at least one record was downloaded, it starts a + per-tenant processing orchestrator whose QueueFunction (AuditLogProcessingBatchV2) pages that + tenant's CacheWebhooks rows into batches handled by the existing AuditLogTenantProcess engine. + If nothing was downloaded, processing is skipped. + .FUNCTIONALITY + Entrypoint + #> + [CmdletBinding()] + param($Item) + try { + $TenantFilter = $Item.Parameters.TenantFilter + if (-not $TenantFilter) { + $TenantFilter = (@($Item.Results) | Where-Object { $_.TenantFilter } | Select-Object -First 1).TenantFilter + } + if (-not $TenantFilter) { + Write-Information 'AuditLogProcessV2: no tenant filter resolved; skipping' + return @{ Success = $false } + } + + # Fire processing whenever the tenant has rows pending in the cache - records just downloaded + # this cycle OR rows left behind by an earlier crash. Not gated on the download count, so a + # crashed/partial processing run is retried on the next cycle. The batch builder is the + # authoritative gate (claims claimable rows; returns nothing if there's truly no work). + $CacheTable = Get-CippTable -TableName 'CacheWebhooks' + $Pending = @(Get-CIPPAzDataTableEntity @CacheTable -Filter "PartitionKey eq '$TenantFilter'" -Property @('PartitionKey', 'RowKey')) + if ($Pending.Count -eq 0) { + Write-Information "AuditLogProcessV2: no pending cache rows for $TenantFilter; nothing to process" + return @{ Success = $true; Processed = $false } + } + + Write-Information "AuditLogProcessV2: enqueueing processing for $TenantFilter ($($Pending.Count) pending cache row(s))" + $InputObject = [PSCustomObject]@{ + OrchestratorName = "AuditLogProcessV2-$TenantFilter" + QueueFunction = [PSCustomObject]@{ + FunctionName = 'AuditLogProcessingBatchV2' + Parameters = @{ TenantFilter = $TenantFilter } + } + SkipLog = $true + } + $InstanceId = Start-CIPPOrchestrator -InputObject $InputObject + return @{ Success = $true; Processed = $true; InstanceId = $InstanceId } + } catch { + Write-Information ('Push-AuditLogProcessV2 error: {0}' -f $_.Exception.Message) + return @{ Success = $false; Error = $_.Exception.Message } + } +} diff --git a/Modules/CIPPCore/Public/Webhooks/Push-AuditLogProcessingBatchV2.ps1 b/Modules/CIPPCore/Public/Webhooks/Push-AuditLogProcessingBatchV2.ps1 new file mode 100644 index 0000000000000..fad2047ad5c9d --- /dev/null +++ b/Modules/CIPPCore/Public/Webhooks/Push-AuditLogProcessingBatchV2.ps1 @@ -0,0 +1,66 @@ +function Push-AuditLogProcessingBatchV2 { + <# + .SYNOPSIS + QueueFunction for the per-tenant V2 processing orchestrator. Builds processing batches from a + single tenant's CacheWebhooks rows. + .DESCRIPTION + Tenant-scoped variant of Push-AuditLogProcessingBatch. Pages the CacheWebhooks rows for the + tenant supplied via the QueueFunction Parameters, claims unclaimed (or stale > 2h) rows by + stamping CippProcessing = true, and returns 500-row batch items routed to the + AuditLogTenantProcessV2 activity (which runs Test-CIPPAuditLogRules and advances the ledger). + Scoping to one tenant avoids cross-tenant scans and claim races when many tenants process + concurrently. The 2h stale window lets a crashed processing run be re-claimed and retried. + .FUNCTIONALITY + Entrypoint + #> + [CmdletBinding()] + param($Item) + + $TenantFilter = $Item.Parameters.TenantFilter ?? $Item.TenantFilter + if (-not $TenantFilter) { + Write-Information 'AuditLogProcessingBatchV2: no tenant filter; nothing to process' + return @() + } + + $WebhookCacheTable = Get-CippTable -TableName 'CacheWebhooks' + $AllBatchItems = [System.Collections.Generic.List[object]]::new() + $NowUtc = (Get-Date).ToUniversalTime() + $StaleThreshold = $NowUtc.AddHours(-2) + + $Rows = @(Get-CIPPAzDataTableEntity @WebhookCacheTable -Filter "PartitionKey eq '$TenantFilter'" -Property @('PartitionKey', 'RowKey', 'ETag', 'Timestamp', 'CippProcessing')) + $Claimable = @($Rows | Where-Object { + -not $_.CippProcessing -or ($_.Timestamp -and $_.Timestamp.UtcDateTime -lt $StaleThreshold) + }) + if ($Claimable.Count -eq 0) { + Write-Information "AuditLogProcessingBatchV2: no claimable rows for $TenantFilter" + return @() + } + + $RowIds = @($Claimable.RowKey) + foreach ($Row in $Claimable) { + Add-CIPPAzDataTableEntity @WebhookCacheTable -Entity ([PSCustomObject]@{ + PartitionKey = $TenantFilter + RowKey = $Row.RowKey + CippProcessing = $true + }) -OperationType UpsertMerge + } + + for ($i = 0; $i -lt $RowIds.Count; $i += 500) { + $BatchRowIds = $RowIds[$i..([Math]::Min($i + 499, $RowIds.Count - 1))] + $AllBatchItems.Add([PSCustomObject]@{ + TenantFilter = $TenantFilter + RowIds = $BatchRowIds + FunctionName = 'AuditLogTenantProcessV2' + }) + } + + if ($AllBatchItems.Count -gt 0) { + $ProcessQueue = New-CippQueueEntry -Name "Audit Logs Process V2 - $TenantFilter" -Reference 'AuditLogsProcessV2' -TotalTasks $RowIds.Count + foreach ($BatchItem in $AllBatchItems) { + $BatchItem | Add-Member -MemberType NoteProperty -Name QueueId -Value $ProcessQueue.RowKey -Force + } + Write-Information "AuditLogProcessingBatchV2: $($AllBatchItems.Count) batch item(s) across $($RowIds.Count) row(s) for $TenantFilter" + } + + return $AllBatchItems.ToArray() +} diff --git a/Modules/CIPPCore/Public/Webhooks/Push-AuditLogSearchCreationV2.ps1 b/Modules/CIPPCore/Public/Webhooks/Push-AuditLogSearchCreationV2.ps1 new file mode 100644 index 0000000000000..70085030edaaf --- /dev/null +++ b/Modules/CIPPCore/Public/Webhooks/Push-AuditLogSearchCreationV2.ps1 @@ -0,0 +1,141 @@ +function Push-AuditLogSearchCreationV2 { + <# + .SYNOPSIS + Per-tenant audit-log search creation activity (V2). Seeds owed regular (35-min) and 12-hour + reconciliation windows into the AuditLogCoverage ledger, then creates Graph searches for the + due ones - oldest first, capped per cycle, with manual throttle handling. + .DESCRIPTION + 1. Seed owed regular windows (Get-CippAuditLogPlannedWindows) and reconciliation windows + (Get-CippAuditLogReconciliationWindows) as Planned ledger rows. + 2. Take the oldest <= MaxPerCycle (6) due Planned windows (regular + reconciliation combined) + and create a Graph search for each, with auto-retry DISABLED (New-CippAuditLogSearchV2 -> + maxRetries 1). + 3. Throttling: the createSearch 429 is a per-tenant cap of ~10 concurrent searches, not a rate + limit, so on a 429 we stop and defer the current window AND all remaining queued windows to + the next cycle (no Attempts increment - a cap is not a failure, so it never dead-letters). + Other transient errors (UnknownError, 5xx, gateway) retry the individual window with backoff + and dead-letter at MaxAttempts. AuditingDisabled caches the tenant for 24h and stops. + + Capping at 6/cycle keeps us well under the ~10 concurrent-search ceiling (they complete within + the cycle and free slots). Oldest-first means gaps/backlog/reconciliation drain before the + freshest window; in steady state only the one new window is due, so latency is unaffected. + .FUNCTIONALITY + Entrypoint + #> + [CmdletBinding()] + param($Item) + + $TenantFilter = $Item.TenantFilter + $TenantId = $Item.TenantId + $MaxPerCycle = 6 + $MaxAttempts = 8 + + try { + $Ledger = Get-CippTable -TableName 'AuditLogCoverage' + $Rows = @(Get-CIPPAzDataTableEntity @Ledger -Filter "PartitionKey eq '$TenantFilter'") + $Now = (Get-Date).ToUniversalTime() + + # 1) Seed owed regular + reconciliation windows as Planned. + foreach ($Window in (Get-CippAuditLogPlannedWindows -ExistingRows $Rows -Now $Now)) { + $Entity = @{ + PartitionKey = [string]$TenantFilter; RowKey = [string]$Window.RowKey; TenantId = [string]$TenantId + WindowStart = [datetime]$Window.WindowStart; WindowEnd = [datetime]$Window.WindowEnd + State = 'Planned'; Type = 'Window'; Attempts = 0; RetryCount = 0; ThrottleCount = 0; SearchId = ''; LastError = '' + } + Add-CIPPAzDataTableEntity @Ledger -Entity $Entity -Force + $Rows += [pscustomobject]$Entity + } + foreach ($Window in (Get-CippAuditLogReconciliationWindows -ExistingRows $Rows -Now $Now)) { + $Entity = @{ + PartitionKey = [string]$TenantFilter; RowKey = [string]$Window.RowKey; TenantId = [string]$TenantId + WindowStart = [datetime]$Window.WindowStart; WindowEnd = [datetime]$Window.WindowEnd + State = 'Planned'; Type = 'Reconciliation'; Attempts = 0; RetryCount = 0; ThrottleCount = 0; SearchId = ''; LastError = '' + } + Add-CIPPAzDataTableEntity @Ledger -Entity $Entity -Force + $Rows += [pscustomobject]$Entity + } + + # 2) Build the create batch: the freshest regular window FIRST (so live events alert + # fast even during a backlog), then the oldest of whatever remains - regular + + # reconciliation - to drain gaps before they age out. Reconciliation windows are + # never "current"; they flow through the oldest-first backfill slots unchanged. + $Due = @($Rows | Where-Object { + $_.State -eq 'Planned' -and (-not $_.NextAttemptUtc -or ([datetimeoffset]$_.NextAttemptUtc).UtcDateTime -le $Now) + } | Sort-Object @{ Expression = { ([datetimeoffset]$_.WindowStart).UtcDateTime } }) + if ($Due.Count -eq 0) { + Write-Information "AuditLogV2: no due windows for $TenantFilter" + return $true + } + # Newest regular (14-digit RowKey) window = the live period; reconciliation (RECON-*) is never current. + $CurrentWindow = @($Due | Where-Object { [string]$_.RowKey -match '^\d{14}$' } | Select-Object -Last 1) + if ($CurrentWindow.Count -gt 0) { + $CurrentKey = [string]$CurrentWindow[0].RowKey + $Backfill = @($Due | Where-Object { [string]$_.RowKey -ne $CurrentKey } | Select-Object -First ($MaxPerCycle - 1)) + $Batch = @($CurrentWindow[0]) + $Backfill + } else { + # Only reconciliation windows are due: oldest-first, capped. + $Batch = @($Due | Select-Object -First $MaxPerCycle) + } + + # 3) Create searches (no auto-retry). On 429, defer current + remaining to next cycle. + $Bail = $false + foreach ($Row in $Batch) { + if ($Bail) { + # Deferred (not attempted): just set NextAttemptUtc, leave Attempts/State as Planned. + Add-CIPPAzDataTableEntity @Ledger -Entity @{ + PartitionKey = $TenantFilter; RowKey = $Row.RowKey; State = 'Planned' + NextAttemptUtc = (Get-CippAuditLogNextAttempt -Attempts 1) + ThrottleCount = ([int]$Row.ThrottleCount + 1); LastError = 'Deferred: tenant search cap (429)'; LastErrorUtc = $Now + } -OperationType UpsertMerge + continue + } + + $Start = ([datetimeoffset]$Row.WindowStart).UtcDateTime + $End = ([datetimeoffset]$Row.WindowEnd).UtcDateTime + $Result = New-CippAuditLogSearchV2 -TenantFilter $TenantFilter -StartTime $Start -EndTime $End + $Attempts = [int]$Row.Attempts + 1 + + if ($Result.Outcome -eq 'Created' -and $Result.Id) { + Add-CIPPAzDataTableEntity @Ledger -Entity @{ + PartitionKey = $TenantFilter; RowKey = $Row.RowKey; State = 'Created' + SearchId = [string]$Result.Id; Attempts = 0; CreatedUtc = $Now + SearchStatus = [string]$Result.Status; LastPolledUtc = $Now + } -OperationType UpsertMerge + Write-Information "AuditLogV2: created search for $TenantFilter window $($Row.RowKey)" + } elseif ($Result.Throttled) { + # 429 = tenant cap full. Defer this window (no Attempts bump - a cap isn't a failure) and bail. + $Bail = $true + Add-CIPPAzDataTableEntity @Ledger -Entity @{ + PartitionKey = $TenantFilter; RowKey = $Row.RowKey; State = 'Planned' + NextAttemptUtc = (Get-CippAuditLogNextAttempt -Attempts 1) + ThrottleCount = ([int]$Row.ThrottleCount + 1); LastError = 'Tenant search cap (429)'; LastErrorUtc = $Now + } -OperationType UpsertMerge + Write-Information "AuditLogV2: 429 for $TenantFilter - deferring this + remaining windows to next cycle" + } elseif ($Result.Outcome -eq 'AuditingDisabled') { + $Bail = $true + try { + $AuditDisabledTable = Get-CIPPTable -TableName 'AuditLogDisabledTenants' + Add-CIPPAzDataTableEntity @AuditDisabledTable -Entity @{ + PartitionKey = 'AuditDisabledTenant'; RowKey = [string]$TenantFilter; TenantFilter = [string]$TenantFilter + Status = 'AuditingDisabledTenant'; ExpiresAtUnix = [int64]([datetimeoffset]::UtcNow.AddHours(24).ToUnixTimeSeconds()) + } -Force + } catch {} + Add-CIPPAzDataTableEntity @Ledger -Entity @{ PartitionKey = $TenantFilter; RowKey = $Row.RowKey; State = 'Skipped'; LastError = 'AuditingDisabledTenant'; LastErrorUtc = $Now } -OperationType UpsertMerge + Write-Information "AuditLogV2: auditing disabled for $TenantFilter; skipping" + } else { + # Other transient: retry this window next cycle; dead-letter at cap. + $RetryTotal = [int]$Row.RetryCount + 1 + if ($Attempts -ge $MaxAttempts) { + Add-CIPPAzDataTableEntity @Ledger -Entity @{ PartitionKey = $TenantFilter; RowKey = $Row.RowKey; State = 'DeadLetter'; Attempts = $Attempts; RetryCount = $RetryTotal; LastError = [string]$Result.Message; LastErrorUtc = $Now } -OperationType UpsertMerge + } else { + Add-CIPPAzDataTableEntity @Ledger -Entity @{ PartitionKey = $TenantFilter; RowKey = $Row.RowKey; State = 'Planned'; Attempts = $Attempts; RetryCount = $RetryTotal; NextAttemptUtc = (Get-CippAuditLogNextAttempt -Attempts $Attempts); LastError = [string]$Result.Message; LastErrorUtc = $Now } -OperationType UpsertMerge + } + } + } + return $true + } catch { + Write-Information ('Push-AuditLogSearchCreationV2 error for {0}: {1}' -f $TenantFilter, $_.Exception.Message) + Write-Information $_.InvocationInfo.PositionMessage + return $false + } +} diff --git a/Modules/CIPPCore/Public/Webhooks/Push-AuditLogTenantProcessV2.ps1 b/Modules/CIPPCore/Public/Webhooks/Push-AuditLogTenantProcessV2.ps1 new file mode 100644 index 0000000000000..e04ec02ca7c5e --- /dev/null +++ b/Modules/CIPPCore/Public/Webhooks/Push-AuditLogTenantProcessV2.ps1 @@ -0,0 +1,104 @@ +function Push-AuditLogTenantProcessV2 { + <# + .SYNOPSIS + Per-batch audit-log processing activity (V2). Processes a batch of cached rows via the + existing Test-CIPPAuditLogRules engine, then advances the AuditLogCoverage ledger to + 'Processed' for any SearchId whose rows are now fully drained from the cache. + .DESCRIPTION + Same processing as the V1 Push-AuditLogTenantProcess (reads the RowIds from CacheWebhooks + and runs Test-CIPPAuditLogRules, which removes processed rows). Additionally: + * captures the distinct SearchIds represented by this batch's rows + * after processing, for each of those SearchIds with zero remaining CacheWebhooks rows, + marks the matching ledger window State = 'Processed' (ProcessedUtc + MatchedCount) + Because the mark is gated on "no rows left for this SearchId", a search split across + multiple 500-row batches is only marked Processed when its final batch completes. + .FUNCTIONALITY + Entrypoint + #> + [CmdletBinding()] + param($Item) + + $TenantFilter = $Item.TenantFilter + $RowIds = $Item.RowIds + + try { + $CacheWebhooksTable = Get-CippTable -TableName 'CacheWebhooks' + $SearchIds = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + + $Rows = foreach ($RowId in $RowIds) { + $CacheEntity = Get-CIPPAzDataTableEntity @CacheWebhooksTable -Filter "PartitionKey eq '$TenantFilter' and RowKey eq '$RowId'" + if ($CacheEntity) { + if ($CacheEntity.SearchId) { [void]$SearchIds.Add([string]$CacheEntity.SearchId) } + $CacheEntity.JSON | ConvertFrom-Json -ErrorAction SilentlyContinue + } + } + + if ($Rows.Count -eq 0) { + Write-Information "AuditLogV2: no rows found in cache for the provided row IDs ($TenantFilter)" + return $false + } + + Write-Information "AuditLogV2: processing $($Rows.Count) row(s) for $TenantFilter" + $Result = Test-CIPPAuditLogRules -TenantFilter $TenantFilter -Rows $Rows + $MatchedLogs = [int]($Result.MatchedLogs ?? 0) + + # Advance the ledger to Processed for any SearchId now fully drained from the cache. + if ($SearchIds.Count -gt 0) { + $Ledger = Get-CippTable -TableName 'AuditLogCoverage' + $SingleSearch = ($SearchIds.Count -eq 1) + $Now = (Get-Date).ToUniversalTime() + foreach ($SearchId in $SearchIds) { + $Remaining = @(Get-CIPPAzDataTableEntity @CacheWebhooksTable -Filter "PartitionKey eq '$TenantFilter' and SearchId eq '$SearchId'" -Property PartitionKey, RowKey) + if ($Remaining.Count -gt 0) { continue } + + $LedgerRows = @(Get-CIPPAzDataTableEntity @Ledger -Filter "PartitionKey eq '$TenantFilter' and SearchId eq '$SearchId'") + foreach ($LedgerRow in $LedgerRows) { + $Update = @{ + PartitionKey = $TenantFilter + RowKey = $LedgerRow.RowKey + State = 'Processed' + ProcessedUtc = $Now + } + # Only attribute matched count when this batch was a single search (unambiguous). + if ($SingleSearch) { $Update.MatchedCount = $MatchedLogs } + Add-CIPPAzDataTableEntity @Ledger -Entity $Update -OperationType UpsertMerge + Write-Information "AuditLogV2: marked window $($LedgerRow.RowKey) Processed for $TenantFilter (search $SearchId)" + } + } + } + + # Sweep orphaned Downloaded windows. Once this batch's rows are processed, re-scan every + # window left at 'Downloaded' for the tenant and cross-check it against the cache by SearchId. + # If no CacheWebhooks rows remain for that search, the records were already processed - often + # under an OVERLAPPING window's search, because CacheWebhooks is keyed by record id, so a 5-min + # window overlap (or a legacy 60-min window sharing record ids) overwrites the SearchId and the + # per-batch marking above never sees this window's id. Mark it Processed. Windows whose search + # still has cache rows are left as-is; they get picked up on the next process round. + try { + $SweepLedger = Get-CippTable -TableName 'AuditLogCoverage' + $SweepNow = (Get-Date).ToUniversalTime() + $DownloadedRows = @(Get-CIPPAzDataTableEntity @SweepLedger -Filter "PartitionKey eq '$TenantFilter' and State eq 'Downloaded'") + foreach ($DownRow in $DownloadedRows) { + $Sid = [string]$DownRow.SearchId + if (-not $Sid) { continue } + $Remaining = @(Get-CIPPAzDataTableEntity @CacheWebhooksTable -Filter "PartitionKey eq '$TenantFilter' and SearchId eq '$Sid'" -Property PartitionKey, RowKey) + if ($Remaining.Count -gt 0) { continue } + Add-CIPPAzDataTableEntity @SweepLedger -Entity @{ + PartitionKey = $TenantFilter + RowKey = $DownRow.RowKey + State = 'Processed' + ProcessedUtc = $SweepNow + MatchedCount = 0 + } -OperationType UpsertMerge + Write-Information "AuditLogV2: swept window $($DownRow.RowKey) to Processed for $TenantFilter (search $Sid drained, no cache rows)" + } + } catch { + Write-Information ('Push-AuditLogTenantProcessV2 sweep error for {0}: {1}' -f $TenantFilter, $_.Exception.Message) + } + + return $true + } catch { + Write-Information ('Push-AuditLogTenantProcessV2: Error {0} line {1} - {2}' -f $_.InvocationInfo.ScriptName, $_.InvocationInfo.ScriptLineNumber, $_.Exception.Message) + return $false + } +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-ListAuditLogCoverage.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-ListAuditLogCoverage.ps1 new file mode 100644 index 0000000000000..2944d6810ae6b --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-ListAuditLogCoverage.ps1 @@ -0,0 +1,135 @@ +function Invoke-ListAuditLogCoverage { + <# + .FUNCTIONALITY + Entrypoint,AnyTenant + .ROLE + Tenant.Alert.Read + .DESCRIPTION + Lists the V2 audit-log coverage ledger (AuditLogCoverage) - one row per tenant + 60-minute + search window with its state (Planned / Created / Downloaded / Retry / DeadLetter / Skipped), + record count, attempts and last error. Honours the tenant selector (a specific tenant or + AllTenants) and CIPP tenant access control. Accepts the same date filters as the Saved Logs + view: RelativeTime (e.g. 48h, 7d) or StartDate/EndDate. Defaults to the last 48 hours. + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $TenantFilter = $Request.Query.tenantFilter ?? $Request.Body.tenantFilter + + # --- Date range (mirrors Invoke-ListAuditLogs) --- + $RelativeTime = $Request.Query.RelativeTime ?? $Request.Body.RelativeTime + $StartDate = $Request.Query.StartDate ?? $Request.Body.StartDate + $EndDate = $Request.Query.EndDate ?? $Request.Body.EndDate + + $EndUtc = (Get-Date).ToUniversalTime() + $StartUtc = $EndUtc.AddHours(-48) + + if (-not $RelativeTime -and -not $StartDate -and -not $EndDate) { + $RelativeTime = '48h' + } + + if ($RelativeTime -and $RelativeTime -match '(\d+)([dhm])') { + $Interval = [int]$Matches[1] + switch ($Matches[2]) { + 'd' { $StartUtc = $EndUtc.AddDays(-$Interval) } + 'h' { $StartUtc = $EndUtc.AddHours(-$Interval) } + 'm' { $StartUtc = $EndUtc.AddMinutes(-$Interval) } + } + } elseif ($StartDate) { + if ([string]$StartDate -match '^\d+$') { + $StartUtc = [DateTimeOffset]::FromUnixTimeSeconds([int64]$StartDate).UtcDateTime + } else { + try { $StartUtc = ([datetimeoffset]$StartDate).UtcDateTime } catch {} + } + if ($EndDate) { + if ([string]$EndDate -match '^\d+$') { + $EndUtc = [DateTimeOffset]::FromUnixTimeSeconds([int64]$EndDate).UtcDateTime + } else { + try { $EndUtc = ([datetimeoffset]$EndDate).UtcDateTime } catch {} + } + } + } + + $AllowedTenants = Test-CIPPAccess -Request $Request -TenantList + + # When access is scoped, resolve the caller's allowed tenants to both domain + id for matching. + $AllowedDomains = $null + $AllowedIds = $null + if ($AllowedTenants -notcontains 'AllTenants') { + $TenantList = Get-Tenants -IncludeErrors | Where-Object { $_.customerId -in $AllowedTenants } + $AllowedDomains = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + $AllowedIds = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($Tenant in $TenantList) { + if ($Tenant.defaultDomainName) { [void]$AllowedDomains.Add([string]$Tenant.defaultDomainName) } + if ($Tenant.customerId) { [void]$AllowedIds.Add([string]$Tenant.customerId) } + } + } + + function ConvertTo-Utc { + param($Value) + if (-not $Value) { return $null } + try { return ([datetimeoffset]$Value).UtcDateTime } catch { return $null } + } + + $Table = Get-CIPPTable -TableName 'AuditLogCoverage' + $Rows = Get-CIPPAzDataTableEntity @Table + + $Results = foreach ($Row in $Rows) { + $WindowStart = ConvertTo-Utc $Row.WindowStart + # Date range filter on the window start + if ($WindowStart) { + if ($WindowStart -lt $StartUtc -or $WindowStart -gt $EndUtc) { continue } + } + + # Tenant selector filter (match on default domain or customer id) + if ($TenantFilter -and $TenantFilter -ne 'AllTenants') { + if (($Row.PartitionKey -ne $TenantFilter) -and ([string]$Row.TenantId -ne [string]$TenantFilter)) { continue } + } + + # Access control when not AllTenants + if ($AllowedTenants -notcontains 'AllTenants') { + if (-not ($AllowedDomains.Contains([string]$Row.PartitionKey) -or $AllowedIds.Contains([string]$Row.TenantId))) { continue } + } + + [PSCustomObject]@{ + Tenant = $Row.PartitionKey + TenantId = $Row.TenantId + Type = if ($Row.Type) { $Row.Type } elseif ($Row.RowKey -like 'RECON-*') { 'Reconciliation' } else { 'Window' } + WindowStart = $WindowStart + WindowEnd = ConvertTo-Utc $Row.WindowEnd + State = $Row.State + RecordCount = if ($null -ne $Row.RecordCount) { [int]$Row.RecordCount } else { $null } + Attempts = if ($null -ne $Row.Attempts) { [int]$Row.Attempts } else { 0 } + RetryCount = if ($null -ne $Row.RetryCount) { [int]$Row.RetryCount } else { 0 } + ThrottleCount = if ($null -ne $Row.ThrottleCount) { [int]$Row.ThrottleCount } else { 0 } + SearchId = $Row.SearchId + SearchStatus = $Row.SearchStatus + NextAttemptUtc = ConvertTo-Utc $Row.NextAttemptUtc + LastError = $Row.LastError + LastErrorUtc = ConvertTo-Utc $Row.LastErrorUtc + LastPolledUtc = ConvertTo-Utc $Row.LastPolledUtc + CreatedUtc = ConvertTo-Utc $Row.CreatedUtc + DownloadedUtc = ConvertTo-Utc $Row.DownloadedUtc + ProcessedUtc = ConvertTo-Utc $Row.ProcessedUtc + MatchedCount = if ($null -ne $Row.MatchedCount) { [int]$Row.MatchedCount } else { $null } + LastUpdated = ConvertTo-Utc $Row.Timestamp + } + } + + $Results = @($Results | Sort-Object -Property @{ Expression = 'Tenant' }, @{ Expression = 'WindowStart'; Descending = $true }) + + $Body = @{ + Results = @($Results) + Metadata = @{ + TenantFilter = $TenantFilter + StartDate = $StartUtc.ToString('o') + EndDate = $EndUtc.ToString('o') + Total = $Results.Count + } + } | ConvertTo-Json -Depth 10 -Compress + + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::OK + Body = $Body + }) +} diff --git a/Shared/CIPPSharp/CIPPRestClient.cs b/Shared/CIPPSharp/CIPPRestClient.cs index 08c5a60fde372..555e16aed89d3 100644 --- a/Shared/CIPPSharp/CIPPRestClient.cs +++ b/Shared/CIPPSharp/CIPPRestClient.cs @@ -36,6 +36,88 @@ public sealed class HttpResult public Dictionary ResponseHeaders { get; init; } = new(); } + // ===================================================================== + // CIPPResponseHeaders / CIPPHttpResponse / CIPPHttpRequestException + // ===================================================================== + // When a request returns a non-success status, the PowerShell wrapper + // (Invoke-CIPPRestMethod) throws a CIPPHttpRequestException. CIPP's Graph + // helpers were written against Invoke-RestMethod's HttpResponseException + // and read: + // $_.Exception.Response.StatusCode -eq 429 + // $_.Exception.Response.Headers['Retry-After'] + // The pooled client previously threw a bare HttpRequestException with no + // .Response, so those branches were dead. These types restore the expected + // shape: a .Response with a StatusCode (HttpStatusCode, so `-eq 429` works) + // and Headers that support case-insensitive string indexing returning a + // scalar value (so ['Retry-After'] works), matching the old behaviour. + // ===================================================================== + + /// + /// Case-insensitive response-header view exposed to PowerShell. The string + /// indexer returns the (comma-joined) value for a header, or null if absent, + /// mirroring how WebHeaderCollection / HttpResponseHeaders were consumed in + /// CIPP via $response.Headers['Header-Name']. + /// + public sealed class CIPPResponseHeaders + { + private readonly Dictionary _headers; + + public CIPPResponseHeaders(Dictionary? headers) + { + _headers = headers is not null + ? new Dictionary(headers, StringComparer.OrdinalIgnoreCase) + : new Dictionary(StringComparer.OrdinalIgnoreCase); + } + + /// $resp.Headers['Retry-After'] -> scalar string (joined), or null. + public string? this[string key] + => key is not null && _headers.TryGetValue(key, out var v) ? string.Join(", ", v) : null; + + public bool Contains(string key) => key is not null && _headers.ContainsKey(key); + public string[]? GetValues(string key) => key is not null && _headers.TryGetValue(key, out var v) ? v : null; + public IEnumerable Keys => _headers.Keys; + public int Count => _headers.Count; + } + + /// + /// Lightweight stand-in for the response object CIPP code reaches through + /// $_.Exception.Response. Carries the status code (as HttpStatusCode so + /// `-eq 429` works), the headers (string-indexable), and the raw body. + /// + public sealed class CIPPHttpResponse + { + public HttpStatusCode StatusCode { get; } + public int StatusCodeValue { get; } + public CIPPResponseHeaders Headers { get; } + public string Content { get; } + + public CIPPHttpResponse(int statusCode, Dictionary? headers, string? content) + { + StatusCode = (HttpStatusCode)statusCode; + StatusCodeValue = statusCode; + Headers = new CIPPResponseHeaders(headers); + Content = content ?? string.Empty; + } + } + + /// + /// HttpRequestException subclass carrying a .Response (CIPPHttpResponse). + /// Subclassing keeps existing `catch [System.Net.Http.HttpRequestException]` + /// and `$_.Exception.Message` / `$_.ErrorDetails.Message` handling intact, + /// while restoring `$_.Exception.Response.StatusCode` / + /// `$_.Exception.Response.Headers['Retry-After']`. + /// + public sealed class CIPPHttpRequestException : HttpRequestException + { + public CIPPHttpResponse Response { get; } + + public CIPPHttpRequestException(string message, int statusCode, Dictionary? headers, string? content) + : base(message, null, (HttpStatusCode)statusCode) + { + Response = new CIPPHttpResponse(statusCode, headers, content); + } + } + // ===================================================================== // CIPPRestClient // ===================================================================== diff --git a/Shared/CIPPSharp/bin/CIPPSharp.dll b/Shared/CIPPSharp/bin/CIPPSharp.dll index 242e6458a8033fa17149cdb26863d007254a2571..9f352615961ffbce098cba92da1f221d9bf52536 100644 GIT binary patch literal 44544 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 xRJrT@+?{lZO%+0R360y-793*j zp{s1_DvkW3bfZmuplAE{((N|&I~V!K=^mTvcawjD?zgFf?ri^M^lh7J@Q{BWeb1(D z0(Ch(Yg4a!vi(=mOE!g@UH`}FA8jfJzFb9b+SEoZ+kZ8^Yg0djFW1oTZ0fu4@)PJ zO??Wo&(Kqfl2LFA{ZuG53T~mdGGy-xbs63hTjsxoey3!<_d@O7Tj|d>^=Hi6TglVK zUSHw+vUi35Rzk`a>UNuH*I(sylHhxXenK9|zy4IiIQy^1p|jZO)(H~i74yPWwCmhzeGEr4!% zp@L7*x+?ix;RT#FW$M3xeWy;#AkE~DpsY@n;JfH~vEv2qp$gbH=;<;(4~>B1qw9cY zmSukwXEJpB)biQ>C#2pV!3T}56HYq*0X%on-%A@=igSwmKmgwQ0|6KAO#%To?o$E* z5AIL`0lqg01iZLU2?Tt&qxtXXPxic6;+%^!L>Ik><3Tr7gX5tXI2!#5KIn9rMDia9zhmxw9+4{5#c zkr*;(a^(MCab|njL!Wr~x8TKiaO#W}#x+X8|0#aDO@$xN@*ncJb_F@+nUUl+DvnOs z=w%l@B%HYdFUZfs4lsak7Cnn2Jx7Obp)Vdl**mqU{$reRJwU&PW}EiQz*hn9t$7fzGyjm* z2g?EK(;vn?XsiB@;g_`sQR*$NRez-HW$jVmf6|_$p9Qk?=LG+e;IGlSB@6VoXt-*H z{x1D8&<)5o-=)s{LH)O)xl6yV_HzACHvWF?L;5a#Z}@WkD*Zs1=-9G{}(5`RibL&1{#jvvAT6e2{j{kG+2lUsf?{Ux8>cih~KcF?&J_1-j z<&gU!{j}Prz+V9VZ0(l(R=|f#e(#>I{Y&)E8P@KrdDL@AudI5;b42X)YDa3{_EhW5wZ8)VM!|nT z=hd1IJ$vBi3cXia@qm7s?+Vyi7=9blH){;))#mvE23spO`lXG%8pm*0V86gtePiVu zqgT7Q@-$EP%59xFL|6&}{7EF29cuCJK(7Z3{f0*L+{!zO) z;P-x?YNzG{E}mKjcrx&>NR68QaLMW3Ed5`q+ri;lz9QBR=)WjE6PgPG3Gbt#`Knes z^=9v@T2pvGINYOOR9QbgRL2cUa3vThXX=8%(p7mW~>4S7gUqlbm7hFpL z=efUxQ(B(UQPj00>ra4}y8nb8IWNnN_-x6_(eJAq%Zh4s6(7&iTxS$q2OW-sTShWR z;HIo|wWmFIX6@1s7nZy75!)|km7(kky;Xk``Jq*RA-vktsvitLk`;7)v-VIH # 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 Date: Mon, 29 Jun 2026 16:10:15 +0800 Subject: [PATCH 039/170] External Storage Account Backup Replication --- Modules/CIPPCore/Public/New-CIPPBackup.ps1 | 4 + .../Public/Push-CIPPBackupReplication.ps1 | 83 +++++++++++++ .../Invoke-ExecBackupReplicationConfig.ps1 | 115 ++++++++++++++++++ 3 files changed, 202 insertions(+) create mode 100644 Modules/CIPPCore/Public/Push-CIPPBackupReplication.ps1 create mode 100644 Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecBackupReplicationConfig.ps1 diff --git a/Modules/CIPPCore/Public/New-CIPPBackup.ps1 b/Modules/CIPPCore/Public/New-CIPPBackup.ps1 index f1f155657c970..741037aa50a9e 100644 --- a/Modules/CIPPCore/Public/New-CIPPBackup.ps1 +++ b/Modules/CIPPCore/Public/New-CIPPBackup.ps1 @@ -161,6 +161,10 @@ function New-CIPPBackup { # If building full URL fails, fall back to resource path $blobUrl = $resourcePath } + + # Best-effort off-site replication to an external storage account. + $ReplType = if ($backupType -eq 'CIPP') { 'Core' } else { 'Tenant' } + Push-CIPPBackupReplication -BackupType $ReplType -BlobName $blobName -Content $BackupData -Headers $Headers } catch { $ErrorMessage = Get-CippException -Exception $_ Write-LogMessage -headers $Headers -API $APINAME -message "Blob upload failed: $($ErrorMessage.NormalizedError)" -Sev 'Error' -LogData $ErrorMessage diff --git a/Modules/CIPPCore/Public/Push-CIPPBackupReplication.ps1 b/Modules/CIPPCore/Public/Push-CIPPBackupReplication.ps1 new file mode 100644 index 0000000000000..20d40026c41bc --- /dev/null +++ b/Modules/CIPPCore/Public/Push-CIPPBackupReplication.ps1 @@ -0,0 +1,83 @@ +function Push-CIPPBackupReplication { + <# + .SYNOPSIS + Replicates a CIPP backup blob to an external storage account using a container SAS URL. + + .DESCRIPTION + After a backup blob is written to the CIPP-bound storage account, this helper uploads an + identical copy to an external Azure Storage container. The destination is described by a + container-level SAS URL (with write+create permission) stored in Key Vault, so the secret + never lands in table storage or the browser. + + There are two independent replication targets: + Core -> KV secret 'BackupReplicationCore' (Config RowKey 'Core') for CIPP backups + Tenant -> KV secret 'BackupReplicationTenant' (Config RowKey 'Tenant') for scheduled tenant backups + + Replication is best-effort: any failure is logged but never thrown, so a replication problem + can never abort the underlying backup. + + .PARAMETER BackupType + 'Core' for CIPP backups, 'Tenant' for scheduled tenant backups. + + .PARAMETER BlobName + The blob file name to write (e.g. 'CIPPBackup_2024-01-15-1430.json'). + + .PARAMETER Content + The backup payload (JSON string) to upload. + + .PARAMETER Headers + Request headers passed through to Write-LogMessage for attribution. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [ValidateSet('Core', 'Tenant')] + [string]$BackupType, + + [Parameter(Mandatory = $true)] + [string]$BlobName, + + [Parameter(Mandatory = $true)] + [string]$Content, + + $Headers + ) + + $SecretName = "BackupReplication$BackupType" + + try { + # Only replicate when explicitly enabled for this scope. + $Table = Get-CIPPTable -TableName 'Config' + $Config = Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq 'BackupReplication' and RowKey eq '$BackupType'" + if (-not $Config -or $Config.Enabled -ne $true) { + return + } + + if ($env:AzureWebJobsStorage -eq 'UseDevelopmentStorage=true' -or $env:NonLocalHostAzurite -eq 'true') { + $DevSecretsTable = Get-CIPPTable -tablename 'DevSecrets' + $SasUrl = (Get-CIPPAzDataTableEntity @DevSecretsTable -Filter "PartitionKey eq 'BackupReplication$BackupType' and RowKey eq 'BackupReplication$BackupType'").SASUrl + } + else { + $SasUrl = Get-CippKeyVaultSecret -Name $SecretName -AsPlainText + } + + if ([string]::IsNullOrWhiteSpace($SasUrl)) { + Write-LogMessage -headers $Headers -API 'BackupReplication' -message "$BackupType backup replication is enabled but no SAS URL is stored" -Sev 'Warning' + return + } + + # Insert the blob name into the container SAS URL, before the query string. + $UrlParts = $SasUrl -split '\?', 2 + $BaseUrl = $UrlParts[0].TrimEnd('/') + $Target = "$BaseUrl/$BlobName" + if ($UrlParts.Count -gt 1 -and -not [string]::IsNullOrWhiteSpace($UrlParts[1])) { + $Target = "$Target`?$($UrlParts[1])" + } + + $null = Invoke-CIPPRestMethod -Uri $Target -Method 'PUT' -Body $Content -ContentType 'application/json; charset=utf-8' -Headers @{ 'x-ms-blob-type' = 'BlockBlob' } + Write-LogMessage -headers $Headers -API 'BackupReplication' -message "Replicated $BackupType backup '$BlobName' to external storage" -Sev 'Debug' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -headers $Headers -API 'BackupReplication' -message "Failed to replicate $BackupType backup '$BlobName' to external storage: $($ErrorMessage.NormalizedError)" -Sev 'Error' -LogData $ErrorMessage + } +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecBackupReplicationConfig.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecBackupReplicationConfig.ps1 new file mode 100644 index 0000000000000..1d71ffc2be0cd --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecBackupReplicationConfig.ps1 @@ -0,0 +1,115 @@ +function Invoke-ExecBackupReplicationConfig { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + CIPP.AppSettings.ReadWrite + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $Table = Get-CIPPTable -TableName Config + $Scopes = @('Core', 'Tenant') + + # Returns whether a SAS URL secret currently exists for the given scope, without ever exposing it. + function Get-ReplicationSecretIsSet { + param([string]$Scope) + try { + if ($env:AzureWebJobsStorage -eq 'UseDevelopmentStorage=true' -or $env:NonLocalHostAzurite -eq 'true') { + $DevSecretsTable = Get-CIPPTable -tablename 'DevSecrets' + $Secret = (Get-CIPPAzDataTableEntity @DevSecretsTable -Filter "PartitionKey eq 'BackupReplication$Scope' and RowKey eq 'BackupReplication$Scope'").SASUrl + if ([string]::IsNullOrWhiteSpace($Secret)) { + return $null + } + return "SentToKeyVault" + } + else { + $Secret = Get-CippKeyVaultSecret -Name "BackupReplication$Scope" -AsPlainText + if ([string]::IsNullOrWhiteSpace($Secret)) { + return $null + } + return "SentToKeyVault" + } + } catch { + return $null + } + } + + $results = try { + if ($Request.Query.List) { + $Output = @{} + foreach ($Scope in $Scopes) { + $Config = Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq 'BackupReplication' and RowKey eq '$Scope'" + $Output[$Scope] = @{ + Enabled = [bool]($Config.Enabled) + IsSet = Get-ReplicationSecretIsSet -Scope $Scope + } + } + [pscustomobject]$Output + } else { + $BackupType = $Request.Body.BackupType + if ($BackupType -notin $Scopes) { + throw "BackupType must be one of: $($Scopes -join ', ')" + } + + $SASUrl = $Request.Body.SASUrl + $Enabled = if ($null -ne $Request.Body.Enabled) { [bool]$Request.Body.Enabled } else { $true } + + # Only update the stored secret when a real new value is supplied (the UI sends the + # 'SentToKeyVault' sentinel when the existing, masked secret is left untouched). + if (-not [string]::IsNullOrWhiteSpace($SASUrl) -and $SASUrl -ne 'SentToKeyVault') { + $ParsedUri = $SASUrl -as [uri] + if (-not $ParsedUri -or $ParsedUri.Query -notmatch 'sig=') { + throw 'SAS URL must contain a SAS token (sig=...)' + } + + # Confirm the SAS actually grants write+create by writing and removing a tiny probe blob. + $guid = [guid]::NewGuid().ToString() + $UrlParts = $SASUrl -split '\?', 2 + $BaseUrl = $UrlParts[0].TrimEnd('/') + $ProbeUrl = "$BaseUrl/.cipp-replication-test-$guid`?$($UrlParts[1])" + try { + $null = Invoke-CIPPRestMethod -Uri $ProbeUrl -Method 'PUT' -Body "cipp-replication-test-$guid" -ContentType 'text/plain' -Headers @{ 'x-ms-blob-type' = 'BlockBlob' } + try { $null = Invoke-CIPPRestMethod -Uri $ProbeUrl -Method 'DELETE' -Headers @{} } catch { } + } catch { + $ProbeError = Get-CippException -Exception $_ + throw "SAS URL validation failed (could not write to the container): $($ProbeError.NormalizedError)" + } + + if ($env:AzureWebJobsStorage -eq 'UseDevelopmentStorage=true' -or $env:NonLocalHostAzurite -eq 'true') { + $DevSecretsTable = Get-CIPPTable -tablename 'DevSecrets' + $Secret = [PSCustomObject]@{ + 'PartitionKey' = "BackupReplication$BackupType" + 'RowKey' = "BackupReplication$BackupType" + 'SASUrl' = $SASUrl + } + Add-CIPPAzDataTableEntity @DevSecretsTable -Entity $Secret -Force | Out-Null + } + else { + Set-CippKeyVaultSecret -Name "BackupReplication$BackupType" -SecretValue (ConvertTo-SecureString -String $SASUrl -AsPlainText -Force) | Out-Null + } + } + + $Config = @{ + 'PartitionKey' = 'BackupReplication' + 'RowKey' = $BackupType + 'Enabled' = $Enabled + } + Add-CIPPAzDataTableEntity @Table -Entity $Config -Force | Out-Null + + Write-LogMessage -headers $Request.Headers -API $Request.Params.CIPPEndpoint -message "Updated $BackupType backup replication settings (Enabled: $Enabled)" -Sev 'Info' + "Successfully updated $BackupType backup replication settings" + } + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -headers $Request.Headers -API $Request.Params.CIPPEndpoint -message "Failed to update backup replication configuration: $($ErrorMessage.NormalizedError)" -Sev 'Error' -LogData $ErrorMessage + "Failed to update configuration: $($ErrorMessage.NormalizedError)" + } + + $body = [pscustomobject]@{'Results' = $Results } + + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::OK + Body = $body + }) +} From 1b8c0c1d8b48c0c05490c049a65f1ff41ff36a51 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Mon, 29 Jun 2026 20:22:19 +0800 Subject: [PATCH 040/170] Fixes for CA template editing dropping the package tag --- .../HTTP Functions/CIPP/Core/Invoke-ExecEditTemplate.ps1 | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecEditTemplate.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecEditTemplate.ps1 index bd5cd4d8e0112..7beffb2959103 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecEditTemplate.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecEditTemplate.ps1 @@ -12,7 +12,7 @@ function Invoke-ExecEditTemplate { try { $Table = Get-CippTable -tablename 'templates' $guid = $request.Body.id ? $request.Body.id : $request.Body.GUID - $JSON = ConvertTo-Json -Compress -Depth 100 -InputObject ($request.Body | Select-Object * -ExcludeProperty GUID) + $JSON = ConvertTo-Json -Compress -Depth 100 -InputObject ($request.Body | Select-Object * -ExcludeProperty GUID, source, isSynced, package) $Type = $request.Query.Type ?? $Request.Body.Type if ($Type -eq 'IntuneTemplate') { @@ -63,13 +63,14 @@ function Invoke-ExecEditTemplate { } Set-CIPPIntuneTemplate @IntuneTemplate } else { - $Table.Force = $true - Add-CIPPAzDataTableEntity @Table -Entity @{ + $Entity = @{ JSON = "$JSON" RowKey = "$GUID" PartitionKey = "$Type" GUID = "$GUID" + SHA = '' } + Add-CIPPAzDataTableEntity @Table -Entity $Entity -OperationType 'UpsertMerge' Write-LogMessage -headers $Request.Headers -API $APINAME -message "Edited template $($Request.Body.name) with GUID $GUID" -Sev 'Debug' } $body = [pscustomobject]@{ 'Results' = 'Successfully saved the template' } From b995698f00f01df682a25dd73d52af0d73e3d8e9 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Mon, 29 Jun 2026 20:22:53 +0800 Subject: [PATCH 041/170] Fix CA template list and table side filter when ID/GUID provided --- .../Conditional/Invoke-ListCAtemplates.ps1 | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Conditional/Invoke-ListCAtemplates.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Conditional/Invoke-ListCAtemplates.ps1 index 769a3e7c43b8a..7c08fb3741ed4 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Conditional/Invoke-ListCAtemplates.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Conditional/Invoke-ListCAtemplates.ps1 @@ -9,7 +9,7 @@ function Invoke-ListCAtemplates { #> [CmdletBinding()] param($Request, $TriggerMetadata) - Write-Host $Request.query.id + $GUID = $Request.query.id ?? $Request.query.ID ?? $Request.query.guid ?? $Request.query.GUID #Migrating old policies whenever you do a list $Table = Get-CippTable -tablename 'templates' $Imported = Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq 'settings'" @@ -31,10 +31,10 @@ function Invoke-ListCAtemplates { } #List new policies $Table = Get-CippTable -tablename 'templates' - $Filter = "PartitionKey eq 'CATemplate'" - $RawTemplates = (Get-CIPPAzDataTableEntity @Table -Filter $Filter) if ($Request.query.mode -eq 'Tag') { + $Filter = "PartitionKey eq 'CATemplate'" + $RawTemplates = (Get-CIPPAzDataTableEntity @Table -Filter $Filter) #when the mode is tag, show all the potential tags, return the object with: label: tag, value: tag, count: number of templates with that tag, unique only $Templates = @($RawTemplates | Where-Object { $_.Package } | Group-Object -Property Package | ForEach-Object { $package = $_.Name @@ -59,6 +59,14 @@ function Invoke-ListCAtemplates { } } | Sort-Object -Property label) } else { + if ($GUID) { + $SafeGUID = ConvertTo-CIPPODataFilterValue -Value $GUID -Type Guid + $Filter = "PartitionKey eq 'CATemplate' and GUID eq '$SafeGUID'" + $RawTemplates = (Get-CIPPAzDataTableEntity @Table -Filter $Filter) + } + else { + $RawTemplates = (Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq 'CATemplate'") + } $Templates = $RawTemplates | ForEach-Object { try { $row = $_ @@ -74,8 +82,6 @@ function Invoke-ListCAtemplates { } | Sort-Object -Property displayName } - if ($Request.query.ID) { $Templates = $Templates | Where-Object -Property GUID -EQ $Request.query.id } - $Templates = ConvertTo-Json -InputObject @($Templates) -Depth 100 return ([HttpResponseContext]@{ StatusCode = [HttpStatusCode]::OK From 20eb3d65cbc081e574eb580af74504d220a62945 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Mon, 29 Jun 2026 20:27:52 +0800 Subject: [PATCH 042/170] Remove standards rows from table when the template is deleted --- .../Standards/Invoke-RemoveStandardTemplate.ps1 | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-RemoveStandardTemplate.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-RemoveStandardTemplate.ps1 index 94444123db7f2..8302317f89a53 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-RemoveStandardTemplate.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-RemoveStandardTemplate.ps1 @@ -35,11 +35,26 @@ function Invoke-RemoveStandardTemplate { Remove-AzDataTableEntity -Force @ScheduledTasksTable -Entity $DriftTask Write-LogMessage -Headers $Headers -API $APIName -message "Removed drift remediation scheduled task: $($DriftTask.Name)" -Sev Info } + $StandardsReportsTable = Get-CIPPTable -TableName 'CippStandardsReports' + $RemovedTemplateIds = @(@($Entities.RowKey) + $ID | Where-Object { $_ } | Select-Object -Unique) + $OrphanedReports = [System.Collections.Generic.List[object]]::new() + foreach ($RemovedTemplateId in $RemovedTemplateIds) { + $SafeTemplateId = ConvertTo-CIPPODataFilterValue -Value $RemovedTemplateId -Type Guid + $Rows = Get-CIPPAzDataTableEntity @StandardsReportsTable -Filter "TemplateId eq '$SafeTemplateId'" + foreach ($Row in $Rows) { $OrphanedReports.Add($Row) } + } + if ($OrphanedReports.Count -gt 0) { + Remove-AzDataTableEntity -Force @StandardsReportsTable -Entity @($OrphanedReports) + Write-LogMessage -Headers $Headers -API $APIName -message "Removed $($OrphanedReports.Count) orphaned standards comparison row(s) for template id: $($ID)" -Sev Info + } $Result = "Removed Standards Template named: '$($TemplateName)' with id: $($ID)" if ($DriftTasks) { $Result += ". Also removed $(@($DriftTasks).Count) associated drift remediation scheduled task(s)." } + if ($OrphanedReports.Count -gt 0) { + $Result += " Cleaned up $($OrphanedReports.Count) orphaned standards comparison row(s)." + } Write-LogMessage -Headers $Headers -API $APIName -message $Result -Sev Info $StatusCode = [HttpStatusCode]::OK } catch { From bbd52dca39883db7e2751a25d665d67186e4f446 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Tue, 30 Jun 2026 00:34:01 +0800 Subject: [PATCH 043/170] Permission update fixes --- .../GraphHelper/Get-CippSamPermissions.ps1 | 76 +++++++--- .../SAMManifest/Update-CippSamPermissions.ps1 | 131 ++++++++---------- .../Settings/Invoke-ExecPermissionRepair.ps1 | 23 ++- 3 files changed, 132 insertions(+), 98 deletions(-) diff --git a/Modules/CIPPCore/Public/GraphHelper/Get-CippSamPermissions.ps1 b/Modules/CIPPCore/Public/GraphHelper/Get-CippSamPermissions.ps1 index 39b3e22721501..6408240f54042 100644 --- a/Modules/CIPPCore/Public/GraphHelper/Get-CippSamPermissions.ps1 +++ b/Modules/CIPPCore/Public/GraphHelper/Get-CippSamPermissions.ps1 @@ -12,9 +12,11 @@ function Get-CippSamPermissions { The effective set returned in .Permissions is therefore always manifest ∪ extras. Each permission is annotated with a 'required' boolean so the UI can lock the manifest-defined defaults. - Unless -NoDiff is used, the function also pulls the live CIPP-SAM application registration from the - partner tenant and diffs its requiredResourceAccess against the effective set, surfacing - permissions that need to be added to (MissingPermissions) and removed from (PartnerAppDiff) the app. + Unless -NoDiff is used, the function also reads what is actually granted on the CIPP-SAM enterprise + application (service principal) in the partner tenant - appRoleAssignments (application/Role) and + oauth2PermissionGrants (delegated/Scope) - and diffs those grants against the effective set, + surfacing permissions that need to be granted (MissingPermissions) and grants that are present but + not in the effective set (PartnerAppDiff). The app registration's requiredResourceAccess is not used. .EXAMPLE Get-CippSamPermissions @@ -195,38 +197,68 @@ function Get-CippSamPermissions { } } - # Diff the effective set against the live CIPP-SAM application registration in the partner tenant. - # MissingPermissions = effective perms not yet on the app (need to be added). - # PartnerAppDiff also surfaces extra perms on the app that are not in the effective set (need to be removed). + # Diff the effective set against what is actually GRANTED on the partner CIPP-SAM enterprise + # application (service principal): appRoleAssignments for application (Role) permissions and + # oauth2PermissionGrants for delegated (Scope) permissions. The app registration's + # requiredResourceAccess is intentionally NOT used - permissions are applied as SP grants, so the + # grants are the real source of truth for what the app can do. + # MissingPermissions = effective perms not yet granted on the SP (need to be added). + # PartnerAppDiff also surfaces extra grants on the SP that are not in the effective set. $MissingPermissions = @{} $PartnerAppDiff = @{} if (!$NoDiff.IsPresent) { try { - $PartnerApp = New-GraphGETRequest -uri "https://graph.microsoft.com/beta/applications(appId='$($env:ApplicationID)')?`$select=requiredResourceAccess" -tenantid $env:TenantID -NoAuthCheck $true + $PartnerSP = New-GraphGETRequest -uri "https://graph.microsoft.com/beta/servicePrincipals(appId='$($env:ApplicationID)')?`$select=id" -tenantid $env:TenantID -NoAuthCheck $true + $AppRoleAssignments = New-GraphGETRequest -uri "https://graph.microsoft.com/beta/servicePrincipals/$($PartnerSP.id)/appRoleAssignments?`$top=999" -tenantid $env:TenantID -NoAuthCheck $true + $OAuthGrants = New-GraphGETRequest -uri "https://graph.microsoft.com/beta/servicePrincipals/$($PartnerSP.id)/oauth2PermissionGrants?`$top=999" -tenantid $env:TenantID -NoAuthCheck $true + + # Grants reference the resource SP's object id; map it back to the resource appId the + # effective set is keyed on. Use $UsedServicePrincipals - it carries both id and appId + # ($ServicePrincipals is selected without id, so its .id is null). + $ResourceIdToAppId = @{} + foreach ($SP in $UsedServicePrincipals) { if ($SP.id) { $ResourceIdToAppId[$SP.id] = $SP.appId } } + + # Granted application roles (GUIDs) per resource appId. + $GrantedRoleIdsByApp = @{} + foreach ($Assignment in $AppRoleAssignments) { + $ResAppId = $ResourceIdToAppId[$Assignment.resourceId] + if (!$ResAppId -or !$Assignment.appRoleId) { continue } + if (-not $GrantedRoleIdsByApp.ContainsKey($ResAppId)) { $GrantedRoleIdsByApp[$ResAppId] = [System.Collections.Generic.List[string]]::new() } + $GrantedRoleIdsByApp[$ResAppId].Add([string]$Assignment.appRoleId) + } + + # Granted delegated scope NAMES per resource appId (oauth2 grants store space-delimited names). + $GrantedScopesByApp = @{} + foreach ($Grant in $OAuthGrants) { + $ResAppId = $ResourceIdToAppId[$Grant.resourceId] + if (!$ResAppId) { continue } + if (-not $GrantedScopesByApp.ContainsKey($ResAppId)) { $GrantedScopesByApp[$ResAppId] = [System.Collections.Generic.List[string]]::new() } + foreach ($ScopeName in @(($Grant.scope -split ' ') | Where-Object { $_ })) { $GrantedScopesByApp[$ResAppId].Add($ScopeName) } + } + foreach ($AppId in $AllAppIds) { $ServicePrincipal = $ServicePrincipals | Where-Object -Property appId -EQ $AppId - $AppRegResource = $PartnerApp.requiredResourceAccess | Where-Object -Property resourceAppId -EQ $AppId - $AppRegRoleIds = @(($AppRegResource.resourceAccess | Where-Object { $_.type -eq 'Role' }).id) - $AppRegScopeIds = @(($AppRegResource.resourceAccess | Where-Object { $_.type -eq 'Scope' }).id) + $GrantedRoleIds = @($GrantedRoleIdsByApp[$AppId] | Where-Object { $_ }) + $GrantedScopeNames = @($GrantedScopesByApp[$AppId] | Where-Object { $_ }) - # Only GUID-based permissions live in the app registration's requiredResourceAccess. - # String-named scopes (e.g. the .Sdp AdditionalPermissions) are applied as direct grants, - # so excluding them here avoids permanent false-positive "missing" entries. + # Application (Role) permissions compare by GUID against appRoleAssignments. $EffApp = @($EffectivePermissions.$AppId.applicationPermissions | Where-Object { $_.id -match $GuidRegex }) - $EffDel = @($EffectivePermissions.$AppId.delegatedPermissions | Where-Object { $_.id -match $GuidRegex }) + # Delegated (Scope) permissions compare by NAME (value) against oauth2 grant scopes - + # this covers both GUID-resolved scopes and the string-named AdditionalPermissions. + $EffDel = @($EffectivePermissions.$AppId.delegatedPermissions) $EffAppIds = @($EffApp.id) - $EffDelIds = @($EffDel.id) + $EffDelNames = @($EffDel.value) - $MissingApp = @(foreach ($Permission in $EffApp) { if ($AppRegRoleIds -notcontains $Permission.id) { $Permission } }) - $MissingDel = @(foreach ($Permission in $EffDel) { if ($AppRegScopeIds -notcontains $Permission.id) { $Permission } }) - $ExtraApp = @(foreach ($Id in $AppRegRoleIds) { + $MissingApp = @(foreach ($Permission in $EffApp) { if ($GrantedRoleIds -notcontains $Permission.id) { $Permission } }) + $MissingDel = @(foreach ($Permission in $EffDel) { if ($Permission.value -and $GrantedScopeNames -notcontains $Permission.value) { $Permission } }) + $ExtraApp = @(foreach ($Id in ($GrantedRoleIds | Sort-Object -Unique)) { if ($EffAppIds -notcontains $Id) { [PSCustomObject]@{ id = $Id; value = (($ServicePrincipal.appRoles | Where-Object -Property id -EQ $Id).value) ?? $Id } } }) - $ExtraDel = @(foreach ($Id in $AppRegScopeIds) { - if ($EffDelIds -notcontains $Id) { - [PSCustomObject]@{ id = $Id; value = (($ServicePrincipal.publishedPermissionScopes | Where-Object -Property id -EQ $Id).value) ?? $Id } + $ExtraDel = @(foreach ($Name in ($GrantedScopeNames | Sort-Object -Unique)) { + if ($EffDelNames -notcontains $Name) { + [PSCustomObject]@{ id = $Name; value = $Name } } }) @@ -246,7 +278,7 @@ function Get-CippSamPermissions { } } } catch { - Write-Information "Failed to retrieve partner app registration for permission diff: $($_.Exception.Message)" + Write-Information "Failed to retrieve partner enterprise app grants for permission diff: $($_.Exception.Message)" } } diff --git a/Modules/CIPPCore/Public/SAMManifest/Update-CippSamPermissions.ps1 b/Modules/CIPPCore/Public/SAMManifest/Update-CippSamPermissions.ps1 index 226e9b9945422..7ed67f5be3111 100644 --- a/Modules/CIPPCore/Public/SAMManifest/Update-CippSamPermissions.ps1 +++ b/Modules/CIPPCore/Public/SAMManifest/Update-CippSamPermissions.ps1 @@ -1,15 +1,19 @@ function Update-CippSamPermissions { <# .SYNOPSIS - Repairs the CIPP-SAM app registration permissions in the partner tenant. + Reconciles the saved CIPP-SAM additional-permission set in the AppPermissions table. .DESCRIPTION - Diffs the effective CIPP-SAM permission set (manifest defaults + saved extras) against the live - CIPP-SAM application registration in the partner tenant and ADDS any missing permissions to the - app registration's requiredResourceAccess. This is additive only: it never removes permissions, - so it cannot strip a legitimately-configured entry. Extra permissions found on the app that are - not part of the effective set are reported back so an admin can review/remove them manually. + The SAM manifest is the immutable permission base and is always layered in at read time by + Get-CippSamPermissions, so the AppPermissions table only ever needs to hold the EXTRA + permissions an admin layered on top. This function keeps that row clean: it drops any saved + entries the manifest now covers (e.g. legacy rows that stored the full manifest+extras set) + so the table stays "extras only". - Pushing these permissions out to customer tenants is handled separately by the CPV refresh. + It deliberately does NOT write the partner CIPP-SAM app registration's requiredResourceAccess. + Permissions reach the CIPP-SAM service principal(s) - partner and clients - through the grant + flow (Add-CIPPApplicationPermission / Add-CIPPDelegatedPermission, which read this table), not + through the app registration. Refreshing those grants is handled by the caller + (Invoke-ExecPermissionRepair for the partner, the per-tenant permission refresh for clients). .PARAMETER UpdatedBy The user or system that is performing the update. Defaults to 'CIPP-API'. .OUTPUTS @@ -22,87 +26,70 @@ function Update-CippSamPermissions { ) try { - $CurrentPermissions = Get-CippSamPermissions - $PartnerAppDiff = $CurrentPermissions.PartnerAppDiff - $MissingPermissions = $CurrentPermissions.MissingPermissions + # Manifest base - always-required permissions that are layered in at read time, so they never + # need to live in the saved extras row. + $ManifestPermissions = (Get-CippSamPermissions -ManifestOnly).Permissions - $MissingAppIds = @($MissingPermissions.PSObject.Properties.Name) - $ExtraAppIds = @($PartnerAppDiff.PSObject.Properties.Name | Where-Object { - ($PartnerAppDiff.$_.extraApplicationPermissions | Measure-Object).Count -gt 0 -or - ($PartnerAppDiff.$_.extraDelegatedPermissions | Measure-Object).Count -gt 0 - }) - - if ($MissingAppIds.Count -eq 0) { - if ($ExtraAppIds.Count -gt 0) { - $ExtraSummary = foreach ($AppId in $ExtraAppIds) { - $Names = @($PartnerAppDiff.$AppId.extraApplicationPermissions.value) + @($PartnerAppDiff.$AppId.extraDelegatedPermissions.value) - "$AppId ($($Names -join ', '))" - } - return "No missing permissions to add. The following extra permissions are present on the app and should be reviewed/removed manually: $($ExtraSummary -join '; ')" - } - return 'No permissions to update' + $Table = Get-CIPPTable -TableName 'AppPermissions' + $SavedRow = Get-CippAzDataTableEntity @Table -Filter "PartitionKey eq 'CIPP-SAM' and RowKey eq 'CIPP-SAM'" + if (-not $SavedRow.Permissions) { + return 'No additional permissions saved. CIPP default (manifest) permissions are always applied.' } - # Retrieve the live CIPP-SAM application registration in the partner tenant. - $PartnerApp = New-GraphGETRequest -uri "https://graph.microsoft.com/beta/applications(appId='$($env:ApplicationID)')?`$select=id,requiredResourceAccess" -tenantid $env:TenantID -NoAuthCheck $true - - $RequiredResourceAccess = [System.Collections.Generic.List[object]]::new() - foreach ($Resource in $PartnerApp.requiredResourceAccess) { - $ResourceAccess = [System.Collections.Generic.List[object]]::new() - foreach ($Access in $Resource.resourceAccess) { - $ResourceAccess.Add(@{ id = $Access.id; type = $Access.type }) - } - $RequiredResourceAccess.Add([PSCustomObject]@{ - resourceAppId = $Resource.resourceAppId - resourceAccess = $ResourceAccess - }) + try { + $Saved = $SavedRow.Permissions | ConvertFrom-Json -ErrorAction Stop + } catch { + return 'Saved additional permissions could not be parsed; nothing to reconcile.' } - $AddedPermissions = [System.Collections.Generic.List[string]]::new() - foreach ($AppId in $MissingAppIds) { - $Resource = $RequiredResourceAccess | Where-Object -Property resourceAppId -EQ $AppId | Select-Object -First 1 - if (!$Resource) { - $Resource = [PSCustomObject]@{ - resourceAppId = $AppId - resourceAccess = [System.Collections.Generic.List[object]]::new() + # Keep only the entries the manifest does NOT already cover. + $Extras = @{} + $RemovedCount = 0 + foreach ($AppId in $Saved.PSObject.Properties.Name) { + $ManifestApp = $ManifestPermissions.$AppId + $ManifestAppIds = @($ManifestApp.applicationPermissions.id) + $ManifestDelIds = @($ManifestApp.delegatedPermissions.id) + + $ExtraApp = [System.Collections.Generic.List[object]]::new() + foreach ($Permission in $Saved.$AppId.applicationPermissions) { + if ($Permission.id -and $ManifestAppIds -notcontains $Permission.id) { + $ExtraApp.Add([PSCustomObject]@{ id = $Permission.id; value = $Permission.value }) + } else { + $RemovedCount++ } - $RequiredResourceAccess.Add($Resource) } - $ExistingIds = @($Resource.resourceAccess.id) - - foreach ($Permission in $MissingPermissions.$AppId.applicationPermissions) { - if ($Permission.id -and $ExistingIds -notcontains $Permission.id) { - $Resource.resourceAccess.Add(@{ id = $Permission.id; type = 'Role' }) - $AddedPermissions.Add("$($Permission.value) (Application)") + $ExtraDel = [System.Collections.Generic.List[object]]::new() + foreach ($Permission in $Saved.$AppId.delegatedPermissions) { + if ($Permission.id -and $ManifestDelIds -notcontains $Permission.id) { + $ExtraDel.Add([PSCustomObject]@{ id = $Permission.id; value = $Permission.value }) + } else { + $RemovedCount++ } } - foreach ($Permission in $MissingPermissions.$AppId.delegatedPermissions) { - if ($Permission.id -and $ExistingIds -notcontains $Permission.id) { - $Resource.resourceAccess.Add(@{ id = $Permission.id; type = 'Scope' }) - $AddedPermissions.Add("$($Permission.value) (Delegated)") + + if ($ExtraApp.Count -gt 0 -or $ExtraDel.Count -gt 0) { + $Extras.$AppId = @{ + applicationPermissions = @($ExtraApp) + delegatedPermissions = @($ExtraDel) } } } - if ($AddedPermissions.Count -eq 0) { - return 'No permissions to update' + if ($RemovedCount -eq 0) { + return 'Saved additional permissions already reconciled; no manifest-covered entries to remove.' } - $PatchBody = @{ requiredResourceAccess = @($RequiredResourceAccess) } | ConvertTo-Json -Depth 10 -Compress - $null = New-GraphPOSTRequest -uri "https://graph.microsoft.com/beta/applications/$($PartnerApp.id)" -tenantid $env:TenantID -body $PatchBody -type PATCH -NoAuthCheck $true - - Write-LogMessage -API 'UpdateCippSamPermissions' -message "CIPP-SAM app registration permissions repaired by $UpdatedBy" -Sev 'Info' -LogData @{ Added = $AddedPermissions } - - $Result = "Added $($AddedPermissions.Count) missing permission(s) to the CIPP-SAM app registration: $($AddedPermissions -join ', '). Run a CPV refresh to apply these to customer tenants." - if ($ExtraAppIds.Count -gt 0) { - $ExtraSummary = foreach ($AppId in $ExtraAppIds) { - $Names = @($PartnerAppDiff.$AppId.extraApplicationPermissions.value) + @($PartnerAppDiff.$AppId.extraDelegatedPermissions.value) - "$AppId ($($Names -join ', '))" - } - $Result += " Extra permissions present on the app that should be reviewed/removed manually: $($ExtraSummary -join '; ')." + $Entity = @{ + 'PartitionKey' = 'CIPP-SAM' + 'RowKey' = 'CIPP-SAM' + 'Permissions' = [string]([PSCustomObject]$Extras | ConvertTo-Json -Depth 10 -Compress) + 'UpdatedBy' = $UpdatedBy } - return $Result + $null = Add-CIPPAzDataTableEntity @Table -Entity $Entity -Force + + $Plural = if ($RemovedCount -eq 1) { 'entry' } else { 'entries' } + return "Reconciled saved additional permissions: removed $RemovedCount $Plural now covered by the CIPP manifest." } catch { - throw "Failed to update permissions: $($_.Exception.Message)" + throw "Failed to reconcile permissions: $($_.Exception.Message)" } } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecPermissionRepair.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecPermissionRepair.ps1 index 513e0bd5aca07..8cb5eb1cda8ef 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecPermissionRepair.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecPermissionRepair.ps1 @@ -1,9 +1,13 @@ function Invoke-ExecPermissionRepair { <# .SYNOPSIS - This endpoint will update the CIPP-SAM app permissions. + Reconciles the CIPP-SAM permissions and re-applies them to the partner service principal. .DESCRIPTION - Merges new permissions from the SAM manifest into the AppPermissions entry for CIPP-SAM. + Reconciles the saved additional-permission set (Update-CippSamPermissions), then refreshes the + grants on the CIPP-SAM service principal in the PARTNER tenant so the current effective set + (manifest + extras) is consented. This never writes the app registration's requiredResourceAccess; + permissions are applied as service-principal grants, the same way the routine refresh does. + Client tenants pick up the same effective set through their own permission refresh. .FUNCTIONALITY Entrypoint .ROLE @@ -14,8 +18,19 @@ function Invoke-ExecPermissionRepair { try { $User = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($Request.Headers.'x-ms-client-principal')) | ConvertFrom-Json - $Result = Update-CippSamPermissions -UpdatedBy ($User.UserDetails ?? 'CIPP-API') - $Body = @{'Results' = $Result } + $UpdatedBy = $User.UserDetails ?? 'CIPP-API' + + # 1) Reconcile the saved extras table (no app-registration write). + $TableResult = Update-CippSamPermissions -UpdatedBy $UpdatedBy + + # 2) Refresh the grants on the partner CIPP-SAM service principal so the effective set + # (manifest + extras, read from the table) is actually consented on the SP. + $AppResults = Add-CIPPApplicationPermission -RequiredResourceAccess 'CIPPDefaults' -ApplicationId $env:ApplicationID -TenantFilter $env:TenantID + $DelegatedResults = Add-CIPPDelegatedPermission -RequiredResourceAccess 'CIPPDefaults' -ApplicationId $env:ApplicationID -TenantFilter $env:TenantID + + $Results = @($TableResult) + @($AppResults) + @($DelegatedResults) | Where-Object { $_ } + Write-LogMessage -Headers $Request.Headers -API 'ExecPermissionRepair' -message "CIPP-SAM permissions repaired by $UpdatedBy" -Sev 'Info' -LogData @{ Results = @($Results) } + $Body = @{'Results' = ($Results -join [Environment]::NewLine) } } catch { $Body = @{ 'Results' = "$($_.Exception.Message) - at line $($_.InvocationInfo.ScriptLineNumber)" From d24f333f84b60b841a55c6a9d7a39ee05b1f8db0 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Mon, 29 Jun 2026 13:15:30 -0400 Subject: [PATCH 044/170] fix: token timer secret cleanup --- .../Start-UpdateTokensTimer.ps1 | 36 +++++++++++++------ 1 file changed, 26 insertions(+), 10 deletions(-) diff --git a/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-UpdateTokensTimer.ps1 b/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-UpdateTokensTimer.ps1 index 037203bb99520..0a616087ae0fa 100644 --- a/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-UpdateTokensTimer.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-UpdateTokensTimer.ps1 @@ -42,6 +42,7 @@ function Start-UpdateTokensTimer { $AppRegistration = New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/applications(appId='$AppId')?`$select=id,passwordCredentials,servicePrincipalLockConfiguration" -NoAuthCheck $true -AsApp $true -ErrorAction Stop # sort by latest expiration date and get the first one $LastPasswordCredential = $AppRegistration.passwordCredentials | Sort-Object -Property endDateTime -Descending | Select-Object -First 1 + $PasswordCredentials = $AppRegistration.passwordCredentials | Sort-Object -Property endDateTime -Descending try { $AppPolicyStatus = Update-AppManagementPolicy @@ -60,24 +61,39 @@ function Start-UpdateTokensTimer { } if ($AppSecret) { - if ($env:AzureWebJobsStorage -eq 'UseDevelopmentStorage=true' -or $env:NonLocalHostAzurite -eq 'true') { - $Table = Get-CIPPTable -tablename 'DevSecrets' - $Secret = Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq 'Secret' and RowKey eq 'Secret'" - $Secret.ApplicationSecret = $AppSecret.secretText - Add-AzDataTableEntity @Table -Entity $Secret -Force - } else { - Set-CippKeyVaultSecret -VaultName $KV -Name 'ApplicationSecret' -SecretValue (ConvertTo-SecureString -String $AppSecret.secretText -AsPlainText -Force) + try { + if ($env:AzureWebJobsStorage -eq 'UseDevelopmentStorage=true' -or $env:NonLocalHostAzurite -eq 'true') { + $Table = Get-CIPPTable -tablename 'DevSecrets' + $Secret = Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq 'Secret' and RowKey eq 'Secret'" + $Secret.ApplicationSecret = $AppSecret.secretText + Add-AzDataTableEntity @Table -Entity $Secret -Force + } else { + Set-CippKeyVaultSecret -VaultName $KV -Name 'ApplicationSecret' -SecretValue (ConvertTo-SecureString -String $AppSecret.secretText -AsPlainText -Force) + } + Write-LogMessage -API 'Update Tokens' -message "New application secret generated for $AppId. Expiration date: $($AppSecret.endDateTime)." -sev 'INFO' + } catch { + # Storing the new secret failed. It exists on the app registration but not where CIPP reads + # it, and as the newest credential it would suppress regeneration on the next run - leaving + # the stored secret permanently stale. Roll the new secret back off the app registration so + # state stays consistent and regeneration is retried on the next run. + Write-LogMessage -API 'Update Tokens' -message "Failed to store new application secret for $AppId. Rolling back the generated secret, see Log Data for details. Will try again in 7 days." -sev 'CRITICAL' -LogData (Get-CippException -Exception $_) + try { + New-GraphPostRequest -uri "https://graph.microsoft.com/v1.0/applications/$($AppRegistration.id)/removePassword" -Body "{`"keyId`":`"$($AppSecret.keyId)`"}" -NoAuthCheck $true -AsApp $true -ErrorAction Stop + Write-Information "Rolled back unstored application secret with keyId $($AppSecret.keyId)." + } catch { + Write-LogMessage -API 'Update Tokens' -message "Failed to roll back unstored application secret with keyId $($AppSecret.keyId) for $AppId, see Log Data for details." -sev 'CRITICAL' -LogData (Get-CippException -Exception $_) + } + $AppSecret = $null } - Write-LogMessage -API 'Update Tokens' -message "New application secret generated for $AppId. Expiration date: $($AppSecret.endDateTime)." -sev 'INFO' } # Clean up expired application secrets - $ExpiredSecrets = $PasswordCredentials.passwordCredentials | Where-Object { $_.endDateTime -lt (Get-Date).ToUniversalTime() } + $ExpiredSecrets = $PasswordCredentials | Where-Object { $_.endDateTime -lt (Get-Date).ToUniversalTime() } if ($ExpiredSecrets.Count -gt 0) { Write-Information "Found $($ExpiredSecrets.Count) expired application secrets for $AppId. Removing them." foreach ($Secret in $ExpiredSecrets) { try { - New-GraphPostRequest -uri "https://graph.microsoft.com/v1.0/applications/$($PasswordCredentials.id)/removePassword" -Body "{`"keyId`":`"$($Secret.keyId)`"}" -NoAuthCheck $true -AsApp $true -ErrorAction Stop + New-GraphPostRequest -uri "https://graph.microsoft.com/v1.0/applications/$($AppRegistration.id)/removePassword" -Body "{`"keyId`":`"$($Secret.keyId)`"}" -NoAuthCheck $true -AsApp $true -ErrorAction Stop Write-Information "Removed expired application secret with keyId $($Secret.keyId)." } catch { Write-LogMessage -API 'Update Tokens' -message "Error removing expired application secret with keyId $($Secret.keyId), see Log Data for details." -sev 'CRITICAL' -LogData (Get-CippException -Exception $_) From d30b2f4c4bac42cef87f077fea625c0fef47bf1a Mon Sep 17 00:00:00 2001 From: John Duprey Date: Mon, 29 Jun 2026 13:15:44 -0400 Subject: [PATCH 045/170] fix: remove execsamsetup, no longer used --- .../CIPP/Setup/Invoke-ExecSAMSetup.ps1 | 236 ------------------ 1 file changed, 236 deletions(-) delete mode 100644 Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecSAMSetup.ps1 diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecSAMSetup.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecSAMSetup.ps1 deleted file mode 100644 index 76fe86b3af932..0000000000000 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecSAMSetup.ps1 +++ /dev/null @@ -1,236 +0,0 @@ -function Invoke-ExecSAMSetup { - <# - .FUNCTIONALITY - Entrypoint,AnyTenant - .ROLE - CIPP.AppSettings.ReadWrite - .LEGACY - This function is a legacy function that was used to set up the CIPP application in Azure AD. It is not used in the current version of CIPP, look at Invoke-ExecCreateSAMApp for the new version. - #> - [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingConvertToSecureStringWithPlainText', '')] - [CmdletBinding()] - param($Request, $TriggerMetadata) - - return ([HttpResponseContext]@{ - StatusCode = [HttpStatusCode]::OK - Body = @{ message = 'This endpoint is no longer used. Please use the CreateSAMApp endpoint instead.' } - }) - - if ($Request.Query.error) { - Add-Type -AssemblyName System.Web - return ([HttpResponseContext]@{ - ContentType = 'text/html' - StatusCode = [HttpStatusCode]::Forbidden - Body = Get-normalizedError -Message [System.Web.HttpUtility]::UrlDecode($Request.Query.error_description) - }) - exit - } - if ($env:AzureWebJobsStorage -eq 'UseDevelopmentStorage=true' -or $env:NonLocalHostAzurite -eq 'true') { - $DevSecretsTable = Get-CIPPTable -tablename 'DevSecrets' - $Secret = Get-CIPPAzDataTableEntity @DevSecretsTable -Filter "PartitionKey eq 'Secret' and RowKey eq 'Secret'" - if (!$Secret) { - $Secret = [PSCustomObject]@{ - 'PartitionKey' = 'Secret' - 'RowKey' = 'Secret' - 'TenantId' = '' - 'RefreshToken' = '' - 'ApplicationId' = '' - 'ApplicationSecret' = '' - } - Add-CIPPAzDataTableEntity @DevSecretsTable -Entity $Secret -Force - } - } - if (!$env:SetFromProfile) { - Write-Information "We're reloading from KV" - Get-CIPPAuthentication - } - - $KV = $env:WEBSITE_DEPLOYMENT_ID - $Table = Get-CIPPTable -TableName SAMWizard - $Rows = Get-CIPPAzDataTableEntity @Table | Where-Object -Property Timestamp -GT (Get-Date).AddMinutes(-10) - - try { - if ($Request.Query.count -lt 1 ) { $Results = 'No authentication code found. Please go back to the wizard.' } - - if ($Request.Body.setkeys) { - if ($env:AzureWebJobsStorage -eq 'UseDevelopmentStorage=true' -or $env:NonLocalHostAzurite -eq 'true') { - if ($Request.Body.TenantId) { $Secret.TenantId = $Request.Body.tenantid } - if ($Request.Body.RefreshToken) { $Secret.RefreshToken = $Request.Body.RefreshToken } - if ($Request.Body.applicationid) { $Secret.ApplicationId = $Request.Body.ApplicationId } - if ($Request.Body.ApplicationSecret) { $Secret.ApplicationSecret = $Request.Body.ApplicationSecret } - Add-CIPPAzDataTableEntity @DevSecretsTable -Entity $Secret -Force - } else { - if ($Request.Body.tenantid) { Set-CippKeyVaultSecret -VaultName $kv -Name 'tenantid' -SecretValue (ConvertTo-SecureString -String $Request.Body.tenantid -AsPlainText -Force) } - if ($Request.Body.RefreshToken) { Set-CippKeyVaultSecret -VaultName $kv -Name 'RefreshToken' -SecretValue (ConvertTo-SecureString -String $Request.Body.RefreshToken -AsPlainText -Force) } - if ($Request.Body.applicationid) { Set-CippKeyVaultSecret -VaultName $kv -Name 'applicationid' -SecretValue (ConvertTo-SecureString -String $Request.Body.applicationid -AsPlainText -Force) } - if ($Request.Body.applicationsecret) { Set-CippKeyVaultSecret -VaultName $kv -Name 'applicationsecret' -SecretValue (ConvertTo-SecureString -String $Request.Body.applicationsecret -AsPlainText -Force) } - } - - $Results = @{ Results = 'The keys have been replaced. Please perform a permissions check.' } - } - if ($Request.Query.error -eq 'invalid_client') { $Results = 'Client ID was not found in Azure. Try waiting 10 seconds to try again, if you have gotten this error after 5 minutes, please restart the process.' } - if ($Request.Query.code) { - try { - $TenantId = $Rows.tenantid - if (!$TenantId -or $TenantId -eq 'NotStarted') { $TenantId = $env:TenantID } - $AppID = $Rows.appid - if (!$AppID -or $AppID -eq 'NotStarted') { $appid = $env:ApplicationID } - $URL = ($Request.headers.'x-ms-original-url').split('?') | Select-Object -First 1 - if ($env:AzureWebJobsStorage -eq 'UseDevelopmentStorage=true' -or $env:NonLocalHostAzurite -eq 'true') { - $clientsecret = $Secret.ApplicationSecret - } else { - $clientsecret = Get-CippKeyVaultSecret -VaultName $kv -Name 'ApplicationSecret' -AsPlainText - } - if (!$clientsecret) { $clientsecret = $env:ApplicationSecret } - Write-Information "client_id=$appid&scope=https://graph.microsoft.com/.default+offline_access+openid+profile&code=$($Request.Query.code)&grant_type=authorization_code&redirect_uri=$($url)&client_secret=$clientsecret" #-Uri "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token" - $RefreshToken = Invoke-RestMethod -Method POST -Body "client_id=$appid&scope=https://graph.microsoft.com/.default+offline_access+openid+profile&code=$($Request.Query.code)&grant_type=authorization_code&redirect_uri=$($url)&client_secret=$clientsecret" -Uri "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token" -ContentType 'application/x-www-form-urlencoded' - - if ($env:AzureWebJobsStorage -eq 'UseDevelopmentStorage=true' -or $env:NonLocalHostAzurite -eq 'true') { - $Secret.RefreshToken = $RefreshToken.refresh_token - Add-CIPPAzDataTableEntity @DevSecretsTable -Entity $Secret -Force - } else { - Set-CippKeyVaultSecret -VaultName $kv -Name 'RefreshToken' -SecretValue (ConvertTo-SecureString -String $RefreshToken.refresh_token -AsPlainText -Force) - } - - $Results = 'Authentication is now complete. You may now close this window.' - try { - $SetupPhase = $rows.validated = $true - Add-CIPPAzDataTableEntity @Table -Entity $Rows -Force | Out-Null - } catch { - #no need. - } - } catch { - $Results = "Authentication failed. $($_.Exception.message)" - } - } - if ($Request.Query.CreateSAM) { - $Rows = @{ - RowKey = 'setup' - PartitionKey = 'setup' - validated = $false - SamSetup = 'NotStarted' - partnersetup = $true - appid = 'NotStarted' - tenantid = 'NotStarted' - } - Add-CIPPAzDataTableEntity @Table -Entity $Rows -Force | Out-Null - $Rows = Get-CIPPAzDataTableEntity @Table | Where-Object -Property Timestamp -GT (Get-Date).AddMinutes(-10) - $step = 1 - $DeviceLogon = New-DeviceLogin -clientid '1b730954-1685-4b74-9bfd-dac224a7b894' -Scope 'https://graph.microsoft.com/.default' -FirstLogon - $SetupPhase = $rows.SamSetup = [string]($DeviceLogon | ConvertTo-Json) - Add-CIPPAzDataTableEntity @Table -Entity $Rows -Force | Out-Null - $Results = @{ code = $($DeviceLogon.user_code); message = "Your code is $($DeviceLogon.user_code). Enter the code" ; step = $step; url = $DeviceLogon.verification_uri } - } - if ($Request.Query.CheckSetupProcess -and $Request.Query.step -eq 1) { - $SAMSetup = $Rows.SamSetup | ConvertFrom-Json -ErrorAction SilentlyContinue - if ($SamSetup.token_type -eq 'Bearer') { - #sleeping for 10 seconds to allow the token to be created. - Start-Sleep 10 - #nulling the token to force a recheck. - $step = 2 - } - $Token = (New-DeviceLogin -clientid '1b730954-1685-4b74-9bfd-dac224a7b894' -Scope 'https://graph.microsoft.com/.default' -device_code $SAMSetup.device_code) - Write-Information "Token is $($token | ConvertTo-Json)" - if ($Token.access_token) { - $step = 2 - $rows.SamSetup = [string]($Token | ConvertTo-Json) - $URL = ($Request.headers.'x-ms-original-url').split('?') | Select-Object -First 1 - $PartnerSetup = $true - $TenantId = (Invoke-RestMethod 'https://graph.microsoft.com/v1.0/organization' -Headers @{ authorization = "Bearer $($Token.access_token)" } -Method GET -ContentType 'application/json').value.id - $SetupPhase = $rows.tenantid = [string]($TenantId) - Add-CIPPAzDataTableEntity @Table -Entity $Rows -Force | Out-Null - if ($PartnerSetup) { - #$app = Get-Content '.\Cache_SAMSetup\SAMManifest.json' | ConvertFrom-Json - $SamManifestFile = Get-Item (Join-Path $env:CIPPRootPath 'Config\SAMManifest.json') - $app = Get-Content $SamManifestFile.FullName | ConvertFrom-Json - - $App.web.redirectUris = @($App.web.redirectUris + $URL) - $app = $app | ConvertTo-Json -Depth 15 - $AppId = (Invoke-RestMethod 'https://graph.microsoft.com/v1.0/applications' -Headers @{ authorization = "Bearer $($Token.access_token)" } -Method POST -Body $app -ContentType 'application/json') - $rows.appid = [string]($AppId.appId) - Add-CIPPAzDataTableEntity @Table -Entity $Rows -Force | Out-Null - $attempt = 0 - do { - try { - try { - $SPNDefender = (Invoke-RestMethod 'https://graph.microsoft.com/v1.0/servicePrincipals' -Headers @{ authorization = "Bearer $($Token.access_token)" } -Method POST -Body "{ `"appId`": `"fc780465-2017-40d4-a0c5-307022471b92`" }" -ContentType 'application/json') - } catch { - Write-Information "didn't deploy spn for defender, probably already there." - } - try { - $SPNTeams = (Invoke-RestMethod 'https://graph.microsoft.com/v1.0/servicePrincipals' -Headers @{ authorization = "Bearer $($Token.access_token)" } -Method POST -Body "{ `"appId`": `"48ac35b8-9aa8-4d74-927d-1f4a14a0b239`" }" -ContentType 'application/json') - } catch { - Write-Information "didn't deploy spn for Teams, probably already there." - } - try { - $SPNO365Manage = (Invoke-RestMethod 'https://graph.microsoft.com/v1.0/servicePrincipals' -Headers @{ authorization = "Bearer $($Token.access_token)" } -Method POST -Body "{ `"appId`": `"c5393580-f805-4401-95e8-94b7a6ef2fc2`" }" -ContentType 'application/json') - } catch { - Write-Information "didn't deploy spn for O365 Management, probably already there." - } - try { - $SPNPartnerCenter = (Invoke-RestMethod 'https://graph.microsoft.com/v1.0/servicePrincipals' -Headers @{ authorization = "Bearer $($Token.access_token)" } -Method POST -Body "{ `"appId`": `"fa3d9a0c-3fb0-42cc-9193-47c7ecd2edbd`" }" -ContentType 'application/json') - } catch { - Write-Information "didn't deploy spn for PartnerCenter, probably already there." - } - $SPN = (Invoke-RestMethod 'https://graph.microsoft.com/v1.0/servicePrincipals' -Headers @{ authorization = "Bearer $($Token.access_token)" } -Method POST -Body "{ `"appId`": `"$($AppId.appId)`" }" -ContentType 'application/json') - Start-Sleep 3 - $attempt ++ - } catch { - $attempt ++ - } - } until ($attempt -gt 5) - } - $AppPassword = (Invoke-RestMethod "https://graph.microsoft.com/v1.0/applications/$($AppId.id)/addPassword" -Headers @{ authorization = "Bearer $($Token.access_token)" } -Method POST -Body '{"passwordCredential":{"displayName":"CIPPInstall"}}' -ContentType 'application/json').secretText - if ($env:AzureWebJobsStorage -eq 'UseDevelopmentStorage=true' -or $env:NonLocalHostAzurite -eq 'true') { - $Secret.TenantId = $TenantId - $Secret.ApplicationId = $AppId.appId - $Secret.ApplicationSecret = $AppPassword - Add-CIPPAzDataTableEntity @DevSecretsTable -Entity $Secret -Force - Write-Information ($Secret | ConvertTo-Json -Depth 5) - } else { - Set-CippKeyVaultSecret -VaultName $kv -Name 'tenantid' -SecretValue (ConvertTo-SecureString -String $TenantId -AsPlainText -Force) - Set-CippKeyVaultSecret -VaultName $kv -Name 'applicationid' -SecretValue (ConvertTo-SecureString -String $Appid.appId -AsPlainText -Force) - Set-CippKeyVaultSecret -VaultName $kv -Name 'applicationsecret' -SecretValue (ConvertTo-SecureString -String $AppPassword -AsPlainText -Force) - } - $Results = @{'message' = 'Created application. Waiting 30 seconds for Azure propagation'; step = $step } - } else { - $step = 1 - $Results = @{ code = $($SAMSetup.user_code); message = "Your code is $($SAMSetup.user_code). Enter the code " ; step = $step; url = $SAMSetup.verification_uri } - } - - } - switch ($Request.Query.step) { - 2 { - $step = 2 - $TenantId = $Rows.tenantid - $AppID = $rows.appid - $PartnerSetup = $true - $SetupPhase = $rows.SamSetup = [string]($FirstLogonRefreshtoken | ConvertTo-Json) - Add-CIPPAzDataTableEntity @Table -Entity $Rows -Force | Out-Null - $URL = ($Request.headers.'x-ms-original-url').split('?') | Select-Object -First 1 - $Validated = $Rows.validated - if ($Validated) { $step = 3 } - $Results = @{ appId = $AppID; message = 'Give the next approval by clicking ' ; step = $step; url = "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/authorize?scope=https://graph.microsoft.com/.default+offline_access+openid+profile&response_type=code&client_id=$($appid)&redirect_uri=$($url)" } - } - 3 { - $step = 4 - $Results = @{'message' = 'Received token.'; step = $step } - } - 4 { - Remove-AzDataTableEntity -Force @Table -Entity $Rows - $step = 5 - $Results = @{'message' = 'setup completed.'; step = $step - } - } - } - - } catch { - $Results = [pscustomobject]@{'Results' = "Failed. $($_.InvocationInfo.ScriptLineNumber): $($_.Exception.message)" ; step = $step } - } - - return ([HttpResponseContext]@{ - StatusCode = [HttpStatusCode]::OK - Body = $Results - }) - -} From 0b72ea50b0745522bf5062f96c87e028cfd576f1 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Tue, 30 Jun 2026 13:12:35 +0800 Subject: [PATCH 046/170] Update Compare-CIPPIntuneObject.ps1 --- Modules/CIPPCore/Public/Compare-CIPPIntuneObject.ps1 | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/Modules/CIPPCore/Public/Compare-CIPPIntuneObject.ps1 b/Modules/CIPPCore/Public/Compare-CIPPIntuneObject.ps1 index fc9902d80823f..da6a537fc21fc 100644 --- a/Modules/CIPPCore/Public/Compare-CIPPIntuneObject.ps1 +++ b/Modules/CIPPCore/Public/Compare-CIPPIntuneObject.ps1 @@ -173,11 +173,12 @@ function Compare-CIPPIntuneObject { if (ShouldCompareAsUnorderedSet -PropertyPath $PropertyPath) { # For unordered sets, compare contents regardless of order if ($Object1.Count -ne $Object2.Count) { - # Different lengths - report the difference + # Different lengths - report the actual values so a technician + # can see exactly what differs and decide on the action. $result.Add([PSCustomObject]@{ Property = $PropertyPath - ExpectedValue = "Array with $($Object1.Count) items" - ReceivedValue = "Array with $($Object2.Count) items" + ExpectedValue = ($Object1 -join ', ') + ReceivedValue = ($Object2 -join ', ') }) } else { # Same length - check if all items exist in both arrays From 3ce368b674ce4d879873826552f8f64047c49aaa Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Tue, 30 Jun 2026 14:00:00 +0800 Subject: [PATCH 047/170] Manual Audit Log Search Fixes --- .../Add-CippAuditLogCoverageManualEntry.ps1 | 74 +++++++++++++++++++ .../AuditLogs/New-CippAuditLogSearch.ps1 | 7 ++ .../Alerts/Invoke-ExecAuditLogSearch.ps1 | 5 ++ 3 files changed, 86 insertions(+) create mode 100644 Modules/CIPPCore/Public/AuditLogs/Add-CippAuditLogCoverageManualEntry.ps1 diff --git a/Modules/CIPPCore/Public/AuditLogs/Add-CippAuditLogCoverageManualEntry.ps1 b/Modules/CIPPCore/Public/AuditLogs/Add-CippAuditLogCoverageManualEntry.ps1 new file mode 100644 index 0000000000000..cf79d871408c6 --- /dev/null +++ b/Modules/CIPPCore/Public/AuditLogs/Add-CippAuditLogCoverageManualEntry.ps1 @@ -0,0 +1,74 @@ +function Add-CippAuditLogCoverageManualEntry { + <# + .SYNOPSIS + Bridge a manually-created audit log search into the V2 AuditLogCoverage ledger so the + pipeline downloads and processes it automatically (Option B). + .DESCRIPTION + Manual searches live in the AuditLogSearches table, which the (now V2-only) pipeline no + longer scans. When alert processing is requested for a manual search, this writes a ledger + row keyed 'MANUAL-' in State 'Created' so Start-AuditLogIngestionV2 / + Push-AuditLogDownloadV2 poll, download and process it like any other search - it inherits + retries, the orphan sweep, SearchStatus tracking and the coverage UI. + + The RowKey prefix keeps these out of the window planner: Get-CippAuditLogPlannedWindows only + considers 14-digit RowKeys and Get-CippAuditLogReconciliationWindows only 'RECON-*', so a + 'MANUAL-*' row is never treated as a window, gap or reconciliation block. Type 'Manual' lets + the UI exclude them from the window heatmap/charts. + + Idempotent (UpsertMerge): re-queuing the same search resets State to 'Created' to reprocess. + .PARAMETER TenantFilter + Tenant default domain (becomes the ledger PartitionKey). + .PARAMETER SearchId + The Graph audit-log search id. + .PARAMETER StartTime + Search start (datetime / DateTimeOffset / ISO string). Stored as WindowStart. + .PARAMETER EndTime + Search end. Stored as WindowEnd. + .PARAMETER SearchStatus + Graph search status at creation (e.g. notStarted); refreshed on each poll. + .PARAMETER TenantId + Tenant customerId. Resolved from TenantFilter if not supplied. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)][string]$TenantFilter, + [Parameter(Mandatory = $true)][string]$SearchId, + $StartTime, + $EndTime, + [string]$SearchStatus, + [string]$TenantId + ) + + try { + if (-not $TenantId) { + try { $TenantId = Get-Tenants -TenantFilter $TenantFilter | Select-Object -First 1 -ExpandProperty customerId } catch {} + } + + $Now = (Get-Date).ToUniversalTime() + $Ledger = Get-CippTable -TableName 'AuditLogCoverage' + $Entity = @{ + PartitionKey = [string]$TenantFilter + RowKey = 'MANUAL-' + [string]$SearchId + TenantId = [string]$TenantId + Type = 'Manual' + State = 'Created' + SearchId = [string]$SearchId + SearchStatus = [string]$SearchStatus + Attempts = 0 + RetryCount = 0 + ThrottleCount = 0 + CreatedUtc = $Now + LastPolledUtc = $Now + LastError = '' + } + if ($StartTime) { try { $Entity.WindowStart = ([datetimeoffset]$StartTime).UtcDateTime } catch {} } + if ($EndTime) { try { $Entity.WindowEnd = ([datetimeoffset]$EndTime).UtcDateTime } catch {} } + + Add-CIPPAzDataTableEntity @Ledger -Entity $Entity -OperationType UpsertMerge + Write-Information "AuditLogV2: bridged manual search $SearchId for $TenantFilter into coverage ledger (MANUAL-$SearchId)" + } catch { + Write-Information ('Add-CippAuditLogCoverageManualEntry error for {0} / {1}: {2}' -f $TenantFilter, $SearchId, $_.Exception.Message) + } +} diff --git a/Modules/CIPPCore/Public/AuditLogs/New-CippAuditLogSearch.ps1 b/Modules/CIPPCore/Public/AuditLogs/New-CippAuditLogSearch.ps1 index befd7cd164966..94e2cd35cfbf9 100644 --- a/Modules/CIPPCore/Public/AuditLogs/New-CippAuditLogSearch.ps1 +++ b/Modules/CIPPCore/Public/AuditLogs/New-CippAuditLogSearch.ps1 @@ -269,6 +269,13 @@ function New-CippAuditLogSearch { } $Table = Get-CIPPTable -TableName 'AuditLogSearches' Add-CIPPAzDataTableEntity @Table -Entity $Entity -Force | Out-Null + + # When alert processing is requested, bridge the search into the V2 AuditLogCoverage + # ledger so the pipeline downloads + processes it automatically (the V2 pipeline does + # not scan the AuditLogSearches table). + if ($ProcessLogs.IsPresent) { + Add-CippAuditLogCoverageManualEntry -TenantFilter $TenantFilter -SearchId $Query.id -StartTime $StartTime -EndTime $EndTime -SearchStatus $Query.status + } } return $Query diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-ExecAuditLogSearch.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-ExecAuditLogSearch.ps1 index 56cc814a95fbc..7956b8e2902c3 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-ExecAuditLogSearch.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-ExecAuditLogSearch.ps1 @@ -50,6 +50,11 @@ function Invoke-ExecAuditLogSearch { Add-CIPPAzDataTableEntity @Table -Entity $Entity -Force | Out-Null + # Bridge into the V2 AuditLogCoverage ledger so the pipeline picks it up automatically + # (re-queuing resets it to State 'Created' to reprocess). + $ManualStatus = if ($Search) { [string]$Search.status } else { '' } + Add-CippAuditLogCoverageManualEntry -TenantFilter $TenantFilter -SearchId $SearchId -StartTime $Entity.StartTime -EndTime $Entity.EndTime -SearchStatus $ManualStatus + $DisplayName = $Entity.DisplayName Write-LogMessage -headers $Headers -API $APIName -message "Queued search for processing: $($Search.displayName)" -Sev 'Info' -tenant $TenantFilter From 034366b4e0b29d1164e59954494354c6fc56faca Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Tue, 30 Jun 2026 17:41:56 +0800 Subject: [PATCH 048/170] Fixes for array value business phone number in templates --- .../Users/Invoke-AddUserDefaults.ps1 | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-AddUserDefaults.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-AddUserDefaults.ps1 index 04bb3c7a4fad5..dd9793aa4c126 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-AddUserDefaults.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-AddUserDefaults.ps1 @@ -73,13 +73,13 @@ function Invoke-AddUserDefaults { # Contact fields $MobilePhone = $Request.Body.mobilePhone - $BusinessPhones = if ($null -ne $Request.Body.businessPhones) { - if ($Request.Body.businessPhones -is [array]) { $Request.Body.businessPhones[0] } else { $Request.Body.businessPhones } - } elseif ($null -ne $Request.Body.'businessPhones[0]') { - $Request.Body.'businessPhones[0]' - } else { - $null - } + $BusinessPhones = @( + if ($null -ne $Request.Body.businessPhones) { + $Request.Body.businessPhones | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } + } elseif ($null -ne $Request.Body.'businessPhones[0]') { + $Request.Body.'businessPhones[0]' | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } + } + ) $OtherMails = $Request.Body.otherMails # User relations From 6089106327bd8e520eb7d59f84d2067656e27a09 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Tue, 30 Jun 2026 18:39:58 +0800 Subject: [PATCH 049/170] Fix for adding groups failing for sec groups --- .../Identity/Administration/Users/Invoke-EditUser.ps1 | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-EditUser.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-EditUser.ps1 index e5f9f1fc482c6..af7659da83c61 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-EditUser.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-EditUser.ps1 @@ -187,12 +187,13 @@ function Invoke-EditUser { $AddToGroups | ForEach-Object { $GroupType = $_.addedFields.groupType + $CalculatedGroupType = $_.addedFields.calculatedGroupType ?? $null $GroupID = $_.value $GroupName = $_.label Write-Host "About to add $($UserObj.userPrincipalName) to $GroupName. Group ID is: $GroupID and type is: $GroupType" try { - if ($GroupType -eq 'distributionList' -or $GroupType -eq 'security') { + if ($GroupType -eq 'distributionList' -or $GroupType -eq 'security' -and ($calculatedGroupType -ne 'generic' )) { Write-Host 'Adding to group via Add-DistributionGroupMember' $Params = @{ Identity = $GroupID; Member = $UserObj.id; BypassSecurityGroupManagerCheck = $true } $null = New-ExoRequest -tenantid $UserObj.tenantFilter -cmdlet 'Add-DistributionGroupMember' -cmdParams $params -UseSystemMailbox $true @@ -219,12 +220,13 @@ function Invoke-EditUser { $RemoveFromGroups | ForEach-Object { $GroupType = $_.addedFields.groupType + $CalculatedGroupType = $_.addedFields.calculatedGroupType ?? $null $GroupID = $_.value $GroupName = $_.label Write-Host "About to remove $($UserObj.userPrincipalName) from $GroupName. Group ID is: $GroupID and type is: $GroupType" try { - if ($GroupType -eq 'distributionList' -or $GroupType -eq 'security') { + if ($GroupType -eq 'distributionList' -or $GroupType -eq 'security' -and ($calculatedGroupType -ne 'generic' )) { Write-Host 'Removing From group via Remove-DistributionGroupMember' $Params = @{ Identity = $GroupID; Member = $UserObj.id; BypassSecurityGroupManagerCheck = $true } $null = New-ExoRequest -tenantid $UserObj.tenantFilter -cmdlet 'Remove-DistributionGroupMember' -cmdParams $params -UseSystemMailbox $true From 6205e97c2cbf4dfb58f20878dd3b76b1b4169b69 Mon Sep 17 00:00:00 2001 From: Bobby <31723128+kris6673@users.noreply.github.com> Date: Tue, 30 Jun 2026 13:46:30 +0200 Subject: [PATCH 050/170] fix: modernize and improve logging --- .../Invoke-ExecDeleteGDAPRelationship.ps1 | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/GDAP/Invoke-ExecDeleteGDAPRelationship.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/GDAP/Invoke-ExecDeleteGDAPRelationship.ps1 index 8c0898c318ef9..d0f4ed0b234cd 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/GDAP/Invoke-ExecDeleteGDAPRelationship.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/GDAP/Invoke-ExecDeleteGDAPRelationship.ps1 @@ -1,4 +1,4 @@ -Function Invoke-ExecDeleteGDAPRelationship { +function Invoke-ExecDeleteGDAPRelationship { <# .FUNCTIONALITY Entrypoint,AnyTenant @@ -13,19 +13,23 @@ Function Invoke-ExecDeleteGDAPRelationship { # Interact with query parameters or the body of the request. - $GDAPID = $Request.Query.GDAPId ?? $Request.Body.GDAPId + $GDAPId = $Request.Query.GDAPId ?? $Request.Body.GDAPId try { - $DELETE = New-GraphPostRequest -NoAuthCheck $True -uri "https://graph.microsoft.com/beta/tenantRelationships/delegatedAdminRelationships/$($GDAPID)/requests" -type POST -body '{"action":"terminate"}' -tenantid $env:TenantID - $Results = [pscustomobject]@{'Results' = "Success. GDAP relationship for $($GDAPID) been revoked" } - Write-LogMessage -headers $Headers -API $APIName -message "Success. GDAP relationship for $($GDAPID) been revoked" -Sev 'Info' + $null = New-GraphPostRequest -NoAuthCheck $True -uri "https://graph.microsoft.com/beta/tenantRelationships/delegatedAdminRelationships/$($GDAPId)/requests" -type POST -body '{"action":"terminate"}' -tenantid $env:TenantID + $Result = "Success. GDAP relationship for $($GDAPId) been revoked" + Write-LogMessage -headers $Headers -API $APIName -message $Result -Sev 'Info' + $StatusCode = [HttpStatusCode]::OK } catch { - $Results = [pscustomobject]@{'Results' = "Failed. $($_.Exception.Message)" } + $ErrorMessage = Get-CippException -Exception $_ + $Result = "Failed to revoke GDAP relationship for $($GDAPId). Error: $($ErrorMessage.NormalizedError)" + $StatusCode = [HttpStatusCode]::InternalServerError + Write-LogMessage -headers $Headers -API $APIName -message $Result -Sev 'Error' -LogData $ErrorMessage } return ([HttpResponseContext]@{ - StatusCode = [HttpStatusCode]::OK - Body = $Results + StatusCode = $StatusCode + Body = @{ 'Results' = $Result } }) } From 7313bd9dc9fec80fbf1be225968bc55238dc5fa6 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Tue, 30 Jun 2026 21:55:31 +0800 Subject: [PATCH 051/170] Update CIPPSharp --- Shared/CIPPSharp/bin/CIPPSharp.dll | Bin 44544 -> 44544 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/Shared/CIPPSharp/bin/CIPPSharp.dll b/Shared/CIPPSharp/bin/CIPPSharp.dll index 9f352615961ffbce098cba92da1f221d9bf52536..c6c5b41d02c2e4e84e2bb8faae1154cc0ebfada8 100644 GIT binary patch delta 170 zcmZp;!_;txX+j5!e(aCg8+$fP5m;d=d4Rj@yj$DsoMzD=9*507Q~&a7m>F1D8XA}x z8<{7iSfrX78Kfqervyh>#x4Z F1OP@(f$GxaaOMpB}AvPEi= zWvZEZYO=ABSz20hVsf%+ikYcNqD87fQfg{Sa*~;G^5(*&`K Date: Tue, 30 Jun 2026 10:11:13 -0400 Subject: [PATCH 052/170] fix: rereun detection logic --- Modules/CIPPCore/Public/Test-CIPPRerun.ps1 | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/Modules/CIPPCore/Public/Test-CIPPRerun.ps1 b/Modules/CIPPCore/Public/Test-CIPPRerun.ps1 index 6a259ea06a9d4..740056e8e8b03 100644 --- a/Modules/CIPPCore/Public/Test-CIPPRerun.ps1 +++ b/Modules/CIPPCore/Public/Test-CIPPRerun.ps1 @@ -28,9 +28,12 @@ function Test-CIPPRerun { } } - # Use BaseTime if provided, otherwise use current time - $CurrentUnixTime = if ($BaseTime -gt 0) { $BaseTime } else { [int][double]::Parse((Get-Date -UFormat %s)) } - $EstimatedNextRun = $CurrentUnixTime + $EstimatedDifference + # Real wall-clock time, used to decide whether enough time has actually elapsed to allow a rerun. + $Now = [int64](([datetime]::UtcNow) - (Get-Date '1/1/1970')).TotalSeconds + # Anchor time for *recording* the next run. For scheduled tasks we anchor to the task's + # ScheduledTime (BaseTime) so EstimatedNextRun aligns to the schedule; otherwise use now. + $AnchorTime = if ($BaseTime -gt 0) { $BaseTime } else { $Now } + $EstimatedNextRun = $AnchorTime + $EstimatedDifference try { $Filters = [System.Collections.Generic.List[string]]::new() @@ -63,7 +66,7 @@ function Test-CIPPRerun { if ($NewSettings.Length -ne $PreviousSettings.Length) { Write-Host "$($NewSettings.Length) vs $($PreviousSettings.Length) - settings have changed." $RerunData | Add-Member -MemberType NoteProperty -Name 'EstimatedNextRun' -Value $EstimatedNextRun -Force - $RerunData | Add-Member -MemberType NoteProperty -Name 'LastScheduledTime' -Value "$CurrentUnixTime" -Force + $RerunData | Add-Member -MemberType NoteProperty -Name 'LastScheduledTime' -Value "$AnchorTime" -Force $RerunData | Add-Member -MemberType NoteProperty -Name 'Settings' -Value "$($Settings | ConvertTo-Json -Depth 10 -Compress)" -Force Add-CIPPAzDataTableEntity @RerunTable -Entity $RerunData -Force return $false # Not a rerun because settings have changed. @@ -79,24 +82,24 @@ function Test-CIPPRerun { Add-CIPPAzDataTableEntity @RerunTable -Entity $RerunData -Force return $false } - if ($RerunData.EstimatedNextRun -gt $CurrentUnixTime) { + if ($RerunData.EstimatedNextRun -gt $Now) { Write-LogMessage -API $API -message "$Type rerun detected for $($API). Prevented from running again." -tenant $TenantFilter -headers $Headers -Sev 'Info' return $true } else { $RerunData | Add-Member -MemberType NoteProperty -Name 'EstimatedNextRun' -Value $EstimatedNextRun -Force - $RerunData | Add-Member -MemberType NoteProperty -Name 'LastScheduledTime' -Value "$BaseTime" -Force + $RerunData | Add-Member -MemberType NoteProperty -Name 'LastScheduledTime' -Value "$AnchorTime" -Force $RerunData | Add-Member -MemberType NoteProperty -Name 'Settings' -Value "$($Settings | ConvertTo-Json -Depth 10 -Compress)" -Force Add-CIPPAzDataTableEntity @RerunTable -Entity $RerunData -Force return $false } } else { - $EstimatedNextRun = $CurrentUnixTime + $EstimatedDifference + $EstimatedNextRun = $AnchorTime + $EstimatedDifference $NewEntity = @{ PartitionKey = "$TenantFilter" RowKey = "$($Type)_$($API)" Settings = "$($Settings | ConvertTo-Json -Depth 10 -Compress)" EstimatedNextRun = $EstimatedNextRun - LastScheduledTime = "$CurrentUnixTime" + LastScheduledTime = "$AnchorTime" } Add-CIPPAzDataTableEntity @RerunTable -Entity $NewEntity -Force return $false From c5a732ddf7f50ab819145f1b2f2cd0c2bd467c5c Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Tue, 30 Jun 2026 22:53:32 +0800 Subject: [PATCH 053/170] Update Invoke-AddSpamFilter.ps1 --- .../Email-Exchange/Spamfilter/Invoke-AddSpamFilter.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Spamfilter/Invoke-AddSpamFilter.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Spamfilter/Invoke-AddSpamFilter.ps1 index c01f5d98d6d32..f998de70ff36e 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Spamfilter/Invoke-AddSpamFilter.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Spamfilter/Invoke-AddSpamFilter.ps1 @@ -1,7 +1,7 @@ Function Invoke-AddSpamFilter { <# .FUNCTIONALITY - Entrypoint + Entrypoint,AnyTenant .ROLE Exchange.SpamFilter.ReadWrite #> From 25445c637b8866ea6165dfe6d9a3ca67fd7b4e3c Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Tue, 30 Jun 2026 23:20:30 +0800 Subject: [PATCH 054/170] Update Oauth standards with conflict information --- Config/standards.json | 8 ++++---- .../Invoke-CIPPStandardOauthConsent.ps1 | 20 ++++++++++++++++--- .../Invoke-CIPPStandardOauthConsentLowSec.ps1 | 4 ++-- 3 files changed, 23 insertions(+), 9 deletions(-) diff --git a/Config/standards.json b/Config/standards.json index 6552e8000429a..f2384c901ae76 100644 --- a/Config/standards.json +++ b/Config/standards.json @@ -1587,8 +1587,8 @@ "ZTNA21807", "ZTNA21810" ], - "helpText": "Disables users from being able to consent to applications, except for those specified in the field below", - "docsDescription": "Requires users to get administrator consent before sharing data with applications. You can preapprove specific applications.", + "helpText": "Disables users from being able to consent to applications, except for those specified in the field below. This standard conflicts with the \"Allow users to consent to applications with low security risk\" standard; only one of the two should be assigned per tenant.", + "docsDescription": "Requires users to get administrator consent before sharing data with applications. You can preapprove specific applications. This standard conflicts with the \"Allow users to consent to applications with low security risk\" (OauthConsentLowSec) standard. Enabling both on the same tenant causes a remediation conflict, so only assign one.", "executiveText": "Requires administrative approval before employees can grant applications access to company data, preventing unauthorized data sharing and potential security breaches. This protects against malicious applications while allowing approved business tools to function normally.", "addedComponent": [ { @@ -1609,8 +1609,8 @@ "name": "standards.OauthConsentLowSec", "cat": "Entra (AAD) Standards", "tag": ["IntegratedApps"], - "helpText": "Sets the default oauth consent level so users can consent to applications that have low risks.", - "docsDescription": "Allows users to consent to applications with low assigned risk.", + "helpText": "Sets the default oauth consent level so users can consent to applications that have low risks. This standard conflicts with the \"Require admin consent for applications\" standard; only one of the two should be assigned per tenant.", + "docsDescription": "Allows users to consent to applications with low assigned risk. This standard conflicts with the \"Require admin consent for applications (Prevent OAuth phishing)\" (OauthConsent) standard. Enabling both on the same tenant causes a remediation conflict, so only assign one.", "executiveText": "Allows employees to approve low-risk applications without administrative intervention, balancing security with productivity. This provides a middle ground between complete restriction and open access, enabling business agility while maintaining protection against high-risk applications.", "label": "Allow users to consent to applications with low security risk (Prevent OAuth phishing. Lower impact, less secure)", "impact": "Medium Impact", diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardOauthConsent.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardOauthConsent.ps1 index f986c4446e3c2..c9bc356ccec04 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardOauthConsent.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardOauthConsent.ps1 @@ -7,8 +7,8 @@ function Invoke-CIPPStandardOauthConsent { .SYNOPSIS (Label) Require admin consent for applications (Prevent OAuth phishing) .DESCRIPTION - (Helptext) Disables users from being able to consent to applications, except for those specified in the field below - (DocsDescription) Requires users to get administrator consent before sharing data with applications. You can preapprove specific applications. + (Helptext) Disables users from being able to consent to applications, except for those specified in the field below. This standard conflicts with the "Allow users to consent to applications with low security risk" standard; only one of the two should be assigned per tenant. + (DocsDescription) Requires users to get administrator consent before sharing data with applications. You can preapprove specific applications. This standard conflicts with the "Allow users to consent to applications with low security risk" (OauthConsentLowSec) standard. Enabling both on the same tenant causes a remediation conflict, so only assign one. .NOTES CAT Entra (AAD) Standards @@ -66,7 +66,13 @@ function Invoke-CIPPStandardOauthConsent { } $StateIsCorrect = if ($State.permissionGrantPolicyIdsAssignedToDefaultUserRole -eq 'ManagePermissionGrantsForSelf.cipp-consent-policy') { $true } else { $false } - if ($Settings.remediate -eq $true) { + $Standards = Get-CIPPStandards -Tenant $tenant + $ConflictingStandard = $Standards | Where-Object -Property Standard -EQ 'OauthConsentLowSec' + + if ($Settings.remediate -eq $true -and $ConflictingStandard -and $State.permissionGrantPolicyIdsAssignedToDefaultUserRole -contains 'ManagePermissionGrantsForSelf.microsoft-user-default-low') { + # A conflicting low security OAuth consent standard is enabled and currently applied. Skip remediation so we don't fight the other standard, but still fall through to alert/report. + Write-LogMessage -API 'Standards' -tenant $tenant -message 'There is a conflicting OAuth Consent policy standard enabled for this tenant. Remove the Allow users to consent to applications with low security risk (Prevent OAuth phishing. Lower impact, less secure) standard from this tenant to apply the require admin consent standard.' -sev Error + } elseif ($Settings.remediate -eq $true) { $DidRemediationChange = $false try { if (-not $CompareIncludesFetched) { @@ -222,6 +228,14 @@ function Invoke-CIPPStandardOauthConsent { permissionGrantPolicyIdsAssignedToDefaultUserRole = $State.permissionGrantPolicyIdsAssignedToDefaultUserRole includes = $CurrentIncludesForCompare } + # Add conflicting standard info if applicable + if ($ConflictingStandard) { + $CurrentValue.conflictingStandard = @{ + name = $ConflictingStandard.Standard + templateid = $ConflictingStandard.TemplateId + } + } + $ExpectedValue = @{ permissionGrantPolicyIdsAssignedToDefaultUserRole = @('ManagePermissionGrantsForSelf.cipp-consent-policy') includes = $ExpectedIncludesForCompare diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardOauthConsentLowSec.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardOauthConsentLowSec.ps1 index bd359c2bba5f4..186f02face965 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardOauthConsentLowSec.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardOauthConsentLowSec.ps1 @@ -7,8 +7,8 @@ function Invoke-CIPPStandardOauthConsentLowSec { .SYNOPSIS (Label) Allow users to consent to applications with low security risk (Prevent OAuth phishing. Lower impact, less secure) .DESCRIPTION - (Helptext) Sets the default oauth consent level so users can consent to applications that have low risks. - (DocsDescription) Allows users to consent to applications with low assigned risk. + (Helptext) Sets the default oauth consent level so users can consent to applications that have low risks. This standard conflicts with the "Require admin consent for applications" standard; only one of the two should be assigned per tenant. + (DocsDescription) Allows users to consent to applications with low assigned risk. This standard conflicts with the "Require admin consent for applications (Prevent OAuth phishing)" (OauthConsent) standard. Enabling both on the same tenant causes a remediation conflict, so only assign one. .NOTES CAT Entra (AAD) Standards From e77de9803ec68a12a52e796eb3dd2b8db3e2789b Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Wed, 1 Jul 2026 00:04:20 +0800 Subject: [PATCH 055/170] CIPP Rogue App List --- Config/MaliciousApps.json | 1074 +++++++++++++++++ .../Alerts/Get-CIPPAlertHuntressRogueApps.ps1 | 21 +- 2 files changed, 1092 insertions(+), 3 deletions(-) create mode 100644 Config/MaliciousApps.json diff --git a/Config/MaliciousApps.json b/Config/MaliciousApps.json new file mode 100644 index 0000000000000..b212e1153a8ea --- /dev/null +++ b/Config/MaliciousApps.json @@ -0,0 +1,1074 @@ +{ + "metadata": { + "name": "CIPP Malicious Applications", + "description": "Curated list of OAuth / Entra ID enterprise applications that have been observed being abused by threat actors against Microsoft 365 tenants. Entries are matched on appId (the Application/Client ID of the service principal that appears in a tenant after consent).", + "permissionTypes": [ + "Delegated", + "Application" + ], + "resources": [ + "Microsoft Graph", + "Office 365 Exchange Online", + "SharePoint" + ], + "categoryDefinitions": { + "Mailbox exfiltration": "Application synchronizes or exports the contents of a user's mailbox, enabling bulk theft of email.", + "Address book exfiltration": "Application harvests contacts or directory information for reconnaissance and follow-on phishing.", + "Sharepoint/OneDrive exfiltration": "Application reads or downloads files from SharePoint Online or OneDrive for Business.", + "Data exfiltration": "Application is used to move data out of the tenant to an attacker-controlled location.", + "Reconnaissance": "Application is used to enumerate users, contacts, or organizational data to identify targets.", + "Phishing": "Application is used to send phishing email or otherwise facilitate phishing campaigns.", + "Persistence": "Application grants the attacker durable, consent-based access that survives a password reset.", + "Business Email Compromise": "Application observed as part of a BEC intrusion (mailbox access, fraudulent email, financial redirection)." + }, + "applicationFields": { + "name": "Display name of the application as it appears on the consent screen / enterprise application.", + "appId": "Application (client) ID of the service principal. Primary key used for detection.", + "description": "What the application is / does.", + "categories": "Controlled-vocabulary classifications (see categoryDefinitions).", + "tags": "Free-form labels.", + "permissions": "Array of { scope, type, resource } granted to or requested by the app.", + "mitreAttackIds": "Relevant MITRE ATT&CK technique IDs.", + "publisher": "Publisher identity { name, id, verified } where known.", + "appOwnerOrganizationId": "Tenant ID that owns the multi-tenant application, where known.", + "references": "Write-ups, threat-intel articles, or official documentation.", + "detection": "Hunting / detection guidance specific to this application.", + "relatedAppIds": "appIds of related entries (e.g. renamed versions of the same app).", + "notes": "Caveats, data-quality flags, or additional context." + } + }, + "applications": [ + { + "name": "CloudSponge", + "appId": "a43e5392-f48b-46a4-a0f1-098b5eeb4757", + "description": "CloudSponge is a software-as-a-service product that imports contacts from all the major address books. It is embedded by websites so that users do not have to type email addresses into referral / invite forms.", + "categories": [ + "Address book exfiltration" + ], + "tags": [], + "permissions": [], + "mitreAttackIds": [], + "publisher": { + "name": null, + "id": null, + "verified": null + }, + "appOwnerOrganizationId": null, + "references": [], + "detection": null, + "relatedAppIds": [], + "notes": null + }, + { + "name": "CubeBackup", + "appId": "412445a2-0794-487e-9dd6-d57d9593b249", + "description": "CubeBackup is a self-hosted cloud backup solution that integrates with Microsoft 365, including Exchange Online, SharePoint and OneDrive. With sufficient privileges it can be leveraged by threat actors to conduct automated, mass exfiltration from a victim's environment.", + "categories": [ + "Data exfiltration", + "Mailbox exfiltration", + "Sharepoint/OneDrive exfiltration", + "Business Email Compromise" + ], + "tags": [ + "BEC", + "Exfiltration" + ], + "permissions": [ + { + "scope": "Calendars.ReadWrite", + "type": "Application", + "resource": "Microsoft Graph" + }, + { + "scope": "Channel.Create", + "type": "Application", + "resource": "Microsoft Graph" + }, + { + "scope": "Channel.ReadBasic.All", + "type": "Application", + "resource": "Microsoft Graph" + }, + { + "scope": "ChannelMember.ReadWrite.All", + "type": "Application", + "resource": "Microsoft Graph" + }, + { + "scope": "ChannelMessage.Read.All", + "type": "Application", + "resource": "Microsoft Graph" + }, + { + "scope": "ChannelSettings.ReadWrite.All", + "type": "Application", + "resource": "Microsoft Graph" + }, + { + "scope": "Contacts.ReadWrite.All", + "type": "Application", + "resource": "Microsoft Graph" + }, + { + "scope": "Directory.ReadWrite.All", + "type": "Application", + "resource": "Microsoft Graph" + }, + { + "scope": "Files.ReadWrite.All", + "type": "Application", + "resource": "Microsoft Graph" + }, + { + "scope": "Group.ReadWrite.All", + "type": "Application", + "resource": "Microsoft Graph" + }, + { + "scope": "Mail.ReadWrite", + "type": "Application", + "resource": "Microsoft Graph" + }, + { + "scope": "Sites.FullControl.All", + "type": "Application", + "resource": "Microsoft Graph" + }, + { + "scope": "Team.Create", + "type": "Application", + "resource": "Microsoft Graph" + }, + { + "scope": "Team.ReadBasic.All", + "type": "Application", + "resource": "Microsoft Graph" + }, + { + "scope": "TeamMember.ReadWrite.All", + "type": "Application", + "resource": "Microsoft Graph" + }, + { + "scope": "TeamSettings.ReadWrite.All", + "type": "Application", + "resource": "Microsoft Graph" + }, + { + "scope": "TeamsTab.Create", + "type": "Application", + "resource": "Microsoft Graph" + }, + { + "scope": "TeamsTab.ReadWrite.All", + "type": "Application", + "resource": "Microsoft Graph" + }, + { + "scope": "TeamsAppInstallation.ReadWriteForTeam.All", + "type": "Application", + "resource": "Microsoft Graph" + }, + { + "scope": "User.ReadWrite.All", + "type": "Application", + "resource": "Microsoft Graph" + }, + { + "scope": "full_access_as_app", + "type": "Application", + "resource": "Office 365 Exchange Online" + }, + { + "scope": "Sites.FullControl.All", + "type": "Application", + "resource": "SharePoint" + } + ], + "mitreAttackIds": [ + "T1537", + "T1567", + "T1020" + ], + "publisher": { + "name": "CubeBackup, Inc.", + "id": "cubebackupinc1662619479161", + "verified": null + }, + "appOwnerOrganizationId": null, + "references": [ + "https://www.cubebackup.com/" + ], + "detection": null, + "relatedAppIds": [], + "notes": null + }, + { + "name": "Edison Mail", + "appId": "62db40a4-2c7e-4373-a609-eda138798962", + "description": "Email client with full Microsoft 365 synchronization capabilities.", + "categories": [ + "Mailbox exfiltration" + ], + "tags": [], + "permissions": [], + "mitreAttackIds": [], + "publisher": { + "name": null, + "id": null, + "verified": null + }, + "appOwnerOrganizationId": null, + "references": [], + "detection": "Unified Audit Log typically records synced items as MailItemsAccessed events. Hunt by App ID.", + "relatedAppIds": [], + "notes": null + }, + { + "name": "eM Client", + "appId": "e9a7fea1-1cc0-4cd9-a31b-9137ca5deedd", + "description": "eM Client is a desktop email client with full Microsoft Office 365 synchronization. Abused to bulk-synchronize and exfiltrate a compromised mailbox and to maintain access.", + "categories": [ + "Mailbox exfiltration", + "Persistence" + ], + "tags": [], + "permissions": [ + { + "scope": "EWS.AccessAsUser.All", + "type": "Delegated", + "resource": "Office 365 Exchange Online" + }, + { + "scope": "offline_access", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "email", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "openid", + "type": "Delegated", + "resource": "Microsoft Graph" + } + ], + "mitreAttackIds": [], + "publisher": { + "name": null, + "id": null, + "verified": null + }, + "appOwnerOrganizationId": null, + "references": [ + "https://www.huntress.com/blog/legitimate-apps-as-traitorware-for-persistent-microsoft-365-compromise", + "https://cybercorner.tech/malicious-usage-of-em-client-in-business-email-compromise/" + ], + "detection": "Unified Audit Log typically records synced items as MailItemsAccessed events. Hunt by App ID.", + "relatedAppIds": [], + "notes": null + }, + { + "name": "Fastmail", + "appId": "77468577-4f6e-40e7-b745-11d3d0c28095", + "description": "Fastmail is an alternative email service that allows import/export from various email providers, including Microsoft 365. If a victim consents, all email can be exfiltrated to an attacker-controlled Fastmail account, with the option to continue exfiltrating mail post-consent.", + "categories": [ + "Mailbox exfiltration", + "Persistence" + ], + "tags": [ + "Persistence", + "Exfiltration" + ], + "permissions": [ + { + "scope": "openid", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "email", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "offline_access", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "IMAP.AccessAsUser.All", + "type": "Delegated", + "resource": "Office 365 Exchange Online" + }, + { + "scope": "SMTP.Send", + "type": "Delegated", + "resource": "Office 365 Exchange Online" + } + ], + "mitreAttackIds": [ + "T1114.002", + "T1567.002", + "T1136.003", + "T1098.001" + ], + "publisher": { + "name": null, + "id": null, + "verified": false + }, + "appOwnerOrganizationId": "9188040d-6c67-4c5b-b112-36a304b66dad", + "references": [ + "https://x.com/mwaski88/status/1775904383382802502", + "https://cybercorner.tech/common-oauth-apps-used-in-business-email-compromise/#Fastmail", + "https://www.fastmail.help/hc/en-us/articles/360060590593-Migrate-to-Fastmail-from-another-provider" + ], + "detection": null, + "relatedAppIds": [], + "notes": "appOwnerOrganizationId 9188040d-6c67-4c5b-b112-36a304b66dad is the Microsoft consumer (personal accounts) tenant. Publisher reported as not verified." + }, + { + "name": "Foxmail", + "appId": "231575bc-9f6c-4539-9241-5cfae696b630", + "description": "Foxmail is a desktop email client. Observed consented in a business email compromise with the full set of legacy Exchange protocol scopes, enabling full mailbox synchronization.", + "categories": [ + "Mailbox exfiltration", + "Business Email Compromise" + ], + "tags": [ + "BEC" + ], + "permissions": [ + { + "scope": "offline_access", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "openid", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "email", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "profile", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "POP.AccessAsUser.All", + "type": "Delegated", + "resource": "Office 365 Exchange Online" + }, + { + "scope": "IMAP.AccessAsUser.All", + "type": "Delegated", + "resource": "Office 365 Exchange Online" + }, + { + "scope": "SMTP.Send", + "type": "Delegated", + "resource": "Office 365 Exchange Online" + }, + { + "scope": "EAS.AccessAsUser.All", + "type": "Delegated", + "resource": "Office 365 Exchange Online" + }, + { + "scope": "EWS.AccessAsUser.All", + "type": "Delegated", + "resource": "Office 365 Exchange Online" + } + ], + "mitreAttackIds": [], + "publisher": { + "name": null, + "id": null, + "verified": null + }, + "appOwnerOrganizationId": null, + "references": [], + "detection": null, + "relatedAppIds": [], + "notes": null + }, + { + "name": "Horizon Tech", + "appId": "b1c4926a-5fb6-4aad-b920-709c957be148", + "description": "Pulls email and contacts and sends phishing emails from the compromised mailbox.", + "categories": [ + "Mailbox exfiltration", + "Address book exfiltration", + "Phishing", + "Persistence", + "Business Email Compromise" + ], + "tags": [ + "BEC", + "persistence", + "spam", + "phishing", + "email abuse" + ], + "permissions": [ + { + "scope": "Mail.Read", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "offline_access", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "User.ReadBasic.All", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "Mail.ReadWrite", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "Mail.Send", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "MailboxSettings.ReadWrite", + "type": "Delegated", + "resource": "Microsoft Graph" + } + ], + "mitreAttackIds": [ + "T1114", + "T1566", + "T1071.001" + ], + "publisher": { + "name": null, + "id": null, + "verified": null + }, + "appOwnerOrganizationId": null, + "references": [], + "detection": null, + "relatedAppIds": [], + "notes": null + }, + { + "name": "Jotform", + "appId": "9af771d1-1288-43f0-91a6-adadcbd212b5", + "description": "Jotform is an online form and survey builder. Abused as a native phishing / spam vector after Microsoft 365 SSO consent.", + "categories": [ + "Phishing", + "Business Email Compromise" + ], + "tags": [ + "BEC", + "spam", + "phishing" + ], + "permissions": [ + { + "scope": "User.Read", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "openid", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "profile", + "type": "Delegated", + "resource": "Microsoft Graph" + } + ], + "mitreAttackIds": [], + "publisher": { + "name": null, + "id": null, + "verified": null + }, + "appOwnerOrganizationId": "261450de-982f-4efb-a5fe-0fc2d9d08717", + "references": [ + "https://www.jotform.com", + "https://www.bleepingcomputer.com/news/security/the-rise-of-native-phishing-microsoft-365-apps-abused-in-attacks/" + ], + "detection": null, + "relatedAppIds": [], + "notes": null + }, + { + "name": "Mail_Backup", + "appId": "2ef68ccc-8a4d-42ff-ae88-2d7bb89ad139", + "description": "Exports mailboxes for backup purposes; used by threat actors to exfiltrate email. This is the renamed successor to PerfectData Software.", + "categories": [ + "Mailbox exfiltration" + ], + "tags": [], + "permissions": [ + { + "scope": "offline_access", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "profile", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "User.Read", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "openid", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "Mail.Read", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "MailboxFolder.Read", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "Contacts.Read", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "Calendars.Read", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "MailboxSettings.Read", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "Mail.ReadWrite", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "MailboxFolder.ReadWrite", + "type": "Delegated", + "resource": "Microsoft Graph" + } + ], + "mitreAttackIds": [], + "publisher": { + "name": null, + "id": null, + "verified": null + }, + "appOwnerOrganizationId": null, + "references": [ + "https://cybercorner.tech/malicious-azure-application-perfectdata-software-and-office365-business-email-compromise/", + "https://darktrace.com/blog/how-abuse-of-perfectdata-software-may-create-a-perfect-storm-an-emerging-trend-in-account-takeovers", + "https://www.secureworks.com/blog/qr-phishing-leads-to-microsoft-365-account-compromise" + ], + "detection": "Updated version of PerfectData Software. Unified Audit Log surfaces synced items as MailItemsAccessed events. Hunt by App ID.", + "relatedAppIds": [ + "ff8d92dc-3d82-41d6-bcbd-b9174d163620" + ], + "notes": null + }, + { + "name": "Newsletter Software Supermailer", + "appId": "a245e8c0-b53c-4b67-9b45-751d1dff8e6b", + "description": "Bulk email sending software. Abused to send phishing / spam from a compromised mailbox.", + "categories": [ + "Phishing" + ], + "tags": [], + "permissions": [ + { + "scope": "offline_access", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "Mail.Send", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "Mail.Read", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "Contacts.Read", + "type": "Delegated", + "resource": "Microsoft Graph" + } + ], + "mitreAttackIds": [], + "publisher": { + "name": null, + "id": null, + "verified": null + }, + "appOwnerOrganizationId": null, + "references": [ + "https://www.huntress.com/blog/legitimate-apps-as-traitorware-for-persistent-microsoft-365-compromise" + ], + "detection": null, + "relatedAppIds": [], + "notes": null + }, + { + "name": "PerfectData Software", + "appId": "ff8d92dc-3d82-41d6-bcbd-b9174d163620", + "description": "Exports mailboxes for backup purposes. Widely abused for Office 365 business email compromise to bulk-export a victim mailbox.", + "categories": [ + "Mailbox exfiltration" + ], + "tags": [], + "permissions": [ + { + "scope": "offline_access", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "profile", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "User.Read", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "openid", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "EWS.AccessAsUser.All", + "type": "Delegated", + "resource": "Office 365 Exchange Online" + } + ], + "mitreAttackIds": [], + "publisher": { + "name": null, + "id": null, + "verified": null + }, + "appOwnerOrganizationId": null, + "references": [ + "https://cybercorner.tech/malicious-azure-application-perfectdata-software-and-office365-business-email-compromise/", + "https://darktrace.com/blog/how-abuse-of-perfectdata-software-may-create-a-perfect-storm-an-emerging-trend-in-account-takeovers", + "https://www.secureworks.com/blog/qr-phishing-leads-to-microsoft-365-account-compromise" + ], + "detection": "The Unified Audit Log initially did not record items synced by this app; it now surfaces them as MailItemsAccessed events. Hunt by App ID.", + "relatedAppIds": [ + "2ef68ccc-8a4d-42ff-ae88-2d7bb89ad139" + ], + "notes": "Renamed/superseded by 'Mail_Backup' (2ef68ccc-8a4d-42ff-ae88-2d7bb89ad139)." + }, + { + "name": "PostBox", + "appId": "179d5108-412b-4c95-8e34-06786784ab39", + "description": "Desktop email client with full Microsoft 365 synchronization capabilities. Abused for mailbox exfiltration and persistence.", + "categories": [ + "Mailbox exfiltration", + "Persistence" + ], + "tags": [], + "permissions": [ + { + "scope": "IMAP.AccessAsUser.All", + "type": "Delegated", + "resource": "Office 365 Exchange Online" + }, + { + "scope": "POP.AccessAsUser.All", + "type": "Delegated", + "resource": "Office 365 Exchange Online" + }, + { + "scope": "SMTP.Send", + "type": "Delegated", + "resource": "Office 365 Exchange Online" + }, + { + "scope": "offline_access", + "type": "Delegated", + "resource": "Microsoft Graph" + } + ], + "mitreAttackIds": [], + "publisher": { + "name": null, + "id": null, + "verified": null + }, + "appOwnerOrganizationId": null, + "references": [], + "detection": null, + "relatedAppIds": [], + "notes": null + }, + { + "name": "rclone", + "appId": "4761b959-9780-4c2d-87a3-512b4638f767", + "description": "rclone is a command-line program for managing files across cloud storage providers. Abused to bulk-download SharePoint Online / OneDrive content from a compromised tenant.", + "categories": [ + "Data exfiltration", + "Sharepoint/OneDrive exfiltration" + ], + "tags": [], + "permissions": [ + { + "scope": "Files.Read", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "Files.ReadWrite", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "Files.Read.All", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "Files.ReadWrite.All", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "offline_access", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "User.Read", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "Sites.Read.All", + "type": "Delegated", + "resource": "Microsoft Graph" + } + ], + "mitreAttackIds": [], + "publisher": { + "name": null, + "id": null, + "verified": null + }, + "appOwnerOrganizationId": null, + "references": [ + "https://www.kroll.com/en/insights/publications/cyber/new-m365-business-email-compromise-attacks-with-rclone" + ], + "detection": null, + "relatedAppIds": [], + "notes": null + }, + { + "name": "SigParser", + "appId": "caffae8c-0882-4c81-9a27-d1803af53a40", + "description": "SigParser scans emails, calendars, address books, spreadsheets and more to automatically generate profiles on the people and companies who have interacted with a business. Abused for address-book / contact harvesting.", + "categories": [ + "Address book exfiltration" + ], + "tags": [], + "permissions": [], + "mitreAttackIds": [], + "publisher": { + "name": null, + "id": null, + "verified": null + }, + "appOwnerOrganizationId": null, + "references": [ + "https://sigparser.com/" + ], + "detection": null, + "relatedAppIds": [], + "notes": null + }, + { + "name": "Spike", + "appId": "946c777c-bc85-489e-b034-392389ae23d6", + "description": "Spike is a conversational email client and team-collaboration app. Abused for mailbox exfiltration, persistence and phishing.", + "categories": [ + "Mailbox exfiltration", + "Persistence", + "Phishing" + ], + "tags": [], + "permissions": [ + { + "scope": "offline_access", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "Mail.ReadWrite", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "Mail.Send", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "User.Read", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "People.Read", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "MailboxSettings.ReadWrite", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "Calendars.ReadWrite", + "type": "Delegated", + "resource": "Microsoft Graph" + } + ], + "mitreAttackIds": [], + "publisher": { + "name": null, + "id": null, + "verified": null + }, + "appOwnerOrganizationId": null, + "references": [ + "https://darktrace.com/blog/breakdown-of-a-multi-account-compromise-within-office-365" + ], + "detection": null, + "relatedAppIds": [], + "notes": null + }, + { + "name": "Teleforge Directory", + "appId": "1a9b8d93-0d60-4835-896f-83016de95ff5", + "description": "Used for business email compromise. Installed shortly after a breach and after an additional MFA authentication device was added; data was collected for roughly a week, after which fraudulent emails were sent.", + "categories": [ + "Business Email Compromise", + "Mailbox exfiltration" + ], + "tags": [ + "BEC" + ], + "permissions": [ + { + "scope": "Mail.Read", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "offline_access", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "User.ReadBasic.All", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "Mail.ReadWrite", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "Mail.Send", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "MailboxSettings.ReadWrite", + "type": "Delegated", + "resource": "Microsoft Graph" + } + ], + "mitreAttackIds": [ + "T1119" + ], + "publisher": { + "name": "Unknown (deleted before recorded)", + "id": null, + "verified": null + }, + "appOwnerOrganizationId": "aa68a5d2-2ee2-4ff7-8193-3d037ab704b1", + "references": [], + "detection": null, + "relatedAppIds": [], + "notes": null + }, + { + "name": "ZoomInfo Communitiez Login", + "appId": "497ac034-5120-4c1a-929a-0351f5c09918", + "description": "Service principal consented to when the 'My Connections' feature within ZoomInfo is used. This feature extracts address-book and other contact information from the victim account, enabling target discovery, exfiltration and targeted phishing.", + "categories": [ + "Address book exfiltration", + "Reconnaissance", + "Phishing", + "Persistence" + ], + "tags": [ + "Discovery", + "Exfiltration", + "Persistence", + "Phishing" + ], + "permissions": [ + { + "scope": "User.Read", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "openid", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "Contacts.Read", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "Mail.Read", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "User.ReadBasic.All", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "email", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "offline_access", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "profile", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "MailboxSettings.Read", + "type": "Delegated", + "resource": "Microsoft Graph" + } + ], + "mitreAttackIds": [ + "T1530", + "T1567", + "T1087.003" + ], + "publisher": { + "name": null, + "id": null, + "verified": null + }, + "appOwnerOrganizationId": "945a9641-35c7-435c-a5a0-c8753e6dff88", + "references": [ + "https://cybercorner.tech/common-oauth-apps-used-in-business-email-compromise/#ZoomInfo" + ], + "detection": null, + "relatedAppIds": [ + "858d7e42-35f0-44b7-9033-df309239a47f" + ], + "notes": null + }, + { + "name": "Zoominfo Login", + "appId": "858d7e42-35f0-44b7-9033-df309239a47f", + "description": "ZoomInfo is a B2B business-intelligence platform with a large database of company and contact information. It can be used via SSO with a Microsoft 365 account, which creates this service principal in the tenant and can be abused for persistence and contact harvesting.", + "categories": [ + "Address book exfiltration", + "Persistence" + ], + "tags": [ + "persistence" + ], + "permissions": [ + { + "scope": "User.Read", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "email", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "openid", + "type": "Delegated", + "resource": "Microsoft Graph" + }, + { + "scope": "profile", + "type": "Delegated", + "resource": "Microsoft Graph" + } + ], + "mitreAttackIds": [ + "T1136.003" + ], + "publisher": { + "name": null, + "id": null, + "verified": null + }, + "appOwnerOrganizationId": "945a9641-35c7-435c-a5a0-c8753e6dff88", + "references": [ + "https://cybercorner.tech/common-oauth-apps-used-in-business-email-compromise/#ZoomInfo", + "https://abnormalsecurity.com/blog/cybercriminals-exploit-b2b-lead-generation-tools" + ], + "detection": null, + "relatedAppIds": [ + "497ac034-5120-4c1a-929a-0351f5c09918" + ], + "notes": null + } + ] +} diff --git a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertHuntressRogueApps.ps1 b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertHuntressRogueApps.ps1 index cb50fae892f54..ec58292bbae10 100644 --- a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertHuntressRogueApps.ps1 +++ b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertHuntressRogueApps.ps1 @@ -3,7 +3,7 @@ function Get-CIPPAlertHuntressRogueApps { .SYNOPSIS Check for rogue apps in a Tenant .DESCRIPTION - This function checks for rogue apps in the tenant by comparing the service principals in the tenant with a list of known rogue apps provided by Huntress. + This function checks for rogue apps in the tenant by comparing the service principals in the tenant with a list of known rogue apps provided by Huntress and a CIPP collections of appids. .FUNCTIONALITY Entrypoint .LINK @@ -19,8 +19,23 @@ function Get-CIPPAlertHuntressRogueApps { try { $RogueApps = Invoke-RestMethod -Uri 'https://huntresslabs.github.io/rogueapps/rogueapps.json' - $RogueAppFilter = $RogueApps.appId -join "','" - $ServicePrincipals = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/servicePrincipals?`$filter=appId in ('$RogueAppFilter')" -tenantid $TenantFilter + $CippRogueApps = (Get-Content -Path (Join-Path $env:CIPPRootPath 'Config\schemaDefinitions.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) { + $Chunk = $RogueAppIds[$i..([Math]::Min($i + 14, $RogueAppIds.Count - 1))] + @{ + id = [string]$i + method = 'GET' + url = "servicePrincipals?`$filter=appId in ('$($Chunk -join "','")')" + } + } + $Requests = @($Requests) + + $ServicePrincipals = if ($Requests.Count -gt 0) { + $Responses = New-GraphBulkRequest -Requests $Requests -tenantid $TenantFilter + foreach ($Response in $Responses) { $Response.body.value } + } # If IgnoreDisabledApps is true, filter out disabled service principals if ($InputValue -eq $true) { $ServicePrincipals = $ServicePrincipals | Where-Object { $_.accountEnabled -eq $true } From 0dcb4fb1515b2f3e40e9081a6ad6e8d747a0104b Mon Sep 17 00:00:00 2001 From: John Duprey Date: Tue, 30 Jun 2026 12:21:44 -0400 Subject: [PATCH 056/170] fix: hudu sync - replace arraylist with generic list - skip sync on archived companies --- .../Public/Hudu/Invoke-HuduExtensionSync.ps1 | 51 +++++++++++-------- 1 file changed, 30 insertions(+), 21 deletions(-) diff --git a/Modules/CippExtensions/Public/Hudu/Invoke-HuduExtensionSync.ps1 b/Modules/CippExtensions/Public/Hudu/Invoke-HuduExtensionSync.ps1 index f184e81f493e1..803865a5e3687 100644 --- a/Modules/CippExtensions/Public/Hudu/Invoke-HuduExtensionSync.ps1 +++ b/Modules/CippExtensions/Public/Hudu/Invoke-HuduExtensionSync.ps1 @@ -8,7 +8,7 @@ function Invoke-HuduExtensionSync { $TenantFilter ) try { - Connect-HuduAPI -configuration $Configuration + Connect-HuduAPI -configuration $Configuration | Out-Null $Configuration = $Configuration.Hudu $Tenant = Get-Tenants -TenantFilter $TenantFilter -IncludeErrors $CompanyResult = [PSCustomObject]@{ @@ -47,6 +47,18 @@ function Invoke-HuduExtensionSync { # Include mailboxes if needed for Hudu sync $ExtensionCache = Get-CippExtensionReportingData -TenantFilter $Tenant.defaultDomainName -IncludeMailboxes $company_id = $TenantMap.IntegrationId + $HuduCompany = Get-HuduCompanies -Id $company_id + if ($HuduCompany.archived -eq $true) { + Write-Host "Company $($HuduCompany.name) is archived. Skipping sync." + $ReturnObject = [PSCustomObject]@{ + Name = $Tenant.displayName + Users = 0 + Devices = 0 + Errors = [System.Collections.Generic.List[string]]@("Company $($HuduCompany.name) is archived. Skipping sync.") + Logs = [System.Collections.Generic.List[string]]@("Company $($HuduCompany.name) is archived. Skipping sync.") + } + return $ReturnObject + } # If tenant not found in mapping table, return error if (!$TenantMap) { @@ -121,7 +133,7 @@ function Invoke-HuduExtensionSync { } $HuduRelations = Get-HuduRelations - [System.Collections.ArrayList]$Links = @( + [System.Collections.Generic.List[object]]$Links = @( @{ Title = 'M365 Admin Portal' URL = 'https://admin.cloud.microsoft?delegatedOrg={0}' -f $Tenant.initialDomainName @@ -153,29 +165,26 @@ function Invoke-HuduExtensionSync { Icon = 'fas fa-server' } ) - if($Configuration.IncludeDefenderLink) - { + if ($Configuration.IncludeDefenderLink) { $Links.Add(@{ - Title = 'Defender Portal' - URL = 'https://security.microsoft.com/?tid={0}' -f $Tenant.customerId - Icon = 'fas fa-shield' - }) + Title = 'Defender Portal' + URL = 'https://security.microsoft.com/?tid={0}' -f $Tenant.customerId + Icon = 'fas fa-shield' + }) } - if($Configuration.IncludeComplianceLink) - { + if ($Configuration.IncludeComplianceLink) { $Links.Add(@{ - Title = 'Compliance Portal' - URL = 'https://compliance.microsoft.com/?tid={0}' -f $Tenant.customerId - Icon = 'fas fa-caret-up' - }) + Title = 'Compliance Portal' + URL = 'https://compliance.microsoft.com/?tid={0}' -f $Tenant.customerId + Icon = 'fas fa-caret-up' + }) } - if($Configuration.IncludeParterCenterLink) - { + if ($Configuration.IncludeParterCenterLink) { $Links.Add(@{ - Title = 'Partner Center Portals' - URL = 'https://partner.microsoft.com/dashboard/v2/customers/{0}/servicemanagementpage' -f $Tenant.customerId - Icon = 'fas fa-arrow-up-right-from-square' - }) + Title = 'Partner Center Portals' + URL = 'https://partner.microsoft.com/dashboard/v2/customers/{0}/servicemanagementpage' -f $Tenant.customerId + Icon = 'fas fa-arrow-up-right-from-square' + }) } $FormattedLinks = foreach ($Link in $Links) { @@ -209,7 +218,7 @@ function Invoke-HuduExtensionSync { $post = '' - if($Configuration.HideEmptyRoles) { + if ($Configuration.HideEmptyRoles) { $Roles = $Roles | Where-Object { $_.ParsedMembers } } From 7c0da88b49c4bf87aed315f4f71ab05c78058f60 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Tue, 30 Jun 2026 12:21:59 -0400 Subject: [PATCH 057/170] feat: add featureflags to stats timer --- .../Timer Functions/Start-CIPPStatsTimer.ps1 | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-CIPPStatsTimer.ps1 b/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-CIPPStatsTimer.ps1 index 4484f851a9fe8..bc1651351056c 100644 --- a/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-CIPPStatsTimer.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-CIPPStatsTimer.ps1 @@ -48,6 +48,12 @@ function Start-CIPPStatsTimer { $driftStandardsCount = Get-CIPPStatsDriftStandardsCount $mobileEnrollment = Get-CIPPStatsMobileEnrollment + # Feature flags + $FeatureFlags = @{} + Get-CIPPFeatureFlag | Select-Object -Property Id, Enabled | ForEach-Object { + $FeatureFlags[$_.Id] = $_.Enabled + } + $SendingObject = [PSCustomObject]@{ rgid = $env:WEBSITE_SITE_NAME SetupComplete = $SetupComplete @@ -77,6 +83,10 @@ function Start-CIPPStatsTimer { PWPush = $RawExt.PWPush.Enabled CFZTNA = $RawExt.CFZTNA.Enabled GitHub = $RawExt.GitHub.Enabled + BestPracticeAnalyser = $FeatureFlags.BestPracticeAnalyser + SuperAdminNG = $FeatureFlags.SuperAdminNG + CopilotAI = $FeatureFlags.CopilotAI + MCPServer = $FeatureFlags.MCPServer } | ConvertTo-Json try { From fc8756f1235e07ac72926d88213ee6df32c98b1c Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Wed, 1 Jul 2026 01:37:22 +0800 Subject: [PATCH 058/170] User Offboarding default settings fix --- .../Users/Invoke-ListUserSettings.ps1 | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListUserSettings.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListUserSettings.ps1 index e539f77654140..147ad97b2e063 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListUserSettings.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListUserSettings.ps1 @@ -45,6 +45,25 @@ function Invoke-ListUserSettings { Write-Warning "Failed to convert UserSpecificSettings JSON: $($_.Exception.Message)" } + $TestOffboardingConfigured = { + param($Offboarding) + if (-not $Offboarding) { return $false } + foreach ($Property in $Offboarding.PSObject.Properties) { + if ($Property.Value -eq $true) { return $true } + } + return $false + } + + $AllUsersOffboardingConfigured = & $TestOffboardingConfigured $UserSettings.offboardingDefaults + $UserOffboardingConfigured = & $TestOffboardingConfigured $UserSpecificSettings.offboardingDefaults + + $OffboardingDefaultsSource = 'allUsers' + if (-not $AllUsersOffboardingConfigured -and $UserOffboardingConfigured) { + $UserSettings | Add-Member -MemberType NoteProperty -Name 'offboardingDefaults' -Value $UserSpecificSettings.offboardingDefaults -Force | Out-Null + $OffboardingDefaultsSource = 'user' + } + $UserSettings | Add-Member -MemberType NoteProperty -Name 'offboardingDefaultsSource' -Value $OffboardingDefaultsSource -Force | Out-Null + # Get user bookmarks try { $UserBookmarks = Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq 'UserBookmarks' and RowKey eq '$Username'" From 3c919651ec9326ad9e9c7c29a46021e80011576c Mon Sep 17 00:00:00 2001 From: James Tarran Date: Tue, 30 Jun 2026 18:53:49 +0100 Subject: [PATCH 059/170] Adds a read-only endpoint that surfaces the currently-active fired alert items for a tenant, so the frontend can display live alert instances (not just configured rules or snoozed items). Backs the new Alerts card on the dashboard. Adds a read-only endpoint that surfaces the currently-active fired alert items for a tenant, so the frontend can display live alert instances (not just configured rules or snoozed items). Backs the new Alerts card on the dashboard. --- .../CIPP/Core/Invoke-ListAlertResults.ps1 | 83 +++++++++++++++++++ .../CIPP/Core/Invoke-ListSnoozedAlerts.ps1 | 1 + 2 files changed, 84 insertions(+) create mode 100644 Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ListAlertResults.ps1 diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ListAlertResults.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ListAlertResults.ps1 new file mode 100644 index 0000000000000..a961058be27fe --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ListAlertResults.ps1 @@ -0,0 +1,83 @@ +function Invoke-ListAlertResults { + <# + .FUNCTIONALITY + Entrypoint,AnyTenant + .ROLE + CIPP.Alert.Read + .DESCRIPTION + Lists the currently-active fired alert items for a tenant, read from the + AlertLastRun table. AlertLastRun stores the items produced by the most recent + run of each scripted alert (Get-CIPPAlert*) after snoozed items have already + been filtered out, so this returns the active (non-snoozed) instances. Each + item is returned with a content preview/hash (matching the snooze format) and + the raw alert item so the frontend can snooze it via ExecSnoozeAlert. + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $TenantFilter = $Request.Query.tenantFilter ?? $Request.Body.tenantFilter + + try { + if ([string]::IsNullOrWhiteSpace($TenantFilter)) { + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::BadRequest + Body = @{ Results = 'tenantFilter is required.' } + }) + } + + $Table = Get-CIPPTable -tablename 'AlertLastRun' + # AlertLastRun: PartitionKey = run date (yyyyMMdd), RowKey = "{tenant}-{cmdlet}" + $SafeTenant = ConvertTo-CIPPODataFilterValue -Value $TenantFilter -Type String + $Rows = Get-CIPPAzDataTableEntity @Table -Filter "Tenant eq '$SafeTenant'" + + # Keep only the most recent run (highest date partition) per alert. RowKey is + # "{tenant}-{cmdlet}", uniquely identifying the alert for this tenant. Write-AlertTrace + # only writes a new row when the data changes, so the latest row is the current state. + $LatestByAlert = @{} + foreach ($Row in @($Rows)) { + $Key = $Row.RowKey + $Existing = $LatestByAlert[$Key] + if (-not $Existing -or [string]$Row.PartitionKey -gt [string]$Existing.PartitionKey) { + $LatestByAlert[$Key] = $Row + } + } + + $Results = [System.Collections.Generic.List[object]]::new() + foreach ($Row in $LatestByAlert.Values) { + if ([string]::IsNullOrWhiteSpace($Row.LogData)) { continue } + try { + $Items = $Row.LogData | ConvertFrom-Json -ErrorAction Stop + } catch { + Write-Information "Failed to parse AlertLastRun LogData for $($Row.RowKey): $($_.Exception.Message)" + continue + } + + foreach ($Item in @($Items)) { + if ($null -eq $Item) { continue } + $Hash = Get-AlertContentHash -AlertItem $Item + $Results.Add([PSCustomObject]@{ + CmdletName = $Row.CmdletName + AlertComment = $Row.AlertComment + Tenant = $Row.Tenant + LastRun = $Row.PartitionKey + ContentHash = $Hash.ContentHash + ContentPreview = $Hash.ContentPreview + AlertItem = $Item + }) + } + } + + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::OK + Body = @($Results) + }) + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API $APIName -tenant $TenantFilter -message "Failed to list alert results: $($ErrorMessage.NormalizedError)" -Sev 'Error' -LogData $ErrorMessage + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::InternalServerError + Body = @{ Results = "Failed to list alert results: $($ErrorMessage.NormalizedError)" } + }) + } +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ListSnoozedAlerts.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ListSnoozedAlerts.ps1 index b4e897b7999a4..e0841c6eeced1 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ListSnoozedAlerts.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ListSnoozedAlerts.ps1 @@ -41,6 +41,7 @@ function Invoke-ListSnoozedAlerts { RowKey = $_.RowKey CmdletName = $_.PartitionKey Tenant = $_.Tenant + ContentHash = $_.ContentHash ContentPreview = $_.ContentPreview SnoozedBy = $_.SnoozedBy SnoozedAt = $_.SnoozedAt From 29e8e6cc46b881853f8f2ff56665b37fd98c1369 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Wed, 1 Jul 2026 02:22:31 +0800 Subject: [PATCH 060/170] Fixes for standards where deployment errors were hidden If a CA failed to be deployed because of a missing X or another issue these were not surfaced to the user and in tern drift/standards alignment indicated the policy as aligned even though nothing was deployed. Also adds some future helpers that might be of use later --- .../Standards/Push-CIPPStandard.ps1 | 21 ++ .../Push-CIPPStandardsApplyBatch.ps1 | 26 ++ .../Public/Invoke-CIPPCATemplateBatch.ps1 | 314 ++++++++++++++++++ Modules/CIPPCore/Public/New-CIPPCAPolicy.ps1 | 130 +++++--- .../Public/Resolve-CIPPCADependencies.ps1 | 216 ++++++++++++ ...-CIPPStandardConditionalAccessTemplate.ps1 | 61 +++- 6 files changed, 713 insertions(+), 55 deletions(-) create mode 100644 Modules/CIPPCore/Public/Invoke-CIPPCATemplateBatch.ps1 create mode 100644 Modules/CIPPCore/Public/Resolve-CIPPCADependencies.ps1 diff --git a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Standards/Push-CIPPStandard.ps1 b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Standards/Push-CIPPStandard.ps1 index 6d8cba6a8cb2c..b3c91c96ed345 100644 --- a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Standards/Push-CIPPStandard.ps1 +++ b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Standards/Push-CIPPStandard.ps1 @@ -11,6 +11,27 @@ function Push-CIPPStandard { $Tenant = $Item.Tenant $Standard = $Item.Standard + + # FUTURE USE - ZAC + # Grouped Conditional Access batch: deploy all of a tenant's CA templates in one sequential + # pass. Per-template rerun checks and standard-info context are handled inside the batch + # function, so we bypass the generic per-item dispatch below. + # if ($Item.BatchTemplates) { + # $TemplateCount = @($Item.BatchTemplates).Count + # Write-Information "Received Conditional Access batch for $Tenant with $TemplateCount template(s)." + # Set-CippUserAgentContext -Source 'standard' -TemplateId $Item.TemplateId + # $QueuedTime = if ($Item.QueuedTime) { [int64]$Item.QueuedTime } else { 0 } + # try { + # Invoke-CIPPCATemplateBatch -Tenant $Tenant -Templates $Item.BatchTemplates -QueuedTime $QueuedTime + # Write-Information "Conditional Access batch completed for tenant $Tenant" + # } catch { + # Write-LogMessage -API 'Standards' -tenant $Tenant -message "Error running Conditional Access batch for tenant $Tenant - $($_.Exception.Message)" -sev Error -LogData (Get-CippException -Exception $_) + # Write-Warning "Error running Conditional Access batch for tenant $Tenant - $($_.Exception.Message)" + # throw $_.Exception.Message + # } + # return + # } + $FunctionName = 'Invoke-CIPPStandard{0}' -f $Standard Write-Information "We'll be running $FunctionName" diff --git a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Standards/Push-CIPPStandardsApplyBatch.ps1 b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Standards/Push-CIPPStandardsApplyBatch.ps1 index 24062cd9847d5..e15912a163472 100644 --- a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Standards/Push-CIPPStandardsApplyBatch.ps1 +++ b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Standards/Push-CIPPStandardsApplyBatch.ps1 @@ -20,6 +20,32 @@ function Push-CIPPStandardsApplyBatch { return } + # FUTURE USE - ZAC + # Group all ConditionalAccessTemplate standards per tenant into a single batch item so + # they deploy sequentially (one activity per tenant) instead of fanning out one activity + # per template. This removes the 429 storm against the ~1 req/s CA write endpoint and the + # duplicate named location / c1-c99 / 1040 races. Non-CA standards pass through unchanged. + # $CAStandards = @($AllStandards | Where-Object { $_.Standard -eq 'ConditionalAccessTemplate' }) + # if ($CAStandards.Count -gt 0) { + # $OtherStandards = @($AllStandards | Where-Object { $_.Standard -ne 'ConditionalAccessTemplate' }) + # $GroupedCA = foreach ($TenantGroup in ($CAStandards | Group-Object -Property Tenant)) { + # [pscustomobject]@{ + # Tenant = $TenantGroup.Name + # Standard = 'ConditionalAccessTemplate' + # FunctionName = 'CIPPStandard' + # QueuedTime = ($TenantGroup.Group | Select-Object -First 1).QueuedTime + # BatchTemplates = @($TenantGroup.Group | ForEach-Object { + # [pscustomobject]@{ + # Settings = $_.Settings + # TemplateId = $_.TemplateId + # } + # }) + # } + # } + # $AllStandards = @($OtherStandards) + @($GroupedCA) + # Write-Information "Grouped $($CAStandards.Count) Conditional Access template standards into $(@($GroupedCA).Count) per-tenant batch item(s)." + # } + Write-Information "Aggregated $($AllStandards.Count) standards from all tenants: $($AllStandards | ConvertTo-Json -Depth 5 -Compress)" # Start orchestrator to apply standards diff --git a/Modules/CIPPCore/Public/Invoke-CIPPCATemplateBatch.ps1 b/Modules/CIPPCore/Public/Invoke-CIPPCATemplateBatch.ps1 new file mode 100644 index 0000000000000..3d5ae1633f994 --- /dev/null +++ b/Modules/CIPPCore/Public/Invoke-CIPPCATemplateBatch.ps1 @@ -0,0 +1,314 @@ +function Invoke-CIPPCATemplateBatch { + + # Future Use - Not currently used + + <# + .SYNOPSIS + Deploy all Conditional Access template standards for a single tenant in one + sequential pass, reconciling shared dependencies once up front. + .DESCRIPTION + Per-tenant batch path for the ConditionalAccessTemplate standard. Replaces the + previous one-activity-per-template fan-out (which caused 429 storms against the + ~1 req/s CA write endpoint, plus duplicate-dependency / c1-c99 / 1040 races) with a + single serial deployment. Dependencies (named locations, auth contexts, auth + strengths) are reconciled ONCE via Resolve-CIPPCADependencies, then each policy is + deployed sequentially using New-CIPPCAPolicy -DependencyMap. Reporting is folded + into the same loop and remains per-template (one compare field per template). + + Dispatched internally from Push-CIPPStandard when a grouped batch item (carrying + BatchTemplates) is dequeued. This is NOT a user-selectable standard. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Tenant, + $Templates, + [int64]$QueuedTime = 0, + $Headers + ) + + $Templates = @($Templates | Where-Object { $_ }) + if ($Templates.Count -eq 0) { + Write-Information "No CA templates to deploy for $Tenant" + return + } + + # Always refresh the CA cache first. This is one long-running activity and both Phase 1 + # (dependency reconciliation) and Phase 2 (existing-policy checks / reporting) read off + # the snapshot, so it must reflect current tenant state before we begin. + try { + Write-Information "Refreshing Conditional Access DB cache for $Tenant before batch deploy" + Set-CIPPDBCacheConditionalAccessPolicies -TenantFilter $Tenant + } catch { + Write-Warning "Failed to refresh CA cache for $Tenant : $($_.Exception.Message)" + } + + # General Entra license gate - applies to every CA template in the batch + $TestResult = Test-CIPPStandardLicense -StandardName 'ConditionalAccessTemplate_general' -TenantFilter $Tenant -Preset Entra + if ($TestResult -eq $false) { + foreach ($t in $Templates) { + Set-CIPPStandardsCompareField -FieldName "standards.ConditionalAccessTemplate.$($t.Settings.TemplateList.value)" -FieldValue 'This tenant does not have the required license for this standard.' -Tenant $Tenant + } + return + } + + # Preload snapshots from the freshly-updated cache + try { + $AllCAPolicies = New-CIPPDbRequest -TenantFilter $Tenant -Type 'ConditionalAccessPolicies' + $PreloadedLocations = New-CIPPDbRequest -TenantFilter $Tenant -Type 'NamedLocations' + $PreloadedSecurityDefaults = New-CIPPDbRequest -TenantFilter $Tenant -Type 'SecurityDefaults' + } catch { + $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message + Write-LogMessage -API 'Standards' -Tenant $Tenant -Message "Could not load the ConditionalAccessTemplate cache for $Tenant. Error: $ErrorMessage" -Sev Error + return + } + # Auth strengths are cached; auth contexts are not - Resolve-CIPPCADependencies fetches + # contexts live and falls back to a live fetch if the strengths snapshot is empty. + try { $PreloadedAuthStrengths = New-CIPPDbRequest -TenantFilter $Tenant -Type 'AuthenticationStrengths' } catch { $PreloadedAuthStrengths = $null } + + # Preload tenant-wide lookups ONCE for the whole batch (reused by every policy deploy and the + # report conversion). Without this, New-CIPPCAPolicy and New-CIPPCATemplate each re-fetch + # users/groups/servicePrincipals/vacation-groups per policy - dozens of redundant Graph calls. + $UGRequests = @( + @{ id = 'users'; url = 'users?$select=id,displayName&$top=999'; method = 'GET' } + @{ id = 'groups'; url = 'groups?$select=id,displayName&$top=999'; method = 'GET' } + ) + $PreloadedUsers = $null; $PreloadedGroups = $null; $PreloadedServicePrincipals = $null; $PreloadedVacationGroups = $null + try { + $UGResults = New-GraphBulkRequest -Requests $UGRequests -tenantid $Tenant -asapp $true + $PreloadedUsers = ($UGResults | Where-Object { $_.id -eq 'users' }).body.value + $PreloadedGroups = ($UGResults | Where-Object { $_.id -eq 'groups' }).body.value + } catch { Write-Warning "Failed to preload users/groups for $Tenant : $($_.Exception.Message)" } + try { + $PreloadedServicePrincipals = New-GraphGETRequest -uri 'https://graph.microsoft.com/v1.0/servicePrincipals?$select=appId&$top=999' -tenantid $Tenant -asApp $true + } catch { Write-Warning "Failed to preload service principals for $Tenant : $($_.Exception.Message)" } + try { + $PreloadedVacationGroups = New-GraphGETRequest -uri "https://graph.microsoft.com/beta/groups?`$filter=startsWith(displayName,'Vacation Exclusion')&`$select=id,displayName&`$top=999&`$count=true" -ComplexFilter -tenantid $Tenant -asApp $true + } catch { Write-Warning "Failed to preload vacation exclusion groups for $Tenant : $($_.Exception.Message)" } + + $Table = Get-CippTable -tablename 'templates' + + # Load each template's JSON once + $CATemplates = foreach ($t in $Templates) { + $TemplateValue = $t.Settings.TemplateList.value + $Filter = "PartitionKey eq 'CATemplate' and RowKey eq '$TemplateValue'" + $JSON = (Get-CippAzDataTableEntity @Table -Filter $Filter).JSON + if (-not $JSON) { + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Conditional Access template '$($t.Settings.TemplateList.label)' ($TemplateValue) could not be loaded from the template store - skipping." -Sev 'Error' + Set-CIPPStandardsCompareField -FieldName "standards.ConditionalAccessTemplate.$TemplateValue" -FieldValue "Template '$($t.Settings.TemplateList.label)' could not be loaded from the template store." -Tenant $Tenant + continue + } + [pscustomobject]@{ + Settings = $t.Settings + TemplateId = $t.TemplateId + RawJSON = $JSON + WillRemediate = $false + NeedsRemediation = $false + Skip = $false + DeployError = $null + } + } + $CATemplates = @($CATemplates) + if ($CATemplates.Count -eq 0) { return } + + # Resolve P2 capability once for the whole tenant (reused for every risk-based policy) + $TenantHasP2 = [bool](Test-CIPPStandardLicense -StandardName 'ConditionalAccessTemplate_p2' -TenantFilter $Tenant -Preset EntraP2 -SkipLog) + + # Nested helper: compare ONE template against the current deployed state, write its compare + # field in the renderable CurrentValue/ExpectedValue shape, and return a status string: + # 'Compliant' | 'Drifted' | 'Missing' | 'P2Blocked' | 'Failed' | 'Error'. + function Set-CABatchCompareStatus { + param($ct) + $Settings = $ct.Settings + $TemplateValue = $Settings.TemplateList.value + $FieldName = "standards.ConditionalAccessTemplate.$TemplateValue" + + Set-CippStandardInfoContext -StandardInfo @{ + Standard = 'ConditionalAccessTemplate' + StandardTemplateId = $ct.TemplateId + ConditionalAccessTemplateId = $TemplateValue + } + + try { + $Policy = $ct.RawJSON | ConvertFrom-Json -Depth 10 + if ($null -eq $Policy) { + Set-CIPPStandardsCompareField -FieldName $FieldName -CurrentValue @{ Differences = "Template '$($Settings.TemplateList.label)' could not be parsed." } -ExpectedValue @{ Differences = @() } -Tenant $Tenant + return 'Error' + } + + # Override the template's state with the standard's state for drift comparison + if ($Settings.state -and $Settings.state -ne 'donotchange') { + $Policy | Add-Member -NotePropertyName 'state' -NotePropertyValue $Settings.state -Force + } + + # Resolve the template's location GUIDs to display names so they compare like-for-like + # with the deployed policy. The template's OWN LocationInfo carries the id->name map + # (the GUID is the source tenant's id); fall back to this tenant's named-location cache. + if ($Policy.conditions.locations) { + $LocNameById = @{} + foreach ($li in @($Policy.LocationInfo)) { if ($li.id -and $li.displayName) { $LocNameById[$li.id] = $li.displayName } } + foreach ($pl in @($PreloadedLocations)) { if ($pl.id -and $pl.displayName -and -not $LocNameById.ContainsKey($pl.id)) { $LocNameById[$pl.id] = $pl.displayName } } + foreach ($LocDir in 'includeLocations', 'excludeLocations') { + if ($Policy.conditions.locations.PSObject.Properties.Name -contains $LocDir -and $Policy.conditions.locations.$LocDir) { + $Policy.conditions.locations.$LocDir = @($Policy.conditions.locations.$LocDir | ForEach-Object { + if ($LocNameById.ContainsKey($_)) { $LocNameById[$_] } else { $_ } + }) + } + } + } + + $CheckExisting = $AllCAPolicies | Where-Object -Property displayName -EQ $Settings.TemplateList.label + if ($CheckExisting -is [array] -and $CheckExisting.Count -gt 1) { + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Found $($CheckExisting.Count) Conditional Access policies named '$($Settings.TemplateList.label)' in $Tenant. Comparing against the first; duplicate policies should be cleaned up." -Sev 'Warning' + $CheckExisting = $CheckExisting[0] + } + + if (!$CheckExisting) { + $NeedsP2 = ($Policy.conditions.userRiskLevels.Count -gt 0 -or $Policy.conditions.signInRiskLevels.Count -gt 0) + if ($ct.DeployError) { + Set-CIPPStandardsCompareField -FieldName $FieldName -CurrentValue @{ Differences = "Failed to deploy: $($ct.DeployError)" } -ExpectedValue @{ Differences = @() } -Tenant $Tenant + return 'Failed' + } elseif ($NeedsP2 -and -not $TenantHasP2) { + Set-CIPPStandardsCompareField -FieldName $FieldName -CurrentValue @{ Differences = 'Policy requires an AAD Premium P2 license, which this tenant does not have.' } -ExpectedValue @{ Differences = @() } -Tenant $Tenant + return 'P2Blocked' + } else { + Set-CIPPStandardsCompareField -FieldName $FieldName -CurrentValue @{ Differences = 'Policy is missing from this tenant.' } -ExpectedValue @{ Differences = @() } -Tenant $Tenant + return 'Missing' + } + } + + $templateResult = New-CIPPCATemplate -TenantFilter $Tenant -JSON $CheckExisting -preloadedLocations $PreloadedLocations -preloadedUsers $PreloadedUsers -preloadedGroups $PreloadedGroups + $CompareObj = ConvertFrom-Json -ErrorAction SilentlyContinue -InputObject $templateResult + if ($null -eq $CompareObj) { + Set-CIPPStandardsCompareField -FieldName $FieldName -CurrentValue @{ Differences = 'Tenant policy conversion returned null.' } -ExpectedValue @{ Differences = @() } -Tenant $Tenant + return 'Error' + } + try { + $Compare = Compare-CIPPIntuneObject -ReferenceObject $Policy -DifferenceObject $CompareObj -CompareType 'ca' + } catch { + Set-CIPPStandardsCompareField -FieldName $FieldName -CurrentValue @{ Differences = "Error comparing policy: $($_.Exception.Message)" } -ExpectedValue @{ Differences = @() } -Tenant $Tenant + return 'Error' + } + if (!$Compare) { + Set-CIPPStandardsCompareField -FieldName $FieldName -FieldValue $true -CurrentValue @{ Differences = 'No Differences found' } -ExpectedValue @{ Differences = 'No Differences found' } -Tenant $Tenant + return 'Compliant' + } else { + Set-CIPPStandardsCompareField -FieldName $FieldName -CurrentValue @{ Differences = $Compare } -ExpectedValue @{ Differences = @() } -Tenant $Tenant + return 'Drifted' + } + } catch { + $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Error evaluating conditional access template $TemplateValue. Error: $ErrorMessage" -sev 'Error' + return 'Error' + } + } + + # Per-template rerun decision (marker written here so a redelivered batch skips handled ones) + foreach ($ct in $CATemplates) { + if ($QueuedTime -gt 0) { + $API = "ConditionalAccessTemplate_$($ct.TemplateId)_$($ct.Settings.TemplateList.value)" + if (Test-CIPPRerun -Type Standard -Tenant $Tenant -API $API -Settings $ct.Settings -BaseTime $QueuedTime) { + Write-Information "Detected rerun for $API. Skipping." + $ct.Skip = $true + } + } + } + + # ---- Evaluate: compare every in-scope template against the CURRENT state, write its result, + # and flag only the ones that actually need remediation (missing or drifted). Compliant + # policies are reported and then left untouched - no needless PATCH on every run. ---- + foreach ($ct in $CATemplates) { + if ($ct.Skip) { continue } + if (-not ($ct.Settings.report -eq $true -or $ct.Settings.remediate -eq $true)) { continue } + $Status = Set-CABatchCompareStatus -ct $ct + if ($ct.Settings.remediate -eq $true -and $Status -in @('Missing', 'Drifted')) { + $ct.WillRemediate = $true + $ct.NeedsRemediation = $true + } + } + + # ---- Reconcile shared dependencies ONCE, only for the policies we will actually deploy ---- + $DeployObjects = [System.Collections.Generic.List[object]]::new() + foreach ($ct in $CATemplates) { + if ($ct.NeedsRemediation) { $DeployObjects.Add(($ct.RawJSON | ConvertFrom-Json)) } + } + $DependencyMap = $null + if ($DeployObjects.Count -gt 0) { + try { + $DependencyMap = Resolve-CIPPCADependencies -TenantFilter $Tenant -PolicyObjects $DeployObjects -AllNamedLocations $PreloadedLocations -AllAuthStrengthPolicies $PreloadedAuthStrengths -Overwrite $true -Headers $Headers -APIName 'Standards' + } catch { + $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Failed to reconcile Conditional Access dependencies for $Tenant. Error: $ErrorMessage" -sev 'Error' + $DependencyMap = $null + } + } + + # ---- Remediate: deploy only the missing/drifted policies, sequentially ---- + $RemediatedAny = $false + foreach ($ct in $CATemplates) { + if (-not $ct.NeedsRemediation -or -not $DependencyMap) { continue } + $Settings = $ct.Settings + $TemplateValue = $Settings.TemplateList.value + Set-CippStandardInfoContext -StandardInfo @{ + Standard = 'ConditionalAccessTemplate' + StandardTemplateId = $ct.TemplateId + ConditionalAccessTemplateId = $TemplateValue + } + try { + $NewCAPolicy = @{ + replacePattern = 'displayName' + TenantFilter = $Tenant + state = $Settings.state + RawJSON = $ct.RawJSON + Overwrite = $true + APIName = 'Standards' + Headers = $Headers + DisableSD = $Settings.DisableSD + CreateGroups = $Settings.CreateGroups ?? $false + PreloadedCAPolicies = $AllCAPolicies + PreloadedLocations = $PreloadedLocations + PreloadedSecurityDefaults = $PreloadedSecurityDefaults + DependencyMap = $DependencyMap + PreloadedServicePrincipals = $PreloadedServicePrincipals + PreloadedUsers = $PreloadedUsers + PreloadedGroups = $PreloadedGroups + PreloadedVacationGroups = $PreloadedVacationGroups + } + $null = New-CIPPCAPolicy @NewCAPolicy + $RemediatedAny = $true + } catch { + $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message + # Record the deploy failure so the re-report surfaces the reason instead of "missing" + $ct.DeployError = $ErrorMessage + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Failed to create or update conditional access rule from template $TemplateValue. Error: $ErrorMessage" -sev 'Error' + } + } + + # ---- Refresh + re-report ONLY the policies we just deployed ---- + # Only refresh when something actually changed ($RemediatedAny is set on a successful + # create/update). When nothing was deployed there's no need to re-pull the cache at all. + if ($RemediatedAny) { + # Give Graph a moment to propagate the new/updated policies before refreshing the cache, + # otherwise the refresh can race ahead of eventual consistency and miss just-created ones. + Start-Sleep -Seconds 5 + try { + Set-CIPPDBCacheConditionalAccessPolicies -TenantFilter $Tenant + $AllCAPolicies = New-CIPPDbRequest -TenantFilter $Tenant -Type 'ConditionalAccessPolicies' + $PreloadedLocations = New-CIPPDbRequest -TenantFilter $Tenant -Type 'NamedLocations' + # Refresh groups too so the report resolves any exclusion groups just created during deploy + $UGResults = New-GraphBulkRequest -Requests $UGRequests -tenantid $Tenant -asapp $true + $PreloadedUsers = ($UGResults | Where-Object { $_.id -eq 'users' }).body.value + $PreloadedGroups = ($UGResults | Where-Object { $_.id -eq 'groups' }).body.value + } catch { + Write-Warning "Failed to refresh CA snapshot after remediation for $Tenant : $($_.Exception.Message)" + } + foreach ($ct in $CATemplates) { + if ($ct.NeedsRemediation -and -not $ct.Skip) { + $null = Set-CABatchCompareStatus -ct $ct + } + } + } + + Set-CippStandardInfoContext -StandardInfo $null +} diff --git a/Modules/CIPPCore/Public/New-CIPPCAPolicy.ps1 b/Modules/CIPPCore/Public/New-CIPPCAPolicy.ps1 index f2b236fdf9b54..c3061a926f554 100644 --- a/Modules/CIPPCore/Public/New-CIPPCAPolicy.ps1 +++ b/Modules/CIPPCore/Public/New-CIPPCAPolicy.ps1 @@ -13,7 +13,12 @@ function New-CIPPCAPolicy { $Headers, $PreloadedCAPolicies = $null, $PreloadedLocations = $null, - $PreloadedSecurityDefaults = $null + $PreloadedSecurityDefaults = $null, + $DependencyMap = $null, + $PreloadedServicePrincipals = $null, + $PreloadedUsers = $null, + $PreloadedGroups = $null, + $PreloadedVacationGroups = $null ) # Helper function to replace group display names with GUIDs @@ -137,9 +142,9 @@ function New-CIPPCAPolicy { } } - # Get named locations once if needed + # Get named locations once if needed (skipped when a shared DependencyMap is supplied - deps were reconciled up front) $AllNamedLocations = $null - if ($JSONobj.LocationInfo) { + if (-not $DependencyMap -and $JSONobj.LocationInfo) { if ($PreloadedLocations) { Write-Information 'Using preloaded named locations' $AllNamedLocations = $PreloadedLocations @@ -155,9 +160,9 @@ function New-CIPPCAPolicy { } } - # Get authentication strength policies once if needed + # Get authentication strength policies once if needed (skipped when a shared DependencyMap is supplied) $AllAuthStrengthPolicies = $null - if ($JSONobj.GrantControls.authenticationStrength.policyType -eq 'custom' -or $JSONobj.GrantControls.authenticationStrength.policyType -eq 'BuiltIn') { + if (-not $DependencyMap -and ($JSONobj.GrantControls.authenticationStrength.policyType -eq 'custom' -or $JSONobj.GrantControls.authenticationStrength.policyType -eq 'BuiltIn')) { try { Write-Information 'Fetching authentication strength policies...' $AllAuthStrengthPolicies = New-GraphGETRequest -uri 'https://graph.microsoft.com/beta/identity/conditionalAccess/authenticationStrength/policies/' -tenantid $TenantFilter -asApp $true @@ -168,9 +173,9 @@ function New-CIPPCAPolicy { } } - # Get authentication context class references once if needed + # Get authentication context class references once if needed (skipped when a shared DependencyMap is supplied) $AllAuthContexts = $null - if ($JSONobj.AuthContextInfo) { + if (-not $DependencyMap -and $JSONobj.AuthContextInfo) { try { Write-Information 'Fetching authentication context class references...' $AllAuthContexts = New-GraphGETRequest -uri 'https://graph.microsoft.com/beta/identity/conditionalAccess/authenticationContextClassReferences' -tenantid $TenantFilter -asApp $true @@ -181,9 +186,13 @@ function New-CIPPCAPolicy { } } - # Get service principals once if needed + # Get service principals once if needed (use preloaded set when supplied to avoid a + # tenant-wide fetch on every policy in a batch) $AllServicePrincipals = $null if (($JSONobj.conditions.applications.includeApplications -and $JSONobj.conditions.applications.includeApplications -notcontains 'All') -or ($JSONobj.conditions.applications.excludeApplications -and $JSONobj.conditions.applications.excludeApplications -notcontains 'All')) { + if ($PreloadedServicePrincipals) { + $AllServicePrincipals = $PreloadedServicePrincipals + } else { try { Write-Information 'Fetching all service principals...' $AllServicePrincipals = New-GraphGETRequest -uri 'https://graph.microsoft.com/v1.0/servicePrincipals?$select=appId&$top=999' -tenantid $TenantFilter -asApp $true @@ -192,19 +201,26 @@ function New-CIPPCAPolicy { Write-Information "Error fetching service principals: $($ErrorMessage | ConvertTo-Json -Depth 10 -Compress)" throw "Failed to fetch service principals: $($ErrorMessage.NormalizedError)" } + } } #If Grant Controls contains authenticationStrength, create these and then replace the id if ($JSONobj.GrantControls.authenticationStrength.policyType -eq 'custom' -or $JSONobj.GrantControls.authenticationStrength.policyType -eq 'BuiltIn') { - $ExistingStrength = $AllAuthStrengthPolicies | Where-Object -Property displayName -EQ $JSONobj.GrantControls.authenticationStrength.displayName - if ($ExistingStrength) { - $JSONobj.GrantControls.authenticationStrength = @{ id = $ExistingStrength.id } - + if ($DependencyMap) { + # Dependencies were reconciled up front - resolve the id from the shared map by display name + $StrengthName = $JSONobj.GrantControls.authenticationStrength.displayName + $JSONobj.GrantControls.authenticationStrength = @{ id = $DependencyMap.AuthStrength[$StrengthName] } } else { - $Body = ConvertTo-Json -InputObject $JSONobj.GrantControls.authenticationStrength - $GraphRequest = New-GraphPOSTRequest -uri 'https://graph.microsoft.com/beta/identity/conditionalAccess/authenticationStrength/policies' -body $body -Type POST -tenantid $TenantFilter -asApp $true -ScheduleRetry $true - $JSONobj.GrantControls.authenticationStrength = @{ id = $ExistingStrength.id } - Write-LogMessage -Headers $Headers -API $APIName -message "Created new Authentication Strength Policy: $($JSONobj.GrantControls.authenticationStrength.displayName)" -Sev 'Info' + $ExistingStrength = $AllAuthStrengthPolicies | Where-Object -Property displayName -EQ $JSONobj.GrantControls.authenticationStrength.displayName + if ($ExistingStrength) { + $JSONobj.GrantControls.authenticationStrength = @{ id = $ExistingStrength.id } + + } else { + $Body = ConvertTo-Json -InputObject $JSONobj.GrantControls.authenticationStrength + $GraphRequest = New-GraphPOSTRequest -uri 'https://graph.microsoft.com/beta/identity/conditionalAccess/authenticationStrength/policies' -body $body -Type POST -tenantid $TenantFilter -asApp $true -ScheduleRetry $true + $JSONobj.GrantControls.authenticationStrength = @{ id = $ExistingStrength.id } + Write-LogMessage -Headers $Headers -API $APIName -message "Created new Authentication Strength Policy: $($JSONobj.GrantControls.authenticationStrength.displayName)" -Sev 'Info' + } } } @@ -234,8 +250,20 @@ function New-CIPPCAPolicy { # Handle authentication context class references - create if missing, replace displayNames with IDs if ($JSONobj.AuthContextInfo) { - $AuthContextLookupTable = foreach ($authContext in $JSONobj.AuthContextInfo) { - if (-not $authContext.displayName) { continue } + if ($DependencyMap) { + # Build this policy's lookup from its own AuthContextInfo + the shared id map. + # templateId stays scoped to THIS policy so per-template ids never collide across policies. + $AuthContextLookupTable = foreach ($authContext in $JSONobj.AuthContextInfo) { + if (-not $authContext.displayName) { continue } + [pscustomobject]@{ + id = $DependencyMap.AuthContexts[$authContext.displayName] + displayName = $authContext.displayName + templateId = $authContext.id + } + } + } else { + $AuthContextLookupTable = foreach ($authContext in $JSONobj.AuthContextInfo) { + if (-not $authContext.displayName) { continue } $ExistingContext = $AllAuthContexts | Where-Object -Property displayName -EQ $authContext.displayName if ($ExistingContext) { Write-LogMessage -Tenant $TenantFilter -Headers $Headers -API $APIName -message "Matched authentication context: $($authContext.displayName)" -Sev 'Info' @@ -280,10 +308,10 @@ function New-CIPPCAPolicy { templateId = $authContext.id } } + } } - Write-Information 'Auth Context Lookup Table:' - Write-Information ($AuthContextLookupTable | ConvertTo-Json -Depth 10) + Write-Information "Auth Context Lookup Table: $(@($AuthContextLookupTable) | ConvertTo-Json -Depth 10 -Compress)" # Replace display names with actual IDs in the policy if ($AuthContextLookupTable -and $JSONobj.conditions.applications.includeAuthenticationContextClassReferences) { @@ -302,6 +330,21 @@ function New-CIPPCAPolicy { } #for each of the locations, check if they exist, if not create them. These are in $JSONobj.LocationInfo + if ($DependencyMap) { + # Build this policy's lookup from its own LocationInfo + the shared id map + $NewLocationsCreated = $DependencyMap.NewLocationsCreated + $LocationLookupTable = foreach ($locations in $JSONobj.LocationInfo) { + if (!$locations) { continue } + foreach ($location in $locations) { + if (!$location.displayName) { continue } + [pscustomobject]@{ + id = $DependencyMap.Locations[$location.displayName] + name = $location.displayName + templateId = $location.id + } + } + } + } else { $NewLocationsCreated = $false $LocationLookupTable = foreach ($locations in $JSONobj.LocationInfo) { if (!$locations) { continue } @@ -383,8 +426,8 @@ function New-CIPPCAPolicy { } } } - Write-Information 'Location Lookup Table:' - Write-Information ($LocationLookupTable | ConvertTo-Json -Depth 10) + } + Write-Information "Location Lookup Table: $(@($LocationLookupTable) | ConvertTo-Json -Depth 10 -Compress)" if ($LocationLookupTable -and $JSONobj.conditions.locations) { foreach ($location in $JSONobj.conditions.locations.includeLocations) { @@ -431,22 +474,28 @@ function New-CIPPCAPolicy { } try { Write-Information 'Replacement pattern for inclusions and exclusions is displayName.' - $Requests = @( - @{ - url = 'users?$select=id,displayName&$top=999' - method = 'GET' - id = 'users' - } - @{ - url = 'groups?$select=id,displayName&$top=999' - method = 'GET' - id = 'groups' - } - ) - $BulkResults = New-GraphBulkRequest -Requests $Requests -tenantid $TenantFilter -asapp $true + if ($null -ne $PreloadedUsers -and $null -ne $PreloadedGroups) { + # Use the batch-level preloaded lookups to avoid a users+groups fetch per policy + $users = $PreloadedUsers + $groups = $PreloadedGroups + } else { + $Requests = @( + @{ + url = 'users?$select=id,displayName&$top=999' + method = 'GET' + id = 'users' + } + @{ + url = 'groups?$select=id,displayName&$top=999' + method = 'GET' + id = 'groups' + } + ) + $BulkResults = New-GraphBulkRequest -Requests $Requests -tenantid $TenantFilter -asapp $true - $users = ($BulkResults | Where-Object { $_.id -eq 'users' }).body.value - $groups = ($BulkResults | Where-Object { $_.id -eq 'groups' }).body.value + $users = ($BulkResults | Where-Object { $_.id -eq 'users' }).body.value + $groups = ($BulkResults | Where-Object { $_.id -eq 'groups' }).body.value + } foreach ($userType in 'includeUsers', 'excludeUsers') { if ($JSONobj.conditions.users.PSObject.Properties.Name -contains $userType -and $JSONobj.conditions.users.$userType -notin 'All', 'None', 'GuestOrExternalUsers') { @@ -548,7 +597,12 @@ function New-CIPPCAPolicy { } # Preserve any exclusion groups named "Vacation Exclusion - " from existing policy try { - $ExistingVacationGroup = New-GraphGETRequest -uri "https://graph.microsoft.com/beta/groups?`$filter=startsWith(displayName,'Vacation Exclusion')&`$select=id,displayName&`$top=999&`$count=true" -ComplexFilter -tenantid $TenantFilter -asApp $true | + $VacationGroups = if ($null -ne $PreloadedVacationGroups) { + $PreloadedVacationGroups + } else { + New-GraphGETRequest -uri "https://graph.microsoft.com/beta/groups?`$filter=startsWith(displayName,'Vacation Exclusion')&`$select=id,displayName&`$top=999&`$count=true" -ComplexFilter -tenantid $TenantFilter -asApp $true + } + $ExistingVacationGroup = $VacationGroups | Where-Object { $CheckExisting.conditions.users.excludeGroups -contains $_.id } if ($ExistingVacationGroup) { if (-not ($JSONobj.conditions.users.PSObject.Properties.Name -contains 'excludeGroups')) { diff --git a/Modules/CIPPCore/Public/Resolve-CIPPCADependencies.ps1 b/Modules/CIPPCore/Public/Resolve-CIPPCADependencies.ps1 new file mode 100644 index 0000000000000..02af8bcb8dfc8 --- /dev/null +++ b/Modules/CIPPCore/Public/Resolve-CIPPCADependencies.ps1 @@ -0,0 +1,216 @@ +function Resolve-CIPPCADependencies { + + # Future Use - Not currently used + + <# + .SYNOPSIS + Reconcile Conditional Access policy dependencies (named locations, authentication + contexts, authentication strengths) ONCE across a set of CA policy/template objects. + .DESCRIPTION + Used by the per-tenant CA batch deployment path (Invoke-CIPPCATemplateBatch) so that + dependencies shared by multiple policies are created/deduplicated a single time. This + avoids the duplicate named locations, c1-c99 authentication-context id collisions, and + error 1040 propagation races that occur when many CA policies deploy concurrently and + each one independently creates the dependencies it references. + + Returns displayName -> id maps that New-CIPPCAPolicy consumes via its -DependencyMap + parameter. Each policy still resolves its OWN references downstream (using its own + LocationInfo / AuthContextInfo) so per-template template-ids never collide across policies. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $TenantFilter, + [object[]]$PolicyObjects, + $AllNamedLocations = $null, + $AllAuthStrengthPolicies = $null, + $AllAuthContexts = $null, + $Overwrite = $true, + $Headers, + $APIName = 'CA Dependency Reconciliation' + ) + + $AuthStrengthMap = @{} + $AuthContextMap = @{} + $LocationMap = @{} + $NewLocationsCreated = $false + + $PolicyObjects = @($PolicyObjects | Where-Object { $_ }) + if ($PolicyObjects.Count -eq 0) { + return @{ + AuthStrength = $AuthStrengthMap + AuthContexts = $AuthContextMap + Locations = $LocationMap + NewLocationsCreated = $false + } + } + + # ---- Authentication strength policies ---- + $NeedAuthStrength = @($PolicyObjects | Where-Object { $_.GrantControls.authenticationStrength.policyType -in @('custom', 'BuiltIn') }) + if ($NeedAuthStrength.Count -gt 0) { + if ($null -eq $AllAuthStrengthPolicies) { + try { + $AllAuthStrengthPolicies = New-GraphGETRequest -uri 'https://graph.microsoft.com/beta/identity/conditionalAccess/authenticationStrength/policies/' -tenantid $TenantFilter -asApp $true + } catch { + $ErrorMessage = Get-CippException -Exception $_ + throw "Failed to fetch authentication strength policies: $($ErrorMessage.NormalizedError)" + } + } + foreach ($Policy in $NeedAuthStrength) { + $Strength = $Policy.GrantControls.authenticationStrength + $Name = $Strength.displayName + if (!$Name -or $AuthStrengthMap.ContainsKey($Name)) { continue } + $ExistingStrength = $AllAuthStrengthPolicies | Where-Object -Property displayName -EQ $Name | Select-Object -First 1 + if ($ExistingStrength) { + $AuthStrengthMap[$Name] = $ExistingStrength.id + } else { + $Body = ConvertTo-Json -InputObject $Strength -Depth 10 + try { + $GraphRequest = New-GraphPOSTRequest -uri 'https://graph.microsoft.com/beta/identity/conditionalAccess/authenticationStrength/policies' -body $Body -Type POST -tenantid $TenantFilter -asApp $true -ScheduleRetry $true + $AuthStrengthMap[$Name] = $GraphRequest.id + $AllAuthStrengthPolicies = @($AllAuthStrengthPolicies) + @([pscustomobject]@{ id = $GraphRequest.id; displayName = $Name }) + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message "Created new Authentication Strength Policy: $Name" -Sev 'Info' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + throw "Failed to create authentication strength policy '$Name': $($ErrorMessage.NormalizedError)" + } + } + } + } + + # ---- Authentication context class references ---- + $NeedAuthContext = @($PolicyObjects | Where-Object { $_.AuthContextInfo }) + if ($NeedAuthContext.Count -gt 0) { + if ($null -eq $AllAuthContexts) { + try { + $AllAuthContexts = New-GraphGETRequest -uri 'https://graph.microsoft.com/beta/identity/conditionalAccess/authenticationContextClassReferences' -tenantid $TenantFilter -asApp $true + } catch { + $ErrorMessage = Get-CippException -Exception $_ + throw "Failed to fetch authentication context class references: $($ErrorMessage.NormalizedError)" + } + } + foreach ($Policy in $NeedAuthContext) { + foreach ($authContext in $Policy.AuthContextInfo) { + if (-not $authContext.displayName) { continue } + $Name = $authContext.displayName + if ($AuthContextMap.ContainsKey($Name)) { continue } + $ExistingContext = $AllAuthContexts | Where-Object -Property displayName -EQ $Name | Select-Object -First 1 + if ($ExistingContext) { + $AuthContextMap[$Name] = $ExistingContext.id + } else { + # Find the next available ID (c1-c99) across the running set so concurrent + # contexts in the same batch never collide on the same id. + $UsedIds = @($AllAuthContexts.id) + $NewId = $null + for ($i = 1; $i -le 99; $i++) { + if ("c$i" -notin $UsedIds) { $NewId = "c$i"; break } + } + if (-not $NewId) { + throw "No available authentication context IDs (c1-c99) in tenant $TenantFilter" + } + $Body = @{ + id = $NewId + displayName = $Name + description = if ($authContext.description) { $authContext.description } else { '' } + isAvailable = $true + } | ConvertTo-Json -Compress + try { + $null = New-GraphPOSTRequest -uri 'https://graph.microsoft.com/beta/identity/conditionalAccess/authenticationContextClassReferences' -body $Body -Type POST -tenantid $TenantFilter -asApp $true + $AuthContextMap[$Name] = $NewId + $AllAuthContexts = @($AllAuthContexts) + @([pscustomobject]@{ id = $NewId; displayName = $Name }) + Write-LogMessage -Tenant $TenantFilter -Headers $Headers -API $APIName -message "Created new Authentication Context: $Name with ID $NewId" -Sev 'Info' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + throw "Failed to create authentication context '$Name': $($ErrorMessage.NormalizedError)" + } + } + } + } + } + + # ---- Named locations ---- + $NeedLocations = @($PolicyObjects | Where-Object { $_.LocationInfo }) + if ($NeedLocations.Count -gt 0) { + if ($null -eq $AllNamedLocations) { + try { + $AllNamedLocations = New-GraphGETRequest -uri 'https://graph.microsoft.com/beta/identity/conditionalAccess/namedLocations?$top=999' -tenantid $TenantFilter -asApp $true + } catch { + $ErrorMessage = Get-CippException -Exception $_ + throw "Failed to fetch named locations: $($ErrorMessage.NormalizedError)" + } + } + foreach ($Policy in $NeedLocations) { + foreach ($locations in $Policy.LocationInfo) { + if (!$locations) { continue } + foreach ($location in $locations) { + if (!$location.displayName) { continue } + $Name = $location.displayName + if ($LocationMap.ContainsKey($Name)) { continue } + $ExistingLocation = @($AllNamedLocations | Where-Object -Property displayName -EQ $Name) + $locationExists = $ExistingLocation.Count -gt 0 + if ($locationExists) { + $ExistingLocation = $ExistingLocation[0] + if ($Overwrite) { + $LocationUpdate = $location | Select-Object * -ExcludeProperty id + Remove-ODataProperties -Object $LocationUpdate + $Body = ConvertTo-Json -InputObject $LocationUpdate -Depth 10 + try { + $null = New-GraphPOSTRequest -uri "https://graph.microsoft.com/beta/identity/conditionalAccess/namedLocations/$($ExistingLocation.id)" -body $Body -Type PATCH -tenantid $TenantFilter -asApp $true -ScheduleRetry $true + Write-LogMessage -Tenant $TenantFilter -Headers $Headers -API $APIName -message "Updated existing Named Location: $Name" -Sev 'Info' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -Tenant $TenantFilter -Headers $Headers -API $APIName -message "Named Location '$Name' (id: $($ExistingLocation.id)) could not be updated - it may have been deleted. Will attempt to create it. Error: $($ErrorMessage.NormalizedError)" -Sev 'Warning' -LogData $ErrorMessage + $locationExists = $false + } + } + if ($locationExists) { + $LocationMap[$Name] = $ExistingLocation.id + } + } + if (-not $locationExists) { + if ($location.countriesAndRegions) { $location.countriesAndRegions = @($location.countriesAndRegions) } + $LocationBody = $location | Select-Object * -ExcludeProperty id + Remove-ODataProperties -Object $LocationBody + $Body = ConvertTo-Json -InputObject $LocationBody + try { + $GraphRequest = New-GraphPOSTRequest -uri 'https://graph.microsoft.com/beta/identity/conditionalAccess/namedLocations' -body $Body -Type POST -tenantid $TenantFilter -asApp $true + Write-Information "Created named location with ID: $($GraphRequest.id)" + # Wait for location to be available before any policy references it + $retryCount = 0 + $MaxRetryCount = 5 + $LocationRequest = $null + do { + Start-Sleep -Seconds 3 + try { + $LocationRequest = New-GraphGETRequest -uri "https://graph.microsoft.com/beta/identity/conditionalAccess/namedLocations/$($GraphRequest.id)" -tenantid $TenantFilter -asApp $true -ErrorAction Stop + } catch { + Write-Information 'Location not yet available, will retry...' + } + $retryCount++ + } while ((!$LocationRequest -or !$LocationRequest.id) -and ($retryCount -lt $MaxRetryCount)) + + if (!$LocationRequest -or !$LocationRequest.id) { + Write-Warning "Location $Name created but could not verify availability after $MaxRetryCount attempts. Proceeding anyway." + } + $NewLocationsCreated = $true + $LocationMap[$Name] = $GraphRequest.id + $AllNamedLocations = @($AllNamedLocations) + @([pscustomobject]@{ id = $GraphRequest.id; displayName = $Name }) + Write-LogMessage -Tenant $TenantFilter -Headers $Headers -API $APIName -message "Created new Named Location: $Name" -Sev 'Info' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + throw "Failed to create named location $Name : $($ErrorMessage.NormalizedError)" + } + } + } + } + } + } + + return @{ + AuthStrength = $AuthStrengthMap + AuthContexts = $AuthContextMap + Locations = $LocationMap + NewLocationsCreated = $NewLocationsCreated + } +} diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardConditionalAccessTemplate.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardConditionalAccessTemplate.ps1 index bb7c1d6d1d2ca..c792e589d626e 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardConditionalAccessTemplate.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardConditionalAccessTemplate.ps1 @@ -67,6 +67,7 @@ function Invoke-CIPPStandardConditionalAccessTemplate { return } + $DeployError = $null if ($Settings.remediate -eq $true) { try { $Filter = "PartitionKey eq 'CATemplate' and RowKey eq '$($Settings.TemplateList.value)'" @@ -76,7 +77,7 @@ function Invoke-CIPPStandardConditionalAccessTemplate { $TestP2 = Test-CIPPStandardLicense -StandardName 'ConditionalAccessTemplate_p2' -TenantFilter $Tenant -Preset EntraP2 -SkipLog if (!$TestP2) { Write-Information "Skipping policy $($Policy.displayName) as it requires AAD Premium P2 license." - Set-CIPPStandardsCompareField -FieldName "standards.ConditionalAccessTemplate.$($Settings.TemplateList.value)" -FieldValue "Policy $($Policy.displayName) requires AAD Premium P2 license." -Tenant $Tenant + Set-CIPPStandardsCompareField -FieldName "standards.ConditionalAccessTemplate.$($Settings.TemplateList.value)" -CurrentValue @{ Differences = 'Policy requires an AAD Premium P2 license, which this tenant does not have.' } -ExpectedValue @{ Differences = @() } -Tenant $Tenant return $true } } @@ -97,17 +98,20 @@ function Invoke-CIPPStandardConditionalAccessTemplate { $null = New-CIPPCAPolicy @NewCAPolicy } catch { - $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message - Write-LogMessage -API 'Standards' -tenant $tenant -message "Failed to create or update conditional access rule $($JSONObj.displayName). Error: $ErrorMessage" -sev 'Error' + # Capture the Graph deploy error (e.g. invalid CA policy 1011/1085) so the report + # section below surfaces the reason in the compare fields instead of just "missing". + $DeployError = Get-NormalizedError -Message $_.Exception.Message + Write-LogMessage -API 'Standards' -tenant $tenant -message "Failed to create or update conditional access rule $($JSONObj.displayName). Error: $DeployError" -sev 'Error' } } if ($Settings.report -eq $true -or $Settings.remediate -eq $true) { + $FieldName = "standards.ConditionalAccessTemplate.$($Settings.TemplateList.value)" $Filter = "PartitionKey eq 'CATemplate' and RowKey eq '$($Settings.TemplateList.value)'" $Policy = (Get-CippAzDataTableEntity @Table -Filter $Filter).JSON | ConvertFrom-Json -Depth 10 if ($null -eq $Policy) { Write-LogMessage -API 'Standards' -tenant $Tenant -message "Conditional Access template '$($Settings.TemplateList.label)' ($($Settings.TemplateList.value)) could not be loaded from the template store - skipping." -Sev 'Error' - Set-CIPPStandardsCompareField -FieldName "standards.ConditionalAccessTemplate.$($Settings.TemplateList.value)" -FieldValue "Template '$($Settings.TemplateList.label)' could not be loaded from the template store." -Tenant $Tenant + Set-CIPPStandardsCompareField -FieldName $FieldName -CurrentValue @{ Differences = "Template '$($Settings.TemplateList.label)' could not be loaded from the template store." } -ExpectedValue @{ Differences = @() } -Tenant $Tenant return } @@ -118,43 +122,66 @@ function Invoke-CIPPStandardConditionalAccessTemplate { $Policy | Add-Member -NotePropertyName 'state' -NotePropertyValue $Settings.state -Force } + # Resolve the template's location GUIDs to display names so they compare like-for-like + # with the deployed policy. The template's own LocationInfo carries the id->name map + # (the GUID is the source tenant's id); fall back to this tenant's named-location cache. + if ($Policy.conditions.locations) { + $LocNameById = @{} + foreach ($li in @($Policy.LocationInfo)) { if ($li.id -and $li.displayName) { $LocNameById[$li.id] = $li.displayName } } + foreach ($pl in @($PreloadedLocations)) { if ($pl.id -and $pl.displayName -and -not $LocNameById.ContainsKey($pl.id)) { $LocNameById[$pl.id] = $pl.displayName } } + foreach ($LocDir in 'includeLocations', 'excludeLocations') { + if ($Policy.conditions.locations.PSObject.Properties.Name -contains $LocDir -and $Policy.conditions.locations.$LocDir) { + $Policy.conditions.locations.$LocDir = @($Policy.conditions.locations.$LocDir | ForEach-Object { + if ($LocNameById.ContainsKey($_)) { $LocNameById[$_] } else { $_ } + }) + } + } + } + $CheckExististing = $AllCAPolicies | Where-Object -Property displayName -EQ $Settings.TemplateList.label + # Duplicate display names would pass an array to New-CIPPCATemplate (breaking its single-object + # conversion and dumping the whole template). Compare against the first match instead. + if ($CheckExististing -is [array] -and $CheckExististing.Count -gt 1) { + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Found $($CheckExististing.Count) Conditional Access policies named '$($Settings.TemplateList.label)' in $Tenant. Comparing against the first; duplicate policies should be cleaned up." -Sev 'Warning' + $CheckExististing = $CheckExististing[0] + } if (!$CheckExististing) { - if ($Policy.conditions.userRiskLevels.Count -gt 0 -or $Policy.conditions.signInRiskLevels.Count -gt 0) { + if ($DeployError) { + # Attempted but the Graph deployment errored (e.g. invalid CA policy) - surface the reason + Set-CIPPStandardsCompareField -FieldName $FieldName -CurrentValue @{ Differences = "Failed to deploy: $DeployError" } -ExpectedValue @{ Differences = @() } -Tenant $Tenant + } elseif ($Policy.conditions.userRiskLevels.Count -gt 0 -or $Policy.conditions.signInRiskLevels.Count -gt 0) { $TestP2 = Test-CIPPStandardLicense -StandardName 'ConditionalAccessTemplate_p2' -TenantFilter $Tenant -Preset EntraP2 -SkipLog if (!$TestP2) { - Set-CIPPStandardsCompareField -FieldName "standards.ConditionalAccessTemplate.$($Settings.TemplateList.value)" -FieldValue "Policy $($Settings.TemplateList.label) requires AAD Premium P2 license." -Tenant $Tenant + Set-CIPPStandardsCompareField -FieldName $FieldName -CurrentValue @{ Differences = 'Policy requires an AAD Premium P2 license, which this tenant does not have.' } -ExpectedValue @{ Differences = @() } -Tenant $Tenant } else { - Set-CIPPStandardsCompareField -FieldName "standards.ConditionalAccessTemplate.$($Settings.TemplateList.value)" -FieldValue "Policy $($Settings.TemplateList.label) is missing from this tenant." -Tenant $Tenant + Set-CIPPStandardsCompareField -FieldName $FieldName -CurrentValue @{ Differences = 'Policy is missing from this tenant.' } -ExpectedValue @{ Differences = @() } -Tenant $Tenant } } else { - Set-CIPPStandardsCompareField -FieldName "standards.ConditionalAccessTemplate.$($Settings.TemplateList.value)" -FieldValue "Policy $($Settings.TemplateList.label) is missing from this tenant." -Tenant $Tenant + Set-CIPPStandardsCompareField -FieldName $FieldName -CurrentValue @{ Differences = 'Policy is missing from this tenant.' } -ExpectedValue @{ Differences = @() } -Tenant $Tenant } } else { - $templateResult = New-CIPPCATemplate -TenantFilter $tenant -JSON $CheckExististing -preloadedLocations $preloadedLocations + $templateResult = New-CIPPCATemplate -TenantFilter $tenant -JSON $CheckExististing -preloadedLocations $PreloadedLocations $CompareObj = ConvertFrom-Json -ErrorAction SilentlyContinue -InputObject $templateResult - if ($null -eq $Policy -or $null -eq $CompareObj) { - $nullSide = if ($null -eq $Policy) { 'template policy' } else { 'tenant policy conversion' } - Write-LogMessage -API 'Standards' -tenant $Tenant -message "Cannot compare CA policy: $nullSide returned null for $($Settings.TemplateList.label)" -sev Error - Set-CIPPStandardsCompareField -FieldName "standards.ConditionalAccessTemplate.$($Settings.TemplateList.value)" -FieldValue "Error comparing policy: $nullSide returned null" -Tenant $Tenant + if ($null -eq $CompareObj) { + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Cannot compare CA policy: tenant policy conversion returned null for $($Settings.TemplateList.label)" -sev Error + Set-CIPPStandardsCompareField -FieldName $FieldName -CurrentValue @{ Differences = 'Tenant policy conversion returned null.' } -ExpectedValue @{ Differences = @() } -Tenant $Tenant return } try { $Compare = Compare-CIPPIntuneObject -ReferenceObject $Policy -DifferenceObject $CompareObj -CompareType 'ca' } catch { Write-LogMessage -API 'Standards' -tenant $Tenant -message "Error comparing CA policy: $($_.Exception.Message)" -sev Error - Set-CIPPStandardsCompareField -FieldName "standards.ConditionalAccessTemplate.$($Settings.TemplateList.value)" -FieldValue "Error comparing policy: $($_.Exception.Message)" -Tenant $Tenant + Set-CIPPStandardsCompareField -FieldName $FieldName -CurrentValue @{ Differences = "Error comparing policy: $($_.Exception.Message)" } -ExpectedValue @{ Differences = @() } -Tenant $Tenant return } if (!$Compare) { $ExpectedValue = @{ 'Differences' = 'No Differences found' } $CurrentValue = @{ 'Differences' = 'No Differences found' } - Set-CIPPStandardsCompareField -FieldName "standards.ConditionalAccessTemplate.$($Settings.TemplateList.value)" -FieldValue $true -CurrentValue $CurrentValue -ExpectedValue $ExpectedValue -Tenant $Tenant + Set-CIPPStandardsCompareField -FieldName $FieldName -FieldValue $true -CurrentValue $CurrentValue -ExpectedValue $ExpectedValue -Tenant $Tenant } else { - #this can still be prettified but is for later. $ExpectedValue = @{ 'Differences' = @() } $CurrentValue = @{ 'Differences' = $Compare } - Set-CIPPStandardsCompareField -FieldName "standards.ConditionalAccessTemplate.$($Settings.TemplateList.value)" -CurrentValue $CurrentValue -ExpectedValue $ExpectedValue -Tenant $Tenant + Set-CIPPStandardsCompareField -FieldName $FieldName -CurrentValue $CurrentValue -ExpectedValue $ExpectedValue -Tenant $Tenant } } } From f965afebf7fc6556a46f3ff62d3e81515bbb7745 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Tue, 30 Jun 2026 22:39:47 +0200 Subject: [PATCH 061/170] Implements #6214 --- .../Public/Alerts/Get-CIPPAlertInactiveLicensedUsers.ps1 | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertInactiveLicensedUsers.ps1 b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertInactiveLicensedUsers.ps1 index 9a304a61ae372..1623d335a2968 100644 --- a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertInactiveLicensedUsers.ps1 +++ b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertInactiveLicensedUsers.ps1 @@ -20,6 +20,8 @@ function Get-CIPPAlertInactiveLicensedUsers { if ($InputValue -is [hashtable] -or $InputValue -is [pscustomobject]) { $excludeDisabled = [bool]$InputValue.ExcludeDisabled + # Allow the alert configuration to opt in to reporting users who have never signed in (e.g. accounts with sign-in blocked) + if ([bool]$InputValue.IncludeNeverSignedIn) { $IncludeNeverSignedIn = $true } if ($null -ne $InputValue.DaysSinceLastLogin -and $InputValue.DaysSinceLastLogin -ne '') { $parsedDays = 0 if ([int]::TryParse($InputValue.DaysSinceLastLogin.ToString(), [ref]$parsedDays) -and $parsedDays -gt 0) { From 67fdcd25d5177327d68b6861a764c775581a0021 Mon Sep 17 00:00:00 2001 From: Bobby <31723128+kris6673@users.noreply.github.com> Date: Tue, 30 Jun 2026 23:31:08 +0200 Subject: [PATCH 062/170] feat: implement clearable fields in user edit API --- .../Administration/Users/Invoke-EditUser.ps1 | 25 ++++- Tests/Endpoint/Invoke-EditUser.Tests.ps1 | 94 +++++++++++++++++++ 2 files changed, 115 insertions(+), 4 deletions(-) create mode 100644 Tests/Endpoint/Invoke-EditUser.Tests.ps1 diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-EditUser.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-EditUser.ps1 index af7659da83c61..84590fda70b59 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-EditUser.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-EditUser.ps1 @@ -30,7 +30,6 @@ function Invoke-EditUser { #Edit the user try { - Write-Host "$([boolean]$UserObj.MustChangePass)" $UserPrincipalName = "$($UserObj.username)@$($UserObj.Domain ? $UserObj.Domain : $UserObj.primDomain.value)" $normalizedOtherMails = @( @($UserObj.otherMails) | ForEach-Object { @@ -66,9 +65,27 @@ function Invoke-EditUser { } } | ForEach-Object { $NonEmptyProperties = $_.PSObject.Properties | - Where-Object { -not [string]::IsNullOrWhiteSpace($_.Value) } | - Select-Object -ExpandProperty Name - $_ | Select-Object -Property $NonEmptyProperties + Where-Object { -not [string]::IsNullOrWhiteSpace($_.Value) } | + Select-Object -ExpandProperty Name + $_ | Select-Object -Property $NonEmptyProperties + } + # Explicit clears: the frontend lists the profile fields the user actively emptied. + # We re-add them as null (scalars) / empty array (collections) so Graph clears them, while + # untouched empty fields stay omitted. Whitelisted to safe attributes + $ClearableFields = @( + 'givenName', 'surname', 'department', 'jobTitle', 'mobilePhone', + 'streetAddress', 'city', 'state', 'postalCode', 'country', 'companyName', + 'businessPhones', 'otherMails' + ) + $ClearList = @($UserObj.clearProperties | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) + foreach ($Prop in $ClearList) { + if ($Prop -notin $ClearableFields) { continue } + # Pass @() literally; routing it through a variable unrolls it back to $null. + if ($Prop -in 'businessPhones', 'otherMails') { + $BodyToShip | Add-Member -NotePropertyName $Prop -NotePropertyValue @() -Force + } else { + $BodyToShip | Add-Member -NotePropertyName $Prop -NotePropertyValue $null -Force + } } if ($UserObj.defaultAttributes) { $UserObj.defaultAttributes | Get-Member -MemberType NoteProperty | ForEach-Object { diff --git a/Tests/Endpoint/Invoke-EditUser.Tests.ps1 b/Tests/Endpoint/Invoke-EditUser.Tests.ps1 new file mode 100644 index 0000000000000..81aee9276912f --- /dev/null +++ b/Tests/Endpoint/Invoke-EditUser.Tests.ps1 @@ -0,0 +1,94 @@ +# Pester tests for Invoke-EditUser +# Validates the clear-vs-omit behaviour of the user PATCH body: +# a profile field present in the request (even as null) is forwarded to Graph as a clear, +# while an omitted field is left untouched. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Join-Path $RepoRoot 'Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-EditUser.ps1' + + class HttpResponseContext { + [object]$StatusCode + [object]$Body + } + # The Functions worker exposes [HttpStatusCode]; map it for standalone test runs. + $Accelerators = [PSObject].Assembly.GetType('System.Management.Automation.TypeAccelerators') + if (-not ('HttpStatusCode' -as [type])) { + $Accelerators::Add('HttpStatusCode', [System.Net.HttpStatusCode]) + } + + function Write-LogMessage { param($headers, $API, $tenant, $message, $Sev, $LogData) } + function Get-CippException { param($Exception) $Exception } + # Capture the body of the user PATCH (first call; password reset is a separate call we don't trigger) + function New-GraphPostRequest { + param($uri, $tenantid, $type, $body, [switch]$verbose) + if ($null -eq $script:lastBody) { $script:lastBody = $body } + } + + function New-EditRequest { + param([hashtable]$Extra) + $body = [pscustomobject]@{ + id = '11111111-1111-1111-1111-111111111111' + tenantFilter = 'contoso.onmicrosoft.com' + username = 'jdoe' + Domain = 'contoso.com' + displayName = 'John Doe' + } + foreach ($key in $Extra.Keys) { + $body | Add-Member -NotePropertyName $key -NotePropertyValue $Extra[$key] -Force + } + [pscustomobject]@{ + Params = @{ CIPPEndpoint = 'EditUser' } + Headers = @{} + Body = $body + } + } + + . $FunctionPath +} + +Describe 'Invoke-EditUser body construction' { + BeforeEach { + $script:lastBody = $null + } + + It 'clears a field listed in clearProperties' { + $request = New-EditRequest -Extra @{ clearProperties = @('jobTitle') } + + $null = Invoke-EditUser -Request $request -TriggerMetadata $null + + $script:lastBody | Should -Match '"jobTitle":null' + } + + It 'omits a field that was neither sent nor listed for clearing' { + $request = New-EditRequest -Extra @{} + + $null = Invoke-EditUser -Request $request -TriggerMetadata $null + + $script:lastBody | Should -Not -Match 'jobTitle' + } + + It 'sends a provided value unchanged' { + $request = New-EditRequest -Extra @{ jobTitle = 'Manager' } + + $null = Invoke-EditUser -Request $request -TriggerMetadata $null + + $script:lastBody | Should -Match '"jobTitle":"Manager"' + } + + It 'clears a collection field with an empty array' { + $request = New-EditRequest -Extra @{ clearProperties = @('otherMails') } + + $null = Invoke-EditUser -Request $request -TriggerMetadata $null + + $script:lastBody | Should -Match '"otherMails":\[\]' + } + + It 'never clears displayName even when listed (Graph rejects it)' { + $request = New-EditRequest -Extra @{ clearProperties = @('displayName') } + + $null = Invoke-EditUser -Request $request -TriggerMetadata $null + + $script:lastBody | Should -Not -Match '"displayName":(null|"")' + } +} From a0b263cd30d3a08e21f60eab384c9c778acc3e91 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Tue, 30 Jun 2026 17:56:44 -0400 Subject: [PATCH 063/170] chore: update version to 10.5.6 --- host.json | 4 ++-- version_latest.txt | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/host.json b/host.json index 67e853b0f3cf9..cfa1e128d096c 100644 --- a/host.json +++ b/host.json @@ -16,9 +16,9 @@ "distributedTracingEnabled": false, "version": "None" }, - "defaultVersion": "10.5.5", + "defaultVersion": "10.5.6", "versionMatchStrategy": "Strict", "versionFailureStrategy": "Fail" } } -} +} \ No newline at end of file diff --git a/version_latest.txt b/version_latest.txt index 23b7528bc2089..3b24057083036 100644 --- a/version_latest.txt +++ b/version_latest.txt @@ -1 +1 @@ -10.5.5 +10.5.6 From 259770d93654ec76cb3d1ae6b44750ea3d9b0ca5 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Tue, 30 Jun 2026 18:37:32 -0400 Subject: [PATCH 064/170] fix: revert rerun protection on scheduled tasks --- .../Push-ExecScheduledCommand.ps1 | 53 ++++--------------- .../Scheduler/Invoke-AddScheduledItem.ps1 | 7 --- 2 files changed, 11 insertions(+), 49 deletions(-) diff --git a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-ExecScheduledCommand.ps1 b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-ExecScheduledCommand.ps1 index 8a358101773e4..b37e214051722 100644 --- a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-ExecScheduledCommand.ps1 +++ b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-ExecScheduledCommand.ps1 @@ -50,48 +50,17 @@ function Push-ExecScheduledCommand { # Task should be 'Pending' (queued by orchestrator) or 'Running' (retry/recovery) # We accept both to handle edge cases - # Check for rerun protection - prevent duplicate executions within the recurrence interval - # Do this BEFORE updating state to 'Running' to avoid getting stuck - if ($task.Recurrence -and $task.Recurrence -ne '0' -and !$IsMultiTenantExecution) { - # Calculate interval in seconds from recurrence string - $IntervalSeconds = switch -Regex ($task.Recurrence) { - '^(\d+)$' { [int64]$matches[1] * 86400 } # Plain number = days - '(\d+)m$' { [int64]$matches[1] * 60 } - '(\d+)h$' { [int64]$matches[1] * 3600 } - '(\d+)d$' { [int64]$matches[1] * 86400 } - default { 0 } - } - - if ($IntervalSeconds -gt 0) { - # Round down to nearest 15-minute interval (900 seconds) since that's when orchestrator runs - # This prevents rerun blocking issues due to slight timing variations - $FifteenMinutes = 900 - $AdjustedInterval = [Math]::Floor($IntervalSeconds / $FifteenMinutes) * $FifteenMinutes - - # Ensure we have at least one 15-minute interval - if ($AdjustedInterval -lt $FifteenMinutes) { - $AdjustedInterval = $FifteenMinutes - } - # Use task RowKey as API identifier for rerun cache - $RerunParams = @{ - TenantFilter = $Tenant - Type = 'ScheduledTask' - API = $task.RowKey - Interval = $AdjustedInterval - BaseTime = [int64]$task.ScheduledTime - Headers = $Headers - } - - $IsRerun = Test-CIPPRerun @RerunParams - if ($IsRerun) { - Write-Information "Scheduled task $($task.Name) for tenant $Tenant was recently executed. Skipping to prevent duplicate execution." - Remove-Variable -Name ScheduledTaskId -Scope Script -ErrorAction SilentlyContinue - return - } - } - } - - # Also check for one-time task rerun protection based on ExecutedTime + # NOTE: Recurring scheduled tasks intentionally have NO rerun-cache protection here. + # Duplicate execution is already prevented by the orchestrator's own state machine: + # - the ETag claim flips the task to 'Pending' atomically (no concurrent dispatch), + # - 'Pending'/'Running' tasks are not re-picked until they go stale (1h/4h), + # - ScheduledTime is advanced on completion so the task isn't eligible again until due. + # A separate rerun cache (Test-CIPPRerun) was a second, independent clock that drifted out + # of sync with ScheduledTime whenever a run didn't finish advancing the schedule, which both + # deadlocked tasks and blocked the orchestrator's stuck-task recovery. ScheduledTime is the + # single source of truth. + + # One-time task rerun protection based on ExecutedTime (the task's own field, not a cache) if ((!$task.Recurrence -or $task.Recurrence -eq '0') -and $task.ExecutedTime -and !$IsMultiTenantExecution) { $currentUnixTime = [int64](([datetime]::UtcNow) - (Get-Date '1/1/1970')).TotalSeconds $timeSinceExecution = $currentUnixTime - [int64]$task.ExecutedTime diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Scheduler/Invoke-AddScheduledItem.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Scheduler/Invoke-AddScheduledItem.ps1 index f3443d1471c64..909c824a59047 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Scheduler/Invoke-AddScheduledItem.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Scheduler/Invoke-AddScheduledItem.ps1 @@ -26,13 +26,6 @@ function Invoke-AddScheduledItem { } if ($ExistingTask -and $Request.Body.RunNow -eq $true) { - $RerunParams = @{ - TenantFilter = $ExistingTask.Tenant - Type = 'ScheduledTask' - API = $Request.Body.RowKey - Clear = $true - } - $null = Test-CIPPRerun @RerunParams # Clear ExecutedTime so the one-time task rerun guard in Push-ExecScheduledCommand does not block re-execution $null = Update-AzDataTableEntity -Force @Table -Entity @{ PartitionKey = $ExistingTask.PartitionKey From d17b33f46405056fcd5a8cbd42a6d8b01e18bc33 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Tue, 30 Jun 2026 21:11:31 -0400 Subject: [PATCH 065/170] fix: add system32,osdrive --- Modules/CIPPCore/Public/Get-CIPPTextReplacement.ps1 | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Modules/CIPPCore/Public/Get-CIPPTextReplacement.ps1 b/Modules/CIPPCore/Public/Get-CIPPTextReplacement.ps1 index f96bef07090cc..f6b1abb01a863 100644 --- a/Modules/CIPPCore/Public/Get-CIPPTextReplacement.ps1 +++ b/Modules/CIPPCore/Public/Get-CIPPTextReplacement.ps1 @@ -29,6 +29,8 @@ function Get-CIPPTextReplacement { '%serial%', '%systemroot%', '%systemdrive%', + '%system32%', + '%osdrive%', '%temp%', '%tenantid%', '%tenantfilter%', From 52ce1759413b411f500df829abca2b5cf2bf5080 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Tue, 30 Jun 2026 21:34:01 -0400 Subject: [PATCH 066/170] fix: exclude imAddresses from user backup/restore --- Modules/CIPPCore/Public/New-CIPPBackupTask.ps1 | 2 +- Modules/CIPPCore/Public/New-CIPPRestoreTask.ps1 | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Modules/CIPPCore/Public/New-CIPPBackupTask.ps1 b/Modules/CIPPCore/Public/New-CIPPBackupTask.ps1 index 55f189947694d..d8dfdeecb4f6b 100644 --- a/Modules/CIPPCore/Public/New-CIPPBackupTask.ps1 +++ b/Modules/CIPPCore/Public/New-CIPPBackupTask.ps1 @@ -27,7 +27,7 @@ function New-CIPPBackupTask { } 'users' { Measure-CippTask -TaskName 'Users' -EventName 'CIPP.BackupCompleted' -Script { - New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/users?$top=999' -tenantid $TenantFilter | Select-Object * -ExcludeProperty mail, provisionedPlans, onPrem*, *passwordProfile*, *serviceProvisioningErrors*, isLicenseReconciliationNeeded, isManagementRestricted, isResourceAccount, *date*, *external*, identities, deletedDateTime, isSipEnabled, assignedPlans, cloudRealtimeCommunicationInfo, deviceKeys, provisionedPlan, securityIdentifier | ForEach-Object { + New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/users?$top=999' -tenantid $TenantFilter | Select-Object * -ExcludeProperty mail, provisionedPlans, onPrem*, *passwordProfile*, *serviceProvisioningErrors*, isLicenseReconciliationNeeded, isManagementRestricted, isResourceAccount, *date*, *external*, identities, deletedDateTime, imAddresses, isSipEnabled, assignedPlans, cloudRealtimeCommunicationInfo, deviceKeys, provisionedPlan, securityIdentifier | ForEach-Object { #remove the property if the value is $null $_.psobject.properties | Where-Object { $null -eq $_.Value } | ForEach-Object { $_.psobject.properties.Remove($_.Name) diff --git a/Modules/CIPPCore/Public/New-CIPPRestoreTask.ps1 b/Modules/CIPPCore/Public/New-CIPPRestoreTask.ps1 index 595b18dae5a79..b1b2dddf77c80 100644 --- a/Modules/CIPPCore/Public/New-CIPPRestoreTask.ps1 +++ b/Modules/CIPPCore/Public/New-CIPPRestoreTask.ps1 @@ -39,7 +39,7 @@ function New-CIPPRestoreTask { # Helper function to clean user object for Graph API - removes reference properties, nulls, and empty strings function Clean-GraphObject { param($Object, [switch]$ExcludeId) - $excludeProps = @('password', 'passwordProfile', '@odata.type', 'manager', 'memberOf', 'createdOnBehalfOf', 'createdByAppId', 'deletedDateTime', 'authorizationInfo') + $excludeProps = @('password', 'passwordProfile', '@odata.type', 'manager', 'memberOf', 'createdOnBehalfOf', 'createdByAppId', 'deletedDateTime', 'authorizationInfo', 'imAddresses') if ($ExcludeId) { $excludeProps += @('id') } From 9cf8a3c674e72204b40b9056ca42dfc74da9e0c6 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Wed, 1 Jul 2026 15:33:56 +0800 Subject: [PATCH 067/170] Reduce Verbose nop logging --- .../Timer Functions/Start-UserSyncTimer.ps1 | 31 ++++++++++++------- 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-UserSyncTimer.ps1 b/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-UserSyncTimer.ps1 index 435d3a3d4a017..89710f3e8bf37 100644 --- a/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-UserSyncTimer.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-UserSyncTimer.ps1 @@ -20,8 +20,6 @@ function Start-UserSyncTimer { $ApiName = 'UserSync' try { - Write-LogMessage -API $ApiName -tenant 'none' -message 'Starting user sync from partner tenant.' -sev Info - # Load the role-to-group mappings $AccessGroupsTable = Get-CippTable -TableName AccessRoleGroups $AccessGroups = @(Get-CIPPAzDataTableEntity @AccessGroupsTable -Filter "PartitionKey eq 'AccessRoleGroups'") @@ -69,12 +67,6 @@ function Start-UserSyncTimer { } } - if ($UserRoleMap.Count -eq 0 -and $RoleGroupIds.Count -gt 0) { - Write-LogMessage -API $ApiName -tenant 'none' -message 'No users found in any role groups.' -sev Info - } elseif ($RoleGroupIds.Count -eq 0) { - Write-LogMessage -API $ApiName -tenant 'none' -message 'No Entra groups mapped to roles — will clean up any stale auto-provisioned users.' -sev Info - } - # Load existing allowedUsers table $UsersTable = Get-CippTable -tablename 'allowedUsers' $ExistingUsers = @(Get-CIPPAzDataTableEntity @UsersTable | Where-Object { -not $_.RowKey.StartsWith('_') }) @@ -91,7 +83,6 @@ function Start-UserSyncTimer { } $Now = (Get-Date).ToUniversalTime().ToString('o') - $UpsertCount = 0 $RemoveCount = 0 $EntitiesToUpsert = [System.Collections.Generic.List[object]]::new() $EntitiesToRemove = [System.Collections.Generic.List[object]]::new() @@ -134,7 +125,6 @@ function Start-UserSyncTimer { } $EntitiesToUpsert.Add($Entity) - $UpsertCount++ } # Reconcile existing users that are NOT in any mapped role group @@ -205,8 +195,22 @@ function Start-UserSyncTimer { } } - # Apply upserts first (write canonical rows), then removals (drop duplicates/stale rows) + # Apply upserts first (write canonical rows), then removals (drop duplicates/stale rows). + # Only count an upsert as a change when the role data actually differs from the + # existing canonical row — LastSync alone changing every run isn't a real change. + $ChangedCount = 0 foreach ($Entity in $EntitiesToUpsert) { + $Canonical = $null + if ($ExistingLookup.ContainsKey($Entity.RowKey)) { + $Canonical = $ExistingLookup[$Entity.RowKey] | Where-Object { $_.RowKey -ceq $Entity.RowKey } | Select-Object -First 1 + } + if (-not $Canonical -or + $Canonical.Roles -ne $Entity.Roles -or + $Canonical.AutoRoles -ne $Entity.AutoRoles -or + $Canonical.ManualRoles -ne $Entity.ManualRoles -or + $Canonical.Source -ne $Entity.Source) { + $ChangedCount++ + } Add-CIPPAzDataTableEntity @UsersTable -Entity $Entity -Force } foreach ($Entity in $EntitiesToRemove) { @@ -217,7 +221,10 @@ function Start-UserSyncTimer { # Invalidate CRAFT's in-memory user cache so changes apply try { [Craft.Services.AuthBridge]::InvalidateUsers() } catch {} - Write-LogMessage -API $ApiName -tenant 'none' -message "User sync completed: $UpsertCount users synced, $RemoveCount duplicate/stale rows removed." -sev Info + # Only log when something actually changed — no noise on steady-state runs. + if ($ChangedCount -gt 0 -or $RemoveCount -gt 0) { + Write-LogMessage -API $ApiName -tenant 'none' -message "User sync completed: $ChangedCount users added/updated, $RemoveCount duplicate/stale rows removed." -sev Info + } } catch { $ErrorData = Get-CippException -Exception $_ From 103078a4210304204077e2cbf577087599a095cd Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Wed, 1 Jul 2026 15:40:33 +0800 Subject: [PATCH 068/170] JSON escaping fixes for intune policies --- .../CIPPCore/Public/Get-CIPPTextReplacement.ps1 | 14 ++++++++++++-- Modules/CIPPCore/Public/Set-CIPPIntunePolicy.ps1 | 2 +- .../Invoke-CIPPStandardIntuneTemplate.ps1 | 2 +- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/Modules/CIPPCore/Public/Get-CIPPTextReplacement.ps1 b/Modules/CIPPCore/Public/Get-CIPPTextReplacement.ps1 index f6b1abb01a863..a89ce2bee2662 100644 --- a/Modules/CIPPCore/Public/Get-CIPPTextReplacement.ps1 +++ b/Modules/CIPPCore/Public/Get-CIPPTextReplacement.ps1 @@ -16,6 +16,16 @@ function Get-CIPPTextReplacement { $Text, [switch]$EscapeForJson ) + # Escapes a replacement value so it can be safely spliced into a serialized JSON + # string literal. Handles quotes, backslashes, newlines, tabs and control chars. + function ConvertTo-CIPPJsonEscapedString { + param($Value) + if ($null -eq $Value) { return '' } + $Encoded = [string]$Value | ConvertTo-Json -Compress + # Strip the surrounding quotes ConvertTo-Json adds, leaving just the escaped body. + return $Encoded.Substring(1, $Encoded.Length - 2) + } + if ($Text -isnot [string]) { return , $Text } @@ -70,7 +80,7 @@ function Get-CIPPTextReplacement { if (-not $Var.PSObject.Properties['Value']) { continue } $Val = $Var.Value if ($EscapeForJson.IsPresent) { - $Val = $Val -replace '(? Date: Wed, 1 Jul 2026 17:05:40 +0800 Subject: [PATCH 069/170] Bump AzBobbyTables version --- .../AzBobbyTables/3.5.1/AzBobbyTables.PS.dll | Bin 43520 -> 43520 bytes .../AzBobbyTables/3.5.1/AzBobbyTables.psd1 | 7 +- Modules/AzBobbyTables/3.5.1/CHANGELOG.md | 13 +- .../3.5.1/dependencies/AzBobbyTables.Core.dll | Bin 49152 -> 49664 bytes .../Microsoft.Bcl.AsyncInterfaces.dll | Bin 27960 -> 28432 bytes .../dependencies/Microsoft.Bcl.Memory.dll | Bin 78096 -> 78136 bytes .../dependencies/System.Interactive.Async.dll | Bin 368080 -> 368080 bytes .../3.5.1/dependencies/System.Linq.Async.dll | Bin 1189840 -> 1190352 bytes .../System.Linq.AsyncEnumerable.dll | Bin 460560 -> 460560 bytes .../3.5.1/en-US/AzBobbyTables.PS.dll-Help.xml | 228 +++++++++++++++++- 10 files changed, 237 insertions(+), 11 deletions(-) diff --git a/Modules/AzBobbyTables/3.5.1/AzBobbyTables.PS.dll b/Modules/AzBobbyTables/3.5.1/AzBobbyTables.PS.dll index cd004ea9a49b2d2e80c431a8b4b0be0ce5f1716d..9854cf9db2e1b423a2d2fa0693be3fa3d0d1cf16 100644 GIT binary patch delta 27841 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 I?*Wl82g}lc#sB~S delta 27457 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 ZIRIh+VgO;2lf88U{5Z1_zV88%FbASj+$R74 delta 31445 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 diff --git a/Modules/AzBobbyTables/3.5.1/dependencies/System.Linq.Async.dll b/Modules/AzBobbyTables/3.5.1/dependencies/System.Linq.Async.dll index 35906b95ebbb0d862af1ada3afb3b0a34217ee15..cfd93e39366fd26596bb7fc320d8169610c89bcb 100644 GIT binary patch delta 111970 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ɋK-*9QQW{!F`N{yNYdYKf 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 z�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 Sp#o^DVTPhQ8uOn4&i@1Ov*~RB delta 111199 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%0Ap4ynpJPUn4?~3`l`7-ld<_|FDGQZM7 zOB*cIowUH^7h^69EXgSy;ywwpx=HNzAvj$ih0d z$bv459vX95>}|fK#TM4&#TNFL#ebKKxh!!r-_jBbc3HB^JeMV{*5suYbXjV_E=#AF z=dyH5D=wGKxAcP2LS354I=QueT&H9`%1UqIQBDaZJ~%z62kYb~6EG>CeM=XR$BHrn zCQoNw@7Xj%swdDd5LiWTB(R>|MzOoUE?6JJ`vd3rHxv0cS6kue0{R?2eSalt^~Ryq z8(ol<&%e>7Bun@=Nv&{3@NcsDHNLFADH*?+^@z=Jm6%GSMV+R=0Y=%6fp z$}r*Hi^uw-Jy?uOyRiDT3#+_cSWDW#!U>+_Ne%B{It~rPgxF9KD1V`oNFu|uybb2g zmEeKVldVW<1t5e-QN0^mXQk;R23sfxZv|wu?j} z(WCVcv%}M?HE(ZjXCa2X zZK3X&c`i&qtN7A_@6U{}P&ds2m$j|99JSEW=N9UIvJhV?TSc)w%(tY?woo_G0+$7? zxR{Pd*t)Y8>K6Tpf0_gN1nR1|Fsu|hXn2o@&W0GsI2Ewj-g zO-GAguPOz{1%HyOMh7j}h5{w%jBSMLUqW!g?K0h$J>!XJeG=b~-B49SSd0lKG0r+Q2qEsZokF-W>sa z`co3NhC6HOqURJFn!6X8yE~e@ zGuxBNz9a-0IgstpF=o4bbQNXpY6sVyL+F>d?%dXn4w_1T#9?1=2X-aj`SdUC=}z60 z?am4x;6ZtK12_h?cEISPEJ|J^72u<92?O^<3cl!%B}v%XYT|N+w@5?i$6BJ#l(0xO zmXHc0+<*LCWMOW6BWei?4c*_FK94ZFBmD$naxncT!u$}%i@?52G(vTM zrVL?EC4C5CstbJ`VXQO#Ho^>7`WAT(ppTK~PJi@m^4y(!j^dsZ=;P!$iT;{A52D{C z&x7f2$n#kG9r8Sm{)#+Ta`=-t{8{vI4Bj(nn*JIKvGhXvUG~jl`WyDmTKawV%{uxn z62-XFhY_ZG(qEEi7gnfrH&&+a=m=$*T}OUD{P<>{RYA? zZ-4qTgi*dM?C(!MMHn4U|A;WIALE2DC64KYFeiaYC-~X)4+MV{qad9=h6x}>P%)_l zzlsS#1#)Pb$-%-9TFXqv!dSYIS%oNhQYHi8XnUp{VUdhkhOk1;93V~EG2Iakr4Bq_Z-WbAf*p$g6LH4Ih}jXqmPp3A@oi1tm2*vx#tSjWGPjw z$)cxo&ok-U*fG&`GdnO+=y~kGh^Akp53p}m&{x?v8|k;$H=F4%*f+cRH*fH7j(^gJ z**C}NFWEPz_%~;#{F`t1H$T$H*f&4%68_D5ZfH>11kOAh)_CkMuwim)e>7C>`oc@Fi?|tXvUSy<`gED%;q#^Aeqe>Ock+{BfXT^NlEV~&tCLP zF?32n1AG9D+Yy�EMGf%_v6l6~1N#1BD8m>2Ovx>HKV2LO~H z$Mb71a`Y|MbYlDfy3(&vws;UO0fk5+f__qoErg1v+d>~{y%_Q&ELtf2E@h{O7B9y< z45Haf>xGDhqIG79hN3Ug(cM-IVIm2+&x^7~_)<1PPZ4-Qgam9L5`SfNl!84I3=kMe z*?@zH+(a&LAf8pBl7MP=0&;x_7!k;3g@Xx@4C`ixDvhMAm76@tGWQYL!#D@{Z zlUdGG_eI!~sqO2{^alVBaA2lV5(pMaC>%((*h@Chx2p!{oISI_!3Hu#GhJ{1$)N+8 zLik4@By(mWvz>Bf;sC0d-BbrqqA3zFttk_0WK28=>3?g?5h@CLNq}wdjOF&KnM;%_ zlYr6GHPh|gW4_gI@2UCr$O#Lh6;N(WA^`o8R!q5@Xps?X9oZH!daTxtuoZ(d_CW`< z9L1TB4D=XnAr%02BCvuik;EOk;Bo*ErLF9PFjYG#gI>y{0Z`g03L%cHLnFK_Tf^SHQ)LA@x70MZN-@Ax4{oaxFMz#f<4OQ$>ad|H~c|SFiuQ;1ymg|^X~;N?pi4B?(Xg` z#ob%9xL@4eor^=U;_mKH+_kuS(Z~0l_kZty&TNu7$tKxNb~l@u`3=xyJJxv#{b%Rk zR|IbSEh+fFaMdj;kS_KvPQRgHXB7$yl*?3J9Dqpv!j2&#luf2RwOlu;plsvjqw>H>Iza=tR2zdK_^E-i5$dc&Od@BG3N!)Mz6oc!PRo{U#O{aiFZA~Ju3c@A$=_3=o4JX=?^lVm6 z{tiDhSxeqk|D}(`l0jsNLaxs=!Sj?hc@;w2?1l9#_>>{hgJf6){z(+Jb_iV{WIHmF z*OXi=zDzB68z`4-nvAR>+NpY?f&F*74B#V2;|tU&FmOj9FOb0BErIOHKmWC$mg*vU| z$T!bj3ye~bJS)x0gra#?4~AxY*22K#oB1Ho7{xP!#8yD+lZ{oN%&@{br9@4wHFQE4 z^a2fvo7-w6?TX})OglZJcxpOM$FA37z7ct9{@HS3rHtSTZJBv&Nl%hs6<=Sbp(=(7 zyPVqbcAU;#cb1Obj4FJzG+QDSvOUXWFe{Akf#rHoEYbG7v8WW#!NM1eN&RWk7HudE zhU$bjNzC?3VK70^oTiZGq8oQM{U@bT`31D8Ck2XS*$fDQ+P@=)!^Exfhl;lY@Eewia zi?j&D6e{7%zKl?F~Jl?E6sck)bW zg))v7g~R&XTwV%CJ%a7d(?peo&VoApR4)6uoh?=lZrS_KzxOD5(~_p+lBV;LsQr_u z~GFwj41Y&BVtN!Q*1d1FBC3Ca1X`u#Boo@VyBU~-3$lZ zz*`j47q)j9`@NRfHv-3bXrU^1Fs4R$^jhIi^vXuO^vb|Nsl979uCH<1XCVCP6?gx@ zgrw_(k)n7j>Y)q)`V0tjL#I7!IPTawS}uH6sUb^pG8!++C86)!eF2k71LG^#8Q|^b zt%Oq$b#-@&K8WA>?X>T{hTck!3_U3NKst{cX2fU-&XOyd%m+aj5Gl;LdMF|6j~;*5&Q8c-L2g$|a+%x}OJS9bX##-khn zcdR7QLdqg@H30UI_H+GEu7PhMFEcKXv|n231g8Jiqu0^ z1j3%iDXK=aK-XX58Gt>Q4Dgtfrok9PsD;3&&<*I6u!mB6ZQ}a|XDCAhiMpep2Oe9iUUrDN31Qu*@})_A zkB6~_YSHg%l93hAMOoBCl|kxUGx*}`I1n^g{=gqZWHF2bQJks@ydpsIQd^SDsKt;W zVC+ch0!sOFge(DizYAv*w;Z`?pCI}w`%YK&z^+0=?P%?0m@ElOH+|9=UJh@OgUf*j zfAm!LTek_wmM9NLQTy)3Rl?;6YEE|L?y_^brMUk=ZKaaDq5USX29P z^dL+ATK6bO%pA=45bg=8*mufjH@-3TFIr2%Ce+kh>DIqCT+NK|r}P zQ7RB!u?P=)co|Skkg%XviBD!4!G6R<5I`U$D$fT8@B)J=TRIX!xN=kI01rK=WGXfN z$dCaDr2#6__}i${d0Xh}j=gL7s??dB3YVjksGefImK;tI^A<6fP1~UgfOCbR2r;?5 zM|vMjTlmO%NHRwk>1zPYt7|wx2sy9TFbL1!(i{p4#Z{{lK#^px#zV^e3>JgDW1E5Ld%A{KP)A$RO9u|p|+_RGeYF8ou32pphSb9B8XbpuR1=^3~iKxmJsQUp* zlMfmv8eV_H>fa`skTM&kDLy6|U^c)Eh#UpiaOQ=S- z!*k6wBxr4W41rwfinS5Q!@mY%u||V^27=Y~t2r^XmV93Oy_sp0kTEM9Ee$OtE$Qyf zsqVJeoEhhotx{<}^#=*wq^0hnVQ(8fy)Nb^FR`a4xSAlU@(iJJem_&&34?-QMu-xo ziy?J&bt)CLc-Y%pG%fKO`#h|*8cS2KS0@pyEQy!KzU^ruGfV1vlpw5+`n7&!-cTlqDXfyr@y=O+oT?sa-6C2uwOdgJw_kraq4E2QX@=!FT$Fd zplu2m0(Qa(jDD$&AxHkYEe#U7AzClfHtBWkKZ5;>(Cn4s}iL}foE^GPjNe*$h?GCDlPGrB27C4DXv4pgNZy8 z_u_7pG6bZE6OC3&72b-lomJroNv&Y98oA6759u(gGkHb{iB~qTO%M9^k9`Ti7x#@P zpfFr;^(WebSphS&mRlnuP_7<(;tN330)h9URcR*2fL7oKBDzbq08?-)$~Ft~7Zh5a z$iELQ_s%*r{H!BAc>n%kSe(nNtXRJqufZFk)%dG()#3kJ++=FtfU7q{Pe8aaNb54m6@Et{c`H%3I z^fWG^HQD)hdL?7vys48t{f}e?+M3b5qy_Jo)UZ!jVlvF-!t$d-%XG&lzP0R%7>%EC zglJ8iX~nOs^mHgcOH2xIgmA}u7C2fGIoqVCZ0eJ+OP=;Seg6~7E-9$SuevkwI;N`3 zUa)Ab6>s1W%}xoj6ka-b)MxdVIg)+2L-9jz;h#h9g^}?}U+lX8va@hOZXi7`6fLIj zpW@lyq2S8Qkk^sN|7*yn%81iMtFB4OI@B#CLEOgniuLp>vqb4kKQ9uz`(b9tH4^0~ zDY{sccO*#Fx3fZGttYdS`8JmC!*NJ(ICLmt(uB)yrOrOGV8 zEh6kuv;H=OwbpQ`pc=hkctF!NSJ;!WQ5dy3Aw}0T7jceYEr5b+PQI4)kG3hK>` zJDBN?U)2`m=o`4-pM2-NAB!Ui61nM)y&Gi-p1LbX3nse5`x8s=%Y~62-L*Z`u7 zC~#{UQ*j_cM<5lPfoqUhBEgNz0Ve1X%cFm2pou^Z=pTHJnSDe{B&^R%rtjf10gH4a zJE5wCT&GggT0DV}Mu@?g##M;2qdZCS_!z&@Wr5qlhnW|+dWrPy$EGoun!gzxw6F zuuM>l${%=H@uORWd?M_X@xCi0VM!p$cAzYaf3?T$0FhK8apLj%7iqm{raBC`>+LT+ z@NLlaXFXJ~ECJG?=8*4!5$xTn-vt4D*mP&C>c-PVwfx6Msy`QZku(FdyTHvDyZR}! zsM%MM6BDVwVfewQZ)OGj@YY90iV_?=YEmFp@Kx~pQ6FFg7nT~|G4U((kLzgs*29#E z7F1GIq`hJ=xLboPD4*ejOqVzGktqalOnnA}F(C!}@z>z@a56L4v-1P2Sp@!jcEFqIPM~gbMp2^;r zdvy^NRB&N=|AmH{aB+i)aXdgDKXUNqa zu{Vl;wpV5o)tX*3=t;D{Z zlU8&xYp!`*Ij>;%EoXG&#_DgJwcQ*f44`&c{YVCQ z8dFP>A5kZcym1J@i`Pj~0%iaJMffI200<&A6%pW;gfTj|EiA2%62j;R1~pOVAv=NH z>CY64gZLulHdYm2BdD&A!HfV$0!rB%9KZnC2St#}3?#(OdGX%3i-%tq(H~JcXCHMJ zA9bXsYBcaIPGNoD3=Ta{W?dlX-KRJSfUs&4Zto*xFBF+@Kms_5G%cFM08~;?>XB~S z@mb5UvyA>QL7bQ!x*_8O&E(;^#z3ucvKIWAqS0=z6dM}00r+3rtB1A&NDx9sXJX=z z421L1mY0ng-!C({mW!a0u4;mhXbI;dusnHX9?xL?l~rOVL?A>6oTl}e0XzzF8Djoq zXGBi$v9G7$TH`U4UoCVC6jZ;5Lr1U`1+_J%!l|dFsT5QiLx>=N#u6GO0gl40=d$ph6 zja)M$Y8$e~mAL;tf3DXGSz0Tg0S<|Yz1kRv5NwUUPpw2t#aY)3?l>u9j$i7b^M}H) zdsuAjnTaXbGEeA15CC-*MNc9~e{1usw5s#*im%+ivuYF33)GDKd#8KDt=k|04`hX; zEx%w$K0}{QIk{(s;bzkeiIQRG`T>DGy%DE$`#0Q&K)|-ETI9mR-U}P|Z;J^X6PI%& z7R{P_9s*gmLj5a=Z&v`eH>sLiT3_aUWx%j z_7}?Cdt{I(;(VI$QswKWsiJhwE0LOK4=BEy24k2+&fqQ|u+2q_H?q}@{O>3Fp-DpS z>m9T_jLH#y&$%#-%Gp#-5GVE}1|XY^5(=8HIbWm<41Gf1<4=j`aiZ7rAxy&pft(j$ zs_bC|a7;=@!a7SLRP1i`hyN{X;@Ubp{Y(vdL*ED-7-@8mu^)2 zn0Uur#9I}(lgICne<9b-6Q@cQB`M6+Za8TiNtr`e%Mm9e28i-En#`byB66r07Zt}g zaFFH62heSVS9&P}9E1MT-dreT#DCj!P~AqXM`*#*>8VeF3^>;WugU{v5C^M;C_sRx z=+C0zw&DS}pSMB_Ec!zeLYN%U$^AgOIIb+OXd z3jE1=(I5lg37E^p3(G&nU%I{M|Pw>Lk5etOa_#pD2=uG>fEuG#G6G z{I^t#M+YGRFxVMBM*gb8sYn{2uU4YPVt^+x)URbO`bLz>qgYy)bgi;~t3Sw7{DvyY z>DXLD{gGX=N`x>0h8Te**5H#aZ(8NEG&YK|t5OO7)xG-j`xGP$B;4V4m zw#@UhhnTZN4O#9wd=MujV1k~L^PLhf;UlhI7$E0r7kyT9|Jet*C^>2cJ=&hily%+m z{SN}V4M>0C*-v|IXuz#XF|RN@;8Im|jsPk}ZteFULP2ZrABU1EEaSz`>!TE?*94PI z)5;jdiiYz@cN~|_Vv?-4RexA83 z(CSAjuz;vpwp&LW-h!{Uj((Svlt!c?4jR^*Sz`XTMdo1LvJ}sSy4LOVFwDm9jHKco z_`k4m%egLu%bG+=R|C_40EhU*X0)q!CJx4BY9Noa9Lz6Q8*xq~eor3Ntj&T|=dD4v_NxlV~4Lew)zjVB`eLyX16BEd7&z zK!BJ^OcXtYZJzyyYjW*a;3g7PgKMMN=tuR9-j9iSQ>F2X%dbwb`Z;v=2ks1ar=tR# zV@fiT#_3Av58SpZ$55_%NzP8_DJU=55*O%^?|Sri9;D+|JXJ^Unrgr+p0!c85nE5;+>Si&w=|-1u)qgZwTNeZFGAV_Om^X{_C%E( z?GZV)!-eAy2xD*H<+I4@o20`xmemm&zgs5?&9Ad@9jg{(1U!W%hay7!P#tVs3IQm~ zj*6_jawQYeSqH!v4p&&F%`ntbiGe=G;>TXzLEJTF8(SWtaM-%dvH-{EWU*lXJVnvD zHcT;qM$Pw}fFHGX4Ed^kIM&;)`_1{f0SP}o22Yp4>URB75It)}SPVcJsSvIXa$t+p z=P=*@_(Zkt!zsITwXT}@l{!aMA(*yQt6I7$m9xNgQrV%p~Ir7-^^HjfPPOzRUhCMPSWKp5Mo-!gt7b#`XPQvog z)e*IA!1L6njTc=%6pTzsaF!d9TJg=*N6zgP2H;H8qWK>CH=^Pkm%^eDS#g4xKW*7= z)936JmifFFCOFB1;cUt?kjU9#)S%Sl;t9Hn*Dad%&NzTE+^?3m0>HMn+E-HZ!uyhQ zBLoOPa!M$Ru!elwYB2Oy9#rZHy3-GW)ce=8v5R>fx6-LPe%I8E^7=C+s6&%l%8WHT z@SzKl3)|%%JnOV6k#?7tW2Oziwu+4x+OL-J|E;2M;y;77@^K(=lnmM^RCk-Ufaoz( zQ$vKfx*aYNd<+I?YHG8oXfA}-6#^Mqboj%+HBk?^A}($~fqP)&&U+0sP684UtlVxW ziPP)2KgFUq5Pu+~0z@)LjO=>m(+DWn!(=zU6%~O33XEE=p(C|b%hg{laDgdT`Y!HB zo3jS3p3-^pZuCrD1t2wxmzHo>{Y40XU-E)WpBBJh)0!`#eDbh$L=2m&gy}?n& zGps}S3KOs`HggvMaXAR-?{lu=M>jG;`4h9b{P5Ch2Gmde&%q)~iRrVTU_eO{ zj8hYofl z%*Y_(kq4rISU{deVNW6^PdKN?T&0ngp&F~4TtHA+vQM2-0C4&_gj2L?_%93GePlCx zVj~O94-7Ob|+{x$Q86vE+FH8AVX<0}|%-E+h z7U4=eIlMOp?esdu93@)iWN0Jax7_d!kN__S#lvAl&@yUW)|6QZGWp+Fg6vU#EmUQC$ObjBWHDiKRz zI3U1-wDdU4{9Jnjx+8+oZmr(V9X-X%lwl^7$$X0OfQgg)t&IQprB)2Ul9mi({f!av zaJ=Np)Mmk-E4O-YLVyxkd!9?yqHp0-d_X>0#n!m1v(e!$g5>|og?cOK@ z`rzU)H0q8?e9L79l!G%5y4^#7iQ2!Y93n+SocyMB&hEy~RR-To!l>L%? zh{svcY8t8b2a-ydK4Qa@!h;Do(cn1uVI3#S5~j*k2g?HqV~6=gD1LdVe;0hK*jMe^@3U+H5U|pmMa=UbI5c(KqW5Thnd@0E;ZYYAIP~ zn;qe82oFi9F-`Jy_spzpXSYDo=6Xf|=w z5>eppNf$k)d`#{{31xY+oGizP8bt2xlA}i-;rudWWB7u`W zoIkDq0U8pGjY>j1$D3@^!URSAZsi>%Kwv-**9$FYZ1gE&fqr$AQC!33XLyr~Y+!g! z)PeWlSD5e<%vRP3cTn`Mk!$o$eCz`9vGx~I5!ADE?TPg@)8T)sZ62WXkp2X4BP1AJo$Inti%D*>Sq658e_w zY|HFfY7~|u2HCDXncZF6tPN>d{IELix<0LpPah?q$t8FG{S~k|(1e%*>CZx-83OZ8 zZzzIaRJBw)3F4ScohINBls*8&H42Gr{&@XWBz&WZe`QDq7@7Oic3E{2?a{16(2&%b z_a0}<8F$zX{!SFf4R`J3ed__$jC7gs_Y_`?nVT`faCh62Vkap407FQ2UcXKX;OPIK z*JUrJ4r9OkAIgapltjvl=cN=%^^u4Wf*@z<4u^9w(7PEmDNjK=ZtR|XB=L&(k|G%_ zj#Mwt-vX1r^0YgDE1>-)X^g&i0KkA^I^Q%sx=eZByTARIW<+lL7x7hLb;m0} zePa86CGUr)7j#rh9Sgp?7AInUTi2bxiG}pfr7>=uD*G!mB=wgxG7@i_%`Qku!>j9W z*Zn}nHppkoOOC?+qsoDv6(j^dSlK}a@DP)Of&7A~M}m+|55%ppvqkra`#VJmd0Y2h zI+YVFIw6wm_y+|7K-9&p%odMbLwkc}A=xcioKrO(2J6vi0~h~+UtpltG(q+5=j$A! z4e%e`IQZIu5n0}H@HGgP|9VVNtb-TKY!Ir|4tl;4mi-h%sLbgPgE-gqtw;#DhG0YP zEYArH1*D(NLIf$Ibdk>C2+vm%WRER7@na{Lp6F9!_m{+i!7d4(mOhLHJ;Z7SDvsU) z?pL`3vJS{{#PDHzULs?|FOWVGaxlK=L-!%x_|Cw`JiOIb z#%4%~=Pv__6X-TaM>MQosV+FPq5fKWJ>ZsGv)Cj1xq@3Ern$~l2Sd*x0k2pMpjOg! zt)^b%f6|la_FS5zLzM_;Kz6l&j{R>}F6@H$t2KSL3S%t=+x63qk29H8do>Et(%6>x zW&U^)!Nl9Lw_fK0hOicUorjq@Lv*y)R)N?axY%Tg9%934uhWIW;5+ZBh~r{be%!39 z3g_3wrq)q;xThDvne_%kILGv45E4uwGdJ$}8g6vc&;|?tZYx#EPi~J0PJ61h-tkM` z!=e!T^r&u|z`QS9(}T(b*M1VW`V)q}qH-`B@jb5MGWZ+*wBvZI{}{iB`n(Tj-#z6` zj{a-obAM(qeX-(ql3$wZmFuNF_59*<=xep<&ZSoe z|Kt72HzLD5my3N`pmA4A8$@Fj72?#S{om+8&PYQ>)!3!HjvgG^Nk(!rt>yQMnF-LR3!1(PQ(Oa?fW)Dy&suT0UIv%A*YOON7%MvgGG zA81!BhL%*@`qcn?_lT9%mgyA0jk=7a@2_3T=>Gs|KJgLP=?ap`8D!!Sj7SY7Y2jt{ zfR<1EM3dRIR_z&Ve(^8>c1vI_>o*WTFu^=bJI9hJ8^FHhxv+602iK!xcJXs{PG5M>XjA)J#(*q(hA7skZqI>Fc-b z;oq`9Gw{9@lWzQdLE2r1&wREAcO&{y6)gbW-X_I@DK2`LG=$dxMef&db3N6rTm@)8 zTtpu+S^(PD4ptNe5(L;LwRCL#&XZomZ|A;8g za!Dmh2`qNDmDrr~J;Yn;<_apshWDf^7k+(QrDO?`N~JRQV6$+^EV|Nmj1Vwv6+Rk)!TF&2BQz@LR^(fA?8>omYUsY}-`MC2;a zQotbyG+5LBkp=v4z?(88lZc-+0CKDa=BsP!G};ko=r`t6A4dILaO0!*TT@(kh=q*O z9At8YwO8Z`g>;XWzSbad8z$E*a9_^)l%+P}Dbs`~*8V!L+yBKVJs2$6tg+Tk;HCJt z$6)u}J&grw-BqYBdpRpOWWiD(0NSHeA({LoaQ&Ll3=T?c#OP!?$MpzB6#? z5y5ba{*$R(+x+w2i3dvEWY+fj(8HOG!fGMrHSJ+``~X!-D6!o5QLyR{ZDNI}P^CdW z$}_wbPW`#3)o!Z`viFu3!yXz?KMp?ZK5RR``xA_G*@18?0^{x^MDp5%O0tS$VF-x| zmp2dP%5^>uC*W8Wb7b;B#rX8zX0Nv(+UzT2a2!79L-i8n(FQu7$h?ClkFdXuM~esavUbYLX-&HBQs!mr3IQf!)`oeFd<6)3&4t& z4mK7b1MZ=d;>N-CdPu+xk&CeqCAh9BqY8Y``w_l>Dvy1nZ-1Bte#F85KPJ)sC5@x` ztojtxq#Vxp0$*z(yz>Wh9a8zJO2Uy7m>aK2Kkp(Sd;(sBNjh!%*~+Ody$?8UQjUO| zEcIwi>QR^0ZG27&DD_Bm-7ZJr?ZBq}aV656bGHVKqjZd`@(>Nj;3EaZ5t{qk(tC%c zj+b~SvA*q0W`D&?%fNDT8Uj6(KXFUOaODX?$9d|&x_8&(Qw431PDYWPTuP$fKkmw@ zFY8~;Zjk_vEzte%>fl7YbOLvi{=|qTdv&Sw3=*Ja&8|b1s3)% z#RNav8<@A7!hInIbrV~iRZ8UgY_yg(G&Y7$V=(W1272&~wBZ=*X|6(pmta7IH5Rm2 zx}zPrQp>s?n+_!H4)80!@*@{|7+88BsFgQi()JH(^T!XRYXdrarX6CC@`|t4$VI!D z+ZJcg7ra;R$Zg?ZxOegJZDA#jK`Tii8Lh+-IcSUIkdiy4U-}JvOfuq_?03EtEF|wF zSMS9*1>T>J&vH*)AaypYr8cP0;&@EY-Q(>b1H;oIx%J{!9#!_5pVewKVUHID_4ts#|||+dK}|VD%pm+sV;B(Ca>kNnbt^ zNQ5e${i7V4N#51MuhPVVyE%phbE;lpOmsf5|L>6kKI*t~C5)_*yjxi6dwy@^#XUX~ z?6!(uXeJvu(F38I-&X-Q+qt|UBjyLm%li)E>Jx!`nu>9SZ9gH_giP~4P0&TzR zrk84#3yF`T>ce{aALZtR2)ZqY?AANE(_o8KLqu+CPtc~^9lUpvl3feb&mVD0CWK-u zl=ZiiK6?&>iQt%e3hO-v_*EYHK(47o@Z{P>`=1u_%8xglL{jblPqi!ikP%GBUoGaA z#x2;a5dKRC$Iw!N8CH4`@zppJ$u9Sh8>EQC@s9*sOyU6sv&hfHBAl8BGO3_#nbd6= z96pR3g!#J7W{kIq%_2#s$}X9MB5jJYm9~}r2A=w;S@&a`BsThb=>J8XhfmdPca0a7 z=}={jqXf=~v8~X<>w#wWy{yeE(~fR!Hf7^}p~lr<*H2=K%ZER>!Ob+T-d9>)Idw-( zf%gJh2nN=Hh=}Mg@2*rbz|xT(c=tBa9SUb)4j`h(fc$TN>5l)EpqM-;n}TgvzV}bP zQ`@y5iSiJU{~ca2-%Ca`gTb&1!PrYCTaWjlH^>5%ZxLtE?jtnq#UScOW7uIu>cx<) zhfi}^x?FfOYk+<9hW2*s*7`kc+NlC^_zk-|gLXo6MryY@ey8Xy7pds2Uuk*!$6+YZ zsrP@8PJQdcqm?XkZ=zu!WhYSH3qJKOkt2%<=FCN_z8uTE`jaG%DfD9lQ_^%I-X}IG zCQHU0e^gNpXpT2W-ZP5jiEo;rR)qq`%h(yuP#>r^{ zeJv&}FD{8_(9f7@){In|gi({>9u5OAdf(@K+9fA%LOnRgoy%2zVwq{$=t&~a?s|Yi z#&0EdoK%py8n;EXrD{wZ>V21Pc%6ppO$(%GC7U(n60l#Y>znT_Fzsw*I8=A?eF{uB z0tp3xjE6+V4h0HWt1JCfYrE~LS^rctf6L{zO>o?Mc(3(VKI)&WWRRZ03U&$ec&3GX zx|@Nqn&d!pt@DTK)SEp654uM|3|E0UQ_$df^7~0r5mje@Pja`3R{ILv+(1}>X?x98m%T@ol0XBcI;0WhGxSX}>RPOpn)w1SqNf|A zdhY$<|F&`ZxIw=>E5u18ERd;$2wV@HA~D5(8W@P`lgr2#KU z4LK-7?o_3#x}7wv63LRQyCL@_@dAYD)6^u6V#`>qi|rFUWtibtGqfbW?4N zDis&^68T^5cFRFi9zlOIDb-50z4UPj^hPgVNHAMvz%`a}Cn7A%!|FEEoq_~n+cp!f zgX#l!3%xw)39P<*P9fUH)Mn9ccMS!6#~Z!cHTeA5XBt<{TOOl4XPE2ENb!fNsxQ*- zuGXGku3|`=NZ6M`4T76Z+BWdM^p#&dUw}uu6VzXJ+~@m1;33LDx7sHMW;}{9dfFmwFcaOc3c@B<~_yA;YQT=F`5(QC7{>0bj4ydl9!7VpkvHCkF9OD@(6d5h7Kj(Cs12 z)+xbLCZDo>XyB)R`C_>)!h4~dnW*!pt;yIuiY*qf&1}O529LS-Nq6~>gVULvWa1b@ zp^8~`&sQJhMr|sHtEFJpD^7U*0B==c5(Qege-o2%KSX85E2pEvvDUQ-q-@_Y)N=BT5(Ed{#AZ z3yb>~ccEs%>F09$n;(XxS*{_4R^LezingMfwSVjamh>hVDi9lqOl3RJVb3uWpuZ`k z97l!JWdIuEK5J&K!`K+zq^P7*Glmv_KJcg@Qy+C7uP{vv+7E|sf^pJu_*#k3rQ|dN z7h{4KWY8#9tLBc6E{J%x*}EzQ=XOQ7$wU7X+XMVouoJ3q7bL=;zA}kuc^#n7(&|d6 zn-~Rso2$)o8SxoW(C$Dnf3S(vyjQll(j0dZL^ONJ4l(Bm@7T zNT-P5e!h!@-@;12Z0;|uo&*W5%vYvQXAq&l>XUnZOK%KYn9uCDFGf1q0h|63z~OXG z8&BD?`E7GcMaZd-9Eb%YEcSp*7XcI&i=dU@ffc6XG;&VRo6e*6xx9M$=4`YlLLgF% z0t$3N zkd6RkJ-3DX-G9{uH5knyJhm-H)PBHRgH$W7_4i)lD|#ml7yKI`u6vPK7ga&_{2`6XFQv^1A39_ACs-#JiB21Gfz$u73-X^R?8YzX~%i}YuD;< zGS&{&vC9u7Y$zYkT+pA0x)FAniks6#(PS=`Rq?a{n~6@^2C}(7L4I-aXB#JS=~-WS zt@CZJhs6QHSdiq8B~e8!m}>3g0*sC5=itcvE4^-REZg!Ase#LsP_F;q#k-=5Ba+ zC&5T9rrw$(#L>9&C~TIQVVeo$DcFf@tYW>;=OuLPFud%>%-q!DrV1{R=gA#(d^O|< zw%Ix#jp1&MHGB2UAw2l22%;5d_Z+X8nXueK9pZ=^%qyYiDTrH5O1E({y36d>ucstP z*k_-6EJPzSYK5Jo7|x_}pHoE|_NlYS#~>c=H#_|M#xCaUxi>kpZn>pA#Gz=L8O(!I z6tFk>e9t)lM_FrHj1ePtS=;K_563&;lAsCCMH{>j8xtanW34pN0ZKc0NSX05+@j_% z%KYMXZMnrf)B#eega6k56z>r*q%`Glm{0BjN}fUgvwsNn9q4k3r6s?7F=G;NBC|9o z{RtA?(ZOzSjs9a2rMDxh$TPo_o7^X7nxAj9I+^Ovm5yNSXc~!}9|M&Un|fp9hG-|n z`$bRYEtMg-c22+i6!emBVbR9S*ieZV!uZ7TNWBvL3!6@Xtb;cq_>qRRLt6H(BmG(x zy*;Sg{~U!7r!3a20QxIG^QAPtuBr(sAMm!?T9ZBcdnn~f@SlNex6WacBkOF_Ff*ps zI5ajw%U^D!?C&gVghN?W)A<%i!)ob7dwV>z)U%?#l{{r}b|6jmnD6#-b?~fO&WAsP zU#=X|PkCsXTAqSqlN>uBzt}JIURGBN51c`_uC9~c`AOyo*Az<)=P#Ht>&EOMwl0&l z%A@@_n9{1}A+f)tW<_l9jt|f2{$`6-#Ug%g@`(>9%qo*jkhuQvjrEicCs8TS7YPIC zxsEEc7-`XwdI)L=UHm-x-vICV^_ z3Vt!Qco>RyS8@;QPHl)&PoSywo6iE{!0e;0ljqs1665%u)TS_fLqeJYoLzR!0bKOL z;OHRpWJJhQEe;O|U0LgJ)MJeUYNCU~7Y`A1EoQ*e;6X9!KA5!mrX&5FI!(9OPO2J6?W`F87rn?-NeVNZ~&gAtEMlR4tQ&=9|c zhtS!D;qQ$Xe&2Oz692i!Z*UY>x~CtiNAJ=m;VpqG^RoB8f0KT|MY(^Cw7J0S0&{ut zg-<*XDCPLQUx2q&paec5e%LrBqzwKR*7L-fh6W@GDW201^v?a6*XjW8iG+9&4DH^B zLE%};Z*AiQp@@k=3Z8xU!jDvOX};)0mZX$HFms=IOp>?FG_Q_FKdPxIr3Oq4VpU;vFqIMp~-ggS_JQRG)|B5#CyV_Qwv`UMP(cJE?km4 z2t-ur*9p@nhv2{wn_@Q6$!x#(d^Kfr-$k3B^G&mWY8k`5Z#Aw!6J)!)vY|Nz zx4udV*H4?&@^sXFUU2CM8zk;sM53@93CdbPxS;&n-YZfo5jBMStAUH{wmK=@GmR6b zshXJR%fiCJ^6=yR)9}n@)RWj|R0G6QkGQw`P&A410jIfa8K;!(0pqnP`+aj7l-WZvZsv;sJtOL(rPDX%8c7Pu<98~Mqd(7(F_29YR7#kM+nJEOP2E&>>0 zVp}pUAQI?};S-nP$=}_4K+sONhS6bpzF~ET4_&RgG8t@ZQ-jWE;+!RQRJ|f827NK? zPP)}dAdpf#>c)Q&$(6>a?G1mgW}TpTL2=RUx>3z4?0&4~+63{DIGB-r-oz($Ot-|- zEuB*9yDMep;KI*TMAv?U5@Z++ReoX5=;Q$yOv43KWg{d7!{40^F2Yc|y{6 zx6((H>G)VsG}@rq^>BvZLc(qwR7Ij6Peqzn_|sZcCo$!lW7FNvxvcP6s?fTUtZ+8J zDB&Qy&}e(1lywr*n3?-j5BBHErPkub`nu8I^hFBKGB%)=D4?I z0%E3%C1kGUE=IEKB1nCg&8@@6UU4FL9T$^nd+E(lwA{0Om~|@ec78TztgrlcGj?P; z=4pg$L_-f-KJ?AZvm>iLDvjY+rql+J;Alqm;jePN(6Y&astS{t%&%_~jShB-Wmw6s zD)oJxm$Ohj{hjEP!5yWJ4zjdxB`OGLoOKF0)*?Rc7~xiIoS+hVG-pn#b&u{4ILhJv zm@=^_x|6V1GG~Az=>ji7jK4ES6zodhiDFh?n2YEknjS%T zhJjTqxCa_JrmA1@Q72p_pJkmxT{#Pt`x+_OG0Ek0PDOHgVG;{s50<{84Mnl-61 zS*VLU6{uu*t(Il}z!%yzK%AXELtc3sSwkScE>59A{Y`WYjyW=TpaM%7%Vn4fe1NCe z`)tS}dJPX86)u>TXMSh_md$3OFhk$3SZ9(~SMW2vL8QJsJ`;Of1)w@^cw4NH!|99@ zF*EY6zLC5Mu<0<)7i`gg)lxv4zm|++tZwp5i)LT(Tx>JL?P*YI_KNfSPANig|BfG4 zL@DI=4|TMrFNp`~h|nPZW|)DBSyVsyw*o1w&TSvejdcP|(1+g=MA9LuHWrVOaAXx} z-C2*!sZbL^__$ZQZEYUUd0M9*Fre1@ zdDZVA|82dW*+}>mX>n6dLgo*A-hiFdPEt`nD#yXJ+5BL(V`oS6FqHIC4}Qv`?!+kR zAMx=@e0)wmDyXR0Ym~QVOd-!AKWM}}p9(`ZA&^g9(O|)+A4aO@?+eh{V%xJ6a0)8A zr|lsljwW~llmT|Kup?s{-{FK}=z?(XhToZ`hfcyZU_?(P&R?k>eC z4hM(gc6smres}Ku_arOHnk2JllKsSXp2a^NVF)uDs&E80tQ=?ST9cDQ;lgP5=CuZ$9;NMPC9x!aePMC@$9C-F$_k{?e9(itG+m9pJiU_Chw|z@qy({2;KA6 z*YEKiE6_r3zlj}-d_+Sl+JA^Owl`LK4Kt`_IV2*{%DTWNzVFNE*R_Z=3}pj z?<~FU`y3SqX)z?PPXSd7xz}*wbq6PB1e6ziwJK7EhvBB_@C^j(~4 zlAgjUPS_k&q8q*m{H(uF$j2DmdC_54Zvexnbp2!y8;s9#Q$E#}g^*`uGHXRi!1yim zFu`RT;XXKos#8pam(u2pH@*blDf-giEjSU}yqK!Jz6_c3W8OpuXrR#%$^v|J*peNL& z!J3WR5iG43zBY9FWqSVUR4PMZXW7pcp)r_Llt3=CMbS#_^H24OAW%Uf{hei8A2l81 zdA1CL2nfN-(<=mjk?(%z_8zoZN*VN8G>#+o z7}#9)B-_aF<&}J-{audjuE+F)v)YwTqTjBK%p5cOYGUU6yR)|9Sl=XDBSKk8OKP?o zSug$f&XGCPYQ&J}3r(UdQ`E5atQ{-Ep;3SK8iV+i9rkCio5}XWh{FFw6?of^=td?v zw2*%BLG+Fxg_+29!l+N0ZSl7d;&XQEK-RVpC08JKlt2LBu;v80{;k?B29wx+%? z3p`mJg}U{0)*^{+WQ?<1yGbv0!G_2(E%tx-?reYSqC$GBP7HnPI=WesEsxfekeE6b z0R&}0)-i#W9RF-HZG(DQu)AIIJ5sSMHUK=05qAU8?MQq9-rOlvT^1}rvhVZ*EG z>qutV16O2m=qdUGDx~$MC`_i+VE>wK_<}j0&fq&K@If# zrn;?o<2V)R@=q9Bnm5HtosKfMMU*4?nj?n_+p73Df0ckgS2;rwdy-NIH|=2heKyk8 zFF*QiA`|xj0B$#={}NRl*VbiL%8~gUt;5{N?{GibC%(;sY%rj~{g`4L;1s) z-~;o&+>b?6G4IvkVUV0M>)>|S1aH}pX#0(%TVnSaJGU&E5 z#c?MS@w?k)H%2_p#C^@{?52Z+jsWr$`YT^~m_zLkk!YsQQQet+?V39xK=z%E_ySN> zOgMYFg%c*jXK#1>?u~4DJa7|mWY_dW@X|px=gCs23sQ>eWgx3LRgXAL=k5cHm2wSc zC!c_P9Q-B5H6LhkH&VOF0^dU15e4h{SqB$1a5saA6VOxt048?C{vk}f83j7sVZ|@W?KGm z=x*@S;%_u869C=y-EWJvp*3#MO@;~f@;5751Hk&(8Z>Xt^GkM@TP?fV5(y7c0Rr?D~yMd|28^8s5d1FTmDAanyxx3A)M58$1^J!~OKMffiGj{d1(R z@u6jL-i*ROOCI3c?c5>BX0FU)Xt8Iu`r?GjD?e>Z6D>Mqd3}GRvm+Xn3nesFakGQ4 zTw7x|TL$rJ-Y18WU6khx4f8x6n%==itaB(UskPoHo&ew3%xX9ps4mo%ZxGfm=Y86n zcWff0${y#XZwWbVPq1Tha5%kNR2>d^xu2)Mp`Q}RSh{iyZFyG+3HisqBYF5q6C&0w zASY?0%$KczqS7mwID=xKm^fYlaec@=e<6f5jX;UkAIJAX6$eW7YCVy=!@$3lg{gg{CXq(%BzgVXJHgjtt)39CT}fAFg5 zZ4a=Evxk!&zaiqA5CvwayZPB`~r-azj4AM?`32ye@Eanc;H6O}$y0(jAae zFR`eP#%CiFrT>joK*5v@x_Ylf2dnK(T5W|!jJQ6&sgTkdze8aINm6RBmb+sUBWGkaJytl^(<;#mQv|Q zZ?!LBCHZL)EQ%}i7?h5_x;bTNc1Vxd%f9L_iID6N$z|CGCd4wXtH7OEDwHzZ77&>m zDm`fu=BDtl*aDKu-A($(==e{%M+ZkyU#dBGM~P65^Oqcrw~YKtor-Q%kxIcOkK#J^ zE2_ay)_R^%x>*598DtF_#B~#2CqC4lP%Lr35JB*huQCvi8?WBKj2{FL*WVa^!5&Cw z^mdW+x!;g)LYq+SExj#NrOLj5J`2)Y_u;&~L7p$X8U?ir!A^J#_4yjQ06YjRMGwhJ zT^){whuc8+(ufDW3?@U@CzS=2O0YPs(=r{Z3Yyb<+8?0(`W5|+Gbu7Z>);PDP${Ia zy3M#b5gNIEy<<}R>+`{w@N(-B}@-zTANq*l7~A|`D9sY1ep(bc98o~FXRNO^F?xXRUuzoTkQb{}*q>rHGJd_^pa{xPAj_`>OosKm zoatJ*_RVmq9>7d3+Du(nAGBs-p#G7q>n|m6kO}ap(FXe_zKQ&=aFR=4+|~UpAJY!+ z1OBJqV473PL8r?}xKD$dQJOlYE_KMvw?Y@Dgm$DuET$}jRMtStDo!G}4EAGZ6`>H0 zq_FKsUHED^51-*nkdAwciVgD&t~C@h6OUB|v}EgeZ^Sb6k1cP?;^~uKxr8}Dmxy{% zvIkE@GE&9`w443V4a}{W|4$w8tC6@9kOg<*ODcSp4o8&phv`$MoqmSjeA;C60+Y56 z1%d8dYD3MDGAr}AlmjsmVL^gyy_ka`wPf}!A9Gfhc#48SjM-TXy}S3Pz2^4v+Is0dW;yEm5uborVKrE0 zJoiaT&CQ>SAo~fYKcZT>k`J&Q4Y&2u9hL zFqLBFq#vgxkgF`a(RdG(?E_Teo~!m22#K90Hqj`7%np>e`|*sowx;v^z3T8UXf0~B zyx+vqeW=9Y%YI+>We3^ zAVuX%U=l~6O_GxOUi?B0n+>p=Dnxvs!@yB%I-T~^CC zgmr&Q^~+0Xe|#p{<3=_z!%6pPp-M89@2loyZpC`Q4VL6-DYeSjj)#(zKF?|lLH%nhbWXq!Z`2TUr z{#FT-V&;u5+tUg>aFY}903p>y`-+wj8DMW77L2H!Aj83PFZClKJJQats9mcos{bOU zX$!t@LTX1txuskVgGvu2XJ$l@eq`p6>c)2Qr(!?ZQWy^nS+@v^u}Z;ae}v$oRW`Q) zCA*paHgeo2L7fddjK|6v46kzXYtY;Z*cgTKomhTvV}trSA!tMziIPmWa??&_RJ%&m zrUpkGOU>pnsRNRyp;N1CnzOT%+gIs&n`!VeB?fFfn0PlS=Og=W+VK>iGuJ)H-^`NpFPp2~{R%xtWC}ctC+`wJH4;l^Mm0$S& zQO}uTQ;{;Sj-Q51=EYNu%o-ep1CeEA9HPz5Mlsd}W45QE;hfXtb|3D_z{#2^&m+_>9pOcwRPev#fS;{0V=^S0vX_uNlmxdsJ745>|e$=0%t zpLP87Fmxx6kP?)!B!nDVKu^)+4d4ix>+`RG6{hf09ErQ7ifKX(wP!nx$etqD6kpK% z=Uq(W>ZdH52XcpptiAqMv$h^pKci1Jw`EOfZYCX1K{|7`b`yy;!M5g~j{D=E7msO3 z0HtnaT7Ig39zrT?d6nkeXJL4yL>AGP^{bx}Z9G+ogxDT0Nz5K%K~lFk!4e>)ObLO~ z%34BdZ%(O_4#@&n*odiWO=B!xq1x^Z20%8elVFe5>rb19Zl#1HF}9aJsasT0N|665 zb%$HWQwe!O&yGmqk&9@-xZPqa3h`^dfMfv^Y^?TDg=1z|+&Okc4WPf)R%44zris=8 zV8~(U_nUlHmJRF#LWvl13zHBd=|=krF!-5pV!L<`6YKHdVf`fP3qXAR6E^&q@(`wX zOIEUdXxmRmOV%xovNd1U?HGTQ-z4jH;&uxiEHOQJWWGT;dln?o`n%#Cn|Jf%-Ktc^ zt@PK`l-{lQ{H5~8>--$N{Q0rQhmBCWNZ^imV>f(FBcDA6HNz)L+#>=uv@8A1{&;$; zm&*Lr4%{`iTU1u;r*n&xK#$x;*Q(5o0J>kSNS5y`P@yYpy-$*ew>OFa=y3YZ^#Xp? ziRqy01ZcB?e*>ZIUaCkxsQK@m3!??~iv6d0M|5{ai=^y2t(y@qNp~;Ea*N-joh(C# z(sjM7^!UU;BA$1uxGDWjnXtEeSXzD%Pl=on_1-$c(HXN6M z26Ha*+l-zzolnj26?a~J4pv4cv~@cyz&pw1T<7}O&bd5)^L0aI`6SC)#2;PUlCMi` zE$89Njj)^wHd{j`vHtl8_u@^gAb}{)pq5 z_as?s$-ZNdvB8svm{gJ6Q^K^|-+$y#AuBB1P_gr|u>6XU0L`n{W68)NfK90hUl{{h z`qr~>lYl;AvW|MOj7`WHd{49m+}5$@CJz;Ea5s|1130P#k!V<*%eg;P5!TJM7p z!gfCpMwQ9d(!OD*diCN!0JN}o_nO0aBshrR5MH}3nezBXOcnS5uiP>)Vi1#%Vjb~0 zW#4d;#!i1dmaK+2I}^^-OXkZ0i%CnNJ7iWHePj+Ngv+@+_&GF^)cS@wh#;oMl3A5`UO!GyLnKTz4* zNm~J3{hJl9k77H%rNyajS)p%G6wclA=G00%uJYbuJTt_pB$ML%3FQz1afkZSH!4wt zrns|$5z{&X$giN#um?X9;}^^N&3Qvi;jG_4X{j5pzu!3DEs-&=*$W?Et0b@>w7Ow% z^>$-!@d$SAwOR*jRl<$)F?z&m!K~bK35;`iJvetQz3^Huk)aPb{iHFRlh$nBqf_u1 zk-5!)k8Qh(R~oUGg{p-e?T#5^#sTHYC{};M+@F;Gm)oGgf!T(9Z)?M*=(OaB)ckly zs$VQg579Ml&@Z1uZ272BC)rnBvw9Q^4u9-y9;mfLEyHiG1h0M8WBX;yBEm;QqEa#R z?1QAz2@-^;TUdDZLDcCaD}q+Yy?R4}PJbtcFaaxFYrBy6kH9E%MLnogTYc=W(JmI;0VhM@XC!N=R@uw^WjdD;OYnEd z2WbRW8y05q;FU=#WM2Gu$wsGOQtI)XXULiUp^S0w~@#ci;q@k$Y)orOvY> zxohqkf%4S@C)K2>QAZR!=aBBz>G(@T#|0fYOQEOx<5tBJ`P~ zqMjKSX5yBVQ%DQ=l!N>f7Vs%ds;J&gJz5BTSEP8BWug%B&YcjcxGs1>4|6#C8m~)4 zB#d8Tx5rMp28`+Mrsg?Mbhry>DsilEPXbHrr}Gix!^Cx?XAC$E7ih)q)u_tCQdrCR z?)E^$W2KTA7PW*~qQP0=qqBu{&#P-LA|ncV#|vYwHYF|Q6nw8sbgSxKqEAOo16rcb z5om{!YK8r}=2rHy{kbiGGjm5MSsjYm@w`;|$QU_1MA;0{&k(B8Cq$7(64F}F&ym?H z&*`|S>dCyH==dvEx2Ewgqq*~dqXDz1Z*QQm^7HDy3cRyy>mgVq9Mi(TfQx>Zc73MD zjVVgsiDnKd7k*=6U1`Wf*w1vX`?}e)cBv}5*$4M*0tIj7u)tSP;4Ar8Xw?3B(Gqe# zGl+WZCpZ7WMwZHkV`;~FB; zsA}<$Bxt|ZGebv8KT3Ee%62aUg@Yav?*a3rVf6a80ypK&-j~L1O6c#sS;@%1iM56& zFqp$0gYl3fxP=S#9}6aN-tzliuB2;~$(piLtoD}BNtH;l>6F5}I;BM1jAB6Pb^hze zT@w#Mt=XV2c`N?dh7jgh>kzZB7--9?1<0O=O-Dg4?@qA;E*B&>@U~>^`Jj3ck2eXt z9r4Q?2|TSwX#~!1-nrX_ou%^Om!11ZW)B*wY&0DY_{g*6>7LGH?=pcSg`4P;TbDT$ z`F&?Hv&DjLLLJFgm(poaF$(s#@>Y-N+ba7LmwWkR3lMewAzjsU!o@gxdSP?=%+~(v znVP9)T4(L_b(X42!<)}>zd;{$P?=^*c-0;yj3`QXIh>{Epk(_ z0Q2xH+4y=(OFRR?`NuL=5>G;+L)onuqEpY2QfHrxxlaiXTa)8n4K%!KTR@6I`nhCi z%OY}Gu-&@pJU)`dIr-~U)LZ_LuvBz-Q%}10Kn3Bu=@5dk9~jfl_QhP0!qN(xB*$cc zu$zR~c$bV$%3ymf&3gcm>+Na?!Ce82d1Cv5g;KVFtRAaj)3ZA zUOkStd`NiZPaep_76i^Q*cD#?^ZosKfvRQcp%kA=$x-R^&Vue+x$l5Ut~}PQ!k7je zb|%LeJfnWP{9>R1+KDBCFXHoi#vThN9oAm@*{|7o>KHt_@lWrZ=KNfU7;lBBI zgy*E--pb7@wqSU@ALUNne^(8zoA4}mS(-KSAvt!^Fbg)x*#P<46X1U7!dWfEL6jOS zQ$H(F@X5r{rX))FgPRfYt{+symHO9UnsVOY6N5j?EX6}L93A^b8TM;{CzQ;>cS7Vk z1qr_bZo-y=_hTdE8}wh_`-@iNkY+mkpG#UyJrnKmow#3x%TNFi|9SfUiIe{)mB4@o z)r68(e1%D$Hg$z5l6sH%l7@DT>5tr2|4$zHe?)8AHKr&ODiDGNf)j%4!}CFKx9(a=cFxALCaq=@3_XaB^cifJ1*W)BV|NhxNL!#qyqQvskmgPpa zHQ@Q4RV(b(b!?}fMAZnO_UpPxt>fN|&XTR>1BKju(a7yd{BSm@ zNHCC@8Hkr^6Fjd}0@`k&O`v2y!=TGPl_Z10?R$Kl2W+EFOu_OnN6qT<zj8NWd7LUg9 zstJ*`syEl+m7hjXh^CX~p>rP|1QVwg(oWvq_=8p~I9bee3Ma*!J6h&0N#bQSLYY8E z_*x_UTL~CYlLuPf1{?HX!TFruwXFAZH~KnG9@4u@##n}X%!qKc;mh$znybkZ5b)~2M#-H(51<__E|8B}(5lJkH zlBnx;2G&Gg1^Ec&e#5&dKsS9CIAsUn#ujnbhtUIuIxhHDQdP{`Gs%Hbh>+jfow3w9 zVcb$;M`rOzUq#P`l=RwymwJQTaBCVf70C%D!W02KAUKqKhiN0MD#^&({Mzk_N~{*q z5L(G4(~TclCPD7(Wae-Lj%)x}+yi$VnIRTPufE<-fD^v2Eck3j)QN>0r|=wXu7Ifg zAbf0K5IzPR5)&LG3}g@wG&CgJhuKG95Z=rG>5zD5AFAWD15ALrpe?NCxzceoWVt;o zZ!6&+0!d7321LzbKc5nWP8tdkAOMq#OAil~otq8F&dv_xm7@4mZP9sHEtzOV3I3XKl zR>7K5@`p7At#J?|Qt0A*=1-IvnK>#Na~}n7Cj4~`fThj-lX}lkDft=1Efbv(*0pNZ zK%kn|Ju9|IAtxx^$#DOP^{N~O$mmMg83;K7m-B4TMNFK8%0rvN zArII?_N2cJm@%ZV{;PAqj<&oj{!XcA$?wDL=(zeFl*&%E=ZTQL@F+ZwpY+9K<$Rk# zN)cx1*BcQz+<2MQ4&KdTZM3+bO}*bD3mT1nL5^9~r;7>Z_Wg4P7IbMvZ~&+2DLX3S zdZS?#$Lv(g`JD**yrp(-mm||Xk%9pny_hy!?5LoADDl}aSgT}2h{xJ>$Qhn}-Eh!F zIMT}%hVrsoJT03PfRF7Gqd4f@jWg)tAI7mRk#Us`K&)#WB?WXsf_zQ}!c*{czB~I| zRVDYK563n)F4)&PKXg9N(UOLdh0V&LvGtS9hd_fD&=n87NHIf{r0M2bg6WcM{F+4D z^5M7it{f7(&SSnYU`*1ve%;<(Rf&{-<-wiqAJ_O3g8T_ESWk}O(3JEkn_!CHHP^k_ z_t;-d+(qWdth3w(4BGI5niT=b+lDNExfhuj6Z*IkvLHA}E9zzDLklrY)4F=gy|{tP zqmTV7Q#(!XXAP~kL)Gu8fGgR8C&SkJebU*XQIY5(JpfG@PW~VQed5^n>2`)?Q(Xx7 z23fvvKIqg@*MQ4-L4}ZrF~3oiBS6cW@A~WFEKC5iP=o<66||PGxu%SRpTv>)3Rts_ zRZIlsmnW4dV@?Ru>k2dYwKi8i3Oi!$7>4LiWqv-c(cza9$dxoKtQO}@mw&UinAhUj z9bp7NNWE&TyP0k;)fN5P<=i8AgiN*li>zGy*OivxgiLTqnym7}c!~26ieNw9%R0#_ z<4sMv`v-fMGl=ficY^g-xTUW@oKB*D#;Y`RWm+C|3OpTk(EdfLFu4%R{IW!cq_U9M zI9WM|I(6A899}Ae-JO0QK=b&HwOwqw?dM+|k!M+kd79DeXp%{brh>mCTw){K_KPFK z0JX1L?bXc#C+SctqJnA|iHst*o_|Akq4K-T}kK|~lfN*MBI?bG>T9))I5 z;Pjkpgd8+K>F+fQ8v#Idvy-YimauhNCKZ5`a*$HvX9N7qGX(wOV5YusDo?E@>>A%S z?ujkP{kGu98R(l9O9Qathr;6c2l@Ul4g!OqP2rILoem&kKmpi-pjAGU3i(0MG9RG? zFbL{XnlJS~Nc#U!(TWCuiJfiXMYL90>0}stV%O)MLZ-?zV2~C~1Msmt9rIuXQsSfk z2akbV?0jr|djF$f|5pL@0Dk^=COI*FPGhaslgr;C-ZRKPNKku_+IAU=%2 zgCR-Stz|-C36AGeKh~pwR8JFb)}{t4M-0Gmq(x;`si4VNPuMc_O8w9kPI=u4e0Icu zu>coG%+$M=X4(NB0nwlmYsD((9HgKyqc>P5@{~$5y`a==S(OijP=jd;mtnO*aU0(i zN27mQG$}HFWt{RjkI|WlWwTMleW$y+j_{H+MV--x+1dFx`t(k6&Sk<_S=5=re)KDtASeiw?vBI-#e}ItSu#?Dnb)gExarWpiqT~Q zALNb$Unt8_A-*;l{xp=!;hcJ~$3?`p{2Df)0z;fG%vgMo+jQvj5R_JNQThP*TG&zr zC1MZhgJc{#%BLVG_10Pjkw9q9R!|JPQf%+7LW*arIP<6dw{)A@`OZP_1t13) z1<$`9_(7-@&yVRk$t>fh#2jvQuZ6!bxk~J9m6pv0C80`4fu&jNx!MXLDekM9h2JR3 z3IJ6WyU)ul^qMZnoAr*uhHl=VBgsq+y1G5~#gyPKY`tW4L-6~!B<&ex)b*-R|H@fB z@pe+}5*JD)rGh^kxlPmey{3P}t?RaO_4RTunG*nXqII}`FosO{D4SkUD5p zkSf{f+wSdah8>K-j8ud`A6Zc5e6^I+NS>sh`B8nTxN{m?9_(kuP3ERwCqDKbMNEwv8Go* zhlrRmk~CDxVJvdC)?nIY$tgK?dcNOluGjs?^Xz@={rh}A-|P6helydw?e{+0;Qy)o z2#>4hwx^9ZE9<`fk%V_X!$!}Tl)Jd}&-@-v<=36e2{{J+I|KI- zZPOoOw zxvV-5C?e5ZE5@)C7QbMzk;akSkQO>IaVOJE!axoT)yqKAp@BGbFi=F|^btU~G1+n; z?o&Fpj@B!gENr3sbhicWR~%>E3R@k`xwe8C$%TRH6{%NSn|jU#V$XR%ye~-@$LS3$ ze!*fR5RYadbYc^bJ-Rd73ZRB4x%yVP6^WdnjUNtoB(e?0m2l~8d=D_oO{%Glo|>?B zdhcS}>FwXx4kZ$<1OT^CZiB0VBTL;(2LWW6Vf1H`0M2q|mS7b8d?M(?)v$B#CVYA?A zH-}wJ#4%p5 zob)mINc8zY+(tD#E0>EQs;;S_*s>JJn(*)BThIHRPTeiT5`cNS8$Yo;h;nCjjZv zs6RbN$@~oFtEs;~2Z{Y?n6IP$UpYv6e$ZFIq6~0RNyG<{7#M+gUtz`I&pwDmUrcrO zK_qGyAl^S2^`9I>q908CIK3RGA$@X@=qst7mWxEK29DN{UAcIHhK?50UYgUqHhG^OBQj)35Ib)ItqFyQuD+hF?Ipsyre+fC>c;gYVwen2eUka<#L(}^O2ax z&^%6`z;Y$c$K)e1&tSfq`l@_n#yALc)TqoyVxfU*vqMPKMj+i-7o1SwAp%6755)C| zsjuX$JHdJ?4#`*?48--wft94~5E7fkQ2&TtNqyhLNc2_Ihv$;|@rRM<>wp?kco>P= zK=r%BNYsr$+%Mt^b%Tie5xl@aj0TB4f<)~C#C6E1f1K0feBT|Bvp5Eb%SoVwXONaj zjv}$S3W)1b11rh$qe%4gn6IP$siR2rtEhiOZ=im|Q6&0C>c{CDf%G7g{@9^s0TK&r zf%K`Ser$o5`7)}H7a*}tPW9^oBx)tq{f{A0X8^GuHTCBnL!zGt#C^~JJ7R$jE;K~V zHCV%jJUwP$bw(g=NFya|F#M_lZ^6fr*bg6wxh*BU3ei03I1+OgAU)75m(e``I1=+< zmPfE$j=6#~9LEbRh@k~=Qh=4jyAX-Kiu!O;sQ+sr68$`289B8I32(`I9nA|1k(e8( zepQG>O$K1Ut|ySF#XvW`h2#LN6 z^Iz3}^l33jEOP>bgIO-8e$8nl`Y}LU zcLL2}!+{#Y*B~)hu{?w2YMKXXkQu|FAdeP=>fr^0&QC`Rlo}*Cf<(;+;ybaJ`mSe?=u3h0P*8u)85#3~ zf%xe_PW`MiNUT><{qPJDwHmd8*lFO3HJr~%T6 z(mb+M$8rPJnWadqHv(}78-Z9);M*AJzZ7VCPHn-!KuiO>b4YCH0>mAW0W~D@91{Hq zAlA!)@IrD9iGBj}l|T)7bPkEWn(E$VNYol2ow&OK3ul(;SWpGT0S!P6$tpu)gGL~o zxd;0Hlo25Md?2pO7DyLBbFcGamdk*ZWX*Xb)<*#8#Hn9$Uanw40t=Kh_;enL4b)Ui z%aN#cRL7SiQP%;nLjzDlPL(6kHv&aOt8ZjE@x;8L9Emv}h%YRTVj2v*fW*KBh%Y5F z>d)gEx|@EnqB4iGwu1*3P6(AxE+Da$3P|77so$$Y&HOy(>!=@DfyDYM>R0LOfOPsa z-(F#0xsm4Zy2okf;q*H|vlJG>8Nn48Mp(Z41PX#Xt>NeG!Si3|L9ZE+SFOskXR; zM4bSn6Qcg)OG@UefVe<4^|LP_v0f*EX9n)%CAgqQ9guDzP(y}R8klbc;>zJ`Pw>}N zBC!K8)t4%fs9k_KkQ9jh$Y^d^g~U9V`Zie+ESF2^Gc&PD&I)3H^i6~nD1jQXvkHkF zsAwLi&tSQl=69=*nCqzSS&c+p1;hy%s6V3`i9Q*M{bpAa1ze!P2jU9Fz)JG08i~G) z>OPl|sO3~oyNpDwq!|KjgG6nhIxK+d(2i{PF#K0Da zzL@%DHAwVjRDY^Lq7DY)3L}76FQ@sCS|sKP%+CYj0_&JIQk~U7-)J(p{GI|w1^f!; zL&$U<^b5rL2q5j3`3X!bnN|TsBt@@gTElW3a5$M?TSapim-zuG~m~X4K1!Dg$ zK#>9llrkd*4g5U8w2ErD^-NbWUB`4IqsuVU#A7I-JrD;_0!Q%9)v8#Y!F(grwtnz5 z(wV>o4lD(t4yGCgW;%~)4byduM(RiE`NK^UXar(=+Yu&88RfuA^1U{JX%$dJZtF8f zkUq3Q9yQ>X0al;`YDfqdC8DpF{E^rne8#7QodeSOG9Apc9H=3iuf$OORIj3hZh<(j zI>tsuzQ3s-TOj6A#t6m)B>tAKf(sllg9RGKMn--B<_&sVAnk}TgRzR`jZBM2;Y9CV zkpi(^MzzD$1g2G#Hd%SV2wb@iE=KU?U9AG*Ky|c0l5M0qR4`dIQnp-1F_sDOAf@c7$A19q72omnO_IQZVgNuS>DKe5@c%6 z2f{be*-}ba7xN>SmNOj#6b*u-pTGho3o@8iGp#XcF1?SS2YlCOV0I(3OZo^L2f{F; zO~cC>6M#5fCDR!u&9y==@WqvZ*+w9K{2;-m=C(j=CT7|Nh%YoUrXyG`XF9>8xp@u( z$FcB%li78w%)oRb(_{?WBp`Nc3#7v_Uux2v-a*jA3%+e*v6{sirgcnL0de+qCZDtG zEAW6@Di1M@k^rQ8z;p)FYNqpm^y4z~bxYE#Bk;k^|~)`5 zf*u3moo%A2&tM?-DQ8;AsG`0F*We^@8w;NVCb4p$h`>-xD;ZVPhwq-4&SP4~bQRTc zTvR{!2De!+o^0w)#uyBQS#aqP`!(y;%r-C@X>4&r9KmJ*q?2Y^MYRR@q@TdiDN=7_ zF_~g27c;s5Ve*`VGyItXc8J*qMk9-(oM|tzNH!P{U*P3T$50)|WkDJI&7&MT1Jr}E ziPRf@GBq*+aY1%2P?X4dyFgJQ7v&=GwF%W%{fzgwdKD7?#F!67mfdf-Ohf6Z)w>9u zn&V$D7Ml9f0O7D|%LfQfTEkGWK5+QpuPpmbu?mPoW%>vldj;GeSteV|=mNAd3*huV zFae+F|X216iTAJkr_A)7{hEN8;n|ogy1VTWr^9zF z>u>)pX%sC=i`95G{Ix%nzfSY-!}DbiMTO5@FQmji-9R4v5j%2A)yLkKd#!7$zP3!Y zu&TH7-pMKVT#u%Nwd?k$y*+lqbx+f3w*goI@Km3q{Z1(}VlVz@<8^LNgK(dZGs!+Sj|)`~o0U++EA zxowDj>Cd-kEemWPj0IYcl#lzvm$P>VztHYn6QD9({}Ppo=)GMJF!pZqq#qR zpHq^eoU*B;Z~2FR_~Qfj#<~vpw~w;oZFOU6NcxVwOB~j13eHi8-wcQ^dprK=KT}4# z&-hlPtnYJY)pTK9XiJ>!e*KjAwPvZ?Q-)N|=-Pe#g4S@~;>&{f7ksY|ahL4y?7t^w z?r{$4)AOZ>+O3=3K|rFH(YEAo2CsUKe~Tk?JVmHd9S zhqA8O1-G6$^K9RyVu`WnPUTd?3dT|1fye<`!t5j&%&t;AE}<0*xIeqLT) z-tL~>!#MZ%!bALFex9D(>W@M%&hmq>N0)wqw?7xO*Os&s9wBD^IFAp)9esT~C0>4B zeo{YgpXoiUCBB~CUcO9vuD{0JsuxCbLq7@w|J&JQMM>%N-_8ZOPu7wCTXl1{Jq*cd zUfR>+jcba}B!^g=BTmDg*ybNUkgZ?%xJ2MPS8!@(Me*z{y=T=qZVG9-U7%~5X5`vW ze*D54Djjty_|s*l^y0y1wppJzKjMeGS3-Ao926hpVVKeSvH0D?;miFU7d?MhYB@^B zJ8}H<-SJafUiVdW>sWJolw|XmjGO$}6W;q~UaZzTANjj`pQN#AGYfXj2)N)IzVqs; zkZaT0R%|>wZTCRO%YHM?{xa@2`>he9+~0a`NgUo~R9xiZym)~?B++pxz4Bg8-*oD2 zs>h?u`2JoNEghGQJl0lm&t~DOl)z_!dDl7xwiq+~26uej(3&?~BUKcshjU-2RwQkE zx~le%r89T&?mh zoz)9}crF@!*?<_tw-F4q6tRT2#L5H_NOf@@srSwbhQU>sD{G zAAR&w8aHW?X7RQ8hez>WC-_sVy2aGJS`l0jg zU55_#+7Z!yX_&>~r0$=4&$&KkT+r;9Z@yY4@43_I?*B}WOX)^V>voR3Y*pLgUFkn( zcfXuz8Qp(zv-kIRKU)xE!G?+T+x`vSs@?nfea+RV=&|{(4R*!~iLBAh@H>KmN3itc(EgrnwwrXbciaVebZAVT(QD)A z>R7Gk>IKRU$%}8~jC=h{+Uod2DUT0G&Kx;tHp_M8y}4^wwe5PllxH2TI2sjD`Z(;; z$k{PTw^eVVl}`4Or@eUNM^vw@S-MVo)GDcE|A_)gWbFQ)l^bJ(HyeFzzk3O9Oc9a~v_*ZM?)@yP;)%k_Xx(*; z&DOTPye}@bKY8`o+Wgy%104;%tDfryom1aPT^ZxvY4+eNe-v!n@$=`T-o~JSIKSNc zL}6onruf7Ni-EPrKb-2iyFuX<3s-*x@UAImoDiWQ0c$rr+4?>Zn`KqcJA5j2|2o^x_s~c z)kmHOWp4N?8PO0dFPv|mura9ps`IBY&AX=$UU=kd(AYJ;mjaXr_v#X!7j-`SuyysP zb2%CN&gZoKkgM=ppA@RC39*|~F}TvhzTmj%=Mrx58&QAxq$Sg5x9;1vXVikd`d|A# z7+U;xrD%)yvUscTVfL@SXSY?Pyi2-Yq`I@tsqkvU9`#%9!GJFXzkX`S?mIMYa^LlX zyKLU~U|!aHuaIIvm)Rrks$cC>=C2E^ZP#$R!QP95a!U*&L*Im%^)2GQn_2Ame{Tr; z%q?VMb4U5$n+>ItwBwXt{yvj7u-nf0`-i-AsFzqR^FJ=RGlpw6x9ICEkVo;X`2Sqq zAYHOUeq)c8FMd_i^H1{4!lv~7Eu*Ea=lb25MHb0i1>Zub_&;wvp8fdZevwZN(-b zv@2=9oW6TO6uH7^lijWiYl*KB@z35=70;`F|LJ&@3(lyei z`|h^e`i4a}bIyKPH-Bbz&6Z=Ye&01gJ;^Tl##%Sew>CvFW0pzMJ3k#0n^M28wZ`h~ zur0rzzn!d^`a+;Lth*C^wOKQFL6-aJzrzwwFPIddFmJvduysrS#lP%Y-9D@}vBZCZ z^1#aoue>Eoj!q1nA@NpZ%+k({o0lH_Lf_yw>W*qn%#|GzSMiUm>Gf^pX7%G$U(X~S zd{tA@?|G%efkn5Hmp<7$EAzg1%umn4>O6kvG+|bj=ctAy^X?a1@Lq9w$IfT^pqNA( zFPl|W`#Nv4`BsppSm`TDs`<6mIrpR5?42PmQMtnN_omTqBRb9<5SukFTdeucfa16 z-PuC+`Ey!y@qfZ*UEFo+`zTTJp5oOTCicC2tkHPWuZ!P|%$$LmTPIhAmz(97ZM|5< zEptA8KG?@K_kT{4`GQd)C7kj2&wQ(hg0N%8(HlaiPTOQ2B-$OZ{N|CBIbWxYyR^e! ZqxJ9P^`ZNOON(Fms~?EWR?$!9{|h$$%{l-8 delta 11551 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 Xj=S<($ph~Gc;^vw`8NDS2mkyJ-dVL> diff --git a/Modules/AzBobbyTables/3.5.1/en-US/AzBobbyTables.PS.dll-Help.xml b/Modules/AzBobbyTables/3.5.1/en-US/AzBobbyTables.PS.dll-Help.xml index 162d20d700f77..66a2240908b39 100644 --- a/Modules/AzBobbyTables/3.5.1/en-US/AzBobbyTables.PS.dll-Help.xml +++ b/Modules/AzBobbyTables/3.5.1/en-US/AzBobbyTables.PS.dll-Help.xml @@ -61,6 +61,18 @@ False + + MaxRetries + + The number of times to retry the operation when the request is throttled by the service with an HTTP 429 response. Between attempts the module waits for the duration indicated by the service's Retry-After response. Defaults to 0, which disables retries. + + Int32 + + Int32 + + + None + Add-AzDataTableEntity @@ -99,6 +111,18 @@ None + + MaxRetries + + The number of times to retry the operation when the request is throttled by the service with an HTTP 429 response. Between attempts the module waits for the duration indicated by the service's Retry-After response. Defaults to 0, which disables retries. + + Int32 + + Int32 + + + None + OperationType @@ -168,6 +192,18 @@ False + + MaxRetries + + The number of times to retry the operation when the request is throttled by the service with an HTTP 429 response. Between attempts the module waits for the duration indicated by the service's Retry-After response. Defaults to 0, which disables retries. + + Int32 + + Int32 + + + None + OperationType @@ -271,6 +307,18 @@ PS C:\> Add-AzDataTableEntity -Entity $Users -Context $Context -OperationType None + + MaxRetries + + The number of times to retry the operation when the request is throttled by the service with an HTTP 429 response. Between attempts the module waits for the duration indicated by the service's Retry-After response. Defaults to 0, which disables retries. + + Int32 + + Int32 + + + None + @@ -286,6 +334,18 @@ PS C:\> Add-AzDataTableEntity -Entity $Users -Context $Context -OperationType None + + MaxRetries + + The number of times to retry the operation when the request is throttled by the service with an HTTP 429 response. Between attempts the module waits for the duration indicated by the service's Retry-After response. Defaults to 0, which disables retries. + + Int32 + + Int32 + + + None + @@ -374,6 +434,18 @@ PS C:\> Clear-AzDataTable $Context None + + MaxRetries + + The number of times to retry the operation when the request is throttled by the service with an HTTP 429 response. Between attempts the module waits for the duration indicated by the service's Retry-After response. Defaults to 0, which disables retries. + + Int32 + + Int32 + + + None + @@ -402,6 +474,18 @@ PS C:\> Clear-AzDataTable $Context None + + MaxRetries + + The number of times to retry the operation when the request is throttled by the service with an HTTP 429 response. Between attempts the module waits for the duration indicated by the service's Retry-After response. Defaults to 0, which disables retries. + + Int32 + + Int32 + + + None + @@ -485,6 +569,18 @@ PS C:\> Clear-AzDataTable $Context False + + MaxRetries + + The number of times to retry the operation when the request is throttled by the service with an HTTP 429 response. Between attempts the module waits for the duration indicated by the service's Retry-After response. Defaults to 0, which disables retries. + + Int32 + + Int32 + + + None + Get-AzDataTableEntity @@ -524,6 +620,18 @@ PS C:\> Clear-AzDataTable $Context None + + MaxRetries + + The number of times to retry the operation when the request is throttled by the service with an HTTP 429 response. Between attempts the module waits for the duration indicated by the service's Retry-After response. Defaults to 0, which disables retries. + + Int32 + + Int32 + + + None + Property @@ -612,6 +720,18 @@ PS C:\> Clear-AzDataTable $Context None + + MaxRetries + + The number of times to retry the operation when the request is throttled by the service with an HTTP 429 response. Between attempts the module waits for the duration indicated by the service's Retry-After response. Defaults to 0, which disables retries. + + Int32 + + Int32 + + + None + Property @@ -792,6 +912,18 @@ PS C:\> $UserEntities = Get-AzDataTableEntity -Property 'FirstName','Age' -Co None + + MaxRetries + + The number of times to retry the operation when the request is throttled by the service with an HTTP 429 response. Between attempts the module waits for the duration indicated by the service's Retry-After response. Defaults to 0, which disables retries. + + Int32 + + Int32 + + + None + @@ -807,6 +939,18 @@ PS C:\> $UserEntities = Get-AzDataTableEntity -Property 'FirstName','Age' -Co None + + MaxRetries + + The number of times to retry the operation when the request is throttled by the service with an HTTP 429 response. Between attempts the module waits for the duration indicated by the service's Retry-After response. Defaults to 0, which disables retries. + + Int32 + + Int32 + + + None + @@ -886,7 +1030,7 @@ PS C:\> New-AzDataTable -Context $Context MaxConnectionsPerServer - {{ Fill MaxConnectionsPerServer Description }} + The maximum number of concurrent connections allowed per server endpoint on the shared HTTP client pool. Applied process-wide the first time a connection is created and cannot be changed afterwards. Defaults to unlimited. Int32 @@ -937,7 +1081,7 @@ PS C:\> New-AzDataTable -Context $Context MaxConnectionsPerServer - {{ Fill MaxConnectionsPerServer Description }} + The maximum number of concurrent connections allowed per server endpoint on the shared HTTP client pool. Applied process-wide the first time a connection is created and cannot be changed afterwards. Defaults to unlimited. Int32 @@ -964,7 +1108,7 @@ PS C:\> New-AzDataTable -Context $Context MaxConnectionsPerServer - {{ Fill MaxConnectionsPerServer Description }} + The maximum number of concurrent connections allowed per server endpoint on the shared HTTP client pool. Applied process-wide the first time a connection is created and cannot be changed afterwards. Defaults to unlimited. Int32 @@ -1003,7 +1147,7 @@ PS C:\> New-AzDataTable -Context $Context MaxConnectionsPerServer - {{ Fill MaxConnectionsPerServer Description }} + The maximum number of concurrent connections allowed per server endpoint on the shared HTTP client pool. Applied process-wide the first time a connection is created and cannot be changed afterwards. Defaults to unlimited. Int32 @@ -1054,7 +1198,7 @@ PS C:\> New-AzDataTable -Context $Context MaxConnectionsPerServer - {{ Fill MaxConnectionsPerServer Description }} + The maximum number of concurrent connections allowed per server endpoint on the shared HTTP client pool. Applied process-wide the first time a connection is created and cannot be changed afterwards. Defaults to unlimited. Int32 @@ -1141,7 +1285,7 @@ PS C:\> New-AzDataTable -Context $Context MaxConnectionsPerServer - {{ Fill MaxConnectionsPerServer Description }} + The maximum number of concurrent connections allowed per server endpoint on the shared HTTP client pool. Applied process-wide the first time a connection is created and cannot be changed afterwards. Defaults to unlimited. Int32 @@ -1302,6 +1446,18 @@ PS C:\> New-AzDataTable -Context $Context None + + MaxRetries + + The number of times to retry the operation when the request is throttled by the service with an HTTP 429 response. Between attempts the module waits for the duration indicated by the service's Retry-After response. Defaults to 0, which disables retries. + + Int32 + + Int32 + + + None + @@ -1317,6 +1473,18 @@ PS C:\> New-AzDataTable -Context $Context None + + MaxRetries + + The number of times to retry the operation when the request is throttled by the service with an HTTP 429 response. Between attempts the module waits for the duration indicated by the service's Retry-After response. Defaults to 0, which disables retries. + + Int32 + + Int32 + + + None + @@ -1405,6 +1573,18 @@ PS C:\> Remove-AzDataTable -Context $Context False + + MaxRetries + + The number of times to retry the operation when the request is throttled by the service with an HTTP 429 response. Between attempts the module waits for the duration indicated by the service's Retry-After response. Defaults to 0, which disables retries. + + Int32 + + Int32 + + + None + @@ -1444,6 +1624,18 @@ PS C:\> Remove-AzDataTable -Context $Context False + + MaxRetries + + The number of times to retry the operation when the request is throttled by the service with an HTTP 429 response. Between attempts the module waits for the duration indicated by the service's Retry-After response. Defaults to 0, which disables retries. + + Int32 + + Int32 + + + None + @@ -1566,6 +1758,18 @@ PS C:\> # OK - The -Force switch overrides ETag validation False + + MaxRetries + + The number of times to retry the operation when the request is throttled by the service with an HTTP 429 response. Between attempts the module waits for the duration indicated by the service's Retry-After response. Defaults to 0, which disables retries. + + Int32 + + Int32 + + + None + OperationType @@ -1622,6 +1826,18 @@ PS C:\> # OK - The -Force switch overrides ETag validation False + + MaxRetries + + The number of times to retry the operation when the request is throttled by the service with an HTTP 429 response. Between attempts the module waits for the duration indicated by the service's Retry-After response. Defaults to 0, which disables retries. + + Int32 + + Int32 + + + None + OperationType From f06ad2fbbb36ab0ddfb3657c187c361df9ff2c4b Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Wed, 1 Jul 2026 17:32:00 +0800 Subject: [PATCH 070/170] Table Read Retry --- Modules/CIPPCore/Public/Get-CIPPAzDatatableEntity.ps1 | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Modules/CIPPCore/Public/Get-CIPPAzDatatableEntity.ps1 b/Modules/CIPPCore/Public/Get-CIPPAzDatatableEntity.ps1 index 01def39a8b870..da15f05e62a8b 100644 --- a/Modules/CIPPCore/Public/Get-CIPPAzDatatableEntity.ps1 +++ b/Modules/CIPPCore/Public/Get-CIPPAzDatatableEntity.ps1 @@ -7,9 +7,11 @@ function Get-CIPPAzDataTableEntity { $First, $Skip, $Sort, - $Count + $Count, + [int]$MaxRetries = 3 ) + $PSBoundParameters['MaxRetries'] = $MaxRetries $Results = Get-AzDataTableEntity @PSBoundParameters $mergedResults = @{} $rootEntities = @{} # Keyed by "$PartitionKey|$RowKey" From 390b07534b89c2fa91a69c784aa6fe69a430a315 Mon Sep 17 00:00:00 2001 From: MatStocks <20848373+matstocks@users.noreply.github.com> Date: Wed, 1 Jul 2026 00:12:08 +0100 Subject: [PATCH 071/170] Escape square brackets in Intune assignment group name matching Group display names are matched with -like, which treats square brackets as a wildcard character class, so a literal group such as [WIN] Company Devices never matched itself and assignment failed with No matching groups resolved. Escape only [ and ] before the match so bracketed names resolve literally, while * and ? wildcards (documented in the assignment UI, 'Wildcards (*) are allowed') keep working. Applied to the six group-resolution sites in Set-CIPPAssignedApplication, Set-CIPPAssignedPolicy and Compare-CIPPIntuneAssignments (include and exclude). Filter-name matching is unchanged. Resolves KelvinTegelaar/CIPP#6246 --- Modules/CIPPCore/Public/Compare-CIPPIntuneAssignments.ps1 | 4 ++-- Modules/CIPPCore/Public/Set-CIPPAssignedApplication.ps1 | 4 ++-- Modules/CIPPCore/Public/Set-CIPPAssignedPolicy.ps1 | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Modules/CIPPCore/Public/Compare-CIPPIntuneAssignments.ps1 b/Modules/CIPPCore/Public/Compare-CIPPIntuneAssignments.ps1 index a123691d9d8cd..7c96361eb7bf6 100644 --- a/Modules/CIPPCore/Public/Compare-CIPPIntuneAssignments.ps1 +++ b/Modules/CIPPCore/Public/Compare-CIPPIntuneAssignments.ps1 @@ -80,7 +80,7 @@ function Compare-CIPPIntuneAssignments { $ExpectedGroupIds = @( $ExpectedCustomGroup.Split(',').Trim() | ForEach-Object { $name = $_ - $AllGroupsCache | Where-Object { $_.displayName -like $name } | Select-Object -ExpandProperty id + $AllGroupsCache | Where-Object { $_.displayName -like ($name -replace '\[', '`[' -replace '\]', '`]') } | Select-Object -ExpandProperty id } | Where-Object { $_ } ) $MissingIds = @($ExpectedGroupIds | Where-Object { $_ -notin $ExistingIncludeGroupIds }) @@ -97,7 +97,7 @@ function Compare-CIPPIntuneAssignments { $ExpectedExcludeIds = @( $ExpectedExcludeGroup.Split(',').Trim() | ForEach-Object { $name = $_ - $AllGroupsCache | Where-Object { $_.displayName -like $name } | Select-Object -ExpandProperty id + $AllGroupsCache | Where-Object { $_.displayName -like ($name -replace '\[', '`[' -replace '\]', '`]') } | Select-Object -ExpandProperty id } | Where-Object { $_ } ) $MissingExcludeIds = @($ExpectedExcludeIds | Where-Object { $_ -notin $ExistingExcludeGroupIds }) diff --git a/Modules/CIPPCore/Public/Set-CIPPAssignedApplication.ps1 b/Modules/CIPPCore/Public/Set-CIPPAssignedApplication.ps1 index 01cb05ec8ec77..18abcd1c7c659 100644 --- a/Modules/CIPPCore/Public/Set-CIPPAssignedApplication.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPAssignedApplication.ps1 @@ -114,7 +114,7 @@ function Set-CIPPAssignedApplication { $resolvedGroupIds = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/groups?$top=999&$select=id,displayName' -tenantid $TenantFilter | ForEach-Object { $Group = $_ foreach ($SingleName in $GroupNames) { - if ($Group.displayName -like $SingleName) { + if ($Group.displayName -like ($SingleName -replace '\[', '`[' -replace '\]', '`]')) { $Group.id } } @@ -161,7 +161,7 @@ function Set-CIPPAssignedApplication { $ResolvedExcludeIds = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/groups?$top=999&$select=id,displayName' -tenantid $TenantFilter | ForEach-Object { $Group = $_ foreach ($SingleName in $ExcludeGroupNames) { - if ($Group.displayName -like $SingleName) { + if ($Group.displayName -like ($SingleName -replace '\[', '`[' -replace '\]', '`]')) { $Group.id } } diff --git a/Modules/CIPPCore/Public/Set-CIPPAssignedPolicy.ps1 b/Modules/CIPPCore/Public/Set-CIPPAssignedPolicy.ps1 index 3aa9d3530e2c3..faff217f016f1 100644 --- a/Modules/CIPPCore/Public/Set-CIPPAssignedPolicy.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPAssignedPolicy.ps1 @@ -89,7 +89,7 @@ function Set-CIPPAssignedPolicy { $resolvedGroupIds = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/groups?$select=id,displayName&$top=999' -tenantid $TenantFilter | ForEach-Object { foreach ($SingleName in $GroupNames) { - if ($_.displayName -like $SingleName) { + if ($_.displayName -like ($SingleName -replace '\[', '`[' -replace '\]', '`]')) { $_.id } } @@ -129,7 +129,7 @@ function Set-CIPPAssignedPolicy { $ResolvedExcludeIds = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/groups?$select=id,displayName&$top=999' -tenantid $TenantFilter | ForEach-Object { foreach ($SingleName in $ExcludeGroupNames) { - if ($_.displayName -like $SingleName) { + if ($_.displayName -like ($SingleName -replace '\[', '`[' -replace '\]', '`]')) { $_.id } } From 614949f2ce1c020a825f782773209787852dcd5b Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Wed, 1 Jul 2026 19:13:01 +0800 Subject: [PATCH 072/170] Update payload for log collection endpoint --- .../Endpoint/MEM/Invoke-ExecDeviceAction.ps1 | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecDeviceAction.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecDeviceAction.ps1 index 76de8b21de39c..8b3123ea19d14 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecDeviceAction.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecDeviceAction.ps1 @@ -38,6 +38,15 @@ function Invoke-ExecDeviceAction { Write-Host "ActionBody: $ActionBody" break } + 'createDeviceLogCollectionRequest' { + $ActionBody = @{ + templateType = @{ + '@odata.type' = '#microsoft.graph.deviceLogCollectionRequest' + templateType = 'predefined' + } + } | ConvertTo-Json -Compress -Depth 5 + break + } default { $ActionBody = $Request.Body | ConvertTo-Json -Compress } } From aae566d72488dfe59e277b261c9885fa764ecfde Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Wed, 1 Jul 2026 14:46:11 +0200 Subject: [PATCH 073/170] add executingUser --- .../Public/Send-CIPPScheduledTaskAlert.ps1 | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/Modules/CIPPCore/Public/Send-CIPPScheduledTaskAlert.ps1 b/Modules/CIPPCore/Public/Send-CIPPScheduledTaskAlert.ps1 index 465634d5e7bea..a8e084fcc376a 100644 --- a/Modules/CIPPCore/Public/Send-CIPPScheduledTaskAlert.ps1 +++ b/Modules/CIPPCore/Public/Send-CIPPScheduledTaskAlert.ps1 @@ -137,6 +137,11 @@ function Send-CIPPScheduledTaskAlert { $NotificationFilter = "RowKey eq 'CippNotifications' and PartitionKey eq 'CippNotifications'" $NotificationConfig = [pscustomobject](Get-CIPPAzDataTableEntity @NotificationTable -Filter $NotificationFilter) $UseStandardizedSchema = [boolean]$NotificationConfig.UseStandardizedSchema + $TaskParameters = $TaskInfo.Parameters + if ($TaskParameters -is [string] -and $TaskParameters) { + try { $TaskParameters = $TaskParameters | ConvertFrom-Json -ErrorAction Stop } catch { $TaskParameters = $null } + } + $ExecutingUser = $TaskParameters.Headers.'x-ms-client-principal-name' # Send to configured alert targets switch -wildcard ($TaskInfo.PostExecution) { @@ -181,10 +186,11 @@ function Send-CIPPScheduledTaskAlert { $Webhook = if ($UseStandardizedSchema) { $obj = [PSCustomObject]@{ - tenantId = $TenantInfo.customerId - tenant = $TenantFilter - taskType = $TaskType - task = [PSCustomObject]@{ + tenantId = $TenantInfo.customerId + tenant = $TenantFilter + taskType = $TaskType + executingUser = $ExecutingUser + task = [PSCustomObject]@{ id = $TaskInfo.RowKey name = $TaskInfo.Name command = $TaskInfo.Command @@ -194,8 +200,8 @@ function Send-CIPPScheduledTaskAlert { executed = $TaskInfo.ExecutedTime partition = $TaskInfo.PartitionKey } - results = $Results - alertComment = $TaskInfo.AlertComment + results = $Results + alertComment = $TaskInfo.AlertComment } if ($SnoozeInfo) { $obj | Add-Member -NotePropertyName 'snooze' -NotePropertyValue $SnoozeInfo } $obj From a973ec62c08346a2b3ccc1ddb806cdaca47204c9 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Wed, 1 Jul 2026 21:44:30 +0800 Subject: [PATCH 074/170] Dep bump --- .../AzBobbyTables/3.5.1/AzBobbyTables.PS.dll | Bin 43520 -> 0 bytes .../3.5.1/dependencies/AzBobbyTables.Core.dll | Bin 49664 -> 0 bytes .../Microsoft.VisualStudio.Threading.dll | Bin 450576 -> 0 bytes .../Microsoft.VisualStudio.Validation.dll | Bin 37904 -> 0 bytes .../AzBobbyTables/3.6.0/AzBobbyTables.PS.dll | Bin 0 -> 44032 bytes .../{3.5.1 => 3.6.0}/AzBobbyTables.psd1 | 12 +- .../{3.5.1 => 3.6.0}/CHANGELOG.md | 8 +- .../AzBobbyTables/{3.5.1 => 3.6.0}/LICENSE | 0 .../AzBobbyTables/3.6.0/PSGetModuleInfo.xml | 159 ++++++++++++++++++ .../3.6.0/dependencies/AzBobbyTables.Core.dll | Bin 0 -> 50176 bytes .../dependencies/Azure.Core.dll | Bin .../dependencies/Azure.Data.Tables.dll | Bin .../Microsoft.Bcl.AsyncInterfaces.dll | Bin .../dependencies/Microsoft.Bcl.Memory.dll | Bin .../Microsoft.VisualStudio.Threading.dll | Bin 0 -> 464224 bytes .../Microsoft.VisualStudio.Validation.dll | Bin 0 -> 45072 bytes .../dependencies/Microsoft.Win32.Registry.dll | Bin .../dependencies/System.Buffers.dll | Bin .../dependencies/System.ClientModel.dll | Bin .../System.Diagnostics.DiagnosticSource.dll | Bin .../dependencies/System.Interactive.Async.dll | Bin .../dependencies/System.Linq.Async.dll | Bin .../System.Linq.AsyncEnumerable.dll | Bin .../dependencies/System.Memory.Data.dll | Bin .../dependencies/System.Memory.dll | Bin .../dependencies/System.Numerics.Vectors.dll | Bin ...System.Runtime.CompilerServices.Unsafe.dll | Bin .../System.Security.AccessControl.dll | Bin .../System.Security.Principal.Windows.dll | Bin .../System.Text.Encodings.Web.dll | Bin .../dependencies/System.Text.Json.dll | Bin .../System.Threading.Tasks.Extensions.dll | Bin .../en-US/AzBobbyTables.PS.dll-Help.xml | 0 33 files changed, 173 insertions(+), 6 deletions(-) delete mode 100644 Modules/AzBobbyTables/3.5.1/AzBobbyTables.PS.dll delete mode 100644 Modules/AzBobbyTables/3.5.1/dependencies/AzBobbyTables.Core.dll delete mode 100644 Modules/AzBobbyTables/3.5.1/dependencies/Microsoft.VisualStudio.Threading.dll delete mode 100644 Modules/AzBobbyTables/3.5.1/dependencies/Microsoft.VisualStudio.Validation.dll create mode 100644 Modules/AzBobbyTables/3.6.0/AzBobbyTables.PS.dll rename Modules/AzBobbyTables/{3.5.1 => 3.6.0}/AzBobbyTables.psd1 (92%) rename Modules/AzBobbyTables/{3.5.1 => 3.6.0}/CHANGELOG.md (93%) rename Modules/AzBobbyTables/{3.5.1 => 3.6.0}/LICENSE (100%) create mode 100644 Modules/AzBobbyTables/3.6.0/PSGetModuleInfo.xml create mode 100644 Modules/AzBobbyTables/3.6.0/dependencies/AzBobbyTables.Core.dll rename Modules/AzBobbyTables/{3.5.1 => 3.6.0}/dependencies/Azure.Core.dll (100%) rename Modules/AzBobbyTables/{3.5.1 => 3.6.0}/dependencies/Azure.Data.Tables.dll (100%) rename Modules/AzBobbyTables/{3.5.1 => 3.6.0}/dependencies/Microsoft.Bcl.AsyncInterfaces.dll (100%) rename Modules/AzBobbyTables/{3.5.1 => 3.6.0}/dependencies/Microsoft.Bcl.Memory.dll (100%) create mode 100644 Modules/AzBobbyTables/3.6.0/dependencies/Microsoft.VisualStudio.Threading.dll create mode 100644 Modules/AzBobbyTables/3.6.0/dependencies/Microsoft.VisualStudio.Validation.dll rename Modules/AzBobbyTables/{3.5.1 => 3.6.0}/dependencies/Microsoft.Win32.Registry.dll (100%) rename Modules/AzBobbyTables/{3.5.1 => 3.6.0}/dependencies/System.Buffers.dll (100%) rename Modules/AzBobbyTables/{3.5.1 => 3.6.0}/dependencies/System.ClientModel.dll (100%) rename Modules/AzBobbyTables/{3.5.1 => 3.6.0}/dependencies/System.Diagnostics.DiagnosticSource.dll (100%) rename Modules/AzBobbyTables/{3.5.1 => 3.6.0}/dependencies/System.Interactive.Async.dll (100%) rename Modules/AzBobbyTables/{3.5.1 => 3.6.0}/dependencies/System.Linq.Async.dll (100%) rename Modules/AzBobbyTables/{3.5.1 => 3.6.0}/dependencies/System.Linq.AsyncEnumerable.dll (100%) rename Modules/AzBobbyTables/{3.5.1 => 3.6.0}/dependencies/System.Memory.Data.dll (100%) rename Modules/AzBobbyTables/{3.5.1 => 3.6.0}/dependencies/System.Memory.dll (100%) rename Modules/AzBobbyTables/{3.5.1 => 3.6.0}/dependencies/System.Numerics.Vectors.dll (100%) rename Modules/AzBobbyTables/{3.5.1 => 3.6.0}/dependencies/System.Runtime.CompilerServices.Unsafe.dll (100%) rename Modules/AzBobbyTables/{3.5.1 => 3.6.0}/dependencies/System.Security.AccessControl.dll (100%) rename Modules/AzBobbyTables/{3.5.1 => 3.6.0}/dependencies/System.Security.Principal.Windows.dll (100%) rename Modules/AzBobbyTables/{3.5.1 => 3.6.0}/dependencies/System.Text.Encodings.Web.dll (100%) rename Modules/AzBobbyTables/{3.5.1 => 3.6.0}/dependencies/System.Text.Json.dll (100%) rename Modules/AzBobbyTables/{3.5.1 => 3.6.0}/dependencies/System.Threading.Tasks.Extensions.dll (100%) rename Modules/AzBobbyTables/{3.5.1 => 3.6.0}/en-US/AzBobbyTables.PS.dll-Help.xml (100%) diff --git a/Modules/AzBobbyTables/3.5.1/AzBobbyTables.PS.dll b/Modules/AzBobbyTables/3.5.1/AzBobbyTables.PS.dll deleted file mode 100644 index 9854cf9db2e1b423a2d2fa0693be3fa3d0d1cf16..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 43520 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 W4bV6TxEg2pzvS=#yZL`A1OExK(TZvS diff --git a/Modules/AzBobbyTables/3.5.1/dependencies/AzBobbyTables.Core.dll b/Modules/AzBobbyTables/3.5.1/dependencies/AzBobbyTables.Core.dll deleted file mode 100644 index d80c65efa5b76d626bcfeb5538cc4fa1f92dfe14..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 49664 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 diff --git a/Modules/AzBobbyTables/3.5.1/dependencies/Microsoft.VisualStudio.Threading.dll b/Modules/AzBobbyTables/3.5.1/dependencies/Microsoft.VisualStudio.Threading.dll deleted file mode 100644 index eb30047dc7144eae400774bd99ebd569399fad71..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 450576 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 diff --git a/Modules/AzBobbyTables/3.5.1/dependencies/Microsoft.VisualStudio.Validation.dll b/Modules/AzBobbyTables/3.5.1/dependencies/Microsoft.VisualStudio.Validation.dll deleted file mode 100644 index 8c4956f420e13c59808569c3a74248ba9e8d3f5e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 37904 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 diff --git a/Modules/AzBobbyTables/3.6.0/AzBobbyTables.PS.dll b/Modules/AzBobbyTables/3.6.0/AzBobbyTables.PS.dll new file mode 100644 index 0000000000000000000000000000000000000000..0886fa7c42d74a7ed3ce0e6458e07f14d919e973 GIT binary patch literal 44032 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 XKp#Zs9uiX=^MAF#{(n6GnGF0lZ70vY literal 0 HcmV?d00001 diff --git a/Modules/AzBobbyTables/3.5.1/AzBobbyTables.psd1 b/Modules/AzBobbyTables/3.6.0/AzBobbyTables.psd1 similarity index 92% rename from Modules/AzBobbyTables/3.5.1/AzBobbyTables.psd1 rename to Modules/AzBobbyTables/3.6.0/AzBobbyTables.psd1 index d8b3c26738268..b07587f76bff4 100644 --- a/Modules/AzBobbyTables/3.5.1/AzBobbyTables.psd1 +++ b/Modules/AzBobbyTables/3.6.0/AzBobbyTables.psd1 @@ -4,7 +4,7 @@ RootModule = 'AzBobbyTables.PS.dll' # Version number of this module. -ModuleVersion = '3.5.1' +ModuleVersion = '3.6.0' # Supported PSEditions CompatiblePSEditions = @('Core') @@ -110,12 +110,16 @@ PrivateData = @{ # IconUri = '' # ReleaseNotes of this module - ReleaseNotes = '## [3.5.1] - 2026-07-01 + ReleaseNotes = '## [3.6.0] - 2026-07-01 ### Added -- Added a `-MaxConnectionsPerServer` parameter to `New-AzDataTableContext` to cap the number of concurrent connections per server endpoint on the shared HTTP client pool. Applied process-wide on first use; default is unlimited. -- Added a `-MaxRetries` parameter to the table operation cmdlets (`Add-`, `Get-`, `Remove-`, `Update-AzDataTableEntity`, `Clear-`, `Get-`, `New-`, `Remove-AzDataTable`) to retry throttled requests (HTTP 429), waiting for the service''s Retry-After hint between attempts. Defaults to `0` (no retries). +- Added a `-MaxConnectionsPerServer` parameter to `New-AzDataTableContext` to cap the number of concurrent connections per server endpoint on the shared HTTP client pool. Applied process-wide on first use; default is unlimited. ([#133](https://github.com/PalmEmanuel/AzBobbyTables/pull/122)) +- Added a `-MaxRetries` parameter to the table operation cmdlets (`Add-`, `Get-`, `Remove-`, `Update-AzDataTableEntity`, `Clear-`, `Get-`, `New-`, `Remove-AzDataTable`) to retry throttled requests (HTTP 429), waiting for the service''s Retry-After hint between attempts. Defaults to `0` (no retries). ([#133](https://github.com/PalmEmanuel/AzBobbyTables/pull/122)) + +### Changed + +Bumped Microsoft.VisualStudio.Threading from 17.14.15 to 18.7.23 (#132) ' diff --git a/Modules/AzBobbyTables/3.5.1/CHANGELOG.md b/Modules/AzBobbyTables/3.6.0/CHANGELOG.md similarity index 93% rename from Modules/AzBobbyTables/3.5.1/CHANGELOG.md rename to Modules/AzBobbyTables/3.6.0/CHANGELOG.md index f11860f075d31..1871f2ceeb3b9 100644 --- a/Modules/AzBobbyTables/3.5.1/CHANGELOG.md +++ b/Modules/AzBobbyTables/3.6.0/CHANGELOG.md @@ -6,8 +6,12 @@ The format is based on and uses the types of changes according to [Keep a Change ### Added -- Added a `-MaxConnectionsPerServer` parameter to `New-AzDataTableContext` to cap the number of concurrent connections per server endpoint on the shared HTTP client pool. Applied process-wide on first use; default is unlimited. -- Added a `-MaxRetries` parameter to the table operation cmdlets (`Add-`, `Get-`, `Remove-`, `Update-AzDataTableEntity`, `Clear-`, `Get-`, `New-`, `Remove-AzDataTable`) to retry throttled requests (HTTP 429), waiting for the service's Retry-After hint between attempts. Defaults to `0` (no retries). +- Added a `-MaxConnectionsPerServer` parameter to `New-AzDataTableContext` to cap the number of concurrent connections per server endpoint on the shared HTTP client pool. Applied process-wide on first use; default is unlimited. ([#133](https://github.com/PalmEmanuel/AzBobbyTables/pull/122)) +- Added a `-MaxRetries` parameter to the table operation cmdlets (`Add-`, `Get-`, `Remove-`, `Update-AzDataTableEntity`, `Clear-`, `Get-`, `New-`, `Remove-AzDataTable`) to retry throttled requests (HTTP 429), waiting for the service's Retry-After hint between attempts. Defaults to `0` (no retries). ([#133](https://github.com/PalmEmanuel/AzBobbyTables/pull/122)) + +### Changed + +Bumped Microsoft.VisualStudio.Threading from 17.14.15 to 18.7.23 (#132) ## [3.5.0] - 2026-04-20 diff --git a/Modules/AzBobbyTables/3.5.1/LICENSE b/Modules/AzBobbyTables/3.6.0/LICENSE similarity index 100% rename from Modules/AzBobbyTables/3.5.1/LICENSE rename to Modules/AzBobbyTables/3.6.0/LICENSE diff --git a/Modules/AzBobbyTables/3.6.0/PSGetModuleInfo.xml b/Modules/AzBobbyTables/3.6.0/PSGetModuleInfo.xml new file mode 100644 index 0000000000000..3ba1a2251829c --- /dev/null +++ b/Modules/AzBobbyTables/3.6.0/PSGetModuleInfo.xml @@ -0,0 +1,159 @@ + + + + Microsoft.PowerShell.Commands.PSRepositoryItemInfo + System.Management.Automation.PSCustomObject + System.Object + + + AzBobbyTables + 3.6.0 + Module + A module for handling Azure Table Storage operations by wrapping the Azure Data Tables SDK. + Emanuel Palm + PalmEmanuel + (c) Emanuel Palm. All rights reserved. +

2026-07-01T12:50:12+08:00
+ +
2026-07-01T21:42:52.9978294+08:00
+ + + + Microsoft.PowerShell.Commands.DisplayHintType + System.Enum + System.ValueType + System.Object + + DateTime + 2 + + +
+ + https://github.com/PalmEmanuel/AzBobbyTables/blob/main/LICENSE + https://github.com/PalmEmanuel/AzBobbyTables + + + + System.Object[] + System.Array + System.Object + + + azure + storage + table + cosmos + cosmosdb + data + PSModule + PSEdition_Core + + + + + System.Collections.Hashtable + System.Object + + + + Function + + + + + + + Command + + + + Add-AzDataTableEntity + Clear-AzDataTable + Get-AzDataTable + Get-AzDataTableEntity + Get-AzDataTableSupportedEntityType + Remove-AzDataTableEntity + Update-AzDataTableEntity + New-AzDataTableContext + Remove-AzDataTable + New-AzDataTable + + + + + Cmdlet + + + + Add-AzDataTableEntity + Clear-AzDataTable + Get-AzDataTable + Get-AzDataTableEntity + Get-AzDataTableSupportedEntityType + Remove-AzDataTableEntity + Update-AzDataTableEntity + New-AzDataTableContext + Remove-AzDataTable + New-AzDataTable + + + + + DscResource + + + + RoleCapability + + + + Workflow + + + + + + ## [3.6.0] - 2026-07-01_x000A__x000A_### Added_x000A__x000A_- Added a `-MaxConnectionsPerServer` parameter to `New-AzDataTableContext` to cap the number of concurrent connections per server endpoint on the shared HTTP client pool. Applied process-wide on first use; default is unlimited. ([#133](https://github.com/PalmEmanuel/AzBobbyTables/pull/122))_x000A_- Added a `-MaxRetries` parameter to the table operation cmdlets (`Add-`, `Get-`, `Remove-`, `Update-AzDataTableEntity`, `Clear-`, `Get-`, `New-`, `Remove-AzDataTable`) to retry throttled requests (HTTP 429), waiting for the service's Retry-After hint between attempts. Defaults to `0` (no retries). ([#133](https://github.com/PalmEmanuel/AzBobbyTables/pull/122))_x000A__x000A_### Changed_x000A__x000A_Bumped Microsoft.VisualStudio.Threading from 17.14.15 to 18.7.23 (#132) + + + + + https://www.powershellgallery.com/api/v2 + PSGallery + NuGet + + + System.Management.Automation.PSCustomObject + System.Object + + + (c) Emanuel Palm. All rights reserved. + A module for handling Azure Table Storage operations by wrapping the Azure Data Tables SDK. + False + ## [3.6.0] - 2026-07-01_x000A__x000A_### Added_x000A__x000A_- Added a `-MaxConnectionsPerServer` parameter to `New-AzDataTableContext` to cap the number of concurrent connections per server endpoint on the shared HTTP client pool. Applied process-wide on first use; default is unlimited. ([#133](https://github.com/PalmEmanuel/AzBobbyTables/pull/122))_x000A_- Added a `-MaxRetries` parameter to the table operation cmdlets (`Add-`, `Get-`, `Remove-`, `Update-AzDataTableEntity`, `Clear-`, `Get-`, `New-`, `Remove-AzDataTable`) to retry throttled requests (HTTP 429), waiting for the service's Retry-After hint between attempts. Defaults to `0` (no retries). ([#133](https://github.com/PalmEmanuel/AzBobbyTables/pull/122))_x000A__x000A_### Changed_x000A__x000A_Bumped Microsoft.VisualStudio.Threading from 17.14.15 to 18.7.23 (#132) + True + True + 3 + 93772 + 1969607 + 1/07/2026 12:50:12 PM +08:00 + 1/07/2026 12:50:12 PM +08:00 + 1/07/2026 1:42:19 PM +08:00 + azure storage table cosmos cosmosdb data PSModule PSEdition_Core PSCmdlet_Add-AzDataTableEntity PSCommand_Add-AzDataTableEntity PSCmdlet_Clear-AzDataTable PSCommand_Clear-AzDataTable PSCmdlet_Get-AzDataTable PSCommand_Get-AzDataTable PSCmdlet_Get-AzDataTableEntity PSCommand_Get-AzDataTableEntity PSCmdlet_Get-AzDataTableSupportedEntityType PSCommand_Get-AzDataTableSupportedEntityType PSCmdlet_Remove-AzDataTableEntity PSCommand_Remove-AzDataTableEntity PSCmdlet_Update-AzDataTableEntity PSCommand_Update-AzDataTableEntity PSCmdlet_New-AzDataTableContext PSCommand_New-AzDataTableContext PSCmdlet_Remove-AzDataTable PSCommand_Remove-AzDataTable PSCmdlet_New-AzDataTable PSCommand_New-AzDataTable PSIncludes_Cmdlet + False + 2026-07-01T13:42:19Z + 3.6.0 + Emanuel Palm + false + Module + AzBobbyTables.nuspec|dependencies\System.Memory.Data.dll|dependencies\System.ClientModel.dll|dependencies\System.Interactive.Async.dll|LICENSE|dependencies\AzBobbyTables.Core.dll|dependencies\System.Text.Encodings.Web.dll|dependencies\Microsoft.Bcl.AsyncInterfaces.dll|AzBobbyTables.psd1|dependencies\System.Linq.AsyncEnumerable.dll|dependencies\Microsoft.Win32.Registry.dll|dependencies\System.Numerics.Vectors.dll|CHANGELOG.md|dependencies\System.Linq.Async.dll|dependencies\System.Security.Principal.Windows.dll|dependencies\System.Buffers.dll|AzBobbyTables.PS.dll|dependencies\System.Threading.Tasks.Extensions.dll|dependencies\System.Security.AccessControl.dll|dependencies\Azure.Core.dll|en-US\AzBobbyTables.PS.dll-Help.xml|dependencies\System.Memory.dll|dependencies\System.Diagnostics.DiagnosticSource.dll|dependencies\Microsoft.VisualStudio.Validation.dll|dependencies\Microsoft.Bcl.Memory.dll|dependencies\Microsoft.VisualStudio.Threading.dll|dependencies\Azure.Data.Tables.dll|dependencies\System.Runtime.CompilerServices.Unsafe.dll|dependencies\System.Text.Json.dll + eead4f42-5080-4f83-8901-340c529a5a11 + 7.0 + pipe.how + + + C:\Users\Zac\Documents\PowerShell\Modules\AzBobbyTables\3.6.0 + + + diff --git a/Modules/AzBobbyTables/3.6.0/dependencies/AzBobbyTables.Core.dll b/Modules/AzBobbyTables/3.6.0/dependencies/AzBobbyTables.Core.dll new file mode 100644 index 0000000000000000000000000000000000000000..4cb326f0b68db96e22dbc081a485f097a3be8bd6 GIT binary patch literal 50176 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 BUt|CP literal 0 HcmV?d00001 diff --git a/Modules/AzBobbyTables/3.5.1/dependencies/Azure.Core.dll b/Modules/AzBobbyTables/3.6.0/dependencies/Azure.Core.dll similarity index 100% rename from Modules/AzBobbyTables/3.5.1/dependencies/Azure.Core.dll rename to Modules/AzBobbyTables/3.6.0/dependencies/Azure.Core.dll diff --git a/Modules/AzBobbyTables/3.5.1/dependencies/Azure.Data.Tables.dll b/Modules/AzBobbyTables/3.6.0/dependencies/Azure.Data.Tables.dll similarity index 100% rename from Modules/AzBobbyTables/3.5.1/dependencies/Azure.Data.Tables.dll rename to Modules/AzBobbyTables/3.6.0/dependencies/Azure.Data.Tables.dll diff --git a/Modules/AzBobbyTables/3.5.1/dependencies/Microsoft.Bcl.AsyncInterfaces.dll b/Modules/AzBobbyTables/3.6.0/dependencies/Microsoft.Bcl.AsyncInterfaces.dll similarity index 100% rename from Modules/AzBobbyTables/3.5.1/dependencies/Microsoft.Bcl.AsyncInterfaces.dll rename to Modules/AzBobbyTables/3.6.0/dependencies/Microsoft.Bcl.AsyncInterfaces.dll diff --git a/Modules/AzBobbyTables/3.5.1/dependencies/Microsoft.Bcl.Memory.dll b/Modules/AzBobbyTables/3.6.0/dependencies/Microsoft.Bcl.Memory.dll similarity index 100% rename from Modules/AzBobbyTables/3.5.1/dependencies/Microsoft.Bcl.Memory.dll rename to Modules/AzBobbyTables/3.6.0/dependencies/Microsoft.Bcl.Memory.dll diff --git a/Modules/AzBobbyTables/3.6.0/dependencies/Microsoft.VisualStudio.Threading.dll b/Modules/AzBobbyTables/3.6.0/dependencies/Microsoft.VisualStudio.Threading.dll new file mode 100644 index 0000000000000000000000000000000000000000..052dcc98c342a3a30e7d6c1461f4331abc61ae79 GIT binary patch literal 464224 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

7(vSO@k$P1MIs5S9;S+B&cq#+WWB*GlGaz` zA|EGj(xSvBpZmRTvMrP+?6J*J>f3@_KNUC};le_45Dj0ex1>9u+>B59m@8y?3H08q=5h z+3f$xm$V15m-a?X=FJ>CWn$P_i?~!Wp>w6urgyYiVb4c-#kdR&!A;VIf6CbOWG-?B63?x=FP;jt}+i8Kd8W;xkdjBu%q>9$uy zR&8uR4j4sg^5qIvO)Tj*&BcHuf7RT@Ls@c~H>XE=q+)W8D@#+R%Xc_YhhGj(!8+$M zBycd9SPZ{~N}HR>ioi(hrEz_uOVBUdBOP9}Fg>tgUkECFf4;t;(J4P8nQXOiXjC=) zE7roE!4&5=9AF6bdZO~Imse!CNivnFDzWnk_C7L#IC0^C8!{v;BaEck0WG3vYp-Z! zq15SNev4t?8#Ubg-+KZNb4ed;2D_*tHxrf_RC*W zXhpLbfNekqN{-wV4X^dIB}^EMF8I^H($> zVJL8FohJ4)t;PO9Y%g$==?EWm9n4L|Pv>AmNnKqtenOUgi?h^X|8~4JE9P+thp42K z8ZGo)Q_3^#?j;>rWRIssOmhUU60W!!m9Dj5x;RiRG#IkhwBZc6xe?ZAvEnWa_GGNB zh?_*kaY!RjRl%yXs6^cTM<{(NiRxZVxmHgyXkcd7#cI@J&5-J|&CU3oBKmDAQYtxo z(h*1k(_|5gj1GZ+drIzUjnl89u&QD*sl2G-qjsbMW_zs4CI(#o6>|Pf473qkBrgTE z>5rrXiAXy2DQ;{wh>#vCstm5S!yr-L4AT$V2K~?($E;d};^daWR3+lwSmoz`yOz=N ztdb*4cW}mO60wtov^N}=kt>(1Yq@=pF)vZqqPLUNArJV<@_kw)b@54i7C0Yb`+^iP zIP`}r*$kR0b1}@JxrE!WIYzAYu+Y?HAx^qu)Ju_z%MspX@a)%GKo}46@ZC{ zScCAy5KHI2#1AC%plULa{flicK2#MfwQX0NiU9E>eggw%6pR!Z8~ydd9WD1Z)9yo6 zJ+7-&WGd0~>M-uP87b!m(oj0Yy@oPpUQevfi7OPnG_)HU*8I_!+0kGsE=2WmK%8Rn zn$AbLazgFg+)yDCOxLYq4vJpmgKHZMLLmlT_h*zY?iBXjGgwgO%+Z(CJ`BVKOx% zXliscUKAd!_%tgb-Wza%U@9RGo8SYnurL(@55k#NuQ6nCa9K?F(RRh%=R%41GH7|J zTR47yK^)Bxp*W31TRte57MxWyVj&c3Fb38%If_FF28~1xO;fT-*xxXoIVonfI-A5^ z5+uYSr~y|fY{g_{{4b^;XVP3>OyV#}N0Js_u?1hnRLn0dti@=dIToYk-8tE$9)q}2 z)Df0q3M7p>8C*<+<}GfrAu&}2a*@V@AtqMzAKqT6(hk|f$ndkr%-sv(~lsUhY!`GO1V0+C$BUHqi5sy$mP1Fw+dC|`bBTF0q3lko6)Eoxj5qu-&u+A zQfNO9O6j{I}9gL@z`May=B`{8y?_7ZtE1XHjqR?%rU zG@2oPZwos!94SNP!n0F4^x-DBF+yb_{z#?`!-tnSir;Ie8?KEoT$2>?3BA+BpmcFs zCP(VjJ|*r7dWB2G%yS7kyXWEKH5Qs&CGg{3Mgr7uX5wfHl%NBpUZnoD4U<578-)*C zwI=aS+fkvjmT!8aAAhRJU=Np?3DIpFPT`WI`>X*_it_d{|VO;Wy&ZoN?-ogBGnr9Z{DCH=&{6Cm>BWT-$*e0o~#6#$*4Tu;;p% zRa`El8tkYM`!XmbinlIBvn`i*`VCZcBUBe@a~NuAHtlAiQc!`={Xjy&9hik@NAle9 zU~J-|4S8C^l_V9HIOgd~p%&6SaM0KX?k1`bVIWERQ=;uH{sago`V|u2WT+n>aS_>Y z#daYVNhO1qNquQzPl1cVmt4v1G-xMe4vV$>r$}CttY}WphAM}h7o?MlbpqI{FufwR zLAO>h2RASnoRu&mvjfZ(YP%|Qb?kRHSk-cZl+*@6<+rgN?aIKD{BdIxryI+9x;Sj> zrcemtUJN%{1=HB>37>z=0zbcs<1I!?L>MIGQLY9Whg4Xm&?uvS%hZuG1M*VM__E2L z6O!f!!?zHMbm=bNuBqw+BLx5KE*(H4>1cQ|>?e5j5amf$l2llXB8#%PYebsOjW7nE ztBFf4vq^c^B`$9FcvR(#W@VB@W}Ge8JaQX;l?Gf)Q6XmN_ZM(k2!1EcX`RCS$sNi- zqlr>GaDGzPpbf4y)MK|cWHo-m%86t!ACSiK))rh#?efGeRFo56`{SZhiTL5=Wov3I zg*65GjP8v11wRi)l@6~2V=AXQF3NxwS?Lt9_&qMVlZa~G#W4t#^ioQs+Yg&}0F0w@ z#Kv1j#JM|l#+sWf`w>TZ1gp40kBLH>$i{CPKU;C=R^>#lJkt(2arluvs+_Z6^CKlP z4P=y)BuC^4eI30hLi1ElXv(rBNTwHkg#yMz+JS^4DDZ4@U~ojB5-+t6l|;7M`9#Mf zqCpWYnkFOkV{Oz34Vns%XPG_}F&Q`TC|$Wz0G5;}Ew>mn65d#|H84W@VuPT8qE?`z z#atj^&=g*_J%d0ejEazoTg)2_W+kuyJhEEGwDt>Xj8|}Ekv@RX5qn~dyO4a1lyORd@VwMu!bAfeptRnJn6&pdT z1hj_fN?@lP6`pksjH>Y#TH|&&C{fOl5V7ObAu8|U+GoR)J|w0$V>*7s;-_vuVzZ?~ zq&{bc17o9#!@b4CEAaRnkl|O)8)*GRUWzUb4Jy13tBMLjI*qMy{HvHkezC#G@Z>HS zDZiy5*I-hSL4_1s8lZ+LaX=TRV)vbscXJw|b|E@cIee%Mh=eb0-s<45{OtlGXp&Bl zHD+uouSf;m>*_}$ODA@8nIvPJYsQsDWK_j=d0gz}Bor)Eivy9}#ipCN2*Ama`~d?b zn|=+$Q`bHy@tdI#vBLal&{gq^@ml=27BBR1kYo~7cZ#?vJlYFG26P89!iYsE3^w97 z=cMYu!QhM<77Hi~(#V9-2=h&B8%w$a70JL2_29=b0-=0x2t=W)1KQkZsTj(iT*tJi zh|b<%$Z>_{sM{DTQT1}I+>DPmtb2UXjbn5N6(6*M9-wYRW;qUf5j4A`s*(R?Db?*m zS}N!&J0}#W(9=09ISL@XF1irMZj_q2$@F{pfi!+F(d{vki3sJw-wPQyNmIm4!hXlPrkmW z5xIOiwp15dY~hx_2yX5^ckv9EM>A$6dLRiAP!x7!-J(cPnPY}YgZ?5;Pekz>f1F2o zX2n2|bdekSjHO#T{uRX?KH8hZrIJ#_LK-8jA=BNa`X)e#yKpl${Ft&=N-C5gX$FQ& z7G2YsE9#w-gR}8&NiP#5iwUbL=iz6_DQRPkw8J$StKolA!kKdRz z2*%gXaS6BuB+zaGPdNu{1*Zv;_w>Th6q)iU<&i<716o*Grrn$_ESXzM6Sg1wYJ{W`=Jr1_@NwkXev{*`s|_Tx{qR0nkxq7iH@ea2xxw)F0gF_ zYJr=q{HbM4H&wJm7 zkaDJb1|w-)E`X=ZWEYua^H7=9H$)^EnaM6@1Sf*5Dvl*!eC0>+1NbR?>1$sYeiQ%x z>#z^s_XV@Mr5mA8$@unK`@Hws>$KO-@F+ddH+Y{G;l?q@M+kQ*oO={41Gkch_z{X` zjFPV(jUhRpD6{PS*^Elwjb0)>hf>xEa-ZJ0v|s=kXK$)a)lcR*ta=H!@3aITf}xED zqn5h89pafpy(ByLrRMbTuAVjd{Pp1wMU5RLvS2m;M>r87y7gs-3fP}~$xxSxQdf=h zK_Mn5*bhf{xG4JDO7wOvc`0R3T^mz)06N7reuO{N4%gDRKrn`IvW&{HJv&OR)1jBc ziPC}>D5lw0Z_E#;?`G^YbRUuidzekkAT3vAoG^WcLbw%PABGC8d$**;fla1t`wruyDQp*s^JMNyaXk)%j1BwIZ%ke zQ(Cae4q7E2mH~niH$bzh-X>_Qpz7T#2<#9MalJl>im@cuEwYafsx4rhJHW`f^x-I?&a7$ zj#&2>Sber2W*v#cH8VnU&++f>5L7Z9({!D`*pqO2Rh<3%;`j8QDUaf-m@6sB#NxEiIML zXfZ5vj)w7bEmZzAdZsrK%zw5;kh-dPf4VciU6`s%mE4l`P$!(46cGDvfK|`dvs1;@ z){}^_5$rU+Jle-}<0e9^s7{6zUG0oe?*xA9a-_5$akz!l!J8887KoKF!m3S*k&_!w zUyvkl`U49xZ16Y{xf_3e8v@?)z^kKlF!x?QyW~o`20^?`qm#Cmz2zPKo_2?IC#0 z7S=dLnLGf=YQda2c0F3o5K&qV4o84oz(ny|KHPN*16+=2dB2w@d>}G<79_AL`{)*4 ziiDfe72PSE@SB?kmUFY1!kapZ(Ys((i+PI{B)eD(~a-T*o>ta%I)2ls82M5 z^w4Mca$?5o(^)OD#wm%zxJ`aXE6Po@FgroFFLSgLbk5C*HeI|#!hWMm*hKQQ@V+Id zV@PC=(;a8kDZGYkyKZDq4(pdbm%$_^cGnP*xok+1JE%f)5iL5|B33*8B&r=;Vh|V* zq6L;N$&YuCH26&RXLL`LwXaJ@A~H6Ka6@knnXYJ@I++HRNq`Gh+TH=KJWZ46SQKVc zRh+$tC8iVjFg-;pGzCq=YP4XZAg?+0n^vQee-|#06W}H<7W!wh_RzRoz%@}81B<5A zm@{p%RkXrGIp}rnNU$xU7r9}{Tg2*uO)>av>NT(y&eLdpf>3I8QkTwfvP+=ABgmHIJml5!uB6Z)$3`CW_YN56QLFfTfyg5hkzn?`6&pH2B*u(crD+d$h)rKveOBki9(*tsRQ zpNs<_E=D*5YRX0CBCQJ6n>>o{0P>}KL8+&|C zta+WANXPCMlqSd~ce&2g@pD&58?Vl>@8+2qTXAMN9OtaVe*mR6ch z_u#s+@)k<72!;h@**bEXD#etF5x2ejMk4vkm}wyR{PYR_0JiXVku)!N30P9Glcj=P z?9!9_XYHCxk$LY{3>gIEdS&jxjm5lT=u9@pW^F`^D}Yv{14k7)dI)s{FANQl142lG z4K620(f2doYy^C3%KnslxK;$3#11m;+hl%{+Dc6k8k@>!ogW-!;YQ-5Sg>P^s!u#b zYSc}=`R;(<$_ZHWM2B~ek$qM`25ufOn!??9D4Ni~Jtq7imtba!GEhNT zk!}RAqTm2`e76jx>T%s2&uqp(mvlIJTt zDKX)b>=NVAH``0KbAeIXTCI$a8pf>%vCtYEn0Hrr%dVl`?dAaQGD4@IO1dZ(Q)+aLm4hrw%r>zb z3g24cq4Ev8Xk`nJFp^ZPyzg$Yw2ZHThU1upWu9k5Dqd)D%iTlKOzo9ujQs8JW7JhO zCD!F?Hc6@4C{`zc)>h4?Bajo~HJ-;!;(;bY9gd9vE9)CGDodH^CAVweN_ff*qr<32 zWzEN>JjocW45XhimLg)aeOS0$iioFCVG_Q87{b{kqFKHC1*UN++1vry8T4)%`u5#_ z_}eQMOFLd6+ouRm^gn#L5wsA)SQMrdt82#800p%Nn;_V5lhECZh$t93A%A7O&ebr- z&qQkqHs0%JW_u1-!(26bCT)d{u(q~tS@f~e=^;hSa2cah!JI5CN+2F)my3ok;YiFRDfA zdx_$#`L{c`edyAF*hjbg)_#W}AZ2}xjDU3$7l#9Mwg&R`bA*?F36kq9Y>cEq6iIgv zN`h}0Ceq+>bCDN$D=5m<9`riiLG4kY5wGM1!cAD@$*vkr7~6XI!j}nm1I!vSJSxO% z*h5)$*tO3A^{Y;U!l>4A886VoXf66AJPzQ53Cq+2L&`Vts)vvbHxP-A{?f?^>MRgL z#6x$}rLu?g>|dW#GOy7oh%e(b0=q<<@N5HiD;iXK`YEntL7^^iu=42$#m`QBcQ zm=Q8;j592Yn0z=C3)YzuZa^z#P~j-i`pi{L#N4CmkX#fwC38E^Pq^cn+}{fz=v<&{ z5b3TwrdVV0AnO4;=fCOGb8(5BN3P$nwT*Agtfp&lN2DRh1%OJ_`iZDfwugh&HquY96!`|@P(`QE z{q{9Tl0zKxD4D}bSX&xi+tPz#ll)J?w6Z$YaxAMHdkHHz^^JBo1E2wdhqj<9rdu2qG+GF@p9ck*D-k;s z<;*a@UE&IXaR{USgVPftwhW5Xxm+pC0N6`AKGMi^yDV4S`+0JFSekl3^mKy%aeflh zm`4Vm!R#1`ZHSVkLNW?TYS73tj9x~k2kA($tVY61mP-i(gs|>d@AKZm07bm>@gB~Y zTj1*N?x8uA4|0y?Qp_?0jSyf1%(9mA&EX*eSy<=c*gMJ$Q#H&Gn@uk7GV6t-VK`Ov zn>PHfO^L$Iph4-{Jjpk4GWezE0gft@e8|YFDV!%m~ zg5))Yq;keWu}8sG3=A&Nqc&x=p-X0kNj1~K7~8PP=K2#a@|xgekr@OI71mW*paX=K z$uo1exxZl0J-IA})Q%^YDmi4(JSUSaph#S?IL)W2L6GwsSAE2FWor+mVuk*d6b4dP z>|wJ90DXpvs6o&6i4fQo`Z0=}v*A+U+$`31lezV$D2^AkDX3k3%~K+6>qwHnnWC{a zEoL7ORjo0KLT1}Tg8yMJ!s^ar3&Cr<+#Z$12fnQ!T*Aa=D3PGcG8w9_G974&NVFv6k9 zaQ97S2yYo8De*XKO%Fn%QaDpAWSV%E$YuSOn|ge!=NZzaTMy20yHv`(ozP?p+a%P>k)8{M^b5~@S z4o|=RThTGyw9nDW%LqZ;r=jMVVcst|L42VEt0Z2P>q~PY+E$+4vwH`p7U<1RPzK6y zDy6{Cob3%4pHy!^2j$Ecorw+LJKt1F7TJT7+N0MW7EHf}Bz;t7<;fFMPiY3zZ=r!J znN_P>+$D?akhHH%axzeDv~tK}L>Io9_}`X%Ou9sj9iWnl;Wx@d64s!wj4>wuB$in5ib%C~Fpw&y(iWLWdBw<{@9dSr zSuV}%Ja|JTq>f@L=^4c_koCsqZ$P^T-<`lgf4iT!S*huaqEGC-SU_!RsxIgSF-Zdz zSFNhzr@0D4%Qh#)%7*LlP%-VH`9uJiYZ^^E^UufOeI9v>_I z(CL*ElxRjQ@pz*b!&ZR8yV^E&2%?poh zM)-!bL@2`Gh2mY-Q4kTp=UtJ9?FK1iizrSxMi9evxQw_3(T5~2MbYx@!Nmz>h|viY zxm>w33};KhGlWXp_c@^0arPRA1Q}F`rGQ}d(ZYDts4F~jFq@ekw#AFX$_|})dZeh7 z8LdI7(m2x^sRbcM=n8c2Yly@|m=9B_v2Pf!+;U1leN11O(;5dq!QTccx~GN`9hI!K ztreb3qvk+cU+)`5Rw)xo#EK@p&El*wM)8`B@Y5EqyG>K+rtxU;FzXR=T3SSwY|WoT znd;pJR8+oAn#R)g{!OB`qJo|wl1)IZHMPR<%Cfm@%YZp(+XWZ?=HfSP3O9hUrZ}g~ z{TaV#Udug1^`-0OHO1uOVWP=o^^w;G-5>;zMu75HClqN40%9%-ZMli~78wF=TZixn zC!aw0~WtK)NyqsC^M#8s(&`eq9Cpw}CxM~vufb5mAOfGnWIXG!+p z_sCKQvSfm58#d7e8$GT#XnRttuv3npul7$fi>+E!!tn-eiCC-!TuSPg-@S{o%-<+8 z<(q0j=b3`2($<`GtA~2wVgwdv$F!s$OJw3am*tGj3QKc#g1mH`#UUV;~0Or6I24Pvxi*kJ%P%}G3e@@z0-@E;?ZG(%T72hDMh{IBrWgh zKaVg0&7u{A2ATLbS%D6fH*ev_)N%MWwCl1D#mHqY+g?q&O_FjP?j2vfYXQdg>4u1n zbCy%DkQlkrKDjX~`J4SW(Km+OR=c>$HW9DaK|+KkYmqExdFiu2 z6udxm;Fz)0B?S|7%;`-riu{ejsGjw#sLJ_liC8Zgj`oglBSX_3SdE3YIxpMp{lN^i zY0O2>Xb*txrL`bCCH$!POp~6#EY@w!%Vrb03igh(I$#YZ-bdcPr z%U$V&JJfGWCi#fV0b}PCWTeali~wpp<7x|y2_TD&s&@~#M|MbWQ;84m@!`9Jeb?=G zxVq4D<@zai+4vH!=`1EuD_iXv;qTrJ>$BzT+SfgIrbK@-iNq=wD>P(~ePzj}e49k8lhRoX*Da1%4CT!;!wnlQZTH~xZ3u3O`B^BVHyqw z$&9SVOd2wLBV{oV9k_#l15#*DaXqFr31ra>ee15LR$Qaw^PSHtKli*OfrWSPWxuwT=LRk`v{6 zZ}iyNCa;I_u}2#F)OG|L?Qs;T9aH&i^97tja-!O8+ostW&4^t6QGGkhIfk$^%Ktt!}sKM@+cjxi!KvE6^4+B!r`hS`hBw%a72EE z*+k*>jlDPVKT6Mu@dt(dbTLI2?AiIROvhw`cG*~*NW@q{UdpE$QxqR#yt6o&OM^$RUJ$<@s9rEy&E|_=V zh9I#fb{}H|(cMl-7{eTJ8sCf(A@O>3Yl!PFj}*Pz2u|(QM(^Cqun16)1K^D1;4%c} zGc!ynFF1_AXlHcNASy@D!C`(1hXG)CU`fW=$EVFOf6P;5ZuMh|va>_kbMvhnVQGqq2LiW_d}Id~TGb*LM^TO5 zg0*;dp|~NxwFWWbex?nxY)fZlq7yK}hErBkQzb5$)viL-a>odM*Pvn)ek*H`M8E|) zbl($5d6*7Cja?>IJ!V$jCmA6U$?>pN z#45pMz4&AKxW<-o4TN_U?-LySzHJ*Na3S(r;JBz`y_x`ui47|jGy>aBTXLaHcmYcN z!p7E3ckK1CWut);Tu8sQ15c{6;O{8NAEdKu9CnkchD9#D{l?s^ zFtiezO+Ke-b4*%`s4o3Kl)=Wbl0CgAt4G?~LDNQz>s*na#X%e2Y7^KAA`Yp}Nj?HM z0rHG;7+Je3gurdn2&bGc~lKG#37Jnm|oZyOc zab={q=>3-DvMKV8(H1=4*BQ<=y*OMcUDvfqSK+F}qLBRcEUHxFKLgE{zT5161~!Gp z;qz?WHB{;d%~XfXk~R)ZM4ieUoE*gq*{ipiB)Gk zZA%?z_eQ<*ZPcQ2yimc}1uB;|St?~L8E7wATH=|r69c?n5sbhRNx z6l!nC;@q2&mtw&w1R3!fi}^p2C0h^EF5kGa7^k6h7HA5g!>F>xuw+w)c|_YG@NCN= zgfki0$L>aQg~6N>%I=8Wvya;aac3@DppK+b;>0OY`J*WHtz1zubbX{%4Nxeu&JT`} z2Dp3|2vy;d6ZLCC%LcI3AncqS|Mjn!n{=d#w|ql*B&Km@I)8n6v5zUzd>M4c6S_+- zbWx^yGE5UQnhuP*$|v0~abvO%@?1?f46PKbdjTS&l$Tj$q}r4W(qd(o)Jo2cy_E}g zv^xq(Qe{thsy|cd%41ga2BGpKQ_w7S3Px|HlA8ig2tALQ5eEvXvP+wk(QlkJi6&<> z-&wPDC><~xIE9$X*6u;YYOlj=PD6dF6s&dz$Ol%EJbMyLFjTn5Yx%{Vwb_<~rUaqx zOOiZVl%C(bsRJ;Ur2C8%7(`mj&vSnD^+|NEjmaZM8sRlN-Ue96C2H`xexSs@glaZZ z^*f6e76;aMDb!*QI7iBey%T#4due8i77g*D`uVeCU{abwCJ4grcj77zOWQ?8JP0j^ z2YU#|6oM&7&ze(!hqOfSqzIUOb-nt0f46wJT`kX7kL&Bj*Qcb-m)DECySY-&79Vdn z4{Q4kEcBeQdAYq3DH1;9R=I;uP9&w%M~;hxImqmO!XfR>3gR)5Y0O)FVcC6*;lk7_Q;HM)UE09X7qtn9qQ)5gletX}Nl$lD~@EEmxvqGxp& zEJKdz?yd8R=0jC>VLUXadGvR?MD!>v(0JqWwfrYDLD!{!T|Ya^ZDHI`+UB~QNJhh^ z5AM}riCz&Sk#Y5f8T09!6}NXTaJ*!w29QG>FvOhg*uhvC@@cMrsjf-+e=BI|(( zm*dhs?9~P2XAFy$!6_0rbFyIC^$e5^S>i15I|qbA#{;=422TtU{EY=HM8(7fMRVM1 zpp2_U{V40j6^u2zyp0)KGqmVdFT#BmZ2W!w)?Q-sh@nc9dL(FZ8^3j4fw-iLBwr@B z9KZ%CD$TZNzEq-c99_l5Y*Mk(UT7>L&-ur1`Ub_N@_?@5@))&m!I_o_^sljG-5l2>^77ojR$8dF9WZhb`H(`~21liYvXb5yfTKUmg;@B+{~epR!2Iwbvyw z@^L%J_J#rghc;}8hW1`+A4KghVY)W0VwrIy_zH&&%jRl#MXwbN(MB!(Jy6?5bmiKr zveB1LPOeG*!fl6?i3NE(H0Ptk$O%!*!z;-2c&Z|1+%)VU43k~PkxkccpvP=$xv=z& z4<4)vohQb24dB_yY-}=01GIP+*iFED-=XM&)s8A`Be(Wk3(A9&d%=*4X2($`A%(EK z{tkl70&~CfD6*Bhvt_uMVYSm?Vm|Rrms_P@2h6OEV|y8vhQICd7=yeG*m!?(kb7Y0 zC{b*M-Ncf9ET3B|A?;yTS_F3#*y zmYQ6+N*#Kf+a^R1n+5oMQ-Dc9hHQPKE(Sqfgm8F!(`Pz zOv}+jGA!*hIk*szGOaBNEyE2sRdH+-13BXxyMOtuyCojwO<#KM4T1@UIgzTncr1XjNTN=J`r{}9xTl9 z>EGxc|10=c)DMklQ#>Y#Hz`QSH_{7Q#F#d;c1C&)%NxycNf*0KiN@W;oEb8Ix*6T<^~ zgf%+jTpcrLm+mx&qGo2wPG|o++*pNX1T}ZcZxzn1nKm7mc*(k)oRh|2qvpyfm+wRe z&l8jA>#Q6-%f}9g#3(|FG%2?R$*RrO0)h8!dv$;lH*S4&I9i!Wb+B}x9M4c! zb+Ftxc8v(FSO?TCoUmwa2hi^>Q~z3DtDSZAU-Ze^dwD7ohG|~q+A6h#j9H3H)r`JS zlLCvjD1=m3R(23c2Sb*wL+8%wxCt{jc&8qX40jJ+uS}4Obf)}q1~l}lt^I9p<+zSB$U2?l7)!Khc^V&3SqxMS~ke&W(PeW znB}=&ZHPf{^KxjKD+~F!xmSv;;*XiPv7#BNivFCx)!A?cHp>HzdkeE5s%-))HdZY2 zdvc~rg?|01MF_HmouN(5danlQnt6L(-2?FNb(tv2yjGCi+p^5fR<{w_L<9u86K_HM zTR^3gjT1wQfT9zQho#yke!G7>P81t<(c`)Fo1@W|K;y$%3l}9us)g0{;i*;5j1VU> zNwgV)c&eFtD7=Tsft|gccNx2UkhQ7w35K%G*2e^bR^#K`!nu(r9q1rcLL=tF35eDo z8sEd0x&tuIC~#UdN2LnrQ;W`eoR?vCm%QTe(>63@QQegz&Vjk63_GI0=#f^Zp;;<> zZ1Jv0h7_f%LQoIWITk0=i*le`2Wtzgi@{yA>}H%)g2Nhi&<=N18WFRwM_j}}IP+L> zE_mDV*H*(oIiyW=$77C18P;0>*v@=Rz{*~3GgPadjpXj+2$ zoruiapl^7w-fSBl$*gVW^fFDIgP;9fv7%08&)7-cfmt%ejfmKPP0xv2hNpU)Qa2aE zFuIrln4;cX?EAZmT8F8L=PaxP<^vN}z_##?tWCfv{p@i6_?4cK($$ZqrRa?8%`4rl zD?wIX!&x^tUE?AW-2nb-kJT&%c$@AXq!CiL1>}k__Aq)|c!XTziX2nrCyOM+vfJZM zn?kX0><8B%twv?lu=3cmb3QQzi7tqY@c3j4bM>U;m5EgGkOK&186rnA zlwN@)x{Lx7o?;J3bz?l=Ij`-;7(~nhbauLih*Xsz*-_yFzQfG9n`zC}^#W$h;72*_ zxvo_VL04)-&)AmcPTN`s`E4N#y; z9%`0vCbArtv|N@<6zm=6svh4_u#DXklml6rbgb_tAzBVE4+w}L#hM<$$SeY|1<t{8!7CjkTrK-nXMLWp74SytYotiK5kG=I3#8O7*@Bw}VOSxmk%`n!?o3Z*dy0LS zzgf;*rXgO+X^IlVLNI^4d~;xkD?;0hUA$Qr+btJWW`ew0d>S)-b~olsYca@Gifw@| z|HCa3Cr~V?DUuc2lIP-clgE`MBFi9r;Avbzp{R4gUy@xhNAKuX>o|kCSHaXV@NOW> zN|*@Sofd?&R2@?)9`qIWDIq3FRctNetsiYfSAlc%xygeiVo(KFU8VcQNDAa3-HPBe z+~N*O=>u&=*`9^xu(S0(QZcE10$mo z7+JW~n}f^=lI)w+sh(7KXGqBwP9ELrK_dEcuDS@ zzE;HBXmuP8I7?sdDFk;{F1P5!A&2XaND=a4$!goiKnAif6460X}LbVgohb|&BJ*FHTQ8=AF33haYpY4yAOTjt-a)142^1_)|K;z8G0dFn`KPPYL;0U={s$4ZVgh;Te z@RZDkh!lkjtUdapn5qTnE(bah%=I<%$U`l!*~dCk2sSKGSp~y*n%JtroZAlZ3Gnw+ z}X?j2ma4qCvFVSV!8%cjK>xPAcVu($6uq7Q8RaG+wtW)$pN+8N^S}c+*pH)23jA#z4Km zoSezL&Lw-8p1#yF9~taIA&si;CR33K!fx!8)r1o9tx4U zI{L;ObZ9?5D`keW;gwy`sI(ZYp)i;samfVfgVUn?nq4AWkzI_OLJ&#K0K4Pt4L`tp zj0Xo617g^W49^@dhD@{~;(vOC{&6kYQ**&W{|v@3Jkki*!gC7vVJfS&Z$Ot#9Yb3YONV zVZRamv2a)%xxsqNeut;vxS3i^c9kH`?QRn47Lw{yYNQVAo5+MqHUY=ubn&@XD=lc3 zd$!OP$@3_aleZ z4_cM|?Scy_s!+1)-%90%K`%3Cnc1s>nb4G~#{@H(?F2u74{r#*=-gkuF3`6)R@ ztSm0{V=%lrKRPM>7)uArmJMMHjH;G~O1elOOl)4$HjizoP4SjUaLI$72zIW6B|S(* zD2uL0WkX!rpwrz#_qbF&n0bb|G8i33ZtX5HHLKDlJ z#Obl2>fqrzI0lfhXqDut3FC1&s0|c%6OlEoD|KJ*dm)0Ew?*4@-$c4}rf2HAJf&B0 zrLF@~m295-*y`Kj;$-d8oX-e3yGM6{sKd_w6LNy9d62sfnyxOc(6K=}xhTmHQJQTOn z`S-AaL2ccyXtIW?oeCqzB$hl&B$#@bpy|5Q@1pk^qSV!b8_l4K3PYxmftDRNK#8@Z zDF54llQ~R4nl3D;!XR613mQ{X4)jx8;c*vt8ZBlA?y8f8Dt@X!N9l*0-oq3po zi9Fo5&*A0N0ZX^QepMz&JtrQgi}UuHqH`Su^ysaDF1+2 zcn@RKhE1T0>PAPllME<$PG4!G8AWju{U9~~iD9ye-wmWlU%mUCS?0 z4Ny`_odo96t}N~#cVo5VjC9v2k)vTllF!tOIIJV$!VNJRhHVU#ABr;+vWX3|ih!kO z?Wld1=L*ys#F=IAjVqaPG0{6R!=jQawg=4zWp$Tl%3c{D?Y}05a;U|Ig&UIs70~0m zMzIEY-C5r#)&L@9L>y)Vh-~Iuz&G>e0F+c0mpS=>em88+g#1+Ia8Q&#hJzv1Jfe2? z8EyPqP%4u_v6y2cRkz7DHI_D~#dicyKv+g1KFBOd`z90hln0vV9u_ak4in{;jnU7` zu4k0?uK49zq2Kn37%meabGylAb#U7%14yk2hYov~zOU}pDY4EnkJ~~*sTW1p0wBX( zbs|_ql}4d;Y3;9=13P?qX{h6;VooU4%^gRacdjpK$RhM8ZT8=s?OwdneJJTp0>$i! zp(|pyKxh(D)eVd=`<~Ys(Xs5ZEh1|$G6RJ`h3^tLSA2WH7t6n^1?5yEnKCdsyf5DZ z6w&kRFO8>UPJwoUs87+`1*UT^i6!6OQ4!&!{_c#+Cl^i8p8j2=dqItNFC%bpbkTP- z>#>J2u3nwy^YvvkQrB&l4r|`nN}Z|PA zUU!xwT#F#LKzyIjt{hz|MBNZ9LMEe*Omy-rf;o0aP_bBTL0toSkP*U6VuL8Bg;hcC ziu7l^W`)F>)1qVnnI}E=9gAW;#>&CR%MG>Kfs}#GY!3F;TZj9sCPmE> zOv&G4h_RO`wclf%I*)1tdH{RAf4EPUmnAJ|#CNi6=?ERU%`Ir@jTaShXT~8LlsVWZjiaUihv1$*R3oi|v!u5U) z6aB4j1C+vEVo5zxsDd^zS7`cMVA&&tMN_{VUnuH?*b$07d46t>L&j!_*7J_{zQT^# z28uK(PCPDrBshoLqDa&qr)p2KTtzC-_n7VXH6w?6Nf|3Q%706Me0EQyim#Cf4%fh{ zi^S@gX)+qzDGpz@cmtuHO8F|SmRl`A+`FvtqF9`Ju+SdLKC*{H$S~QQJiL)Zkk-h- z8?<*PZ@Ow6dkl$qCUS2(vzxR{!bvF7wHG~Kz;W3fj zOR|>`m6I<^DiaP6jxLQg&Mua>iT9Mzh$}8v=}~T7w=dk2NyUIapL8H-FK;yZar+ym9TFPrLZW&XF7~m zat~+{O;^XB(Pl3$j3~sgsrd3XvA>i72M;}fI%;|}RC&(FDu{(GVXU;aJ=p_JkniWf zCM8H53-3zv(4dB}_C+E1f}0>XQGPN8h&vO|u*EpfKLIzDKw0QE7rHxbmd2)oq1$T$ zrUeiHGZ+RYjlW$WvG4_f!Pa$2R-Ja~XBJfnMUH^K;U<^F@ZxHkoj4vdqwf%7 zQ(}SGMwhe5`RkJ$VH8PWeBVV$ZSN3T=Lcw&)1WdIoOzEUJ(YQaps^Hl9uzNy2YM4M z?vhOsqS09ksvJrjZL-owslHHMrN*6G^pq9WDB6VP&mUE!I)Jfp?OGkIx7qQ!ay;;y zi7rWM&94En)!;SiI3w5%h|58vq-sI%3$f87^aZ+{l=`sOhriarhc*%GU-sJWO@}I1 z3UYVOHp*^i)`H59^&p)L!OO*&&Hb^4pK0ND|XKg{W&savAGe zVw+1tIsuzVqP>WFW=@-gX5`hjB1T6sh9X)+IdwDsQ{SFsxZtGxc2~PNw5tk-YXM@0 z-TC$$jHn(a{WC#?#+tvVzwjZ|Nx>6!;g5`cDeg_Ui<)3`lU6Bf^oc#y>b zJZF6pXgsk!C;AeiVT$dxl|`$NRc;yk!Wbl8Dn3pw5&txKMM2kNH2fI~L;`fhQmyH% zjV9elq)uz(k7oaJBK3(uVL73oshp{DZD=!9>6uQu8!lV|7BIwu7OyO4;|W^`?8+#u zwweaX>N4sL>8A%8<|$z6pcuRsK}tbA7LGdYTe{*r5bL@YF~ERhiR;=ORvch%aMFaB z!pm;GL_6sdY$o#K`JPG99Dt<{DOtB^rrC}wB9gjWm@`SNDHof&XN?D#cx;Hsh{+$8 z0$DefNAq%suB0@Ffy_|uZka(%=_h#fMUUpBbw@{E51l%wi-9Fd==Jus>SC^_Nj~vr z0+8l`=ti({x(XwQphipRI$tR#qmFzlSF%&4*>4$r+r(sE!o7tlO7w)lk(!H!UxqG> zX*0E_yaH+rw6g+-xX5r`ijS$Ku>>xECSJ&}!Q@R8WUrcYzDpMgO4VFlod5OeAYe0I;K*QT4iA=ucCJ}aKDI7}!s!-x8-^SAH_QG)ei_w%b z%H?iRDv@1Pj(rCqS< z>yN=XaAi_E`F2n%44`y|?im*Aip6YRGJR?)az^)pbsOE3t8$Z3SldcnQ|0K?)MeQ* zR6CU*33jV%3@rhCAT||ht@le(<8*Dgthz#*1CP2)pxrG+M(<=tEuLl;g6v9$JN8-D zt*A_#5%x@#jh(mLx@=7%QLwzNvSe~Hs+Vt-(dhTq{BBX`4}Z}sH{+|ski_u zGIXkpV58n8Qbs)W`yOIPD=ZIM5fS-tgtJV8AfX{h;?#qgOrdNVh z-JZQIw79vLZ@xeIkWEvyu7SYRa~Yj-*8q3kWV@8~I*QPSy_KK0Tm zQ&((Orw|ATOhhh=VM{=p=3B#!vKHLL)SoRzngMtkUaFiHlm#kmNC#*L6HhL;SsKW< zJtUp(a62cs28S2hO`0nrzC`;QA!ZC@oLNuukAW~t&C{YZ6!@&ZRmx@IzcH!d@Lemw z_85Pg7|weI*|9x-a=d%8dy=g)O`DutSOy}t9NA5&1Lv9Im}UeArRDz4chO6eiEyzZ z*aQr$MKj7}FL%{JS|xfp8GROWypsaHtim4d)Ky}&yXH@pQmyUn71ESy`T}c(8YAQ3 zpF@z&BD`z}vD@~x_KZ`23t!uT9i&7_CRYcT?u4_<;n7jp#-EX~L*dXz!-EMuUJWX7 zsAA@ZaGh%yMtI_Y*i{*`s>j{AloQqvs4XAVevpkKrvb)W`=#v0bb&dzZn&s<@9=Ez z@`$VIBM6&IVW?O`GEACDbT8K{YG=E4{8|qn*=2vbN;Kolfk66i-#e9=u)uQiUgXMG zR35BxD6<(FEuR*__C-X~7y}2$x=2+EXtuv~gHc4dii*3By6DacE^>rz9!6O_l<;5I zOchVd>~a?`K`Hm5$Q~MI)C!jK>XAE!rT2FQQl7btjUh}z;lYw;b7TFe+T6vkj|dC8 z@Ae#;bffXX zI*_`~R=ugugt^0XLlr z>$geDohGxfd=LvRgCbayLNjD-f1_YDI+j@Y1Y6@ltnZq`(lkrTNZ&o0UaTD(hzBfqJETBO}0BhGQAvgBT-u*6=NCb^Sjx=AgEyq+=>&5xd;xLv!l}*q1o<-4RN=n z*ZI>8cNqBJy3DR76L8WsT?$=tB9|^*x6Inw-?|o5!wZ>TP^=icFy`Cxc!!rZi}|lb zit!2EPYYrHN{Z!uFe5S3ciSmlc24_SI7aOixP)w zZ5|e%U+k^!?iSa2n|wpX%fncG;C-6@#W+4diFv8M9C-1{l(4g_51;~ zqjhCx^91z#$J>YNPZz7>`R%ezKUwZBKCIS@qt*5IXN$YVe6zUm90@u2>H6+*bNj<2 z$}_;z_2PcMUYsp9E4{Vug)(@3!dph@}bA!?B8zgSDPNyA*bur^0^7i3&es}v10lmEc zxSroEG#zIw9F1eNfcZ60O?ta#In+-rqc^Ju*Am-=YNdJZ^V^Th`CYX5@s6I{#g}q< z`q^XrxL7|OELmut?1SXfT562Jc|Bj=+%49dD7c>@=Aq2z&b0<<8`|D#dHuLvvu0bo z^TmT_h{i8(G?ktdlX~z|Ca4=1w|933pYI=@9OtXAO5eWzcy*MR)t$)B5Ux7*e5{8c7Fbj9S6|;< zUETWX^c_xXgt9JQ)?TKS1_Ki%JT6~?u{u5mAbx6Fl46bcw7CAB-`DZ?j+GuDEn9xR zyj|X$Jg(#G)#9OkEEhjLMEZjxtH@XD)#E)GK@C!wDc7sz0|U8OcTj!YKJ2YNf4+Ti zz#Gav)ArpcLABJcbxBugfWQ!4{J2^_@pB!7X|r?v_m8*h#m(E>hflZ5!G4}S_WOGl z8|iHEVMJ+dn8t+m<^1-Jd0Igpu0I|8bPqkxX`9`#B7Ax*^XcQ})64bh^V{`m`LRhq z-1x`oYIWBnS7PpyS(cu+1iJr;f>w{4yQf`Emo2ZP|1+Hn&%ux9Pwy9&uc_yn(~;(% zESt3RhxvM##^^qNUUXeiYabEN;&CaCRN@6jZf1Wdr=kVwT+8(nJ8$Wm0V=& z)oacTNy+m&j<5QuvZ)ZfM{CTvX5dkXBE{1X-$y<`hnt=EoZnjBvw8V-xUsXJG?m}( zE0qY@d$YQMd$5Ujo@xQHo6#+`xg8vB1rfs8SHJxF>MFUx*$2`FRPl(REAW#c%^X0d zIg#vADaJ?MPUv9XblMsh=eP%V^Lw~cwY>6>&VP`4xOvH7pFiH;Gh{+jdFOe!9>d~i z;^D?B*>g%}ct`Vpc(O#XV@-O=4~e_}{tc%LbNub?>P|F}S${c)Gt*JI5AhON=Xm&d z3+KK*S$4^Lx9jW2yZO4Ep1L_p?vtglrDaP|MVq5dZ>S5^+VOe0+{`~fLo1pkw+S}L z%OBuhZ+1S=%!>NOEp~W{07UnlxneIR<$Pc;UL0=zs}&55Q4~zU#iLd9Ifa$9&fS2>fJ7s{NtbRQFke|_p0Dg$S z{p`dsXbb9?Qf@iY+D*M-acO>~^y~ zk9KM_fe9@{!uY9ZBjdEYSS*93=D=7lez-N3!j`IbLC8<~d9Wn!V`Nq7hntN)uN$&w z3$KBlaU?C)CaJ8OWc8_tFDH)=VwL{uoP}q$7;a|$$RJ>?evFn&+h)S^9wdCRX5w1t z&i(z}?cyf6ncIJ`5iiuX;O?$|qP$?q5g2b~Io)u|CPLoKDEe%1z52K`xcXcV;++p4 zEVFV}_M=Vpd1n{gQqtnbSC9PlwA1-UGW!JGe!aL$r01bggr-N+(&U>+3gj|j4fa&p z&hja8l%fwe<)?Q6oGzsh<5;Iw1)C1R?B%UG^=k33zqp3VE^a*GtNlh=`}+H&_6FR3)s|n!6 zE!@rpN|fcfsb$95n}Y?KunkL0#W-(_=os*3{&^Bzjhu#EJl!v*Auo|Y9@krd&c{z@ zJe{v;2B+RM*f#L;VH&stPx1Nv-P1Jm{Cagi(LeQJJQmUZY2fbT?H$x`8W=5{1|HMC z#oCx`4bQmVJnXoTuOfeQ$GeJ`R6R0MnHDGm&`H08)v>;PbAv5 z9OJg#*@D7=ql?=DJic9y3;p#YylhHEWAG*H%V$F$`kdk;8!&PW03Ea&8OS@G3;V}`|;yqJqDP3KTZzK)fk{v z0rwPabCAZ(7|$D{O}!O30XSVfFy&~XFP8``*PpbZQlfa6z{ggApu3y`Nv>JkxZ-mn zCu46(>P8Irf&?m#8|YxdxEwlo#@%bagO8pPvb-~KTM_XFH8oJ1b@1^yS;uI zXWe7_O4T`roUU(ISR%%d`Q4B6r_Fc9Onrtx_%COdeQh=U3A$%`nok^Bn67S@d-e-u z(>8Q;eTH&v|BBIrvzf8yKHJ~b`NLWa8pe7nE=+V!R`~!neGJ@ zu7A+qkjqZkIX+rTRl-NCki-1e?6{HYZt;U*RFax0XNw;eYvhsqezkZwy0zkae$4VFX#8(;guLH%@nx3J%`tk@5q z^Efdrg(~f@3ZU*cP9DlZW-!b$n?B_x&Flu3nHj!a)x#W=-|b=yI_)?jJz3uMQ&Yqs z@e^Zltx|1-PwwsaXC*B4&-=I1p`Sm#e^@UTvme`!u-;>!g^gNZ#s7Sd4QPGtn|AZU z$&!t6@C8r_v&+rSWC)+lQ89$&+mr_M5Yy}@gPmW`Sp*ADgIvb1T~v_E z_-*Qi;11B+-?5Ka36Gye31%1^bBu~wrzHF?=*8^tU?~l`<@jcSp}Bk=x}Me`hh}VT z5*YChw;z!=+?}9-R8iR$f@QDR+A4PLRN#{ir23glxu*2Fr_xOjQPCCnW@Re1?T{XQ z=i^6?3=SFQVPSlIElQXv*sp7H)KC+GSZgsB8$dOQ-91&z`Hv6=8yVjivf9ti(eA<} z>u0PiF^!S3cZa(}{5G|TBeK7QB;pgmeJvu6z73!(EtJ;SK&dtoRk3Ppk{AG#+v_Km zK_Ces*!oQR4z>)=+)fPU|0v9WY(qW@I6{)?klOnE6vOC!HE67BbO15cY15@)c08U11Z?Np0i1~WK66Uif^^1*x zDPk;5YruXq>Xw{#gZai67FRP9+4YAUswJgS$jshX<&4fs!$DeLfD3v|n0+@BXx}k` zo=r6#Zj#&vty$|}m8IhCC!`@8_7opXx$Bsw%???Uc=8 z{WgO!&9W zG%tzpfaiue#<4AebY=qOubC)^nlW$|M}KoRzVNdTI=DvZy{1{kiDl;z(O@mnrKZ`J zGfLPF9W1F$%2Y0>3@aRQs$kVMV*hWvTo6#wd!BEz63Wk~RuIC$U3NifJit0R?fRYv>z~b-& zG;zJUXQ?t#%(OFL5X*s)1(&9+sBQz>0%w!U?t=5)c7$YLAEq&l*4l2Xzba97fC&c< z7X5Nj!@r@{m&@;$_>|=e^z{A3`$wtFb}^ZnV%L)o(7vsPiJ6mq!%}uxPTZ4d{_LUr z3Zt1PV+E6YZruTv%@b&o4U7vln~PPnu%Mf<(!U_tJ5>6h6W3FowQ1vr`^z|uX6yOw zW>Ni%a@L`DK?;m2N|Fomb6xDw=|OgL4qg2I$>!LOLn#3Ui^I91iuR)%MrZHE(3{oN zs0~gUvTx7c?L43?d5`N+;jQ43%+t6+4xqGh{i!J_jKsMp#eys~+$igTZ#d`OUT;y+7OHLLhx~=c+eDnG6vn=~!?dEtgm!=qzzA*+|dGO`I=oWAzZG&X7z_(OGt|q>&@a3F&xSe8{mi|>ai=w8?FvDxzd5WG&8$b z_HacW?BfbxCE+@+qo$@MqiI}zn=GECVeBgC5CzB_LNN{P6?kHuYR;4Qo0Yr;4j5C z|F~LzUjdMUoV}W;jf1We0)%sSsoT8k9JhFUMDhCVe% z?WXRxTKL3TERXNxzB6*;j;c_l0&)q`WCX(Kym>N)63Yv5;y4GDDNJ!>Z z!MJHG7ZY>_U>L^v=XwjI<_m)Bf-A~iJ2BfnC&_rVSI@1QJx64JvAJH~x;~?<$8M9< zJb6_3*w^$>H4l?(t3!sWIm#5VrM~ChLaEtYOYhyk&P@T%tT!m=8}XSuEY~m^P){ss zsX6-DX)S|I3;rNI#>3MY1vlxYw>YR0dIQQ5AuvDOE`E&3Xb@iB%|AAuF+}_9Xgem( z7xVS?r_=evrv}NXmn{Ieiuq6WtAXbh396)H=yK`mmYbrLYVhHWHvOD6z&JM{5Krkt z=@Z!C^DQ4aNxM!LhAs2zMn6fY;n|RsF?ouw|5YfTBtPjcdNN+^Y0#IA4pqX#W9b& zoh2*6ZkkCJ$)CGV@QT%w%pCYa;c$F=}%K z$6IlB{u$!o8WlU%EfFg=XQS?6U@sF^CDgM#kR%q>MLarQ%nk!;>yXEyD`^ary?1Y5gP!t{xwZ zI0^HJ`#_KeT^MBsd^SIZc|GjQ_Z10;?*BT1zm-n?G?6yJB|B&YQ%wY$~~ zznxgv$K{e;XulZCjVG;dXRG(W%WK#6;094k?9~E){O$beZ~x}|fGhmEK<8Y`ZlP&h zc3reiI5|yNN8B2OQnWbenXP9cd0nhdrGQHJn@c01VX5VP^((HYd~y4gLfOV;qD!Jv zwQ;)Ec11vG1SQSOij(Nw4J*Aw5vB>WbtIaRAl$^}tvZ&akx2ab-3(n!jC{g*QT{VH_XoqQmQsT&Z;-_Un!AlTo zW}j{t$S<`pZV(xCiPv4iVru()5HlO{BwoGySfgx)h?mO4r?q^egx1{v?R-v?lB_ag z?OEDp@J+u@7mM$0<4U2$HC6sm2g=!;QzH3-j>x`T#*G$zKg(66-<(ZFL1M*-kg+Ju zZeo461mAaVuPBGGAg+Y=I`E7L4A{4~5Z3(svH89DNtgf4@L~0tO7MUA(eFCLD$>ed6xq?h|}(dwiI4TpkQFvlLl{#_ZWIv2pf;73nA9$+9{1 z4HVsA)!65)iCDORMgdl%c!5T-KD$^M)b~d?GtYtzhP|>WSR2ZY`7v9Ua?yp4-|QKO zzK|bWETUz@tcT&cGXZ*qdl^I#h;diukIC8@mXH$H!w|4x*~SHC>p-3{ZK0c5p!;dJ zQ)#&*M8wpVk$STlrsTHQ!VLqXVOz`X*MmTFwTV47znl4W*BPFcXz%&EC;cIV?%MY_yKeoe|@0u?%;boe8tSv zbR^(7g=T;sKgM5P7~eTP{Oe!6xFPWy0xljL#mAR;b<3fVl{z4X*nWn7boTBuBVnVs z|FQhMSfGetU@hrJ=_;aGUiH4biw%-;XF9rNUwYoL7j~cs#^xtxd5y^Ff~v^wn!;2e zj@7md^mqQ*TTjpzUmE)1`YaQPrRb9h$w-l z=;s+hBUm;t{)-2jAWQtRORouIqPP%{o%_#R$96veWY#QTPo_9zYEVa?K)3kP&W^2G*t4@ z@uUysm#Xl+Aa|s29knqYrv@iIb~Tmv>a-K8u7dY&sXi+6awEg)LRY}1@wP#q%WwBj zKNK#omF*m-hSop)G#$F?_wsL(sUPK7Ld|9 zLzaiRX)59-{cP(Uduvp8zr;Gyr{o2blt?)eO@PH;eukKdwjapjOkr?XZ7w1Vz4T!V=pCv)1X{0{beH{A)mvYdJhg*oRF3wn_ z$xf2JQ~{Cf2h{{1ZDN5p_jUe3_+4ZQo`*B0r~I|T?Jm}UGlBDisRe9 z(SpSJt`>P{r$D;5sEot(`5QrCVWmVZy<}0xmyv|>g|y~K5-Z2C03uWc8aTEA6*fCX z+aywrycau|jcWax6ANN&EJFshxDXSNLJNIWFRF!Y=KOQ=$hD5bc!fw=A;}{#K$=2} z08ycbtf-IpcqHFujvy5bKS>a@u!j){;pBte5vT}^LE|Hfz%%w$pJjeA$2F%Iy!vGo z+1^U*S{tg1#SJ=?H`ps4+dOl*zslLokKARtU849BwrI=;5FqF(KA(>C;eG z)P#ORnrh%~OYP{srMOYoeoE}Z{ZRGZ>Yv|o4S>!_abmkA?7!TKHDc3zel~|) z-Ipa`)aQFdq5Gnsfr)7yie(xx6UJ~W8$dEt8R~*se^iP7?#WWu_w4r7_mB5-?&Wr< zgbO}&Kkq(;S7f{wsHy%wzwLM2Q^7k3l|8xEsMGkzdonv@m!P|cMfySJFxO*$3zzA$ z5e^HUtofWswv94|`O3)xzWVy%>MGpE8C1!Rg-Z}A^8;9^ntQ%XP3wXF*0askdXOS4 zE=$VWk{f>Rb~#%;;4O}bE>cz&06fT{;+5je1`z&*pj+4jpypjN1HL=w_7^zJH>Xy9 zT^z5LxAc$Y*YU z^|@T3>R2I-es1QqdymG}X6(u@@jftA)#8+fI@4vC^1j-ub+kYiEGXFQohZZ{1<2Z} z^|y=1^O2#oL*6I{i9!vA%V<&l{se!JzpH^S zQRjFSiSE08R2;dFm4L_(gLG z@vA}^k`b?H={|8fzg>TI!PmcapOLS>R@mk|5`KPF7k^;BXkw~m|I1486Xg#qV~chK zTEb3(D!wrCJUpvk#JczcE7i#VjI7P14yK3v&15aYxrcdsp-^x9Yk$sZ{~h-+&CiLW zT=Epq&2+MB+D_9+2Twmj)`i}XTsq#An{{GdG?G|#oZfvnS?{3X+X#5bDK%s&Nxp6r zG_985^nrbWK3{)vL^lHm^3Z~7x9>MHs;GGQ6FFguH{dF^MFVQz8?s_2Y%D4#GChKuXSlm`x%kkFyAScbLoKYQe_%sEOn+ zQj#t4@+COp#$}7&Vt-`2u5THvKeW`3FbL0=%&`>~HQ&TtwaUH+`$2#dwZhf2RlrvbSW>wlwT9Wo?jDeDPqXG#4!@>2Kma`hgiY%S=xR5nF^|QR9t5*wVN}@ zo7vrc{?c)szY)q6{Yy8h>CXJl!^k(@B4&W!h`a^GmoKC3?0_I5aMibIqEfD9b%BbY&xhWz@qur+E%2WI| zy@|$+Sxxcl2dTFlW**tIbQ|_Bkzf_%UmBa1?s@7R6wrUQYKY9Q8`1vzgFL=Naj=

tf?FgNY6vv~4;PGBJx?3pb(eI>@9pUZ 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 z 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 E4@o0tBme*a literal 0 HcmV?d00001 diff --git a/Modules/AzBobbyTables/3.6.0/dependencies/Microsoft.VisualStudio.Validation.dll b/Modules/AzBobbyTables/3.6.0/dependencies/Microsoft.VisualStudio.Validation.dll new file mode 100644 index 0000000000000000000000000000000000000000..40afae8203d659b8d83228a9bfca9e64cd3f3ab4 GIT binary patch literal 45072 zcmd?S33yXgw?DkkNtz}pP3H+pNoj#l+LE@60!nEM6e&}g$EHmRh0>IyEdqiqlL{h} zA_!`Mf}%L%JQYDi5eF0zRH%YD;DGb=3jg0)=VWM$@B7~S-TU18d^eDF_8RuuYp=cb zaQ4|bEttGcXoL`2d@o-X;%QvzGmzmw2en|EIy`F<`wT}DpO%InO&mL`%x$i8IcK_T zbIm2TiVA0yx!7TLRacnHD$Kbf3(a$#c1L<}urXOxJt|L#;gUu)B@RF3DXmF#GzUpm zA*cYoLM^@rS2Mo#_zDrqX<4zG2xLEhngBtMYA2yyMJq^@{`;;fN+f)Op?4%D>Y*cm z66$9aX$LS%5hy4@L4wifX8)(-Toi55Jl-O zx2pt1wyhos9llHP_4^D&bEUf+A=_jTAnJPy z@qCdG1|s4!b+Zr__+|NBiiryK?_q*$Nfg4E((*c8q02FAV1m>sTC;7jgj%IsrP4Jm zQP}U#MT}}wb|;Eq>$iU~>|=e5yCX`eHGPgPDG5=^b&%}ICM$%&K0>KNDV7(JuITQ1B~hB9TvOC*^d)Ty+!JBdh!|W^ z$uLF0C|eMRx>TJ`6Ekt5QJ1n%Qw=Z6sgVj_vw$#Geo0D+PPtkodngGCPM!@Wo2d** zB%lnqDoj-#C|gjfE=9J4;9?9HmLzHol9ffAYL5#>Rr(~*6^)ioN~0}`AU~m~NV;AP zKM_IJE@qtTC5x+ZMSFy4s^L62wTEUGU8%5G6dDvR$(*ntt1dMfWE8BBDZymaPqC>x zbfNEtg-DzSN}6Q`nes5XS!SwFx|&zX0hvqD-<+*gsK`vx6u~Uj+9Y%sX7klobO)8JUKPL0_LgA=TaI1> z!=%L+-~5YbmR6ynSVF0Imhu$ux%$dO?6R2WihiJYk`o?z&vklh*I|0}cO9cBwgovu zErzc?mt}&CPK{2$Mi5FiGw!pohf4NbdlF7j#R-pXKKVYXyn1aOTOM)G^(y%m^!s*c z0Tzr#-BPX&5f*e!qX&OhwqRK6COf-<7=1~0p_^4&%BinKJ5!maaiw`^DQGE-NDv2! z#YcKsC9o<1sxvs@QsYND*E}j{7L=z5DU`LlzOLowCOL zDRTK6TH!0)i3m1YKJ=B=iubXPM;6qQ1&1`QJE)-$JKZ{KUArg^pLFXir$))KYCCCm zbc#V+^+ExiQ#Sm44!x|=I(tLvib8NDsbwK_Cu7RpvTF~OhY1d4r$%dUO$}F^>g)?X zbX|puoZ^`mA&2>U znvF}!A+^OUKBxVCi`_)tNs&`^8jG4t^CU~<#^W_DycV#7-K=m2 z$p}KNAK_{_El7<@E~6OX?Ro(&3B`(G5KNAiEjeU)FSWT8J%iPH1Zv#5;4YL?Cd*6m zkg{MpF`}<|$lP5NB*j17TETAWSZX#Np0L7H#w7G4%Uh_R#yy0}O8MpAm6R<*$(lBn z_x*D7xP?}w!6eLIQ$9#WT{U7@3exegLSOw|P{BNY0LGA_l6nC{BBMB||y zdU-tb)#blzOvKRSZ%v9){s7S?Unzg7e`RREZec!5=DLr^6~n$?-J^}lXdKinKF~P~ zIb)2_0>O6amq*je$cfpM6oMEpqs$ON}1>}bPp9Kr}kEgGIndLbJS-k7L#du#QA2BI;Tw6SPDocPcU&dox%fdfxp2y;!$73||!0Hy&y1#kG zw*DDS+Ge#$klmxnWi$#<#VtDk7G4Dx6DGUI5EE@o_h*=2_~RSml)=JM2%)rTZ)2EH zW+F(|vNFHD0~_23Ym5zSgDj|j2P58a^TKl_?teJY zE=AK!>6`%B)ObxxJ;$NP(-KPDqF%zpeI>}%4UMOaPnxtKS6?sY3FufQpo27l&WX^U zrVr361C|=jP0hnu9Y2yp@(^D?ne-nhuS1LSLITiewWvb#Vo$ax)?0435lXqm32IAXuBtOFl&|cB=-z(f&u08<5t_EkyFTh0=a*VbtF(+&})e`-QCb^9!MW=NICs(|Qpc zy%1~b<||?fN4LUSD5tf((!xY#(|D<_OjHW5Rd@td39B^C2GDSb4@9XvfT|)`OZtzo ziT)y<@l#z8sX0zH&(YaGhiCNk4EcVw+0i^(tbeuu?uDv;ni@2m?xc0v*hv^n*(IJO zm4fkJ5*C%Ttr{k~3TUdpG>xgkxddKJ?IJsiskXGD@06y-rcsls$B4s!CHTuvHhWNez~x+zzO>lmev4mYH-}vIO%w zf+M=~+ptRJ+Ya|Ea5~izBc<&U)Q4_?Yxz3a@(L;0EPwX#($bIms^;l))72?<7;&OLdk3H3r5^3<}iTT9)}L zwvt?KWKr~{Eak%eDBhA}%WUL?^ww6=K1yrp{jH>Zl-AN0eA25FOMDccG-7FvVoR{x z(sCcgC-1Smw@-SdqTNUFNo(kKpDJ4B6VG(5_eT!+5Y=HmicgWJ*iTwX`zWoYFSnBR zQCdn5awTa*rK>xDvSRBcw=0%Aw6hn<6E(J!bk`i>@S1M0tfAbN6NnzmT&mzOKZ@2; zL8Q*OIkY$_HK=gdAc=O7@o9r^O^;rJtC&YI9+hrxi2F!6_! zMK|_TIu;57H==_2{aJi7K!jQWBY8jAj*L*n_ut)!k1qxV&P(~}tHrlHzC_2p2R=0+ zL?`T&p&(KxP6rVj8cgudz;=-l;?79oeq$&I)1kW%+!s0*+=GDx+qJndPA8r*r6c`J z5Wxfb2$N294<^ZSmY>{)(hZ!>=JZHCQGU=7ye5#!sts-zsuRi41os9KtO~q0LMK)S z{tRwG3{hSUAgIw1Y%d5dW^3=@bRcUO#N3Z{g&{hTs3*8v-wqm{Zu5444)v#81H>8B zD1_m)3@aI4#qw*yNv2d!@WpT|EPO1M;Crn3d?e8~L=d#Gw*6X4|H7rT4M~mCiC%)> z6WRvA^AQAZl1OH71bJ*^D5X<4Z-N+d2F7lKb=8RGSk8BY;b-KF5=o3rMpO`n0Uh}^ zFfT@QWo*BZcroxW@vhS=730UM!W=$F7=VXOo7NMM(OsC2U!$k_L76fB#u^=w(XpqrOau{kKhGzrgB z1~X;9dm01a6fcVjTOrDyATE zE=G_^%p1m%9;}pGvyYg}yw$9;ub9f%2P%d|G<1$pF)X8jRkJO9#SBpg?CHqqF>|r| zTEy6zCEVg3=Jf)o;BOtkiE&E>O-N2HYVwHS#j5Vq{R|yFv^e{}1nS^}z z3j?r#>VQr#lWE&c}6pe;T&yb6~@Fcj2;%Am=fQ93016b$LD55Xc z%+tdd7gfhnZ}%zu_BVjor%q`qGs4EFLpij)OxWxU~9b?@$U9w z0nqs%W9wsekvD>Of-s57TMg`I=G6fUz!-Iju}%7UvFr6(p-Y6M*rdNhLzsy$X_HtK zy9rna6?;OnNgp6GSaPL)ZSX>jdc#!gVa-FpCbHy8F*atKUMJ`ol@NzSPE@EIDDGtJ zyp$8QU9T7StGt2Qg%}YZV{DUtR?H^7K|HVWt})qhWBvtWb>bckVZj|q=So<8uihx4 zRO}87VeJ(R_1g=~%9v7m8*x2jRQf)Buy|VKQRyb}lZsJ!CXt0f7@iOhL_e!HiGeD% z3s{IKP%wQ($bNmOC?ZT+DLybA0(OLXO528ucNI)u68erlTzsfvb3%^;JEvktV&Bn6 zh_6-b-Pq&6epWD!kVx@YGb{iTf+u<+MX(pk7m*@f!Sn}$AEKGV%L@gTrSks3u57gE zr(!=u6PBZ5A|@2raBn_I#E7w8EFV~jDtRwTh!u0Zyij1vyjXvn0@$ER?vFa9Z!7Nf z^3DKz#*2NXj~8#LlB6?1obbz+AkKIt=@i1}UJN6@_(hexGr%r7h)Z5xKCs|U>?J+v zG>a$&V}F`OdoM-@eEN8?uhF+hsQEsOwPPM0uVPivKkAdjbg!g@S-G?sR^i1$!CS26 z8?LpBPGW_M1%(o}M#UmRLxF8#Y#A&I#rS)-!ecwTh}~W+RCEz9da?e(B91DUzAE~Z zzNxsaRxW zH$yM6PQ~ujt&@6-ZC)(H&{sU=#rhcfi`Tr^K*KfSl#1z0>!fUP-iu`za zgJFiaPsL7%#|_1#0-r;{nbJeTA>L-*MsXzKMS}x(n8e%2CCn7DjBOGFQKK?3OvQrW zv)N*tij~H$lgdSr7t1hI2;R;{`<2GNZkQ)FdwG2f)nbnq%Qsvno>8%~kk!&cam#<*JiBWkSkBB~AY_@TqDEDI3#%IJXFIH!KNxZFMWg+tn2gL_oY>n|1ao&q< zGQKK)Q?V3jhw*iBeRrkwmtuAp-w+2?tY`H7#y3R|tHSFU{gUyR*vps_pC`li9bWvNf*R#UM$1#jflu1 zEyR1>a8YD>u|9^MM6MUhH~cEbd9m+|zl-a<*l)&vi2J>m93-SqyjXBhfMmpN9F_1{ zc$(25wO6rUL+2UVNS(ac8e_23L&dVC*q{*UdM{=U3X@*&V(X+x>2(!*J*G=gv~*s< z^n)VOf?_2dZK`6eI9#_#ij{&@Y@#j;yePt?jUvr7OTkYP?07r^I|_p>Cz^~XmwvN<3J7W1%xRyR2DC2 zlypn@yU9nH72!*-z?Ot8-t^0*aEUH4ooYg9e+~3-P!jJ46AV&Ir}R@y!NL~lkE1*k zStu0Z$|`+tfQKuI^IR4|S!|6~$|LSgT7pVhihLqVl8hv>bRLN3o~*5op(4KoyD^@; z3PpjhMG{?s{&XdMTNwFJDfhN8vS%V}(1;c60e{Lx)}v57^8eYqN{uCv5*r#ywI)5Q zQBx0{^vhywcxb2wbVa5$m+Cb*#^Xat+=Et-ML$4|FmQYQJCr-ZDOWbUr%}B$1b&vq zX>L=0{pH*i%{|uA17vw~@A)WNeo`t4pAR*nxmP_kRU$!Fxk{WX@$g@jel3zLS9JbI z)BZLnb=ez0Ek!l;v>;KM#}$>O$ap;9qiCDOTT6OCGr#$B{k`X-H`hNA?Wh%Y?kOH? z$r6R02YSF2N^i-<4mhfICC!Qk#S-!W_B{YSZ8w&qOX*XJjKBB%)6_#{@j7Bp!|kF~ zdu3}nX$w$e!#_SRAm(K;0a2oOh3C zIbu+}N#a9}x&yHW>{%J{FI60;U!-7H4nsV4$6y@NiGW7hKgVp_mtj6&lo$;t2^(O% zC}a9Oh6@8{v^#2%?$Cy6pHJxr1VVx=%?4R#3f<&JquHheVikcbFY%N8D_{rO{QA zLAGNhnIT^jQzy-n&jhRnT#LKF1*li3sFRz*H%WD}BW{ayGbkNl=Z~6uq}QY$A|8~+ ziXO3tq?6K9amN82ai^f?j<|ayJjO*{f*r*t(ooY^oHtaEh99IaC5!Y2N)N_f$QDp! znP4dVLwl7|VSiWocdqI0(h*H}q>pHN$?y+$w70{nL*+ekOynp~MC3U6QO;W?X>soX z4@2iOpmY=j$$pBc{qoboArqVw$a_I^J>WGlOBt?axJf1thq9l4fDdUkCnGk; zX-#;tFivB_UY{K>R@4E;i-!Qs43ow8NLz(gE29m^0rn9SkgLC#1UOK9s57BG{{eWD z2njTaTSPkGI&m%FZDJPSX0aG>o7ezYFSY_Uh$jK>6Z--8iqn9P;JGP&kSAolNqis- zfFFqj>}h@?1^^l)f;|CoPF#9K%G0T^VLD z?8`8lVLrpr3@0&kFr3FQO~aM|>ftF$ui*49PVZruttEN^pe&|o$HC4rraS@I1brUoZT;YcSjC5tIJ zOvzzNiL_NbVJhMDGAR@C%OJB8aH}|CTE_I{5^27f^KN9glXLB4?&B zGQ&a+uuK!vKVg}(pdT@vWxCLiJrYBWW~-PNqR~)|%uGqtP%RQUorDdB8hsKdJA;!T zxj7_>C6hJJK|YHmvsp3++?PUfm|Mv7BIKPKY-4T-r%PB*2_%0tm9S(TbC-c~K4c^3 z+Rl`npah3Da4C&!!+xe8;Pe63bAWSw!t}FDKg;y9p!Wzp%laiP)l{S97SwVJYPX6h zp&BjK*8mD?ieD@0Oo=R+#F9xYnFPssp-C*6qdg$@1m|!%S4(xzLk}>ei7976K^t*dXPK@EfFHs%0UU1uRBloL zw@3ihC5O{FYB~tqk^q9W0aWXq%-zY{MyBj%c!22#nBIi+)Ziwjf5PxAbI&sO9N^U8 za{*%lwgsIFI3;Zh5;~F>I*uQP8XeiD(NPO(btI|Pkz^uslOT`y(Q*9f+Cnl(_oqk- zOVZshUk^xLP0|(1+k%ogZ!+i2=DazaH;41)aNZowo5Oi?IByQ;&Bae+c82A$hFsQA z#2Rc2OIS|{>#?(>oh9uoS;sQVSY{c^EC-w#yq)Qd3=gv8Io)K8BvK$pR3Q056G-*a z22PeA3eyIDh_soxiJ(snP7WmbflSF}N-oRTIBjQGr$W-XoaO6TzJVnV22zV345avM zV){9j(duWT#AN+sX=iY*o=VTvQ|U!|(qm_OEvJ_=y+Kd*9Ax@ArU(N`Y7L}cYasn* zrX(9k=Ri*98mN>a1GS*tunKLu+(0ecU?6P`1}d?MCC`CUqt_bQ1K1tj7F5L0ZX`)N z(`%Ws9F!V;15+BA(!@{%u{}X-PY~IY97K7Gn7*9T=Rn^XCfXcF&$TBg@?x}MVwEYrx8My51z zx{1^0m@7L9fy0nm9t3QiM5qPjC_D;l}_eZ>@=HRLk^Q z(BDCt==CgL&*?^%Y2@@l*4D(7CZ?P-kq6G1$O9sT{S!hOl0ztp28INIn;XJ5aN5r4 zT23zy;T8#@y!FUiqpxRrLkNv04IvbD2SccK2SX;yFNA4B37SKH6n_RMhmuTkD9H>A z<<@6C0KNo+ay<(!eqeEYrx8gRJu)OExj(9Lt{4kN3yVN|*{jO}Dfav0kg#&(8Ls|^gJ$jN2NT$U_i4Mof?Vy>NY**RA&(`%W& zoO3NluESyVoU5L>^~`Mm7rl)0HgMiX<{o7ECQhGYnR6_2j%7qRS)~mpon}rahqKM$ zY;!pKmg%|S>|2&BVtNtN?V#7_YdO7~x%JGgXG$Zd8##Rt+!w-{n9{_QbKx}dhzRy! z1V?oQ#gCaOW~L0}^gvGMMo?KrOetbYEyH@I)N{Haf^;@=T12wuNUmcf(aoG5s6r|$ z7k5O^$&{iDgO^8f zoJUcdH!!_1imW;pMSVmYO_IscBv}+q^yN&c=X8BEX=`9^Bd42~-o$AU!}W@xyk<@( z$B?#xoGxN$2N$iw>E)cR=X7HXmDR}UCKXcM&#?v(OPWP2QM9qFpD6?Jun8kAr;9jU z#A$mh)zOZ}s5(E~Ed#F75wIMS9JN80Rh zRJuKmBBz1V4V*rRbdCOC9L4H6PMh0O-rTlCS>Bd%)idQ_They0EosxXqqMdir5oFE zecO>95l?AzJk@$2!=iW^k&4vx1#eK3JbOrTQE zF-6;f=k5+9xt!s-4xHCa6uX(y%NaH>MVrWWCX$BaM3Onkl!Hvsb|m@aj;xJgauS!G zM6+81(o=&EGCapno6KB5%;22Xc47?-a~Vz*MWO<~3|k?N3ZoP%jg=at$E71ukQ^ls zkf+G=Wdru7jM%?ygXi=iNa0+k2*+=@BCv@T2}(5fsA52g1En2K_ap%8fc*e7D2bpX z4n`nS2e?{La40jII^vmMCrj1 zw*WeI8vtYCHUln{2+lJR%n2pIr)}}%P7DEj z8|BF27|z(r;ys+EmBsrgO%^9mnv9*BLcmivJ1b-VW&-NgQ`{=<7Q3PQqWBGaH!n(w zvbL7a++({I9|_;b<>R5WBkTLzFQ35+kCIPxY#is2r_`;wyemr(WBu*G}rS z3tzR^V;##RAY8Z?CDuq5$y^dC|Q`GA;_3BWsGjaVEXMY>TWB5LY?|Jxf zJGNyL@hrWQ6faSlzUP3?kY$VjI(&n1{x(|tjBhoftC~!yw2Bec<>j{Gaz{~y$j_^& zp6hUVv3b?DDis;!a@fmCY*i#S*5;l=(mBrZaz{y3nX`hhF%Fx3WJURcX4JeIM@7|8 zTZO&c;qoh>%1PL8+rkC7*jzI+#HN*I+%>`XB$Q&eV( z{sm5Zb-CkeF?N>AIX~~Z5=SMKKG^9RItH;k(@`}e7Z!_byPdCv3*1$Xx#>QO-04Fc z6%JQfi6UEOn_1y>SCy6I+lAd98B$i2Aw!_N7DmANaOZlEKa zRo--VWu>FS4rAups;X4cY}ZV9w4!Q8-rUNn1w*RK>_QQ^0;7Mfp9xpbnK5IKtz-^< z`7*f7QEnIg^D6AEi3JX~+cwj2B}UaOr+tKN?iF~09k!}!m*dLJQ8v8o%u(fVU701gbtU>JSDDjQR<)p24hm#6w;1bG ze)}}o<(x}6(evFNtW>4CXF03O?Mn0s#Jyu|*<8o9Wst9MRJp6r`D`w`7~`lcx0N^$ z%GBXUx+csjt8x@p5(z=viZ;dxA_Bq95>&9qPct~DLLcHl=XTG^!E5l?I5iwabw#PC z8+b@;dj;aE!dC9>xgI9Up)eMMZDr+SY!yi5E~v20MaQhDan4~2F}#$_qQS_2loI($ z?^Eb%*HaK5V`Q=0;i_TknCgluIESKF8I%NSKp6Q{rDVI^2$k{$W6P>g7@19eP^;(Z zR03T@?O#`@>==jJS?wy}9wuD=2&wbyexlUZ6}g1LXd7>F-j2bPO8<^2JXSKx)96AS z@YHIG35YJoaEx05qggRsxfR^%AV?Kg$689k6*T^jt3dxQUx0y!`IN)2(~Dx?0N`<+ zsG{s7s15+a?Uz>QE*C~Y4?EvIs=BzmtYnO%++oA-TYV{Bz04Yrj7s$;A0 zj>p;6aJ!4@>!UGN>6xEIF7(VKfjoN|=9xj5Dcscf=&>Mr{8r$ove_|LWLM$Wg~in* zj`6{6ci4RtIIXe_6PMaxV;u9U%TP>f0rn@fH)B;$*Q?6At`bwEKgTOfy~d{teWJYD z&Vfjb9A^zC;+cK|UeR&XRlJxO;i#^1*~&dJ=N^v!Q{7A=-#uu-7)R;wvN?X)avjCh zGiQ=x{8Xt+0UxD>4}FZ`&JtUB*}~@eJl>0b2 z&I*JE77qSIYA6@R1`O4Hq6M}E#SR)AeYr79mD#DW)nU`eQ3?5_tvJf$(~6v3UhbUV zTstD()|%=yW5O&)g|EP7YMZn2uB&oXxKT%+w5N3jBc{h=D!d{yUmdyGJFTR0V4K6` znF-)B3~tT!sGY=3@j)(bT|B&BS~j!V)q>4~oo}VqI$y5CUE(U^m4J`gERcL;PghkO z>tpCv$21?$S9U8FR;3PCrL&w`U73;9C9N;FvNFxDKwMG3fRnrf3u3v@ zR_dr)Fa~Q^w~x*X+`MuJ#T*Ueek_j{F)Y@UIp(Vake>jSqc$wIa?8u}=T9T_J}Spvw##K(;G^ZkHOdW*kLIh?+)7)8pRhXNlEdNT;<9p# zJHBiqF_f1(Z5Y%r4pvjI@T~lNGJ_Ui3Zki!7s0q2QEo64jOcz#Xe=qb%W2NT_S2eO z5wG;D6nwd{N>ddpfukTZYUoBwpm8w=mx{sVwwW!K5b5d=03XwA@0ny2H+t8n#8OFQ zs%LyqZhBP4m2P|d@)Tl;R#xIDD8qPyvJ~kSM4ksk0fJmyA@JD~!yOef!G=8}OG~i| z$*!=gBs2*o)N&7|$c=NA@mgi5qr4L9q&Z@iCqehPD9YuYWh-aRzPk|4;fE?K@8ODm zkN+qXoE7NV7#JMoys%|8=vJbjtit5$CtUQZRLek)%G%HI@*LL7KBH-9a7}U zaTN}A{Xh||dZi^1Yi8!Q$l$EFavl$zvMCFHF>$V1oU5EGhx;D29%3vl2^Gsdx3b8^ z#1<{;AuCI}YaI*5V&z`JS#d+gdgW6mv*Je$IDbBQH(3c5*{P5Cl;|%EHB2L zvS-Hm*Y4%k7>Vhgo$W24*~H{&$02x`1TS=`T|7j!yQd>Qd9+vbcoyG|Qgv5^*4Ao^ z{_WU?8yts=Mpn<7M3i__h+@B$I`2U6vPFz6o{dGNx;VCb@1~S(5l`>KfH<<0Z>D?# z9v(%8kIK6#JR*4bzPvL=IB2C)F~Tu(MD<)>9@tHl^Lo+8DXYK^ix1_R(>r%nh%@4|FR+(IM1Z2u?S1KU0+Xo|9!HJ=x)M3a^b5%Bp5jPxp>n z7#(tO)8WG19BmGWd?lnYnbnlxCP*mble!!bZhjAm7sFl;M5xnwBC)_$VVen>dxlHB z=krjURkJ|qZ{r4$`gI9xGe%Ro}S% zI;!GmH(vX$+&H^&7e}`zm zNPTe(`xH^;roBY3oqjcgM{z5LAYI@t^Eay*S?%8FELa!7uymRt+#(N~q~&f=hMh#0 zgV$@$YB%amOC`5}k1*8JEg*&u@0uLBJQ40s^V=)+Be&kf@7U6q|Jk2CB_ zCvdAl?H05{qXdatVBt}Nv6DNaheqquDi>C7@SidvV3Kmq_t3m+6lKxnR>D|aw|cYi z{^m#*4=l=(R%s286rJ7-)MGqdUa5fJAm?MCsd(oUzN)}|H5wCZQCu-l;?4psjTkN~ zQ97)kja4<7i|Ww^n2Pc|?8e-z;#9hKThBAusXXc!YLYZybBwl@G1mw}X>VIasSH1~ zR1s1}W#8Jv7vI8JIRoo-?0A$_p?^{1(l!_^f4K!cwVt8)oOhdPdEaucxFs8|Rc{2u zPz*wp1JTJ>e=pDQYn7HN{aF~$XI9YNi0~Lh9fn&0vy!@=QG&0U_j9mlgb4_GH)WzC zbDY>=^Y&KEYJ{?sz^YV{;g%G%O-4AiVe{=%VC10s@?7k}N3|t9U*7*WAeb|3RU$i@}gYubx&D_JW%gg_k-tpsj zmo?rEJuGp=9DK1bDJxxo!MM!rS=`XRBzJk6YXLPr4-&X1;Bq~Sf4pK#+5h!a(6eKK z5TRxn>Ehx&ozf3g==A(XgLlUSq~nR2NUXtc1j_ML#{q0OAeLSN@qj0_SL2JHsQO7v z0X_}XL_Fa$zR5$v-iHo$zU)VlpR zTq$-c0X?nlsTE!WcSSBmmscjp*Y2Vpd8`7@!b#^$SWty>DDuppkPj)sDB>tK=7Cbi zDT*&CnBvmYr>N^aT~&3l`pt{i)?O38=+B00qJ_pRB&}JJ^Z@{2QIs_4%(7$(OJqt~ z03jw$Pt!$(FOb5Lm5YUF?IDbOwQDG&%(8MIau~zva5`1e<7+?>CY&rVnM}GsIm{B4 ztPO+`Q1Sj74X7r4pjMC5q+!W{QDL=pvR+RbB1D8FCkU-kN)SZ93s>ZkHAcyV!Zdn= zQHlz$-Og;~8XKsURT3_Hz`^A~Toln=swgo6!6Dp$*PoC~ga`M)?n?-nCSE_$}ye?2`kIbZ^E&~bT)!vNr z6i|IL{_F6P6(S2{2{6C~;dMPt?c=oqu7{r_*(B>?k&t6!L6hMgWc)I`_Bbp0(ikXH zEva5)1YNae9KtXJl0O*Lh1GsT<>7+=Z2tE!+=P+>r~;<&+9v`Pd*bzGjbzfp9<&N+ zlH~ZHKu-Zm5x;|h+RG8BEj;lTwK*>Ffq_~Rnh55aRP8-supXf#qkYiyCNBCBs+6AU zBIzkwxGYo)V$}Gm1LAR@hdmJ=A8){0a>*%3K~;F|3t_eU^=~YSxO_D2u?@qx~P>d8}7NqP#B1)&KX}+iy7d$^6i@hXUu1?D|EfX~S0mU!VIT zv2oXTUwm($I=s!s{SR!hOe;>k-;vt>kM{>uO>?Gw_WhFS4~HDwG@x_*s_+~~ta)zZ z#vTQek8L!KL9e5pM>Bv!#Ao23N4m(s;gxieiGS&$7mgnF!9S}=ZOQD05zLMZRtWY+ z?o4Fsg)C$6Zx9VMCFtHX*gGLu1;%*L-01c+aM6HR%@c%^IYgr&TNyNH7zy_;&k7-{ z7_4LP2Dg;8Q0+*o71CrEg`^caXj*cxjy|Af03&}8y%b;4mH}(=zanM3kntiSX9FDS=jxv`(ZB zrmPbNBaOWlD6SImxqNZpm{ z8X!Hinks3g%}4_bEfKF1@t)GIXp)Cn$cvqdW=eG9ShH>-3Mm(nq_UI;5Esg&Rf}6B z5Yc~0LCYY|g2PS2EtZ$M_5rdE|MePO*gRP$051`+e)2yMFp))KwKwyEO2cPB=Nydu73@XiBVaebU$)bY{ttB9DF`6ZH z<)FtBA)XWii1t{v&~!?3v`i}%%zQ8i44%eemq`~YNtoS&=%Ow%l(e#<@T6&}5I{2V zSh7H4d>~)i$Lj@DhlbT2_9PAlU|O$z0>U_7ia8c(lWY_ky5cLtY)WEwRw-IYN;+B| zwZ{~w0rwSJUnt3=lr)7(8k*W^xe=d$m)A?01Sp3*PG_PL%Kt#2CBtAK*HRx!t0zcd z@{Nr(!4hcx*whAO()9^+@nXZIvOe>&sIFdwmRBr+bp&4OOLZrdY{!*KG{FY}d_{r@ z?J<9oAlz~gGdWUdcN3;}H3hSEo+Q#9GCPwLQ@zMvN;;4%h7pmWl9ntd^KhXxiOCcl zDh0G6LXlaE(2?<$e=9ey-U<(5QAdjn3C4d9|HCdy(6U2KA*m7(AMCG3lq$!wr}Q#6 z?e}OwEbIc5CNm*&O<38OC@|ptc(@xEK)qhC)gx*(7NN&i)>?oN#R4oIU;F_EL@0sy z01K@U1N4C36RZo+=)srJz3@&-J#{mYKz(Mc98LSA*xL5)#qmD4TY?;|gm9B62~$Sz z^o*?Z%*^y&J^J*)O*H-xXS9dbt<>Jzn%Uctnbj+O%otcNi2z4M+PFf_P37V6FFr#g z5tlw9Z!ESu_$*ec`Y2;SO=ddYoio-H={>$cd-~O8UXJS{rx;PyhOx@>t9*lLbai{r=!4?NM$v{c;8|85E zc3VY>!;Iqqe6qt_rqro&u}Ehrp-E5q(Eslf;B!dHre zxN&ZWnNFQq+`L)b6^ihn+eYreD-n`guv5+6VDXnUqTzG%28|nncbqH1BO!v|ndNin z1oK!qn0HhQut8UbL#-ef@+RZMMdiN|OP56a2#3pFY^#`)j$>15 zsCoCR+hnC9qBDA?_sQsiS2#sUqSio5{bnYCN?oNjbs#KgO}&h_{|e?zRzzh~Q2weA0j zL#UP7@C{kZ`d_}`N~W}OQ`5g^il<^eQ=qAZYvHC_5FP)j{;>F4U90w=D2XxjB+qT0 zk4|r<=N5G04r5X&j{l)cyUjM2!(4&Cv+S;}q)lKj@u!xn=npYt9HOH}W@o8+0Zvz% zX-Aq)=ON++Uc7JgdyFBXm0qY*4gu)YhyI?sivXS4Q8g9u8}tzDcbMQ;xEF%EVb78Xi2> zj>^uZ|NCwmu`KE7wrifh>($LuZaKTX`%>Gx`5W?*ciDzpyH5-4)uu4L=>9(km0o%{ zVD8PRSU0Gch3g~uzKir&eCbn%?{0i)O2bSeKBnQV^(LZkW0-jAMkul+|1(j~%AH=2 ziNDtO_nWK!+o8iCY;G;wd`EP{l{I>{2Agk+9*3~CwZ?zGFG>UD75s3etN4?Oy@XQ2V&sJroW4Tpk z9A+^)lqY-XQi38`;{BWGwhHuS{KCOJ6HnsOrNMv8SEgCBrDMmgW~Ymm7_KU1G{7Tr za|IrIm~qV8Wy6_S^z7`hAaZAT)u#+G@nl4oB1%kuiox-X+Sou$&|;0W(OUe zFgr_do)yQB)6Jv!ptRCNB~^ZFzCvZtOqaUlC@llK>{!Y{6&y`VCKO5bqn*vP48)16 zbjjj7P=ukkB8?lt>vYwZ2#jPk{wANwfozAoQ5-@NbQk9P%%RUeDIf={iF zEcmHl+%HA)&O=Y0J=wPW$5B5`I&t~A%g%kJm4`Qu@AJcR_gHp~$ep%Q6H@bf-A(V` zeN$LQ?N8fZJ@b0I>a$}mcOCcbfaLLsS!+_#Ylb~DeBF}4>nq=u>ptr89db~CZvKI^z}JT+sgd#bZ|_Eh|V`>FqO zJv>!iw@>x^g$5S*|2kuOrM*}b4$aQ&*$Xd|WrHFERD)1yN7{ws`QxjXoQ%Bq^2(m~ z_-Zhc(qE0On!;xDda19Eqn)GjJV&&7hGi`hxbm zOSI|<#xEHw|BB|T5YhMrrSc(_6qEj+Tp$v(+A#GE#{LxTcGEmjiVZRPBn!$lmZwu3 zqXXZ!14Gdt86nnSouO#uP|2au%2I%^PSqK^>98Tb zw6`p2>ql6JTVwnvAy$J%67||0a;>mtw8*P9r@r}k*e^fcy5z4}ALbYSHmK;y`$zRU zqdQaiXU}=rpZ_|3LjBU9nbxIRl&(2ZufYQjnO@KzaPQM=|GZ;b&*fiJ0|nemYE@&U zAegiyBgksdX~t=F5%Rdgj0pV6JRtfA;{+V##O+FzvmztZY9eA_1onXK*uu4EBv|8# zVvLCLu0Q>Efio=D&cxS5bnxff9p*wD`=gtnQ90S>OjOdE5EGo)!`de^Gox>2&z`*} z1MO+;ZSAR|)~f#&`&9cie`CLOsnpTmQD{5OQYi#&Eg9vdk|Z{@9sBy4)eDb3a((?r zYbV~W%bvEToniFU9Ytr>_IaS}wN;mwL_h!6m*3wvef(#MpUywjfCDAVgKs)Jw|3XE z_CIIbVHvz5=h(|Td%dr*1P9;TWkjc-2hV(R^w_>fzq{rB?T_rgrqQVBRNHg&gQKO- z`!CL2-}8;?xS5?Re;)jfI5`VuwfX{$=uqKR^Eb=IqzDoSoMF!EtA6ED5>yUYl^$hbvzG z;i{BnckIo5UEimlj6jukE|n_ak=fP`XzTW!w6WG`Z!{0kNjg1ZJs==Zqp`Lp0sNt9 zZRD7!XO9+E-q?3_#jBR1tIGDD>Ci3Qnn&y~?bX))4OdzFc!DeFZyF~f+8Rm06%Y}e znPu(Mt7o^Kcrs$mQp`vqGioEx|6|6Y=Q@gs=UgM6_$jd}Df9eyd#&TCoDSNN)&lFb z_4)NfR_4)z0X!Edah0cg9|-W{fyy~$MD1Rw{MgLh9dV2!+6Z4n?hK0DG;8lPYp-+& zStom{DoI-E)WbZuRbF|uTEzVM^RH9{&Y=D?-Bs2$WOIZhUDnE2O*iknh)KNF<@iB; z&L=sS&Yzli(=E&12;Db){;+F9&%b!T9C&)~=g$`0J!MPpHftSY*SRjdIBSe+`Sf3I zyT-UT?!t?kpMIJJ9@in;z8 zY2zeA>fOtp?$CB+yOIv89_VtF#`4p3ySHT2{A2Niw6~g0W_=m6|FNlomT!knz47Mi z7}rFEbGxpj_w$KeIPMXfX0?+ooW^uw6GL!bO=$H&Ew&Uxjg+_`f? z=4HR|b4F>~$IEK|nm@IC$=gw@hBZ{Q58ga1@#N_7rCke?C-*o%zPD!T*A-je*dc#Z zsf+wn>t4BR&^M2b{A1tYyWh7i)m38Ho2U$VMq8UP!+9#wv~ZTS?xz2ip(N94rD)Vd zboC8&^hN`74%$|En&mJnza^)qS)M@;Ltk%euS{zXYle5w1KN|&|M5ZjUk!g>n(U(% z%zWbD*}n{8_}#bd{dN4TuW~y?Qk_w1^$$F&vt!{pcZ@jzWI27m25GiZh zBp7iwU5&MOj>xtKQFjl~lA4v)5O3cPuxc;_G#lZL|1$o_+AnrYEuQtxfZNthIq~M5 zu?PRZj&O)uL@k0<9pQSVWoG?Xjc|XXyUJR37gf`&t-IY?x4~Ms*6W;fjkT`MI>0lS z%2IU3-wvjuuFdamcb2%j=Zq@su5Ov8&#Ibh9q7$2TYDsAn%n!`n$E!OloPi+bWmpj zwpVBao^CtoR!G^LNH@1{F^p)IqJi61eK%ob*F^`TS6uh>sCPU5!*JK$rFZ<+<8n&s z9k+*ncJ8IOUfOv0Y>z!3);;}Ef_U**Pv^s*-LT;H`Jc(}eShJDqaza9*>=1nFxlY@=X9AC(bcximl{EuKf4IBv(ARp~<_3OT7M*RWscg@QkM=KlC%^LkCm(#yHKZiwkKOO@*uC=W`$BWKmOS}b z+0D9F2d`Whd(9WGv`;*~=yy3O?Un2!FSQ?jIQqLsx83lMj@Ra|s)#zf`-Yk+?-VXr zzv8ar$37h3?)hW?hsHiOX2|Tu`y*~Yc5B$FEi3*wV5K&b?lI{n1-0?r(o?#i=1##cLa0Tc0&!Uc$hKH#=VI_{E^aPOIY6 zica>vIs4|6=#Ue({hpZTO z)Rn(MlhMYzRKu+ZR`b3alb{R}RvkV(%G%wRQJc|GYnCT={yysA@7}7d{`BL`kN0G=nCWkA-uYhjV|n`*3>*B9oAw4@{@p$@_v{}*&u>fXaO#f2={JnNs>|knN58rz zZ*=3aoH4W8#avf^IQ)H+?G0C?BVpdLSr^u5E*vV`aeB~G{gy{Bxfpe6a`t0Kzu7cA z^4PeG(!S$&ZFuj*Jz4_3d{A7DDL{<%T|@Ht|&9UBu{?Ux~9NZ zw`9^iPoH-zYe?HR=$*7*-=6ZYy@&mq*#oyPjk(RbG-jRGxf+d>u{5R@wClYsM0cPu zb3rP@Y3|l{pi8AOx*$&!;A~d?QYjn09e|0fFQQphy?l~UBVFMZw8yF2zHD>5wD#Qb z{)5*Kw#N01KC^l2mY@ExjQBD9fv^WQ$hw&u3O--@QEWt}6}NGj8Ce;bJu)+U;6zie z$yRNxEd5kp_fA9Io7TD`|1FELPSzw^j0Hu+`xax_{OdiN+56jEcSaX$Cq*owqHq?y z^2|K8u+W@WIO3{4eXUs8_sp}V<@W8-J44l{@z+N`&`ZNvskxPA_4lUrOT)ghE)6tW zmj>v4ciz%BpUgON{MTL6+}O7h(l`DQO?Tec|F?Lcvo)Ejrip0p^MIel&F;I=I@Vqp zeR}llk)hmoXW_nELI3~NK-Q&l>-%|`?&oFP&tpQr&-!xthG)N;y=`0fh?n16@?*%; z{V$%H64r3lu-*}g(LWldZXWny*7v(QoB!^d`+_UI=ytIyw(X?=C|ppOc4^Rnjj-dxztur50PhYzOJ9$sb3 ze(Ug0^-rukQeBi@RA_lKBmIFtgKy8e*nUI3VQcuQg{Q6yzhmf#`qRG-ovQtP&C|oa zUzahFH~N1-TrW6?-xfHE~F(i7KBacyI5p(cr~x-TQl+s}H~nM4^LO@J^L6l=vmqVx?_JdK*y>^5zBD`I za^&DAUS51SV$iwh^&39;@!KOKXW#VBZxnKi%hQN`LqX?Z-Js zS2s+~z3`p(r#Gi{oc_|9CwC=JO1gJa%;}3CKd~?2p_i)dQ(k+e$})HElat=6=z86A z`C(hv>^&26!@KYHf8*rT%v*7U`})#xkB)QBe$O1#t3yh2L{;)HE@@c-pd`K0D^Pw_Rb+xG!DS8ML77pq*$PZyj4drhe4Q5q~$! zb;mw~v(n954c`9RDbwofu|Uvg0qMrklF^ly=3EjApAtkbctsP463!(r6FEX%X! zpu8C5UzOH!3nH2-*NHYH+E|UAay5G1B5WT*blkiI*0vb!c-ef{`|^`{XYP)DAt9mr zj);f93SOH!_c_xiU*37w>kBPEE;;njBJyK`6`S*RjK3{ZsMek$XmweOi{cqgsPCq~YyU+T)_^@N+-pvg~ zYwjKTS(h38|Iuyz)yA9e`s10f6L(&}xVFCHs^z;jUYBs-nVP+APw#Emc=xuMcMW^$ zsis%9>^XNhXV|Get0!%nvN9z7>O-|Z?}`5<+xgHbapNqf7}RIts5jF`bf5dxwR6|r z+i%*Q2R^*&{bycXcH`AwTb`SnmbvKVHz)25zUljepOrUu_+Zz*xQVaNt-I^DCkNMj zV*KvXnaAsHTlf05&qtT{eCLUKzHV|q|LSwIb#YB~%U1Vyw2zBPU3BZ=&c7U7p>ODG zJks$nyugWT352;VCIlV@0&EfJhlGAPmXL!ztr`;KR=()q5st@r+ihASoq_q z!%-8b-ucSfHXD!TCGW{>YdBL~<9PV9b@#SgpVK3MpZVHTd*@@0oQFAbl5dU_QLlVH z=WcmxKK_yzK2a^A=6~6qO=gC*ZZEBY%9Q=I528KH8sq zc;3{}8NKU`eJa*`b0Xw}&V|?awaE()-v5`Y?BVtg?ER(Ji#D;Eh(y-@VoVO=t%FLqEzin8b(Ek6;_OHVE;#$X9Zl!0B z#GBj|>b&-MsX}etpPTbs?{6#8zg-*OuY*w4zm_+s%gU-ObF zGA2#@9|S&acb9m({Kd=~d!O6u;|_hkdZXCZ+3%pSrB5Hz9;XaFfmbboZjUbR&60>p zWGp_Fz9{axMj6v1V~L{c%N0Hx`X$75JvpD}q*+RL^nzv0im~wqB@?Gixv?|gCwFJ! zXEQcai5B9fX!f_5pukw6+p4?P2e4M<92Jx;df)pq=t!&h#DbJx668@w-#2x-33-~Hw}c~V=TZCD^{=^u;C0J z#bN@D)N3sd`j>KK*1o2RHg2Yx=LP=jv%Ev98L?J;F`&9ZfhE)+*dUON2RbO`(_9Pe zCOaC~!zv5lK#nn?O3DQ^mV;Qt4<5=92G=}j)z0Wx8nC|t9j7ZnD?TBWZfNZvt+fwz zGtNPR)f<%iP`khEERl z|IWF%aEgS|yO(L{R>8h^XYAT0S8>BkZ_di5Fb3ugtou4&WIou%`L6nYR{lQC(|I@S zJxUpGO5DqB;`y4Z9P*?hxKt&4=iT|A`)rSW&bXiYZAsXw?^CyFgsNXq+3T=izvMO{ zA;DN%^#=~qEY^Jgso8mWkNAd=R;SH>c;O6ZL&I*y26F04lzVErXglEs2i4B)*pLyA(S^Mv9`(-3`bJ~~wZxb26ojX?Y_nS{q zzQF2IgQF%Js%`}Q`EQR}1eyZ#%&OOz~xpy;TxMxVp z&Eo6za?vh~eNmDVvw7z)(YTW?siCsV!h2TUZ^e-8ELTDIl^?gSJMp43&VTNu3qECA z!$UYXW-_|e)hq-a&-0IK_Z!DF+swbG4?23*)lJ@a`iJ$C?V|g)oU>bM7}&CBKhwdc zMb+Z6qMf}3|DJEXpdr=C-EVelm(S1H|G8e8{7^AX*M7je<5|zlYY&xPeNcC@Rh{yz zq$E1);yk;zbqfrQYxkUfS-sz)z~$nEk0(}Jby;yF)!!^g4)*PR*Oa@IRp4MDTb95U z*-+z%%?k_CJ}qow_67EHOu=JSKNk+e?)M^PE?@z}&c><@ykbv^S(M?O?3ykgwHX_G zJlkK1`OB>f`B?CCL1VK)W5bfV0j{S}C-8uK(_V*P^fFMA;D-&E0NeS%k&`I60C4aO zEMQP#V1*c+VPQ1XG0-yLf*Jstb6^J&OiVV1pbU|{5ZOqjQy^J0B)v?a>3R4NA84$H zX#tl32S`6N`$C0-aW9#CWwEMHNw?~#7Fj>7iKVo;d$iqH z?u}JvYo!kuIbP2HTeW!6_m@@Qzq{VL`_c0d(+Q80PyfDZm)@>$`{rY|r9Drl1x>rR zZ&mnt%oaS z-tJ5?X#9m_0F%C9<12&4=LU^W3>x>cDD>*_W|(k9%V`U3XO3HXo$JJ*-9mg@Z@w0p ze<98==ai*E5)*Oxul zN6Qf`O-u~zn}o~e2i0HF)}Hlt*Y7h2!|hkUo3*6s5YtAT#x$?LZT!>tEGj}g)`-v8 zr(JK6sb2r*60g#SE#dD!RKGR6bY8*ZfKL7Aj?$FBcOnPRD+EfKQpGsiP`eLd-0C!$|`_W50F{F_Og zO;7N*Q1IN%83nf;Lfxc7PDJu4nu`fej>X ziwZ7$lXf!u-udemuhA<1+04#+9TnaeOq|tX@Id^JhOO@-tfaKe` icQ@4A>i*f2W2H19K(JD3fuU|U`vsSSz30GPR|WtW7khO8 literal 0 HcmV?d00001 diff --git a/Modules/AzBobbyTables/3.5.1/dependencies/Microsoft.Win32.Registry.dll b/Modules/AzBobbyTables/3.6.0/dependencies/Microsoft.Win32.Registry.dll similarity index 100% rename from Modules/AzBobbyTables/3.5.1/dependencies/Microsoft.Win32.Registry.dll rename to Modules/AzBobbyTables/3.6.0/dependencies/Microsoft.Win32.Registry.dll diff --git a/Modules/AzBobbyTables/3.5.1/dependencies/System.Buffers.dll b/Modules/AzBobbyTables/3.6.0/dependencies/System.Buffers.dll similarity index 100% rename from Modules/AzBobbyTables/3.5.1/dependencies/System.Buffers.dll rename to Modules/AzBobbyTables/3.6.0/dependencies/System.Buffers.dll diff --git a/Modules/AzBobbyTables/3.5.1/dependencies/System.ClientModel.dll b/Modules/AzBobbyTables/3.6.0/dependencies/System.ClientModel.dll similarity index 100% rename from Modules/AzBobbyTables/3.5.1/dependencies/System.ClientModel.dll rename to Modules/AzBobbyTables/3.6.0/dependencies/System.ClientModel.dll diff --git a/Modules/AzBobbyTables/3.5.1/dependencies/System.Diagnostics.DiagnosticSource.dll b/Modules/AzBobbyTables/3.6.0/dependencies/System.Diagnostics.DiagnosticSource.dll similarity index 100% rename from Modules/AzBobbyTables/3.5.1/dependencies/System.Diagnostics.DiagnosticSource.dll rename to Modules/AzBobbyTables/3.6.0/dependencies/System.Diagnostics.DiagnosticSource.dll diff --git a/Modules/AzBobbyTables/3.5.1/dependencies/System.Interactive.Async.dll b/Modules/AzBobbyTables/3.6.0/dependencies/System.Interactive.Async.dll similarity index 100% rename from Modules/AzBobbyTables/3.5.1/dependencies/System.Interactive.Async.dll rename to Modules/AzBobbyTables/3.6.0/dependencies/System.Interactive.Async.dll diff --git a/Modules/AzBobbyTables/3.5.1/dependencies/System.Linq.Async.dll b/Modules/AzBobbyTables/3.6.0/dependencies/System.Linq.Async.dll similarity index 100% rename from Modules/AzBobbyTables/3.5.1/dependencies/System.Linq.Async.dll rename to Modules/AzBobbyTables/3.6.0/dependencies/System.Linq.Async.dll diff --git a/Modules/AzBobbyTables/3.5.1/dependencies/System.Linq.AsyncEnumerable.dll b/Modules/AzBobbyTables/3.6.0/dependencies/System.Linq.AsyncEnumerable.dll similarity index 100% rename from Modules/AzBobbyTables/3.5.1/dependencies/System.Linq.AsyncEnumerable.dll rename to Modules/AzBobbyTables/3.6.0/dependencies/System.Linq.AsyncEnumerable.dll diff --git a/Modules/AzBobbyTables/3.5.1/dependencies/System.Memory.Data.dll b/Modules/AzBobbyTables/3.6.0/dependencies/System.Memory.Data.dll similarity index 100% rename from Modules/AzBobbyTables/3.5.1/dependencies/System.Memory.Data.dll rename to Modules/AzBobbyTables/3.6.0/dependencies/System.Memory.Data.dll diff --git a/Modules/AzBobbyTables/3.5.1/dependencies/System.Memory.dll b/Modules/AzBobbyTables/3.6.0/dependencies/System.Memory.dll similarity index 100% rename from Modules/AzBobbyTables/3.5.1/dependencies/System.Memory.dll rename to Modules/AzBobbyTables/3.6.0/dependencies/System.Memory.dll diff --git a/Modules/AzBobbyTables/3.5.1/dependencies/System.Numerics.Vectors.dll b/Modules/AzBobbyTables/3.6.0/dependencies/System.Numerics.Vectors.dll similarity index 100% rename from Modules/AzBobbyTables/3.5.1/dependencies/System.Numerics.Vectors.dll rename to Modules/AzBobbyTables/3.6.0/dependencies/System.Numerics.Vectors.dll diff --git a/Modules/AzBobbyTables/3.5.1/dependencies/System.Runtime.CompilerServices.Unsafe.dll b/Modules/AzBobbyTables/3.6.0/dependencies/System.Runtime.CompilerServices.Unsafe.dll similarity index 100% rename from Modules/AzBobbyTables/3.5.1/dependencies/System.Runtime.CompilerServices.Unsafe.dll rename to Modules/AzBobbyTables/3.6.0/dependencies/System.Runtime.CompilerServices.Unsafe.dll diff --git a/Modules/AzBobbyTables/3.5.1/dependencies/System.Security.AccessControl.dll b/Modules/AzBobbyTables/3.6.0/dependencies/System.Security.AccessControl.dll similarity index 100% rename from Modules/AzBobbyTables/3.5.1/dependencies/System.Security.AccessControl.dll rename to Modules/AzBobbyTables/3.6.0/dependencies/System.Security.AccessControl.dll diff --git a/Modules/AzBobbyTables/3.5.1/dependencies/System.Security.Principal.Windows.dll b/Modules/AzBobbyTables/3.6.0/dependencies/System.Security.Principal.Windows.dll similarity index 100% rename from Modules/AzBobbyTables/3.5.1/dependencies/System.Security.Principal.Windows.dll rename to Modules/AzBobbyTables/3.6.0/dependencies/System.Security.Principal.Windows.dll diff --git a/Modules/AzBobbyTables/3.5.1/dependencies/System.Text.Encodings.Web.dll b/Modules/AzBobbyTables/3.6.0/dependencies/System.Text.Encodings.Web.dll similarity index 100% rename from Modules/AzBobbyTables/3.5.1/dependencies/System.Text.Encodings.Web.dll rename to Modules/AzBobbyTables/3.6.0/dependencies/System.Text.Encodings.Web.dll diff --git a/Modules/AzBobbyTables/3.5.1/dependencies/System.Text.Json.dll b/Modules/AzBobbyTables/3.6.0/dependencies/System.Text.Json.dll similarity index 100% rename from Modules/AzBobbyTables/3.5.1/dependencies/System.Text.Json.dll rename to Modules/AzBobbyTables/3.6.0/dependencies/System.Text.Json.dll diff --git a/Modules/AzBobbyTables/3.5.1/dependencies/System.Threading.Tasks.Extensions.dll b/Modules/AzBobbyTables/3.6.0/dependencies/System.Threading.Tasks.Extensions.dll similarity index 100% rename from Modules/AzBobbyTables/3.5.1/dependencies/System.Threading.Tasks.Extensions.dll rename to Modules/AzBobbyTables/3.6.0/dependencies/System.Threading.Tasks.Extensions.dll diff --git a/Modules/AzBobbyTables/3.5.1/en-US/AzBobbyTables.PS.dll-Help.xml b/Modules/AzBobbyTables/3.6.0/en-US/AzBobbyTables.PS.dll-Help.xml similarity index 100% rename from Modules/AzBobbyTables/3.5.1/en-US/AzBobbyTables.PS.dll-Help.xml rename to Modules/AzBobbyTables/3.6.0/en-US/AzBobbyTables.PS.dll-Help.xml From 8ff71da63dd808e38fdb020aad625253f7a19eb3 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Wed, 1 Jul 2026 22:44:56 +0800 Subject: [PATCH 075/170] ACSC Essential Eight Test Suite --- .../Tests/Push-CIPPTestsList.ps1 | 2 +- .../Public/Invoke-CIPPTestCollection.ps1 | 4 +- .../Public/Helpers/Test-E8AsrRule.ps1 | 66 ++++++++++++++++ .../Devices/Invoke-CippTestE8_AppCtrl_01.md | 13 ++++ .../Devices/Invoke-CippTestE8_AppCtrl_01.ps1 | 41 ++++++++++ .../Devices/Invoke-CippTestE8_AppCtrl_02.md | 12 +++ .../Devices/Invoke-CippTestE8_AppCtrl_02.ps1 | 8 ++ .../Devices/Invoke-CippTestE8_AppCtrl_03.md | 12 +++ .../Devices/Invoke-CippTestE8_AppCtrl_03.ps1 | 8 ++ .../Devices/Invoke-CippTestE8_AppCtrl_04.md | 12 +++ .../Devices/Invoke-CippTestE8_AppCtrl_04.ps1 | 8 ++ .../Devices/Invoke-CippTestE8_AppCtrl_05.md | 12 +++ .../Devices/Invoke-CippTestE8_AppCtrl_05.ps1 | 8 ++ .../Devices/Invoke-CippTestE8_AppHard_01.md | 13 ++++ .../Devices/Invoke-CippTestE8_AppHard_01.ps1 | 9 +++ .../Devices/Invoke-CippTestE8_AppHard_02.md | 12 +++ .../Devices/Invoke-CippTestE8_AppHard_02.ps1 | 9 +++ .../Devices/Invoke-CippTestE8_AppHard_03.md | 12 +++ .../Devices/Invoke-CippTestE8_AppHard_03.ps1 | 9 +++ .../Devices/Invoke-CippTestE8_AppHard_04.md | 12 +++ .../Devices/Invoke-CippTestE8_AppHard_04.ps1 | 9 +++ .../Devices/Invoke-CippTestE8_AppHard_05.md | 12 +++ .../Devices/Invoke-CippTestE8_AppHard_05.ps1 | 9 +++ .../Devices/Invoke-CippTestE8_AppHard_06.md | 13 ++++ .../Devices/Invoke-CippTestE8_AppHard_06.ps1 | 12 +++ .../Devices/Invoke-CippTestE8_AppHard_07.md | 13 ++++ .../Devices/Invoke-CippTestE8_AppHard_07.ps1 | 12 +++ .../Devices/Invoke-CippTestE8_AppHard_08.md | 12 +++ .../Devices/Invoke-CippTestE8_AppHard_08.ps1 | 12 +++ .../Devices/Invoke-CippTestE8_AppHard_09.md | 12 +++ .../Devices/Invoke-CippTestE8_AppHard_09.ps1 | 12 +++ .../Devices/Invoke-CippTestE8_AppHard_10.md | 13 ++++ .../Devices/Invoke-CippTestE8_AppHard_10.ps1 | 12 +++ .../Devices/Invoke-CippTestE8_AppHard_11.md | 13 ++++ .../Devices/Invoke-CippTestE8_AppHard_11.ps1 | 12 +++ .../Devices/Invoke-CippTestE8_AppHard_12.md | 13 ++++ .../Devices/Invoke-CippTestE8_AppHard_12.ps1 | 12 +++ .../Devices/Invoke-CippTestE8_AppHard_13.md | 12 +++ .../Devices/Invoke-CippTestE8_AppHard_13.ps1 | 12 +++ .../Devices/Invoke-CippTestE8_AppHard_14.md | 13 ++++ .../Devices/Invoke-CippTestE8_AppHard_14.ps1 | 12 +++ .../E8/Devices/Invoke-CippTestE8_Macro_01.md | 14 ++++ .../E8/Devices/Invoke-CippTestE8_Macro_01.ps1 | 14 ++++ .../E8/Devices/Invoke-CippTestE8_Macro_02.md | 13 ++++ .../E8/Devices/Invoke-CippTestE8_Macro_02.ps1 | 12 +++ .../E8/Devices/Invoke-CippTestE8_Macro_03.md | 13 ++++ .../E8/Devices/Invoke-CippTestE8_Macro_03.ps1 | 12 +++ .../E8/Devices/Invoke-CippTestE8_Macro_04.md | 13 ++++ .../E8/Devices/Invoke-CippTestE8_Macro_04.ps1 | 12 +++ .../E8/Devices/Invoke-CippTestE8_Macro_05.md | 13 ++++ .../E8/Devices/Invoke-CippTestE8_Macro_05.ps1 | 12 +++ .../E8/Devices/Invoke-CippTestE8_Macro_06.md | 13 ++++ .../E8/Devices/Invoke-CippTestE8_Macro_06.ps1 | 9 +++ .../E8/Devices/Invoke-CippTestE8_Macro_07.md | 12 +++ .../E8/Devices/Invoke-CippTestE8_Macro_07.ps1 | 9 +++ .../Devices/Invoke-CippTestE8_PatchApp_01.md | 13 ++++ .../Devices/Invoke-CippTestE8_PatchApp_01.ps1 | 8 ++ .../Devices/Invoke-CippTestE8_PatchApp_02.md | 12 +++ .../Devices/Invoke-CippTestE8_PatchApp_02.ps1 | 41 ++++++++++ .../Devices/Invoke-CippTestE8_PatchApp_03.md | 12 +++ .../Devices/Invoke-CippTestE8_PatchApp_03.ps1 | 8 ++ .../Devices/Invoke-CippTestE8_PatchApp_04.md | 12 +++ .../Devices/Invoke-CippTestE8_PatchApp_04.ps1 | 8 ++ .../Devices/Invoke-CippTestE8_PatchOS_01.md | 13 ++++ .../Devices/Invoke-CippTestE8_PatchOS_01.ps1 | 43 +++++++++++ .../Devices/Invoke-CippTestE8_PatchOS_02.md | 13 ++++ .../Devices/Invoke-CippTestE8_PatchOS_02.ps1 | 52 +++++++++++++ .../Devices/Invoke-CippTestE8_PatchOS_03.md | 13 ++++ .../Devices/Invoke-CippTestE8_PatchOS_03.ps1 | 38 ++++++++++ .../Devices/Invoke-CippTestE8_PatchOS_04.md | 13 ++++ .../Devices/Invoke-CippTestE8_PatchOS_04.ps1 | 42 +++++++++++ .../Devices/Invoke-CippTestE8_PatchOS_05.md | 13 ++++ .../Devices/Invoke-CippTestE8_PatchOS_05.ps1 | 8 ++ .../Devices/Invoke-CippTestE8_PatchOS_06.md | 13 ++++ .../Devices/Invoke-CippTestE8_PatchOS_06.ps1 | 33 ++++++++ .../E8/Identity/Invoke-CippTestE8_Admin_01.md | 14 ++++ .../Identity/Invoke-CippTestE8_Admin_01.ps1 | 59 +++++++++++++++ .../E8/Identity/Invoke-CippTestE8_Admin_02.md | 14 ++++ .../Identity/Invoke-CippTestE8_Admin_02.ps1 | 61 +++++++++++++++ .../E8/Identity/Invoke-CippTestE8_Admin_03.md | 16 ++++ .../Identity/Invoke-CippTestE8_Admin_03.ps1 | 47 ++++++++++++ .../E8/Identity/Invoke-CippTestE8_Admin_04.md | 14 ++++ .../Identity/Invoke-CippTestE8_Admin_04.ps1 | 47 ++++++++++++ .../E8/Identity/Invoke-CippTestE8_Admin_05.md | 14 ++++ .../Identity/Invoke-CippTestE8_Admin_05.ps1 | 69 +++++++++++++++++ .../E8/Identity/Invoke-CippTestE8_Admin_06.md | 14 ++++ .../Identity/Invoke-CippTestE8_Admin_06.ps1 | 9 +++ .../E8/Identity/Invoke-CippTestE8_Admin_07.md | 14 ++++ .../Identity/Invoke-CippTestE8_Admin_07.ps1 | 75 +++++++++++++++++++ .../E8/Identity/Invoke-CippTestE8_Admin_08.md | 14 ++++ .../Identity/Invoke-CippTestE8_Admin_08.ps1 | 70 +++++++++++++++++ .../E8/Identity/Invoke-CippTestE8_Admin_09.md | 13 ++++ .../Identity/Invoke-CippTestE8_Admin_09.ps1 | 9 +++ .../E8/Identity/Invoke-CippTestE8_Admin_10.md | 13 ++++ .../Identity/Invoke-CippTestE8_Admin_10.ps1 | 9 +++ .../E8/Identity/Invoke-CippTestE8_Admin_11.md | 13 ++++ .../Identity/Invoke-CippTestE8_Admin_11.ps1 | 45 +++++++++++ .../E8/Identity/Invoke-CippTestE8_Admin_12.md | 13 ++++ .../Identity/Invoke-CippTestE8_Admin_12.ps1 | 58 ++++++++++++++ .../E8/Identity/Invoke-CippTestE8_Admin_13.md | 13 ++++ .../Identity/Invoke-CippTestE8_Admin_13.ps1 | 9 +++ .../Identity/Invoke-CippTestE8_Backup_01.md | 13 ++++ .../Identity/Invoke-CippTestE8_Backup_01.ps1 | 49 ++++++++++++ .../Identity/Invoke-CippTestE8_Backup_02.md | 13 ++++ .../Identity/Invoke-CippTestE8_Backup_02.ps1 | 8 ++ .../Identity/Invoke-CippTestE8_Backup_03.md | 12 +++ .../Identity/Invoke-CippTestE8_Backup_03.ps1 | 8 ++ .../Identity/Invoke-CippTestE8_Backup_04.md | 14 ++++ .../Identity/Invoke-CippTestE8_Backup_04.ps1 | 8 ++ .../E8/Identity/Invoke-CippTestE8_MFA_01.md | 14 ++++ .../E8/Identity/Invoke-CippTestE8_MFA_01.ps1 | 49 ++++++++++++ .../E8/Identity/Invoke-CippTestE8_MFA_02.md | 16 ++++ .../E8/Identity/Invoke-CippTestE8_MFA_02.ps1 | 42 +++++++++++ .../E8/Identity/Invoke-CippTestE8_MFA_03.md | 17 +++++ .../E8/Identity/Invoke-CippTestE8_MFA_03.ps1 | 39 ++++++++++ .../E8/Identity/Invoke-CippTestE8_MFA_04.md | 14 ++++ .../E8/Identity/Invoke-CippTestE8_MFA_04.ps1 | 40 ++++++++++ .../E8/Identity/Invoke-CippTestE8_MFA_05.md | 14 ++++ .../E8/Identity/Invoke-CippTestE8_MFA_05.ps1 | 38 ++++++++++ .../E8/Identity/Invoke-CippTestE8_MFA_06.md | 16 ++++ .../E8/Identity/Invoke-CippTestE8_MFA_06.ps1 | 47 ++++++++++++ .../E8/Identity/Invoke-CippTestE8_MFA_07.md | 14 ++++ .../E8/Identity/Invoke-CippTestE8_MFA_07.ps1 | 67 +++++++++++++++++ .../E8/Identity/Invoke-CippTestE8_MFA_08.md | 14 ++++ .../E8/Identity/Invoke-CippTestE8_MFA_08.ps1 | 50 +++++++++++++ .../E8/Identity/Invoke-CippTestE8_MFA_09.md | 14 ++++ .../E8/Identity/Invoke-CippTestE8_MFA_09.ps1 | 46 ++++++++++++ .../E8/Identity/Invoke-CippTestE8_MFA_10.md | 17 +++++ .../E8/Identity/Invoke-CippTestE8_MFA_10.ps1 | 41 ++++++++++ Modules/CIPPTests/Public/Tests/E8/report.json | 74 ++++++++++++++++++ 130 files changed, 2665 insertions(+), 2 deletions(-) create mode 100644 Modules/CIPPTests/Public/Helpers/Test-E8AsrRule.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppCtrl_01.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppCtrl_01.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppCtrl_02.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppCtrl_02.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppCtrl_03.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppCtrl_03.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppCtrl_04.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppCtrl_04.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppCtrl_05.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppCtrl_05.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_01.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_01.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_02.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_02.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_03.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_03.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_04.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_04.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_05.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_05.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_06.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_06.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_07.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_07.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_08.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_08.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_09.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_09.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_10.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_10.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_11.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_11.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_12.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_12.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_13.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_13.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_14.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_14.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_01.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_01.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_02.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_02.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_03.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_03.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_04.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_04.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_05.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_05.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_06.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_06.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_07.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_07.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchApp_01.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchApp_01.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchApp_02.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchApp_02.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchApp_03.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchApp_03.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchApp_04.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchApp_04.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_01.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_01.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_02.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_02.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_03.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_03.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_04.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_04.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_05.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_05.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_06.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_06.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_01.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_01.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_02.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_02.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_03.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_03.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_04.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_04.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_05.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_05.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_06.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_06.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_07.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_07.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_08.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_08.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_09.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_09.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_10.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_10.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_11.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_11.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_12.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_12.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_13.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_13.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Backup_01.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Backup_01.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Backup_02.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Backup_02.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Backup_03.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Backup_03.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Backup_04.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Backup_04.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_01.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_01.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_02.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_02.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_03.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_03.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_04.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_04.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_05.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_05.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_06.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_06.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_07.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_07.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_08.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_08.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_09.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_09.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_10.md create mode 100644 Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_10.ps1 create mode 100644 Modules/CIPPTests/Public/Tests/E8/report.json diff --git a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Tests/Push-CIPPTestsList.ps1 b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Tests/Push-CIPPTestsList.ps1 index d018d1d0aa9d5..ead567fdb9bf5 100644 --- a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Tests/Push-CIPPTestsList.ps1 +++ b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Tests/Push-CIPPTestsList.ps1 @@ -37,7 +37,7 @@ function Push-CIPPTestsList { # Emit one task per suite — suite names must match the ValidateSet in Invoke-CIPPTestCollection. # Function discovery happens inside Invoke-CIPPTestCollection via Get-Command (path-independent). - $Suites = @('ZTNA', 'ORCA', 'EIDSCA', 'CISA', 'CIS', 'SMB1001', 'CopilotReadiness', 'GenericTests', 'Custom') + $Suites = @('ZTNA', 'ORCA', 'EIDSCA', 'CISA', 'CIS', 'SMB1001', 'CopilotReadiness', 'GenericTests', 'Custom', 'E8') $Tasks = foreach ($Suite in $Suites) { [PSCustomObject]@{ diff --git a/Modules/CIPPCore/Public/Invoke-CIPPTestCollection.ps1 b/Modules/CIPPCore/Public/Invoke-CIPPTestCollection.ps1 index e03fd8ee901f7..1963ff0b6de30 100644 --- a/Modules/CIPPCore/Public/Invoke-CIPPTestCollection.ps1 +++ b/Modules/CIPPCore/Public/Invoke-CIPPTestCollection.ps1 @@ -17,6 +17,7 @@ function Invoke-CIPPTestCollection { - CIS → Invoke-CippTestCIS_* - SMB1001 → Invoke-CippTestSMB1001_* - CopilotReadiness → Invoke-CippTestCopilotReady* + - E8 → Invoke-CippTestE8_* - Custom → Special: enumerates enabled ScriptGuids from DB and calls Invoke-CippTestCustomScripts once per guid (the function requires a ScriptGuid parameter to filter the table query) @@ -33,7 +34,7 @@ function Invoke-CIPPTestCollection { [CmdletBinding()] param( [Parameter(Mandatory = $true)] - [ValidateSet('ZTNA', 'ORCA', 'EIDSCA', 'CISA', 'CIS', 'SMB1001', 'CopilotReadiness', 'GenericTests', 'Custom')] + [ValidateSet('ZTNA', 'ORCA', 'EIDSCA', 'CISA', 'CIS', 'SMB1001', 'CopilotReadiness', 'GenericTests', 'E8', 'Custom')] [string]$SuiteName, [Parameter(Mandatory = $true)] @@ -51,6 +52,7 @@ function Invoke-CIPPTestCollection { SMB1001 = 'Invoke-CippTestSMB1001_*' CopilotReadiness = 'Invoke-CippTestCopilotReady*' GenericTests = 'Invoke-CippTestGenericTest*' + E8 = 'Invoke-CippTestE8_*' } $SuiteStopwatch = [System.Diagnostics.Stopwatch]::StartNew() diff --git a/Modules/CIPPTests/Public/Helpers/Test-E8AsrRule.ps1 b/Modules/CIPPTests/Public/Helpers/Test-E8AsrRule.ps1 new file mode 100644 index 0000000000000..da1f1d06ec693 --- /dev/null +++ b/Modules/CIPPTests/Public/Helpers/Test-E8AsrRule.ps1 @@ -0,0 +1,66 @@ +function Test-E8AsrRule { + <# + .SYNOPSIS + Internal helper used by E8 Macro/AppHard tests to verify a single Defender ASR rule child setting is enabled and assigned. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] [string] $Tenant, + [Parameter(Mandatory)] [string] $TestId, + [Parameter(Mandatory)] [string] $Name, + [Parameter(Mandatory)] [string] $RuleSettingId, + [Parameter(Mandatory)] [string] $FriendlyRule, + [string] $Risk = 'High', + [string] $Category, + [string] $UserImpact = 'Medium', + [string] $ImplementationEffort = 'Medium' + ) + + try { + $ConfigPolicies = Get-CIPPTestData -TenantFilter $Tenant -Type 'IntuneConfigurationPolicies' + if (-not $ConfigPolicies) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status 'Skipped' -ResultMarkdown 'No Intune Configuration Policies cached for this tenant.' -Risk $Risk -Name $Name -UserImpact $UserImpact -ImplementationEffort $ImplementationEffort -Category $Category + return + } + + $AsrPolicies = $ConfigPolicies | Where-Object { + $_.platforms -like '*windows10*' -and + $_.technologies -like '*mdm*' -and + ($_.settings.settingInstance.settingDefinitionId -contains 'device_vendor_msft_policy_config_defender_attacksurfacereductionrules') + } + + if (-not $AsrPolicies) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status 'Failed' -ResultMarkdown 'No Defender Attack Surface Reduction policy is configured for Windows 10/11.' -Risk $Risk -Name $Name -UserImpact $UserImpact -ImplementationEffort $ImplementationEffort -Category $Category + return + } + + $Matching = foreach ($P in $AsrPolicies) { + $children = $P.settings.settingInstance.groupSettingCollectionValue.children + $found = $children | Where-Object { $_.settingDefinitionId -eq $RuleSettingId } + $value = $found.choiceSettingValue.value + if ($value -like '*_block' -or $value -like '*_warn') { + [pscustomobject]@{ Policy = $P; Value = $value } + } + } + + if (-not $Matching) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status 'Failed' -ResultMarkdown "No ASR policy enables ``$FriendlyRule`` (in Block or Warn mode)." -Risk $Risk -Name $Name -UserImpact $UserImpact -ImplementationEffort $ImplementationEffort -Category $Category + return + } + + $Assigned = $Matching | Where-Object { $_.Policy.assignments -and $_.Policy.assignments.Count -gt 0 } + + if ($Assigned) { + $Status = 'Passed' + $Result = "ASR rule ``$FriendlyRule`` is enabled and assigned in $($Assigned.Count) policy/policies." + } else { + $Status = 'Failed' + $Result = "ASR rule ``$FriendlyRule`` is configured but not assigned to any group/device." + } + + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status $Status -ResultMarkdown $Result -Risk $Risk -Name $Name -UserImpact $UserImpact -ImplementationEffort $ImplementationEffort -Category $Category + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk $Risk -Name $Name -UserImpact $UserImpact -ImplementationEffort $ImplementationEffort -Category $Category + } +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppCtrl_01.md b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppCtrl_01.md new file mode 100644 index 0000000000000..504d1286c14b6 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppCtrl_01.md @@ -0,0 +1,13 @@ +Application control (allowlisting) is the most effective single mitigation against malware. Implement WDAC, Smart App Control, or AppLocker on all Windows endpoints. + +**Remediation Action** + +1. Intune > Endpoint security > Account protection / Attack surface reduction > **App and browser control** or **Microsoft Defender Application Control (WDAC)**. +2. Deploy a base policy in audit mode, then move to enforced mode. +3. Assign to all Windows devices. + +**Links** +- [WDAC overview](https://learn.microsoft.com/en-us/windows/security/application-security/application-control/windows-defender-application-control/) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppCtrl_01.ps1 b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppCtrl_01.ps1 new file mode 100644 index 0000000000000..1b650e5e478d0 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppCtrl_01.ps1 @@ -0,0 +1,41 @@ +function Invoke-CippTestE8_AppCtrl_01 { + <# + .SYNOPSIS + ACSC Essential Eight (Application Control, ML1) - Application control is implemented on workstations + #> + param($Tenant) + + $TestId = 'E8_AppCtrl_01' + $Name = 'Application control (WDAC / Smart App Control / AppLocker) is configured for Windows endpoints' + + try { + $ConfigPolicies = Get-CIPPTestData -TenantFilter $Tenant -Type 'IntuneConfigurationPolicies' + + if (-not $ConfigPolicies) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status 'Skipped' -ResultMarkdown 'No Intune Configuration Policies cached for this tenant.' -Risk 'High' -Name $Name -UserImpact 'High' -ImplementationEffort 'High' -Category 'E8 ML1 - Application Control' + return + } + + $AppControlPolicies = $ConfigPolicies | Where-Object { + $ids = $_.settings.settingInstance.settingDefinitionId + ($ids -match 'applicationcontrol') -or ($ids -match 'smartappcontrol') -or ($ids -match 'applocker') + } + $Assigned = $AppControlPolicies | Where-Object { $_.assignments -and $_.assignments.Count -gt 0 } + + if ($Assigned) { + $Status = 'Passed' + $Result = "$($Assigned.Count) application-control policy/policies (WDAC/Smart App Control/AppLocker) are configured and assigned." + } elseif ($AppControlPolicies) { + $Status = 'Failed' + $Result = "$($AppControlPolicies.Count) application-control policy/policies exist but none are assigned." + } else { + $Status = 'Failed' + $Result = 'No WDAC, Smart App Control, or AppLocker configuration policy is deployed via Intune.' + } + + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name $Name -UserImpact 'High' -ImplementationEffort 'High' -Category 'E8 ML1 - Application Control' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name $Name -UserImpact 'High' -ImplementationEffort 'High' -Category 'E8 ML1 - Application Control' + } +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppCtrl_02.md b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppCtrl_02.md new file mode 100644 index 0000000000000..e85bbf1e8c602 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppCtrl_02.md @@ -0,0 +1,12 @@ +ISM-0843 — application control covers more than `.exe`. Scripts (PS1/JS/VBS), DLLs, MSIs, HTAs, drivers, and control panel applets must all be subject to allowlisting. + +**Remediation Action** + +1. Author / extend WDAC policy XML to set `Enabled:Audit Mode` off for the additional file rule levels (DLL, Script, MSI, etc.). +2. Deploy via Intune > Endpoint security > Application Control for Business policy XML. + +**Links** +- [WDAC policy file rules](https://learn.microsoft.com/en-us/windows/security/application-security/application-control/windows-defender-application-control/design/select-types-of-rules-to-create) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppCtrl_02.ps1 b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppCtrl_02.ps1 new file mode 100644 index 0000000000000..ecc0ac4cb9a2d --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppCtrl_02.ps1 @@ -0,0 +1,8 @@ +function Invoke-CippTestE8_AppCtrl_02 { + <# + .SYNOPSIS + ACSC Essential Eight (Application Control, ML2) - App control allowlist covers all executable types (ISM-0843) + #> + param($Tenant) + Add-CippTestResult -TenantFilter $Tenant -TestId 'E8_AppCtrl_02' -TestType 'Devices' -Status 'Informational' -ResultMarkdown 'This is a task performed manually. Confirm WDAC/AppLocker rules cover executables, software libraries (DLLs/OCX), scripts (PS1, JS, VBS), installers (MSI/MSIX), compiled HTML, HTA, control panel applets, and drivers. The full rule contents are stored as XML inside Intune profiles which are not easily summarised; review the deployed policy in Intune.' -Risk 'High' -Name 'Application control covers all executable types (ISM-0843)' -UserImpact 'High' -ImplementationEffort 'High' -Category 'E8 ML2 - Application Control' +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppCtrl_03.md b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppCtrl_03.md new file mode 100644 index 0000000000000..34246723caa07 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppCtrl_03.md @@ -0,0 +1,12 @@ +Microsoft maintains a Recommended Block Rules list that bans known LOLBin abuse (e.g. `bginfo`, `cdb`, `csi`, `dnx`, `mshta` minus exceptions). Merge this list into your WDAC policy. + +**Remediation Action** + +1. Download the latest WDAC recommended block rules XML from Microsoft. +2. Merge with your base policy and re-deploy via Intune. + +**Links** +- [Microsoft recommended block rules](https://learn.microsoft.com/en-us/windows/security/application-security/application-control/windows-defender-application-control/design/applications-that-can-bypass-wdac) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppCtrl_03.ps1 b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppCtrl_03.ps1 new file mode 100644 index 0000000000000..e9f531eeb1f3d --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppCtrl_03.ps1 @@ -0,0 +1,8 @@ +function Invoke-CippTestE8_AppCtrl_03 { + <# + .SYNOPSIS + ACSC Essential Eight (Application Control, ML2) - Microsoft recommended block list is implemented + #> + param($Tenant) + Add-CippTestResult -TenantFilter $Tenant -TestId 'E8_AppCtrl_03' -TestType 'Devices' -Status 'Informational' -ResultMarkdown 'This is a task performed manually. Confirm Microsoft''s **recommended block rules** (LOLBins such as bash, bginfo, cdb, msbuild, powershell_ise.exe, etc.) are deployed via WDAC. The block list is published as XML at `https://aka.ms/wdac-block-rules` and is delivered through an Intune WDAC policy XML file.' -Risk 'High' -Name 'Microsoft recommended WDAC block rules are deployed' -UserImpact 'Medium' -ImplementationEffort 'Medium' -Category 'E8 ML2 - Application Control' +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppCtrl_04.md b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppCtrl_04.md new file mode 100644 index 0000000000000..5a06847acb07e --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppCtrl_04.md @@ -0,0 +1,12 @@ +Microsoft's vulnerable driver blocklist prevents kernel-level BYOVD attacks. On Windows 11 22H2+ it is auto-enabled with Memory Integrity; older builds require a WDAC driver policy. + +**Remediation Action** + +1. Intune > Settings catalog > **Memory Integrity / HVCI** = Enabled. +2. Confirm `Enable Microsoft Vulnerable Driver Blocklist` is on. + +**Links** +- [Microsoft recommended driver block rules](https://learn.microsoft.com/en-us/windows/security/application-security/application-control/microsoft-recommended-driver-block-rules) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppCtrl_04.ps1 b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppCtrl_04.ps1 new file mode 100644 index 0000000000000..939f67181246d --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppCtrl_04.ps1 @@ -0,0 +1,8 @@ +function Invoke-CippTestE8_AppCtrl_04 { + <# + .SYNOPSIS + ACSC Essential Eight (Application Control, ML2) - Microsoft recommended driver block list is implemented + #> + param($Tenant) + Add-CippTestResult -TenantFilter $Tenant -TestId 'E8_AppCtrl_04' -TestType 'Devices' -Status 'Informational' -ResultMarkdown 'This is a task performed manually. Confirm the Microsoft Vulnerable Driver Blocklist is enabled via *Memory Integrity / Core Isolation*, or via WDAC driver block XML. From Windows 11 22H2 the blocklist is on by default when Memory Integrity is enabled; verify in Settings catalog under *Defender > Allow Memory Integrity*.' -Risk 'High' -Name 'Microsoft vulnerable driver blocklist is deployed' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML2 - Application Control' +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppCtrl_05.md b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppCtrl_05.md new file mode 100644 index 0000000000000..6e21e03d58393 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppCtrl_05.md @@ -0,0 +1,12 @@ +App-control logs (CodeIntegrity event log, AppLocker event logs) must be centrally collected so blocked-execution events become detection signals. + +**Remediation Action** + +1. Sentinel > Data connectors > Windows Security Events via AMA — include `Microsoft-Windows-CodeIntegrity/Operational` and `Microsoft-Windows-AppLocker/EXE and DLL`. +2. Or: ingest via Defender for Endpoint Advanced Hunting (`DeviceEvents | where ActionType startswith "AppControlCodeIntegrity"`). + +**Links** +- [WDAC event logs](https://learn.microsoft.com/en-us/windows/security/application-security/application-control/windows-defender-application-control/operations/event-id-explanations) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppCtrl_05.ps1 b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppCtrl_05.ps1 new file mode 100644 index 0000000000000..489faed92a296 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppCtrl_05.ps1 @@ -0,0 +1,8 @@ +function Invoke-CippTestE8_AppCtrl_05 { + <# + .SYNOPSIS + ACSC Essential Eight (Application Control, ML3) - Application control event logs are centrally collected + #> + param($Tenant) + Add-CippTestResult -TenantFilter $Tenant -TestId 'E8_AppCtrl_05' -TestType 'Devices' -Status 'Informational' -ResultMarkdown 'This is a task performed manually. Confirm WDAC / AppLocker event logs (Microsoft-Windows-CodeIntegrity, Microsoft-Windows-AppLocker) are forwarded to a SIEM (Sentinel via the Windows Security Events connector or Defender for Endpoint AdvancedHunting).' -Risk 'Medium' -Name 'Application control event logs are centrally collected' -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'E8 ML3 - Application Control' +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_01.md b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_01.md new file mode 100644 index 0000000000000..b31776277713e --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_01.md @@ -0,0 +1,13 @@ +Web browsers are the most-attacked client application in the enterprise. Disable Flash (now removed), Java applets, and reduce drive-by exposure with an enterprise ad-blocker. + +**Remediation Action** + +1. Intune > Configuration profiles > Settings catalog > Microsoft Edge. +2. Disable plug-ins (`PluginsBlockedForUrls = *`) and Java; deploy an enterprise ad-blocker (uBlock Origin / NoScript / Edge tracking prevention strict). +3. Repeat for Chrome / Firefox where deployed. + +**Links** +- [ACSC Essential Eight - User Application Hardening](https://learn.microsoft.com/en-us/compliance/anz/e8-uah) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_01.ps1 b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_01.ps1 new file mode 100644 index 0000000000000..16a0a60b1529b --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_01.ps1 @@ -0,0 +1,9 @@ +function Invoke-CippTestE8_AppHard_01 { + <# + .SYNOPSIS + ACSC Essential Eight (User Application Hardening, ML1) - Web browsers block Flash, web ads and Java content + #> + param($Tenant) + + Add-CippTestResult -TenantFilter $Tenant -TestId 'E8_AppHard_01' -TestType 'Devices' -Status 'Informational' -ResultMarkdown 'This is a task performed manually. Confirm Edge / Chrome / Firefox managed policies disable Flash and Java plugins, and that an enterprise ad-blocking solution is in place. Browser policies (e.g. Edge ADMX *PluginsBlockedForUrls*, *DefaultPluginsSetting*) live in the Settings Catalog; confirming end-to-end enforcement requires inspection beyond what is cached.' -Risk 'High' -Name 'Web browsers block Flash, web ads, and Java content (ISM-1486)' -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'E8 ML1 - User Application Hardening' +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_02.md b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_02.md new file mode 100644 index 0000000000000..3d9869a1fb8fc --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_02.md @@ -0,0 +1,12 @@ +Internet Explorer 11 is retired and unsupported. Even though Edge can render legacy sites in IE Mode, the standalone IE11 desktop must be disabled to prevent its insecure scripting engines being exposed. + +**Remediation Action** + +1. Intune > Settings catalog > Internet Explorer > **Disable Internet Explorer 11 as a standalone browser** = Enabled. +2. Curate the IE Mode site list in Edge Update for any legacy line-of-business apps. + +**Links** +- [Internet Explorer 11 desktop app retirement](https://learn.microsoft.com/en-us/lifecycle/announcements/internet-explorer-11-end-of-support-microsoft-365) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_02.ps1 b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_02.ps1 new file mode 100644 index 0000000000000..1da0a2a831aef --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_02.ps1 @@ -0,0 +1,9 @@ +function Invoke-CippTestE8_AppHard_02 { + <# + .SYNOPSIS + ACSC Essential Eight (User Application Hardening, ML1) - Internet Explorer 11 is disabled or removed + #> + param($Tenant) + + Add-CippTestResult -TenantFilter $Tenant -TestId 'E8_AppHard_02' -TestType 'Devices' -Status 'Informational' -ResultMarkdown 'This is a task performed manually. IE11 has been retired by Microsoft, but the legacy MSHTML engine and IE mode still exist on Windows. Confirm IE11 desktop is disabled via the *DisableInternetExplorerApp* policy and that any IE-mode site list is curated. CIPP cannot verify per-device installation state of legacy components from Graph.' -Risk 'High' -Name 'Internet Explorer 11 is disabled or removed (ISM-1666)' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML1 - User Application Hardening' +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_03.md b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_03.md new file mode 100644 index 0000000000000..78832029e053d --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_03.md @@ -0,0 +1,12 @@ +Malicious PDFs are a long-standing delivery vector. Configure the standard PDF viewer with Protected View on, JavaScript disabled, and external content blocked. + +**Remediation Action** + +1. Intune > Settings catalog > deploy ADMX for the chosen viewer (Adobe Acrobat, Foxit, Edge built-in viewer). +2. Disable JavaScript inside PDFs and enable Protected View / Sandbox. + +**Links** +- [Acrobat enterprise hardening](https://www.adobe.com/devnet-docs/acrobatetk/tools/AdminGuide/index.html) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_03.ps1 b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_03.ps1 new file mode 100644 index 0000000000000..d52b8cfd98777 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_03.ps1 @@ -0,0 +1,9 @@ +function Invoke-CippTestE8_AppHard_03 { + <# + .SYNOPSIS + ACSC Essential Eight (User Application Hardening, ML1) - PDF viewers are configured securely + #> + param($Tenant) + + Add-CippTestResult -TenantFilter $Tenant -TestId 'E8_AppHard_03' -TestType 'Devices' -Status 'Informational' -ResultMarkdown 'This is a task performed manually. Confirm the standard organisation PDF viewer (Edge, Adobe Acrobat Reader, Foxit, etc.) is configured with Protected View / Sandbox enabled and JavaScript disabled. PDF viewer configuration is application-specific and not exposed via Graph; verify by reviewing the deployed Intune ADMX/Settings Catalog policy.' -Risk 'High' -Name 'PDF viewers are configured securely (Protected View, no JavaScript)' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML1 - User Application Hardening' +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_04.md b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_04.md new file mode 100644 index 0000000000000..7ef2b88de046e --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_04.md @@ -0,0 +1,12 @@ +.NET Framework 3.5 (which carries .NET 2.0 and 3.0 runtimes) lacks modern hardening — no AMSI, no per-app strong name verification — and is a frequent ROP gadget source. Remove it from SOEs. + +**Remediation Action** + +1. Intune > Endpoint security > Compliance policies > custom compliance script: fail when `(Get-WindowsOptionalFeature -Online -FeatureName 'NetFx3').State -eq 'Enabled'`. +2. Remediate via PSscript: `Disable-WindowsOptionalFeature -Online -FeatureName NetFx3`. + +**Links** +- [.NET Framework lifecycle](https://learn.microsoft.com/en-us/lifecycle/products/microsoft-net-framework) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_04.ps1 b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_04.ps1 new file mode 100644 index 0000000000000..991de2ffee7a4 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_04.ps1 @@ -0,0 +1,9 @@ +function Invoke-CippTestE8_AppHard_04 { + <# + .SYNOPSIS + ACSC Essential Eight (User Application Hardening, ML1) - Legacy .NET Framework 3.5/2.0 is removed or disabled + #> + param($Tenant) + + Add-CippTestResult -TenantFilter $Tenant -TestId 'E8_AppHard_04' -TestType 'Devices' -Status 'Informational' -ResultMarkdown 'This is a task performed manually. Confirm .NET Framework 3.5 (which includes 2.0 and 3.0) is uninstalled or never installed on standard SOEs. CIPP can list detected applications via the Intune Discovered Apps inventory but the optional Windows feature state is not surfaced; verify with an Intune Compliance Policy or PowerShell script (Get-WindowsOptionalFeature -FeatureName NetFx3).' -Risk 'High' -Name '.NET Framework 3.5/2.0 is removed (ISM-1655)' -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'E8 ML1 - User Application Hardening' +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_05.md b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_05.md new file mode 100644 index 0000000000000..94b748e3d3a20 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_05.md @@ -0,0 +1,12 @@ +Windows PowerShell 2.0 lacks AMSI, ScriptBlockLogging, and the Constrained Language Mode hardening present in 5.1+. Attackers downgrade to v2 to bypass logging. Remove the optional feature. + +**Remediation Action** + +1. Intune > Compliance / Remediation script: `Disable-WindowsOptionalFeature -Online -FeatureName MicrosoftWindowsPowerShellV2,MicrosoftWindowsPowerShellV2Root -NoRestart`. +2. Verify with `Get-WindowsOptionalFeature -Online -FeatureName MicrosoftWindowsPowerShellV2*`. + +**Links** +- [PowerShell v2 deprecation](https://learn.microsoft.com/en-us/powershell/scripting/whats-new/what-s-new-in-powershell-50) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_05.ps1 b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_05.ps1 new file mode 100644 index 0000000000000..a79786597fc3e --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_05.ps1 @@ -0,0 +1,9 @@ +function Invoke-CippTestE8_AppHard_05 { + <# + .SYNOPSIS + ACSC Essential Eight (User Application Hardening, ML1) - Windows PowerShell 2.0 is removed or disabled + #> + param($Tenant) + + Add-CippTestResult -TenantFilter $Tenant -TestId 'E8_AppHard_05' -TestType 'Devices' -Status 'Informational' -ResultMarkdown 'This is a task performed manually. Confirm the Windows optional feature *MicrosoftWindowsPowerShellV2* is removed from all Windows endpoints. PowerShell 2.0 lacks AMSI and ScriptBlockLogging. Verify with an Intune compliance script (Get-WindowsOptionalFeature -FeatureName MicrosoftWindowsPowerShellV2*).' -Risk 'High' -Name 'Windows PowerShell 2.0 is removed (ISM-1622)' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML1 - User Application Hardening' +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_06.md b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_06.md new file mode 100644 index 0000000000000..e867d1a70201e --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_06.md @@ -0,0 +1,13 @@ +Credential theft from `lsass.exe` (e.g. Mimikatz) is the foundation of most lateral movement attacks. The ASR rule blocks reads against LSASS memory by untrusted processes. + +**Remediation Action** + +1. Intune > Endpoint security > Attack surface reduction. +2. Set *Block credential stealing from the Windows local security authority subsystem* to **Block**. +3. Assign to all Windows endpoints; pair with Credential Guard. + +**Links** +- [ASR rules reference](https://learn.microsoft.com/en-us/microsoft-365/security/defender-endpoint/attack-surface-reduction-rules-reference) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_06.ps1 b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_06.ps1 new file mode 100644 index 0000000000000..26078a0bf9864 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_06.ps1 @@ -0,0 +1,12 @@ +function Invoke-CippTestE8_AppHard_06 { + <# + .SYNOPSIS + ACSC Essential Eight (User Application Hardening, ML2) - ASR rule "Block credential stealing from LSASS" is enabled and assigned + #> + param($Tenant) + Test-E8AsrRule -Tenant $Tenant -TestId 'E8_AppHard_06' ` + -Name 'ASR rule "Block credential stealing from the Windows local security authority subsystem (lsass.exe)"' ` + -RuleSettingId 'device_vendor_msft_policy_config_defender_attacksurfacereductionrules_blockcredentialstealingfromwindowslocalsecurityauthoritysubsystem' ` + -FriendlyRule 'Block credential stealing from the Windows local security authority subsystem' ` + -Risk 'High' -Category 'E8 ML2 - User Application Hardening' +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_07.md b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_07.md new file mode 100644 index 0000000000000..cc2f7ea7a3942 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_07.md @@ -0,0 +1,13 @@ +Email is the #1 phishing vector. The ASR rule **Block executable content from email client and webmail** prevents Outlook (or web browsers viewing webmail) from saving and launching `.exe`/`.scr`/`.js` attachments. + +**Remediation Action** + +1. Intune > Endpoint security > Attack surface reduction. +2. Set *Block executable content from email client and webmail* to **Block**. +3. Assign to all Windows endpoints. + +**Links** +- [ASR rules reference](https://learn.microsoft.com/en-us/microsoft-365/security/defender-endpoint/attack-surface-reduction-rules-reference) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_07.ps1 b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_07.ps1 new file mode 100644 index 0000000000000..932ef79741997 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_07.ps1 @@ -0,0 +1,12 @@ +function Invoke-CippTestE8_AppHard_07 { + <# + .SYNOPSIS + ACSC Essential Eight (User Application Hardening, ML2) - ASR rule "Block executable content from email and webmail" is enabled and assigned + #> + param($Tenant) + Test-E8AsrRule -Tenant $Tenant -TestId 'E8_AppHard_07' ` + -Name 'ASR rule "Block executable content from email client and webmail"' ` + -RuleSettingId 'device_vendor_msft_policy_config_defender_attacksurfacereductionrules_blockexecutablecontentfromemailclientandwebmail' ` + -FriendlyRule 'Block executable content from email client and webmail' ` + -Risk 'High' -Category 'E8 ML2 - User Application Hardening' +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_08.md b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_08.md new file mode 100644 index 0000000000000..ecf5bbac7c7d7 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_08.md @@ -0,0 +1,12 @@ +Obfuscated PowerShell, JavaScript, and VBScript are hallmarks of malware delivery. The ASR rule blocks scripts that exhibit obfuscation patterns from running. + +**Remediation Action** + +1. Intune > Endpoint security > Attack surface reduction. +2. Set *Block execution of potentially obfuscated scripts* to **Block** (start with *Warn* if you suspect false positives). + +**Links** +- [ASR rules reference](https://learn.microsoft.com/en-us/microsoft-365/security/defender-endpoint/attack-surface-reduction-rules-reference) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_08.ps1 b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_08.ps1 new file mode 100644 index 0000000000000..3f5a18f499c81 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_08.ps1 @@ -0,0 +1,12 @@ +function Invoke-CippTestE8_AppHard_08 { + <# + .SYNOPSIS + ACSC Essential Eight (User Application Hardening, ML2) - ASR rule "Block execution of potentially obfuscated scripts" is enabled and assigned + #> + param($Tenant) + Test-E8AsrRule -Tenant $Tenant -TestId 'E8_AppHard_08' ` + -Name 'ASR rule "Block execution of potentially obfuscated scripts"' ` + -RuleSettingId 'device_vendor_msft_policy_config_defender_attacksurfacereductionrules_blockexecutionofpotentiallyobfuscatedscripts' ` + -FriendlyRule 'Block execution of potentially obfuscated scripts' ` + -Risk 'High' -Category 'E8 ML2 - User Application Hardening' +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_09.md b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_09.md new file mode 100644 index 0000000000000..e77c618ad94f4 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_09.md @@ -0,0 +1,12 @@ +Many drive-by downloads end with a JavaScript or VBScript stub that pulls down and runs a binary. This ASR rule terminates that hand-off. + +**Remediation Action** + +1. Intune > Endpoint security > Attack surface reduction. +2. Set *Block JavaScript or VBScript from launching downloaded executable content* to **Block**. + +**Links** +- [ASR rules reference](https://learn.microsoft.com/en-us/microsoft-365/security/defender-endpoint/attack-surface-reduction-rules-reference) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_09.ps1 b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_09.ps1 new file mode 100644 index 0000000000000..20c1cf782dcda --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_09.ps1 @@ -0,0 +1,12 @@ +function Invoke-CippTestE8_AppHard_09 { + <# + .SYNOPSIS + ACSC Essential Eight (User Application Hardening, ML2) - ASR rule "Block JS/VBS launching downloaded executable content" is enabled and assigned + #> + param($Tenant) + Test-E8AsrRule -Tenant $Tenant -TestId 'E8_AppHard_09' ` + -Name 'ASR rule "Block JavaScript or VBScript from launching downloaded executable content"' ` + -RuleSettingId 'device_vendor_msft_policy_config_defender_attacksurfacereductionrules_blockjavascriptorvbscriptfromlaunchingdownloadedexecutablecontent' ` + -FriendlyRule 'Block JavaScript or VBScript from launching downloaded executable content' ` + -Risk 'High' -Category 'E8 ML2 - User Application Hardening' +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_10.md b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_10.md new file mode 100644 index 0000000000000..ef8f7ddfe8434 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_10.md @@ -0,0 +1,13 @@ +Removable media is a common malware vector. This ASR rule blocks unsigned/untrusted executables from launching when run from USB drives. + +**Remediation Action** + +1. Intune > Endpoint security > Attack surface reduction. +2. Set *Block untrusted and unsigned processes that run from USB* to **Block**. +3. Pair with a removable storage access policy if USB is not required. + +**Links** +- [ASR rules reference](https://learn.microsoft.com/en-us/microsoft-365/security/defender-endpoint/attack-surface-reduction-rules-reference) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_10.ps1 b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_10.ps1 new file mode 100644 index 0000000000000..6c9cd88b38c95 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_10.ps1 @@ -0,0 +1,12 @@ +function Invoke-CippTestE8_AppHard_10 { + <# + .SYNOPSIS + ACSC Essential Eight (User Application Hardening, ML3) - ASR rule "Block untrusted/unsigned processes from USB" is enabled and assigned + #> + param($Tenant) + Test-E8AsrRule -Tenant $Tenant -TestId 'E8_AppHard_10' ` + -Name 'ASR rule "Block untrusted and unsigned processes that run from USB"' ` + -RuleSettingId 'device_vendor_msft_policy_config_defender_attacksurfacereductionrules_blockuntrustedunsignedprocessesthatrunfromusb' ` + -FriendlyRule 'Block untrusted and unsigned processes that run from USB' ` + -Risk 'Medium' -Category 'E8 ML3 - User Application Hardening' +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_11.md b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_11.md new file mode 100644 index 0000000000000..4aaa8a80dbcc3 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_11.md @@ -0,0 +1,13 @@ +PsExec and WMI process creation are heavily abused for lateral movement. This ASR rule blocks processes spawned through these mechanisms unless explicitly allowed. + +**Remediation Action** + +1. Intune > Endpoint security > Attack surface reduction. +2. Set *Block process creations originating from PsExec and WMI commands* to **Block**. +3. Note: this may impact some legitimate management tooling — pilot first. + +**Links** +- [ASR rules reference](https://learn.microsoft.com/en-us/microsoft-365/security/defender-endpoint/attack-surface-reduction-rules-reference) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_11.ps1 b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_11.ps1 new file mode 100644 index 0000000000000..20bcc8b1022ac --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_11.ps1 @@ -0,0 +1,12 @@ +function Invoke-CippTestE8_AppHard_11 { + <# + .SYNOPSIS + ACSC Essential Eight (User Application Hardening, ML3) - ASR rule "Block process creations from PsExec and WMI commands" is enabled and assigned + #> + param($Tenant) + Test-E8AsrRule -Tenant $Tenant -TestId 'E8_AppHard_11' ` + -Name 'ASR rule "Block process creations originating from PsExec and WMI commands"' ` + -RuleSettingId 'device_vendor_msft_policy_config_defender_attacksurfacereductionrules_blockprocesscreationsfrompsexecandwmicommands' ` + -FriendlyRule 'Block process creations originating from PsExec and WMI commands' ` + -Risk 'High' -Category 'E8 ML3 - User Application Hardening' +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_12.md b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_12.md new file mode 100644 index 0000000000000..8d7cd18f39d19 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_12.md @@ -0,0 +1,13 @@ +Bring-Your-Own-Vulnerable-Driver (BYOVD) is a common kernel-privilege escalation technique. This ASR rule blocks Microsoft's curated list of known-bad signed drivers from loading. + +**Remediation Action** + +1. Intune > Endpoint security > Attack surface reduction. +2. Set *Block abuse of exploited vulnerable signed drivers* to **Block**. +3. Pair with the Microsoft vulnerable driver blocklist (Smart App Control / WDAC). + +**Links** +- [Microsoft recommended driver block rules](https://learn.microsoft.com/en-us/windows/security/application-security/application-control/microsoft-recommended-driver-block-rules) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_12.ps1 b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_12.ps1 new file mode 100644 index 0000000000000..7f8efedb98f31 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_12.ps1 @@ -0,0 +1,12 @@ +function Invoke-CippTestE8_AppHard_12 { + <# + .SYNOPSIS + ACSC Essential Eight (User Application Hardening, ML3) - ASR rule "Block abuse of exploited vulnerable signed drivers" is enabled and assigned + #> + param($Tenant) + Test-E8AsrRule -Tenant $Tenant -TestId 'E8_AppHard_12' ` + -Name 'ASR rule "Block abuse of exploited vulnerable signed drivers"' ` + -RuleSettingId 'device_vendor_msft_policy_config_defender_attacksurfacereductionrules_blockabuseofexploitedvulnerablesigneddrivers' ` + -FriendlyRule 'Block abuse of exploited vulnerable signed drivers' ` + -Risk 'High' -Category 'E8 ML3 - User Application Hardening' +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_13.md b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_13.md new file mode 100644 index 0000000000000..e07093adf7364 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_13.md @@ -0,0 +1,12 @@ +WMI event subscriptions are a stealthy persistence mechanism (ATT&CK T1546.003). This ASR rule blocks creation of new WMI event consumers/filters/bindings. + +**Remediation Action** + +1. Intune > Endpoint security > Attack surface reduction. +2. Set *Block persistence through WMI event subscription* to **Block**. + +**Links** +- [ASR rules reference](https://learn.microsoft.com/en-us/microsoft-365/security/defender-endpoint/attack-surface-reduction-rules-reference) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_13.ps1 b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_13.ps1 new file mode 100644 index 0000000000000..5a88b0185e7c2 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_13.ps1 @@ -0,0 +1,12 @@ +function Invoke-CippTestE8_AppHard_13 { + <# + .SYNOPSIS + ACSC Essential Eight (User Application Hardening, ML3) - ASR rule "Block persistence through WMI event subscription" is enabled and assigned + #> + param($Tenant) + Test-E8AsrRule -Tenant $Tenant -TestId 'E8_AppHard_13' ` + -Name 'ASR rule "Block persistence through WMI event subscription"' ` + -RuleSettingId 'device_vendor_msft_policy_config_defender_attacksurfacereductionrules_blockpersistencethroughwmieventsubscription' ` + -FriendlyRule 'Block persistence through WMI event subscription' ` + -Risk 'Medium' -Category 'E8 ML3 - User Application Hardening' +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_14.md b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_14.md new file mode 100644 index 0000000000000..1441fa643b5b4 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_14.md @@ -0,0 +1,13 @@ +Defender's advanced ransomware protection rule uses cloud-delivered ML to detect and block ransomware behaviour patterns even when the binary itself is unknown. + +**Remediation Action** + +1. Intune > Endpoint security > Attack surface reduction. +2. Set *Use advanced protection against ransomware* to **Block**. +3. Pair with Controlled Folder Access for additional anti-ransomware defence. + +**Links** +- [ASR rules reference](https://learn.microsoft.com/en-us/microsoft-365/security/defender-endpoint/attack-surface-reduction-rules-reference) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_14.ps1 b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_14.ps1 new file mode 100644 index 0000000000000..25bf3b6c77be4 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_AppHard_14.ps1 @@ -0,0 +1,12 @@ +function Invoke-CippTestE8_AppHard_14 { + <# + .SYNOPSIS + ACSC Essential Eight (User Application Hardening, ML3) - ASR rule "Use advanced protection against ransomware" is enabled and assigned + #> + param($Tenant) + Test-E8AsrRule -Tenant $Tenant -TestId 'E8_AppHard_14' ` + -Name 'ASR rule "Use advanced protection against ransomware"' ` + -RuleSettingId 'device_vendor_msft_policy_config_defender_attacksurfacereductionrules_useadvancedprotectionagainstransomware' ` + -FriendlyRule 'Use advanced protection against ransomware' ` + -Risk 'High' -Category 'E8 ML3 - User Application Hardening' +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_01.md b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_01.md new file mode 100644 index 0000000000000..bda0c651ef616 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_01.md @@ -0,0 +1,14 @@ +Office macros are a major delivery vector for malware. The Defender Attack Surface Reduction rule **Block Win32 API calls from Office macros** stops a macro from invoking the Windows API directly, which is how most macro-based loaders detonate. + +**Remediation Action** + +1. Intune > Endpoint security > Attack surface reduction > Create policy (Windows 10+, Endpoint detection and response/Attack Surface Reduction Rules). +2. Set *Block Win32 API calls from Office macros* to **Block** (or *Warn*). +3. Assign to all Windows devices. + +**Links** +- [ACSC Essential Eight - Configure Microsoft Office macros](https://learn.microsoft.com/en-us/compliance/anz/e8-macro) +- [Defender ASR rules reference](https://learn.microsoft.com/en-us/microsoft-365/security/defender-endpoint/attack-surface-reduction-rules-reference) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_01.ps1 b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_01.ps1 new file mode 100644 index 0000000000000..8403fac123f86 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_01.ps1 @@ -0,0 +1,14 @@ +function Invoke-CippTestE8_Macro_01 { + <# + .SYNOPSIS + ACSC Essential Eight (Configure Office Macros, ML1) - Win32 API calls from Office macros are blocked via ASR + #> + param($Tenant) + + $TestId = 'E8_Macro_01' + $Name = 'ASR rule "Block Win32 API calls from Office macros" is enabled and assigned' + $RuleId = 'device_vendor_msft_policy_config_defender_attacksurfacereductionrules_blockwin32apicallsfromofficemacros' + $Friendly = 'Block Win32 API calls from Office macros' + + Test-E8AsrRule -Tenant $Tenant -TestId $TestId -Name $Name -RuleSettingId $RuleId -FriendlyRule $Friendly -Risk 'High' -Category 'E8 ML1 - Configure Office Macros' +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_02.md b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_02.md new file mode 100644 index 0000000000000..3f90f556da36d --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_02.md @@ -0,0 +1,13 @@ +Office macros frequently drop and execute payloads. The ASR rule **Block Office applications from creating executable content** prevents Word/Excel/PowerPoint from writing `.exe`/`.dll`/`.scr`/macros that drop binaries to disk. + +**Remediation Action** + +1. Intune > Endpoint security > Attack surface reduction. +2. Set *Block Office applications from creating executable content* to **Block** (or *Warn*). +3. Assign to all Windows devices. + +**Links** +- [Defender ASR rules reference](https://learn.microsoft.com/en-us/microsoft-365/security/defender-endpoint/attack-surface-reduction-rules-reference) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_02.ps1 b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_02.ps1 new file mode 100644 index 0000000000000..0f7d019aeaa2e --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_02.ps1 @@ -0,0 +1,12 @@ +function Invoke-CippTestE8_Macro_02 { + <# + .SYNOPSIS + ACSC Essential Eight (Configure Office Macros, ML1) - Office apps blocked from creating executable content via ASR + #> + param($Tenant) + Test-E8AsrRule -Tenant $Tenant -TestId 'E8_Macro_02' ` + -Name 'ASR rule "Block Office applications from creating executable content" is enabled and assigned' ` + -RuleSettingId 'device_vendor_msft_policy_config_defender_attacksurfacereductionrules_blockofficeapplicationsfromcreatingexecutablecontent' ` + -FriendlyRule 'Block Office applications from creating executable content' ` + -Risk 'High' -Category 'E8 ML1 - Configure Office Macros' +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_03.md b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_03.md new file mode 100644 index 0000000000000..70785a432a746 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_03.md @@ -0,0 +1,13 @@ +Macro-based attacks routinely launch PowerShell, cmd, or wscript as a child of Word/Excel. The ASR rule **Block all Office applications from creating child processes** blocks this entire technique class. + +**Remediation Action** + +1. Intune > Endpoint security > Attack surface reduction. +2. Set *Block all Office applications from creating child processes* to **Block**. +3. Assign to all Windows devices. + +**Links** +- [Defender ASR rules reference](https://learn.microsoft.com/en-us/microsoft-365/security/defender-endpoint/attack-surface-reduction-rules-reference) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_03.ps1 b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_03.ps1 new file mode 100644 index 0000000000000..8089fc95e1c9d --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_03.ps1 @@ -0,0 +1,12 @@ +function Invoke-CippTestE8_Macro_03 { + <# + .SYNOPSIS + ACSC Essential Eight (Configure Office Macros, ML1) - Office apps blocked from creating child processes via ASR + #> + param($Tenant) + Test-E8AsrRule -Tenant $Tenant -TestId 'E8_Macro_03' ` + -Name 'ASR rule "Block all Office applications from creating child processes" is enabled and assigned' ` + -RuleSettingId 'device_vendor_msft_policy_config_defender_attacksurfacereductionrules_blockallofficeapplicationsfromcreatingchildprocesses' ` + -FriendlyRule 'Block all Office applications from creating child processes' ` + -Risk 'High' -Category 'E8 ML1 - Configure Office Macros' +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_04.md b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_04.md new file mode 100644 index 0000000000000..eabf6b3df9abb --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_04.md @@ -0,0 +1,13 @@ +Code injection is a common technique used by malicious macros to evade detection by piggy-backing on a legitimate process. The ASR rule **Block Office applications from injecting code into other processes** disrupts this. + +**Remediation Action** + +1. Intune > Endpoint security > Attack surface reduction. +2. Set *Block Office applications from injecting code into other processes* to **Block**. +3. Assign to all Windows devices. + +**Links** +- [Defender ASR rules reference](https://learn.microsoft.com/en-us/microsoft-365/security/defender-endpoint/attack-surface-reduction-rules-reference) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_04.ps1 b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_04.ps1 new file mode 100644 index 0000000000000..f524854516dd0 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_04.ps1 @@ -0,0 +1,12 @@ +function Invoke-CippTestE8_Macro_04 { + <# + .SYNOPSIS + ACSC Essential Eight (Configure Office Macros, ML2) - Office apps blocked from injecting code via ASR + #> + param($Tenant) + Test-E8AsrRule -Tenant $Tenant -TestId 'E8_Macro_04' ` + -Name 'ASR rule "Block Office applications from injecting code into other processes" is enabled and assigned' ` + -RuleSettingId 'device_vendor_msft_policy_config_defender_attacksurfacereductionrules_blockofficeapplicationsfrominjectingcodeintootherprocesses' ` + -FriendlyRule 'Block Office applications from injecting code into other processes' ` + -Risk 'High' -Category 'E8 ML2 - Configure Office Macros' +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_05.md b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_05.md new file mode 100644 index 0000000000000..085a3c6f0fb51 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_05.md @@ -0,0 +1,13 @@ +Outlook is a frequent first-stage delivery vector. The ASR rule **Block Office communication application from creating child processes** blocks Outlook from spawning PowerShell, cmd, or scripting hosts — a key step in many phishing kill-chains. + +**Remediation Action** + +1. Intune > Endpoint security > Attack surface reduction. +2. Set *Block Office communication application from creating child processes* to **Block**. +3. Assign to all Windows devices. + +**Links** +- [Defender ASR rules reference](https://learn.microsoft.com/en-us/microsoft-365/security/defender-endpoint/attack-surface-reduction-rules-reference) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_05.ps1 b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_05.ps1 new file mode 100644 index 0000000000000..a128ebe36cbad --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_05.ps1 @@ -0,0 +1,12 @@ +function Invoke-CippTestE8_Macro_05 { + <# + .SYNOPSIS + ACSC Essential Eight (Configure Office Macros, ML2) - Office communication apps blocked from creating child processes via ASR + #> + param($Tenant) + Test-E8AsrRule -Tenant $Tenant -TestId 'E8_Macro_05' ` + -Name 'ASR rule "Block Office communication application from creating child processes" is enabled and assigned' ` + -RuleSettingId 'device_vendor_msft_policy_config_defender_attacksurfacereductionrules_blockofficecommunicationappfromcreatingchildprocesses' ` + -FriendlyRule 'Block Office communication application from creating child processes' ` + -Risk 'Medium' -Category 'E8 ML2 - Configure Office Macros' +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_06.md b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_06.md new file mode 100644 index 0000000000000..1584b4db20158 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_06.md @@ -0,0 +1,13 @@ +Microsoft now blocks VBA macros from the internet by default in supported Office versions, but the policy must be enforced via Office Cloud Policy or Intune ADMX templates to be guaranteed in-tenant. Verify the *Block macros from running in Office files from the Internet* policy is on for Word, Excel, PowerPoint, Visio, and Outlook. + +**Remediation Action** + +1. Office Cloud Policy Service ([config.office.com](https://config.office.com)) > Customization > Policy configurations. +2. Search **Block macros from running in Office files from the Internet**. +3. Enable for each Office app and assign to all users. + +**Links** +- [Macros from the internet are blocked by default in Office](https://learn.microsoft.com/en-us/deployoffice/security/internet-macros-blocked) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_06.ps1 b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_06.ps1 new file mode 100644 index 0000000000000..314dbba5b1b25 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_06.ps1 @@ -0,0 +1,9 @@ +function Invoke-CippTestE8_Macro_06 { + <# + .SYNOPSIS + ACSC Essential Eight (Configure Office Macros, ML2) - Macros from the internet are blocked + #> + param($Tenant) + + Add-CippTestResult -TenantFilter $Tenant -TestId 'E8_Macro_06' -TestType 'Devices' -Status 'Informational' -ResultMarkdown 'This is a task performed manually. Confirm the *Block macros from running in Office files from the Internet* policy is set in the Office Cloud Policy Service (or the corresponding Microsoft Endpoint Manager Settings Catalog ADMX values for Word/Excel/PowerPoint/Visio/Outlook). The setting lives under each application''s Trust Center.' -Risk 'High' -Name 'Macros from the internet are blocked' -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'E8 ML2 - Configure Office Macros' +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_07.md b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_07.md new file mode 100644 index 0000000000000..05b5c8a89743a --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_07.md @@ -0,0 +1,12 @@ +Office VBA integrates with the Antimalware Scan Interface (AMSI) so Defender can scan macro contents at runtime. Confirm AMSI/Defender is enabled and that the *Macro Runtime Scan Scope* policy is set to *Enable for all documents*. + +**Remediation Action** + +1. Office Cloud Policy / Intune ADMX > **Macro Runtime Scan Scope** = *Enable for all documents*. +2. Confirm Defender Antivirus is the active AV (or third-party with macro AMSI integration). + +**Links** +- [Office VBA + AMSI](https://learn.microsoft.com/en-us/microsoft-365-apps/security/integration-with-amsi) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_07.ps1 b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_07.ps1 new file mode 100644 index 0000000000000..9eaf219728b40 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_Macro_07.ps1 @@ -0,0 +1,9 @@ +function Invoke-CippTestE8_Macro_07 { + <# + .SYNOPSIS + ACSC Essential Eight (Configure Office Macros, ML3) - Macros are scanned by anti-virus software + #> + param($Tenant) + + Add-CippTestResult -TenantFilter $Tenant -TestId 'E8_Macro_07' -TestType 'Devices' -Status 'Informational' -ResultMarkdown 'This is a task performed manually. Confirm the Office *Macro Runtime Scan Scope* policy is set to *Enable for all documents* and that Microsoft Defender Antivirus AMSI is enabled on all Windows endpoints. AMSI integration with Office macros is on by default on supported builds; this control is verified by inspecting Defender + Office configuration which is not exposed via Graph in a deterministic way.' -Risk 'High' -Name 'Macros are scanned by anti-virus software' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML3 - Configure Office Macros' +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchApp_01.md b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchApp_01.md new file mode 100644 index 0000000000000..ac259b5b35e68 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchApp_01.md @@ -0,0 +1,13 @@ +Office and other internet-facing apps must auto-update so vulnerabilities are remediated within the E8 windows (2 weeks ML1, 48 hours ML2, fully supported only ML3). + +**Remediation Action** + +1. Office Cloud Policy > **Update Channel** = *Current Channel* or *Monthly Enterprise*; **Enable Automatic Updates** = On. +2. Edge update policies (`UpdateDefault` = 1). +3. Where third-party browsers / PDF viewers are deployed, configure their auto-update. + +**Links** +- [Choose Microsoft 365 Apps update channel](https://learn.microsoft.com/en-us/deployoffice/updates/overview-update-channels) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchApp_01.ps1 b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchApp_01.ps1 new file mode 100644 index 0000000000000..4c89da953fcc8 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchApp_01.ps1 @@ -0,0 +1,8 @@ +function Invoke-CippTestE8_PatchApp_01 { + <# + .SYNOPSIS + ACSC Essential Eight (Patch Applications, ML1) - Office and supported applications use automatic updates + #> + param($Tenant) + Add-CippTestResult -TenantFilter $Tenant -TestId 'E8_PatchApp_01' -TestType 'Devices' -Status 'Informational' -ResultMarkdown 'This is a task performed manually. Confirm Microsoft 365 Apps is set to a current channel (Current/Monthly Enterprise) with automatic updates, and that browsers (Edge, Chrome, Firefox) and PDF viewers self-update. Office update channel can be enforced via Office Cloud Policy *UpdateChannel*; Edge auto-update via *UpdateDefault*.' -Risk 'High' -Name 'Office and supported applications use automatic updates' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML1 - Patch Applications' +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchApp_02.md b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchApp_02.md new file mode 100644 index 0000000000000..4271b8f6435d1 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchApp_02.md @@ -0,0 +1,12 @@ +A device that has not synced with Intune for two weeks is invisible to MDM-based patching and detection. Patch state cannot be enforced or measured for these endpoints. + +**Remediation Action** + +1. Contact users of the listed devices and ensure they bring them online and sign in. +2. If a device has been offline >30 days, retire/wipe it from Intune and re-enrol when returned. + +**Links** +- [Intune device sync troubleshooting](https://learn.microsoft.com/en-us/mem/intune/remote-actions/device-sync) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchApp_02.ps1 b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchApp_02.ps1 new file mode 100644 index 0000000000000..7f810f28621fe --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchApp_02.ps1 @@ -0,0 +1,41 @@ +function Invoke-CippTestE8_PatchApp_02 { + <# + .SYNOPSIS + ACSC Essential Eight (Patch Applications, ML1) - Managed devices have synced with Intune within the last 14 days + #> + param($Tenant) + + $TestId = 'E8_PatchApp_02' + $Name = 'Managed devices have synced with Intune within the last 14 days' + + try { + $Devices = Get-CIPPTestData -TenantFilter $Tenant -Type 'ManagedDevices' + if (-not $Devices) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status 'Skipped' -ResultMarkdown 'No ManagedDevices cached for this tenant.' -Risk 'Medium' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML1 - Patch Applications' + return + } + + $Threshold = (Get-Date).AddDays(-14) + $Stale = foreach ($D in $Devices) { + $LastSync = $D.lastSyncDateTime + if (-not $LastSync) { [pscustomobject]@{ Device = $D.deviceName; LastSync = 'never' }; continue } + $LastSyncDt = [datetime]::Parse($LastSync) + if ($LastSyncDt -lt $Threshold) { [pscustomobject]@{ Device = $D.deviceName; LastSync = $LastSyncDt.ToString('yyyy-MM-dd') } } + } + + if (-not $Stale) { + $Status = 'Passed' + $Result = "All $($Devices.Count) managed device(s) have synced with Intune within the last 14 days." + } else { + $Status = 'Failed' + $Sb = [System.Text.StringBuilder]::new("$($Stale.Count) of $($Devices.Count) managed device(s) have not synced for >14 days; their patch state is unknown:`n`n| Device | Last sync |`n| :----- | :-------- |`n") + foreach ($S in ($Stale | Select-Object -First 50)) { $null = $Sb.Append("| $($S.Device) | $($S.LastSync) |`n") } + $Result = $Sb.ToString() + } + + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status $Status -ResultMarkdown $Result -Risk 'Medium' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML1 - Patch Applications' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'Medium' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML1 - Patch Applications' + } +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchApp_03.md b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchApp_03.md new file mode 100644 index 0000000000000..84d1c86b6c3da --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchApp_03.md @@ -0,0 +1,12 @@ +E8 ML2 requires patching of applications with critical vulnerabilities within 48 hours of an exploit becoming public. Use Microsoft Defender Vulnerability Management to identify exposed apps and prioritise. + +**Remediation Action** + +1. Defender > Vulnerability management > Weaknesses; sort by Exposed devices and Public exploit available. +2. Patch listed apps via Intune Win32 / MSIX or vendor auto-update. + +**Links** +- [Microsoft Defender Vulnerability Management](https://learn.microsoft.com/en-us/defender-vulnerability-management/) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchApp_03.ps1 b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchApp_03.ps1 new file mode 100644 index 0000000000000..b0c0bd90748d7 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchApp_03.ps1 @@ -0,0 +1,8 @@ +function Invoke-CippTestE8_PatchApp_03 { + <# + .SYNOPSIS + ACSC Essential Eight (Patch Applications, ML2) - Vulnerable applications detected on managed devices are reviewed + #> + param($Tenant) + Add-CippTestResult -TenantFilter $Tenant -TestId 'E8_PatchApp_03' -TestType 'Devices' -Status 'Informational' -ResultMarkdown 'This is a task performed manually. Use Microsoft Defender Vulnerability Management (or the Intune Discovered Apps inventory) to triage applications with known CVEs. Patch internet-facing apps within 48 hours of an exploit being known and within 2 weeks otherwise. Determining "critical" CVE status programmatically requires Defender Vulnerability Management licensing and is not surfaced in the local cache.' -Risk 'High' -Name 'Vulnerable applications are patched within 48 hours of an exploit becoming public' -UserImpact 'Medium' -ImplementationEffort 'Medium' -Category 'E8 ML2 - Patch Applications' +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchApp_04.md b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchApp_04.md new file mode 100644 index 0000000000000..483fef3401021 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchApp_04.md @@ -0,0 +1,12 @@ +ISM-1467 — applications that are no longer supported by the vendor must be removed because they will not receive security fixes. Examples: Office 2016 (out of support October 2025), Java 8 unsupported builds, Adobe Reader 11. + +**Remediation Action** + +1. Inventory installed apps via Intune > Apps > Discovered apps or Defender Vulnerability Management software inventory. +2. Schedule replacement / uninstall for unsupported versions. + +**Links** +- [Microsoft product lifecycle](https://learn.microsoft.com/en-us/lifecycle/products/) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchApp_04.ps1 b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchApp_04.ps1 new file mode 100644 index 0000000000000..7ba4fc795a099 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchApp_04.ps1 @@ -0,0 +1,8 @@ +function Invoke-CippTestE8_PatchApp_04 { + <# + .SYNOPSIS + ACSC Essential Eight (Patch Applications, ML3) - Unsupported applications are removed + #> + param($Tenant) + Add-CippTestResult -TenantFilter $Tenant -TestId 'E8_PatchApp_04' -TestType 'Devices' -Status 'Informational' -ResultMarkdown 'This is a task performed manually. Identify and remove applications that are no longer supported by the vendor (e.g. Office 2016/2019 past support, Adobe Reader 11, Java 8 unpatched, Flash). Use the Intune Discovered Apps inventory or Defender Vulnerability Management software inventory to enumerate.' -Risk 'High' -Name 'Unsupported applications are removed (ISM-1467)' -UserImpact 'Medium' -ImplementationEffort 'Medium' -Category 'E8 ML3 - Patch Applications' +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_01.md b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_01.md new file mode 100644 index 0000000000000..9fc6820e60f93 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_01.md @@ -0,0 +1,13 @@ +Windows Update for Business is the cloud-native way to deliver OS quality and feature updates. At least one Update Ring policy must be deployed and assigned for E8 patch-OS controls to be measurable. + +**Remediation Action** + +1. Intune > Devices > Windows > Update rings for Windows 10 and later > Create. +2. Set service channel, deferral periods, and deadlines (see ML2 quality deferral test). +3. Assign to all Windows devices. + +**Links** +- [Update rings in Intune](https://learn.microsoft.com/en-us/mem/intune/protect/windows-10-update-rings) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_01.ps1 b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_01.ps1 new file mode 100644 index 0000000000000..58b895b81594c --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_01.ps1 @@ -0,0 +1,43 @@ +function Invoke-CippTestE8_PatchOS_01 { + <# + .SYNOPSIS + ACSC Essential Eight (Patch Operating Systems, ML1) - A Windows Update Ring policy is configured and assigned + #> + param($Tenant) + + $TestId = 'E8_PatchOS_01' + $Name = 'A Windows Update Ring policy is configured and assigned' + + try { + $ConfigPolicies = Get-CIPPTestData -TenantFilter $Tenant -Type 'IntuneConfigurationPolicies' + $LegacyPolicies = Get-CIPPTestData -TenantFilter $Tenant -Type 'IntuneDeviceConfigurations' + + $UpdateRings = @() + if ($ConfigPolicies) { + $UpdateRings += $ConfigPolicies | Where-Object { + $ids = $_.settings.settingInstance.settingDefinitionId + ($ids -match 'windowsupdate') -or ($ids -match 'update_ring') + } + } + if ($LegacyPolicies) { + $UpdateRings += $LegacyPolicies | Where-Object { + $_.'@odata.type' -eq '#microsoft.graph.windowsUpdateForBusinessConfiguration' + } + } + + if (-not $UpdateRings) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status 'Failed' -ResultMarkdown 'No Windows Update for Business / Update Ring configuration policy is deployed.' -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML1 - Patch Operating Systems' + return + } + + $Assigned = $UpdateRings | Where-Object { $_.assignments -and $_.assignments.Count -gt 0 } + if ($Assigned) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status 'Passed' -ResultMarkdown "$($Assigned.Count) Windows Update Ring policy/policies are configured and assigned." -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML1 - Patch Operating Systems' + } else { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status 'Failed' -ResultMarkdown "$($UpdateRings.Count) Windows Update Ring policy/policies exist but none are assigned to any group/device." -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML1 - Patch Operating Systems' + } + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML1 - Patch Operating Systems' + } +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_02.md b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_02.md new file mode 100644 index 0000000000000..961afb55c4b12 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_02.md @@ -0,0 +1,13 @@ +Only Windows 10 22H2 (build 19045) and Windows 11 (22H2 / 23H2 / 24H2 — builds 22621/22631/26100) currently receive monthly security updates. Older builds must be upgraded. + +**Remediation Action** + +1. Use Intune Feature Update profiles to roll devices to a supported feature release. +2. For devices that cannot upgrade, retire and replace. + +**Links** +- [Windows 10 release information](https://learn.microsoft.com/en-us/windows/release-health/release-information) +- [Windows 11 release information](https://learn.microsoft.com/en-us/windows/release-health/windows11-release-information) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_02.ps1 b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_02.ps1 new file mode 100644 index 0000000000000..e3caead2bd8ef --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_02.ps1 @@ -0,0 +1,52 @@ +function Invoke-CippTestE8_PatchOS_02 { + <# + .SYNOPSIS + ACSC Essential Eight (Patch Operating Systems, ML1) - All managed Windows devices run a supported OS build + #> + param($Tenant) + + $TestId = 'E8_PatchOS_02' + $Name = 'All managed Windows devices run a supported Windows build (Win10 22H2 / Win11 22H2+)' + + try { + $Devices = Get-CIPPTestData -TenantFilter $Tenant -Type 'ManagedDevices' + if (-not $Devices) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status 'Skipped' -ResultMarkdown 'No ManagedDevices cached for this tenant.' -Risk 'High' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'Medium' -Category 'E8 ML1 - Patch Operating Systems' + return + } + + $Windows = $Devices | Where-Object { $_.operatingSystem -eq 'Windows' } + if (-not $Windows) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status 'Skipped' -ResultMarkdown 'No Windows managed devices found.' -Risk 'High' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'Medium' -Category 'E8 ML1 - Patch Operating Systems' + return + } + + # Win10 22H2 = 19045.x ; Win11 22H2 = 22621.x ; Win11 23H2 = 22631.x ; Win11 24H2 = 26100.x + $Unsupported = foreach ($D in $Windows) { + $V = $D.osVersion + if (-not $V) { continue } + $parts = $V.Split('.') + if ($parts.Count -lt 3) { continue } + $build = [int]$parts[2] + $Reason = $null + if ($build -lt 19045) { $Reason = 'Windows 10 build < 22H2 (out of support)' } + elseif ($build -ge 20000 -and $build -lt 22621) { $Reason = 'Windows 11 build < 22H2 (out of support)' } + if ($Reason) { [pscustomobject]@{ Device = $D.deviceName; OSVersion = $V; Reason = $Reason } } + } + + if (-not $Unsupported) { + $Status = 'Passed' + $Result = "All $($Windows.Count) Windows device(s) run a supported build." + } else { + $Status = 'Failed' + $Sb = [System.Text.StringBuilder]::new("$($Unsupported.Count) of $($Windows.Count) Windows device(s) are on unsupported builds:`n`n| Device | OS version | Reason |`n| :----- | :--------- | :----- |`n") + foreach ($U in ($Unsupported | Select-Object -First 50)) { $null = $Sb.Append("| $($U.Device) | $($U.OSVersion) | $($U.Reason) |`n") } + $Result = $Sb.ToString() + } + + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'Medium' -Category 'E8 ML1 - Patch Operating Systems' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'Medium' -Category 'E8 ML1 - Patch Operating Systems' + } +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_03.md b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_03.md new file mode 100644 index 0000000000000..e62d844e3c748 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_03.md @@ -0,0 +1,13 @@ +A device compliance policy that sets `osMinimumVersion` causes Conditional Access to block sign-ins from devices on out-of-support builds, providing a strong forcing function for OS patching. + +**Remediation Action** + +1. Intune > Devices > Compliance policies > Windows 10 / 11 policy. +2. Set **Minimum OS version** to the latest supported build (e.g. `10.0.19045.0` for Windows 10 22H2). +3. Assign to all Windows devices and pair with a CA policy requiring compliant device. + +**Links** +- [Windows compliance settings](https://learn.microsoft.com/en-us/mem/intune/protect/compliance-policy-create-windows) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_03.ps1 b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_03.ps1 new file mode 100644 index 0000000000000..790ccf31f1e88 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_03.ps1 @@ -0,0 +1,38 @@ +function Invoke-CippTestE8_PatchOS_03 { + <# + .SYNOPSIS + ACSC Essential Eight (Patch Operating Systems, ML1) - A device compliance policy enforces a minimum OS version + #> + param($Tenant) + + $TestId = 'E8_PatchOS_03' + $Name = 'A device compliance policy enforces a minimum Windows OS version' + + try { + $Compliance = Get-CIPPTestData -TenantFilter $Tenant -Type 'IntuneDeviceCompliancePolicies' + if (-not $Compliance) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status 'Skipped' -ResultMarkdown 'No Intune compliance policies cached for this tenant.' -Risk 'Medium' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'E8 ML1 - Patch Operating Systems' + return + } + + $Win = $Compliance | Where-Object { $_.'@odata.type' -eq '#microsoft.graph.windows10CompliancePolicy' } + $WithMinVersion = $Win | Where-Object { $_.osMinimumVersion } + $Assigned = $WithMinVersion | Where-Object { $_.assignments -and $_.assignments.Count -gt 0 } + + if ($Assigned) { + $Status = 'Passed' + $Result = "$($Assigned.Count) Windows compliance policy/policies enforce a minimum OS version (e.g. $($Assigned[0].osMinimumVersion))." + } elseif ($WithMinVersion) { + $Status = 'Failed' + $Result = "$($WithMinVersion.Count) Windows compliance policy/policies set a minimum OS version but none are assigned." + } else { + $Status = 'Failed' + $Result = 'No Windows compliance policy enforces a minimum OS version (`osMinimumVersion`).' + } + + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status $Status -ResultMarkdown $Result -Risk 'Medium' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'E8 ML1 - Patch Operating Systems' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'Medium' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'E8 ML1 - Patch Operating Systems' + } +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_04.md b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_04.md new file mode 100644 index 0000000000000..5a1c94be5763f --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_04.md @@ -0,0 +1,13 @@ +E8 ML2 requires patches for the operating system within 2 weeks of release (and 48 hours for known exploited vulnerabilities). Update Rings must therefore not defer quality updates beyond 14 days. + +**Remediation Action** + +1. Intune > Devices > Update rings > each ring > Settings. +2. **Quality update deferral period (days)** = `0` for production, up to `7` for pilot. +3. Configure a deadline of 2 days for installation/restart. + +**Links** +- [Update rings in Intune](https://learn.microsoft.com/en-us/mem/intune/protect/windows-10-update-rings) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_04.ps1 b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_04.ps1 new file mode 100644 index 0000000000000..848d61102f5a1 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_04.ps1 @@ -0,0 +1,42 @@ +function Invoke-CippTestE8_PatchOS_04 { + <# + .SYNOPSIS + ACSC Essential Eight (Patch Operating Systems, ML2) - Quality update deferral is 14 days or less + #> + param($Tenant) + + $TestId = 'E8_PatchOS_04' + $Name = 'Windows Update Ring quality update deferral is 14 days or less' + + try { + $Legacy = Get-CIPPTestData -TenantFilter $Tenant -Type 'IntuneDeviceConfigurations' + $Rings = $Legacy | Where-Object { $_.'@odata.type' -eq '#microsoft.graph.windowsUpdateForBusinessConfiguration' } + + if (-not $Rings) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status 'Skipped' -ResultMarkdown 'No `windowsUpdateForBusinessConfiguration` Update Ring policies cached for this tenant; quality deferral cannot be evaluated automatically.' -Risk 'Medium' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML2 - Patch Operating Systems' + return + } + + $Bad = foreach ($R in $Rings) { + $Defer = $R.qualityUpdatesDeferralPeriodInDays + if ($null -ne $Defer -and $Defer -gt 14) { + [pscustomobject]@{ Ring = $R.displayName; Deferral = $Defer } + } + } + + if (-not $Bad) { + $Status = 'Passed' + $Result = "All $($Rings.Count) Update Ring policy/policies defer quality updates by 14 days or less." + } else { + $Status = 'Failed' + $Sb = [System.Text.StringBuilder]::new("$($Bad.Count) Update Ring policy/policies defer quality updates by more than 14 days:`n`n| Ring | Deferral (days) |`n| :--- | :-------------: |`n") + foreach ($B in $Bad) { $null = $Sb.Append("| $($B.Ring) | $($B.Deferral) |`n") } + $Result = $Sb.ToString() + } + + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status $Status -ResultMarkdown $Result -Risk 'Medium' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML2 - Patch Operating Systems' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'Medium' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML2 - Patch Operating Systems' + } +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_05.md b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_05.md new file mode 100644 index 0000000000000..a39c3d8fdf26a --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_05.md @@ -0,0 +1,13 @@ +Feature Update profiles target a specific feature release (e.g. Windows 11 23H2) so devices stay on a supported branch automatically. + +**Remediation Action** + +1. Intune > Devices > Windows > Feature updates for Windows 10 and later > Create profile. +2. Choose the latest supported feature update version. +3. Assign to all Windows devices. + +**Links** +- [Feature updates in Intune](https://learn.microsoft.com/en-us/mem/intune/protect/windows-10-feature-updates) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_05.ps1 b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_05.ps1 new file mode 100644 index 0000000000000..60440e405aa86 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_05.ps1 @@ -0,0 +1,8 @@ +function Invoke-CippTestE8_PatchOS_05 { + <# + .SYNOPSIS + ACSC Essential Eight (Patch Operating Systems, ML2) - A Windows Feature Update profile is configured + #> + param($Tenant) + Add-CippTestResult -TenantFilter $Tenant -TestId 'E8_PatchOS_05' -TestType 'Devices' -Status 'Informational' -ResultMarkdown 'This is a task performed manually. Confirm a Windows Feature Update profile (Intune > Devices > Windows > Feature updates for Windows 10 and later) is deployed targeting the latest supported feature release. Feature update profiles are stored in a Graph endpoint that is not currently part of the cached IntuneConfigurationPolicies/DeviceConfigurations collections.' -Risk 'Medium' -Name 'A Windows Feature Update profile is configured' -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'E8 ML2 - Patch Operating Systems' +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_06.md b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_06.md new file mode 100644 index 0000000000000..030d15321e7a5 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_06.md @@ -0,0 +1,13 @@ +Disk encryption protects data on lost or stolen devices. The compliance policy must require BitLocker (or `storageRequireEncryption`) so devices that aren't encrypted are blocked by Conditional Access. + +**Remediation Action** + +1. Intune > Devices > Compliance policies > Windows policy > **Require BitLocker** = Require. +2. Pair with a *BitLocker* configuration profile (Endpoint security > Disk encryption) to actually enable it. +3. Assign to all Windows devices. + +**Links** +- [BitLocker policy settings](https://learn.microsoft.com/en-us/mem/intune/protect/encrypt-devices) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_06.ps1 b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_06.ps1 new file mode 100644 index 0000000000000..1681bcd1224d7 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Devices/Invoke-CippTestE8_PatchOS_06.ps1 @@ -0,0 +1,33 @@ +function Invoke-CippTestE8_PatchOS_06 { + <# + .SYNOPSIS + ACSC Essential Eight (Patch Operating Systems, ML3) - BitLocker / disk encryption is required by compliance policy + #> + param($Tenant) + + $TestId = 'E8_PatchOS_06' + $Name = 'Compliance policy requires storage to be encrypted (BitLocker)' + + try { + $Compliance = Get-CIPPTestData -TenantFilter $Tenant -Type 'IntuneDeviceCompliancePolicies' + if (-not $Compliance) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status 'Skipped' -ResultMarkdown 'No Intune compliance policies cached for this tenant.' -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML3 - Patch Operating Systems' + return + } + + $Win = $Compliance | Where-Object { $_.'@odata.type' -eq '#microsoft.graph.windows10CompliancePolicy' } + $WithEnc = $Win | Where-Object { $_.bitLockerEnabled -eq $true -or $_.storageRequireEncryption -eq $true } + $Assigned = $WithEnc | Where-Object { $_.assignments -and $_.assignments.Count -gt 0 } + + if ($Assigned) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status 'Passed' -ResultMarkdown "$($Assigned.Count) Windows compliance policy/policies require encryption (BitLocker) and are assigned." -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML3 - Patch Operating Systems' + } elseif ($WithEnc) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status 'Failed' -ResultMarkdown "Encryption is required by $($WithEnc.Count) compliance policy/policies but none are assigned." -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML3 - Patch Operating Systems' + } else { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status 'Failed' -ResultMarkdown 'No Windows compliance policy requires storage encryption / BitLocker.' -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML3 - Patch Operating Systems' + } + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Devices' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML3 - Patch Operating Systems' + } +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_01.md b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_01.md new file mode 100644 index 0000000000000..7c8e9fb156d0d --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_01.md @@ -0,0 +1,14 @@ +ISM-0445 — privileged users are issued dedicated privileged accounts that are separate from their unprivileged identities. In a Microsoft 365 tenant the strongest implementation is **cloud-only** privileged accounts so the on-premises directory cannot compromise privileged identities. This test fails any privileged role member whose `onPremisesSyncEnabled` is true. + +**Remediation Action** + +1. Create a dedicated cloud-only account on the `.onmicrosoft.com` domain for each administrator. +2. Reassign privileged roles to the cloud-only account. +3. Remove privileged role assignments from synced accounts. + +**Links** +- [ACSC Essential Eight - Restrict Administrative Privileges](https://learn.microsoft.com/en-us/compliance/anz/e8-admin) +- [ISM-0445](https://www.cyber.gov.au/ism) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_01.ps1 b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_01.ps1 new file mode 100644 index 0000000000000..43ebe72c74335 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_01.ps1 @@ -0,0 +1,59 @@ +function Invoke-CippTestE8_Admin_01 { + <# + .SYNOPSIS + ACSC Essential Eight (Restrict Admin Privileges, ML1) - Privileged accounts are dedicated cloud-only accounts (ISM-0445) + #> + param($Tenant) + + $TestId = 'E8_Admin_01' + $Name = 'Privileged accounts are dedicated cloud-only accounts (ISM-0445)' + + try { + $Roles = Get-CippDbRole -TenantFilter $Tenant -IncludePrivilegedRoles + $RoleAssignmentScheduleInstances = Get-CIPPTestData -TenantFilter $Tenant -Type 'RoleAssignmentScheduleInstances' + $Users = Get-CIPPTestData -TenantFilter $Tenant -Type 'Users' + + if (-not $Roles -or -not $Users) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'Required cache (Roles or Users) not found.' -Risk 'High' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'Medium' -Category 'E8 ML1 - Restrict Admin Privileges' + return + } + + $PrivRoleIds = [System.Collections.Generic.HashSet[string]]::new() + $PrivUserIds = [System.Collections.Generic.HashSet[string]]::new() + foreach ($Role in @($Roles)) { + $RoleTemplateId = if ($Role.roleTemplateId) { [string]$Role.roleTemplateId } elseif ($Role.RoletemplateId) { [string]$Role.RoletemplateId } else { $null } + if ($RoleTemplateId) { [void]$PrivRoleIds.Add($RoleTemplateId) } + foreach ($M in @($Role.members)) { + if ($M.id) { [void]$PrivUserIds.Add([string]$M.id) } + } + } + foreach ($A in @($RoleAssignmentScheduleInstances)) { + if ($A.assignmentType -eq 'Assigned' -and $null -eq $A.endDateTime -and $A.principalId -and $PrivRoleIds.Contains([string]$A.roleDefinitionId)) { + [void]$PrivUserIds.Add([string]$A.principalId) + } + } + $PrivUsers = $Users | Where-Object { $PrivUserIds.Contains($_.id) } + + if (-not $PrivUsers) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Passed' -ResultMarkdown 'No privileged users found.' -Risk 'High' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'Medium' -Category 'E8 ML1 - Restrict Admin Privileges' + return + } + + $NonCompliant = $PrivUsers | Where-Object { $_.onPremisesSyncEnabled -eq $true } + + if (-not $NonCompliant) { + $Status = 'Passed' + $Result = "All $($PrivUsers.Count) privileged user(s) are cloud-only." + } else { + $Status = 'Failed' + $Sb = [System.Text.StringBuilder]::new("$($NonCompliant.Count) of $($PrivUsers.Count) privileged user(s) are synced from on-premises Active Directory:`n`n| UPN |`n| :-- |`n") + foreach ($U in ($NonCompliant | Select-Object -First 50)) { $null = $Sb.Append("| $($U.userPrincipalName) |`n") } + $Result = $Sb.ToString() + } + + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'Medium' -Category 'E8 ML1 - Restrict Admin Privileges' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'Medium' -Category 'E8 ML1 - Restrict Admin Privileges' + } +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_02.md b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_02.md new file mode 100644 index 0000000000000..0964cc5c24c56 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_02.md @@ -0,0 +1,14 @@ +ISM-1175 — privileged accounts must be prevented from accessing the internet, email and web services. In a Microsoft 365 tenant the cleanest implementation is to leave privileged accounts unlicensed (no Exchange / Teams / SharePoint mailbox or apps) so that the account cannot send or receive mail, browse SharePoint, or run Office. Entra ID P2 is provisioned via group-based licensing if needed for PIM, but no productivity SKU should be attached. + +**Remediation Action** + +1. Identify each privileged user listed in the test results. +2. Remove all `Microsoft 365 / Office 365` and `Business Premium` style licenses. +3. Where an admin needs email, use a separate unprivileged mailbox. + +**Links** +- [ACSC Essential Eight - Restrict Administrative Privileges](https://learn.microsoft.com/en-us/compliance/anz/e8-admin) +- [ISM-1175](https://www.cyber.gov.au/ism) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_02.ps1 b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_02.ps1 new file mode 100644 index 0000000000000..4be920fd7608d --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_02.ps1 @@ -0,0 +1,61 @@ +function Invoke-CippTestE8_Admin_02 { + <# + .SYNOPSIS + ACSC Essential Eight (Restrict Admin Privileges, ML1) - Privileged accounts have no productivity licenses (ISM-1175) + #> + param($Tenant) + + $TestId = 'E8_Admin_02' + $Name = 'Privileged accounts have no productivity licenses (ISM-1175)' + + try { + $Roles = Get-CippDbRole -TenantFilter $Tenant -IncludePrivilegedRoles + $RoleAssignmentScheduleInstances = Get-CIPPTestData -TenantFilter $Tenant -Type 'RoleAssignmentScheduleInstances' + $Users = Get-CIPPTestData -TenantFilter $Tenant -Type 'Users' + + if (-not $Roles -or -not $Users) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'Required cache (Roles or Users) not found.' -Risk 'Medium' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'Medium' -Category 'E8 ML1 - Restrict Admin Privileges' + return + } + + $PrivRoleIds = [System.Collections.Generic.HashSet[string]]::new() + $PrivUserIds = [System.Collections.Generic.HashSet[string]]::new() + foreach ($Role in @($Roles)) { + $RoleTemplateId = if ($Role.roleTemplateId) { [string]$Role.roleTemplateId } elseif ($Role.RoletemplateId) { [string]$Role.RoletemplateId } else { $null } + if ($RoleTemplateId) { [void]$PrivRoleIds.Add($RoleTemplateId) } + foreach ($M in @($Role.members)) { + if ($M.id) { [void]$PrivUserIds.Add([string]$M.id) } + } + } + foreach ($A in @($RoleAssignmentScheduleInstances)) { + if ($A.assignmentType -eq 'Assigned' -and $null -eq $A.endDateTime -and $A.principalId -and $PrivRoleIds.Contains([string]$A.roleDefinitionId)) { + [void]$PrivUserIds.Add([string]$A.principalId) + } + } + $PrivUsers = $Users | Where-Object { $PrivUserIds.Contains($_.id) } + + if (-not $PrivUsers) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Passed' -ResultMarkdown 'No privileged users found.' -Risk 'Medium' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'Medium' -Category 'E8 ML1 - Restrict Admin Privileges' + return + } + + $Licensed = $PrivUsers | Where-Object { $_.assignedLicenses -and $_.assignedLicenses.Count -gt 0 } + + if (-not $Licensed) { + $Status = 'Passed' + $Result = "All $($PrivUsers.Count) privileged user(s) have no licenses assigned." + } else { + $Status = 'Failed' + $Sb = [System.Text.StringBuilder]::new("$($Licensed.Count) of $($PrivUsers.Count) privileged user(s) have productivity licenses assigned. ACSC ISM-1175 requires admins not to use mail/Teams/internet on the privileged account.`n`n| UPN | License count |`n| :-- | :-----------: |`n") + foreach ($U in ($Licensed | Select-Object -First 50)) { + $null = $Sb.Append("| $($U.userPrincipalName) | $($U.assignedLicenses.Count) |`n") + } + $Result = $Sb.ToString() + } + + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'Medium' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'Medium' -Category 'E8 ML1 - Restrict Admin Privileges' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'Medium' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'Medium' -Category 'E8 ML1 - Restrict Admin Privileges' + } +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_03.md b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_03.md new file mode 100644 index 0000000000000..62eee976a6839 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_03.md @@ -0,0 +1,16 @@ +Privileged sign-ins must come from devices the organisation manages. ISM-1380, 1688 and 1689 require admins to operate from a privileged operating environment; in a cloud-first tenant the equivalent is a Conditional Access policy that requires the device to be Intune-compliant or hybrid Azure AD joined before privileged roles are activated. + +**Remediation Action** + +1. Entra ID > Conditional Access > New policy. +2. Users > Directory roles: include all privileged roles. +3. Cloud apps: All cloud apps. +4. Grant: **Require compliant device** (and/or *Require Hybrid Azure AD joined device*). +5. Enable. + +**Links** +- [ACSC Essential Eight - Restrict Administrative Privileges](https://learn.microsoft.com/en-us/compliance/anz/e8-admin) +- [Require managed devices in CA](https://learn.microsoft.com/en-us/azure/active-directory/conditional-access/require-managed-devices) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_03.ps1 b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_03.ps1 new file mode 100644 index 0000000000000..9de335c058c5b --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_03.ps1 @@ -0,0 +1,47 @@ +function Invoke-CippTestE8_Admin_03 { + <# + .SYNOPSIS + ACSC Essential Eight (Restrict Admin Privileges, ML1) - Conditional Access requires a compliant device for privileged sign-ins + #> + param($Tenant) + + $TestId = 'E8_Admin_03' + $Name = 'Conditional Access requires a compliant or hybrid-joined device for privileged role sign-ins' + + try { + $CA = Get-CIPPTestData -TenantFilter $Tenant -Type 'ConditionalAccessPolicies' + $Roles = Get-CippDbRole -TenantFilter $Tenant -IncludePrivilegedRoles + + if (-not $CA -or -not $Roles) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'Required cache (ConditionalAccessPolicies or Roles) not found.' -Risk 'High' -Name $Name -UserImpact 'High' -ImplementationEffort 'High' -Category 'E8 ML1 - Restrict Admin Privileges' + return + } + + # Conditional Access includeRoles reference role template IDs, not directory role instance IDs. + $PrivRoleIds = @($Roles | ForEach-Object { if ($_.roleTemplateId) { [string]$_.roleTemplateId } elseif ($_.RoletemplateId) { [string]$_.RoletemplateId } }) + + $Match = $CA | Where-Object { + $_.state -eq 'enabled' -and + $_.conditions.users.includeRoles -and + (@($_.conditions.users.includeRoles) | Where-Object { $_ -in $PrivRoleIds }).Count -gt 0 -and + ( + ($_.grantControls.builtInControls -contains 'compliantDevice') -or + ($_.grantControls.builtInControls -contains 'domainJoinedDevice') + ) + } + + if ($Match) { + $Status = 'Passed' + $Result = "$($Match.Count) Conditional Access policy/policies require a compliant/domain-joined device for privileged role sign-ins:`n`n" + + (($Match | ForEach-Object { "- $($_.displayName)" }) -join "`n") + } else { + $Status = 'Failed' + $Result = 'No enabled Conditional Access policy targets privileged roles with a *Require compliant device* or *Require hybrid Azure AD joined device* grant. Privileged accounts may sign in from unmanaged endpoints.' + } + + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name $Name -UserImpact 'High' -ImplementationEffort 'High' -Category 'E8 ML1 - Restrict Admin Privileges' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name $Name -UserImpact 'High' -ImplementationEffort 'High' -Category 'E8 ML1 - Restrict Admin Privileges' + } +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_04.md b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_04.md new file mode 100644 index 0000000000000..1808623cdfa8e --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_04.md @@ -0,0 +1,14 @@ +Restricting user consent for OAuth applications stops attackers from luring users (including admins) into authorising malicious third-party apps to read mail or files. The Essential Eight ISM-1883 control limits which online services privileged accounts can authorise. + +**Remediation Action** + +1. Entra ID > Enterprise applications > Consent and permissions > User consent settings. +2. Set to **Allow user consent for apps from verified publishers, for selected permissions** (low-impact) — or **Do not allow user consent**. +3. Entra ID > User settings > **Users can register applications** = No. + +**Links** +- [Configure user consent settings](https://learn.microsoft.com/en-us/azure/active-directory/manage-apps/configure-user-consent) +- [ACSC Essential Eight - Restrict Administrative Privileges](https://learn.microsoft.com/en-us/compliance/anz/e8-admin) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_04.ps1 b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_04.ps1 new file mode 100644 index 0000000000000..492cb3a3da390 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_04.ps1 @@ -0,0 +1,47 @@ +function Invoke-CippTestE8_Admin_04 { + <# + .SYNOPSIS + ACSC Essential Eight (Restrict Admin Privileges, ML1) - Privileged accounts are blocked from authorising risky OAuth grants (ISM-1883) + #> + param($Tenant) + + $TestId = 'E8_Admin_04' + $Name = 'User consent for risky OAuth applications is restricted (ISM-1883)' + + try { + $AuthPolicy = Get-CIPPTestData -TenantFilter $Tenant -Type 'AuthorizationPolicy' + if (-not $AuthPolicy) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'AuthorizationPolicy cache not found.' -Risk 'Medium' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML1 - Restrict Admin Privileges' + return + } + + $Cfg = $AuthPolicy | Select-Object -First 1 + $Permissions = $Cfg.defaultUserRolePermissions + # The assigned consent policies live at the top level of the authorization policy, not under defaultUserRolePermissions. + $UserConsent = $Cfg.permissionGrantPolicyIdsAssignedToDefaultUserRole + + $Issues = [System.Collections.Generic.List[string]]::new() + if ($Permissions.allowedToCreateApps -eq $true) { + $Issues.Add('defaultUserRolePermissions.allowedToCreateApps is true — non-admin users can register new applications.') + } + if ($Cfg.allowUserConsentForRiskyApps -eq $true) { + $Issues.Add('allowUserConsentForRiskyApps is true — users can consent to applications Microsoft flags as risky.') + } + if ($UserConsent -contains 'ManagePermissionGrantsForSelf.microsoft-user-default-legacy') { + $Issues.Add('Legacy user consent policy in effect (`ManagePermissionGrantsForSelf.microsoft-user-default-legacy`). Switch to `microsoft-user-default-low` or admin-only.') + } + + if ($Issues.Count -eq 0) { + $Status = 'Passed' + $Result = 'User consent for OAuth apps is restricted to low-impact (or admin-only).' + } else { + $Status = 'Failed' + $Result = "Risky OAuth consent configuration:`n`n$(($Issues | ForEach-Object { "- $_" }) -join "`n")" + } + + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'Medium' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML1 - Restrict Admin Privileges' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'Medium' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML1 - Restrict Admin Privileges' + } +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_05.md b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_05.md new file mode 100644 index 0000000000000..692fbc06241a5 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_05.md @@ -0,0 +1,14 @@ +ISM-1648 — privileged access disabled after 45 days of inactivity. Stale privileged accounts are a common foothold for attackers; if no one is signing in, the account either should not exist or should be disabled. The control window is signal-of-life via Entra ID `signInActivity.lastSignInDateTime`. + +**Remediation Action** + +1. Review each stale privileged account listed. +2. Disable, delete, or remove privileged role assignments where appropriate. +3. Where the account is genuinely needed for monthly tasks, document the exception and consider PIM eligible assignment instead of permanent. + +**Links** +- [ACSC Essential Eight - Restrict Administrative Privileges](https://learn.microsoft.com/en-us/compliance/anz/e8-admin) +- [ISM-1648](https://www.cyber.gov.au/ism) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_05.ps1 b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_05.ps1 new file mode 100644 index 0000000000000..7f7873cbbe148 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_05.ps1 @@ -0,0 +1,69 @@ +function Invoke-CippTestE8_Admin_05 { + <# + .SYNOPSIS + ACSC Essential Eight (Restrict Admin Privileges, ML2) - No privileged account is inactive for more than 45 days (ISM-1648) + #> + param($Tenant) + + $TestId = 'E8_Admin_05' + $Name = 'No privileged account inactive for more than 45 days (ISM-1648)' + + try { + $Roles = Get-CippDbRole -TenantFilter $Tenant -IncludePrivilegedRoles + $RoleAssignmentScheduleInstances = Get-CIPPTestData -TenantFilter $Tenant -Type 'RoleAssignmentScheduleInstances' + $Users = Get-CIPPTestData -TenantFilter $Tenant -Type 'Users' + + if (-not $Roles -or -not $Users) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'Required cache (Roles or Users) not found.' -Risk 'Medium' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML2 - Restrict Admin Privileges' + return + } + + $PrivRoleIds = [System.Collections.Generic.HashSet[string]]::new() + $PrivUserIds = [System.Collections.Generic.HashSet[string]]::new() + foreach ($Role in @($Roles)) { + $RoleTemplateId = if ($Role.roleTemplateId) { [string]$Role.roleTemplateId } elseif ($Role.RoletemplateId) { [string]$Role.RoletemplateId } else { $null } + if ($RoleTemplateId) { [void]$PrivRoleIds.Add($RoleTemplateId) } + foreach ($M in @($Role.members)) { + if ($M.id) { [void]$PrivUserIds.Add([string]$M.id) } + } + } + foreach ($A in @($RoleAssignmentScheduleInstances)) { + if ($A.assignmentType -eq 'Assigned' -and $null -eq $A.endDateTime -and $A.principalId -and $PrivRoleIds.Contains([string]$A.roleDefinitionId)) { + [void]$PrivUserIds.Add([string]$A.principalId) + } + } + $PrivUsers = $Users | Where-Object { $PrivUserIds.Contains($_.id) -and $_.accountEnabled -eq $true } + + if (-not $PrivUsers) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Passed' -ResultMarkdown 'No enabled privileged users found.' -Risk 'Medium' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML2 - Restrict Admin Privileges' + return + } + + $Threshold = (Get-Date).AddDays(-45) + $Stale = foreach ($U in $PrivUsers) { + $Last = $U.signInActivity.lastSignInDateTime + if (-not $Last) { continue } + $LastDt = [datetime]::Parse($Last) + if ($LastDt -lt $Threshold) { + [pscustomobject]@{ UPN = $U.userPrincipalName; LastSignIn = $LastDt.ToString('yyyy-MM-dd') } + } + } + + if (-not $Stale) { + $Status = 'Passed' + $Result = "All $($PrivUsers.Count) enabled privileged user(s) signed in within the last 45 days (or have no recorded sign-in)." + } else { + $Status = 'Failed' + $Sb = [System.Text.StringBuilder]::new("$($Stale.Count) of $($PrivUsers.Count) enabled privileged user(s) have not signed in for more than 45 days:`n`n| UPN | Last sign-in |`n| :-- | :----------- |`n") + foreach ($S in ($Stale | Sort-Object LastSignIn | Select-Object -First 50)) { + $null = $Sb.Append("| $($S.UPN) | $($S.LastSignIn) |`n") + } + $Result = $Sb.ToString() + } + + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'Medium' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML2 - Restrict Admin Privileges' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'Medium' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML2 - Restrict Admin Privileges' + } +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_06.md b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_06.md new file mode 100644 index 0000000000000..804215e13c39a --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_06.md @@ -0,0 +1,14 @@ +ISM-1509 — privileged access events are centrally logged. The Entra ID Sign-in log and Audit log must be forwarded to a SIEM and retained for 12 months. This is configured under *Entra ID > Diagnostic settings* and depends on a target Log Analytics workspace / event hub / storage account that lives outside the tenant CIPP can read; verify manually. + +**Remediation Action** + +1. Entra ID > Diagnostic settings > Add diagnostic setting. +2. Send `SignInLogs`, `AuditLogs`, `RiskyUsers`, `UserRiskEvents`, `ServicePrincipalSignInLogs` to a Log Analytics workspace / Sentinel / Event Hub. +3. Confirm retention ≥ 12 months on the destination. + +**Links** +- [Entra ID diagnostic settings](https://learn.microsoft.com/en-us/azure/active-directory/reports-monitoring/howto-integrate-activity-logs-with-azure-monitor-logs) +- [ACSC Essential Eight - Restrict Administrative Privileges](https://learn.microsoft.com/en-us/compliance/anz/e8-admin) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_06.ps1 b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_06.ps1 new file mode 100644 index 0000000000000..4402d31eb7bd3 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_06.ps1 @@ -0,0 +1,9 @@ +function Invoke-CippTestE8_Admin_06 { + <# + .SYNOPSIS + ACSC Essential Eight (Restrict Admin Privileges, ML2) - Privileged access events are forwarded to a SIEM (ISM-1509) + #> + param($Tenant) + + Add-CippTestResult -TenantFilter $Tenant -TestId 'E8_Admin_06' -TestType 'Identity' -Status 'Informational' -ResultMarkdown 'This is a task performed manually. Confirm Entra ID Sign-in and Audit logs (and Microsoft 365 Unified Audit Log) are forwarded to a SIEM (Sentinel, Splunk, etc.) and retained for at least 12 months. CIPP cannot verify diagnostic settings or external SIEM connectivity from the partner tenant.' -Risk 'Medium' -Name 'Privileged access events are centrally logged (ISM-1509)' -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'E8 ML2 - Restrict Admin Privileges' +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_07.md b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_07.md new file mode 100644 index 0000000000000..50f9723f57f05 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_07.md @@ -0,0 +1,14 @@ +Microsoft and ACSC both recommend 2-4 dedicated cloud-only Global Administrator break-glass accounts on the `*.onmicrosoft.com` domain that are excluded from MFA-enforcing Conditional Access policies. Their purpose is recovery during a Conditional Access misconfiguration or MFA service outage. + +**Remediation Action** + +1. Maintain 2 (recommended) or up to 4 cloud-only `*.onmicrosoft.com` GA accounts. +2. Exclude them from MFA-required Conditional Access policies. +3. Protect them with FIDO2 / hardware tokens, store credentials in a sealed envelope, monitor sign-ins via Sentinel. + +**Links** +- [Manage emergency access accounts](https://learn.microsoft.com/en-us/azure/active-directory/roles/security-emergency-access) +- [ACSC Essential Eight - Restrict Administrative Privileges](https://learn.microsoft.com/en-us/compliance/anz/e8-admin) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_07.ps1 b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_07.ps1 new file mode 100644 index 0000000000000..5d9a8b2267b2e --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_07.ps1 @@ -0,0 +1,75 @@ +function Invoke-CippTestE8_Admin_07 { + <# + .SYNOPSIS + ACSC Essential Eight (Restrict Admin Privileges, ML2) - Break-glass accounts exist and are excluded from MFA enforcement + #> + param($Tenant) + + $TestId = 'E8_Admin_07' + $Name = 'Break-glass accounts (2-4) exist and are excluded from at least one MFA Conditional Access policy' + + try { + $Roles = Get-CIPPTestData -TenantFilter $Tenant -Type 'Roles' + $RoleAssignmentScheduleInstances = Get-CIPPTestData -TenantFilter $Tenant -Type 'RoleAssignmentScheduleInstances' + $Users = Get-CIPPTestData -TenantFilter $Tenant -Type 'Users' + $CA = Get-CIPPTestData -TenantFilter $Tenant -Type 'ConditionalAccessPolicies' + + if (-not $Roles -or -not $Users -or -not $CA) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'Required cache (Roles, Users or ConditionalAccessPolicies) not found.' -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'E8 ML2 - Restrict Admin Privileges' + return + } + + $GaRole = $Roles | Where-Object { $_.displayName -eq 'Global Administrator' } | Select-Object -First 1 + if (-not $GaRole) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'Global Administrator role not found in the Roles cache.' -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'E8 ML2 - Restrict Admin Privileges' + return + } + + $GaTemplateId = if ($GaRole.roleTemplateId) { [string]$GaRole.roleTemplateId } elseif ($GaRole.RoletemplateId) { [string]$GaRole.RoletemplateId } else { $null } + + $GaUserIds = [System.Collections.Generic.HashSet[string]]::new() + foreach ($M in @($GaRole.members)) { + if ($M.id) { [void]$GaUserIds.Add([string]$M.id) } + } + # RoleAssignmentScheduleInstances.roleDefinitionId is a role template ID, not the directory role instance ID. + foreach ($A in @($RoleAssignmentScheduleInstances)) { + if ($A.assignmentType -eq 'Assigned' -and $null -eq $A.endDateTime -and $A.principalId -and $GaTemplateId -and [string]$A.roleDefinitionId -eq $GaTemplateId) { + [void]$GaUserIds.Add([string]$A.principalId) + } + } + $GaUsers = $Users | Where-Object { $GaUserIds.Contains($_.id) } + $BreakGlass = $GaUsers | Where-Object { $_.userPrincipalName -like '*onmicrosoft.com' -and $_.accountEnabled -eq $true } + + if (-not $BreakGlass -or $BreakGlass.Count -lt 2) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Only $($BreakGlass.Count) Global Administrator(s) on the *.onmicrosoft.com domain. ACSC guidance recommends 2-4 dedicated cloud-only break-glass accounts." -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'E8 ML2 - Restrict Admin Privileges' + return + } + if ($BreakGlass.Count -gt 4) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Failed' -ResultMarkdown "$($BreakGlass.Count) cloud-only Global Administrators exist. Excessive break-glass accounts increase risk; reduce to 2-4." -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'E8 ML2 - Restrict Admin Privileges' + return + } + + $BreakGlassIds = [System.Collections.Generic.HashSet[string]]::new([string[]]$BreakGlass.id) + $MfaPolicies = $CA | Where-Object { + $_.state -eq 'enabled' -and + (($_.grantControls.builtInControls -contains 'mfa') -or $_.grantControls.authenticationStrength) + } + $WithExclusion = foreach ($P in $MfaPolicies) { + $Excluded = $P.conditions.users.excludeUsers + if ($Excluded -and (@($Excluded) | Where-Object { $BreakGlassIds.Contains($_) }).Count -gt 0) { $P } + } + + if ($WithExclusion) { + $Status = 'Passed' + $Result = "$($BreakGlass.Count) break-glass account(s) found. They are excluded from $((@($WithExclusion)).Count) MFA-enforcing Conditional Access policy/policies." + } else { + $Status = 'Failed' + $Result = "$($BreakGlass.Count) break-glass account(s) exist but no MFA-enforcing Conditional Access policy excludes them. A token-service MFA outage will lock the tenant." + } + + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'E8 ML2 - Restrict Admin Privileges' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'E8 ML2 - Restrict Admin Privileges' + } +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_08.md b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_08.md new file mode 100644 index 0000000000000..f83c1b86a154b --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_08.md @@ -0,0 +1,14 @@ +ISM-1508 — privileged accounts are time-bound and reauthenticated for each session. Microsoft's equivalent is Privileged Identity Management (PIM): admins are *eligible* for a role and must activate it for a limited time. This test fails when highly-privileged roles have permanent (active) assignments rather than eligible-only. + +**Remediation Action** + +1. Entra ID > Privileged Identity Management > Microsoft Entra roles. +2. For each role listed, convert all members from *Active* to *Eligible*. +3. Set max activation duration ≤ 8 hours; require justification + MFA on activation. + +**Links** +- [PIM in Microsoft Entra](https://learn.microsoft.com/en-us/entra/id-governance/privileged-identity-management/pim-configure) +- [ACSC Essential Eight - Restrict Administrative Privileges](https://learn.microsoft.com/en-us/compliance/anz/e8-admin) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_08.ps1 b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_08.ps1 new file mode 100644 index 0000000000000..5c7a0fee1911f --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_08.ps1 @@ -0,0 +1,70 @@ +function Invoke-CippTestE8_Admin_08 { + <# + .SYNOPSIS + ACSC Essential Eight (Restrict Admin Privileges, ML3) - Just-in-Time / PIM is used for highly privileged roles (ISM-1508) + #> + param($Tenant) + + $TestId = 'E8_Admin_08' + $Name = 'Just-in-Time activation (PIM eligibility) is used for highly privileged roles' + + $HighlyPriv = @('Global Administrator','Privileged Role Administrator','Privileged Authentication Administrator','Conditional Access Administrator','Intune Administrator','Security Administrator') + + try { + $Roles = Get-CIPPTestData -TenantFilter $Tenant -Type 'Roles' + $RoleAssignmentScheduleInstances = Get-CIPPTestData -TenantFilter $Tenant -Type 'RoleAssignmentScheduleInstances' + + if (-not $Roles) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'Required cache (Roles) not found.' -Risk 'High' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'Medium' -Category 'E8 ML3 - Restrict Admin Privileges' + return + } + + # Without PIM active-assignment data we cannot distinguish permanent assignments from + # PIM-eligible activation, so treating a missing cache as "compliant" would be a false pass. + if (-not $RoleAssignmentScheduleInstances) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'RoleAssignmentScheduleInstances (PIM active assignments) cache not found — cannot verify whether highly-privileged roles use Just-in-Time activation.' -Risk 'High' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'Medium' -Category 'E8 ML3 - Restrict Admin Privileges' + return + } + + $TargetRoles = $Roles | Where-Object { $_.displayName -in $HighlyPriv } + if (-not $TargetRoles) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'No highly-privileged roles found in cache.' -Risk 'High' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'Medium' -Category 'E8 ML3 - Restrict Admin Privileges' + return + } + + # RoleAssignmentScheduleInstances.roleDefinitionId is a role template ID, not a directory role instance ID. + $TargetRoleTemplates = @{} + foreach ($R in $TargetRoles) { + $Tid = if ($R.roleTemplateId) { [string]$R.roleTemplateId } elseif ($R.RoletemplateId) { [string]$R.RoletemplateId } else { $null } + if ($Tid) { $TargetRoleTemplates[$Tid] = $R.displayName } + } + $PermanentByRole = @{} + foreach ($A in @($RoleAssignmentScheduleInstances)) { + if ($A.assignmentType -eq 'Assigned' -and $null -eq $A.endDateTime -and $A.principalId -and $TargetRoleTemplates.ContainsKey([string]$A.roleDefinitionId)) { + $key = [string]$A.roleDefinitionId + if (-not $PermanentByRole.ContainsKey($key)) { $PermanentByRole[$key] = [System.Collections.Generic.HashSet[string]]::new() } + [void]$PermanentByRole[$key].Add([string]$A.principalId) + } + } + + $RolesWithPermanent = foreach ($Tid in $TargetRoleTemplates.Keys) { + $count = if ($PermanentByRole.ContainsKey($Tid)) { $PermanentByRole[$Tid].Count } else { 0 } + if ($count -gt 0) { [pscustomobject]@{ Role = $TargetRoleTemplates[$Tid]; Permanent = $count } } + } + + if (-not $RolesWithPermanent) { + $Status = 'Passed' + $Result = "No permanent role assignments found for highly-privileged roles ($($TargetRoles.displayName -join ', ')). All access appears to be PIM-eligible." + } else { + $Status = 'Failed' + $Sb = [System.Text.StringBuilder]::new("Permanent (non-PIM) assignments to highly-privileged roles:`n`n| Role | Permanent assignees |`n| :--- | :-----------------: |`n") + foreach ($R in $RolesWithPermanent) { $null = $Sb.Append("| $($R.Role) | $($R.Permanent) |`n") } + $Result = $Sb.ToString() + } + + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'Medium' -Category 'E8 ML3 - Restrict Admin Privileges' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'Medium' -Category 'E8 ML3 - Restrict Admin Privileges' + } +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_09.md b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_09.md new file mode 100644 index 0000000000000..f9849564a60ea --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_09.md @@ -0,0 +1,13 @@ +PIM activation should require MFA and a typed justification for every privileged role activation. This raises the bar against stolen tokens and provides an audit trail. + +**Remediation Action** + +1. Entra ID > PIM > Microsoft Entra roles > Roles. +2. For each role, open *Role settings* > *Edit*. +3. On Activation, tick **Azure MFA** and **Require justification on activation**. + +**Links** +- [Configure Microsoft Entra role settings in PIM](https://learn.microsoft.com/en-us/entra/id-governance/privileged-identity-management/pim-how-to-change-default-settings) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_09.ps1 b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_09.ps1 new file mode 100644 index 0000000000000..ad1add866ded9 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_09.ps1 @@ -0,0 +1,9 @@ +function Invoke-CippTestE8_Admin_09 { + <# + .SYNOPSIS + ACSC Essential Eight (Restrict Admin Privileges, ML3) - PIM activation requires MFA and justification + #> + param($Tenant) + + Add-CippTestResult -TenantFilter $Tenant -TestId 'E8_Admin_09' -TestType 'Identity' -Status 'Informational' -ResultMarkdown 'This is a task performed manually. In Entra ID > PIM > Microsoft Entra roles > Settings, confirm each highly-privileged role requires MFA on activation and a justification. The full PIM rule set is not exposed in cached `RoleManagementPolicies` (rules require `$expand=rules` per role).' -Risk 'High' -Name 'PIM activation requires MFA and justification' -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'E8 ML3 - Restrict Admin Privileges' +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_10.md b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_10.md new file mode 100644 index 0000000000000..a8cc878a93236 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_10.md @@ -0,0 +1,13 @@ +For tier-zero roles (Global Administrator, Privileged Role Administrator) activation should require approval from a second administrator. This adds a four-eyes control on the most damaging roles. + +**Remediation Action** + +1. Entra ID > PIM > Microsoft Entra roles > Roles > *Global Administrator* > Role settings > Edit. +2. On Activation, enable **Require approval to activate** and add at least one approver. +3. Repeat for *Privileged Role Administrator*. + +**Links** +- [Approve activation requests in PIM](https://learn.microsoft.com/en-us/entra/id-governance/privileged-identity-management/pim-approval-workflow) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_10.ps1 b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_10.ps1 new file mode 100644 index 0000000000000..5ef104d433cc0 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_10.ps1 @@ -0,0 +1,9 @@ +function Invoke-CippTestE8_Admin_10 { + <# + .SYNOPSIS + ACSC Essential Eight (Restrict Admin Privileges, ML3) - PIM activation requires approval for highly privileged roles + #> + param($Tenant) + + Add-CippTestResult -TenantFilter $Tenant -TestId 'E8_Admin_10' -TestType 'Identity' -Status 'Informational' -ResultMarkdown 'This is a task performed manually. Confirm Global Administrator and Privileged Role Administrator activations require approval (PIM > Role settings > Activation > Require approval to activate). The full PIM rule set is not exposed in the cached `RoleManagementPolicies` collection.' -Risk 'High' -Name 'PIM activation requires approval for Global Administrator and Privileged Role Administrator' -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'E8 ML3 - Restrict Admin Privileges' +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_11.md b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_11.md new file mode 100644 index 0000000000000..6f06c280ce97a --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_11.md @@ -0,0 +1,13 @@ +ISM-1647 — privileges are reviewed at least annually. PIM eligibility assignments must therefore expire within 12 months so the next assignment is a deliberate decision. Permanent eligibility defeats the purpose of access reviews. + +**Remediation Action** + +1. Entra ID > PIM > Microsoft Entra roles > Assignments > Eligible. +2. For each assignment without expiry, set an end date ≤ 12 months from now. +3. Tighten role settings so future assignments cannot exceed 12 months. + +**Links** +- [Assign Microsoft Entra roles in PIM](https://learn.microsoft.com/en-us/entra/id-governance/privileged-identity-management/pim-how-to-add-role-to-user) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_11.ps1 b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_11.ps1 new file mode 100644 index 0000000000000..fa90401cba9ad --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_11.ps1 @@ -0,0 +1,45 @@ +function Invoke-CippTestE8_Admin_11 { + <# + .SYNOPSIS + ACSC Essential Eight (Restrict Admin Privileges, ML3) - PIM eligibility is reviewed at least every 12 months (ISM-1647) + #> + param($Tenant) + + $TestId = 'E8_Admin_11' + $Name = 'PIM role eligibility expires within 12 months (no permanent eligibility) (ISM-1647)' + + try { + $Schedules = Get-CIPPTestData -TenantFilter $Tenant -Type 'RoleEligibilitySchedules' + if (-not $Schedules) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'RoleEligibilitySchedules cache not found (no PIM in use, or P2 not licensed).' -Risk 'Medium' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML3 - Restrict Admin Privileges' + return + } + + $Now = Get-Date + $MaxFuture = $Now.AddDays(366) + $Bad = foreach ($S in $Schedules) { + $Type = $S.scheduleInfo.expiration.type + $End = $S.scheduleInfo.expiration.endDateTime + if ($Type -eq 'noExpiration' -or -not $End) { + [pscustomobject]@{ Principal = $S.principalId; RoleId = $S.roleDefinitionId; Reason = 'No expiration' } + } elseif ([datetime]::Parse($End) -gt $MaxFuture) { + [pscustomobject]@{ Principal = $S.principalId; RoleId = $S.roleDefinitionId; Reason = "Expires $End (>12 months)" } + } + } + + if (-not $Bad) { + $Status = 'Passed' + $Result = "All $($Schedules.Count) PIM eligibility schedule(s) expire within 12 months." + } else { + $Status = 'Failed' + $Sb = [System.Text.StringBuilder]::new("$($Bad.Count) of $($Schedules.Count) PIM eligibility schedule(s) do not expire within 12 months:`n`n| Principal | Role | Reason |`n| :-------- | :--- | :----- |`n") + foreach ($B in ($Bad | Select-Object -First 50)) { $null = $Sb.Append("| $($B.Principal) | $($B.RoleId) | $($B.Reason) |`n") } + $Result = $Sb.ToString() + } + + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'Medium' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML3 - Restrict Admin Privileges' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'Medium' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML3 - Restrict Admin Privileges' + } +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_12.md b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_12.md new file mode 100644 index 0000000000000..3bfb761392c9b --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_12.md @@ -0,0 +1,13 @@ +Microsoft and ACSC recommend a minimum of 2 and a maximum of 4 Global Administrators. Two is the minimum to avoid total lockout; more than four needlessly increases attack surface. + +**Remediation Action** + +1. Identify excess Global Administrators in Entra ID > Roles and administrators > Global Administrator. +2. Replace with least-privileged roles (e.g. *Exchange Administrator*, *User Administrator*). +3. If fewer than 2 — add a second cloud-only break-glass GA. + +**Links** +- [Best practices for roles in Microsoft Entra ID](https://learn.microsoft.com/en-us/entra/identity/role-based-access-control/best-practices) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_12.ps1 b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_12.ps1 new file mode 100644 index 0000000000000..a01b938a4e951 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_12.ps1 @@ -0,0 +1,58 @@ +function Invoke-CippTestE8_Admin_12 { + <# + .SYNOPSIS + ACSC Essential Eight (Restrict Admin Privileges, ML1) - Global Administrator count is between 2 and 4 + #> + param($Tenant) + + $TestId = 'E8_Admin_12' + $Name = 'Global Administrator count is between 2 and 4' + + try { + $Roles = Get-CIPPTestData -TenantFilter $Tenant -Type 'Roles' + $RoleAssignmentScheduleInstances = Get-CIPPTestData -TenantFilter $Tenant -Type 'RoleAssignmentScheduleInstances' + + if (-not $Roles) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'Required cache (Roles) not found.' -Risk 'Medium' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML1 - Restrict Admin Privileges' + return + } + + $GaRole = $Roles | Where-Object { $_.displayName -eq 'Global Administrator' } | Select-Object -First 1 + if (-not $GaRole) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'Global Administrator role not present in cache.' -Risk 'Medium' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML1 - Restrict Admin Privileges' + return + } + + $GaTemplateId = if ($GaRole.roleTemplateId) { [string]$GaRole.roleTemplateId } elseif ($GaRole.RoletemplateId) { [string]$GaRole.RoletemplateId } else { $null } + + # Only count user accounts as Global Administrators — service principals holding the role + # (e.g. the CIPP-SAM application) are not human admins and cannot be reduced by delegation. + $GaUserIds = [System.Collections.Generic.HashSet[string]]::new() + foreach ($M in @($GaRole.members)) { + if ($M.id -and $M.'@odata.type' -eq '#microsoft.graph.user') { [void]$GaUserIds.Add([string]$M.id) } + } + # RoleAssignmentScheduleInstances.roleDefinitionId is a role template ID, not the directory role instance ID. + foreach ($A in @($RoleAssignmentScheduleInstances)) { + if ($A.assignmentType -eq 'Assigned' -and $null -eq $A.endDateTime -and $A.principalId -and $GaTemplateId -and [string]$A.roleDefinitionId -eq $GaTemplateId) { + [void]$GaUserIds.Add([string]$A.principalId) + } + } + $GaCount = $GaUserIds.Count + + if ($GaCount -ge 2 -and $GaCount -le 4) { + $Status = 'Passed' + $Result = "$GaCount Global Administrator(s) — within recommended range of 2-4." + } elseif ($GaCount -lt 2) { + $Status = 'Failed' + $Result = "Only $GaCount Global Administrator(s). At least 2 are required so a single account loss does not lock the tenant." + } else { + $Status = 'Failed' + $Result = "$GaCount Global Administrators — exceeds the recommended maximum of 4. Reduce by delegating finer-grained roles." + } + + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'Medium' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML1 - Restrict Admin Privileges' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'Medium' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML1 - Restrict Admin Privileges' + } +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_13.md b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_13.md new file mode 100644 index 0000000000000..1f1dc92d11784 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_13.md @@ -0,0 +1,13 @@ +High-privilege OAuth grants (e.g. application permissions on Microsoft Graph such as `Directory.ReadWrite.All`, `RoleManagement.ReadWrite.Directory`, `Application.ReadWrite.All`, `Mail.ReadWrite`) effectively grant Global-Admin-equivalent access via a service principal. Review these regularly. + +**Remediation Action** + +1. Entra ID > Enterprise applications > All applications. +2. For each app with admin-consented permissions, review *Permissions* and revoke unused. +3. Use Microsoft Defender for Cloud Apps / Microsoft 365 Defender to alert on new high-privilege consents. + +**Links** +- [Investigate risky OAuth apps](https://learn.microsoft.com/en-us/defender-cloud-apps/investigate-risky-oauth) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_13.ps1 b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_13.ps1 new file mode 100644 index 0000000000000..d0a1d832cb1a0 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Admin_13.ps1 @@ -0,0 +1,9 @@ +function Invoke-CippTestE8_Admin_13 { + <# + .SYNOPSIS + ACSC Essential Eight (Restrict Admin Privileges, ML2) - High-privilege OAuth grants are reviewed + #> + param($Tenant) + + Add-CippTestResult -TenantFilter $Tenant -TestId 'E8_Admin_13' -TestType 'Identity' -Status 'Informational' -ResultMarkdown 'This is a task performed manually. Review enterprise applications and OAuth2 permission grants for high-privilege scopes (Directory.ReadWrite.All, RoleManagement.ReadWrite.Directory, Application.ReadWrite.All, Mail.ReadWrite, full_access_as_app). The OAuth2PermissionGrants and ServicePrincipals collections are not currently cached for analysis here; use the CIPP *Application Approvals* and *Enterprise Applications* views instead.' -Risk 'Medium' -Name 'High-privilege OAuth grants are reviewed' -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'E8 ML2 - Restrict Admin Privileges' +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Backup_01.md b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Backup_01.md new file mode 100644 index 0000000000000..421c17398fdbc --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Backup_01.md @@ -0,0 +1,13 @@ +Litigation hold and retention policies prevent permanent loss of mailbox content from accidental or malicious deletion. While not a true backup, they provide point-in-time recovery for E8 ML1. + +**Remediation Action** + +1. Apply a Microsoft 365 retention policy to user mailboxes covering Exchange, OneDrive, and SharePoint. +2. Or enable per-mailbox litigation hold with a duration of at least the organisation's retention requirement. + +**Links** +- [Litigation hold](https://learn.microsoft.com/en-us/purview/ediscovery-create-a-litigation-hold) +- [Microsoft 365 retention policies](https://learn.microsoft.com/en-us/purview/retention) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Backup_01.ps1 b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Backup_01.ps1 new file mode 100644 index 0000000000000..081976351991d --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Backup_01.ps1 @@ -0,0 +1,49 @@ +function Invoke-CippTestE8_Backup_01 { + <# + .SYNOPSIS + ACSC Essential Eight (Regular Backups, ML1) - User mailboxes have litigation hold or retention applied + #> + param($Tenant) + + $TestId = 'E8_Backup_01' + $Name = 'User mailboxes have litigation hold or a retention policy applied' + + try { + $Mailboxes = Get-CIPPTestData -TenantFilter $Tenant -Type 'Mailboxes' + if (-not $Mailboxes) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'No Mailboxes cached for this tenant.' -Risk 'Medium' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML1 - Regular Backups' + return + } + + # Inactive (soft-deleted) mailboxes carry WhenSoftDeleted; there is no IsInactiveMailbox field in the cache. + $UserMailboxes = $Mailboxes | Where-Object { $_.RecipientTypeDetails -eq 'UserMailbox' -and -not $_.WhenSoftDeleted } + if (-not $UserMailboxes) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'No user mailboxes found.' -Risk 'Medium' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML1 - Regular Backups' + return + } + + # The built-in "Default MRM Policy" is present on every mailbox and only manages archive/deletion tags — + # it is not a data-protection retention control, so it does not count as protected. + $Unprotected = $UserMailboxes | Where-Object { + -not ($_.LitigationHoldEnabled -eq $true) -and + -not ($_.ComplianceTagHoldApplied -eq $true) -and + ([string]::IsNullOrWhiteSpace($_.RetentionPolicy) -or $_.RetentionPolicy -eq 'Default MRM Policy') -and + -not $_.InPlaceHolds + } + + if (-not $Unprotected) { + $Status = 'Passed' + $Result = "All $($UserMailboxes.Count) user mailbox(es) have at least one of: litigation hold, a non-default retention policy, or compliance hold applied." + } else { + $Status = 'Failed' + $Sb = [System.Text.StringBuilder]::new("$($Unprotected.Count) of $($UserMailboxes.Count) user mailbox(es) have no litigation hold, retention policy, or compliance tag applied:`n`n| UPN |`n| :-- |`n") + foreach ($M in ($Unprotected | Select-Object -First 50)) { $null = $Sb.Append("| $($M.UPN) |`n") } + $Result = $Sb.ToString() + } + + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'Medium' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML1 - Regular Backups' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'Medium' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML1 - Regular Backups' + } +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Backup_02.md b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Backup_02.md new file mode 100644 index 0000000000000..885790e1ad9e6 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Backup_02.md @@ -0,0 +1,13 @@ +SharePoint version history and the recycle bin together provide point-in-time recovery for documents. Confirm versioning is on (default 500 versions for new libraries) and the recycle bin retention is 93 days. + +**Remediation Action** + +1. SharePoint admin centre > Sites > Active sites. +2. For critical sites, confirm library versioning is enabled and the version count is acceptable. +3. Recycle bin retention is fixed at 93 days for SharePoint and OneDrive. + +**Links** +- [SharePoint versioning](https://learn.microsoft.com/en-us/sharepoint/enable-disable-versioning) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Backup_02.ps1 b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Backup_02.ps1 new file mode 100644 index 0000000000000..b22052da74da8 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Backup_02.ps1 @@ -0,0 +1,8 @@ +function Invoke-CippTestE8_Backup_02 { + <# + .SYNOPSIS + ACSC Essential Eight (Regular Backups, ML1) - SharePoint Online retains versions and recycle bin items + #> + param($Tenant) + Add-CippTestResult -TenantFilter $Tenant -TestId 'E8_Backup_02' -TestType 'Identity' -Status 'Informational' -ResultMarkdown 'This is a task performed manually. Confirm SharePoint Online sites have versioning enabled (default minimum 100 versions) and the second-stage recycle bin retention is at least 93 days. Site-level versioning is configured per library and is not exposed centrally; review via SharePoint admin centre or PnP PowerShell.' -Risk 'Medium' -Name 'SharePoint Online versioning and recycle bin retention is configured' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML1 - Regular Backups' +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Backup_03.md b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Backup_03.md new file mode 100644 index 0000000000000..c1cd843c1a17d --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Backup_03.md @@ -0,0 +1,12 @@ +Known Folder Move redirects users' Desktop, Documents, and Pictures to OneDrive so file changes are continuously synced and version-protected. + +**Remediation Action** + +1. Intune > Settings catalog > **OneDrive** > *Silently move Windows known folders to OneDrive* = Enabled with the tenant ID. +2. Optionally enable *Use OneDrive Files On-Demand* and *Prevent users from redirecting Windows known folders to their PC*. + +**Links** +- [Redirect known folders to OneDrive](https://learn.microsoft.com/en-us/sharepoint/redirect-known-folders) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Backup_03.ps1 b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Backup_03.ps1 new file mode 100644 index 0000000000000..f51b96dd5987d --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Backup_03.ps1 @@ -0,0 +1,8 @@ +function Invoke-CippTestE8_Backup_03 { + <# + .SYNOPSIS + ACSC Essential Eight (Regular Backups, ML1) - OneDrive Known Folder Move (KFM) is configured + #> + param($Tenant) + Add-CippTestResult -TenantFilter $Tenant -TestId 'E8_Backup_03' -TestType 'Identity' -Status 'Informational' -ResultMarkdown 'This is a task performed manually. Confirm OneDrive Known Folder Move is configured to redirect Desktop, Documents, and Pictures to OneDrive on all Windows endpoints. Configure via Intune Settings catalog: *OneDrive > Silently move Windows known folders to OneDrive*.' -Risk 'Medium' -Name 'OneDrive Known Folder Move (KFM) is enforced' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML1 - Regular Backups' +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Backup_04.md b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Backup_04.md new file mode 100644 index 0000000000000..97d502bf95dc0 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Backup_04.md @@ -0,0 +1,14 @@ +ISM-1547 — backups must be performed regularly, retained for an organisation-determined period, stored separately from the source data, and **tested**. Microsoft retention is not a backup. Use Microsoft 365 Backup or a third-party solution (Veeam, Datto, Druva, AvePoint, etc.) and run a documented restore test at least quarterly. + +**Remediation Action** + +1. Provision a Microsoft 365 backup solution covering Exchange, OneDrive, SharePoint, and Teams. +2. Schedule a quarterly test restore and record the results in your backup runbook. +3. Verify the backup admin account is not synced from on-premises and uses an isolated identity. + +**Links** +- [Microsoft 365 Backup](https://learn.microsoft.com/en-us/microsoft-365/backup/) +- [ACSC Essential Eight - Regular Backups](https://learn.microsoft.com/en-us/compliance/anz/e8-backup) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Backup_04.ps1 b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Backup_04.ps1 new file mode 100644 index 0000000000000..5052f18712213 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_Backup_04.ps1 @@ -0,0 +1,8 @@ +function Invoke-CippTestE8_Backup_04 { + <# + .SYNOPSIS + ACSC Essential Eight (Regular Backups, ML2) - A tested backup and restore process exists for Microsoft 365 data + #> + param($Tenant) + Add-CippTestResult -TenantFilter $Tenant -TestId 'E8_Backup_04' -TestType 'Identity' -Status 'Informational' -ResultMarkdown 'This is a task performed manually. Confirm a third-party (or Microsoft 365 Backup) solution is in place for mailboxes, OneDrive, SharePoint, and Teams data, and that restore tests are performed at least quarterly with documented results. Microsoft retention is **not** a backup — it does not protect against admin deletion or compliance policy changes.' -Risk 'High' -Name 'Microsoft 365 data is backed up by a tested process (ISM-1547)' -UserImpact 'Low' -ImplementationEffort 'High' -Category 'E8 ML2 - Regular Backups' +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_01.md b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_01.md new file mode 100644 index 0000000000000..977487368e95c --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_01.md @@ -0,0 +1,14 @@ +All staff (and any service accounts that interact with the tenant) must be registered for multifactor authentication. The Essential Eight requires MFA at Maturity Level 1 across all users so a stolen password cannot, on its own, grant access to corporate data. + +**Remediation Action** + +1. Identify users without MFA registration (this test lists them). +2. Enable a Conditional Access policy or Security Defaults to force registration on next sign-in. +3. Validate via Entra ID > Authentication methods > User registration details that `isMfaCapable = true`. + +**Links** +- [ACSC Essential Eight - Multifactor Authentication](https://learn.microsoft.com/en-us/compliance/anz/e8-mfa) +- [Plan an Authentication methods deployment](https://learn.microsoft.com/en-us/azure/active-directory/authentication/howto-authentication-methods-activity) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_01.ps1 b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_01.ps1 new file mode 100644 index 0000000000000..ad4c52391273d --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_01.ps1 @@ -0,0 +1,49 @@ +function Invoke-CippTestE8_MFA_01 { + <# + .SYNOPSIS + ACSC Essential Eight (MFA, ML1) - All member users are MFA capable + #> + param($Tenant) + + $TestId = 'E8_MFA_01' + $Name = 'All member users are registered for MFA' + + try { + $Reg = Get-CIPPTestData -TenantFilter $Tenant -Type 'UserRegistrationDetails' + $Users = Get-CIPPTestData -TenantFilter $Tenant -Type 'Users' + + if (-not $Reg -or -not $Users) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'Required cache (UserRegistrationDetails or Users) not found. Please refresh the cache for this tenant.' -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'E8 ML1 - MFA' + return + } + + $RegByUpn = @{} + foreach ($R in ($Reg | Where-Object { $_.userPrincipalName })) { + $RegByUpn[$R.userPrincipalName.ToLower()] = $R + } + + $MemberUsers = $Users | Where-Object { $_.accountEnabled -eq $true -and $_.userType -ne 'Guest' } + $NotMfaCapable = foreach ($U in $MemberUsers) { + $R = $RegByUpn[[string]$U.userPrincipalName.ToLower()] + if (-not $R -or $R.isMfaCapable -ne $true) { $U } + } + + if (-not $NotMfaCapable) { + $Status = 'Passed' + $Result = "All $($MemberUsers.Count) enabled member users are MFA capable." + } else { + $Status = 'Failed' + $Sb = [System.Text.StringBuilder]::new("$($NotMfaCapable.Count) of $($MemberUsers.Count) enabled member users are not MFA capable:`n`n") + $null = $Sb.Append("| UPN | Display Name |`n| :-- | :----------- |`n") + foreach ($U in ($NotMfaCapable | Select-Object -First 50)) { + $null = $Sb.Append("| $($U.userPrincipalName) | $($U.displayName) |`n") + } + $Result = $Sb.ToString() + } + + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'E8 ML1 - MFA' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'E8 ML1 - MFA' + } +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_02.md b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_02.md new file mode 100644 index 0000000000000..2cee23be4ddbf --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_02.md @@ -0,0 +1,16 @@ +A tenant-wide Conditional Access policy that requires MFA on every sign-in to every cloud app is the baseline control for Essential Eight ML1. Without it, MFA remains optional and attackers can bypass it via legacy clients or unprotected applications. + +**Remediation Action** + +1. Entra ID > Conditional Access > New policy. +2. Users: All users (exclude break-glass). +3. Cloud apps: All cloud apps. +4. Grant: Require multifactor authentication (or an Authentication Strength). +5. Enable policy. + +**Links** +- [ACSC Essential Eight - Multifactor Authentication](https://learn.microsoft.com/en-us/compliance/anz/e8-mfa) +- [Common CA policy: Require MFA for all users](https://learn.microsoft.com/en-us/azure/active-directory/conditional-access/howto-conditional-access-policy-all-users-mfa) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_02.ps1 b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_02.ps1 new file mode 100644 index 0000000000000..9d30202b53d56 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_02.ps1 @@ -0,0 +1,42 @@ +function Invoke-CippTestE8_MFA_02 { + <# + .SYNOPSIS + ACSC Essential Eight (MFA, ML1) - A Conditional Access policy enforces MFA for all users + #> + param($Tenant) + + $TestId = 'E8_MFA_02' + $Name = 'A Conditional Access policy enforces MFA for all users on all cloud apps' + + try { + $CA = Get-CIPPTestData -TenantFilter $Tenant -Type 'ConditionalAccessPolicies' + if (-not $CA) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'ConditionalAccessPolicies cache not found.' -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'E8 ML1 - MFA' + return + } + + $Match = $CA | Where-Object { + $_.state -eq 'enabled' -and + ($_.conditions.users.includeUsers -contains 'All') -and + ($_.conditions.applications.includeApplications -contains 'All') -and + ( + ($_.grantControls.builtInControls -contains 'mfa') -or + $_.grantControls.authenticationStrength + ) + } + + if ($Match) { + $Status = 'Passed' + $Result = "$($Match.Count) Conditional Access policy/policies enforce MFA on all users for all cloud apps:`n`n" + + (($Match | ForEach-Object { "- $($_.displayName)" }) -join "`n") + } else { + $Status = 'Failed' + $Result = 'No enabled Conditional Access policy targets All Users + All Cloud Apps with an MFA grant control.' + } + + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'E8 ML1 - MFA' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'E8 ML1 - MFA' + } +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_03.md b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_03.md new file mode 100644 index 0000000000000..37065b793d250 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_03.md @@ -0,0 +1,17 @@ +Legacy authentication protocols (POP, IMAP, SMTP AUTH, older Outlook clients, ActiveSync basic auth) cannot enforce MFA. If they are reachable, an attacker with stolen credentials defeats Essential Eight MFA controls. + +**Remediation Action** + +1. Entra ID > Conditional Access > Create policy. +2. Users: All users (exclude break-glass + service accounts that genuinely need legacy auth). +3. Cloud apps: All cloud apps. +4. Conditions > Client apps: tick *Exchange ActiveSync clients* and *Other clients*. +5. Grant: Block access. +6. Enable. + +**Links** +- [Block legacy authentication](https://learn.microsoft.com/en-us/azure/active-directory/conditional-access/howto-conditional-access-policy-block-legacy) +- [ACSC Essential Eight - MFA](https://learn.microsoft.com/en-us/compliance/anz/e8-mfa) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_03.ps1 b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_03.ps1 new file mode 100644 index 0000000000000..217c9b3799f35 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_03.ps1 @@ -0,0 +1,39 @@ +function Invoke-CippTestE8_MFA_03 { + <# + .SYNOPSIS + ACSC Essential Eight (MFA, ML1) - Legacy authentication is blocked + #> + param($Tenant) + + $TestId = 'E8_MFA_03' + $Name = 'Legacy authentication is blocked tenant-wide' + + try { + $CA = Get-CIPPTestData -TenantFilter $Tenant -Type 'ConditionalAccessPolicies' + if (-not $CA) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'ConditionalAccessPolicies cache not found.' -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'E8 ML1 - MFA' + return + } + + $Match = $CA | Where-Object { + $_.state -eq 'enabled' -and + ($_.conditions.users.includeUsers -contains 'All') -and + ($_.conditions.clientAppTypes -contains 'exchangeActiveSync' -or $_.conditions.clientAppTypes -contains 'other') -and + ($_.grantControls.builtInControls -contains 'block') + } + + if ($Match) { + $Status = 'Passed' + $Result = "$($Match.Count) Conditional Access policy/policies block legacy auth:`n`n" + + (($Match | ForEach-Object { "- $($_.displayName)" }) -join "`n") + } else { + $Status = 'Failed' + $Result = 'No enabled Conditional Access policy blocks legacy authentication clients (`exchangeActiveSync`/`other`). MFA can be bypassed via legacy protocols if not blocked.' + } + + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'E8 ML1 - MFA' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'E8 ML1 - MFA' + } +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_04.md b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_04.md new file mode 100644 index 0000000000000..268ec4164e343 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_04.md @@ -0,0 +1,14 @@ +Essential Eight Maturity Level 2 requires phishing-resistant MFA for privileged users. Before users can be required to use it, the tenant must have at least one phishing-resistant method (FIDO2 security keys, Windows Hello for Business, or certificate-based authentication) enabled in the Authentication methods policy. + +**Remediation Action** + +1. Entra ID > Authentication methods > Policies. +2. Enable at least one of: FIDO2 security key, Windows Hello for Business, Certificate-based authentication. +3. Target the appropriate user/group scope and save. + +**Links** +- [ACSC Essential Eight - MFA](https://learn.microsoft.com/en-us/compliance/anz/e8-mfa) +- [Authentication methods policy](https://learn.microsoft.com/en-us/azure/active-directory/authentication/concept-authentication-methods-manage) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_04.ps1 b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_04.ps1 new file mode 100644 index 0000000000000..f1e6e15ad81c8 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_04.ps1 @@ -0,0 +1,40 @@ +function Invoke-CippTestE8_MFA_04 { + <# + .SYNOPSIS + ACSC Essential Eight (MFA, ML2) - At least one phishing-resistant authentication method is enabled + #> + param($Tenant) + + $TestId = 'E8_MFA_04' + $Name = 'A phishing-resistant authentication method is enabled in the tenant' + + try { + $AuthMethodsPolicy = Get-CIPPTestData -TenantFilter $Tenant -Type 'AuthenticationMethodsPolicy' + if (-not $AuthMethodsPolicy) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'AuthenticationMethodsPolicy cache not found.' -Risk 'High' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'Medium' -Category 'E8 ML2 - MFA' + return + } + + # Windows Hello for Business is provisioned via Intune / device registration and is not part of + # authenticationMethodConfigurations, so it cannot be evaluated from this policy. + $Configs = $AuthMethodsPolicy.authenticationMethodConfigurations + $Enabled = [System.Collections.Generic.List[string]]::new() + foreach ($Id in 'Fido2','X509Certificate') { + $C = $Configs | Where-Object { $_.id -eq $Id } + if ($C -and $C.state -eq 'enabled') { $Enabled.Add($Id) } + } + + if ($Enabled.Count -gt 0) { + $Status = 'Passed' + $Result = "Phishing-resistant authentication method(s) enabled: $($Enabled -join ', ')." + } else { + $Status = 'Failed' + $Result = 'No phishing-resistant authentication method (FIDO2 security key or X509 certificate-based auth) is enabled in the tenant. Windows Hello for Business is managed via Intune and is not evaluated by this test.' + } + + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'Medium' -Category 'E8 ML2 - MFA' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'Medium' -Category 'E8 ML2 - MFA' + } +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_05.md b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_05.md new file mode 100644 index 0000000000000..eee46bb0e1586 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_05.md @@ -0,0 +1,14 @@ +SMS, Voice and Email OTP are not phishing-resistant: an attacker who can intercept SMS or proxy a sign-in page can capture the code. ACSC Essential Eight ML2/ML3 requires phasing these out in favour of FIDO2, Windows Hello, or certificate-based authentication. + +**Remediation Action** + +1. Entra ID > Authentication methods > Policies. +2. Set *SMS*, *Voice call*, and *Email OTP* to Disabled (or restrict to a tightly-scoped group during transition). +3. Make sure users have a phishing-resistant or Authenticator App method registered first. + +**Links** +- [Phishing-resistant MFA in Entra ID](https://learn.microsoft.com/en-us/azure/active-directory/authentication/concept-authentication-strengths) +- [ACSC Essential Eight - MFA](https://learn.microsoft.com/en-us/compliance/anz/e8-mfa) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_05.ps1 b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_05.ps1 new file mode 100644 index 0000000000000..4e3cfe13e30fb --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_05.ps1 @@ -0,0 +1,38 @@ +function Invoke-CippTestE8_MFA_05 { + <# + .SYNOPSIS + ACSC Essential Eight (MFA, ML2) - Weak authentication methods (SMS / Voice / Email OTP) are disabled + #> + param($Tenant) + + $TestId = 'E8_MFA_05' + $Name = 'Weak MFA methods (SMS, Voice call, Email OTP) are disabled' + + try { + $AuthMethodsPolicy = Get-CIPPTestData -TenantFilter $Tenant -Type 'AuthenticationMethodsPolicy' + if (-not $AuthMethodsPolicy) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'AuthenticationMethodsPolicy cache not found.' -Risk 'Medium' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'E8 ML2 - MFA' + return + } + + $Configs = $AuthMethodsPolicy.authenticationMethodConfigurations + $Issues = [System.Collections.Generic.List[string]]::new() + foreach ($Id in 'Sms','Voice','Email') { + $C = $Configs | Where-Object { $_.id -eq $Id } + if ($C -and $C.state -eq 'enabled') { $Issues.Add($Id) } + } + + if ($Issues.Count -eq 0) { + $Status = 'Passed' + $Result = 'SMS, Voice and Email OTP methods are all disabled.' + } else { + $Status = 'Failed' + $Result = "The following weak (non phishing-resistant) MFA methods are still enabled: $($Issues -join ', ')." + } + + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'Medium' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'E8 ML2 - MFA' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'Medium' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'E8 ML2 - MFA' + } +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_06.md b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_06.md new file mode 100644 index 0000000000000..59d59d0279f02 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_06.md @@ -0,0 +1,16 @@ +Privileged identities are the highest-value targets in a tenant. ACSC Essential Eight Maturity Level 2 mandates phishing-resistant MFA for them. In Entra ID this is enforced by a Conditional Access policy that targets directory roles with the built-in *Phishing-resistant MFA* authentication strength. + +**Remediation Action** + +1. Entra ID > Conditional Access > New policy. +2. Users: include Directory roles (all privileged roles such as Global Administrator, Privileged Role Administrator, Security Administrator, etc.). +3. Cloud apps: All cloud apps. +4. Grant > Require authentication strength > **Phishing-resistant MFA**. +5. Enable. + +**Links** +- [ACSC Essential Eight - MFA](https://learn.microsoft.com/en-us/compliance/anz/e8-mfa) +- [Conditional Access authentication strengths](https://learn.microsoft.com/en-us/azure/active-directory/authentication/concept-authentication-strengths) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_06.ps1 b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_06.ps1 new file mode 100644 index 0000000000000..6998fc66f35e9 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_06.ps1 @@ -0,0 +1,47 @@ +function Invoke-CippTestE8_MFA_06 { + <# + .SYNOPSIS + ACSC Essential Eight (MFA, ML2) - Phishing-resistant MFA strength is required for privileged roles + #> + param($Tenant) + + $TestId = 'E8_MFA_06' + $Name = 'Phishing-resistant authentication strength is required for privileged roles' + # Built-in Phishing-resistant MFA strength + $PhishResistantId = '00000000-0000-0000-0000-000000000004' + + try { + $CA = Get-CIPPTestData -TenantFilter $Tenant -Type 'ConditionalAccessPolicies' + $Roles = Get-CippDbRole -TenantFilter $Tenant -IncludePrivilegedRoles + + if (-not $CA -or -not $Roles) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'Required cache (ConditionalAccessPolicies or Roles) not found.' -Risk 'High' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'High' -Category 'E8 ML2 - MFA' + return + } + + # Conditional Access includeRoles reference role template IDs, not directory role instance IDs. + $PrivRoleIds = @($Roles | ForEach-Object { if ($_.roleTemplateId) { [string]$_.roleTemplateId } elseif ($_.RoletemplateId) { [string]$_.RoletemplateId } }) + + $Match = $CA | Where-Object { + $_.state -eq 'enabled' -and + $_.conditions.users.includeRoles -and + (@($_.conditions.users.includeRoles) | Where-Object { $_ -in $PrivRoleIds }).Count -gt 0 -and + $_.grantControls.authenticationStrength -and + $_.grantControls.authenticationStrength.id -eq $PhishResistantId + } + + if ($Match) { + $Status = 'Passed' + $Result = "$($Match.Count) Conditional Access policy/policies require phishing-resistant MFA for privileged roles:`n`n" + + (($Match | ForEach-Object { "- $($_.displayName)" }) -join "`n") + } else { + $Status = 'Failed' + $Result = 'No enabled Conditional Access policy targets privileged roles with the built-in *Phishing-resistant MFA* authentication strength.' + } + + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'High' -Category 'E8 ML2 - MFA' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'High' -Category 'E8 ML2 - MFA' + } +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_07.md b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_07.md new file mode 100644 index 0000000000000..3bd85f92ec72d --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_07.md @@ -0,0 +1,14 @@ +A Conditional Access policy can require phishing-resistant MFA, but it only takes effect once each privileged user has actually registered such a method (FIDO2 key, Windows Hello, certificate, or device-bound passkey). This test enumerates privileged role members whose `methodsRegistered` list does not yet include a phishing-resistant method. + +**Remediation Action** + +1. Issue FIDO2 keys (or enable Windows Hello / certificate auth / passkeys) to each privileged user. +2. Have them register the method at https://aka.ms/mysecurityinfo before the Conditional Access policy is enforced. +3. Re-run this test once registrations complete. + +**Links** +- [ACSC Essential Eight - MFA](https://learn.microsoft.com/en-us/compliance/anz/e8-mfa) +- [User registration details API](https://learn.microsoft.com/en-us/graph/api/resources/userregistrationdetails) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_07.ps1 b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_07.ps1 new file mode 100644 index 0000000000000..69aaa8442ef98 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_07.ps1 @@ -0,0 +1,67 @@ +function Invoke-CippTestE8_MFA_07 { + <# + .SYNOPSIS + ACSC Essential Eight (MFA, ML2) - Privileged users have a phishing-resistant method registered + #> + param($Tenant) + + $TestId = 'E8_MFA_07' + $Name = 'All privileged users have a phishing-resistant authentication method registered' + + try { + $Reg = Get-CIPPTestData -TenantFilter $Tenant -Type 'UserRegistrationDetails' + $Roles = Get-CippDbRole -TenantFilter $Tenant -IncludePrivilegedRoles + $RoleAssignmentScheduleInstances = Get-CIPPTestData -TenantFilter $Tenant -Type 'RoleAssignmentScheduleInstances' + + if (-not $Reg -or -not $Roles) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'Required cache (UserRegistrationDetails or Roles) not found.' -Risk 'High' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'High' -Category 'E8 ML2 - MFA' + return + } + + $PrivRoleIds = [System.Collections.Generic.HashSet[string]]::new() + $PrivUserIds = [System.Collections.Generic.HashSet[string]]::new() + foreach ($Role in @($Roles)) { + $RoleTemplateId = if ($Role.roleTemplateId) { [string]$Role.roleTemplateId } elseif ($Role.RoletemplateId) { [string]$Role.RoletemplateId } else { $null } + if ($RoleTemplateId) { [void]$PrivRoleIds.Add($RoleTemplateId) } + foreach ($M in @($Role.members)) { + if ($M.id -and $M.'@odata.type' -eq '#microsoft.graph.user') { [void]$PrivUserIds.Add([string]$M.id) } + } + } + foreach ($A in @($RoleAssignmentScheduleInstances)) { + if ($A.assignmentType -eq 'Assigned' -and $null -eq $A.endDateTime -and $A.principalId -and $PrivRoleIds.Contains([string]$A.roleDefinitionId)) { + [void]$PrivUserIds.Add([string]$A.principalId) + } + } + + if ($PrivUserIds.Count -eq 0) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Passed' -ResultMarkdown 'No privileged users found.' -Risk 'High' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'High' -Category 'E8 ML2 - MFA' + return + } + + $PhishMethods = @('fido2SecurityKey','windowsHelloForBusiness','x509CertificateSingleFactor','x509CertificateMultiFactor','passKeyDeviceBound','passKeyDeviceBoundAuthenticator','passKeyDeviceBoundWindowsHello') + $NonCompliant = foreach ($R in $Reg | Where-Object { $PrivUserIds.Contains($_.id) }) { + $HasPhish = $false + foreach ($M in $R.methodsRegistered) { + if ($PhishMethods -contains $M) { $HasPhish = $true; break } + } + if (-not $HasPhish) { $R } + } + + if (-not $NonCompliant) { + $Status = 'Passed' + $Result = "All $($PrivUserIds.Count) privileged users have at least one phishing-resistant method registered." + } else { + $Status = 'Failed' + $Sb = [System.Text.StringBuilder]::new("$($NonCompliant.Count) of $($PrivUserIds.Count) privileged users have no phishing-resistant method registered:`n`n| UPN | Methods Registered |`n| :-- | :----------------- |`n") + foreach ($U in ($NonCompliant | Select-Object -First 50)) { + $null = $Sb.Append("| $($U.userPrincipalName) | $(($U.methodsRegistered) -join ', ') |`n") + } + $Result = $Sb.ToString() + } + + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'High' -Category 'E8 ML2 - MFA' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name $Name -UserImpact 'Medium' -ImplementationEffort 'High' -Category 'E8 ML2 - MFA' + } +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_08.md b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_08.md new file mode 100644 index 0000000000000..ce78ca97c809e --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_08.md @@ -0,0 +1,14 @@ +Essential Eight Maturity Level 3 extends the phishing-resistant MFA requirement from privileged users to **all** users. Every enabled member account must have at least one phishing-resistant method (FIDO2, Windows Hello for Business, certificate-based authentication, or device-bound passkey) registered. + +**Remediation Action** + +1. Roll out FIDO2 security keys, Windows Hello for Business, or device-bound passkeys to all staff. +2. Run a registration campaign — make sure each user registers at least one phishing-resistant method. +3. Once coverage is complete, enforce via a tenant-wide Conditional Access policy with the *Phishing-resistant MFA* authentication strength. + +**Links** +- [ACSC Essential Eight - MFA](https://learn.microsoft.com/en-us/compliance/anz/e8-mfa) +- [Plan a passwordless authentication deployment](https://learn.microsoft.com/en-us/azure/active-directory/authentication/howto-authentication-passwordless-deployment) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_08.ps1 b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_08.ps1 new file mode 100644 index 0000000000000..715ffd6898656 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_08.ps1 @@ -0,0 +1,50 @@ +function Invoke-CippTestE8_MFA_08 { + <# + .SYNOPSIS + ACSC Essential Eight (MFA, ML3) - All member users have a phishing-resistant method registered + #> + param($Tenant) + + $TestId = 'E8_MFA_08' + $Name = 'All member users have a phishing-resistant authentication method registered' + + try { + $Reg = Get-CIPPTestData -TenantFilter $Tenant -Type 'UserRegistrationDetails' + $Users = Get-CIPPTestData -TenantFilter $Tenant -Type 'Users' + + if (-not $Reg -or -not $Users) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'Required cache (UserRegistrationDetails or Users) not found.' -Risk 'High' -Name $Name -UserImpact 'High' -ImplementationEffort 'High' -Category 'E8 ML3 - MFA' + return + } + + $MemberIds = [System.Collections.Generic.HashSet[string]]::new( + [string[]]($Users | Where-Object { $_.accountEnabled -eq $true -and $_.userType -ne 'Guest' }).id) + + if ($MemberIds.Count -eq 0) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Passed' -ResultMarkdown 'No enabled member users found.' -Risk 'High' -Name $Name -UserImpact 'High' -ImplementationEffort 'High' -Category 'E8 ML3 - MFA' + return + } + + $PhishMethods = @('fido2SecurityKey','windowsHelloForBusiness','x509CertificateSingleFactor','x509CertificateMultiFactor','passKeyDeviceBound','passKeyDeviceBoundAuthenticator','passKeyDeviceBoundWindowsHello') + $Total = 0; $NonCompliant = 0 + foreach ($R in $Reg | Where-Object { $MemberIds.Contains($_.id) }) { + $Total++ + $HasPhish = $false + foreach ($M in $R.methodsRegistered) { if ($PhishMethods -contains $M) { $HasPhish = $true; break } } + if (-not $HasPhish) { $NonCompliant++ } + } + + if ($NonCompliant -eq 0) { + $Status = 'Passed' + $Result = "All $Total enabled member users have a phishing-resistant method registered." + } else { + $Status = 'Failed' + $Result = "$NonCompliant of $Total enabled member users have no phishing-resistant authentication method registered (FIDO2, Windows Hello for Business, X509 cert, or device-bound passkey)." + } + + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name $Name -UserImpact 'High' -ImplementationEffort 'High' -Category 'E8 ML3 - MFA' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name $Name -UserImpact 'High' -ImplementationEffort 'High' -Category 'E8 ML3 - MFA' + } +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_09.md b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_09.md new file mode 100644 index 0000000000000..d51eb2f1c84be --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_09.md @@ -0,0 +1,14 @@ +Number matching defeats MFA fatigue / push-bombing attacks by forcing the user to read a number from the sign-in screen and type it into the Authenticator app. Microsoft made it default in 2023, but tenants that previously customized the policy can still have it disabled or scoped narrowly. + +**Remediation Action** + +1. Entra ID > Authentication methods > Policies > **Microsoft Authenticator**. +2. Configure features > **Require number matching for push notifications** = Enabled. +3. Set the include target to **All users**. + +**Links** +- [How number matching works](https://learn.microsoft.com/en-us/azure/active-directory/authentication/how-to-mfa-number-match) +- [ACSC Essential Eight - MFA](https://learn.microsoft.com/en-us/compliance/anz/e8-mfa) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_09.ps1 b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_09.ps1 new file mode 100644 index 0000000000000..4902fa2ed428e --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_09.ps1 @@ -0,0 +1,46 @@ +function Invoke-CippTestE8_MFA_09 { + <# + .SYNOPSIS + ACSC Essential Eight (MFA, ML3) - Number matching is enforced for Microsoft Authenticator + #> + param($Tenant) + + $TestId = 'E8_MFA_09' + $Name = 'Microsoft Authenticator number matching is enforced' + + try { + $AuthMethodsPolicy = Get-CIPPTestData -TenantFilter $Tenant -Type 'AuthenticationMethodsPolicy' + if (-not $AuthMethodsPolicy) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'AuthenticationMethodsPolicy cache not found.' -Risk 'Medium' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML3 - MFA' + return + } + + $MsAuth = $AuthMethodsPolicy.authenticationMethodConfigurations | Where-Object { $_.id -eq 'MicrosoftAuthenticator' } + if (-not $MsAuth -or $MsAuth.state -ne 'enabled') { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Passed' -ResultMarkdown 'Microsoft Authenticator method is not enabled in the tenant.' -Risk 'Medium' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML3 - MFA' + return + } + + $NumberMatching = $MsAuth.featureSettings.numberMatchingRequiredState + $Issues = [System.Collections.Generic.List[string]]::new() + if ($NumberMatching.state -ne 'enabled') { + $Issues.Add("numberMatchingRequiredState.state is '$($NumberMatching.state)' (expected 'enabled').") + } + if ($NumberMatching.includeTarget.id -and $NumberMatching.includeTarget.id -ne 'all_users') { + $Issues.Add("numberMatchingRequiredState.includeTarget is '$($NumberMatching.includeTarget.id)' (expected 'all_users').") + } + + if ($Issues.Count -eq 0) { + $Status = 'Passed' + $Result = 'Microsoft Authenticator number matching is enabled and targets all users.' + } else { + $Status = 'Failed' + $Result = "Microsoft Authenticator number matching is not fully enforced:`n`n$(($Issues | ForEach-Object { "- $_" }) -join "`n")" + } + + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'Medium' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML3 - MFA' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'Medium' -Name $Name -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'E8 ML3 - MFA' + } +} diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_10.md b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_10.md new file mode 100644 index 0000000000000..32ee5f5e7c810 --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_10.md @@ -0,0 +1,17 @@ +At Maturity Level 3 the Essential Eight requires phishing-resistant MFA for every user, not just admins. The technical control is a tenant-wide Conditional Access policy that targets *All users* + *All cloud apps* with the built-in **Phishing-resistant MFA** authentication strength. + +**Remediation Action** + +1. Confirm test E8_MFA_08 is passing (every user has a phishing-resistant method registered). +2. Entra ID > Conditional Access > New policy. +3. Users: All users (exclude break-glass). +4. Cloud apps: All cloud apps. +5. Grant > Require authentication strength > **Phishing-resistant MFA**. +6. Enable the policy. + +**Links** +- [ACSC Essential Eight - MFA](https://learn.microsoft.com/en-us/compliance/anz/e8-mfa) +- [Conditional Access authentication strengths](https://learn.microsoft.com/en-us/azure/active-directory/authentication/concept-authentication-strengths) + + +%TestResult% diff --git a/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_10.ps1 b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_10.ps1 new file mode 100644 index 0000000000000..53b0e061cfcbb --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/Identity/Invoke-CippTestE8_MFA_10.ps1 @@ -0,0 +1,41 @@ +function Invoke-CippTestE8_MFA_10 { + <# + .SYNOPSIS + ACSC Essential Eight (MFA, ML3) - Phishing-resistant authentication strength is required for all users + #> + param($Tenant) + + $TestId = 'E8_MFA_10' + $Name = 'Phishing-resistant authentication strength is required for all users' + $PhishResistantId = '00000000-0000-0000-0000-000000000004' + + try { + $CA = Get-CIPPTestData -TenantFilter $Tenant -Type 'ConditionalAccessPolicies' + if (-not $CA) { + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'ConditionalAccessPolicies cache not found.' -Risk 'High' -Name $Name -UserImpact 'High' -ImplementationEffort 'High' -Category 'E8 ML3 - MFA' + return + } + + $Match = $CA | Where-Object { + $_.state -eq 'enabled' -and + ($_.conditions.users.includeUsers -contains 'All') -and + ($_.conditions.applications.includeApplications -contains 'All') -and + $_.grantControls.authenticationStrength -and + $_.grantControls.authenticationStrength.id -eq $PhishResistantId + } + + if ($Match) { + $Status = 'Passed' + $Result = "$($Match.Count) Conditional Access policy/policies enforce phishing-resistant MFA tenant-wide:`n`n" + + (($Match | ForEach-Object { "- $($_.displayName)" }) -join "`n") + } else { + $Status = 'Failed' + $Result = 'No enabled Conditional Access policy enforces the *Phishing-resistant MFA* authentication strength on All Users + All Cloud Apps.' + } + + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status $Status -ResultMarkdown $Result -Risk 'High' -Name $Name -UserImpact 'High' -ImplementationEffort 'High' -Category 'E8 ML3 - MFA' + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Add-CippTestResult -TenantFilter $Tenant -TestId $TestId -TestType 'Identity' -Status 'Failed' -ResultMarkdown "Test failed: $($ErrorMessage.NormalizedError)" -Risk 'High' -Name $Name -UserImpact 'High' -ImplementationEffort 'High' -Category 'E8 ML3 - MFA' + } +} diff --git a/Modules/CIPPTests/Public/Tests/E8/report.json b/Modules/CIPPTests/Public/Tests/E8/report.json new file mode 100644 index 0000000000000..13f01c3c3295f --- /dev/null +++ b/Modules/CIPPTests/Public/Tests/E8/report.json @@ -0,0 +1,74 @@ +{ + "name": "ACSC Essential Eight", + "description": "Australian Cyber Security Centre (ACSC) Essential Eight Maturity Model — eight mitigation strategies for adversary defence (MFA, Restrict Administrative Privileges, Application Control, Patch Applications, Patch Operating Systems, Configure Microsoft Office Macro Settings, User Application Hardening, Regular Backups). CIPP tests cover what Microsoft 365 / Entra / Intune / Defender APIs expose. Some lower-level enforcement controls (e.g., kernel-mode WDAC rule contents, Credential Guard runtime state, SAW physical separation) cannot be validated from cloud telemetry and are flagged as manual.", + "version": "November 2023", + "source": "https://learn.microsoft.com/en-us/compliance/anz/e8-overview", + "category": "ACSC Essential Eight", + "IdentityTests": [ + "E8_MFA_01", + "E8_MFA_02", + "E8_MFA_03", + "E8_MFA_04", + "E8_MFA_05", + "E8_MFA_06", + "E8_MFA_07", + "E8_MFA_08", + "E8_MFA_09", + "E8_MFA_10", + "E8_Admin_01", + "E8_Admin_02", + "E8_Admin_03", + "E8_Admin_04", + "E8_Admin_05", + "E8_Admin_06", + "E8_Admin_07", + "E8_Admin_08", + "E8_Admin_09", + "E8_Admin_10", + "E8_Admin_11", + "E8_Admin_12", + "E8_Admin_13", + "E8_Backup_01", + "E8_Backup_02", + "E8_Backup_03", + "E8_Backup_04" + ], + "DevicesTests": [ + "E8_AppCtrl_01", + "E8_AppCtrl_02", + "E8_AppCtrl_03", + "E8_AppCtrl_04", + "E8_AppCtrl_05", + "E8_PatchApp_01", + "E8_PatchApp_02", + "E8_PatchApp_03", + "E8_PatchApp_04", + "E8_PatchOS_01", + "E8_PatchOS_02", + "E8_PatchOS_03", + "E8_PatchOS_04", + "E8_PatchOS_05", + "E8_PatchOS_06", + "E8_Macro_01", + "E8_Macro_02", + "E8_Macro_03", + "E8_Macro_04", + "E8_Macro_05", + "E8_Macro_06", + "E8_Macro_07", + "E8_AppHard_01", + "E8_AppHard_02", + "E8_AppHard_03", + "E8_AppHard_04", + "E8_AppHard_05", + "E8_AppHard_06", + "E8_AppHard_07", + "E8_AppHard_08", + "E8_AppHard_09", + "E8_AppHard_10", + "E8_AppHard_11", + "E8_AppHard_12", + "E8_AppHard_13", + "E8_AppHard_14" + ] +} From 5d29976356c421815e064f9abbc8efe7bbcdbd6a Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Wed, 1 Jul 2026 22:45:14 +0800 Subject: [PATCH 076/170] Role lookup fixes --- .../CIS/Identity/Invoke-CippTestCIS_1_1_4.ps1 | 9 +++++---- .../CIS/Identity/Invoke-CippTestCIS_5_2_2_1.ps1 | 5 +++-- .../CIS/Identity/Invoke-CippTestCIS_5_2_2_4.ps1 | 5 +++-- .../CIS/Identity/Invoke-CippTestCIS_5_2_2_5.ps1 | 5 +++-- .../ZTNA/Identity/Invoke-CippTestZTNA21782.ps1 | 14 ++++++++------ .../ZTNA/Identity/Invoke-CippTestZTNA21783.ps1 | 11 ++++++++--- 6 files changed, 30 insertions(+), 19 deletions(-) diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_1_4.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_1_4.ps1 index 9c69b5aea1c4e..9b0eaedac7c9e 100644 --- a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_1_4.ps1 +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_1_1_4.ps1 @@ -6,7 +6,7 @@ function Invoke-CippTestCIS_1_1_4 { param($Tenant) try { - $Roles = Get-CIPPTestData -TenantFilter $Tenant -Type 'Roles' + $Roles = Get-CippDbRole -TenantFilter $Tenant -IncludePrivilegedRoles $RoleAssignmentScheduleInstances = Get-CIPPTestData -TenantFilter $Tenant -Type 'RoleAssignmentScheduleInstances' $Users = Get-CIPPTestData -TenantFilter $Tenant -Type 'Users' @@ -18,9 +18,10 @@ function Invoke-CippTestCIS_1_1_4 { $PrivilegedRoleIds = [System.Collections.Generic.HashSet[string]]::new() $PrivilegedUserIds = [System.Collections.Generic.HashSet[string]]::new() - foreach ($Role in @($Roles.Where({ $_.isPrivileged -eq $true }))) { - if ($Role.id) { - [void]$PrivilegedRoleIds.Add([string]$Role.id) + foreach ($Role in @($Roles)) { + $RoleTemplateId = if ($Role.roleTemplateId) { [string]$Role.roleTemplateId } elseif ($Role.RoletemplateId) { [string]$Role.RoletemplateId } else { $null } + if ($RoleTemplateId) { + [void]$PrivilegedRoleIds.Add($RoleTemplateId) } foreach ($Member in @($Role.members)) { diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_1.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_1.ps1 index 3cd5c1eca8f6b..6778b8ceeeaca 100644 --- a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_1.ps1 +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_1.ps1 @@ -7,14 +7,15 @@ function Invoke-CippTestCIS_5_2_2_1 { try { $CA = Get-CIPPTestData -TenantFilter $Tenant -Type 'ConditionalAccessPolicies' - $Roles = Get-CIPPTestData -TenantFilter $Tenant -Type 'Roles' + $Roles = Get-CippDbRole -TenantFilter $Tenant -IncludePrivilegedRoles if (-not $CA -or -not $Roles) { Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_2_2_1' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'Required cache (ConditionalAccessPolicies or Roles) not found. Please refresh the cache for this tenant.' -Risk 'High' -Name 'MFA is enabled for all users in administrative roles' -UserImpact 'Low' -ImplementationEffort 'Low' -Category 'Authentication' return } - $PrivRoleIds = [System.Collections.Generic.HashSet[string]]::new([string[]]$Roles.Where({ $_.isPrivileged -eq $true }).id) + # Conditional Access includeRoles reference role template IDs, not directory role instance IDs. + $PrivRoleIds = [System.Collections.Generic.HashSet[string]]::new([string[]]@($Roles | ForEach-Object { if ($_.roleTemplateId) { [string]$_.roleTemplateId } elseif ($_.RoletemplateId) { [string]$_.RoletemplateId } })) $Matching = $CA.Where({ $_.state -eq 'enabled' -and diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_4.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_4.ps1 index c30b77edc38a0..a8170cb48189d 100644 --- a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_4.ps1 +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_4.ps1 @@ -7,14 +7,15 @@ function Invoke-CippTestCIS_5_2_2_4 { try { $CA = Get-CIPPTestData -TenantFilter $Tenant -Type 'ConditionalAccessPolicies' - $Roles = Get-CIPPTestData -TenantFilter $Tenant -Type 'Roles' + $Roles = Get-CippDbRole -TenantFilter $Tenant -IncludePrivilegedRoles if (-not $CA -or -not $Roles) { Add-CippTestResult -TenantFilter $Tenant -TestId 'CIS_5_2_2_4' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'Required cache (ConditionalAccessPolicies or Roles) not found.' -Risk 'Medium' -Name 'Sign-in frequency for administrative users is configured' -UserImpact 'Medium' -ImplementationEffort 'Low' -Category 'Session Management' return } - $PrivRoleIds = [System.Collections.Generic.HashSet[string]]::new([string[]]$Roles.Where({ $_.isPrivileged -eq $true }).id) + # Conditional Access includeRoles reference role template IDs, not directory role instance IDs. + $PrivRoleIds = [System.Collections.Generic.HashSet[string]]::new([string[]]@($Roles | ForEach-Object { if ($_.roleTemplateId) { [string]$_.roleTemplateId } elseif ($_.RoletemplateId) { [string]$_.RoletemplateId } })) $Matching = $CA.Where({ $_.state -eq 'enabled' -and diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_5.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_5.ps1 index bfc12f0bceede..8ef0c74e6d816 100644 --- a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_5.ps1 +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_5_2_2_5.ps1 @@ -7,7 +7,7 @@ function Invoke-CippTestCIS_5_2_2_5 { try { $CA = Get-CIPPTestData -TenantFilter $Tenant -Type 'ConditionalAccessPolicies' - $Roles = Get-CIPPTestData -TenantFilter $Tenant -Type 'Roles' + $Roles = Get-CippDbRole -TenantFilter $Tenant -IncludePrivilegedRoles $Strengths = Get-CIPPTestData -TenantFilter $Tenant -Type 'AuthenticationStrengths' if (-not $CA -or -not $Roles) { @@ -15,7 +15,8 @@ function Invoke-CippTestCIS_5_2_2_5 { return } - $PrivRoleIds = ($Roles | Where-Object { $_.isPrivileged -eq $true }).id + # Conditional Access includeRoles reference role template IDs, not directory role instance IDs. + $PrivRoleIds = @($Roles | ForEach-Object { if ($_.roleTemplateId) { [string]$_.roleTemplateId } elseif ($_.RoletemplateId) { [string]$_.RoletemplateId } }) $PhishResistantId = '00000000-0000-0000-0000-000000000004' # Built-in 'Phishing-resistant MFA' strength $Matching = $CA | Where-Object { diff --git a/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21782.ps1 b/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21782.ps1 index 8d4ed2870e3b6..71dc891fce1b5 100644 --- a/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21782.ps1 +++ b/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21782.ps1 @@ -7,7 +7,7 @@ function Invoke-CippTestZTNA21782 { try { $UserRegistrationDetails = Get-CIPPTestData -TenantFilter $Tenant -Type 'UserRegistrationDetails' - $Roles = Get-CIPPTestData -TenantFilter $Tenant -Type 'Roles' + $Roles = Get-CippDbRole -TenantFilter $Tenant -IncludePrivilegedRoles $RoleAssignmentScheduleInstances = Get-CIPPTestData -TenantFilter $Tenant -Type 'RoleAssignmentScheduleInstances' if ($null -eq $UserRegistrationDetails -or $null -eq $Roles) { @@ -17,17 +17,19 @@ function Invoke-CippTestZTNA21782 { $PhishResistantMethods = @('passKeyDeviceBound', 'passKeyDeviceBoundAuthenticator', 'windowsHelloForBusiness') + # RoleAssignmentScheduleInstances.roleDefinitionId is a role template ID, so key the privileged role set by template ID. $PrivilegedRoleIds = [System.Collections.Generic.HashSet[string]]::new() $RoleNamesById = @{} - foreach ($Role in @($Roles.Where({ $_.isPrivileged -eq $true }))) { - if ($Role.id) { - [void]$PrivilegedRoleIds.Add([string]$Role.id) - $RoleNamesById[[string]$Role.id] = $Role.displayName + foreach ($Role in @($Roles)) { + $RoleTemplateId = if ($Role.roleTemplateId) { [string]$Role.roleTemplateId } elseif ($Role.RoletemplateId) { [string]$Role.RoletemplateId } else { $null } + if ($RoleTemplateId) { + [void]$PrivilegedRoleIds.Add($RoleTemplateId) + $RoleNamesById[$RoleTemplateId] = $Role.displayName } } $PrivilegedPrincipalsById = @{} - foreach ($Role in @($Roles.Where({ $_.isPrivileged -eq $true }))) { + foreach ($Role in @($Roles)) { foreach ($Member in @($Role.members)) { if (-not $Member.id) { continue } $principalId = [string]$Member.id diff --git a/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21783.ps1 b/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21783.ps1 index 76ee3e4300cda..9dd3017715458 100644 --- a/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21783.ps1 +++ b/Modules/CIPPTests/Public/Tests/ZTNA/Identity/Invoke-CippTestZTNA21783.ps1 @@ -7,14 +7,15 @@ function Invoke-CippTestZTNA21783 { #tested try { $CAPolicies = Get-CIPPTestData -TenantFilter $Tenant -Type 'ConditionalAccessPolicies' - $Roles = Get-CIPPTestData -TenantFilter $Tenant -Type 'Roles' + $Roles = Get-CippDbRole -TenantFilter $Tenant -IncludePrivilegedRoles if (-not $CAPolicies -or -not $Roles) { Add-CippTestResult -TenantFilter $Tenant -TestId 'ZTNA21783' -TestType 'Identity' -Status 'Skipped' -ResultMarkdown 'No data found in database. This may be due to missing required licenses or data collection not yet completed.' -Risk 'High' -Name 'Privileged Microsoft Entra built-in roles are targeted with Conditional Access policies to enforce phishing-resistant methods' -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'Access Control' return } - $PrivilegedRoles = $Roles | Where-Object { $_.isPrivileged -and $_.isBuiltIn } + # Get-CippDbRole -IncludePrivilegedRoles already returns the privileged built-in directory roles. + $PrivilegedRoles = @($Roles) if (-not $PrivilegedRoles) { Add-CippTestResult -TenantFilter $Tenant -TestId 'ZTNA21783' -TestType 'Identity' -Status 'Passed' -ResultMarkdown 'No privileged built-in roles found in tenant' -Risk 'High' -Name 'Privileged Microsoft Entra built-in roles are targeted with Conditional Access policies to enforce phishing-resistant methods' -UserImpact 'Low' -ImplementationEffort 'Medium' -Category 'Access Control' @@ -31,7 +32,11 @@ function Invoke-CippTestZTNA21783 { $CoveredRoleIds = $PhishResistantPolicies.conditions.users.includeRoles | Select-Object -Unique - $UnprotectedRoles = $PrivilegedRoles | Where-Object { $_.id -notin $CoveredRoleIds } + # Conditional Access includeRoles reference role template IDs, not directory role instance IDs. + $UnprotectedRoles = $PrivilegedRoles | Where-Object { + $Tid = if ($_.roleTemplateId) { [string]$_.roleTemplateId } elseif ($_.RoletemplateId) { [string]$_.RoletemplateId } else { $null } + $Tid -notin $CoveredRoleIds + } if ($UnprotectedRoles.Count -eq 0) { $Status = 'Passed' From 7a69da158b7e802562fa9a17afc4e29648d5d383 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Wed, 1 Jul 2026 23:42:03 +0800 Subject: [PATCH 077/170] Harden app template creation to check app type --- .../Invoke-ExecCreateAppTemplate.ps1 | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Application Approval/Invoke-ExecCreateAppTemplate.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Application Approval/Invoke-ExecCreateAppTemplate.ps1 index 78945aa34b2a2..3723e94b01930 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Application Approval/Invoke-ExecCreateAppTemplate.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Application Approval/Invoke-ExecCreateAppTemplate.ps1 @@ -34,12 +34,12 @@ function Invoke-ExecCreateAppTemplate { [PSCustomObject]@{ id = 'app' method = 'GET' - url = "/applications(appId='$AppId')?`$select=id,appId,displayName,requiredResourceAccess" + url = "/applications(appId='$AppId')?`$select=id,appId,displayName,signInAudience,requiredResourceAccess" } [PSCustomObject]@{ id = 'splist' method = 'GET' - url = '/servicePrincipals?$top=999&$select=id,appId,displayName' + url = '/servicePrincipals?$top=999&$select=id,appId,displayName,signInAudience' } ) @@ -52,6 +52,20 @@ function Invoke-ExecCreateAppTemplate { # Find the specific service principal in the list $SPResult = $TenantInfo | Where-Object { $_.appId -eq $AppId } | Select-Object -First 1 + # Determine the source app's sign-in audience so we only build Enterprise App + # templates for genuinely multi-tenant apps. A single-tenant app (AzureADMyOrg) + # cannot be deployed via an appId-based service principal, and copying it to the + # partner tenant as multi-tenant produces a template that fails to deploy. Those + # apps must use a Manifest (single-tenant) template instead. + $MultiTenantAudiences = @('AzureADMultipleOrgs', 'AzureADandPersonalMicrosoftAccount') + $SignInAudience = $AppResult.body.signInAudience + if ([string]::IsNullOrWhiteSpace($SignInAudience)) { + $SignInAudience = $SPResult.signInAudience + } + if (-not [string]::IsNullOrWhiteSpace($SignInAudience) -and $SignInAudience -notin $MultiTenantAudiences) { + throw "Application '$DisplayName' is single-tenant (signInAudience '$SignInAudience') and cannot be used as an Enterprise App template. Create a Manifest (single-tenant) template for this app instead." + } + # Get the app details based on type if ($Type -eq 'servicePrincipal') { if (-not $SPResult) { From 7227962fede36bd8ed16d691df84d396163a1c4e Mon Sep 17 00:00:00 2001 From: John Duprey Date: Wed, 1 Jul 2026 12:29:14 -0400 Subject: [PATCH 078/170] fix: update sherweb functions to support API client callers --- .../Sherweb/Remove-SherwebSubscription.ps1 | 20 ++++++++++++++----- .../Sherweb/Set-SherwebSubscription.ps1 | 20 ++++++++++++++----- 2 files changed, 30 insertions(+), 10 deletions(-) diff --git a/Modules/CippExtensions/Public/Sherweb/Remove-SherwebSubscription.ps1 b/Modules/CippExtensions/Public/Sherweb/Remove-SherwebSubscription.ps1 index b24e173dd4881..83bcd58aec88c 100644 --- a/Modules/CippExtensions/Public/Sherweb/Remove-SherwebSubscription.ps1 +++ b/Modules/CippExtensions/Public/Sherweb/Remove-SherwebSubscription.ps1 @@ -15,18 +15,28 @@ function Remove-SherwebSubscription { $Config = $ExtensionConfig.Sherweb $AllowedRoles = $Config.AllowedCustomRoles.value - if ($AllowedRoles -and $Headers.'x-ms-client-principal') { - $UserRoles = Get-CIPPAccessRole -Headers $Headers + if ($AllowedRoles) { + # Resolve caller roles for both interactive users and direct API clients, + # mirroring the principal detection Test-CIPPAccess/Test-CippApiClientRoleGrant use. + if ($Headers.'x-ms-client-principal-idp' -eq 'aad' -and $Headers.'x-ms-client-principal-name' -match '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$') { + $Client = Get-CippApiClient -AppId $Headers.'x-ms-client-principal-name' + $CallerRoles = if ($Client.Role) { @($Client.Role) } else { @('cipp-api') } + } elseif ($Headers.'x-ms-client-principal') { + $CallerRoles = @(Get-CIPPAccessRole -Headers $Headers) + } else { + $CallerRoles = @() + } + $Allowed = $false - foreach ($Role in $UserRoles) { + foreach ($Role in $CallerRoles) { if ($AllowedRoles -contains $Role) { - Write-Information "User has allowed CIPP role: $Role" + Write-Information "Caller has allowed CIPP role: $Role" $Allowed = $true break } } if (-not $Allowed) { - throw 'This user is not allowed to modify Sherweb Licenses.' + throw 'This caller is not allowed to modify Sherweb Licenses.' } } } diff --git a/Modules/CippExtensions/Public/Sherweb/Set-SherwebSubscription.ps1 b/Modules/CippExtensions/Public/Sherweb/Set-SherwebSubscription.ps1 index 62a2c5ccf2ffa..b2a6766bd8354 100644 --- a/Modules/CippExtensions/Public/Sherweb/Set-SherwebSubscription.ps1 +++ b/Modules/CippExtensions/Public/Sherweb/Set-SherwebSubscription.ps1 @@ -18,18 +18,28 @@ function Set-SherwebSubscription { $Config = $ExtensionConfig.Sherweb $AllowedRoles = $Config.AllowedCustomRoles.value - if ($AllowedRoles -and $Headers.'x-ms-client-principal') { - $UserRoles = Get-CIPPAccessRole -Headers $Headers + if ($AllowedRoles) { + # Resolve caller roles for both interactive users and direct API clients, + # mirroring the principal detection Test-CIPPAccess/Test-CippApiClientRoleGrant use. + if ($Headers.'x-ms-client-principal-idp' -eq 'aad' -and $Headers.'x-ms-client-principal-name' -match '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$') { + $Client = Get-CippApiClient -AppId $Headers.'x-ms-client-principal-name' + $CallerRoles = if ($Client.Role) { @($Client.Role) } else { @('cipp-api') } + } elseif ($Headers.'x-ms-client-principal') { + $CallerRoles = @(Get-CIPPAccessRole -Headers $Headers) + } else { + $CallerRoles = @() + } + $Allowed = $false - foreach ($Role in $UserRoles) { + foreach ($Role in $CallerRoles) { if ($AllowedRoles -contains $Role) { - Write-Information "User has allowed CIPP role: $Role" + Write-Information "Caller has allowed CIPP role: $Role" $Allowed = $true break } } if (-not $Allowed) { - throw 'This user is not allowed to modify Sherweb subscriptions.' + throw 'This caller is not allowed to modify Sherweb subscriptions.' } } } From 915355cb977ad97350507fe82b616b9756e5d192 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Wed, 1 Jul 2026 13:12:37 -0400 Subject: [PATCH 079/170] feat: move edituser to reusable set-cippuser function add scheduling capability in API add pester test --- Modules/CIPPCore/Public/Set-CIPPUser.ps1 | 264 +++++++++++++++++ .../Administration/Users/Invoke-EditUser.ps1 | 276 +++--------------- Tests/Endpoint/Invoke-EditUser.Tests.ps1 | 24 ++ 3 files changed, 321 insertions(+), 243 deletions(-) create mode 100644 Modules/CIPPCore/Public/Set-CIPPUser.ps1 diff --git a/Modules/CIPPCore/Public/Set-CIPPUser.ps1 b/Modules/CIPPCore/Public/Set-CIPPUser.ps1 new file mode 100644 index 0000000000000..691801cb6706e --- /dev/null +++ b/Modules/CIPPCore/Public/Set-CIPPUser.ps1 @@ -0,0 +1,264 @@ +function Set-CIPPUser { + [CmdletBinding()] + param ( + $UserObj, + $APIName = 'Edit User', + $Headers + ) + + $Results = [System.Collections.Generic.List[object]]::new() + $licenses = ($UserObj.licenses).value + $Aliases = if ($UserObj.AddedAliases) { ($UserObj.AddedAliases) -split '\s' } + $AddToGroups = $UserObj.AddToGroups + $RemoveFromGroups = $UserObj.RemoveFromGroups + + + #Edit the user + try { + $UserPrincipalName = "$($UserObj.username)@$($UserObj.Domain ? $UserObj.Domain : $UserObj.primDomain.value)" + $normalizedOtherMails = @( + @($UserObj.otherMails) | ForEach-Object { + if ($null -ne $_) { + [string]$_ -split ',' + } + } | ForEach-Object { + $_.Trim() + } | Where-Object { + -not [string]::IsNullOrWhiteSpace($_) + } + ) + $BodyToship = [pscustomobject] @{ + 'givenName' = $UserObj.givenName + 'surname' = $UserObj.surname + 'displayName' = $UserObj.displayName + 'department' = $UserObj.department + 'mailNickname' = $UserObj.username ? $UserObj.username : $UserObj.mailNickname + 'userPrincipalName' = $UserPrincipalName + 'usageLocation' = $UserObj.usageLocation.value ? $UserObj.usageLocation.value : $UserObj.usageLocation + 'jobTitle' = $UserObj.jobTitle + 'mobilePhone' = $UserObj.mobilePhone + 'streetAddress' = $UserObj.streetAddress + 'city' = $UserObj.city + 'state' = $UserObj.state + 'postalCode' = $UserObj.postalCode + 'country' = $UserObj.country + 'companyName' = $UserObj.companyName + 'businessPhones' = $UserObj.businessPhones ? @($UserObj.businessPhones) : @() + 'otherMails' = $normalizedOtherMails + 'passwordProfile' = @{ + 'forceChangePasswordNextSignIn' = [bool]$UserObj.MustChangePass + } + } | ForEach-Object { + $NonEmptyProperties = $_.PSObject.Properties | + Where-Object { -not [string]::IsNullOrWhiteSpace($_.Value) } | + Select-Object -ExpandProperty Name + $_ | Select-Object -Property $NonEmptyProperties + } + # Explicit clears: the frontend lists the profile fields the user actively emptied. + # We re-add them as null (scalars) / empty array (collections) so Graph clears them, while + # untouched empty fields stay omitted. Whitelisted to safe attributes + $ClearableFields = @( + 'givenName', 'surname', 'department', 'jobTitle', 'mobilePhone', + 'streetAddress', 'city', 'state', 'postalCode', 'country', 'companyName', + 'businessPhones', 'otherMails' + ) + $ClearList = @($UserObj.clearProperties | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) + foreach ($Prop in $ClearList) { + if ($Prop -notin $ClearableFields) { continue } + # Pass @() literally; routing it through a variable unrolls it back to $null. + if ($Prop -in 'businessPhones', 'otherMails') { + $BodyToShip | Add-Member -NotePropertyName $Prop -NotePropertyValue @() -Force + } else { + $BodyToShip | Add-Member -NotePropertyName $Prop -NotePropertyValue $null -Force + } + } + if ($UserObj.defaultAttributes) { + $UserObj.defaultAttributes | Get-Member -MemberType NoteProperty | ForEach-Object { + if (-not [string]::IsNullOrWhiteSpace($UserObj.defaultAttributes.$($_.Name).value)) { + Write-Host "Editing user and adding $($_.Name) with value $($UserObj.defaultAttributes.$($_.Name).value)" + $BodyToShip | Add-Member -NotePropertyName $_.Name -NotePropertyValue $UserObj.defaultAttributes.$($_.Name).value -Force + } + } + } + if ($UserObj.customData) { + $UserObj.customData | Get-Member -MemberType NoteProperty | ForEach-Object { + if (-not [string]::IsNullOrWhiteSpace($UserObj.customData.$($_.Name))) { + Write-Host "Editing user and adding custom data $($_.Name) with value $($UserObj.customData.$($_.Name))" + $BodyToShip | Add-Member -NotePropertyName $_.Name -NotePropertyValue $UserObj.customData.$($_.Name) -Force + } + } + } + $bodyToShip = ConvertTo-Json -Depth 10 -InputObject $BodyToship -Compress + $null = New-GraphPostRequest -uri "https://graph.microsoft.com/beta/users/$($UserObj.id)" -tenantid $UserObj.tenantFilter -type PATCH -body $BodyToship -verbose + $Results.Add( 'Success. The user has been edited.' ) + Write-LogMessage -API $APIName -tenant ($UserObj.tenantFilter) -headers $Headers -message "Edited user $($UserObj.DisplayName) with id $($UserObj.id)" -Sev Info + if ($UserObj.password) { + $passwordProfile = [pscustomobject]@{'passwordProfile' = @{ 'password' = $UserObj.password; 'forceChangePasswordNextSignIn' = [boolean]$UserObj.MustChangePass } } | ConvertTo-Json + $null = New-GraphPostRequest -uri "https://graph.microsoft.com/beta/users/$($UserObj.id)" -tenantid $UserObj.tenantFilter -type PATCH -body $PasswordProfile -Verbose + $Results.Add("Success. The password has been set to $($UserObj.password)") + Write-LogMessage -API $APIName -tenant ($UserObj.tenantFilter) -headers $Headers -message "Reset $($UserObj.DisplayName)'s Password" -Sev Info + } + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API $APIName -tenant ($UserObj.tenantFilter) -headers $Headers -message "User edit API failed. $($ErrorMessage.NormalizedError)" -Sev Error -LogData $ErrorMessage + $Results.Add( "Failed to edit user. $($ErrorMessage.NormalizedError)") + } + + + #Reassign the licenses + try { + + if ($licenses -or $UserObj.removeLicenses) { + if ($UserObj.sherwebLicense.value) { + $null = Set-SherwebSubscription -Headers $Headers -TenantFilter $UserObj.tenantFilter -SKU $UserObj.sherwebLicense.value -Add 1 + $Results.Add('Added Sherweb License, scheduling assignment') + $taskObject = [PSCustomObject]@{ + TenantFilter = $UserObj.tenantFilter + Name = "Assign License: $UserPrincipalName" + Command = @{ + value = 'Set-CIPPUserLicense' + } + Parameters = [pscustomobject]@{ + UserId = $UserObj.id + APIName = 'Sherweb License Assignment' + AddLicenses = $licenses + UserPrincipalName = $UserPrincipalName + } + ScheduledTime = 0 #right now, which is in the next 15 minutes and should cover most cases. + PostExecution = @{ + Webhook = [bool]$UserObj.PostExecution.webhook + Email = [bool]$UserObj.PostExecution.email + PSA = [bool]$UserObj.PostExecution.psa + } + } + Add-CIPPScheduledTask -Task $taskObject -hidden $false -Headers $Headers + } else { + $CurrentLicenses = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/users/$($UserObj.id)" -tenantid $UserObj.tenantFilter + #if the list of skuIds in $CurrentLicenses.assignedLicenses is EXACTLY the same as $licenses, we don't need to do anything, but the order in both can be different. + if (($CurrentLicenses.assignedLicenses.skuId -join ',') -eq ($licenses -join ',') -and $UserObj.removeLicenses -eq $false) { + Write-Host "$($CurrentLicenses.assignedLicenses.skuId -join ',') $(($licenses -join ','))" + $Results.Add( 'Success. User license is already correct.' ) + } else { + if ($UserObj.removeLicenses) { + $licResults = Set-CIPPUserLicense -UserPrincipalName $UserPrincipalName -UserId $UserObj.id -TenantFilter $UserObj.tenantFilter -RemoveLicenses $CurrentLicenses.assignedLicenses.skuId -Headers $Headers -APIName $APIName + $Results.Add($licResults) + } else { + #Remove all objects from $CurrentLicenses.assignedLicenses.skuId that are in $licenses + $RemoveLicenses = $CurrentLicenses.assignedLicenses.skuId | Where-Object { $_ -notin $licenses } + $licResults = Set-CIPPUserLicense -UserPrincipalName $UserPrincipalName -UserId $UserObj.id -TenantFilter $UserObj.tenantFilter -RemoveLicenses $RemoveLicenses -AddLicenses $licenses -Headers $Headers -APIName $APIName + $Results.Add($licResults) + } + + } + } + } + + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API $APIName -tenant ($UserObj.tenantFilter) -headers $Headers -message "License assign API failed. $($ErrorMessage.NormalizedError)" -Sev Error -LogData $ErrorMessage + $Results.Add( "We've failed to assign the license. $($ErrorMessage.NormalizedError)") + Write-Warning "License assign API failed. $($_.Exception.Message)" + Write-Information $_.InvocationInfo.PositionMessage + } + + #Add Aliases, removal currently not supported. + try { + if ($Aliases) { + Write-Host ($Aliases | ConvertTo-Json) + foreach ($Alias in $Aliases) { + $null = New-GraphPostRequest -uri "https://graph.microsoft.com/beta/users/$($UserObj.id)" -tenantid $UserObj.tenantFilter -type 'patch' -body "{`"mail`": `"$Alias`"}" -Verbose + } + $null = New-GraphPostRequest -uri "https://graph.microsoft.com/beta/users/$($UserObj.id)" -tenantid $UserObj.tenantFilter -type 'patch' -body "{`"mail`": `"$UserPrincipalName`"}" -Verbose + Write-LogMessage -API $APIName -tenant ($UserObj.tenantFilter) -headers $Headers -message "Added Aliases to $($UserObj.DisplayName)" -Sev Info + $Results.Add( 'Success. Added aliases to user.') + } + + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Message = "Failed to add aliases to user $($UserObj.DisplayName). Error: $($ErrorMessage.NormalizedError)" + Write-LogMessage -API $APIName -tenant ($UserObj.tenantFilter) -headers $Headers -message $Message -Sev Error -LogData $ErrorMessage + $Results.Add($Message) + } + + if ($UserObj.CopyFrom.value) { + $CopyFrom = Set-CIPPCopyGroupMembers -Headers $Headers -CopyFromId $UserObj.CopyFrom.value -UserID $UserPrincipalName -TenantFilter $UserObj.tenantFilter + $Results.AddRange(@($CopyFrom)) + } + + if ($AddToGroups) { + $AddToGroups | ForEach-Object { + + $GroupType = $_.addedFields.groupType + $CalculatedGroupType = $_.addedFields.calculatedGroupType ?? $null + $GroupID = $_.value + $GroupName = $_.label + Write-Host "About to add $($UserObj.userPrincipalName) to $GroupName. Group ID is: $GroupID and type is: $GroupType" + + try { + if ($GroupType -eq 'distributionList' -or $GroupType -eq 'security' -and ($calculatedGroupType -ne 'generic' )) { + Write-Host 'Adding to group via Add-DistributionGroupMember' + $Params = @{ Identity = $GroupID; Member = $UserObj.id; BypassSecurityGroupManagerCheck = $true } + $null = New-ExoRequest -tenantid $UserObj.tenantFilter -cmdlet 'Add-DistributionGroupMember' -cmdParams $params -UseSystemMailbox $true + } else { + Write-Host 'Adding to group via Graph' + $UserBody = [PSCustomObject]@{ + '@odata.id' = "https://graph.microsoft.com/beta/directoryObjects/$($UserObj.id)" + } + $UserBodyJSON = ConvertTo-Json -Compress -Depth 10 -InputObject $UserBody + $null = New-GraphPostRequest -uri "https://graph.microsoft.com/beta/groups/$GroupID/members/`$ref" -tenantid $UserObj.tenantFilter -type POST -body $UserBodyJSON -Verbose + } + Write-LogMessage -headers $Headers -API $APIName -tenant $UserObj.tenantFilter -message "Added $($UserObj.DisplayName) to $GroupName group" -Sev Info + $Results.Add("Success. $($UserObj.DisplayName) has been added to $GroupName") + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Message = "Failed to add member $($UserObj.DisplayName) to $GroupName. Error: $($ErrorMessage.NormalizedError)" + Write-LogMessage -headers $Headers -API $APIName -tenant $UserObj.tenantFilter -message $Message -Sev Error -LogData $ErrorMessage + $Results.Add($Message) + } + } + } + + if ($RemoveFromGroups) { + $RemoveFromGroups | ForEach-Object { + + $GroupType = $_.addedFields.groupType + $CalculatedGroupType = $_.addedFields.calculatedGroupType ?? $null + $GroupID = $_.value + $GroupName = $_.label + Write-Host "About to remove $($UserObj.userPrincipalName) from $GroupName. Group ID is: $GroupID and type is: $GroupType" + + try { + if ($GroupType -eq 'distributionList' -or $GroupType -eq 'security' -and ($calculatedGroupType -ne 'generic' )) { + Write-Host 'Removing From group via Remove-DistributionGroupMember' + $Params = @{ Identity = $GroupID; Member = $UserObj.id; BypassSecurityGroupManagerCheck = $true } + $null = New-ExoRequest -tenantid $UserObj.tenantFilter -cmdlet 'Remove-DistributionGroupMember' -cmdParams $params -UseSystemMailbox $true + } else { + Write-Host 'Removing From group via Graph' + $null = New-GraphPostRequest -uri "https://graph.microsoft.com/beta/groups/$GroupID/members/$($UserObj.id)/`$ref" -tenantid $UserObj.tenantFilter -type DELETE + } + Write-LogMessage -headers $Headers -API $APIName -tenant $UserObj.tenantFilter -message "Removed $($UserObj.DisplayName) from $GroupName group" -Sev Info + $Results.Add("Success. $($UserObj.DisplayName) has been removed from $GroupName") + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Message = "Failed to remove member $($UserObj.DisplayName) from $GroupName. Error: $($ErrorMessage.NormalizedError)" + Write-LogMessage -headers $Headers -API $APIName -tenant $UserObj.tenantFilter -message $Message -Sev Error -LogData $ErrorMessage + $Results.Add($Message) + } + } + } + + if ($UserObj.setManager.value) { + $ManagerResults = Set-CIPPManager -Users $UserPrincipalName -Manager $UserObj.setManager.value -TenantFilter $UserObj.tenantFilter -Headers $Headers + $Results.Add($ManagerResults.Result) + } + + if ($UserObj.setSponsor.value) { + $SponsorResults = Set-CIPPSponsor -Users $UserPrincipalName -Sponsor $UserObj.setSponsor.value -TenantFilter $UserObj.tenantFilter -Headers $Headers + $Results.Add($SponsorResults.Result) + } + + return @{ + Results = $Results + UserPrincipalName = $UserPrincipalName + } +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-EditUser.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-EditUser.ps1 index 84590fda70b59..c9576f8b7fd4b 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-EditUser.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-EditUser.ps1 @@ -19,262 +19,52 @@ function Invoke-EditUser { StatusCode = [HttpStatusCode]::BadRequest Body = $Body }) - return } - $Results = [System.Collections.Generic.List[object]]::new() - $licenses = ($UserObj.licenses).value - $Aliases = if ($UserObj.AddedAliases) { ($UserObj.AddedAliases) -split '\s' } - $AddToGroups = $Request.Body.AddToGroups - $RemoveFromGroups = $Request.Body.RemoveFromGroups - - #Edit the user - try { - $UserPrincipalName = "$($UserObj.username)@$($UserObj.Domain ? $UserObj.Domain : $UserObj.primDomain.value)" - $normalizedOtherMails = @( - @($UserObj.otherMails) | ForEach-Object { - if ($null -ne $_) { - [string]$_ -split ',' + if ($UserObj.Scheduled.Enabled) { + try { + $TaskBody = [pscustomobject]@{ + TenantFilter = $UserObj.tenantFilter + Name = "Edit user: $($UserObj.DisplayName)" + Command = @{ + value = 'Set-CIPPUser' + label = 'Set-CIPPUser' } - } | ForEach-Object { - $_.Trim() - } | Where-Object { - -not [string]::IsNullOrWhiteSpace($_) - } - ) - $BodyToship = [pscustomobject] @{ - 'givenName' = $UserObj.givenName - 'surname' = $UserObj.surname - 'displayName' = $UserObj.displayName - 'department' = $UserObj.department - 'mailNickname' = $UserObj.username ? $UserObj.username : $UserObj.mailNickname - 'userPrincipalName' = $UserPrincipalName - 'usageLocation' = $UserObj.usageLocation.value ? $UserObj.usageLocation.value : $UserObj.usageLocation - 'jobTitle' = $UserObj.jobTitle - 'mobilePhone' = $UserObj.mobilePhone - 'streetAddress' = $UserObj.streetAddress - 'city' = $UserObj.city - 'state' = $UserObj.state - 'postalCode' = $UserObj.postalCode - 'country' = $UserObj.country - 'companyName' = $UserObj.companyName - 'businessPhones' = $UserObj.businessPhones ? @($UserObj.businessPhones) : @() - 'otherMails' = $normalizedOtherMails - 'passwordProfile' = @{ - 'forceChangePasswordNextSignIn' = [bool]$UserObj.MustChangePass - } - } | ForEach-Object { - $NonEmptyProperties = $_.PSObject.Properties | - Where-Object { -not [string]::IsNullOrWhiteSpace($_.Value) } | - Select-Object -ExpandProperty Name - $_ | Select-Object -Property $NonEmptyProperties - } - # Explicit clears: the frontend lists the profile fields the user actively emptied. - # We re-add them as null (scalars) / empty array (collections) so Graph clears them, while - # untouched empty fields stay omitted. Whitelisted to safe attributes - $ClearableFields = @( - 'givenName', 'surname', 'department', 'jobTitle', 'mobilePhone', - 'streetAddress', 'city', 'state', 'postalCode', 'country', 'companyName', - 'businessPhones', 'otherMails' - ) - $ClearList = @($UserObj.clearProperties | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) - foreach ($Prop in $ClearList) { - if ($Prop -notin $ClearableFields) { continue } - # Pass @() literally; routing it through a variable unrolls it back to $null. - if ($Prop -in 'businessPhones', 'otherMails') { - $BodyToShip | Add-Member -NotePropertyName $Prop -NotePropertyValue @() -Force - } else { - $BodyToShip | Add-Member -NotePropertyName $Prop -NotePropertyValue $null -Force - } - } - if ($UserObj.defaultAttributes) { - $UserObj.defaultAttributes | Get-Member -MemberType NoteProperty | ForEach-Object { - if (-not [string]::IsNullOrWhiteSpace($UserObj.defaultAttributes.$($_.Name).value)) { - Write-Host "Editing user and adding $($_.Name) with value $($UserObj.defaultAttributes.$($_.Name).value)" - $BodyToShip | Add-Member -NotePropertyName $_.Name -NotePropertyValue $UserObj.defaultAttributes.$($_.Name).value -Force + Parameters = [pscustomobject]@{ UserObj = $UserObj } + ScheduledTime = $UserObj.Scheduled.date + Reference = $UserObj.reference ?? $null + PostExecution = @{ + Webhook = [bool]$Request.Body.PostExecution.Webhook + Email = [bool]$Request.Body.PostExecution.Email + PSA = [bool]$Request.Body.PostExecution.PSA } } - } - if ($UserObj.customData) { - $UserObj.customData | Get-Member -MemberType NoteProperty | ForEach-Object { - if (-not [string]::IsNullOrWhiteSpace($UserObj.customData.$($_.Name))) { - Write-Host "Editing user and adding custom data $($_.Name) with value $($UserObj.customData.$($_.Name))" - $BodyToShip | Add-Member -NotePropertyName $_.Name -NotePropertyValue $UserObj.customData.$($_.Name) -Force - } + Add-CIPPScheduledTask -Task $TaskBody -hidden $false -DisallowDuplicateName $true -Headers $Headers + $body = [pscustomobject]@{ + 'Results' = @("Successfully created scheduled task to edit user $($UserObj.DisplayName)") } - } - $bodyToShip = ConvertTo-Json -Depth 10 -InputObject $BodyToship -Compress - $null = New-GraphPostRequest -uri "https://graph.microsoft.com/beta/users/$($UserObj.id)" -tenantid $UserObj.tenantFilter -type PATCH -body $BodyToship -verbose - $Results.Add( 'Success. The user has been edited.' ) - Write-LogMessage -API $APIName -tenant ($UserObj.tenantFilter) -headers $Headers -message "Edited user $($UserObj.DisplayName) with id $($UserObj.id)" -Sev Info - if ($UserObj.password) { - $passwordProfile = [pscustomobject]@{'passwordProfile' = @{ 'password' = $UserObj.password; 'forceChangePasswordNextSignIn' = [boolean]$UserObj.MustChangePass } } | ConvertTo-Json - $null = New-GraphPostRequest -uri "https://graph.microsoft.com/beta/users/$($UserObj.id)" -tenantid $UserObj.tenantFilter -type PATCH -body $PasswordProfile -Verbose - $Results.Add("Success. The password has been set to $($UserObj.password)") - Write-LogMessage -API $APIName -tenant ($UserObj.tenantFilter) -headers $Headers -message "Reset $($UserObj.DisplayName)'s Password" -Sev Info - } - } catch { - $ErrorMessage = Get-CippException -Exception $_ - Write-LogMessage -API $APIName -tenant ($UserObj.tenantFilter) -headers $Headers -message "User edit API failed. $($ErrorMessage.NormalizedError)" -Sev Error -LogData $ErrorMessage - $Results.Add( "Failed to edit user. $($ErrorMessage.NormalizedError)") - } - - - #Reassign the licenses - try { - - if ($licenses -or $UserObj.removeLicenses) { - if ($UserObj.sherwebLicense.value) { - $null = Set-SherwebSubscription -Headers $Headers -TenantFilter $UserObj.tenantFilter -SKU $UserObj.sherwebLicense.value -Add 1 - $Results.Add('Added Sherweb License, scheduling assignment') - $taskObject = [PSCustomObject]@{ - TenantFilter = $UserObj.tenantFilter - Name = "Assign License: $UserPrincipalName" - Command = @{ - value = 'Set-CIPPUserLicense' - } - Parameters = [pscustomobject]@{ - UserId = $UserObj.id - APIName = 'Sherweb License Assignment' - AddLicenses = $licenses - UserPrincipalName = $UserPrincipalName - } - ScheduledTime = 0 #right now, which is in the next 15 minutes and should cover most cases. - PostExecution = @{ - Webhook = [bool]$Request.Body.PostExecution.webhook - Email = [bool]$Request.Body.PostExecution.email - PSA = [bool]$Request.Body.PostExecution.psa - } - } - Add-CIPPScheduledTask -Task $taskObject -hidden $false -Headers $Headers - } else { - $CurrentLicenses = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/users/$($UserObj.id)" -tenantid $UserObj.tenantFilter - #if the list of skuIds in $CurrentLicenses.assignedLicenses is EXACTLY the same as $licenses, we don't need to do anything, but the order in both can be different. - if (($CurrentLicenses.assignedLicenses.skuId -join ',') -eq ($licenses -join ',') -and $UserObj.removeLicenses -eq $false) { - Write-Host "$($CurrentLicenses.assignedLicenses.skuId -join ',') $(($licenses -join ','))" - $Results.Add( 'Success. User license is already correct.' ) - } else { - if ($UserObj.removeLicenses) { - $licResults = Set-CIPPUserLicense -UserPrincipalName $UserPrincipalName -UserId $UserObj.id -TenantFilter $UserObj.tenantFilter -RemoveLicenses $CurrentLicenses.assignedLicenses.skuId -Headers $Headers -APIName $APIName - $Results.Add($licResults) - } else { - #Remove all objects from $CurrentLicenses.assignedLicenses.skuId that are in $licenses - $RemoveLicenses = $CurrentLicenses.assignedLicenses.skuId | Where-Object { $_ -notin $licenses } - $licResults = Set-CIPPUserLicense -UserPrincipalName $UserPrincipalName -UserId $UserObj.id -TenantFilter $UserObj.tenantFilter -RemoveLicenses $RemoveLicenses -AddLicenses $licenses -Headers $Headers -APIName $APIName - $Results.Add($licResults) - } - - } + } catch { + $body = [pscustomobject]@{ + 'Results' = @("Failed to create scheduled task to edit user $($UserObj.DisplayName): $($_.Exception.Message)") } + $StatusCode = [HttpStatusCode]::InternalServerError } - - } catch { - $ErrorMessage = Get-CippException -Exception $_ - Write-LogMessage -API $APIName -tenant ($UserObj.tenantFilter) -headers $Headers -message "License assign API failed. $($ErrorMessage.NormalizedError)" -Sev Error -LogData $ErrorMessage - $Results.Add( "We've failed to assign the license. $($ErrorMessage.NormalizedError)") - Write-Warning "License assign API failed. $($_.Exception.Message)" - Write-Information $_.InvocationInfo.PositionMessage - } - - #Add Aliases, removal currently not supported. - try { - if ($Aliases) { - Write-Host ($Aliases | ConvertTo-Json) - foreach ($Alias in $Aliases) { - $null = New-GraphPostRequest -uri "https://graph.microsoft.com/beta/users/$($UserObj.id)" -tenantid $UserObj.tenantFilter -type 'patch' -body "{`"mail`": `"$Alias`"}" -Verbose + } else { + try { + $EditResults = Set-CIPPUser -UserObj $UserObj -APIName $APIName -Headers $Headers + $body = [pscustomobject]@{ 'Results' = @($EditResults.Results) } + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $body = [pscustomobject]@{ + 'Results' = @("Failed to edit user. $($ErrorMessage.NormalizedError)") } - $null = New-GraphPostRequest -uri "https://graph.microsoft.com/beta/users/$($UserObj.id)" -tenantid $UserObj.tenantFilter -type 'patch' -body "{`"mail`": `"$UserPrincipalName`"}" -Verbose - Write-LogMessage -API $APIName -tenant ($UserObj.tenantFilter) -headers $Headers -message "Added Aliases to $($UserObj.DisplayName)" -Sev Info - $Results.Add( 'Success. Added aliases to user.') + $StatusCode = [HttpStatusCode]::InternalServerError } - - } catch { - $ErrorMessage = Get-CippException -Exception $_ - $Message = "Failed to add aliases to user $($UserObj.DisplayName). Error: $($ErrorMessage.NormalizedError)" - Write-LogMessage -API $APIName -tenant ($UserObj.tenantFilter) -headers $Headers -message $Message -Sev Error -LogData $ErrorMessage - $Results.Add($Message) - } - - if ($Request.Body.CopyFrom.value) { - $CopyFrom = Set-CIPPCopyGroupMembers -Headers $Headers -CopyFromId $Request.Body.CopyFrom.value -UserID $UserPrincipalName -TenantFilter $UserObj.tenantFilter - $Results.AddRange(@($CopyFrom)) - } - - if ($AddToGroups) { - $AddToGroups | ForEach-Object { - - $GroupType = $_.addedFields.groupType - $CalculatedGroupType = $_.addedFields.calculatedGroupType ?? $null - $GroupID = $_.value - $GroupName = $_.label - Write-Host "About to add $($UserObj.userPrincipalName) to $GroupName. Group ID is: $GroupID and type is: $GroupType" - - try { - if ($GroupType -eq 'distributionList' -or $GroupType -eq 'security' -and ($calculatedGroupType -ne 'generic' )) { - Write-Host 'Adding to group via Add-DistributionGroupMember' - $Params = @{ Identity = $GroupID; Member = $UserObj.id; BypassSecurityGroupManagerCheck = $true } - $null = New-ExoRequest -tenantid $UserObj.tenantFilter -cmdlet 'Add-DistributionGroupMember' -cmdParams $params -UseSystemMailbox $true - } else { - Write-Host 'Adding to group via Graph' - $UserBody = [PSCustomObject]@{ - '@odata.id' = "https://graph.microsoft.com/beta/directoryObjects/$($UserObj.id)" - } - $UserBodyJSON = ConvertTo-Json -Compress -Depth 10 -InputObject $UserBody - $null = New-GraphPostRequest -uri "https://graph.microsoft.com/beta/groups/$GroupID/members/`$ref" -tenantid $UserObj.tenantFilter -type POST -body $UserBodyJSON -Verbose - } - Write-LogMessage -headers $Headers -API $APIName -tenant $UserObj.tenantFilter -message "Added $($UserObj.DisplayName) to $GroupName group" -Sev Info - $Results.Add("Success. $($UserObj.DisplayName) has been added to $GroupName") - } catch { - $ErrorMessage = Get-CippException -Exception $_ - $Message = "Failed to add member $($UserObj.DisplayName) to $GroupName. Error: $($ErrorMessage.NormalizedError)" - Write-LogMessage -headers $Headers -API $APIName -tenant $UserObj.tenantFilter -message $Message -Sev Error -LogData $ErrorMessage - $Results.Add($Message) - } - } - } - - if ($RemoveFromGroups) { - $RemoveFromGroups | ForEach-Object { - - $GroupType = $_.addedFields.groupType - $CalculatedGroupType = $_.addedFields.calculatedGroupType ?? $null - $GroupID = $_.value - $GroupName = $_.label - Write-Host "About to remove $($UserObj.userPrincipalName) from $GroupName. Group ID is: $GroupID and type is: $GroupType" - - try { - if ($GroupType -eq 'distributionList' -or $GroupType -eq 'security' -and ($calculatedGroupType -ne 'generic' )) { - Write-Host 'Removing From group via Remove-DistributionGroupMember' - $Params = @{ Identity = $GroupID; Member = $UserObj.id; BypassSecurityGroupManagerCheck = $true } - $null = New-ExoRequest -tenantid $UserObj.tenantFilter -cmdlet 'Remove-DistributionGroupMember' -cmdParams $params -UseSystemMailbox $true - } else { - Write-Host 'Removing From group via Graph' - $null = New-GraphPostRequest -uri "https://graph.microsoft.com/beta/groups/$GroupID/members/$($UserObj.id)/`$ref" -tenantid $UserObj.tenantFilter -type DELETE - } - Write-LogMessage -headers $Headers -API $APIName -tenant $UserObj.tenantFilter -message "Removed $($UserObj.DisplayName) from $GroupName group" -Sev Info - $Results.Add("Success. $($UserObj.DisplayName) has been removed from $GroupName") - } catch { - $ErrorMessage = Get-CippException -Exception $_ - $Message = "Failed to remove member $($UserObj.DisplayName) from $GroupName. Error: $($ErrorMessage.NormalizedError)" - Write-LogMessage -headers $Headers -API $APIName -tenant $UserObj.tenantFilter -message $Message -Sev Error -LogData $ErrorMessage - $Results.Add($Message) - } - } - } - - if ($Request.body.setManager.value) { - $ManagerResults = Set-CIPPManager -Users $UserPrincipalName -Manager $Request.body.setManager.value -TenantFilter $UserObj.tenantFilter -Headers $Headers - $Results.Add($ManagerResults.Result) - } - - if ($Request.body.setSponsor.value) { - $SponsorResults = Set-CIPPSponsor -Users $UserPrincipalName -Sponsor $Request.body.setSponsor.value -TenantFilter $UserObj.tenantFilter -Headers $Headers - $Results.Add($SponsorResults.Result) } return ([HttpResponseContext]@{ - StatusCode = [HttpStatusCode]::OK - Body = @{'Results' = @($Results) } + StatusCode = $StatusCode ? $StatusCode : [HttpStatusCode]::OK + Body = $Body }) } diff --git a/Tests/Endpoint/Invoke-EditUser.Tests.ps1 b/Tests/Endpoint/Invoke-EditUser.Tests.ps1 index 81aee9276912f..6681b230f4f53 100644 --- a/Tests/Endpoint/Invoke-EditUser.Tests.ps1 +++ b/Tests/Endpoint/Invoke-EditUser.Tests.ps1 @@ -6,6 +6,7 @@ BeforeAll { $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) $FunctionPath = Join-Path $RepoRoot 'Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-EditUser.ps1' + $CorePath = Join-Path $RepoRoot 'Modules/CIPPCore/Public/Set-CIPPUser.ps1' class HttpResponseContext { [object]$StatusCode @@ -24,6 +25,10 @@ BeforeAll { param($uri, $tenantid, $type, $body, [switch]$verbose) if ($null -eq $script:lastBody) { $script:lastBody = $body } } + function Add-CIPPScheduledTask { + param($Task, $hidden, $DisallowDuplicateName, $Headers) + $script:lastScheduledTask = $Task + } function New-EditRequest { param([hashtable]$Extra) @@ -44,6 +49,7 @@ BeforeAll { } } + . $CorePath . $FunctionPath } @@ -92,3 +98,21 @@ Describe 'Invoke-EditUser body construction' { $script:lastBody | Should -Not -Match '"displayName":(null|"")' } } + +Describe 'Invoke-EditUser scheduling' { + BeforeEach { + $script:lastBody = $null + $script:lastScheduledTask = $null + } + + It 'schedules a Set-CIPPUser task instead of editing immediately when Scheduled.Enabled is set' { + $request = New-EditRequest -Extra @{ Scheduled = @{ Enabled = $true; date = 1234567890 } } + + $null = Invoke-EditUser -Request $request -TriggerMetadata $null + + $script:lastBody | Should -BeNullOrEmpty + $script:lastScheduledTask.Command.value | Should -Be 'Set-CIPPUser' + $script:lastScheduledTask.Parameters.UserObj.id | Should -Be $request.Body.id + $script:lastScheduledTask.ScheduledTime | Should -Be 1234567890 + } +} From ea8259f8c0a016db2558f2c88dd67b7c323b8833 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Wed, 1 Jul 2026 22:45:38 +0200 Subject: [PATCH 080/170] add package to exclusions --- Modules/CIPPCore/Public/Compare-CIPPIntuneObject.ps1 | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Modules/CIPPCore/Public/Compare-CIPPIntuneObject.ps1 b/Modules/CIPPCore/Public/Compare-CIPPIntuneObject.ps1 index da6a537fc21fc..7ea75b264fac1 100644 --- a/Modules/CIPPCore/Public/Compare-CIPPIntuneObject.ps1 +++ b/Modules/CIPPCore/Public/Compare-CIPPIntuneObject.ps1 @@ -35,7 +35,8 @@ function Compare-CIPPIntuneObject { 'isSynced' 'locationInfo', 'templateId', - 'source' + 'source', + 'package' ) $excludeProps = $defaultExcludeProperties + $ExcludeProperties From b326768d6b4fe2e78fcce088fd3568d8e4a6d2fc Mon Sep 17 00:00:00 2001 From: Bobby <31723128+kris6673@users.noreply.github.com> Date: Wed, 1 Jul 2026 22:52:55 +0200 Subject: [PATCH 081/170] fix(tests): resolve function paths by filename Existing tests hardcoded module paths that break whenever a function moves between modules. Resolve the function under test by filename under Modules/ instead, so tests keep passing after file moves. Fixes the Intune/Alert/Standards tests currently failing on dev. --- ...et-CIPPAlertGlobalAdminAllowList.Tests.ps1 | 14 +++- ...t-CIPPAlertIntunePolicyConflicts.Tests.ps1 | 2 +- .../Invoke-AddIntuneReusableSetting.Tests.ps1 | 5 +- ...AddIntuneReusableSettingTemplate.Tests.ps1 | 10 ++- ...stIntuneReusableSettingTemplates.Tests.ps1 | 5 +- ...nvoke-ListIntuneReusableSettings.Tests.ps1 | 68 ++++++++++++------- ...voke-RemoveIntuneReusableSetting.Tests.ps1 | 8 ++- ...oveIntuneReusableSettingTemplate.Tests.ps1 | 7 +- ...StandardReusableSettingsTemplate.Tests.ps1 | 5 +- 9 files changed, 89 insertions(+), 35 deletions(-) diff --git a/Tests/Alerts/Get-CIPPAlertGlobalAdminAllowList.Tests.ps1 b/Tests/Alerts/Get-CIPPAlertGlobalAdminAllowList.Tests.ps1 index 7a8ae25cc5909..c2a9e70906284 100644 --- a/Tests/Alerts/Get-CIPPAlertGlobalAdminAllowList.Tests.ps1 +++ b/Tests/Alerts/Get-CIPPAlertGlobalAdminAllowList.Tests.ps1 @@ -3,13 +3,19 @@ BeforeAll { $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) - $AlertPath = Join-Path $RepoRoot 'Modules/CIPPCore/Public/Alerts/Get-CIPPAlertGlobalAdminAllowList.ps1' + # Resolve by name under Modules/ so the test survives the function moving between modules. + $AlertPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Get-CIPPAlertGlobalAdminAllowList.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $AlertPath) { throw 'Could not locate Get-CIPPAlertGlobalAdminAllowList.ps1 under Modules/' } # Provide minimal stubs so Mock has commands to replace during tests function New-GraphGetRequest { param($uri, $tenantid, $AsApp) } function Write-AlertTrace { param($cmdletName, $tenantFilter, $data) } function Write-AlertMessage { param($tenant, $message) } function Get-NormalizedError { param($message) $message } + # The error path now normalises via Get-CippException and logs via Write-LogMessage. + function Get-CippException { param($Exception) @{ NormalizedError = $Exception } } + function Write-LogMessage { param($API, $tenant, $message, $sev, $Headers, $LogData) } . $AlertPath } @@ -47,6 +53,12 @@ Describe 'Get-CIPPAlertGlobalAdminAllowList' { param($tenant, $message) $script:CapturedErrorMessage = $message } + + # The function logs failures through Write-LogMessage, so capture the error from there. + Mock -CommandName Write-LogMessage -MockWith { + param($API, $tenant, $message, $sev, $Headers, $LogData) + $script:CapturedErrorMessage = $message + } } It 'emits per-admin alerts when AlertEachAdmin is true' { diff --git a/Tests/Alerts/Get-CIPPAlertIntunePolicyConflicts.Tests.ps1 b/Tests/Alerts/Get-CIPPAlertIntunePolicyConflicts.Tests.ps1 index f44bb399798e0..7cfd7476d483e 100644 --- a/Tests/Alerts/Get-CIPPAlertIntunePolicyConflicts.Tests.ps1 +++ b/Tests/Alerts/Get-CIPPAlertIntunePolicyConflicts.Tests.ps1 @@ -122,7 +122,7 @@ Describe 'Get-CIPPAlertIntunePolicyConflicts' { $AppIssue = $CapturedData | Where-Object { $_.Type -eq 'Application' } $AppIssue.FailedDeviceCount | Should -Be 3 - $AppIssue.Message | Should -Match "failed to install on 3 device" + $AppIssue.Message | Should -Match 'failed to install on 3 device' } It 'skips processing when license check fails' { diff --git a/Tests/Endpoint/Invoke-AddIntuneReusableSetting.Tests.ps1 b/Tests/Endpoint/Invoke-AddIntuneReusableSetting.Tests.ps1 index 555008547f998..4d926187dfaf3 100644 --- a/Tests/Endpoint/Invoke-AddIntuneReusableSetting.Tests.ps1 +++ b/Tests/Endpoint/Invoke-AddIntuneReusableSetting.Tests.ps1 @@ -3,7 +3,10 @@ BeforeAll { $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) - $FunctionPath = Join-Path $RepoRoot 'Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-AddIntuneReusableSetting.ps1' + # Resolve by name under Modules/ so the test survives the function moving between modules. + $FunctionPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Invoke-AddIntuneReusableSetting.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $FunctionPath) { throw 'Could not locate Invoke-AddIntuneReusableSetting.ps1 under Modules/' } class HttpResponseContext { [int]$StatusCode diff --git a/Tests/Endpoint/Invoke-AddIntuneReusableSettingTemplate.Tests.ps1 b/Tests/Endpoint/Invoke-AddIntuneReusableSettingTemplate.Tests.ps1 index d29a4530611fb..7889fc9ffbd88 100644 --- a/Tests/Endpoint/Invoke-AddIntuneReusableSettingTemplate.Tests.ps1 +++ b/Tests/Endpoint/Invoke-AddIntuneReusableSettingTemplate.Tests.ps1 @@ -3,8 +3,14 @@ BeforeAll { $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) - $FunctionPath = Join-Path $RepoRoot 'Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-AddIntuneReusableSettingTemplate.ps1' - $MetadataPath = Join-Path $RepoRoot 'Modules/CIPPCore/Public/Remove-CIPPReusableSettingMetadata.ps1' + # Resolve by name under Modules/ so the tests survive functions moving between modules. + $ModulesRoot = Join-Path $RepoRoot 'Modules' + $FunctionPath = Get-ChildItem -Path $ModulesRoot -Recurse -Filter 'Invoke-AddIntuneReusableSettingTemplate.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $FunctionPath) { throw 'Could not locate Invoke-AddIntuneReusableSettingTemplate.ps1 under Modules/' } + $MetadataPath = Get-ChildItem -Path $ModulesRoot -Recurse -Filter 'Remove-CIPPReusableSettingMetadata.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $MetadataPath) { throw 'Could not locate Remove-CIPPReusableSettingMetadata.ps1 under Modules/' } class HttpResponseContext { [int]$StatusCode diff --git a/Tests/Endpoint/Invoke-ListIntuneReusableSettingTemplates.Tests.ps1 b/Tests/Endpoint/Invoke-ListIntuneReusableSettingTemplates.Tests.ps1 index 3813c7de946b9..095fb82996528 100644 --- a/Tests/Endpoint/Invoke-ListIntuneReusableSettingTemplates.Tests.ps1 +++ b/Tests/Endpoint/Invoke-ListIntuneReusableSettingTemplates.Tests.ps1 @@ -3,7 +3,10 @@ BeforeAll { $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) - $FunctionPath = Join-Path $RepoRoot 'Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListIntuneReusableSettingTemplates.ps1' + # Resolve by name under Modules/ so the test survives the function moving between modules. + $FunctionPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Invoke-ListIntuneReusableSettingTemplates.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $FunctionPath) { throw 'Could not locate Invoke-ListIntuneReusableSettingTemplates.ps1 under Modules/' } class HttpResponseContext { [int]$StatusCode diff --git a/Tests/Endpoint/Invoke-ListIntuneReusableSettings.Tests.ps1 b/Tests/Endpoint/Invoke-ListIntuneReusableSettings.Tests.ps1 index 5a66f62a43a44..730b9ee2f4364 100644 --- a/Tests/Endpoint/Invoke-ListIntuneReusableSettings.Tests.ps1 +++ b/Tests/Endpoint/Invoke-ListIntuneReusableSettings.Tests.ps1 @@ -1,66 +1,82 @@ # Pester tests for Invoke-ListIntuneReusableSettings -# Validates listing and filtering of live reusable settings +# Validates listing of live reusable settings, the report-DB branch, and tenant validation. BeforeAll { + # Resolve by name under Modules/ so the test survives the function moving between modules. $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) - $FunctionPath = Join-Path $RepoRoot 'Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListIntuneReusableSettings.ps1' + $FunctionPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Invoke-ListIntuneReusableSettings.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $FunctionPath) { throw 'Could not locate Invoke-ListIntuneReusableSettings.ps1 under Modules/' } + # Azure Functions binding types do not exist outside the Functions host - fake them. class HttpResponseContext { [int]$StatusCode [object]$Body } - Add-Type -AssemblyName System.Net.Http - - function Write-LogMessage { param($headers, $API, $message, $sev, $LogData) } - function Get-CippException { param($Exception) $Exception } - function New-GraphGETRequest { param($uri, $tenantid) } + # Stub every CIPP helper the function calls so Pester's Mock has a command to replace. + function Get-CippException { param($Exception) @{ NormalizedError = $Exception } } + function Get-CIPPIntuneReusableSettingsReport { param($TenantFilter) } + function New-GraphGetRequest { param($uri, $tenantid) } + function Write-LogMessage { param($headers, $API, $message, $Sev, $LogData) } . $FunctionPath } Describe 'Invoke-ListIntuneReusableSettings' { BeforeEach { - $script:lastUri = $null + $script:logs = @() + Mock -CommandName Write-LogMessage -MockWith { $script:logs += $message } + Mock -CommandName Get-CippException -MockWith { param($Exception) @{ NormalizedError = "$Exception" } } } - It 'returns reusable settings with raw JSON when tenantFilter is provided' { - Mock -CommandName New-GraphGETRequest -MockWith { + It 'returns OK and the live Graph results on the happy path' { + Mock -CommandName New-GraphGetRequest -MockWith { @( - [pscustomobject]@{ id = 'one'; displayName = 'A Item'; description = 'A description'; version = 1 }, - [pscustomobject]@{ id = 'two'; displayName = 'Z Item'; description = 'Z description'; version = 2 } + [pscustomobject]@{ id = 'setting-1'; displayName = 'Reusable A' }, + [pscustomobject]@{ id = 'setting-2'; displayName = 'Reusable B' } ) } - $request = [pscustomobject]@{ query = @{ tenantFilter = 'contoso.onmicrosoft.com' } } + $request = [pscustomobject]@{ + Params = @{ CIPPEndpoint = 'ListIntuneReusableSettings' } + Headers = @{ Authorization = 'token' } + Query = [pscustomobject]@{ tenantFilter = 'contoso.onmicrosoft.com' } + } + $response = Invoke-ListIntuneReusableSettings -Request $request -TriggerMetadata $null $response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK) - $response.Body.Count | Should -Be 2 - $response.Body[0].displayName | Should -Be 'A Item' + $response.Body | Should -HaveCount 2 + # The function enriches each item with a compact RawJSON copy. $response.Body[0].RawJSON | Should -Not -BeNullOrEmpty + Should -Invoke New-GraphGetRequest -ParameterFilter { $uri -like '*reusablePolicySettings*' -and $tenantid -eq 'contoso.onmicrosoft.com' } -Times 1 } - It 'requests a specific setting when ID is provided' { - Mock -CommandName New-GraphGETRequest -MockWith { - param($uri, $tenantid) - $script:lastUri = $uri - @([pscustomobject]@{ id = 'beta'; displayName = 'Beta' }) + It 'reads from the reporting DB when UseReportDB is true' { + Mock -CommandName Get-CIPPIntuneReusableSettingsReport -MockWith { + @([pscustomobject]@{ id = 'cached-1'; displayName = 'Cached' }) + } + Mock -CommandName New-GraphGetRequest -MockWith { throw 'live Graph should not be called' } + + $request = [pscustomobject]@{ + Params = @{ CIPPEndpoint = 'ListIntuneReusableSettings' } + Headers = @{ Authorization = 'token' } + Query = [pscustomobject]@{ tenantFilter = 'contoso.onmicrosoft.com'; UseReportDB = 'true' } } - $request = [pscustomobject]@{ query = @{ tenantFilter = 'contoso.onmicrosoft.com'; ID = 'beta' } } $response = Invoke-ListIntuneReusableSettings -Request $request -TriggerMetadata $null - $lastUri | Should -Match '/reusablePolicySettings/beta' - $response.Body.Count | Should -Be 1 - $response.Body[0].displayName | Should -Be 'Beta' - $response.Body[0].RawJSON | Should -Match '"id":"beta"' + $response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK) + Should -Invoke Get-CIPPIntuneReusableSettingsReport -Times 1 + Should -Invoke New-GraphGetRequest -Times 0 } It 'returns BadRequest when tenantFilter is missing' { - $request = [pscustomobject]@{ query = @{} } + $request = [pscustomobject]@{ Body = [pscustomobject]@{} ; Query = [pscustomobject]@{} } $response = Invoke-ListIntuneReusableSettings -Request $request -TriggerMetadata $null $response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::BadRequest) + $response.Body.Results | Should -Match 'tenantFilter is required' } } diff --git a/Tests/Endpoint/Invoke-RemoveIntuneReusableSetting.Tests.ps1 b/Tests/Endpoint/Invoke-RemoveIntuneReusableSetting.Tests.ps1 index da1acacfab221..290ab4bb0a710 100644 --- a/Tests/Endpoint/Invoke-RemoveIntuneReusableSetting.Tests.ps1 +++ b/Tests/Endpoint/Invoke-RemoveIntuneReusableSetting.Tests.ps1 @@ -2,8 +2,14 @@ # Validates deletion and required parameters BeforeAll { + # Locate the function by name under Modules/ so the test survives the function being + # moved between modules (it has already moved from CIPPCore to CIPPHTTP once). $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) - $FunctionPath = Join-Path $RepoRoot 'Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-RemoveIntuneReusableSetting.ps1' + $FunctionPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Invoke-RemoveIntuneReusableSetting.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $FunctionPath) { + throw 'Could not locate Invoke-RemoveIntuneReusableSetting.ps1 under Modules/' + } class HttpResponseContext { [int]$StatusCode diff --git a/Tests/Endpoint/Invoke-RemoveIntuneReusableSettingTemplate.Tests.ps1 b/Tests/Endpoint/Invoke-RemoveIntuneReusableSettingTemplate.Tests.ps1 index e39bb1dfa0b7f..0e12a26d892d2 100644 --- a/Tests/Endpoint/Invoke-RemoveIntuneReusableSettingTemplate.Tests.ps1 +++ b/Tests/Endpoint/Invoke-RemoveIntuneReusableSettingTemplate.Tests.ps1 @@ -3,7 +3,10 @@ BeforeAll { $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) - $FunctionPath = Join-Path $RepoRoot 'Modules/CIPPCore/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-RemoveIntuneReusableSettingTemplate.ps1' + # Resolve by name under Modules/ so the test survives the function moving between modules. + $FunctionPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Invoke-RemoveIntuneReusableSettingTemplate.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $FunctionPath) { throw 'Could not locate Invoke-RemoveIntuneReusableSettingTemplate.ps1 under Modules/' } class HttpResponseContext { [int]$StatusCode @@ -15,6 +18,8 @@ BeforeAll { function Remove-AzDataTableEntity { param([switch]$Force, $Entity) $script:lastRemoved = $Entity; $script:lastForce = $Force } function Write-LogMessage { param($Headers, $API, $message, $sev, $LogData) $script:logs += $message } function Get-CippException { param($Exception) [pscustomobject]@{ NormalizedError = $Exception } } + # The ID is sanitised for OData before the table lookup; stub it to pass the value through. + function ConvertTo-CIPPODataFilterValue { param($Value, $Type) $Value } . $FunctionPath } diff --git a/Tests/Standards/Invoke-CIPPStandardReusableSettingsTemplate.Tests.ps1 b/Tests/Standards/Invoke-CIPPStandardReusableSettingsTemplate.Tests.ps1 index 9777672690c5f..82e43e1d0d4b7 100644 --- a/Tests/Standards/Invoke-CIPPStandardReusableSettingsTemplate.Tests.ps1 +++ b/Tests/Standards/Invoke-CIPPStandardReusableSettingsTemplate.Tests.ps1 @@ -3,7 +3,10 @@ BeforeAll { $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) - $StandardPath = Join-Path $RepoRoot 'Modules/CIPPCore/Public/Standards/Invoke-CIPPStandardReusableSettingsTemplate.ps1' + # Resolve by name under Modules/ so the test survives the function moving between modules. + $StandardPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Invoke-CIPPStandardReusableSettingsTemplate.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $StandardPath) { throw 'Could not locate Invoke-CIPPStandardReusableSettingsTemplate.ps1 under Modules/' } function Test-CIPPStandardLicense { param($StandardName, $TenantFilter, $RequiredCapabilities) } function Get-CippTable { param($tablename) } From b6ceba4140b3260ff015d2dcf3e9cce9082f4544 Mon Sep 17 00:00:00 2001 From: Bobby <31723128+kris6673@users.noreply.github.com> Date: Wed, 1 Jul 2026 22:53:33 +0200 Subject: [PATCH 082/170] refactor(mailbox-perms): share permission helper Set-CIPPMailboxAccess, Invoke-ExecEditMailboxPermissions and Invoke-ExecModifyMBPerms each duplicated the permission-level to EXO cmdlet mapping. Route all three through the existing Set-CIPPMailboxPermission helper (~230 fewer lines) and add Pester coverage for the three refactored functions. Intended behavior changes: - ExecEditMailboxPermissions drops a redundant Graph id lookup; the UPN is passed straight to EXO as identity. - SendOnBehalf now syncs the permission cache, matching FullAccess/SendAs. Bulk processing in ExecModifyMBPerms is unchanged. --- .../CIPPCore/Public/Set-CIPPMailboxAccess.ps1 | 20 +- .../Invoke-ExecEditMailboxPermissions.ps1 | 121 ++------ .../Invoke-ExecModifyMBPerms.ps1 | 260 +++++----------- ...nvoke-ExecEditMailboxPermissions.Tests.ps1 | 127 ++++++++ .../Invoke-ExecModifyMBPerms.Tests.ps1 | 278 ++++++++++++++++++ Tests/Private/Set-CIPPMailboxAccess.Tests.ps1 | 79 +++++ 6 files changed, 576 insertions(+), 309 deletions(-) create mode 100644 Tests/Endpoint/Invoke-ExecEditMailboxPermissions.Tests.ps1 create mode 100644 Tests/Endpoint/Invoke-ExecModifyMBPerms.Tests.ps1 create mode 100644 Tests/Private/Set-CIPPMailboxAccess.Tests.ps1 diff --git a/Modules/CIPPCore/Public/Set-CIPPMailboxAccess.ps1 b/Modules/CIPPCore/Public/Set-CIPPMailboxAccess.ps1 index b16d867d6f411..54a1a075c040f 100644 --- a/Modules/CIPPCore/Public/Set-CIPPMailboxAccess.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPMailboxAccess.ps1 @@ -7,7 +7,7 @@ function Set-CIPPMailboxAccess { $TenantFilter, $APIName = 'Manage Shared Mailbox Access', $Headers, - [array]$AccessRights + [array]$AccessRights # Retained for caller compatibility; this helper grants FullAccess ) # Ensure AccessUser is always an array @@ -22,20 +22,12 @@ function Set-CIPPMailboxAccess { $Results = [system.collections.generic.list[string]]::new() - # Process each access user + # Delegate each grant to Set-CIPPMailboxPermission so the permission-level -> EXO cmdlet mapping, + # logging, cache sync, and error handling all live in one place. This helper grants FullAccess. foreach ($User in $AccessUser) { - try { - $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Add-MailboxPermission' -cmdParams @{Identity = $userid; user = $User; AutoMapping = $Automap; accessRights = $AccessRights; InheritanceType = 'all' } -Anchor $userid - - $Message = "Successfully added $($User) to $($userid) Shared Mailbox $($Automap ? 'with' : 'without') AutoMapping, with the following permissions: $AccessRights" - Write-LogMessage -headers $Headers -API $APIName -message $Message -Sev 'Info' -tenant $TenantFilter - $Results.Add($Message) - } catch { - $ErrorMessage = Get-CippException -Exception $_ - $Message = "Failed to add mailbox permissions for $($User) on $($userid). Error: $($ErrorMessage.NormalizedError)" - Write-LogMessage -headers $Headers -API $APIName -message $Message -Sev 'Error' -tenant $TenantFilter -LogData $ErrorMessage - $Results.Add($Message) - } + $Results.Add( + (Set-CIPPMailboxPermission -UserId $userid -AccessUser $User -PermissionLevel 'FullAccess' -Action 'Add' -AutoMap $Automap -TenantFilter $TenantFilter -APIName $APIName -Headers $Headers) + ) } return $Results diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ExecEditMailboxPermissions.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ExecEditMailboxPermissions.ps1 index aa7996657fee8..478bc1aa4ef83 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ExecEditMailboxPermissions.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ExecEditMailboxPermissions.ps1 @@ -17,110 +17,27 @@ function Invoke-ExecEditMailboxPermissions { $Username = $request.body.userID $Tenantfilter = $request.body.tenantfilter if ($username -eq $null) { exit } - $userid = (New-GraphGetRequest -uri "https://graph.microsoft.com/beta/users/$($username)" -tenantid $Tenantfilter).id $Results = [System.Collections.ArrayList]@() - $RemoveFullAccess = ($Request.body.RemoveFullAccess).value - foreach ($RemoveUser in $RemoveFullAccess) { - try { - $MailboxPerms = New-ExoRequest -Anchor $username -tenantid $Tenantfilter -cmdlet 'Remove-mailboxpermission' -cmdParams @{Identity = $userid; user = $RemoveUser; accessRights = @('FullAccess'); } - $results.add("Removed $($removeuser) from $($username) Shared Mailbox permissions") - Write-LogMessage -headers $Request.Headers -API $APINAME-message "Removed $($RemoveUser) from $($username) Shared Mailbox permission" -Sev 'Info' -tenant $TenantFilter - - # Sync cache - Sync-CIPPMailboxPermissionCache -TenantFilter $TenantFilter -MailboxIdentity $username -User $RemoveUser -PermissionType 'FullAccess' -Action 'Remove' - } catch { - Write-LogMessage -headers $Request.Headers -API $APINAME-message "Could not remove mailbox permissions for $($removeuser) on $($username)" -Sev 'Error' -tenant $TenantFilter - $results.add("Could not remove $($removeuser) shared mailbox permissions for $($username). Error: $($_.Exception.Message)") - } - } - $AddFullAccess = ($Request.body.AddFullAccess).value - - foreach ($UserAutomap in $AddFullAccess) { - try { - $MailboxPerms = New-ExoRequest -Anchor $username -tenantid $Tenantfilter -cmdlet 'Add-MailboxPermission' -cmdParams @{Identity = $userid; user = $UserAutomap; accessRights = @('FullAccess'); automapping = $true } - $results.add( "Granted $($UserAutomap) access to $($username) Mailbox with automapping") - Write-LogMessage -headers $Request.Headers -API $APINAME-message "Granted $($UserAutomap) access to $($username) Mailbox with automapping" -Sev 'Info' -tenant $TenantFilter - - # Sync cache - Sync-CIPPMailboxPermissionCache -TenantFilter $TenantFilter -MailboxIdentity $username -User $UserAutomap -PermissionType 'FullAccess' -Action 'Add' - - } catch { - Write-LogMessage -headers $Request.Headers -API $APINAME-message "Could not add mailbox permissions for $($UserAutomap) on $($username)" -Sev 'Error' -tenant $TenantFilter - $results.add( "Could not add $($UserAutomap) shared mailbox permissions for $($username). Error: $($_.Exception.Message)") - } - } - $AddFullAccessNoAutoMap = ($Request.body.AddFullAccessNoAutoMap).value - - foreach ($UserNoAutomap in $AddFullAccessNoAutoMap) { - try { - $MailboxPerms = New-ExoRequest -Anchor $username -tenantid $Tenantfilter -cmdlet 'Add-MailboxPermission' -cmdParams @{Identity = $userid; user = $UserNoAutomap; accessRights = @('FullAccess'); automapping = $false } - $results.add( "Granted $UserNoAutomap access to $($username) Mailbox without automapping") - Write-LogMessage -headers $Request.Headers -API $APINAME-message "Granted $UserNoAutomap access to $($username) Mailbox without automapping" -Sev 'Info' -tenant $TenantFilter - - # Sync cache - Sync-CIPPMailboxPermissionCache -TenantFilter $TenantFilter -MailboxIdentity $username -User $UserNoAutomap -PermissionType 'FullAccess' -Action 'Add' - } catch { - Write-LogMessage -headers $Request.Headers -API $APINAME-message "Could not add mailbox permissions for $($UserNoAutomap) on $($username)" -Sev 'Error' -tenant $TenantFilter - $results.add("Could not add $($UserNoAutomap) shared mailbox permissions for $($username). Error: $($_.Exception.Message)") - } - } - - $AddSendAS = ($Request.body.AddSendAs).value - - foreach ($UserSendAs in $AddSendAS) { - try { - $MailboxPerms = New-ExoRequest -Anchor $username -tenantid $Tenantfilter -cmdlet 'Add-RecipientPermission' -cmdParams @{Identity = $userid; Trustee = $UserSendAs; accessRights = @('SendAs') } - $results.add( "Granted $UserSendAs access to $($username) with Send As permissions") - Write-LogMessage -headers $Request.Headers -API $APINAME-message "Granted $UserSendAs access to $($username) with Send As permissions" -Sev 'Info' -tenant $TenantFilter - - # Sync cache - Sync-CIPPMailboxPermissionCache -TenantFilter $TenantFilter -MailboxIdentity $username -User $UserSendAs -PermissionType 'SendAs' -Action 'Add' - } catch { - Write-LogMessage -headers $Request.Headers -API $APINAME-message "Could not add mailbox permissions for $($UserSendAs) on $($username)" -Sev 'Error' -tenant $TenantFilter - $results.add("Could not add $($UserSendAs) send-as permissions for $($username). Error: $($_.Exception.Message)") - } - } - - $RemoveSendAs = ($Request.body.RemoveSendAs).value - - foreach ($UserSendAs in $RemoveSendAs) { - try { - $MailboxPerms = New-ExoRequest -Anchor $username -tenantid $Tenantfilter -cmdlet 'Remove-RecipientPermission' -cmdParams @{Identity = $userid; Trustee = $UserSendAs; accessRights = @('SendAs') } - $results.add( "Removed $UserSendAs from $($username) with Send As permissions") - Write-LogMessage -headers $Request.Headers -API $APINAME-message "Removed $UserSendAs from $($username) with Send As permissions" -Sev 'Info' -tenant $TenantFilter - - # Sync cache - Sync-CIPPMailboxPermissionCache -TenantFilter $TenantFilter -MailboxIdentity $username -User $UserSendAs -PermissionType 'SendAs' -Action 'Remove' - } catch { - Write-LogMessage -headers $Request.Headers -API $APINAME-message "Could not remove mailbox permissions for $($UserSendAs) on $($username)" -Sev 'Error' -tenant $TenantFilter - $results.add("Could not remove $($UserSendAs) send-as permissions for $($username). Error: $($_.Exception.Message)") - } - } - - $AddSendOnBehalf = ($Request.body.AddSendOnBehalf).value - - foreach ($UserSendOnBehalf in $AddSendOnBehalf) { - try { - $MailboxPerms = New-ExoRequest -Anchor $username -tenantid $Tenantfilter -cmdlet 'Set-Mailbox' -cmdParams @{Identity = $userid; GrantSendonBehalfTo = @{'@odata.type' = '#Exchange.GenericHashTable'; add = $UserSendOnBehalf }; } - $results.add( "Granted $UserSendOnBehalf access to $($username) with Send On Behalf Permissions") - Write-LogMessage -headers $Request.Headers -API $APINAME-message "Granted $UserSendOnBehalf access to $($username) with Send On Behalf Permissions" -Sev 'Info' -tenant $TenantFilter - } catch { - Write-LogMessage -headers $Request.Headers -API $APINAME-message "Could not add send on behalf permissions for $($UserSendOnBehalf) on $($username)" -Sev 'Error' -tenant $TenantFilter - $results.add("Could not add $($UserSendOnBehalf) send on behalf permissions for $($username). Error: $($_.Exception.Message)") - } - } - - $RemoveSendOnBehalf = ($Request.body.RemoveSendOnBehalf).value - - foreach ($UserSendOnBehalf in $RemoveSendOnBehalf) { - try { - $MailboxPerms = New-ExoRequest -Anchor $username -tenantid $Tenantfilter -cmdlet 'Set-Mailbox' -cmdParams @{Identity = $userid; GrantSendonBehalfTo = @{'@odata.type' = '#Exchange.GenericHashTable'; remove = $UserSendOnBehalf }; } - $results.add( "Removed $UserSendOnBehalf from $($username) Send on Behalf Permissions") - Write-LogMessage -headers $Request.Headers -API $APINAME-message "Removed $UserSendOnBehalf from $($username) Send on Behalf Permissions" -Sev 'Info' -tenant $TenantFilter - } catch { - Write-LogMessage -headers $Request.Headers -API $APINAME-message "Could not Remove send on behalf permissions for $($UserSendOnBehalf) on $($username)" -Sev 'Error' -tenant $TenantFilter - $results.add("Could not remove $($UserSendOnBehalf) send on behalf permissions for $($username). Error: $($_.Exception.Message)") + # Each request-body bucket maps to a (PermissionLevel, Action) pair. Delegate to + # Set-CIPPMailboxPermission so the EXO cmdlet mapping, logging, cache sync, and error handling + # all live in one place. The mailbox UPN is passed straight through as the identity - EXO accepts + # it, so no Graph id lookup is required. + $PermissionBuckets = @( + @{ Bucket = 'RemoveFullAccess'; PermissionLevel = 'FullAccess'; Action = 'Remove'; AutoMap = $true } + @{ Bucket = 'AddFullAccess'; PermissionLevel = 'FullAccess'; Action = 'Add'; AutoMap = $true } + @{ Bucket = 'AddFullAccessNoAutoMap'; PermissionLevel = 'FullAccess'; Action = 'Add'; AutoMap = $false } + @{ Bucket = 'AddSendAs'; PermissionLevel = 'SendAs'; Action = 'Add'; AutoMap = $true } + @{ Bucket = 'RemoveSendAs'; PermissionLevel = 'SendAs'; Action = 'Remove'; AutoMap = $true } + @{ Bucket = 'AddSendOnBehalf'; PermissionLevel = 'SendOnBehalf'; Action = 'Add'; AutoMap = $true } + @{ Bucket = 'RemoveSendOnBehalf'; PermissionLevel = 'SendOnBehalf'; Action = 'Remove'; AutoMap = $true } + ) + + foreach ($Bucket in $PermissionBuckets) { + foreach ($AccessUser in ($Request.body.($Bucket.Bucket)).value) { + $null = $Results.Add( + (Set-CIPPMailboxPermission -UserId $Username -AccessUser $AccessUser -PermissionLevel $Bucket.PermissionLevel -Action $Bucket.Action -AutoMap $Bucket.AutoMap -TenantFilter $Tenantfilter -APIName $APIName -Headers $Headers) + ) } } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ExecModifyMBPerms.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ExecModifyMBPerms.ps1 index a97e3a4148255..3f92aa8715991 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ExecModifyMBPerms.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ExecModifyMBPerms.ps1 @@ -1,4 +1,4 @@ -Function Invoke-ExecModifyMBPerms { +function Invoke-ExecModifyMBPerms { <# .FUNCTIONALITY Entrypoint @@ -9,41 +9,42 @@ Function Invoke-ExecModifyMBPerms { param($Request, $TriggerMetadata) $APIName = $Request.Params.CIPPEndpoint - Write-LogMessage -headers $Request.Headers -API $APINAME -message 'Accessed this API' -Sev 'Debug' + $Headers = $Request.Headers + Write-LogMessage -headers $Headers -API $APIName -message 'Accessed this API' -Sev 'Debug' # Extract mailbox requests - handle all three formats $MailboxRequests = $null $Results = [System.Collections.ArrayList]::new() # Direct array format - if ($request.body -is [array]) { - $MailboxRequests = $request.body + if ($Request.Body -is [array]) { + $MailboxRequests = $Request.Body } # Bulk format with mailboxRequests property - elseif ($request.body.mailboxRequests) { - $MailboxRequests = $request.body.mailboxRequests + elseif ($Request.Body.mailboxRequests) { + $MailboxRequests = $Request.Body.mailboxRequests } # Legacy single mailbox format - elseif ($request.body.userID -and $request.body.permissions) { + elseif ($Request.Body.userID -and $Request.Body.permissions) { $MailboxRequests = @([PSCustomObject]@{ - userID = $request.body.userID - tenantFilter = $request.body.tenantFilter - permissions = $request.body.permissions - }) + userID = $Request.Body.userID + tenantFilter = $Request.Body.tenantFilter + permissions = $Request.Body.permissions + }) } if (-not $MailboxRequests -or $MailboxRequests.Count -eq 0) { - Write-LogMessage -headers $Request.Headers -API $APINAME -message 'No mailbox requests provided' -Sev 'Error' - $body = [pscustomobject]@{'Results' = @("No mailbox requests provided") } + Write-LogMessage -headers $Headers -API $APIName -message 'No mailbox requests provided' -Sev 'Error' + $body = [pscustomobject]@{'Results' = @('No mailbox requests provided') } return ([HttpResponseContext]@{ - StatusCode = [HttpStatusCode]::BadRequest - Body = $Body - }) + StatusCode = [HttpStatusCode]::BadRequest + Body = $Body + }) return } - $TenantFilter = $Request.body.tenantFilter - Write-LogMessage -headers $Request.Headers -API $APINAME -message "Processing permission changes for $($MailboxRequests.Count) mailboxes" -Sev 'Info' -tenant $TenantFilter + $TenantFilter = $Request.Body.tenantFilter + Write-LogMessage -headers $Headers -API $APIName -message "Processing permission changes for $($MailboxRequests.Count) mailboxes" -Sev 'Info' -tenant $TenantFilter # Build cmdlet array for processing $CmdletArray = [System.Collections.ArrayList]::new() @@ -51,12 +52,16 @@ Function Invoke-ExecModifyMBPerms { $GuidToMetadataMap = @{} # Map GUIDs to our metadata $UserLookupCache = @{} + # Permission levels Set-CIPPMailboxPermission understands (its ValidateSet). Levels outside this + # set are silently skipped, matching the behaviour of the inline switch this used to carry. + $SupportedPermissionLevels = @('FullAccess', 'SendAs', 'SendOnBehalf', 'ReadPermission', 'ExternalAccount', 'DeleteItem', 'ChangePermission', 'ChangeOwner') + foreach ($MailboxRequest in $MailboxRequests) { $Username = $MailboxRequest.userID $Permissions = $MailboxRequest.permissions if ([string]::IsNullOrEmpty($Username)) { - $null = $Results.Add("Skipped mailbox with missing userID") + $null = $Results.Add('Skipped mailbox with missing userID') continue } @@ -65,18 +70,16 @@ Function Invoke-ExecModifyMBPerms { try { $UserObject = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/users/$($Username)" -tenantid $TenantFilter $UserLookupCache[$Username] = $UserObject.userPrincipalName - } - catch { + } catch { try { $UserObject = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/users?`$filter=userPrincipalName eq '$Username'" -tenantid $TenantFilter if ($UserObject.value -and $UserObject.value.Count -gt 0) { $UserLookupCache[$Username] = $UserObject.value[0].userPrincipalName } else { - throw "User not found" + throw 'User not found' } - } - catch { - Write-LogMessage -headers $Request.Headers -API $APINAME -message "Could not find user $($Username)" -Sev 'Error' -tenant $TenantFilter + } catch { + Write-LogMessage -headers $Headers -API $APIName -message "Could not find user $($Username)" -Sev 'Error' -tenant $TenantFilter $null = $Results.Add("Could not find user $($Username)") continue } @@ -99,7 +102,7 @@ Function Invoke-ExecModifyMBPerms { $AutoMap = if ($Permission.PSObject.Properties.Name -contains 'AutoMap') { $Permission.AutoMap } else { $true } # Handle multiple permission levels - $PermissionLevelArray = if ($PermissionLevels -like "*,*") { + $PermissionLevelArray = if ($PermissionLevels -like '*,*') { $PermissionLevels -split ',' | ForEach-Object { $_.Trim() } } else { @($PermissionLevels.Trim()) @@ -125,160 +128,35 @@ Function Invoke-ExecModifyMBPerms { foreach ($TargetUser in $TargetUsers) { foreach ($PermissionLevel in $PermissionLevelArray) { - # Create cmdlet parameters based on permission type and action - $CmdletParams = @{} - $CmdletName = "" - $ExpectedResult = "" + # Build the EXO cmdlet for this change via Set-CIPPMailboxPermission's + # -AsCmdletObject mode - the single source of truth for the permission-level -> + # cmdlet/parameter mapping. It returns @{ CmdletName; Parameters; ExpectedResult } + # for supported combinations, or a plain string for unsupported ones (e.g. an Add + # on a remove-only level), which we skip. The bulk machinery below is unchanged. + if ($PermissionLevel -notin $SupportedPermissionLevels) { continue } + $Action = if ($Modification -eq 'Remove') { 'Remove' } else { 'Add' } - switch ($PermissionLevel) { - 'FullAccess' { - if ($Modification -eq 'Remove') { - $CmdletName = 'Remove-MailboxPermission' - $CmdletParams = @{ - Identity = $UserId - user = $TargetUser - accessRights = @('FullAccess') - Confirm = $false - } - $ExpectedResult = "Removed $($TargetUser) from $($Username) FullAccess permissions" - } else { - $CmdletName = 'Add-MailboxPermission' - $CmdletParams = @{ - Identity = $UserId - user = $TargetUser - accessRights = @('FullAccess') - automapping = $AutoMap - Confirm = $false - } - $ExpectedResult = "Granted $($TargetUser) FullAccess to $($Username) with automapping $($AutoMap)" - } - } - 'SendAs' { - if ($Modification -eq 'Remove') { - $CmdletName = 'Remove-RecipientPermission' - $CmdletParams = @{ - Identity = $UserId - Trustee = $TargetUser - accessRights = @('SendAs') - Confirm = $false - } - $ExpectedResult = "Removed $($TargetUser) SendAs permissions from $($Username)" - } else { - $CmdletName = 'Add-RecipientPermission' - $CmdletParams = @{ - Identity = $UserId - Trustee = $TargetUser - accessRights = @('SendAs') - Confirm = $false - } - $ExpectedResult = "Granted $($TargetUser) SendAs permissions to $($Username)" - } - } - 'SendOnBehalf' { - $CmdletName = 'Set-Mailbox' - if ($Modification -eq 'Remove') { - $CmdletParams = @{ - Identity = $UserId - GrantSendonBehalfTo = @{ - '@odata.type' = '#Exchange.GenericHashTable' - remove = $TargetUser - } - Confirm = $false - } - $ExpectedResult = "Removed $($TargetUser) SendOnBehalf permissions from $($Username)" - } else { - $CmdletParams = @{ - Identity = $UserId - GrantSendonBehalfTo = @{ - '@odata.type' = '#Exchange.GenericHashTable' - add = $TargetUser - } - Confirm = $false - } - $ExpectedResult = "Granted $($TargetUser) SendOnBehalf permissions to $($Username)" - } - } - 'ReadPermission' { - if ($Modification -eq 'Remove') { - $CmdletName = 'Remove-MailboxPermission' - $CmdletParams = @{ - Identity = $UserId - user = $TargetUser - accessRights = @('ReadPermission') - Confirm = $false - } - $ExpectedResult = "Removed $($TargetUser) ReadPermission from $($Username)" - } - } - 'ExternalAccount' { - if ($Modification -eq 'Remove') { - $CmdletName = 'Remove-MailboxPermission' - $CmdletParams = @{ - Identity = $UserId - user = $TargetUser - accessRights = @('ExternalAccount') - Confirm = $false - } - $ExpectedResult = "Removed $($TargetUser) ExternalAccount permissions from $($Username)" - } - } - 'DeleteItem' { - if ($Modification -eq 'Remove') { - $CmdletName = 'Remove-MailboxPermission' - $CmdletParams = @{ - Identity = $UserId - user = $TargetUser - accessRights = @('DeleteItem') - Confirm = $false - } - $ExpectedResult = "Removed $($TargetUser) DeleteItem permissions from $($Username)" - } - } - 'ChangePermission' { - if ($Modification -eq 'Remove') { - $CmdletName = 'Remove-MailboxPermission' - $CmdletParams = @{ - Identity = $UserId - user = $TargetUser - accessRights = @('ChangePermission') - Confirm = $false - } - $ExpectedResult = "Removed $($TargetUser) ChangePermission from $($Username)" - } - } - 'ChangeOwner' { - if ($Modification -eq 'Remove') { - $CmdletName = 'Remove-MailboxPermission' - $CmdletParams = @{ - Identity = $UserId - user = $TargetUser - accessRights = @('ChangeOwner') - Confirm = $false - } - $ExpectedResult = "Removed $($TargetUser) ChangeOwner permissions from $($Username)" - } - } - } + $Mapping = Set-CIPPMailboxPermission -UserId $UserId -AccessUser $TargetUser -PermissionLevel $PermissionLevel -Action $Action -AutoMap $AutoMap -TenantFilter $TenantFilter -AsCmdletObject - if ($CmdletName) { + if ($Mapping -is [hashtable] -and $Mapping.CmdletName) { # Generate unique GUID for this operation $OperationGuid = [Guid]::NewGuid().ToString() $CmdletObj = @{ - CmdletInput = @{ - CmdletName = $CmdletName - Parameters = $CmdletParams + CmdletInput = @{ + CmdletName = $Mapping.CmdletName + Parameters = $Mapping.Parameters } OperationGuid = $OperationGuid # Add GUID to cmdlet object } $CmdletMetadata = [PSCustomObject]@{ - ExpectedResult = $ExpectedResult - Mailbox = $Username - TargetUser = $TargetUser - Permission = $PermissionLevel - Action = $Modification - OperationGuid = $OperationGuid + ExpectedResult = $Mapping.ExpectedResult + Mailbox = $Username + TargetUser = $TargetUser + Permission = $PermissionLevel + Action = $Modification + OperationGuid = $OperationGuid } $null = $CmdletArray.Add($CmdletObj) @@ -293,12 +171,12 @@ Function Invoke-ExecModifyMBPerms { } if ($CmdletArray.Count -eq 0) { - Write-LogMessage -headers $Request.Headers -API $APINAME -message 'No valid cmdlets to process' -sev 'Warning' -tenant $TenantFilter - $body = [pscustomobject]@{'Results' = @("No valid permission changes to process") } + Write-LogMessage -headers $Headers -API $APIName -message 'No valid cmdlets to process' -sev 'Warning' -tenant $TenantFilter + $body = [pscustomobject]@{'Results' = @('No valid permission changes to process') } return ([HttpResponseContext]@{ - StatusCode = [HttpStatusCode]::OK - Body = $Body - }) + StatusCode = [HttpStatusCode]::OK + Body = $Body + }) return } @@ -306,7 +184,7 @@ Function Invoke-ExecModifyMBPerms { if ($CmdletArray.Count -gt 1) { # Use bulk processing with GUID tracking try { - Write-LogMessage -headers $Request.Headers -API $APINAME -message "Executing bulk request with $($CmdletArray.Count) cmdlets" -Sev 'Info' -tenant $TenantFilter + Write-LogMessage -headers $Headers -API $APIName -message "Executing bulk request with $($CmdletArray.Count) cmdlets" -Sev 'Info' -tenant $TenantFilter $BulkResults = New-ExoBulkRequest -tenantid $TenantFilter -cmdletArray @($CmdletArray) -ReturnWithCommand $true # Process bulk results using GUID mapping @@ -321,13 +199,13 @@ Function Invoke-ExecModifyMBPerms { if ($result.error) { $ErrorMessage = try { (Get-CippException -Exception $result.error).NormalizedError } catch { $result.error } $null = $Results.Add("Error processing $($metadata.Permission) for $($metadata.TargetUser) on $($metadata.Mailbox): $ErrorMessage") - Write-LogMessage -headers $Request.Headers -API $APINAME -message "Error for operation $operationGuid`: $ErrorMessage" -Sev 'Error' -tenant $TenantFilter + Write-LogMessage -headers $Headers -API $APIName -message "Error for operation $operationGuid`: $ErrorMessage" -Sev 'Error' -tenant $TenantFilter } else { $null = $Results.Add($metadata.ExpectedResult) - Write-LogMessage -headers $Request.Headers -API $APINAME -message "Success for operation $operationGuid`: $($metadata.ExpectedResult)" -Sev 'Info' -tenant $TenantFilter + Write-LogMessage -headers $Headers -API $APIName -message "Success for operation $operationGuid`: $($metadata.ExpectedResult)" -Sev 'Info' -tenant $TenantFilter } } else { - Write-LogMessage -headers $Request.Headers -API $APINAME -message "Could not map result to operation. GUID: $operationGuid, Available GUIDs: $($GuidToMetadataMap.Keys -join ', ')" -sev 'Warning' -tenant $TenantFilter + Write-LogMessage -headers $Headers -API $APIName -message "Could not map result to operation. GUID: $operationGuid, Available GUIDs: $($GuidToMetadataMap.Keys -join ', ')" -sev 'Warning' -tenant $TenantFilter # Fallback for unmapped results if ($result.error) { @@ -348,10 +226,9 @@ Function Invoke-ExecModifyMBPerms { } } - Write-LogMessage -headers $Request.Headers -API $APINAME -message "Bulk request completed successfully" -Sev 'Info' -tenant $TenantFilter - } - catch { - Write-LogMessage -headers $Request.Headers -API $APINAME -message "Bulk request failed, using fallback: $($_.Exception.Message)" -Sev 'Error' -tenant $TenantFilter + Write-LogMessage -headers $Headers -API $APIName -message 'Bulk request completed successfully' -Sev 'Info' -tenant $TenantFilter + } catch { + Write-LogMessage -headers $Headers -API $APIName -message "Bulk request failed, using fallback: $($_.Exception.Message)" -Sev 'Error' -tenant $TenantFilter # Fallback to individual processing for ($i = 0; $i -lt $CmdletArray.Count; $i++) { @@ -360,31 +237,28 @@ Function Invoke-ExecModifyMBPerms { try { $null = New-ExoRequest -Anchor $CmdletMetadata.Mailbox -tenantid $TenantFilter -cmdlet $CmdletObj.CmdletInput.CmdletName -cmdParams $CmdletObj.CmdletInput.Parameters $null = $Results.Add($CmdletMetadata.ExpectedResult) - } - catch { + } catch { $null = $Results.Add("Error processing $($CmdletMetadata.Permission) for $($CmdletMetadata.TargetUser) on $($CmdletMetadata.Mailbox): $($_.Exception.Message)") } } } - } - else { + } else { # Use individual processing for single operation $CmdletObj = $CmdletArray[0] $CmdletMetadata = $CmdletMetadataArray[0] try { $null = New-ExoRequest -Anchor $CmdletMetadata.Mailbox -tenantid $TenantFilter -cmdlet $CmdletObj.CmdletInput.CmdletName -cmdParams $CmdletObj.CmdletInput.Parameters $null = $Results.Add($CmdletMetadata.ExpectedResult) - Write-LogMessage -headers $Request.Headers -API $APINAME -message "Executed $($CmdletMetadata.Permission) permission modification" -Sev 'Info' -tenant $TenantFilter - } - catch { - Write-LogMessage -headers $Request.Headers -API $APINAME -message "Permission modification failed: $($_.Exception.Message)" -Sev 'Error' -tenant $TenantFilter + Write-LogMessage -headers $Headers -API $APIName -message "Executed $($CmdletMetadata.Permission) permission modification" -Sev 'Info' -tenant $TenantFilter + } catch { + Write-LogMessage -headers $Headers -API $APIName -message "Permission modification failed: $($_.Exception.Message)" -Sev 'Error' -tenant $TenantFilter $null = $Results.Add("Error processing $($CmdletMetadata.Permission) for $($CmdletMetadata.TargetUser) on $($CmdletMetadata.Mailbox): $($_.Exception.Message)") } } $body = [pscustomobject]@{'Results' = @($Results) } return ([HttpResponseContext]@{ - StatusCode = [HttpStatusCode]::OK - Body = $Body - }) + StatusCode = [HttpStatusCode]::OK + Body = $Body + }) } diff --git a/Tests/Endpoint/Invoke-ExecEditMailboxPermissions.Tests.ps1 b/Tests/Endpoint/Invoke-ExecEditMailboxPermissions.Tests.ps1 new file mode 100644 index 0000000000000..f223485d6c55e --- /dev/null +++ b/Tests/Endpoint/Invoke-ExecEditMailboxPermissions.Tests.ps1 @@ -0,0 +1,127 @@ +# Pester tests for Invoke-ExecEditMailboxPermissions +# The endpoint now delegates every request bucket (RemoveFullAccess / AddFullAccess / +# AddFullAccessNoAutoMap / AddSendAs / RemoveSendAs / AddSendOnBehalf / RemoveSendOnBehalf) to +# Set-CIPPMailboxPermission, so these tests assert each bucket maps to the right +# PermissionLevel / Action / AutoMap. The EXO cmdlet mapping, logging, and cache sync are the +# delegate's responsibility and are covered by Set-CIPPMailboxPermission.Tests.ps1. +# +# NOTE: the early `if ($username -eq $null) { exit }` guard is deliberately NOT exercised - `exit` +# inside a dot-sourced function terminates the Pester runspace, so it cannot be tested in-process. +# Every test below supplies a userID. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Invoke-ExecEditMailboxPermissions.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $FunctionPath) { throw 'Could not locate Invoke-ExecEditMailboxPermissions.ps1 under Modules/' } + + class HttpResponseContext { + [int]$StatusCode + [object]$Body + } + + # The function uses the short [HttpStatusCode] (the Functions host supplies `using namespace + # System.Net`). Register a type accelerator so it resolves when the function is dot-sourced here. + $TypeAccelerators = [PowerShell].Assembly.GetType('System.Management.Automation.TypeAccelerators') + if (-not ([System.Management.Automation.PSTypeName]'HttpStatusCode').Type) { + $TypeAccelerators::Add('HttpStatusCode', [System.Net.HttpStatusCode]) + } + + function Set-CIPPMailboxPermission { param($UserId, $AccessUser, $PermissionLevel, $Action, $AutoMap, $TenantFilter, $APIName, $Headers) } + function Write-LogMessage { param($headers, $API, $tenant, $message, $Sev, $LogData) } + + . $FunctionPath + + # Build a request whose body carries a single bucket of delegates (mirrors the frontend's + # { value = @(...) } shape that the function reads via ($Request.body.).value). + function New-EditRequest { + param([string]$Bucket, [string[]]$Users, [string]$UserID = 'shared@contoso.com') + $body = [pscustomobject]@{ userID = $UserID; tenantfilter = 'contoso.com' } + $body | Add-Member -NotePropertyName $Bucket -NotePropertyValue ([pscustomobject]@{ value = $Users }) + [pscustomobject]@{ + Params = @{ CIPPEndpoint = 'ExecEditMailboxPermissions' } + Headers = @{ Authorization = 'token' } + Body = $body + } + } +} + +Describe 'Invoke-ExecEditMailboxPermissions' { + BeforeEach { + Mock -CommandName Set-CIPPMailboxPermission -MockWith { "$Action $PermissionLevel for $AccessUser" } + Mock -CommandName Write-LogMessage -MockWith { } + } + + It 'returns OK and passes the mailbox UPN through as the identity' { + $response = Invoke-ExecEditMailboxPermissions -Request (New-EditRequest -Bucket 'AddFullAccess' -Users @('user@contoso.com')) -TriggerMetadata $null + + $response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK) + Should -Invoke Set-CIPPMailboxPermission -Times 1 -Exactly -ParameterFilter { $UserId -eq 'shared@contoso.com' -and $TenantFilter -eq 'contoso.com' } + } + + It 'RemoveFullAccess -> FullAccess / Remove' { + Invoke-ExecEditMailboxPermissions -Request (New-EditRequest -Bucket 'RemoveFullAccess' -Users @('user@contoso.com')) -TriggerMetadata $null + + Should -Invoke Set-CIPPMailboxPermission -Times 1 -Exactly -ParameterFilter { + $PermissionLevel -eq 'FullAccess' -and $Action -eq 'Remove' -and $AccessUser -eq 'user@contoso.com' + } + } + + It 'AddFullAccess -> FullAccess / Add with AutoMap $true' { + Invoke-ExecEditMailboxPermissions -Request (New-EditRequest -Bucket 'AddFullAccess' -Users @('user@contoso.com')) -TriggerMetadata $null + + Should -Invoke Set-CIPPMailboxPermission -Times 1 -Exactly -ParameterFilter { + $PermissionLevel -eq 'FullAccess' -and $Action -eq 'Add' -and $AutoMap -eq $true + } + } + + It 'AddFullAccessNoAutoMap -> FullAccess / Add with AutoMap $false' { + Invoke-ExecEditMailboxPermissions -Request (New-EditRequest -Bucket 'AddFullAccessNoAutoMap' -Users @('user@contoso.com')) -TriggerMetadata $null + + Should -Invoke Set-CIPPMailboxPermission -Times 1 -Exactly -ParameterFilter { + $PermissionLevel -eq 'FullAccess' -and $Action -eq 'Add' -and $AutoMap -eq $false + } + } + + It 'AddSendAs -> SendAs / Add' { + Invoke-ExecEditMailboxPermissions -Request (New-EditRequest -Bucket 'AddSendAs' -Users @('user@contoso.com')) -TriggerMetadata $null + + Should -Invoke Set-CIPPMailboxPermission -Times 1 -Exactly -ParameterFilter { $PermissionLevel -eq 'SendAs' -and $Action -eq 'Add' } + } + + It 'RemoveSendAs -> SendAs / Remove' { + Invoke-ExecEditMailboxPermissions -Request (New-EditRequest -Bucket 'RemoveSendAs' -Users @('user@contoso.com')) -TriggerMetadata $null + + Should -Invoke Set-CIPPMailboxPermission -Times 1 -Exactly -ParameterFilter { $PermissionLevel -eq 'SendAs' -and $Action -eq 'Remove' } + } + + It 'AddSendOnBehalf -> SendOnBehalf / Add' { + Invoke-ExecEditMailboxPermissions -Request (New-EditRequest -Bucket 'AddSendOnBehalf' -Users @('user@contoso.com')) -TriggerMetadata $null + + Should -Invoke Set-CIPPMailboxPermission -Times 1 -Exactly -ParameterFilter { $PermissionLevel -eq 'SendOnBehalf' -and $Action -eq 'Add' } + } + + It 'RemoveSendOnBehalf -> SendOnBehalf / Remove' { + Invoke-ExecEditMailboxPermissions -Request (New-EditRequest -Bucket 'RemoveSendOnBehalf' -Users @('user@contoso.com')) -TriggerMetadata $null + + Should -Invoke Set-CIPPMailboxPermission -Times 1 -Exactly -ParameterFilter { $PermissionLevel -eq 'SendOnBehalf' -and $Action -eq 'Remove' } + } + + It 'processes every user in a multi-user bucket and collects their results' { + $response = Invoke-ExecEditMailboxPermissions -Request (New-EditRequest -Bucket 'AddSendAs' -Users @('a@contoso.com', 'b@contoso.com')) -TriggerMetadata $null + + Should -Invoke Set-CIPPMailboxPermission -Times 2 -Exactly -ParameterFilter { $PermissionLevel -eq 'SendAs' -and $Action -eq 'Add' } + $response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK) + $response.Body.Results | Should -Contain 'Add SendAs for a@contoso.com' + $response.Body.Results | Should -Contain 'Add SendAs for b@contoso.com' + } + + It 'surfaces the delegate failure string in Results and still returns OK' { + Mock -CommandName Set-CIPPMailboxPermission -MockWith { 'Failed to Add FullAccess for user@contoso.com on shared@contoso.com: boom' } + + $response = Invoke-ExecEditMailboxPermissions -Request (New-EditRequest -Bucket 'AddFullAccess' -Users @('user@contoso.com')) -TriggerMetadata $null + + $response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK) + ($response.Body.Results -join "`n") | Should -Match 'Failed to Add FullAccess for user@contoso.com on shared@contoso.com: boom' + } +} diff --git a/Tests/Endpoint/Invoke-ExecModifyMBPerms.Tests.ps1 b/Tests/Endpoint/Invoke-ExecModifyMBPerms.Tests.ps1 new file mode 100644 index 0000000000000..1bb74948d83a5 --- /dev/null +++ b/Tests/Endpoint/Invoke-ExecModifyMBPerms.Tests.ps1 @@ -0,0 +1,278 @@ +# Pester tests for Invoke-ExecModifyMBPerms +# The endpoint delegates the permission-level -> EXO cmdlet/parameter mapping to +# Set-CIPPMailboxPermission (dot-sourced below and called in -AsCmdletObject mode), so these tests +# focus on what the endpoint owns: the single-operation vs bulk (New-ExoBulkRequest + GUID mapping) +# execution paths, the bulk-failure fallback, the three accepted input shapes, the user-lookup +# fallback, and the request guards. The mapping itself is covered by Set-CIPPMailboxPermission.Tests.ps1. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Invoke-ExecModifyMBPerms.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $FunctionPath) { throw 'Could not locate Invoke-ExecModifyMBPerms.ps1 under Modules/' } + + # Dot-source the REAL Set-CIPPMailboxPermission so -AsCmdletObject produces real mappings - the + # endpoint now delegates the level -> cmdlet mapping to it. Its -AsCmdletObject path only builds a + # hashtable and returns early, so none of the EXO / log stubs below are exercised by it. + $PermissionFunctionPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Set-CIPPMailboxPermission.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $PermissionFunctionPath) { throw 'Could not locate Set-CIPPMailboxPermission.ps1 under Modules/' } + + class HttpResponseContext { + [int]$StatusCode + [object]$Body + } + + # The function uses the short [HttpStatusCode]; register a type accelerator so it resolves here. + $TypeAccelerators = [PowerShell].Assembly.GetType('System.Management.Automation.TypeAccelerators') + if (-not ([System.Management.Automation.PSTypeName]'HttpStatusCode').Type) { + $TypeAccelerators::Add('HttpStatusCode', [System.Net.HttpStatusCode]) + } + + function New-GraphGetRequest { param($uri, $tenantid) } + function New-ExoRequest { param($Anchor, $tenantid, $cmdlet, $cmdParams) } + function New-ExoBulkRequest { param($tenantid, $cmdletArray, $ReturnWithCommand) } + function Get-CippException { param($Exception) } + function Write-LogMessage { param($headers, $API, $tenant, $message, $Sev, $LogData) } + + . $PermissionFunctionPath + . $FunctionPath + + # Build a request in the bulk 'mailboxRequests' shape. + function New-ModifyRequest { + param($Mailboxes) + [pscustomobject]@{ + Params = @{ CIPPEndpoint = 'ExecModifyMBPerms' } + Headers = @{ Authorization = 'token' } + Body = [pscustomobject]@{ tenantFilter = 'contoso.com'; mailboxRequests = $Mailboxes } + } + } + + # Build a single mailbox request object with one permission. + function New-Perm { + param($Level, $Modification, $TargetUser = 'user@contoso.com', $AutoMap = $true) + [pscustomobject]@{ + PermissionLevel = $Level + Modification = $Modification + AutoMap = $AutoMap + UserID = @([pscustomobject]@{ value = $TargetUser }) + } + } + function New-Mailbox { + param($UserID = 'shared@contoso.com', $Permissions) + [pscustomobject]@{ userID = $UserID; permissions = $Permissions } + } +} + +Describe 'Invoke-ExecModifyMBPerms' { + BeforeEach { + Mock -CommandName New-GraphGetRequest -MockWith { [pscustomobject]@{ userPrincipalName = 'shared@contoso.com' } } + Mock -CommandName New-ExoRequest -MockWith { } + Mock -CommandName New-ExoBulkRequest -MockWith { + param($tenantid, $cmdletArray, $ReturnWithCommand) + # Echo each operation back as a success keyed by cmdlet name (GUID round-trips so the + # function can map results to its metadata). + $h = @{} + foreach ($c in $cmdletArray) { + $name = $c.CmdletInput.CmdletName + if (-not $h.ContainsKey($name)) { $h[$name] = @() } + $h[$name] += [pscustomobject]@{ OperationGuid = $c.OperationGuid } + } + $h + } + Mock -CommandName Get-CippException -MockWith { [pscustomobject]@{ NormalizedError = 'boom' } } + Mock -CommandName Write-LogMessage -MockWith { } + } + + Context 'Single-operation execution and per-level mapping' { + It 'FullAccess Add -> individual New-ExoRequest with Add-MailboxPermission' { + $req = New-ModifyRequest -Mailboxes @(New-Mailbox -Permissions @(New-Perm -Level 'FullAccess' -Modification 'Add')) + + $response = Invoke-ExecModifyMBPerms -Request $req -TriggerMetadata $null + + $response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK) + Should -Invoke New-ExoRequest -Times 1 -Exactly -ParameterFilter { + $cmdlet -eq 'Add-MailboxPermission' -and $cmdParams.user -eq 'user@contoso.com' -and + $cmdParams.Identity -eq 'shared@contoso.com' -and $cmdParams.automapping -eq $true + } + Should -Invoke New-ExoBulkRequest -Times 0 -Exactly + $response.Body.Results | Should -Contain 'Granted user@contoso.com FullAccess to shared@contoso.com with automapping True' + } + + It 'FullAccess Remove -> Remove-MailboxPermission' { + $req = New-ModifyRequest -Mailboxes @(New-Mailbox -Permissions @(New-Perm -Level 'FullAccess' -Modification 'Remove')) + $response = Invoke-ExecModifyMBPerms -Request $req -TriggerMetadata $null + Should -Invoke New-ExoRequest -Times 1 -Exactly -ParameterFilter { $cmdlet -eq 'Remove-MailboxPermission' } + $response.Body.Results | Should -Contain 'Removed user@contoso.com FullAccess from shared@contoso.com' + } + + It 'SendAs Add -> Add-RecipientPermission with Trustee' { + $req = New-ModifyRequest -Mailboxes @(New-Mailbox -Permissions @(New-Perm -Level 'SendAs' -Modification 'Add')) + Invoke-ExecModifyMBPerms -Request $req -TriggerMetadata $null + Should -Invoke New-ExoRequest -Times 1 -Exactly -ParameterFilter { $cmdlet -eq 'Add-RecipientPermission' -and $cmdParams.Trustee -eq 'user@contoso.com' } + } + + It 'SendOnBehalf Add -> Set-Mailbox with GrantSendonBehalfTo add hashtable' { + $req = New-ModifyRequest -Mailboxes @(New-Mailbox -Permissions @(New-Perm -Level 'SendOnBehalf' -Modification 'Add')) + Invoke-ExecModifyMBPerms -Request $req -TriggerMetadata $null + Should -Invoke New-ExoRequest -Times 1 -Exactly -ParameterFilter { + $cmdlet -eq 'Set-Mailbox' -and $cmdParams.GrantSendonBehalfTo.add -eq 'user@contoso.com' -and + $cmdParams.GrantSendonBehalfTo['@odata.type'] -eq '#Exchange.GenericHashTable' + } + } + + It 'SendOnBehalf Remove -> Set-Mailbox with GrantSendonBehalfTo remove hashtable' { + $req = New-ModifyRequest -Mailboxes @(New-Mailbox -Permissions @(New-Perm -Level 'SendOnBehalf' -Modification 'Remove')) + Invoke-ExecModifyMBPerms -Request $req -TriggerMetadata $null + Should -Invoke New-ExoRequest -Times 1 -Exactly -ParameterFilter { $cmdlet -eq 'Set-Mailbox' -and $cmdParams.GrantSendonBehalfTo.remove -eq 'user@contoso.com' } + } + + It 'default-level Remove () -> Remove-MailboxPermission with that access right' -ForEach @( + @{ Level = 'ReadPermission' } + @{ Level = 'ExternalAccount' } + @{ Level = 'DeleteItem' } + @{ Level = 'ChangePermission' } + @{ Level = 'ChangeOwner' } + ) { + $req = New-ModifyRequest -Mailboxes @(New-Mailbox -Permissions @(New-Perm -Level $Level -Modification 'Remove')) + Invoke-ExecModifyMBPerms -Request $req -TriggerMetadata $null + Should -Invoke New-ExoRequest -Times 1 -Exactly -ParameterFilter { $cmdlet -eq 'Remove-MailboxPermission' -and $cmdParams.accessRights -contains $Level } + } + + It 'default-level Add produces no cmdlet -> OK with a no-op message' { + $req = New-ModifyRequest -Mailboxes @(New-Mailbox -Permissions @(New-Perm -Level 'ReadPermission' -Modification 'Add')) + $response = Invoke-ExecModifyMBPerms -Request $req -TriggerMetadata $null + Should -Invoke New-ExoRequest -Times 0 -Exactly + $response.Body.Results | Should -Contain 'No valid permission changes to process' + } + } + + Context 'Bulk execution path' { + It 'two operations -> New-ExoBulkRequest with GUID-mapped results, no individual calls' { + $req = New-ModifyRequest -Mailboxes @(New-Mailbox -Permissions @( + (New-Perm -Level 'FullAccess' -Modification 'Add') + (New-Perm -Level 'SendAs' -Modification 'Add') + )) + + $response = Invoke-ExecModifyMBPerms -Request $req -TriggerMetadata $null + + Should -Invoke New-ExoBulkRequest -Times 1 -Exactly + Should -Invoke New-ExoRequest -Times 0 -Exactly + $response.Body.Results | Should -Contain 'Granted user@contoso.com FullAccess to shared@contoso.com with automapping True' + $response.Body.Results | Should -Contain 'Granted user@contoso.com SendAs permissions to shared@contoso.com' + } + + It 'maps a per-operation error from the bulk result to an error string' { + Mock -CommandName New-ExoBulkRequest -MockWith { + param($tenantid, $cmdletArray, $ReturnWithCommand) + $h = @{} + foreach ($c in $cmdletArray) { + $name = $c.CmdletInput.CmdletName + if (-not $h.ContainsKey($name)) { $h[$name] = @() } + $h[$name] += [pscustomobject]@{ OperationGuid = $c.OperationGuid; error = 'kaboom' } + } + $h + } + $req = New-ModifyRequest -Mailboxes @(New-Mailbox -Permissions @( + (New-Perm -Level 'FullAccess' -Modification 'Add') + (New-Perm -Level 'SendAs' -Modification 'Add') + )) + + $response = Invoke-ExecModifyMBPerms -Request $req -TriggerMetadata $null + + ($response.Body.Results -join "`n") | Should -Match 'Error processing FullAccess for user@contoso.com on shared@contoso.com: boom' + } + + It 'falls back to individual New-ExoRequest calls when the bulk request throws' { + Mock -CommandName New-ExoBulkRequest -MockWith { throw 'bulk endpoint down' } + $req = New-ModifyRequest -Mailboxes @(New-Mailbox -Permissions @( + (New-Perm -Level 'FullAccess' -Modification 'Add') + (New-Perm -Level 'SendAs' -Modification 'Add') + )) + + $response = Invoke-ExecModifyMBPerms -Request $req -TriggerMetadata $null + + Should -Invoke New-ExoRequest -Times 2 -Exactly + $response.Body.Results | Should -Contain 'Granted user@contoso.com FullAccess to shared@contoso.com with automapping True' + } + } + + Context 'Input shapes' { + It 'accepts the legacy single-mailbox format (userID + permissions on the body)' { + $req = [pscustomobject]@{ + Params = @{ CIPPEndpoint = 'ExecModifyMBPerms' } + Headers = @{ Authorization = 'token' } + Body = [pscustomobject]@{ + tenantFilter = 'contoso.com' + userID = 'shared@contoso.com' + permissions = @(New-Perm -Level 'FullAccess' -Modification 'Add') + } + } + + $response = Invoke-ExecModifyMBPerms -Request $req -TriggerMetadata $null + + Should -Invoke New-ExoRequest -Times 1 -Exactly -ParameterFilter { $cmdlet -eq 'Add-MailboxPermission' } + $response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK) + } + } + + Context 'User lookup' { + It 'falls back to a userPrincipalName filter query when the direct Graph lookup fails' { + Mock -CommandName New-GraphGetRequest -MockWith { [pscustomobject]@{ value = @([pscustomobject]@{ userPrincipalName = 'shared@contoso.com' }) } } -ParameterFilter { $uri -like '*filter*' } + Mock -CommandName New-GraphGetRequest -MockWith { throw 'direct lookup failed' } + + $req = New-ModifyRequest -Mailboxes @(New-Mailbox -Permissions @(New-Perm -Level 'FullAccess' -Modification 'Add')) + + $response = Invoke-ExecModifyMBPerms -Request $req -TriggerMetadata $null + + Should -Invoke New-GraphGetRequest -ParameterFilter { $uri -like '*filter*' } + Should -Invoke New-ExoRequest -Times 1 -Exactly -ParameterFilter { $cmdlet -eq 'Add-MailboxPermission' } + $response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK) + } + + It 'records a "could not find user" message when both lookups fail' { + # Graph fails for 'ghost' on both the direct and filter lookups; a second valid mailbox + # keeps CmdletArray non-empty so the specific message is not discarded by the + # "No valid permission changes" guard (which returns only a generic string). + Mock -CommandName New-GraphGetRequest -MockWith { throw 'not found' } -ParameterFilter { $uri -like '*ghost@contoso.com*' } + + $req = New-ModifyRequest -Mailboxes @( + (New-Mailbox -UserID 'ghost@contoso.com' -Permissions @(New-Perm -Level 'FullAccess' -Modification 'Add')) + (New-Mailbox -UserID 'valid@contoso.com' -Permissions @(New-Perm -Level 'FullAccess' -Modification 'Add')) + ) + + $response = Invoke-ExecModifyMBPerms -Request $req -TriggerMetadata $null + + ($response.Body.Results -join "`n") | Should -Match 'Could not find user ghost@contoso.com' + } + } + + Context 'Guards' { + It 'returns BadRequest when no mailbox requests are provided' { + $req = [pscustomobject]@{ + Params = @{ CIPPEndpoint = 'ExecModifyMBPerms' } + Headers = @{ Authorization = 'token' } + Body = [pscustomobject]@{ tenantFilter = 'contoso.com' } + } + + $response = Invoke-ExecModifyMBPerms -Request $req -TriggerMetadata $null + + $response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::BadRequest) + $response.Body.Results | Should -Contain 'No mailbox requests provided' + } + + It 'skips a mailbox request that is missing its userID' { + # A second valid mailbox keeps CmdletArray non-empty so the "Skipped" message survives + # (the empty-array guard would otherwise replace Results with a generic string). + $req = New-ModifyRequest -Mailboxes @( + (New-Mailbox -UserID '' -Permissions @(New-Perm -Level 'FullAccess' -Modification 'Add')) + (New-Mailbox -UserID 'valid@contoso.com' -Permissions @(New-Perm -Level 'FullAccess' -Modification 'Add')) + ) + + $response = Invoke-ExecModifyMBPerms -Request $req -TriggerMetadata $null + + Should -Invoke New-ExoRequest -Times 1 -Exactly + ($response.Body.Results -join "`n") | Should -Match 'Skipped mailbox with missing userID' + } + } +} diff --git a/Tests/Private/Set-CIPPMailboxAccess.Tests.ps1 b/Tests/Private/Set-CIPPMailboxAccess.Tests.ps1 new file mode 100644 index 0000000000000..d837ba181dabe --- /dev/null +++ b/Tests/Private/Set-CIPPMailboxAccess.Tests.ps1 @@ -0,0 +1,79 @@ +# Pester tests for Set-CIPPMailboxAccess +# Set-CIPPMailboxAccess now delegates each grant to Set-CIPPMailboxPermission (FullAccess / Add), so +# these tests cover the per-user fan-out, extraction of frontend objects with a .value property, +# AutoMap pass-through, and that one user's failure does not stop the rest (the delegate returns an +# error string rather than throwing). The EXO cmdlet mapping itself is covered by +# Set-CIPPMailboxPermission.Tests.ps1. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Set-CIPPMailboxAccess.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $FunctionPath) { throw 'Could not locate Set-CIPPMailboxAccess.ps1 under Modules/' } + + function Set-CIPPMailboxPermission { param($UserId, $AccessUser, $PermissionLevel, $Action, $AutoMap, $TenantFilter, $APIName, $Headers) } + + . $FunctionPath +} + +Describe 'Set-CIPPMailboxAccess' { + BeforeEach { + Mock -CommandName Set-CIPPMailboxPermission -MockWith { "Granted $AccessUser FullAccess to $UserId with automapping $AutoMap" } + } + + It 'delegates a single user to Set-CIPPMailboxPermission as a FullAccess Add' { + $result = Set-CIPPMailboxAccess -userid 'shared@contoso.com' -AccessUser 'user@contoso.com' ` + -Automap $true -TenantFilter 'contoso.com' -AccessRights @('FullAccess') + + Should -Invoke Set-CIPPMailboxPermission -Times 1 -Exactly -ParameterFilter { + $UserId -eq 'shared@contoso.com' -and + $AccessUser -eq 'user@contoso.com' -and + $PermissionLevel -eq 'FullAccess' -and + $Action -eq 'Add' -and + $AutoMap -eq $true -and + $TenantFilter -eq 'contoso.com' + } + $result | Should -Contain 'Granted user@contoso.com FullAccess to shared@contoso.com with automapping True' + } + + It 'processes an array of users, one delegate call per user' { + $result = Set-CIPPMailboxAccess -userid 'shared@contoso.com' -AccessUser @('a@contoso.com', 'b@contoso.com') ` + -Automap $true -TenantFilter 'contoso.com' -AccessRights @('FullAccess') + + Should -Invoke Set-CIPPMailboxPermission -Times 2 -Exactly + $result.Count | Should -Be 2 + } + + It 'extracts the .value property from frontend objects' { + $accessUsers = @([pscustomobject]@{ value = 'picked@contoso.com'; label = 'Picked User' }) + + Set-CIPPMailboxAccess -userid 'shared@contoso.com' -AccessUser $accessUsers ` + -Automap $true -TenantFilter 'contoso.com' -AccessRights @('FullAccess') + + Should -Invoke Set-CIPPMailboxPermission -Times 1 -Exactly -ParameterFilter { $AccessUser -eq 'picked@contoso.com' } + } + + It 'passes AutoMap through to the delegate when disabled' { + Set-CIPPMailboxAccess -userid 'shared@contoso.com' -AccessUser 'user@contoso.com' ` + -Automap $false -TenantFilter 'contoso.com' -AccessRights @('FullAccess') + + Should -Invoke Set-CIPPMailboxPermission -Times 1 -Exactly -ParameterFilter { $AutoMap -eq $false } + } + + It 'continues to the next user when one user returns a failure string' { + Mock -CommandName Set-CIPPMailboxPermission -MockWith { + if ($AccessUser -eq 'bad@contoso.com') { + 'Failed to Add FullAccess for bad@contoso.com on shared@contoso.com: boom' + } else { + "Granted $AccessUser FullAccess to shared@contoso.com with automapping True" + } + } + + $result = Set-CIPPMailboxAccess -userid 'shared@contoso.com' -AccessUser @('bad@contoso.com', 'good@contoso.com') ` + -Automap $true -TenantFilter 'contoso.com' -AccessRights @('FullAccess') + + Should -Invoke Set-CIPPMailboxPermission -Times 2 -Exactly + ($result -join "`n") | Should -Match 'Failed to Add FullAccess for bad@contoso.com on shared@contoso.com: boom' + ($result -join "`n") | Should -Match 'Granted good@contoso.com FullAccess to shared@contoso.com' + } +} From 877a4668df5d13e3aa5ffb8367b50c5688ebd5b4 Mon Sep 17 00:00:00 2001 From: Bobby <31723128+kris6673@users.noreply.github.com> Date: Wed, 1 Jul 2026 22:54:07 +0200 Subject: [PATCH 083/170] test: add mailbox permission coverage Pester coverage for Set-CIPPMailboxPermission, Set-CIPPMailboxVacation and Remove-CIPPMailboxPermissions: permission-level to EXO cmdlet mapping, cache sync, Add/Remove actions and error paths. Tests dot-source the function under test and stub CIPP helpers, per house style. --- .../Remove-CIPPMailboxPermissions.Tests.ps1 | 147 ++++++++++++++ .../Set-CIPPMailboxPermission.Tests.ps1 | 185 ++++++++++++++++++ .../Private/Set-CIPPMailboxVacation.Tests.ps1 | 132 +++++++++++++ 3 files changed, 464 insertions(+) create mode 100644 Tests/Private/Remove-CIPPMailboxPermissions.Tests.ps1 create mode 100644 Tests/Private/Set-CIPPMailboxPermission.Tests.ps1 create mode 100644 Tests/Private/Set-CIPPMailboxVacation.Tests.ps1 diff --git a/Tests/Private/Remove-CIPPMailboxPermissions.Tests.ps1 b/Tests/Private/Remove-CIPPMailboxPermissions.Tests.ps1 new file mode 100644 index 0000000000000..cb6a7508c0dfa --- /dev/null +++ b/Tests/Private/Remove-CIPPMailboxPermissions.Tests.ps1 @@ -0,0 +1,147 @@ +# Pester tests for Remove-CIPPMailboxPermissions +# Covers the per-level single-mailbox removal branches (SendOnBehalf -> Set-Mailbox + GrantSendonBehalfTo, +# SendAs -> Remove-RecipientPermission, FullAccess -> Remove-MailboxPermission), cache-sync gating, +# the "already removed" message selection, the -UseCache report-driven path, and the error path. +# +# NOTE: the 'AllUsers' branch uses ForEach-Object -Parallel, which runs each iteration in a separate +# runspace that re-imports the real CIPPCore/AzBobbyTables modules. Pester mocks live in the test +# runspace and cannot cross that boundary, so that branch is intentionally NOT unit-tested here. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Remove-CIPPMailboxPermissions.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $FunctionPath) { throw 'Could not locate Remove-CIPPMailboxPermissions.ps1 under Modules/' } + + function New-ExoRequest { param($Anchor, $tenantid, $cmdlet, $cmdParams, $Select) } + function Get-CippException { param($Exception) } + function Get-CIPPMailboxPermissionReport { param($TenantFilter, [switch]$ByUser) } + function Sync-CIPPMailboxPermissionCache { param($TenantFilter, $MailboxIdentity, $User, $PermissionType, $Action) } + function Write-LogMessage { param($headers, $API, $tenant, $message, $Sev, $LogData) } + + . $FunctionPath +} + +Describe 'Remove-CIPPMailboxPermissions' { + BeforeEach { + # A successful EXO removal returns a truthy response that does NOT contain an error substring. + # (The branches use `$result -notlike '*error*'`; note that with a $null response, + # $null -notlike '' is $false, which would route to the "already removed" message.) + Mock -CommandName New-ExoRequest -MockWith { 'OK' } + Mock -CommandName Get-CippException -MockWith { [pscustomobject]@{ NormalizedError = 'boom' } } + Mock -CommandName Get-CIPPMailboxPermissionReport -MockWith { } + Mock -CommandName Sync-CIPPMailboxPermissionCache -MockWith { } + Mock -CommandName Write-LogMessage -MockWith { } + } + + Context 'Single-mailbox removal branches' { + It 'removes SendOnBehalf via Set-Mailbox with a GrantSendonBehalfTo remove hashtable' { + $result = Remove-CIPPMailboxPermissions -userid 'shared@contoso.com' -AccessUser 'user@contoso.com' ` + -TenantFilter 'contoso.com' -PermissionsLevel @('SendOnBehalf') + + Should -Invoke New-ExoRequest -Times 1 -Exactly -ParameterFilter { + $cmdlet -eq 'Set-Mailbox' -and + $cmdParams.GrantSendonBehalfTo.remove -eq 'user@contoso.com' -and + $cmdParams.GrantSendonBehalfTo['@odata.type'] -eq '#Exchange.GenericHashTable' + } + $result | Should -Match "Removed SendOnBehalf permissions for user@contoso.com from shared@contoso.com's mailbox\." + } + + It 'removes SendAs via Remove-RecipientPermission and syncs the cache' { + $result = Remove-CIPPMailboxPermissions -userid 'shared@contoso.com' -AccessUser 'user@contoso.com' ` + -TenantFilter 'contoso.com' -PermissionsLevel @('SendAs') + + Should -Invoke New-ExoRequest -Times 1 -Exactly -ParameterFilter { + $cmdlet -eq 'Remove-RecipientPermission' -and $cmdParams.Trustee -eq 'user@contoso.com' + } + Should -Invoke Sync-CIPPMailboxPermissionCache -Times 1 -Exactly -ParameterFilter { + $PermissionType -eq 'SendAs' -and $Action -eq 'Remove' + } + $result | Should -Match "Removed SendAs permissions for user@contoso.com from shared@contoso.com's mailbox\." + } + + It 'reports SendAs as already-removed when EXO says the ACE is not present' { + Mock -CommandName New-ExoRequest -MockWith { "can't remove the ACL because the ACE isn't present" } + + $result = Remove-CIPPMailboxPermissions -userid 'shared@contoso.com' -AccessUser 'user@contoso.com' ` + -TenantFilter 'contoso.com' -PermissionsLevel @('SendAs') + + # cache is still synced regardless of whether the permission existed + Should -Invoke Sync-CIPPMailboxPermissionCache -Times 1 -Exactly -ParameterFilter { $PermissionType -eq 'SendAs' } + $result | Should -Match "were already removed or don't exist" + } + + It 'removes FullAccess via Remove-MailboxPermission and syncs the cache' { + $result = Remove-CIPPMailboxPermissions -userid 'shared@contoso.com' -AccessUser 'user@contoso.com' ` + -TenantFilter 'contoso.com' -PermissionsLevel @('FullAccess') + + Should -Invoke New-ExoRequest -Times 1 -Exactly -ParameterFilter { + $cmdlet -eq 'Remove-MailboxPermission' -and + $cmdParams.user -eq 'user@contoso.com' -and + $cmdParams.accessRights -contains 'FullAccess' + } + Should -Invoke Sync-CIPPMailboxPermissionCache -Times 1 -Exactly -ParameterFilter { $PermissionType -eq 'FullAccess' } + $result | Should -Match "Removed FullAccess permissions for user@contoso.com from shared@contoso.com's mailbox\." + } + + It 'reports FullAccess as already-removed when EXO says the ACE does not exist' { + Mock -CommandName New-ExoRequest -MockWith { "can't remove because the ACE doesn't exist on the object." } + + $result = Remove-CIPPMailboxPermissions -userid 'shared@contoso.com' -AccessUser 'user@contoso.com' ` + -TenantFilter 'contoso.com' -PermissionsLevel @('FullAccess') + + $result | Should -Match "were already removed or don't exist" + } + + It 'processes multiple permission levels in one call' { + $result = Remove-CIPPMailboxPermissions -userid 'shared@contoso.com' -AccessUser 'user@contoso.com' ` + -TenantFilter 'contoso.com' -PermissionsLevel @('FullAccess', 'SendAs', 'SendOnBehalf') + + Should -Invoke New-ExoRequest -Times 3 -Exactly + $result.Count | Should -Be 3 + } + } + + Context '-UseCache path' { + It 'removes every cached permission for the user via recursion and returns per-mailbox results' { + Mock -CommandName Get-CIPPMailboxPermissionReport -MockWith { + [pscustomobject]@{ + User = 'user@contoso.com' + MailboxCount = 2 + Permissions = @( + [pscustomobject]@{ MailboxUPN = 'sharedA@contoso.com'; AccessRights = 'FullAccess' } + [pscustomobject]@{ MailboxUPN = 'sharedB@contoso.com'; AccessRights = 'SendAs' } + ) + } + } + + $result = Remove-CIPPMailboxPermissions -AccessUser 'user@contoso.com' -TenantFilter 'contoso.com' -UseCache + + Should -Invoke Get-CIPPMailboxPermissionReport -Times 1 -Exactly + # one EXO call per recursive removal (FullAccess + SendAs) + Should -Invoke New-ExoRequest -Times 2 -Exactly + $result.Count | Should -Be 2 + } + + It 'returns an informational message when no cached permissions exist' { + Mock -CommandName Get-CIPPMailboxPermissionReport -MockWith { } + + $result = Remove-CIPPMailboxPermissions -AccessUser 'user@contoso.com' -TenantFilter 'contoso.com' -UseCache + + Should -Invoke New-ExoRequest -Times 0 -Exactly + $result | Should -Be 'No mailbox permissions found for user@contoso.com in cached data' + } + } + + Context 'Error path' { + It 'returns a failure string and logs an error when New-ExoRequest throws' { + Mock -CommandName New-ExoRequest -MockWith { throw 'EXO down' } + + $result = Remove-CIPPMailboxPermissions -userid 'shared@contoso.com' -AccessUser 'user@contoso.com' ` + -TenantFilter 'contoso.com' -PermissionsLevel @('FullAccess') + + $result | Should -Be 'Could not remove mailbox permissions for shared@contoso.com. Error: boom' + Should -Invoke Write-LogMessage -Times 1 -Exactly -ParameterFilter { $Sev -eq 'Error' } + } + } +} diff --git a/Tests/Private/Set-CIPPMailboxPermission.Tests.ps1 b/Tests/Private/Set-CIPPMailboxPermission.Tests.ps1 new file mode 100644 index 0000000000000..761057d51287c --- /dev/null +++ b/Tests/Private/Set-CIPPMailboxPermission.Tests.ps1 @@ -0,0 +1,185 @@ +# Pester tests for Set-CIPPMailboxPermission +# Covers the permission-level -> EXO cmdlet/parameter mapping (via -AsCmdletObject, no execution), +# the execute path (New-ExoRequest + logging), cache-sync gating, and the error path. + +BeforeAll { + # Resolve by name under Modules/ so the test survives the function moving between modules. + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Set-CIPPMailboxPermission.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $FunctionPath) { throw 'Could not locate Set-CIPPMailboxPermission.ps1 under Modules/' } + + # Stub every CIPP helper the function calls so Pester's Mock has a command to replace. + function New-ExoRequest { param($Anchor, $tenantid, $cmdlet, $cmdParams) } + function Get-CippException { param($Exception) } + function Sync-CIPPMailboxPermissionCache { param($TenantFilter, $MailboxIdentity, $User, $PermissionType, $Action) } + function Write-LogMessage { param($headers, $API, $tenant, $message, $Sev, $LogData) } + + . $FunctionPath +} + +Describe 'Set-CIPPMailboxPermission' { + + Context '-AsCmdletObject mapping matrix (no execution)' { + + It 'maps FullAccess Add to Add-MailboxPermission with automapping and InheritanceType' { + $result = Set-CIPPMailboxPermission -UserId 'shared@contoso.com' -AccessUser 'user@contoso.com' ` + -PermissionLevel 'FullAccess' -Action 'Add' -AutoMap $true -TenantFilter 'contoso.com' -AsCmdletObject + + $result.CmdletName | Should -Be 'Add-MailboxPermission' + $result.Parameters.Identity | Should -Be 'shared@contoso.com' + $result.Parameters.user | Should -Be 'user@contoso.com' + $result.Parameters.accessRights | Should -Be @('FullAccess') + $result.Parameters.automapping | Should -BeTrue + $result.Parameters.InheritanceType | Should -Be 'all' + $result.Parameters.Confirm | Should -BeFalse + $result.ExpectedResult | Should -Be 'Granted user@contoso.com FullAccess to shared@contoso.com with automapping True' + } + + It 'passes automapping through as $false when AutoMap is disabled' { + $result = Set-CIPPMailboxPermission -UserId 'shared@contoso.com' -AccessUser 'user@contoso.com' ` + -PermissionLevel 'FullAccess' -Action 'Add' -AutoMap $false -TenantFilter 'contoso.com' -AsCmdletObject + + $result.Parameters.automapping | Should -BeFalse + $result.ExpectedResult | Should -Match 'automapping False' + } + + It 'maps FullAccess Remove to Remove-MailboxPermission' { + $result = Set-CIPPMailboxPermission -UserId 'shared@contoso.com' -AccessUser 'user@contoso.com' ` + -PermissionLevel 'FullAccess' -Action 'Remove' -TenantFilter 'contoso.com' -AsCmdletObject + + $result.CmdletName | Should -Be 'Remove-MailboxPermission' + $result.Parameters.accessRights | Should -Be @('FullAccess') + $result.Parameters.Keys | Should -Not -Contain 'automapping' + $result.ExpectedResult | Should -Be 'Removed user@contoso.com FullAccess from shared@contoso.com' + } + + It 'maps SendAs Add to Add-RecipientPermission with Trustee' { + $result = Set-CIPPMailboxPermission -UserId 'shared@contoso.com' -AccessUser 'user@contoso.com' ` + -PermissionLevel 'SendAs' -Action 'Add' -TenantFilter 'contoso.com' -AsCmdletObject + + $result.CmdletName | Should -Be 'Add-RecipientPermission' + $result.Parameters.Trustee | Should -Be 'user@contoso.com' + $result.Parameters.accessRights | Should -Be @('SendAs') + $result.ExpectedResult | Should -Be 'Granted user@contoso.com SendAs permissions to shared@contoso.com' + } + + It 'maps SendAs Remove to Remove-RecipientPermission with Trustee' { + $result = Set-CIPPMailboxPermission -UserId 'shared@contoso.com' -AccessUser 'user@contoso.com' ` + -PermissionLevel 'SendAs' -Action 'Remove' -TenantFilter 'contoso.com' -AsCmdletObject + + $result.CmdletName | Should -Be 'Remove-RecipientPermission' + $result.Parameters.Trustee | Should -Be 'user@contoso.com' + $result.ExpectedResult | Should -Be 'Removed user@contoso.com SendAs permissions from shared@contoso.com' + } + + It 'maps SendOnBehalf Add to Set-Mailbox with GrantSendonBehalfTo add hashtable' { + $result = Set-CIPPMailboxPermission -UserId 'shared@contoso.com' -AccessUser 'user@contoso.com' ` + -PermissionLevel 'SendOnBehalf' -Action 'Add' -TenantFilter 'contoso.com' -AsCmdletObject + + $result.CmdletName | Should -Be 'Set-Mailbox' + $result.Parameters.GrantSendonBehalfTo['@odata.type'] | Should -Be '#Exchange.GenericHashTable' + $result.Parameters.GrantSendonBehalfTo.add | Should -Be 'user@contoso.com' + $result.Parameters.GrantSendonBehalfTo.Keys | Should -Not -Contain 'remove' + $result.ExpectedResult | Should -Be 'Granted user@contoso.com SendOnBehalf permissions to shared@contoso.com' + } + + It 'maps SendOnBehalf Remove to Set-Mailbox with GrantSendonBehalfTo remove hashtable' { + $result = Set-CIPPMailboxPermission -UserId 'shared@contoso.com' -AccessUser 'user@contoso.com' ` + -PermissionLevel 'SendOnBehalf' -Action 'Remove' -TenantFilter 'contoso.com' -AsCmdletObject + + $result.CmdletName | Should -Be 'Set-Mailbox' + $result.Parameters.GrantSendonBehalfTo.remove | Should -Be 'user@contoso.com' + $result.Parameters.GrantSendonBehalfTo.Keys | Should -Not -Contain 'add' + $result.ExpectedResult | Should -Be 'Removed user@contoso.com SendOnBehalf permissions from shared@contoso.com' + } + + It 'maps default-level Remove () to Remove-MailboxPermission with that access right' -ForEach @( + @{ Level = 'ReadPermission' } + @{ Level = 'ExternalAccount' } + @{ Level = 'DeleteItem' } + @{ Level = 'ChangePermission' } + @{ Level = 'ChangeOwner' } + ) { + $result = Set-CIPPMailboxPermission -UserId 'shared@contoso.com' -AccessUser 'user@contoso.com' ` + -PermissionLevel $Level -Action 'Remove' -TenantFilter 'contoso.com' -AsCmdletObject + + $result.CmdletName | Should -Be 'Remove-MailboxPermission' + $result.Parameters.accessRights | Should -Be @($Level) + $result.ExpectedResult | Should -Be "Removed user@contoso.com $Level from shared@contoso.com" + } + + It 'returns an unsupported-action string for default-level Add ()' -ForEach @( + @{ Level = 'ReadPermission' } + @{ Level = 'ExternalAccount' } + @{ Level = 'DeleteItem' } + @{ Level = 'ChangePermission' } + @{ Level = 'ChangeOwner' } + ) { + $result = Set-CIPPMailboxPermission -UserId 'shared@contoso.com' -AccessUser 'user@contoso.com' ` + -PermissionLevel $Level -Action 'Add' -TenantFilter 'contoso.com' -AsCmdletObject + + $result | Should -Be "Add action is not supported for $Level" + } + } + + Context 'Execute path' { + BeforeEach { + Mock -CommandName New-ExoRequest -MockWith { } + Mock -CommandName Sync-CIPPMailboxPermissionCache -MockWith { } + Mock -CommandName Write-LogMessage -MockWith { } + Mock -CommandName Get-CippException -MockWith { [pscustomobject]@{ NormalizedError = 'boom' } } + } + + It 'invokes New-ExoRequest with the mapped cmdlet/params anchored on the mailbox and returns the result string' { + $result = Set-CIPPMailboxPermission -UserId 'shared@contoso.com' -AccessUser 'user@contoso.com' ` + -PermissionLevel 'FullAccess' -Action 'Add' -TenantFilter 'contoso.com' + + Should -Invoke New-ExoRequest -Times 1 -Exactly -ParameterFilter { + $cmdlet -eq 'Add-MailboxPermission' -and + $Anchor -eq 'shared@contoso.com' -and + $tenantid -eq 'contoso.com' -and + $cmdParams.user -eq 'user@contoso.com' + } + $result | Should -Be 'Granted user@contoso.com FullAccess to shared@contoso.com with automapping True' + } + + It 'logs an Info message on success' { + Set-CIPPMailboxPermission -UserId 'shared@contoso.com' -AccessUser 'user@contoso.com' ` + -PermissionLevel 'SendAs' -Action 'Add' -TenantFilter 'contoso.com' + + Should -Invoke Write-LogMessage -Times 1 -Exactly -ParameterFilter { $Sev -eq 'Info' } + } + + It 'syncs the cache for cached permission types ()' -ForEach @( + @{ Level = 'FullAccess' } + @{ Level = 'SendAs' } + @{ Level = 'SendOnBehalf' } + ) { + Set-CIPPMailboxPermission -UserId 'shared@contoso.com' -AccessUser 'user@contoso.com' ` + -PermissionLevel $Level -Action 'Add' -TenantFilter 'contoso.com' + + Should -Invoke Sync-CIPPMailboxPermissionCache -Times 1 -Exactly -ParameterFilter { + $PermissionType -eq $Level -and $Action -eq 'Add' + } + } + + It 'does not sync the cache for non-cached permission types' { + Set-CIPPMailboxPermission -UserId 'shared@contoso.com' -AccessUser 'user@contoso.com' ` + -PermissionLevel 'ReadPermission' -Action 'Remove' -TenantFilter 'contoso.com' + + Should -Invoke Sync-CIPPMailboxPermissionCache -Times 0 -Exactly + } + + It 'returns a failure string and logs an error when New-ExoRequest throws' { + Mock -CommandName New-ExoRequest -MockWith { throw 'EXO down' } + + $result = Set-CIPPMailboxPermission -UserId 'shared@contoso.com' -AccessUser 'user@contoso.com' ` + -PermissionLevel 'FullAccess' -Action 'Add' -TenantFilter 'contoso.com' + + $result | Should -Be 'Failed to Add FullAccess for user@contoso.com on shared@contoso.com: boom' + Should -Invoke Write-LogMessage -Times 1 -Exactly -ParameterFilter { $Sev -eq 'Error' } + Should -Invoke Sync-CIPPMailboxPermissionCache -Times 0 -Exactly + } + } +} diff --git a/Tests/Private/Set-CIPPMailboxVacation.Tests.ps1 b/Tests/Private/Set-CIPPMailboxVacation.Tests.ps1 new file mode 100644 index 0000000000000..f1aeeacced3ce --- /dev/null +++ b/Tests/Private/Set-CIPPMailboxVacation.Tests.ps1 @@ -0,0 +1,132 @@ +# Pester tests for Set-CIPPMailboxVacation +# Covers the mailbox-permission loop (delegating to Set-CIPPMailboxPermission), the calendar-permission +# loop (Set-CIPPCalendarPermission), hashtable vs PSCustomObject entry access, missing-field skips, +# Action propagation, and the calendar error path. + +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $FunctionPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Set-CIPPMailboxVacation.ps1' -File -ErrorAction SilentlyContinue | + Select-Object -First 1 -ExpandProperty FullName + if (-not $FunctionPath) { throw 'Could not locate Set-CIPPMailboxVacation.ps1 under Modules/' } + + function Set-CIPPMailboxPermission { param($UserId, $AccessUser, $PermissionLevel, $Action, $AutoMap, $TenantFilter, $APIName, $Headers) } + function Set-CIPPCalendarPermission { param($TenantFilter, $UserID, $FolderName, $APIName, $Headers, $RemoveAccess, $UserToGetPermissions, $Permissions, $CanViewPrivateItems) } + function Get-CippException { param($Exception) } + + . $FunctionPath +} + +Describe 'Set-CIPPMailboxVacation' { + BeforeEach { + Mock -CommandName Set-CIPPMailboxPermission -MockWith { 'mailbox-perm-result' } + Mock -CommandName Set-CIPPCalendarPermission -MockWith { 'calendar-perm-result' } + Mock -CommandName Get-CippException -MockWith { [pscustomobject]@{ NormalizedError = 'boom' } } + } + + Context 'Mailbox permissions' { + It 'forwards each mailbox permission to Set-CIPPMailboxPermission with the requested Action' { + $perms = @( + [pscustomobject]@{ UserId = 'shared@contoso.com'; AccessUser = 'user@contoso.com'; PermissionLevel = 'FullAccess'; AutoMap = $true } + ) + + $results = Set-CIPPMailboxVacation -TenantFilter 'contoso.com' -Action 'Add' -MailboxPermissions $perms + + Should -Invoke Set-CIPPMailboxPermission -Times 1 -Exactly -ParameterFilter { + $UserId -eq 'shared@contoso.com' -and + $AccessUser -eq 'user@contoso.com' -and + $PermissionLevel -eq 'FullAccess' -and + $Action -eq 'Add' -and + $AutoMap -eq $true -and + $TenantFilter -eq 'contoso.com' + } + $results | Should -Contain 'mailbox-perm-result' + } + + It 'propagates the Remove action to the delegate cmdlet' { + $perms = @([pscustomobject]@{ UserId = 'shared@contoso.com'; AccessUser = 'user@contoso.com'; PermissionLevel = 'SendAs' }) + + Set-CIPPMailboxVacation -TenantFilter 'contoso.com' -Action 'Remove' -MailboxPermissions $perms + + Should -Invoke Set-CIPPMailboxPermission -Times 1 -Exactly -ParameterFilter { $Action -eq 'Remove' } + } + + It 'accepts hashtable entries as well as PSCustomObject entries' { + $perms = @(@{ UserId = 'shared@contoso.com'; AccessUser = 'user@contoso.com'; PermissionLevel = 'FullAccess' }) + + Set-CIPPMailboxVacation -TenantFilter 'contoso.com' -Action 'Add' -MailboxPermissions $perms + + Should -Invoke Set-CIPPMailboxPermission -Times 1 -Exactly -ParameterFilter { $UserId -eq 'shared@contoso.com' } + } + + It 'defaults AutoMap to $true when not supplied' { + $perms = @([pscustomobject]@{ UserId = 'shared@contoso.com'; AccessUser = 'user@contoso.com'; PermissionLevel = 'FullAccess' }) + + Set-CIPPMailboxVacation -TenantFilter 'contoso.com' -Action 'Add' -MailboxPermissions $perms + + Should -Invoke Set-CIPPMailboxPermission -Times 1 -Exactly -ParameterFilter { $AutoMap -eq $true } + } + + It 'skips entries with missing required fields and records a skip message' { + $perms = @([pscustomobject]@{ UserId = 'shared@contoso.com'; PermissionLevel = 'FullAccess' }) # no AccessUser + + $results = Set-CIPPMailboxVacation -TenantFilter 'contoso.com' -Action 'Add' -MailboxPermissions $perms + + Should -Invoke Set-CIPPMailboxPermission -Times 0 -Exactly + $results | Should -Contain 'Skipped mailbox permission with missing fields' + } + } + + Context 'Calendar permissions' { + It 'forwards Add calendar permissions with delegate, permissions and private-items flag' { + $cal = @( + [pscustomobject]@{ UserID = 'shared@contoso.com'; UserToGetPermissions = 'user@contoso.com'; FolderName = 'Calendar'; Permissions = 'Editor'; CanViewPrivateItems = $true } + ) + + $results = Set-CIPPMailboxVacation -TenantFilter 'contoso.com' -Action 'Add' -CalendarPermissions $cal + + Should -Invoke Set-CIPPCalendarPermission -Times 1 -Exactly -ParameterFilter { + $UserID -eq 'shared@contoso.com' -and + $UserToGetPermissions -eq 'user@contoso.com' -and + $Permissions -eq 'Editor' -and + $CanViewPrivateItems -eq $true + } + $results | Should -Contain 'calendar-perm-result' + } + + It 'uses RemoveAccess when the action is Remove' { + $cal = @([pscustomobject]@{ UserID = 'shared@contoso.com'; UserToGetPermissions = 'user@contoso.com' }) + + Set-CIPPMailboxVacation -TenantFilter 'contoso.com' -Action 'Remove' -CalendarPermissions $cal + + Should -Invoke Set-CIPPCalendarPermission -Times 1 -Exactly -ParameterFilter { + $RemoveAccess -eq 'user@contoso.com' + } + } + + It 'defaults the calendar folder name to Calendar' { + $cal = @([pscustomobject]@{ UserID = 'shared@contoso.com'; UserToGetPermissions = 'user@contoso.com' }) + + Set-CIPPMailboxVacation -TenantFilter 'contoso.com' -Action 'Add' -CalendarPermissions $cal + + Should -Invoke Set-CIPPCalendarPermission -Times 1 -Exactly -ParameterFilter { $FolderName -eq 'Calendar' } + } + + It 'skips calendar entries with missing required fields' { + $cal = @([pscustomobject]@{ UserID = 'shared@contoso.com' }) # no delegate + + $results = Set-CIPPMailboxVacation -TenantFilter 'contoso.com' -Action 'Add' -CalendarPermissions $cal + + Should -Invoke Set-CIPPCalendarPermission -Times 0 -Exactly + $results | Should -Contain 'Skipped calendar permission with missing fields' + } + + It 'records a failure message when the calendar permission throws' { + Mock -CommandName Set-CIPPCalendarPermission -MockWith { throw 'cal down' } + $cal = @([pscustomobject]@{ UserID = 'shared@contoso.com'; UserToGetPermissions = 'user@contoso.com' }) + + $results = Set-CIPPMailboxVacation -TenantFilter 'contoso.com' -Action 'Add' -CalendarPermissions $cal + + $results | Should -Match 'Failed calendar permission for user@contoso.com on shared@contoso.com: boom' + } + } +} From dab2d8f75c051744842ce31f673f71724ccd8738 Mon Sep 17 00:00:00 2001 From: Bobby <31723128+kris6673@users.noreply.github.com> Date: Wed, 1 Jul 2026 22:54:42 +0200 Subject: [PATCH 084/170] feat(tests): add test runner and scaffolder Invoke-CippTests.ps1 runs the Pester suite with optional -CI (NUnit results) and -Coverage output; New-CippTest.ps1 scaffolds a runnable test skeleton for any function (helper stubs, fake HTTP contexts, move-resilient path resolution) to lower the barrier to adding backend tests. Ignore the runner's result/coverage artifacts. --- .gitignore | 4 + Tests/Invoke-CippTests.ps1 | 101 ++++++++++++++ Tests/New-CippTest.ps1 | 263 +++++++++++++++++++++++++++++++++++++ 3 files changed, 368 insertions(+) create mode 100644 Tests/Invoke-CippTests.ps1 create mode 100644 Tests/New-CippTest.ps1 diff --git a/.gitignore b/.gitignore index 4f4c7911d7505..326a9b52c6735 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,7 @@ Shared/CIPPSharp/obj/ !/profile.ps1 .DS_Store proxyman.pem + +# Pester test runner artifacts (Invoke-CippTests.ps1 -CI / -Coverage) +Tests/TestResults.xml +Tests/coverage.xml diff --git a/Tests/Invoke-CippTests.ps1 b/Tests/Invoke-CippTests.ps1 new file mode 100644 index 0000000000000..e6c672ee710f6 --- /dev/null +++ b/Tests/Invoke-CippTests.ps1 @@ -0,0 +1,101 @@ +#Requires -Version 7.0 +<# +.SYNOPSIS + Runs the CIPP-API Pester test suite. + +.DESCRIPTION + Thin, opinionated wrapper around Pester 5 so the backend tests can be run with a + single command regardless of the caller's current directory. Pester only discovers + files named '*.Tests.ps1', so the non-Pester helper scripts that also live under + Tests/ (Test-ODataFilterInjection.ps1, Test-SchedulerBlocklist.ps1) are ignored + automatically. + +.PARAMETER Path + Limit the run to a specific test file or folder (relative to the repo root or absolute). + Defaults to the entire Tests/ directory. + +.PARAMETER Tag + Only run It/Describe blocks carrying one of these Pester tags. + +.PARAMETER CI + Emit a NUnit result file (Tests/TestResults.xml). Use this from automation / GitHub Actions. + Note: a non-zero exit on failure is the DEFAULT (not gated on -CI) - see -NoExitCode. + +.PARAMETER NoExitCode + Do not set the process exit code from the test result. Use this in an interactive session + where a failing run should not terminate your shell. By default the script exits with the + failed-test count so scripts/agents relying on process status see red as red. + +.PARAMETER Coverage + Also collect code coverage over Modules/**/Public and write Tests/coverage.xml. + +.EXAMPLE + pwsh CIPP-API/Tests/Invoke-CippTests.ps1 + Runs the whole suite with detailed console output. + +.EXAMPLE + pwsh CIPP-API/Tests/Invoke-CippTests.ps1 -Path Tests/Endpoint -CI + Runs only the Endpoint tests and produces a CI result file + exit code. +#> +[CmdletBinding()] +param( + [string[]]$Path, + [string[]]$Tag, + [switch]$CI, + [switch]$NoExitCode, + [switch]$Coverage +) + +$ErrorActionPreference = 'Stop' + +# Tests/ is one level under the repo root. +$TestsRoot = $PSScriptRoot +$RepoRoot = Split-Path -Parent $TestsRoot + +# Pester 5 is required for the Should -Invoke / -ParameterFilter syntax the suite uses. +$pester = Get-Module -ListAvailable -Name Pester | Where-Object { $_.Version -ge [version]'5.0.0' } | Sort-Object Version -Descending | Select-Object -First 1 +if (-not $pester) { + throw "Pester 5+ is required but was not found. Install it with: Install-Module Pester -MinimumVersion 5.0 -Scope CurrentUser" +} +Import-Module $pester -ErrorAction Stop + +# Resolve -Path entries against the repo root when they are not already absolute, +# so callers can pass repo-relative paths like 'Tests/Endpoint' from anywhere. +$runPaths = if ($Path) { + foreach ($p in $Path) { + if ([System.IO.Path]::IsPathRooted($p)) { $p } + elseif (Test-Path -LiteralPath (Join-Path $RepoRoot $p)) { Join-Path $RepoRoot $p } + else { $p } + } +} else { + $TestsRoot +} + +$config = New-PesterConfiguration +$config.Run.Path = $runPaths +$config.Run.PassThru = $true +$config.Output.Verbosity = 'Detailed' + +if ($Tag) { + $config.Filter.Tag = $Tag +} + +if ($CI) { + $config.TestResult.Enabled = $true + $config.TestResult.OutputFormat = 'NUnitXml' + $config.TestResult.OutputPath = Join-Path $TestsRoot 'TestResults.xml' +} + +if ($Coverage) { + $config.CodeCoverage.Enabled = $true + $config.CodeCoverage.Path = Join-Path $RepoRoot 'Modules' + $config.CodeCoverage.OutputPath = Join-Path $TestsRoot 'coverage.xml' +} + +$result = Invoke-Pester -Configuration $config + +# Surface a real exit code by default so agents / pipelines that key off process status +# see a red suite as red. -NoExitCode opts out for interactive shells. +if (-not $NoExitCode) { + exit $result.FailedCount +} diff --git a/Tests/New-CippTest.ps1 b/Tests/New-CippTest.ps1 new file mode 100644 index 0000000000000..cdfdb80cfe377 --- /dev/null +++ b/Tests/New-CippTest.ps1 @@ -0,0 +1,263 @@ +#Requires -Version 7.0 +<# +.SYNOPSIS + Scaffolds a Pester test for a CIPP-API backend function. + +.DESCRIPTION + Locates a function by name under Modules/, parses it with the PowerShell AST, and + emits a starter .Tests.ps1 under Tests// that already contains: + * a move-resilient path resolver (find the function by filename, never hardcode a module), + * stub functions for every CIPP helper the function calls (so Pester Mock can replace them), + * fake HttpResponseContext / HttpRequestContext classes for HTTP endpoints, + * a Describe block with placeholder It cases (happy path + one per required field), + each marked with # TODO where a human or Claude fills in real assertions. + + It deliberately does NOT try to write meaningful assertions - that requires reading the + function's intent. The goal is to remove the ~30 lines of boilerplate and dependency + guesswork, then hand a runnable skeleton to the author. + +.PARAMETER FunctionName + The function to scaffold a test for, e.g. Invoke-ListIntuneReusableSettings. + +.PARAMETER Force + Overwrite an existing test file. + +.PARAMETER Area + Override the auto-detected Tests/ subfolder (Endpoint, Standards, Alerts, Private). + +.EXAMPLE + pwsh CIPP-API/Tests/New-CippTest.ps1 -FunctionName Invoke-ListIntuneReusableSettings +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [string]$FunctionName, + + [switch]$Force, + + [ValidateSet('Endpoint', 'Standards', 'Alerts', 'Private')] + [string]$Area +) + +$ErrorActionPreference = 'Stop' + +$TestsRoot = $PSScriptRoot +$RepoRoot = Split-Path -Parent $TestsRoot +$ModulesRoot = Join-Path $RepoRoot 'Modules' + +# --- 1. Locate the function file by name (this is what avoids hardcoded, rot-prone paths) --- +$matches = @(Get-ChildItem -Path $ModulesRoot -Recurse -Filter "$FunctionName.ps1" -File -ErrorAction SilentlyContinue) +if ($matches.Count -eq 0) { + throw "No file named '$FunctionName.ps1' found under $ModulesRoot. Check the function name." +} +if ($matches.Count -gt 1) { + $list = ($matches.FullName | ForEach-Object { " $_" }) -join "`n" + throw "Multiple files named '$FunctionName.ps1' found - cannot disambiguate:`n$list" +} +$FunctionFile = $matches[0].FullName + +# --- 2. Parse with the AST --- +$tokens = $null +$parseErrors = $null +$ast = [System.Management.Automation.Language.Parser]::ParseFile($FunctionFile, [ref]$tokens, [ref]$parseErrors) + +$funcAst = $ast.Find({ + param($node) + $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq $FunctionName + }, $true) +if (-not $funcAst) { + throw "File '$FunctionFile' does not define a function called '$FunctionName'." +} + +# Endpoint detection: HTTP entrypoints take $Request and $TriggerMetadata. +$paramNames = @() +if ($funcAst.Body.ParamBlock) { + $paramNames = $funcAst.Body.ParamBlock.Parameters.Name.VariablePath.UserPath +} +$isEndpoint = ($paramNames -contains 'Request' -and $paramNames -contains 'TriggerMetadata') + +# --- 3. Discover called CIPP helpers (to emit as stubbable functions) --- +# Only stub commands that look like CIPP helpers; never stub PowerShell built-ins. +$helperPattern = '(?i)(cipp|graph.*request|azdatatable|write-logmessage|write-alert|write-standardsalert|get-normalizederror)' +$commandAsts = $funcAst.FindAll({ + param($node) $node -is [System.Management.Automation.Language.CommandAst] + }, $true) +$helpers = [System.Collections.Generic.SortedSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) +foreach ($c in $commandAsts) { + $name = $c.GetCommandName() + if ($name -and $name -ne $FunctionName -and $name -match $helperPattern) { + [void]$helpers.Add($name) + } +} + +# --- 4. Discover input fields read from $Request.Body.* / $Request.Query.* --- +# Track which container each field comes from so the happy-path request seeds the right bag. +$bodyFields = [System.Collections.Generic.SortedSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) +$queryFields = [System.Collections.Generic.SortedSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) +if ($isEndpoint) { + $memberAsts = $funcAst.FindAll({ + param($node) $node -is [System.Management.Automation.Language.MemberExpressionAst] + }, $true) + foreach ($m in $memberAsts) { + $inner = $m.Expression + if ($inner -is [System.Management.Automation.Language.MemberExpressionAst] -and + $inner.Expression -is [System.Management.Automation.Language.VariableExpressionAst] -and + $inner.Expression.VariablePath.UserPath -eq 'Request' -and + $inner.Member.Value -in @('Body', 'Query') -and + $m.Member -is [System.Management.Automation.Language.StringConstantExpressionAst]) { + $field = $m.Member.Value + if ($inner.Member.Value -eq 'Body') { [void]$bodyFields.Add($field) } + else { [void]$queryFields.Add($field) } + } + } +} +$usesBody = $bodyFields.Count -gt 0 +$usesQuery = $queryFields.Count -gt 0 + +# --- 5. Discover required-field guards from ' is required' literals --- +$requiredFields = [System.Collections.Generic.List[string]]::new() +$strings = $funcAst.FindAll({ + param($node) $node -is [System.Management.Automation.Language.StringConstantExpressionAst] + }, $true) +foreach ($s in $strings) { + if ($s.Value -match '^(\w+)\s+is required') { + if (-not $requiredFields.Contains($Matches[1])) { $requiredFields.Add($Matches[1]) } + } +} + +# --- 6. Determine the Area (Tests/ subfolder) --- +if (-not $Area) { + $rel = $FunctionFile.Substring($ModulesRoot.Length).Replace('\', '/') + $Area = switch -Regex ($rel) { + 'Entrypoints/HTTP Functions' { 'Endpoint'; break } + '/Standards/' { 'Standards'; break } + '/Alerts/' { 'Alerts'; break } + default { 'Private' } + } +} +$OutDir = Join-Path $TestsRoot $Area +$OutFile = Join-Path $OutDir "$FunctionName.Tests.ps1" +if ((Test-Path $OutFile) -and -not $Force) { + throw "Test already exists: $OutFile (use -Force to overwrite)." +} + +# --- 7. Build the test file content --- +$nl = "`n" +$sb = [System.Text.StringBuilder]::new() +[void]$sb.Append("# Pester tests for $FunctionName$nl") +[void]$sb.Append("# Scaffolded by New-CippTest.ps1 - replace every # TODO with real assertions.$nl$nl") +[void]$sb.Append("BeforeAll {$nl") +[void]$sb.Append(" # Resolve by name under Modules/ so the test survives the function moving between modules.$nl") +[void]$sb.Append(" `$RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent `$PSCommandPath))$nl") +[void]$sb.Append(" `$FunctionPath = Get-ChildItem -Path (Join-Path `$RepoRoot 'Modules') -Recurse -Filter '$FunctionName.ps1' -File -ErrorAction SilentlyContinue |$nl") +[void]$sb.Append(" Select-Object -First 1 -ExpandProperty FullName$nl") +[void]$sb.Append(" if (-not `$FunctionPath) { throw 'Could not locate $FunctionName.ps1 under Modules/' }$nl$nl") + +if ($isEndpoint) { + [void]$sb.Append(" # Azure Functions binding types do not exist outside the Functions host - fake them.$nl") + [void]$sb.Append(" class HttpResponseContext {$nl [int]`$StatusCode$nl [object]`$Body$nl }$nl$nl") +} + +if ($helpers.Count -gt 0) { + [void]$sb.Append(" # Stub every CIPP helper the function calls so Pester's Mock has a command to replace.$nl") + foreach ($h in $helpers) { + [void]$sb.Append(" function $h { }$nl") + } + [void]$sb.Append($nl) +} + +[void]$sb.Append(" . `$FunctionPath$nl") +[void]$sb.Append("}$nl$nl") + +[void]$sb.Append("Describe '$FunctionName' {$nl") +[void]$sb.Append(" BeforeEach {$nl") +foreach ($h in $helpers) { + [void]$sb.Append(" Mock -CommandName $h -MockWith { } # TODO: return realistic data / capture args$nl") +} +[void]$sb.Append(" }$nl$nl") + +if ($isEndpoint) { + # Render a container literal (Body/Query) seeded with the fields it actually exposes. + function Format-Container { + param([System.Collections.Generic.SortedSet[string]]$Fields) + $pairs = foreach ($f in $Fields) { + if ($f -ieq 'tenantFilter') { "$f = 'contoso.onmicrosoft.com'" } else { "$f = 'TODO'" } + } + '[pscustomobject]@{ ' + ($pairs -join '; ') + ' }' + } + + # Happy-path case with a sample request built from discovered input fields. + [void]$sb.Append(" It 'returns OK on the happy path' {$nl") + [void]$sb.Append(" `$request = [pscustomobject]@{$nl") + [void]$sb.Append(" Params = @{ CIPPEndpoint = '$($FunctionName -replace '^Invoke-', '')' }$nl") + [void]$sb.Append(" Headers = @{ Authorization = 'token' }$nl") + if ($usesBody) { + [void]$sb.Append(" Body = $(Format-Container $bodyFields)$nl") + } + if ($usesQuery) { + [void]$sb.Append(" Query = $(Format-Container $queryFields)$nl") + } + [void]$sb.Append(" }$nl$nl") + [void]$sb.Append(" `$response = $FunctionName -Request `$request -TriggerMetadata `$null$nl$nl") + [void]$sb.Append(" `$response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::OK)$nl") + [void]$sb.Append(" # TODO: assert `$response.Body and Should -Invoke the helpers with -ParameterFilter$nl") + [void]$sb.Append(" }$nl") + + # Negative cases: endpoints validate required fields in order, so an all-empty request + # would always trip the FIRST guard. Instead start from a baseline that satisfies every + # guard, then drop ONLY the field under test so its specific guard is the one that fires. + function Get-FieldContainer { + param([string]$Field) + if ($bodyFields.Contains($Field)) { 'Body' } elseif ($queryFields.Contains($Field)) { 'Query' } else { 'Body' } + } + function Format-RequiredRequest { + param([string]$Omit) + $bodyPairs = [System.Collections.Generic.List[string]]::new() + $queryPairs = [System.Collections.Generic.List[string]]::new() + foreach ($f in $requiredFields) { + if ($f -ieq $Omit) { continue } + $val = if ($f -ieq 'tenantFilter') { "'contoso.onmicrosoft.com'" } else { "'TODO'" } + if ((Get-FieldContainer $f) -eq 'Body') { $bodyPairs.Add("$f = $val") } else { $queryPairs.Add("$f = $val") } + } + $b = '[pscustomobject]@{' + $(if ($bodyPairs.Count) { ' ' + ($bodyPairs -join '; ') + ' ' } else { '' }) + '}' + $q = '[pscustomobject]@{' + $(if ($queryPairs.Count) { ' ' + ($queryPairs -join '; ') + ' ' } else { '' }) + '}' + "Body = $b ; Query = $q" + } + + foreach ($field in $requiredFields) { + [void]$sb.Append($nl) + [void]$sb.Append(" It 'returns BadRequest when $field is missing' {$nl") + [void]$sb.Append(" # Baseline has every other required field populated; only $field is dropped.$nl") + [void]$sb.Append(" `$request = [pscustomobject]@{ $(Format-RequiredRequest -Omit $field) }$nl") + [void]$sb.Append(" `$response = $FunctionName -Request `$request -TriggerMetadata `$null$nl$nl") + [void]$sb.Append(" `$response.StatusCode | Should -Be ([System.Net.HttpStatusCode]::BadRequest)$nl") + [void]$sb.Append(" `$response.Body.Results | Should -Match '$field is required'$nl") + [void]$sb.Append(" }$nl") + } +} else { + # Non-endpoint function: emit a single placeholder invocation case. + $callParams = ($paramNames | ForEach-Object { "-$_ `$null" }) -join ' ' + [void]$sb.Append(" It 'does the expected thing' {$nl") + [void]$sb.Append(" # TODO: call with realistic arguments and assert behaviour$nl") + [void]$sb.Append(" # $FunctionName $callParams$nl") + [void]$sb.Append(" `$true | Should -BeTrue # TODO: replace$nl") + [void]$sb.Append(" }$nl") +} + +[void]$sb.Append("}$nl") + +# --- 8. Write it out --- +if (-not (Test-Path $OutDir)) { New-Item -ItemType Directory -Path $OutDir -Force | Out-Null } +Set-Content -Path $OutFile -Value $sb.ToString() -Encoding utf8 -NoNewline + +Write-Host "Scaffolded: $OutFile" -ForegroundColor Green +Write-Host " Function : $FunctionFile" +Write-Host " Area : $Area | Endpoint: $isEndpoint" +Write-Host " Helpers : $(if ($helpers.Count) { $helpers -join ', ' } else { '(none detected)' })" +if ($isEndpoint) { + Write-Host " Body : $(if ($bodyFields.Count) { $bodyFields -join ', ' } else { '(none)' })" + Write-Host " Query : $(if ($queryFields.Count) { $queryFields -join ', ' } else { '(none)' })" + Write-Host " Required : $(if ($requiredFields.Count) { $requiredFields -join ', ' } else { '(none)' })" +} +Write-Host "Next: fill in the # TODOs, then run:" -ForegroundColor Cyan +Write-Host " pwsh $($MyInvocation.MyCommand.Path -replace 'New-CippTest','Invoke-CippTests') -Path `"$([System.IO.Path]::GetRelativePath($RepoRoot, $OutFile))`"" From 2df58194ecc51797f1e4751973e128239908a335 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Wed, 1 Jul 2026 23:46:44 +0200 Subject: [PATCH 085/170] MSP App improvements --- .../Public/Get-CIPPMSPAppInstallCommand.ps1 | 113 ++++++++++++++++++ .../Public/New-CIPPIntuneAppDeployment.ps1 | 23 ++++ .../Applications/Invoke-AddMSPApp.ps1 | 58 ++------- 3 files changed, 144 insertions(+), 50 deletions(-) create mode 100644 Modules/CIPPCore/Public/Get-CIPPMSPAppInstallCommand.ps1 diff --git a/Modules/CIPPCore/Public/Get-CIPPMSPAppInstallCommand.ps1 b/Modules/CIPPCore/Public/Get-CIPPMSPAppInstallCommand.ps1 new file mode 100644 index 0000000000000..86da343f15a58 --- /dev/null +++ b/Modules/CIPPCore/Public/Get-CIPPMSPAppInstallCommand.ps1 @@ -0,0 +1,113 @@ +function Get-CIPPMSPAppInstallCommand { + <# + .SYNOPSIS + Builds the install/uninstall command lines for an MSP RMM app for a single tenant. + .DESCRIPTION + Shared by Invoke-AddMSPApp (manual/queue deploy) and New-CIPPIntuneAppDeployment + (the 'Deploy Intune Application Template' standard). Each parameter value may be: + - a flat string, optionally containing %CIPP variables% that resolve per-tenant + (Application Template shape), or + - an object keyed by tenant customerId (legacy per-tenant deploy shape). + Values are resolved for the tenant, run through Get-CIPPTextReplacement so any + %variables% are substituted with the tenant's value, then escaped with + ConvertTo-CIPPSafePwshArg before being placed on the command line. + .PARAMETER RmmName + The MSP tool identifier (datto, syncro, Huntress, automate, cwcommand, ninja, NCentral). + .PARAMETER Params + The params object from the app config / request body. + .PARAMETER Tenant + The tenant object, requires customerId and defaultDomainName. + .PARAMETER PackageName + Package name for ninja/NCentral installs (not stored under params). + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$RmmName, + + $Params, + + [Parameter(Mandatory = $true)] + $Tenant, + + [string]$PackageName + ) + + $InstallParams = [PSCustomObject]$Params + + # Resolve a raw parameter value for this tenant: pick the per-tenant keyed value when the + # value is an object (legacy shape), otherwise use it as-is (template shape), then replace + # any %CIPP variables% using the tenant context. Returns the raw (unescaped) string. + function Resolve-MSPValue { + param($Value) + if ($null -eq $Value) { return '' } + if ($Value -is [string]) { + $Resolved = $Value + } elseif ($Value -is [System.Collections.IDictionary]) { + $Resolved = [string]$Value[$Tenant.customerId] + } elseif ($Value -is [pscustomobject]) { + $Resolved = [string]$Value.$($Tenant.customerId) + } else { + $Resolved = [string]$Value + } + if ($Resolved -match '%') { + $Resolved = Get-CIPPTextReplacement -TenantFilter $Tenant.defaultDomainName -Text $Resolved + } + return $Resolved + } + + $DetectionScriptContent = $null + + switch ($RmmName) { + 'datto' { + $DattoUrl = ConvertTo-CIPPSafePwshArg -Value (Resolve-MSPValue $InstallParams.DattoURL) + $DattoGuid = ConvertTo-CIPPSafePwshArg -Value (Resolve-MSPValue $InstallParams.DattoGUID) + $installCommandLine = "powershell.exe -ExecutionPolicy Bypass .\install.ps1 -URL $DattoUrl -GUID $DattoGuid" + $uninstallCommandLine = 'powershell.exe -ExecutionPolicy Bypass .\uninstall.ps1' + } + 'ninja' { + $NinjaPackage = ConvertTo-CIPPSafePwshArg -Value ([string]$PackageName) + $installCommandLine = "powershell.exe -ExecutionPolicy Bypass .\install.ps1 -InstallParam $NinjaPackage" + $uninstallCommandLine = 'powershell.exe -ExecutionPolicy Bypass .\uninstall.ps1' + } + 'Huntress' { + $HuntressOrgKey = ConvertTo-CIPPSafePwshArg -Value (Resolve-MSPValue $InstallParams.Orgkey) + $HuntressAccountKey = ConvertTo-CIPPSafePwshArg -Value (Resolve-MSPValue $InstallParams.AccountKey) + $installCommandLine = "powershell.exe -ExecutionPolicy Bypass .\install.ps1 -OrgKey $HuntressOrgKey -acctkey $HuntressAccountKey" + $uninstallCommandLine = 'powershell.exe -ExecutionPolicy Bypass .\install.ps1 -Uninstall' + } + 'syncro' { + $SyncroUrl = ConvertTo-CIPPSafePwshArg -Value (Resolve-MSPValue $InstallParams.ClientURL) + $installCommandLine = "powershell.exe -ExecutionPolicy Bypass .\install.ps1 -URL $SyncroUrl" + $uninstallCommandLine = 'powershell.exe -ExecutionPolicy Bypass .\uninstall.ps1' + } + 'NCentral' { + $NCentralPackage = ConvertTo-CIPPSafePwshArg -Value ([string]$PackageName) + $installCommandLine = "powershell.exe -ExecutionPolicy Bypass .\install.ps1 -InstallParam $NCentralPackage" + $uninstallCommandLine = 'powershell.exe -ExecutionPolicy Bypass .\uninstall.ps1' + } + 'automate' { + $ServerRaw = Resolve-MSPValue $InstallParams.Server + $AutomateServer = ConvertTo-CIPPSafePwshArg -Value $ServerRaw + $AutomateInstallerToken = ConvertTo-CIPPSafePwshArg -Value (Resolve-MSPValue $InstallParams.InstallerToken) + $AutomateLocationId = ConvertTo-CIPPSafePwshArg -Value (Resolve-MSPValue $InstallParams.LocationID) + $installCommandLine = "c:\windows\sysnative\windowspowershell\v1.0\powershell.exe -ExecutionPolicy Bypass .\install.ps1 -Server $AutomateServer -InstallerToken $AutomateInstallerToken -LocationID $AutomateLocationId" + $uninstallCommandLine = "c:\windows\sysnative\windowspowershell\v1.0\powershell.exe -ExecutionPolicy Bypass .\uninstall.ps1 -Server $AutomateServer" + $DetectionScriptContent = (Get-Content 'AddMSPApp\automate.detection.ps1' -Raw) -replace '##SERVER##', $ServerRaw + } + 'cwcommand' { + $CwClientUrl = ConvertTo-CIPPSafePwshArg -Value (Resolve-MSPValue $InstallParams.ClientURL) + $installCommandLine = "powershell.exe -ExecutionPolicy Bypass .\install.ps1 -Url $CwClientUrl" + $uninstallCommandLine = 'powershell.exe -ExecutionPolicy Bypass .\uninstall.ps1' + } + default { + throw "Unknown MSP app type '$RmmName'" + } + } + + return [PSCustomObject]@{ + InstallCommandLine = $installCommandLine + UninstallCommandLine = $uninstallCommandLine + DetectionScriptContent = $DetectionScriptContent + } +} diff --git a/Modules/CIPPCore/Public/New-CIPPIntuneAppDeployment.ps1 b/Modules/CIPPCore/Public/New-CIPPIntuneAppDeployment.ps1 index 2b7f3dfac5ff9..5c6d4492ca783 100644 --- a/Modules/CIPPCore/Public/New-CIPPIntuneAppDeployment.ps1 +++ b/Modules/CIPPCore/Public/New-CIPPIntuneAppDeployment.ps1 @@ -78,6 +78,29 @@ function New-CIPPIntuneAppDeployment { } } + # Build IntuneBody from raw config if not pre-built (template/standard path). MSP apps store + # only the vendor + params in the template, so build the install command here using the shared + # helper, which resolves %CIPP variables% in the params per-tenant. + if (-not $IntuneBody -and $AppType -eq 'MSPApp') { + $MSPAppName = $AppConfig.MSPAppName ?? $AppConfig.rmmname.value ?? $AppConfig.rmmname + if ([string]::IsNullOrWhiteSpace($MSPAppName)) { + throw 'MSP app vendor (rmmname) is required for MSP app deployments but was not found in the template config.' + } + # Ensure the file-loading block below can locate the packaged app files. + $AppConfig | Add-Member -NotePropertyName 'MSPAppName' -NotePropertyValue $MSPAppName -Force + + $IntuneBody = Get-Content (Join-Path $env:CIPPRootPath "AddMSPApp\$MSPAppName.app.json") | ConvertFrom-Json + $IntuneBody.displayName = $AppConfig.Applicationname ?? $AppConfig.displayName + + $TenantObj = Get-Tenants -TenantFilter $TenantFilter + $CommandResult = Get-CIPPMSPAppInstallCommand -RmmName $MSPAppName -Params $AppConfig.params -Tenant $TenantObj -PackageName $AppConfig.PackageName + $IntuneBody.installCommandLine = $CommandResult.InstallCommandLine + $IntuneBody.UninstallCommandLine = $CommandResult.UninstallCommandLine + if ($CommandResult.DetectionScriptContent) { + $IntuneBody.detectionRules[0].scriptContent = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($CommandResult.DetectionScriptContent)) + } + } + # Load files based on app type (only for types that need them) $Intunexml = $null $Infile = $null diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-AddMSPApp.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-AddMSPApp.ps1 index 2c703956fad93..cf34067491c1c 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-AddMSPApp.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/Applications/Invoke-AddMSPApp.ps1 @@ -34,57 +34,15 @@ function Invoke-AddMSPApp { $SuccessCount = 0 $ErrorCount = 0 $Results = foreach ($Tenant in $Tenants) { - $InstallParams = [PSCustomObject]$RMMApp.params - switch ($RmmName) { - 'datto' { - Write-Host 'Processing Datto installation' - $DattoUrl = ConvertTo-CIPPSafePwshArg -Value ([string]$InstallParams.DattoURL) - $DattoGuid = ConvertTo-CIPPSafePwshArg -Value ([string]$InstallParams.DattoGUID."$($Tenant.customerId)") - $installCommandLine = "powershell.exe -ExecutionPolicy Bypass .\install.ps1 -URL $DattoUrl -GUID $DattoGuid" - $uninstallCommandLine = 'powershell.exe -ExecutionPolicy Bypass .\uninstall.ps1' - } - 'ninja' { - Write-Host 'Processing Ninja installation' - $NinjaPackage = ConvertTo-CIPPSafePwshArg -Value ([string]$RMMApp.PackageName) - $installCommandLine = "powershell.exe -ExecutionPolicy Bypass .\install.ps1 -InstallParam $NinjaPackage" - $uninstallCommandLine = 'powershell.exe -ExecutionPolicy Bypass .\uninstall.ps1' - } - 'Huntress' { - $HuntressOrgKey = ConvertTo-CIPPSafePwshArg -Value ([string]$InstallParams.Orgkey."$($Tenant.customerId)") - $HuntressAccountKey = ConvertTo-CIPPSafePwshArg -Value ([string]$InstallParams.AccountKey) - $installCommandLine = "powershell.exe -ExecutionPolicy Bypass .\install.ps1 -OrgKey $HuntressOrgKey -acctkey $HuntressAccountKey" - $uninstallCommandLine = 'powershell.exe -ExecutionPolicy Bypass .\install.ps1 -Uninstall' - } - 'syncro' { - $SyncroUrl = ConvertTo-CIPPSafePwshArg -Value ([string]$InstallParams.ClientURL."$($Tenant.customerId)") - $installCommandLine = "powershell.exe -ExecutionPolicy Bypass .\install.ps1 -URL $SyncroUrl" - $uninstallCommandLine = 'powershell.exe -ExecutionPolicy Bypass .\uninstall.ps1' - } - 'NCentral' { - $NCentralPackage = ConvertTo-CIPPSafePwshArg -Value ([string]$RMMApp.PackageName) - $installCommandLine = "powershell.exe -ExecutionPolicy Bypass .\install.ps1 -InstallParam $NCentralPackage" - $uninstallCommandLine = 'powershell.exe -ExecutionPolicy Bypass .\uninstall.ps1' - } - 'automate' { - $AutomateServer = ConvertTo-CIPPSafePwshArg -Value ([string]$InstallParams.Server) - $AutomateInstallerToken = ConvertTo-CIPPSafePwshArg -Value ([string]$InstallParams.InstallerToken."$($Tenant.customerId)") - $AutomateLocationId = ConvertTo-CIPPSafePwshArg -Value ([string]$InstallParams.LocationID."$($Tenant.customerId)") - $installCommandLine = "c:\windows\sysnative\windowspowershell\v1.0\powershell.exe -ExecutionPolicy Bypass .\install.ps1 -Server $AutomateServer -InstallerToken $AutomateInstallerToken -LocationID $AutomateLocationId" - $uninstallCommandLine = "c:\windows\sysnative\windowspowershell\v1.0\powershell.exe -ExecutionPolicy Bypass .\uninstall.ps1 -Server $AutomateServer" - $DetectionScript = (Get-Content 'AddMSPApp\automate.detection.ps1' -Raw) -replace '##SERVER##', $InstallParams.Server - $intuneBody.detectionRules[0].scriptContent = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($DetectionScript)) - } - 'cwcommand' { - $CwClientUrl = ConvertTo-CIPPSafePwshArg -Value ([string]$InstallParams.ClientURL."$($Tenant.customerId)") - $installCommandLine = "powershell.exe -ExecutionPolicy Bypass .\install.ps1 -Url $CwClientUrl" - $uninstallCommandLine = 'powershell.exe -ExecutionPolicy Bypass .\uninstall.ps1' - } - default { - throw "Unknown MSP app type '$RmmName'" - } + # Build the install/uninstall command lines for this tenant. Get-CIPPMSPAppInstallCommand + # resolves each param whether it is a per-tenant keyed value (interactive deploy) or a + # flat value / %CIPP variable% (Application Template deploy). + $CommandResult = Get-CIPPMSPAppInstallCommand -RmmName $RmmName -Params $RMMApp.params -Tenant $Tenant -PackageName $RMMApp.PackageName + $intuneBody.installCommandLine = $CommandResult.InstallCommandLine + $intuneBody.UninstallCommandLine = $CommandResult.UninstallCommandLine + if ($CommandResult.DetectionScriptContent) { + $intuneBody.detectionRules[0].scriptContent = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($CommandResult.DetectionScriptContent)) } - $intuneBody.installCommandLine = $installCommandLine - $intuneBody.UninstallCommandLine = $uninstallCommandLine try { From b17579056401fda266e92e8cabc84838ab1fbedf Mon Sep 17 00:00:00 2001 From: Bobby <31723128+kris6673@users.noreply.github.com> Date: Thu, 2 Jul 2026 00:44:46 +0200 Subject: [PATCH 086/170] fix: sync mailbox permission cache after changes ExecModifyMBPerms executes EXO cmdlets itself via -AsCmdletObject, which bypasses Set-CIPPMailboxPermission's execute-mode cache sync, so every change through this endpoint left the cached permission report stale until the next full sync. Track which operations actually succeeded and sync each cacheable one. --- .../Invoke-ExecModifyMBPerms.ps1 | 20 ++++- .../Invoke-ExecModifyMBPerms.Tests.ps1 | 78 +++++++++++++++++++ 2 files changed, 97 insertions(+), 1 deletion(-) diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ExecModifyMBPerms.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ExecModifyMBPerms.ps1 index 3f92aa8715991..d2f73c23fe76c 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ExecModifyMBPerms.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ExecModifyMBPerms.ps1 @@ -15,6 +15,7 @@ function Invoke-ExecModifyMBPerms { # Extract mailbox requests - handle all three formats $MailboxRequests = $null $Results = [System.Collections.ArrayList]::new() + $SuccessfulOps = [System.Collections.ArrayList]::new() # Direct array format if ($Request.Body -is [array]) { @@ -155,7 +156,7 @@ function Invoke-ExecModifyMBPerms { Mailbox = $Username TargetUser = $TargetUser Permission = $PermissionLevel - Action = $Modification + Action = $Action OperationGuid = $OperationGuid } @@ -202,6 +203,7 @@ function Invoke-ExecModifyMBPerms { Write-LogMessage -headers $Headers -API $APIName -message "Error for operation $operationGuid`: $ErrorMessage" -Sev 'Error' -tenant $TenantFilter } else { $null = $Results.Add($metadata.ExpectedResult) + $null = $SuccessfulOps.Add($metadata) Write-LogMessage -headers $Headers -API $APIName -message "Success for operation $operationGuid`: $($metadata.ExpectedResult)" -Sev 'Info' -tenant $TenantFilter } } else { @@ -222,6 +224,7 @@ function Invoke-ExecModifyMBPerms { foreach ($CmdletMetadata in $CmdletMetadataArray) { if ($CmdletMetadata.ExpectedResult) { $null = $Results.Add($CmdletMetadata.ExpectedResult) + $null = $SuccessfulOps.Add($CmdletMetadata) } } } @@ -237,6 +240,7 @@ function Invoke-ExecModifyMBPerms { try { $null = New-ExoRequest -Anchor $CmdletMetadata.Mailbox -tenantid $TenantFilter -cmdlet $CmdletObj.CmdletInput.CmdletName -cmdParams $CmdletObj.CmdletInput.Parameters $null = $Results.Add($CmdletMetadata.ExpectedResult) + $null = $SuccessfulOps.Add($CmdletMetadata) } catch { $null = $Results.Add("Error processing $($CmdletMetadata.Permission) for $($CmdletMetadata.TargetUser) on $($CmdletMetadata.Mailbox): $($_.Exception.Message)") } @@ -249,6 +253,7 @@ function Invoke-ExecModifyMBPerms { try { $null = New-ExoRequest -Anchor $CmdletMetadata.Mailbox -tenantid $TenantFilter -cmdlet $CmdletObj.CmdletInput.CmdletName -cmdParams $CmdletObj.CmdletInput.Parameters $null = $Results.Add($CmdletMetadata.ExpectedResult) + $null = $SuccessfulOps.Add($CmdletMetadata) Write-LogMessage -headers $Headers -API $APIName -message "Executed $($CmdletMetadata.Permission) permission modification" -Sev 'Info' -tenant $TenantFilter } catch { Write-LogMessage -headers $Headers -API $APIName -message "Permission modification failed: $($_.Exception.Message)" -Sev 'Error' -tenant $TenantFilter @@ -256,6 +261,19 @@ function Invoke-ExecModifyMBPerms { } } + # Keep the reporting DB cache in step with what actually changed. The bulk path bypasses + # Set-CIPPMailboxPermission's own execute-mode sync (-AsCmdletObject returns early), so + # without this the cached permission report goes stale after every bulk change. + foreach ($Op in $SuccessfulOps) { + if ($Op.Permission -in @('FullAccess', 'SendAs', 'SendOnBehalf')) { + try { + Sync-CIPPMailboxPermissionCache -TenantFilter $TenantFilter -MailboxIdentity $Op.Mailbox -User $Op.TargetUser -PermissionType $Op.Permission -Action $Op.Action + } catch { + Write-Information "Cache sync warning: $($_.Exception.Message)" + } + } + } + $body = [pscustomobject]@{'Results' = @($Results) } return ([HttpResponseContext]@{ StatusCode = [HttpStatusCode]::OK diff --git a/Tests/Endpoint/Invoke-ExecModifyMBPerms.Tests.ps1 b/Tests/Endpoint/Invoke-ExecModifyMBPerms.Tests.ps1 index 1bb74948d83a5..c1e09adb97cf3 100644 --- a/Tests/Endpoint/Invoke-ExecModifyMBPerms.Tests.ps1 +++ b/Tests/Endpoint/Invoke-ExecModifyMBPerms.Tests.ps1 @@ -34,6 +34,7 @@ BeforeAll { function New-ExoBulkRequest { param($tenantid, $cmdletArray, $ReturnWithCommand) } function Get-CippException { param($Exception) } function Write-LogMessage { param($headers, $API, $tenant, $message, $Sev, $LogData) } + function Sync-CIPPMailboxPermissionCache { param($TenantFilter, $MailboxIdentity, $User, $PermissionType, $Action) } . $PermissionFunctionPath . $FunctionPath @@ -82,6 +83,7 @@ Describe 'Invoke-ExecModifyMBPerms' { } Mock -CommandName Get-CippException -MockWith { [pscustomobject]@{ NormalizedError = 'boom' } } Mock -CommandName Write-LogMessage -MockWith { } + Mock -CommandName Sync-CIPPMailboxPermissionCache -MockWith { } } Context 'Single-operation execution and per-level mapping' { @@ -247,6 +249,82 @@ Describe 'Invoke-ExecModifyMBPerms' { } } + Context 'Cache sync' { + # The endpoint executes cmdlets itself (bypassing Set-CIPPMailboxPermission's execute-mode + # sync), so it must sync the reporting-DB cache for every operation that succeeded. + It 'syncs the cache after a successful single Remove' { + $req = New-ModifyRequest -Mailboxes @(New-Mailbox -Permissions @(New-Perm -Level 'FullAccess' -Modification 'Remove')) + + Invoke-ExecModifyMBPerms -Request $req -TriggerMetadata $null + + Should -Invoke Sync-CIPPMailboxPermissionCache -Times 1 -Exactly -ParameterFilter { + $TenantFilter -eq 'contoso.com' -and $MailboxIdentity -eq 'shared@contoso.com' -and + $User -eq 'user@contoso.com' -and $PermissionType -eq 'FullAccess' -and $Action -eq 'Remove' + } + } + + It 'syncs with Action Add for additions' { + $req = New-ModifyRequest -Mailboxes @(New-Mailbox -Permissions @(New-Perm -Level 'SendAs' -Modification 'Add')) + + Invoke-ExecModifyMBPerms -Request $req -TriggerMetadata $null + + Should -Invoke Sync-CIPPMailboxPermissionCache -Times 1 -Exactly -ParameterFilter { + $PermissionType -eq 'SendAs' -and $Action -eq 'Add' + } + } + + It 'syncs every successful operation in a bulk request' { + $req = New-ModifyRequest -Mailboxes @(New-Mailbox -Permissions @( + (New-Perm -Level 'FullAccess' -Modification 'Remove') + (New-Perm -Level 'SendOnBehalf' -Modification 'Remove') + )) + + Invoke-ExecModifyMBPerms -Request $req -TriggerMetadata $null + + Should -Invoke Sync-CIPPMailboxPermissionCache -Times 1 -Exactly -ParameterFilter { $PermissionType -eq 'FullAccess' } + Should -Invoke Sync-CIPPMailboxPermissionCache -Times 1 -Exactly -ParameterFilter { $PermissionType -eq 'SendOnBehalf' } + } + + It 'does not sync operations that failed in the bulk response' { + # FullAccess Remove (Remove-MailboxPermission) succeeds; SendAs Remove + # (Remove-RecipientPermission) comes back with an error attached. + Mock -CommandName New-ExoBulkRequest -MockWith { + param($tenantid, $cmdletArray, $ReturnWithCommand) + $h = @{} + foreach ($c in $cmdletArray) { + $name = $c.CmdletInput.CmdletName + if (-not $h.ContainsKey($name)) { $h[$name] = @() } + $entry = [pscustomobject]@{ OperationGuid = $c.OperationGuid } + if ($name -eq 'Remove-RecipientPermission') { + $entry | Add-Member -NotePropertyName error -NotePropertyValue 'denied' + } + $h[$name] += $entry + } + $h + } + $req = New-ModifyRequest -Mailboxes @(New-Mailbox -Permissions @( + (New-Perm -Level 'FullAccess' -Modification 'Remove') + (New-Perm -Level 'SendAs' -Modification 'Remove') + )) + + Invoke-ExecModifyMBPerms -Request $req -TriggerMetadata $null + + Should -Invoke Sync-CIPPMailboxPermissionCache -Times 1 -Exactly + Should -Invoke Sync-CIPPMailboxPermissionCache -Times 1 -Exactly -ParameterFilter { $PermissionType -eq 'FullAccess' } + } + + It 'does not sync non-cacheable permission levels' { + # Comma-joined levels are split per operation; ReadPermission executes but has no + # cache representation, so only the FullAccess half syncs. + $req = New-ModifyRequest -Mailboxes @(New-Mailbox -Permissions @(New-Perm -Level 'FullAccess, ReadPermission' -Modification 'Remove')) + + Invoke-ExecModifyMBPerms -Request $req -TriggerMetadata $null + + Should -Invoke Sync-CIPPMailboxPermissionCache -Times 1 -Exactly + Should -Invoke Sync-CIPPMailboxPermissionCache -Times 1 -Exactly -ParameterFilter { $PermissionType -eq 'FullAccess' } + } + } + Context 'Guards' { It 'returns BadRequest when no mailbox requests are provided' { $req = [pscustomobject]@{ From 84945c6da1beb40c76bee3ed0807378c4817549e Mon Sep 17 00:00:00 2001 From: Bobby <31723128+kris6673@users.noreply.github.com> Date: Thu, 2 Jul 2026 00:44:53 +0200 Subject: [PATCH 087/170] feat: filter permission report by user Lets the frontend fetch a single user's mailbox access from the cached report without downloading the whole tenant's data. --- .../Administration/Invoke-ListmailboxPermissions.ps1 | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ListmailboxPermissions.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ListmailboxPermissions.ps1 index 37468a9c1b209..97a265af98989 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ListmailboxPermissions.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ListmailboxPermissions.ps1 @@ -14,6 +14,7 @@ function Invoke-ListmailboxPermissions { $UserID = $Request.Query.userId $UseReportDB = $Request.Query.UseReportDB $ByUser = $Request.Query.ByUser + $User = $Request.Query.User try { # If UseReportDB is specified and no specific UserID, retrieve from report database @@ -28,6 +29,9 @@ function Invoke-ListmailboxPermissions { } try { $GraphRequest = Get-CIPPMailboxPermissionReport @ReportParams + if ($User) { + $GraphRequest = @($GraphRequest | Where-Object { $_.User -eq $User }) + } $StatusCode = [HttpStatusCode]::OK } catch { $StatusCode = [HttpStatusCode]::InternalServerError From 15c545ff1f124e3df7c7bb647a779ac0ce93d2c7 Mon Sep 17 00:00:00 2001 From: Bobby <31723128+kris6673@users.noreply.github.com> Date: Thu, 2 Jul 2026 10:28:59 +0200 Subject: [PATCH 088/170] fix: sync cache with resolved UPN, guard filter Cache rows are keyed by mailbox UPN, so syncing with the raw request identifier missed the row whenever the caller passed a Graph object id. The new User query filter also emptied the mailbox-grouped report shape, which has no top-level User property; it now only applies alongside ByUser=true. --- .../Administration/Invoke-ExecModifyMBPerms.ps1 | 4 +++- .../Administration/Invoke-ListmailboxPermissions.ps1 | 4 +++- Tests/Endpoint/Invoke-ExecModifyMBPerms.Tests.ps1 | 12 ++++++++++++ 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ExecModifyMBPerms.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ExecModifyMBPerms.ps1 index d2f73c23fe76c..3b1d822a3dfcc 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ExecModifyMBPerms.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ExecModifyMBPerms.ps1 @@ -151,9 +151,11 @@ function Invoke-ExecModifyMBPerms { OperationGuid = $OperationGuid # Add GUID to cmdlet object } + # Use the resolved UPN, not the raw request identifier (which may be an + # object id) - the cache sync below matches cached rows by mailbox UPN. $CmdletMetadata = [PSCustomObject]@{ ExpectedResult = $Mapping.ExpectedResult - Mailbox = $Username + Mailbox = $UserId TargetUser = $TargetUser Permission = $PermissionLevel Action = $Action diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ListmailboxPermissions.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ListmailboxPermissions.ps1 index 97a265af98989..18099cd59af71 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ListmailboxPermissions.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Email-Exchange/Administration/Invoke-ListmailboxPermissions.ps1 @@ -29,7 +29,9 @@ function Invoke-ListmailboxPermissions { } try { $GraphRequest = Get-CIPPMailboxPermissionReport @ReportParams - if ($User) { + if ($User -and $ByUser -eq 'true') { + # Only the user-grouped report has a top-level User property; in the + # mailbox-grouped shape this filter would empty the whole result set. $GraphRequest = @($GraphRequest | Where-Object { $_.User -eq $User }) } $StatusCode = [HttpStatusCode]::OK diff --git a/Tests/Endpoint/Invoke-ExecModifyMBPerms.Tests.ps1 b/Tests/Endpoint/Invoke-ExecModifyMBPerms.Tests.ps1 index c1e09adb97cf3..cf12187b65f0f 100644 --- a/Tests/Endpoint/Invoke-ExecModifyMBPerms.Tests.ps1 +++ b/Tests/Endpoint/Invoke-ExecModifyMBPerms.Tests.ps1 @@ -263,6 +263,18 @@ Describe 'Invoke-ExecModifyMBPerms' { } } + It 'syncs with the resolved UPN when the request identifies the mailbox by object id' { + # Cached permission rows are keyed by mailbox UPN, so the sync must use what the + # Graph lookup resolved (BeforeEach mock: shared@contoso.com), not the raw request id. + $req = New-ModifyRequest -Mailboxes @(New-Mailbox -UserID '11111111-2222-3333-4444-555555555555' -Permissions @(New-Perm -Level 'FullAccess' -Modification 'Remove')) + + Invoke-ExecModifyMBPerms -Request $req -TriggerMetadata $null + + Should -Invoke Sync-CIPPMailboxPermissionCache -Times 1 -Exactly -ParameterFilter { + $MailboxIdentity -eq 'shared@contoso.com' + } + } + It 'syncs with Action Add for additions' { $req = New-ModifyRequest -Mailboxes @(New-Mailbox -Permissions @(New-Perm -Level 'SendAs' -Modification 'Add')) From 81e7900ca0cb03a1ac084a34f3522fbe102196ee Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Thu, 2 Jul 2026 17:21:06 +0800 Subject: [PATCH 089/170] Correct missing templates from policy compare page and endpoint --- .../CIPPCore/Public/Get-CIPPIntunePolicy.ps1 | 49 +++++++++++++++++++ .../MEM/Invoke-ExecCompareIntunePolicy.ps1 | 2 + .../Endpoint/MEM/Invoke-ListIntunePolicy.ps1 | 27 ++++++++-- 3 files changed, 75 insertions(+), 3 deletions(-) diff --git a/Modules/CIPPCore/Public/Get-CIPPIntunePolicy.ps1 b/Modules/CIPPCore/Public/Get-CIPPIntunePolicy.ps1 index 9d9143b631512..dfa8a52eec933 100644 --- a/Modules/CIPPCore/Public/Get-CIPPIntunePolicy.ps1 +++ b/Modules/CIPPCore/Public/Get-CIPPIntunePolicy.ps1 @@ -429,6 +429,55 @@ function Get-CIPPIntunePolicy { return $policies } } + 'Intents' { + $PlatformType = 'deviceManagement' + $TemplateTypeURL = 'intents' + + if ($DisplayName) { + $policies = New-GraphGETRequest -uri "https://graph.microsoft.com/beta/$PlatformType/$TemplateTypeURL" -tenantid $tenantFilter + $policy = $policies | Where-Object -Property displayName -EQ $DisplayName | Sort-Object -Property lastModifiedDateTime -Descending | Select-Object -First 1 + if ($policy) { + $settings = New-GraphGETRequest -uri "https://graph.microsoft.com/beta/$PlatformType/$TemplateTypeURL('$($policy.id)')/settings" -tenantid $tenantFilter + $policyDetails = [PSCustomObject]@{ + displayName = $policy.displayName + description = $policy.description + templateId = $policy.templateId + settings = @($settings) + } + $policyJson = ConvertTo-Json -InputObject $policyDetails -Depth 100 -Compress + $policy | Add-Member -MemberType NoteProperty -Name 'cippconfiguration' -Value $policyJson -Force + } + return $policy + } elseif ($PolicyId) { + $policy = New-GraphGETRequest -uri "https://graph.microsoft.com/beta/$PlatformType/$TemplateTypeURL('$PolicyId')" -tenantid $tenantFilter + if ($policy) { + $settings = New-GraphGETRequest -uri "https://graph.microsoft.com/beta/$PlatformType/$TemplateTypeURL('$PolicyId')/settings" -tenantid $tenantFilter + $policyDetails = [PSCustomObject]@{ + displayName = $policy.displayName + description = $policy.description + templateId = $policy.templateId + settings = @($settings) + } + $policyJson = ConvertTo-Json -InputObject $policyDetails -Depth 100 -Compress + $policy | Add-Member -MemberType NoteProperty -Name 'cippconfiguration' -Value $policyJson -Force + } + return $policy + } else { + $policies = New-GraphGETRequest -uri "https://graph.microsoft.com/beta/$PlatformType/$TemplateTypeURL" -tenantid $tenantFilter + foreach ($policy in $policies) { + $settings = New-GraphGETRequest -uri "https://graph.microsoft.com/beta/$PlatformType/$TemplateTypeURL('$($policy.id)')/settings" -tenantid $tenantFilter + $policyDetails = [PSCustomObject]@{ + displayName = $policy.displayName + description = $policy.description + templateId = $policy.templateId + settings = @($settings) + } + $policyJson = ConvertTo-Json -InputObject $policyDetails -Depth 100 -Compress + $policy | Add-Member -MemberType NoteProperty -Name 'cippconfiguration' -Value $policyJson -Force + } + return $policies + } + } default { return $null } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecCompareIntunePolicy.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecCompareIntunePolicy.ps1 index 26f924e6e1958..a74d26c71b0a5 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecCompareIntunePolicy.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ExecCompareIntunePolicy.ps1 @@ -21,6 +21,7 @@ function Invoke-ExecCompareIntunePolicy { 'WindowsFeatureUpdateProfiles' = 'windowsFeatureUpdateProfiles' 'windowsQualityUpdatePolicies' = 'windowsQualityUpdatePolicies' 'windowsQualityUpdateProfiles' = 'windowsQualityUpdateProfiles' + 'Intents' = 'Intents' } try { @@ -125,6 +126,7 @@ function Invoke-ExecCompareIntunePolicy { '*configurationPolicies*' { 'Catalog' } '*managedAppPolicies*' { 'AppProtection' } '*deviceAppManagement*' { 'AppProtection' } + '*intents*' { 'Intents' } default { 'Unknown' } } $PolicyObj = $ParsedJson diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListIntunePolicy.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListIntunePolicy.ps1 index 0354734b23238..94e1bf627f379 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListIntunePolicy.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListIntunePolicy.ps1 @@ -1,4 +1,3 @@ - function Invoke-ListIntunePolicy { <# .FUNCTIONALITY @@ -216,6 +215,16 @@ function Invoke-ListIntunePolicy { method = 'GET' url = "/deviceManagement/configurationPolicies?`$expand=assignments&`$top=1000" } + @{ + id = 'deviceCompliancePolicies' + method = 'GET' + url = "/deviceManagement/deviceCompliancePolicies?`$expand=assignments&`$top=1000" + } + @{ + id = 'Intents' + method = 'GET' + url = "/deviceManagement/intents?`$top=1000" + } ) $BulkResults = New-GraphBulkRequest -Requests $BulkRequests -tenantid $TenantFilter @@ -226,7 +235,8 @@ function Invoke-ListIntunePolicy { $GraphRequest = $BulkResults | Where-Object { $_.id -ne 'Groups' } | ForEach-Object { $URLName = $_.Id $_.body.Value | ForEach-Object { - $policyTypeName = switch -Wildcard ($_.'assignments@odata.context') { + $AssignmentContext = $_.'assignments@odata.context' + $policyTypeName = switch -Wildcard ($AssignmentContext) { '*microsoft.graph.windowsIdentityProtectionConfiguration*' { 'Identity Protection' } '*microsoft.graph.windows10EndpointProtectionConfiguration*' { 'Endpoint Protection' } '*microsoft.graph.windows10CustomConfiguration*' { 'Custom' } @@ -244,7 +254,18 @@ function Invoke-ListIntunePolicy { '*iosUpdateConfiguration*' { 'iOS Update Configuration' } '*windowsDriverUpdateProfiles*' { 'Driver Update' } '*configurationPolicies*' { 'Device Configuration' } - default { $_.'assignments@odata.context' } + '*deviceCompliancePolicies*' { 'Compliance Policy' } + '*intents*' { 'Endpoint Security' } + default { $null } + } + # Fall back to the request type when the assignment context does not identify the policy + # (e.g. Intents are listed without expanding assignments). + if ([string]::IsNullOrWhiteSpace($policyTypeName)) { + $policyTypeName = switch ($URLName) { + 'deviceCompliancePolicies' { 'Compliance Policy' } + 'Intents' { 'Endpoint Security' } + default { $AssignmentContext } + } } $Assignments = $_.assignments.target | Select-Object -Property '@odata.type', groupId $PolicyAssignment = [System.Collections.Generic.List[string]]::new() From dbc414a85320e51caa251c217dd258f6290c052f Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Thu, 2 Jul 2026 17:24:37 +0800 Subject: [PATCH 090/170] fix logging semantics --- .../Tenant/Standards/Invoke-listStandardTemplates.ps1 | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-listStandardTemplates.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-listStandardTemplates.ps1 index 8f844ddb9de77..eb71fa0dcbfb2 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-listStandardTemplates.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-listStandardTemplates.ps1 @@ -35,7 +35,8 @@ function Invoke-listStandardTemplates { } -Force Write-LogMessage -headers $Request.Headers -API 'Standards' -message "Standards template '$($RowKey)' contained corrupt data (case-duplicate keys) and was automatically repaired and re-saved." -Sev 'Warning' } catch { - Write-LogMessage -headers $Request.Headers -API 'Standards' -message "Standards template '$($RowKey)' was repaired for this response but could not be re-saved: $($_.Exception.Message)" -Sev 'Warning' + Write-LogMessage -headers $Request.Headers -API 'Standards' -message "Standards template '$($RowKey)' was repaired but could not be re-saved, so it was omitted from the response: $($_.Exception.Message)" -Sev 'Error' + return } } if ($Data) { From bb1e10de1de194edfb1bbd5832f3c8d11dbdf638 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Thu, 2 Jul 2026 18:10:10 +0800 Subject: [PATCH 091/170] Add Stale Entra Devices Remediation Action --- Config/standards.json | 21 +- .../Invoke-CIPPStandardStaleEntraDevices.ps1 | 207 ++++++++++++++---- 2 files changed, 183 insertions(+), 45 deletions(-) diff --git a/Config/standards.json b/Config/standards.json index f2384c901ae76..5a8d0a83eba9f 100644 --- a/Config/standards.json +++ b/Config/standards.json @@ -1656,27 +1656,38 @@ "name": "standards.StaleEntraDevices", "cat": "Entra (AAD) Standards", "tag": ["Essential 8 (1501)", "NIST CSF 2.0 (ID.AM-08)", "NIST CSF 2.0 (PR.PS-03)"], - "helpText": "**Remediate is currently not available**. Cleans up Entra devices that have not connected/signed in for the specified number of days.", - "docsDescription": "Remediate is currently not available. Cleans up Entra devices that have not connected/signed in for the specified number of days. First disables and later deletes the devices. More info can be found in the [Microsoft documentation](https://learn.microsoft.com/en-us/entra/identity/devices/manage-stale-devices)", + "helpText": "Cleans up Entra devices that have not connected/signed in for the specified number of days. Remediation first disables stale enabled devices and, on a later run, deletes stale devices that are already disabled. Hybrid-joined, Intune-managed and Autopilot devices are skipped. Deleting a device permanently removes any BitLocker recovery keys stored on it.", + "docsDescription": "Cleans up Entra devices that have not connected/signed in for the specified number of days. Remediation first disables stale enabled devices once they pass the disable threshold, and later deletes devices that are already disabled once they have been inactive for the disable threshold plus the configured grace delta (deletion age = disable threshold + grace days). The disable-before-delete grace period is further guaranteed by never deleting a device in the same pass it was disabled. Hybrid-joined (on-premises synced), Intune-managed/compliant, and system-managed Autopilot devices are excluded, in line with the [Microsoft guidance](https://learn.microsoft.com/en-us/entra/identity/devices/manage-stale-devices). **Warning:** deleting a device permanently removes any BitLocker recovery keys stored on that device object.", "executiveText": "Automatically identifies and removes inactive devices that haven't connected to company systems for a specified period, reducing security risks from abandoned or lost devices. This maintains a clean device inventory and prevents potential unauthorized access through dormant device registrations.", "addedComponent": [ { "type": "number", "name": "standards.StaleEntraDevices.deviceAgeThreshold", - "label": "Days before stale(Do not set below 30)", + "required": true, + "defaultValue": 90, + "label": "Days before stale (disables the device after this many days of inactivity, minimum 30)", "validators": { "min": { "value": 30, "message": "Minimum value is 30" } } + }, + { + "type": "number", + "name": "standards.StaleEntraDevices.deviceDeleteThreshold", + "defaultValue": 0, + "label": "Grace days after disable before deletion (0 = never delete). Devices are deleted once inactive for the disable threshold plus this many additional days.", + "validators": { + "min": { "value": 0, "message": "Minimum value is 0" } + } } ], - "disabledFeatures": { "report": false, "warn": false, "remediate": true }, + "disabledFeatures": { "report": false, "warn": false, "remediate": false }, "label": "Cleanup stale Entra devices", "impact": "High Impact", "impactColour": "danger", "addedDate": "2025-01-19", "powershellEquivalent": "Remove-MgDevice, Update-MgDevice or Graph API", "recommendedBy": [], - "requiredCapabilities": ["INTUNE_A", "MDM_Services", "EMS", "SCCM", "MICROSOFTINTUNEPLAN1"] + "requiredCapabilities": [] }, { "name": "standards.UndoOauth", diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardStaleEntraDevices.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardStaleEntraDevices.ps1 index 3ad06b586deae..726cb64bf0d49 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardStaleEntraDevices.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardStaleEntraDevices.ps1 @@ -7,8 +7,8 @@ function Invoke-CIPPStandardStaleEntraDevices { .SYNOPSIS (Label) Cleanup stale Entra devices .DESCRIPTION - (Helptext) **Remediate is currently not available**. Cleans up Entra devices that have not connected/signed in for the specified number of days. - (DocsDescription) Remediate is currently not available. Cleans up Entra devices that have not connected/signed in for the specified number of days. First disables and later deletes the devices. More info can be found in the [Microsoft documentation](https://learn.microsoft.com/en-us/entra/identity/devices/manage-stale-devices) + (Helptext) Cleans up Entra devices that have not connected/signed in for the specified number of days. Remediation first disables stale enabled devices and, on a later run, deletes stale devices that are already disabled. Hybrid-joined, Intune-managed and Autopilot devices are skipped. Deleting a device permanently removes any BitLocker recovery keys stored on it. + (DocsDescription) Cleans up Entra devices that have not connected/signed in for the specified number of days. Remediation first disables stale enabled devices once they pass the disable threshold, and later deletes devices that are already disabled once they have been inactive for the disable threshold plus the configured grace delta (deletion age = disable threshold + grace days). The disable-before-delete grace period is further guaranteed by never deleting a device in the same pass it was disabled. Hybrid-joined (on-premises synced), Intune-managed/compliant, and system-managed Autopilot devices are excluded, in line with the [Microsoft guidance](https://learn.microsoft.com/en-us/entra/identity/devices/manage-stale-devices). **Warning:** deleting a device permanently removes any BitLocker recovery keys stored on that device object. .NOTES CAT Entra (AAD) Standards @@ -19,9 +19,10 @@ function Invoke-CIPPStandardStaleEntraDevices { EXECUTIVETEXT Automatically identifies and removes inactive devices that haven't connected to company systems for a specified period, reducing security risks from abandoned or lost devices. This maintains a clean device inventory and prevents potential unauthorized access through dormant device registrations. ADDEDCOMPONENT - {"type":"number","name":"standards.StaleEntraDevices.deviceAgeThreshold","label":"Days before stale(Do not set below 30)","validators":{"min":{"value":30,"message":"Minimum value is 30"}}} + {"type":"number","name":"standards.StaleEntraDevices.deviceAgeThreshold","label":"Days before stale (disables the device after this many days of inactivity, minimum 30)","required":true,"defaultValue":90,"validators":{"min":{"value":30,"message":"Minimum value is 30"}}} + {"type":"number","name":"standards.StaleEntraDevices.deviceDeleteThreshold","label":"Grace days after disable before deletion (0 = never delete). Devices are deleted once inactive for the disable threshold plus this many additional days.","defaultValue":0,"validators":{"min":{"value":0,"message":"Minimum value is 0"}}} DISABLEDFEATURES - {"report":false,"warn":false,"remediate":true} + {"report":false,"warn":false,"remediate":false} IMPACT High Impact ADDEDDATE @@ -30,11 +31,6 @@ function Invoke-CIPPStandardStaleEntraDevices { Remove-MgDevice, Update-MgDevice or Graph API RECOMMENDEDBY REQUIREDCAPABILITIES - "INTUNE_A" - "MDM_Services" - "EMS" - "SCCM" - "MICROSOFTINTUNEPLAN1" UPDATECOMMENTBLOCK Run the Tools\Update-StandardsComments.ps1 script to update this comment block .LINK @@ -42,72 +38,203 @@ function Invoke-CIPPStandardStaleEntraDevices { #> param($Tenant, $Settings) - $TestResult = Test-CIPPStandardLicense -StandardName 'StaleEntraDevices' -TenantFilter $Tenant -Preset Intune - # Get all Entra devices + # Safety guard: never run below the supported minimum. A blank or low threshold would otherwise treat + # every device with any sign-in as stale and disable the entire fleet. + $DisableThreshold = if ([string]::IsNullOrWhiteSpace([string]$Settings.deviceAgeThreshold)) { 0 } else { [int]$Settings.deviceAgeThreshold } + if ($DisableThreshold -lt 30) { + Write-LogMessage -API 'Standards' -Tenant $Tenant -Message "StaleEntraDevices: deviceAgeThreshold ($DisableThreshold) is below the minimum of 30 days. Skipping run to prevent mass device changes." -Sev Error + return + } - if ($TestResult -eq $false) { - return $true - } #we're done. + # deviceDeleteThreshold is a delta (grace days) added on top of the disable threshold, so the effective + # delete age is always greater than the disable age - deletion can never overtake disable by construction. + $DeleteDelta = if ([string]::IsNullOrWhiteSpace([string]$Settings.deviceDeleteThreshold)) { 0 } else { [int]$Settings.deviceDeleteThreshold } + if ($DeleteDelta -lt 0) { $DeleteDelta = 0 } + $DeleteEnabled = $DeleteDelta -gt 0 + $DeleteAge = $DisableThreshold + $DeleteDelta try { - $AllDevices = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/devices?$select=id,displayName,approximateLastSignInDateTime,accountEnabled,enrollmentProfileName,operatingSystem,managementType,profileType' -tenantid $Tenant | Where-Object { $null -ne $_.approximateLastSignInDateTime } + $AllDevices = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/devices?$select=id,displayName,approximateLastSignInDateTime,accountEnabled,enrollmentProfileName,operatingSystem,managementType,profileType,onPremisesSyncEnabled,isManaged,isCompliant,physicalIds' -tenantid $Tenant | Where-Object { $null -ne $_.approximateLastSignInDateTime } } catch { $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message Write-LogMessage -API 'Standards' -Tenant $Tenant -Message "Could not get the StaleEntraDevices state for $Tenant. Error: $ErrorMessage" -Sev Error return } - $Date = (Get-Date).AddDays( - [int]$Settings.deviceAgeThreshold) - $StaleDevices = $AllDevices | Where-Object { $_.approximateLastSignInDateTime -lt $Date } + $DisableDate = (Get-Date).AddDays(-$DisableThreshold) + $DeleteDate = (Get-Date).AddDays(-$DeleteAge) + + # Devices are excluded from remediation when they are hybrid-joined (managed on-premises via Entra Connect), + # Intune managed/compliant (should be retired in Intune first), or system-managed Autopilot devices + # (identified by a ZTDID in physicalIds - deleting these breaks re-provisioning and cannot be undone). + $SafetyFilter = { + $_.onPremisesSyncEnabled -ne $true -and + $_.isManaged -ne $true -and + $_.isCompliant -ne $true -and + (@($_.physicalIds) -join ' ') -notmatch '\[ZTDID\]' + } + + # Compute the working sets from the current device state. Re-run after remediation so alert/report reflect + # the post-remediation state. Delete only targets devices that are ALREADY disabled and stale beyond the + # delete threshold; devices disabled in this same run are not deleted until a later run (grace period). + # Dot-sourced so assignments land in this function's scope (no module-level state persists between runs). + $ComputeSets = { + $StaleDevices = @($AllDevices | Where-Object { $_.approximateLastSignInDateTime -lt $DisableDate }) + $RemediationEligibleStaleDevices = @($StaleDevices | Where-Object $SafetyFilter) + $DevicesToDisable = @($RemediationEligibleStaleDevices | Where-Object { $_.accountEnabled -eq $true }) + if ($DeleteEnabled) { + # Every safety-eligible device inactive beyond the delete age meets the delete threshold (surfaced in reports). + $DevicesMeetingDeleteThreshold = @($AllDevices | Where-Object { $_.approximateLastSignInDateTime -lt $DeleteDate } | Where-Object $SafetyFilter) + # Only those already disabled are actually deleted this run; enabled ones are disabled first and deleted later. + $DevicesToDelete = @($DevicesMeetingDeleteThreshold | Where-Object { $_.accountEnabled -ne $true }) + } else { + $DevicesMeetingDeleteThreshold = @() + $DevicesToDelete = @() + } + } + . $ComputeSets if ($Settings.remediate -eq $true) { - # TODO: Implement remediation. For others in the future that want to try this: - # Good MS guide on what to watch out for https://learn.microsoft.com/en-us/entra/identity/devices/manage-stale-devices#clean-up-stale-devices - # https://learn.microsoft.com/en-us/graph/api/device-list?view=graph-rest-beta&tabs=http - # Properties to look at: - # approximateLastSignInDateTime: For knowing when the device last signed in - # enrollmentProfileName and operatingSystem: For knowing if it's an AutoPilot device - # managementType or isManaged: For knowing if it's an Intune managed device. If it is, should be removed from Intune also. Stale intune standard could possibly be used for this. - # profileType: For knowing if it's only registered or also managed - # accountEnabled: For knowing if the device is disabled or not + $DeletedDeviceIds = [System.Collections.Generic.List[string]]::new() + $DisabledCount = 0 + $DeletedCount = 0 + $FailedCount = 0 + + if ($DevicesToDisable.Count -gt 0) { + $DisableRequests = [System.Collections.Generic.List[hashtable]]::new() + $DisableMap = @{} + $RequestId = 0 + + foreach ($Device in $DevicesToDisable) { + $CurrentId = $RequestId++ + $DisableMap[$CurrentId] = $Device + $DisableRequests.Add(@{ + id = $CurrentId + method = 'PATCH' + url = "devices/$($Device.id)" + body = @{ accountEnabled = $false } + headers = @{ + 'Content-Type' = 'application/json' + } + }) + } + + try { + $DisableResults = New-GraphBulkRequest -tenantid $Tenant -Version 'v1.0' -Requests @($DisableRequests) + foreach ($Result in $DisableResults) { + $Device = $DisableMap[[int]$Result.id] + if ($null -eq $Device) { + continue + } + + if ($Result.status -eq 200 -or $Result.status -eq 204) { + $DisabledCount++ + $Device.accountEnabled = $false + } else { + $FailedCount++ + $ErrorMessage = if ($Result.body.error.message) { $Result.body.error.message } else { "Unknown error (Status: $($Result.status))" } + Write-LogMessage -API 'Standards' -Tenant $Tenant -Message "Could not disable stale device $($Device.displayName) ($($Device.id)). Error: $ErrorMessage" -Sev Error + } + } + } catch { + $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message + $FailedCount += $DevicesToDisable.Count + Write-LogMessage -API 'Standards' -Tenant $Tenant -Message "Failed to process bulk disable stale devices request. Error: $ErrorMessage" -Sev Error + } + } + + if ($DevicesToDelete.Count -gt 0) { + $DeleteRequests = [System.Collections.Generic.List[hashtable]]::new() + $DeleteMap = @{} + $RequestId = 0 + + foreach ($Device in $DevicesToDelete) { + $CurrentId = $RequestId++ + $DeleteMap[$CurrentId] = $Device + $DeleteRequests.Add(@{ + id = $CurrentId + method = 'DELETE' + url = "devices/$($Device.id)" + }) + } + + try { + $DeleteResults = New-GraphBulkRequest -tenantid $Tenant -Version 'v1.0' -Requests @($DeleteRequests) + foreach ($Result in $DeleteResults) { + $Device = $DeleteMap[[int]$Result.id] + if ($null -eq $Device) { + continue + } + + if ($Result.status -eq 200 -or $Result.status -eq 204) { + $DeletedCount++ + $null = $DeletedDeviceIds.Add([string]$Device.id) + } else { + $FailedCount++ + $ErrorMessage = if ($Result.body.error.message) { $Result.body.error.message } else { "Unknown error (Status: $($Result.status))" } + Write-LogMessage -API 'Standards' -Tenant $Tenant -Message "Could not delete stale eligible device $($Device.displayName) ($($Device.id)). Error: $ErrorMessage" -Sev Error + } + } + } catch { + $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message + $FailedCount += $DevicesToDelete.Count + Write-LogMessage -API 'Standards' -Tenant $Tenant -Message "Failed to process bulk delete stale devices request. Error: $ErrorMessage" -Sev Error + } + } + + # Drop deleted devices, then recompute the working sets so alert/report reflect the post-remediation state. + if ($DeletedDeviceIds.Count -gt 0) { + $AllDevices = @($AllDevices | Where-Object { $_.id -notin $DeletedDeviceIds }) + } + . $ComputeSets + + # Only log when the standard actually acted; skipped devices alone never generate output. + if ($DisabledCount -gt 0 -or $DeletedCount -gt 0 -or $FailedCount -gt 0) { + Write-LogMessage -API 'Standards' -Tenant $Tenant -Message "StaleEntraDevices remediation completed. Disabled: $DisabledCount. Deleted: $DeletedCount. Failed: $FailedCount." -Sev Info + } } if ($Settings.alert -eq $true) { - if ($StaleDevices.Count -gt 0) { - Write-StandardsAlert -message "$($StaleDevices.Count) Stale devices found" -object $StaleDevices -tenant $Tenant -standardName 'StaleEntraDevices' -standardId $Settings.standardId - Write-LogMessage -API 'Standards' -tenant $Tenant -message "$($StaleDevices.Count) Stale devices found" -sev Info + # Alert only on actionable devices. Skipped devices (hybrid-joined/Intune-managed/Autopilot) are + # intentionally excluded so the alert never fires on stale devices this standard would never touch. + if ($RemediationEligibleStaleDevices.Count -gt 0) { + $AlertMessage = "$($RemediationEligibleStaleDevices.Count) stale devices requiring action found (to disable: $($DevicesToDisable.Count), meeting delete threshold: $($DevicesMeetingDeleteThreshold.Count))." + Write-StandardsAlert -message $AlertMessage -object $RemediationEligibleStaleDevices -tenant $Tenant -standardName 'StaleEntraDevices' -standardId $Settings.standardId + Write-LogMessage -API 'Standards' -tenant $Tenant -message $AlertMessage -sev Info } else { - Write-LogMessage -API 'Standards' -tenant $Tenant -message 'No stale devices found' -sev Info + Write-LogMessage -API 'Standards' -tenant $Tenant -message 'No stale devices requiring action found' -sev Info } } if ($Settings.report -eq $true) { - if ($StaleDevices.Count -gt 0) { - $StaleReport = ConvertTo-Json -InputObject ($StaleDevices | Select-Object -Property displayName, id, approximateLastSignInDateTime, accountEnabled, enrollmentProfileName, operatingSystem, managementType, profileType) -Depth 10 -Compress + # Report only on actionable devices; skipped devices (hybrid-joined/Intune-managed/Autopilot) are excluded. + if ($RemediationEligibleStaleDevices.Count -gt 0) { + $StaleReport = ConvertTo-Json -InputObject ($RemediationEligibleStaleDevices | Select-Object -Property displayName, id, approximateLastSignInDateTime, accountEnabled, enrollmentProfileName, operatingSystem, managementType, profileType) -Depth 10 -Compress Add-CIPPBPAField -FieldName 'StaleEntraDevices' -FieldValue $StaleReport -StoreAs json -Tenant $Tenant } else { Add-CIPPBPAField -FieldName 'StaleEntraDevices' -FieldValue $true -StoreAs bool -Tenant $Tenant } - if ($StaleDevices.Count -gt 0) { - $FieldValue = $StaleDevices | Select-Object -Property displayName, id, approximateLastSignInDateTime, accountEnabled, enrollmentProfileName, operatingSystem, managementType, profileType + if ($DevicesToDisable.Count -gt 0) { + $EligibleToDisableFieldValue = $DevicesToDisable | Select-Object -Property displayName, id, approximateLastSignInDateTime, accountEnabled, enrollmentProfileName, operatingSystem, managementType, profileType + } + if ($DeleteEnabled -and $DevicesMeetingDeleteThreshold.Count -gt 0) { + $MeetingDeleteThresholdFieldValue = $DevicesMeetingDeleteThreshold | Select-Object -Property displayName, id, approximateLastSignInDateTime, accountEnabled, enrollmentProfileName, operatingSystem, managementType, profileType } $CurrentValue = @{ - StaleDevicesCount = $StaleDevices.Count - StaleDevices = ($FieldValue ? @($FieldValue) :@()) - DeviceAgeThreshold = [int]$Settings.deviceAgeThreshold + EligibleDevicesToDisable = ($EligibleToDisableFieldValue ? @($EligibleToDisableFieldValue) :@()) + DevicesMeetingDeleteThreshold = if ($DeleteEnabled) { ($MeetingDeleteThresholdFieldValue ? @($MeetingDeleteThresholdFieldValue) :@()) } else { 'Deletion disabled' } } $ExpectedValue = @{ - StaleDevicesCount = 0 - StaleDevices = @() - DeviceAgeThreshold = [int]$Settings.deviceAgeThreshold + EligibleDevicesToDisable = @() + DevicesMeetingDeleteThreshold = if ($DeleteEnabled) { @() } else { 'Deletion disabled' } } Set-CIPPStandardsCompareField -FieldName 'standards.StaleEntraDevices' -CurrentValue $CurrentValue -ExpectedValue $ExpectedValue -Tenant $Tenant } From 913b8285be7d61c3ce9514b8eb11ad2c470f01a2 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Thu, 2 Jul 2026 10:21:38 -0400 Subject: [PATCH 092/170] fix: increase template depth in get-cippdrift --- Modules/CIPPCore/Public/Get-CIPPDrift.ps1 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Modules/CIPPCore/Public/Get-CIPPDrift.ps1 b/Modules/CIPPCore/Public/Get-CIPPDrift.ps1 index f877a2a4af800..30733255552fd 100644 --- a/Modules/CIPPCore/Public/Get-CIPPDrift.ps1 +++ b/Modules/CIPPCore/Public/Get-CIPPDrift.ps1 @@ -49,8 +49,8 @@ function Get-CIPPDrift { $IntuneTemplatesByGuid = @{} $AllIntuneTemplates = foreach ($RawTemplate in $RawIntuneTemplates) { try { - $JSONData = $RawTemplate.JSON | ConvertFrom-Json -Depth 10 -ErrorAction SilentlyContinue - $data = $JSONData.RAWJson | ConvertFrom-Json -Depth 10 -ErrorAction SilentlyContinue + $JSONData = $RawTemplate.JSON | ConvertFrom-Json -Depth 100 -ErrorAction SilentlyContinue + $data = $JSONData.RAWJson | ConvertFrom-Json -Depth 100 -ErrorAction SilentlyContinue $data | Add-Member -NotePropertyName 'displayName' -NotePropertyValue $JSONData.Displayname -Force $data | Add-Member -NotePropertyName 'description' -NotePropertyValue $JSONData.Description -Force $data | Add-Member -NotePropertyName 'Type' -NotePropertyValue $JSONData.Type -Force From 7c1afbfa3e52f8f0df73c91c01d78617a43581cb Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Fri, 3 Jul 2026 00:15:42 +0800 Subject: [PATCH 093/170] Use app permissions to update partner tenant sam app else use delegate permissions as fallback --- .../Public/Add-CIPPApplicationPermission.ps1 | 28 ++++++++++++---- .../Public/Add-CIPPDelegatedPermission.ps1 | 32 ++++++++++++++----- .../Settings/Invoke-ExecPermissionRepair.ps1 | 4 +-- 3 files changed, 48 insertions(+), 16 deletions(-) diff --git a/Modules/CIPPCore/Public/Add-CIPPApplicationPermission.ps1 b/Modules/CIPPCore/Public/Add-CIPPApplicationPermission.ps1 index 25481b363bccc..4f717ca4e86f2 100644 --- a/Modules/CIPPCore/Public/Add-CIPPApplicationPermission.ps1 +++ b/Modules/CIPPCore/Public/Add-CIPPApplicationPermission.ps1 @@ -4,7 +4,8 @@ function Add-CIPPApplicationPermission { $RequiredResourceAccess, $TemplateId, $ApplicationId, - $TenantFilter + $TenantFilter, + [bool]$AsApp = $false ) if ($ApplicationId -eq $env:ApplicationID -and $TenantFilter -eq $env:TenantID) { $RequiredResourceAccess = 'CIPPDefaults' @@ -57,22 +58,37 @@ function Add-CIPPApplicationPermission { Write-Information "Adding application permissions to application $ApplicationId in tenant $TenantFilter" + # Use app auth when requested, but fall back to delegated on the first failure - the app may not + # hold the application permissions needed. $UseAsApp latches to $false for the rest of the run. + $UseAsApp = $AsApp + $GetServicePrincipalList = { New-GraphGETRequest -uri "https://graph.microsoft.com/beta/servicePrincipals?`$select=AppId,id,displayName&`$top=999" -skipTokenCache $true -tenantid $TenantFilter -NoAuthCheck $true -AsApp $UseAsApp } + $ServicePrincipalList = [System.Collections.Generic.List[object]]::new() - $SPList = New-GraphGETRequest -uri "https://graph.microsoft.com/beta/servicePrincipals?`$select=AppId,id,displayName&`$top=999" -skipTokenCache $true -tenantid $TenantFilter -NoAuthCheck $true + try { + $SPList = & $GetServicePrincipalList + } catch { + if ($UseAsApp) { + Write-Information "App-auth request failed, falling back to delegated permissions: $($_.Exception.Message)" + $UseAsApp = $false + $SPList = & $GetServicePrincipalList + } else { + throw + } + } foreach ($SP in $SPList) { $ServicePrincipalList.Add($SP) } $ourSVCPrincipal = $ServicePrincipalList | Where-Object -Property AppId -EQ $ApplicationId if (!$ourSVCPrincipal) { #Our Service Principal isn't available yet. We do a sleep and reexecute after 3 seconds. Start-Sleep -Seconds 5 $ServicePrincipalList.Clear() - $SPList = New-GraphGETRequest -uri "https://graph.microsoft.com/beta/servicePrincipals?`$select=AppId,id,displayName&`$top=999" -skipTokenCache $true -tenantid $TenantFilter -NoAuthCheck $true + $SPList = & $GetServicePrincipalList foreach ($SP in $SPList) { $ServicePrincipalList.Add($SP) } $ourSVCPrincipal = $ServicePrincipalList | Where-Object -Property AppId -EQ $ApplicationId } $Results = [System.Collections.Generic.List[string]]::new() - $CurrentRoles = New-GraphGETRequest -uri "https://graph.microsoft.com/beta/servicePrincipals/$($ourSVCPrincipal.id)/appRoleAssignments" -tenantid $TenantFilter -skipTokenCache $true -NoAuthCheck $true + $CurrentRoles = New-GraphGETRequest -uri "https://graph.microsoft.com/beta/servicePrincipals/$($ourSVCPrincipal.id)/appRoleAssignments" -tenantid $TenantFilter -skipTokenCache $true -NoAuthCheck $true -AsApp $UseAsApp # Collect missing service principals and prepare bulk request $MissingServicePrincipals = [System.Collections.Generic.List[object]]::new() @@ -102,7 +118,7 @@ function Add-CIPPApplicationPermission { # Create missing service principals in bulk if ($MissingServicePrincipals.Count -gt 0) { try { - $BulkResults = New-GraphBulkRequest -Requests $MissingServicePrincipals -tenantid $TenantFilter -NoAuthCheck $true + $BulkResults = New-GraphBulkRequest -Requests $MissingServicePrincipals -tenantid $TenantFilter -NoAuthCheck $true -AsApp $UseAsApp foreach ($Result in $BulkResults) { if ($Result.status -eq 201) { $ServicePrincipalList.Add($Result.body) @@ -150,7 +166,7 @@ function Add-CIPPApplicationPermission { } try { - $BulkResults = New-GraphBulkRequest -Requests $GrantRequests -tenantid $TenantFilter -NoAuthCheck $true + $BulkResults = New-GraphBulkRequest -Requests $GrantRequests -tenantid $TenantFilter -NoAuthCheck $true -AsApp $UseAsApp foreach ($Result in $BulkResults) { if ($Result.status -eq 201) { $counter++ diff --git a/Modules/CIPPCore/Public/Add-CIPPDelegatedPermission.ps1 b/Modules/CIPPCore/Public/Add-CIPPDelegatedPermission.ps1 index 821a1fe115dfb..cf7703c5970c9 100644 --- a/Modules/CIPPCore/Public/Add-CIPPDelegatedPermission.ps1 +++ b/Modules/CIPPCore/Public/Add-CIPPDelegatedPermission.ps1 @@ -5,7 +5,8 @@ function Add-CIPPDelegatedPermission { $TemplateId, $ApplicationId, $NoTranslateRequired, - $TenantFilter + $TenantFilter, + [bool]$AsApp = $false ) Write-Information 'Adding Delegated Permissions' $ApplicationId = $ApplicationId ?? $env:ApplicationID @@ -71,19 +72,34 @@ function Add-CIPPDelegatedPermission { } $Translator = Get-Content (Join-Path $env:CIPPRootPath 'Config\PermissionsTranslator.json') | ConvertFrom-Json - $ServicePrincipalList = New-GraphGETRequest -uri "https://graph.microsoft.com/beta/servicePrincipals?`$select=appId,id,displayName&`$top=999" -tenantid $TenantFilter -skipTokenCache $true -NoAuthCheck $true + + # Use app auth when requested, but fall back to delegated on the first failure - the app may not + # hold the application permissions needed. $UseAsApp latches to $false for the rest of the run. + $UseAsApp = $AsApp + $GetServicePrincipalList = { New-GraphGETRequest -uri "https://graph.microsoft.com/beta/servicePrincipals?`$select=appId,id,displayName&`$top=999" -tenantid $TenantFilter -skipTokenCache $true -NoAuthCheck $true -AsApp $UseAsApp } + try { + $ServicePrincipalList = & $GetServicePrincipalList + } catch { + if ($UseAsApp) { + Write-Information "App-auth request failed, falling back to delegated permissions: $($_.Exception.Message)" + $UseAsApp = $false + $ServicePrincipalList = & $GetServicePrincipalList + } else { + throw + } + } $Results = [System.Collections.Generic.List[string]]::new() $ourSVCPrincipal = $ServicePrincipalList | Where-Object -Property AppId -EQ $ApplicationId | Select-Object -First 1 if (!$ourSVCPrincipal) { - $ourSvcPrincipal = New-GraphGETRequest -uri "https://graph.microsoft.com/beta/servicePrincipals(appId='$ApplicationId')?`$select=appId,id,displayName" -tenantid $TenantFilter -skipTokenCache $true -NoAuthCheck $true + $ourSvcPrincipal = New-GraphGETRequest -uri "https://graph.microsoft.com/beta/servicePrincipals(appId='$ApplicationId')?`$select=appId,id,displayName" -tenantid $TenantFilter -skipTokenCache $true -NoAuthCheck $true -AsApp $UseAsApp } if (!$ourSVCPrincipal) { $Results.Add("Failed to find service principal for application $ApplicationId in tenant $TenantFilter") return $Results } - $CurrentDelegatedScopes = New-GraphGETRequest -uri "https://graph.microsoft.com/beta/servicePrincipals/$($ourSVCPrincipal.id)/oauth2PermissionGrants" -skipTokenCache $true -tenantid $TenantFilter -NoAuthCheck $true + $CurrentDelegatedScopes = New-GraphGETRequest -uri "https://graph.microsoft.com/beta/servicePrincipals/$($ourSVCPrincipal.id)/oauth2PermissionGrants" -skipTokenCache $true -tenantid $TenantFilter -NoAuthCheck $true -AsApp $UseAsApp foreach ($App in $RequiredResourceAccess) { if (!$App) { @@ -95,7 +111,7 @@ function Add-CIPPDelegatedPermission { $Body = @{ appId = $App.resourceAppId } | ConvertTo-Json -Compress - $svcPrincipalId = New-GraphPOSTRequest -uri 'https://graph.microsoft.com/v1.0/servicePrincipals' -tenantid $TenantFilter -body $Body -type POST -NoAuthCheck $true + $svcPrincipalId = New-GraphPOSTRequest -uri 'https://graph.microsoft.com/v1.0/servicePrincipals' -tenantid $TenantFilter -body $Body -type POST -NoAuthCheck $true -AsApp $UseAsApp } catch { $Results.add("Failed to create service principal for $($App.resourceAppId): $(Get-NormalizedError -message $_.Exception.Message)") continue @@ -134,7 +150,7 @@ function Add-CIPPDelegatedPermission { resourceId = $svcPrincipalId.id scope = $NewScope } | ConvertTo-Json -Compress - $CreateRequest = New-GraphPOSTRequest -uri 'https://graph.microsoft.com/v1.0/oauth2PermissionGrants' -tenantid $TenantFilter -body $Createbody -type POST -NoAuthCheck $true + $CreateRequest = New-GraphPOSTRequest -uri 'https://graph.microsoft.com/v1.0/oauth2PermissionGrants' -tenantid $TenantFilter -body $Createbody -type POST -NoAuthCheck $true -AsApp $UseAsApp $Results.add("Successfully added permissions for $($svcPrincipalId.displayName)") } catch { $Results.add("Failed to add permissions for $($svcPrincipalId.displayName): $(Get-NormalizedError -message $_.Exception.Message)") @@ -147,7 +163,7 @@ function Add-CIPPDelegatedPermission { $OldScope.id | ForEach-Object { if ($_ -ne $OldScopeId) { try { - $null = New-GraphPOSTRequest -uri "https://graph.microsoft.com/v1.0/oauth2PermissionGrants/$_" -tenantid $TenantFilter -type DELETE -NoAuthCheck $true + $null = New-GraphPOSTRequest -uri "https://graph.microsoft.com/v1.0/oauth2PermissionGrants/$_" -tenantid $TenantFilter -type DELETE -NoAuthCheck $true -AsApp $UseAsApp } catch { } } @@ -171,7 +187,7 @@ function Add-CIPPDelegatedPermission { scope = "$NewScope" } | ConvertTo-Json -Compress try { - $null = New-GraphPOSTRequest -uri "https://graph.microsoft.com/v1.0/oauth2PermissionGrants/$($OldScopeId)" -tenantid $TenantFilter -body $Patchbody -type PATCH -NoAuthCheck $true + $null = New-GraphPOSTRequest -uri "https://graph.microsoft.com/v1.0/oauth2PermissionGrants/$($OldScopeId)" -tenantid $TenantFilter -body $Patchbody -type PATCH -NoAuthCheck $true -AsApp $UseAsApp } catch { $Results.add("Failed to update permissions for $($svcPrincipalId.displayName): $(Get-NormalizedError -message $_.Exception.Message)") continue diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecPermissionRepair.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecPermissionRepair.ps1 index 8cb5eb1cda8ef..d3301fb7a296d 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecPermissionRepair.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecPermissionRepair.ps1 @@ -25,8 +25,8 @@ function Invoke-ExecPermissionRepair { # 2) Refresh the grants on the partner CIPP-SAM service principal so the effective set # (manifest + extras, read from the table) is actually consented on the SP. - $AppResults = Add-CIPPApplicationPermission -RequiredResourceAccess 'CIPPDefaults' -ApplicationId $env:ApplicationID -TenantFilter $env:TenantID - $DelegatedResults = Add-CIPPDelegatedPermission -RequiredResourceAccess 'CIPPDefaults' -ApplicationId $env:ApplicationID -TenantFilter $env:TenantID + $AppResults = Add-CIPPApplicationPermission -RequiredResourceAccess 'CIPPDefaults' -ApplicationId $env:ApplicationID -TenantFilter $env:TenantID -AsApp $true + $DelegatedResults = Add-CIPPDelegatedPermission -RequiredResourceAccess 'CIPPDefaults' -ApplicationId $env:ApplicationID -TenantFilter $env:TenantID -AsApp $true $Results = @($TableResult) + @($AppResults) + @($DelegatedResults) | Where-Object { $_ } Write-LogMessage -Headers $Request.Headers -API 'ExecPermissionRepair' -message "CIPP-SAM permissions repaired by $UpdatedBy" -Sev 'Info' -LogData @{ Results = @($Results) } From 396606f0fa6ab4cc2140bc8d713c1ba21afb9f37 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Fri, 3 Jul 2026 01:12:53 +0800 Subject: [PATCH 094/170] Implement rest api management of teams admin management and update cache, tests and standards This addressed authentication issues with GDAP and the Teams PowerShell module, not all uses of the module have been replaced but this covers most of them with the remaining to be ported at a later date allowing for the complete removal of the teams PowerShell module --- .../Public/GraphHelper/New-TeamsRequestV2.ps1 | 198 ++++++++++++++++++ .../Public/Invoke-CIPPDBCacheCollection.ps1 | 1 + .../Set-CIPPDBCacheCsExternalAccessPolicy.ps1 | 2 +- ...-CIPPDBCacheCsTeamsAppPermissionPolicy.ps1 | 2 +- ...-CIPPDBCacheCsTeamsClientConfiguration.ps1 | 2 +- .../Set-CIPPDBCacheCsTeamsMeetingPolicy.ps1 | 2 +- ...PPDBCacheCsTeamsMessagingConfiguration.ps1 | 40 ++++ .../Set-CIPPDBCacheCsTeamsMessagingPolicy.ps1 | 2 +- ...DBCacheCsTenantFederationConfiguration.ps1 | 2 +- ...Invoke-CIPPStandardTeamsChatProtection.ps1 | 4 +- ...voke-CIPPStandardTeamsEmailIntegration.ps1 | 4 +- .../Invoke-CIPPStandardTeamsEnrollUser.ps1 | 4 +- ...-CIPPStandardTeamsExternalAccessPolicy.ps1 | 4 +- ...IPPStandardTeamsExternalChatWithAnyone.ps1 | 4 +- ...e-CIPPStandardTeamsExternalFileSharing.ps1 | 4 +- ...PPStandardTeamsFederationConfiguration.ps1 | 114 +++------- ...e-CIPPStandardTeamsGlobalMeetingPolicy.ps1 | 4 +- .../Invoke-CIPPStandardTeamsGuestAccess.ps1 | 4 +- ...tandardTeamsMeetingRecordingExpiration.ps1 | 4 +- ...e-CIPPStandardTeamsMeetingVerification.ps1 | 4 +- ...nvoke-CIPPStandardTeamsMessagingPolicy.ps1 | 4 +- .../CIS/Identity/Invoke-CippTestCIS_8_2_1.ps1 | 2 +- Shared/CIPPSharp/CIPPRestClient.cs | 43 +++- Shared/CIPPSharp/bin/CIPPSharp.dll | Bin 44544 -> 46592 bytes 24 files changed, 344 insertions(+), 110 deletions(-) create mode 100644 Modules/CIPPCore/Public/GraphHelper/New-TeamsRequestV2.ps1 create mode 100644 Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCsTeamsMessagingConfiguration.ps1 diff --git a/Modules/CIPPCore/Public/GraphHelper/New-TeamsRequestV2.ps1 b/Modules/CIPPCore/Public/GraphHelper/New-TeamsRequestV2.ps1 new file mode 100644 index 0000000000000..71972d62a4157 --- /dev/null +++ b/Modules/CIPPCore/Public/GraphHelper/New-TeamsRequestV2.ps1 @@ -0,0 +1,198 @@ +function New-TeamsRequestV2 { + <# + .SYNOPSIS + Talks to the Teams admin ConfigAPI (api.interfaces.records.teams.microsoft.com) + directly, instead of the MicrosoftTeams PowerShell module. + + .DESCRIPTION + The MicrosoftTeams module routes policy-definition writes through the legacy + /Skype.Policy/tenants/policies surface, which returns 40301 Forbidden under CSP/GDAP + (for both delegate-with-Teams-Admin and app-only-with-Global-Admin tokens). The Teams + admin center (ACMS) instead uses /Skype.Policy/configurations/{Type}/configuration/{Identity}, + which authorizes fine with the roles CIPP already has. This helper speaks that surface. + + All HTTP goes through Invoke-CIPPRestMethod (pooled CIPP.CIPPRestClient) so gzip + responses are decompressed and JSON is deserialized consistently across platforms + (raw Invoke-RestMethod does NOT decompress gzip in the Linux worker, which yields + garbage/null objects and unreadable error bodies). + + Operations: + Get -> GET configurations/{Type}/configuration/{Identity} (single object) + Get -ListAll -> GET configurations/{Type} (all instances, array) + Set -> PUT configurations/{Type}/configuration/{Identity} (MERGE; only changed props) + New -> PUT configurations/{Type}/configuration/{Identity} (create named policy) [best-effort] + Remove -> DELETE configurations/{Type}/configuration/{Identity} [best-effort] + + .PARAMETER TenantFilter + Target tenant (GUID or default domain). + + .PARAMETER Type + ConfigAPI type (e.g. 'TeamsMeetingPolicy'), a cmdlet noun, or a full cmdlet name + (e.g. 'Set-CsTenantFederationConfiguration'); the verb is stripped and known + noun->type aliases are applied automatically. + + .PARAMETER Action + Get (default) | Set | New | Remove. + + .PARAMETER Identity + Policy instance identity. Default 'Global'. (e.g. 'Tag:Default' or a custom name.) + + .PARAMETER Parameters + Hashtable of properties to write (Set/New). Only these are sent (merge). + + .PARAMETER ListAll + Get: return every instance of the type (array) instead of one identity. + + .PARAMETER AsApp + Use an app-only (client_credentials) token instead of the delegate token. + + .PARAMETER NoRead + Set: skip the read-for-Key step and PUT only the bare changed props (ACMS-style). + Faster; safe for flat/Host-authority types and federation. Default is read-modify-write + (adds the Key envelope when the type carries one) for maximum compatibility. + + .PARAMETER UseServiceDiscovery + Resolve the per-tenant ConfigApi host + X-MS-Forest via Teams.Tenant/tenants and use + them (and, for federation/ACS types, the OcsPowershellWebservice target headers). + + .EXAMPLE + New-TeamsRequestV2 -TenantFilter $t -Type TeamsMeetingPolicy -Action Set -Parameters @{ AllowAnonymousUsersToJoinMeeting = $false } + + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] $TenantFilter, + [Parameter(Mandatory)] [string] $Type, + [ValidateSet('Get', 'Set', 'New', 'Remove')] [string] $Action = 'Get', + [string] $Identity = 'Global', + [hashtable] $Parameters = @{}, + [switch] $ListAll, + [switch] $AsApp, + [switch] $NoRead, + [switch] $UseServiceDiscovery + ) + + # ---- cmdlet-noun -> ConfigAPI type aliases (noun != type) ---- + $TypeAliases = @{ + 'TenantFederationConfiguration' = 'TenantFederationSettings' + } + # types backed by the legacy OcsPowershellWebservice (need target-uri headers via discovery) + $FederationTypes = @('TenantFederationSettings', 'TeamsAcsFederationConfiguration') + + # normalize Type: strip Get-/Set-/New-/Remove-Cs prefix, then alias + $ConfigType = $Type -replace '^(Get|Set|New|Remove|Grant|Revoke)-Cs', '' + if ($TypeAliases.ContainsKey($ConfigType)) { $ConfigType = $TypeAliases[$ConfigType] } + + # ---- token ---- + $TokenSplat = @{ tenantid = $TenantFilter; scope = '48ac35b8-9aa8-4d74-927d-1f4a14a0b239/.default' } + if ($AsApp) { $TokenSplat['AsApp'] = $true } + $TeamsToken = (Get-GraphToken @TokenSplat).Authorization -replace 'Bearer ' + + # ---- endpoint + forest (service discovery optional) ---- + $ApiHost = 'api.interfaces.records.teams.microsoft.com' + $Forest = $null + $AdminServiceEndpoint = $null + $AdminDomain = $null + if ($UseServiceDiscovery) { + try { + $disc = Invoke-CIPPRestMethod -Uri "https://$ApiHost/Teams.Tenant/tenants" -Method GET ` + -Headers @{ Authorization = "Bearer $TeamsToken"; Accept = 'application/json' } + if ($disc.serviceDiscovery.Endpoints.ConfigApiEndpoint) { $ApiHost = $disc.serviceDiscovery.Endpoints.ConfigApiEndpoint } + $Forest = $disc.serviceDiscovery.Headers.'X-MS-Forest' + $AdminServiceEndpoint = $disc.serviceDiscovery.Endpoints.AdminServiceEndpoint + $AdminDomain = ($disc.verifiedDomains | Where-Object { $_.name -like '*.onmicrosoft.com' -and $_.name -notlike '*.*.onmicrosoft.com' } | Select-Object -First 1).name + } catch { + Write-Verbose "Service discovery failed, using defaults: $($_.Exception.Message)" + } + } + $Base = "https://$ApiHost/Skype.Policy/configurations/$ConfigType" + + # ---- headers ---- + $Headers = @{ + Authorization = "Bearer $TeamsToken" + Accept = 'application/json' + 'x-authz-scope' = 'tenant' + 'x-ms-correlation-id' = (New-Guid).Guid + } + if ($Forest) { $Headers['x-ms-forest'] = $Forest } + if ($ConfigType -in $FederationTypes -and $AdminServiceEndpoint) { + $Headers['x-ms-target-uri'] = "https://$AdminServiceEndpoint/OcsPowershellWebservice" + $Headers['x-ms-tenant-id'] = $TenantFilter + } + $Query = if ($ConfigType -in $FederationTypes -and $AdminDomain) { "?adminDomain=$AdminDomain" } else { '' } + + switch ($Action) { + + 'Get' { + $Uri = if ($ListAll) { "$Base$Query" } else { "$Base/configuration/$Identity$Query" } + return Invoke-CIPPRestMethod -Uri $Uri -Method GET -Headers $Headers + } + + 'Set' { + $Uri = "$Base/configuration/$Identity$Query" + + # build body of only the changed props (skip control keys) + $Body = [ordered]@{} + foreach ($k in $Parameters.Keys) { + if ($k -in @('Identity', 'ErrorAction', 'Confirm', 'WhatIf', 'Verbose', 'Debug')) { continue } + $Body[$k] = $Parameters[$k] + } + + if (-not $NoRead) { + # read-modify-write: include the Key envelope when the type carries one (max compat) + try { + $Current = Invoke-CIPPRestMethod -Uri $Uri -Method GET -Headers $Headers + if ($Current -and ($Current.PSObject.Properties.Name -contains 'Key')) { + $Merged = [ordered]@{ Identity = $Identity; Key = $Current.Key } + foreach ($k in $Body.Keys) { $Merged[$k] = $Body[$k] } + $Body = $Merged + } + } catch { + Write-Verbose "Pre-read failed ($($_.Exception.Message)); sending bare props." + } + } + + $Json = $Body | ConvertTo-Json -Depth 25 -Compress + $StatusCode = $null + $RespBody = Invoke-CIPPRestMethod -Uri $Uri -Method PUT -Body $Json -ContentType 'application/json' ` + -Headers $Headers -SkipHttpErrorCheck -StatusCodeVariable StatusCode + if ([int]$StatusCode -ge 400) { + $Detail = if ($RespBody -is [string]) { $RespBody } elseif ($null -ne $RespBody) { $RespBody | ConvertTo-Json -Compress -Depth 10 } else { '' } + throw "Teams ConfigApi Set $ConfigType/$Identity failed: $StatusCode $Detail" + } + return [pscustomobject]@{ Type = $ConfigType; Identity = $Identity; StatusCode = [int]$StatusCode } + } + + 'New' { + # best-effort: create a named policy by PUTting the props at a new identity + $Uri = "$Base/configuration/$Identity$Query" + $Body = [ordered]@{ Identity = $Identity } + foreach ($k in $Parameters.Keys) { + if ($k -in @('Identity', 'ErrorAction', 'Confirm', 'WhatIf', 'Verbose', 'Debug')) { continue } + $Body[$k] = $Parameters[$k] + } + $Json = $Body | ConvertTo-Json -Depth 25 -Compress + $StatusCode = $null + $RespBody = Invoke-CIPPRestMethod -Uri $Uri -Method PUT -Body $Json -ContentType 'application/json' ` + -Headers $Headers -SkipHttpErrorCheck -StatusCodeVariable StatusCode + if ([int]$StatusCode -ge 400) { + $Detail = if ($RespBody -is [string]) { $RespBody } elseif ($null -ne $RespBody) { $RespBody | ConvertTo-Json -Compress -Depth 10 } else { '' } + throw "Teams ConfigApi New $ConfigType/$Identity failed: $StatusCode $Detail" + } + return [pscustomobject]@{ Type = $ConfigType; Identity = $Identity; StatusCode = [int]$StatusCode } + } + + 'Remove' { + $Uri = "$Base/configuration/$Identity$Query" + $StatusCode = $null + $RespBody = Invoke-CIPPRestMethod -Uri $Uri -Method DELETE -Headers $Headers -SkipHttpErrorCheck -StatusCodeVariable StatusCode + if ([int]$StatusCode -ge 400) { + $Detail = if ($RespBody -is [string]) { $RespBody } elseif ($null -ne $RespBody) { $RespBody | ConvertTo-Json -Compress -Depth 10 } else { '' } + throw "Teams ConfigApi Remove $ConfigType/$Identity failed: $StatusCode $Detail" + } + return [pscustomobject]@{ Type = $ConfigType; Identity = $Identity; StatusCode = [int]$StatusCode } + } + } +} diff --git a/Modules/CIPPCore/Public/Invoke-CIPPDBCacheCollection.ps1 b/Modules/CIPPCore/Public/Invoke-CIPPDBCacheCollection.ps1 index 57d69ca1f95d6..1c2eb64addd24 100644 --- a/Modules/CIPPCore/Public/Invoke-CIPPDBCacheCollection.ps1 +++ b/Modules/CIPPCore/Public/Invoke-CIPPDBCacheCollection.ps1 @@ -150,6 +150,7 @@ function Invoke-CIPPDBCacheCollection { 'CsExternalAccessPolicy' 'CsTenantFederationConfiguration' 'CsTeamsMessagingPolicy' + 'CsTeamsMessagingConfiguration' 'CsTeamsAppPermissionPolicy' 'Teams' 'TeamsActivity' diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCsExternalAccessPolicy.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCsExternalAccessPolicy.ps1 index 96afe6b7eab90..bb2297345f6eb 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCsExternalAccessPolicy.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCsExternalAccessPolicy.ps1 @@ -24,7 +24,7 @@ function Set-CIPPDBCacheCsExternalAccessPolicy { try { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching Teams External Access Policy' -sev Debug - $ExternalAccess = New-TeamsRequest -TenantFilter $TenantFilter -Cmdlet 'Get-CsExternalAccessPolicy' -CmdParams @{ Identity = 'Global' } + $ExternalAccess = New-TeamsRequestV2 -TenantFilter $TenantFilter -Type 'ExternalAccessPolicy' -Action Get -Identity 'Global' if ($ExternalAccess) { $Data = @($ExternalAccess) diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCsTeamsAppPermissionPolicy.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCsTeamsAppPermissionPolicy.ps1 index 66c2a171aecad..470a0fd3e3f7c 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCsTeamsAppPermissionPolicy.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCsTeamsAppPermissionPolicy.ps1 @@ -24,7 +24,7 @@ function Set-CIPPDBCacheCsTeamsAppPermissionPolicy { try { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching Teams App Permission Policies' -sev Debug - $AppPermissionPolicies = New-TeamsRequest -TenantFilter $TenantFilter -Cmdlet 'Get-CsTeamsAppPermissionPolicy' + $AppPermissionPolicies = New-TeamsRequestV2 -TenantFilter $TenantFilter -Type 'TeamsAppPermissionPolicy' -Action Get -ListAll if ($AppPermissionPolicies) { $Data = @($AppPermissionPolicies) diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCsTeamsClientConfiguration.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCsTeamsClientConfiguration.ps1 index 1db2ec626fe98..64233843eae51 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCsTeamsClientConfiguration.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCsTeamsClientConfiguration.ps1 @@ -25,7 +25,7 @@ function Set-CIPPDBCacheCsTeamsClientConfiguration { try { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching Teams Client Configuration' -sev Debug - $ClientConfig = New-TeamsRequest -TenantFilter $TenantFilter -Cmdlet 'Get-CsTeamsClientConfiguration' -CmdParams @{ Identity = 'Global' } + $ClientConfig = New-TeamsRequestV2 -TenantFilter $TenantFilter -Type 'TeamsClientConfiguration' -Action Get -Identity 'Global' if ($ClientConfig) { $Data = @($ClientConfig) diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCsTeamsMeetingPolicy.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCsTeamsMeetingPolicy.ps1 index 5a67acdb1ddb0..e7e166ae7cb17 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCsTeamsMeetingPolicy.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCsTeamsMeetingPolicy.ps1 @@ -24,7 +24,7 @@ function Set-CIPPDBCacheCsTeamsMeetingPolicy { try { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching Teams Meeting Policy' -sev Debug - $MeetingPolicy = New-TeamsRequest -TenantFilter $TenantFilter -Cmdlet 'Get-CsTeamsMeetingPolicy' -CmdParams @{ Identity = 'Global' } + $MeetingPolicy = New-TeamsRequestV2 -TenantFilter $TenantFilter -Type 'TeamsMeetingPolicy' -Action Get -Identity 'Global' if ($MeetingPolicy) { $Data = @($MeetingPolicy) diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCsTeamsMessagingConfiguration.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCsTeamsMessagingConfiguration.ps1 new file mode 100644 index 0000000000000..bff91cce2f2f6 --- /dev/null +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCsTeamsMessagingConfiguration.ps1 @@ -0,0 +1,40 @@ +function Set-CIPPDBCacheCsTeamsMessagingConfiguration { + <# + .SYNOPSIS + Caches the Teams Messaging Configuration (Global) + + .DESCRIPTION + Calls the Teams ConfigAPI (TeamsMessagingConfiguration) via New-TeamsRequestV2 and + writes the result into the CippReportingDB under Type 'CsTeamsMessagingConfiguration'. + Holds the org-wide message-safety settings (FileTypeCheck, UrlReputationCheck, + ContentBasedPhishingCheck, ReportIncorrectSecurityDetections, etc.). + + .PARAMETER TenantFilter + The tenant to cache the messaging configuration for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + [string]$QueueId + ) + + try { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching Teams Messaging Configuration' -sev Debug + + $MessagingConfig = New-TeamsRequestV2 -TenantFilter $TenantFilter -Type 'TeamsMessagingConfiguration' -Action Get -Identity 'Global' + + if ($MessagingConfig) { + $Data = @($MessagingConfig) + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'CsTeamsMessagingConfiguration' -Data $Data -AddCount + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached Teams Messaging Configuration' -sev Debug + } + $MessagingConfig = $null + + } catch { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to cache Teams Messaging Configuration: $($_.Exception.Message)" -sev Error + } +} diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCsTeamsMessagingPolicy.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCsTeamsMessagingPolicy.ps1 index 2fc1ef784c239..2fb1b95196d65 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCsTeamsMessagingPolicy.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCsTeamsMessagingPolicy.ps1 @@ -25,7 +25,7 @@ function Set-CIPPDBCacheCsTeamsMessagingPolicy { try { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching Teams Messaging Policy' -sev Debug - $MessagingPolicy = New-TeamsRequest -TenantFilter $TenantFilter -Cmdlet 'Get-CsTeamsMessagingPolicy' -CmdParams @{ Identity = 'Global' } + $MessagingPolicy = New-TeamsRequestV2 -TenantFilter $TenantFilter -Type 'TeamsMessagingPolicy' -Action Get -Identity 'Global' if ($MessagingPolicy) { $Data = @($MessagingPolicy) diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCsTenantFederationConfiguration.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCsTenantFederationConfiguration.ps1 index 47d09881239e5..88cd4788f1000 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCsTenantFederationConfiguration.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheCsTenantFederationConfiguration.ps1 @@ -25,7 +25,7 @@ function Set-CIPPDBCacheCsTenantFederationConfiguration { try { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching Teams Tenant Federation Configuration' -sev Debug - $Federation = New-TeamsRequest -TenantFilter $TenantFilter -Cmdlet 'Get-CsTenantFederationConfiguration' -CmdParams @{ Identity = 'Global' } + $Federation = New-TeamsRequestV2 -TenantFilter $TenantFilter -Type 'TenantFederationConfiguration' -Action Get -Identity 'Global' if ($Federation) { $Data = @($Federation) diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsChatProtection.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsChatProtection.ps1 index 93eccf035e10e..aaa33a4ae2d38 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsChatProtection.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsChatProtection.ps1 @@ -46,7 +46,7 @@ function Invoke-CIPPStandardTeamsChatProtection { } #we're done. try { - $CurrentState = New-TeamsRequest -TenantFilter $Tenant -Cmdlet 'Get-CsTeamsMessagingConfiguration' | Select-Object -Property Identity, FileTypeCheck, UrlReputationCheck + $CurrentState = New-TeamsRequestV2 -TenantFilter $Tenant -Type 'TeamsMessagingConfiguration' -Action Get -Identity 'Global' | Select-Object -Property Identity, FileTypeCheck, UrlReputationCheck } catch { $ErrorMessage = Get-CippException -Exception $_ Write-LogMessage -API 'Standards' -Tenant $Tenant -Message "Could not get the Teams Chat Protection state for $Tenant. Error: $($ErrorMessage.NormalizedError)" -Sev Error -LogData $ErrorMessage @@ -75,7 +75,7 @@ function Invoke-CIPPStandardTeamsChatProtection { } try { - $null = New-TeamsRequest -TenantFilter $Tenant -Cmdlet 'Set-CsTeamsMessagingConfiguration' -CmdParams $cmdParams + $null = New-TeamsRequestV2 -TenantFilter $Tenant -Type 'TeamsMessagingConfiguration' -Action Set -Parameters $cmdParams Write-LogMessage -API 'Standards' -tenant $Tenant -message "Successfully updated Teams Chat Protection settings to FileTypeCheck: $FileTypeCheckState, UrlReputationCheck: $UrlReputationCheckState" -sev Info } catch { $ErrorMessage = Get-CippException -Exception $_ diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsEmailIntegration.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsEmailIntegration.ps1 index fe913f850dacb..aa4735fa00c90 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsEmailIntegration.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsEmailIntegration.ps1 @@ -46,7 +46,7 @@ function Invoke-CIPPStandardTeamsEmailIntegration { } #we're done. try { - $CurrentState = New-TeamsRequest -TenantFilter $Tenant -Cmdlet 'Get-CsTeamsClientConfiguration' -CmdParams @{Identity = 'Global' } | + $CurrentState = New-TeamsRequestV2 -TenantFilter $Tenant -Type 'TeamsClientConfiguration' -Action Get -Identity 'Global' | Select-Object AllowEmailIntoChannel } catch { $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message @@ -68,7 +68,7 @@ function Invoke-CIPPStandardTeamsEmailIntegration { } try { - New-TeamsRequest -TenantFilter $Tenant -Cmdlet 'Set-CsTeamsClientConfiguration' -CmdParams $cmdParams + $null = New-TeamsRequestV2 -TenantFilter $Tenant -Type 'TeamsClientConfiguration' -Action Set -Parameters $cmdParams Write-LogMessage -API 'Standards' -tenant $Tenant -message 'Updated Teams Email Integration settings' -sev Info } catch { $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsEnrollUser.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsEnrollUser.ps1 index 3748d8d67a03d..805afb25e8680 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsEnrollUser.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsEnrollUser.ps1 @@ -47,7 +47,7 @@ function Invoke-CIPPStandardTeamsEnrollUser { $enrollUserOverride = $Settings.EnrollUserOverride.value ?? $Settings.EnrollUserOverride try { - $CurrentState = New-TeamsRequest -TenantFilter $Tenant -Cmdlet 'Get-CsTeamsMeetingPolicy' -cmdParams @{Identity = 'Global' } | + $CurrentState = New-TeamsRequestV2 -TenantFilter $Tenant -Type 'TeamsMeetingPolicy' -Action Get -Identity 'Global' | Select-Object EnrollUserOverride } catch { $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message @@ -67,7 +67,7 @@ function Invoke-CIPPStandardTeamsEnrollUser { } try { - $null = New-TeamsRequest -TenantFilter $Tenant -Cmdlet 'Set-CsTeamsMeetingPolicy' -cmdParams $cmdParams + $null = New-TeamsRequestV2 -TenantFilter $Tenant -Type 'TeamsMeetingPolicy' -Action Set -Parameters $cmdParams Write-LogMessage -API 'Standards' -tenant $Tenant -message "Updated Teams Enroll User Override setting to $enrollUserOverride." -sev Info } catch { $ErrorMessage = Get-CippException -Exception $_ diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsExternalAccessPolicy.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsExternalAccessPolicy.ps1 index a6014c5fec2a8..8eb632ba19456 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsExternalAccessPolicy.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsExternalAccessPolicy.ps1 @@ -45,7 +45,7 @@ function Invoke-CIPPStandardTeamsExternalAccessPolicy { } #we're done. try { - $CurrentState = New-TeamsRequest -TenantFilter $Tenant -Cmdlet 'Get-CsExternalAccessPolicy' -CmdParams @{Identity = 'Global' } | + $CurrentState = New-TeamsRequestV2 -TenantFilter $Tenant -Type 'ExternalAccessPolicy' -Action Get -Identity 'Global' | Select-Object * } catch { $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message @@ -70,7 +70,7 @@ function Invoke-CIPPStandardTeamsExternalAccessPolicy { } try { - New-TeamsRequest -TenantFilter $Tenant -Cmdlet 'Set-CsExternalAccessPolicy' -CmdParams $cmdParams + $null = New-TeamsRequestV2 -TenantFilter $Tenant -Type 'ExternalAccessPolicy' -Action Set -Parameters $cmdParams Write-LogMessage -API 'Standards' -tenant $Tenant -message 'Updated External Access Policy' -sev Info } catch { $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsExternalChatWithAnyone.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsExternalChatWithAnyone.ps1 index f4772514fb929..14ac9225c9cc0 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsExternalChatWithAnyone.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsExternalChatWithAnyone.ps1 @@ -45,7 +45,7 @@ function Invoke-CIPPStandardTeamsExternalChatWithAnyone { } try { - $CurrentState = New-TeamsRequest -TenantFilter $Tenant -Cmdlet 'Get-CsTeamsMessagingPolicy' -CmdParams @{ Identity = 'Global' } | Select-Object -Property Identity, UseB2BInvitesToAddExternalUsers + $CurrentState = New-TeamsRequestV2 -TenantFilter $Tenant -Type 'TeamsMessagingPolicy' -Action Get -Identity 'Global' | Select-Object -Property Identity, UseB2BInvitesToAddExternalUsers } catch { $ErrorMessage = Get-CippException -Exception $_ Write-LogMessage -API 'Standards' -Tenant $Tenant -Message "Could not get the Teams external chat state for $Tenant. Error: $($ErrorMessage.NormalizedError)" -Sev Error -LogData $ErrorMessage @@ -67,7 +67,7 @@ function Invoke-CIPPStandardTeamsExternalChatWithAnyone { } try { - $null = New-TeamsRequest -TenantFilter $Tenant -Cmdlet 'Set-CsTeamsMessagingPolicy' -CmdParams $cmdParams + $null = New-TeamsRequestV2 -TenantFilter $Tenant -Type 'TeamsMessagingPolicy' -Action Set -Parameters $cmdParams Write-LogMessage -API 'Standards' -tenant $Tenant -message "Successfully updated Teams external chat with anyone setting to UseB2BInvitesToAddExternalUsers: $DesiredState" -sev Info } catch { $ErrorMessage = Get-CippException -Exception $_ diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsExternalFileSharing.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsExternalFileSharing.ps1 index 1d29eb41f6fab..d772b556d3ced 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsExternalFileSharing.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsExternalFileSharing.ps1 @@ -50,7 +50,7 @@ function Invoke-CIPPStandardTeamsExternalFileSharing { } #we're done. try { - $CurrentState = New-TeamsRequest -TenantFilter $Tenant -Cmdlet 'Get-CsTeamsClientConfiguration' | + $CurrentState = New-TeamsRequestV2 -TenantFilter $Tenant -Type 'TeamsClientConfiguration' -Action Get -Identity 'Global' | Select-Object AllowGoogleDrive, AllowShareFile, AllowBox, AllowDropBox, AllowEgnyte } catch { $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message @@ -78,7 +78,7 @@ function Invoke-CIPPStandardTeamsExternalFileSharing { } try { - New-TeamsRequest -TenantFilter $Tenant -Cmdlet 'Set-CsTeamsClientConfiguration' -CmdParams $cmdParams + $null = New-TeamsRequestV2 -TenantFilter $Tenant -Type 'TeamsClientConfiguration' -Action Set -Parameters $cmdParams Write-LogMessage -API 'Standards' -tenant $Tenant -message 'Updated Teams External File Sharing' -sev Info } catch { $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsFederationConfiguration.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsFederationConfiguration.ps1 index 4bebc295e2ee0..75cd4fc383475 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsFederationConfiguration.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsFederationConfiguration.ps1 @@ -46,49 +46,46 @@ function Invoke-CIPPStandardTeamsFederationConfiguration { } #we're done. try { - $CurrentState = New-TeamsRequest -TenantFilter $Tenant -Cmdlet 'Get-CsTenantFederationConfiguration' -CmdParams @{Identity = 'Global' } | - Select-Object * + $CurrentState = New-TeamsRequestV2 -TenantFilter $Tenant -Type 'TenantFederationConfiguration' -Action Get -Identity 'Global' } catch { $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message Write-LogMessage -API 'Standards' -Tenant $Tenant -Message "Could not get the TeamsFederationConfiguration state for $Tenant. Error: $ErrorMessage" -Sev Error return } - $AllowAllKnownDomains = New-CsEdgeAllowAllKnownDomains + # ConfigAPI (TenantFederationSettings) domain payload shapes: + # Allow all external -> AllowedDomains = @() (empty array) + # Allow specific external -> AllowedDomains = @{ AllowList = @(list) } + # Block specific external -> AllowedDomains = @{ AllowList = @() } + BlockedDomains = @(list) $DomainControl = $Settings.DomainControl.value ?? $Settings.DomainControl $AllowedDomainsAsAList = @() + $BlockedDomains = @() switch ($DomainControl) { 'AllowAllExternal' { $AllowFederatedUsers = $true - $AllowedDomains = $AllowAllKnownDomains - $AllowedDomainsAsAList = @() - $BlockedDomains = @() + $AllowedDomainsPayload = @() + $ExpectedAllowAllKnown = $true } 'BlockAllExternal' { $AllowFederatedUsers = $false - $AllowedDomains = $AllowAllKnownDomains - $AllowedDomainsAsAList = @() - $BlockedDomains = @() + $AllowedDomainsPayload = @() + $ExpectedAllowAllKnown = $true } 'AllowSpecificExternal' { $AllowFederatedUsers = $true - $AllowedDomains = $null - $BlockedDomains = @() if ($null -ne $Settings.DomainList) { $AllowedDomainsAsAList = @($Settings.DomainList).Split(',').Trim() | Sort-Object - } else { - $AllowedDomainsAsAList = @() } + $AllowedDomainsPayload = @{ AllowList = @($AllowedDomainsAsAList) } + $ExpectedAllowAllKnown = $false } 'BlockSpecificExternal' { $AllowFederatedUsers = $true - $AllowedDomains = $AllowAllKnownDomains - $AllowedDomainsAsAList = @() if ($null -ne $Settings.DomainList) { $BlockedDomains = @($Settings.DomainList).Split(',').Trim() | Sort-Object - } else { - $BlockedDomains = @() } + $AllowedDomainsPayload = @{ AllowList = @() } + $ExpectedAllowAllKnown = $true } default { Write-LogMessage -API 'Standards' -tenant $Tenant -message "Federation Configuration: Invalid $DomainControl parameter" -sev Error @@ -96,62 +93,23 @@ function Invoke-CIPPStandardTeamsFederationConfiguration { } } - # Parse current state based on DomainControl mode - $CurrentAllowedDomains = $CurrentState.AllowedDomains - $CurrentBlockedDomains = $CurrentState.BlockedDomains - $IsCurrentAllowAllKnownDomains = $false - $AllowedDomainsMatches = $false - $BlockedDomainsMatches = $false - - # Check if current allowed domains is AllowAllKnownDomains, and parse specific domains if not - if ($CurrentAllowedDomains) { - if ($CurrentAllowedDomains.GetType().Name -eq 'PSObject') { - $properties = Get-Member -InputObject $CurrentAllowedDomains -MemberType Properties, NoteProperty - if (($null -ne $CurrentAllowedDomains.AllowAllKnownDomains) -or - (Get-Member -InputObject $CurrentAllowedDomains -Name 'AllowAllKnownDomains') -or - (!$properties -or $properties.Count -eq 0)) { - $IsCurrentAllowAllKnownDomains = $true - Write-Information "Current AllowedDomains is AllowAllKnownDomains" - } else { - # Parse specific allowed domains list - if ($null -ne $CurrentAllowedDomains.AllowedDomain -or (Get-Member -InputObject $CurrentAllowedDomains -Name 'AllowedDomain')) { - $CurrentAllowedDomains = @($CurrentAllowedDomains.AllowedDomain | ForEach-Object { $_.Domain }) | Sort-Object - Write-Information "Current AllowedDomains (extracted): $($CurrentAllowedDomains -join ', ')" - } elseif ($null -ne $CurrentAllowedDomains.Domain -or (Get-Member -InputObject $CurrentAllowedDomains -Name 'Domain')) { - $CurrentAllowedDomains = @($CurrentAllowedDomains.Domain) | Sort-Object - Write-Information "Current AllowedDomains (via Domain property): $($CurrentAllowedDomains -join ', ')" - } else { - $CurrentAllowedDomains = @() - } - } - } elseif ($CurrentAllowedDomains.GetType().Name -eq 'Deserialized.Microsoft.Rtc.Management.WritableConfig.Settings.Edge.AllowAllKnownDomains') { - $IsCurrentAllowAllKnownDomains = $true - Write-Information "Current AllowedDomains is AllowAllKnownDomains (Deserialized type)" - } - } else { - $CurrentAllowedDomains = @() + # Parse current state (ConfigAPI TenantFederationSettings GET shape). NOTE the GET/PUT + # asymmetry: the GET nests the allow-list under AllowedDomains.AllowedDomain (allow-all = + # {} with no AllowedDomain), whereas the PUT expects AllowedDomains.AllowList / []. Items + # may be plain strings or objects with a .Domain property, so handle both. + $CurrentAllowedDomains = @() + $ad = $CurrentState.AllowedDomains + if ($ad -and ($ad.PSObject.Properties.Name -contains 'AllowedDomain') -and $ad.AllowedDomain) { + $CurrentAllowedDomains = @($ad.AllowedDomain | ForEach-Object { if ($_ -is [string]) { $_ } elseif ($_.Domain) { $_.Domain } else { "$_" } }) | Sort-Object } - - # Parse blocked domains upfront (always extract Domain property if present) - if ($CurrentBlockedDomains -is [System.Collections.IEnumerable] -and $CurrentBlockedDomains -isnot [string]) { - $blockedDomainsArray = @($CurrentBlockedDomains) - if ($blockedDomainsArray.Count -gt 0) { - $firstElement = $blockedDomainsArray[0] - $hasDomainProperty = ($null -ne $firstElement.Domain) -or (Get-Member -InputObject $firstElement -Name 'Domain' -MemberType Properties, NoteProperty) - - if ($hasDomainProperty) { - $CurrentBlockedDomains = @($blockedDomainsArray | ForEach-Object { $_.Domain }) | Sort-Object - Write-Information "Current BlockedDomains (extracted): $($CurrentBlockedDomains -join ', ')" - } else { - $CurrentBlockedDomains = @($blockedDomainsArray) | Sort-Object - Write-Information "Current BlockedDomains (plain strings): $($CurrentBlockedDomains -join ', ')" - } - } else { - $CurrentBlockedDomains = @() - } - } else { - $CurrentBlockedDomains = @() + # Allow-all-known = no explicit allow-list present (ConfigAPI returns {} for allow-all). + $IsCurrentAllowAllKnownDomains = ($CurrentAllowedDomains.Count -eq 0) + $CurrentBlockedDomains = @() + if ($CurrentState.BlockedDomains) { + $CurrentBlockedDomains = @($CurrentState.BlockedDomains | ForEach-Object { if ($_ -is [string]) { $_ } elseif ($_.Domain) { $_.Domain } else { "$_" } }) | Sort-Object } + $AllowedDomainsMatches = $false + $BlockedDomainsMatches = $false # Mode-specific validation switch ($DomainControl) { @@ -190,17 +148,13 @@ function Invoke-CIPPStandardTeamsFederationConfiguration { Identity = 'Global' AllowTeamsConsumer = $Settings.AllowTeamsConsumer AllowFederatedUsers = $AllowFederatedUsers - BlockedDomains = $BlockedDomains - } - - if ($AllowedDomainsAsAList -and $AllowedDomainsAsAList.Count -gt 0) { - $cmdParams.AllowedDomainsAsAList = $AllowedDomainsAsAList - } else { - $cmdParams.AllowedDomains = $AllowedDomains + AllowedDomains = $AllowedDomainsPayload + BlockedDomains = @($BlockedDomains) } try { - New-TeamsRequest -TenantFilter $Tenant -Cmdlet 'Set-CsTenantFederationConfiguration' -CmdParams $cmdParams + # -NoRead: send bare props exactly like ACMS (no Key envelope) for the federation write + $null = New-TeamsRequestV2 -TenantFilter $Tenant -Type 'TenantFederationConfiguration' -Action Set -Parameters $cmdParams -NoRead Write-LogMessage -API 'Standards' -tenant $Tenant -message 'Updated Federation Configuration Policy' -sev Info } catch { $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message @@ -232,7 +186,7 @@ function Invoke-CIPPStandardTeamsFederationConfiguration { # Normalize expected allowed domains for reporting $ExpectedAllowedDomainsForReport = if ($AllowedDomainsAsAList -and $AllowedDomainsAsAList.Count -gt 0) { $AllowedDomainsAsAList - } elseif ($AllowedDomains) { + } elseif ($ExpectedAllowAllKnown) { 'AllowAllKnownDomains' } else { @() diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsGlobalMeetingPolicy.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsGlobalMeetingPolicy.ps1 index 64f368e07dbca..729d0bb3ff3b1 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsGlobalMeetingPolicy.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsGlobalMeetingPolicy.ps1 @@ -57,7 +57,7 @@ function Invoke-CIPPStandardTeamsGlobalMeetingPolicy { } #we're done. try { - $CurrentState = New-TeamsRequest -TenantFilter $Tenant -Cmdlet 'Get-CsTeamsMeetingPolicy' -CmdParams @{Identity = 'Global' } | + $CurrentState = New-TeamsRequestV2 -TenantFilter $Tenant -Type 'TeamsMeetingPolicy' -Action Get -Identity 'Global' | Select-Object AllowAnonymousUsersToJoinMeeting, AllowAnonymousUsersToStartMeeting, AutoAdmittedUsers, AllowPSTNUsersToBypassLobby, MeetingChatEnabledType, DesignatedPresenterRoleMode, AllowExternalParticipantGiveRequestControl, AllowParticipantGiveRequestControl } catch { $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message @@ -96,7 +96,7 @@ function Invoke-CIPPStandardTeamsGlobalMeetingPolicy { } try { - New-TeamsRequest -TenantFilter $Tenant -Cmdlet 'Set-CsTeamsMeetingPolicy' -CmdParams $cmdParams + $null = New-TeamsRequestV2 -TenantFilter $Tenant -Type 'TeamsMeetingPolicy' -Action Set -Parameters $cmdParams Write-LogMessage -API 'Standards' -tenant $Tenant -message 'Updated Teams Global Policy' -sev Info } catch { $ErrorMessage = Get-CippException -Exception $_ diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsGuestAccess.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsGuestAccess.ps1 index 082638fd09fd0..043e4a988a87a 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsGuestAccess.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsGuestAccess.ps1 @@ -44,7 +44,7 @@ function Invoke-CIPPStandardTeamsGuestAccess { } #we're done. try { - $CurrentState = New-TeamsRequest -TenantFilter $Tenant -Cmdlet 'Get-CsTeamsClientConfiguration' -CmdParams @{Identity = 'Global' } | + $CurrentState = New-TeamsRequestV2 -TenantFilter $Tenant -Type 'TeamsClientConfiguration' -Action Get -Identity 'Global' | Select-Object AllowGuestUser } catch { $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message @@ -66,7 +66,7 @@ function Invoke-CIPPStandardTeamsGuestAccess { } try { - New-TeamsRequest -TenantFilter $Tenant -Cmdlet 'Set-CsTeamsClientConfiguration' -CmdParams $cmdParams + $null = New-TeamsRequestV2 -TenantFilter $Tenant -Type 'TeamsClientConfiguration' -Action Set -Parameters $cmdParams Write-LogMessage -API 'Standards' -tenant $Tenant -message 'Updated Teams Guest Access settings' -sev Info } catch { $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsMeetingRecordingExpiration.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsMeetingRecordingExpiration.ps1 index d1ae1626cafb5..2e45f2ca7a5fb 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsMeetingRecordingExpiration.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsMeetingRecordingExpiration.ps1 @@ -51,7 +51,7 @@ function Invoke-CIPPStandardTeamsMeetingRecordingExpiration { } try { - $CurrentExpirationDays = (New-TeamsRequest -TenantFilter $Tenant -Cmdlet 'Get-CsTeamsMeetingPolicy' -CmdParams @{Identity = 'Global' }).NewMeetingRecordingExpirationDays + $CurrentExpirationDays = (New-TeamsRequestV2 -TenantFilter $Tenant -Type 'TeamsMeetingPolicy' -Action Get -Identity 'Global').NewMeetingRecordingExpirationDays } catch { $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message Write-LogMessage -API 'Standards' -Tenant $Tenant -Message "Could not get the TeamsMeetingRecordingExpiration state for $Tenant. Error: $ErrorMessage" -Sev Error @@ -70,7 +70,7 @@ function Invoke-CIPPStandardTeamsMeetingRecordingExpiration { } try { - New-TeamsRequest -TenantFilter $Tenant -Cmdlet 'Set-CsTeamsMeetingPolicy' -CmdParams $cmdParams + $null = New-TeamsRequestV2 -TenantFilter $Tenant -Type 'TeamsMeetingPolicy' -Action Set -Parameters $cmdParams Write-LogMessage -API 'Standards' -tenant $Tenant -message "Successfully updated Teams Meeting Recording Expiration Policy to $ExpirationDays days." -sev Info } catch { $ErrorMessage = Get-CippException -Exception $_ diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsMeetingVerification.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsMeetingVerification.ps1 index e13384d13c8db..b4848b022703b 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsMeetingVerification.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsMeetingVerification.ps1 @@ -45,7 +45,7 @@ function Invoke-CIPPStandardTeamsMeetingVerification { } #we're done. try { - $CurrentState = New-TeamsRequest -TenantFilter $Tenant -Cmdlet 'Get-CsTeamsMeetingPolicy' -CmdParams @{Identity = 'Global' } | + $CurrentState = New-TeamsRequestV2 -TenantFilter $Tenant -Type 'TeamsMeetingPolicy' -Action Get -Identity 'Global' | Select-Object CaptchaVerificationForMeetingJoin } catch { $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message @@ -66,7 +66,7 @@ function Invoke-CIPPStandardTeamsMeetingVerification { } try { - New-TeamsRequest -TenantFilter $Tenant -Cmdlet 'Set-CsTeamsMeetingPolicy' -CmdParams $cmdParams + $null = New-TeamsRequestV2 -TenantFilter $Tenant -Type 'TeamsMeetingPolicy' -Action Set -Parameters $cmdParams Write-LogMessage -API 'Standards' -tenant $Tenant -message 'Updated Teams Meeting Verification Policy' -sev Info } catch { $ErrorMessage = Get-CippException -Exception $_ diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsMessagingPolicy.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsMessagingPolicy.ps1 index 31a6384293c49..94fbf91a0705c 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsMessagingPolicy.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardTeamsMessagingPolicy.ps1 @@ -52,7 +52,7 @@ function Invoke-CIPPStandardTeamsMessagingPolicy { } #we're done. try { - $CurrentState = New-TeamsRequest -TenantFilter $Tenant -Cmdlet 'Get-CsTeamsMessagingPolicy' -CmdParams @{Identity = 'Global' } + $CurrentState = New-TeamsRequestV2 -TenantFilter $Tenant -Type 'TeamsMessagingPolicy' -Action Get -Identity 'Global' } catch { $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message Write-LogMessage -API 'Standards' -Tenant $Tenant -Message "Could not get the TeamsMessagingPolicy state for $Tenant. Error: $ErrorMessage" -Sev Error @@ -98,7 +98,7 @@ function Invoke-CIPPStandardTeamsMessagingPolicy { } try { - New-TeamsRequest -TenantFilter $Tenant -Cmdlet 'Set-CsTeamsMessagingPolicy' -CmdParams $cmdParams + $null = New-TeamsRequestV2 -TenantFilter $Tenant -Type 'TeamsMessagingPolicy' -Action Set -Parameters $cmdParams Write-LogMessage -API 'Standards' -tenant $Tenant -message 'Updated global Teams messaging policy' -sev Info } catch { $ErrorMessage = Get-NormalizedError -Message $_.Exception.Message diff --git a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_2_1.ps1 b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_2_1.ps1 index dc196f8c0935b..87702dbc75df5 100644 --- a/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_2_1.ps1 +++ b/Modules/CIPPTests/Public/Tests/CIS/Identity/Invoke-CippTestCIS_8_2_1.ps1 @@ -19,7 +19,7 @@ function Invoke-CippTestCIS_8_2_1 { $PolicyDisabled = $E.EnableFederationAccess -eq $false $TenantDisabled = $F.AllowFederatedUsers -eq $false - $TenantAllowList = $F.AllowedDomains -and ($F.AllowedDomains.AllowedDomain -or ($F.AllowedDomains -is [array] -and $F.AllowedDomains.Count -gt 0)) + $TenantAllowList = $F.AllowedDomains -and ($F.AllowedDomains.AllowList -or $F.AllowedDomains.AllowedDomain -or ($F.AllowedDomains -is [array] -and $F.AllowedDomains.Count -gt 0)) if ($PolicyDisabled -or $TenantDisabled -or $TenantAllowList) { $Status = 'Passed' diff --git a/Shared/CIPPSharp/CIPPRestClient.cs b/Shared/CIPPSharp/CIPPRestClient.cs index 555e16aed89d3..4857bc8bce073 100644 --- a/Shared/CIPPSharp/CIPPRestClient.cs +++ b/Shared/CIPPSharp/CIPPRestClient.cs @@ -3,6 +3,7 @@ using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; +using System.IO.Compression; using System.Linq; using System.Net; using System.Net.Http; @@ -704,7 +705,7 @@ public static async Task SendAsync( try { content = response.Content is not null - ? await response.Content.ReadAsStringAsync(token).ConfigureAwait(false) + ? await ReadDecodedContentAsync(response.Content, token).ConfigureAwait(false) : string.Empty; } catch (Exception ex) when (ex is InvalidDataException || ex is IOException) @@ -773,6 +774,46 @@ public static async Task SendAsync( } } + // ----------------------------------------------------------------- + // Content decoding + // ----------------------------------------------------------------- + // Reads the response body as a string, decompressing explicitly based on + // the Content-Encoding header. SocketsHttpHandler.AutomaticDecompression + // normally handles this transparently AND strips Content-Encoding, in + // which case ContentEncoding is empty here and we just read the string. + // But AutomaticDecompression silently no-ops whenever a request carries a + // caller-supplied Accept-Encoding header (HttpClient assumes the caller + // will decode), and some endpoints (e.g. the Teams ConfigAPI) return gzip + // even when unprompted. When Content-Encoding survives, we decode it here + // so callers never receive raw compressed bytes. Idempotent: if the + // handler already decoded, there's nothing left to do. + // ----------------------------------------------------------------- + private static async Task ReadDecodedContentAsync(HttpContent httpContent, CancellationToken token) + { + var encodings = httpContent.Headers.ContentEncoding; + if (encodings is null || encodings.Count == 0) + return await httpContent.ReadAsStringAsync(token).ConfigureAwait(false); + + var raw = await httpContent.ReadAsStreamAsync(token).ConfigureAwait(false); + Stream decoded = raw; + // Content-Encoding lists encodings in the order applied; decode in reverse. + foreach (var enc in encodings.Reverse()) + { + decoded = enc?.Trim().ToLowerInvariant() switch + { + "gzip" or "x-gzip" => new GZipStream(decoded, CompressionMode.Decompress), + "deflate" => new DeflateStream(decoded, CompressionMode.Decompress), + "br" => new BrotliStream(decoded, CompressionMode.Decompress), + "identity" or "" or null => decoded, + _ => decoded, + }; + } + using var reader = new StreamReader(decoded, Encoding.UTF8); + var result = await reader.ReadToEndAsync(token).ConfigureAwait(false); + decoded.Dispose(); + return result; + } + // ================================================================= // Diagnostics // ================================================================= diff --git a/Shared/CIPPSharp/bin/CIPPSharp.dll b/Shared/CIPPSharp/bin/CIPPSharp.dll index c6c5b41d02c2e4e84e2bb8faae1154cc0ebfada8..06c361ccaea70f9896ea516105b4cbcacc36cc12 100644 GIT binary patch literal 46592 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 AE&u=k literal 44544 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 Date: Fri, 3 Jul 2026 01:56:59 +0800 Subject: [PATCH 095/170] Revert permission repair changes (AsApp grant application + Jun 30 rework) --- .../Public/Add-CIPPApplicationPermission.ps1 | 28 ++++------------ .../Public/Add-CIPPDelegatedPermission.ps1 | 32 +++++-------------- .../Settings/Invoke-ExecPermissionRepair.ps1 | 23 +++---------- 3 files changed, 18 insertions(+), 65 deletions(-) diff --git a/Modules/CIPPCore/Public/Add-CIPPApplicationPermission.ps1 b/Modules/CIPPCore/Public/Add-CIPPApplicationPermission.ps1 index 4f717ca4e86f2..25481b363bccc 100644 --- a/Modules/CIPPCore/Public/Add-CIPPApplicationPermission.ps1 +++ b/Modules/CIPPCore/Public/Add-CIPPApplicationPermission.ps1 @@ -4,8 +4,7 @@ function Add-CIPPApplicationPermission { $RequiredResourceAccess, $TemplateId, $ApplicationId, - $TenantFilter, - [bool]$AsApp = $false + $TenantFilter ) if ($ApplicationId -eq $env:ApplicationID -and $TenantFilter -eq $env:TenantID) { $RequiredResourceAccess = 'CIPPDefaults' @@ -58,37 +57,22 @@ function Add-CIPPApplicationPermission { Write-Information "Adding application permissions to application $ApplicationId in tenant $TenantFilter" - # Use app auth when requested, but fall back to delegated on the first failure - the app may not - # hold the application permissions needed. $UseAsApp latches to $false for the rest of the run. - $UseAsApp = $AsApp - $GetServicePrincipalList = { New-GraphGETRequest -uri "https://graph.microsoft.com/beta/servicePrincipals?`$select=AppId,id,displayName&`$top=999" -skipTokenCache $true -tenantid $TenantFilter -NoAuthCheck $true -AsApp $UseAsApp } - $ServicePrincipalList = [System.Collections.Generic.List[object]]::new() - try { - $SPList = & $GetServicePrincipalList - } catch { - if ($UseAsApp) { - Write-Information "App-auth request failed, falling back to delegated permissions: $($_.Exception.Message)" - $UseAsApp = $false - $SPList = & $GetServicePrincipalList - } else { - throw - } - } + $SPList = New-GraphGETRequest -uri "https://graph.microsoft.com/beta/servicePrincipals?`$select=AppId,id,displayName&`$top=999" -skipTokenCache $true -tenantid $TenantFilter -NoAuthCheck $true foreach ($SP in $SPList) { $ServicePrincipalList.Add($SP) } $ourSVCPrincipal = $ServicePrincipalList | Where-Object -Property AppId -EQ $ApplicationId if (!$ourSVCPrincipal) { #Our Service Principal isn't available yet. We do a sleep and reexecute after 3 seconds. Start-Sleep -Seconds 5 $ServicePrincipalList.Clear() - $SPList = & $GetServicePrincipalList + $SPList = New-GraphGETRequest -uri "https://graph.microsoft.com/beta/servicePrincipals?`$select=AppId,id,displayName&`$top=999" -skipTokenCache $true -tenantid $TenantFilter -NoAuthCheck $true foreach ($SP in $SPList) { $ServicePrincipalList.Add($SP) } $ourSVCPrincipal = $ServicePrincipalList | Where-Object -Property AppId -EQ $ApplicationId } $Results = [System.Collections.Generic.List[string]]::new() - $CurrentRoles = New-GraphGETRequest -uri "https://graph.microsoft.com/beta/servicePrincipals/$($ourSVCPrincipal.id)/appRoleAssignments" -tenantid $TenantFilter -skipTokenCache $true -NoAuthCheck $true -AsApp $UseAsApp + $CurrentRoles = New-GraphGETRequest -uri "https://graph.microsoft.com/beta/servicePrincipals/$($ourSVCPrincipal.id)/appRoleAssignments" -tenantid $TenantFilter -skipTokenCache $true -NoAuthCheck $true # Collect missing service principals and prepare bulk request $MissingServicePrincipals = [System.Collections.Generic.List[object]]::new() @@ -118,7 +102,7 @@ function Add-CIPPApplicationPermission { # Create missing service principals in bulk if ($MissingServicePrincipals.Count -gt 0) { try { - $BulkResults = New-GraphBulkRequest -Requests $MissingServicePrincipals -tenantid $TenantFilter -NoAuthCheck $true -AsApp $UseAsApp + $BulkResults = New-GraphBulkRequest -Requests $MissingServicePrincipals -tenantid $TenantFilter -NoAuthCheck $true foreach ($Result in $BulkResults) { if ($Result.status -eq 201) { $ServicePrincipalList.Add($Result.body) @@ -166,7 +150,7 @@ function Add-CIPPApplicationPermission { } try { - $BulkResults = New-GraphBulkRequest -Requests $GrantRequests -tenantid $TenantFilter -NoAuthCheck $true -AsApp $UseAsApp + $BulkResults = New-GraphBulkRequest -Requests $GrantRequests -tenantid $TenantFilter -NoAuthCheck $true foreach ($Result in $BulkResults) { if ($Result.status -eq 201) { $counter++ diff --git a/Modules/CIPPCore/Public/Add-CIPPDelegatedPermission.ps1 b/Modules/CIPPCore/Public/Add-CIPPDelegatedPermission.ps1 index cf7703c5970c9..821a1fe115dfb 100644 --- a/Modules/CIPPCore/Public/Add-CIPPDelegatedPermission.ps1 +++ b/Modules/CIPPCore/Public/Add-CIPPDelegatedPermission.ps1 @@ -5,8 +5,7 @@ function Add-CIPPDelegatedPermission { $TemplateId, $ApplicationId, $NoTranslateRequired, - $TenantFilter, - [bool]$AsApp = $false + $TenantFilter ) Write-Information 'Adding Delegated Permissions' $ApplicationId = $ApplicationId ?? $env:ApplicationID @@ -72,34 +71,19 @@ function Add-CIPPDelegatedPermission { } $Translator = Get-Content (Join-Path $env:CIPPRootPath 'Config\PermissionsTranslator.json') | ConvertFrom-Json - - # Use app auth when requested, but fall back to delegated on the first failure - the app may not - # hold the application permissions needed. $UseAsApp latches to $false for the rest of the run. - $UseAsApp = $AsApp - $GetServicePrincipalList = { New-GraphGETRequest -uri "https://graph.microsoft.com/beta/servicePrincipals?`$select=appId,id,displayName&`$top=999" -tenantid $TenantFilter -skipTokenCache $true -NoAuthCheck $true -AsApp $UseAsApp } - try { - $ServicePrincipalList = & $GetServicePrincipalList - } catch { - if ($UseAsApp) { - Write-Information "App-auth request failed, falling back to delegated permissions: $($_.Exception.Message)" - $UseAsApp = $false - $ServicePrincipalList = & $GetServicePrincipalList - } else { - throw - } - } + $ServicePrincipalList = New-GraphGETRequest -uri "https://graph.microsoft.com/beta/servicePrincipals?`$select=appId,id,displayName&`$top=999" -tenantid $TenantFilter -skipTokenCache $true -NoAuthCheck $true $Results = [System.Collections.Generic.List[string]]::new() $ourSVCPrincipal = $ServicePrincipalList | Where-Object -Property AppId -EQ $ApplicationId | Select-Object -First 1 if (!$ourSVCPrincipal) { - $ourSvcPrincipal = New-GraphGETRequest -uri "https://graph.microsoft.com/beta/servicePrincipals(appId='$ApplicationId')?`$select=appId,id,displayName" -tenantid $TenantFilter -skipTokenCache $true -NoAuthCheck $true -AsApp $UseAsApp + $ourSvcPrincipal = New-GraphGETRequest -uri "https://graph.microsoft.com/beta/servicePrincipals(appId='$ApplicationId')?`$select=appId,id,displayName" -tenantid $TenantFilter -skipTokenCache $true -NoAuthCheck $true } if (!$ourSVCPrincipal) { $Results.Add("Failed to find service principal for application $ApplicationId in tenant $TenantFilter") return $Results } - $CurrentDelegatedScopes = New-GraphGETRequest -uri "https://graph.microsoft.com/beta/servicePrincipals/$($ourSVCPrincipal.id)/oauth2PermissionGrants" -skipTokenCache $true -tenantid $TenantFilter -NoAuthCheck $true -AsApp $UseAsApp + $CurrentDelegatedScopes = New-GraphGETRequest -uri "https://graph.microsoft.com/beta/servicePrincipals/$($ourSVCPrincipal.id)/oauth2PermissionGrants" -skipTokenCache $true -tenantid $TenantFilter -NoAuthCheck $true foreach ($App in $RequiredResourceAccess) { if (!$App) { @@ -111,7 +95,7 @@ function Add-CIPPDelegatedPermission { $Body = @{ appId = $App.resourceAppId } | ConvertTo-Json -Compress - $svcPrincipalId = New-GraphPOSTRequest -uri 'https://graph.microsoft.com/v1.0/servicePrincipals' -tenantid $TenantFilter -body $Body -type POST -NoAuthCheck $true -AsApp $UseAsApp + $svcPrincipalId = New-GraphPOSTRequest -uri 'https://graph.microsoft.com/v1.0/servicePrincipals' -tenantid $TenantFilter -body $Body -type POST -NoAuthCheck $true } catch { $Results.add("Failed to create service principal for $($App.resourceAppId): $(Get-NormalizedError -message $_.Exception.Message)") continue @@ -150,7 +134,7 @@ function Add-CIPPDelegatedPermission { resourceId = $svcPrincipalId.id scope = $NewScope } | ConvertTo-Json -Compress - $CreateRequest = New-GraphPOSTRequest -uri 'https://graph.microsoft.com/v1.0/oauth2PermissionGrants' -tenantid $TenantFilter -body $Createbody -type POST -NoAuthCheck $true -AsApp $UseAsApp + $CreateRequest = New-GraphPOSTRequest -uri 'https://graph.microsoft.com/v1.0/oauth2PermissionGrants' -tenantid $TenantFilter -body $Createbody -type POST -NoAuthCheck $true $Results.add("Successfully added permissions for $($svcPrincipalId.displayName)") } catch { $Results.add("Failed to add permissions for $($svcPrincipalId.displayName): $(Get-NormalizedError -message $_.Exception.Message)") @@ -163,7 +147,7 @@ function Add-CIPPDelegatedPermission { $OldScope.id | ForEach-Object { if ($_ -ne $OldScopeId) { try { - $null = New-GraphPOSTRequest -uri "https://graph.microsoft.com/v1.0/oauth2PermissionGrants/$_" -tenantid $TenantFilter -type DELETE -NoAuthCheck $true -AsApp $UseAsApp + $null = New-GraphPOSTRequest -uri "https://graph.microsoft.com/v1.0/oauth2PermissionGrants/$_" -tenantid $TenantFilter -type DELETE -NoAuthCheck $true } catch { } } @@ -187,7 +171,7 @@ function Add-CIPPDelegatedPermission { scope = "$NewScope" } | ConvertTo-Json -Compress try { - $null = New-GraphPOSTRequest -uri "https://graph.microsoft.com/v1.0/oauth2PermissionGrants/$($OldScopeId)" -tenantid $TenantFilter -body $Patchbody -type PATCH -NoAuthCheck $true -AsApp $UseAsApp + $null = New-GraphPOSTRequest -uri "https://graph.microsoft.com/v1.0/oauth2PermissionGrants/$($OldScopeId)" -tenantid $TenantFilter -body $Patchbody -type PATCH -NoAuthCheck $true } catch { $Results.add("Failed to update permissions for $($svcPrincipalId.displayName): $(Get-NormalizedError -message $_.Exception.Message)") continue diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecPermissionRepair.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecPermissionRepair.ps1 index d3301fb7a296d..513e0bd5aca07 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecPermissionRepair.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecPermissionRepair.ps1 @@ -1,13 +1,9 @@ function Invoke-ExecPermissionRepair { <# .SYNOPSIS - Reconciles the CIPP-SAM permissions and re-applies them to the partner service principal. + This endpoint will update the CIPP-SAM app permissions. .DESCRIPTION - Reconciles the saved additional-permission set (Update-CippSamPermissions), then refreshes the - grants on the CIPP-SAM service principal in the PARTNER tenant so the current effective set - (manifest + extras) is consented. This never writes the app registration's requiredResourceAccess; - permissions are applied as service-principal grants, the same way the routine refresh does. - Client tenants pick up the same effective set through their own permission refresh. + Merges new permissions from the SAM manifest into the AppPermissions entry for CIPP-SAM. .FUNCTIONALITY Entrypoint .ROLE @@ -18,19 +14,8 @@ function Invoke-ExecPermissionRepair { try { $User = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($Request.Headers.'x-ms-client-principal')) | ConvertFrom-Json - $UpdatedBy = $User.UserDetails ?? 'CIPP-API' - - # 1) Reconcile the saved extras table (no app-registration write). - $TableResult = Update-CippSamPermissions -UpdatedBy $UpdatedBy - - # 2) Refresh the grants on the partner CIPP-SAM service principal so the effective set - # (manifest + extras, read from the table) is actually consented on the SP. - $AppResults = Add-CIPPApplicationPermission -RequiredResourceAccess 'CIPPDefaults' -ApplicationId $env:ApplicationID -TenantFilter $env:TenantID -AsApp $true - $DelegatedResults = Add-CIPPDelegatedPermission -RequiredResourceAccess 'CIPPDefaults' -ApplicationId $env:ApplicationID -TenantFilter $env:TenantID -AsApp $true - - $Results = @($TableResult) + @($AppResults) + @($DelegatedResults) | Where-Object { $_ } - Write-LogMessage -Headers $Request.Headers -API 'ExecPermissionRepair' -message "CIPP-SAM permissions repaired by $UpdatedBy" -Sev 'Info' -LogData @{ Results = @($Results) } - $Body = @{'Results' = ($Results -join [Environment]::NewLine) } + $Result = Update-CippSamPermissions -UpdatedBy ($User.UserDetails ?? 'CIPP-API') + $Body = @{'Results' = $Result } } catch { $Body = @{ 'Results' = "$($_.Exception.Message) - at line $($_.InvocationInfo.ScriptLineNumber)" From f95046cbeeaf5f0bff25302397cefb3e36656255 Mon Sep 17 00:00:00 2001 From: Bobby <31723128+kris6673@users.noreply.github.com> Date: Thu, 2 Jul 2026 21:08:34 +0200 Subject: [PATCH 096/170] feat(bec): surface inbox rule changes in report The BEC report only listed inbox rules that still exist on the mailbox, so rules an attacker created, used and then deleted were invisible. Add a user-scoped audit log search for rule create, change and remove events over the last 7 days, expose it as InboxRuleChanges, and flag surviving rules as RecentlyChanged. Also harden the run: audit log search failures and timeouts were killing the entire BEC run, so wrap the search in try/catch and degrade to partial results with a warning instead. Drop the unused high-volume MailboxLogin/UserLoggedIn operations from the tenant-wide search (nothing downstream consumed them) and log Get-InboxRule failures properly instead of a broken Write-Host. --- .../Activity Triggers/BEC/Push-BECRun.ps1 | 69 +++++++++++++++---- 1 file changed, 55 insertions(+), 14 deletions(-) diff --git a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/BEC/Push-BECRun.ps1 b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/BEC/Push-BECRun.ps1 index b933239db4136..ef6ff5b01d255 100644 --- a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/BEC/Push-BECRun.ps1 +++ b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/BEC/Push-BECRun.ps1 @@ -21,7 +21,7 @@ function Push-BECRun { $endDate = (Get-Date) Write-Information 'Getting audit logs' $auditLog = (New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-AdminAuditLogConfig').UnifiedAuditLogIngestionEnabled - $7dayslog = if ($auditLog -eq $false) { + $7DaysLog = if ($auditLog -eq $false) { $ExtractResult = 'AuditLog is disabled. Cannot perform full analysis' } else { $sessionid = Get-Random -Minimum 10000 -Maximum 99999 @@ -29,9 +29,7 @@ function Push-BECRun { 'Remove-MailboxPermission', 'Add-MailboxPermission', 'UpdateCalendarDelegation', - 'AddFolderPermissions', - 'MailboxLogin', - 'UserLoggedIn' + 'AddFolderPermissions' ) $startDate = (Get-Date).AddDays(-7) $endDate = (Get-Date) @@ -42,12 +40,19 @@ function Push-BECRun { startDate = $startDate endDate = $endDate } - do { - New-ExoRequest -tenantid $TenantFilter -cmdlet 'Search-unifiedAuditLog' -cmdParams $SearchParam -Anchor $Username - Write-Information "Retrieved $($logsTenant.count) logs" - $logsTenant - } while ($LogsTenant.count % 5000 -eq 0 -and $LogsTenant.count -ne 0) - $ExtractResult = 'Successfully extracted logs from auditlog' + try { + do { + New-ExoRequest -tenantid $TenantFilter -cmdlet 'Search-unifiedAuditLog' -cmdParams $SearchParam -Anchor $Username + Write-Information "Retrieved $($logsTenant.count) logs" + $logsTenant + } while ($LogsTenant.count % 5000 -eq 0 -and $LogsTenant.count -ne 0) + $ExtractResult = 'Successfully extracted logs from auditlog' + } catch { + $CippAuditError = Get-CippException -Exception $_ + Write-LogMessage -API 'BECRun' -message "Audit log search failed for $($UserName): $($CippAuditError.NormalizedError)" -tenant $TenantFilter -sev Warning -LogData $CippAuditError + $ExtractResult = "Audit log search failed or timed out - mailbox permission changes could not be analyzed. Error: $($CippAuditError.NormalizedError)" + @() + } } Write-Information 'Getting last sign-in' try { @@ -76,7 +81,7 @@ function Push-BECRun { } try { - $PermissionsLog = ($7dayslog | Where-Object -Property Operations -In 'Remove-MailboxPermission', 'Add-MailboxPermission', 'UpdateCalendarDelegation', 'AddFolderPermissions' ).AuditData | ConvertFrom-Json -ErrorAction Stop | ForEach-Object { + $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 @@ -93,16 +98,51 @@ function Push-BECRun { $PermissionsLog = @() } + Write-Information 'Getting inbox rule changes' + try { + $RuleChangesLog = if ($auditLog -eq $false) { @() } else { + # ponytail: separate user-scoped search - UpdateInboxRules is too high-volume for the tenant-wide query above + $RuleSearchParam = @{ + SessionCommand = 'ReturnLargeSet' + Operations = @('New-InboxRule', 'Set-InboxRule', 'Remove-InboxRule', 'UpdateInboxRules') + sessionid = (Get-Random -Minimum 10000 -Maximum 99999) + startDate = $startDate + endDate = $endDate + 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 + } + } + } + } catch { + $RuleChangesLog = @() + $CippRuleError = Get-CippException -Exception $_ + Write-LogMessage -API 'BECRun' -message "Failed to retrieve inbox rule changes for $($UserName): $($CippRuleError.NormalizedError)" -tenant $TenantFilter -sev Warning -LogData $CippRuleError + } + Write-Information 'Getting rules' try { $RulesLog = New-ExoRequest -cmdlet 'Get-InboxRule' -tenantid $TenantFilter -cmdParams @{ Mailbox = $Username; IncludeHidden = $true } -Anchor $Username | Where-Object { $_.Name -ne 'Junk E-Mail Rule' -and $_.Name -notlike 'Microsoft.Exchange.OOF.*' } } catch { - Write-Host 'Failed to get rules: ' + $_.Exception.Message + $CippRulesError = Get-CippException -Exception $_ + Write-LogMessage -API 'BECRun' -message "Failed to retrieve inbox rules for $($UserName): $($CippRulesError.NormalizedError)" -tenant $TenantFilter -sev Warning -LogData $CippRulesError $RulesLog = @() } + # inbox rules carry no timestamps, so 'recent' = name-matches a 7-day audit event; Outlook-client changes (UpdateInboxRules) carry no rule name and stay unflagged + $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 sent message trace' try { $MessageTraceParams = @{ @@ -111,7 +151,7 @@ function Push-BECRun { 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) + Select-Object MessageTraceId, Status, Subject, RecipientAddress, @{ Name = 'Received'; Expression = { $_.Received.ToString('u') } }, FromIP) } catch { $SentMessages = @() $CippTraceError = Get-CippException -Exception $_ @@ -170,6 +210,7 @@ function Push-BECRun { LastSuspectUserLogon = @($LastSignIn) SuspectUserDevices = @($Devices) NewRules = @($RulesLog) + InboxRuleChanges = @($RuleChangesLog) SentMessages = @($SentMessages) MailboxPermissionChanges = @($PermissionsLog) NewUsers = @($NewUsers) @@ -191,7 +232,7 @@ function Push-BECRun { } catch { $errMessage = Get-NormalizedError -message $_.Exception.Message $CippError = Get-CippException -Exception $_ - $results = [pscustomobject]@{'Results' = "$errMessage"; Exception = $CippError } + $results = [pscustomobject]@{'Results' = "$errMessage"; Exception = $CippError; ExtractedAt = (Get-Date) } Write-LogMessage -API 'BECRun' -message "Error Running BEC for $($UserName): $errMessage" -tenant $TenantFilter -sev 'Error' -LogData $CIPPError $Entity = @{ UserId = $SuspectUser From 4ee969426f6f387618724889462cf6ff75996a49 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Thu, 2 Jul 2026 15:17:55 -0400 Subject: [PATCH 097/170] chore: bump version to 10.5.7 --- host.json | 4 ++-- version_latest.txt | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/host.json b/host.json index cfa1e128d096c..2d527d8c41083 100644 --- a/host.json +++ b/host.json @@ -16,9 +16,9 @@ "distributedTracingEnabled": false, "version": "None" }, - "defaultVersion": "10.5.6", + "defaultVersion": "10.5.7", "versionMatchStrategy": "Strict", "versionFailureStrategy": "Fail" } } -} \ No newline at end of file +} diff --git a/version_latest.txt b/version_latest.txt index 3b24057083036..e9d57a4235a04 100644 --- a/version_latest.txt +++ b/version_latest.txt @@ -1 +1 @@ -10.5.6 +10.5.7 From 965352fe5a8443c7cc3bb30ffa33299ca727d53a Mon Sep 17 00:00:00 2001 From: John Duprey Date: Thu, 2 Jul 2026 15:18:57 -0400 Subject: [PATCH 098/170] fix: add caching for hudu relations to reduce api overload --- .../Public/Hudu/Invoke-HuduExtensionSync.ps1 | 50 ++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) diff --git a/Modules/CippExtensions/Public/Hudu/Invoke-HuduExtensionSync.ps1 b/Modules/CippExtensions/Public/Hudu/Invoke-HuduExtensionSync.ps1 index 803865a5e3687..c0ad986868841 100644 --- a/Modules/CippExtensions/Public/Hudu/Invoke-HuduExtensionSync.ps1 +++ b/Modules/CippExtensions/Public/Hudu/Invoke-HuduExtensionSync.ps1 @@ -32,6 +32,12 @@ function Invoke-HuduExtensionSync { # Get Asset cache $HuduAssetCache = Get-CippTable -tablename 'CacheHuduAssets' + # Get Relations cache - Hudu's relations API has no per-company filter, so this is cached + # globally and shared across every tenant's sync instead of pulling the full relations + # table (30s+ on large instances) on every single sync run. + $HuduRelationsCache = Get-CippTable -tablename 'CacheHuduRelations' + $HuduRelationsCacheTTLMinutes = 15 + # Import license mapping $LicTable = [System.IO.File]::ReadAllText((Join-Path $env:CIPPRootPath 'Config\ConversionTable.csv')) | ConvertFrom-Csv @@ -132,7 +138,49 @@ function Invoke-HuduExtensionSync { $ExcludeSerials = $DefaultSerials } - $HuduRelations = Get-HuduRelations + $RelationsCacheMeta = Get-CIPPAzDataTableEntity @HuduRelationsCache -Filter "PartitionKey eq 'CacheMetadata' and RowKey eq 'LastRefresh'" + $RelationsCacheAgeMinutes = if ($RelationsCacheMeta.LastRefresh) { ((Get-Date).ToUniversalTime() - [datetime]$RelationsCacheMeta.LastRefresh).TotalMinutes } else { $null } + + if ($null -ne $RelationsCacheAgeMinutes -and $RelationsCacheAgeMinutes -lt $HuduRelationsCacheTTLMinutes) { + $CachedRelationRows = Get-CIPPAzDataTableEntity @HuduRelationsCache -Filter "PartitionKey eq 'HuduRelation'" + $HuduRelations = foreach ($CachedRelationRow in $CachedRelationRows) { + [PSCustomObject]@{ + id = $CachedRelationRow.RowKey + fromable_type = $CachedRelationRow.FromableType + fromable_id = $CachedRelationRow.FromableId + toable_type = $CachedRelationRow.ToableType + toable_id = $CachedRelationRow.ToableId + } + } + } else { + $HuduRelations = Get-HuduRelations + + $ExistingRelationRows = Get-CIPPAzDataTableEntity @HuduRelationsCache -Filter "PartitionKey eq 'HuduRelation'" + if ($ExistingRelationRows) { + Remove-AzDataTableEntity @HuduRelationsCache -Entity $ExistingRelationRows -Force + } + + $RelationEntities = foreach ($Relation in $HuduRelations) { + [PSCustomObject]@{ + PartitionKey = 'HuduRelation' + RowKey = [string]$Relation.id + FromableType = [string]$Relation.fromable_type + FromableId = [string]$Relation.fromable_id + ToableType = [string]$Relation.toable_type + ToableId = [string]$Relation.toable_id + } + } + if ($RelationEntities) { + Add-CIPPAzDataTableEntity @HuduRelationsCache -Entity $RelationEntities -Force + } + + $RelationsCacheMetaEntity = [PSCustomObject]@{ + PartitionKey = 'CacheMetadata' + RowKey = 'LastRefresh' + LastRefresh = (Get-Date).ToUniversalTime().ToString('o') + } + Add-CIPPAzDataTableEntity @HuduRelationsCache -Entity $RelationsCacheMetaEntity -Force + } [System.Collections.Generic.List[object]]$Links = @( @{ Title = 'M365 Admin Portal' From 50ba47ff269aaf57eca89fffdd90dfa06b36a536 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Thu, 2 Jul 2026 15:32:54 -0400 Subject: [PATCH 099/170] feat: add psscriptanalyzer --- .github/workflows/psscriptanalyzer.yml | 47 ++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 .github/workflows/psscriptanalyzer.yml diff --git a/.github/workflows/psscriptanalyzer.yml b/.github/workflows/psscriptanalyzer.yml new file mode 100644 index 0000000000000..da71991767877 --- /dev/null +++ b/.github/workflows/psscriptanalyzer.yml @@ -0,0 +1,47 @@ +# SAST for PowerShell: PSScriptAnalyzer with SARIF upload to the GitHub Security tab +# ISO 27001:2022 A.8.28/8.29 evidence — CodeQL does not support PowerShell, so +# PSScriptAnalyzer is the SAST tool for CIPP-API. +name: SAST - PSScriptAnalyzer +on: + push: + branches: [main, dev] + pull_request: + branches: [main, dev] + schedule: + - cron: "30 5 * * 1" # Weekly, catches newly added rules against unchanged code + +permissions: + contents: read + security-events: write + actions: read + +jobs: + psscriptanalyzer: + if: github.repository_owner == 'KelvinTegelaar' + name: Run PSScriptAnalyzer + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Run PSScriptAnalyzer + uses: microsoft/psscriptanalyzer-action@v1.1 + with: + path: .\ + recurse: true + # Security-focused rules; extend with full default ruleset if desired + includeRule: + '"PSAvoidUsingPlainTextForPassword", + "PSAvoidUsingConvertToSecureStringWithPlainText", + "PSAvoidUsingUsernameAndPasswordParams", + "PSUsePSCredentialType", + "PSAvoidUsingInvokeExpression", + "PSAvoidGlobalVars", + "PSAvoidUsingComputerNameHardcoded", + "PSUseDeclaredVarsMoreThanAssignments"' + output: results.sarif + + - name: Upload SARIF to Security tab + uses: github/codeql-action/upload-sarif@v4 + with: + sarif_file: results.sarif From 1390e14e421d9244b8b27083eeb7894c1d749bc0 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Thu, 2 Jul 2026 22:23:06 +0200 Subject: [PATCH 100/170] updated standards report item --- .../Invoke-CippTestGenericTest011.ps1 | 67 +++++++++++++++---- 1 file changed, 53 insertions(+), 14 deletions(-) diff --git a/Modules/CIPPTests/Public/Tests/GenericTests/Identity/Invoke-CippTestGenericTest011.ps1 b/Modules/CIPPTests/Public/Tests/GenericTests/Identity/Invoke-CippTestGenericTest011.ps1 index ebcb738157ac9..3930ff44c207b 100644 --- a/Modules/CIPPTests/Public/Tests/GenericTests/Identity/Invoke-CippTestGenericTest011.ps1 +++ b/Modules/CIPPTests/Public/Tests/GenericTests/Identity/Invoke-CippTestGenericTest011.ps1 @@ -13,8 +13,9 @@ function Invoke-CippTestGenericTest011 { return } - # Load standards.json for friendly name resolution + # Load standards.json for friendly name and compliance-tag resolution $StandardsLabelMap = @{} + $StandardsTagMap = @{} $StandardsJsonPath = Join-Path $env:CIPPRootPath 'Config\standards.json' if (Test-Path $StandardsJsonPath) { $StandardsJson = Get-Content $StandardsJsonPath -Raw | ConvertFrom-Json @@ -22,6 +23,14 @@ function Invoke-CippTestGenericTest011 { if ($Std.name -and $Std.label) { $StandardsLabelMap[$Std.name] = $Std.label } + if ($Std.name -and $Std.tag) { + # Keep human-readable compliance-framework references (CIS, NIST, etc. - they + # contain spaces) and drop internal single-token tags like 'mip_search_auditlog'. + $FrameworkTags = @($Std.tag | Where-Object { $_ -is [string] -and $_ -match '\s' }) + if ($FrameworkTags.Count -gt 0) { + $StandardsTagMap[$Std.name] = ($FrameworkTags -join ', ') + } + } } } @@ -130,6 +139,26 @@ function Invoke-CippTestGenericTest011 { return $null } + # Helper: resolve a standard's compliance-framework tags (CIS/NIST/etc.). Template-based + # standards (Intune/CA/Quarantine) have no standards.json entry, so they return empty. + $ResolveTags = { + param($StandardName) + if ([string]::IsNullOrWhiteSpace($StandardName)) { return '' } + if ($StandardsTagMap.ContainsKey($StandardName)) { return $StandardsTagMap[$StandardName] } + return '' + } + + # Helper: render a stored CurrentValue/ExpectedValue (plain string, bool, or compact JSON) + # safely inside a markdown table cell - escape pipes, collapse newlines, and truncate blobs. + $FormatValue = { + param($Value) + if ($null -eq $Value -or "$Value" -eq '') { return '' } + $Text = [string]$Value + $Text = $Text -replace '\|', '\|' -replace '\r?\n', ' ' + if ($Text.Length -gt 300) { $Text = $Text.Substring(0, 297) + '...' } + return $Text + } + foreach ($Template in $AlignmentItems) { $TemplateName = $Template.StandardName $Score = $Template.AlignmentScore @@ -168,24 +197,32 @@ function Invoke-CippTestGenericTest011 { # Compliant items if ($CompliantItems.Count -gt 0) { - $null = $Result.Append("| Standard | Status |`n") - $null = $Result.Append("|----------|--------|`n") + $null = $Result.Append("#### Compliant Standards`n`n") + $null = $Result.Append("| Standard | Tags | Status |`n") + $null = $Result.Append("|----------|------|--------|`n") foreach ($Item in $CompliantItems) { $FriendlyName = & $ResolveDisplayName $Item.StandardName $TemplateSettings if (-not $FriendlyName) { continue } - $null = $Result.Append("| $FriendlyName | ✅ Compliant |`n") + $Tags = & $ResolveTags $Item.StandardName + $null = $Result.Append("| $FriendlyName | $Tags | ✅ Compliant |`n") } $null = $Result.Append("`n") } - # Non-compliant items + # Non-compliant items — include the compliance tags and the reason for failure + # (expected value from the standard vs. the current config on the tenant). if ($NonCompliantItems.Count -gt 0) { - $null = $Result.Append("| Standard | Status |`n") - $null = $Result.Append("|----------|--------|`n") + $null = $Result.Append("#### Non-Compliant Standards`n`n") + $null = $Result.Append("| Standard | Tags | Expected Value | Current Value (on tenant) |`n") + $null = $Result.Append("|----------|------|----------------|---------------------------|`n") foreach ($Item in $NonCompliantItems) { $FriendlyName = & $ResolveDisplayName $Item.StandardName $TemplateSettings if (-not $FriendlyName) { continue } - $null = $Result.Append("| $FriendlyName | ❌ Non-Compliant |`n") + $Tags = & $ResolveTags $Item.StandardName + $Expected = & $FormatValue $Item.ExpectedValue + $Current = & $FormatValue $Item.CurrentValue + if (-not $Current) { $Current = '_Not configured / no data_' } + $null = $Result.Append("| $FriendlyName | $Tags | $Expected | $Current |`n") } $null = $Result.Append("`n") } @@ -194,12 +231,13 @@ function Invoke-CippTestGenericTest011 { if ($LicenseMissingItems.Count -gt 0) { $null = $Result.Append("#### Standards Not Applied Due to Missing Licenses`n`n") $null = $Result.Append("These items are part of this baseline, but your environment does not meet the minimum required licenses for them to be applied.`n`n") - $null = $Result.Append("| Standard | Status |`n") - $null = $Result.Append("|----------|--------|`n") + $null = $Result.Append("| Standard | Tags | Status |`n") + $null = $Result.Append("|----------|------|--------|`n") foreach ($Item in $LicenseMissingItems) { $FriendlyName = & $ResolveDisplayName $Item.StandardName $TemplateSettings if (-not $FriendlyName) { continue } - $null = $Result.Append("| $FriendlyName | ⚠️ License Missing |`n") + $Tags = & $ResolveTags $Item.StandardName + $null = $Result.Append("| $FriendlyName | $Tags | ⚠️ License Missing |`n") } $null = $Result.Append("`n") } @@ -207,12 +245,13 @@ function Invoke-CippTestGenericTest011 { # Reporting disabled items if ($ReportingDisabledItems.Count -gt 0) { $null = $Result.Append("#### Standards With Reporting Disabled`n`n") - $null = $Result.Append("| Standard | Status |`n") - $null = $Result.Append("|----------|--------|`n") + $null = $Result.Append("| Standard | Tags | Status |`n") + $null = $Result.Append("|----------|------|--------|`n") foreach ($Item in $ReportingDisabledItems) { $FriendlyName = & $ResolveDisplayName $Item.StandardName $TemplateSettings if (-not $FriendlyName) { continue } - $null = $Result.Append("| $FriendlyName | ⏸️ Reporting Disabled |`n") + $Tags = & $ResolveTags $Item.StandardName + $null = $Result.Append("| $FriendlyName | $Tags | ⏸️ Reporting Disabled |`n") } $null = $Result.Append("`n") } From 1f0a3c40a49a1f5e96bd2478f00b96c61dcce7ee Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Fri, 3 Jul 2026 16:19:26 +0800 Subject: [PATCH 101/170] Revert partner SP permission lookup and revert to table permissions list for setting CPV permissions --- .../GraphHelper/Get-CippSamPermissions.ps1 | 102 +++++------------- .../SAMManifest/Update-CippSamPermissions.ps1 | 84 +++++++-------- .../Settings/Invoke-ExecSAMAppPermissions.ps1 | 52 +++++---- 3 files changed, 103 insertions(+), 135 deletions(-) diff --git a/Modules/CIPPCore/Public/GraphHelper/Get-CippSamPermissions.ps1 b/Modules/CIPPCore/Public/GraphHelper/Get-CippSamPermissions.ps1 index 6408240f54042..4b01cda92a402 100644 --- a/Modules/CIPPCore/Public/GraphHelper/Get-CippSamPermissions.ps1 +++ b/Modules/CIPPCore/Public/GraphHelper/Get-CippSamPermissions.ps1 @@ -197,88 +197,44 @@ function Get-CippSamPermissions { } } - # Diff the effective set against what is actually GRANTED on the partner CIPP-SAM enterprise - # application (service principal): appRoleAssignments for application (Role) permissions and - # oauth2PermissionGrants for delegated (Scope) permissions. The app registration's - # requiredResourceAccess is intentionally NOT used - permissions are applied as SP grants, so the - # grants are the real source of truth for what the app can do. - # MissingPermissions = effective perms not yet granted on the SP (need to be added). - # PartnerAppDiff also surfaces extra grants on the SP that are not in the effective set. + # Diff the manifest-required base against the saved AppPermissions table. The table records what has + # been applied to the CIPP-SAM app - the repair/update flow persists it as manifest ∪ extras - so it + # stands in for the "current" permission set and no partner-tenant Graph call is needed here. + # MissingPermissions = manifest-required perms not yet present in the table (a Permissions repair is needed). + # PartnerAppDiff mirrors MissingPermissions in the shape the SAM permissions page expects. $MissingPermissions = @{} $PartnerAppDiff = @{} if (!$NoDiff.IsPresent) { - try { - $PartnerSP = New-GraphGETRequest -uri "https://graph.microsoft.com/beta/servicePrincipals(appId='$($env:ApplicationID)')?`$select=id" -tenantid $env:TenantID -NoAuthCheck $true - $AppRoleAssignments = New-GraphGETRequest -uri "https://graph.microsoft.com/beta/servicePrincipals/$($PartnerSP.id)/appRoleAssignments?`$top=999" -tenantid $env:TenantID -NoAuthCheck $true - $OAuthGrants = New-GraphGETRequest -uri "https://graph.microsoft.com/beta/servicePrincipals/$($PartnerSP.id)/oauth2PermissionGrants?`$top=999" -tenantid $env:TenantID -NoAuthCheck $true - - # Grants reference the resource SP's object id; map it back to the resource appId the - # effective set is keyed on. Use $UsedServicePrincipals - it carries both id and appId - # ($ServicePrincipals is selected without id, so its .id is null). - $ResourceIdToAppId = @{} - foreach ($SP in $UsedServicePrincipals) { if ($SP.id) { $ResourceIdToAppId[$SP.id] = $SP.appId } } - - # Granted application roles (GUIDs) per resource appId. - $GrantedRoleIdsByApp = @{} - foreach ($Assignment in $AppRoleAssignments) { - $ResAppId = $ResourceIdToAppId[$Assignment.resourceId] - if (!$ResAppId -or !$Assignment.appRoleId) { continue } - if (-not $GrantedRoleIdsByApp.ContainsKey($ResAppId)) { $GrantedRoleIdsByApp[$ResAppId] = [System.Collections.Generic.List[string]]::new() } - $GrantedRoleIdsByApp[$ResAppId].Add([string]$Assignment.appRoleId) - } + foreach ($AppId in $AllAppIds) { + $ManifestApp = $ManifestPermissions.$AppId + $SavedApp = $SavedPermissions.$AppId - # Granted delegated scope NAMES per resource appId (oauth2 grants store space-delimited names). - $GrantedScopesByApp = @{} - foreach ($Grant in $OAuthGrants) { - $ResAppId = $ResourceIdToAppId[$Grant.resourceId] - if (!$ResAppId) { continue } - if (-not $GrantedScopesByApp.ContainsKey($ResAppId)) { $GrantedScopesByApp[$ResAppId] = [System.Collections.Generic.List[string]]::new() } - foreach ($ScopeName in @(($Grant.scope -split ' ') | Where-Object { $_ })) { $GrantedScopesByApp[$ResAppId].Add($ScopeName) } - } + $SavedAppIds = @($SavedApp.applicationPermissions.id) + $SavedDelIds = @($SavedApp.delegatedPermissions.id) - foreach ($AppId in $AllAppIds) { - $ServicePrincipal = $ServicePrincipals | Where-Object -Property appId -EQ $AppId - $GrantedRoleIds = @($GrantedRoleIdsByApp[$AppId] | Where-Object { $_ }) - $GrantedScopeNames = @($GrantedScopesByApp[$AppId] | Where-Object { $_ }) - - # Application (Role) permissions compare by GUID against appRoleAssignments. - $EffApp = @($EffectivePermissions.$AppId.applicationPermissions | Where-Object { $_.id -match $GuidRegex }) - # Delegated (Scope) permissions compare by NAME (value) against oauth2 grant scopes - - # this covers both GUID-resolved scopes and the string-named AdditionalPermissions. - $EffDel = @($EffectivePermissions.$AppId.delegatedPermissions) - $EffAppIds = @($EffApp.id) - $EffDelNames = @($EffDel.value) - - $MissingApp = @(foreach ($Permission in $EffApp) { if ($GrantedRoleIds -notcontains $Permission.id) { $Permission } }) - $MissingDel = @(foreach ($Permission in $EffDel) { if ($Permission.value -and $GrantedScopeNames -notcontains $Permission.value) { $Permission } }) - $ExtraApp = @(foreach ($Id in ($GrantedRoleIds | Sort-Object -Unique)) { - if ($EffAppIds -notcontains $Id) { - [PSCustomObject]@{ id = $Id; value = (($ServicePrincipal.appRoles | Where-Object -Property id -EQ $Id).value) ?? $Id } - } - }) - $ExtraDel = @(foreach ($Name in ($GrantedScopeNames | Sort-Object -Unique)) { - if ($EffDelNames -notcontains $Name) { - [PSCustomObject]@{ id = $Name; value = $Name } - } - }) - - if ($MissingApp.Count -gt 0 -or $MissingDel.Count -gt 0) { - $MissingPermissions.$AppId = @{ - applicationPermissions = $MissingApp - delegatedPermissions = $MissingDel + $MissingApp = @(foreach ($Permission in $ManifestApp.applicationPermissions) { + if ($Permission.id -and $SavedAppIds -notcontains $Permission.id) { + [PSCustomObject]@{ id = $Permission.id; value = $Permission.value } } - } - if ($MissingApp.Count -gt 0 -or $MissingDel.Count -gt 0 -or $ExtraApp.Count -gt 0 -or $ExtraDel.Count -gt 0) { - $PartnerAppDiff.$AppId = @{ - missingApplicationPermissions = $MissingApp - missingDelegatedPermissions = $MissingDel - extraApplicationPermissions = $ExtraApp - extraDelegatedPermissions = $ExtraDel + }) + $MissingDel = @(foreach ($Permission in $ManifestApp.delegatedPermissions) { + if ($Permission.id -and $SavedDelIds -notcontains $Permission.id) { + [PSCustomObject]@{ id = $Permission.id; value = $Permission.value } } + }) + + if ($MissingApp.Count -gt 0 -or $MissingDel.Count -gt 0) { + $MissingPermissions.$AppId = @{ + applicationPermissions = $MissingApp + delegatedPermissions = $MissingDel + } + $PartnerAppDiff.$AppId = @{ + missingApplicationPermissions = $MissingApp + missingDelegatedPermissions = $MissingDel + extraApplicationPermissions = @() + extraDelegatedPermissions = @() } } - } catch { - Write-Information "Failed to retrieve partner enterprise app grants for permission diff: $($_.Exception.Message)" } } diff --git a/Modules/CIPPCore/Public/SAMManifest/Update-CippSamPermissions.ps1 b/Modules/CIPPCore/Public/SAMManifest/Update-CippSamPermissions.ps1 index 7ed67f5be3111..bad4157e0f263 100644 --- a/Modules/CIPPCore/Public/SAMManifest/Update-CippSamPermissions.ps1 +++ b/Modules/CIPPCore/Public/SAMManifest/Update-CippSamPermissions.ps1 @@ -1,19 +1,18 @@ function Update-CippSamPermissions { <# .SYNOPSIS - Reconciles the saved CIPP-SAM additional-permission set in the AppPermissions table. + Reconciles the applied CIPP-SAM permission set in the AppPermissions table. .DESCRIPTION - The SAM manifest is the immutable permission base and is always layered in at read time by - Get-CippSamPermissions, so the AppPermissions table only ever needs to hold the EXTRA - permissions an admin layered on top. This function keeps that row clean: it drops any saved - entries the manifest now covers (e.g. legacy rows that stored the full manifest+extras set) - so the table stays "extras only". + Writes the full applied permission set - the SAM manifest base PLUS any admin-configured extra + permissions - into the AppPermissions table, so the table always reflects everything the + CIPP-SAM app is expected to have. Get-CippSamPermissions diffs the manifest against this table + to decide when a Permissions repair is needed, so persisting the manifest here is what lets that + check clear after a repair. It deliberately does NOT write the partner CIPP-SAM app registration's requiredResourceAccess. Permissions reach the CIPP-SAM service principal(s) - partner and clients - through the grant flow (Add-CIPPApplicationPermission / Add-CIPPDelegatedPermission, which read this table), not - through the app registration. Refreshing those grants is handled by the caller - (Invoke-ExecPermissionRepair for the partner, the per-tenant permission refresh for clients). + through the app registration. .PARAMETER UpdatedBy The user or system that is performing the update. Defaults to 'CIPP-API'. .OUTPUTS @@ -26,69 +25,68 @@ function Update-CippSamPermissions { ) try { - # Manifest base - always-required permissions that are layered in at read time, so they never - # need to live in the saved extras row. + # Manifest base - the always-required permissions. $ManifestPermissions = (Get-CippSamPermissions -ManifestOnly).Permissions $Table = Get-CIPPTable -TableName 'AppPermissions' $SavedRow = Get-CippAzDataTableEntity @Table -Filter "PartitionKey eq 'CIPP-SAM' and RowKey eq 'CIPP-SAM'" - if (-not $SavedRow.Permissions) { - return 'No additional permissions saved. CIPP default (manifest) permissions are always applied.' - } - - try { - $Saved = $SavedRow.Permissions | ConvertFrom-Json -ErrorAction Stop - } catch { - return 'Saved additional permissions could not be parsed; nothing to reconcile.' + $Saved = $null + if ($SavedRow.Permissions) { + try { + $Saved = $SavedRow.Permissions | ConvertFrom-Json -ErrorAction Stop + } catch { + $Saved = $null + } } - # Keep only the entries the manifest does NOT already cover. - $Extras = @{} - $RemovedCount = 0 - foreach ($AppId in $Saved.PSObject.Properties.Name) { + # Build the full applied set = manifest base ∪ admin extras, keyed by resource appId. + $Applied = @{} + $AppIds = @(@($ManifestPermissions.PSObject.Properties.Name) + @($Saved.PSObject.Properties.Name)) | Where-Object { $_ } | Sort-Object -Unique + foreach ($AppId in $AppIds) { $ManifestApp = $ManifestPermissions.$AppId + $SavedApp = $Saved.$AppId $ManifestAppIds = @($ManifestApp.applicationPermissions.id) $ManifestDelIds = @($ManifestApp.delegatedPermissions.id) - $ExtraApp = [System.Collections.Generic.List[object]]::new() - foreach ($Permission in $Saved.$AppId.applicationPermissions) { + $AppPerms = [System.Collections.Generic.List[object]]::new() + $DelPerms = [System.Collections.Generic.List[object]]::new() + + # Manifest base (always applied). + foreach ($Permission in $ManifestApp.applicationPermissions) { + $AppPerms.Add([PSCustomObject]@{ id = $Permission.id; value = $Permission.value }) + } + foreach ($Permission in $ManifestApp.delegatedPermissions) { + $DelPerms.Add([PSCustomObject]@{ id = $Permission.id; value = $Permission.value }) + } + # Admin extras (anything the manifest does not already cover). + foreach ($Permission in $SavedApp.applicationPermissions) { if ($Permission.id -and $ManifestAppIds -notcontains $Permission.id) { - $ExtraApp.Add([PSCustomObject]@{ id = $Permission.id; value = $Permission.value }) - } else { - $RemovedCount++ + $AppPerms.Add([PSCustomObject]@{ id = $Permission.id; value = $Permission.value }) } } - $ExtraDel = [System.Collections.Generic.List[object]]::new() - foreach ($Permission in $Saved.$AppId.delegatedPermissions) { + foreach ($Permission in $SavedApp.delegatedPermissions) { if ($Permission.id -and $ManifestDelIds -notcontains $Permission.id) { - $ExtraDel.Add([PSCustomObject]@{ id = $Permission.id; value = $Permission.value }) - } else { - $RemovedCount++ + $DelPerms.Add([PSCustomObject]@{ id = $Permission.id; value = $Permission.value }) } } - if ($ExtraApp.Count -gt 0 -or $ExtraDel.Count -gt 0) { - $Extras.$AppId = @{ - applicationPermissions = @($ExtraApp) - delegatedPermissions = @($ExtraDel) + if ($AppPerms.Count -gt 0 -or $DelPerms.Count -gt 0) { + $Applied.$AppId = @{ + applicationPermissions = @($AppPerms) + delegatedPermissions = @($DelPerms) } } } - if ($RemovedCount -eq 0) { - return 'Saved additional permissions already reconciled; no manifest-covered entries to remove.' - } - $Entity = @{ 'PartitionKey' = 'CIPP-SAM' 'RowKey' = 'CIPP-SAM' - 'Permissions' = [string]([PSCustomObject]$Extras | ConvertTo-Json -Depth 10 -Compress) + 'Permissions' = [string]([PSCustomObject]$Applied | ConvertTo-Json -Depth 10 -Compress) 'UpdatedBy' = $UpdatedBy } $null = Add-CIPPAzDataTableEntity @Table -Entity $Entity -Force - $Plural = if ($RemovedCount -eq 1) { 'entry' } else { 'entries' } - return "Reconciled saved additional permissions: removed $RemovedCount $Plural now covered by the CIPP manifest." + return 'CIPP-SAM permissions reconciled: the applied permission table now contains the CIPP manifest permissions plus any additional permissions.' } catch { throw "Failed to reconcile permissions: $($_.Exception.Message)" } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecSAMAppPermissions.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecSAMAppPermissions.ps1 index 9c7885ab908a1..0a19eeb588041 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecSAMAppPermissions.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecSAMAppPermissions.ps1 @@ -16,27 +16,41 @@ function Invoke-ExecSAMAppPermissions { $Submitted = $Request.Body.Permissions $ManifestPermissions = (Get-CippSamPermissions -ManifestOnly).Permissions - $Extras = @{} - foreach ($AppId in $Submitted.PSObject.Properties.Name) { + # Persist the full applied set = manifest base ∪ submitted extras, so the AppPermissions + # table always reflects everything the CIPP-SAM app should have (the manifest is always + # applied and cannot be removed). Get-CippSamPermissions diffs the manifest against this + # table to decide when a Permissions repair is needed. + $Applied = @{} + $AppIds = @(@($ManifestPermissions.PSObject.Properties.Name) + @($Submitted.PSObject.Properties.Name)) | Where-Object { $_ } | Sort-Object -Unique + foreach ($AppId in $AppIds) { $ManifestApp = $ManifestPermissions.$AppId $ManifestAppIds = @($ManifestApp.applicationPermissions.id) $ManifestDelIds = @($ManifestApp.delegatedPermissions.id) - $ExtraApp = @(foreach ($Permission in $Submitted.$AppId.applicationPermissions) { - if ($Permission.id -and $ManifestAppIds -notcontains $Permission.id) { - [PSCustomObject]@{ id = $Permission.id; value = $Permission.value } - } - }) - $ExtraDel = @(foreach ($Permission in $Submitted.$AppId.delegatedPermissions) { - if ($Permission.id -and $ManifestDelIds -notcontains $Permission.id) { - [PSCustomObject]@{ id = $Permission.id; value = $Permission.value } - } - }) + $AppPerms = [System.Collections.Generic.List[object]]::new() + $DelPerms = [System.Collections.Generic.List[object]]::new() - if ($ExtraApp.Count -gt 0 -or $ExtraDel.Count -gt 0) { - $Extras.$AppId = @{ - applicationPermissions = $ExtraApp - delegatedPermissions = $ExtraDel + foreach ($Permission in $ManifestApp.applicationPermissions) { + $AppPerms.Add([PSCustomObject]@{ id = $Permission.id; value = $Permission.value }) + } + foreach ($Permission in $ManifestApp.delegatedPermissions) { + $DelPerms.Add([PSCustomObject]@{ id = $Permission.id; value = $Permission.value }) + } + foreach ($Permission in $Submitted.$AppId.applicationPermissions) { + if ($Permission.id -and $ManifestAppIds -notcontains $Permission.id) { + $AppPerms.Add([PSCustomObject]@{ id = $Permission.id; value = $Permission.value }) + } + } + foreach ($Permission in $Submitted.$AppId.delegatedPermissions) { + if ($Permission.id -and $ManifestDelIds -notcontains $Permission.id) { + $DelPerms.Add([PSCustomObject]@{ id = $Permission.id; value = $Permission.value }) + } + } + + if ($AppPerms.Count -gt 0 -or $DelPerms.Count -gt 0) { + $Applied.$AppId = @{ + applicationPermissions = @($AppPerms) + delegatedPermissions = @($DelPerms) } } } @@ -44,15 +58,15 @@ function Invoke-ExecSAMAppPermissions { $Entity = @{ 'PartitionKey' = 'CIPP-SAM' 'RowKey' = 'CIPP-SAM' - 'Permissions' = [string]([PSCustomObject]$Extras | ConvertTo-Json -Depth 10 -Compress) + 'Permissions' = [string]([PSCustomObject]$Applied | ConvertTo-Json -Depth 10 -Compress) 'UpdatedBy' = $User.UserDetails ?? 'CIPP-API' } $Table = Get-CIPPTable -TableName 'AppPermissions' $null = Add-CIPPAzDataTableEntity @Table -Entity $Entity -Force $Body = @{ - 'Results' = 'Additional permissions updated. Default CIPP permissions are always applied and cannot be removed. Please run a Permissions check and CPV refresh to finalise the changes.' + 'Results' = 'Permissions updated. Default CIPP permissions are always applied and cannot be removed. Please run a Permissions check and CPV refresh to finalise the changes.' } - Write-LogMessage -headers $Request.Headers -API 'ExecSAMAppPermissions' -message 'CIPP-SAM additional permissions updated' -Sev 'Info' -LogData $Extras + Write-LogMessage -headers $Request.Headers -API 'ExecSAMAppPermissions' -message 'CIPP-SAM permissions updated' -Sev 'Info' -LogData $Applied } catch { $Body = @{ 'Results' = $_.Exception.Message From 127bdc688b223f9295922960b39c46cee10ae4ab Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Fri, 3 Jul 2026 20:15:49 +0800 Subject: [PATCH 102/170] BEC and false network timeout fixes --- .../Activity Triggers/BEC/Push-BECRun.ps1 | 61 ++++++++++-------- Shared/CIPPSharp/CIPPRestClient.cs | 21 ++++++ Shared/CIPPSharp/bin/CIPPSharp.dll | Bin 46592 -> 47104 bytes 3 files changed, 55 insertions(+), 27 deletions(-) diff --git a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/BEC/Push-BECRun.ps1 b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/BEC/Push-BECRun.ps1 index b933239db4136..201d17000808e 100644 --- a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/BEC/Push-BECRun.ps1 +++ b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/BEC/Push-BECRun.ps1 @@ -20,34 +20,41 @@ function Push-BECRun { $startDate = (Get-Date).AddDays(-7).ToUniversalTime() $endDate = (Get-Date) Write-Information 'Getting audit logs' - $auditLog = (New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-AdminAuditLogConfig').UnifiedAuditLogIngestionEnabled - $7dayslog = if ($auditLog -eq $false) { - $ExtractResult = 'AuditLog is disabled. Cannot perform full analysis' - } else { - $sessionid = Get-Random -Minimum 10000 -Maximum 99999 - $operations = @( - 'Remove-MailboxPermission', - 'Add-MailboxPermission', - 'UpdateCalendarDelegation', - 'AddFolderPermissions', - 'MailboxLogin', - 'UserLoggedIn' - ) - $startDate = (Get-Date).AddDays(-7) - $endDate = (Get-Date) - $SearchParam = @{ - SessionCommand = 'ReturnLargeSet' - Operations = $operations - sessionid = $sessionid - startDate = $startDate - endDate = $endDate + try { + $auditLog = (New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-AdminAuditLogConfig').UnifiedAuditLogIngestionEnabled + $7dayslog = if ($auditLog -eq $false) { + $ExtractResult = 'AuditLog is disabled. Cannot perform full analysis' + } else { + $sessionid = Get-Random -Minimum 10000 -Maximum 99999 + $operations = @( + 'Remove-MailboxPermission', + 'Add-MailboxPermission', + 'UpdateCalendarDelegation', + 'AddFolderPermissions', + 'MailboxLogin', + 'UserLoggedIn' + ) + $startDate = (Get-Date).AddDays(-7) + $endDate = (Get-Date) + $SearchParam = @{ + SessionCommand = 'ReturnLargeSet' + Operations = $operations + sessionid = $sessionid + startDate = $startDate + endDate = $endDate + } + do { + $logsTenant = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Search-unifiedAuditLog' -cmdParams $SearchParam -Anchor $Username + Write-Information "Retrieved $($logsTenant.count) logs" + $logsTenant + } while ($LogsTenant.count % 5000 -eq 0 -and $LogsTenant.count -ne 0) + $ExtractResult = 'Successfully extracted logs from auditlog' } - do { - New-ExoRequest -tenantid $TenantFilter -cmdlet 'Search-unifiedAuditLog' -cmdParams $SearchParam -Anchor $Username - Write-Information "Retrieved $($logsTenant.count) logs" - $logsTenant - } while ($LogsTenant.count % 5000 -eq 0 -and $LogsTenant.count -ne 0) - $ExtractResult = 'Successfully extracted logs from auditlog' + } catch { + $7dayslog = @() + $CippAuditError = Get-CippException -Exception $_ + $ExtractResult = "Could not retrieve audit logs: $($CippAuditError.NormalizedError)" + Write-LogMessage -API 'BECRun' -message "Failed to retrieve audit logs for $($UserName): $($CippAuditError.NormalizedError)" -tenant $TenantFilter -sev Warning -LogData $CippAuditError } Write-Information 'Getting last sign-in' try { diff --git a/Shared/CIPPSharp/CIPPRestClient.cs b/Shared/CIPPSharp/CIPPRestClient.cs index 4857bc8bce073..7647639c7251e 100644 --- a/Shared/CIPPSharp/CIPPRestClient.cs +++ b/Shared/CIPPSharp/CIPPRestClient.cs @@ -684,6 +684,27 @@ public static async Task SendAsync( { response = await client.SendAsync(request, token).ConfigureAwait(false); } + catch (OperationCanceledException) when (cts is not null && cts.IsCancellationRequested) + { + // Our per-request timeout CTS actually fired — this is a genuine + // client-side timeout after timeoutSec. Let it propagate as an + // OperationCanceledException so the PowerShell wrapper reports it + // as a timeout (and the "timed out after {timeoutSec}s" message is true). + TrackTransportError(selection.Pool); + throw; + } + catch (OperationCanceledException ex) + { + // Cancellation was NOT triggered by our timeout token. The server + // reset/closed the request before our timeout elapsed — common for + // slow EXO InvokeCommand cmdlets (Search-UnifiedAuditLog, + // Get-MessageTraceV2) which the service cuts off well under 100s. + // Surface it as a transport error so it is not mislabeled as a + // client timeout and is correctly treated as a retryable failure. + TrackTransportError(selection.Pool); + throw new HttpRequestException( + $"The request to '{uri}' was canceled by the server before completing.", ex); + } catch { TrackTransportError(selection.Pool); diff --git a/Shared/CIPPSharp/bin/CIPPSharp.dll b/Shared/CIPPSharp/bin/CIPPSharp.dll index 06c361ccaea70f9896ea516105b4cbcacc36cc12..df386f1ae5a83f5c47abe12b651334f854917fb3 100644 GIT binary patch delta 14099 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 delta 13544 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 Us9j$>C5`G|FTehks3|c0C#hz$C;$Ke From 4efcd9f35e7aa05edd918e728374177c96ac4d84 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Fri, 3 Jul 2026 08:44:23 -0400 Subject: [PATCH 103/170] chore: update version to 10.5.8 --- host.json | 2 +- version_latest.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/host.json b/host.json index 2d527d8c41083..9de15356dcb97 100644 --- a/host.json +++ b/host.json @@ -16,7 +16,7 @@ "distributedTracingEnabled": false, "version": "None" }, - "defaultVersion": "10.5.7", + "defaultVersion": "10.5.8", "versionMatchStrategy": "Strict", "versionFailureStrategy": "Fail" } diff --git a/version_latest.txt b/version_latest.txt index e9d57a4235a04..0b09579a68d16 100644 --- a/version_latest.txt +++ b/version_latest.txt @@ -1 +1 @@ -10.5.7 +10.5.8 From 288351fbf41d1d07e31e409cedb3c20f8a8e3ff4 Mon Sep 17 00:00:00 2001 From: Luis Mengel Date: Sun, 5 Jul 2026 00:13:26 +0200 Subject: [PATCH 104/170] fix: remove users from mail-enabled security groups during license removal --- .../CIPPCore/Public/Remove-CIPPLicense.ps1 | 70 ++++++++++++++----- 1 file changed, 52 insertions(+), 18 deletions(-) diff --git a/Modules/CIPPCore/Public/Remove-CIPPLicense.ps1 b/Modules/CIPPCore/Public/Remove-CIPPLicense.ps1 index 9422e8556faa8..3f39bda0c3f18 100644 --- a/Modules/CIPPCore/Public/Remove-CIPPLicense.ps1 +++ b/Modules/CIPPCore/Public/Remove-CIPPLicense.ps1 @@ -35,32 +35,66 @@ function Remove-CIPPLicense { try { $ConvertTable = [System.IO.File]::ReadAllText((Join-Path $env:CIPPRootPath 'Config\ConversionTable.csv')) | ConvertFrom-Csv $User = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/users/$($userid)" -tenantid $tenantFilter - $GroupMemberships = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/users/$($userid)/memberOf/microsoft.graph.group?`$select=id,displayName,assignedLicenses" -tenantid $tenantFilter + $GroupMemberships = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/users/$($userid)/memberOf/microsoft.graph.group?`$select=id,displayName,assignedLicenses,mailEnabled,groupTypes" -tenantid $tenantFilter $LicenseGroups = $GroupMemberships | Where-Object { ($_.assignedLicenses | Measure-Object).Count -gt 0 } if ($LicenseGroups) { - # remove user from groups with licenses, these can only be graph groups - $RemoveRequests = foreach ($LicenseGroup in $LicenseGroups) { - @{ - id = $LicenseGroup.id - method = 'DELETE' - url = "groups/$($LicenseGroup.id)/members/$($User.id)/`$ref" + # remove user from license groups. Mail-enabled security groups can't be modified through Graph, so those go through Exchange Online instead + $GraphRemoveRequests = [System.Collections.Generic.List[object]]::new() + $ExoRemoveRequests = [System.Collections.Generic.List[object]]::new() + $ExoGroups = [System.Collections.Generic.List[object]]::new() + + foreach ($LicenseGroup in $LicenseGroups) { + $IsM365Group = $LicenseGroup.groupTypes -contains 'Unified' + if ($LicenseGroup.mailEnabled -and -not $IsM365Group) { + $ExoRemoveRequests.Add(@{ + CmdletInput = @{ + CmdletName = 'Remove-DistributionGroupMember' + Parameters = @{ + Identity = $LicenseGroup.id + Member = $User.id + BypassSecurityGroupManagerCheck = $true + } + } + }) + $ExoGroups.Add($LicenseGroup) + } else { + $GraphRemoveRequests.Add(@{ + id = $LicenseGroup.id + method = 'DELETE' + url = "groups/$($LicenseGroup.id)/members/$($User.id)/`$ref" + }) } } Write-Information 'Removing user from groups with licenses' - $RemoveResults = New-GraphBulkRequest -tenantid $tenantFilter -requests @($RemoveRequests) - Write-Information ($RemoveResults | ConvertTo-Json -Depth 5) - foreach ($Result in $RemoveResults) { - $Group = $LicenseGroups | Where-Object { $_.id -eq $Result.id } - $GroupName = $Group.displayName - if ($Result.status -eq 204) { - Write-LogMessage -headers $Headers -API $APIName -message "Removed $($User.displayName) from license group $GroupName" -Sev 'Info' -tenant $TenantFilter - "Removed $($User.displayName) from license group $GroupName" - } else { - Write-LogMessage -headers $Headers -API $APIName -message "Failed to remove $($User.displayName) from license group $GroupName. This is likely because its a Dynamic Group or synced with active directory." -Sev 'Error' -tenant $TenantFilter - "Failed to remove $($User.displayName) from license group $GroupName. This is likely because its a Dynamic Group or synced with active directory." + if ($GraphRemoveRequests.Count -gt 0) { + $RemoveResults = New-GraphBulkRequest -tenantid $tenantFilter -requests @($GraphRemoveRequests) + Write-Information ($RemoveResults | ConvertTo-Json -Depth 5) + foreach ($Result in $RemoveResults) { + $GroupName = ($LicenseGroups | Where-Object { $_.id -eq $Result.id }).displayName + if ($Result.status -eq 204) { + Write-LogMessage -headers $Headers -API $APIName -message "Removed $($User.displayName) from license group $GroupName" -Sev 'Info' -tenant $TenantFilter + "Removed $($User.displayName) from license group $GroupName" + } else { + Write-LogMessage -headers $Headers -API $APIName -message "Failed to remove $($User.displayName) from license group $GroupName. This is likely because its a Dynamic Group or synced with active directory." -Sev 'Error' -tenant $TenantFilter + "Failed to remove $($User.displayName) from license group $GroupName. This is likely because its a Dynamic Group or synced with active directory." + } + } + } + + if ($ExoRemoveRequests.Count -gt 0) { + $RawExoRequest = New-ExoBulkRequest -tenantid $tenantFilter -cmdletArray @($ExoRemoveRequests) + $LastError = $RawExoRequest | Select-Object -Last 1 + foreach ($ExoGroup in $ExoGroups) { + if (!$LastError -or ($LastError.error -and $LastError.target -notcontains $User.id)) { + Write-LogMessage -headers $Headers -API $APIName -message "Removed $($User.displayName) from mail-enabled license group $($ExoGroup.displayName)" -Sev 'Info' -tenant $TenantFilter + "Removed $($User.displayName) from mail-enabled license group $($ExoGroup.displayName)" + } else { + Write-LogMessage -headers $Headers -API $APIName -message "Failed to remove $($User.displayName) from mail-enabled license group $($ExoGroup.displayName). This is likely because its a Dynamic Group or synced with active directory." -Sev 'Error' -tenant $TenantFilter + "Failed to remove $($User.displayName) from mail-enabled license group $($ExoGroup.displayName)." + } } } } From b378d96e0f6e2884a524b435e2bb825c6e1ade37 Mon Sep 17 00:00:00 2001 From: Luis Mengel Date: Sun, 5 Jul 2026 01:11:31 +0200 Subject: [PATCH 105/170] feat: add email aliases and hide-from-GAL to group templates --- Modules/CIPPCore/Public/New-CIPPGroup.ps1 | 12 ++++++++++++ .../Groups/Invoke-AddGroupTemplate.ps1 | 2 ++ .../Groups/Invoke-ListGroupTemplates.ps1 | 2 ++ 3 files changed, 16 insertions(+) diff --git a/Modules/CIPPCore/Public/New-CIPPGroup.ps1 b/Modules/CIPPCore/Public/New-CIPPGroup.ps1 index ff186140304d0..d6913f7f55651 100644 --- a/Modules/CIPPCore/Public/New-CIPPGroup.ps1 +++ b/Modules/CIPPCore/Public/New-CIPPGroup.ps1 @@ -275,6 +275,18 @@ function New-CIPPGroup { } $GraphRequest = New-ExoRequest -tenantid $TenantFilter -cmdlet 'New-DistributionGroup' -cmdParams $ExoParams + + $Aliases = @($GroupObject.aliases -split '\r?\n' | ForEach-Object { $_.Trim() } | Where-Object { $_ }) + $SetParams = @{ Identity = $GraphRequest.Identity } + if ($GroupObject.hideFromGAL) { + $SetParams.HiddenFromAddressListsEnabled = $true + } + if ($Aliases.Count -gt 0) { + $SetParams.EmailAddresses = @{ '@odata.type' = '#Exchange.GenericHashTable'; Add = @($Aliases | ForEach-Object { "smtp:$_" }) } + } + if ($SetParams.Keys.Count -gt 1) { + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Set-DistributionGroup' -cmdParams $SetParams + } } $Result = [PSCustomObject]@{ diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Groups/Invoke-AddGroupTemplate.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Groups/Invoke-AddGroupTemplate.ps1 index 8d17d0aa0118e..d5c985745043f 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Groups/Invoke-AddGroupTemplate.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Groups/Invoke-AddGroupTemplate.ps1 @@ -53,6 +53,8 @@ function Invoke-AddGroupTemplate { allowExternal = $Request.Body.allowExternal username = $Request.Body.username # Can contain variables like @%tenantfilter% licenses = $Request.Body.licenses + aliases = $Request.Body.aliases # One per line, variables allowed + hideFromGAL = $Request.Body.hideFromGAL GUID = $GUID } | ConvertTo-Json -Depth 10 $Table = Get-CippTable -tablename 'templates' diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Groups/Invoke-ListGroupTemplates.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Groups/Invoke-ListGroupTemplates.ps1 index 4a4373733049c..9413c7da609d5 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Groups/Invoke-ListGroupTemplates.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Groups/Invoke-ListGroupTemplates.ps1 @@ -48,6 +48,8 @@ function Invoke-ListGroupTemplates { allowExternal = $data.allowExternal username = $data.username licenses = $data.licenses + aliases = $data.aliases + hideFromGAL = $data.hideFromGAL GUID = $_.RowKey source = $_.Source isSynced = (![string]::IsNullOrEmpty($_.SHA)) From 78c74362bd7fe9d3bbebfc5e6de2b44790ff37e2 Mon Sep 17 00:00:00 2001 From: DamienMatthys Date: Mon, 6 Jul 2026 14:42:05 +1000 Subject: [PATCH 106/170] Update Get-CIPPCVEReport.ps1 Added extra properties Improved efficiency of adding exception items Signed-off-by: DamienMatthys --- Modules/CIPPCore/Public/Get-CIPPCVEReport.ps1 | 58 +++++++++++++------ 1 file changed, 40 insertions(+), 18 deletions(-) diff --git a/Modules/CIPPCore/Public/Get-CIPPCVEReport.ps1 b/Modules/CIPPCore/Public/Get-CIPPCVEReport.ps1 index 6023e78108da3..a1f4e7e58d5c8 100644 --- a/Modules/CIPPCore/Public/Get-CIPPCVEReport.ps1 +++ b/Modules/CIPPCore/Public/Get-CIPPCVEReport.ps1 @@ -84,9 +84,12 @@ function Get-CIPPCVEReport { softwareName = $Item.softwareName softwareVendor = $Item.softwareVendor softwareVersion = $Item.softwareVersion + lastUpdated = $Item.lastUpdated TotalDeviceCount = 0 AffectedTenantsList = [System.Collections.Generic.List[object]]::new() AffectedDevicesList = [System.Collections.Generic.List[object]]::new() + DiskPathList = [System.Collections.Generic.List[object]]::new() + RegistryPathList = [System.Collections.Generic.List[object]]::new() ExceptionMatchCount = 0 TotalTenantGroupCount = 0 ExceptionSources = [System.Collections.Generic.HashSet[string]]::new() @@ -102,9 +105,13 @@ function Get-CIPPCVEReport { if ($Item.deviceDetailsJson) { $Devices = ConvertFrom-Json $Item.deviceDetailsJson | Sort-Object -Property deviceName -Unique foreach ($Dev in $Devices) { - [void]$CveGroup.AffectedDevicesList.Add(@{ deviceName = $Dev.deviceName }) - $CveGroup.TotalDeviceCount ++ - } + [void]$CveGroup.AffectedDevicesList.Add(@{ deviceName = $Dev.deviceName }) + if($Dev.registryPaths){[void]$CveGroup.RegistryPathList.Add(@{ deviceName = $Dev.deviceName + registryPaths = $Dev.registryPaths })} + if($Dev.diskPaths){[void]$CveGroup.DiskPathList.Add(@{ deviceName = $Dev.deviceName + diskPaths = $Dev.diskPaths })} + $CveGroup.TotalDeviceCount ++ + } } } @@ -116,12 +123,35 @@ function Get-CIPPCVEReport { $ExceptionStatus = 'None' $HasException = $false $Exceptions = @{} + $ExceptionType = '' + $ExceptionComment = '' + $ExceptionCreatedBy = '' + $ExceptionDate = '' + $ExceptionExpiry = '' if ($ExceptionsByCve.ContainsKey($CveKey)){ $Exceptions = @($ExceptionsByCve[$CveKey]) $HasException = $true $ExceptionStatus = if ($Exceptions.customerId -contains "ALL") { "All" } else { "Partial" } } + + if ($HasException){ + $ExceptionType = $Exceptions | ForEach-Object { + @{ customerId = $Exceptions.customerId + exceptionType = $Exceptions.exceptionType }} + $ExceptionComment = $Exceptions | ForEach-Object { + @{ customerId = $_.customerId + exceptionComment = $_.exceptionComment } } + $ExceptionCreatedBy = $Exceptions | ForEach-Object { + @{ customerId = $_.customerId + exceptionCreatedBy = $_.exceptionCreatedBy } } + $ExceptionDate = $Exceptions | ForEach-Object { + @{ customerId = $_.customerId + exceptionDate = $_.exceptionDate } } + $ExceptionExpiry = $Exceptions | ForEach-Object { + @{ customerId = $_.customerId + exceptionExpiry = $_.exceptionExpiry } } + } [void]$SortedCves.Add([PSCustomObject]@{ cveId = $Target.cveId @@ -132,25 +162,17 @@ function Get-CIPPCVEReport { softwareVersion = $Target.softwareVersion deviceCount = $Target.TotalDeviceCount tenantCount = $Target.TotalTenantGroupCount + registryPaths = $Target.RegistryPathList + diskPaths = $Target.DiskPathList exceptionStatus = $ExceptionStatus hasException = $HasException affectedTenants = $Target.AffectedTenantsList affectedDevices = $Target.AffectedDevicesList - exceptionType = if ($HasException){$Exceptions | ForEach-Object { - @{ customerId = $_.customerId - exceptionType = $_.exceptionType } } }else{''} - exceptionComment = if ($HasException){$Exceptions | ForEach-Object { - @{ customerId = $_.customerId - exceptionComment = $_.exceptionComment } } }else{''} - exceptionCreatedBy = if ($HasException){$Exceptions | ForEach-Object { - @{ customerId = $_.customerId - exceptionCreatedBy = $_.exceptionCreatedBy } } }else{''} - exceptionDate = if ($HasException){$Exceptions | ForEach-Object { - @{ customerId = $_.customerId - exceptionDate = $_.exceptionDate } } }else{''} - exceptionExpiry = if ($HasException){$Exceptions | ForEach-Object { - @{ customerId = $_.customerId - exceptionExpiry = $_.exceptionExpiry } } }else{''} + exceptionType = $ExceptionType + exceptionComment = $ExceptionComment + exceptionCreatedBy = $ExceptionCreatedBy + exceptionDate = $ExceptionDate + exceptionExpiry = $ExceptionExpiry cacheTimeStamp = $Target.lastUpdated }) } From bb94134ff4fb7bab3f5f4bf7bfd8584de85ae4c1 Mon Sep 17 00:00:00 2001 From: DamienMatthys Date: Mon, 6 Jul 2026 14:43:02 +1000 Subject: [PATCH 107/170] Update Invoke-ListCVEManagement.ps1 Added extra properties Improved efficiency of adding exception items Signed-off-by: DamienMatthys --- .../Endpoint/MEM/Invoke-ListCVEManagement.ps1 | 82 ++++++++++++------- 1 file changed, 52 insertions(+), 30 deletions(-) diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListCVEManagement.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListCVEManagement.ps1 index 33c5a561001db..d9e3dee4092ed 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListCVEManagement.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListCVEManagement.ps1 @@ -77,9 +77,12 @@ function Invoke-ListCVEManagement { softwareName = $Item.softwareName softwareVendor = $Item.softwareVendor softwareVersion = $Item.softwareVersion + lastUpdated = $Item.lastUpdated TotalDeviceCount = 0 AffectedTenantsList = [System.Collections.Generic.List[object]]::new() AffectedDevicesList = [System.Collections.Generic.List[object]]::new() + DiskPathList = [System.Collections.Generic.List[object]]::new() + RegistryPathList = [System.Collections.Generic.List[object]]::new() ExceptionMatchCount = 0 TotalTenantGroupCount = 0 ExceptionSources = [System.Collections.Generic.HashSet[string]]::new() @@ -95,9 +98,13 @@ function Invoke-ListCVEManagement { if ($Item.deviceDetailsJson) { $Devices = ConvertFrom-Json $Item.deviceDetailsJson | Sort-Object -Property deviceName -Unique foreach ($Dev in $Devices) { - [void]$CveGroup.AffectedDevicesList.Add(@{ deviceName = $Dev.deviceName }) + [void]$CveGroup.AffectedDevicesList.Add(@{ deviceName = $Dev.deviceName }) + if($Dev.registryPaths){[void]$CveGroup.RegistryPathList.Add(@{ deviceName = $Dev.deviceName + registryPaths = $Dev.registryPaths })} + if($Dev.diskPaths){[void]$CveGroup.DiskPathList.Add(@{ deviceName = $Dev.deviceName + diskPaths = $Dev.diskPaths })} $CveGroup.TotalDeviceCount ++ - } + } } } @@ -109,6 +116,11 @@ function Invoke-ListCVEManagement { $ExceptionStatus = 'None' $HasException = $false $Exceptions = @{} + $ExceptionType = '' + $ExceptionComment = '' + $ExceptionCreatedBy = '' + $ExceptionDate = '' + $ExceptionExpiry = '' if ($ExceptionsByCve.ContainsKey($CveKey)){ $Exceptions = @($ExceptionsByCve[$CveKey]) @@ -116,35 +128,45 @@ function Invoke-ListCVEManagement { $ExceptionStatus = if ($Exceptions.customerId -contains "ALL") { "All" } else { "Partial" } } + if ($HasException){ + $ExceptionType = $Exceptions | ForEach-Object { + @{ customerId = $Exceptions.customerId + exceptionType = $Exceptions.exceptionType }} + $ExceptionComment = $Exceptions | ForEach-Object { + @{ customerId = $_.customerId + exceptionComment = $_.exceptionComment } } + $ExceptionCreatedBy = $Exceptions | ForEach-Object { + @{ customerId = $_.customerId + exceptionCreatedBy = $_.exceptionCreatedBy } } + $ExceptionDate = $Exceptions | ForEach-Object { + @{ customerId = $_.customerId + exceptionDate = $_.exceptionDate } } + $ExceptionExpiry = $Exceptions | ForEach-Object { + @{ customerId = $_.customerId + exceptionExpiry = $_.exceptionExpiry } } + } + [void]$SortedCves.Add([PSCustomObject]@{ - cveId = $Target.cveId - vulnerabilitySeverityLevel = $Target.vulnerabilitySeverityLevel - exploitabilityLevel = $Target.exploitabilityLevel - softwareName = $Target.softwareName - softwareVendor = $Target.softwareVendor - softwareVersion = $Target.softwareVersion - deviceCount = $Target.TotalDeviceCount - tenantCount = $Target.TotalTenantGroupCount - exceptionStatus = $ExceptionStatus - hasException = $HasException - affectedTenants = $Target.AffectedTenantsList - affectedDevices = $Target.AffectedDevicesList - exceptionType = if ($HasException){$Exceptions | ForEach-Object { - @{ customerId = $_.customerId - exceptionType = $_.exceptionType } } }else{''} - exceptionComment = if ($HasException){$Exceptions | ForEach-Object { - @{ customerId = $_.customerId - exceptionComment = $_.exceptionComment } } }else{''} - exceptionCreatedBy = if ($HasException){$Exceptions | ForEach-Object { - @{ customerId = $_.customerId - exceptionCreatedBy = $_.exceptionCreatedBy } } }else{''} - exceptionDate = if ($HasException){$Exceptions | ForEach-Object { - @{ customerId = $_.customerId - exceptionDate = $_.exceptionDate } } }else{''} - exceptionExpiry = if ($HasException){$Exceptions | ForEach-Object { - @{ customerId = $_.customerId - exceptionExpiry = $_.exceptionExpiry } } }else{''} - cacheTimeStamp = $Target.lastUpdated + cveId = $Target.cveId + vulnerabilitySeverityLevel = $Target.vulnerabilitySeverityLevel + exploitabilityLevel = $Target.exploitabilityLevel + softwareName = $Target.softwareName + softwareVendor = $Target.softwareVendor + softwareVersion = $Target.softwareVersion + deviceCount = $Target.TotalDeviceCount + tenantCount = $Target.TotalTenantGroupCount + registryPaths = $Target.RegistryPathList + diskPaths = $Target.DiskPathList + exceptionStatus = $ExceptionStatus + hasException = $HasException + affectedTenants = $Target.AffectedTenantsList + affectedDevices = $Target.AffectedDevicesList + exceptionType = $ExceptionType + exceptionComment = $ExceptionComment + exceptionCreatedBy = $ExceptionCreatedBy + exceptionDate = $ExceptionDate + exceptionExpiry = $ExceptionExpiry + cacheTimeStamp = $Target.lastUpdated }) $StatusCode = [HttpStatusCode]::OK } From e083b193530b702a7f9bc7107ddf7dac713e2929 Mon Sep 17 00:00:00 2001 From: DamienMatthys Date: Mon, 6 Jul 2026 14:45:55 +1000 Subject: [PATCH 108/170] Update Invoke-NinjaOneTenantSync.ps1 Enabled syncing with no CVEs in case they have all been cleaned up Signed-off-by: DamienMatthys --- .../NinjaOne/Invoke-NinjaOneTenantSync.ps1 | 106 +++++++++--------- 1 file changed, 52 insertions(+), 54 deletions(-) diff --git a/Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneTenantSync.ps1 b/Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneTenantSync.ps1 index a041ff7a6ea20..0a79c0e349a2d 100644 --- a/Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneTenantSync.ps1 +++ b/Modules/CippExtensions/Public/NinjaOne/Invoke-NinjaOneTenantSync.ps1 @@ -2197,70 +2197,68 @@ function Invoke-NinjaOneTenantSync { $DeviceIdHeader = $ResolvedScanGroup.deviceIdHeader $CveIdHeader = $ResolvedScanGroup.cveIdHeader - if ([string]::IsNullOrWhiteSpace($DeviceIdHeader) -or [string]::IsNullOrWhiteSpace($CveIdHeader)) { - Write-LogMessage -API 'NinjaOneSync' -tenant $TenantFilter -message "CVE sync skipped — scan group missing required header config" -sev 'Warning' + $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() + + if (-not $AllVulns) { + Write-LogMessage -API 'NinjaOneSync' -tenant $TenantFilter -message 'CVE sync — no vulnerability data returned' -sev 'Warning' + [void]$CsvRows.Add([PSCustomObject]@{ + $DeviceIdHeader = "" + $CveIdHeader = ""}) } else { - $RawVulns = Get-CIPPDbItem -TenantFilter $TenantFilter -Type 'DefenderCVEs' | Where-Object { $_.RowKey -ne 'DefenderCVEs-Count' } - $AllVulns = $RawVulns.Data | ConvertFrom-Json - - if (-not $AllVulns) { - Write-LogMessage -API 'NinjaOneSync' -tenant $TenantFilter -message 'CVE sync — no vulnerability data returned' -sev 'Warning' - } 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' - } + $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' + } - $CsvRows = [System.Collections.Generic.List[object]]::new() - $SkippedCount = 0 + $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() - }) - } - } + foreach ($Item in $AllVulns) { + if ([string]::IsNullOrWhiteSpace($Item.cveId)) { + $SkippedCount++ + continue } - - if ($SkippedCount -gt 0) { - Write-LogMessage -API 'NinjaOneSync' -tenant $TenantFilter -message "CVE sync — skipped $SkippedCount rows (missing deviceName or cveId)" -sev 'Warning' + 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() + }) + } } + } - $CsvBytes = New-VulnCsvBytes -Rows $CsvRows -Headers @($DeviceIdHeader, $CveIdHeader) + if ($SkippedCount -gt 0) { + Write-LogMessage -API 'NinjaOneSync' -tenant $TenantFilter -message "CVE sync — skipped $SkippedCount rows (missing deviceName or cveId)" -sev 'Warning' + } + } + $CsvBytes = New-VulnCsvBytes -TenantFilter $TenantFilter -Rows $CsvRows -Headers @($DeviceIdHeader, $CveIdHeader) - if ($CsvBytes -and $CsvBytes.Length -gt 0) { - $UploadUri = "$NinjaBaseUrl/vulnerability/scan-groups/$ResolvedScanGroupId/upload" - $PollUri = "$NinjaBaseUrl/vulnerability/scan-groups/$ResolvedScanGroupId" - $CveResp = Invoke-NinjaOneVulnCsvUpload -Uri $UploadUri -PollUri $PollUri -CsvBytes $CsvBytes -Headers @{ Authorization = "Bearer $($Token.access_token)" } + if ($CsvBytes -and $CsvBytes.Length -gt 0) { + $UploadUri = "$NinjaBaseUrl/vulnerability/scan-groups/$ResolvedScanGroupId/upload" + $PollUri = "$NinjaBaseUrl/vulnerability/scan-groups/$ResolvedScanGroupId" + $CveResp = Invoke-NinjaOneVulnCsvUpload -Uri $UploadUri -PollUri $PollUri -CsvBytes $CsvBytes -Headers @{ Authorization = "Bearer $($Token.access_token)" } - $FinalStatus = $CveResp.status ?? 'unknown' - $ProcessedCount = $CveResp.recordsProcessed ?? '?' + $FinalStatus = $CveResp.status ?? 'unknown' + $ProcessedCount = $CveResp.recordsProcessed ?? '?' - if ($FinalStatus -eq 'COMPLETE') { - Write-LogMessage -API 'NinjaOneSync' -tenant $TenantFilter -message "CVE sync complete — $($CsvRows.Count) CVEs sent to '$ScanGroupName', $ProcessedCount processed" -sev 'Info' - } elseif ($FinalStatus -eq 'IN_PROGRESS') { - Write-LogMessage -API 'NinjaOneSync' -tenant $TenantFilter -message "CVE sync upload accepted — $($CsvRows.Count) CVEs sent to '$ScanGroupName', still processing (timed out polling)" -sev 'Warning' - } else { - Write-LogMessage -API 'NinjaOneSync' -tenant $TenantFilter -message "CVE sync finished with status '$FinalStatus' for '$ScanGroupName', $ProcessedCount processed" -sev 'Warning' - } - } else { - Write-LogMessage -API 'NinjaOneSync' -tenant $TenantFilter -message 'CVE sync — failed to generate CSV bytes' -sev 'Warning' - } + if ($FinalStatus -eq 'COMPLETE') { + Write-LogMessage -API 'NinjaOneSync' -tenant $TenantFilter -message "CVE sync complete — $($CsvRows.Count) CVEs sent to '$ScanGroupName', $ProcessedCount processed" -sev 'Info' + } elseif ($FinalStatus -eq 'IN_PROGRESS') { + Write-LogMessage -API 'NinjaOneSync' -tenant $TenantFilter -message "CVE sync upload accepted — $($CsvRows.Count) CVEs sent to '$ScanGroupName', still processing (timed out polling)" -sev 'Warning' + } else { + Write-LogMessage -API 'NinjaOneSync' -tenant $TenantFilter -message "CVE sync finished with status '$FinalStatus' for '$ScanGroupName', $ProcessedCount processed" -sev 'Warning' } + } else { + Write-LogMessage -API 'NinjaOneSync' -tenant $TenantFilter -message 'CVE sync — failed to generate CSV bytes' -sev 'Warning' } } } catch { From 36eca35d2489f075e5a2d4e2686b7450bdfa9368 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Mon, 6 Jul 2026 14:02:48 +0200 Subject: [PATCH 109/170] vacation mode updates --- .../CIPPCore/Public/New-CIPPTravelPolicy.ps1 | 87 +++++++++++++++++++ .../Public/Remove-CIPPTravelPolicy.ps1 | 75 ++++++++++++++++ .../Conditional/Invoke-ExecCAExclusion.ps1 | 49 +++++++++++ 3 files changed, 211 insertions(+) create mode 100644 Modules/CIPPCore/Public/New-CIPPTravelPolicy.ps1 create mode 100644 Modules/CIPPCore/Public/Remove-CIPPTravelPolicy.ps1 diff --git a/Modules/CIPPCore/Public/New-CIPPTravelPolicy.ps1 b/Modules/CIPPCore/Public/New-CIPPTravelPolicy.ps1 new file mode 100644 index 0000000000000..21d3b4c0a5ef3 --- /dev/null +++ b/Modules/CIPPCore/Public/New-CIPPTravelPolicy.ps1 @@ -0,0 +1,87 @@ +function New-CIPPTravelPolicy { + <# + .SYNOPSIS + Creates a temporary travel conditional access policy and named location for vacation mode. + + .DESCRIPTION + Builds a template-style policy JSON and hands it to New-CIPPCAPolicy, which creates the country + named location from LocationInfo, waits for it to propagate, replaces the display name reference + with the location ID and retries policy creation on propagation errors. The resulting policy blocks + sign-ins for the included users from all locations except the travel destination. Entra ID has no + standalone 'allow' control, so restricting sign-ins to the travel destination is achieved by blocking + every other location. The policy and named location share the same display name so that + Remove-CIPPTravelPolicy can remove both when the vacation ends. + + .PARAMETER Headers + The headers to include in the request, typically containing authentication tokens. Supplied automatically by the API. + + .PARAMETER TenantFilter + The tenant identifier to filter the request. + + .PARAMETER Users + An array of user principal names or object IDs of the users travelling. + + .PARAMETER Countries + An array of ISO 3166-1 alpha-2 country codes for the travel destination(s). + + .PARAMETER PolicyName + The display name used for both the conditional access policy and the named location. + + .PARAMETER APIName + The name of the API operation being performed. Defaults to 'New-CIPPTravelPolicy'. + #> + [CmdletBinding()] + param( + $Headers, + [Parameter(Mandatory = $true)][string]$TenantFilter, + [Parameter(Mandatory = $true)][string[]]$Users, + [Parameter(Mandatory = $true)][string[]]$Countries, + [Parameter(Mandatory = $true)][string]$PolicyName, + [string]$APIName = 'New-CIPPTravelPolicy' + ) + try { + $GuidRegex = '^[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}$' + $UserIds = foreach ($User in $Users) { + if ($User -match $GuidRegex) { + $User + } else { + (New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/users/$([System.Web.HttpUtility]::UrlEncode($User))?`$select=id" -tenantid $TenantFilter -asApp $true).id + } + } + + $RawJSON = ConvertTo-Json -Depth 10 -InputObject @{ + displayName = $PolicyName + state = 'enabled' + conditions = @{ + users = @{ includeUsers = @($UserIds) } + applications = @{ includeApplications = @('All') } + clientAppTypes = @('all') + locations = @{ + includeLocations = @('All') + excludeLocations = @($PolicyName) + } + } + grantControls = @{ + operator = 'OR' + builtInControls = @('block') + } + LocationInfo = @( + @{ + '@odata.type' = '#microsoft.graph.countryNamedLocation' + displayName = $PolicyName + countriesAndRegions = @($Countries) + includeUnknownCountriesAndRegions = $false + } + ) + } + $null = New-CIPPCAPolicy -RawJSON $RawJSON -TenantFilter $TenantFilter -Overwrite $true -ReplacePattern 'none' -APIName $APIName -Headers $Headers + $Result = "Created temporary travel policy '$PolicyName' allowing sign-ins from $($Countries -join ', ') only." + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $Result -Sev 'Info' + return $Result + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Result = "Failed to create temporary travel policy '$PolicyName': $($ErrorMessage.NormalizedError)" + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $Result -Sev 'Error' -LogData $ErrorMessage + throw $Result + } +} diff --git a/Modules/CIPPCore/Public/Remove-CIPPTravelPolicy.ps1 b/Modules/CIPPCore/Public/Remove-CIPPTravelPolicy.ps1 new file mode 100644 index 0000000000000..c2f6b3a8295b1 --- /dev/null +++ b/Modules/CIPPCore/Public/Remove-CIPPTravelPolicy.ps1 @@ -0,0 +1,75 @@ +function Remove-CIPPTravelPolicy { + <# + .SYNOPSIS + Removes a temporary travel conditional access policy and its named location. + + .DESCRIPTION + Deletes the conditional access policy and the country named location that were created by + New-CIPPTravelPolicy. Both objects are located by their shared display name. The policy is + deleted first because the named location cannot be removed while a policy still references it. + + .PARAMETER Headers + The headers to include in the request, typically containing authentication tokens. Supplied automatically by the API. + + .PARAMETER TenantFilter + The tenant identifier to filter the request. + + .PARAMETER PolicyName + The display name shared by the conditional access policy and the named location. + + .PARAMETER APIName + The name of the API operation being performed. Defaults to 'Remove-CIPPTravelPolicy'. + #> + [CmdletBinding()] + param( + $Headers, + [Parameter(Mandatory = $true)][string]$TenantFilter, + [Parameter(Mandatory = $true)][string]$PolicyName, + [string]$APIName = 'Remove-CIPPTravelPolicy' + ) + try { + $Results = [System.Collections.Generic.List[string]]::new() + + $Policies = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/identity/conditionalAccess/policies?$select=id,displayName&$top=999' -tenantid $TenantFilter -asApp $true | Where-Object { $_.displayName -eq $PolicyName } + foreach ($Policy in $Policies) { + $null = New-GraphPOSTRequest -uri "https://graph.microsoft.com/v1.0/identity/conditionalAccess/policies/$($Policy.id)" -type DELETE -tenantid $TenantFilter -asApp $true + $Results.Add("Deleted temporary travel policy '$PolicyName'.") + } + if (!$Policies) { + $Results.Add("No conditional access policy named '$PolicyName' was found, it may have been removed already.") + } + + $Locations = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/identity/conditionalAccess/namedLocations?$select=id,displayName&$top=999' -tenantid $TenantFilter -asApp $true | Where-Object { $_.displayName -eq $PolicyName } + foreach ($Location in $Locations) { + # Deleting a named location right after deleting the policy that references it can fail + # while the policy deletion propagates, so retry a few times. + $RetryCount = 0 + $MaxRetryCount = 5 + $Deleted = $false + do { + try { + $null = Set-CIPPNamedLocation -NamedLocationId $Location.id -TenantFilter $TenantFilter -Change 'delete' -APIName $APIName -Headers $Headers + $Deleted = $true + } catch { + $RetryCount++ + if ($RetryCount -ge $MaxRetryCount) { throw } + Write-Information "Named location '$PolicyName' could not be deleted yet, will retry..." + Start-Sleep -Seconds 5 + } + } while (!$Deleted -and $RetryCount -lt $MaxRetryCount) + $Results.Add("Deleted temporary travel named location '$PolicyName'.") + } + if (!$Locations) { + $Results.Add("No named location named '$PolicyName' was found, it may have been removed already.") + } + + $Result = $Results -join ' ' + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $Result -Sev 'Info' + return $Result + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Result = "Failed to remove temporary travel policy '$PolicyName': $($ErrorMessage.NormalizedError)" + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $Result -Sev 'Error' -LogData $ErrorMessage + throw $Result + } +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Conditional/Invoke-ExecCAExclusion.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Conditional/Invoke-ExecCAExclusion.ps1 index f8936e91f4d1a..8f2c3ffaf4031 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Conditional/Invoke-ExecCAExclusion.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Conditional/Invoke-ExecCAExclusion.ps1 @@ -133,6 +133,55 @@ function Invoke-ExecCAExclusion { Add-CIPPScheduledTask -Task $AuditRemoveTask -hidden $true } $Results = @("Successfully added vacation mode schedule for $Username on policy '$PolicyName'.") + + # Optional: temporary travel policy that only allows sign-ins from the travel destination + $TravelCountries = @($Request.Body.TravelCountries.value | Where-Object { $_ }) + if ($Request.Body.CreateTravelPolicy -eq $true -and $TravelCountries.Count -gt 0) { + $TravelUsers = @($Users.addedFields.userPrincipalName ?? $Users.value ?? $Users ?? $UserID) + $StartLabel = [DateTimeOffset]::FromUnixTimeSeconds([int64]$StartDate).ToString('yyyy-MM-dd') + $EndLabel = [DateTimeOffset]::FromUnixTimeSeconds([int64]$EndDate).ToString('yyyy-MM-dd') + $UserLabel = $TravelUsers -join ', ' + $TravelPolicyName = "Travel Policy $UserLabel - $StartLabel - $EndLabel" + if ($TravelPolicyName.Length -gt 256) { + $TravelPolicyName = "Travel Policy $($TravelUsers.Count) users - $StartLabel - $EndLabel" + } + + $TravelCreateTask = [pscustomobject]@{ + TenantFilter = $TenantFilter + Name = "Create Travel Policy Vacation Mode: $TravelPolicyName" + Command = @{ + value = 'New-CIPPTravelPolicy' + label = 'New-CIPPTravelPolicy' + } + Parameters = [pscustomobject]@{ + Users = $TravelUsers + Countries = $TravelCountries + PolicyName = $TravelPolicyName + } + ScheduledTime = $StartDate + PostExecution = $Request.Body.postExecution + Reference = $Request.Body.reference + } + Add-CIPPScheduledTask -Task $TravelCreateTask -hidden $false + + $TravelRemoveTask = [pscustomobject]@{ + TenantFilter = $TenantFilter + Name = "Remove Travel Policy Vacation Mode: $TravelPolicyName" + Command = @{ + value = 'Remove-CIPPTravelPolicy' + label = 'Remove-CIPPTravelPolicy' + } + Parameters = [pscustomobject]@{ + PolicyName = $TravelPolicyName + } + ScheduledTime = $EndDate + PostExecution = $Request.Body.postExecution + Reference = $Request.Body.reference + } + Add-CIPPScheduledTask -Task $TravelRemoveTask -hidden $false + $Results += "Successfully scheduled temporary travel policy '$TravelPolicyName' restricting sign-ins to $($TravelCountries -join ', '). The policy and named location will be removed at the end date." + } + if ($DuplicateGroupWarning) { $Results += $DuplicateGroupWarning } From 9c1879c10c066fd0cdb6de1936a536fe84c1905e Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Mon, 6 Jul 2026 20:22:15 +0800 Subject: [PATCH 110/170] Drop disableResilienceDefaults when not enabled Fixes: https://github.com/KelvinTegelaar/CIPP/issues/6287 --- Modules/CIPPCore/Public/New-CIPPCAPolicy.ps1 | 9 ++++++++- .../Invoke-CIPPStandardConditionalAccessTemplate.ps1 | 9 +++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/Modules/CIPPCore/Public/New-CIPPCAPolicy.ps1 b/Modules/CIPPCore/Public/New-CIPPCAPolicy.ps1 index c3061a926f554..415c81a66f12b 100644 --- a/Modules/CIPPCore/Public/New-CIPPCAPolicy.ps1 +++ b/Modules/CIPPCore/Public/New-CIPPCAPolicy.ps1 @@ -1,4 +1,3 @@ - function New-CIPPCAPolicy { [CmdletBinding()] param ( @@ -116,6 +115,14 @@ function New-CIPPCAPolicy { if ($JSONobj.conditions.users.excludeGuestsOrExternalUsers.externalTenants.Members) { $JSONobj.conditions.users.excludeGuestsOrExternalUsers.externalTenants.PSObject.Properties.Remove('@odata.context') } + if ($JSONobj.sessionControls) { + if ($JSONobj.sessionControls.disableResilienceDefaults -ne $true) { + $JSONobj.sessionControls.PSObject.Properties.Remove('disableResilienceDefaults') + } + if (@($JSONobj.sessionControls.PSObject.Properties).Count -eq 0) { + $JSONobj.PSObject.Properties.Remove('sessionControls') + } + } if ($State -and $State -ne 'donotchange') { $JSONobj | Add-Member -NotePropertyName 'state' -NotePropertyValue $State -Force } diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardConditionalAccessTemplate.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardConditionalAccessTemplate.ps1 index c792e589d626e..1fe08bd2235e4 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardConditionalAccessTemplate.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardConditionalAccessTemplate.ps1 @@ -122,6 +122,15 @@ function Invoke-CIPPStandardConditionalAccessTemplate { $Policy | Add-Member -NotePropertyName 'state' -NotePropertyValue $Settings.state -Force } + if ($Policy.sessionControls) { + if ($Policy.sessionControls.disableResilienceDefaults -ne $true) { + $Policy.sessionControls.PSObject.Properties.Remove('disableResilienceDefaults') + } + if (@($Policy.sessionControls.PSObject.Properties).Count -eq 0) { + $Policy.PSObject.Properties.Remove('sessionControls') + } + } + # Resolve the template's location GUIDs to display names so they compare like-for-like # with the deployed policy. The template's own LocationInfo carries the id->name map # (the GUID is the source tenant's id); fall back to this tenant's named-location cache. From e380a7ce22bcdd5028056df9ffc344bd32087554 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Mon, 6 Jul 2026 13:52:32 -0400 Subject: [PATCH 111/170] feat: enhance sensitivity label handling with advanced settings --- .../Public/ConvertTo-CIPPExoHashtable.ps1 | 47 +++++++++++++++++++ .../ConvertTo-CIPPSensitivityLabelParams.ps1 | 38 +++++++++++++++ .../Public/Set-CIPPSensitivityLabel.ps1 | 22 +++++++++ .../Invoke-EditSensitivityLabel.ps1 | 7 +++ .../Invoke-ListSensitivityLabel.ps1 | 13 +++++ 5 files changed, 127 insertions(+) create mode 100644 Modules/CIPPCore/Public/ConvertTo-CIPPExoHashtable.ps1 diff --git a/Modules/CIPPCore/Public/ConvertTo-CIPPExoHashtable.ps1 b/Modules/CIPPCore/Public/ConvertTo-CIPPExoHashtable.ps1 new file mode 100644 index 0000000000000..c852d58f52c91 --- /dev/null +++ b/Modules/CIPPCore/Public/ConvertTo-CIPPExoHashtable.ps1 @@ -0,0 +1,47 @@ +function ConvertTo-CIPPExoHashtable { + <# + .SYNOPSIS + Convert a value into the hashtable shape the EXO AdminApi accepts for PswsHashtable parameters. + .DESCRIPTION + Hashtable-typed cmdlet parameters (e.g. Set-Label -AdvancedSettings, New-LabelPolicy -Settings) + sent over the AdminApi REST endpoint deserialize server-side as Newtonsoft JObjects, which the + parameter binder cannot convert to PswsHashtable. Tagging the object with + '@odata.type' = '#Exchange.GenericHashTable' makes the binder accept it - the same convention + used elsewhere in CIPP for MultiValuedProperty add/remove hashtables. + + Accepts a dictionary, a PSCustomObject (deserialized JSON object), or an array of key/value + pairs in either the {Key, Value} object shape or the [key, value] pair shape used by label + policy Settings in template JSON. + .PARAMETER InputObject + The value to convert. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] $InputObject + ) + + $Result = @{ '@odata.type' = '#Exchange.GenericHashTable' } + + if ($InputObject -is [System.Collections.IDictionary]) { + foreach ($Key in @($InputObject.Keys)) { + if ($Key -ne '@odata.type') { $Result[$Key] = $InputObject[$Key] } + } + } elseif ($InputObject -is [System.Collections.IEnumerable] -and $InputObject -isnot [string]) { + foreach ($Entry in $InputObject) { + if ($null -eq $Entry) { continue } + if ($Entry -isnot [string] -and $Entry.PSObject.Properties['Key']) { + $Result[$Entry.Key] = $Entry.Value + } elseif ($Entry -is [System.Collections.IList] -and $Entry.Count -ge 2) { + $Result["$($Entry[0])"] = $Entry[1] + } + } + } else { + foreach ($Prop in $InputObject.PSObject.Properties) { + if ($Prop.Name -ne '@odata.type') { $Result[$Prop.Name] = $Prop.Value } + } + } + + return $Result +} diff --git a/Modules/CIPPCore/Public/ConvertTo-CIPPSensitivityLabelParams.ps1 b/Modules/CIPPCore/Public/ConvertTo-CIPPSensitivityLabelParams.ps1 index 7c3894d872e4a..6cea14dc0e2d7 100644 --- a/Modules/CIPPCore/Public/ConvertTo-CIPPSensitivityLabelParams.ps1 +++ b/Modules/CIPPCore/Public/ConvertTo-CIPPSensitivityLabelParams.ps1 @@ -15,6 +15,11 @@ function ConvertTo-CIPPSensitivityLabelParams { arrays (which are not valid input in their read form). A flat object (manual JSON authored against the deploy schema) has no 'LabelActions' and passes through unchanged. + Applied -AdvancedSettings values (e.g. a custom label color) are only readable through the same + read-only 'Settings' array, so before dropping it the writable advanced settings are lifted into + an 'AdvancedSettings' dictionary that New-/Set-Label accept. An explicit 'AdvancedSettings' value + already on the template wins over captured values. + Deploy-time validation/allowlisting still happens in Set-CIPPSensitivityLabel via Get-CIPPSensitivityLabelField; this function only reshapes. .PARAMETER Label @@ -42,6 +47,39 @@ function ConvertTo-CIPPSensitivityLabelParams { return [pscustomobject]$Flat } + # Writable advanced settings that Get-Label only reports inside the read-only Settings array + # ([key, value] pairs). The rest of Settings is system metadata (displayname, contenttype, + # tooltip, ...) that must not be echoed back to New-/Set-Label. Extend this list as more + # -AdvancedSettings keys gain first-class support. + $WritableAdvancedSettings = @('color') + $CapturedAdvanced = @{} + foreach ($Entry in @($Label.Settings)) { + if ($null -eq $Entry) { continue } + $Key = $null + $Value = $null + if ($Entry -isnot [string] -and $Entry.PSObject.Properties['Key']) { + $Key = $Entry.Key + $Value = $Entry.Value + } elseif ("$Entry" -match '^\[\s*(.+?)\s*,\s*(.*?)\s*\]$') { + # Get-Label serializes each entry as the string '[key, value]' + $Key = $Matches[1] + $Value = $Matches[2] + } + if ($Key -and $Key.ToLower() -in $WritableAdvancedSettings -and -not [string]::IsNullOrWhiteSpace("$Value")) { + $CapturedAdvanced[$Key.ToLower()] = "$Value" + } + } + if ($CapturedAdvanced.Count -gt 0) { + # Explicit AdvancedSettings on the template win over values captured from Settings. + $Explicit = $Flat['AdvancedSettings'] + if ($Explicit -is [System.Collections.IDictionary]) { + foreach ($ExplicitKey in @($Explicit.Keys)) { $CapturedAdvanced[$ExplicitKey] = $Explicit[$ExplicitKey] } + } elseif ($null -ne $Explicit) { + foreach ($ExplicitProp in $Explicit.PSObject.Properties) { $CapturedAdvanced[$ExplicitProp.Name] = $ExplicitProp.Value } + } + $Flat['AdvancedSettings'] = $CapturedAdvanced + } + foreach ($Raw in @($Label.LabelActions)) { if ($null -eq $Raw) { continue } $Action = if ($Raw -is [string]) { $Raw | ConvertFrom-Json } else { $Raw } diff --git a/Modules/CIPPCore/Public/Set-CIPPSensitivityLabel.ps1 b/Modules/CIPPCore/Public/Set-CIPPSensitivityLabel.ps1 index 1fb407a28228b..fa545eb35a880 100644 --- a/Modules/CIPPCore/Public/Set-CIPPSensitivityLabel.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPSensitivityLabel.ps1 @@ -36,6 +36,11 @@ function Set-CIPPSensitivityLabel { $PolicySource = $Template.PolicyParams $LabelName = $LabelParams.Name + # PswsHashtable parameters need the Exchange.GenericHashTable odata type to bind over the AdminApi. + if ($LabelParams.ContainsKey('AdvancedSettings')) { + $LabelParams['AdvancedSettings'] = ConvertTo-CIPPExoHashtable -InputObject $LabelParams['AdvancedSettings'] + } + # Priority is valid on Set-Label but not New-Label, so it is applied via a dedicated Set-Label call below. $LabelPriority = $null if ($LabelParams.ContainsKey('Priority')) { @@ -44,6 +49,16 @@ function Set-CIPPSensitivityLabel { } try { + # A custom label color travels as the 'color' advanced setting. Validate the hex format up front + # so a bad value fails with a clear message instead of an opaque compliance-endpoint error. + # An empty string is valid: it clears a previously set color. + if ($LabelParams.ContainsKey('AdvancedSettings')) { + $ColorValue = $LabelParams['AdvancedSettings']['color'] + if (-not [string]::IsNullOrEmpty("$ColorValue") -and "$ColorValue" -notmatch '^#[0-9A-Fa-f]{6}$') { + throw "Invalid label color '$ColorValue' in the AdvancedSettings of '$LabelName'. Use a 6-digit hex color like #40E0D0." + } + } + $ExistingLabels = try { New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-Label' -Compliance | Select-Object Name, DisplayName } catch { @() } $ExistingLabelPolicies = try { New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-LabelPolicy' -Compliance | Select-Object Name } catch { @() } @@ -71,6 +86,13 @@ function Set-CIPPSensitivityLabel { if ($PolicySource) { $PolicyHash = Format-CIPPCompliancePolicyParams -Source $PolicySource -AllowedFields $PolicyAllowedFields + # Settings/AdvancedSettings are PswsHashtable on New-/Set-LabelPolicy; template JSON authors + # Settings as [key, value] pairs, which the helper also normalizes. + foreach ($HashtableParam in @('AdvancedSettings', 'Settings')) { + if ($PolicyHash.ContainsKey($HashtableParam)) { + $PolicyHash[$HashtableParam] = ConvertTo-CIPPExoHashtable -InputObject $PolicyHash[$HashtableParam] + } + } if (-not $PolicyHash.ContainsKey('Labels') -or -not $PolicyHash['Labels']) { $PolicyHash['Labels'] = @($LabelName) } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SensitivityLabel/Invoke-EditSensitivityLabel.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SensitivityLabel/Invoke-EditSensitivityLabel.ps1 index acad97a18693f..5081739fdb737 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SensitivityLabel/Invoke-EditSensitivityLabel.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SensitivityLabel/Invoke-EditSensitivityLabel.ps1 @@ -23,6 +23,13 @@ Function Invoke-EditSensitivityLabel { } } + # PswsHashtable parameters need the Exchange.GenericHashTable odata type to bind over the AdminApi. + foreach ($HashtableParam in @('AdvancedSettings', 'Settings')) { + if ($Params.ContainsKey($HashtableParam)) { + $Params[$HashtableParam] = ConvertTo-CIPPExoHashtable -InputObject $Params[$HashtableParam] + } + } + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Set-Label' -cmdParams $Params -Compliance -useSystemMailbox $true $Result = "Updated sensitivity label $Identity" Write-LogMessage -Headers $Request.Headers -API $APIName -tenant $TenantFilter -message $Result -Sev Info diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SensitivityLabel/Invoke-ListSensitivityLabel.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SensitivityLabel/Invoke-ListSensitivityLabel.ps1 index dc24e819d687b..302babc81eeec 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SensitivityLabel/Invoke-ListSensitivityLabel.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Security/Compliance-SensitivityLabel/Invoke-ListSensitivityLabel.ps1 @@ -20,6 +20,19 @@ Function Invoke-ListSensitivityLabel { $labelGuid = $_.Guid @($Policies | Where-Object { $_.Labels -contains $labelGuid -or $_.Labels -contains $_.ImmutableId }) | Select-Object -ExpandProperty Name } + }, + @{l = 'Color'; e = { + # The 'color' advanced setting is only exposed inside the read-only Settings array, + # either as a {Key, Value} object or as the serialized string '[color, #RRGGBB]'. + foreach ($Entry in @($_.Settings)) { + if ($null -eq $Entry) { continue } + if ($Entry -isnot [string] -and $Entry.PSObject.Properties['Key']) { + if ("$($Entry.Key)" -eq 'color') { "$($Entry.Value)"; break } + } elseif ("$Entry" -match '^\[\s*color\s*,\s*(.*?)\s*\]$') { + $Matches[1]; break + } + } + } } $StatusCode = [HttpStatusCode]::OK From b1bc57f2ca2259455f7e15fe55bc9e803702f94b Mon Sep 17 00:00:00 2001 From: John Duprey Date: Mon, 6 Jul 2026 20:24:27 -0400 Subject: [PATCH 112/170] feat: enhance drift data handling by pruning stale entries and tracking valid standards --- Modules/CIPPCore/Public/Get-CIPPDrift.ps1 | 73 ++++++++++++++++++++--- 1 file changed, 64 insertions(+), 9 deletions(-) diff --git a/Modules/CIPPCore/Public/Get-CIPPDrift.ps1 b/Modules/CIPPCore/Public/Get-CIPPDrift.ps1 index 30733255552fd..5f4d7c6d582aa 100644 --- a/Modules/CIPPCore/Public/Get-CIPPDrift.ps1 +++ b/Modules/CIPPCore/Public/Get-CIPPDrift.ps1 @@ -108,6 +108,13 @@ function Get-CIPPDrift { } $Results = [System.Collections.Generic.List[object]]::new() + # Tracks every StandardName that is still valid (live deviations + standards present in the + # template, whether assigned directly or via a package) so stale tenantDrift rows can be pruned. + $ValidDriftKeys = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + # Policy rows are only pruned when the matching Graph collection succeeded, so a transient + # failure or missing license never wipes accepted/customer-specific statuses. + $IntunePoliciesCollected = $false + $CAPoliciesCollected = $false foreach ($Alignment in $AlignmentData) { # Initialize deviation collections $StandardsDeviations = [System.Collections.Generic.List[object]]::new() @@ -158,6 +165,7 @@ function Get-CIPPDrift { } $reason = if ($ExistingDriftStates.ContainsKey($ComparisonItem.StandardName)) { $ExistingDriftStates[$ComparisonItem.StandardName].Reason } $User = if ($ExistingDriftStates.ContainsKey($ComparisonItem.StandardName)) { $ExistingDriftStates[$ComparisonItem.StandardName].User } + $IsLicenseMissing = $ComparisonItem.ComplianceStatus -eq 'License Missing' $StandardsDeviations.Add([PSCustomObject]@{ standardName = $ComparisonItem.StandardName standardDisplayName = $displayName @@ -167,7 +175,8 @@ function Get-CIPPDrift { Status = $Status Reason = $reason lastChangedByUser = $User - LicenseAvailable = $ComparisonItem.LicenseAvailable + ComplianceStatus = $ComparisonItem.ComplianceStatus + LicenseAvailable = if ($IsLicenseMissing) { $false } else { $ComparisonItem.LicenseAvailable } CurrentValue = $ComparisonItem.CurrentValue ExpectedValue = $ComparisonItem.ExpectedValue }) @@ -241,6 +250,7 @@ function Get-CIPPDrift { } } } + $IntunePoliciesCollected = $true } catch { Write-Warning "Failed to get Intune policies: $($_.Exception.Message)" } @@ -257,6 +267,7 @@ function Get-CIPPDrift { ) $CAGraphRequest = New-GraphBulkRequest -Requests $CARequests -tenantid $TenantFilter -asapp $true $TenantCAPolicies = ($CAGraphRequest | Where-Object { $_.id -eq 'policies' }).body.value + $CAPoliciesCollected = $true } catch { Write-Warning "Failed to get Conditional Access policies: $($_.Exception.Message)" $TenantCAPolicies = @() @@ -290,9 +301,9 @@ function Get-CIPPDrift { $CATemplateIds.Add($Template.TemplateList.value) } if ($Template.'TemplateList-Tags') { - $TagValue = if ($Template.'TemplateList-Tags'.value) { $Template.'TemplateList-Tags'.value } else { $null } - if ($TagValue) { - $ResolvedCATagTemplates = if ($CATemplatesByPackage.ContainsKey($TagValue)) { $CATemplatesByPackage[$TagValue] } else { @() } + foreach ($Tag in $Template.'TemplateList-Tags') { + $TagValue = if ($Tag.value) { $Tag.value } else { $Tag } + $ResolvedCATagTemplates = if ($TagValue -and $CATemplatesByPackage.ContainsKey($TagValue)) { $CATemplatesByPackage[$TagValue] } else { @() } foreach ($ResolvedTemplate in $ResolvedCATagTemplates) { if ($ResolvedTemplate.RowKey -and $ResolvedTemplate.RowKey -notin $CATemplateIds) { $CATemplateIds.Add($ResolvedTemplate.RowKey) @@ -406,6 +417,17 @@ function Get-CIPPDrift { $AllDeviations.AddRange($StandardsDeviations) $AllDeviations.AddRange($PolicyDeviations) + # Record which drift keys are still valid: live deviations and every standard in the + # template (compliant ones keep their row so an accepted status survives re-drift). + foreach ($Deviation in $AllDeviations) { + $KeyName = [string]$Deviation.standardName + if (-not [string]::IsNullOrWhiteSpace($KeyName)) { $null = $ValidDriftKeys.Add($KeyName) } + } + foreach ($ComparisonItem in $Alignment.ComparisonDetails) { + $KeyName = [string]$ComparisonItem.StandardName + if (-not [string]::IsNullOrWhiteSpace($KeyName)) { $null = $ValidDriftKeys.Add($KeyName) } + } + # Persist newly detected deviations to the tenantDrift table so the summary page can count them $NewDriftEntities = [System.Collections.Generic.List[object]]::new() foreach ($Deviation in $AllDeviations) { @@ -438,14 +460,19 @@ function Get-CIPPDrift { } } + # License-missing standards are excluded from the deviation buckets so the counts match + # the alignment score, which tracks them separately; they surface via licenseMissingDeviations. + $LicenseMissingDeviations = $AllDeviations | Where-Object { $_.ComplianceStatus -eq 'License Missing' } + $CountableDeviations = $AllDeviations | Where-Object { $_.ComplianceStatus -ne 'License Missing' } + # Filter deviations by status for counting - $NewDeviations = $AllDeviations | Where-Object { $_.Status -eq 'New' } - $AcceptedDeviations = $AllDeviations | Where-Object { $_.Status -eq 'Accepted' } - $DeniedDeviations = $AllDeviations | Where-Object { $_.Status -like 'Denied*' } - $CustomerSpecificDeviations = $AllDeviations | Where-Object { $_.Status -eq 'CustomerSpecific' } + $NewDeviations = $CountableDeviations | Where-Object { $_.Status -eq 'New' } + $AcceptedDeviations = $CountableDeviations | Where-Object { $_.Status -eq 'Accepted' } + $DeniedDeviations = $CountableDeviations | Where-Object { $_.Status -like 'Denied*' } + $CustomerSpecificDeviations = $CountableDeviations | Where-Object { $_.Status -eq 'CustomerSpecific' } # Current deviations are New + Denied (not accepted or customer specific) - $CurrentDeviations = $AllDeviations | Where-Object { $_.Status -in @('New', 'Denied') } + $CurrentDeviations = $CountableDeviations | Where-Object { $_.Status -in @('New', 'Denied') } $Result = [PSCustomObject]@{ tenantFilter = $TenantFilter @@ -457,11 +484,13 @@ function Get-CIPPDrift { deniedDeviationsCount = $DeniedDeviations.Count customerSpecificDeviationsCount = $CustomerSpecificDeviations.Count newDeviationsCount = $NewDeviations.Count + licenseMissingDeviationsCount = $LicenseMissingDeviations.Count alignedCount = $Alignment.CompliantStandards - $AcceptedDeviations.Count - $CustomerSpecificDeviations.Count currentDeviations = @($CurrentDeviations) acceptedDeviations = @($AcceptedDeviations) customerSpecificDeviations = @($CustomerSpecificDeviations) deniedDeviations = @($DeniedDeviations) + licenseMissingDeviations = @($LicenseMissingDeviations) allDeviations = @($AllDeviations) latestDataCollection = $Alignment.LatestDataCollection driftSettings = $AlignmentData @@ -470,6 +499,32 @@ function Get-CIPPDrift { $Results.Add($Result) } + # Prune stale tenantDrift rows so the alignment score only counts real deviations. + # A row goes stale when its policy was deleted/recreated in the tenant, or its template was + # removed from the drift standard (directly or via a package). Policy rows require a + # successful Graph collection this run before they are eligible for deletion. + $StaleDriftEntities = foreach ($Entity in $DriftEntities) { + $EntityName = [string]$Entity.StandardName + if ([string]::IsNullOrWhiteSpace($EntityName) -or $ValidDriftKeys.Contains($EntityName)) { continue } + if ($EntityName -like 'IntuneTemplates.*') { + if ($IntunePoliciesCollected) { $Entity } + } elseif ($EntityName -like 'ConditionalAccessTemplates.*') { + if ($CAPoliciesCollected) { $Entity } + } else { + $Entity + } + } + if ($StaleDriftEntities) { + try { + foreach ($StaleEntity in $StaleDriftEntities) { + Remove-AzDataTableEntity @DriftTable -Entity $StaleEntity + } + Write-Information "Removed $(@($StaleDriftEntities).Count) stale drift deviation entries for $TenantFilter" + } catch { + Write-Warning "Failed to remove stale drift deviation entries for $($TenantFilter): $($_.Exception.Message)" + } + } + return @($Results) } catch { From 61c55e730515e9f95fa6125e6e1266ab4e706f94 Mon Sep 17 00:00:00 2001 From: DamienMatthys Date: Tue, 7 Jul 2026 12:01:51 +1000 Subject: [PATCH 113/170] Update Invoke-ListCVEManagement.ps1 Minimising tasks Signed-off-by: DamienMatthys --- .../Endpoint/MEM/Invoke-ListCVEManagement.ps1 | 28 +++++++------------ 1 file changed, 10 insertions(+), 18 deletions(-) diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListCVEManagement.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListCVEManagement.ps1 index d9e3dee4092ed..96850436772b8 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListCVEManagement.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListCVEManagement.ps1 @@ -126,24 +126,16 @@ function Invoke-ListCVEManagement { $Exceptions = @($ExceptionsByCve[$CveKey]) $HasException = $true $ExceptionStatus = if ($Exceptions.customerId -contains "ALL") { "All" } else { "Partial" } - } - - if ($HasException){ - $ExceptionType = $Exceptions | ForEach-Object { - @{ customerId = $Exceptions.customerId - exceptionType = $Exceptions.exceptionType }} - $ExceptionComment = $Exceptions | ForEach-Object { - @{ customerId = $_.customerId - exceptionComment = $_.exceptionComment } } - $ExceptionCreatedBy = $Exceptions | ForEach-Object { - @{ customerId = $_.customerId - exceptionCreatedBy = $_.exceptionCreatedBy } } - $ExceptionDate = $Exceptions | ForEach-Object { - @{ customerId = $_.customerId - exceptionDate = $_.exceptionDate } } - $ExceptionExpiry = $Exceptions | ForEach-Object { - @{ customerId = $_.customerId - exceptionExpiry = $_.exceptionExpiry } } + $ExceptionType = @{ customerId = $Exceptions.customerId + exceptionType = $Exceptions.exceptionType } + $ExceptionComment = @{ customerId = $Exceptions.customerId + exceptionComment = $Exceptions.exceptionComment } + $ExceptionCreatedBy = @{ customerId = $Exceptions.customerId + exceptionCreatedBy = $Exceptions.exceptionCreatedBy } + $ExceptionDate = @{ customerId = $Exceptions.customerId + exceptionDate = $Exceptions.exceptionDate } + $ExceptionExpiry = @{ customerId = $Exceptions.customerId + exceptionExpiry = $Exceptions.exceptionExpiry } } [void]$SortedCves.Add([PSCustomObject]@{ From 866ede9f9483aaf9c2be65356d34bea8e9c00b33 Mon Sep 17 00:00:00 2001 From: DamienMatthys Date: Tue, 7 Jul 2026 12:02:34 +1000 Subject: [PATCH 114/170] Update Get-CIPPCVEReport.ps1 Minimising tasks Signed-off-by: DamienMatthys --- Modules/CIPPCore/Public/Get-CIPPCVEReport.ps1 | 28 +++++++------------ 1 file changed, 10 insertions(+), 18 deletions(-) diff --git a/Modules/CIPPCore/Public/Get-CIPPCVEReport.ps1 b/Modules/CIPPCore/Public/Get-CIPPCVEReport.ps1 index a1f4e7e58d5c8..117127cfdac2a 100644 --- a/Modules/CIPPCore/Public/Get-CIPPCVEReport.ps1 +++ b/Modules/CIPPCore/Public/Get-CIPPCVEReport.ps1 @@ -133,24 +133,16 @@ function Get-CIPPCVEReport { $Exceptions = @($ExceptionsByCve[$CveKey]) $HasException = $true $ExceptionStatus = if ($Exceptions.customerId -contains "ALL") { "All" } else { "Partial" } - } - - if ($HasException){ - $ExceptionType = $Exceptions | ForEach-Object { - @{ customerId = $Exceptions.customerId - exceptionType = $Exceptions.exceptionType }} - $ExceptionComment = $Exceptions | ForEach-Object { - @{ customerId = $_.customerId - exceptionComment = $_.exceptionComment } } - $ExceptionCreatedBy = $Exceptions | ForEach-Object { - @{ customerId = $_.customerId - exceptionCreatedBy = $_.exceptionCreatedBy } } - $ExceptionDate = $Exceptions | ForEach-Object { - @{ customerId = $_.customerId - exceptionDate = $_.exceptionDate } } - $ExceptionExpiry = $Exceptions | ForEach-Object { - @{ customerId = $_.customerId - exceptionExpiry = $_.exceptionExpiry } } + $ExceptionType = @{ customerId = $Exceptions.customerId + exceptionType = $Exceptions.exceptionType } + $ExceptionComment = @{ customerId = $Exceptions.customerId + exceptionComment = $Exceptions.exceptionComment } + $ExceptionCreatedBy = @{ customerId = $Exceptions.customerId + exceptionCreatedBy = $Exceptions.exceptionCreatedBy } + $ExceptionDate = @{ customerId = $Exceptions.customerId + exceptionDate = $Exceptions.exceptionDate } + $ExceptionExpiry = @{ customerId = $Exceptions.customerId + exceptionExpiry = $Exceptions.exceptionExpiry } } [void]$SortedCves.Add([PSCustomObject]@{ From 2999e363f6bebbd3c7cc08acc91821ce3017558d Mon Sep 17 00:00:00 2001 From: James Tarran Date: Tue, 7 Jul 2026 09:05:35 +0100 Subject: [PATCH 115/170] App registration love - Ability to add secrets - Ability to edit URI's - Ability to edit audience - Added 'rotate' secret workflow (delete and recreate with the same name) --- .../Invoke-ExecApplication.ps1 | 3 +- .../Invoke-ExecManageAppCredentials.ps1 | 122 +++++++++++++++++- 2 files changed, 121 insertions(+), 4 deletions(-) diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Application Approval/Invoke-ExecApplication.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Application Approval/Invoke-ExecApplication.ps1 index 64de2982c3416..6436c66157592 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Application Approval/Invoke-ExecApplication.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Application Approval/Invoke-ExecApplication.ps1 @@ -62,7 +62,8 @@ function Invoke-ExecApplication { } if ($Request.Body) { - $PostParams.Body = $Request.Body.Payload | ConvertTo-Json -Compress + # Depth 10 so nested payloads (e.g. web/spa/publicClient redirectUris and implicitGrantSettings) aren't truncated. + $PostParams.Body = $Request.Body.Payload | ConvertTo-Json -Depth 10 -Compress } $TenantFilter = $Request.Query.tenantFilter ?? $Request.Body.tenantFilter diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Application Approval/Invoke-ExecManageAppCredentials.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Application Approval/Invoke-ExecManageAppCredentials.ps1 index 87178d788e332..032995b28fef0 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Application Approval/Invoke-ExecManageAppCredentials.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Application Approval/Invoke-ExecManageAppCredentials.ps1 @@ -8,6 +8,9 @@ function Invoke-ExecManageAppCredentials { [CmdletBinding()] param($Request, $TriggerMetadata) + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + $TenantFilter = $Request.Body.tenantFilter $Action = $Request.Body.Action $AppType = $Request.Body.AppType # applications | servicePrincipals @@ -15,27 +18,138 @@ function Invoke-ExecManageAppCredentials { $KeyId = $Request.Body.KeyId $AppId = $Request.Body.AppId $Id = $Request.Body.Id + $DisplayName = $Request.Body.DisplayName + $EndDateTime = $Request.Body.EndDateTime + # ExpiryMonths may arrive as a plain number or a frontend autocomplete object { value, label }. + $ExpiryMonths = $Request.Body.ExpiryMonths.value ?? $Request.Body.ExpiryMonths + # ExpiryDate is the frontend date picker value, a Unix timestamp in seconds. + $ExpiryDate = $Request.Body.ExpiryDate + $AppRef = $Id ?? $AppId $IdPath = if ($Id) { "/$Id" } else { "(appId='$AppId')" } $Uri = "https://graph.microsoft.com/beta/$AppType$IdPath" + # Max credential end date allowed by the tenant default app management policy's passwordLifetime + # restriction, or $null when not enforced/readable. Custom per-app policies aren't read here. + function Get-PasswordPolicyMaxEnd { + param([datetime]$From) + try { + $Policy = New-GraphGetRequest -uri 'https://graph.microsoft.com/v1.0/policies/defaultAppManagementPolicy' -tenantid $TenantFilter -AsApp $true + $Lifetime = $Policy.applicationRestrictions.passwordCredentials | Where-Object { $_.restrictionType -eq 'passwordLifetime' } + if ($Lifetime -and -not ($Lifetime.state -eq 'disabled' -or $null -eq $Lifetime.state) -and $Lifetime.maxLifetime) { + return $From.Add([System.Xml.XmlConvert]::ToTimeSpan($Lifetime.maxLifetime)) + } + } catch { + Write-LogMessage -API $APIName -tenant $TenantFilter -headers $Headers -message "Could not read app management policy for expiry clamping: $($_.Exception.Message)" -sev Debug + } + return $null + } + try { $Results = switch ($Action) { 'Remove' { if ($CredentialType -eq 'password') { $null = New-GraphPOSTRequest -Uri "$Uri/removePassword" -Body (@{ keyId = $KeyId } | ConvertTo-Json) -tenantid $TenantFilter + Write-LogMessage -API $APIName -tenant $TenantFilter -headers $Headers -message "Removed password credential $KeyId from $AppType $AppRef" -sev Info @{ resultText = "Successfully removed password credential $KeyId"; state = 'success' } } else { # Certificates can't use removeKey without a proof JWT, so PATCH the array instead $Current = New-GraphGetRequest -Uri $Uri -tenantid $TenantFilter $Updated = @($Current.keyCredentials | Where-Object { $_.keyId -ne $KeyId }) $null = New-GraphPOSTRequest -Uri $Uri -Type 'PATCH' -Body (@{ keyCredentials = $Updated } | ConvertTo-Json -Depth 10) -tenantid $TenantFilter + Write-LogMessage -API $APIName -tenant $TenantFilter -headers $Headers -message "Removed key credential $KeyId from $AppType $AppRef" -sev Info @{ resultText = "Successfully removed key credential $KeyId"; state = 'success' } } } 'Add' { - # TODO: implement credential addition - @{ resultText = 'Add not yet implemented'; state = 'info' } + # Only client secret (password) addition is implemented here. Adding a certificate means + # PATCHing keyCredentials with an uploaded public-key cert - not built yet; use Entra. + if ($CredentialType -ne 'password') { + @{ resultText = 'Adding certificate credentials is not supported here yet. Upload the certificate in Entra instead.'; state = 'error' } + } else { + $PasswordCredential = @{ + displayName = if ([string]::IsNullOrWhiteSpace($DisplayName)) { 'CIPP-Generated Secret' } else { $DisplayName } + } + + # Requested expiry precedence: explicit ISO end date > custom date > month preset (default 12). + $Now = (Get-Date).ToUniversalTime() + if ($EndDateTime) { + $RequestedEnd = ([System.DateTimeOffset]$EndDateTime).UtcDateTime + } elseif ($ExpiryDate) { + $RequestedEnd = [System.DateTimeOffset]::FromUnixTimeSeconds([long]$ExpiryDate).UtcDateTime + } else { + $Months = if ($ExpiryMonths -as [int]) { [int]$ExpiryMonths } else { 12 } + $RequestedEnd = $Now.AddMonths($Months) + } + + # Clamp the expiry to the tenant policy maximum so the add isn't rejected outright. + $ClampNote = '' + $MaxAllowedEnd = Get-PasswordPolicyMaxEnd -From $Now + if ($MaxAllowedEnd -and $RequestedEnd -gt $MaxAllowedEnd) { + $RequestedEnd = $MaxAllowedEnd + $ClampNote = " Expiry was reduced to the tenant policy maximum of $([math]::Round(($MaxAllowedEnd - $Now).TotalDays)) day(s)." + } + + $PasswordCredential.endDateTime = $RequestedEnd.ToString('yyyy-MM-ddTHH:mm:ss.fffZ') + + $NewSecret = New-GraphPOSTRequest -Uri "$Uri/addPassword" -Body (@{ passwordCredential = $PasswordCredential } | ConvertTo-Json -Depth 10) -tenantid $TenantFilter + Write-LogMessage -API $APIName -tenant $TenantFilter -headers $Headers -message "Added password credential '$($PasswordCredential.displayName)' (keyId $($NewSecret.keyId)) to $AppType $AppRef" -sev Info + @{ + resultText = "Client secret '$($PasswordCredential.displayName)' created for the app registration. Use the Copy to Clipboard button to retrieve the secret.$ClampNote" + copyField = $NewSecret.secretText + state = 'success' + } + } + } + 'Rotate' { + # Rotate a client secret: add a replacement with the same display name, then remove the + # original. Add first so a failure leaves the existing secret intact. + if ($CredentialType -ne 'password') { + @{ resultText = 'Rotation is only supported for client secrets.'; state = 'error' } + } elseif (-not $KeyId) { + @{ resultText = 'KeyId is required to rotate a secret.'; state = 'error' } + } else { + $App = New-GraphGetRequest -Uri $Uri -tenantid $TenantFilter + $OldCred = $App.passwordCredentials | Where-Object { $_.keyId -eq $KeyId } + if (-not $OldCred) { + @{ resultText = "Secret $KeyId was not found on this application."; state = 'error' } + } else { + $Name = if ([string]::IsNullOrWhiteSpace($OldCred.displayName)) { 'CIPP-Generated Secret' } else { $OldCred.displayName } + + $Now = (Get-Date).ToUniversalTime() + $RequestedEnd = $Now.AddMonths(12) + $ClampNote = '' + $MaxAllowedEnd = Get-PasswordPolicyMaxEnd -From $Now + if ($MaxAllowedEnd -and $RequestedEnd -gt $MaxAllowedEnd) { + $RequestedEnd = $MaxAllowedEnd + $ClampNote = " New secret expiry was set to the tenant policy maximum of $([math]::Round(($MaxAllowedEnd - $Now).TotalDays)) day(s)." + } + + $NewCredential = @{ + displayName = $Name + endDateTime = $RequestedEnd.ToString('yyyy-MM-ddTHH:mm:ss.fffZ') + } + $NewSecret = New-GraphPOSTRequest -Uri "$Uri/addPassword" -Body (@{ passwordCredential = $NewCredential } | ConvertTo-Json -Depth 10) -tenantid $TenantFilter + + # Remove the old secret; if that fails, keep the new one and tell the caller. + $RemoveNote = '' + try { + $null = New-GraphPOSTRequest -Uri "$Uri/removePassword" -Body (@{ keyId = $KeyId } | ConvertTo-Json) -tenantid $TenantFilter + } catch { + $RemoveNote = ' The new secret was created, but the old one could not be removed - remove it manually.' + } + + Write-LogMessage -API $APIName -tenant $TenantFilter -headers $Headers -message "Rotated password credential '$Name' on $AppType $AppRef (old keyId $KeyId, new keyId $($NewSecret.keyId))" -sev Info + @{ + resultText = "Secret '$Name' rotated. Use the Copy to Clipboard button to retrieve the new secret.$ClampNote$RemoveNote" + copyField = $NewSecret.secretText + state = 'success' + } + } + } + } + default { + @{ resultText = "Unknown action: $Action"; state = 'error' } } } @@ -44,9 +158,11 @@ function Invoke-ExecManageAppCredentials { Body = @{ Results = $Results } }) } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API $APIName -tenant $TenantFilter -headers $Headers -message "Failed to $Action credential: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage return ([HttpResponseContext]@{ StatusCode = [HttpStatusCode]::InternalServerError - Body = @{ Results = @{ resultText = "Failed to $Action credential: $($_.Exception.Message)"; state = 'error' } } + Body = @{ Results = @{ resultText = "Failed to $Action credential: $($ErrorMessage.NormalizedError)"; state = 'error' } } }) } } From 403082ad8827ff44a872cbb73af68d2e8c564fc4 Mon Sep 17 00:00:00 2001 From: James Tarran Date: Tue, 7 Jul 2026 09:25:24 +0100 Subject: [PATCH 116/170] Update Invoke-ExecManageAppCredentials.ps1 Remove un-needed comments --- .../Application Approval/Invoke-ExecManageAppCredentials.ps1 | 3 --- 1 file changed, 3 deletions(-) diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Application Approval/Invoke-ExecManageAppCredentials.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Application Approval/Invoke-ExecManageAppCredentials.ps1 index 032995b28fef0..5715084610646 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Application Approval/Invoke-ExecManageAppCredentials.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Application Approval/Invoke-ExecManageAppCredentials.ps1 @@ -20,9 +20,7 @@ function Invoke-ExecManageAppCredentials { $Id = $Request.Body.Id $DisplayName = $Request.Body.DisplayName $EndDateTime = $Request.Body.EndDateTime - # ExpiryMonths may arrive as a plain number or a frontend autocomplete object { value, label }. $ExpiryMonths = $Request.Body.ExpiryMonths.value ?? $Request.Body.ExpiryMonths - # ExpiryDate is the frontend date picker value, a Unix timestamp in seconds. $ExpiryDate = $Request.Body.ExpiryDate $AppRef = $Id ?? $AppId @@ -71,7 +69,6 @@ function Invoke-ExecManageAppCredentials { displayName = if ([string]::IsNullOrWhiteSpace($DisplayName)) { 'CIPP-Generated Secret' } else { $DisplayName } } - # Requested expiry precedence: explicit ISO end date > custom date > month preset (default 12). $Now = (Get-Date).ToUniversalTime() if ($EndDateTime) { $RequestedEnd = ([System.DateTimeOffset]$EndDateTime).UtcDateTime From 158ebf27cc2a61dea6026f1519f7e0be297949a7 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Tue, 7 Jul 2026 17:41:35 +0800 Subject: [PATCH 117/170] Introduce pipeline streaming for graph get request to reduce memory during cache collection --- .../GraphHelper/New-GraphGetRequest.ps1 | 21 +++++++++++++++++-- .../Public/DBCache/Set-CIPPDBCacheApps.ps1 | 6 ++---- .../DBCache/Set-CIPPDBCacheBitlockerKeys.ps1 | 6 ++---- .../DBCache/Set-CIPPDBCacheDeviceSettings.ps1 | 5 ++--- .../Public/DBCache/Set-CIPPDBCacheDevices.ps1 | 6 ++---- ...et-CIPPDBCacheDirectoryRecommendations.ps1 | 5 ++--- .../Public/DBCache/Set-CIPPDBCacheDomains.ps1 | 5 ++--- .../Public/DBCache/Set-CIPPDBCacheGuests.ps1 | 6 ++---- .../DBCache/Set-CIPPDBCacheMailboxUsage.ps1 | 5 ++--- .../DBCache/Set-CIPPDBCacheManagedDevices.ps1 | 6 ++---- .../Set-CIPPDBCacheOfficeActivations.ps1 | 5 ++--- .../DBCache/Set-CIPPDBCacheOrganization.ps1 | 5 ++--- ...DBCacheRoleAssignmentScheduleInstances.ps1 | 5 ++--- ...et-CIPPDBCacheRoleEligibilitySchedules.ps1 | 5 ++--- .../Set-CIPPDBCacheRoleManagementPolicies.ps1 | 5 ++--- .../Set-CIPPDBCacheServicePrincipals.ps1 | 5 ++--- .../Public/DBCache/Set-CIPPDBCacheUsers.ps1 | 2 +- 17 files changed, 50 insertions(+), 53 deletions(-) diff --git a/Modules/CIPPCore/Public/GraphHelper/New-GraphGetRequest.ps1 b/Modules/CIPPCore/Public/GraphHelper/New-GraphGetRequest.ps1 index a943f97e3c743..6abf600735435 100644 --- a/Modules/CIPPCore/Public/GraphHelper/New-GraphGetRequest.ps1 +++ b/Modules/CIPPCore/Public/GraphHelper/New-GraphGetRequest.ps1 @@ -19,6 +19,12 @@ function New-GraphGetRequest { [hashtable]$extraHeaders, [switch]$ReturnRawResponse, [switch]$SkipValueExtraction, + # Emit each page straight to the pipeline instead of buffering the whole + # paginated result. Peak memory becomes one page rather than the entire + # dataset — use for large collections piped into a streaming consumer + # (e.g. Set-CIPPDBCache* | Add-CIPPDbItem). Trade-off: on a mid-pagination + # failure, pages already emitted have flowed downstream before the throw. + [switch]$Stream, $Headers ) @@ -58,7 +64,11 @@ function New-GraphGetRequest { } - $ReturnedData = do { + # Pagination loop. Its per-page output ($data.value etc.) is either buffered + # into $ReturnedData and returned (default), or streamed straight to the + # pipeline (-Stream). Same loop body either way — see the dispatch below. + $Pager = { + do { $RetryCount = 0 $MaxRetries = 3 $RequestSuccessful = $false @@ -180,7 +190,14 @@ function New-GraphGetRequest { } } while (-not $RequestSuccessful -and $RetryCount -le $MaxRetries) } until ([string]::IsNullOrEmpty($NextURL) -or $NextURL -is [object[]] -or ' ' -eq $NextURL) - return $ReturnedData + } + + if ($Stream) { + & $Pager + } else { + $ReturnedData = & $Pager + return $ReturnedData + } } else { Write-Error 'Not allowed. You cannot manage your own tenant or tenants not under your scope' } diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheApps.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheApps.ps1 index 56b65c6c07d1c..ac1b1d7aeceb7 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheApps.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheApps.ps1 @@ -19,10 +19,8 @@ function Set-CIPPDBCacheApps { try { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching applications' -sev Debug - $Apps = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/applications?$top=999&expand=owners' -tenantid $TenantFilter - if (!$Apps) { $Apps = @() } - Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'Apps' -Data $Apps -AddCount - $Apps = $null + New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/applications?$top=999&expand=owners' -tenantid $TenantFilter -Stream | + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'Apps' -AddCount Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached applications successfully' -sev Debug diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheBitlockerKeys.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheBitlockerKeys.ps1 index 71b70fd9a2508..55cf9e78815af 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheBitlockerKeys.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheBitlockerKeys.ps1 @@ -19,10 +19,8 @@ function Set-CIPPDBCacheBitlockerKeys { try { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching BitLocker recovery keys' -sev Debug - $BitlockerKeys = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/informationProtection/bitlocker/recoveryKeys' -tenantid $TenantFilter - if (!$BitlockerKeys) { $BitlockerKeys = @() } - Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'BitlockerKeys' -Data $BitlockerKeys -AddCount - $BitlockerKeys = $null + New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/informationProtection/bitlocker/recoveryKeys' -tenantid $TenantFilter -Stream | + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'BitlockerKeys' -AddCount Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached BitLocker recovery keys successfully' -sev Debug diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheDeviceSettings.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheDeviceSettings.ps1 index 79dd4c4cff821..a9be98143e58f 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheDeviceSettings.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheDeviceSettings.ps1 @@ -19,9 +19,8 @@ function Set-CIPPDBCacheDeviceSettings { try { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching device settings' -sev Debug - $DeviceSettings = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/directory/deviceLocalCredentials' -tenantid $TenantFilter - Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'DeviceSettings' -Data @($DeviceSettings) -AddCount - $DeviceSettings = $null + New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/directory/deviceLocalCredentials' -tenantid $TenantFilter -Stream | + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'DeviceSettings' -AddCount Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached device settings successfully' -sev Debug diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheDevices.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheDevices.ps1 index cb2bec81698f2..a51fd7ce1b03b 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheDevices.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheDevices.ps1 @@ -19,10 +19,8 @@ function Set-CIPPDBCacheDevices { try { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching Azure AD devices' -sev Debug - $Devices = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/devices?$top=999&$select=id,displayName,operatingSystem,operatingSystemVersion,trustType,accountEnabled,approximateLastSignInDateTime' -tenantid $TenantFilter - if (!$Devices) { $Devices = @() } - Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'Devices' -Data $Devices -AddCount - $Devices = $null + New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/devices?$top=999&$select=id,displayName,operatingSystem,operatingSystemVersion,trustType,accountEnabled,approximateLastSignInDateTime' -tenantid $TenantFilter -Stream | + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'Devices' -AddCount Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached Azure AD devices successfully' -sev Debug diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheDirectoryRecommendations.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheDirectoryRecommendations.ps1 index 75074c9ab3b48..3a191fb6a10c3 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheDirectoryRecommendations.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheDirectoryRecommendations.ps1 @@ -19,9 +19,8 @@ function Set-CIPPDBCacheDirectoryRecommendations { try { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching directory recommendations' -sev Debug - $Recommendations = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/directory/recommendations?$top=999' -tenantid $TenantFilter - Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'DirectoryRecommendations' -Data $Recommendations -AddCount - $Recommendations = $null + New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/directory/recommendations?$top=999' -tenantid $TenantFilter -Stream | + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'DirectoryRecommendations' -AddCount Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached directory recommendations successfully' -sev Debug diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheDomains.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheDomains.ps1 index 0a67432e77b8b..1dbe4a8964dc7 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheDomains.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheDomains.ps1 @@ -18,9 +18,8 @@ function Set-CIPPDBCacheDomains { try { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching domains' -sev Debug - $Domains = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/domains' -tenantid $TenantFilter - Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'Domains' -Data @($Domains) -AddCount - $Domains = $null + New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/domains' -tenantid $TenantFilter -Stream | + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'Domains' -AddCount Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached domains successfully' -sev Debug diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheGuests.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheGuests.ps1 index 7a25235e6946d..42fadf461acd9 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheGuests.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheGuests.ps1 @@ -19,10 +19,8 @@ function Set-CIPPDBCacheGuests { try { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching guest users' -sev Debug - $Guests = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/users?`$filter=userType eq 'Guest'&`$expand=sponsors&`$top=999" -tenantid $TenantFilter - if (!$Guests) { $Guests = @() } - Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'Guests' -Data $Guests -AddCount - $Guests = $null + New-GraphGetRequest -uri "https://graph.microsoft.com/beta/users?`$filter=userType eq 'Guest'&`$expand=sponsors&`$top=999" -tenantid $TenantFilter -Stream | + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'Guests' -AddCount Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached guest users successfully' -sev Debug diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheMailboxUsage.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheMailboxUsage.ps1 index 1488af0eda4c0..f523adf31fd16 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheMailboxUsage.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheMailboxUsage.ps1 @@ -19,9 +19,8 @@ function Set-CIPPDBCacheMailboxUsage { try { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching mailbox usage' -sev Debug - $MailboxUsage = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/reports/getMailboxUsageDetail(period='D7')?`$format=application%2fjson" -tenantid $TenantFilter - Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'MailboxUsage' -Data $MailboxUsage -AddCount - $MailboxUsage = $null + New-GraphGetRequest -uri "https://graph.microsoft.com/beta/reports/getMailboxUsageDetail(period='D7')?`$format=application%2fjson" -tenantid $TenantFilter -Stream | + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'MailboxUsage' -AddCount Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached mailbox usage successfully' -sev Debug } catch { diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheManagedDevices.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheManagedDevices.ps1 index 5c462e1d91e31..27032d5d4a4e2 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheManagedDevices.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheManagedDevices.ps1 @@ -18,10 +18,8 @@ function Set-CIPPDBCacheManagedDevices { try { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching managed devices' -sev Debug - $ManagedDevices = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/deviceManagement/managedDevices?$top=999' -tenantid $TenantFilter - if (!$ManagedDevices) { $ManagedDevices = @() } - Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ManagedDevices' -Data $ManagedDevices -AddCount - $ManagedDevices = $null + New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/deviceManagement/managedDevices?$top=999' -tenantid $TenantFilter -Stream | + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ManagedDevices' -AddCount Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached managed devices successfully' -sev Debug } catch { diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheOfficeActivations.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheOfficeActivations.ps1 index 272f1fc317d65..c3592e6f259b1 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheOfficeActivations.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheOfficeActivations.ps1 @@ -19,9 +19,8 @@ function Set-CIPPDBCacheOfficeActivations { try { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching Office activations' -sev Debug - $Activations = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/reports/getOffice365ActivationsUserDetail?`$format=application%2fjson" -tenantid $TenantFilter - Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'OfficeActivations' -Data $Activations -AddCount - $Activations = $null + New-GraphGetRequest -uri "https://graph.microsoft.com/beta/reports/getOffice365ActivationsUserDetail?`$format=application%2fjson" -tenantid $TenantFilter -Stream | + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'OfficeActivations' -AddCount Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached Office activations successfully' -sev Debug } catch { diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheOrganization.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheOrganization.ps1 index 80b4dea03bf43..af291016ea5fb 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheOrganization.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheOrganization.ps1 @@ -19,9 +19,8 @@ function Set-CIPPDBCacheOrganization { try { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching organization data' -sev Debug - $Organization = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/organization' -tenantid $TenantFilter - Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'Organization' -Data $Organization -AddCount - $Organization = $null + New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/organization' -tenantid $TenantFilter -Stream | + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'Organization' -AddCount Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached organization data successfully' -sev Debug diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheRoleAssignmentScheduleInstances.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheRoleAssignmentScheduleInstances.ps1 index 04b802332c703..ca5aab39a4bf0 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheRoleAssignmentScheduleInstances.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheRoleAssignmentScheduleInstances.ps1 @@ -18,9 +18,8 @@ function Set-CIPPDBCacheRoleAssignmentScheduleInstances { try { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching role assignment schedule instances' -sev Debug - $RoleAssignmentScheduleInstances = New-GraphGetRequest -Uri 'https://graph.microsoft.com/v1.0/roleManagement/directory/roleAssignmentScheduleInstances' -tenantid $TenantFilter - Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'RoleAssignmentScheduleInstances' -Data @($RoleAssignmentScheduleInstances) -AddCount - $RoleAssignmentScheduleInstances = $null + New-GraphGetRequest -Uri 'https://graph.microsoft.com/v1.0/roleManagement/directory/roleAssignmentScheduleInstances' -tenantid $TenantFilter -Stream | + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'RoleAssignmentScheduleInstances' -AddCount Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached role assignment schedule instances successfully' -sev Debug } catch { diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheRoleEligibilitySchedules.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheRoleEligibilitySchedules.ps1 index 3a3b4208ced57..e730bab436fc5 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheRoleEligibilitySchedules.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheRoleEligibilitySchedules.ps1 @@ -18,9 +18,8 @@ function Set-CIPPDBCacheRoleEligibilitySchedules { try { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching role eligibility schedules' -sev Debug - $RoleEligibilitySchedules = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/roleManagement/directory/roleEligibilitySchedules' -tenantid $TenantFilter - Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'RoleEligibilitySchedules' -Data @($RoleEligibilitySchedules) -AddCount - $RoleEligibilitySchedules = $null + New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/roleManagement/directory/roleEligibilitySchedules' -tenantid $TenantFilter -Stream | + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'RoleEligibilitySchedules' -AddCount Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached role eligibility schedules successfully' -sev Debug diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheRoleManagementPolicies.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheRoleManagementPolicies.ps1 index 6d88d55f0259a..168e8aeedbf3b 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheRoleManagementPolicies.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheRoleManagementPolicies.ps1 @@ -18,9 +18,8 @@ function Set-CIPPDBCacheRoleManagementPolicies { try { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching role management policies' -sev Debug - $RoleManagementPolicies = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/policies/roleManagementPolicies' -tenantid $TenantFilter - Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'RoleManagementPolicies' -Data @($RoleManagementPolicies) -AddCount - $RoleManagementPolicies = $null + New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/policies/roleManagementPolicies' -tenantid $TenantFilter -Stream | + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'RoleManagementPolicies' -AddCount Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached role management policies successfully' -sev Debug diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheServicePrincipals.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheServicePrincipals.ps1 index 166b6cac7fd63..c36e84f9137ce 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheServicePrincipals.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheServicePrincipals.ps1 @@ -19,9 +19,8 @@ function Set-CIPPDBCacheServicePrincipals { try { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching service principals' -sev Debug - $ServicePrincipals = New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/servicePrincipals' -tenantid $TenantFilter - Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ServicePrincipals' -Data $ServicePrincipals -AddCount - $ServicePrincipals = $null + New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/servicePrincipals' -tenantid $TenantFilter -Stream | + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'ServicePrincipals' -AddCount Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached service principals successfully' -sev Debug diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheUsers.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheUsers.ps1 index e78271e0650f7..14eb2bef641d1 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheUsers.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheUsers.ps1 @@ -87,7 +87,7 @@ function Set-CIPPDBCacheUsers { } # Stream users directly from Graph API to batch processor - New-GraphGetRequest -uri "https://graph.microsoft.com/beta/users?`$top=$Top&`$select=$Select&`$count=true" -ComplexFilter -tenantid $TenantFilter | + New-GraphGetRequest -uri "https://graph.microsoft.com/beta/users?`$top=$Top&`$select=$Select&`$count=true" -ComplexFilter -tenantid $TenantFilter -Stream | Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'Users' -AddCount Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Cached users successfully' -sev Debug From cb33a142ac719d81adc185dc01782cdb5d9fe014 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Tue, 7 Jul 2026 19:41:43 +0800 Subject: [PATCH 118/170] Harden KV and Offloading name resolutions --- .../Authentication/Initialize-CIPPAuth.ps1 | 2 +- .../Authentication/Set-CIPPSSOEasyAuth.ps1 | 3 +- .../Set-CIPPSSOStoredCredentials.ps1 | 5 +- .../Update-CIPPSSORedirectUri.ps1 | 3 +- .../Start-UpdateTokensTimer.ps1 | 2 +- .../Public/Get-CIPPAuthentication.ps1 | 2 +- .../Public/Get-CIPPTimerFunctions.ps1 | 14 +++-- .../CIPPCore/Public/Get-CippKeyVaultName.ps1 | 55 +++++++++++++++++++ .../Public/Get-CippKeyVaultSecret.ps1 | 7 +-- .../CIPPCore/Public/Get-CippOffloadSuffix.ps1 | 49 +++++++++++++++++ .../Public/GraphHelper/Get-GraphToken.ps1 | 2 +- .../Set-CIPPOffloadFunctionTriggers.ps1 | 13 +++-- .../Public/Remove-CippKeyVaultSecret.ps1 | 7 +-- Modules/CIPPCore/Public/Send-CIPPAlert.ps1 | 2 +- .../Public/Set-CIPPNotificationConfig.ps1 | 2 +- .../Public/Set-CippKeyVaultSecret.ps1 | 7 +-- .../Public/Test-CIPPAccessPermissions.ps1 | 2 +- .../Public/Test-CippOffloadFunctionApp.ps1 | 27 +++++++++ .../Settings/Invoke-ExecOffloadFunctions.ps1 | 4 +- .../CIPP/Setup/Invoke-ExecCombinedSetup.ps1 | 2 +- .../CIPP/Setup/Invoke-ExecCreateSAMApp.ps1 | 2 +- .../CIPP/Setup/Invoke-ExecSSOSetup.ps1 | 6 +- .../CIPP/Setup/Invoke-ExecTokenExchange.ps1 | 2 +- .../Setup/Invoke-ExecUpdateRefreshToken.ps1 | 2 +- .../HTTP Functions/Invoke-ExecListAppId.ps1 | 2 +- .../Get-ExtensionAPIKey.ps1 | 2 +- .../Remove-ExtensionAPIKey.ps1 | 2 +- .../Set-ExtensionAPIKey.ps1 | 2 +- .../Public/Gradient/Get-GradientToken.ps1 | 2 +- .../Public/HIBP/Get-HIBPAuth.ps1 | 2 +- 30 files changed, 180 insertions(+), 54 deletions(-) create mode 100644 Modules/CIPPCore/Public/Get-CippKeyVaultName.ps1 create mode 100644 Modules/CIPPCore/Public/Get-CippOffloadSuffix.ps1 create mode 100644 Modules/CIPPCore/Public/Test-CippOffloadFunctionApp.ps1 diff --git a/Modules/CIPPCore/Public/Authentication/Initialize-CIPPAuth.ps1 b/Modules/CIPPCore/Public/Authentication/Initialize-CIPPAuth.ps1 index 127af216dcc51..bdcdc9728e870 100644 --- a/Modules/CIPPCore/Public/Authentication/Initialize-CIPPAuth.ps1 +++ b/Modules/CIPPCore/Public/Authentication/Initialize-CIPPAuth.ps1 @@ -23,7 +23,7 @@ function Initialize-CIPPAuth { # -- Entry logging -- $EasyAuthEnabled = [Craft.Services.AppLifecycleBridge]::IsEasyAuthConfigured() $IsDevStorage = ($env:AzureWebJobsStorage -eq 'UseDevelopmentStorage=true') -or ($env:NonLocalHostAzurite -eq 'true') - $KVName = ($env:WEBSITE_DEPLOYMENT_ID -split '-')[0] + $KVName = Get-CippKeyVaultName Write-Information "[Auth-Init] Starting — EasyAuth=$EasyAuthEnabled, DevStorage=$IsDevStorage, KVName='$KVName', DeploymentId='$env:WEBSITE_DEPLOYMENT_ID'" diff --git a/Modules/CIPPCore/Public/Authentication/Set-CIPPSSOEasyAuth.ps1 b/Modules/CIPPCore/Public/Authentication/Set-CIPPSSOEasyAuth.ps1 index 8ced685da8420..c87f28a6fc09d 100644 --- a/Modules/CIPPCore/Public/Authentication/Set-CIPPSSOEasyAuth.ps1 +++ b/Modules/CIPPCore/Public/Authentication/Set-CIPPSSOEasyAuth.ps1 @@ -71,8 +71,7 @@ function Set-CIPPSSOEasyAuth { # Set AUTH_SECRET as a KV reference when requested (initial setup) # Skip for implicit auth (no client secret needed — e.g. central migration app) if ($UseKvReferences -and -not $ImplicitAuth) { - $KV = $env:WEBSITE_DEPLOYMENT_ID - $VaultName = if ($KV) { ($KV -split '-')[0] } else { $null } + $VaultName = Get-CippKeyVaultName if ($VaultName) { $MergedSettings['AUTH_SECRET'] = "@Microsoft.KeyVault(VaultName=$VaultName;SecretName=SSOAppSecret)" } diff --git a/Modules/CIPPCore/Public/Authentication/Set-CIPPSSOStoredCredentials.ps1 b/Modules/CIPPCore/Public/Authentication/Set-CIPPSSOStoredCredentials.ps1 index 8ee4bd1456c1b..560fff8522933 100644 --- a/Modules/CIPPCore/Public/Authentication/Set-CIPPSSOStoredCredentials.ps1 +++ b/Modules/CIPPCore/Public/Authentication/Set-CIPPSSOStoredCredentials.ps1 @@ -32,9 +32,8 @@ function Set-CIPPSSOStoredCredentials { return } - $KV = $env:WEBSITE_DEPLOYMENT_ID - $VaultName = if ($KV) { ($KV -split '-')[0] } else { $null } - if (-not $VaultName) { throw 'Cannot determine Key Vault name from WEBSITE_DEPLOYMENT_ID' } + $VaultName = Get-CippKeyVaultName + if (-not $VaultName) { throw 'Cannot determine Key Vault name (WEBSITE_SITE_NAME / WEBSITE_DEPLOYMENT_ID not set)' } if ($AppId) { $ExistingAppIdSecret = $null diff --git a/Modules/CIPPCore/Public/Authentication/Update-CIPPSSORedirectUri.ps1 b/Modules/CIPPCore/Public/Authentication/Update-CIPPSSORedirectUri.ps1 index 03d113d2a2145..5f9d71d2ae125 100644 --- a/Modules/CIPPCore/Public/Authentication/Update-CIPPSSORedirectUri.ps1 +++ b/Modules/CIPPCore/Public/Authentication/Update-CIPPSSORedirectUri.ps1 @@ -32,8 +32,7 @@ function Update-CIPPSSORedirectUri { $SSOMultiTenant = $Secret.SSOMultiTenant -eq 'True' } catch { } } else { - $KV = $env:WEBSITE_DEPLOYMENT_ID - $VaultName = if ($KV) { ($KV -split '-')[0] } else { $null } + $VaultName = Get-CippKeyVaultName if ($VaultName) { try { $SSOAppId = Get-CippKeyVaultSecret -VaultName $VaultName -Name 'SSOAppId' -AsPlainText -ErrorAction Stop diff --git a/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-UpdateTokensTimer.ps1 b/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-UpdateTokensTimer.ps1 index 0a616087ae0fa..b7e40c3cc9fd6 100644 --- a/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-UpdateTokensTimer.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-UpdateTokensTimer.ps1 @@ -23,7 +23,7 @@ function Start-UpdateTokensTimer { Write-LogMessage -API 'Update Tokens' -message 'Could not update refresh token. Will try again in 7 days.' -sev 'CRITICAL' } } else { - $KV = ($env:WEBSITE_DEPLOYMENT_ID -split '-')[0] + $KV = Get-CippKeyVaultName if ($Refreshtoken) { Set-CippKeyVaultSecret -VaultName $KV -Name 'RefreshToken' -SecretValue (ConvertTo-SecureString -String $Refreshtoken -AsPlainText -Force) } else { diff --git a/Modules/CIPPCore/Public/Get-CIPPAuthentication.ps1 b/Modules/CIPPCore/Public/Get-CIPPAuthentication.ps1 index cc70d3e0a5b5e..1b082efd2b41e 100644 --- a/Modules/CIPPCore/Public/Get-CIPPAuthentication.ps1 +++ b/Modules/CIPPCore/Public/Get-CIPPAuthentication.ps1 @@ -21,7 +21,7 @@ function Get-CIPPAuthentication { } Write-Host "Got secrets from dev storage. ApplicationID: $env:ApplicationID" } else { - $keyvaultname = ($env:WEBSITE_DEPLOYMENT_ID -split '-')[0] + $keyvaultname = Get-CippKeyVaultName $Variables | ForEach-Object { Set-Item -Path env:$_ -Value (Get-CippKeyVaultSecret -VaultName $keyvaultname -Name $_ -AsPlainText -ErrorAction Stop) -Force } diff --git a/Modules/CIPPCore/Public/Get-CIPPTimerFunctions.ps1 b/Modules/CIPPCore/Public/Get-CIPPTimerFunctions.ps1 index 306bce8d75c7d..bafac2b76eae5 100644 --- a/Modules/CIPPCore/Public/Get-CIPPTimerFunctions.ps1 +++ b/Modules/CIPPCore/Public/Get-CIPPTimerFunctions.ps1 @@ -28,12 +28,14 @@ function Get-CIPPTimerFunctions { $FunctionName = $env:WEBSITE_SITE_NAME $MainFunctionVersion = ($Nodes | Where-Object { $_.RowKey -eq $FunctionName }).Version - $AvailableNodes = $Nodes | Where-Object { $_.RowKey -match '-' -and $_.Version -eq $MainFunctionVersion } | ForEach-Object { ($_.RowKey -split '-')[1] } - - # Get node name - if ($FunctionName -match '-') { - $Node = ($FunctionName -split '-')[1] - } else { + # Offloaded nodes: RowKey is '-'. Use the known offload suffix as the + # node name (Get-CippOffloadSuffix), NOT the second dash segment - a dashed main-app name + # (e.g. 'compaction-01-z2ir2') would otherwise yield a wrong node like '01'. + $AvailableNodes = $Nodes | Where-Object { (Test-CippOffloadFunctionApp -SiteName $_.RowKey) -and $_.Version -eq $MainFunctionVersion } | ForEach-Object { Get-CippOffloadSuffix -SiteName $_.RowKey } + + # Get node name for the current app: the offload suffix, or 'http' for the main app. + $Node = Get-CippOffloadSuffix -SiteName $FunctionName + if (-not $Node) { $Node = 'http' } diff --git a/Modules/CIPPCore/Public/Get-CippKeyVaultName.ps1 b/Modules/CIPPCore/Public/Get-CippKeyVaultName.ps1 new file mode 100644 index 0000000000000..f538397e20e53 --- /dev/null +++ b/Modules/CIPPCore/Public/Get-CippKeyVaultName.ps1 @@ -0,0 +1,55 @@ +function Get-CippKeyVaultName { + <# + .SYNOPSIS + Returns the name of the CIPP Azure Key Vault for the current instance. + + .DESCRIPTION + The Key Vault is named after the main App Service instance, so its name equals + $env:WEBSITE_SITE_NAME on the main app. + + Two things have to be handled: + + 1. Dashed instance names. Earlier code derived the vault name as + ($env:WEBSITE_DEPLOYMENT_ID -split '-')[0], which keeps only the segment before + the first dash. That silently truncated any instance name containing a dash - + e.g. 'compaction-01-z2ir2' became 'compaction' - so every secret call was pointed + at a vault that does not exist (404). The full name must be kept intact. + + 2. Offloaded function apps. When function offloading is enabled, extra Function Apps + are deployed alongside the main app and share its Key Vault. They are named + '-' (e.g. 'compaction-01-z2ir2-standards'). Those apps must + resolve the SAME vault as the main app, so a known offload suffix is stripped from + the end of the site name. Only the fixed offload suffixes are stripped, never an + arbitrary trailing segment, so a legitimate dashed vault name is left intact. + + This is the single source of truth for the vault name so those bugs cannot reappear + per call site. + + .EXAMPLE + $VaultName = Get-CippKeyVaultName + #> + [CmdletBinding()] + [OutputType([string])] + param() + + # Primary: the App Service site name IS the vault name (on the main app). + # Fallback: the full deployment id. Never split on '-' (that is the truncation bug this + # helper exists to prevent); a dashed vault name must be kept whole. + $Name = if (-not [string]::IsNullOrWhiteSpace($env:WEBSITE_SITE_NAME)) { + $env:WEBSITE_SITE_NAME + } elseif (-not [string]::IsNullOrWhiteSpace($env:WEBSITE_DEPLOYMENT_ID)) { + $env:WEBSITE_DEPLOYMENT_ID + } else { + return $null + } + + # If running on an offloaded app ('-'), strip the known suffix so it + # resolves the SAME vault as the main app. Get-CippOffloadSuffix is the single source of + # truth for the suffix list. + $Suffix = Get-CippOffloadSuffix -SiteName $Name + if ($Suffix) { + $Name = $Name -replace "-$([regex]::Escape($Suffix))$", '' + } + + return $Name +} diff --git a/Modules/CIPPCore/Public/Get-CippKeyVaultSecret.ps1 b/Modules/CIPPCore/Public/Get-CippKeyVaultSecret.ps1 index b4d032f2c6774..30f0ac4b08f05 100644 --- a/Modules/CIPPCore/Public/Get-CippKeyVaultSecret.ps1 +++ b/Modules/CIPPCore/Public/Get-CippKeyVaultSecret.ps1 @@ -37,10 +37,9 @@ function Get-CippKeyVaultSecret { try { # Derive vault name if not provided if (-not $VaultName) { - if ($env:WEBSITE_DEPLOYMENT_ID) { - $VaultName = ($env:WEBSITE_DEPLOYMENT_ID -split '-')[0] - } else { - throw 'VaultName not provided and WEBSITE_DEPLOYMENT_ID environment variable not set' + $VaultName = Get-CippKeyVaultName + if (-not $VaultName) { + throw 'VaultName not provided and could not be derived (WEBSITE_SITE_NAME / WEBSITE_DEPLOYMENT_ID not set)' } } diff --git a/Modules/CIPPCore/Public/Get-CippOffloadSuffix.ps1 b/Modules/CIPPCore/Public/Get-CippOffloadSuffix.ps1 new file mode 100644 index 0000000000000..fc828ca8d9a7f --- /dev/null +++ b/Modules/CIPPCore/Public/Get-CippOffloadSuffix.ps1 @@ -0,0 +1,49 @@ +function Get-CippOffloadSuffix { + <# + .SYNOPSIS + Returns the offload-app suffix for a function app name, or $null when it is not an + offloaded app. + + .DESCRIPTION + When function offloading is enabled, extra Function Apps are deployed alongside the + main app and share its resources (Key Vault, etc.). They are named + '-' - e.g. 'compaction-01-z2ir2-standards'. + + This is the SINGLE SOURCE OF TRUTH for the known offload suffixes so that vault-name + derivation ([[Get-CippKeyVaultName]]) and offload detection stay in sync. Matching is + anchored to the end and requires a whole '-' segment, so a legitimate dashed + main-app name (e.g. 'compaction-01-z2ir2', which contains dashes but is NOT offloaded) + is never misdetected. + + Keep $OffloadSuffixes in sync with the deployment's offload app names. + + .PARAMETER SiteName + Function app name to inspect. Defaults to $env:WEBSITE_SITE_NAME (the current app). + + .EXAMPLE + Get-CippOffloadSuffix -SiteName 'compaction-01-z2ir2-standards' # -> 'standards' + + .EXAMPLE + Get-CippOffloadSuffix -SiteName 'compaction-01-z2ir2' # -> $null + #> + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory = $false)] + [AllowNull()] + [AllowEmptyString()] + [string]$SiteName = $env:WEBSITE_SITE_NAME + ) + + $OffloadSuffixes = @('proc', 'auditlog', 'standards', 'usertasks') + + if ([string]::IsNullOrWhiteSpace($SiteName)) { return $null } + + foreach ($Suffix in $OffloadSuffixes) { + if ($SiteName -match "-$([regex]::Escape($Suffix))$") { + return $Suffix + } + } + + return $null +} diff --git a/Modules/CIPPCore/Public/GraphHelper/Get-GraphToken.ps1 b/Modules/CIPPCore/Public/GraphHelper/Get-GraphToken.ps1 index 257b7b22aad05..62005b67c8e80 100644 --- a/Modules/CIPPCore/Public/GraphHelper/Get-GraphToken.ps1 +++ b/Modules/CIPPCore/Public/GraphHelper/Get-GraphToken.ps1 @@ -82,7 +82,7 @@ function Get-GraphToken($tenantid, $scope, $AsApp, $AppID, $AppSecret, $refreshT } } else { # Production environment - get from Key Vault - $keyvaultname = ($env:WEBSITE_DEPLOYMENT_ID -split '-')[0] + $keyvaultname = Get-CippKeyVaultName $secret = Get-CippKeyVaultSecret -VaultName $keyvaultname -Name $clientType.customerId -AsPlainText -ErrorAction Stop if ($secret) { Set-Item -Path "env:\$($clientType.customerId)" -Value $secret -Force diff --git a/Modules/CIPPCore/Public/GraphHelper/Set-CIPPOffloadFunctionTriggers.ps1 b/Modules/CIPPCore/Public/GraphHelper/Set-CIPPOffloadFunctionTriggers.ps1 index 5b3ed653fe58c..ae206f1cd3f0f 100644 --- a/Modules/CIPPCore/Public/GraphHelper/Set-CIPPOffloadFunctionTriggers.ps1 +++ b/Modules/CIPPCore/Public/GraphHelper/Set-CIPPOffloadFunctionTriggers.ps1 @@ -3,10 +3,10 @@ function Set-CIPPOffloadFunctionTriggers { .SYNOPSIS Manages non-HTTP triggers on function apps based on offloading configuration. .DESCRIPTION - Automatically detects if running on an offloaded function app (contains hyphen in name). - If this is the main function app (no hyphen), checks the offloading state from Config table - and disables/enables timer, activity, orchestrator, and queue triggers accordingly. - Offloaded function apps (with hyphen) are skipped as they should have triggers enabled. + Automatically detects if running on an offloaded function app (name ends with a known + offload suffix). If this is the main function app, checks the offloading state from the + Config table and disables/enables timer, activity, orchestrator, and queue triggers + accordingly. Offloaded function apps are skipped as they should have triggers enabled. .EXAMPLE Set-CIPPOffloadFunctionTriggers Automatically manages triggers based on current function app context and offloading state. @@ -17,8 +17,9 @@ function Set-CIPPOffloadFunctionTriggers { # Get current function app name $FunctionAppName = $env:WEBSITE_SITE_NAME - # Check if this is an offloaded function app (contains hyphen) - if ($FunctionAppName -match '-') { + # Check if this is an offloaded function app (name ends with a known offload suffix). + # A dashed main-app name (e.g. 'compaction-01-z2ir2') is NOT offloaded. + if (Test-CippOffloadFunctionApp -SiteName $FunctionAppName) { return $true } diff --git a/Modules/CIPPCore/Public/Remove-CippKeyVaultSecret.ps1 b/Modules/CIPPCore/Public/Remove-CippKeyVaultSecret.ps1 index 667da0c800d82..67f676f38d043 100644 --- a/Modules/CIPPCore/Public/Remove-CippKeyVaultSecret.ps1 +++ b/Modules/CIPPCore/Public/Remove-CippKeyVaultSecret.ps1 @@ -23,10 +23,9 @@ function Remove-CippKeyVaultSecret { try { if (-not $VaultName) { - if ($env:WEBSITE_DEPLOYMENT_ID) { - $VaultName = ($env:WEBSITE_DEPLOYMENT_ID -split '-')[0] - } else { - throw 'VaultName not provided and WEBSITE_DEPLOYMENT_ID environment variable not set' + $VaultName = Get-CippKeyVaultName + if (-not $VaultName) { + throw 'VaultName not provided and could not be derived (WEBSITE_SITE_NAME / WEBSITE_DEPLOYMENT_ID not set)' } } diff --git a/Modules/CIPPCore/Public/Send-CIPPAlert.ps1 b/Modules/CIPPCore/Public/Send-CIPPAlert.ps1 index edd6a2af17edf..ffffabf6cdd01 100644 --- a/Modules/CIPPCore/Public/Send-CIPPAlert.ps1 +++ b/Modules/CIPPCore/Public/Send-CIPPAlert.ps1 @@ -128,7 +128,7 @@ function Send-CIPPAlert { return (Get-CIPPAzDataTableEntity @DevSecretsTable -Filter "PartitionKey eq '$SecretName' and RowKey eq '$SecretName'").APIKey } - $KeyVaultName = ($env:WEBSITE_DEPLOYMENT_ID -split '-')[0] + $KeyVaultName = Get-CippKeyVaultName return (Get-CippKeyVaultSecret -VaultName $KeyVaultName -Name $SecretName -AsPlainText) } diff --git a/Modules/CIPPCore/Public/Set-CIPPNotificationConfig.ps1 b/Modules/CIPPCore/Public/Set-CIPPNotificationConfig.ps1 index 3679e4a89c060..89e767d32a44a 100644 --- a/Modules/CIPPCore/Public/Set-CIPPNotificationConfig.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPNotificationConfig.ps1 @@ -37,7 +37,7 @@ function Set-CIPPNotificationConfig { } Add-CIPPAzDataTableEntity @DevSecretsTable -Entity $Secret -Force | Out-Null } else { - $KeyVaultName = ($env:WEBSITE_DEPLOYMENT_ID -split '-')[0] + $KeyVaultName = Get-CippKeyVaultName Set-CippKeyVaultSecret -VaultName $KeyVaultName -Name $SecretName -SecretValue (ConvertTo-SecureString -AsPlainText -Force -String $SecretValue) | Out-Null } } diff --git a/Modules/CIPPCore/Public/Set-CippKeyVaultSecret.ps1 b/Modules/CIPPCore/Public/Set-CippKeyVaultSecret.ps1 index 937adef2d434d..518131d481bbe 100644 --- a/Modules/CIPPCore/Public/Set-CippKeyVaultSecret.ps1 +++ b/Modules/CIPPCore/Public/Set-CippKeyVaultSecret.ps1 @@ -38,10 +38,9 @@ function Set-CippKeyVaultSecret { try { # Derive vault name if not provided if (-not $VaultName) { - if ($env:WEBSITE_DEPLOYMENT_ID) { - $VaultName = ($env:WEBSITE_DEPLOYMENT_ID -split '-')[0] - } else { - throw "VaultName not provided and WEBSITE_DEPLOYMENT_ID environment variable not set" + $VaultName = Get-CippKeyVaultName + if (-not $VaultName) { + throw 'VaultName not provided and could not be derived (WEBSITE_SITE_NAME / WEBSITE_DEPLOYMENT_ID not set)' } } diff --git a/Modules/CIPPCore/Public/Test-CIPPAccessPermissions.ps1 b/Modules/CIPPCore/Public/Test-CIPPAccessPermissions.ps1 index fe23662fcb033..48a561c3b4ce9 100644 --- a/Modules/CIPPCore/Public/Test-CIPPAccessPermissions.ps1 +++ b/Modules/CIPPCore/Public/Test-CIPPAccessPermissions.ps1 @@ -32,7 +32,7 @@ function Test-CIPPAccessPermissions { } if ($env:MSI_SECRET) { try { - $KV = $env:WEBSITE_DEPLOYMENT_ID + $KV = Get-CippKeyVaultName $KeyVaultRefresh = Get-CippKeyVaultSecret -VaultName $kv -Name 'RefreshToken' -AsPlainText if ($env:RefreshToken -ne $KeyVaultRefresh) { $Success = $false diff --git a/Modules/CIPPCore/Public/Test-CippOffloadFunctionApp.ps1 b/Modules/CIPPCore/Public/Test-CippOffloadFunctionApp.ps1 new file mode 100644 index 0000000000000..9fa5c3d299c8b --- /dev/null +++ b/Modules/CIPPCore/Public/Test-CippOffloadFunctionApp.ps1 @@ -0,0 +1,27 @@ +function Test-CippOffloadFunctionApp { + <# + .SYNOPSIS + Returns $true when the given (or current) function app is an offloaded app. + + .DESCRIPTION + Thin boolean wrapper over [[Get-CippOffloadSuffix]] for readability at detection sites. + An app is "offloaded" when its name ends with a known offload suffix (e.g. '-standards'). + A dashed main-app name (e.g. 'compaction-01-z2ir2') is NOT offloaded. + + .PARAMETER SiteName + Function app name to inspect. Defaults to $env:WEBSITE_SITE_NAME (the current app). + + .EXAMPLE + Test-CippOffloadFunctionApp -SiteName 'compaction-01-z2ir2-proc' # -> $true + #> + [CmdletBinding()] + [OutputType([bool])] + param( + [Parameter(Mandatory = $false)] + [AllowNull()] + [AllowEmptyString()] + [string]$SiteName = $env:WEBSITE_SITE_NAME + ) + + return [bool](Get-CippOffloadSuffix -SiteName $SiteName) +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecOffloadFunctions.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecOffloadFunctions.ps1 index 8326ceacbfb49..a2081374dffc9 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecOffloadFunctions.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Settings/Invoke-ExecOffloadFunctions.ps1 @@ -16,7 +16,7 @@ function Invoke-ExecOffloadFunctions { $VersionTable = Get-CippTable -tablename 'Version' $Version = Get-CIPPAzDataTableEntity @VersionTable -Filter "RowKey ne 'Version'" $MainVersion = $Version | Where-Object { $_.RowKey -eq $env:WEBSITE_SITE_NAME } - $OffloadVersions = $Version | Where-Object { $_.RowKey -match '-' } + $OffloadVersions = $Version | Where-Object { Test-CippOffloadFunctionApp -SiteName $_.RowKey } $Alerts = [System.Collections.Generic.List[string]]::new() @@ -35,7 +35,7 @@ function Invoke-ExecOffloadFunctions { } } - $VersionTable = $Version | Select-Object @{n = 'Name'; e = { $_.RowKey } }, @{n = 'Version'; e = { $_.Version } }, @{n = 'Default'; e = { $_.RowKey -notmatch '-' } } + $VersionTable = $Version | Select-Object @{n = 'Name'; e = { $_.RowKey } }, @{n = 'Version'; e = { $_.Version } }, @{n = 'Default'; e = { -not (Test-CippOffloadFunctionApp -SiteName $_.RowKey) } } $CurrentState = if (!$CurrentState) { [PSCustomObject]@{ diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecCombinedSetup.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecCombinedSetup.ps1 index 154edd7c73af1..a6e6622499adc 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecCombinedSetup.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecCombinedSetup.ps1 @@ -56,7 +56,7 @@ function Invoke-ExecCombinedSetup { $Results.add($notificationResults) } if ($Request.Body.selectedOption -eq 'Manual') { - $KV = $env:WEBSITE_DEPLOYMENT_ID + $KV = Get-CippKeyVaultName if ($env:AzureWebJobsStorage -eq 'UseDevelopmentStorage=true' -or $env:NonLocalHostAzurite -eq 'true') { $DevSecretsTable = Get-CIPPTable -tablename 'DevSecrets' diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecCreateSAMApp.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecCreateSAMApp.ps1 index c1a8350cbc19b..bd919ba0cb879 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecCreateSAMApp.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecCreateSAMApp.ps1 @@ -9,7 +9,7 @@ function Invoke-ExecCreateSAMApp { [CmdletBinding()] param($Request, $TriggerMetadata) - $KV = $env:WEBSITE_DEPLOYMENT_ID + $KV = Get-CippKeyVaultName try { $Token = $Request.body diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecSSOSetup.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecSSOSetup.ps1 index f693c9d6648a2..aa21c5fc18507 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecSSOSetup.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecSSOSetup.ps1 @@ -193,8 +193,7 @@ function Invoke-ExecSSOSetup { # Best-effort: stash TenantID in KV if missing (was previously inline) if (-not ($env:AzureWebJobsStorage -eq 'UseDevelopmentStorage=true' -or $env:NonLocalHostAzurite -eq 'true')) { - $KV = $env:WEBSITE_DEPLOYMENT_ID - $VaultName = if ($KV) { ($KV -split '-')[0] } else { $null } + $VaultName = Get-CippKeyVaultName if ($VaultName -and $env:TenantID) { $ExistingTenantId = $null try { $ExistingTenantId = Get-CippKeyVaultSecret -VaultName $VaultName -Name 'TenantID' -AsPlainText -ErrorAction Stop } catch { } @@ -513,8 +512,7 @@ function Invoke-ExecSSOSetup { $DevSecret = Get-CIPPAzDataTableEntity @DevSecretsTable -Filter "PartitionKey eq 'SSO' and RowKey eq 'SSO'" -ErrorAction SilentlyContinue $ExistingAppId = $DevSecret.SSOAppId } else { - $KV = $env:WEBSITE_DEPLOYMENT_ID - $VaultName = if ($KV) { ($KV -split '-')[0] } else { $null } + $VaultName = Get-CippKeyVaultName if ($VaultName) { try { $ExistingAppId = Get-CippKeyVaultSecret -VaultName $VaultName -Name 'SSOAppId' -AsPlainText -ErrorAction Stop } catch { } } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecTokenExchange.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecTokenExchange.ps1 index 5621576362f63..dcf2154812270 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecTokenExchange.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecTokenExchange.ps1 @@ -9,7 +9,7 @@ function Invoke-ExecTokenExchange { param($Request, $TriggerMetadata) # Get the key vault name - $KV = $env:WEBSITE_DEPLOYMENT_ID + $KV = Get-CippKeyVaultName $APIName = $Request.Params.CIPPEndpoint try { diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecUpdateRefreshToken.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecUpdateRefreshToken.ps1 index 7461952cc25ca..321388cd22332 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecUpdateRefreshToken.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecUpdateRefreshToken.ps1 @@ -9,7 +9,7 @@ function Invoke-ExecUpdateRefreshToken { [CmdletBinding()] param($Request, $TriggerMetadata) - $KV = $env:WEBSITE_DEPLOYMENT_ID + $KV = Get-CippKeyVaultName try { # Handle refresh token update diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Invoke-ExecListAppId.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Invoke-ExecListAppId.ps1 index 69a66fb92bcdd..984d37b4fe630 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Invoke-ExecListAppId.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Invoke-ExecListAppId.ps1 @@ -21,7 +21,7 @@ function Invoke-ExecListAppId { $env:ApplicationID = $Secret.ApplicationID $env:TenantID = $Secret.TenantID } else { - $keyvaultname = ($env:WEBSITE_DEPLOYMENT_ID -split '-')[0] + $keyvaultname = Get-CippKeyVaultName try { $env:ApplicationID = (Get-CippKeyVaultSecret -AsPlainText -VaultName $keyvaultname -Name 'ApplicationID') $env:TenantID = (Get-CippKeyVaultSecret -AsPlainText -VaultName $keyvaultname -Name 'TenantID') diff --git a/Modules/CippExtensions/Public/Extension Functions/Get-ExtensionAPIKey.ps1 b/Modules/CippExtensions/Public/Extension Functions/Get-ExtensionAPIKey.ps1 index 561893aee457f..ccf25d46bf996 100644 --- a/Modules/CippExtensions/Public/Extension Functions/Get-ExtensionAPIKey.ps1 +++ b/Modules/CippExtensions/Public/Extension Functions/Get-ExtensionAPIKey.ps1 @@ -20,7 +20,7 @@ function Get-ExtensionAPIKey { $DevSecretsTable = Get-CIPPTable -tablename 'DevSecrets' $APIKey = (Get-CIPPAzDataTableEntity @DevSecretsTable -Filter "PartitionKey eq '$Extension' and RowKey eq '$Extension'").APIKey } else { - $keyvaultname = ($env:WEBSITE_DEPLOYMENT_ID -split '-')[0] + $keyvaultname = Get-CippKeyVaultName $APIKey = (Get-CippKeyVaultSecret -VaultName $keyvaultname -Name $Extension -AsPlainText) } Set-Item -Path "env:$Var" -Value $APIKey -Force -ErrorAction SilentlyContinue diff --git a/Modules/CippExtensions/Public/Extension Functions/Remove-ExtensionAPIKey.ps1 b/Modules/CippExtensions/Public/Extension Functions/Remove-ExtensionAPIKey.ps1 index 3e42100a218fe..804dd8e69994d 100644 --- a/Modules/CippExtensions/Public/Extension Functions/Remove-ExtensionAPIKey.ps1 +++ b/Modules/CippExtensions/Public/Extension Functions/Remove-ExtensionAPIKey.ps1 @@ -19,7 +19,7 @@ function Remove-ExtensionAPIKey { Write-Information "No existing DevSecrets row found for '$Extension' to delete." } } else { - $keyvaultname = ($env:WEBSITE_DEPLOYMENT_ID -split '-')[0] + $keyvaultname = Get-CippKeyVaultName try { $null = Remove-CippKeyVaultSecret -VaultName $keyvaultname -Name $Extension } catch { diff --git a/Modules/CippExtensions/Public/Extension Functions/Set-ExtensionAPIKey.ps1 b/Modules/CippExtensions/Public/Extension Functions/Set-ExtensionAPIKey.ps1 index f8b0975bc9639..c7ab471f0df77 100644 --- a/Modules/CippExtensions/Public/Extension Functions/Set-ExtensionAPIKey.ps1 +++ b/Modules/CippExtensions/Public/Extension Functions/Set-ExtensionAPIKey.ps1 @@ -23,7 +23,7 @@ function Set-ExtensionAPIKey { } Add-CIPPAzDataTableEntity @DevSecretsTable -Entity $Secret -Force } else { - $keyvaultname = ($env:WEBSITE_DEPLOYMENT_ID -split '-')[0] + $keyvaultname = Get-CippKeyVaultName $null = Set-CippKeyVaultSecret -VaultName $keyvaultname -Name $Extension -SecretValue (ConvertTo-SecureString -AsPlainText -Force -String $APIKey) } Set-Item -Path "env:$Var" -Value $APIKey -Force -ErrorAction SilentlyContinue diff --git a/Modules/CippExtensions/Public/Gradient/Get-GradientToken.ps1 b/Modules/CippExtensions/Public/Gradient/Get-GradientToken.ps1 index fc9968070e66c..c7cdb3703b89e 100644 --- a/Modules/CippExtensions/Public/Gradient/Get-GradientToken.ps1 +++ b/Modules/CippExtensions/Public/Gradient/Get-GradientToken.ps1 @@ -3,7 +3,7 @@ function Get-GradientToken { $Configuration ) if ($Configuration.vendorKey) { - $keyvaultname = ($env:WEBSITE_DEPLOYMENT_ID -split '-')[0] + $keyvaultname = Get-CippKeyVaultName $partnerApiKey = (Get-CippKeyVaultSecret -VaultName $keyvaultname -Name 'Gradient' -AsPlainText) $authorizationToken = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes("$($configuration.vendorKey):$($partnerApiKey)")) diff --git a/Modules/CippExtensions/Public/HIBP/Get-HIBPAuth.ps1 b/Modules/CippExtensions/Public/HIBP/Get-HIBPAuth.ps1 index 365b387e09fc2..4af70cb4e57b8 100644 --- a/Modules/CippExtensions/Public/HIBP/Get-HIBPAuth.ps1 +++ b/Modules/CippExtensions/Public/HIBP/Get-HIBPAuth.ps1 @@ -9,7 +9,7 @@ function Get-HIBPAuth { $DevSecretsTable = Get-CIPPTable -tablename 'DevSecrets' $Secret = (Get-CIPPAzDataTableEntity @DevSecretsTable -Filter "PartitionKey eq 'HIBP' and RowKey eq 'HIBP'").APIKey } else { - $VaultName = ($env:WEBSITE_DEPLOYMENT_ID -split '-')[0] + $VaultName = Get-CippKeyVaultName try { $Secret = Get-CippKeyVaultSecret -VaultName $VaultName -Name 'HIBP' -AsPlainText -ErrorAction Stop } catch { From 122fbf2fcff1e8351816e3516d9862b7ad01d263 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Tue, 7 Jul 2026 14:41:24 +0200 Subject: [PATCH 119/170] remove copilot feature flag. its timmmeee --- Config/FeatureFlags.json | 25 ------------------- .../Timer Functions/Start-CIPPStatsTimer.ps1 | 1 - 2 files changed, 26 deletions(-) diff --git a/Config/FeatureFlags.json b/Config/FeatureFlags.json index e90b238e94beb..bad3baf6e8117 100644 --- a/Config/FeatureFlags.json +++ b/Config/FeatureFlags.json @@ -44,31 +44,6 @@ ], "Hidden": true }, - { - "Id": "CopilotAI", - "Name": "Copilot & AI", - "Description": "Under Development: Microsoft 365 Copilot and AI management pages including settings, usage reports, Agent365 packages, and Shadow AI analysis.", - "Enabled": false, - "AllowUserToggle": false, - "Timers": [], - "Endpoints": [ - "ListCopilotSettings", - "ExecCopilotSettings", - "ListCopilotUsage", - "ListAgent365Packages", - "ListAgent365PackageDetail", - "ListShadowAI" - ], - "Pages": [ - "/copilot/settings", - "/copilot/shadow-ai", - "/copilot/agent365/packages", - "/copilot/reports/copilot-adoption", - "/copilot/reports/copilot-usage", - "/copilot/reports/copilot-trend" - ], - "Hidden": true - }, { "Id": "MCPServer", "Name": "MCP Server", diff --git a/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-CIPPStatsTimer.ps1 b/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-CIPPStatsTimer.ps1 index bc1651351056c..c965fd2da5a84 100644 --- a/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-CIPPStatsTimer.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-CIPPStatsTimer.ps1 @@ -85,7 +85,6 @@ function Start-CIPPStatsTimer { GitHub = $RawExt.GitHub.Enabled BestPracticeAnalyser = $FeatureFlags.BestPracticeAnalyser SuperAdminNG = $FeatureFlags.SuperAdminNG - CopilotAI = $FeatureFlags.CopilotAI MCPServer = $FeatureFlags.MCPServer } | ConvertTo-Json From 168a1103248acaabdb5bafc1122c3c7cf12fef99 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Tue, 7 Jul 2026 21:29:09 +0800 Subject: [PATCH 120/170] SSO manual cred input --- Config/openapi.json | 8 ++- .../CIPP/Setup/Invoke-ExecSSOSetup.ps1 | 71 ++++++++++++++++++- 2 files changed, 77 insertions(+), 2 deletions(-) diff --git a/Config/openapi.json b/Config/openapi.json index 7fbc0152a540a..0456733b43b00 100644 --- a/Config/openapi.json +++ b/Config/openapi.json @@ -23157,6 +23157,12 @@ }, "targetUrl": { "type": "string" + }, + "appId": { + "type": "string" + }, + "appSecret": { + "type": "string" } } } @@ -42056,4 +42062,4 @@ } } } -} \ No newline at end of file +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecSSOSetup.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecSSOSetup.ps1 index aa21c5fc18507..b6114a265aaad 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecSSOSetup.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecSSOSetup.ps1 @@ -591,9 +591,78 @@ function Invoke-ExecSSOSetup { } } + 'ManualConfigure' { + # Manually set the SSO AppId / client secret / multi-tenant flag directly in Key Vault. + # Used to rotate the secret by hand or repoint EasyAuth at a different app registration + # without the automated Create/Repair flow. Credentials are read from KV at startup, + # so the instance must be restarted for the change to take effect. + try { + $AppId = $Request.Body.appId + $AppSecret = $Request.Body.appSecret + $MultiTenant = [bool]($Request.Body.multiTenant) + + # Validate AppId — must be a GUID + $ParsedGuid = [System.Guid]::Empty + if ([string]::IsNullOrWhiteSpace($AppId) -or -not [System.Guid]::TryParse($AppId.Trim(), [ref]$ParsedGuid)) { + $StatusCode = [HttpStatusCode]::BadRequest + $Body = @{ Results = 'A valid Application (client) ID is required.' } + break + } + $AppId = $ParsedGuid.ToString() + + if ([string]::IsNullOrWhiteSpace($AppSecret)) { + $StatusCode = [HttpStatusCode]::BadRequest + $Body = @{ Results = 'A client secret is required.' } + break + } + + # Persist to Key Vault (or the DevSecrets table in dev mode) + Set-CIPPSSOStoredCredentials -AppId $AppId -AppSecret $AppSecret -MultiTenant $MultiTenant + + # Update the migration table so the Status page reflects the manual config. + # Clear ObjectId — it belonged to the previous app registration and would be stale + # if the AppId was changed. Repair/RotateSecret re-fetch it by AppId when missing. + $Existing = Get-CIPPAzDataTableEntity @MigrationTable -Filter "PartitionKey eq 'SSO' and RowKey eq 'MigrationConfig'" -ErrorAction SilentlyContinue + & $SaveMigrationRow @{ + AppId = $AppId + ObjectId = '' + MultiTenant = [string]$MultiTenant + Status = 'secrets_stored' + CreatedAt = $Existing.CreatedAt ?? (Get-Date).ToUniversalTime().ToString('o') + ManualConfig = 'true' + LastError = '' + } + + Write-LogMessage -API $APIName -headers $Headers -message "SSO credentials manually configured for app $AppId (multiTenant=$MultiTenant)" -sev Info + + $IsCippNg = [bool]$env:CIPPNG + $Message = if ($IsCippNg) { + 'SSO credentials saved to Key Vault. Restart the instance to apply the new configuration.' + } else { + 'SSO credentials saved to Key Vault successfully.' + } + + $Body = @{ + Results = @{ + message = $Message + appId = $AppId + multiTenant = $MultiTenant + requiresRestart = $IsCippNg + isCippNg = $IsCippNg + severity = 'success' + } + } + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API $APIName -headers $Headers -message "Manual SSO configuration failed: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + $StatusCode = [HttpStatusCode]::InternalServerError + $Body = @{ Results = "Manual SSO configuration failed: $($ErrorMessage.NormalizedError)" } + } + } + default { $StatusCode = [HttpStatusCode]::BadRequest - $Body = @{ Results = "Unknown action: $Action. Use 'Status', 'Create', 'Repair', 'Recreate', 'Update', 'RotateSecret', or 'Migrate'." } + $Body = @{ Results = "Unknown action: $Action. Use 'Status', 'Create', 'Repair', 'Recreate', 'Update', 'RotateSecret', 'ManualConfigure', or 'Migrate'." } } } From bf3f22879f0bc1a5e6b49ebd9e1f5d4a7fd5401f Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Tue, 7 Jul 2026 15:51:04 +0200 Subject: [PATCH 121/170] fixes manual run jobs with detectedApp data --- .../Push-IntuneReportExportSubmit.ps1 | 46 +----------- .../Public/New-CIPPIntuneReportExportJob.ps1 | 71 +++++++++++++++++++ .../DBCache/Set-CIPPDBCacheDetectedApps.ps1 | 35 +++++---- .../Set-CIPPDBCacheIntuneAppInstallStatus.ps1 | 39 ++++++---- 4 files changed, 116 insertions(+), 75 deletions(-) create mode 100644 Modules/CIPPCore/Public/New-CIPPIntuneReportExportJob.ps1 diff --git a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-IntuneReportExportSubmit.ps1 b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-IntuneReportExportSubmit.ps1 index be40e229a3307..f222f537dd589 100644 --- a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-IntuneReportExportSubmit.ps1 +++ b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-IntuneReportExportSubmit.ps1 @@ -17,51 +17,7 @@ function Push-IntuneReportExportSubmit { } try { - $Select = switch ($ReportName) { - 'AppInvRawData' { - @( - 'ApplicationKey', 'ApplicationName', 'ApplicationPublisher', 'ApplicationVersion', - 'DeviceId', 'DeviceName', 'OSDescription', 'OSVersion', 'Platform', - 'UserId', 'UserName', 'EmailAddress' - ) - } - 'AppInstallStatusAggregate' { - @( - 'ApplicationId', 'DisplayName', 'Publisher', 'Platform', 'AppVersion', 'AppPlatform', - 'InstalledDeviceCount', 'FailedDeviceCount', 'FailedUserCount', - 'PendingInstallDeviceCount', 'NotInstalledDeviceCount', 'FailedDevicePercentage' - ) - } - default { throw "Unknown Intune report '$ReportName'" } - } - - $Body = @{ - reportName = $ReportName - format = 'json' - localizationType = 'replaceLocalizableValues' - select = $Select - } | ConvertTo-Json -Depth 5 - - $Job = New-GraphPOSTRequest ` - -uri 'https://graph.microsoft.com/beta/deviceManagement/reports/exportJobs' ` - -tenantid $TenantFilter ` - -body $Body - - if (-not $Job.id) { throw "Intune returned no job id for $ReportName" } - - $JobsTable = Get-CIPPTable -tablename 'IntuneReportJobs' - $Existing = Get-CIPPAzDataTableEntity @JobsTable -Filter "PartitionKey eq '$TenantFilter' and RowKey eq '$ReportName'" - if ($Existing) { - Remove-AzDataTableEntity @JobsTable -Entity $Existing -Force -ErrorAction SilentlyContinue - } - - Add-CIPPAzDataTableEntity @JobsTable -Entity @{ - PartitionKey = $TenantFilter - RowKey = $ReportName - JobId = $Job.id - ReportName = $ReportName - SubmittedAt = ([DateTime]::UtcNow).ToString('o') - } -Force + $Job = New-CIPPIntuneReportExportJob -TenantFilter $TenantFilter -ReportName $ReportName Write-LogMessage -API 'IntuneReportExport' -tenant $TenantFilter -message "Submitted $ReportName export job $($Job.id)" -sev Info return @{ Status = 'Submitted'; JobId = $Job.id; ReportName = $ReportName; TenantFilter = $TenantFilter } diff --git a/Modules/CIPPCore/Public/New-CIPPIntuneReportExportJob.ps1 b/Modules/CIPPCore/Public/New-CIPPIntuneReportExportJob.ps1 new file mode 100644 index 0000000000000..96c85aa1936d7 --- /dev/null +++ b/Modules/CIPPCore/Public/New-CIPPIntuneReportExportJob.ps1 @@ -0,0 +1,71 @@ +function New-CIPPIntuneReportExportJob { + <# + .SYNOPSIS + Submits an Intune report export job and stores the job id in the IntuneReportJobs table. + + .DESCRIPTION + Posts an export job to deviceManagement/reports/exportJobs for the given report and upserts + the job id into the IntuneReportJobs table (PartitionKey = tenant, RowKey = report name) so + a later run can poll and download the result. Used by the nightly report-export orchestrator + and by the DB cache functions to self-submit when no job is pending. + + .PARAMETER TenantFilter + The tenant to submit the export job for. + + .PARAMETER ReportName + The Intune report to export. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + + [Parameter(Mandatory = $true)] + [ValidateSet('AppInvRawData', 'AppInstallStatusAggregate')] + [string]$ReportName + ) + + $Select = switch ($ReportName) { + 'AppInvRawData' { + @( + 'ApplicationKey', 'ApplicationName', 'ApplicationPublisher', 'ApplicationVersion', + 'DeviceId', 'DeviceName', 'OSDescription', 'OSVersion', 'Platform', + 'UserId', 'UserName', 'EmailAddress' + ) + } + 'AppInstallStatusAggregate' { + @( + 'ApplicationId', 'DisplayName', 'Publisher', 'Platform', 'AppVersion', 'AppPlatform', + 'InstalledDeviceCount', 'FailedDeviceCount', 'FailedUserCount', + 'PendingInstallDeviceCount', 'NotInstalledDeviceCount', 'FailedDevicePercentage' + ) + } + } + + $Body = @{ + reportName = $ReportName + format = 'json' + localizationType = 'replaceLocalizableValues' + select = $Select + } | ConvertTo-Json -Depth 5 + + $Job = New-GraphPOSTRequest -uri 'https://graph.microsoft.com/beta/deviceManagement/reports/exportJobs' -tenantid $TenantFilter -body $Body + + if (-not $Job.id) { throw "Intune returned no job id for $ReportName" } + + $JobsTable = Get-CIPPTable -tablename 'IntuneReportJobs' + $Existing = Get-CIPPAzDataTableEntity @JobsTable -Filter "PartitionKey eq '$TenantFilter' and RowKey eq '$ReportName'" + if ($Existing) { + Remove-AzDataTableEntity @JobsTable -Entity $Existing -Force -ErrorAction SilentlyContinue + } + + Add-CIPPAzDataTableEntity @JobsTable -Entity @{ + PartitionKey = $TenantFilter + RowKey = $ReportName + JobId = $Job.id + ReportName = $ReportName + SubmittedAt = ([DateTime]::UtcNow).ToString('o') + } -Force + + return $Job +} diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheDetectedApps.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheDetectedApps.ps1 index 09c22d47b137d..158b818122cda 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheDetectedApps.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheDetectedApps.ps1 @@ -24,8 +24,9 @@ function Set-CIPPDBCacheDetectedApps { $JobRow = Get-CIPPAzDataTableEntity @JobsTable -Filter "PartitionKey eq '$TenantFilter' and RowKey eq '$ReportName'" if (-not $JobRow) { - Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "No $ReportName job submitted - skipping detected apps cache" -sev Info - return + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "No $ReportName export job pending - submitting a new one" -sev Info + $null = New-CIPPIntuneReportExportJob -TenantFilter $TenantFilter -ReportName $ReportName + $JobRow = Get-CIPPAzDataTableEntity @JobsTable -Filter "PartitionKey eq '$TenantFilter' and RowKey eq '$ReportName'" } $JobId = $JobRow.JobId @@ -35,26 +36,30 @@ function Set-CIPPDBCacheDetectedApps { return } - try { - $Job = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/deviceManagement/reports/exportJobs/$JobId" -tenantid $TenantFilter - } catch { - $ErrorMessage = Get-CippException -Exception $_ - Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "$ReportName job $JobId not retrievable: $($ErrorMessage.NormalizedError)" -sev Warning -LogData $ErrorMessage - Remove-AzDataTableEntity @JobsTable -Entity $JobRow -Force -ErrorAction SilentlyContinue - return - } - switch ($Job.status) { - 'completed' { } - 'failed' { + $Deadline = [datetime]::UtcNow.AddMinutes(4) + while ($true) { + try { + $Job = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/deviceManagement/reports/exportJobs/$JobId" -tenantid $TenantFilter + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "$ReportName job $JobId not retrievable: $($ErrorMessage.NormalizedError)" -sev Warning -LogData $ErrorMessage + Remove-AzDataTableEntity @JobsTable -Entity $JobRow -Force -ErrorAction SilentlyContinue + return + } + + if ($Job.status -eq 'completed') { break } + if ($Job.status -eq 'failed') { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "$ReportName job $JobId failed" -sev Error Remove-AzDataTableEntity @JobsTable -Entity $JobRow -Force -ErrorAction SilentlyContinue return } - default { - Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "$ReportName job $JobId still '$($Job.status)' - skipping" -sev Info + if ([datetime]::UtcNow -ge $Deadline) { + # Keep the job row so the next cache run can consume the result + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "$ReportName job $JobId still '$($Job.status)' after waiting - the result will be cached on the next run" -sev Info return } + Start-Sleep -Seconds 20 } if (-not $Job.url) { diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntuneAppInstallStatus.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntuneAppInstallStatus.ps1 index 329145fd4b641..8a72b2aaf8116 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntuneAppInstallStatus.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheIntuneAppInstallStatus.ps1 @@ -30,8 +30,11 @@ function Set-CIPPDBCacheIntuneAppInstallStatus { $JobRow = Get-CIPPAzDataTableEntity @JobsTable -Filter "PartitionKey eq '$TenantFilter' and RowKey eq '$ReportName'" if (-not $JobRow) { - Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "No $ReportName job submitted - skipping app install status cache" -sev Info - return + # No pending job - the nightly submission was already consumed by a previous cache run + # or never ran. Submit a new export job now so forced cache runs are self-sufficient. + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "No $ReportName export job pending - submitting a new one" -sev Info + $null = New-CIPPIntuneReportExportJob -TenantFilter $TenantFilter -ReportName $ReportName + $JobRow = Get-CIPPAzDataTableEntity @JobsTable -Filter "PartitionKey eq '$TenantFilter' and RowKey eq '$ReportName'" } $JobId = $JobRow.JobId @@ -41,26 +44,32 @@ function Set-CIPPDBCacheIntuneAppInstallStatus { return } - try { - $Job = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/deviceManagement/reports/exportJobs/$JobId" -tenantid $TenantFilter - } catch { - $ErrorMessage = Get-CippException -Exception $_ - Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "$ReportName job $JobId not retrievable: $($ErrorMessage.NormalizedError)" -sev Warning -LogData $ErrorMessage - Remove-AzDataTableEntity @JobsTable -Entity $JobRow -Force -ErrorAction SilentlyContinue - return - } + # Poll the export job until it completes. Jobs submitted by the nightly orchestrator are + # long since completed and pass on the first check; freshly self-submitted jobs get a + # bounded wait so a forced run still returns fresh data. + $Deadline = [datetime]::UtcNow.AddMinutes(4) + while ($true) { + try { + $Job = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/deviceManagement/reports/exportJobs/$JobId" -tenantid $TenantFilter + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "$ReportName job $JobId not retrievable: $($ErrorMessage.NormalizedError)" -sev Warning -LogData $ErrorMessage + Remove-AzDataTableEntity @JobsTable -Entity $JobRow -Force -ErrorAction SilentlyContinue + return + } - switch ($Job.status) { - 'completed' { } - 'failed' { + if ($Job.status -eq 'completed') { break } + if ($Job.status -eq 'failed') { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "$ReportName job $JobId failed" -sev Error Remove-AzDataTableEntity @JobsTable -Entity $JobRow -Force -ErrorAction SilentlyContinue return } - default { - Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "$ReportName job $JobId still '$($Job.status)' - skipping" -sev Info + if ([datetime]::UtcNow -ge $Deadline) { + # Keep the job row so the next cache run can consume the result + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "$ReportName job $JobId still '$($Job.status)' after waiting - the result will be cached on the next run" -sev Info return } + Start-Sleep -Seconds 20 } if (-not $Job.url) { From 405c55d085d80d1bcd3674f993e739a0dcdda14d Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Tue, 7 Jul 2026 21:55:50 +0800 Subject: [PATCH 122/170] Update Update-CIPPSSORedirectUri.ps1 --- .../Update-CIPPSSORedirectUri.ps1 | 35 ++++++++++--------- 1 file changed, 19 insertions(+), 16 deletions(-) diff --git a/Modules/CIPPCore/Public/Authentication/Update-CIPPSSORedirectUri.ps1 b/Modules/CIPPCore/Public/Authentication/Update-CIPPSSORedirectUri.ps1 index 5f9d71d2ae125..e5c7bd830b6f4 100644 --- a/Modules/CIPPCore/Public/Authentication/Update-CIPPSSORedirectUri.ps1 +++ b/Modules/CIPPCore/Public/Authentication/Update-CIPPSSORedirectUri.ps1 @@ -94,30 +94,33 @@ function Update-CIPPSSORedirectUri { return } - # Build patch body - $PatchBody = @{} - + # Patch redirect URIs and signInAudience as separate requests. A tenant app-management + # policy can reject an audience change (e.g. downgrading a multi-tenant app to + # single-tenant fails with "SigninAudienceRestrictions with restricted mode can be + # configured only on multi-tenants apps"). Sending them together would let that + # rejection also drop the redirect URI additions, which are needed for sign-in. if ($MissingUris.Count -gt 0) { $UpdatedUris = [System.Collections.Generic.List[string]]::new() $ExistingUris | ForEach-Object { $UpdatedUris.Add($_) } $MissingUris | ForEach-Object { $UpdatedUris.Add($_) } - $PatchBody.web = @{ redirectUris = $UpdatedUris } - } - - if ($AudienceMismatch) { - $PatchBody.signInAudience = $ExpectedAudience - Write-Information "[SSO-Redirect] Correcting signInAudience: $($AppResponse.signInAudience) -> $ExpectedAudience" - } - - $Body = $PatchBody | ConvertTo-Json -Depth 5 - New-GraphPOSTRequest -uri "https://graph.microsoft.com/v1.0/applications/$($AppResponse.id)" -body $Body -type PATCH -NoAuthCheck $true -AsApp $true - - if ($MissingUris.Count -gt 0) { + $UriBody = @{ web = @{ redirectUris = $UpdatedUris } } | ConvertTo-Json -Depth 5 + New-GraphPOSTRequest -uri "https://graph.microsoft.com/v1.0/applications/$($AppResponse.id)" -body $UriBody -type PATCH -NoAuthCheck $true -AsApp $true Write-Information "[SSO-Redirect] Added redirect URIs: $($MissingUris -join ', ')" Write-LogMessage -API 'SSO-Redirect' -message "Added redirect URIs: $($MissingUris -join ', ')" -sev Info } + if ($AudienceMismatch) { - Write-LogMessage -API 'SSO-Redirect' -message "Updated signInAudience to $ExpectedAudience (multiTenant=$SSOMultiTenant)" -sev Info + Write-Information "[SSO-Redirect] Correcting signInAudience: $($AppResponse.signInAudience) -> $ExpectedAudience" + try { + $AudienceBody = @{ signInAudience = $ExpectedAudience } | ConvertTo-Json -Compress + New-GraphPOSTRequest -uri "https://graph.microsoft.com/v1.0/applications/$($AppResponse.id)" -body $AudienceBody -type PATCH -NoAuthCheck $true -AsApp $true + Write-LogMessage -API 'SSO-Redirect' -message "Updated signInAudience to $ExpectedAudience (multiTenant=$SSOMultiTenant)" -sev Info + } catch { + # Non-fatal: a tenant app-management policy is blocking the audience change. + # EasyAuth issuer validation already enforces the effective tenant scope, so the + # app registration can stay as-is. Log at Info so warmup doesn't spam warnings. + Write-Information "[SSO-Redirect] signInAudience change to $ExpectedAudience was rejected by tenant policy (leaving app reg as $($AppResponse.signInAudience)): $($_.Exception.Message)" + } } } catch { Write-LogMessage -API 'SSO-Redirect' -message "Failed to update SSO app registration: $_" -LogData (Get-CippException -Exception $_) -sev Warning From 9520792f466eeb52548577ff5da6c3d85ceb4d43 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Tue, 7 Jul 2026 10:00:37 -0400 Subject: [PATCH 123/170] feat: Include SSO migration status in stats timer --- .../Entrypoints/Timer Functions/Start-CIPPStatsTimer.ps1 | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-CIPPStatsTimer.ps1 b/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-CIPPStatsTimer.ps1 index c965fd2da5a84..342cb828a1b17 100644 --- a/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-CIPPStatsTimer.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-CIPPStatsTimer.ps1 @@ -54,6 +54,11 @@ function Start-CIPPStatsTimer { $FeatureFlags[$_.Id] = $_.Enabled } + # SSO migration status + $MigrationTable = Get-CIPPTable -tablename 'SSOMigration' + $MigrationConfig = Get-CIPPAzDataTableEntity @MigrationTable -Filter "PartitionKey eq 'SSO' and RowKey eq 'MigrationConfig'" -ErrorAction SilentlyContinue + $MigrationStatus = $MigrationConfig.Status + $SendingObject = [PSCustomObject]@{ rgid = $env:WEBSITE_SITE_NAME SetupComplete = $SetupComplete @@ -86,6 +91,7 @@ function Start-CIPPStatsTimer { BestPracticeAnalyser = $FeatureFlags.BestPracticeAnalyser SuperAdminNG = $FeatureFlags.SuperAdminNG MCPServer = $FeatureFlags.MCPServer + SSOMigrationStatus = $MigrationStatus } | ConvertTo-Json try { From 9c5b576ef918cdae3bfb4e4686ac7e8c5a620c95 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Tue, 7 Jul 2026 16:56:12 +0200 Subject: [PATCH 124/170] Last copilot AI improvements reporting. --- .../Tenant/Standards/Invoke-ListShadowAI.ps1 | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ListShadowAI.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ListShadowAI.ps1 index c5ea0190d57d6..d007b3f7e8cdc 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ListShadowAI.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ListShadowAI.ps1 @@ -64,15 +64,16 @@ function Invoke-ListShadowAI { $DeviceCount = [int]($App.deviceCount ?? 0) if ($DeviceCount -eq 0 -and $App.managedDevices) { $DeviceCount = @($App.managedDevices).Count } $DetectedApps.Add([PSCustomObject]@{ - application = $App.displayName - aiTool = $Match.name - vendor = $Match.vendor - category = $Match.category - risk = $Match.risk - publisher = $App.publisher - version = $App.version - platform = if ([string]::IsNullOrWhiteSpace($App.platform)) { 'Unknown' } else { $App.platform } - deviceCount = $DeviceCount + application = $App.displayName + aiTool = $Match.name + vendor = $Match.vendor + category = $Match.category + risk = $Match.risk + publisher = $App.publisher + version = $App.version + platform = if ([string]::IsNullOrWhiteSpace($App.platform)) { 'Unknown' } else { $App.platform } + deviceCount = $DeviceCount + managedDevices = @($App.managedDevices ?? @()) }) } From c0573d20545e48a1dde54d21e17aefc9cfb2f37a Mon Sep 17 00:00:00 2001 From: John Duprey Date: Tue, 7 Jul 2026 12:58:08 -0400 Subject: [PATCH 125/170] feat: Enhance reusable settings template handling in tenant alignment functions and standards --- .../Functions/Get-CIPPTenantAlignment.ps1 | 15 ++++++++++++ Modules/CIPPCore/Public/Get-CIPPDrift.ps1 | 24 +++++++++++++++++++ .../Sync-CIPPReusablePolicySettings.ps1 | 3 ++- .../Standards/Invoke-ListTenantAlignment.ps1 | 3 ++- ...e-CIPPStandardReusableSettingsTemplate.ps1 | 20 +++++++++++----- 5 files changed, 57 insertions(+), 8 deletions(-) diff --git a/Modules/CIPPCore/Public/Functions/Get-CIPPTenantAlignment.ps1 b/Modules/CIPPCore/Public/Functions/Get-CIPPTenantAlignment.ps1 index 0c59d0696f0d8..38c19947804b4 100644 --- a/Modules/CIPPCore/Public/Functions/Get-CIPPTenantAlignment.ps1 +++ b/Modules/CIPPCore/Public/Functions/Get-CIPPTenantAlignment.ps1 @@ -270,6 +270,21 @@ function Get-CIPPTenantAlignment { } } } + # Handle Reusable Settings templates — TemplateList is multi-select, one key per template id + elseif ($StandardKey -eq 'ReusableSettingsTemplate' -and $StandardConfig) { + foreach ($RSTemplate in @($StandardConfig)) { + $RSActions = if ($RSTemplate.action) { $RSTemplate.action } else { @() } + $RSReportingEnabled = ($RSActions | Where-Object { $_.value -and ($_.value.ToLower() -eq 'report' -or $_.value.ToLower() -eq 'remediate') }).Count -gt 0 + foreach ($RSTemplateId in @($RSTemplate.TemplateList.value)) { + if ($RSTemplateId) { + [PSCustomObject]@{ + StandardId = "standards.ReusableSettingsTemplate.$RSTemplateId" + ReportingEnabled = $RSReportingEnabled + } + } + } + } + } # Handle QuarantineTemplate — each policy is keyed by hex-encoded display name elseif ($StandardKey -eq 'QuarantineTemplate' -and $StandardConfig -is [array]) { foreach ($QTemplate in $StandardConfig) { diff --git a/Modules/CIPPCore/Public/Get-CIPPDrift.ps1 b/Modules/CIPPCore/Public/Get-CIPPDrift.ps1 index 5f4d7c6d582aa..86f65813db4d6 100644 --- a/Modules/CIPPCore/Public/Get-CIPPDrift.ps1 +++ b/Modules/CIPPCore/Public/Get-CIPPDrift.ps1 @@ -86,6 +86,21 @@ function Get-CIPPDrift { } } + # Load reusable settings templates for display name lookup + $RawReusableSettingTemplates = Get-CIPPAzDataTableEntity @IntuneTable -Filter "PartitionKey eq 'IntuneReusableSettingTemplate'" + $ReusableSettingTemplatesByGuid = @{} + foreach ($RawTemplate in $RawReusableSettingTemplates) { + try { + $data = $RawTemplate.JSON | ConvertFrom-Json -Depth 100 -ErrorAction SilentlyContinue + $ReusableSettingTemplatesByGuid[$RawTemplate.RowKey] = [PSCustomObject]@{ + displayName = $data.DisplayName ?? $data.displayName ?? $RawTemplate.RowKey + description = $data.Description ?? $data.description + } + } catch { + # Skip invalid templates + } + } + try { $AlignmentData = Get-CIPPTenantAlignment -TenantFilter $TenantFilter -TemplateId $TemplateId | Where-Object -Property standardType -EQ 'drift' @@ -152,6 +167,15 @@ function Get-CIPPDrift { $standardDescription = $Template.description } } + # Handle Reusable Settings templates + if ($ComparisonItem.StandardName -like 'standards.ReusableSettingsTemplate.*') { + $CompareGuid = $ComparisonItem.StandardName.Substring('standards.ReusableSettingsTemplate.'.Length) + if ($CompareGuid -and $ReusableSettingTemplatesByGuid.ContainsKey($CompareGuid)) { + $Template = $ReusableSettingTemplatesByGuid[$CompareGuid] + $displayName = "Reusable Setting - $($Template.displayName)" + $standardDescription = $Template.description + } + } # Handle QuarantineTemplate — suffix is hex-encoded display name, decode it if ($ComparisonItem.StandardName -like 'standards.QuarantineTemplate.*') { $HexEncodedName = $ComparisonItem.StandardName.Substring('standards.QuarantineTemplate.'.Length) diff --git a/Modules/CIPPCore/Public/Sync-CIPPReusablePolicySettings.ps1 b/Modules/CIPPCore/Public/Sync-CIPPReusablePolicySettings.ps1 index 3a87963cba8c9..8e88186827bb4 100644 --- a/Modules/CIPPCore/Public/Sync-CIPPReusablePolicySettings.ps1 +++ b/Modules/CIPPCore/Public/Sync-CIPPReusablePolicySettings.ps1 @@ -12,7 +12,8 @@ function Sync-CIPPReusablePolicySettings { $reusableRefs = @($TemplateInfo.ReusableSettings) if (-not $reusableRefs) { return $result } - $existingReusableSettings = New-GraphGETRequest -Uri 'https://graph.microsoft.com/beta/deviceManagement/reusablePolicySettings?$top=999' -tenantid $Tenant + # The list endpoint omits settingInstance unless explicitly selected, which would make every compare fail + $existingReusableSettings = New-GraphGETRequest -Uri 'https://graph.microsoft.com/beta/deviceManagement/reusablePolicySettings?$top=999&$select=id,displayName,description,settingDefinitionId,settingInstance,version' -tenantid $Tenant $table = Get-CippTable -tablename 'templates' $templateEntities = Get-CIPPAzDataTableEntity @table -Filter "PartitionKey eq 'IntuneReusableSettingTemplate'" diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ListTenantAlignment.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ListTenantAlignment.ps1 index aae480c97d51e..5e717af28807e 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ListTenantAlignment.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ListTenantAlignment.ps1 @@ -20,7 +20,7 @@ function Invoke-ListTenantAlignment { $TemplateLookup = @{} if ($Granular) { $TemplateTable = Get-CippTable -tablename 'templates' - $TemplatePartitions = @('IntuneTemplate', 'ConditionalAccessTemplate', 'QuarantineTemplate') + $TemplatePartitions = @('IntuneTemplate', 'ConditionalAccessTemplate', 'QuarantineTemplate', 'IntuneReusableSettingTemplate') foreach ($Partition in $TemplatePartitions) { Get-CIPPAzDataTableEntity @TemplateTable -Filter "PartitionKey eq '$Partition'" | ForEach-Object { $TemplateRow = $_ @@ -64,6 +64,7 @@ function Invoke-ListTenantAlignment { 'IntuneTemplate' { 'Intune Template' } 'ConditionalAccessTemplate' { 'Conditional Access Template' } 'QuarantineTemplate' { 'Quarantine Template' } + 'ReusableSettingsTemplate' { 'Reusable Settings Template' } default { $MatchType } } "$FriendlyType - $PolicyName" diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardReusableSettingsTemplate.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardReusableSettingsTemplate.ps1 index c03242b0d0c79..99f4b13fa6705 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardReusableSettingsTemplate.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardReusableSettingsTemplate.ps1 @@ -65,15 +65,16 @@ function Invoke-CIPPStandardReusableSettingsTemplate { $TestResult = Test-CIPPStandardLicense -StandardName 'ReusableSettingsTemplate_general' -TenantFilter $Tenant -Preset Intune if ($TestResult -eq $false) { $settings.TemplateList | ForEach-Object { - $MissingLicenseMessage = "This tenant is missing one or more required licenses for this standard: $($RequiredCapabilities -join ', ')." - Set-CIPPStandardsCompareField -FieldName "standards.ReusableSettingsTemplate.$($_.value)" -FieldValue $MissingLicenseMessage -Tenant $Tenant + $MissingLicenseMessage = 'License Missing: This tenant is missing the required Intune license for this standard.' + Set-CIPPStandardsCompareField -FieldName "standards.ReusableSettingsTemplate.$($_.value)" -FieldValue $MissingLicenseMessage -LicenseAvailable $false -TenantFilter $Tenant } - Write-LogMessage -API 'Standards' -tenant $Tenant -message "Exiting as the correct license is not present for this standard. Missing: $($RequiredCapabilities -join ', ')" -sev 'Warning' + Write-LogMessage -API 'Standards' -tenant $Tenant -message 'Exiting as the correct license is not present for this standard.' -sev 'Warning' return $true } $Table = Get-CippTable -tablename 'templates' - $ExistingReusableSettings = New-GraphGETRequest -Uri 'https://graph.microsoft.com/beta/deviceManagement/reusablePolicySettings?$top=999' -tenantid $Tenant + # The list endpoint omits settingInstance unless explicitly selected, which would make every compare fail + $ExistingReusableSettings = New-GraphGETRequest -Uri 'https://graph.microsoft.com/beta/deviceManagement/reusablePolicySettings?$top=999&$select=id,displayName,description,settingDefinitionId,settingInstance,version' -tenantid $Tenant # Align with other template standards by resolving all selected templates upfront $SelectedTemplateIds = @($Settings.TemplateList.value) @@ -169,8 +170,15 @@ function Invoke-CIPPStandardReusableSettingsTemplate { if ($true -in $Settings.report) { foreach ($Template in $CompareList | Where-Object { $_.report -eq $true -or $_.remediate -eq $true }) { $id = $Template.templateId - $state = $Template.compare ? $Template.compare : $true - Set-CIPPStandardsCompareField -FieldName "standards.ReusableSettingsTemplate.$id" -FieldValue $state -TenantFilter $Tenant + $CurrentValue = @{ + displayName = $Template.displayname + isCompliant = if ($Template.compare) { $false } else { $true } + } + $ExpectedValue = @{ + displayName = $Template.displayname + isCompliant = $true + } + Set-CIPPStandardsCompareField -FieldName "standards.ReusableSettingsTemplate.$id" -CurrentValue $CurrentValue -ExpectedValue $ExpectedValue -TenantFilter $Tenant } } } From 239cfeb54c1c692215816dc5dc8bd0d7798c9eb1 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Tue, 7 Jul 2026 13:29:43 -0400 Subject: [PATCH 126/170] feat: Refactor target object handling in Get-CippCustomDataAttributes and enhance user attribute selection in Invoke-ListUsers --- .../Public/Get-CippCustomDataAttributes.ps1 | 8 +-- .../Administration/Users/Invoke-ListUsers.ps1 | 60 ++++++++++++++++++- 2 files changed, 63 insertions(+), 5 deletions(-) diff --git a/Modules/CIPPCore/Public/Get-CippCustomDataAttributes.ps1 b/Modules/CIPPCore/Public/Get-CippCustomDataAttributes.ps1 index 90a8469c53547..8ed8727c6e1ce 100644 --- a/Modules/CIPPCore/Public/Get-CippCustomDataAttributes.ps1 +++ b/Modules/CIPPCore/Public/Get-CippCustomDataAttributes.ps1 @@ -17,12 +17,12 @@ function Get-CippCustomDataAttributes { if ($CustomData) { if ($Type -eq 'SchemaExtension') { $Name = $CustomData.id - foreach ($TargetObject in $CustomData.targetTypes) { + foreach ($Target in $CustomData.targetTypes) { foreach ($Property in $CustomData.properties) { [PSCustomObject]@{ name = '{0}.{1}' -f $Name, $Property.name type = $Type - targetObject = $TargetObject + targetObject = $Target dataType = $Property.type isMultiValued = $false } @@ -30,11 +30,11 @@ function Get-CippCustomDataAttributes { } } elseif ($Type -eq 'DirectoryExtension') { $Name = $CustomDataEntity.RowKey - foreach ($TargetObject in $CustomData.targetObjects) { + foreach ($Target in $CustomData.targetObjects) { [PSCustomObject]@{ name = $Name type = $Type - targetObject = $TargetObject + targetObject = $Target dataType = $CustomData.dataType isMultiValued = $CustomData.isMultiValued } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListUsers.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListUsers.ps1 index 843fc4159857f..4590a1c0cf432 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListUsers.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListUsers.ps1 @@ -15,8 +15,66 @@ Function Invoke-ListUsers { $GraphFilter = $Request.Query.graphFilter $userid = $Request.Query.UserID + # When fetching a single user, use an explicit $select so directory/schema extension properties are + # returned. Covers the properties the user view and edit form consume, any attributes added via + # Preferences > Added Attributes, and custom data attributes mapped for manual entry on users. + $SelectParam = '' + if ($userid -and $TenantFilter -ne 'AllTenants') { + $BaseProperties = @( + 'id', 'accountEnabled', 'ageGroup', 'assignedLicenses', 'businessPhones', 'city', 'companyName', + 'consentProvidedForMinor', 'country', 'createdDateTime', 'department', 'displayName', + 'employeeHireDate', 'employeeId', 'employeeLeaveDateTime', 'employeeType', 'faxNumber', + 'givenName', 'jobTitle', 'lastPasswordChangeDateTime', 'legalAgeGroupClassification', 'mail', + 'mailNickname', 'mobilePhone', 'officeLocation', 'onPremisesDistinguishedName', + 'onPremisesLastSyncDateTime', 'onPremisesSyncEnabled', 'otherMails', 'postalCode', + 'preferredLanguage', 'proxyAddresses', 'showInAddressList', 'state', 'streetAddress', + 'surname', 'usageLocation', 'userPrincipalName', 'userType' + ) + $CustomAttributes = [System.Collections.Generic.List[string]]::new() + try { + # Attributes added via Preferences > 'Added Attributes when creating a new user' + $Username = ([System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($Request.Headers.'x-ms-client-principal')) | ConvertFrom-Json).userDetails + $EscapedUsername = $Username -replace "'", "''" + $UserSettingsTable = Get-CippTable -tablename 'UserSettings' + $UserSettingsEntities = Get-CIPPAzDataTableEntity @UserSettingsTable -Filter "PartitionKey eq 'UserSettings' and (RowKey eq 'allUsers' or RowKey eq '$EscapedUsername')" + foreach ($Entity in $UserSettingsEntities) { + foreach ($Label in (($Entity.JSON | ConvertFrom-Json -Depth 10 -ErrorAction SilentlyContinue).userAttributes.label)) { + if ($Label) { $CustomAttributes.Add($Label) } + } + } + # Custom data attributes mapped for manual entry on users for this tenant + $MappingsTable = Get-CippTable -tablename 'CustomDataMappings' + foreach ($Entity in (Get-CIPPAzDataTableEntity @MappingsTable)) { + $Mapping = $Entity.JSON | ConvertFrom-Json -ErrorAction SilentlyContinue + if ($Mapping.sourceType.value -ne 'manualEntry' -or $Mapping.directoryObjectType.value -ne 'user') { continue } + $TenantList = Expand-CIPPTenantGroups -TenantFilter $Mapping.tenantFilter + if ($TenantList.value -notcontains $TenantFilter -and $TenantList.value -notcontains 'AllTenants') { continue } + # Schema extension names are 'schemaId.property'; Graph $select takes the schema id + $AttributeName = ($Mapping.customDataAttribute.value -split '\.')[0] + if ($AttributeName) { $CustomAttributes.Add($AttributeName) } + } + } catch { + Write-Warning "Failed to resolve custom attributes for user $($userid): $($_.Exception.Message)" + } + $ValidCustomAttributes = $CustomAttributes | Where-Object { $_ -match '^[A-Za-z][A-Za-z0-9_]*$' } + $SelectParam = '&$select=' + ((@($BaseProperties) + @($ValidCustomAttributes) | Sort-Object -Unique) -join ',') + Write-Information $SelectParam + } + $GraphRequest = if ($TenantFilter -ne 'AllTenants') { - New-GraphGetRequest -uri "https://graph.microsoft.com/beta/users/$($userid)?`$top=999&`$filter=$GraphFilter&`$count=true&`$expand=manager(`$select=id,userPrincipalName,displayName)" -tenantid $TenantFilter -ComplexFilter | ForEach-Object { + try { + $UserData = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/users/$($userid)?`$top=999&`$filter=$GraphFilter&`$count=true&`$expand=manager(`$select=id,userPrincipalName,displayName)$SelectParam" -tenantid $TenantFilter -ComplexFilter + } catch { + if ($SelectParam) { + # A preference-added attribute name Graph does not recognize fails the whole $select; + # fall back to the default property set rather than failing the request. + Write-Warning "ListUsers select query failed, falling back to default properties: $($_.Exception.Message)" + $UserData = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/users/$($userid)?`$top=999&`$filter=$GraphFilter&`$count=true&`$expand=manager(`$select=id,userPrincipalName,displayName)" -tenantid $TenantFilter -ComplexFilter + } else { + throw + } + } + $UserData | ForEach-Object { $_ | Add-Member -MemberType NoteProperty -Name 'onPremisesSyncEnabled' -Value ([bool]($_.onPremisesSyncEnabled)) -Force $_ | Add-Member -MemberType NoteProperty -Name 'username' -Value ($_.userPrincipalName -split '@' | Select-Object -First 1) -Force $_ | Add-Member -MemberType NoteProperty -Name 'Aliases' -Value ($_.ProxyAddresses -join ', ') -Force From fea7f9f85f5841f52cfa8b41038a2b3f26cae12a Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Tue, 7 Jul 2026 22:58:29 +0200 Subject: [PATCH 127/170] shadow ai updates --- .../Standards/Invoke-ExecShadowAISanction.ps1 | 61 +++++++++++++++++++ .../Tenant/Standards/Invoke-ListShadowAI.ps1 | 26 ++++++-- 2 files changed, 83 insertions(+), 4 deletions(-) create mode 100644 Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecShadowAISanction.ps1 diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecShadowAISanction.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecShadowAISanction.ps1 new file mode 100644 index 0000000000000..8c5f84c6d1e0a --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecShadowAISanction.ps1 @@ -0,0 +1,61 @@ +function Invoke-ExecShadowAISanction { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Tenant.Standards.ReadWrite + .DESCRIPTION + Marks an AI tool from the Shadow AI catalog as company sanctioned for a tenant, or removes + that status. Sanctioned tools are stored per tenant in the ShadowAIConfig table and are + reported by ListShadowAI with risk 'Informational' and status 'Sanctioned'; all other + detected AI tools report status 'Unsanctioned'. + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $TenantFilter = $Request.Body.tenantFilter ?? $Request.Query.tenantFilter + $Tools = @($Request.Body.Tools ?? $Request.Body.Tool) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } + $Action = $Request.Body.Action ?? 'Sanction' + + try { + if (-not $TenantFilter) { throw 'tenantFilter is required' } + if ($Tools.Count -eq 0) { throw 'No AI tool specified' } + if ($Action -notin @('Sanction', 'Unsanction')) { throw "Unknown action '$Action'. Use Sanction or Unsanction." } + + $Table = Get-CIPPTable -TableName 'ShadowAIConfig' + $Results = foreach ($Tool in $Tools) { + # Table storage forbids /, \, # and ? in row keys + $RowKey = ($Tool -replace '[\\/#\?]', ' ').Trim() + if ($Action -eq 'Sanction') { + Add-CIPPAzDataTableEntity @Table -Entity @{ + PartitionKey = $TenantFilter + RowKey = $RowKey + Tool = "$Tool" + Sanctioned = $true + } -Force + Write-LogMessage -headers $Request.Headers -API 'ExecShadowAISanction' -tenant $TenantFilter -message "Marked AI tool '$Tool' as company sanctioned" -Sev 'Info' + "Marked '$Tool' as company sanctioned. Its risk level now reports as Informational." + } else { + $EscapedTenant = $TenantFilter -replace "'", "''" + $EscapedRowKey = $RowKey -replace "'", "''" + $Entity = Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq '$EscapedTenant' and RowKey eq '$EscapedRowKey'" + if ($Entity) { + Remove-AzDataTableEntity @Table -Entity $Entity -Force + } + Write-LogMessage -headers $Request.Headers -API 'ExecShadowAISanction' -tenant $TenantFilter -message "Removed company sanctioned status from AI tool '$Tool'" -Sev 'Info' + "Removed company sanctioned status from '$Tool'. Its catalog risk level applies again." + } + } + + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::OK + Body = @{ Results = @($Results) } + }) + } catch { + Write-LogMessage -headers $Request.Headers -API 'ExecShadowAISanction' -tenant $TenantFilter -message "Failed to update sanctioned AI tools: $($_.Exception.Message)" -Sev 'Error' + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::BadRequest + Body = @{ Results = @("Failed to update sanctioned AI tools: $($_.Exception.Message)") } + }) + } +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ListShadowAI.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ListShadowAI.ps1 index d007b3f7e8cdc..7701856fdbb00 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ListShadowAI.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ListShadowAI.ps1 @@ -37,6 +37,18 @@ function Invoke-ListShadowAI { return $null } + $SanctionedTools = @{} + try { + $SanctionTable = Get-CIPPTable -TableName 'ShadowAIConfig' + $EscapedTenant = $TenantFilter -replace "'", "''" + foreach ($Row in @(Get-CIPPAzDataTableEntity @SanctionTable -Filter "PartitionKey eq '$EscapedTenant'")) { + $ToolName = if ($Row.Tool) { $Row.Tool } else { $Row.RowKey } + if ($ToolName) { $SanctionedTools[$ToolName.ToLower()] = $true } + } + } catch { + Write-LogMessage -API 'ShadowAI' -tenant $TenantFilter -message "Could not load sanctioned AI tools: $($_.Exception.Message)" -Sev 'Warning' + } + # --- Cached datasets from the CIPP reporting database (no live Graph enumeration) --- $CacheTypes = @('DetectedApps', 'ServicePrincipals', 'OAuth2PermissionGrants') $CacheData = @{} @@ -63,12 +75,14 @@ function Invoke-ListShadowAI { if (-not $Match) { continue } $DeviceCount = [int]($App.deviceCount ?? 0) if ($DeviceCount -eq 0 -and $App.managedDevices) { $DeviceCount = @($App.managedDevices).Count } + $IsSanctioned = $SanctionedTools.ContainsKey($Match.name.ToLower()) $DetectedApps.Add([PSCustomObject]@{ application = $App.displayName aiTool = $Match.name vendor = $Match.vendor category = $Match.category - risk = $Match.risk + risk = if ($IsSanctioned) { 'Informational' } else { $Match.risk } + status = if ($IsSanctioned) { 'Sanctioned' } else { 'Unsanctioned' } publisher = $App.publisher version = $App.version platform = if ([string]::IsNullOrWhiteSpace($App.platform)) { 'Unknown' } else { $App.platform } @@ -102,12 +116,14 @@ function Invoke-ListShadowAI { } else { @() } + $IsSanctioned = $SanctionedTools.ContainsKey($Match.name.ToLower()) $Consent = [PSCustomObject]@{ application = $Sp.displayName aiTool = $Match.name vendor = $Match.vendor category = $Match.category - risk = $Match.risk + risk = if ($IsSanctioned) { 'Informational' } else { $Match.risk } + status = if ($IsSanctioned) { 'Sanctioned' } else { 'Unsanctioned' } applicationId = $Sp.appId approvedPermissions = @($Permissions) firstConsentedDateTime = $Sp.createdDateTime @@ -153,13 +169,13 @@ function Invoke-ListShadowAI { $ToolMap = @{} foreach ($App in $DetectedApps) { if (-not $ToolMap.ContainsKey($App.aiTool)) { - $ToolMap[$App.aiTool] = [PSCustomObject]@{ Tool = $App.aiTool; Category = $App.category; Risk = $App.risk; Devices = 0; Users = 0 } + $ToolMap[$App.aiTool] = [PSCustomObject]@{ Tool = $App.aiTool; Category = $App.category; Risk = $App.risk; Status = $App.status; Devices = 0; Users = 0 } } $ToolMap[$App.aiTool].Devices += $App.deviceCount } foreach ($App in $ConsentedApps) { if (-not $ToolMap.ContainsKey($App.aiTool)) { - $ToolMap[$App.aiTool] = [PSCustomObject]@{ Tool = $App.aiTool; Category = $App.category; Risk = $App.risk; Devices = 0; Users = 0 } + $ToolMap[$App.aiTool] = [PSCustomObject]@{ Tool = $App.aiTool; Category = $App.category; Risk = $App.risk; Status = $App.status; Devices = 0; Users = 0 } } $ToolMap[$App.aiTool].Users += [int]$App.activeUsersLast7Days } @@ -185,6 +201,7 @@ function Invoke-ListShadowAI { users = $_.Users footprint = $_.Devices + $_.Users category = $_.Category + status = $_.Status } } @@ -194,6 +211,7 @@ function Invoke-ListShadowAI { deviceInstalls = [int](($DetectedApps | Measure-Object -Property deviceCount -Sum).Sum) consentedAiApps = $ConsentedApps.Count highRiskTools = @($ToolMap.Values | Where-Object { $_.Risk -eq 'High' }).Count + sanctionedTools = @($ToolMap.Values | Where-Object { $_.Status -eq 'Sanctioned' }).Count intuneSynced = $IntuneSynced entraSynced = $EntraSynced lastDataRefresh = $LastDataRefresh From 5b7cf18234b4840f3eacafe27b041fa2657094fb Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Tue, 7 Jul 2026 23:37:35 +0200 Subject: [PATCH 128/170] json updates --- Config/ShadowAI.json | 348 +++++++++--------- .../Tenant/Standards/Invoke-ListShadowAI.ps1 | 28 +- 2 files changed, 191 insertions(+), 185 deletions(-) diff --git a/Config/ShadowAI.json b/Config/ShadowAI.json index 081ecc500456c..05481845dce75 100644 --- a/Config/ShadowAI.json +++ b/Config/ShadowAI.json @@ -1,176 +1,176 @@ [ - { "name": "Azure OpenAI", "vendor": "Microsoft", "category": "AI Platform & API", "risk": "Low", "matchNames": ["azure openai"], "appIds": [] }, - { "name": "GitHub Copilot", "vendor": "GitHub", "category": "AI Coding", "risk": "Medium", "matchNames": ["github copilot"], "appIds": [] }, - { "name": "OpenAI Codex", "vendor": "OpenAI", "category": "AI Coding", "risk": "High", "matchNames": ["codex"], "appIds": [] }, - { "name": "OpenAI Sora", "vendor": "OpenAI", "category": "AI Video & Audio", "risk": "Medium", "matchNames": ["sora"], "appIds": [] }, - { "name": "ChatGPT", "vendor": "OpenAI", "category": "AI Assistant", "risk": "High", "matchNames": ["chatgpt", "openai"], "appIds": [] }, - { "name": "Claude", "vendor": "Anthropic", "category": "AI Assistant", "risk": "Medium", "matchNames": ["claude", "anthropic"], "appIds": [] }, - { "name": "NotebookLM", "vendor": "Google", "category": "AI Search & Research", "risk": "Low", "matchNames": ["notebooklm"], "appIds": [] }, - { "name": "Google Gemini", "vendor": "Google", "category": "AI Assistant", "risk": "Medium", "matchNames": ["gemini"], "appIds": [] }, - { "name": "Google Antigravity", "vendor": "Google", "category": "AI Coding", "risk": "Medium", "matchNames": ["antigravity"], "appIds": [] }, - { "name": "Vertex AI", "vendor": "Google", "category": "AI Platform & API", "risk": "Low", "matchNames": ["vertex ai"], "appIds": [] }, - { "name": "Microsoft Copilot", "vendor": "Microsoft", "category": "AI Assistant", "risk": "Low", "matchNames": ["copilot"], "appIds": [] }, - { "name": "Perplexity", "vendor": "Perplexity AI", "category": "AI Assistant", "risk": "Medium", "matchNames": ["perplexity"], "appIds": [] }, - { "name": "DeepSeek", "vendor": "DeepSeek", "category": "AI Assistant", "risk": "High", "matchNames": ["deepseek"], "appIds": [] }, - { "name": "Grok", "vendor": "xAI", "category": "AI Assistant", "risk": "Medium", "matchNames": ["grok", "x.ai"], "appIds": [] }, - { "name": "Mistral Le Chat", "vendor": "Mistral AI", "category": "AI Assistant", "risk": "Medium", "matchNames": ["mistral"], "appIds": [] }, - { "name": "Meta AI", "vendor": "Meta", "category": "AI Assistant", "risk": "Medium", "matchNames": ["meta ai", "llama.cpp"], "appIds": [] }, - { "name": "Qwen", "vendor": "Alibaba", "category": "AI Assistant", "risk": "High", "matchNames": ["qwen"], "appIds": [] }, - { "name": "Kimi", "vendor": "Moonshot AI", "category": "AI Assistant", "risk": "High", "matchNames": ["kimi", "moonshot ai"], "appIds": [] }, - { "name": "Poe", "vendor": "Quora", "category": "AI Assistant", "risk": "Medium", "matchNames": ["poe by quora", "quora"], "appIds": [] }, - { "name": "You.com", "vendor": "You.com", "category": "AI Assistant", "risk": "Medium", "matchNames": ["you.com"], "appIds": [] }, - { "name": "Pi", "vendor": "Inflection AI", "category": "AI Assistant", "risk": "Medium", "matchNames": ["inflection"], "appIds": [] }, - { "name": "Character.AI", "vendor": "Character Technologies", "category": "AI Companion Chatbot", "risk": "High", "matchNames": ["character.ai", "character ai"], "appIds": [] }, - { "name": "Candy.AI", "vendor": "Candy.AI", "category": "AI Companion Chatbot", "risk": "High", "matchNames": ["candy.ai"], "appIds": [] }, - { "name": "Janitor AI", "vendor": "JanitorAI", "category": "AI Companion Chatbot", "risk": "High", "matchNames": ["janitor ai", "janitorai"], "appIds": [] }, - { "name": "Crushon AI", "vendor": "Crushon", "category": "AI Companion Chatbot", "risk": "High", "matchNames": ["crushon"], "appIds": [] }, - { "name": "FlowGPT", "vendor": "FlowGPT", "category": "AI Companion Chatbot", "risk": "High", "matchNames": ["flowgpt"], "appIds": [] }, - { "name": "Cursor", "vendor": "Anysphere", "category": "AI Coding", "risk": "Medium", "matchNames": ["cursor"], "appIds": [] }, - { "name": "Codeium / Windsurf", "vendor": "Codeium", "category": "AI Coding", "risk": "Medium", "matchNames": ["codeium", "windsurf"], "appIds": [] }, - { "name": "Tabnine", "vendor": "Tabnine", "category": "AI Coding", "risk": "Medium", "matchNames": ["tabnine"], "appIds": [] }, - { "name": "Sourcegraph Cody", "vendor": "Sourcegraph", "category": "AI Coding", "risk": "Medium", "matchNames": ["sourcegraph", "cody"], "appIds": [] }, - { "name": "Aider", "vendor": "Aider", "category": "AI Coding", "risk": "Medium", "matchNames": ["aider"], "appIds": [] }, - { "name": "Amazon Q / CodeWhisperer", "vendor": "Amazon", "category": "AI Coding", "risk": "Medium", "matchNames": ["codewhisperer", "amazon q"], "appIds": [] }, - { "name": "Amazon Bedrock", "vendor": "Amazon", "category": "AI Platform & API", "risk": "Medium", "matchNames": ["bedrock"], "appIds": [] }, - { "name": "Amazon Kiro", "vendor": "Amazon", "category": "AI Coding", "risk": "Medium", "matchNames": ["kiro"], "appIds": [] }, - { "name": "Replit", "vendor": "Replit", "category": "AI Coding", "risk": "High", "matchNames": ["replit"], "appIds": [] }, - { "name": "Lovable", "vendor": "Lovable", "category": "AI Coding", "risk": "High", "matchNames": ["lovable"], "appIds": [] }, - { "name": "Bolt.new", "vendor": "StackBlitz", "category": "AI Coding", "risk": "High", "matchNames": ["bolt.new", "stackblitz"], "appIds": [] }, - { "name": "v0", "vendor": "Vercel", "category": "AI Coding", "risk": "High", "matchNames": ["v0.dev"], "appIds": [] }, - { "name": "Devin", "vendor": "Cognition", "category": "AI Coding", "risk": "High", "matchNames": ["devin ai", "cognition labs"], "appIds": [] }, - { "name": "Continue", "vendor": "Continue", "category": "AI Coding", "risk": "Medium", "matchNames": ["continue.dev"], "appIds": [] }, - { "name": "Cline", "vendor": "Cline", "category": "AI Coding", "risk": "Medium", "matchNames": ["cline"], "appIds": [] }, - { "name": "Roo Code", "vendor": "Roo Code", "category": "AI Coding", "risk": "Medium", "matchNames": ["roo code"], "appIds": [] }, - { "name": "Qodo", "vendor": "Qodo", "category": "AI Coding", "risk": "Medium", "matchNames": ["qodo"], "appIds": [] }, - { "name": "Warp", "vendor": "Warp", "category": "AI Coding", "risk": "Medium", "matchNames": ["warp.dev"], "appIds": [] }, - { "name": "Visual Studio Code", "vendor": "Microsoft", "category": "AI-Capable Editor", "risk": "Informational", "matchNames": ["visual studio code", "vscode"], "appIds": [] }, - { "name": "JetBrains AI", "vendor": "JetBrains", "category": "AI-Capable Editor", "risk": "Low", "matchNames": ["jetbrains ai"], "appIds": [] }, - { "name": "Ollama", "vendor": "Ollama", "category": "Local AI Runtime", "risk": "Medium", "matchNames": ["ollama"], "appIds": [] }, - { "name": "LM Studio", "vendor": "LM Studio", "category": "Local AI Runtime", "risk": "Medium", "matchNames": ["lm studio", "lmstudio"], "appIds": [] }, - { "name": "GPT4All", "vendor": "Nomic AI", "category": "Local AI Runtime", "risk": "Medium", "matchNames": ["gpt4all"], "appIds": [] }, - { "name": "Jan", "vendor": "Jan", "category": "Local AI Runtime", "risk": "Low", "matchNames": ["jan.ai"], "appIds": [] }, - { "name": "Open WebUI", "vendor": "Open WebUI", "category": "Local AI Runtime", "risk": "Medium", "matchNames": ["open webui"], "appIds": [] }, - { "name": "AnythingLLM", "vendor": "Mintplex Labs", "category": "Local AI Runtime", "risk": "Medium", "matchNames": ["anythingllm"], "appIds": [] }, - { "name": "Oobabooga", "vendor": "Community", "category": "Local AI Runtime", "risk": "Medium", "matchNames": ["oobabooga"], "appIds": [] }, - { "name": "KoboldCpp", "vendor": "Community", "category": "Local AI Runtime", "risk": "Medium", "matchNames": ["koboldcpp"], "appIds": [] }, - { "name": "LocalAI", "vendor": "LocalAI", "category": "Local AI Runtime", "risk": "Medium", "matchNames": ["localai"], "appIds": [] }, - { "name": "Msty", "vendor": "Msty", "category": "Local AI Runtime", "risk": "Medium", "matchNames": ["msty"], "appIds": [] }, - { "name": "OpenClaw / Claw", "vendor": "Community", "category": "AI Agent & Automation", "risk": "High", "matchNames": ["claw"], "appIds": [] }, - { "name": "AutoGPT", "vendor": "Significant Gravitas", "category": "AI Agent & Automation", "risk": "High", "matchNames": ["autogpt"], "appIds": [] }, - { "name": "AgentGPT", "vendor": "Reworkd", "category": "AI Agent & Automation", "risk": "High", "matchNames": ["agentgpt"], "appIds": [] }, - { "name": "BabyAGI", "vendor": "Community", "category": "AI Agent & Automation", "risk": "High", "matchNames": ["babyagi"], "appIds": [] }, - { "name": "Manus", "vendor": "Monica", "category": "AI Agent & Automation", "risk": "High", "matchNames": ["manus ai", "manus.im"], "appIds": [] }, - { "name": "LangChain", "vendor": "LangChain", "category": "AI Platform & API", "risk": "Medium", "matchNames": ["langchain"], "appIds": [] }, - { "name": "LlamaIndex", "vendor": "LlamaIndex", "category": "AI Platform & API", "risk": "Medium", "matchNames": ["llamaindex"], "appIds": [] }, - { "name": "CrewAI", "vendor": "CrewAI", "category": "AI Agent & Automation", "risk": "High", "matchNames": ["crewai"], "appIds": [] }, - { "name": "n8n", "vendor": "n8n", "category": "AI Agent & Automation", "risk": "Medium", "matchNames": ["n8n"], "appIds": [] }, - { "name": "Zapier", "vendor": "Zapier", "category": "AI Agent & Automation", "risk": "Medium", "matchNames": ["zapier"], "appIds": [] }, - { "name": "Make", "vendor": "Make", "category": "AI Agent & Automation", "risk": "Medium", "matchNames": ["make.com"], "appIds": [] }, - { "name": "Lindy", "vendor": "Lindy", "category": "AI Agent & Automation", "risk": "High", "matchNames": ["lindy"], "appIds": [] }, - { "name": "Relevance AI", "vendor": "Relevance AI", "category": "AI Agent & Automation", "risk": "High", "matchNames": ["relevance ai"], "appIds": [] }, - { "name": "Dust", "vendor": "Dust", "category": "AI Agent & Automation", "risk": "Medium", "matchNames": ["dust.tt"], "appIds": [] }, - { "name": "Bardeen", "vendor": "Bardeen", "category": "AI Agent & Automation", "risk": "Medium", "matchNames": ["bardeen"], "appIds": [] }, - { "name": "Gumloop", "vendor": "Gumloop", "category": "AI Agent & Automation", "risk": "Medium", "matchNames": ["gumloop"], "appIds": [] }, - { "name": "Browse AI", "vendor": "Browse AI", "category": "AI Agent & Automation", "risk": "Medium", "matchNames": ["browse ai"], "appIds": [] }, - { "name": "Midjourney", "vendor": "Midjourney", "category": "AI Image & Design", "risk": "Medium", "matchNames": ["midjourney"], "appIds": [] }, - { "name": "Stable Diffusion", "vendor": "Stability AI", "category": "AI Image & Design", "risk": "Medium", "matchNames": ["stable diffusion", "stability ai"], "appIds": [] }, - { "name": "DALL-E", "vendor": "OpenAI", "category": "AI Image & Design", "risk": "Medium", "matchNames": ["dall-e", "dalle"], "appIds": [] }, - { "name": "Adobe Firefly", "vendor": "Adobe", "category": "AI Image & Design", "risk": "Low", "matchNames": ["firefly"], "appIds": [] }, - { "name": "Canva", "vendor": "Canva", "category": "AI Image & Design", "risk": "Informational", "matchNames": ["canva"], "appIds": [] }, - { "name": "Leonardo AI", "vendor": "Leonardo.Ai", "category": "AI Image & Design", "risk": "Medium", "matchNames": ["leonardo.ai", "leonardo ai"], "appIds": [] }, - { "name": "Ideogram", "vendor": "Ideogram", "category": "AI Image & Design", "risk": "Medium", "matchNames": ["ideogram"], "appIds": [] }, - { "name": "Krea", "vendor": "Krea", "category": "AI Image & Design", "risk": "Medium", "matchNames": ["krea.ai"], "appIds": [] }, - { "name": "ComfyUI", "vendor": "Comfy Org", "category": "AI Image & Design", "risk": "Medium", "matchNames": ["comfyui"], "appIds": [] }, - { "name": "Automatic1111", "vendor": "Community", "category": "AI Image & Design", "risk": "Medium", "matchNames": ["automatic1111"], "appIds": [] }, - { "name": "InvokeAI", "vendor": "Invoke", "category": "AI Image & Design", "risk": "Medium", "matchNames": ["invokeai"], "appIds": [] }, - { "name": "Remove.bg", "vendor": "Kaleido", "category": "AI Image & Design", "risk": "Medium", "matchNames": ["remove.bg"], "appIds": [] }, - { "name": "Photoroom", "vendor": "Photoroom", "category": "AI Image & Design", "risk": "Medium", "matchNames": ["photoroom"], "appIds": [] }, - { "name": "Synthesia", "vendor": "Synthesia", "category": "AI Video & Audio", "risk": "Medium", "matchNames": ["synthesia"], "appIds": [] }, - { "name": "HeyGen", "vendor": "HeyGen", "category": "AI Video & Audio", "risk": "Medium", "matchNames": ["heygen"], "appIds": [] }, - { "name": "RunwayML", "vendor": "Runway", "category": "AI Video & Audio", "risk": "Medium", "matchNames": ["runwayml", "runway ml"], "appIds": [] }, - { "name": "Descript", "vendor": "Descript", "category": "AI Video & Audio", "risk": "Medium", "matchNames": ["descript"], "appIds": [] }, - { "name": "OpusClip", "vendor": "OpusClip", "category": "AI Video & Audio", "risk": "Medium", "matchNames": ["opusclip", "opus clip"], "appIds": [] }, - { "name": "Veed", "vendor": "Veed", "category": "AI Video & Audio", "risk": "Medium", "matchNames": ["veed.io"], "appIds": [] }, - { "name": "Pika", "vendor": "Pika Labs", "category": "AI Video & Audio", "risk": "Medium", "matchNames": ["pika labs"], "appIds": [] }, - { "name": "Luma", "vendor": "Luma AI", "category": "AI Video & Audio", "risk": "Medium", "matchNames": ["luma ai", "lumalabs"], "appIds": [] }, - { "name": "Pictory", "vendor": "Pictory", "category": "AI Video & Audio", "risk": "Medium", "matchNames": ["pictory"], "appIds": [] }, - { "name": "InVideo", "vendor": "InVideo", "category": "AI Video & Audio", "risk": "Medium", "matchNames": ["invideo"], "appIds": [] }, - { "name": "CapCut", "vendor": "ByteDance", "category": "AI Video & Audio", "risk": "High", "matchNames": ["capcut"], "appIds": [] }, - { "name": "ElevenLabs", "vendor": "ElevenLabs", "category": "AI Video & Audio", "risk": "Medium", "matchNames": ["elevenlabs"], "appIds": [] }, - { "name": "Murf", "vendor": "Murf", "category": "AI Video & Audio", "risk": "Medium", "matchNames": ["murf"], "appIds": [] }, - { "name": "Play.ht", "vendor": "PlayHT", "category": "AI Video & Audio", "risk": "Medium", "matchNames": ["play.ht"], "appIds": [] }, - { "name": "Speechify", "vendor": "Speechify", "category": "AI Video & Audio", "risk": "Medium", "matchNames": ["speechify"], "appIds": [] }, - { "name": "Suno", "vendor": "Suno", "category": "AI Video & Audio", "risk": "Medium", "matchNames": ["suno"], "appIds": [] }, - { "name": "Otter.ai", "vendor": "Otter", "category": "AI Meeting Notetaker", "risk": "High", "matchNames": ["otter.ai"], "appIds": [] }, - { "name": "Fireflies.ai", "vendor": "Fireflies", "category": "AI Meeting Notetaker", "risk": "High", "matchNames": ["fireflies"], "appIds": [] }, - { "name": "Fathom", "vendor": "Fathom", "category": "AI Meeting Notetaker", "risk": "Medium", "matchNames": ["fathom"], "appIds": [] }, - { "name": "tl;dv", "vendor": "tldv", "category": "AI Meeting Notetaker", "risk": "Medium", "matchNames": ["tldv", "tl;dv"], "appIds": [] }, - { "name": "Read AI", "vendor": "Read", "category": "AI Meeting Notetaker", "risk": "Medium", "matchNames": ["read.ai"], "appIds": [] }, - { "name": "Krisp", "vendor": "Krisp", "category": "AI Meeting Notetaker", "risk": "Medium", "matchNames": ["krisp"], "appIds": [] }, - { "name": "Sembly", "vendor": "Sembly AI", "category": "AI Meeting Notetaker", "risk": "Medium", "matchNames": ["sembly"], "appIds": [] }, - { "name": "Avoma", "vendor": "Avoma", "category": "AI Meeting Notetaker", "risk": "Medium", "matchNames": ["avoma"], "appIds": [] }, - { "name": "MeetGeek", "vendor": "MeetGeek", "category": "AI Meeting Notetaker", "risk": "Medium", "matchNames": ["meetgeek"], "appIds": [] }, - { "name": "Supernormal", "vendor": "Supernormal", "category": "AI Meeting Notetaker", "risk": "Medium", "matchNames": ["supernormal"], "appIds": [] }, - { "name": "Granola", "vendor": "Granola", "category": "AI Meeting Notetaker", "risk": "Medium", "matchNames": ["granola"], "appIds": [] }, - { "name": "Grammarly", "vendor": "Grammarly", "category": "AI Writing & Translation", "risk": "Medium", "matchNames": ["grammarly"], "appIds": [] }, - { "name": "Jasper", "vendor": "Jasper", "category": "AI Writing & Translation", "risk": "Medium", "matchNames": ["jasper.ai"], "appIds": [] }, - { "name": "QuillBot", "vendor": "QuillBot", "category": "AI Writing & Translation", "risk": "Medium", "matchNames": ["quillbot"], "appIds": [] }, - { "name": "Wordtune", "vendor": "AI21 Labs", "category": "AI Writing & Translation", "risk": "Medium", "matchNames": ["wordtune"], "appIds": [] }, - { "name": "Copy.ai", "vendor": "Copy.ai", "category": "AI Writing & Translation", "risk": "Medium", "matchNames": ["copy.ai"], "appIds": [] }, - { "name": "Writesonic", "vendor": "Writesonic", "category": "AI Writing & Translation", "risk": "Medium", "matchNames": ["writesonic"], "appIds": [] }, - { "name": "Rytr", "vendor": "Rytr", "category": "AI Writing & Translation", "risk": "Medium", "matchNames": ["rytr"], "appIds": [] }, - { "name": "Sudowrite", "vendor": "Sudowrite", "category": "AI Writing & Translation", "risk": "Medium", "matchNames": ["sudowrite"], "appIds": [] }, - { "name": "HyperWrite", "vendor": "HyperWrite", "category": "AI Writing & Translation", "risk": "Medium", "matchNames": ["hyperwrite"], "appIds": [] }, - { "name": "Writer", "vendor": "Writer", "category": "AI Writing & Translation", "risk": "Low", "matchNames": ["writer.com"], "appIds": [] }, - { "name": "DeepL", "vendor": "DeepL", "category": "AI Writing & Translation", "risk": "Medium", "matchNames": ["deepl"], "appIds": [] }, - { "name": "Fyxer", "vendor": "Fyxer AI", "category": "AI Email", "risk": "High", "matchNames": ["fyxer"], "appIds": [] }, - { "name": "Superhuman", "vendor": "Superhuman", "category": "AI Email", "risk": "Medium", "matchNames": ["superhuman"], "appIds": [] }, - { "name": "Shortwave", "vendor": "Shortwave", "category": "AI Email", "risk": "Medium", "matchNames": ["shortwave"], "appIds": [] }, - { "name": "Lavender", "vendor": "Lavender", "category": "AI Email", "risk": "Medium", "matchNames": ["lavender"], "appIds": [] }, - { "name": "Glean", "vendor": "Glean", "category": "AI Search & Research", "risk": "Medium", "matchNames": ["glean"], "appIds": [] }, - { "name": "Phind", "vendor": "Phind", "category": "AI Search & Research", "risk": "Medium", "matchNames": ["phind"], "appIds": [] }, - { "name": "ChatPDF", "vendor": "ChatPDF", "category": "AI Search & Research", "risk": "High", "matchNames": ["chatpdf"], "appIds": [] }, - { "name": "AskYourPDF", "vendor": "AskYourPDF", "category": "AI Search & Research", "risk": "High", "matchNames": ["askyourpdf"], "appIds": [] }, - { "name": "SciSpace", "vendor": "SciSpace", "category": "AI Search & Research", "risk": "Medium", "matchNames": ["scispace"], "appIds": [] }, - { "name": "Gamma", "vendor": "Gamma", "category": "AI Presentation & Productivity", "risk": "Medium", "matchNames": ["gamma"], "appIds": [] }, - { "name": "Tome", "vendor": "Tome", "category": "AI Presentation & Productivity", "risk": "Medium", "matchNames": ["tome.app", "tome ai"], "appIds": [] }, - { "name": "Beautiful.ai", "vendor": "Beautiful.ai", "category": "AI Presentation & Productivity", "risk": "Medium", "matchNames": ["beautiful.ai"], "appIds": [] }, - { "name": "Notion", "vendor": "Notion", "category": "AI Presentation & Productivity", "risk": "Informational", "matchNames": ["notion"], "appIds": [] }, - { "name": "Coda", "vendor": "Coda", "category": "AI Presentation & Productivity", "risk": "Informational", "matchNames": ["coda"], "appIds": [] }, - { "name": "Mem", "vendor": "Mem", "category": "AI Presentation & Productivity", "risk": "Medium", "matchNames": ["mem.ai"], "appIds": [] }, - { "name": "Monica", "vendor": "Monica", "category": "AI Presentation & Productivity", "risk": "High", "matchNames": ["monica"], "appIds": [] }, - { "name": "Sider", "vendor": "Sider", "category": "AI Presentation & Productivity", "risk": "High", "matchNames": ["sider.ai"], "appIds": [] }, - { "name": "Merlin", "vendor": "Merlin", "category": "AI Presentation & Productivity", "risk": "High", "matchNames": ["merlin"], "appIds": [] }, - { "name": "MaxAI", "vendor": "MaxAI", "category": "AI Presentation & Productivity", "risk": "High", "matchNames": ["maxai"], "appIds": [] }, - { "name": "Hugging Face", "vendor": "Hugging Face", "category": "AI Platform & API", "risk": "Low", "matchNames": ["hugging face", "huggingface"], "appIds": [] }, - { "name": "Replicate", "vendor": "Replicate", "category": "AI Platform & API", "risk": "Medium", "matchNames": ["replicate"], "appIds": [] }, - { "name": "OpenRouter", "vendor": "OpenRouter", "category": "AI Platform & API", "risk": "High", "matchNames": ["openrouter"], "appIds": [] }, - { "name": "Together AI", "vendor": "Together AI", "category": "AI Platform & API", "risk": "Medium", "matchNames": ["together ai", "together.ai"], "appIds": [] }, - { "name": "Groq", "vendor": "Groq", "category": "AI Platform & API", "risk": "Medium", "matchNames": ["groq"], "appIds": [] }, - { "name": "Fireworks AI", "vendor": "Fireworks AI", "category": "AI Platform & API", "risk": "Medium", "matchNames": ["fireworks ai"], "appIds": [] }, - { "name": "Cohere", "vendor": "Cohere", "category": "AI Platform & API", "risk": "Medium", "matchNames": ["cohere"], "appIds": [] }, - { "name": "AI21 Labs", "vendor": "AI21 Labs", "category": "AI Platform & API", "risk": "Medium", "matchNames": ["ai21"], "appIds": [] }, - { "name": "Databricks", "vendor": "Databricks", "category": "AI Platform & API", "risk": "Informational", "matchNames": ["databricks"], "appIds": [] }, - { "name": "Weights & Biases", "vendor": "Weights & Biases", "category": "AI Platform & API", "risk": "Low", "matchNames": ["weights & biases", "wandb"], "appIds": [] }, - { "name": "Gong", "vendor": "Gong", "category": "AI Business Apps", "risk": "Medium", "matchNames": ["gong"], "appIds": [] }, - { "name": "Apollo.io", "vendor": "Apollo", "category": "AI Business Apps", "risk": "Medium", "matchNames": ["apollo.io"], "appIds": [] }, - { "name": "Regie.ai", "vendor": "Regie.ai", "category": "AI Business Apps", "risk": "Medium", "matchNames": ["regie"], "appIds": [] }, - { "name": "Intercom Fin", "vendor": "Intercom", "category": "AI Business Apps", "risk": "Low", "matchNames": ["intercom"], "appIds": [] }, - { "name": "Salesforce Einstein", "vendor": "Salesforce", "category": "AI Business Apps", "risk": "Informational", "matchNames": ["einstein"], "appIds": [] }, - { "name": "ServiceNow Now Assist", "vendor": "ServiceNow", "category": "AI Business Apps", "risk": "Informational", "matchNames": ["now assist"], "appIds": [] }, - { "name": "Atlassian Rovo", "vendor": "Atlassian", "category": "AI Business Apps", "risk": "Informational", "matchNames": ["rovo"], "appIds": [] }, - { "name": "Moveworks", "vendor": "Moveworks", "category": "AI Business Apps", "risk": "Low", "matchNames": ["moveworks"], "appIds": [] }, - { "name": "HireVue", "vendor": "HireVue", "category": "AI Business Apps", "risk": "Medium", "matchNames": ["hirevue"], "appIds": [] }, - { "name": "Eightfold", "vendor": "Eightfold AI", "category": "AI Business Apps", "risk": "Medium", "matchNames": ["eightfold"], "appIds": [] }, - { "name": "Harvey", "vendor": "Harvey", "category": "AI Business Apps", "risk": "Medium", "matchNames": ["harvey.ai", "harvey ai"], "appIds": [] }, - { "name": "Spellbook", "vendor": "Spellbook", "category": "AI Business Apps", "risk": "Medium", "matchNames": ["spellbook"], "appIds": [] }, - { "name": "DataRobot", "vendor": "DataRobot", "category": "AI Business Apps", "risk": "Medium", "matchNames": ["datarobot"], "appIds": [] }, - { "name": "H2O.ai", "vendor": "H2O.ai", "category": "AI Business Apps", "risk": "Medium", "matchNames": ["h2o.ai", "h2o ai"], "appIds": [] }, - { "name": "Julius AI", "vendor": "Julius", "category": "AI Business Apps", "risk": "High", "matchNames": ["julius ai"], "appIds": [] }, - { "name": "Vapi", "vendor": "Vapi", "category": "AI Business Apps", "risk": "Medium", "matchNames": ["vapi"], "appIds": [] }, - { "name": "Retell AI", "vendor": "Retell", "category": "AI Business Apps", "risk": "Medium", "matchNames": ["retell ai"], "appIds": [] }, - { "name": "Voiceflow", "vendor": "Voiceflow", "category": "AI Business Apps", "risk": "Medium", "matchNames": ["voiceflow"], "appIds": [] }, - { "name": "Botpress", "vendor": "Botpress", "category": "AI Business Apps", "risk": "Medium", "matchNames": ["botpress"], "appIds": [] }, - { "name": "Chatbase", "vendor": "Chatbase", "category": "AI Business Apps", "risk": "High", "matchNames": ["chatbase"], "appIds": [] }, - { "name": "StackAI", "vendor": "StackAI", "category": "AI Business Apps", "risk": "Medium", "matchNames": ["stackai", "stack ai"], "appIds": [] } + { "name": "Azure OpenAI", "vendor": "Microsoft", "category": "AI Platform & API", "risk": "Low", "matchNames": ["azure openai"], "appIds": [], "description": "Microsoft-hosted OpenAI models (GPT, DALL-E, Whisper) delivered as an Azure service inside your own subscription.", "riskReason": "Runs inside the organization's Azure tenant under Microsoft's enterprise terms: prompts and completions are not used to train models, data stays in the selected region, and access is controlled through Entra ID." }, + { "name": "GitHub Copilot", "vendor": "GitHub", "category": "AI Coding", "risk": "Medium", "matchNames": ["github copilot"], "appIds": [], "description": "AI pair programmer from GitHub that suggests code and answers questions inside the editor.", "riskReason": "Business and Enterprise plans exclude code from model training, but the free and individual tiers are common on unmanaged accounts and send proprietary source code under consumer terms." }, + { "name": "OpenAI Codex", "vendor": "OpenAI", "category": "AI Coding", "risk": "High", "matchNames": ["codex"], "appIds": [], "description": "OpenAI's autonomous coding agent that clones repositories and performs multi-step programming tasks.", "riskReason": "Requires deep repository access and executes code autonomously; when used through personal OpenAI accounts, proprietary source and secrets leave the organization with no contractual protection." }, + { "name": "OpenAI Sora", "vendor": "OpenAI", "category": "AI Video & Audio", "risk": "Medium", "matchNames": ["sora"], "appIds": [], "description": "OpenAI's text-to-video generator for producing short video clips from prompts.", "riskReason": "Consumer-account driven with limited business controls; uploaded reference footage and prompts are processed under consumer terms, and generated media carries brand-misuse and deepfake potential." }, + { "name": "ChatGPT", "vendor": "OpenAI", "category": "AI Assistant", "risk": "High", "matchNames": ["chatgpt","openai"], "appIds": [], "description": "OpenAI's chat assistant, the most widely used general-purpose AI tool for drafting, analysis and coding.", "riskReason": "Free and Plus accounts train on conversations by default and are almost always personal accounts, so any customer data, credentials or source pasted in leaves the organization's control permanently. Enterprise and Team plans address this but are rarely what is detected." }, + { "name": "Claude", "vendor": "Anthropic", "category": "AI Assistant", "risk": "Medium", "matchNames": ["claude","anthropic"], "appIds": [], "description": "Anthropic's AI assistant for writing, analysis and coding, available via web, desktop and API.", "riskReason": "Anthropic does not train on business API data and offers enterprise controls, but the consumer app is typically used with personal accounts where uploaded files and chats sit outside organizational governance." }, + { "name": "NotebookLM", "vendor": "Google", "category": "AI Search & Research", "risk": "Low", "matchNames": ["notebooklm"], "appIds": [], "description": "Google's research assistant that answers questions grounded in documents you upload.", "riskReason": "Covered by Google Workspace terms when used with a work account: uploaded sources are not used to train models and stay within the Workspace data boundary." }, + { "name": "Google Gemini", "vendor": "Google", "category": "AI Assistant", "risk": "Medium", "matchNames": ["gemini"], "appIds": [], "description": "Google's AI assistant integrated with search and Workspace, with standalone web and mobile apps.", "riskReason": "Workspace-integrated use is contractually covered, but consumer Gemini accounts may use conversations for model improvement and human review, and personal-account use is what typically shows up in discovery." }, + { "name": "Google Antigravity", "vendor": "Google", "category": "AI Coding", "risk": "Medium", "matchNames": ["antigravity"], "appIds": [], "description": "Google's agent-first AI development environment where coding agents plan and execute tasks across editor, terminal and browser.", "riskReason": "Agents get broad workstation access including terminal and browser control; source code and task context are processed by Google models, typically under individual accounts rather than enterprise agreements." }, + { "name": "Vertex AI", "vendor": "Google", "category": "AI Platform & API", "risk": "Low", "matchNames": ["vertex ai"], "appIds": [], "description": "Google Cloud's managed machine-learning platform for building and deploying models, including Gemini APIs.", "riskReason": "An enterprise cloud service governed by Google Cloud terms: customer data is not used to train foundation models and access runs through the organization's GCP project and IAM." }, + { "name": "Microsoft Copilot", "vendor": "Microsoft", "category": "AI Assistant", "risk": "Low", "matchNames": ["copilot"], "appIds": [], "description": "Microsoft's AI assistant across Windows, Edge and Microsoft 365 applications.", "riskReason": "With commercial data protection under a work account, prompts and responses are not retained for training and inherit Microsoft 365 compliance boundaries, making it the default sanctioned option in most tenants." }, + { "name": "Perplexity", "vendor": "Perplexity AI", "category": "AI Assistant", "risk": "Medium", "matchNames": ["perplexity"], "appIds": [], "description": "AI answer engine that combines live web search with model-generated summaries and citations.", "riskReason": "Business-grade privacy exists on Enterprise Pro, but typical usage is free personal accounts where queries - which often contain client and project context - are retained and may improve the service." }, + { "name": "DeepSeek", "vendor": "DeepSeek", "category": "AI Assistant", "risk": "High", "matchNames": ["deepseek"], "appIds": [], "description": "Chinese AI assistant and model family known for strong reasoning at low cost.", "riskReason": "Data is processed and stored in China under Chinese jurisdiction, with documented weak safeguards and prompt data used for training; most Western regulators and enterprises treat any submitted content as disclosed." }, + { "name": "Grok", "vendor": "xAI", "category": "AI Assistant", "risk": "Medium", "matchNames": ["grok","x.ai"], "appIds": [], "description": "xAI's chatbot integrated with X (Twitter), with real-time access to platform content.", "riskReason": "Consumer-first tool tied to personal X accounts; conversations may be used for training and the platform's data handling and moderation posture remain immature compared to enterprise vendors." }, + { "name": "Mistral Le Chat", "vendor": "Mistral AI", "category": "AI Assistant", "risk": "Medium", "matchNames": ["mistral"], "appIds": [], "description": "Chat assistant from French AI vendor Mistral, backed by their open and commercial models.", "riskReason": "EU-based vendor with reasonable privacy posture and paid tiers that exclude training, but detected usage is usually free personal accounts outside any organizational agreement." }, + { "name": "Meta AI", "vendor": "Meta", "category": "AI Assistant", "risk": "Medium", "matchNames": ["meta ai","llama.cpp"], "appIds": [], "description": "Meta's assistant built on Llama models, embedded in WhatsApp, Instagram and standalone apps.", "riskReason": "Consumer product with no business tier: prompts flow into Meta's advertising-driven ecosystem and may be used for model training, with no enterprise controls or contractual protections." }, + { "name": "Qwen", "vendor": "Alibaba", "category": "AI Assistant", "risk": "High", "matchNames": ["qwen"], "appIds": [], "description": "Alibaba's AI assistant and open-model family (Tongyi Qianwen).", "riskReason": "Hosted services process data in China under Chinese data-access laws; submitted business content should be treated as disclosed to an uncontrolled third party." }, + { "name": "Kimi", "vendor": "Moonshot AI", "category": "AI Assistant", "risk": "High", "matchNames": ["kimi","moonshot ai"], "appIds": [], "description": "Moonshot AI's Chinese chat assistant known for very long document context windows.", "riskReason": "Its headline feature is uploading long documents - contracts, reports, specifications - to servers in China under Chinese jurisdiction, combining maximum data exposure with minimal legal recourse." }, + { "name": "Poe", "vendor": "Quora", "category": "AI Assistant", "risk": "Medium", "matchNames": ["poe by quora","quora"], "appIds": [], "description": "Quora's multi-model chat hub giving one subscription access to many AI models and community bots.", "riskReason": "Conversations pass through Quora and on to multiple third-party model providers with varying data policies, and community-created bots can capture inputs; there is no enterprise governance layer." }, + { "name": "You.com", "vendor": "You.com", "category": "AI Assistant", "risk": "Medium", "matchNames": ["you.com"], "appIds": [], "description": "AI search and chat platform offering multiple models with web-connected answers.", "riskReason": "Personal-subscription tool routing queries through multiple model backends; business data in prompts is handled under consumer terms without organizational visibility." }, + { "name": "Pi", "vendor": "Inflection AI", "category": "AI Assistant", "risk": "Medium", "matchNames": ["inflection"], "appIds": [], "description": "Inflection AI's conversational assistant focused on personal, empathetic dialogue.", "riskReason": "A consumer companion-style assistant with no business controls; conversations are retained by the vendor and any work content shared sits outside organizational governance." }, + { "name": "Character.AI", "vendor": "Character Technologies", "category": "AI Companion Chatbot", "risk": "High", "matchNames": ["character.ai","character ai"], "appIds": [], "description": "Platform for chatting with user-created AI personas and characters.", "riskReason": "Entertainment platform with no legitimate business use: conversations are retained and used for training, personas encourage oversharing, and its presence on work devices is a policy violation in most organizations." }, + { "name": "Candy.AI", "vendor": "Candy.AI", "category": "AI Companion Chatbot", "risk": "High", "matchNames": ["candy.ai"], "appIds": [], "description": "AI companion service for romantic and adult-oriented persona chat.", "riskReason": "Adult content service with aggressive data collection and no business justification; presence on managed devices is an HR and security concern rather than a productivity question." }, + { "name": "Janitor AI", "vendor": "JanitorAI", "category": "AI Companion Chatbot", "risk": "High", "matchNames": ["janitor ai","janitorai"], "appIds": [], "description": "Community platform for roleplay chat with user-created AI characters, largely adult-oriented.", "riskReason": "Unvetted community platform with adult content, opaque data handling and user-supplied API key patterns that leak credentials; no business use case exists." }, + { "name": "Crushon AI", "vendor": "Crushon", "category": "AI Companion Chatbot", "risk": "High", "matchNames": ["crushon"], "appIds": [], "description": "AI companion chat service focused on unfiltered romantic and adult conversations.", "riskReason": "Adult companion service with no enterprise controls or business purpose; conversations are retained by an anonymous operator and its presence signals acceptable-use policy violations." }, + { "name": "FlowGPT", "vendor": "FlowGPT", "category": "AI Companion Chatbot", "risk": "High", "matchNames": ["flowgpt"], "appIds": [], "description": "Community marketplace for sharing AI prompts and character bots across models.", "riskReason": "Community-run bots and prompt-sharing mean inputs may be visible to bot creators and the platform; content moderation is weak and there is no business tier or data protection agreement." }, + { "name": "Cursor", "vendor": "Anysphere", "category": "AI Coding", "risk": "Medium", "matchNames": ["cursor"], "appIds": [], "description": "AI-first code editor (VS Code fork) with deep codebase-aware chat and autonomous edit modes.", "riskReason": "Indexes and uploads codebase context to cloud models; Business plans offer privacy mode with no training, but individual subscriptions on personal accounts are the norm and put proprietary source outside company control." }, + { "name": "Codeium / Windsurf", "vendor": "Codeium", "category": "AI Coding", "risk": "Medium", "matchNames": ["codeium","windsurf"], "appIds": [], "description": "AI coding assistant and the Windsurf agentic editor from Codeium.", "riskReason": "Sends code context to cloud completion services; enterprise self-hosted options exist, but free individual tiers dominate detection and carry code exfiltration risk under consumer terms." }, + { "name": "Tabnine", "vendor": "Tabnine", "category": "AI Coding", "risk": "Medium", "matchNames": ["tabnine"], "appIds": [], "description": "Privacy-focused AI code completion tool supporting local and private deployments.", "riskReason": "Positions on privacy with zero-retention and private deployment options, but free personal use still sends code snippets to cloud services outside organizational agreements." }, + { "name": "Sourcegraph Cody", "vendor": "Sourcegraph", "category": "AI Coding", "risk": "Medium", "matchNames": ["sourcegraph","cody"], "appIds": [], "description": "Codebase-aware AI assistant from Sourcegraph that answers questions across large repositories.", "riskReason": "Enterprise deployments are well-governed, but free individual use grants a third party read access to whole repositories under personal accounts." }, + { "name": "Aider", "vendor": "Aider", "category": "AI Coding", "risk": "Medium", "matchNames": ["aider"], "appIds": [], "description": "Open-source terminal-based AI pair programmer that edits local git repositories.", "riskReason": "The tool itself is local, but it ships code to whichever model API key the user supplies - typically personal OpenAI or Anthropic keys with no organizational data terms." }, + { "name": "Amazon Q / CodeWhisperer", "vendor": "Amazon", "category": "AI Coding", "risk": "Medium", "matchNames": ["codewhisperer","amazon q"], "appIds": [], "description": "AWS's AI coding assistant and enterprise Q&A assistant for developers.", "riskReason": "Professional tiers under an AWS organization inherit enterprise terms, but Builder ID (personal) usage sends code to AWS outside the organization's agreements." }, + { "name": "Amazon Bedrock", "vendor": "Amazon", "category": "AI Platform & API", "risk": "Medium", "matchNames": ["bedrock"], "appIds": [], "description": "AWS managed service for accessing foundation models (Anthropic, Meta, Amazon) via API.", "riskReason": "An enterprise cloud service with no training on customer data, but usage detected outside a governed AWS account may indicate shadow API projects moving company data through personal accounts." }, + { "name": "Amazon Kiro", "vendor": "Amazon", "category": "AI Coding", "risk": "Medium", "matchNames": ["kiro"], "appIds": [], "description": "AWS's agentic AI development environment built around spec-driven coding.", "riskReason": "Agentic access to source and specs routed through AWS accounts; safe under organizational AWS agreements but frequently adopted via personal Builder IDs first." }, + { "name": "Replit", "vendor": "Replit", "category": "AI Coding", "risk": "High", "matchNames": ["replit"], "appIds": [], "description": "Browser-based development platform with AI agent that builds and deploys applications.", "riskReason": "Code lives on Replit's cloud by default and free-tier workspaces are public, so proprietary code pasted in becomes literally public; the AI agent also deploys with broad permissions." }, + { "name": "Lovable", "vendor": "Lovable", "category": "AI Coding", "risk": "High", "matchNames": ["lovable"], "appIds": [], "description": "AI app builder that generates full-stack applications from natural-language prompts.", "riskReason": "Prompt-to-app platforms receive business logic, data models and often production credentials during building; generated apps default to public hosting and personal accounts, bypassing all development governance." }, + { "name": "Bolt.new", "vendor": "StackBlitz", "category": "AI Coding", "risk": "High", "matchNames": ["bolt.new","stackblitz"], "appIds": [], "description": "StackBlitz's AI web-app builder that generates and runs full-stack projects in the browser.", "riskReason": "Project code and any secrets pasted during development are processed in the vendor's cloud under personal accounts; output ships to public hosting with no organizational review." }, + { "name": "v0", "vendor": "Vercel", "category": "AI Coding", "risk": "High", "matchNames": ["v0.dev"], "appIds": [], "description": "Vercel's AI UI generator that produces React components and full pages from prompts.", "riskReason": "Designs, internal UI patterns and business context flow to Vercel under personal accounts, and generated projects deploy publicly with one click, outside change control." }, + { "name": "Devin", "vendor": "Cognition", "category": "AI Coding", "risk": "High", "matchNames": ["devin ai","cognition labs"], "appIds": [], "description": "Cognition's autonomous AI software engineer that plans and completes entire development tasks.", "riskReason": "Requires standing access to repositories, credentials and infrastructure to work autonomously - an extremely broad grant to an external service, usually procured by individual developers rather than through security review." }, + { "name": "Continue", "vendor": "Continue", "category": "AI Coding", "risk": "Medium", "matchNames": ["continue.dev"], "appIds": [], "description": "Open-source AI coding assistant extension that connects editors to any model provider.", "riskReason": "The extension is local and configurable for private models, but in practice routes code to cloud APIs on personal keys, inheriting whatever data terms the chosen provider applies." }, + { "name": "Cline", "vendor": "Cline", "category": "AI Coding", "risk": "Medium", "matchNames": ["cline"], "appIds": [], "description": "Open-source autonomous coding agent for VS Code with terminal and file access.", "riskReason": "Executes commands and edits files autonomously while shipping repository context to user-supplied model APIs on personal keys; power and data exposure depend entirely on the individual's configuration." }, + { "name": "Roo Code", "vendor": "Roo Code", "category": "AI Coding", "risk": "Medium", "matchNames": ["roo code"], "appIds": [], "description": "Fork of Cline offering multi-mode autonomous coding agents in VS Code.", "riskReason": "Same profile as other agentic coding extensions: broad workspace access with code context sent to personal model API keys outside organizational terms." }, + { "name": "Qodo", "vendor": "Qodo", "category": "AI Coding", "risk": "Medium", "matchNames": ["qodo"], "appIds": [], "description": "AI code-review and test-generation platform (formerly CodiumAI).", "riskReason": "Enterprise tiers provide zero-retention guarantees, but individual free usage sends diffs and tests to the vendor cloud under personal accounts." }, + { "name": "Warp", "vendor": "Warp", "category": "AI Coding", "risk": "Medium", "matchNames": ["warp.dev"], "appIds": [], "description": "AI-enhanced terminal with command generation and agentic workflows.", "riskReason": "Terminal history and command context - which routinely contain hostnames, paths and secrets - are processed by cloud AI under individual accounts unless the enterprise tier with zero-retention is used." }, + { "name": "Visual Studio Code", "vendor": "Microsoft", "category": "AI-Capable Editor", "risk": "Informational", "matchNames": ["visual studio code","vscode"], "appIds": [], "description": "Microsoft's code editor; not an AI tool itself but the primary host for AI coding extensions.", "riskReason": "Listed for visibility only: the editor is standard software, but its presence indicates where AI coding extensions like Copilot, Cline or Continue may be running." }, + { "name": "JetBrains AI", "vendor": "JetBrains", "category": "AI-Capable Editor", "risk": "Low", "matchNames": ["jetbrains ai"], "appIds": [], "description": "AI assistant integrated into JetBrains IDEs with organizational controls.", "riskReason": "Offered through JetBrains accounts with enterprise licensing, no training on customer code and admin-controllable data sharing, making governed deployment straightforward." }, + { "name": "Ollama", "vendor": "Ollama", "category": "Local AI Runtime", "risk": "Medium", "matchNames": ["ollama"], "appIds": [], "description": "Local model runtime for running open-weight LLMs on the user's own machine.", "riskReason": "Data stays on-device which removes exfiltration risk, but it bypasses all monitoring, model outputs are ungoverned, and pulled models plus exposed local APIs create an unmanaged attack surface." }, + { "name": "LM Studio", "vendor": "LM Studio", "category": "Local AI Runtime", "risk": "Medium", "matchNames": ["lm studio","lmstudio"], "appIds": [], "description": "Desktop application for downloading and running local LLMs with a chat interface.", "riskReason": "Fully local processing avoids data leakage, but unvetted community models, an optional local server that other apps can call, and zero organizational visibility keep it in the review category." }, + { "name": "GPT4All", "vendor": "Nomic AI", "category": "Local AI Runtime", "risk": "Medium", "matchNames": ["gpt4all"], "appIds": [], "description": "Nomic's desktop app for running local open-source models, including local document Q&A.", "riskReason": "Local-first with optional telemetry; the data risk is low but local document indexing and unvetted models operate entirely outside IT visibility." }, + { "name": "Jan", "vendor": "Jan", "category": "Local AI Runtime", "risk": "Low", "matchNames": ["jan.ai"], "appIds": [], "description": "Open-source, offline-first desktop assistant running local models.", "riskReason": "Designed to run fully offline with local storage and no cloud dependency, giving it one of the smallest data-exposure footprints of any AI tool - the remaining concern is only ungoverned output." }, + { "name": "Open WebUI", "vendor": "Open WebUI", "category": "Local AI Runtime", "risk": "Medium", "matchNames": ["open webui"], "appIds": [], "description": "Self-hosted web interface for local and remote LLMs, often paired with Ollama.", "riskReason": "Self-hosted and private by design, but frequently configured with remote API keys and exposed as a network service without authentication hardening, quietly becoming unmanaged AI infrastructure." }, + { "name": "AnythingLLM", "vendor": "Mintplex Labs", "category": "Local AI Runtime", "risk": "Medium", "matchNames": ["anythingllm"], "appIds": [], "description": "Desktop and server app for chatting with documents using local or cloud models.", "riskReason": "Document ingestion is the core feature: with local models exposure is minimal, but many setups connect personal cloud API keys, sending indexed company documents to third parties." }, + { "name": "Oobabooga", "vendor": "Community", "category": "Local AI Runtime", "risk": "Medium", "matchNames": ["oobabooga"], "appIds": [], "description": "Community web UI for running and experimenting with local text-generation models.", "riskReason": "Hobbyist tooling on work devices: local processing limits data risk, but unvetted models and extensions and the experimentation culture around it fall outside any governance." }, + { "name": "KoboldCpp", "vendor": "Community", "category": "Local AI Runtime", "risk": "Medium", "matchNames": ["koboldcpp"], "appIds": [], "description": "Lightweight community runtime for local models, popular for storytelling and roleplay.", "riskReason": "Local execution keeps data on-device; primarily an indicator of personal/entertainment model use on managed hardware rather than a data-loss vector." }, + { "name": "LocalAI", "vendor": "LocalAI", "category": "Local AI Runtime", "risk": "Medium", "matchNames": ["localai"], "appIds": [], "description": "Self-hosted, OpenAI-compatible API server for running models locally.", "riskReason": "Keeps inference on-premises, but stands up an unmanaged OpenAI-compatible endpoint other tools and scripts can silently target, creating shadow AI infrastructure." }, + { "name": "Msty", "vendor": "Msty", "category": "Local AI Runtime", "risk": "Medium", "matchNames": ["msty"], "appIds": [], "description": "Desktop app that unifies local and cloud models in a single chat interface.", "riskReason": "Mixes local models with cloud providers behind one UI, so users may believe they are working locally while prompts actually flow to cloud APIs on personal keys." }, + { "name": "OpenClaw / Claw", "vendor": "Community", "category": "AI Agent & Automation", "risk": "High", "matchNames": ["claw"], "appIds": [], "description": "Community autonomous agent framework that chains model calls to complete tasks.", "riskReason": "Autonomous agents with tool access run unattended actions on the user's machine and accounts, with prompts and captured data sent to whatever model API is configured - unvetted code with broad permissions." }, + { "name": "AutoGPT", "vendor": "Significant Gravitas", "category": "AI Agent & Automation", "risk": "High", "matchNames": ["autogpt"], "appIds": [], "description": "Early autonomous GPT agent framework that self-directs multi-step tasks.", "riskReason": "Executes self-generated actions with file, browser and API access on personal OpenAI keys; unpredictable behavior plus broad local permissions make it high risk on any managed device." }, + { "name": "AgentGPT", "vendor": "Reworkd", "category": "AI Agent & Automation", "risk": "High", "matchNames": ["agentgpt"], "appIds": [], "description": "Browser-based autonomous agent platform from Reworkd.", "riskReason": "Goals and intermediate results are processed in the vendor's cloud on consumer accounts, and autonomous web actions run without organizational oversight." }, + { "name": "BabyAGI", "vendor": "Community", "category": "AI Agent & Automation", "risk": "High", "matchNames": ["babyagi"], "appIds": [], "description": "Minimal open-source task-driven autonomous agent experiment.", "riskReason": "Experimental agent code run from personal API keys with no controls; its presence indicates ungoverned agent experimentation on work devices." }, + { "name": "Manus", "vendor": "Monica", "category": "AI Agent & Automation", "risk": "High", "matchNames": ["manus ai","manus.im"], "appIds": [], "description": "Autonomous general-purpose AI agent from Monica (Butterfly Effect) that completes complex tasks in cloud VMs.", "riskReason": "Tasks, files and credentials are handed to autonomous cloud VMs operated by a Chinese-founded vendor with limited transparency; data jurisdiction and agent autonomy combine into high exposure." }, + { "name": "LangChain", "vendor": "LangChain", "category": "AI Platform & API", "risk": "Medium", "matchNames": ["langchain"], "appIds": [], "description": "Developer framework for building LLM applications and agent pipelines.", "riskReason": "The framework itself is neutral, but its presence means employees are building AI integrations that move company data to model APIs - typically on personal keys and without security review." }, + { "name": "LlamaIndex", "vendor": "LlamaIndex", "category": "AI Platform & API", "risk": "Medium", "matchNames": ["llamaindex"], "appIds": [], "description": "Framework for connecting private data sources to LLMs for retrieval-augmented generation.", "riskReason": "Purpose-built to index internal documents and feed them to models; unofficial projects using it move company data into embeddings and third-party APIs without governance." }, + { "name": "CrewAI", "vendor": "CrewAI", "category": "AI Agent & Automation", "risk": "High", "matchNames": ["crewai"], "appIds": [], "description": "Framework for orchestrating teams of autonomous AI agents.", "riskReason": "Multi-agent orchestration amplifies single-agent risk: several autonomous agents share data and take actions across tools on personal API keys, with no audit trail." }, + { "name": "n8n", "vendor": "n8n", "category": "AI Agent & Automation", "risk": "Medium", "matchNames": ["n8n"], "appIds": [], "description": "Workflow automation platform with extensive AI and LLM integration nodes.", "riskReason": "Self-hosted deployments can be well-governed, but cloud accounts wired to mailboxes, CRMs and LLM APIs move production data through personal automations that IT cannot see." }, + { "name": "Zapier", "vendor": "Zapier", "category": "AI Agent & Automation", "risk": "Medium", "matchNames": ["zapier"], "appIds": [], "description": "Cloud automation service connecting thousands of apps, now with AI steps and agents.", "riskReason": "Every zap is a standing data pipe between company systems and third parties under a personal account; adding AI steps sends that data onward to model providers." }, + { "name": "Make", "vendor": "Make", "category": "AI Agent & Automation", "risk": "Medium", "matchNames": ["make.com"], "appIds": [], "description": "Visual cloud automation platform (formerly Integromat) with AI modules.", "riskReason": "Same profile as other cloud automation: personal accounts holding OAuth grants into company mailboxes and files, now feeding content to AI modules outside governance." }, + { "name": "Lindy", "vendor": "Lindy", "category": "AI Agent & Automation", "risk": "High", "matchNames": ["lindy"], "appIds": [], "description": "No-code platform for building personal AI agents that handle email, scheduling and workflows.", "riskReason": "Agents are granted standing OAuth access to mailboxes and calendars and act autonomously on their content; a personal Lindy account is effectively an unmanaged delegate with inbox access." }, + { "name": "Relevance AI", "vendor": "Relevance AI", "category": "AI Agent & Automation", "risk": "High", "matchNames": ["relevance ai"], "appIds": [], "description": "Platform for building AI agent workforces for sales, support and operations tasks.", "riskReason": "Business processes and customer data are delegated to cloud agents configured by individuals; broad integrations plus autonomous outbound actions carry high exposure without contract review." }, + { "name": "Dust", "vendor": "Dust", "category": "AI Agent & Automation", "risk": "Medium", "matchNames": ["dust.tt"], "appIds": [], "description": "Platform for building internal AI assistants connected to company knowledge sources.", "riskReason": "Connecting Slack, Drive and Notion to a third-party assistant platform is exactly the kind of grant that needs vendor review; adopted properly it is governable, adopted personally it is a data pipeline." }, + { "name": "Bardeen", "vendor": "Bardeen", "category": "AI Agent & Automation", "risk": "Medium", "matchNames": ["bardeen"], "appIds": [], "description": "Browser extension that automates web tasks with AI, including scraping and form filling.", "riskReason": "A browser extension with page access on work sites: it can read whatever the user sees, including CRM and admin consoles, and sends context to cloud AI under personal accounts." }, + { "name": "Gumloop", "vendor": "Gumloop", "category": "AI Agent & Automation", "risk": "Medium", "matchNames": ["gumloop"], "appIds": [], "description": "No-code AI workflow builder for automating data processing and web tasks.", "riskReason": "Workflows routinely ingest spreadsheets and scraped business data into cloud AI processing under individual accounts, outside data-handling agreements." }, + { "name": "Browse AI", "vendor": "Browse AI", "category": "AI Agent & Automation", "risk": "Medium", "matchNames": ["browse ai"], "appIds": [], "description": "No-code web scraping and monitoring service with AI extraction.", "riskReason": "Primarily a scraping tool on personal accounts; risk centers on ungoverned collection of competitor or customer data and credentials saved for authenticated scraping." }, + { "name": "Midjourney", "vendor": "Midjourney", "category": "AI Image & Design", "risk": "Medium", "matchNames": ["midjourney"], "appIds": [], "description": "Leading AI image generator operated through Discord and web.", "riskReason": "Generations are public by default on standard plans and prompts may include product or campaign details; operation through personal Discord accounts leaves no organizational control." }, + { "name": "Stable Diffusion", "vendor": "Stability AI", "category": "AI Image & Design", "risk": "Medium", "matchNames": ["stable diffusion","stability ai"], "appIds": [], "description": "Open-source image generation model family from Stability AI, run locally or via services.", "riskReason": "Local use keeps data private but unmanaged; hosted services vary widely in data handling. Main concerns are ungoverned content generation and licensing of outputs." }, + { "name": "DALL-E", "vendor": "OpenAI", "category": "AI Image & Design", "risk": "Medium", "matchNames": ["dall-e","dalle"], "appIds": [], "description": "OpenAI's image generation model, accessed through ChatGPT and the API.", "riskReason": "Image prompts on personal OpenAI accounts fall under consumer terms; risk is moderate and mostly about prompt content and brand-safe usage of outputs." }, + { "name": "Adobe Firefly", "vendor": "Adobe", "category": "AI Image & Design", "risk": "Low", "matchNames": ["firefly"], "appIds": [], "description": "Adobe's image and design generation integrated into Creative Cloud.", "riskReason": "Trained on licensed content with commercial-use indemnification and delivered through managed Creative Cloud accounts, making it one of the safer generative design options." }, + { "name": "Canva", "vendor": "Canva", "category": "AI Image & Design", "risk": "Informational", "matchNames": ["canva"], "appIds": [], "description": "Design platform with embedded AI features (Magic Studio); primarily a standard business tool.", "riskReason": "Listed for visibility because of its embedded AI features; as a mainstream design platform commonly under business accounts, it is not in itself a shadow AI concern." }, + { "name": "Leonardo AI", "vendor": "Leonardo.Ai", "category": "AI Image & Design", "risk": "Medium", "matchNames": ["leonardo.ai","leonardo ai"], "appIds": [], "description": "AI image generation platform popular for marketing and game assets.", "riskReason": "Consumer accounts with community-visible generations by default; prompts and reference uploads sit under personal accounts outside brand and data governance." }, + { "name": "Ideogram", "vendor": "Ideogram", "category": "AI Image & Design", "risk": "Medium", "matchNames": ["ideogram"], "appIds": [], "description": "AI image generator known for accurate text rendering in images.", "riskReason": "Free tiers make generations public and usage is personal-account based; exposure is mostly prompt content and ungoverned brand asset creation." }, + { "name": "Krea", "vendor": "Krea", "category": "AI Image & Design", "risk": "Medium", "matchNames": ["krea.ai"], "appIds": [], "description": "Real-time AI image generation and enhancement studio.", "riskReason": "Uploaded reference images and brand assets are processed under personal consumer accounts with default sharing, outside organizational control." }, + { "name": "ComfyUI", "vendor": "Comfy Org", "category": "AI Image & Design", "risk": "Medium", "matchNames": ["comfyui"], "appIds": [], "description": "Node-based local interface for building image and video generation pipelines.", "riskReason": "Local generation keeps data on-device; the risk profile is unvetted community workflows and custom nodes executing arbitrary code on work machines." }, + { "name": "Automatic1111", "vendor": "Community", "category": "AI Image & Design", "risk": "Medium", "matchNames": ["automatic1111"], "appIds": [], "description": "Community web UI for running Stable Diffusion locally.", "riskReason": "Local processing with minimal data risk; concerns are community extensions running arbitrary code and ungoverned content generation on managed hardware." }, + { "name": "InvokeAI", "vendor": "Invoke", "category": "AI Image & Design", "risk": "Medium", "matchNames": ["invokeai"], "appIds": [], "description": "Professional-oriented local Stable Diffusion interface.", "riskReason": "Local-first generation with an optional server mode; low data exposure but operates entirely outside IT visibility on work devices." }, + { "name": "Remove.bg", "vendor": "Kaleido", "category": "AI Image & Design", "risk": "Medium", "matchNames": ["remove.bg"], "appIds": [], "description": "Web service that removes image backgrounds automatically.", "riskReason": "Every processed image is uploaded to the vendor's cloud; used casually with product shots or people photos under free accounts with retention rights." }, + { "name": "Photoroom", "vendor": "Photoroom", "category": "AI Image & Design", "risk": "Medium", "matchNames": ["photoroom"], "appIds": [], "description": "AI photo editing service for product imagery and background editing.", "riskReason": "Product and staff images are uploaded to consumer cloud processing; moderate exposure centered on image rights and retention under personal accounts." }, + { "name": "Synthesia", "vendor": "Synthesia", "category": "AI Video & Audio", "risk": "Medium", "matchNames": ["synthesia"], "appIds": [], "description": "AI video platform generating presenter-led videos from scripts with avatars.", "riskReason": "Scripts often contain internal training or product material and are processed in the vendor cloud; enterprise plans exist but individual accounts dominate casual adoption." }, + { "name": "HeyGen", "vendor": "HeyGen", "category": "AI Video & Audio", "risk": "Medium", "matchNames": ["heygen"], "appIds": [], "description": "AI video generation platform with talking avatars and video translation.", "riskReason": "Voice cloning and avatar creation from uploaded footage of real people raises consent and impersonation concerns; scripts and media are processed under personal accounts." }, + { "name": "RunwayML", "vendor": "Runway", "category": "AI Video & Audio", "risk": "Medium", "matchNames": ["runwayml","runway ml"], "appIds": [], "description": "Creative AI suite for video generation and editing (Gen-series models).", "riskReason": "Uploaded footage and creative material are processed in the vendor cloud on consumer plans; exposure is project confidentiality rather than structured data loss." }, + { "name": "Descript", "vendor": "Descript", "category": "AI Video & Audio", "risk": "Medium", "matchNames": ["descript"], "appIds": [], "description": "AI audio/video editor with transcription, overdub voice cloning and screen recording.", "riskReason": "Meeting and call recordings uploaded for editing contain conversations that were never cleared for third-party processing, and voice cloning adds impersonation risk." }, + { "name": "OpusClip", "vendor": "OpusClip", "category": "AI Video & Audio", "risk": "Medium", "matchNames": ["opusclip","opus clip"], "appIds": [], "description": "AI service that turns long videos into short social clips.", "riskReason": "Uploaded video content is processed under consumer accounts; moderate exposure limited to the confidentiality of the footage itself." }, + { "name": "Veed", "vendor": "Veed", "category": "AI Video & Audio", "risk": "Medium", "matchNames": ["veed.io"], "appIds": [], "description": "Browser-based AI video editing and subtitling service.", "riskReason": "Video and audio uploads processed in the vendor cloud on personal accounts; typical exposure is internal or client footage leaving controlled storage." }, + { "name": "Pika", "vendor": "Pika Labs", "category": "AI Video & Audio", "risk": "Medium", "matchNames": ["pika labs"], "appIds": [], "description": "Text-to-video and image-to-video generation service.", "riskReason": "Consumer generation service; prompts and reference uploads sit under personal accounts with default community visibility on lower tiers." }, + { "name": "Luma", "vendor": "Luma AI", "category": "AI Video & Audio", "risk": "Medium", "matchNames": ["luma ai","lumalabs"], "appIds": [], "description": "AI 3D capture and video generation (Dream Machine) from Luma AI.", "riskReason": "Uploaded captures and prompts processed under consumer terms; low-to-moderate business exposure unless internal environments or products are captured." }, + { "name": "Pictory", "vendor": "Pictory", "category": "AI Video & Audio", "risk": "Medium", "matchNames": ["pictory"], "appIds": [], "description": "AI service converting scripts and articles into narrated videos.", "riskReason": "Marketing scripts and internal content are uploaded to consumer cloud processing; exposure is content confidentiality under personal accounts." }, + { "name": "InVideo", "vendor": "InVideo", "category": "AI Video & Audio", "risk": "Medium", "matchNames": ["invideo"], "appIds": [], "description": "Template-driven AI video creation platform.", "riskReason": "Script and asset uploads under personal accounts; moderate confidentiality exposure typical of consumer creative tools." }, + { "name": "CapCut", "vendor": "ByteDance", "category": "AI Video & Audio", "risk": "High", "matchNames": ["capcut"], "appIds": [], "description": "ByteDance's video editor with AI features, tied to the TikTok ecosystem.", "riskReason": "Owned by ByteDance with data processed under Chinese jurisdiction and aggressive telemetry; uploaded footage and device data sit outside any acceptable enterprise boundary." }, + { "name": "ElevenLabs", "vendor": "ElevenLabs", "category": "AI Video & Audio", "risk": "Medium", "matchNames": ["elevenlabs"], "appIds": [], "description": "Leading AI voice generation and cloning platform.", "riskReason": "High-quality voice cloning from short samples creates real impersonation and fraud risk (vishing, fake executive audio); business tiers exist but personal accounts dominate." }, + { "name": "Murf", "vendor": "Murf", "category": "AI Video & Audio", "risk": "Medium", "matchNames": ["murf"], "appIds": [], "description": "AI voiceover generation service for narration and presentations.", "riskReason": "Scripts uploaded to consumer cloud processing; moderate exposure around content confidentiality, lower cloning risk than sample-based services." }, + { "name": "Play.ht", "vendor": "PlayHT", "category": "AI Video & Audio", "risk": "Medium", "matchNames": ["play.ht"], "appIds": [], "description": "AI text-to-speech and voice cloning API and studio.", "riskReason": "Voice cloning capability plus consumer-account usage carries impersonation risk and puts scripts under personal-account terms." }, + { "name": "Speechify", "vendor": "Speechify", "category": "AI Video & Audio", "risk": "Medium", "matchNames": ["speechify"], "appIds": [], "description": "Text-to-speech app that reads documents and web pages aloud.", "riskReason": "Documents are uploaded for narration - often internal reports read on commutes - placing their content under a consumer service's retention terms." }, + { "name": "Suno", "vendor": "Suno", "category": "AI Video & Audio", "risk": "Medium", "matchNames": ["suno"], "appIds": [], "description": "AI music generation service creating songs from text prompts.", "riskReason": "Low business-data exposure; concerns are personal use on work devices and unresolved copyright status of generated music used in company content." }, + { "name": "Otter.ai", "vendor": "Otter", "category": "AI Meeting Notetaker", "risk": "High", "matchNames": ["otter.ai"], "appIds": [], "description": "AI meeting transcription service with an auto-joining meeting bot.", "riskReason": "The bot joins and records entire meetings - including other parties who never consented - and stores full transcripts of confidential discussions under a personal account; recording-consent laws make this a legal exposure, not just a data one." }, + { "name": "Fireflies.ai", "vendor": "Fireflies", "category": "AI Meeting Notetaker", "risk": "High", "matchNames": ["fireflies"], "appIds": [], "description": "AI notetaker that records, transcribes and analyzes meetings across platforms.", "riskReason": "Standing calendar and meeting access with automatic recording; complete conversation archives including client calls accumulate in a third-party cloud under individual accounts." }, + { "name": "Fathom", "vendor": "Fathom", "category": "AI Meeting Notetaker", "risk": "Medium", "matchNames": ["fathom"], "appIds": [], "description": "Free AI meeting recorder and summarizer for Zoom, Meet and Teams.", "riskReason": "Same recording-consent and transcript-retention concerns as other notetakers, moderated by a clearer free-tier privacy stance; still individual-account driven." }, + { "name": "tl;dv", "vendor": "tldv", "category": "AI Meeting Notetaker", "risk": "Medium", "matchNames": ["tldv","tl;dv"], "appIds": [], "description": "Meeting recording and AI summary tool for Meet, Zoom and Teams.", "riskReason": "Records meetings under personal accounts with transcripts stored in the vendor cloud; consent and client-confidentiality obligations are routinely skipped." }, + { "name": "Read AI", "vendor": "Read", "category": "AI Meeting Notetaker", "risk": "Medium", "matchNames": ["read.ai"], "appIds": [], "description": "Meeting intelligence tool measuring engagement and generating summaries, expanding into email and messaging.", "riskReason": "Beyond recording meetings it requests mail and calendar scopes to score communications, concentrating a broad slice of workplace interaction data under a personal grant." }, + { "name": "Krisp", "vendor": "Krisp", "category": "AI Meeting Notetaker", "risk": "Medium", "matchNames": ["krisp"], "appIds": [], "description": "AI noise cancellation and meeting transcription running on the local device.", "riskReason": "Noise processing is local which is genuinely privacy-friendly; the optional cloud transcription and summaries carry standard notetaker exposure." }, + { "name": "Sembly", "vendor": "Sembly AI", "category": "AI Meeting Notetaker", "risk": "Medium", "matchNames": ["sembly"], "appIds": [], "description": "AI meeting assistant generating notes, tasks and insights.", "riskReason": "Meeting audio and transcripts processed in vendor cloud under individual accounts; standard notetaker consent and retention concerns." }, + { "name": "Avoma", "vendor": "Avoma", "category": "AI Meeting Notetaker", "risk": "Medium", "matchNames": ["avoma"], "appIds": [], "description": "Meeting lifecycle assistant with recording, transcription and revenue intelligence.", "riskReason": "Customer call recordings and CRM-linked conversation data in a third-party cloud; governable on business plans, risky as individual adoption." }, + { "name": "MeetGeek", "vendor": "MeetGeek", "category": "AI Meeting Notetaker", "risk": "Medium", "matchNames": ["meetgeek"], "appIds": [], "description": "Automatic meeting recorder with AI summaries and insights.", "riskReason": "Auto-join recording under personal accounts with cloud transcript retention; standard meeting-notetaker exposure." }, + { "name": "Supernormal", "vendor": "Supernormal", "category": "AI Meeting Notetaker", "risk": "Medium", "matchNames": ["supernormal"], "appIds": [], "description": "AI meeting notes tool integrated with calendars.", "riskReason": "Calendar access plus automatic meeting capture under individual accounts; transcripts of internal and client meetings accumulate outside governance." }, + { "name": "Granola", "vendor": "Granola", "category": "AI Meeting Notetaker", "risk": "Medium", "matchNames": ["granola"], "appIds": [], "description": "AI notepad that transcribes meetings locally from system audio without a bot.", "riskReason": "No visible bot means participants rarely know transcription is happening; notes and transcripts still sync to the vendor cloud under personal accounts, with consent risk hidden from other parties." }, + { "name": "Grammarly", "vendor": "Grammarly", "category": "AI Writing & Translation", "risk": "Medium", "matchNames": ["grammarly"], "appIds": [], "description": "AI writing assistant checking and rewriting text across every application.", "riskReason": "As a system-wide extension it reads nearly everything typed - emails, contracts, HR notes - and processes it in the vendor cloud; Business plans add controls, but free personal accounts are pervasive." }, + { "name": "Jasper", "vendor": "Jasper", "category": "AI Writing & Translation", "risk": "Medium", "matchNames": ["jasper.ai"], "appIds": [], "description": "AI marketing content generation platform.", "riskReason": "Campaign briefs and brand material are processed under vendor cloud terms; business-oriented vendor with acceptable terms, but individual accounts skip procurement review." }, + { "name": "QuillBot", "vendor": "QuillBot", "category": "AI Writing & Translation", "risk": "Medium", "matchNames": ["quillbot"], "appIds": [], "description": "AI paraphrasing and summarization tool popular for rewriting text.", "riskReason": "Users paste whole documents for rewriting into a free consumer service; content retention under personal accounts is the primary exposure." }, + { "name": "Wordtune", "vendor": "AI21 Labs", "category": "AI Writing & Translation", "risk": "Medium", "matchNames": ["wordtune"], "appIds": [], "description": "AI21's writing companion for rewriting and summarizing.", "riskReason": "Browser-extension text access plus consumer-account processing; pasted business text falls under personal-account terms." }, + { "name": "Copy.ai", "vendor": "Copy.ai", "category": "AI Writing & Translation", "risk": "Medium", "matchNames": ["copy.ai"], "appIds": [], "description": "AI copywriting and go-to-market content platform.", "riskReason": "Marketing and sales content processed on free or personal plans; moderate confidentiality exposure, business tiers available." }, + { "name": "Writesonic", "vendor": "Writesonic", "category": "AI Writing & Translation", "risk": "Medium", "matchNames": ["writesonic"], "appIds": [], "description": "AI content writing platform for articles and marketing copy.", "riskReason": "Standard consumer content-generation exposure: briefs and drafts under personal accounts in the vendor cloud." }, + { "name": "Rytr", "vendor": "Rytr", "category": "AI Writing & Translation", "risk": "Medium", "matchNames": ["rytr"], "appIds": [], "description": "Budget AI writing assistant for short-form content.", "riskReason": "Free consumer writing tool; pasted content is retained under consumer terms with no organizational controls." }, + { "name": "Sudowrite", "vendor": "Sudowrite", "category": "AI Writing & Translation", "risk": "Medium", "matchNames": ["sudowrite"], "appIds": [], "description": "AI creative writing tool aimed at fiction authors.", "riskReason": "Primarily personal creative use on work devices; business-data exposure is low but usage is entirely ungoverned." }, + { "name": "HyperWrite", "vendor": "HyperWrite", "category": "AI Writing & Translation", "risk": "Medium", "matchNames": ["hyperwrite"], "appIds": [], "description": "AI writing assistant with autonomous browser agent features.", "riskReason": "Beyond text generation, its browser agent can act on pages the user is signed into, extending consumer-account AI into authenticated business webapps." }, + { "name": "Writer", "vendor": "Writer", "category": "AI Writing & Translation", "risk": "Low", "matchNames": ["writer.com"], "appIds": [], "description": "Enterprise generative AI platform with brand and compliance guardrails.", "riskReason": "Built for enterprise deployment: no training on customer data, SOC 2 controls and admin governance; risk mainly arises if adopted as individual accounts instead of a managed rollout." }, + { "name": "DeepL", "vendor": "DeepL", "category": "AI Writing & Translation", "risk": "Medium", "matchNames": ["deepl"], "appIds": [], "description": "High-quality machine translation service with document upload.", "riskReason": "Whole documents - contracts, correspondence - are uploaded for translation; Pro guarantees deletion after translation, but free usage retains text for training under consumer terms." }, + { "name": "Fyxer", "vendor": "Fyxer AI", "category": "AI Email", "risk": "High", "matchNames": ["fyxer"], "appIds": [], "description": "AI executive assistant that drafts email replies and organizes inboxes.", "riskReason": "Requires full mailbox access and reads every message to draft replies - complete correspondence history including confidential threads flows to a young vendor, usually via an individual OAuth grant." }, + { "name": "Superhuman", "vendor": "Superhuman", "category": "AI Email", "risk": "Medium", "matchNames": ["superhuman"], "appIds": [], "description": "Premium email client with AI drafting and triage.", "riskReason": "Full mailbox access through a third-party client with AI processing; a credible vendor, but individual adoption grants inbox-wide access without organizational review." }, + { "name": "Shortwave", "vendor": "Shortwave", "category": "AI Email", "risk": "Medium", "matchNames": ["shortwave"], "appIds": [], "description": "AI-native email client built on Gmail with assistant-driven search and drafting.", "riskReason": "Inbox-wide access and AI processing of mail content under personal choice of client; same standing-mailbox-grant concern as other AI email tools." }, + { "name": "Lavender", "vendor": "Lavender", "category": "AI Email", "risk": "Medium", "matchNames": ["lavender"], "appIds": [], "description": "AI sales email coach that scores and improves outreach messages.", "riskReason": "Reads and analyzes sales correspondence via extension access; prospect data and messaging flow to the vendor under individual seats." }, + { "name": "Glean", "vendor": "Glean", "category": "AI Search & Research", "risk": "Medium", "matchNames": ["glean"], "appIds": [], "description": "Enterprise AI search across company applications and knowledge.", "riskReason": "A legitimate enterprise product whose entire function is indexing company data; properly deployed it is well-governed, but any non-IT-sanctioned connection of data sources is a serious grant." }, + { "name": "Phind", "vendor": "Phind", "category": "AI Search & Research", "risk": "Medium", "matchNames": ["phind"], "appIds": [], "description": "AI answer engine specialized for developers and technical questions.", "riskReason": "Developers paste error output and code snippets into a consumer web service; retention under personal accounts is the main exposure." }, + { "name": "ChatPDF", "vendor": "ChatPDF", "category": "AI Search & Research", "risk": "High", "matchNames": ["chatpdf"], "appIds": [], "description": "Web service for chatting with uploaded PDF documents.", "riskReason": "Its sole function is uploading documents - contracts, reports, financials - to an inexpensive consumer service with minimal transparency about retention and reuse; document exfiltration is the product." }, + { "name": "AskYourPDF", "vendor": "AskYourPDF", "category": "AI Search & Research", "risk": "High", "matchNames": ["askyourpdf"], "appIds": [], "description": "Document Q&A service for uploaded PDFs and files.", "riskReason": "Same profile as other document-chat services: business documents are uploaded wholesale to a small vendor under consumer terms with unclear retention." }, + { "name": "SciSpace", "vendor": "SciSpace", "category": "AI Search & Research", "risk": "Medium", "matchNames": ["scispace"], "appIds": [], "description": "AI research assistant for reading and explaining academic papers.", "riskReason": "Mostly public-paper analysis with moderate exposure; uploaded internal research or drafts would sit under consumer terms." }, + { "name": "Gamma", "vendor": "Gamma", "category": "AI Presentation & Productivity", "risk": "Medium", "matchNames": ["gamma"], "appIds": [], "description": "AI presentation and document builder generating decks from prompts.", "riskReason": "Business plans, strategy outlines and client material are pasted in to generate decks, all stored in the vendor cloud under personal accounts." }, + { "name": "Tome", "vendor": "Tome", "category": "AI Presentation & Productivity", "risk": "Medium", "matchNames": ["tome.app","tome ai"], "appIds": [], "description": "AI storytelling and presentation tool.", "riskReason": "Same as other AI deck builders: presentation content with business context processed and stored under consumer accounts." }, + { "name": "Beautiful.ai", "vendor": "Beautiful.ai", "category": "AI Presentation & Productivity", "risk": "Medium", "matchNames": ["beautiful.ai"], "appIds": [], "description": "AI-assisted presentation design platform.", "riskReason": "Deck content in vendor cloud under individual accounts; moderate confidentiality exposure, team plans available." }, + { "name": "Notion", "vendor": "Notion", "category": "AI Presentation & Productivity", "risk": "Informational", "matchNames": ["notion"], "appIds": [], "description": "Workspace platform with embedded Notion AI; primarily a standard business tool.", "riskReason": "Listed for visibility because of embedded AI features; as a mainstream workspace product it is a procurement question rather than shadow AI, unless personal workspaces hold company content." }, + { "name": "Coda", "vendor": "Coda", "category": "AI Presentation & Productivity", "risk": "Informational", "matchNames": ["coda"], "appIds": [], "description": "Document/database workspace with embedded AI; primarily a standard business tool.", "riskReason": "Included for visibility of its AI features; risk profile is that of a normal SaaS workspace, notable only when company data lives in personal accounts." }, + { "name": "Mem", "vendor": "Mem", "category": "AI Presentation & Productivity", "risk": "Medium", "matchNames": ["mem.ai"], "appIds": [], "description": "AI-organized note-taking app.", "riskReason": "Personal notes app that accumulates meeting notes and work context in a consumer cloud with AI processing under individual accounts." }, + { "name": "Monica", "vendor": "Monica", "category": "AI Presentation & Productivity", "risk": "High", "matchNames": ["monica"], "appIds": [], "description": "All-in-one AI browser extension bundling chat, reading and writing on any page.", "riskReason": "Browser extensions with page access can read everything the user views - webmail, CRM, admin panels - and send page content to cloud AI on consumer accounts; broad access with minimal transparency." }, + { "name": "Sider", "vendor": "Sider", "category": "AI Presentation & Productivity", "risk": "High", "matchNames": ["sider.ai"], "appIds": [], "description": "AI sidebar browser extension for chat, reading and writing on any page.", "riskReason": "Same profile as other AI sidebar extensions: standing read access to visited pages, content routed to multiple model backends under personal accounts." }, + { "name": "Merlin", "vendor": "Merlin", "category": "AI Presentation & Productivity", "risk": "High", "matchNames": ["merlin"], "appIds": [], "description": "Browser extension providing one-click AI access across models on any website.", "riskReason": "Page-content access across all browsing plus multi-provider routing under consumer accounts; high exposure surface with weak governance." }, + { "name": "MaxAI", "vendor": "MaxAI", "category": "AI Presentation & Productivity", "risk": "High", "matchNames": ["maxai"], "appIds": [], "description": "AI browser extension for summarizing, rewriting and chatting with page content.", "riskReason": "Reads and processes page content across authenticated business webapps, sending it to cloud models under a personal subscription." }, + { "name": "Hugging Face", "vendor": "Hugging Face", "category": "AI Platform & API", "risk": "Low", "matchNames": ["hugging face","huggingface"], "appIds": [], "description": "Platform hosting open models, datasets and AI apps (Spaces).", "riskReason": "Primarily a development resource with reasonable terms; main exposures are uploading company data to public Spaces or datasets, and unvetted model downloads." }, + { "name": "Replicate", "vendor": "Replicate", "category": "AI Platform & API", "risk": "Medium", "matchNames": ["replicate"], "appIds": [], "description": "API platform for running open-source models in the cloud.", "riskReason": "Developer usage on personal API keys sends inference data to third-party-hosted community models with variable provenance and terms." }, + { "name": "OpenRouter", "vendor": "OpenRouter", "category": "AI Platform & API", "risk": "High", "matchNames": ["openrouter"], "appIds": [], "description": "Aggregator API routing requests to many model providers through one key.", "riskReason": "One key fans prompts out to dozens of providers - including free routes that explicitly log and train on inputs - so data terms are effectively whichever backend was cheapest that day." }, + { "name": "Together AI", "vendor": "Together AI", "category": "AI Platform & API", "risk": "Medium", "matchNames": ["together ai","together.ai"], "appIds": [], "description": "Cloud platform for running and fine-tuning open-source models.", "riskReason": "Developer platform with acceptable commercial terms; individual API keys moving company data through personal accounts is the primary concern." }, + { "name": "Groq", "vendor": "Groq", "category": "AI Platform & API", "risk": "Medium", "matchNames": ["groq"], "appIds": [], "description": "Ultra-fast inference API for open models on custom hardware.", "riskReason": "Free-tier speed attracts personal-key experimentation; prompts flow under individual accounts outside organizational data terms." }, + { "name": "Fireworks AI", "vendor": "Fireworks AI", "category": "AI Platform & API", "risk": "Medium", "matchNames": ["fireworks ai"], "appIds": [], "description": "Inference platform for open and fine-tuned production models.", "riskReason": "Reasonable enterprise posture; risk is standard personal-API-key usage outside procurement and data agreements." }, + { "name": "Cohere", "vendor": "Cohere", "category": "AI Platform & API", "risk": "Medium", "matchNames": ["cohere"], "appIds": [], "description": "Enterprise-focused LLM provider for search, RAG and generation.", "riskReason": "Enterprise-oriented vendor with solid data terms; detected personal-key usage still bypasses organizational agreements." }, + { "name": "AI21 Labs", "vendor": "AI21 Labs", "category": "AI Platform & API", "risk": "Medium", "matchNames": ["ai21"], "appIds": [], "description": "LLM provider (Jamba, Jurassic) with APIs and writing products.", "riskReason": "Established vendor with business terms; individual API usage under personal accounts is the main gap." }, + { "name": "Databricks", "vendor": "Databricks", "category": "AI Platform & API", "risk": "Informational", "matchNames": ["databricks"], "appIds": [], "description": "Data and AI platform; AI features ride on the governed lakehouse deployment.", "riskReason": "An enterprise data platform procured and governed centrally; listed for visibility of its AI capabilities rather than as a shadow AI concern." }, + { "name": "Weights & Biases", "vendor": "Weights & Biases", "category": "AI Platform & API", "risk": "Low", "matchNames": ["weights & biases","wandb"], "appIds": [], "description": "ML experiment tracking and model registry platform.", "riskReason": "Developer tooling with enterprise terms; the main caution is experiment logs and datasets uploaded to personal free accounts." }, + { "name": "Gong", "vendor": "Gong", "category": "AI Business Apps", "risk": "Medium", "matchNames": ["gong"], "appIds": [], "description": "Revenue intelligence platform recording and analyzing sales calls.", "riskReason": "Records customer calls at scale; as a sanctioned deployment it is governed, but any individual or trial usage records conversations without proper consent chains." }, + { "name": "Apollo.io", "vendor": "Apollo", "category": "AI Business Apps", "risk": "Medium", "matchNames": ["apollo.io"], "appIds": [], "description": "Sales intelligence and engagement platform with AI outreach.", "riskReason": "Individual seats sync contacts and mailboxes for outreach; CRM-grade customer data accumulates under personal accounts, plus contributory data-sharing terms." }, + { "name": "Regie.ai", "vendor": "Regie.ai", "category": "AI Business Apps", "risk": "Medium", "matchNames": ["regie"], "appIds": [], "description": "AI sales prospecting and sequence generation platform.", "riskReason": "Prospect data and outreach content processed in vendor cloud under sales-team or individual accounts; moderate, procurement-level exposure." }, + { "name": "Intercom Fin", "vendor": "Intercom", "category": "AI Business Apps", "risk": "Low", "matchNames": ["intercom"], "appIds": [], "description": "Intercom's AI customer support agent answering on company knowledge.", "riskReason": "Deployed as part of a governed Intercom contract with enterprise data terms; a procurement item rather than typical shadow adoption." }, + { "name": "Salesforce Einstein", "vendor": "Salesforce", "category": "AI Business Apps", "risk": "Informational", "matchNames": ["einstein"], "appIds": [], "description": "AI layer inside Salesforce (Einstein/Agentforce); part of the governed CRM.", "riskReason": "Runs inside the organization's existing Salesforce trust boundary with contractual controls; listed for visibility, not as shadow AI." }, + { "name": "ServiceNow Now Assist", "vendor": "ServiceNow", "category": "AI Business Apps", "risk": "Informational", "matchNames": ["now assist"], "appIds": [], "description": "AI capabilities embedded in the ServiceNow platform.", "riskReason": "Part of a governed enterprise ITSM deployment with platform-level data terms; informational visibility only." }, + { "name": "Atlassian Rovo", "vendor": "Atlassian", "category": "AI Business Apps", "risk": "Informational", "matchNames": ["rovo"], "appIds": [], "description": "Atlassian's AI search and agents across Jira and Confluence.", "riskReason": "Ships inside the existing Atlassian cloud agreement and permission model; a licensing and configuration question rather than shadow AI." }, + { "name": "Moveworks", "vendor": "Moveworks", "category": "AI Business Apps", "risk": "Low", "matchNames": ["moveworks"], "appIds": [], "description": "Enterprise AI assistant automating IT and HR support.", "riskReason": "Sold and deployed as a governed enterprise product with admin controls and contractual data terms; low residual risk when centrally managed." }, + { "name": "HireVue", "vendor": "HireVue", "category": "AI Business Apps", "risk": "Medium", "matchNames": ["hirevue"], "appIds": [], "description": "AI-assisted video interviewing and candidate assessment platform.", "riskReason": "Processes candidate PII and interview recordings with algorithmic assessment - heavily regulated territory (GDPR, EEOC) that demands a governed deployment, not team-level adoption." }, + { "name": "Eightfold", "vendor": "Eightfold AI", "category": "AI Business Apps", "risk": "Medium", "matchNames": ["eightfold"], "appIds": [], "description": "AI talent intelligence platform for recruiting and workforce planning.", "riskReason": "Employee and candidate career data processed with AI matching; regulated HR data means contractual deployment is essential." }, + { "name": "Harvey", "vendor": "Harvey", "category": "AI Business Apps", "risk": "Medium", "matchNames": ["harvey.ai","harvey ai"], "appIds": [], "description": "AI legal assistant for research, review and drafting.", "riskReason": "Privileged legal documents and matters are processed in the vendor cloud; strong enterprise posture, but privilege and confidentiality demand firm-level agreements, not individual accounts." }, + { "name": "Spellbook", "vendor": "Spellbook", "category": "AI Business Apps", "risk": "Medium", "matchNames": ["spellbook"], "appIds": [], "description": "AI contract drafting and review add-in for Word.", "riskReason": "Contracts under negotiation - among the most sensitive documents an organization holds - are processed by the vendor; needs contractual deployment rather than personal trials." }, + { "name": "DataRobot", "vendor": "DataRobot", "category": "AI Business Apps", "risk": "Medium", "matchNames": ["datarobot"], "appIds": [], "description": "Automated machine learning platform for building predictive models.", "riskReason": "Training datasets uploaded to the platform often contain customer records; enterprise-grade vendor, but data-science teams adopting it without review move regulated data." }, + { "name": "H2O.ai", "vendor": "H2O.ai", "category": "AI Business Apps", "risk": "Medium", "matchNames": ["h2o.ai","h2o ai"], "appIds": [], "description": "Open-source and enterprise AutoML platform.", "riskReason": "Similar to other ML platforms: dataset uploads to cloud services under team accounts need data-governance review; local open-source use is lower risk." }, + { "name": "Julius AI", "vendor": "Julius", "category": "AI Business Apps", "risk": "High", "matchNames": ["julius ai"], "appIds": [], "description": "AI data analyst that answers questions about uploaded spreadsheets and datasets.", "riskReason": "Users upload raw business data - sales figures, customer lists, financials - to a consumer service for analysis; wholesale dataset exfiltration under personal accounts." }, + { "name": "Vapi", "vendor": "Vapi", "category": "AI Business Apps", "risk": "Medium", "matchNames": ["vapi"], "appIds": [], "description": "Developer platform for building AI voice agents for calls.", "riskReason": "Voice agents handle live customer calls and their recordings via developer accounts; consent, recording law and call-content retention need review before production use." }, + { "name": "Retell AI", "vendor": "Retell", "category": "AI Business Apps", "risk": "Medium", "matchNames": ["retell ai"], "appIds": [], "description": "Platform for building AI phone agents.", "riskReason": "Same profile as other voice-agent platforms: live call audio and transcripts processed under developer accounts, with telephony consent obligations." }, + { "name": "Voiceflow", "vendor": "Voiceflow", "category": "AI Business Apps", "risk": "Medium", "matchNames": ["voiceflow"], "appIds": [], "description": "Design and orchestration platform for chat and voice AI agents.", "riskReason": "Conversation flows and connected knowledge bases sit in the vendor cloud under team accounts; moderate exposure typical of agent-building SaaS." }, + { "name": "Botpress", "vendor": "Botpress", "category": "AI Business Apps", "risk": "Medium", "matchNames": ["botpress"], "appIds": [], "description": "Platform for building and deploying chatbots with LLM integration.", "riskReason": "Bot knowledge bases and conversation logs in vendor or self-hosted deployments; risk depends on hosting choice and what data the bots are fed." }, + { "name": "Chatbase", "vendor": "Chatbase", "category": "AI Business Apps", "risk": "High", "matchNames": ["chatbase"], "appIds": [], "description": "Service for building custom AI chatbots trained on uploaded company content.", "riskReason": "The setup flow is uploading internal documents and site content to train a hosted bot - company knowledge wholesale into a small vendor's cloud, then exposed through a public chat widget." }, + { "name": "StackAI", "vendor": "StackAI", "category": "AI Business Apps", "risk": "Medium", "matchNames": ["stackai","stack ai"], "appIds": [], "description": "No-code platform for building internal AI workflows and agents.", "riskReason": "Internal documents and workflow data connected to a third-party orchestration cloud under team accounts; needs vendor review before feeding it business systems." } ] diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ListShadowAI.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ListShadowAI.ps1 index 7701856fdbb00..ba68e810df751 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ListShadowAI.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ListShadowAI.ps1 @@ -77,17 +77,20 @@ function Invoke-ListShadowAI { if ($DeviceCount -eq 0 -and $App.managedDevices) { $DeviceCount = @($App.managedDevices).Count } $IsSanctioned = $SanctionedTools.ContainsKey($Match.name.ToLower()) $DetectedApps.Add([PSCustomObject]@{ - application = $App.displayName - aiTool = $Match.name - vendor = $Match.vendor - category = $Match.category - risk = if ($IsSanctioned) { 'Informational' } else { $Match.risk } - status = if ($IsSanctioned) { 'Sanctioned' } else { 'Unsanctioned' } - publisher = $App.publisher - version = $App.version - platform = if ([string]::IsNullOrWhiteSpace($App.platform)) { 'Unknown' } else { $App.platform } - deviceCount = $DeviceCount - managedDevices = @($App.managedDevices ?? @()) + application = $App.displayName + aiTool = $Match.name + vendor = $Match.vendor + category = $Match.category + risk = if ($IsSanctioned) { 'Informational' } else { $Match.risk } + catalogRisk = $Match.risk + status = if ($IsSanctioned) { 'Sanctioned' } else { 'Unsanctioned' } + toolDescription = $Match.description + riskReason = $Match.riskReason + publisher = $App.publisher + version = $App.version + platform = if ([string]::IsNullOrWhiteSpace($App.platform)) { 'Unknown' } else { $App.platform } + deviceCount = $DeviceCount + managedDevices = @($App.managedDevices ?? @()) }) } @@ -123,7 +126,10 @@ function Invoke-ListShadowAI { vendor = $Match.vendor category = $Match.category risk = if ($IsSanctioned) { 'Informational' } else { $Match.risk } + catalogRisk = $Match.risk status = if ($IsSanctioned) { 'Sanctioned' } else { 'Unsanctioned' } + toolDescription = $Match.description + riskReason = $Match.riskReason applicationId = $Sp.appId approvedPermissions = @($Permissions) firstConsentedDateTime = $Sp.createdDateTime From fe93fbd4b45ba804ad651a825b49b0062d53846d Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Wed, 8 Jul 2026 19:32:44 +0800 Subject: [PATCH 129/170] Self fix on read duplicate key values for KeepCopy, keepCopy and keepcopy --- .../Users/Invoke-ListUserSettings.ps1 | 52 ++++++++++++++----- 1 file changed, 39 insertions(+), 13 deletions(-) diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListUserSettings.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListUserSettings.ps1 index 147ad97b2e063..01282d8552c0c 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListUserSettings.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListUserSettings.ps1 @@ -15,15 +15,45 @@ function Invoke-ListUserSettings { try { $Table = Get-CippTable -tablename 'UserSettings' - $UserSettings = Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq 'UserSettings' and RowKey eq 'allUsers'" - if (!$UserSettings) { $UserSettings = Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq 'UserSettings' and RowKey eq '$Username'" } - - try { - $UserSettings = $UserSettings.JSON | ConvertFrom-Json -Depth 10 -ErrorAction SilentlyContinue - } catch { - Write-Warning "Failed to convert UserSettings JSON: $($_.Exception.Message)" + $ConvertUserSettings = { + param($Entity) + if (!$Entity -or !$Entity.JSON) { return $null } + try { + return $Entity.JSON | ConvertFrom-Json -Depth 10 -ErrorAction Stop + } catch { + Write-Warning "UserSettings JSON for '$($Entity.RowKey)' had a key case collision, self-healing: $($_.Exception.Message)" + try { + $Hash = $Entity.JSON | ConvertFrom-Json -Depth 10 -AsHashtable + if ($Hash.offboardingDefaults -is [System.Collections.IDictionary]) { + $Offboarding = $Hash.offboardingDefaults + $Variants = @($Offboarding.Keys | Where-Object { $_ -ieq 'keepCopy' }) + if ($Variants.Count -gt 0) { + $CanonicalValue = if ($Offboarding.Contains('KeepCopy')) { $Offboarding['KeepCopy'] } else { $Offboarding[$Variants[0]] } + foreach ($Variant in $Variants) { $null = $Offboarding.Remove($Variant) } + $Offboarding['KeepCopy'] = $CanonicalValue + } + } + $HealedJson = $Hash | ConvertTo-Json -Depth 10 -Compress + $Healed = $HealedJson | ConvertFrom-Json -Depth 10 -ErrorAction Stop + $HealTable = Get-CippTable -tablename 'UserSettings' + $HealTable.Force = $true + Add-CIPPAzDataTableEntity @HealTable -Entity @{ + JSON = "$HealedJson" + RowKey = "$($Entity.RowKey)" + PartitionKey = "$($Entity.PartitionKey)" + } + return $Healed + } catch { + Write-Warning "Failed to self-heal UserSettings JSON for '$($Entity.RowKey)': $($_.Exception.Message)" + return $null + } + } } + $UserSettingsEntity = Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq 'UserSettings' and RowKey eq 'allUsers'" + if (!$UserSettingsEntity) { $UserSettingsEntity = Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq 'UserSettings' and RowKey eq '$Username'" } + $UserSettings = & $ConvertUserSettings $UserSettingsEntity + if (!$UserSettings) { $UserSettings = [pscustomobject]@{ direction = 'ltr' @@ -38,12 +68,8 @@ function Invoke-ListUserSettings { } } - try { - $UserSpecificSettings = Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq 'UserSettings' and RowKey eq '$Username'" - $UserSpecificSettings = $UserSpecificSettings.JSON | ConvertFrom-Json -Depth 10 -ErrorAction SilentlyContinue - } catch { - Write-Warning "Failed to convert UserSpecificSettings JSON: $($_.Exception.Message)" - } + $UserSpecificEntity = Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq 'UserSettings' and RowKey eq '$Username'" + $UserSpecificSettings = & $ConvertUserSettings $UserSpecificEntity $TestOffboardingConfigured = { param($Offboarding) From be7b3a704f0de621144b666951c8c72119dc531d Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Wed, 8 Jul 2026 20:17:37 +0800 Subject: [PATCH 130/170] Fix for when editing a scheduled and no hidden param is sent from the frontend --- .../CIPP/Scheduler/Invoke-AddScheduledItem.ps1 | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Scheduler/Invoke-AddScheduledItem.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Scheduler/Invoke-AddScheduledItem.ps1 index 909c824a59047..bb5101bde039f 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Scheduler/Invoke-AddScheduledItem.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Scheduler/Invoke-AddScheduledItem.ps1 @@ -7,11 +7,6 @@ function Invoke-AddScheduledItem { #> [CmdletBinding()] param($Request, $TriggerMetadata) - if ($null -eq $Request.Query.hidden) { - $hidden = $false - } else { - $hidden = $true - } $DisallowDuplicateName = $Request.Query.DisallowDuplicateName ?? $Request.Body.DisallowDuplicateName @@ -25,6 +20,16 @@ function Invoke-AddScheduledItem { $ExistingTask = (Get-CIPPAzDataTableEntity @Table -Filter $Filter) } + if ($null -eq $Request.Query.hidden) { + if ($ExistingTask -and $null -ne $ExistingTask.Hidden) { + $hidden = [bool]$ExistingTask.Hidden + } else { + $hidden = $false + } + } else { + $hidden = $true + } + if ($ExistingTask -and $Request.Body.RunNow -eq $true) { # Clear ExecutedTime so the one-time task rerun guard in Push-ExecScheduledCommand does not block re-execution $null = Update-AzDataTableEntity -Force @Table -Entity @{ From c90254651259e6694f23e72efc513fe4641fd9b7 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Wed, 8 Jul 2026 20:59:37 +0800 Subject: [PATCH 131/170] Remove legacy formatting for antiphish standard --- .../Public/Standards/Invoke-CIPPStandardAntiPhishPolicy.ps1 | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardAntiPhishPolicy.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardAntiPhishPolicy.ps1 index 855f555caf193..c202a60b3ff68 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardAntiPhishPolicy.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardAntiPhishPolicy.ps1 @@ -312,8 +312,7 @@ function Invoke-CIPPStandardAntiPhishPolicy { } if ($Settings.report -eq $true) { - $FieldValue = $StateIsCorrect ? $true : $CurrentState - Set-CIPPStandardsCompareField -FieldName 'standards.AntiPhishPolicy' -FieldValue $FieldValue -TenantFilter $Tenant + Set-CIPPStandardsCompareField -FieldName 'standards.AntiPhishPolicy' -CurrentValue $CurrentValue -ExpectedValue $ExpectedValue -TenantFilter $Tenant Add-CIPPBPAField -FieldName 'AntiPhishPolicy' -FieldValue $StateIsCorrect -StoreAs bool -Tenant $Tenant } From ff91091df1a085feec45d12c31eeb953f4fa4af6 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Wed, 8 Jul 2026 12:04:04 -0400 Subject: [PATCH 132/170] fix: exclude .None permissions from permission check --- .../Public/Authentication/Test-CippApiClientRoleGrant.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Modules/CIPPCore/Public/Authentication/Test-CippApiClientRoleGrant.ps1 b/Modules/CIPPCore/Public/Authentication/Test-CippApiClientRoleGrant.ps1 index eeb49beeb8687..ddb6f89decd6d 100644 --- a/Modules/CIPPCore/Public/Authentication/Test-CippApiClientRoleGrant.ps1 +++ b/Modules/CIPPCore/Public/Authentication/Test-CippApiClientRoleGrant.ps1 @@ -102,7 +102,7 @@ function Test-CippApiClientRoleGrant { # Effective permissions a client holding this role would receive, computed the # same way Test-CIPPAccess evaluates an API client (single role, no base ceiling). $RolePermissions = @(Get-CippAllowedPermissions -UserRoles @($TargetRole)) - $Escalation = @($RolePermissions | Where-Object { $CallerPermissions -notcontains $_ }) + $Escalation = @($RolePermissions | Where-Object { $CallerPermissions -notcontains $_ -and $_ -notmatch 'None$' }) if ($Escalation.Count -gt 0) { return (New-Denial "You do not have sufficient permissions to manage an API client with the '$TargetRole' role; it grants permissions beyond your own.") From bc3230e725df650575eb17293096da5e636d4857 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Wed, 8 Jul 2026 12:06:38 -0400 Subject: [PATCH 133/170] fix: ensure sorted output for child values and handle comparison errors in Intune template --- Modules/CIPPCore/Public/Compare-CIPPIntuneObject.ps1 | 8 +++++--- .../Standards/Invoke-CIPPStandardIntuneTemplate.ps1 | 5 +++++ 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/Modules/CIPPCore/Public/Compare-CIPPIntuneObject.ps1 b/Modules/CIPPCore/Public/Compare-CIPPIntuneObject.ps1 index 7ea75b264fac1..c52306940cbbd 100644 --- a/Modules/CIPPCore/Public/Compare-CIPPIntuneObject.ps1 +++ b/Modules/CIPPCore/Public/Compare-CIPPIntuneObject.ps1 @@ -450,7 +450,7 @@ function Compare-CIPPIntuneObject { } $values.Add($displayValue) } - $childValue = $values -join ', ' + $childValue = ($values | Sort-Object) -join ', ' $results.Add([PSCustomObject]@{ Key = "GroupChild-$($child.settingDefinitionId)" @@ -466,7 +466,7 @@ function Compare-CIPPIntuneObject { foreach ($simpleValue in $child.simpleSettingCollectionValue) { $values.Add($simpleValue.value) } - $childValue = $values -join ', ' + $childValue = ($values | Sort-Object) -join ', ' $results.Add([PSCustomObject]@{ Key = "GroupChild-$($child.settingDefinitionId)" @@ -767,7 +767,9 @@ function Compare-CIPPIntuneObject { $key } - if ($refRawValue -ne $diffRawValue -or $null -eq $refRawValue -or $null -eq $diffRawValue) { + # Flag when values differ or the setting exists on only one side; a setting present on both sides with equal (even null) values is compliant + $presenceMismatch = ($null -eq $refItem) -xor ($null -eq $diffItem) + if ($refRawValue -ne $diffRawValue -or $presenceMismatch) { $result.Add([PSCustomObject]@{ Property = $label ExpectedValue = $refValue diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardIntuneTemplate.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardIntuneTemplate.ps1 index 8cf64f34ada05..2158e121f8c21 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardIntuneTemplate.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardIntuneTemplate.ps1 @@ -121,6 +121,11 @@ function Invoke-CIPPStandardIntuneTemplate { $JSONTemplate = $RawJSON | ConvertFrom-Json $Compare = Compare-CIPPIntuneObject -ReferenceObject $JSONTemplate -DifferenceObject $JSONExistingPolicy -compareType $TemplateType -ErrorAction SilentlyContinue } catch { + Write-LogMessage -API 'Standards' -tenant $Tenant -message "Failed to compare Intune Template $displayname against the existing policy: $($_.Exception.Message)" -sev 'Error' + $Compare = [pscustomobject]@{ + MatchFailed = $true + Difference = "Comparison failed: $($_.Exception.Message)" + } } Write-Information "[IntuneTemplate][$Tenant] Compare '$displayname': $([int]($sw.Elapsed - $lap).TotalMilliseconds)ms" $lap = $sw.Elapsed From 660a177d5e62b7f985d835c6541692644cc6166b Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Thu, 9 Jul 2026 00:45:22 +0800 Subject: [PATCH 134/170] Optionally set company branding for usernameHintText and signInPageText only if specified --- .../Standards/Invoke-CIPPStandardBranding.ps1 | 68 +++++++++---------- 1 file changed, 34 insertions(+), 34 deletions(-) diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardBranding.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardBranding.ps1 index 050f5d6043f6d..38986f49dd7d6 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardBranding.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardBranding.ps1 @@ -54,6 +54,22 @@ function Invoke-CIPPStandardBranding { $Localizations = New-GraphGetRequest -Uri "https://graph.microsoft.com/beta/organization/$($TenantId.customerId)/branding/localizations" -tenantID $Tenant -AsApp $true # Get layoutTemplateType value using null-coalescing operator $layoutTemplateType = $Settings.layoutTemplateType.value ?? $Settings.layoutTemplateType + + $SetSignInPageText = -not [string]::IsNullOrEmpty($Settings.signInPageText) + $SetUsernameHintText = -not [string]::IsNullOrEmpty($Settings.usernameHintText) + + $BrandingBody = [ordered]@{ + loginPageTextVisibilitySettings = [pscustomobject]@{ + hideAccountResetCredentials = $Settings.hideAccountResetCredentials + } + loginPageLayoutConfiguration = [pscustomobject]@{ + layoutTemplateType = $layoutTemplateType + isHeaderShown = $Settings.isHeaderShown + isFooterShown = $Settings.isFooterShown + } + } + if ($SetSignInPageText) { $BrandingBody['signInPageText'] = $Settings.signInPageText } + if ($SetUsernameHintText) { $BrandingBody['usernameHintText'] = $Settings.usernameHintText } # If default localization (id "0") exists, use that to get the currentState. Otherwise we have to create it first. if ($Localizations | Where-Object { $_.id -eq '0' }) { try { @@ -71,18 +87,7 @@ function Invoke-CIPPStandardBranding { AsApp = $true Type = 'POST' ContentType = 'application/json; charset=utf-8' - Body = [pscustomobject]@{ - signInPageText = $Settings.signInPageText - usernameHintText = $Settings.usernameHintText - loginPageTextVisibilitySettings = [pscustomobject]@{ - hideAccountResetCredentials = $Settings.hideAccountResetCredentials - } - loginPageLayoutConfiguration = [pscustomobject]@{ - layoutTemplateType = $layoutTemplateType - isHeaderShown = $Settings.isHeaderShown - isFooterShown = $Settings.isFooterShown - } - } | ConvertTo-Json -Compress + Body = [pscustomobject]$BrandingBody | ConvertTo-Json -Compress } $CurrentState = New-GraphPostRequest @GraphRequest } catch { @@ -92,22 +97,18 @@ function Invoke-CIPPStandardBranding { } } - $StateIsCorrect = ($CurrentState.signInPageText -eq $Settings.signInPageText) -and - ($CurrentState.usernameHintText -eq $Settings.usernameHintText) -and - ($CurrentState.loginPageTextVisibilitySettings.hideAccountResetCredentials -eq $Settings.hideAccountResetCredentials) -and + $StateIsCorrect = ($CurrentState.loginPageTextVisibilitySettings.hideAccountResetCredentials -eq $Settings.hideAccountResetCredentials) -and ($CurrentState.loginPageLayoutConfiguration.layoutTemplateType -eq $layoutTemplateType) -and ($CurrentState.loginPageLayoutConfiguration.isHeaderShown -eq $Settings.isHeaderShown) -and - ($CurrentState.loginPageLayoutConfiguration.isFooterShown -eq $Settings.isFooterShown) + ($CurrentState.loginPageLayoutConfiguration.isFooterShown -eq $Settings.isFooterShown) -and + (-not $SetSignInPageText -or ($CurrentState.signInPageText -eq $Settings.signInPageText)) -and + (-not $SetUsernameHintText -or ($CurrentState.usernameHintText -eq $Settings.usernameHintText)) - $CurrentValue = [PSCustomObject]@{ - signInPageText = $CurrentState.signInPageText - usernameHintText = $CurrentState.usernameHintText + $CurrentValue = [ordered]@{ loginPageTextVisibilitySettings = $CurrentState.loginPageTextVisibilitySettings | Select-Object -Property hideAccountResetCredentials loginPageLayoutConfiguration = $CurrentState.loginPageLayoutConfiguration | Select-Object -Property layoutTemplateType, isHeaderShown, isFooterShown } - $ExpectedValue = [PSCustomObject]@{ - signInPageText = $Settings.signInPageText - usernameHintText = $Settings.usernameHintText + $ExpectedValue = [ordered]@{ loginPageTextVisibilitySettings = [pscustomobject]@{ hideAccountResetCredentials = $Settings.hideAccountResetCredentials } @@ -117,6 +118,16 @@ function Invoke-CIPPStandardBranding { isFooterShown = $Settings.isFooterShown } } + if ($SetSignInPageText) { + $CurrentValue['signInPageText'] = $CurrentState.signInPageText + $ExpectedValue['signInPageText'] = $Settings.signInPageText + } + if ($SetUsernameHintText) { + $CurrentValue['usernameHintText'] = $CurrentState.usernameHintText + $ExpectedValue['usernameHintText'] = $Settings.usernameHintText + } + $CurrentValue = [pscustomobject]$CurrentValue + $ExpectedValue = [pscustomobject]$ExpectedValue if ($Settings.remediate -eq $true) { if ($StateIsCorrect -eq $true) { @@ -129,18 +140,7 @@ function Invoke-CIPPStandardBranding { AsApp = $true Type = 'PATCH' ContentType = 'application/json; charset=utf-8' - Body = [pscustomobject]@{ - signInPageText = $Settings.signInPageText - usernameHintText = $Settings.usernameHintText - loginPageTextVisibilitySettings = [pscustomobject]@{ - hideAccountResetCredentials = $Settings.hideAccountResetCredentials - } - loginPageLayoutConfiguration = [pscustomobject]@{ - layoutTemplateType = $layoutTemplateType - isHeaderShown = $Settings.isHeaderShown - isFooterShown = $Settings.isFooterShown - } - } | ConvertTo-Json -Compress + Body = [pscustomobject]$BrandingBody | ConvertTo-Json -Compress } $null = New-GraphPostRequest @GraphRequest Write-LogMessage -API 'Standards' -Tenant $Tenant -Message 'Successfully updated branding.' -Sev Info From 15cc4b45937a8f263a179464b0a9ce80e85e65d7 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Wed, 8 Jul 2026 12:50:56 -0400 Subject: [PATCH 135/170] feat: enhance role permission handling and introduce Get-CippHttpPermissions function fix issue with stale permissions causing escalation alerts on api client management --- .../Get-CIPPRolePermissions.ps1 | 16 +++++- .../Get-CippAllowedPermissions.ps1 | 22 +------- .../Get-CippHttpPermissions.ps1 | 50 +++++++++++++++++++ .../Test-CippApiClientRoleGrant.ps1 | 2 +- 4 files changed, 66 insertions(+), 24 deletions(-) create mode 100644 Modules/CIPPCore/Public/Authentication/Get-CippHttpPermissions.ps1 diff --git a/Modules/CIPPCore/Public/Authentication/Get-CIPPRolePermissions.ps1 b/Modules/CIPPCore/Public/Authentication/Get-CIPPRolePermissions.ps1 index 4b89c560b7594..dbc1b4759e68e 100644 --- a/Modules/CIPPCore/Public/Authentication/Get-CIPPRolePermissions.ps1 +++ b/Modules/CIPPCore/Public/Authentication/Get-CIPPRolePermissions.ps1 @@ -17,13 +17,25 @@ function Get-CIPPRolePermissions { $Filter = "RowKey eq '$RoleName'" $Role = Get-CIPPAzDataTableEntity @Table -Filter $Filter if ($Role) { - $Permissions = $Role.Permissions | ConvertFrom-Json + $Permissions = ($Role.Permissions | ConvertFrom-Json).PSObject.Properties.Value + # Stored permissions can reference endpoints removed or renamed in later CIPP + # versions; drop those so stale entries don't inflate the role's permission set + # (e.g. failing the Test-CippApiClientRoleGrant subset check). Skip filtering if + # the valid-permission universe can't be resolved, rather than emptying the role. + try { + $ValidPermissions = Get-CippHttpPermissions + if (@($ValidPermissions).Count -gt 0) { + $Permissions = @($Permissions | Where-Object { $ValidPermissions -contains $_ }) + } + } catch { + Write-Warning "Unable to resolve valid permissions to filter role '$RoleName': $($_.Exception.Message)" + } $AllowedTenants = if ($Role.AllowedTenants) { $Role.AllowedTenants | ConvertFrom-Json } else { @() } $BlockedTenants = if ($Role.BlockedTenants) { $Role.BlockedTenants | ConvertFrom-Json } else { @() } $BlockedEndpoints = if ($Role.BlockedEndpoints) { $Role.BlockedEndpoints | ConvertFrom-Json } else { @() } [PSCustomObject]@{ Role = $Role.RowKey - Permissions = $Permissions.PSObject.Properties.Value + Permissions = @($Permissions) AllowedTenants = @($AllowedTenants) BlockedTenants = @($BlockedTenants) BlockedEndpoints = @($BlockedEndpoints) diff --git a/Modules/CIPPCore/Public/Authentication/Get-CippAllowedPermissions.ps1 b/Modules/CIPPCore/Public/Authentication/Get-CippAllowedPermissions.ps1 index 0fd04bd57e070..7d7a245274222 100644 --- a/Modules/CIPPCore/Public/Authentication/Get-CippAllowedPermissions.ps1 +++ b/Modules/CIPPCore/Public/Authentication/Get-CippAllowedPermissions.ps1 @@ -22,30 +22,10 @@ function Get-CippAllowedPermissions { ) # Get all available permissions and base roles configuration - - $Version = if ($env:CIPPNG -eq 'true') { - $env:APP_VERSION - } else { - (Get-Content -Path (Join-Path $env:CIPPRootPath 'version_latest.txt')).Trim() - } $BaseRoles = Get-Content -Path (Join-Path $env:CIPPRootPath 'Config\cipp-roles.json') | ConvertFrom-Json $DefaultRoles = @('superadmin', 'admin', 'editor', 'readonly', 'anonymous', 'authenticated') - $AllPermissionCacheTable = Get-CIPPTable -tablename 'cachehttppermissions' - $AllPermissionsRow = Get-CIPPAzDataTableEntity @AllPermissionCacheTable -Filter "PartitionKey eq 'HttpFunctions' and RowKey eq 'HttpFunctions' and Version eq '$($Version)'" - - if (-not $AllPermissionsRow.Permissions) { - $AllPermissions = Get-CIPPHttpFunctions -ByRole | Select-Object -ExpandProperty Permission - $Entity = @{ - PartitionKey = 'HttpFunctions' - RowKey = 'HttpFunctions' - Version = [string]$Version - Permissions = [string]($AllPermissions | ConvertTo-Json -Compress) - } - Add-CIPPAzDataTableEntity @AllPermissionCacheTable -Entity $Entity -Force - } else { - $AllPermissions = $AllPermissionsRow.Permissions | ConvertFrom-Json - } + $AllPermissions = Get-CippHttpPermissions $AllowedPermissions = [System.Collections.Generic.List[string]]::new() diff --git a/Modules/CIPPCore/Public/Authentication/Get-CippHttpPermissions.ps1 b/Modules/CIPPCore/Public/Authentication/Get-CippHttpPermissions.ps1 new file mode 100644 index 0000000000000..599ce78189de4 --- /dev/null +++ b/Modules/CIPPCore/Public/Authentication/Get-CippHttpPermissions.ps1 @@ -0,0 +1,50 @@ +function Get-CippHttpPermissions { + <# + .SYNOPSIS + Returns the set of API permissions that exist on the current HTTP functions. + + .DESCRIPTION + Resolves the full permission universe for the running CIPP version from the + cachehttppermissions table, computing and caching it via Get-CIPPHttpFunctions + on a cache miss. Results are memoized in-process per version so hot paths + (Test-CIPPAccess, Get-CippAllowedPermissions) avoid repeated table reads. + + .OUTPUTS + [string[]] of valid permission names, e.g. 'Exchange.Mailbox.ReadWrite'. + + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param() + + $Version = if ($env:CIPPNG -eq 'true') { + $env:APP_VERSION + } else { + (Get-Content -Path (Join-Path $env:CIPPRootPath 'version_latest.txt')).Trim() + } + + if ($script:CippHttpPermissions -and $script:CippHttpPermissionsVersion -eq $Version) { + return $script:CippHttpPermissions + } + + $AllPermissionCacheTable = Get-CIPPTable -tablename 'cachehttppermissions' + $AllPermissionsRow = Get-CIPPAzDataTableEntity @AllPermissionCacheTable -Filter "PartitionKey eq 'HttpFunctions' and RowKey eq 'HttpFunctions' and Version eq '$($Version)'" + + if (-not $AllPermissionsRow.Permissions) { + $AllPermissions = Get-CIPPHttpFunctions -ByRole | Select-Object -ExpandProperty Permission + $Entity = @{ + PartitionKey = 'HttpFunctions' + RowKey = 'HttpFunctions' + Version = [string]$Version + Permissions = [string]($AllPermissions | ConvertTo-Json -Compress) + } + Add-CIPPAzDataTableEntity @AllPermissionCacheTable -Entity $Entity -Force + } else { + $AllPermissions = $AllPermissionsRow.Permissions | ConvertFrom-Json + } + + $script:CippHttpPermissions = @($AllPermissions) + $script:CippHttpPermissionsVersion = $Version + return $script:CippHttpPermissions +} diff --git a/Modules/CIPPCore/Public/Authentication/Test-CippApiClientRoleGrant.ps1 b/Modules/CIPPCore/Public/Authentication/Test-CippApiClientRoleGrant.ps1 index ddb6f89decd6d..eeb49beeb8687 100644 --- a/Modules/CIPPCore/Public/Authentication/Test-CippApiClientRoleGrant.ps1 +++ b/Modules/CIPPCore/Public/Authentication/Test-CippApiClientRoleGrant.ps1 @@ -102,7 +102,7 @@ function Test-CippApiClientRoleGrant { # Effective permissions a client holding this role would receive, computed the # same way Test-CIPPAccess evaluates an API client (single role, no base ceiling). $RolePermissions = @(Get-CippAllowedPermissions -UserRoles @($TargetRole)) - $Escalation = @($RolePermissions | Where-Object { $CallerPermissions -notcontains $_ -and $_ -notmatch 'None$' }) + $Escalation = @($RolePermissions | Where-Object { $CallerPermissions -notcontains $_ }) if ($Escalation.Count -gt 0) { return (New-Denial "You do not have sufficient permissions to manage an API client with the '$TargetRole' role; it grants permissions beyond your own.") From 23502b984c5dbe88a4d07ca553fd802e3656d85b Mon Sep 17 00:00:00 2001 From: John Duprey Date: Wed, 8 Jul 2026 16:20:57 -0400 Subject: [PATCH 136/170] feat: add functions to format alert cell values and normalize alert display rows --- .../Public/Send-CIPPScheduledTaskAlert.ps1 | 135 +++++++++++++++--- 1 file changed, 115 insertions(+), 20 deletions(-) diff --git a/Modules/CIPPCore/Public/Send-CIPPScheduledTaskAlert.ps1 b/Modules/CIPPCore/Public/Send-CIPPScheduledTaskAlert.ps1 index af288aa91d094..6f6f51c5ee2c9 100644 --- a/Modules/CIPPCore/Public/Send-CIPPScheduledTaskAlert.ps1 +++ b/Modules/CIPPCore/Public/Send-CIPPScheduledTaskAlert.ps1 @@ -36,6 +36,47 @@ function Send-CIPPScheduledTaskAlert { $Attachments ) + function Format-AlertCellValue { + <# + ConvertTo-Html stringifies non-primitive property values with .ToString(), + which renders nested collections/objects as .NET type names like + 'System.Collections.Generic.List`1[System.Object]'. Flatten them to + readable text instead. '[[BR]]' survives ConvertTo-Html's HTML encoding + and is swapped for
after the fragment is generated. + #> + param($Value, [int]$Depth = 0) + if ($null -eq $Value) { return '' } + if ($Value -is [string]) { return $Value } + if ($Value -is [datetime]) { return $Value.ToString('yyyy-MM-dd HH:mm:ss') } + if ($Value -is [bool] -or $Value.GetType().IsPrimitive -or $Value -is [decimal]) { return "$Value" } + if ($Depth -ge 3) { return "$(try { $Value | ConvertTo-Json -Compress -Depth 5 } catch { $Value })" } + if ($Value -is [System.Collections.IDictionary]) { + return (@($Value.GetEnumerator() | ForEach-Object { "$($_.Key): $(Format-AlertCellValue -Value $_.Value -Depth ($Depth + 1))" }) -join '[[BR]]') + } + if ($Value -is [System.Collections.IEnumerable]) { + return (@($Value | ForEach-Object { Format-AlertCellValue -Value $_ -Depth ($Depth + 1) }) -join '[[BR]]') + } + $Props = @($Value.PSObject.Properties) + if ($Props.Count -gt 0) { + return (@($Props | ForEach-Object { "$($_.Name): $(Format-AlertCellValue -Value $_.Value -Depth ($Depth + 1))" }) -join '[[BR]]') + } + return "$Value" + } + + function ConvertTo-AlertDisplayRow { + # Normalizes a result row for ConvertTo-Html: hashtables become objects (so they + # render as columns instead of Keys/Values/Count) and every value is flattened. + param($Row) + if ($null -eq $Row -or $Row -is [string]) { return $Row } + $Display = [ordered]@{} + if ($Row -is [System.Collections.IDictionary]) { + foreach ($Key in $Row.Keys) { $Display[[string]$Key] = Format-AlertCellValue -Value $Row[$Key] -Depth 1 } + } else { + foreach ($Prop in $Row.PSObject.Properties) { $Display[$Prop.Name] = Format-AlertCellValue -Value $Prop.Value -Depth 1 } + } + [pscustomobject]$Display + } + try { Write-Information "Sending post-execution alerts for task $($TaskInfo.Name)" @@ -56,12 +97,15 @@ function Send-CIPPScheduledTaskAlert { # Build HTML with adaptive table styling $TableDesign = '' + $EncodedTaskName = [System.Web.HttpUtility]::HtmlEncode($TaskInfo.Name) + $EncodedTenantName = [System.Web.HttpUtility]::HtmlEncode($TenantFilter) + $AlertHeader = "

$EncodedTaskName

Tenant: $EncodedTenantName

" $FinalResults = if ($Results -is [array] -and $Results[0] -is [string]) { $Results | ConvertTo-Html -Fragment -Property @{ l = 'Text'; e = { $_ } } } else { - $Results | ConvertTo-Html -Fragment + $Results | ForEach-Object { ConvertTo-AlertDisplayRow -Row $_ } | ConvertTo-Html -Fragment } - $HTML = $FinalResults -replace '
', "This alert is for tenant $TenantFilter.

$TableDesign
" | Out-String + $HTML = $FinalResults -replace '\[\[BR\]\]', '
' -replace '
', "$AlertHeader $TableDesign
" | Out-String # For alert tasks, add per-row snooze links to the email if ($TaskType -eq 'Alert' -and $Results -is [array] -and $Results.Count -gt 0 -and $Results[0] -isnot [string]) { @@ -147,16 +191,62 @@ function Send-CIPPScheduledTaskAlert { switch -wildcard ($TaskInfo.PostExecution) { '*psa*' { $PsaSplitSent = $false - if ($TaskType -eq 'Alert' -and $Results -is [array] -and $Results.Count -gt 0 -and $Results[0] -isnot [string]) { - try { - $ExtConfigTable = Get-CIPPTable -TableName Extensionsconfig - $ExtConfig = (Get-CIPPAzDataTableEntity @ExtConfigTable).config | ConvertFrom-Json -ErrorAction SilentlyContinue - $HaloConfig = $ExtConfig.HaloPSA - - # Per-task PsaTicketStrategy (configured on the alert) overrides the global - # HaloPSA.LinkTicketsToUsers toggle. Lets MSPs decide on a per-alert basis - # whether a wide alert (e.g. "users without MFA") should produce one ticket - # per user or one consolidated ticket per tenant. + $TaskAffectedUser = $null + try { + $ExtConfigTable = Get-CIPPTable -TableName Extensionsconfig + $ExtConfig = (Get-CIPPAzDataTableEntity @ExtConfigTable).config | ConvertFrom-Json -ErrorAction SilentlyContinue + $HaloConfig = $ExtConfig.HaloPSA + + if ($HaloConfig -and $HaloConfig.Enabled) { + # Resolve an affected user from the scheduled task's own parameters first. + # User-targeted tasks (Edit user, license changes, sign-in state) carry the + # user in Parameters while their results often have no UPN column. A UPN-like + # value maps to UPN, a GUID to AzureOID; New-CippExtAlert resolves the rest + # via Graph. Placeholder values (%userid%) from trigger tasks are skipped. + $UserUpn = $null + $UserOid = $null + $UserDisplay = $null + $GuidPattern = '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$' + + # Add/Edit user tasks nest the full target user in a UserObj parameter. + # The UPN is either stored directly or composed from username + domain + # (matching how New-CIPPUser/Set-CIPPUser build it). + $UserObj = $TaskParameters.UserObj + if ($UserObj) { + if ($UserObj.userPrincipalName -is [string] -and $UserObj.userPrincipalName -match '@') { + $UserUpn = $UserObj.userPrincipalName + } elseif ($UserObj.username -is [string] -and -not [string]::IsNullOrWhiteSpace($UserObj.username)) { + $UserDomain = if ($UserObj.Domain -is [string] -and $UserObj.Domain) { $UserObj.Domain } else { $UserObj.primDomain.value } + if ($UserDomain -is [string] -and -not [string]::IsNullOrWhiteSpace($UserDomain)) { $UserUpn = "$($UserObj.username)@$UserDomain" } + } + if ($UserObj.id -is [string] -and $UserObj.id -match $GuidPattern) { $UserOid = $UserObj.id } + if ($UserObj.displayName -is [string] -and -not [string]::IsNullOrWhiteSpace($UserObj.displayName)) { $UserDisplay = $UserObj.displayName } + } + + if (-not $UserUpn -and -not $UserOid) { + $ParamKeys = @('UserPrincipalName', 'UPN', 'username', 'user', 'userid', 'id') + foreach ($ParamKey in $ParamKeys) { + $ParamValue = $TaskParameters.$ParamKey + # Form fields store autocomplete selections as {label, value} objects. + if ($ParamValue -isnot [string] -and $null -ne $ParamValue.value -and $ParamValue.value -is [string]) { $ParamValue = $ParamValue.value } + if ($ParamValue -isnot [string] -or [string]::IsNullOrWhiteSpace($ParamValue) -or $ParamValue -match '%') { continue } + if (-not $UserUpn -and $ParamValue -match '@') { $UserUpn = $ParamValue } + elseif (-not $UserOid -and $ParamValue -match $GuidPattern) { $UserOid = $ParamValue } + } + } + + if ($UserUpn -or $UserOid) { + $TaskAffectedUser = [pscustomobject]@{ + UPN = $UserUpn + AzureOID = $UserOid + DisplayName = $UserDisplay + } + } + + # Per-task PsaTicketStrategy (configured on the task) overrides the global + # HaloPSA.LinkTicketsToUsers toggle. Lets MSPs decide on a per-task basis + # whether a wide result set (e.g. "users without MFA") should produce one + # ticket per user or one consolidated ticket per tenant. $TaskStrategy = $TaskInfo.PsaTicketStrategy $ShouldSplit = switch ($TaskStrategy) { 'split' { $true } @@ -164,7 +254,7 @@ function Send-CIPPScheduledTaskAlert { default { [bool]$HaloConfig.LinkTicketsToUsers } } - if ($HaloConfig -and $HaloConfig.Enabled -and $ShouldSplit) { + if ($ShouldSplit -and $Results -is [array] -and $Results.Count -gt 0 -and $Results[0] -isnot [string]) { $UpnFieldCandidates = @('UserPrincipalName', 'userPrincipalName', 'UPN', 'userId', 'Userkey') $RowProperties = $Results[0].PSObject.Properties.Name $UpnField = $UpnFieldCandidates | Where-Object { $_ -in $RowProperties } | Select-Object -First 1 @@ -177,12 +267,15 @@ function Send-CIPPScheduledTaskAlert { foreach ($Group in $Groups) { $GroupKey = $Group.Name - $GroupHTMLFragment = $Group.Group | ConvertTo-Html -Fragment - $GroupHTML = $GroupHTMLFragment -replace '
', "This alert is for tenant $TenantFilter.

$TableDesign
" | Out-String + $GroupHTMLFragment = $Group.Group | ForEach-Object { ConvertTo-AlertDisplayRow -Row $_ } | ConvertTo-Html -Fragment + $GroupHTML = $GroupHTMLFragment -replace '\[\[BR\]\]', '
' -replace '
', "$AlertHeader $TableDesign
" | Out-String if ([string]::IsNullOrWhiteSpace($GroupKey)) { - # Rows without a usable user identifier - send as a single tenant-scoped ticket. - Send-CIPPAlert -Type 'psa' -Title $title -HTMLContent $GroupHTML -TenantFilter $TenantFilter + # Rows without a usable user identifier - fall back to the + # task-level affected user if one was resolved. + $GroupParams = @{ Type = 'psa'; Title = $title; HTMLContent = $GroupHTML; TenantFilter = $TenantFilter } + if ($TaskAffectedUser) { $GroupParams.AffectedUser = $TaskAffectedUser } + Send-CIPPAlert @GroupParams } else { $GroupDisplayName = if ($DisplayField) { $Group.Group[0].$DisplayField } else { $null } $UserLabel = if ($GroupDisplayName) { "$GroupDisplayName ($GroupKey)" } else { $GroupKey } @@ -197,13 +290,15 @@ function Send-CIPPScheduledTaskAlert { $PsaSplitSent = $true } } - } catch { - Write-Information "Failed to split PSA alert by user, falling back to consolidated ticket: $($_.Exception.Message)" } + } catch { + Write-Information "Failed to resolve PSA affected user or split by user, falling back to consolidated ticket: $($_.Exception.Message)" } if (-not $PsaSplitSent) { - Send-CIPPAlert -Type 'psa' -Title $title -HTMLContent $HTML -TenantFilter $TenantFilter + $PsaParams = @{ Type = 'psa'; Title = $title; HTMLContent = $HTML; TenantFilter = $TenantFilter } + if ($TaskAffectedUser) { $PsaParams.AffectedUser = $TaskAffectedUser } + Send-CIPPAlert @PsaParams } } '*email*' { From faa1b841e31bcbafe49da3b365f1bc75c33be5f6 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Wed, 8 Jul 2026 23:04:51 -0400 Subject: [PATCH 137/170] fix: add member to upn field candidates --- Modules/CIPPCore/Public/Send-CIPPScheduledTaskAlert.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Modules/CIPPCore/Public/Send-CIPPScheduledTaskAlert.ps1 b/Modules/CIPPCore/Public/Send-CIPPScheduledTaskAlert.ps1 index 6f6f51c5ee2c9..c8ab3e2bf6664 100644 --- a/Modules/CIPPCore/Public/Send-CIPPScheduledTaskAlert.ps1 +++ b/Modules/CIPPCore/Public/Send-CIPPScheduledTaskAlert.ps1 @@ -255,7 +255,7 @@ function Send-CIPPScheduledTaskAlert { } if ($ShouldSplit -and $Results -is [array] -and $Results.Count -gt 0 -and $Results[0] -isnot [string]) { - $UpnFieldCandidates = @('UserPrincipalName', 'userPrincipalName', 'UPN', 'userId', 'Userkey') + $UpnFieldCandidates = @('UserPrincipalName', 'userPrincipalName', 'UPN', 'userId', 'Userkey', 'Member') $RowProperties = $Results[0].PSObject.Properties.Name $UpnField = $UpnFieldCandidates | Where-Object { $_ -in $RowProperties } | Select-Object -First 1 From 856a5c3d0e6655e8184844b6124b6356e7c5a226 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Thu, 9 Jul 2026 17:09:18 +0800 Subject: [PATCH 138/170] Feature Flag updates Hide backend links for hosted instances Hide offloading when not needed --- Config/FeatureFlags.json | 30 +++++++++++++++++++ .../CIPP/Core/Invoke-ListFeatureFlags.ps1 | 12 ++++++++ 2 files changed, 42 insertions(+) diff --git a/Config/FeatureFlags.json b/Config/FeatureFlags.json index bad3baf6e8117..e0b5e01998d69 100644 --- a/Config/FeatureFlags.json +++ b/Config/FeatureFlags.json @@ -71,5 +71,35 @@ "/cipp/advanced/diagnostics" ], "Hidden": true + }, + { + "Id": "BackendSettings", + "Name": "Backend Settings", + "Description": "Backend settings page showing Azure resource URLs. Hidden on hosted instances.", + "Enabled": true, + "AllowUserToggle": false, + "Timers": [], + "Endpoints": [ + "ExecBackendURLs" + ], + "Pages": [ + "/cipp/settings/backend" + ], + "Hidden": true + }, + { + "Id": "FunctionOffloading", + "Name": "Function Offloading", + "Description": "Function offloading configuration page. Hidden in CIPP-NG.", + "Enabled": true, + "AllowUserToggle": false, + "Timers": [], + "Endpoints": [ + "ExecOffloadFunctions" + ], + "Pages": [ + "/cipp/advanced/super-admin/function-offloading" + ], + "Hidden": true } ] diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ListFeatureFlags.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ListFeatureFlags.ps1 index d4d924f206839..8c1ebf12ca5da 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ListFeatureFlags.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ListFeatureFlags.ps1 @@ -24,6 +24,18 @@ function Invoke-ListFeatureFlags { elseIf ($Flag.Id -eq 'AppInsights') { $Flag.Enabled = $false } + elseIf ($Flag.Id -eq 'FunctionOffloading') { + $Flag.Enabled = $false + } + } + } + + # Hosted instances hide the backend settings page (Azure resource URLs) + if ($env:CIPP_HOSTED -eq 'true') { + foreach ($Flag in $FeatureFlags) { + if ($Flag.Id -eq 'BackendSettings') { + $Flag.Enabled = $false + } } } From 4f2dad21ee11581116659e56714218aca966a0f4 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Thu, 9 Jul 2026 17:53:15 +0800 Subject: [PATCH 139/170] Custom Tests Results Overview page --- .../Tests/Push-CIPPTestsList.ps1 | 12 ++ .../Start-CIPPDBTestsRun.ps1 | 14 +- .../Public/Get-CIPPTestResultsTenants.ps1 | 166 ++++++++++++++++++ .../Invoke-ExecCustomTestRun.ps1 | 48 +++++ .../Invoke-ListTestResultsTenants.ps1 | 80 +++++++++ 5 files changed, 318 insertions(+), 2 deletions(-) create mode 100644 Modules/CIPPCore/Public/Get-CIPPTestResultsTenants.ps1 create mode 100644 Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Invoke-ExecCustomTestRun.ps1 create mode 100644 Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Invoke-ListTestResultsTenants.ps1 diff --git a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Tests/Push-CIPPTestsList.ps1 b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Tests/Push-CIPPTestsList.ps1 index ead567fdb9bf5..fff94115fdb04 100644 --- a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Tests/Push-CIPPTestsList.ps1 +++ b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Tests/Push-CIPPTestsList.ps1 @@ -39,6 +39,18 @@ function Push-CIPPTestsList { # Function discovery happens inside Invoke-CIPPTestCollection via Get-Command (path-independent). $Suites = @('ZTNA', 'ORCA', 'EIDSCA', 'CISA', 'CIS', 'SMB1001', 'CopilotReadiness', 'GenericTests', 'Custom', 'E8') + # Optional caller-supplied suite filter (e.g. a Custom-only run). When present, restrict + # the emitted suites to the requested subset so we don't spin up every suite unnecessarily. + if ($Item.Suites) { + $Requested = @($Item.Suites) + $Suites = @($Suites | Where-Object { $_ -in $Requested }) + if ($Suites.Count -eq 0) { + Write-Information "No suites matched the requested filter ($($Requested -join ', ')) for tenant $TenantFilter. Skipping." + return @() + } + Write-Information "Suite filter applied for $TenantFilter — running: $($Suites -join ', ')" + } + $Tasks = foreach ($Suite in $Suites) { [PSCustomObject]@{ FunctionName = 'CIPPTestCollection' diff --git a/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-CIPPDBTestsRun.ps1 b/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-CIPPDBTestsRun.ps1 index 4e08a43c16c19..944cd07190f38 100644 --- a/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-CIPPDBTestsRun.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/Orchestrator Functions/Start-CIPPDBTestsRun.ps1 @@ -15,7 +15,12 @@ function Start-CIPPDBTestsRun { [string]$TenantFilter = 'allTenants', [Parameter(Mandatory = $false)] - [switch]$Force + [switch]$Force, + + # Optional subset of suites to run (e.g. 'Custom'). Omit to run every suite. + # Suite names must match the ValidateSet in Invoke-CIPPTestCollection. + [Parameter(Mandatory = $false)] + [string[]]$Suites ) Write-Information "Starting tests run for tenant: $TenantFilter" @@ -64,11 +69,16 @@ function Start-CIPPDBTestsRun { # The tenants below were already filtered by data presence above, so we pass # SkipDbCheck=$true to avoid a redundant CountsOnly round-trip per tenant. $Batch = foreach ($Tenant in $AllTenantsList) { - @{ + $ListItem = @{ FunctionName = 'CIPPTestsList' TenantFilter = $Tenant SkipDbCheck = $true } + # Propagate an optional suite filter so Phase 1 emits only the requested suites. + if ($Suites) { + $ListItem.Suites = @($Suites) + } + $ListItem } Write-Information "Built batch of $($Batch.Count) tenant test list activities" diff --git a/Modules/CIPPCore/Public/Get-CIPPTestResultsTenants.ps1 b/Modules/CIPPCore/Public/Get-CIPPTestResultsTenants.ps1 new file mode 100644 index 0000000000000..fbf099fff32a2 --- /dev/null +++ b/Modules/CIPPCore/Public/Get-CIPPTestResultsTenants.ps1 @@ -0,0 +1,166 @@ +function Get-CIPPTestResultsTenants { + <# + .SYNOPSIS + Retrieves CIPP test results across one, many, or all tenants with flexible filtering. + + .DESCRIPTION + Queries the shared CippTestResults table server-side (partition / row / status / type / + risk / category) so a single test can be compared across every tenant in one call. This is + the cross-tenant counterpart to Get-CIPPTestResults (which is scoped to a single tenant). + + Custom test rows are enriched with their definition (Description / ReturnType / + MarkdownTemplate) from the CustomPowershellScripts table so the off-canvas detail renders + identically to the per-tenant dashboard. Every row is stamped with its tenant identity + (Tenant / TenantId / TenantName) and a serialisable LastRun timestamp for display. + + .PARAMETER TenantFilter + One or more tenant domains. Omit, or pass 'AllTenants' / 'allTenants', to query every tenant. + + .PARAMETER TestId + One or more test IDs (the row's RowKey), e.g. 'CustomScript-'. + + .PARAMETER Status + One or more statuses to filter on (Passed / Failed / Investigate / Skipped / Informational). + + .PARAMETER TestType + Restrict to a single test type (Identity / Devices / Custom). + + .PARAMETER Risk + Restrict to a single risk level (High / Medium / Low). + + .PARAMETER Category + Restrict to a single category. + + .EXAMPLE + Get-CIPPTestResultsTenants -TestId 'CustomScript-1234' -TestType 'Custom' + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $false)] + [string[]]$TenantFilter, + + [Parameter(Mandatory = $false)] + [string[]]$TestId, + + [Parameter(Mandatory = $false)] + [string[]]$Status, + + [Parameter(Mandatory = $false)] + [string]$TestType, + + [Parameter(Mandatory = $false)] + [string]$Risk, + + [Parameter(Mandatory = $false)] + [string]$Category + ) + + $Table = Get-CippTable -tablename 'CippTestResults' + + # Build a single OData filter so all narrowing happens server-side. A cross-partition scan + # (no PartitionKey clause) is acceptable here because it is always bounded by a RowKey/Status/ + # TestType clause when driven from the UI. + $FilterParts = [System.Collections.Generic.List[string]]::new() + + $AllTenants = (-not $TenantFilter) -or ($TenantFilter -contains 'AllTenants') -or ($TenantFilter -contains 'allTenants') + if (-not $AllTenants) { + $TenantClause = (@($TenantFilter | Where-Object { $_ }) | ForEach-Object { "PartitionKey eq '$_'" }) -join ' or ' + if ($TenantClause) { $FilterParts.Add("($TenantClause)") } + } + if ($TestId) { + $TestClause = (@($TestId | Where-Object { $_ }) | ForEach-Object { "RowKey eq '$_'" }) -join ' or ' + if ($TestClause) { $FilterParts.Add("($TestClause)") } + } + if ($Status) { + $StatusClause = (@($Status | Where-Object { $_ }) | ForEach-Object { "Status eq '$_'" }) -join ' or ' + if ($StatusClause) { $FilterParts.Add("($StatusClause)") } + } + if ($TestType) { $FilterParts.Add("TestType eq '$TestType'") } + if ($Risk) { $FilterParts.Add("Risk eq '$Risk'") } + if ($Category) { $FilterParts.Add("Category eq '$Category'") } + + $Filter = $FilterParts -join ' and ' + + $GetParams = @{} + if ($Filter) { $GetParams.Filter = $Filter } + $Results = @(Get-CIPPAzDataTableEntity @Table @GetParams) + + if ($Results.Count -eq 0) { return @() } + + # Map tenant domain (PartitionKey) -> tenant identity for display and access control. + $TenantLookup = @{} + try { + foreach ($Tenant in (Get-Tenants -IncludeErrors)) { + if ($Tenant.defaultDomainName) { + $TenantLookup[$Tenant.defaultDomainName] = $Tenant + } + } + } catch { + Write-Warning "Get-CIPPTestResultsTenants: failed to load tenant list: $($_.Exception.Message)" + } + + # Enrich Custom rows with their definition (latest version per ScriptGuid), mirroring the + # per-tenant enrichment in Invoke-ListTests so the shared off-canvas renders identically. + $CustomMeta = @{} + if (@($Results | Where-Object { $_.TestType -eq 'Custom' }).Count -gt 0) { + $ScriptTable = Get-CippTable -tablename 'CustomPowershellScripts' + $Scripts = @(Get-CIPPAzDataTableEntity @ScriptTable -Filter "PartitionKey eq 'CustomScript'") + $LatestByGuid = @{} + foreach ($Script in $Scripts) { + if (-not $Script.ScriptGuid) { continue } + $Existing = $LatestByGuid[$Script.ScriptGuid] + if (-not $Existing -or [int]$Script.Version -gt [int]$Existing.Version) { + $LatestByGuid[$Script.ScriptGuid] = $Script + } + } + foreach ($Script in $LatestByGuid.Values) { + # Treat a missing Enabled property as enabled, matching Invoke-CIPPTestCollection. + # A disabled (or deleted) script can still have stale results in the table; surfacing + # this lets the UI show that the data no longer reflects an active test. + $EnabledProp = $Script.PSObject.Properties['Enabled'] + $CustomMeta[$Script.ScriptGuid] = [PSCustomObject]@{ + ScriptName = $Script.ScriptName ?? '' + Description = $Script.Description ?? '' + ReturnType = $Script.ReturnType ?? 'JSON' + MarkdownTemplate = $Script.MarkdownTemplate ?? '' + Enabled = (-not $EnabledProp) -or [bool]$EnabledProp.Value + } + } + } + + foreach ($Result in $Results) { + $TenantInfo = $TenantLookup[$Result.PartitionKey] + $Result | Add-Member -NotePropertyName 'Tenant' -NotePropertyValue $Result.PartitionKey -Force + $Result | Add-Member -NotePropertyName 'TenantId' -NotePropertyValue ($TenantInfo.customerId ?? '') -Force + $Result | Add-Member -NotePropertyName 'TenantName' -NotePropertyValue ($TenantInfo.displayName ?? $Result.PartitionKey) -Force + + # Surface the Azure entity timestamp as a stable, serialisable last-run field. + $LastRun = $Result.Timestamp + if ($LastRun -is [DateTimeOffset]) { + $LastRun = $LastRun.UtcDateTime.ToString('o') + } elseif ($LastRun) { + $LastRun = [string]$LastRun + } + $Result | Add-Member -NotePropertyName 'LastRun' -NotePropertyValue $LastRun -Force + + if ($Result.TestType -eq 'Custom') { + $ScriptGuid = ($Result.RowKey -replace '^CustomScript-', '') + if (-not [string]::IsNullOrWhiteSpace($ScriptGuid) -and $CustomMeta.ContainsKey($ScriptGuid)) { + $Meta = $CustomMeta[$ScriptGuid] + $Result | Add-Member -NotePropertyName 'Description' -NotePropertyValue $Meta.Description -Force + $Result | Add-Member -NotePropertyName 'ReturnType' -NotePropertyValue $Meta.ReturnType -Force + $Result | Add-Member -NotePropertyName 'MarkdownTemplate' -NotePropertyValue $Meta.MarkdownTemplate -Force + $Result | Add-Member -NotePropertyName 'Enabled' -NotePropertyValue $Meta.Enabled -Force + if ([string]::IsNullOrWhiteSpace($Result.Name) -and $Meta.ScriptName) { + $Result | Add-Member -NotePropertyName 'Name' -NotePropertyValue $Meta.ScriptName -Force + } + } else { + # Result exists but the script no longer does (deleted). The data is stale — flag + # it as not enabled so the column stays populated and truthful. + $Result | Add-Member -NotePropertyName 'Enabled' -NotePropertyValue $false -Force + } + } + } + + return $Results +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Invoke-ExecCustomTestRun.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Invoke-ExecCustomTestRun.ps1 new file mode 100644 index 0000000000000..7e02d1911c8d6 --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Invoke-ExecCustomTestRun.ps1 @@ -0,0 +1,48 @@ +function Invoke-ExecCustomTestRun { + <# + .SYNOPSIS + Triggers a Custom test-suite run for one or all tenants. + + .DESCRIPTION + Re-runs the enabled Custom tests against the most recent cached data for the requested + tenant(s) by starting the standard tests orchestration with a Custom-only suite filter. + Used by the cross-tenant Custom Test Report to refresh results on demand. + + .FUNCTIONALITY + Entrypoint,AnyTenant + + .ROLE + Tenant.Tests.ReadWrite + #> + param($Request, $TriggerMetadata) + + $APIName = $TriggerMetadata.FunctionName + Write-LogMessage -headers $Request.Headers -API $APIName -message 'Accessed this API' -Sev 'Debug' + + $TenantFilter = 'allTenants' + try { + $TenantFilterRaw = $Request.Query.tenantFilter ?? $Request.Body.tenantFilter + # Accept a plain string, a {value} object, or a single-element array of either. + if ($TenantFilterRaw -is [array]) { $TenantFilterRaw = $TenantFilterRaw | Select-Object -First 1 } + if ($TenantFilterRaw -is [pscustomobject]) { $TenantFilterRaw = $TenantFilterRaw.value } + if (-not [string]::IsNullOrWhiteSpace($TenantFilterRaw)) { $TenantFilter = [string]$TenantFilterRaw } + if ($TenantFilter -eq 'AllTenants') { $TenantFilter = 'allTenants' } + + Write-LogMessage -API $APIName -tenant $TenantFilter -message "Starting Custom test run for: $TenantFilter" -sev Info + $null = Start-CIPPDBTestsRun -TenantFilter $TenantFilter -Suites 'Custom' -Force + + $Scope = if ($TenantFilter -eq 'allTenants') { 'all tenants' } else { $TenantFilter } + $StatusCode = [HttpStatusCode]::OK + $Body = [PSCustomObject]@{ Results = "Successfully started a custom test run for $Scope. Results will populate here as each tenant completes." } + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API $APIName -tenant $TenantFilter -message "Failed to start custom test run: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + $StatusCode = [HttpStatusCode]::BadRequest + $Body = [PSCustomObject]@{ Results = "Failed to start custom test run: $($ErrorMessage.NormalizedError)" } + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = $Body + }) +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Invoke-ListTestResultsTenants.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Invoke-ListTestResultsTenants.ps1 new file mode 100644 index 0000000000000..a6d1ab1054284 --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Invoke-ListTestResultsTenants.ps1 @@ -0,0 +1,80 @@ +function Invoke-ListTestResultsTenants { + <# + .SYNOPSIS + Lists CIPP test results for a given test across one, many, or all tenants. + + .DESCRIPTION + Cross-tenant overview of stored test results. Backed by Get-CIPPTestResultsTenants, which + queries the shared CippTestResults table server-side and enriches Custom rows with their + definition. Results are filtered to the tenants the calling user is permitted to see via + Test-CIPPAccess, so an all-tenants read still respects per-user tenant scoping. + + .FUNCTIONALITY + Entrypoint,AnyTenant + + .ROLE + Tenant.Reports.Read + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $TriggerMetadata.FunctionName + + try { + $TenantFilterRaw = $Request.Query.tenantFilter ?? $Request.Body.tenantFilter + $TestIdRaw = $Request.Query.testId ?? $Request.Body.testId + $StatusRaw = $Request.Query.status ?? $Request.Body.status + $TestType = $Request.Query.testType ?? $Request.Body.testType + $Risk = $Request.Query.risk ?? $Request.Body.risk + $Category = $Request.Query.category ?? $Request.Body.category + + # 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). + $ToArray = { + param($Value) + if ($null -eq $Value) { return @() } + if ($Value -is [string]) { + return @($Value -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ }) + } + return @($Value | ForEach-Object { $_.value ?? $_ } | Where-Object { $_ }) + } + + $TenantFilterList = & $ToArray $TenantFilterRaw + $TestIdList = & $ToArray $TestIdRaw + $StatusList = & $ToArray $StatusRaw + + $Params = @{} + if ($TenantFilterList.Count -gt 0) { $Params.TenantFilter = $TenantFilterList } + if ($TestIdList.Count -gt 0) { $Params.TestId = $TestIdList } + if ($StatusList.Count -gt 0) { $Params.Status = $StatusList } + if ($TestType) { $Params.TestType = $TestType } + if ($Risk) { $Params.Risk = $Risk } + if ($Category) { $Params.Category = $Category } + + $Results = @(Get-CIPPTestResultsTenants @Params) + + # Restrict to tenants the caller is allowed to see. Test-CIPPAccess returns the list of + # permitted customerIds, or 'AllTenants' for unrestricted users. + $AllowedTenants = Test-CIPPAccess -Request $Request -TenantList + if ($AllowedTenants -notcontains 'AllTenants') { + $AllowedSet = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($Allowed in $AllowedTenants) { + if ($Allowed) { [void]$AllowedSet.Add([string]$Allowed) } + } + $Results = @($Results | Where-Object { $_.TenantId -and $AllowedSet.Contains([string]$_.TenantId) }) + } + + $StatusCode = [HttpStatusCode]::OK + $Body = @{ Results = @($Results) } + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API $APIName -message "Error retrieving cross-tenant test results: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage + $StatusCode = [HttpStatusCode]::BadRequest + $Body = @{ Error = $ErrorMessage.NormalizedError } + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = $Body + }) +} From aa7e060b6754298862a439549a86607da3788b2e Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Thu, 9 Jul 2026 15:44:12 +0200 Subject: [PATCH 140/170] sharepoint reports --- Config/CIPPDBCacheTypes.json | 5 + Config/SAMManifest.json | 4 + .../Public/Invoke-CIPPDBCacheCollection.ps1 | 1 + .../Set-CIPPDBCacheSharePointSharingLinks.ps1 | 213 ++++++++++++++++++ .../Invoke-ExecRemoveSharingLink.ps1 | 54 +++++ .../Invoke-ExecSetLibraryPermission.ps1 | 129 +++++++++++ .../Invoke-ListSharePointSharing.ps1 | 120 ++++++++++ .../Invoke-ListSiteLibraries.ps1 | 43 ++++ 8 files changed, 569 insertions(+) create mode 100644 Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheSharePointSharingLinks.ps1 create mode 100644 Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecRemoveSharingLink.ps1 create mode 100644 Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecSetLibraryPermission.ps1 create mode 100644 Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSharePointSharing.ps1 create mode 100644 Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSiteLibraries.ps1 diff --git a/Config/CIPPDBCacheTypes.json b/Config/CIPPDBCacheTypes.json index 721725f8aea15..ee0dc3de42c32 100644 --- a/Config/CIPPDBCacheTypes.json +++ b/Config/CIPPDBCacheTypes.json @@ -294,6 +294,11 @@ "friendlyName": "SharePoint Site Usage", "description": "SharePoint site usage statistics" }, + { + "type": "SharePointSharingLinks", + "friendlyName": "SharePoint & OneDrive Sharing Links", + "description": "Sharing links and external grants on SharePoint and OneDrive files and folders" + }, { "type": "OfficeActivations", "friendlyName": "Office Activations", diff --git a/Config/SAMManifest.json b/Config/SAMManifest.json index 8868687473b2f..3b304150e7cf8 100644 --- a/Config/SAMManifest.json +++ b/Config/SAMManifest.json @@ -655,6 +655,10 @@ { "id": "56680e0d-d2a3-4ae1-80d8-3c4f2100e3d0", "type": "Scope" + }, + { + "id": "678536fe-1083-478a-9c59-b99265e6b0d3", + "type": "Role" } ] }, diff --git a/Modules/CIPPCore/Public/Invoke-CIPPDBCacheCollection.ps1 b/Modules/CIPPCore/Public/Invoke-CIPPDBCacheCollection.ps1 index 1c2eb64addd24..673b4514c6c27 100644 --- a/Modules/CIPPCore/Public/Invoke-CIPPDBCacheCollection.ps1 +++ b/Modules/CIPPCore/Public/Invoke-CIPPDBCacheCollection.ps1 @@ -143,6 +143,7 @@ function Invoke-CIPPDBCacheCollection { 'SPOTenantSyncClientRestriction' 'SharePointSiteUsage' 'OneDriveUsage' + 'SharePointSharingLinks' ) Teams = @( 'CsTeamsMeetingPolicy' diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheSharePointSharingLinks.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheSharePointSharingLinks.ps1 new file mode 100644 index 0000000000000..fecb0b0af96a8 --- /dev/null +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheSharePointSharingLinks.ps1 @@ -0,0 +1,213 @@ +function Set-CIPPDBCacheSharePointSharingLinks { + <# + .SYNOPSIS + Caches SharePoint and OneDrive sharing links for a tenant + + .DESCRIPTION + Enumerates every drive in the tenant (SharePoint sites and OneDrive personal sites), + scans each drive via the driveItem delta endpoint for items carrying the "shared" facet + and collects the direct (non-inherited) sharing permissions of those items. Sharing + links are classified as Anonymous, External or Internal based on the link scope and the + recipients' domains; direct (non-link) grants to external users are included as well. + + .PARAMETER TenantFilter + The tenant to cache sharing links for + + .PARAMETER QueueId + The queue ID to update with total tasks (optional) + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + [string]$QueueId + ) + + # Returns $true when an identity (link recipient or direct grant) is external to the tenant. + function Test-CIPPExternalIdentity { + param($Identity, $InternalDomains) + $LoginName = [string]($Identity.siteUser.loginName ?? $Identity.user.loginName ?? '') + if ($LoginName -match '#ext#' -or $LoginName -match 'urn%3aspo%3aguest' -or $LoginName -match 'urn:spo:guest') { return $true } + $Email = [string]($Identity.user.email ?? $Identity.user.userPrincipalName ?? $Identity.siteUser.email ?? '') + if ($Email -match '#EXT#') { return $true } + if ($Email -and $Email.Contains('@') -and $InternalDomains.Count -gt 0) { + return -not $InternalDomains.Contains($Email.Split('@')[-1]) + } + return $false + } + + # Friendly display value for an identity, preferring email over display name. + function Get-CIPPIdentityLabel { + param($Identity) + $Identity.user.email ?? $Identity.user.userPrincipalName ?? $Identity.siteUser.email ?? $Identity.user.displayName ?? $Identity.siteUser.displayName ?? $Identity.group.email ?? $Identity.group.displayName ?? $Identity.siteGroup.displayName + } + + try { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching SharePoint and OneDrive sharing links' -sev Debug + + # Verified domains, used to tell internal from external recipients. + $InternalDomains = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + try { + $Domains = New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/domains?`$select=id,isVerified" -tenantid $TenantFilter -asapp $true + foreach ($Domain in ($Domains | Where-Object { $_.isVerified })) { [void]$InternalDomains.Add($Domain.id) } + } catch { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Could not list verified domains for sharing link classification: $($_.Exception.Message)" -sev Warning + } + + # 1) All sites, including OneDrive personal sites. + $Sites = @(New-GraphGetRequest -uri "https://graph.microsoft.com/beta/sites/getAllSites?`$select=id,displayName,name,webUrl,isPersonalSite&`$top=999" -tenantid $TenantFilter -asapp $true) + + # 2) All document libraries (drives) per site, in bulk. + $SiteByRequestId = @{} + $RequestId = 0 + $DriveRequests = foreach ($Site in $Sites) { + $SiteByRequestId["$RequestId"] = $Site + @{ + id = "$RequestId" + method = 'GET' + url = "sites/$($Site.id)/drives?`$select=id,name,driveType,webUrl" + } + $RequestId++ + } + + $DriveEntries = [System.Collections.Generic.List[object]]::new() + if (@($DriveRequests).Count -gt 0) { + $DriveResponses = New-GraphBulkRequest -tenantid $TenantFilter -Requests @($DriveRequests) -asapp $true + foreach ($Response in $DriveResponses) { + if ($Response.status -and $Response.status -ne 200) { continue } + $Site = $SiteByRequestId["$($Response.id)"] + foreach ($Drive in @($Response.body.value)) { + if ($Drive.id) { + $DriveEntries.Add([PSCustomObject]@{ Site = $Site; Drive = $Drive }) + } + } + } + } + + # 3) Delta-scan every drive and keep items that carry the "shared" facet. + $DriveByRequestId = @{} + $RequestId = 0 + $DeltaRequests = foreach ($Entry in $DriveEntries) { + $DriveByRequestId["$RequestId"] = $Entry + @{ + id = "$RequestId" + method = 'GET' + url = "drives/$($Entry.Drive.id)/root/delta?`$select=id,name,webUrl,parentReference,file,folder,shared,size,lastModifiedDateTime&`$top=999" + } + $RequestId++ + } + + $SharedItems = [System.Collections.Generic.List[object]]::new() + $FailedDrives = 0 + if (@($DeltaRequests).Count -gt 0) { + $DeltaResponses = New-GraphBulkRequest -tenantid $TenantFilter -Requests @($DeltaRequests) -asapp $true + foreach ($Response in $DeltaResponses) { + if ($Response.status -and $Response.status -ne 200) { $FailedDrives++; continue } + $Entry = $DriveByRequestId["$($Response.id)"] + foreach ($Item in @($Response.body.value)) { + if ($Item.shared -and -not $Item.deleted) { + $SharedItems.Add([PSCustomObject]@{ Site = $Entry.Site; Drive = $Entry.Drive; Item = $Item }) + } + } + } + } + + # 4) Fetch the permissions of every shared item, in bulk. + $SharedItemByRequestId = @{} + $RequestId = 0 + $PermissionRequests = foreach ($Entry in $SharedItems) { + $SharedItemByRequestId["$RequestId"] = $Entry + @{ + id = "$RequestId" + method = 'GET' + url = "drives/$($Entry.Drive.id)/items/$($Entry.Item.id)/permissions" + } + $RequestId++ + } + + # 5) Build one row per sharing link (any scope) or direct grant to an external user. + $Rows = [System.Collections.Generic.List[object]]::new() + if (@($PermissionRequests).Count -gt 0) { + $PermissionResponses = New-GraphBulkRequest -tenantid $TenantFilter -Requests @($PermissionRequests) -asapp $true + foreach ($Response in $PermissionResponses) { + if ($Response.status -and $Response.status -ne 200) { continue } + $Entry = $SharedItemByRequestId["$($Response.id)"] + $Site = $Entry.Site + $Drive = $Entry.Drive + $Item = $Entry.Item + + foreach ($Permission in @($Response.body.value)) { + # Only permissions set on the item itself; inherited ones are reported on their parent. + if ($Permission.inheritedFrom) { continue } + + if ($Permission.link) { + $Recipients = @($Permission.grantedToIdentitiesV2 ?? $Permission.grantedToIdentities) + $LinkScope = $Permission.link.scope ?? 'users' + $Classification = switch ($LinkScope) { + 'anonymous' { 'Anonymous' } + 'organization' { 'Internal' } + 'existingAccess' { 'Internal' } + default { + $HasExternal = $false + foreach ($Recipient in $Recipients) { + if (Test-CIPPExternalIdentity -Identity $Recipient -InternalDomains $InternalDomains) { $HasExternal = $true; break } + } + if ($HasExternal) { 'External' } else { 'Internal' } + } + } + $LinkType = $Permission.link.type ?? 'link' + $LinkUrl = $Permission.link.webUrl + } else { + # Direct grant (no sharing link): only report grants to external users. + $Recipients = @($Permission.grantedToV2 ?? $Permission.grantedTo) + if ($Permission.roles -contains 'owner') { continue } + $HasExternal = $false + foreach ($Recipient in $Recipients) { + if (Test-CIPPExternalIdentity -Identity $Recipient -InternalDomains $InternalDomains) { $HasExternal = $true; break } + } + if (-not $HasExternal) { continue } + $Classification = 'External' + $LinkScope = 'direct' + $LinkType = 'directGrant' + $LinkUrl = $null + } + + $SharedWith = @($Recipients | ForEach-Object { Get-CIPPIdentityLabel -Identity $_ } | Where-Object { $_ } | Sort-Object -Unique) + + $Rows.Add([PSCustomObject]@{ + id = "$($Drive.id)_$($Item.id)_$($Permission.id)" + siteId = $Site.id + siteName = $Site.displayName ?? $Site.name + siteUrl = $Site.webUrl + workload = if ($Site.isPersonalSite) { 'OneDrive' } else { 'SharePoint' } + driveId = $Drive.id + driveName = $Drive.name + itemId = $Item.id + fileName = $Item.name + itemUrl = $Item.webUrl + itemType = if ($Item.folder) { 'Folder' } else { 'File' } + size = $Item.size + lastModifiedDateTime = $Item.lastModifiedDateTime + permissionId = $Permission.id + linkType = $LinkType + linkScope = $LinkScope + classification = $Classification + roles = @($Permission.roles) + sharedWith = $SharedWith + linkUrl = $LinkUrl + hasPassword = $Permission.hasPassword ?? $false + expirationDateTime = $Permission.expirationDateTime + }) + } + } + } + + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'SharePointSharingLinks' -Data @($Rows) -AddCount + + $Message = "Cached $($Rows.Count) sharing links across $($DriveEntries.Count) drives" + $(if ($FailedDrives -gt 0) { " ($FailedDrives drives could not be scanned)" } else { '' }) + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message $Message -sev Debug + + } catch { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to cache SharePoint sharing links: $($_.Exception.Message)" -sev Error -LogData (Get-CippException -Exception $_) + } +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecRemoveSharingLink.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecRemoveSharingLink.ps1 new file mode 100644 index 0000000000000..f8d6d448b8888 --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecRemoveSharingLink.ps1 @@ -0,0 +1,54 @@ +function Invoke-ExecRemoveSharingLink { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Sharepoint.Site.ReadWrite + .DESCRIPTION + Revokes a sharing link (or direct sharing grant) on a SharePoint or OneDrive item by + deleting the permission from the drive item. Also removes the revoked link from the + SharePointSharingLinks reporting cache so the sharing report reflects it immediately. + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + $TenantFilter = $Request.Body.tenantFilter + $DriveId = $Request.Body.DriveId + $ItemId = $Request.Body.ItemId + $PermissionId = $Request.Body.PermissionId + $FileName = $Request.Body.FileName + $CacheId = $Request.Body.CacheId + + try { + if ([string]::IsNullOrWhiteSpace($DriveId) -or [string]::IsNullOrWhiteSpace($ItemId) -or [string]::IsNullOrWhiteSpace($PermissionId)) { + throw 'DriveId, ItemId and PermissionId are required.' + } + + $null = New-GraphPostRequest -uri "https://graph.microsoft.com/v1.0/drives/$DriveId/items/$ItemId/permissions/$PermissionId" -tenantid $TenantFilter -type DELETE -asapp $true + + # Best effort: drop the revoked link from the reporting cache so the report updates without a full sync. + if (-not [string]::IsNullOrWhiteSpace($CacheId)) { + try { + Remove-CIPPDbItem -TenantFilter $TenantFilter -Type 'SharePointSharingLinks' -ItemId $CacheId + } catch { + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message "Revoked sharing link but could not update the reporting cache: $($_.Exception.Message)" -sev Warning + } + } + + $Result = "Successfully revoked sharing link$(if ($FileName) { " for $FileName" })." + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message $Result -sev Info + $StatusCode = [HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Result = "Failed to revoke sharing link$(if ($FileName) { " for $FileName" }). Error: $($ErrorMessage.NormalizedError)" + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message $Result -sev Error -LogData $ErrorMessage + $StatusCode = [HttpStatusCode]::BadRequest + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @{'Results' = $Result } + }) +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecSetLibraryPermission.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecSetLibraryPermission.ps1 new file mode 100644 index 0000000000000..2ba78aca52211 --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecSetLibraryPermission.ps1 @@ -0,0 +1,129 @@ +function Invoke-ExecSetLibraryPermission { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Sharepoint.Site.ReadWrite + .DESCRIPTION + Grants users and/or groups a SharePoint permission level (Read, Contribute, Edit, Design + or Full Control) on a document library via the SharePoint REST API. Principals are + resolved with ensureuser, role inheritance on the library is broken (copying the + existing permissions) when it still inherits, and the role assignment is added with + addroleassignment. + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + $TenantFilter = $Request.Body.tenantFilter + $SiteUrl = $Request.Body.SiteUrl + $ListId = $Request.Body.ListId + $LibraryName = $Request.Body.LibraryName + $PermissionLevel = $Request.Body.PermissionLevel + $Users = @($Request.Body.Users) + $Groups = @($Request.Body.Groups) + + # Standard SharePoint role definition IDs. + $RoleDefinitionIds = @{ + 'read' = 1073741826 + 'contribute' = 1073741827 + 'design' = 1073741828 + 'fullControl' = 1073741829 + 'edit' = 1073741830 + } + + try { + if ([string]::IsNullOrWhiteSpace($SiteUrl) -or [string]::IsNullOrWhiteSpace($ListId)) { + throw 'SiteUrl and ListId are required.' + } + if (-not $RoleDefinitionIds.ContainsKey([string]$PermissionLevel)) { + throw "Invalid permission level '$PermissionLevel'. Valid values are: $($RoleDefinitionIds.Keys -join ', ')." + } + $RoleDefId = $RoleDefinitionIds[[string]$PermissionLevel] + + # Build the claims-encoded logon names for ensureuser. + $Principals = [System.Collections.Generic.List[object]]::new() + foreach ($User in $Users) { + if ($null -eq $User -or -not $User.value) { continue } + $Principals.Add([PSCustomObject]@{ + LogonName = "i:0#.f|membership|$($User.value)" + Label = "$($User.value)" + }) + } + foreach ($Group in $Groups) { + if ($null -eq $Group -or -not $Group.value) { continue } + # Microsoft 365 groups use the federated directory claim; security groups the tenant claim. + $IsUnified = @($Group.addedFields.groupTypes) -contains 'Unified' + $LogonName = if ($IsUnified) { + "c:0o.c|federateddirectoryclaimprovider|$($Group.value)" + } else { + "c:0t.c|tenant|$($Group.value)" + } + $Principals.Add([PSCustomObject]@{ + LogonName = $LogonName + Label = "$($Group.label ?? $Group.value)" + }) + } + if ($Principals.Count -eq 0) { + throw 'No users or groups selected.' + } + + $SharePointInfo = Get-SharePointAdminLink -Public $false -tenantFilter $TenantFilter + $Scope = "$($SharePointInfo.SharePointUrl)/.default" + $JsonAccept = @{ Accept = 'application/json;odata=nometadata' } + $BaseUri = "$($SiteUrl.TrimEnd('/'))/_api" + + # Break role inheritance (copying the existing permissions) when the library still inherits. + $ListInfo = New-GraphGetRequest -uri "$BaseUri/web/lists(guid'$ListId')?`$select=HasUniqueRoleAssignments" -tenantid $TenantFilter -scope $Scope -extraHeaders $JsonAccept -CertAuth + if (-not $ListInfo.HasUniqueRoleAssignments) { + $null = New-GraphPostRequest -uri "$BaseUri/web/lists(guid'$ListId')/breakroleinheritance(copyRoleAssignments=true,clearSubscopes=false)" -tenantid $TenantFilter -scope $Scope -type POST -body '{}' -AddedHeaders $JsonAccept -CertAuth + } + + $Granted = [System.Collections.Generic.List[string]]::new() + $Failed = [System.Collections.Generic.List[string]]::new() + foreach ($Principal in $Principals) { + try { + $EnsureBody = ConvertTo-Json -Compress -InputObject @{ logonName = $Principal.LogonName } + $EnsuredUser = New-GraphPostRequest -uri "$BaseUri/web/ensureuser" -tenantid $TenantFilter -scope $Scope -type POST -body $EnsureBody -AddedHeaders $JsonAccept -CertAuth + if (-not $EnsuredUser.Id) { + throw 'Could not resolve principal on the site.' + } + $null = New-GraphPostRequest -uri "$BaseUri/web/lists(guid'$ListId')/roleassignments/addroleassignment(principalid=$($EnsuredUser.Id),roledefid=$RoleDefId)" -tenantid $TenantFilter -scope $Scope -type POST -body '{}' -AddedHeaders $JsonAccept -CertAuth + $Granted.Add($Principal.Label) + } catch { + $Failed.Add("$($Principal.Label) ($($_.Exception.Message))") + } + } + + $LevelLabel = switch ([string]$PermissionLevel) { + 'fullControl' { 'Full Control' } + default { (Get-Culture).TextInfo.ToTitleCase([string]$PermissionLevel) } + } + $Messages = [System.Collections.Generic.List[string]]::new() + if ($Granted.Count -gt 0) { + $Messages.Add("Successfully granted $LevelLabel on library $LibraryName to $($Granted -join ', ').") + } + if ($Failed.Count -gt 0) { + $Messages.Add("Failed for $($Failed -join '; ').") + } + $Result = $Messages -join ' ' + if ($Granted.Count -gt 0) { + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message $Result -sev Info + $StatusCode = [HttpStatusCode]::OK + } else { + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message $Result -sev Error + $StatusCode = [HttpStatusCode]::BadRequest + } + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Result = "Failed to set permission on library $LibraryName. Error: $($ErrorMessage.NormalizedError)" + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message $Result -sev Error -LogData $ErrorMessage + $StatusCode = [HttpStatusCode]::BadRequest + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @{'Results' = $Result } + }) +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSharePointSharing.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSharePointSharing.ps1 new file mode 100644 index 0000000000000..b96a7e1a99ab3 --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSharePointSharing.ps1 @@ -0,0 +1,120 @@ +function Invoke-ListSharePointSharing { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Sharepoint.Site.Read + .DESCRIPTION + Compiles the SharePoint & OneDrive sharing report for a tenant from CACHED data in the + CIPP reporting database (SharePointSharingLinks, SharePointSiteUsage, OneDriveUsage). + No live Graph enumeration is performed - refresh the data by syncing those caches + (ExecCIPPDBCache). Returns environment/file/storage summaries per workload, sharing link + counts by classification, chart datasets and the individual sharing link rows. + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $TenantFilter = $Request.Query.tenantFilter ?? $Request.Body.tenantFilter + + # Usage report values can arrive as numbers, strings or empty strings depending on the tenant. + function ConvertTo-SafeDouble { + param($Value) + $Parsed = [double]0 + if ($null -ne $Value -and [double]::TryParse("$Value", [ref]$Parsed)) { return $Parsed } + return [double]0 + } + + # --- Cached datasets from the CIPP reporting database --- + $CacheTypes = @('SharePointSharingLinks', 'SharePointSiteUsage', 'OneDriveUsage') + $CacheData = @{} + $CacheSynced = @{} + $CacheTimestamps = [System.Collections.Generic.List[object]]::new() + foreach ($Type in $CacheTypes) { + try { + $CacheData[$Type] = @(New-CIPPDbRequest -TenantFilter $TenantFilter -Type $Type) + } catch { + $CacheData[$Type] = @() + } + $CacheSynced[$Type] = $false + try { + $CountRow = Get-CIPPDbItem -TenantFilter $TenantFilter -Type $Type -CountsOnly | Select-Object -First 1 + if ($CountRow) { $CacheSynced[$Type] = $true } + if ($CountRow.Timestamp) { $CacheTimestamps.Add($CountRow.Timestamp) } + } catch {} + } + $LastDataRefresh = $CacheTimestamps | Sort-Object | Select-Object -First 1 + + # --- Environment summaries per workload. Teams-connected sites (rootWebTemplate 'Group') + # are reported separately from the remaining SharePoint sites; OneDrive is per account. --- + $SharePointSites = 0; $SharePointFiles = [int64]0; $SharePointStorage = [double]0 + $TeamsSites = 0; $TeamsFiles = [int64]0; $TeamsStorage = [double]0 + foreach ($Site in $CacheData['SharePointSiteUsage']) { + $Files = [int64](ConvertTo-SafeDouble -Value $Site.fileCount) + $Storage = ConvertTo-SafeDouble -Value $Site.storageUsedInBytes + if ($Site.rootWebTemplate -eq 'Group') { + $TeamsSites++; $TeamsFiles += $Files; $TeamsStorage += $Storage + } else { + $SharePointSites++; $SharePointFiles += $Files; $SharePointStorage += $Storage + } + } + + $OneDriveAccounts = 0; $OneDriveFiles = [int64]0; $OneDriveStorage = [double]0 + foreach ($Account in $CacheData['OneDriveUsage']) { + $OneDriveAccounts++ + $OneDriveFiles += [int64](ConvertTo-SafeDouble -Value $Account.fileCount) + $OneDriveStorage += ConvertTo-SafeDouble -Value $Account.storageUsedInBytes + } + + # --- Sharing link rollups --- + $Links = $CacheData['SharePointSharingLinks'] + $AnonymousLinks = 0; $ExternalLinks = 0; $InternalLinks = 0 + $ByScope = @{} + $ByLinkType = @{} + $BySite = @{} + $SharedItemIds = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($Link in $Links) { + switch ($Link.classification) { + 'Anonymous' { $AnonymousLinks++ } + 'External' { $ExternalLinks++ } + default { $InternalLinks++ } + } + $Scope = [string]($Link.classification ?? 'Internal') + $ByScope[$Scope] = [int]($ByScope[$Scope] ?? 0) + 1 + $Type = [string]($Link.linkType ?? 'link') + $ByLinkType[$Type] = [int]($ByLinkType[$Type] ?? 0) + 1 + $SiteName = [string]($Link.siteName ?? $Link.siteUrl) + if ($SiteName) { $BySite[$SiteName] = [int]($BySite[$SiteName] ?? 0) + 1 } + if ($Link.driveId -and $Link.itemId) { [void]$SharedItemIds.Add("$($Link.driveId)|$($Link.itemId)") } + } + + $Body = [PSCustomObject]@{ + summary = [PSCustomObject]@{ + sharePointSites = $SharePointSites + sharePointFiles = $SharePointFiles + sharePointStorageUsedGB = [math]::Round($SharePointStorage / 1GB, 2) + teamsSites = $TeamsSites + teamsFiles = $TeamsFiles + teamsStorageUsedGB = [math]::Round($TeamsStorage / 1GB, 2) + oneDriveAccounts = $OneDriveAccounts + oneDriveFiles = $OneDriveFiles + oneDriveStorageUsedGB = [math]::Round($OneDriveStorage / 1GB, 2) + totalLinks = @($Links).Count + anonymousLinks = $AnonymousLinks + externalLinks = $ExternalLinks + internalLinks = $InternalLinks + itemsShared = $SharedItemIds.Count + linksSynced = $CacheSynced['SharePointSharingLinks'] + usageSynced = ($CacheSynced['SharePointSiteUsage'] -or $CacheSynced['OneDriveUsage']) + lastDataRefresh = $LastDataRefresh + } + byScope = @($ByScope.GetEnumerator() | Sort-Object -Property Value -Descending | ForEach-Object { [PSCustomObject]@{ scope = $_.Key; links = $_.Value } }) + byLinkType = @($ByLinkType.GetEnumerator() | Sort-Object -Property Value -Descending | ForEach-Object { [PSCustomObject]@{ type = $_.Key; links = $_.Value } }) + topSites = @($BySite.GetEnumerator() | Sort-Object -Property Value -Descending | Select-Object -First 10 | ForEach-Object { [PSCustomObject]@{ site = $_.Key; links = $_.Value } }) + links = @($Links) + } + + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::OK + Body = $Body + }) +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSiteLibraries.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSiteLibraries.ps1 new file mode 100644 index 0000000000000..35d08578cdcbc --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSiteLibraries.ps1 @@ -0,0 +1,43 @@ +function Invoke-ListSiteLibraries { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Sharepoint.Site.Read + .DESCRIPTION + Lists the visible document libraries of a SharePoint site via the Graph API. The site can + be addressed by SiteId or by SiteUrl (hostname:path addressing). Returns the SharePoint + list GUID as Id so the result can be used directly against the SharePoint REST API. + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $TenantFilter = $Request.Query.tenantFilter ?? $Request.Body.tenantFilter + $SiteId = $Request.Query.SiteId ?? $Request.Body.SiteId + $SiteUrl = $Request.Query.SiteUrl ?? $Request.Body.SiteUrl + + try { + $Lists = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/sites/$SiteSegment/lists?`$select=id,displayName,name,webUrl,list" -tenantid $TenantFilter -asapp $true + # documentLibrary covers regular libraries; webPageLibrary is the Site Pages library. + $Results = @($Lists | Where-Object { $_.list.hidden -ne $true -and $_.list.template -in @('documentLibrary', 'webPageLibrary') } | ForEach-Object { + [PSCustomObject]@{ + Id = $_.id + Title = $_.displayName + Template = $_.list.template + WebUrl = $_.webUrl + } + }) + $StatusCode = [HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Results = "Failed to list document libraries: $($ErrorMessage.NormalizedError)" + Write-LogMessage -Headers $Request.Headers -API $APIName -tenant $TenantFilter -message $Results -sev Error -LogData $ErrorMessage + $StatusCode = [HttpStatusCode]::BadRequest + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @{'Results' = $Results } + }) +} From 70a8722c1bf4f80a0049d448ab4cef8e88c8e883 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Thu, 9 Jul 2026 16:05:32 +0200 Subject: [PATCH 141/170] Add or update the Azure App Service build and deployment workflow config --- .github/workflows/dev_cippahmcc.yml | 32 +++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 .github/workflows/dev_cippahmcc.yml diff --git a/.github/workflows/dev_cippahmcc.yml b/.github/workflows/dev_cippahmcc.yml new file mode 100644 index 0000000000000..11f85d798405d --- /dev/null +++ b/.github/workflows/dev_cippahmcc.yml @@ -0,0 +1,32 @@ +# Docs for the Azure Web Apps Deploy action: https://github.com/azure/functions-action +# More GitHub Actions for Azure: https://github.com/Azure/actions + +name: Build and deploy Powershell project to Azure Function App - cippahmcc + +on: + push: + branches: + - dev + workflow_dispatch: + +env: + AZURE_FUNCTIONAPP_PACKAGE_PATH: '.' # set this to the path to your web app project, defaults to the repository root + +jobs: + deploy: + runs-on: ubuntu-latest + + steps: + - name: 'Checkout GitHub Action' + uses: actions/checkout@v4 + + - name: 'Run Azure Functions Action' + uses: Azure/functions-action@v1 + id: fa + with: + app-name: 'cippahmcc' + slot-name: 'Production' + package: ${{ env.AZURE_FUNCTIONAPP_PACKAGE_PATH }} + publish-profile: ${{ secrets.AZUREAPPSERVICE_PUBLISHPROFILE_D3F28C6FE40549659A230F7C9B4F2B98 }} + sku: 'flexconsumption' + \ No newline at end of file From e7ed3a226ae12d44ec213783d66f7c2449b9a313 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Thu, 9 Jul 2026 16:35:20 +0200 Subject: [PATCH 142/170] extra logging --- .../Set-CIPPDBCacheSharePointSharingLinks.ps1 | 54 ++++++++++++++++--- 1 file changed, 47 insertions(+), 7 deletions(-) diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheSharePointSharingLinks.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheSharePointSharingLinks.ps1 index fecb0b0af96a8..7558418db2c59 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheSharePointSharingLinks.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheSharePointSharingLinks.ps1 @@ -42,6 +42,8 @@ function Set-CIPPDBCacheSharePointSharingLinks { $Identity.user.email ?? $Identity.user.userPrincipalName ?? $Identity.siteUser.email ?? $Identity.user.displayName ?? $Identity.siteUser.displayName ?? $Identity.group.email ?? $Identity.group.displayName ?? $Identity.siteGroup.displayName } + $StartTime = Get-Date + Write-Host "[SharingLinks][$TenantFilter] START at $($StartTime.ToString('o'))" try { Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching SharePoint and OneDrive sharing links' -sev Debug @@ -50,12 +52,20 @@ function Set-CIPPDBCacheSharePointSharingLinks { try { $Domains = New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/domains?`$select=id,isVerified" -tenantid $TenantFilter -asapp $true foreach ($Domain in ($Domains | Where-Object { $_.isVerified })) { [void]$InternalDomains.Add($Domain.id) } + Write-Host "[SharingLinks][$TenantFilter] Phase 0 (domains): $($InternalDomains.Count) verified domains" } catch { + Write-Host "[SharingLinks][$TenantFilter] Phase 0 (domains) FAILED: $($_.Exception.Message)" Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Could not list verified domains for sharing link classification: $($_.Exception.Message)" -sev Warning } # 1) All sites, including OneDrive personal sites. - $Sites = @(New-GraphGetRequest -uri "https://graph.microsoft.com/beta/sites/getAllSites?`$select=id,displayName,name,webUrl,isPersonalSite&`$top=999" -tenantid $TenantFilter -asapp $true) + try { + $Sites = @(New-GraphGetRequest -uri "https://graph.microsoft.com/beta/sites/getAllSites?`$select=id,displayName,name,webUrl,isPersonalSite&`$top=999" -tenantid $TenantFilter -asapp $true) + } catch { + Write-Host "[SharingLinks][$TenantFilter] Phase 1 (getAllSites) FAILED: $($_.Exception.Message)" + throw + } + Write-Host "[SharingLinks][$TenantFilter] Phase 1 (getAllSites): $($Sites.Count) sites (+$([math]::Round(((Get-Date) - $StartTime).TotalSeconds,1))s)" # 2) All document libraries (drives) per site, in bulk. $SiteByRequestId = @{} @@ -71,10 +81,17 @@ function Set-CIPPDBCacheSharePointSharingLinks { } $DriveEntries = [System.Collections.Generic.List[object]]::new() + $FailedDriveLookups = 0 if (@($DriveRequests).Count -gt 0) { - $DriveResponses = New-GraphBulkRequest -tenantid $TenantFilter -Requests @($DriveRequests) -asapp $true + Write-Host "[SharingLinks][$TenantFilter] Phase 2 (site drives): requesting drives for $(@($DriveRequests).Count) sites" + try { + $DriveResponses = New-GraphBulkRequest -tenantid $TenantFilter -Requests @($DriveRequests) -asapp $true + } catch { + Write-Host "[SharingLinks][$TenantFilter] Phase 2 (site drives) BULK FAILED: $($_.Exception.Message)" + throw + } foreach ($Response in $DriveResponses) { - if ($Response.status -and $Response.status -ne 200) { continue } + if ($Response.status -and $Response.status -ne 200) { $FailedDriveLookups++; continue } $Site = $SiteByRequestId["$($Response.id)"] foreach ($Drive in @($Response.body.value)) { if ($Drive.id) { @@ -83,6 +100,7 @@ function Set-CIPPDBCacheSharePointSharingLinks { } } } + Write-Host "[SharingLinks][$TenantFilter] Phase 2 (site drives): $($DriveEntries.Count) drives, $FailedDriveLookups site drive lookups failed (+$([math]::Round(((Get-Date) - $StartTime).TotalSeconds,1))s)" # 3) Delta-scan every drive and keep items that carry the "shared" facet. $DriveByRequestId = @{} @@ -100,9 +118,20 @@ function Set-CIPPDBCacheSharePointSharingLinks { $SharedItems = [System.Collections.Generic.List[object]]::new() $FailedDrives = 0 if (@($DeltaRequests).Count -gt 0) { - $DeltaResponses = New-GraphBulkRequest -tenantid $TenantFilter -Requests @($DeltaRequests) -asapp $true + Write-Host "[SharingLinks][$TenantFilter] Phase 3 (delta scan): scanning $(@($DeltaRequests).Count) drives for shared items" + try { + $DeltaResponses = New-GraphBulkRequest -tenantid $TenantFilter -Requests @($DeltaRequests) -asapp $true + } catch { + Write-Host "[SharingLinks][$TenantFilter] Phase 3 (delta scan) BULK FAILED after $([math]::Round(((Get-Date) - $StartTime).TotalSeconds,1))s: $($_.Exception.Message)" + throw + } foreach ($Response in $DeltaResponses) { - if ($Response.status -and $Response.status -ne 200) { $FailedDrives++; continue } + if ($Response.status -and $Response.status -ne 200) { + $FailedDrives++ + $FailedEntry = $DriveByRequestId["$($Response.id)"] + Write-Host "[SharingLinks][$TenantFilter] Phase 3: drive delta returned status $($Response.status) for '$($FailedEntry.Site.webUrl)' / '$($FailedEntry.Drive.name)' - $($Response.body.error.message)" + continue + } $Entry = $DriveByRequestId["$($Response.id)"] foreach ($Item in @($Response.body.value)) { if ($Item.shared -and -not $Item.deleted) { @@ -111,6 +140,7 @@ function Set-CIPPDBCacheSharePointSharingLinks { } } } + Write-Host "[SharingLinks][$TenantFilter] Phase 3 (delta scan): $($SharedItems.Count) shared items, $FailedDrives drive scans failed (+$([math]::Round(((Get-Date) - $StartTime).TotalSeconds,1))s)" # 4) Fetch the permissions of every shared item, in bulk. $SharedItemByRequestId = @{} @@ -127,10 +157,17 @@ function Set-CIPPDBCacheSharePointSharingLinks { # 5) Build one row per sharing link (any scope) or direct grant to an external user. $Rows = [System.Collections.Generic.List[object]]::new() + $FailedPermissionLookups = 0 if (@($PermissionRequests).Count -gt 0) { - $PermissionResponses = New-GraphBulkRequest -tenantid $TenantFilter -Requests @($PermissionRequests) -asapp $true + Write-Host "[SharingLinks][$TenantFilter] Phase 4 (permissions): fetching permissions for $(@($PermissionRequests).Count) shared items" + try { + $PermissionResponses = New-GraphBulkRequest -tenantid $TenantFilter -Requests @($PermissionRequests) -asapp $true + } catch { + Write-Host "[SharingLinks][$TenantFilter] Phase 4 (permissions) BULK FAILED after $([math]::Round(((Get-Date) - $StartTime).TotalSeconds,1))s: $($_.Exception.Message)" + throw + } foreach ($Response in $PermissionResponses) { - if ($Response.status -and $Response.status -ne 200) { continue } + if ($Response.status -and $Response.status -ne 200) { $FailedPermissionLookups++; continue } $Entry = $SharedItemByRequestId["$($Response.id)"] $Site = $Entry.Site $Drive = $Entry.Drive @@ -202,12 +239,15 @@ function Set-CIPPDBCacheSharePointSharingLinks { } } + Write-Host "[SharingLinks][$TenantFilter] Phase 5 (write): writing $($Rows.Count) rows to cache ($FailedPermissionLookups permission lookups failed)" Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'SharePointSharingLinks' -Data @($Rows) -AddCount $Message = "Cached $($Rows.Count) sharing links across $($DriveEntries.Count) drives" + $(if ($FailedDrives -gt 0) { " ($FailedDrives drives could not be scanned)" } else { '' }) + Write-Host "[SharingLinks][$TenantFilter] DONE: $Message (total $([math]::Round(((Get-Date) - $StartTime).TotalSeconds,1))s)" Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message $Message -sev Debug } catch { + Write-Host "[SharingLinks][$TenantFilter] ABORTED after $([math]::Round(((Get-Date) - $StartTime).TotalSeconds,1))s - existing cache left intact. Error: $($_.Exception.Message)" Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to cache SharePoint sharing links: $($_.Exception.Message)" -sev Error -LogData (Get-CippException -Exception $_) } } From 6c86428ccd5fabcd8898fd4abe705940b68a205a Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Thu, 9 Jul 2026 16:52:18 +0200 Subject: [PATCH 143/170] oom management --- .../Set-CIPPDBCacheSharePointSharingLinks.ps1 | 103 ++++++++++++------ 1 file changed, 69 insertions(+), 34 deletions(-) diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheSharePointSharingLinks.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheSharePointSharingLinks.ps1 index 7558418db2c59..f719c202c8f43 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheSharePointSharingLinks.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheSharePointSharingLinks.ps1 @@ -103,28 +103,40 @@ function Set-CIPPDBCacheSharePointSharingLinks { Write-Host "[SharingLinks][$TenantFilter] Phase 2 (site drives): $($DriveEntries.Count) drives, $FailedDriveLookups site drive lookups failed (+$([math]::Round(((Get-Date) - $StartTime).TotalSeconds,1))s)" # 3) Delta-scan every drive and keep items that carry the "shared" facet. - $DriveByRequestId = @{} - $RequestId = 0 - $DeltaRequests = foreach ($Entry in $DriveEntries) { - $DriveByRequestId["$RequestId"] = $Entry - @{ - id = "$RequestId" - method = 'GET' - url = "drives/$($Entry.Drive.id)/root/delta?`$select=id,name,webUrl,parentReference,file,folder,shared,size,lastModifiedDateTime&`$top=999" - } - $RequestId++ - } - + # Enumerating every driveItem across hundreds of drives in a single bulk call buffers + # the whole tenant's file tree in memory and OOMs the worker on large tenants. Scan a + # small number of drives per bulk call instead, extract only shared items, and release + # the raw item pages between batches to keep peak memory bounded. + $DeltaBatchSize = 20 $SharedItems = [System.Collections.Generic.List[object]]::new() $FailedDrives = 0 - if (@($DeltaRequests).Count -gt 0) { - Write-Host "[SharingLinks][$TenantFilter] Phase 3 (delta scan): scanning $(@($DeltaRequests).Count) drives for shared items" + $DriveTotal = $DriveEntries.Count + Write-Host "[SharingLinks][$TenantFilter] Phase 3 (delta scan): scanning $DriveTotal drives for shared items in batches of $DeltaBatchSize" + for ($Offset = 0; $Offset -lt $DriveTotal; $Offset += $DeltaBatchSize) { + $BatchEntries = @($DriveEntries[$Offset..([Math]::Min($Offset + $DeltaBatchSize - 1, $DriveTotal - 1))]) + $DriveByRequestId = @{} + $RequestId = 0 + $DeltaRequests = foreach ($Entry in $BatchEntries) { + $DriveByRequestId["$RequestId"] = $Entry + @{ + id = "$RequestId" + method = 'GET' + url = "drives/$($Entry.Drive.id)/root/delta?`$select=id,name,webUrl,folder,shared,size,lastModifiedDateTime&`$top=999" + } + $RequestId++ + } + try { $DeltaResponses = New-GraphBulkRequest -tenantid $TenantFilter -Requests @($DeltaRequests) -asapp $true } catch { - Write-Host "[SharingLinks][$TenantFilter] Phase 3 (delta scan) BULK FAILED after $([math]::Round(((Get-Date) - $StartTime).TotalSeconds,1))s: $($_.Exception.Message)" - throw + # A whole-batch failure (throttle/OOM/token) loses only this batch's drives, not the run. + $FailedDrives += @($BatchEntries).Count + Write-Host "[SharingLinks][$TenantFilter] Phase 3: batch at offset $Offset ($(@($BatchEntries).Count) drives) BULK FAILED: $($_.Exception.Message)" + $DeltaResponses = $null + [System.GC]::Collect() + continue } + foreach ($Response in $DeltaResponses) { if ($Response.status -and $Response.status -ne 200) { $FailedDrives++ @@ -139,33 +151,52 @@ function Set-CIPPDBCacheSharePointSharingLinks { } } } - } - Write-Host "[SharingLinks][$TenantFilter] Phase 3 (delta scan): $($SharedItems.Count) shared items, $FailedDrives drive scans failed (+$([math]::Round(((Get-Date) - $StartTime).TotalSeconds,1))s)" - # 4) Fetch the permissions of every shared item, in bulk. - $SharedItemByRequestId = @{} - $RequestId = 0 - $PermissionRequests = foreach ($Entry in $SharedItems) { - $SharedItemByRequestId["$RequestId"] = $Entry - @{ - id = "$RequestId" - method = 'GET' - url = "drives/$($Entry.Drive.id)/items/$($Entry.Item.id)/permissions" - } - $RequestId++ + # Release this batch's raw item pages before the next batch. + $DeltaResponses = $null + [System.GC]::Collect() + Write-Host "[SharingLinks][$TenantFilter] Phase 3: processed $([Math]::Min($Offset + $DeltaBatchSize, $DriveTotal))/$DriveTotal drives, $($SharedItems.Count) shared so far (+$([math]::Round(((Get-Date) - $StartTime).TotalSeconds,1))s)" } + # If every drive failed, don't proceed to overwrite good cached data with an empty set. + if ($DriveTotal -gt 0 -and $FailedDrives -ge $DriveTotal) { + throw "All $DriveTotal drive delta scans failed - aborting to preserve existing cache." + } + Write-Host "[SharingLinks][$TenantFilter] Phase 3 (delta scan): $($SharedItems.Count) shared items, $FailedDrives drive scans failed (+$([math]::Round(((Get-Date) - $StartTime).TotalSeconds,1))s)" - # 5) Build one row per sharing link (any scope) or direct grant to an external user. + # 4) Fetch the permissions of every shared item and 5) build one row per sharing link (any + # scope) or direct grant to an external user. Batched for the same memory reason as the + # delta scan above. + $PermissionBatchSize = 100 $Rows = [System.Collections.Generic.List[object]]::new() $FailedPermissionLookups = 0 - if (@($PermissionRequests).Count -gt 0) { - Write-Host "[SharingLinks][$TenantFilter] Phase 4 (permissions): fetching permissions for $(@($PermissionRequests).Count) shared items" + $SharedTotal = $SharedItems.Count + if ($SharedTotal -gt 0) { + Write-Host "[SharingLinks][$TenantFilter] Phase 4 (permissions): fetching permissions for $SharedTotal shared items in batches of $PermissionBatchSize" + } + for ($Offset = 0; $Offset -lt $SharedTotal; $Offset += $PermissionBatchSize) { + $BatchShared = @($SharedItems[$Offset..([Math]::Min($Offset + $PermissionBatchSize - 1, $SharedTotal - 1))]) + $SharedItemByRequestId = @{} + $RequestId = 0 + $PermissionRequests = foreach ($Entry in $BatchShared) { + $SharedItemByRequestId["$RequestId"] = $Entry + @{ + id = "$RequestId" + method = 'GET' + url = "drives/$($Entry.Drive.id)/items/$($Entry.Item.id)/permissions" + } + $RequestId++ + } + try { $PermissionResponses = New-GraphBulkRequest -tenantid $TenantFilter -Requests @($PermissionRequests) -asapp $true } catch { - Write-Host "[SharingLinks][$TenantFilter] Phase 4 (permissions) BULK FAILED after $([math]::Round(((Get-Date) - $StartTime).TotalSeconds,1))s: $($_.Exception.Message)" - throw + $FailedPermissionLookups += @($BatchShared).Count + Write-Host "[SharingLinks][$TenantFilter] Phase 4: batch at offset $Offset ($(@($BatchShared).Count) items) BULK FAILED: $($_.Exception.Message)" + $PermissionResponses = $null + [System.GC]::Collect() + continue } + foreach ($Response in $PermissionResponses) { if ($Response.status -and $Response.status -ne 200) { $FailedPermissionLookups++; continue } $Entry = $SharedItemByRequestId["$($Response.id)"] @@ -237,6 +268,10 @@ function Set-CIPPDBCacheSharePointSharingLinks { }) } } + + $PermissionResponses = $null + [System.GC]::Collect() + Write-Host "[SharingLinks][$TenantFilter] Phase 4: processed $([Math]::Min($Offset + $PermissionBatchSize, $SharedTotal))/$SharedTotal shared items, $($Rows.Count) links so far (+$([math]::Round(((Get-Date) - $StartTime).TotalSeconds,1))s)" } Write-Host "[SharingLinks][$TenantFilter] Phase 5 (write): writing $($Rows.Count) rows to cache ($FailedPermissionLookups permission lookups failed)" From 82ac60ee99ef117bf11103fa9821b7df47e0f1bb Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Thu, 9 Jul 2026 17:13:23 +0200 Subject: [PATCH 144/170] introducing streaming --- .../Set-CIPPDBCacheSharePointSharingLinks.ps1 | 92 +++++++++++++------ 1 file changed, 66 insertions(+), 26 deletions(-) diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheSharePointSharingLinks.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheSharePointSharingLinks.ps1 index f719c202c8f43..707181c18f8eb 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheSharePointSharingLinks.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheSharePointSharingLinks.ps1 @@ -42,6 +42,26 @@ function Set-CIPPDBCacheSharePointSharingLinks { $Identity.user.email ?? $Identity.user.userPrincipalName ?? $Identity.siteUser.email ?? $Identity.user.displayName ?? $Identity.siteUser.displayName ?? $Identity.group.email ?? $Identity.group.displayName ?? $Identity.siteGroup.displayName } + # Flatten a shared driveItem to only the scalars we need downstream. Keeping full Graph + # Site/Drive/Item objects in $SharedItems across the whole tenant is a major memory sink. + function New-CIPPLeanSharedItem { + param($Item, $Site, $Drive) + [PSCustomObject]@{ + SiteId = $Site.id + SiteName = $Site.displayName ?? $Site.name + SiteUrl = $Site.webUrl + IsPersonalSite = [bool]$Site.isPersonalSite + DriveId = $Drive.id + DriveName = $Drive.name + ItemId = $Item.id + FileName = $Item.name + ItemUrl = $Item.webUrl + IsFolder = [bool]$Item.folder + Size = $Item.size + LastModified = $Item.lastModifiedDateTime + } + } + $StartTime = Get-Date Write-Host "[SharingLinks][$TenantFilter] START at $($StartTime.ToString('o'))" try { @@ -103,13 +123,16 @@ function Set-CIPPDBCacheSharePointSharingLinks { Write-Host "[SharingLinks][$TenantFilter] Phase 2 (site drives): $($DriveEntries.Count) drives, $FailedDriveLookups site drive lookups failed (+$([math]::Round(((Get-Date) - $StartTime).TotalSeconds,1))s)" # 3) Delta-scan every drive and keep items that carry the "shared" facet. - # Enumerating every driveItem across hundreds of drives in a single bulk call buffers - # the whole tenant's file tree in memory and OOMs the worker on large tenants. Scan a - # small number of drives per bulk call instead, extract only shared items, and release - # the raw item pages between batches to keep peak memory bounded. + # A bulk delta over many drives auto-follows every drive's nextLinks and buffers the + # whole file tree in memory, OOM-killing the worker on tenants with huge libraries. + # Strategy: bulk only the FIRST delta page per drive (fast + parallel for the common + # case that fits in one page) via -NoPaginateIds, then stream any large drive's + # remaining pages one page at a time, discarding non-shared items as they flow. Only + # lean scalar objects are retained. $DeltaBatchSize = 20 $SharedItems = [System.Collections.Generic.List[object]]::new() $FailedDrives = 0 + $LargeDrives = 0 $DriveTotal = $DriveEntries.Count Write-Host "[SharingLinks][$TenantFilter] Phase 3 (delta scan): scanning $DriveTotal drives for shared items in batches of $DeltaBatchSize" for ($Offset = 0; $Offset -lt $DriveTotal; $Offset += $DeltaBatchSize) { @@ -127,9 +150,11 @@ function Set-CIPPDBCacheSharePointSharingLinks { } try { - $DeltaResponses = New-GraphBulkRequest -tenantid $TenantFilter -Requests @($DeltaRequests) -asapp $true + # -NoPaginateIds with every id => bulk returns only each drive's first page and + # leaves its @odata.nextLink intact instead of buffering all continuations. + $DeltaResponses = New-GraphBulkRequest -tenantid $TenantFilter -Requests @($DeltaRequests) -asapp $true -NoPaginateIds @($DeltaRequests.id) } catch { - # A whole-batch failure (throttle/OOM/token) loses only this batch's drives, not the run. + # A whole-batch failure (throttle/token) loses only this batch's drives, not the run. $FailedDrives += @($BatchEntries).Count Write-Host "[SharingLinks][$TenantFilter] Phase 3: batch at offset $Offset ($(@($BatchEntries).Count) drives) BULK FAILED: $($_.Exception.Message)" $DeltaResponses = $null @@ -145,9 +170,27 @@ function Set-CIPPDBCacheSharePointSharingLinks { continue } $Entry = $DriveByRequestId["$($Response.id)"] + $Site = $Entry.Site + $Drive = $Entry.Drive + + # First page (already fetched in bulk). foreach ($Item in @($Response.body.value)) { if ($Item.shared -and -not $Item.deleted) { - $SharedItems.Add([PSCustomObject]@{ Site = $Entry.Site; Drive = $Entry.Drive; Item = $Item }) + $SharedItems.Add((New-CIPPLeanSharedItem -Item $Item -Site $Site -Drive $Drive)) + } + } + + # Large drive: stream the remaining pages one at a time to bound memory. + $NextLink = $Response.body.'@odata.nextLink' + if ($NextLink) { + $LargeDrives++ + try { + New-GraphGetRequest -uri $NextLink -tenantid $TenantFilter -asapp $true -Stream | + Where-Object { $_.shared -and -not $_.deleted } | + ForEach-Object { $SharedItems.Add((New-CIPPLeanSharedItem -Item $_ -Site $Site -Drive $Drive)) } + } catch { + $FailedDrives++ + Write-Host "[SharingLinks][$TenantFilter] Phase 3: streaming remaining pages FAILED for '$($Site.webUrl)' / '$($Drive.name)': $($_.Exception.Message)" } } } @@ -155,13 +198,13 @@ function Set-CIPPDBCacheSharePointSharingLinks { # Release this batch's raw item pages before the next batch. $DeltaResponses = $null [System.GC]::Collect() - Write-Host "[SharingLinks][$TenantFilter] Phase 3: processed $([Math]::Min($Offset + $DeltaBatchSize, $DriveTotal))/$DriveTotal drives, $($SharedItems.Count) shared so far (+$([math]::Round(((Get-Date) - $StartTime).TotalSeconds,1))s)" + Write-Host "[SharingLinks][$TenantFilter] Phase 3: processed $([Math]::Min($Offset + $DeltaBatchSize, $DriveTotal))/$DriveTotal drives, $($SharedItems.Count) shared so far, $LargeDrives large drives streamed (+$([math]::Round(((Get-Date) - $StartTime).TotalSeconds,1))s)" } # If every drive failed, don't proceed to overwrite good cached data with an empty set. if ($DriveTotal -gt 0 -and $FailedDrives -ge $DriveTotal) { throw "All $DriveTotal drive delta scans failed - aborting to preserve existing cache." } - Write-Host "[SharingLinks][$TenantFilter] Phase 3 (delta scan): $($SharedItems.Count) shared items, $FailedDrives drive scans failed (+$([math]::Round(((Get-Date) - $StartTime).TotalSeconds,1))s)" + Write-Host "[SharingLinks][$TenantFilter] Phase 3 (delta scan): $($SharedItems.Count) shared items, $FailedDrives drive scans failed, $LargeDrives large drives streamed (+$([math]::Round(((Get-Date) - $StartTime).TotalSeconds,1))s)" # 4) Fetch the permissions of every shared item and 5) build one row per sharing link (any # scope) or direct grant to an external user. Batched for the same memory reason as the @@ -182,7 +225,7 @@ function Set-CIPPDBCacheSharePointSharingLinks { @{ id = "$RequestId" method = 'GET' - url = "drives/$($Entry.Drive.id)/items/$($Entry.Item.id)/permissions" + url = "drives/$($Entry.DriveId)/items/$($Entry.ItemId)/permissions" } $RequestId++ } @@ -200,9 +243,6 @@ function Set-CIPPDBCacheSharePointSharingLinks { foreach ($Response in $PermissionResponses) { if ($Response.status -and $Response.status -ne 200) { $FailedPermissionLookups++; continue } $Entry = $SharedItemByRequestId["$($Response.id)"] - $Site = $Entry.Site - $Drive = $Entry.Drive - $Item = $Entry.Item foreach ($Permission in @($Response.body.value)) { # Only permissions set on the item itself; inherited ones are reported on their parent. @@ -243,19 +283,19 @@ function Set-CIPPDBCacheSharePointSharingLinks { $SharedWith = @($Recipients | ForEach-Object { Get-CIPPIdentityLabel -Identity $_ } | Where-Object { $_ } | Sort-Object -Unique) $Rows.Add([PSCustomObject]@{ - id = "$($Drive.id)_$($Item.id)_$($Permission.id)" - siteId = $Site.id - siteName = $Site.displayName ?? $Site.name - siteUrl = $Site.webUrl - workload = if ($Site.isPersonalSite) { 'OneDrive' } else { 'SharePoint' } - driveId = $Drive.id - driveName = $Drive.name - itemId = $Item.id - fileName = $Item.name - itemUrl = $Item.webUrl - itemType = if ($Item.folder) { 'Folder' } else { 'File' } - size = $Item.size - lastModifiedDateTime = $Item.lastModifiedDateTime + id = "$($Entry.DriveId)_$($Entry.ItemId)_$($Permission.id)" + siteId = $Entry.SiteId + siteName = $Entry.SiteName + siteUrl = $Entry.SiteUrl + workload = if ($Entry.IsPersonalSite) { 'OneDrive' } else { 'SharePoint' } + driveId = $Entry.DriveId + driveName = $Entry.DriveName + itemId = $Entry.ItemId + fileName = $Entry.FileName + itemUrl = $Entry.ItemUrl + itemType = if ($Entry.IsFolder) { 'Folder' } else { 'File' } + size = $Entry.Size + lastModifiedDateTime = $Entry.LastModified permissionId = $Permission.id linkType = $LinkType linkScope = $LinkScope From da30ffcadb00a0b55b19fe50350ef620f248d3dc Mon Sep 17 00:00:00 2001 From: John Duprey Date: Thu, 9 Jul 2026 11:20:10 -0400 Subject: [PATCH 145/170] feat: Add SAM cert lifecycle management and endpoint Introduces full SAM certificate lifecycle support: create, load (with cache), store (Key Vault cert with secret fallback), and renew with app-registration key credential sync/rollback logic. The weekly token timer now auto-renews the SAM cert when missing or near expiry. Graph/EXO helpers gain a `UseCertificate` path so app-only requests can authenticate via the stored SAM certificate, and certificate-based token acquisition now includes token caching plus transient AADSTS700027 retry handling. Adds a new `Invoke-ExecSAMCertificate` HTTP endpoint for status and forced renewal, and blocks SAM certificate management commands from scheduler/ExecCippFunction execution. --- .../Start-UpdateTokensTimer.ps1 | 12 ++ .../Public/Get-CIPPSAMCertificate.ps1 | 95 ++++++++++++ .../GraphHelper/Get-GraphTokenFromCert.ps1 | 60 +++++++- .../Public/GraphHelper/New-ExoRequest.ps1 | 15 +- .../GraphHelper/New-GraphGetRequest.ps1 | 19 ++- .../GraphHelper/New-GraphPOSTRequest.ps1 | 15 ++ .../Public/New-CIPPSAMCertificate.ps1 | 103 +++++++++++++ .../Public/Set-CIPPSAMCertificate.ps1 | 107 ++++++++++++++ .../Get-CIPPSchedulerBlockedCommands.ps1 | 4 + .../Public/Update-CIPPSAMCertificate.ps1 | 138 ++++++++++++++++++ .../CIPP/Core/Invoke-ExecCippFunction.ps1 | 4 + .../CIPP/Core/Invoke-ExecSAMCertificate.ps1 | 75 ++++++++++ 12 files changed, 637 insertions(+), 10 deletions(-) create mode 100644 Modules/CIPPCore/Public/Get-CIPPSAMCertificate.ps1 create mode 100644 Modules/CIPPCore/Public/New-CIPPSAMCertificate.ps1 create mode 100644 Modules/CIPPCore/Public/Set-CIPPSAMCertificate.ps1 create mode 100644 Modules/CIPPCore/Public/Update-CIPPSAMCertificate.ps1 create mode 100644 Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecSAMCertificate.ps1 diff --git a/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-UpdateTokensTimer.ps1 b/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-UpdateTokensTimer.ps1 index b7e40c3cc9fd6..77a45fc69813e 100644 --- a/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-UpdateTokensTimer.ps1 +++ b/Modules/CIPPCore/Public/Entrypoints/Timer Functions/Start-UpdateTokensTimer.ps1 @@ -122,6 +122,18 @@ function Start-UpdateTokensTimer { Write-LogMessage -API 'Update Tokens' -message 'Error updating application secret, will try again in 7 days' -sev 'CRITICAL' -LogData (Get-CippException -Exception $_) } + # Create or renew the SAM certificate when missing or within 30 days of expiry + try { + $CertResult = Update-CIPPSAMCertificate -ErrorAction Stop + if ($CertResult.Renewed) { + Write-Information "SAM certificate renewed. Thumbprint: $($CertResult.Thumbprint), expires: $($CertResult.NotAfter), storage mode: $($CertResult.StorageMode)" + } + } catch { + Write-Warning "Error updating SAM certificate $($_.Exception.Message)." + Write-Information ($_.InvocationInfo.PositionMessage) + Write-LogMessage -API 'Update Tokens' -message 'Error updating SAM certificate, will try again in 7 days' -sev 'CRITICAL' -LogData (Get-CippException -Exception $_) + } + # Get new refresh token for each direct added tenant $TenantList = Get-Tenants -IncludeAll | Where-Object { $_.Excluded -eq $false -and $_.delegatedPrivilegeStatus -eq 'directTenant' } if ($TenantList.Count -eq 0) { diff --git a/Modules/CIPPCore/Public/Get-CIPPSAMCertificate.ps1 b/Modules/CIPPCore/Public/Get-CIPPSAMCertificate.ps1 new file mode 100644 index 0000000000000..918dbb5091b74 --- /dev/null +++ b/Modules/CIPPCore/Public/Get-CIPPSAMCertificate.ps1 @@ -0,0 +1,95 @@ +function Get-CIPPSAMCertificate { + <# + .SYNOPSIS + Retrieves the SAM certificate from Key Vault or the DevSecrets table + + .DESCRIPTION + Loads the stored SAM certificate PFX and materializes it as an X509Certificate2 with + its private key, for use with Get-GraphTokenFromCert. The read path is storage-mode + agnostic: a Key Vault certificate's private key is exposed through the secrets endpoint + under the same name, so a single secret GET covers both the certificate and the + secret-fallback storage modes. Read-only - never creates or renews a certificate. + + .PARAMETER Name + Storage name of the certificate. Defaults to SAMCertificate. + + .PARAMETER VaultName + Name of the Key Vault. If not provided, derives via Get-CippKeyVaultName. + + .PARAMETER SkipCache + Bypass the in-memory certificate cache and fetch from storage. + + .EXAMPLE + $SAMCert = Get-CIPPSAMCertificate + Get-GraphTokenFromCert -TenantId $env:TenantID -AppId $env:ApplicationID -Certificate $SAMCert.Certificate + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $false)] + [string]$Name = 'SAMCertificate', + + [Parameter(Mandatory = $false)] + [string]$VaultName, + + [switch]$SkipCache + ) + + # Serve from cache when fresh (1 hour TTL) + if (-not $SkipCache -and $script:SAMCertificateCache.$Name -and $script:SAMCertificateCache.$Name.FetchedAt -gt (Get-Date).AddHours(-1)) { + return $script:SAMCertificateCache.$Name.Result + } + + if ($env:AzureWebJobsStorage -eq 'UseDevelopmentStorage=true' -or $env:NonLocalHostAzurite -eq 'true') { + $Table = Get-CIPPTable -tablename 'DevSecrets' + $Secret = Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq 'Secret' and RowKey eq 'Secret'" + $PfxBase64 = $Secret.$Name + } else { + try { + $PfxBase64 = Get-CippKeyVaultSecret -VaultName $VaultName -Name $Name -AsPlainText -ErrorAction Stop + } catch { + # A missing secret means the certificate has not been created yet (bootstrap pending) + if ($_.Exception.Message -match 'SecretNotFound|404') { + return $null + } + throw + } + } + + if ([string]::IsNullOrEmpty($PfxBase64)) { + return $null + } + + $PfxBytes = [Convert]::FromBase64String($PfxBase64) + try { + # Ephemeral avoids writing key files to disk on the Linux consumption plan + $Certificate = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new( + $PfxBytes, + [string]::Empty, + [System.Security.Cryptography.X509Certificates.X509KeyStorageFlags]::EphemeralKeySet + ) + } catch { + # EphemeralKeySet is not supported on all platforms (e.g. macOS local dev) + $Certificate = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new( + $PfxBytes, + [string]::Empty, + [System.Security.Cryptography.X509Certificates.X509KeyStorageFlags]::Exportable + ) + } + + $Result = [PSCustomObject]@{ + Certificate = $Certificate + Thumbprint = $Certificate.Thumbprint + NotBefore = $Certificate.NotBefore.ToUniversalTime() + NotAfter = $Certificate.NotAfter.ToUniversalTime() + } + + if (-not $script:SAMCertificateCache) { + $script:SAMCertificateCache = [HashTable]::Synchronized(@{}) + } + $script:SAMCertificateCache.$Name = @{ + Result = $Result + FetchedAt = Get-Date + } + + return $Result +} diff --git a/Modules/CIPPCore/Public/GraphHelper/Get-GraphTokenFromCert.ps1 b/Modules/CIPPCore/Public/GraphHelper/Get-GraphTokenFromCert.ps1 index 7d619a133d9fe..274777b14c7d6 100644 --- a/Modules/CIPPCore/Public/GraphHelper/Get-GraphTokenFromCert.ps1 +++ b/Modules/CIPPCore/Public/GraphHelper/Get-GraphTokenFromCert.ps1 @@ -8,12 +8,34 @@ function Get-GraphTokenFromCert { [string]$Scope = 'https://graph.microsoft.com/.default', [Parameter(Mandatory = $true)] - [System.Security.Cryptography.X509Certificates.X509Certificate2]$Certificate + [System.Security.Cryptography.X509Certificates.X509Certificate2]$Certificate, + [switch]$SkipCache ) ######################################################### # Create Bearer Token From Certificate for HBU Graph ######################################################### + # Check the token cache before building a new assertion. Uses the shared .NET cache when + # loaded (cross-runspace), otherwise a script-scope fallback (local dev). + $UseSharedTokenCache = ($SkipCache -ne $true) -and ($null -ne ('CIPP.CIPPTokenCache' -as [type])) + if ($UseSharedTokenCache) { + $TokenCacheKey = [CIPP.CIPPTokenCache]::BuildKey([string]$TenantId, [string]$Scope, $true, [string]$AppId, 'client_credentials_certificate') + $CacheEntry = [CIPP.CIPPTokenCache]::Lookup($TokenCacheKey, 120) + if ($CacheEntry.Found -and -not [string]::IsNullOrWhiteSpace($CacheEntry.TokenPayloadJson)) { + try { + return ($CacheEntry.TokenPayloadJson | ConvertFrom-Json -ErrorAction Stop) + } catch { + [CIPP.CIPPTokenCache]::Remove($TokenCacheKey) + } + } + } elseif ($SkipCache -ne $true) { + $ScriptCacheKey = "$TenantId|$AppId|$Scope" + $Cached = $script:CertTokenCache.$ScriptCacheKey + if ($Cached.expires_on -and [int](Get-Date -UFormat %s -Millisecond 0) -lt ($Cached.expires_on - 120)) { + return $Cached + } + } + # get sha256 hash of certificate $sha256 = New-Object System.Security.Cryptography.SHA256CryptoServiceProvider $hash = $sha256.ComputeHash($Certificate.RawData) @@ -108,10 +130,38 @@ function Get-GraphTokenFromCert { Headers = $Header } - try { - return Invoke-CIPPRestMethod @PostSplat - } catch { - Write-Error $_ + # AADSTS700027 occurs transiently after certificate rotation while the load-balanced + # token service nodes catch up with the directory - retry briefly before giving up. + $MaxRetries = 3 + for ($Attempt = 1; $Attempt -le $MaxRetries; $Attempt++) { + try { + $AccessToken = Invoke-CIPPRestMethod @PostSplat -ErrorAction Stop + if ($AccessToken.access_token) { + if ($null -eq $AccessToken.expires_on -and $AccessToken.expires_in) { + $ExpiresOn = [int](Get-Date -UFormat %s -Millisecond 0) + $AccessToken.expires_in + Add-Member -InputObject $AccessToken -NotePropertyName 'expires_on' -NotePropertyValue $ExpiresOn -Force + } + if ($UseSharedTokenCache) { + try { + [CIPP.CIPPTokenCache]::Store($TokenCacheKey, ($AccessToken | ConvertTo-Json -Depth 20 -Compress), [int64]$AccessToken.expires_on) + } catch { + # Ignore shared cache write failures + } + } elseif ($SkipCache -ne $true) { + if (-not $script:CertTokenCache) { $script:CertTokenCache = [HashTable]::Synchronized(@{}) } + $script:CertTokenCache.$ScriptCacheKey = $AccessToken + } + } + return $AccessToken + } catch { + if ($Attempt -lt $MaxRetries -and $_.ErrorDetails.Message -match 'AADSTS700027') { + Write-Warning "Certificate not yet recognized by the token service (attempt $Attempt of $MaxRetries). Retrying in 10 seconds." + Start-Sleep -Seconds 10 + } else { + Write-Error $_ + return + } + } } } diff --git a/Modules/CIPPCore/Public/GraphHelper/New-ExoRequest.ps1 b/Modules/CIPPCore/Public/GraphHelper/New-ExoRequest.ps1 index f562d32c2eedc..570ab8d1f043a 100644 --- a/Modules/CIPPCore/Public/GraphHelper/New-ExoRequest.ps1 +++ b/Modules/CIPPCore/Public/GraphHelper/New-ExoRequest.ps1 @@ -32,7 +32,8 @@ function New-ExoRequest { [switch]$AvailableCmdlets, $ModuleVersion = '3.9.2', - [switch]$AsApp + [switch]$AsApp, + [switch]$UseCertificate ) if ((Get-AuthorisedRequest -TenantID $tenantid) -or $NoAuthCheck -eq $True) { if ($Compliance.IsPresent) { @@ -40,7 +41,17 @@ function New-ExoRequest { } else { $Resource = 'https://outlook.office365.com' } - $token = Get-GraphToken -Tenantid $tenantid -scope "$Resource/.default" -AsApp:$AsApp.IsPresent + if ($UseCertificate) { + # App-only auth using the stored SAM certificate (always client_credentials) + $SAMCert = Get-CIPPSAMCertificate -ErrorAction Stop + if (-not $SAMCert) { throw 'No SAM certificate available. Run Update-CIPPSAMCertificate to create one.' } + $CertTenantId = if ($tenantid) { $tenantid } else { $env:TenantID } + $CertToken = Get-GraphTokenFromCert -TenantId $CertTenantId -AppId $env:ApplicationID -Scope "$Resource/.default" -Certificate $SAMCert.Certificate -ErrorAction Stop + if (-not $CertToken.access_token) { throw "Could not get a token using the SAM certificate for tenant $tenantid" } + $token = @{ Authorization = "Bearer $($CertToken.access_token)" } + } else { + $token = Get-GraphToken -Tenantid $tenantid -scope "$Resource/.default" -AsApp:$AsApp.IsPresent + } if ($cmdParams) { #if cmdParams is a pscustomobject, convert to hashtable, otherwise leave as is diff --git a/Modules/CIPPCore/Public/GraphHelper/New-GraphGetRequest.ps1 b/Modules/CIPPCore/Public/GraphHelper/New-GraphGetRequest.ps1 index 6abf600735435..89cfcc6885e80 100644 --- a/Modules/CIPPCore/Public/GraphHelper/New-GraphGetRequest.ps1 +++ b/Modules/CIPPCore/Public/GraphHelper/New-GraphGetRequest.ps1 @@ -25,6 +25,7 @@ function New-GraphGetRequest { # (e.g. Set-CIPPDBCache* | Add-CIPPDbItem). Trade-off: on a mid-pagination # failure, pages already emitted have flowed downstream before the throw. [switch]$Stream, + [switch]$UseCertificate, $Headers ) @@ -38,10 +39,22 @@ function New-GraphGetRequest { if ($headers) { $headers = $Headers } else { - if ($scope -eq 'ExchangeOnline') { - $headers = Get-GraphToken -tenantid $tenantid -scope 'https://outlook.office365.com/.default' -AsApp $asapp -SkipCache $skipTokenCache + $TokenScope = if ($scope -eq 'ExchangeOnline') { 'https://outlook.office365.com/.default' } else { $scope } + if ($UseCertificate) { + # App-only auth using the stored SAM certificate (always client_credentials) + $SAMCert = Get-CIPPSAMCertificate -ErrorAction Stop + if (-not $SAMCert) { throw 'No SAM certificate available. Run Update-CIPPSAMCertificate to create one.' } + $CertTokenSplat = @{ + TenantId = if ($tenantid) { $tenantid } else { $env:TenantID } + AppId = $env:ApplicationID + Certificate = $SAMCert.Certificate + } + if ($TokenScope) { $CertTokenSplat.Scope = $TokenScope } + $CertToken = Get-GraphTokenFromCert @CertTokenSplat -ErrorAction Stop + if (-not $CertToken.access_token) { throw "Could not get a token using the SAM certificate for tenant $tenantid" } + $headers = @{ Authorization = "Bearer $($CertToken.access_token)" } } else { - $headers = Get-GraphToken -tenantid $tenantid -scope $scope -AsApp $asapp -SkipCache $skipTokenCache + $headers = Get-GraphToken -tenantid $tenantid -scope $TokenScope -AsApp $asapp -SkipCache $skipTokenCache } } if ($ComplexFilter) { diff --git a/Modules/CIPPCore/Public/GraphHelper/New-GraphPOSTRequest.ps1 b/Modules/CIPPCore/Public/GraphHelper/New-GraphPOSTRequest.ps1 index 904972d98fe65..ac9ab5302c93c 100644 --- a/Modules/CIPPCore/Public/GraphHelper/New-GraphPOSTRequest.ps1 +++ b/Modules/CIPPCore/Public/GraphHelper/New-GraphPOSTRequest.ps1 @@ -19,12 +19,26 @@ function New-GraphPOSTRequest { $returnHeaders = $false, $maxRetries = 3, $ScheduleRetry = $false, + [switch]$UseCertificate, $headers ) if ($NoAuthCheck -or (Get-AuthorisedRequest -Uri $uri -TenantID $tenantid)) { if ($Headers) { $Headers = $Headers + } elseif ($UseCertificate) { + # App-only auth using the stored SAM certificate (always client_credentials) + $SAMCert = Get-CIPPSAMCertificate -ErrorAction Stop + if (-not $SAMCert) { throw 'No SAM certificate available. Run Update-CIPPSAMCertificate to create one.' } + $CertTokenSplat = @{ + TenantId = if ($tenantid) { $tenantid } else { $env:TenantID } + AppId = $env:ApplicationID + Certificate = $SAMCert.Certificate + } + if ($scope) { $CertTokenSplat.Scope = $scope } + $CertToken = Get-GraphTokenFromCert @CertTokenSplat -ErrorAction Stop + if (-not $CertToken.access_token) { throw "Could not get a token using the SAM certificate for tenant $tenantid" } + $Headers = @{ Authorization = "Bearer $($CertToken.access_token)" } } else { $Headers = Get-GraphToken -tenantid $tenantid -scope $scope -AsApp $asapp -SkipCache $skipTokenCache } @@ -118,6 +132,7 @@ function New-GraphPOSTRequest { if ($IgnoreErrors) { $RetryParameters.IgnoreErrors = $IgnoreErrors } if ($returnHeaders) { $RetryParameters.ReturnHeaders = $returnHeaders } if ($maxRetries) { $RetryParameters.maxRetries = $maxRetries } + if ($UseCertificate) { $RetryParameters.UseCertificate = $true } # Create the scheduled task object $TaskObject = [PSCustomObject]@{ diff --git a/Modules/CIPPCore/Public/New-CIPPSAMCertificate.ps1 b/Modules/CIPPCore/Public/New-CIPPSAMCertificate.ps1 new file mode 100644 index 0000000000000..ff5c89dd5acda --- /dev/null +++ b/Modules/CIPPCore/Public/New-CIPPSAMCertificate.ps1 @@ -0,0 +1,103 @@ +function New-CIPPSAMCertificate { + <# + .SYNOPSIS + Generates a self-signed certificate for SAM app certificate-based authentication + + .DESCRIPTION + Creates a new RSA self-signed certificate in memory suitable for use as a key credential + on the SAM app registration (consumed by Get-GraphTokenFromCert). Pure generation - no + storage or Graph interaction. The PFX is exported without a password so it is + byte-compatible with how Key Vault exposes certificate private keys through the secrets + endpoint (application/x-pkcs12); protection comes from Key Vault access control. + + .PARAMETER ValidityDays + Number of days the certificate is valid for. Defaults to 365. + + .PARAMETER KeySize + RSA key size in bits. Defaults to 2048. + + .PARAMETER SubjectName + Certificate subject. Defaults to CN=CIPP-SAM-. + + .EXAMPLE + New-CIPPSAMCertificate + + .EXAMPLE + New-CIPPSAMCertificate -ValidityDays 730 -KeySize 4096 + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $false)] + [int]$ValidityDays = 365, + + [Parameter(Mandatory = $false)] + [int]$KeySize = 2048, + + [Parameter(Mandatory = $false)] + [string]$SubjectName + ) + + if ([string]::IsNullOrEmpty($SubjectName)) { + # Machine name for local dev (WEBSITE_SITE_NAME is often set there too), site name in Azure + if ($env:AzureWebJobsStorage -eq 'UseDevelopmentStorage=true' -or $env:NonLocalHostAzurite -eq 'true') { + $InstanceName = [System.Environment]::MachineName + } else { + $InstanceName = $env:WEBSITE_SITE_NAME + } + $SubjectName = "CN=CIPP-SAM-$InstanceName" + } + + $RSA = [System.Security.Cryptography.RSA]::Create($KeySize) + try { + $CertRequest = [System.Security.Cryptography.X509Certificates.CertificateRequest]::new( + $SubjectName, + $RSA, + [System.Security.Cryptography.HashAlgorithmName]::SHA256, + [System.Security.Cryptography.RSASignaturePadding]::Pkcs1 + ) + + # Key usage: digital signature only (client assertion signing) + $CertRequest.CertificateExtensions.Add( + [System.Security.Cryptography.X509Certificates.X509KeyUsageExtension]::new( + [System.Security.Cryptography.X509Certificates.X509KeyUsageFlags]::DigitalSignature, + $true + ) + ) + + # Enhanced key usage: client authentication + $EkuOids = [System.Security.Cryptography.OidCollection]::new() + $null = $EkuOids.Add([System.Security.Cryptography.Oid]::new('1.3.6.1.5.5.7.3.2')) + $CertRequest.CertificateExtensions.Add( + [System.Security.Cryptography.X509Certificates.X509EnhancedKeyUsageExtension]::new($EkuOids, $false) + ) + + # Subject key identifier + $CertRequest.CertificateExtensions.Add( + [System.Security.Cryptography.X509Certificates.X509SubjectKeyIdentifierExtension]::new($CertRequest.PublicKey, $false) + ) + + # Backdate NotBefore to tolerate clock skew between this host and Entra ID + $NotBefore = (Get-Date).ToUniversalTime().AddMinutes(-15) + $NotAfter = $NotBefore.AddDays($ValidityDays) + + $Certificate = $CertRequest.CreateSelfSigned($NotBefore, $NotAfter) + try { + $PfxBytes = $Certificate.Export([System.Security.Cryptography.X509Certificates.X509ContentType]::Pfx) + $PublicKeyBytes = $Certificate.Export([System.Security.Cryptography.X509Certificates.X509ContentType]::Cert) + + return [PSCustomObject]@{ + Certificate = $Certificate + PfxBase64 = [Convert]::ToBase64String($PfxBytes) + PublicKeyBase64 = [Convert]::ToBase64String($PublicKeyBytes) + Thumbprint = $Certificate.Thumbprint + NotBefore = $NotBefore + NotAfter = $NotAfter + } + } catch { + $Certificate.Dispose() + throw + } + } finally { + $RSA.Dispose() + } +} diff --git a/Modules/CIPPCore/Public/Set-CIPPSAMCertificate.ps1 b/Modules/CIPPCore/Public/Set-CIPPSAMCertificate.ps1 new file mode 100644 index 0000000000000..0c51d16aeb2d1 --- /dev/null +++ b/Modules/CIPPCore/Public/Set-CIPPSAMCertificate.ps1 @@ -0,0 +1,107 @@ +function Set-CIPPSAMCertificate { + <# + .SYNOPSIS + Stores the SAM certificate PFX in Key Vault (dual-mode) or the DevSecrets table + + .DESCRIPTION + Persists a base64 PFX. In production it first attempts the Key Vault certificates + import API; if the managed identity lacks certificate permissions (403, the case on + deployments created before certificate permissions were added to the templates) or the + name is already occupied by a plain secret (409), it falls back to storing the base64 + PFX as a regular Key Vault secret. Both modes are readable through the secrets endpoint + under the same name, so Get-CIPPSAMCertificate does not need to know which mode is active. + In dev mode (Azurite) the PFX is stored on the DevSecrets table row instead. + + .PARAMETER PfxBase64 + The certificate as a base64-encoded passwordless PFX. + + .PARAMETER Name + Storage name used for both the Key Vault certificate and the fallback secret. Defaults to SAMCertificate. + + .PARAMETER VaultName + Name of the Key Vault. If not provided, derives via Get-CippKeyVaultName. + + .EXAMPLE + Set-CIPPSAMCertificate -PfxBase64 $Cert.PfxBase64 + #> + [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingConvertToSecureStringWithPlainText', '')] + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$PfxBase64, + + [Parameter(Mandatory = $false)] + [string]$Name = 'SAMCertificate', + + [Parameter(Mandatory = $false)] + [string]$VaultName + ) + + if ($env:AzureWebJobsStorage -eq 'UseDevelopmentStorage=true' -or $env:NonLocalHostAzurite -eq 'true') { + $Table = Get-CIPPTable -tablename 'DevSecrets' + $Secret = Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq 'Secret' and RowKey eq 'Secret'" + if (!$Secret) { + throw 'DevSecrets table row not found. Cannot store SAM certificate in dev mode.' + } + $Secret | Add-Member -MemberType NoteProperty -Name $Name -Value $PfxBase64 -Force + Add-AzDataTableEntity @Table -Entity $Secret -Force + return @{ StorageMode = 'DevTable'; Name = $Name } + } + + if (-not $VaultName) { + $VaultName = Get-CippKeyVaultName + if (-not $VaultName) { + throw 'VaultName not provided and could not be derived (WEBSITE_SITE_NAME / WEBSITE_DEPLOYMENT_ID not set)' + } + } + + $Token = Get-CIPPAzIdentityToken -ResourceUrl 'https://vault.azure.net' + + # Attempt the certificates import API first. exportable must be true so the private + # key remains retrievable through the secrets endpoint. + $ImportBody = @{ + value = $PfxBase64 + policy = @{ + key_props = @{ + exportable = $true + kty = 'RSA' + key_size = 2048 + reuse_key = $false + } + secret_props = @{ + contentType = 'application/x-pkcs12' + } + } + } | ConvertTo-Json -Compress -Depth 10 + + $ImportUri = "https://$VaultName.vault.azure.net/certificates/$Name/import?api-version=7.4" + $StatusCode = $null + $ImportResponse = Invoke-CIPPRestMethod -Uri $ImportUri -Method POST -Body $ImportBody -ContentType 'application/json' -Headers @{ + Authorization = "Bearer $Token" + } -SkipHttpErrorCheck -StatusCodeVariable StatusCode + + if ($StatusCode -ge 200 -and $StatusCode -lt 300) { + Write-LogMessage -API 'SAMCertificate' -message "Stored SAM certificate '$Name' as a Key Vault certificate in vault '$VaultName'" -sev 'Info' + return @{ StorageMode = 'Certificate'; Name = $Name; VaultName = $VaultName } + } + + if ($StatusCode -eq 403 -or $StatusCode -eq 409) { + # 403: access policy has no certificate permissions (pre-existing deployments). + # 409: the name is already owned by a plain secret from prior secret-mode operation. + # Either way, fall back to storing the base64 PFX as a regular secret. + Write-Information "Key Vault certificate import returned $StatusCode, falling back to secret storage for '$Name'" + try { + $null = Set-CippKeyVaultSecret -VaultName $VaultName -Name $Name -SecretValue (ConvertTo-SecureString -String $PfxBase64 -AsPlainText -Force) + } catch { + # A 409 on the secret PUT means the name is backed by a Key Vault certificate but we can + # no longer import one - certificate permissions were revoked after operating in + # certificate mode. Manual intervention required; the previous certificate remains valid. + Write-LogMessage -API 'SAMCertificate' -message "Failed to store SAM certificate '$Name' in vault '$VaultName'. If certificate permissions were removed from the Key Vault access policy after the certificate was stored via the certificates API, restore them (get, list, import, update, delete). See Log Data for details." -sev 'CRITICAL' -LogData (Get-CippException -Exception $_) + throw + } + return @{ StorageMode = 'Secret'; Name = $Name; VaultName = $VaultName } + } + + $ErrorDetail = if ($ImportResponse.error.message) { $ImportResponse.error.message } else { $ImportResponse | ConvertTo-Json -Compress -Depth 5 } + throw "Key Vault certificate import for '$Name' in vault '$VaultName' failed with status $StatusCode : $ErrorDetail" +} diff --git a/Modules/CIPPCore/Public/Tools/Get-CIPPSchedulerBlockedCommands.ps1 b/Modules/CIPPCore/Public/Tools/Get-CIPPSchedulerBlockedCommands.ps1 index 9ed724c78ed65..cdd0a149c829d 100644 --- a/Modules/CIPPCore/Public/Tools/Get-CIPPSchedulerBlockedCommands.ps1 +++ b/Modules/CIPPCore/Public/Tools/Get-CIPPSchedulerBlockedCommands.ps1 @@ -42,6 +42,10 @@ function Get-CIPPSchedulerBlockedCommands { 'Get-CippKeyVaultSecret' 'Set-CippKeyVaultSecret' 'Remove-CippKeyVaultSecret' + 'Get-CIPPSAMCertificate' + 'New-CIPPSAMCertificate' + 'Set-CIPPSAMCertificate' + 'Update-CIPPSAMCertificate' 'Get-ExtensionAPIKey' 'Set-ExtensionAPIKey' 'Remove-ExtensionAPIKey' diff --git a/Modules/CIPPCore/Public/Update-CIPPSAMCertificate.ps1 b/Modules/CIPPCore/Public/Update-CIPPSAMCertificate.ps1 new file mode 100644 index 0000000000000..56b9bd74d995d --- /dev/null +++ b/Modules/CIPPCore/Public/Update-CIPPSAMCertificate.ps1 @@ -0,0 +1,138 @@ +function Update-CIPPSAMCertificate { + <# + .SYNOPSIS + Creates or renews the SAM app certificate + + .DESCRIPTION + Checks the stored SAM certificate and renews it when missing (first-run bootstrap), + within the renewal threshold of expiry, or when the stored certificate is not registered + on the SAM app (drift from a previously failed run). Renewal generates a new self-signed + certificate, registers it on the app registration via a full keyCredentials PATCH + (addKey requires a proof-of-possession token signed by an existing key, which is + impossible at bootstrap), then stores the PFX via Set-CIPPSAMCertificate. Still-valid + existing key credentials are kept during rotation so in-flight assertions keep working; + expired ones are pruned in the same PATCH. If storage fails, the new credential is + rolled back off the app registration so renewal is retried on the next run. + + .PARAMETER RenewalThresholdDays + Renew when the stored certificate expires within this many days. Defaults to 30. + + .PARAMETER Force + Renew regardless of the stored certificate's expiry. + + .EXAMPLE + Update-CIPPSAMCertificate + + .EXAMPLE + Update-CIPPSAMCertificate -Force + #> + [CmdletBinding(SupportsShouldProcess = $true)] + param( + [Parameter(Mandatory = $false)] + [int]$RenewalThresholdDays = 30, + + [switch]$Force + ) + + $AppId = $env:ApplicationID + $AppRegistration = New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/applications(appId='$AppId')?`$select=id,keyCredentials" -NoAuthCheck $true -AsApp $true -ErrorAction Stop + + $Stored = $null + try { + $Stored = Get-CIPPSAMCertificate -SkipCache -ErrorAction Stop + } catch { + Write-Warning "Could not retrieve stored SAM certificate: $($_.Exception.Message)" + } + + $RenewalReason = if ($Force) { + 'Forced renewal requested' + } elseif ($null -eq $Stored) { + 'No stored certificate found, creating initial certificate' + } elseif ($Stored.NotAfter -lt (Get-Date).AddDays($RenewalThresholdDays).ToUniversalTime()) { + "Stored certificate expires $($Stored.NotAfter), within the $RenewalThresholdDays day renewal threshold" + } else { + # Drift check: the stored certificate should be registered on the app. If it is not, + # a previous run failed between storage and Graph (or its rollback failed) - self-heal. + # Graph returns customKeyIdentifier as the hex thumbprint string, but tolerate the + # base64-encoded thumbprint bytes form as well. + $Identifiers = @($AppRegistration.keyCredentials.customKeyIdentifier) + if ($Identifiers -notcontains $Stored.Thumbprint -and $Identifiers -notcontains [Convert]::ToBase64String([Convert]::FromHexString($Stored.Thumbprint))) { + 'Stored certificate is not registered on the app registration, re-registering' + } else { + $null + } + } + + if (-not $RenewalReason) { + Write-Information "SAM certificate is valid until $($Stored.NotAfter) and registered on the app. No renewal needed." + return [PSCustomObject]@{ + Renewed = $false + Thumbprint = $Stored.Thumbprint + NotAfter = $Stored.NotAfter + } + } + + if (-not $PSCmdlet.ShouldProcess($AppId, "Renew SAM certificate: $RenewalReason")) { + return + } + + Write-Information "Renewing SAM certificate for $AppId. Reason: $RenewalReason" + $NewCert = New-CIPPSAMCertificate + + # Keep still-valid credentials (rotation overlap) and prune expired ones in the same PATCH. + # Existing credentials are sent back with their keyId and null key material, which Graph + # preserves; only the appended entry carries new key material. + $Now = (Get-Date).ToUniversalTime() + $KeyCredentials = [System.Collections.Generic.List[object]]::new() + foreach ($Credential in $AppRegistration.keyCredentials) { + if ($Credential.endDateTime -ge $Now) { + $KeyCredentials.Add($Credential) + } else { + Write-Information "Pruning expired key credential $($Credential.keyId) (expired $($Credential.endDateTime))" + } + } + # Record which instance issued the certificate: machine name for local dev, site name in Azure + if ($env:AzureWebJobsStorage -eq 'UseDevelopmentStorage=true' -or $env:NonLocalHostAzurite -eq 'true') { + $InstanceName = [System.Environment]::MachineName + } else { + $InstanceName = $env:WEBSITE_SITE_NAME + } + $KeyCredentials.Add(@{ + type = 'AsymmetricX509Cert' + usage = 'Verify' + key = $NewCert.PublicKeyBase64 + displayName = "CIPP-SAM Certificate ($InstanceName)" + }) + + $PatchBody = @{ keyCredentials = $KeyCredentials } | ConvertTo-Json -Compress -Depth 10 + New-GraphPOSTRequest -type PATCH -uri "https://graph.microsoft.com/v1.0/applications/$($AppRegistration.id)" -Body $PatchBody -NoAuthCheck $true -AsApp $true -ErrorAction Stop + Write-Information "Registered new SAM certificate $($NewCert.Thumbprint) on application $AppId" + + try { + $StoreResult = Set-CIPPSAMCertificate -PfxBase64 $NewCert.PfxBase64 -ErrorAction Stop + } catch { + # The new certificate is registered on the app but not retrievable where CIPP reads it, + # and as the newest credential it would suppress renewal on the next run. Roll it back + # off the app registration so state stays consistent and renewal is retried. + Write-LogMessage -API 'SAMCertificate' -message "Failed to store new SAM certificate for $AppId. Rolling back the registered key credential, see Log Data for details." -sev 'CRITICAL' -LogData (Get-CippException -Exception $_) + try { + $RollbackCredentials = $KeyCredentials | Where-Object { $_.key -ne $NewCert.PublicKeyBase64 } + $RollbackBody = @{ keyCredentials = @($RollbackCredentials) } | ConvertTo-Json -Compress -Depth 10 + New-GraphPOSTRequest -type PATCH -uri "https://graph.microsoft.com/v1.0/applications/$($AppRegistration.id)" -Body $RollbackBody -NoAuthCheck $true -AsApp $true -ErrorAction Stop + Write-Information "Rolled back unstored SAM certificate $($NewCert.Thumbprint) from application $AppId" + } catch { + # Rollback failed - the drift check on the next run will force a fresh renewal + Write-LogMessage -API 'SAMCertificate' -message "Failed to roll back unstored SAM certificate $($NewCert.Thumbprint) for $AppId, see Log Data for details. Renewal will retry on the next run." -sev 'CRITICAL' -LogData (Get-CippException -Exception $_) + } + throw + } + + Write-LogMessage -API 'SAMCertificate' -message "SAM certificate renewed for $AppId. Thumbprint: $($NewCert.Thumbprint), expires: $($NewCert.NotAfter), storage mode: $($StoreResult.StorageMode). Reason: $RenewalReason" -sev 'Info' + + return [PSCustomObject]@{ + Renewed = $true + Thumbprint = $NewCert.Thumbprint + NotAfter = $NewCert.NotAfter + StorageMode = $StoreResult.StorageMode + } +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecCippFunction.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecCippFunction.ps1 index 222e7abed29d8..5d54f97826062 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecCippFunction.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecCippFunction.ps1 @@ -15,6 +15,10 @@ function Invoke-ExecCippFunction { 'Get-GraphToken' 'Get-GraphTokenFromCert' 'Get-ClassicAPIToken' + 'Get-CIPPSAMCertificate' + 'New-CIPPSAMCertificate' + 'Set-CIPPSAMCertificate' + 'Update-CIPPSAMCertificate' ) $Function = $Request.Body.FunctionName diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecSAMCertificate.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecSAMCertificate.ps1 new file mode 100644 index 0000000000000..7be0de1667a54 --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecSAMCertificate.ps1 @@ -0,0 +1,75 @@ +function Invoke-ExecSAMCertificate { + <# + .SYNOPSIS + Get SAM certificate status or trigger a renewal + .DESCRIPTION + Returns status information about the SAM app certificate (thumbprint, validity, + registration state) or forces a renewal via Update-CIPPSAMCertificate. Never + returns private key material. + .FUNCTIONALITY + Entrypoint + .ROLE + CIPP.AppSettings.ReadWrite + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + try { + $Action = $Request.Body.Action ?? $Request.Query.Action ?? 'Get' + + switch ($Action) { + 'Get' { + $Stored = Get-CIPPSAMCertificate -SkipCache -ErrorAction SilentlyContinue + if ($null -eq $Stored) { + $Body = @{ + Configured = $false + Results = 'No SAM certificate found. One will be created automatically by the weekly token update, or use the Renew action to create it now.' + } + } else { + $RegisteredOnApp = $false + try { + $AppRegistration = New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/applications(appId='$($env:ApplicationID)')?`$select=id,keyCredentials" -NoAuthCheck $true -AsApp $true -ErrorAction Stop + # Graph returns customKeyIdentifier as the hex thumbprint string, but tolerate base64 as well + $Identifiers = @($AppRegistration.keyCredentials.customKeyIdentifier) + $RegisteredOnApp = $Identifiers -contains $Stored.Thumbprint -or $Identifiers -contains [Convert]::ToBase64String([Convert]::FromHexString($Stored.Thumbprint)) + } catch { + Write-Warning "Could not check app registration key credentials: $($_.Exception.Message)" + } + $Body = @{ + Configured = $true + Thumbprint = $Stored.Thumbprint + NotBefore = $Stored.NotBefore + NotAfter = $Stored.NotAfter + DaysRemaining = [math]::Floor(($Stored.NotAfter - (Get-Date).ToUniversalTime()).TotalDays) + RegisteredOnApp = $RegisteredOnApp + } + } + $StatusCode = [HttpStatusCode]::OK + } + 'Renew' { + $Result = Update-CIPPSAMCertificate -Force -ErrorAction Stop + $Body = @{ + Results = "SAM certificate renewed. Thumbprint: $($Result.Thumbprint), expires: $($Result.NotAfter)" + Thumbprint = $Result.Thumbprint + NotAfter = $Result.NotAfter + StorageMode = $Result.StorageMode + } + $StatusCode = [HttpStatusCode]::OK + } + default { + throw "Invalid action: $Action. Valid actions are 'Get' or 'Renew'" + } + } + } catch { + Write-LogMessage -API 'ExecSAMCertificate' -message "Failed to process SAM certificate request: $($_.Exception.Message)" -sev 'Error' -LogData (Get-CippException -Exception $_) + $StatusCode = [HttpStatusCode]::BadRequest + $Body = @{ + Results = "Failed to process SAM certificate request: $($_.Exception.Message)" + } + } + + return [HttpResponseContext]@{ + StatusCode = $StatusCode + Body = $Body + } +} From bf76f2bd6b01a8f3f294b04e67ce1871309e6c7f Mon Sep 17 00:00:00 2001 From: John Duprey Date: Thu, 9 Jul 2026 11:36:55 -0400 Subject: [PATCH 146/170] feat: Support SAM certificate setup exemptions Ensure SAM certificate creation works during initial setup and renewal when app management policies restrict key credentials. This updates the exemption policy logic to disable certificate-related restrictions, lets SAM certificate rotation use delegated Graph headers for setup-time calls, and attempts to create the certificate immediately after provisioning the SAM app instead of waiting for the scheduled renewal. --- .../Update-AppManagementPolicy.ps1 | 33 +++++++++++++++++-- .../Public/Update-CIPPSAMCertificate.ps1 | 33 ++++++++++++++++--- .../CIPP/Setup/Invoke-ExecCreateSAMApp.ps1 | 13 ++++++++ 3 files changed, 72 insertions(+), 7 deletions(-) diff --git a/Modules/CIPPCore/Public/GraphHelper/Update-AppManagementPolicy.ps1 b/Modules/CIPPCore/Public/GraphHelper/Update-AppManagementPolicy.ps1 index d585e01220894..566f250f36c9f 100644 --- a/Modules/CIPPCore/Public/GraphHelper/Update-AppManagementPolicy.ps1 +++ b/Modules/CIPPCore/Public/GraphHelper/Update-AppManagementPolicy.ps1 @@ -124,9 +124,19 @@ function Update-AppManagementPolicy { $DefaultPolicyBlocksCredentials = ($DefaultPolicy.applicationRestrictions.passwordCredentials | Where-Object { $_.restrictionType -in @('passwordAddition', 'symmetricKeyAddition') -and $_.state -eq 'enabled' }).Count -gt 0 } + # Determine if default policy restricts key credentials: asymmetricKeyLifetime caps + # certificate validity (blocks the 1 year SAM certificate), trustedCertificateAuthority + # blocks self-signed certificates entirely + $DefaultKeyRestrictions = @() + $DefaultPolicyBlocksKeyCredentials = $false + if ($DefaultPolicy.applicationRestrictions.keyCredentials) { + $DefaultKeyRestrictions = @($DefaultPolicy.applicationRestrictions.keyCredentials | Where-Object { $_.restrictionType -in @('asymmetricKeyLifetime', 'trustedCertificateAuthority') -and $_.state -eq 'enabled' }) + $DefaultPolicyBlocksKeyCredentials = $DefaultKeyRestrictions.Count -gt 0 + } + # If default policy blocks credentials and CIPP app doesn't have an exemption, create/update policy $PolicyAction = $null - if ($DefaultPolicyBlocksCredentials -and $CIPPApp) { + if (($DefaultPolicyBlocksCredentials -or $DefaultPolicyBlocksKeyCredentials) -and $CIPPApp) { # Check if a CIPP-SAM Exemption Policy already exists $ExistingExemptionPolicy = $AppPolicies | Where-Object { $_.displayName -eq 'CIPP Exemption Policy' } | Select-Object -First 1 @@ -141,6 +151,14 @@ function Update-AppManagementPolicy { # No password restrictions means it allows credentials $CIPPHasExemption = $true } + # When the default policy restricts key credentials, the exemption must explicitly + # disable those restriction types - otherwise certificate rotation stays blocked + if ($CIPPHasExemption -and $DefaultPolicyBlocksKeyCredentials) { + foreach ($Restriction in $DefaultKeyRestrictions) { + $ExplicitlyDisabled = $CIPPPolicy.restrictions.keyCredentials | Where-Object { $_.restrictionType -eq $Restriction.restrictionType -and $_.state -eq 'disabled' } + if (-not $ExplicitlyDisabled) { $CIPPHasExemption = $false } + } + } } if (-not $CIPPHasExemption) { @@ -164,7 +182,18 @@ function Update-AppManagementPolicy { restrictForAppsCreatedAfterDateTime = '0001-01-01T00:00:00Z' } ) - keyCredentials = @() + keyCredentials = @( + @{ + restrictionType = 'asymmetricKeyLifetime' + state = 'disabled' + restrictForAppsCreatedAfterDateTime = '0001-01-01T00:00:00Z' + } + @{ + restrictionType = 'trustedCertificateAuthority' + state = 'disabled' + restrictForAppsCreatedAfterDateTime = '0001-01-01T00:00:00Z' + } + ) } } diff --git a/Modules/CIPPCore/Public/Update-CIPPSAMCertificate.ps1 b/Modules/CIPPCore/Public/Update-CIPPSAMCertificate.ps1 index 56b9bd74d995d..8fb1cec650878 100644 --- a/Modules/CIPPCore/Public/Update-CIPPSAMCertificate.ps1 +++ b/Modules/CIPPCore/Public/Update-CIPPSAMCertificate.ps1 @@ -20,6 +20,13 @@ function Update-CIPPSAMCertificate { .PARAMETER Force Renew regardless of the stored certificate's expiry. + .PARAMETER ApplicationId + The app registration (client) id to manage the certificate for. Defaults to the SAM app. + + .PARAMETER Headers + Optional pre-built authorization headers for the Graph calls (e.g. the delegated token + during the setup wizard, before the app's own credentials are usable). + .EXAMPLE Update-CIPPSAMCertificate @@ -31,11 +38,17 @@ function Update-CIPPSAMCertificate { [Parameter(Mandatory = $false)] [int]$RenewalThresholdDays = 30, - [switch]$Force + [switch]$Force, + + [Parameter(Mandatory = $false)] + [string]$ApplicationId, + + [Parameter(Mandatory = $false)] + $Headers ) - $AppId = $env:ApplicationID - $AppRegistration = New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/applications(appId='$AppId')?`$select=id,keyCredentials" -NoAuthCheck $true -AsApp $true -ErrorAction Stop + $AppId = if ($ApplicationId) { $ApplicationId } else { $env:ApplicationID } + $AppRegistration = New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/applications(appId='$AppId')?`$select=id,keyCredentials" -NoAuthCheck $true -AsApp $true -Headers $Headers -ErrorAction Stop $Stored = $null try { @@ -77,6 +90,16 @@ function Update-CIPPSAMCertificate { } Write-Information "Renewing SAM certificate for $AppId. Reason: $RenewalReason" + + # Ensure the CIPP exemption policy covers key credential restrictions (asymmetricKeyLifetime / + # trustedCertificateAuthority would otherwise block registering a 1 year self-signed certificate) + try { + $AppPolicyStatus = Update-AppManagementPolicy -ApplicationId $AppId -Headers $Headers + if ($AppPolicyStatus.PolicyAction) { Write-Information $AppPolicyStatus.PolicyAction } + } catch { + Write-Warning "Error updating app management policy $($_.Exception.Message)." + } + $NewCert = New-CIPPSAMCertificate # Keep still-valid credentials (rotation overlap) and prune expired ones in the same PATCH. @@ -105,7 +128,7 @@ function Update-CIPPSAMCertificate { }) $PatchBody = @{ keyCredentials = $KeyCredentials } | ConvertTo-Json -Compress -Depth 10 - New-GraphPOSTRequest -type PATCH -uri "https://graph.microsoft.com/v1.0/applications/$($AppRegistration.id)" -Body $PatchBody -NoAuthCheck $true -AsApp $true -ErrorAction Stop + New-GraphPOSTRequest -type PATCH -uri "https://graph.microsoft.com/v1.0/applications/$($AppRegistration.id)" -Body $PatchBody -NoAuthCheck $true -AsApp $true -Headers $Headers -ErrorAction Stop Write-Information "Registered new SAM certificate $($NewCert.Thumbprint) on application $AppId" try { @@ -118,7 +141,7 @@ function Update-CIPPSAMCertificate { try { $RollbackCredentials = $KeyCredentials | Where-Object { $_.key -ne $NewCert.PublicKeyBase64 } $RollbackBody = @{ keyCredentials = @($RollbackCredentials) } | ConvertTo-Json -Compress -Depth 10 - New-GraphPOSTRequest -type PATCH -uri "https://graph.microsoft.com/v1.0/applications/$($AppRegistration.id)" -Body $RollbackBody -NoAuthCheck $true -AsApp $true -ErrorAction Stop + New-GraphPOSTRequest -type PATCH -uri "https://graph.microsoft.com/v1.0/applications/$($AppRegistration.id)" -Body $RollbackBody -NoAuthCheck $true -AsApp $true -Headers $Headers -ErrorAction Stop Write-Information "Rolled back unstored SAM certificate $($NewCert.Thumbprint) from application $AppId" } catch { # Rollback failed - the drift check on the next run will force a fresh renewal diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecCreateSAMApp.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecCreateSAMApp.ps1 index bd919ba0cb879..b51d8c26a76fc 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecCreateSAMApp.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Setup/Invoke-ExecCreateSAMApp.ps1 @@ -107,6 +107,19 @@ function Invoke-ExecCreateSAMApp { # Reload credentials into env vars $null = Get-CIPPAuthentication + # Create the SAM certificate alongside the secret so certificate auth is available + # immediately. Uses the wizard's delegated token because the app secret was created + # seconds ago and may not have propagated yet. Non-fatal: the weekly token update + # timer creates the certificate if this fails. + try { + $CertResult = Update-CIPPSAMCertificate -ApplicationId $AppId.appId -Headers @{ authorization = "Bearer $($Token.access_token)" } + if ($CertResult.Renewed) { + Write-Information "SAM certificate created. Thumbprint: $($CertResult.Thumbprint), expires: $($CertResult.NotAfter), storage mode: $($CertResult.StorageMode)" + } + } catch { + Write-Warning "Failed to create SAM certificate during setup, the weekly token update will create it: $($_.Exception.Message)" + } + $Results = @{'message' = "Successfully $state the application registration. The application ID is $($AppId.appid). You may continue to the next step."; severity = 'success' } } From 2e636ca5454fc49be48dbb93028bd6a651fa1a7b Mon Sep 17 00:00:00 2001 From: John Duprey Date: Thu, 9 Jul 2026 12:15:41 -0400 Subject: [PATCH 147/170] feat: Unify certificate token auth flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a shared `New-CIPPCertificateAssertion` helper and refactors Graph/EXO request helpers to route `-UseCertificate` through `Get-GraphToken` for both delegated refresh-token and app-only flows. Updates token cache grant-type keys for certificate auth, defaults EXO tenant ID to env TenantID, and blocks the new assertion helper from scheduler/exec invocation. Also changes `CIPPRestClient` to use `ResponseHeadersRead` so malformed EXO content-encoding responses don’t fail in `SendAsync` before status/body handling. --- .../Public/GraphHelper/Get-GraphToken.ps1 | 36 ++++++- .../GraphHelper/Get-GraphTokenFromCert.ps1 | 70 +------------- .../New-CIPPCertificateAssertion.ps1 | 94 +++++++++++++++++++ .../Public/GraphHelper/New-ExoRequest.ps1 | 16 +--- .../GraphHelper/New-GraphGetRequest.ps1 | 19 +--- .../GraphHelper/New-GraphPOSTRequest.ps1 | 17 +--- .../Get-CIPPSchedulerBlockedCommands.ps1 | 1 + .../CIPP/Core/Invoke-ExecCippFunction.ps1 | 1 + 8 files changed, 142 insertions(+), 112 deletions(-) create mode 100644 Modules/CIPPCore/Public/GraphHelper/New-CIPPCertificateAssertion.ps1 diff --git a/Modules/CIPPCore/Public/GraphHelper/Get-GraphToken.ps1 b/Modules/CIPPCore/Public/GraphHelper/Get-GraphToken.ps1 index 62005b67c8e80..3a1aaffecd31e 100644 --- a/Modules/CIPPCore/Public/GraphHelper/Get-GraphToken.ps1 +++ b/Modules/CIPPCore/Public/GraphHelper/Get-GraphToken.ps1 @@ -1,8 +1,20 @@ -function Get-GraphToken($tenantid, $scope, $AsApp, $AppID, $AppSecret, $refreshToken, $ReturnRefresh, $SkipCache) { +function Get-GraphToken { <# .FUNCTIONALITY Internal #> + [CmdletBinding()] + param( + $tenantid, + $scope, + $AsApp, + $AppID, + $AppSecret, + $refreshToken, + $ReturnRefresh, + $SkipCache, + [switch]$UseCertificate + ) if (!$scope) { $scope = 'https://graph.microsoft.com/.default' } if (!$tenantid) { $tenantid = $env:TenantID } @@ -12,6 +24,7 @@ function Get-GraphToken($tenantid, $scope, $AsApp, $AppID, $AppSecret, $refreshT if ($UseSharedTokenCache) { $CacheClientId = if ($AppID) { [string]$AppID } else { [string]$env:ApplicationID } $GrantType = if ($asApp -eq $true -or ($null -ne $AppID -and $null -ne $AppSecret)) { 'client_credentials' } else { 'refresh_token' } + if ($UseCertificate -eq $true) { $GrantType = "${GrantType}_certificate" } $SharedTokenCacheKey = [CIPP.CIPPTokenCache]::BuildKey([string]$tenantid, [string]$scope, [bool]$asApp, $CacheClientId, $GrantType) $SharedCacheEntry = [CIPP.CIPPTokenCache]::Lookup($SharedTokenCacheKey, 120) if ($SharedCacheEntry.Found -and -not [string]::IsNullOrWhiteSpace($SharedCacheEntry.TokenPayloadJson)) { @@ -135,11 +148,30 @@ function Get-GraphToken($tenantid, $scope, $AsApp, $AppID, $AppSecret, $refreshT if ($UseSharedTokenCache) { $CacheClientId = if ($AppID) { [string]$AppID } else { [string]$env:ApplicationID } $GrantType = if ($asApp -eq $true -or ($null -ne $AppID -and $null -ne $AppSecret)) { 'client_credentials' } else { 'refresh_token' } + if ($UseCertificate -eq $true) { $GrantType = "${GrantType}_certificate" } $SharedTokenCacheKey = [CIPP.CIPPTokenCache]::BuildKey([string]$tenantid, [string]$scope, [bool]$asApp, $CacheClientId, $GrantType) } try { - $AccessToken = (Invoke-CIPPRestMethod -Method post -Uri "https://login.microsoftonline.com/$($tenantid)/oauth2/v2.0/token" -Body $Authbody -ContentType 'application/x-www-form-urlencoded' -ErrorAction Stop) + if ($UseCertificate -eq $true) { + $SAMCert = Get-CIPPSAMCertificate -ErrorAction Stop + if (-not $SAMCert) { throw 'No SAM certificate available. Run Update-CIPPSAMCertificate to create one.' } + } + if ($UseCertificate -eq $true -and $AuthBody.grant_type -eq 'client_credentials') { + # App-only with certificate: use the dedicated cert token function + # (has its own cache under the same key and the AADSTS700027 retry) + $AccessToken = Get-GraphTokenFromCert -TenantId $tenantid -AppId $AuthBody.client_id -Scope $scope -Certificate $SAMCert.Certificate -SkipCache:($SkipCache -eq $true) -ErrorAction Stop + if (-not $AccessToken.access_token) { throw "Could not get a token using the SAM certificate for $tenantid" } + } else { + if ($UseCertificate -eq $true) { + # Delegated with certificate: authenticate the app with a signed assertion + # instead of the client secret; the refresh token still provides user context + $null = $AuthBody.Remove('client_secret') + $AuthBody.client_assertion_type = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer' + $AuthBody.client_assertion = New-CIPPCertificateAssertion -TenantId $tenantid -AppId $AuthBody.client_id -Certificate $SAMCert.Certificate + } + $AccessToken = (Invoke-CIPPRestMethod -Method post -Uri "https://login.microsoftonline.com/$($tenantid)/oauth2/v2.0/token" -Body $Authbody -ContentType 'application/x-www-form-urlencoded' -ErrorAction Stop) + } if ($null -eq $AccessToken.expires_on -and $AccessToken.expires_in) { $ExpiresOn = [int](Get-Date -UFormat %s -Millisecond 0) + $AccessToken.expires_in Add-Member -InputObject $AccessToken -NotePropertyName 'expires_on' -NotePropertyValue $ExpiresOn -Force diff --git a/Modules/CIPPCore/Public/GraphHelper/Get-GraphTokenFromCert.ps1 b/Modules/CIPPCore/Public/GraphHelper/Get-GraphTokenFromCert.ps1 index 274777b14c7d6..4ff324b320611 100644 --- a/Modules/CIPPCore/Public/GraphHelper/Get-GraphTokenFromCert.ps1 +++ b/Modules/CIPPCore/Public/GraphHelper/Get-GraphTokenFromCert.ps1 @@ -36,74 +36,8 @@ function Get-GraphTokenFromCert { } } - # get sha256 hash of certificate - $sha256 = New-Object System.Security.Cryptography.SHA256CryptoServiceProvider - $hash = $sha256.ComputeHash($Certificate.RawData) - $hash = [Convert]::ToBase64String($hash) - - # Create JWT timestamp for expiration - $StartDate = (Get-Date '1970-01-01T00:00:00Z' ).ToUniversalTime() - $JWTExpirationTimeSpan = (New-TimeSpan -Start $StartDate -End (Get-Date).ToUniversalTime().AddMinutes(2)).TotalSeconds - $JWTExpiration = [math]::Round($JWTExpirationTimeSpan, 0) - - # Create JWT validity start timestamp - $NotBeforeExpirationTimeSpan = (New-TimeSpan -Start $StartDate -End ((Get-Date).ToUniversalTime())).TotalSeconds - $NotBefore = [math]::Round($NotBeforeExpirationTimeSpan, 0) - - # Create JWT header - $JWTHeader = @{ - alg = 'PS256' - typ = 'JWT' - # Use the CertificateBase64Hash and replace/strip to match web encoding of base64 - 'x5t#S256' = $hash -replace '\+', '-' -replace '/', '_' -replace '=' - } - - # Create JWT payload - $JWTPayLoad = @{ - # Issuer = your application - iss = $AppId - - # What endpoint is allowed to use this JWT - aud = "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token" - - # JWT ID: random guid - jti = [guid]::NewGuid() - - # Expiration timestamp - exp = $JWTExpiration - - # Not to be used before - nbf = $NotBefore - - # JWT Subject - sub = $AppId - } - - # Convert header and payload to base64 - $JWTHeaderToByte = [System.Text.Encoding]::UTF8.GetBytes(($JWTHeader | ConvertTo-Json)) - $EncodedHeader = [System.Convert]::ToBase64String($JWTHeaderToByte) - - $JWTPayLoadToByte = [System.Text.Encoding]::UTF8.GetBytes(($JWTPayload | ConvertTo-Json)) - $EncodedPayload = [System.Convert]::ToBase64String($JWTPayLoadToByte) - - # Join header and Payload with "." to create a valid (unsigned) JWT - $JWT = $EncodedHeader + '.' + $EncodedPayload - - # Get the private key object of your certificate - # $PrivateKey = $Certificate.PrivateKey - $PrivateKey = ([System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPrivateKey($Certificate)) - - # Define RSA signature and hashing algorithm - $RSAPadding = [Security.Cryptography.RSASignaturePadding]::Pss - $HashAlgorithm = [Security.Cryptography.HashAlgorithmName]::SHA256 - - # Create a signature of the JWT - $Signature = [Convert]::ToBase64String( - $PrivateKey.SignData([System.Text.Encoding]::UTF8.GetBytes($JWT), $HashAlgorithm, $RSAPadding) - ) -replace '\+', '-' -replace '/', '_' -replace '=' - - # Join the signature to the JWT with "." - $JWT = $JWT + '.' + $Signature + # Build the signed client assertion (shared with Get-GraphToken -UseCertificate) + $JWT = New-CIPPCertificateAssertion -TenantId $TenantId -AppId $AppId -Certificate $Certificate # Create a hash with body parameters $Body = @{ diff --git a/Modules/CIPPCore/Public/GraphHelper/New-CIPPCertificateAssertion.ps1 b/Modules/CIPPCore/Public/GraphHelper/New-CIPPCertificateAssertion.ps1 new file mode 100644 index 0000000000000..bda4ea3aa77e6 --- /dev/null +++ b/Modules/CIPPCore/Public/GraphHelper/New-CIPPCertificateAssertion.ps1 @@ -0,0 +1,94 @@ +function New-CIPPCertificateAssertion { + <# + .SYNOPSIS + Builds a PS256-signed JWT client assertion from a certificate + + .DESCRIPTION + Creates the signed JWT used as client_assertion when authenticating an app against the + Entra token endpoint with a certificate instead of a client secret. Shared by the + client_credentials flow (Get-GraphTokenFromCert) and the refresh_token flow + (Get-GraphToken -UseCertificate). + + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantId, + + [Parameter(Mandatory = $true)] + [string]$AppId, + + [Parameter(Mandatory = $true)] + [System.Security.Cryptography.X509Certificates.X509Certificate2]$Certificate + ) + + # get sha256 hash of certificate for the x5t#S256 header + $sha256 = New-Object System.Security.Cryptography.SHA256CryptoServiceProvider + $hash = $sha256.ComputeHash($Certificate.RawData) + $hash = [Convert]::ToBase64String($hash) + + # Create JWT timestamp for expiration + $StartDate = (Get-Date '1970-01-01T00:00:00Z' ).ToUniversalTime() + $JWTExpirationTimeSpan = (New-TimeSpan -Start $StartDate -End (Get-Date).ToUniversalTime().AddMinutes(2)).TotalSeconds + $JWTExpiration = [math]::Round($JWTExpirationTimeSpan, 0) + + # Create JWT validity start timestamp + $NotBeforeExpirationTimeSpan = (New-TimeSpan -Start $StartDate -End ((Get-Date).ToUniversalTime())).TotalSeconds + $NotBefore = [math]::Round($NotBeforeExpirationTimeSpan, 0) + + # Create JWT header + $JWTHeader = @{ + alg = 'PS256' + typ = 'JWT' + # Use the CertificateBase64Hash and replace/strip to match web encoding of base64 + 'x5t#S256' = $hash -replace '\+', '-' -replace '/', '_' -replace '=' + } + + # Create JWT payload + $JWTPayLoad = @{ + # Issuer = your application + iss = $AppId + + # What endpoint is allowed to use this JWT + aud = "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token" + + # JWT ID: random guid + jti = [guid]::NewGuid() + + # Expiration timestamp + exp = $JWTExpiration + + # Not to be used before + nbf = $NotBefore + + # JWT Subject + sub = $AppId + } + + # Convert header and payload to base64 + $JWTHeaderToByte = [System.Text.Encoding]::UTF8.GetBytes(($JWTHeader | ConvertTo-Json)) + $EncodedHeader = [System.Convert]::ToBase64String($JWTHeaderToByte) + + $JWTPayLoadToByte = [System.Text.Encoding]::UTF8.GetBytes(($JWTPayload | ConvertTo-Json)) + $EncodedPayload = [System.Convert]::ToBase64String($JWTPayLoadToByte) + + # Join header and Payload with "." to create a valid (unsigned) JWT + $JWT = $EncodedHeader + '.' + $EncodedPayload + + # Get the private key object of your certificate + $PrivateKey = ([System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPrivateKey($Certificate)) + + # Define RSA signature and hashing algorithm + $RSAPadding = [Security.Cryptography.RSASignaturePadding]::Pss + $HashAlgorithm = [Security.Cryptography.HashAlgorithmName]::SHA256 + + # Create a signature of the JWT + $Signature = [Convert]::ToBase64String( + $PrivateKey.SignData([System.Text.Encoding]::UTF8.GetBytes($JWT), $HashAlgorithm, $RSAPadding) + ) -replace '\+', '-' -replace '/', '_' -replace '=' + + # Join the signature to the JWT with "." + return $JWT + '.' + $Signature +} diff --git a/Modules/CIPPCore/Public/GraphHelper/New-ExoRequest.ps1 b/Modules/CIPPCore/Public/GraphHelper/New-ExoRequest.ps1 index 570ab8d1f043a..01bae571afc76 100644 --- a/Modules/CIPPCore/Public/GraphHelper/New-ExoRequest.ps1 +++ b/Modules/CIPPCore/Public/GraphHelper/New-ExoRequest.ps1 @@ -20,7 +20,7 @@ function New-ExoRequest { [Parameter(Mandatory = $false, ParameterSetName = 'ExoRequest')] [bool]$useSystemMailbox, - [string]$tenantid, + [string]$tenantid = $env:TenantID, [bool]$NoAuthCheck, @@ -41,17 +41,9 @@ function New-ExoRequest { } else { $Resource = 'https://outlook.office365.com' } - if ($UseCertificate) { - # App-only auth using the stored SAM certificate (always client_credentials) - $SAMCert = Get-CIPPSAMCertificate -ErrorAction Stop - if (-not $SAMCert) { throw 'No SAM certificate available. Run Update-CIPPSAMCertificate to create one.' } - $CertTenantId = if ($tenantid) { $tenantid } else { $env:TenantID } - $CertToken = Get-GraphTokenFromCert -TenantId $CertTenantId -AppId $env:ApplicationID -Scope "$Resource/.default" -Certificate $SAMCert.Certificate -ErrorAction Stop - if (-not $CertToken.access_token) { throw "Could not get a token using the SAM certificate for tenant $tenantid" } - $token = @{ Authorization = "Bearer $($CertToken.access_token)" } - } else { - $token = Get-GraphToken -Tenantid $tenantid -scope "$Resource/.default" -AsApp:$AsApp.IsPresent - } + # -UseCertificate authenticates the app with the SAM certificate instead of the + # client secret: delegated (refresh token) by default, app-only with -AsApp + $token = Get-GraphToken -Tenantid $tenantid -scope "$Resource/.default" -AsApp:$AsApp.IsPresent -UseCertificate:$UseCertificate if ($cmdParams) { #if cmdParams is a pscustomobject, convert to hashtable, otherwise leave as is diff --git a/Modules/CIPPCore/Public/GraphHelper/New-GraphGetRequest.ps1 b/Modules/CIPPCore/Public/GraphHelper/New-GraphGetRequest.ps1 index 89cfcc6885e80..a99502edcdd3f 100644 --- a/Modules/CIPPCore/Public/GraphHelper/New-GraphGetRequest.ps1 +++ b/Modules/CIPPCore/Public/GraphHelper/New-GraphGetRequest.ps1 @@ -40,22 +40,9 @@ function New-GraphGetRequest { $headers = $Headers } else { $TokenScope = if ($scope -eq 'ExchangeOnline') { 'https://outlook.office365.com/.default' } else { $scope } - if ($UseCertificate) { - # App-only auth using the stored SAM certificate (always client_credentials) - $SAMCert = Get-CIPPSAMCertificate -ErrorAction Stop - if (-not $SAMCert) { throw 'No SAM certificate available. Run Update-CIPPSAMCertificate to create one.' } - $CertTokenSplat = @{ - TenantId = if ($tenantid) { $tenantid } else { $env:TenantID } - AppId = $env:ApplicationID - Certificate = $SAMCert.Certificate - } - if ($TokenScope) { $CertTokenSplat.Scope = $TokenScope } - $CertToken = Get-GraphTokenFromCert @CertTokenSplat -ErrorAction Stop - if (-not $CertToken.access_token) { throw "Could not get a token using the SAM certificate for tenant $tenantid" } - $headers = @{ Authorization = "Bearer $($CertToken.access_token)" } - } else { - $headers = Get-GraphToken -tenantid $tenantid -scope $TokenScope -AsApp $asapp -SkipCache $skipTokenCache - } + # -UseCertificate authenticates the app with the SAM certificate instead of the + # client secret: delegated (refresh token) by default, app-only with -AsApp $true + $headers = Get-GraphToken -tenantid $tenantid -scope $TokenScope -AsApp $asapp -SkipCache $skipTokenCache -UseCertificate:$UseCertificate } if ($ComplexFilter) { $headers['ConsistencyLevel'] = 'eventual' diff --git a/Modules/CIPPCore/Public/GraphHelper/New-GraphPOSTRequest.ps1 b/Modules/CIPPCore/Public/GraphHelper/New-GraphPOSTRequest.ps1 index ac9ab5302c93c..1eeb101e7c309 100644 --- a/Modules/CIPPCore/Public/GraphHelper/New-GraphPOSTRequest.ps1 +++ b/Modules/CIPPCore/Public/GraphHelper/New-GraphPOSTRequest.ps1 @@ -26,21 +26,10 @@ function New-GraphPOSTRequest { if ($NoAuthCheck -or (Get-AuthorisedRequest -Uri $uri -TenantID $tenantid)) { if ($Headers) { $Headers = $Headers - } elseif ($UseCertificate) { - # App-only auth using the stored SAM certificate (always client_credentials) - $SAMCert = Get-CIPPSAMCertificate -ErrorAction Stop - if (-not $SAMCert) { throw 'No SAM certificate available. Run Update-CIPPSAMCertificate to create one.' } - $CertTokenSplat = @{ - TenantId = if ($tenantid) { $tenantid } else { $env:TenantID } - AppId = $env:ApplicationID - Certificate = $SAMCert.Certificate - } - if ($scope) { $CertTokenSplat.Scope = $scope } - $CertToken = Get-GraphTokenFromCert @CertTokenSplat -ErrorAction Stop - if (-not $CertToken.access_token) { throw "Could not get a token using the SAM certificate for tenant $tenantid" } - $Headers = @{ Authorization = "Bearer $($CertToken.access_token)" } } else { - $Headers = Get-GraphToken -tenantid $tenantid -scope $scope -AsApp $asapp -SkipCache $skipTokenCache + # -UseCertificate authenticates the app with the SAM certificate instead of the + # client secret: delegated (refresh token) by default, app-only with -AsApp $true + $Headers = Get-GraphToken -tenantid $tenantid -scope $scope -AsApp $asapp -SkipCache $skipTokenCache -UseCertificate:$UseCertificate } if ($AddedHeaders) { foreach ($header in $AddedHeaders.GetEnumerator()) { diff --git a/Modules/CIPPCore/Public/Tools/Get-CIPPSchedulerBlockedCommands.ps1 b/Modules/CIPPCore/Public/Tools/Get-CIPPSchedulerBlockedCommands.ps1 index cdd0a149c829d..be6aeed06a6ad 100644 --- a/Modules/CIPPCore/Public/Tools/Get-CIPPSchedulerBlockedCommands.ps1 +++ b/Modules/CIPPCore/Public/Tools/Get-CIPPSchedulerBlockedCommands.ps1 @@ -14,6 +14,7 @@ function Get-CIPPSchedulerBlockedCommands { # Token & authentication functions - would exfiltrate access/refresh tokens 'Get-GraphToken' 'Get-GraphTokenFromCert' + 'New-CIPPCertificateAssertion' 'Get-ClassicAPIToken' 'Get-CIPPAzIdentityToken' 'Get-CIPPAuthentication' diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecCippFunction.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecCippFunction.ps1 index 5d54f97826062..867ce9a67cb0d 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecCippFunction.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecCippFunction.ps1 @@ -14,6 +14,7 @@ function Invoke-ExecCippFunction { $BlockList = @( 'Get-GraphToken' 'Get-GraphTokenFromCert' + 'New-CIPPCertificateAssertion' 'Get-ClassicAPIToken' 'Get-CIPPSAMCertificate' 'New-CIPPSAMCertificate' From 98e1a315f4525056e0191fad9e253d73c2bf56d6 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Thu, 9 Jul 2026 12:16:21 -0400 Subject: [PATCH 148/170] fix: Preserve EXO error responses Use `ResponseHeadersRead` in `CIPPRestClient` so mislabelled compressed Exchange error bodies do not fail inside `SendAsync`. This keeps the HTTP status available for the existing defensive response handling path. --- Shared/CIPPSharp/CIPPRestClient.cs | 7 ++++++- Shared/CIPPSharp/bin/CIPPSharp.dll | Bin 47104 -> 47104 bytes 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/Shared/CIPPSharp/CIPPRestClient.cs b/Shared/CIPPSharp/CIPPRestClient.cs index 7647639c7251e..ee6e56bd5a4b5 100644 --- a/Shared/CIPPSharp/CIPPRestClient.cs +++ b/Shared/CIPPSharp/CIPPRestClient.cs @@ -682,7 +682,12 @@ public static async Task SendAsync( HttpResponseMessage response; try { - response = await client.SendAsync(request, token).ConfigureAwait(false); + // ResponseHeadersRead: do NOT buffer the body inside SendAsync. With the + // default (ResponseContentRead) the body is downloaded and auto-decompressed + // here, so a mislabeled Content-Encoding (EXO error responses declare gzip on + // a plain body) throws InvalidDataException from SendAsync and bypasses the + // defensive body read below — masking the HTTP status the caller needs. + response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, token).ConfigureAwait(false); } catch (OperationCanceledException) when (cts is not null && cts.IsCancellationRequested) { diff --git a/Shared/CIPPSharp/bin/CIPPSharp.dll b/Shared/CIPPSharp/bin/CIPPSharp.dll index df386f1ae5a83f5c47abe12b651334f854917fb3..0817a1537960420061ec40542e39e7558edd0819 100644 GIT binary patch delta 10164 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 RSlTzP%lXomV*L=)e*@IEmu~<7 delta 10154 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 Date: Thu, 9 Jul 2026 18:40:38 +0200 Subject: [PATCH 149/170] merge issue --- .../Push-CIPPDBCacheData.ps1 | 9 + ...Push-DBCacheSharePointSiteSharingLinks.ps1 | 190 ++++++++++ .../Push-StoreSharePointSharingLinks.ps1 | 36 ++ .../Public/GraphHelper/Get-GraphToken.ps1 | 36 +- .../GraphHelper/Get-GraphTokenFromCert.ps1 | 70 +--- .../New-CIPPCertificateAssertion.ps1 | 94 +++++ .../Public/GraphHelper/New-ExoRequest.ps1 | 16 +- .../GraphHelper/New-GraphGetRequest.ps1 | 19 +- .../GraphHelper/New-GraphPOSTRequest.ps1 | 17 +- .../Public/Invoke-CIPPDBCacheCollection.ps1 | 1 - .../Get-CIPPSchedulerBlockedCommands.ps1 | 1 + .../Set-CIPPDBCacheSharePointSharingLinks.ps1 | 326 +++--------------- .../CIPP/Core/Invoke-ExecCippFunction.ps1 | 1 + .../Invoke-ExecSetLibraryPermission.ps1 | 8 +- Shared/CIPPSharp/CIPPRestClient.cs | 7 +- 15 files changed, 431 insertions(+), 400 deletions(-) create mode 100644 Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/SharePoint Sharing/Push-DBCacheSharePointSiteSharingLinks.ps1 create mode 100644 Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/SharePoint Sharing/Push-StoreSharePointSharingLinks.ps1 create mode 100644 Modules/CIPPCore/Public/GraphHelper/New-CIPPCertificateAssertion.ps1 diff --git a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-CIPPDBCacheData.ps1 b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-CIPPDBCacheData.ps1 index 24f93b25fd47e..f0eac8d4d0020 100644 --- a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-CIPPDBCacheData.ps1 +++ b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-CIPPDBCacheData.ps1 @@ -219,6 +219,15 @@ function Push-CIPPDBCacheData { QueueId = $QueueId QueueName = "DB Cache SharePoint - $TenantFilter" }) + # SharePointSharingLinks runs as its own activity — it's heavy (scans every drive) and + # spawns a child orchestrator (one activity per site) that needs its own time budget. + $Tasks.Add(@{ + FunctionName = 'ExecCIPPDBCache' + Name = 'SharePointSharingLinks' + TenantFilter = $TenantFilter + QueueId = $QueueId + QueueName = "DB Cache SharePointSharingLinks - $TenantFilter" + }) } else { Write-Host "Skipping SharePoint data collection for $TenantFilter - no required license" } diff --git a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/SharePoint Sharing/Push-DBCacheSharePointSiteSharingLinks.ps1 b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/SharePoint Sharing/Push-DBCacheSharePointSiteSharingLinks.ps1 new file mode 100644 index 0000000000000..42bd5b3e68e4c --- /dev/null +++ b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/SharePoint Sharing/Push-DBCacheSharePointSiteSharingLinks.ps1 @@ -0,0 +1,190 @@ +function Push-DBCacheSharePointSiteSharingLinks { + <# + .SYNOPSIS + Scans a single SharePoint/OneDrive site for sharing links and returns the rows. + + .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. + + .FUNCTIONALITY + Entrypoint + #> + [CmdletBinding()] + param($Item) + + $TenantFilter = $Item.TenantFilter + $SiteId = $Item.SiteId + $SiteName = $Item.SiteName + $SiteUrl = $Item.SiteUrl + $IsPersonalSite = [bool]$Item.IsPersonalSite + + # Verified domains passed from the parent; used to tell internal from external recipients. + $InternalDomains = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($Domain in @($Item.InternalDomains)) { if ($Domain) { [void]$InternalDomains.Add([string]$Domain) } } + + # Returns $true when an identity (link recipient or direct grant) is external to the tenant. + function Test-CIPPExternalIdentity { + param($Identity, $InternalDomains) + $LoginName = [string]($Identity.siteUser.loginName ?? $Identity.user.loginName ?? '') + if ($LoginName -match '#ext#' -or $LoginName -match 'urn%3aspo%3aguest' -or $LoginName -match 'urn:spo:guest') { return $true } + $Email = [string]($Identity.user.email ?? $Identity.user.userPrincipalName ?? $Identity.siteUser.email ?? '') + if ($Email -match '#EXT#') { return $true } + if ($Email -and $Email.Contains('@') -and $InternalDomains.Count -gt 0) { + return -not $InternalDomains.Contains($Email.Split('@')[-1]) + } + return $false + } + + # Friendly display value for an identity, preferring email over display name. + function Get-CIPPIdentityLabel { + param($Identity) + $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. + function Add-CIPPSharingRows { + param($Buffer, $Drive, $Site, $InternalDomains, $TenantFilter, $RowsOut) + + if (@($Buffer).Count -eq 0) { return } + + $ItemByRequestId = @{} + $RequestId = 0 + $PermissionRequests = foreach ($SharedItem in $Buffer) { + $ItemByRequestId["$RequestId"] = $SharedItem + @{ + id = "$RequestId" + method = 'GET' + url = "drives/$($Drive.id)/items/$($SharedItem.id)/permissions" + } + $RequestId++ + } + + $PermissionResponses = New-GraphBulkRequest -tenantid $TenantFilter -Requests @($PermissionRequests) -asapp $true + foreach ($Response in $PermissionResponses) { + if ($Response.status -and $Response.status -ne 200) { continue } + $DriveItem = $ItemByRequestId["$($Response.id)"] + + foreach ($Permission in @($Response.body.value)) { + # Only permissions set on the item itself; inherited ones are reported on their parent. + if ($Permission.inheritedFrom) { continue } + + if ($Permission.link) { + $Recipients = @($Permission.grantedToIdentitiesV2 ?? $Permission.grantedToIdentities) + $LinkScope = $Permission.link.scope ?? 'users' + $Classification = switch ($LinkScope) { + 'anonymous' { 'Anonymous' } + 'organization' { 'Internal' } + 'existingAccess' { 'Internal' } + default { + $HasExternal = $false + foreach ($Recipient in $Recipients) { + if (Test-CIPPExternalIdentity -Identity $Recipient -InternalDomains $InternalDomains) { $HasExternal = $true; break } + } + if ($HasExternal) { 'External' } else { 'Internal' } + } + } + $LinkType = $Permission.link.type ?? 'link' + $LinkUrl = $Permission.link.webUrl + } else { + # Direct grant (no sharing link): only report grants to external users. + $Recipients = @($Permission.grantedToV2 ?? $Permission.grantedTo) + if ($Permission.roles -contains 'owner') { continue } + $HasExternal = $false + foreach ($Recipient in $Recipients) { + if (Test-CIPPExternalIdentity -Identity $Recipient -InternalDomains $InternalDomains) { $HasExternal = $true; break } + } + if (-not $HasExternal) { continue } + $Classification = 'External' + $LinkScope = 'direct' + $LinkType = 'directGrant' + $LinkUrl = $null + } + + $SharedWith = @($Recipients | ForEach-Object { Get-CIPPIdentityLabel -Identity $_ } | Where-Object { $_ } | Sort-Object -Unique) + + $RowsOut.Add([PSCustomObject]@{ + id = "$($Drive.id)_$($DriveItem.id)_$($Permission.id)" + siteId = $Site.SiteId + siteName = $Site.SiteName + siteUrl = $Site.SiteUrl + workload = if ($Site.IsPersonalSite) { 'OneDrive' } else { 'SharePoint' } + driveId = $Drive.id + driveName = $Drive.name + itemId = $DriveItem.id + fileName = $DriveItem.name + itemUrl = $DriveItem.webUrl + itemType = if ($DriveItem.folder) { 'Folder' } else { 'File' } + size = $DriveItem.size + lastModifiedDateTime = $DriveItem.lastModifiedDateTime + permissionId = $Permission.id + linkType = $LinkType + linkScope = $LinkScope + classification = $Classification + roles = @($Permission.roles) + sharedWith = $SharedWith + linkUrl = $LinkUrl + hasPassword = $Permission.hasPassword ?? $false + expirationDateTime = $Permission.expirationDateTime + }) + } + } + } + + try { + # 1) Drives (document libraries) for this one site. + $Drives = @() + try { + $Drives = @(New-GraphGetRequest -uri "https://graph.microsoft.com/beta/sites/$SiteId/drives?`$select=id,name,driveType,webUrl" -tenantid $TenantFilter -asapp $true) + } catch { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Sharing links: could not list drives for '$SiteUrl': $($_.Exception.Message)" -sev Warning + return @() + } + + $SiteContext = [PSCustomObject]@{ + SiteId = $SiteId + SiteName = $SiteName + 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. + 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() + } + } + # 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() + } + } + + return @($Rows) + + } catch { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Sharing links: failed scanning site '$SiteUrl': $($_.Exception.Message)" -sev Error -LogData (Get-CippException -Exception $_) + return @($Rows) + } +} diff --git a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/SharePoint Sharing/Push-StoreSharePointSharingLinks.ps1 b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/SharePoint Sharing/Push-StoreSharePointSharingLinks.ps1 new file mode 100644 index 0000000000000..40532b7f4b4e1 --- /dev/null +++ b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/SharePoint Sharing/Push-StoreSharePointSharingLinks.ps1 @@ -0,0 +1,36 @@ +function Push-StoreSharePointSharingLinks { + <# + .SYNOPSIS + Post-execution function that aggregates per-site sharing links and writes the cache. + + .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. + + .FUNCTIONALITY + Entrypoint + #> + [CmdletBinding()] + param($Item) + + $TenantFilter = $Item.Parameters.TenantFilter + + 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) } + } + } + + 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 + return + + } catch { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to store SharePoint sharing links: $($_.Exception.Message)" -sev Error -LogData (Get-CippException -Exception $_) + throw + } +} diff --git a/Modules/CIPPCore/Public/GraphHelper/Get-GraphToken.ps1 b/Modules/CIPPCore/Public/GraphHelper/Get-GraphToken.ps1 index 62005b67c8e80..3a1aaffecd31e 100644 --- a/Modules/CIPPCore/Public/GraphHelper/Get-GraphToken.ps1 +++ b/Modules/CIPPCore/Public/GraphHelper/Get-GraphToken.ps1 @@ -1,8 +1,20 @@ -function Get-GraphToken($tenantid, $scope, $AsApp, $AppID, $AppSecret, $refreshToken, $ReturnRefresh, $SkipCache) { +function Get-GraphToken { <# .FUNCTIONALITY Internal #> + [CmdletBinding()] + param( + $tenantid, + $scope, + $AsApp, + $AppID, + $AppSecret, + $refreshToken, + $ReturnRefresh, + $SkipCache, + [switch]$UseCertificate + ) if (!$scope) { $scope = 'https://graph.microsoft.com/.default' } if (!$tenantid) { $tenantid = $env:TenantID } @@ -12,6 +24,7 @@ function Get-GraphToken($tenantid, $scope, $AsApp, $AppID, $AppSecret, $refreshT if ($UseSharedTokenCache) { $CacheClientId = if ($AppID) { [string]$AppID } else { [string]$env:ApplicationID } $GrantType = if ($asApp -eq $true -or ($null -ne $AppID -and $null -ne $AppSecret)) { 'client_credentials' } else { 'refresh_token' } + if ($UseCertificate -eq $true) { $GrantType = "${GrantType}_certificate" } $SharedTokenCacheKey = [CIPP.CIPPTokenCache]::BuildKey([string]$tenantid, [string]$scope, [bool]$asApp, $CacheClientId, $GrantType) $SharedCacheEntry = [CIPP.CIPPTokenCache]::Lookup($SharedTokenCacheKey, 120) if ($SharedCacheEntry.Found -and -not [string]::IsNullOrWhiteSpace($SharedCacheEntry.TokenPayloadJson)) { @@ -135,11 +148,30 @@ function Get-GraphToken($tenantid, $scope, $AsApp, $AppID, $AppSecret, $refreshT if ($UseSharedTokenCache) { $CacheClientId = if ($AppID) { [string]$AppID } else { [string]$env:ApplicationID } $GrantType = if ($asApp -eq $true -or ($null -ne $AppID -and $null -ne $AppSecret)) { 'client_credentials' } else { 'refresh_token' } + if ($UseCertificate -eq $true) { $GrantType = "${GrantType}_certificate" } $SharedTokenCacheKey = [CIPP.CIPPTokenCache]::BuildKey([string]$tenantid, [string]$scope, [bool]$asApp, $CacheClientId, $GrantType) } try { - $AccessToken = (Invoke-CIPPRestMethod -Method post -Uri "https://login.microsoftonline.com/$($tenantid)/oauth2/v2.0/token" -Body $Authbody -ContentType 'application/x-www-form-urlencoded' -ErrorAction Stop) + if ($UseCertificate -eq $true) { + $SAMCert = Get-CIPPSAMCertificate -ErrorAction Stop + if (-not $SAMCert) { throw 'No SAM certificate available. Run Update-CIPPSAMCertificate to create one.' } + } + if ($UseCertificate -eq $true -and $AuthBody.grant_type -eq 'client_credentials') { + # App-only with certificate: use the dedicated cert token function + # (has its own cache under the same key and the AADSTS700027 retry) + $AccessToken = Get-GraphTokenFromCert -TenantId $tenantid -AppId $AuthBody.client_id -Scope $scope -Certificate $SAMCert.Certificate -SkipCache:($SkipCache -eq $true) -ErrorAction Stop + if (-not $AccessToken.access_token) { throw "Could not get a token using the SAM certificate for $tenantid" } + } else { + if ($UseCertificate -eq $true) { + # Delegated with certificate: authenticate the app with a signed assertion + # instead of the client secret; the refresh token still provides user context + $null = $AuthBody.Remove('client_secret') + $AuthBody.client_assertion_type = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer' + $AuthBody.client_assertion = New-CIPPCertificateAssertion -TenantId $tenantid -AppId $AuthBody.client_id -Certificate $SAMCert.Certificate + } + $AccessToken = (Invoke-CIPPRestMethod -Method post -Uri "https://login.microsoftonline.com/$($tenantid)/oauth2/v2.0/token" -Body $Authbody -ContentType 'application/x-www-form-urlencoded' -ErrorAction Stop) + } if ($null -eq $AccessToken.expires_on -and $AccessToken.expires_in) { $ExpiresOn = [int](Get-Date -UFormat %s -Millisecond 0) + $AccessToken.expires_in Add-Member -InputObject $AccessToken -NotePropertyName 'expires_on' -NotePropertyValue $ExpiresOn -Force diff --git a/Modules/CIPPCore/Public/GraphHelper/Get-GraphTokenFromCert.ps1 b/Modules/CIPPCore/Public/GraphHelper/Get-GraphTokenFromCert.ps1 index 274777b14c7d6..4ff324b320611 100644 --- a/Modules/CIPPCore/Public/GraphHelper/Get-GraphTokenFromCert.ps1 +++ b/Modules/CIPPCore/Public/GraphHelper/Get-GraphTokenFromCert.ps1 @@ -36,74 +36,8 @@ function Get-GraphTokenFromCert { } } - # get sha256 hash of certificate - $sha256 = New-Object System.Security.Cryptography.SHA256CryptoServiceProvider - $hash = $sha256.ComputeHash($Certificate.RawData) - $hash = [Convert]::ToBase64String($hash) - - # Create JWT timestamp for expiration - $StartDate = (Get-Date '1970-01-01T00:00:00Z' ).ToUniversalTime() - $JWTExpirationTimeSpan = (New-TimeSpan -Start $StartDate -End (Get-Date).ToUniversalTime().AddMinutes(2)).TotalSeconds - $JWTExpiration = [math]::Round($JWTExpirationTimeSpan, 0) - - # Create JWT validity start timestamp - $NotBeforeExpirationTimeSpan = (New-TimeSpan -Start $StartDate -End ((Get-Date).ToUniversalTime())).TotalSeconds - $NotBefore = [math]::Round($NotBeforeExpirationTimeSpan, 0) - - # Create JWT header - $JWTHeader = @{ - alg = 'PS256' - typ = 'JWT' - # Use the CertificateBase64Hash and replace/strip to match web encoding of base64 - 'x5t#S256' = $hash -replace '\+', '-' -replace '/', '_' -replace '=' - } - - # Create JWT payload - $JWTPayLoad = @{ - # Issuer = your application - iss = $AppId - - # What endpoint is allowed to use this JWT - aud = "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token" - - # JWT ID: random guid - jti = [guid]::NewGuid() - - # Expiration timestamp - exp = $JWTExpiration - - # Not to be used before - nbf = $NotBefore - - # JWT Subject - sub = $AppId - } - - # Convert header and payload to base64 - $JWTHeaderToByte = [System.Text.Encoding]::UTF8.GetBytes(($JWTHeader | ConvertTo-Json)) - $EncodedHeader = [System.Convert]::ToBase64String($JWTHeaderToByte) - - $JWTPayLoadToByte = [System.Text.Encoding]::UTF8.GetBytes(($JWTPayload | ConvertTo-Json)) - $EncodedPayload = [System.Convert]::ToBase64String($JWTPayLoadToByte) - - # Join header and Payload with "." to create a valid (unsigned) JWT - $JWT = $EncodedHeader + '.' + $EncodedPayload - - # Get the private key object of your certificate - # $PrivateKey = $Certificate.PrivateKey - $PrivateKey = ([System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPrivateKey($Certificate)) - - # Define RSA signature and hashing algorithm - $RSAPadding = [Security.Cryptography.RSASignaturePadding]::Pss - $HashAlgorithm = [Security.Cryptography.HashAlgorithmName]::SHA256 - - # Create a signature of the JWT - $Signature = [Convert]::ToBase64String( - $PrivateKey.SignData([System.Text.Encoding]::UTF8.GetBytes($JWT), $HashAlgorithm, $RSAPadding) - ) -replace '\+', '-' -replace '/', '_' -replace '=' - - # Join the signature to the JWT with "." - $JWT = $JWT + '.' + $Signature + # Build the signed client assertion (shared with Get-GraphToken -UseCertificate) + $JWT = New-CIPPCertificateAssertion -TenantId $TenantId -AppId $AppId -Certificate $Certificate # Create a hash with body parameters $Body = @{ diff --git a/Modules/CIPPCore/Public/GraphHelper/New-CIPPCertificateAssertion.ps1 b/Modules/CIPPCore/Public/GraphHelper/New-CIPPCertificateAssertion.ps1 new file mode 100644 index 0000000000000..bda4ea3aa77e6 --- /dev/null +++ b/Modules/CIPPCore/Public/GraphHelper/New-CIPPCertificateAssertion.ps1 @@ -0,0 +1,94 @@ +function New-CIPPCertificateAssertion { + <# + .SYNOPSIS + Builds a PS256-signed JWT client assertion from a certificate + + .DESCRIPTION + Creates the signed JWT used as client_assertion when authenticating an app against the + Entra token endpoint with a certificate instead of a client secret. Shared by the + client_credentials flow (Get-GraphTokenFromCert) and the refresh_token flow + (Get-GraphToken -UseCertificate). + + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantId, + + [Parameter(Mandatory = $true)] + [string]$AppId, + + [Parameter(Mandatory = $true)] + [System.Security.Cryptography.X509Certificates.X509Certificate2]$Certificate + ) + + # get sha256 hash of certificate for the x5t#S256 header + $sha256 = New-Object System.Security.Cryptography.SHA256CryptoServiceProvider + $hash = $sha256.ComputeHash($Certificate.RawData) + $hash = [Convert]::ToBase64String($hash) + + # Create JWT timestamp for expiration + $StartDate = (Get-Date '1970-01-01T00:00:00Z' ).ToUniversalTime() + $JWTExpirationTimeSpan = (New-TimeSpan -Start $StartDate -End (Get-Date).ToUniversalTime().AddMinutes(2)).TotalSeconds + $JWTExpiration = [math]::Round($JWTExpirationTimeSpan, 0) + + # Create JWT validity start timestamp + $NotBeforeExpirationTimeSpan = (New-TimeSpan -Start $StartDate -End ((Get-Date).ToUniversalTime())).TotalSeconds + $NotBefore = [math]::Round($NotBeforeExpirationTimeSpan, 0) + + # Create JWT header + $JWTHeader = @{ + alg = 'PS256' + typ = 'JWT' + # Use the CertificateBase64Hash and replace/strip to match web encoding of base64 + 'x5t#S256' = $hash -replace '\+', '-' -replace '/', '_' -replace '=' + } + + # Create JWT payload + $JWTPayLoad = @{ + # Issuer = your application + iss = $AppId + + # What endpoint is allowed to use this JWT + aud = "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token" + + # JWT ID: random guid + jti = [guid]::NewGuid() + + # Expiration timestamp + exp = $JWTExpiration + + # Not to be used before + nbf = $NotBefore + + # JWT Subject + sub = $AppId + } + + # Convert header and payload to base64 + $JWTHeaderToByte = [System.Text.Encoding]::UTF8.GetBytes(($JWTHeader | ConvertTo-Json)) + $EncodedHeader = [System.Convert]::ToBase64String($JWTHeaderToByte) + + $JWTPayLoadToByte = [System.Text.Encoding]::UTF8.GetBytes(($JWTPayload | ConvertTo-Json)) + $EncodedPayload = [System.Convert]::ToBase64String($JWTPayLoadToByte) + + # Join header and Payload with "." to create a valid (unsigned) JWT + $JWT = $EncodedHeader + '.' + $EncodedPayload + + # Get the private key object of your certificate + $PrivateKey = ([System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPrivateKey($Certificate)) + + # Define RSA signature and hashing algorithm + $RSAPadding = [Security.Cryptography.RSASignaturePadding]::Pss + $HashAlgorithm = [Security.Cryptography.HashAlgorithmName]::SHA256 + + # Create a signature of the JWT + $Signature = [Convert]::ToBase64String( + $PrivateKey.SignData([System.Text.Encoding]::UTF8.GetBytes($JWT), $HashAlgorithm, $RSAPadding) + ) -replace '\+', '-' -replace '/', '_' -replace '=' + + # Join the signature to the JWT with "." + return $JWT + '.' + $Signature +} diff --git a/Modules/CIPPCore/Public/GraphHelper/New-ExoRequest.ps1 b/Modules/CIPPCore/Public/GraphHelper/New-ExoRequest.ps1 index 570ab8d1f043a..01bae571afc76 100644 --- a/Modules/CIPPCore/Public/GraphHelper/New-ExoRequest.ps1 +++ b/Modules/CIPPCore/Public/GraphHelper/New-ExoRequest.ps1 @@ -20,7 +20,7 @@ function New-ExoRequest { [Parameter(Mandatory = $false, ParameterSetName = 'ExoRequest')] [bool]$useSystemMailbox, - [string]$tenantid, + [string]$tenantid = $env:TenantID, [bool]$NoAuthCheck, @@ -41,17 +41,9 @@ function New-ExoRequest { } else { $Resource = 'https://outlook.office365.com' } - if ($UseCertificate) { - # App-only auth using the stored SAM certificate (always client_credentials) - $SAMCert = Get-CIPPSAMCertificate -ErrorAction Stop - if (-not $SAMCert) { throw 'No SAM certificate available. Run Update-CIPPSAMCertificate to create one.' } - $CertTenantId = if ($tenantid) { $tenantid } else { $env:TenantID } - $CertToken = Get-GraphTokenFromCert -TenantId $CertTenantId -AppId $env:ApplicationID -Scope "$Resource/.default" -Certificate $SAMCert.Certificate -ErrorAction Stop - if (-not $CertToken.access_token) { throw "Could not get a token using the SAM certificate for tenant $tenantid" } - $token = @{ Authorization = "Bearer $($CertToken.access_token)" } - } else { - $token = Get-GraphToken -Tenantid $tenantid -scope "$Resource/.default" -AsApp:$AsApp.IsPresent - } + # -UseCertificate authenticates the app with the SAM certificate instead of the + # client secret: delegated (refresh token) by default, app-only with -AsApp + $token = Get-GraphToken -Tenantid $tenantid -scope "$Resource/.default" -AsApp:$AsApp.IsPresent -UseCertificate:$UseCertificate if ($cmdParams) { #if cmdParams is a pscustomobject, convert to hashtable, otherwise leave as is diff --git a/Modules/CIPPCore/Public/GraphHelper/New-GraphGetRequest.ps1 b/Modules/CIPPCore/Public/GraphHelper/New-GraphGetRequest.ps1 index 89cfcc6885e80..a99502edcdd3f 100644 --- a/Modules/CIPPCore/Public/GraphHelper/New-GraphGetRequest.ps1 +++ b/Modules/CIPPCore/Public/GraphHelper/New-GraphGetRequest.ps1 @@ -40,22 +40,9 @@ function New-GraphGetRequest { $headers = $Headers } else { $TokenScope = if ($scope -eq 'ExchangeOnline') { 'https://outlook.office365.com/.default' } else { $scope } - if ($UseCertificate) { - # App-only auth using the stored SAM certificate (always client_credentials) - $SAMCert = Get-CIPPSAMCertificate -ErrorAction Stop - if (-not $SAMCert) { throw 'No SAM certificate available. Run Update-CIPPSAMCertificate to create one.' } - $CertTokenSplat = @{ - TenantId = if ($tenantid) { $tenantid } else { $env:TenantID } - AppId = $env:ApplicationID - Certificate = $SAMCert.Certificate - } - if ($TokenScope) { $CertTokenSplat.Scope = $TokenScope } - $CertToken = Get-GraphTokenFromCert @CertTokenSplat -ErrorAction Stop - if (-not $CertToken.access_token) { throw "Could not get a token using the SAM certificate for tenant $tenantid" } - $headers = @{ Authorization = "Bearer $($CertToken.access_token)" } - } else { - $headers = Get-GraphToken -tenantid $tenantid -scope $TokenScope -AsApp $asapp -SkipCache $skipTokenCache - } + # -UseCertificate authenticates the app with the SAM certificate instead of the + # client secret: delegated (refresh token) by default, app-only with -AsApp $true + $headers = Get-GraphToken -tenantid $tenantid -scope $TokenScope -AsApp $asapp -SkipCache $skipTokenCache -UseCertificate:$UseCertificate } if ($ComplexFilter) { $headers['ConsistencyLevel'] = 'eventual' diff --git a/Modules/CIPPCore/Public/GraphHelper/New-GraphPOSTRequest.ps1 b/Modules/CIPPCore/Public/GraphHelper/New-GraphPOSTRequest.ps1 index ac9ab5302c93c..1eeb101e7c309 100644 --- a/Modules/CIPPCore/Public/GraphHelper/New-GraphPOSTRequest.ps1 +++ b/Modules/CIPPCore/Public/GraphHelper/New-GraphPOSTRequest.ps1 @@ -26,21 +26,10 @@ function New-GraphPOSTRequest { if ($NoAuthCheck -or (Get-AuthorisedRequest -Uri $uri -TenantID $tenantid)) { if ($Headers) { $Headers = $Headers - } elseif ($UseCertificate) { - # App-only auth using the stored SAM certificate (always client_credentials) - $SAMCert = Get-CIPPSAMCertificate -ErrorAction Stop - if (-not $SAMCert) { throw 'No SAM certificate available. Run Update-CIPPSAMCertificate to create one.' } - $CertTokenSplat = @{ - TenantId = if ($tenantid) { $tenantid } else { $env:TenantID } - AppId = $env:ApplicationID - Certificate = $SAMCert.Certificate - } - if ($scope) { $CertTokenSplat.Scope = $scope } - $CertToken = Get-GraphTokenFromCert @CertTokenSplat -ErrorAction Stop - if (-not $CertToken.access_token) { throw "Could not get a token using the SAM certificate for tenant $tenantid" } - $Headers = @{ Authorization = "Bearer $($CertToken.access_token)" } } else { - $Headers = Get-GraphToken -tenantid $tenantid -scope $scope -AsApp $asapp -SkipCache $skipTokenCache + # -UseCertificate authenticates the app with the SAM certificate instead of the + # client secret: delegated (refresh token) by default, app-only with -AsApp $true + $Headers = Get-GraphToken -tenantid $tenantid -scope $scope -AsApp $asapp -SkipCache $skipTokenCache -UseCertificate:$UseCertificate } if ($AddedHeaders) { foreach ($header in $AddedHeaders.GetEnumerator()) { diff --git a/Modules/CIPPCore/Public/Invoke-CIPPDBCacheCollection.ps1 b/Modules/CIPPCore/Public/Invoke-CIPPDBCacheCollection.ps1 index 673b4514c6c27..1c2eb64addd24 100644 --- a/Modules/CIPPCore/Public/Invoke-CIPPDBCacheCollection.ps1 +++ b/Modules/CIPPCore/Public/Invoke-CIPPDBCacheCollection.ps1 @@ -143,7 +143,6 @@ function Invoke-CIPPDBCacheCollection { 'SPOTenantSyncClientRestriction' 'SharePointSiteUsage' 'OneDriveUsage' - 'SharePointSharingLinks' ) Teams = @( 'CsTeamsMeetingPolicy' diff --git a/Modules/CIPPCore/Public/Tools/Get-CIPPSchedulerBlockedCommands.ps1 b/Modules/CIPPCore/Public/Tools/Get-CIPPSchedulerBlockedCommands.ps1 index cdd0a149c829d..be6aeed06a6ad 100644 --- a/Modules/CIPPCore/Public/Tools/Get-CIPPSchedulerBlockedCommands.ps1 +++ b/Modules/CIPPCore/Public/Tools/Get-CIPPSchedulerBlockedCommands.ps1 @@ -14,6 +14,7 @@ function Get-CIPPSchedulerBlockedCommands { # Token & authentication functions - would exfiltrate access/refresh tokens 'Get-GraphToken' 'Get-GraphTokenFromCert' + 'New-CIPPCertificateAssertion' 'Get-ClassicAPIToken' 'Get-CIPPAzIdentityToken' 'Get-CIPPAuthentication' diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheSharePointSharingLinks.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheSharePointSharingLinks.ps1 index 707181c18f8eb..0516291c1b9f8 100644 --- a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheSharePointSharingLinks.ps1 +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheSharePointSharingLinks.ps1 @@ -1,14 +1,16 @@ function Set-CIPPDBCacheSharePointSharingLinks { <# .SYNOPSIS - Caches SharePoint and OneDrive sharing links for a tenant + Fans out SharePoint & OneDrive sharing link collection, one activity per site. .DESCRIPTION - Enumerates every drive in the tenant (SharePoint sites and OneDrive personal sites), - scans each drive via the driveItem delta endpoint for items carrying the "shared" facet - and collects the direct (non-inherited) sharing permissions of those items. Sharing - links are classified as Anonymous, External or Internal based on the link scope and the - recipients' domains; direct (non-link) grants to external users are included as well. + 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. .PARAMETER TenantFilter The tenant to cache sharing links for @@ -23,306 +25,66 @@ function Set-CIPPDBCacheSharePointSharingLinks { [string]$QueueId ) - # Returns $true when an identity (link recipient or direct grant) is external to the tenant. - function Test-CIPPExternalIdentity { - param($Identity, $InternalDomains) - $LoginName = [string]($Identity.siteUser.loginName ?? $Identity.user.loginName ?? '') - if ($LoginName -match '#ext#' -or $LoginName -match 'urn%3aspo%3aguest' -or $LoginName -match 'urn:spo:guest') { return $true } - $Email = [string]($Identity.user.email ?? $Identity.user.userPrincipalName ?? $Identity.siteUser.email ?? '') - if ($Email -match '#EXT#') { return $true } - if ($Email -and $Email.Contains('@') -and $InternalDomains.Count -gt 0) { - return -not $InternalDomains.Contains($Email.Split('@')[-1]) - } - return $false - } - - # Friendly display value for an identity, preferring email over display name. - function Get-CIPPIdentityLabel { - param($Identity) - $Identity.user.email ?? $Identity.user.userPrincipalName ?? $Identity.siteUser.email ?? $Identity.user.displayName ?? $Identity.siteUser.displayName ?? $Identity.group.email ?? $Identity.group.displayName ?? $Identity.siteGroup.displayName - } - - # Flatten a shared driveItem to only the scalars we need downstream. Keeping full Graph - # Site/Drive/Item objects in $SharedItems across the whole tenant is a major memory sink. - function New-CIPPLeanSharedItem { - param($Item, $Site, $Drive) - [PSCustomObject]@{ - SiteId = $Site.id - SiteName = $Site.displayName ?? $Site.name - SiteUrl = $Site.webUrl - IsPersonalSite = [bool]$Site.isPersonalSite - DriveId = $Drive.id - DriveName = $Drive.name - ItemId = $Item.id - FileName = $Item.name - ItemUrl = $Item.webUrl - IsFolder = [bool]$Item.folder - Size = $Item.size - LastModified = $Item.lastModifiedDateTime - } - } - - $StartTime = Get-Date - Write-Host "[SharingLinks][$TenantFilter] START at $($StartTime.ToString('o'))" try { - Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Caching SharePoint and OneDrive sharing links' -sev Debug + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Starting SharePoint/OneDrive sharing link collection (per-site fan-out)' -sev Debug - # Verified domains, used to tell internal from external recipients. - $InternalDomains = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + # Verified domains, used by each site activity to tell internal from external recipients. + $InternalDomains = [System.Collections.Generic.List[string]]::new() try { $Domains = New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/domains?`$select=id,isVerified" -tenantid $TenantFilter -asapp $true - foreach ($Domain in ($Domains | Where-Object { $_.isVerified })) { [void]$InternalDomains.Add($Domain.id) } - Write-Host "[SharingLinks][$TenantFilter] Phase 0 (domains): $($InternalDomains.Count) verified domains" + foreach ($Domain in ($Domains | Where-Object { $_.isVerified })) { $InternalDomains.Add([string]$Domain.id) } } catch { - Write-Host "[SharingLinks][$TenantFilter] Phase 0 (domains) FAILED: $($_.Exception.Message)" Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Could not list verified domains for sharing link classification: $($_.Exception.Message)" -sev Warning } - # 1) All sites, including OneDrive personal sites. - try { - $Sites = @(New-GraphGetRequest -uri "https://graph.microsoft.com/beta/sites/getAllSites?`$select=id,displayName,name,webUrl,isPersonalSite&`$top=999" -tenantid $TenantFilter -asapp $true) - } catch { - Write-Host "[SharingLinks][$TenantFilter] Phase 1 (getAllSites) FAILED: $($_.Exception.Message)" - throw - } - Write-Host "[SharingLinks][$TenantFilter] Phase 1 (getAllSites): $($Sites.Count) sites (+$([math]::Round(((Get-Date) - $StartTime).TotalSeconds,1))s)" + # All sites, including OneDrive personal sites. Cheap - just the site list, no drive scan here. + $Sites = @(New-GraphGetRequest -uri "https://graph.microsoft.com/beta/sites/getAllSites?`$select=id,displayName,name,webUrl,isPersonalSite&`$top=999" -tenantid $TenantFilter -asapp $true) - # 2) All document libraries (drives) per site, in bulk. - $SiteByRequestId = @{} - $RequestId = 0 - $DriveRequests = foreach ($Site in $Sites) { - $SiteByRequestId["$RequestId"] = $Site - @{ - id = "$RequestId" - method = 'GET' - url = "sites/$($Site.id)/drives?`$select=id,name,driveType,webUrl" - } - $RequestId++ + if ($Sites.Count -eq 0) { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'No sites found; writing empty SharePointSharingLinks cache' -sev Debug + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'SharePointSharingLinks' -Data @() -AddCount + return } - $DriveEntries = [System.Collections.Generic.List[object]]::new() - $FailedDriveLookups = 0 - if (@($DriveRequests).Count -gt 0) { - Write-Host "[SharingLinks][$TenantFilter] Phase 2 (site drives): requesting drives for $(@($DriveRequests).Count) sites" - try { - $DriveResponses = New-GraphBulkRequest -tenantid $TenantFilter -Requests @($DriveRequests) -asapp $true - } catch { - Write-Host "[SharingLinks][$TenantFilter] Phase 2 (site drives) BULK FAILED: $($_.Exception.Message)" - throw - } - foreach ($Response in $DriveResponses) { - if ($Response.status -and $Response.status -ne 200) { $FailedDriveLookups++; continue } - $Site = $SiteByRequestId["$($Response.id)"] - foreach ($Drive in @($Response.body.value)) { - if ($Drive.id) { - $DriveEntries.Add([PSCustomObject]@{ Site = $Site; Drive = $Drive }) - } - } + $Batch = foreach ($Site in $Sites) { + [PSCustomObject]@{ + FunctionName = 'DBCacheSharePointSiteSharingLinks' + TenantFilter = $TenantFilter + SiteId = $Site.id + SiteName = $Site.displayName ?? $Site.name + SiteUrl = $Site.webUrl + IsPersonalSite = [bool]$Site.isPersonalSite + InternalDomains = @($InternalDomains) + QueueId = $QueueId + QueueName = "Sharing Links - $($Site.webUrl)" } } - Write-Host "[SharingLinks][$TenantFilter] Phase 2 (site drives): $($DriveEntries.Count) drives, $FailedDriveLookups site drive lookups failed (+$([math]::Round(((Get-Date) - $StartTime).TotalSeconds,1))s)" - - # 3) Delta-scan every drive and keep items that carry the "shared" facet. - # A bulk delta over many drives auto-follows every drive's nextLinks and buffers the - # whole file tree in memory, OOM-killing the worker on tenants with huge libraries. - # Strategy: bulk only the FIRST delta page per drive (fast + parallel for the common - # case that fits in one page) via -NoPaginateIds, then stream any large drive's - # remaining pages one page at a time, discarding non-shared items as they flow. Only - # lean scalar objects are retained. - $DeltaBatchSize = 20 - $SharedItems = [System.Collections.Generic.List[object]]::new() - $FailedDrives = 0 - $LargeDrives = 0 - $DriveTotal = $DriveEntries.Count - Write-Host "[SharingLinks][$TenantFilter] Phase 3 (delta scan): scanning $DriveTotal drives for shared items in batches of $DeltaBatchSize" - for ($Offset = 0; $Offset -lt $DriveTotal; $Offset += $DeltaBatchSize) { - $BatchEntries = @($DriveEntries[$Offset..([Math]::Min($Offset + $DeltaBatchSize - 1, $DriveTotal - 1))]) - $DriveByRequestId = @{} - $RequestId = 0 - $DeltaRequests = foreach ($Entry in $BatchEntries) { - $DriveByRequestId["$RequestId"] = $Entry - @{ - id = "$RequestId" - method = 'GET' - url = "drives/$($Entry.Drive.id)/root/delta?`$select=id,name,webUrl,folder,shared,size,lastModifiedDateTime&`$top=999" - } - $RequestId++ - } + # Track the per-site activities against the same queue counter. + if ($QueueId) { try { - # -NoPaginateIds with every id => bulk returns only each drive's first page and - # leaves its @odata.nextLink intact instead of buffering all continuations. - $DeltaResponses = New-GraphBulkRequest -tenantid $TenantFilter -Requests @($DeltaRequests) -asapp $true -NoPaginateIds @($DeltaRequests.id) + Update-CippQueueEntry -RowKey $QueueId -TotalTasks $Sites.Count -IncrementTotalTasks } catch { - # A whole-batch failure (throttle/token) loses only this batch's drives, not the run. - $FailedDrives += @($BatchEntries).Count - Write-Host "[SharingLinks][$TenantFilter] Phase 3: batch at offset $Offset ($(@($BatchEntries).Count) drives) BULK FAILED: $($_.Exception.Message)" - $DeltaResponses = $null - [System.GC]::Collect() - continue - } - - foreach ($Response in $DeltaResponses) { - if ($Response.status -and $Response.status -ne 200) { - $FailedDrives++ - $FailedEntry = $DriveByRequestId["$($Response.id)"] - Write-Host "[SharingLinks][$TenantFilter] Phase 3: drive delta returned status $($Response.status) for '$($FailedEntry.Site.webUrl)' / '$($FailedEntry.Drive.name)' - $($Response.body.error.message)" - continue - } - $Entry = $DriveByRequestId["$($Response.id)"] - $Site = $Entry.Site - $Drive = $Entry.Drive - - # First page (already fetched in bulk). - foreach ($Item in @($Response.body.value)) { - if ($Item.shared -and -not $Item.deleted) { - $SharedItems.Add((New-CIPPLeanSharedItem -Item $Item -Site $Site -Drive $Drive)) - } - } - - # Large drive: stream the remaining pages one at a time to bound memory. - $NextLink = $Response.body.'@odata.nextLink' - if ($NextLink) { - $LargeDrives++ - try { - New-GraphGetRequest -uri $NextLink -tenantid $TenantFilter -asapp $true -Stream | - Where-Object { $_.shared -and -not $_.deleted } | - ForEach-Object { $SharedItems.Add((New-CIPPLeanSharedItem -Item $_ -Site $Site -Drive $Drive)) } - } catch { - $FailedDrives++ - Write-Host "[SharingLinks][$TenantFilter] Phase 3: streaming remaining pages FAILED for '$($Site.webUrl)' / '$($Drive.name)': $($_.Exception.Message)" - } - } + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Could not update queue $QueueId with sharing-link tasks: $($_.Exception.Message)" -sev Warning } - - # Release this batch's raw item pages before the next batch. - $DeltaResponses = $null - [System.GC]::Collect() - Write-Host "[SharingLinks][$TenantFilter] Phase 3: processed $([Math]::Min($Offset + $DeltaBatchSize, $DriveTotal))/$DriveTotal drives, $($SharedItems.Count) shared so far, $LargeDrives large drives streamed (+$([math]::Round(((Get-Date) - $StartTime).TotalSeconds,1))s)" } - # If every drive failed, don't proceed to overwrite good cached data with an empty set. - if ($DriveTotal -gt 0 -and $FailedDrives -ge $DriveTotal) { - throw "All $DriveTotal drive delta scans failed - aborting to preserve existing cache." - } - Write-Host "[SharingLinks][$TenantFilter] Phase 3 (delta scan): $($SharedItems.Count) shared items, $FailedDrives drive scans failed, $LargeDrives large drives streamed (+$([math]::Round(((Get-Date) - $StartTime).TotalSeconds,1))s)" - # 4) Fetch the permissions of every shared item and 5) build one row per sharing link (any - # scope) or direct grant to an external user. Batched for the same memory reason as the - # delta scan above. - $PermissionBatchSize = 100 - $Rows = [System.Collections.Generic.List[object]]::new() - $FailedPermissionLookups = 0 - $SharedTotal = $SharedItems.Count - if ($SharedTotal -gt 0) { - Write-Host "[SharingLinks][$TenantFilter] Phase 4 (permissions): fetching permissions for $SharedTotal shared items in batches of $PermissionBatchSize" - } - for ($Offset = 0; $Offset -lt $SharedTotal; $Offset += $PermissionBatchSize) { - $BatchShared = @($SharedItems[$Offset..([Math]::Min($Offset + $PermissionBatchSize - 1, $SharedTotal - 1))]) - $SharedItemByRequestId = @{} - $RequestId = 0 - $PermissionRequests = foreach ($Entry in $BatchShared) { - $SharedItemByRequestId["$RequestId"] = $Entry - @{ - id = "$RequestId" - method = 'GET' - url = "drives/$($Entry.DriveId)/items/$($Entry.ItemId)/permissions" + $InputObject = [PSCustomObject]@{ + Batch = @($Batch) + OrchestratorName = "SharePointSharingLinks_$TenantFilter" + SkipLog = $true + PostExecution = @{ + FunctionName = 'StoreSharePointSharingLinks' + Parameters = @{ + TenantFilter = $TenantFilter } - $RequestId++ } - - try { - $PermissionResponses = New-GraphBulkRequest -tenantid $TenantFilter -Requests @($PermissionRequests) -asapp $true - } catch { - $FailedPermissionLookups += @($BatchShared).Count - Write-Host "[SharingLinks][$TenantFilter] Phase 4: batch at offset $Offset ($(@($BatchShared).Count) items) BULK FAILED: $($_.Exception.Message)" - $PermissionResponses = $null - [System.GC]::Collect() - continue - } - - foreach ($Response in $PermissionResponses) { - if ($Response.status -and $Response.status -ne 200) { $FailedPermissionLookups++; continue } - $Entry = $SharedItemByRequestId["$($Response.id)"] - - foreach ($Permission in @($Response.body.value)) { - # Only permissions set on the item itself; inherited ones are reported on their parent. - if ($Permission.inheritedFrom) { continue } - - if ($Permission.link) { - $Recipients = @($Permission.grantedToIdentitiesV2 ?? $Permission.grantedToIdentities) - $LinkScope = $Permission.link.scope ?? 'users' - $Classification = switch ($LinkScope) { - 'anonymous' { 'Anonymous' } - 'organization' { 'Internal' } - 'existingAccess' { 'Internal' } - default { - $HasExternal = $false - foreach ($Recipient in $Recipients) { - if (Test-CIPPExternalIdentity -Identity $Recipient -InternalDomains $InternalDomains) { $HasExternal = $true; break } - } - if ($HasExternal) { 'External' } else { 'Internal' } - } - } - $LinkType = $Permission.link.type ?? 'link' - $LinkUrl = $Permission.link.webUrl - } else { - # Direct grant (no sharing link): only report grants to external users. - $Recipients = @($Permission.grantedToV2 ?? $Permission.grantedTo) - if ($Permission.roles -contains 'owner') { continue } - $HasExternal = $false - foreach ($Recipient in $Recipients) { - if (Test-CIPPExternalIdentity -Identity $Recipient -InternalDomains $InternalDomains) { $HasExternal = $true; break } - } - if (-not $HasExternal) { continue } - $Classification = 'External' - $LinkScope = 'direct' - $LinkType = 'directGrant' - $LinkUrl = $null - } - - $SharedWith = @($Recipients | ForEach-Object { Get-CIPPIdentityLabel -Identity $_ } | Where-Object { $_ } | Sort-Object -Unique) - - $Rows.Add([PSCustomObject]@{ - id = "$($Entry.DriveId)_$($Entry.ItemId)_$($Permission.id)" - siteId = $Entry.SiteId - siteName = $Entry.SiteName - siteUrl = $Entry.SiteUrl - workload = if ($Entry.IsPersonalSite) { 'OneDrive' } else { 'SharePoint' } - driveId = $Entry.DriveId - driveName = $Entry.DriveName - itemId = $Entry.ItemId - fileName = $Entry.FileName - itemUrl = $Entry.ItemUrl - itemType = if ($Entry.IsFolder) { 'Folder' } else { 'File' } - size = $Entry.Size - lastModifiedDateTime = $Entry.LastModified - permissionId = $Permission.id - linkType = $LinkType - linkScope = $LinkScope - classification = $Classification - roles = @($Permission.roles) - sharedWith = $SharedWith - linkUrl = $LinkUrl - hasPassword = $Permission.hasPassword ?? $false - expirationDateTime = $Permission.expirationDateTime - }) - } - } - - $PermissionResponses = $null - [System.GC]::Collect() - Write-Host "[SharingLinks][$TenantFilter] Phase 4: processed $([Math]::Min($Offset + $PermissionBatchSize, $SharedTotal))/$SharedTotal shared items, $($Rows.Count) links so far (+$([math]::Round(((Get-Date) - $StartTime).TotalSeconds,1))s)" } - Write-Host "[SharingLinks][$TenantFilter] Phase 5 (write): writing $($Rows.Count) rows to cache ($FailedPermissionLookups permission lookups failed)" - Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'SharePointSharingLinks' -Data @($Rows) -AddCount - - $Message = "Cached $($Rows.Count) sharing links across $($DriveEntries.Count) drives" + $(if ($FailedDrives -gt 0) { " ($FailedDrives drives could not be scanned)" } else { '' }) - Write-Host "[SharingLinks][$TenantFilter] DONE: $Message (total $([math]::Round(((Get-Date) - $StartTime).TotalSeconds,1))s)" - Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message $Message -sev Debug + $null = Start-CIPPOrchestrator -InputObject $InputObject + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Started sharing link collection across $($Sites.Count) sites" -sev Debug } catch { - Write-Host "[SharingLinks][$TenantFilter] ABORTED after $([math]::Round(((Get-Date) - $StartTime).TotalSeconds,1))s - existing cache left intact. Error: $($_.Exception.Message)" - Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to cache SharePoint sharing links: $($_.Exception.Message)" -sev Error -LogData (Get-CippException -Exception $_) + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to start SharePoint sharing link collection: $($_.Exception.Message)" -sev Error -LogData (Get-CippException -Exception $_) } } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecCippFunction.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecCippFunction.ps1 index 5d54f97826062..867ce9a67cb0d 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecCippFunction.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecCippFunction.ps1 @@ -14,6 +14,7 @@ function Invoke-ExecCippFunction { $BlockList = @( 'Get-GraphToken' 'Get-GraphTokenFromCert' + 'New-CIPPCertificateAssertion' 'Get-ClassicAPIToken' 'Get-CIPPSAMCertificate' 'New-CIPPSAMCertificate' diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecSetLibraryPermission.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecSetLibraryPermission.ps1 index 2ba78aca52211..e18f8793a4274 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecSetLibraryPermission.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecSetLibraryPermission.ps1 @@ -75,9 +75,9 @@ function Invoke-ExecSetLibraryPermission { $BaseUri = "$($SiteUrl.TrimEnd('/'))/_api" # Break role inheritance (copying the existing permissions) when the library still inherits. - $ListInfo = New-GraphGetRequest -uri "$BaseUri/web/lists(guid'$ListId')?`$select=HasUniqueRoleAssignments" -tenantid $TenantFilter -scope $Scope -extraHeaders $JsonAccept -CertAuth + $ListInfo = New-GraphGetRequest -uri "$BaseUri/web/lists(guid'$ListId')?`$select=HasUniqueRoleAssignments" -tenantid $TenantFilter -scope $Scope -extraHeaders $JsonAccept -UseCertificate if (-not $ListInfo.HasUniqueRoleAssignments) { - $null = New-GraphPostRequest -uri "$BaseUri/web/lists(guid'$ListId')/breakroleinheritance(copyRoleAssignments=true,clearSubscopes=false)" -tenantid $TenantFilter -scope $Scope -type POST -body '{}' -AddedHeaders $JsonAccept -CertAuth + $null = New-GraphPostRequest -uri "$BaseUri/web/lists(guid'$ListId')/breakroleinheritance(copyRoleAssignments=true,clearSubscopes=false)" -tenantid $TenantFilter -scope $Scope -type POST -body '{}' -AddedHeaders $JsonAccept -UseCertificate } $Granted = [System.Collections.Generic.List[string]]::new() @@ -85,11 +85,11 @@ function Invoke-ExecSetLibraryPermission { foreach ($Principal in $Principals) { try { $EnsureBody = ConvertTo-Json -Compress -InputObject @{ logonName = $Principal.LogonName } - $EnsuredUser = New-GraphPostRequest -uri "$BaseUri/web/ensureuser" -tenantid $TenantFilter -scope $Scope -type POST -body $EnsureBody -AddedHeaders $JsonAccept -CertAuth + $EnsuredUser = New-GraphPostRequest -uri "$BaseUri/web/ensureuser" -tenantid $TenantFilter -scope $Scope -type POST -body $EnsureBody -AddedHeaders $JsonAccept -UseCertificate if (-not $EnsuredUser.Id) { throw 'Could not resolve principal on the site.' } - $null = New-GraphPostRequest -uri "$BaseUri/web/lists(guid'$ListId')/roleassignments/addroleassignment(principalid=$($EnsuredUser.Id),roledefid=$RoleDefId)" -tenantid $TenantFilter -scope $Scope -type POST -body '{}' -AddedHeaders $JsonAccept -CertAuth + $null = New-GraphPostRequest -uri "$BaseUri/web/lists(guid'$ListId')/roleassignments/addroleassignment(principalid=$($EnsuredUser.Id),roledefid=$RoleDefId)" -tenantid $TenantFilter -scope $Scope -type POST -body '{}' -AddedHeaders $JsonAccept -UseCertificate $Granted.Add($Principal.Label) } catch { $Failed.Add("$($Principal.Label) ($($_.Exception.Message))") diff --git a/Shared/CIPPSharp/CIPPRestClient.cs b/Shared/CIPPSharp/CIPPRestClient.cs index 7647639c7251e..ee6e56bd5a4b5 100644 --- a/Shared/CIPPSharp/CIPPRestClient.cs +++ b/Shared/CIPPSharp/CIPPRestClient.cs @@ -682,7 +682,12 @@ public static async Task SendAsync( HttpResponseMessage response; try { - response = await client.SendAsync(request, token).ConfigureAwait(false); + // ResponseHeadersRead: do NOT buffer the body inside SendAsync. With the + // default (ResponseContentRead) the body is downloaded and auto-decompressed + // here, so a mislabeled Content-Encoding (EXO error responses declare gzip on + // a plain body) throws InvalidDataException from SendAsync and bypasses the + // defensive body read below — masking the HTTP status the caller needs. + response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, token).ConfigureAwait(false); } catch (OperationCanceledException) when (cts is not null && cts.IsCancellationRequested) { From 5f2a3e3ec03b8894b4be4c8a17f31f6128240995 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Thu, 9 Jul 2026 13:56:01 -0400 Subject: [PATCH 150/170] feat: Enhance SAM certificate handling with preload and cache update functionality --- .../Public/Get-CIPPAuthentication.ps1 | 17 +++++++++++ .../Public/Get-CIPPSAMCertificate.ps1 | 5 +++- .../Public/Set-CIPPSAMCertificate.ps1 | 3 ++ .../Update-CIPPSAMCertificateEnvCache.ps1 | 30 +++++++++++++++++++ 4 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 Modules/CIPPCore/Public/Update-CIPPSAMCertificateEnvCache.ps1 diff --git a/Modules/CIPPCore/Public/Get-CIPPAuthentication.ps1 b/Modules/CIPPCore/Public/Get-CIPPAuthentication.ps1 index 1b082efd2b41e..3a1ef42e624a2 100644 --- a/Modules/CIPPCore/Public/Get-CIPPAuthentication.ps1 +++ b/Modules/CIPPCore/Public/Get-CIPPAuthentication.ps1 @@ -26,6 +26,23 @@ function Get-CIPPAuthentication { Set-Item -Path env:$_ -Value (Get-CippKeyVaultSecret -VaultName $keyvaultname -Name $_ -AsPlainText -ErrorAction Stop) -Force } } + # Preload the SAM certificate PFX alongside the other credentials. Non-fatal: the + # certificate does not exist until first bootstrap (setup wizard or weekly timer). + try { + if ($env:AzureWebJobsStorage -eq 'UseDevelopmentStorage=true' -or $env:NonLocalHostAzurite -eq 'true') { + if ($Secret.SAMCertificate) { + $env:SAMCertificate = $Secret.SAMCertificate + } + } else { + $SAMCertificate = Get-CippKeyVaultSecret -VaultName $keyvaultname -Name 'SAMCertificate' -AsPlainText -ErrorAction Stop + if ($SAMCertificate) { + $env:SAMCertificate = $SAMCertificate + } + } + } catch { + Write-Information "SAM certificate not preloaded (not created yet?): $($_.Exception.Message)" + } + $env:SetFromProfile = $true Write-LogMessage -message 'Reloaded authentication data from KeyVault' -Sev 'debug' -API 'CIPP Authentication' diff --git a/Modules/CIPPCore/Public/Get-CIPPSAMCertificate.ps1 b/Modules/CIPPCore/Public/Get-CIPPSAMCertificate.ps1 index 918dbb5091b74..776d81380351e 100644 --- a/Modules/CIPPCore/Public/Get-CIPPSAMCertificate.ps1 +++ b/Modules/CIPPCore/Public/Get-CIPPSAMCertificate.ps1 @@ -39,7 +39,10 @@ function Get-CIPPSAMCertificate { return $script:SAMCertificateCache.$Name.Result } - if ($env:AzureWebJobsStorage -eq 'UseDevelopmentStorage=true' -or $env:NonLocalHostAzurite -eq 'true') { + if (-not $SkipCache -and $Name -eq 'SAMCertificate' -and $env:SAMCertificate) { + # Preloaded by Get-CIPPAuthentication at startup (and refreshed by Set-CIPPSAMCertificate) + $PfxBase64 = $env:SAMCertificate + } elseif ($env:AzureWebJobsStorage -eq 'UseDevelopmentStorage=true' -or $env:NonLocalHostAzurite -eq 'true') { $Table = Get-CIPPTable -tablename 'DevSecrets' $Secret = Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq 'Secret' and RowKey eq 'Secret'" $PfxBase64 = $Secret.$Name diff --git a/Modules/CIPPCore/Public/Set-CIPPSAMCertificate.ps1 b/Modules/CIPPCore/Public/Set-CIPPSAMCertificate.ps1 index 0c51d16aeb2d1..1fc745e0ca115 100644 --- a/Modules/CIPPCore/Public/Set-CIPPSAMCertificate.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPSAMCertificate.ps1 @@ -45,6 +45,7 @@ function Set-CIPPSAMCertificate { } $Secret | Add-Member -MemberType NoteProperty -Name $Name -Value $PfxBase64 -Force Add-AzDataTableEntity @Table -Entity $Secret -Force + Update-CIPPSAMCertificateEnvCache -Name $Name -PfxBase64 $PfxBase64 return @{ StorageMode = 'DevTable'; Name = $Name } } @@ -82,6 +83,7 @@ function Set-CIPPSAMCertificate { if ($StatusCode -ge 200 -and $StatusCode -lt 300) { Write-LogMessage -API 'SAMCertificate' -message "Stored SAM certificate '$Name' as a Key Vault certificate in vault '$VaultName'" -sev 'Info' + Update-CIPPSAMCertificateEnvCache -Name $Name -PfxBase64 $PfxBase64 return @{ StorageMode = 'Certificate'; Name = $Name; VaultName = $VaultName } } @@ -99,6 +101,7 @@ function Set-CIPPSAMCertificate { Write-LogMessage -API 'SAMCertificate' -message "Failed to store SAM certificate '$Name' in vault '$VaultName'. If certificate permissions were removed from the Key Vault access policy after the certificate was stored via the certificates API, restore them (get, list, import, update, delete). See Log Data for details." -sev 'CRITICAL' -LogData (Get-CippException -Exception $_) throw } + Update-CIPPSAMCertificateEnvCache -Name $Name -PfxBase64 $PfxBase64 return @{ StorageMode = 'Secret'; Name = $Name; VaultName = $VaultName } } diff --git a/Modules/CIPPCore/Public/Update-CIPPSAMCertificateEnvCache.ps1 b/Modules/CIPPCore/Public/Update-CIPPSAMCertificateEnvCache.ps1 new file mode 100644 index 0000000000000..60edc22bb2109 --- /dev/null +++ b/Modules/CIPPCore/Public/Update-CIPPSAMCertificateEnvCache.ps1 @@ -0,0 +1,30 @@ +function Update-CIPPSAMCertificateEnvCache { + <# + .SYNOPSIS + Refreshes the in-process SAM certificate caches after a store + + .DESCRIPTION + Updates the preloaded environment variable and invalidates the script-scope certificate + cache so the current runspace immediately serves the newly stored certificate. Other + runspaces pick it up when their 1 hour memory cache expires or on restart; the previous + certificate remains valid on the app registration during that window. + + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$Name, + + [Parameter(Mandatory = $true)] + [string]$PfxBase64 + ) + + if ($Name -eq 'SAMCertificate') { + $env:SAMCertificate = $PfxBase64 + } + if ($script:SAMCertificateCache) { + $script:SAMCertificateCache.Remove($Name) + } +} From 559036d6fbfa71123395eefa5c65131dbb4f40f5 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Thu, 9 Jul 2026 21:25:22 +0200 Subject: [PATCH 151/170] fix for listening libraries --- .../Teams-Sharepoint/Invoke-ListSiteLibraries.ps1 | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSiteLibraries.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSiteLibraries.ps1 index 35d08578cdcbc..9a9eea2c2ae03 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSiteLibraries.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSiteLibraries.ps1 @@ -18,6 +18,20 @@ function Invoke-ListSiteLibraries { $SiteUrl = $Request.Query.SiteUrl ?? $Request.Body.SiteUrl try { + # Resolve the site addressing segment: prefer the Graph site id, else hostname:path from the URL. + if (-not [string]::IsNullOrWhiteSpace($SiteId)) { + $SiteSegment = $SiteId + } elseif (-not [string]::IsNullOrWhiteSpace($SiteUrl)) { + $ParsedUrl = [System.Uri]$SiteUrl + $SiteSegment = if ($ParsedUrl.AbsolutePath -in @('', '/')) { + $ParsedUrl.Host + } else { + "$($ParsedUrl.Host):$($ParsedUrl.AbsolutePath):" + } + } else { + throw 'SiteId or SiteUrl is required.' + } + $Lists = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/sites/$SiteSegment/lists?`$select=id,displayName,name,webUrl,list" -tenantid $TenantFilter -asapp $true # documentLibrary covers regular libraries; webPageLibrary is the Site Pages library. $Results = @($Lists | Where-Object { $_.list.hidden -ne $true -and $_.list.template -in @('documentLibrary', 'webPageLibrary') } | ForEach-Object { From 94ab1afa6ff6424f5333ddf911c7e588102c6146 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Thu, 9 Jul 2026 21:48:48 +0200 Subject: [PATCH 152/170] fix as app because I forgot --- .../Public/GraphHelper/Get-GraphToken.ps1 | 260 +++++++++--------- .../Invoke-ExecSetLibraryPermission.ps1 | 14 +- 2 files changed, 134 insertions(+), 140 deletions(-) diff --git a/Modules/CIPPCore/Public/GraphHelper/Get-GraphToken.ps1 b/Modules/CIPPCore/Public/GraphHelper/Get-GraphToken.ps1 index 3a1aaffecd31e..5628ffc486ebd 100644 --- a/Modules/CIPPCore/Public/GraphHelper/Get-GraphToken.ps1 +++ b/Modules/CIPPCore/Public/GraphHelper/Get-GraphToken.ps1 @@ -61,163 +61,163 @@ function Get-GraphToken { } } try { - if (!$env:SetFromProfile) { $CIPPAuth = Get-CIPPAuthentication; Write-Host 'Could not get Refreshtoken from environment variable. Reloading token.' } - $ConfigTable = Get-CippTable -tablename 'Config' - $Filter = "PartitionKey eq 'AppCache' and RowKey eq 'AppCache'" - $AppCache = Get-CIPPAzDataTableEntity @ConfigTable -Filter $Filter - #force auth update is appId is not the same as the one in the environment variable. - if ($AppCache.ApplicationId -and $env:ApplicationID -ne $AppCache.ApplicationId) { - Write-Host "Setting environment variable ApplicationID to $($AppCache.ApplicationId)" - $CIPPAuth = Get-CIPPAuthentication - } - $refreshToken = $env:RefreshToken - #Get list of tenants that have 'directTenant' set to true - #get directtenants directly from table, avoid get-tenants due to performance issues - $TenantsTable = Get-CippTable -tablename 'Tenants' - $Filter = "PartitionKey eq 'Tenants' and delegatedPrivilegeStatus eq 'directTenant'" - $ClientType = Get-CIPPAzDataTableEntity @TenantsTable -Filter $Filter | Where-Object { $_.customerId -eq $tenantid -or $_.defaultDomainName -eq $tenantid } - if ($tenantid -ne $env:TenantID -and $clientType.delegatedPrivilegeStatus -eq 'directTenant') { - Write-Host "Using direct tenant refresh token for $($clientType.customerId)" - $ClientRefreshToken = Get-Item -Path "env:\$($clientType.customerId)" -ErrorAction SilentlyContinue + if (!$env:SetFromProfile) { $CIPPAuth = Get-CIPPAuthentication; Write-Host 'Could not get Refreshtoken from environment variable. Reloading token.' } + $ConfigTable = Get-CippTable -tablename 'Config' + $Filter = "PartitionKey eq 'AppCache' and RowKey eq 'AppCache'" + $AppCache = Get-CIPPAzDataTableEntity @ConfigTable -Filter $Filter + #force auth update is appId is not the same as the one in the environment variable. + if ($AppCache.ApplicationId -and $env:ApplicationID -ne $AppCache.ApplicationId) { + Write-Host "Setting environment variable ApplicationID to $($AppCache.ApplicationId)" + $CIPPAuth = Get-CIPPAuthentication + } + $refreshToken = $env:RefreshToken + #Get list of tenants that have 'directTenant' set to true + #get directtenants directly from table, avoid get-tenants due to performance issues + $TenantsTable = Get-CippTable -tablename 'Tenants' + $Filter = "PartitionKey eq 'Tenants' and delegatedPrivilegeStatus eq 'directTenant'" + $ClientType = Get-CIPPAzDataTableEntity @TenantsTable -Filter $Filter | Where-Object { $_.customerId -eq $tenantid -or $_.defaultDomainName -eq $tenantid } + if ($tenantid -ne $env:TenantID -and $clientType.delegatedPrivilegeStatus -eq 'directTenant') { + Write-Host "Using direct tenant refresh token for $($clientType.customerId)" + $ClientRefreshToken = Get-Item -Path "env:\$($clientType.customerId)" -ErrorAction SilentlyContinue - if ($null -eq $ClientRefreshToken) { - # Lazy load the refresh token from Key Vault only when needed - Write-Host "Fetching refresh token for direct tenant $($clientType.customerId) from Key Vault" - try { - if ($env:AzureWebJobsStorage -eq 'UseDevelopmentStorage=true' -or $env:NonLocalHostAzurite -eq 'true') { - # Development environment - get from table storage - $Table = Get-CIPPTable -tablename 'DevSecrets' - $Secret = Get-AzDataTableEntity @Table -Filter "PartitionKey eq 'Secret' and RowKey eq 'Secret'" - $secretname = $clientType.customerId -replace '-', '_' - if ($Secret.$secretname) { - Set-Item -Path "env:\$($clientType.customerId)" -Value $Secret.$secretname -Force - $ClientRefreshToken = Get-Item -Path "env:\$($clientType.customerId)" -ErrorAction SilentlyContinue - } - } else { - # Production environment - get from Key Vault - $keyvaultname = Get-CippKeyVaultName - $secret = Get-CippKeyVaultSecret -VaultName $keyvaultname -Name $clientType.customerId -AsPlainText -ErrorAction Stop - if ($secret) { - Set-Item -Path "env:\$($clientType.customerId)" -Value $secret -Force - $ClientRefreshToken = Get-Item -Path "env:\$($clientType.customerId)" -ErrorAction SilentlyContinue + if ($null -eq $ClientRefreshToken) { + # Lazy load the refresh token from Key Vault only when needed + Write-Host "Fetching refresh token for direct tenant $($clientType.customerId) from Key Vault" + try { + if ($env:AzureWebJobsStorage -eq 'UseDevelopmentStorage=true' -or $env:NonLocalHostAzurite -eq 'true') { + # Development environment - get from table storage + $Table = Get-CIPPTable -tablename 'DevSecrets' + $Secret = Get-AzDataTableEntity @Table -Filter "PartitionKey eq 'Secret' and RowKey eq 'Secret'" + $secretname = $clientType.customerId -replace '-', '_' + if ($Secret.$secretname) { + Set-Item -Path "env:\$($clientType.customerId)" -Value $Secret.$secretname -Force + $ClientRefreshToken = Get-Item -Path "env:\$($clientType.customerId)" -ErrorAction SilentlyContinue + } + } else { + # Production environment - get from Key Vault + $keyvaultname = Get-CippKeyVaultName + $secret = Get-CippKeyVaultSecret -VaultName $keyvaultname -Name $clientType.customerId -AsPlainText -ErrorAction Stop + if ($secret) { + Set-Item -Path "env:\$($clientType.customerId)" -Value $secret -Force + $ClientRefreshToken = Get-Item -Path "env:\$($clientType.customerId)" -ErrorAction SilentlyContinue + } } + } catch { + Write-Host "Failed to retrieve refresh token for direct tenant $($clientType.customerId): $($_.Exception.Message)" } - } catch { - Write-Host "Failed to retrieve refresh token for direct tenant $($clientType.customerId): $($_.Exception.Message)" } - } - $refreshToken = $ClientRefreshToken.Value - } + $refreshToken = $ClientRefreshToken.Value + } - $AuthBody = @{ - client_id = $env:ApplicationID - client_secret = $env:ApplicationSecret - scope = $Scope - refresh_token = $refreshToken - grant_type = 'refresh_token' - } - if ($asApp -eq $true) { $AuthBody = @{ client_id = $env:ApplicationID client_secret = $env:ApplicationSecret scope = $Scope - grant_type = 'client_credentials' - } - } - - if ($null -ne $AppID -and $null -ne $refreshToken) { - $AuthBody = @{ - client_id = $appid refresh_token = $refreshToken - scope = $Scope grant_type = 'refresh_token' } - } + if ($asApp -eq $true) { + $AuthBody = @{ + client_id = $env:ApplicationID + client_secret = $env:ApplicationSecret + scope = $Scope + grant_type = 'client_credentials' + } + } - if ($null -ne $AppID -and $null -ne $AppSecret) { - $AuthBody = @{ - client_id = $AppID - client_secret = $AppSecret - scope = $Scope - grant_type = 'client_credentials' + if ($null -ne $AppID -and $null -ne $refreshToken) { + $AuthBody = @{ + client_id = $appid + refresh_token = $refreshToken + scope = $Scope + grant_type = 'refresh_token' + } } - } - # Rebuild cache key after credential loading (env vars may have been set by Get-CIPPAuthentication) - if ($UseSharedTokenCache) { - $CacheClientId = if ($AppID) { [string]$AppID } else { [string]$env:ApplicationID } - $GrantType = if ($asApp -eq $true -or ($null -ne $AppID -and $null -ne $AppSecret)) { 'client_credentials' } else { 'refresh_token' } - if ($UseCertificate -eq $true) { $GrantType = "${GrantType}_certificate" } - $SharedTokenCacheKey = [CIPP.CIPPTokenCache]::BuildKey([string]$tenantid, [string]$scope, [bool]$asApp, $CacheClientId, $GrantType) - } + if ($null -ne $AppID -and $null -ne $AppSecret) { + $AuthBody = @{ + client_id = $AppID + client_secret = $AppSecret + scope = $Scope + grant_type = 'client_credentials' + } + } - try { - if ($UseCertificate -eq $true) { - $SAMCert = Get-CIPPSAMCertificate -ErrorAction Stop - if (-not $SAMCert) { throw 'No SAM certificate available. Run Update-CIPPSAMCertificate to create one.' } + # Rebuild cache key after credential loading (env vars may have been set by Get-CIPPAuthentication) + if ($UseSharedTokenCache) { + $CacheClientId = if ($AppID) { [string]$AppID } else { [string]$env:ApplicationID } + $GrantType = if ($asApp -eq $true -or ($null -ne $AppID -and $null -ne $AppSecret)) { 'client_credentials' } else { 'refresh_token' } + if ($UseCertificate -eq $true) { $GrantType = "${GrantType}_certificate" } + $SharedTokenCacheKey = [CIPP.CIPPTokenCache]::BuildKey([string]$tenantid, [string]$scope, [bool]$asApp, $CacheClientId, $GrantType) } - if ($UseCertificate -eq $true -and $AuthBody.grant_type -eq 'client_credentials') { - # App-only with certificate: use the dedicated cert token function - # (has its own cache under the same key and the AADSTS700027 retry) - $AccessToken = Get-GraphTokenFromCert -TenantId $tenantid -AppId $AuthBody.client_id -Scope $scope -Certificate $SAMCert.Certificate -SkipCache:($SkipCache -eq $true) -ErrorAction Stop - if (-not $AccessToken.access_token) { throw "Could not get a token using the SAM certificate for $tenantid" } - } else { + + try { if ($UseCertificate -eq $true) { - # Delegated with certificate: authenticate the app with a signed assertion - # instead of the client secret; the refresh token still provides user context - $null = $AuthBody.Remove('client_secret') - $AuthBody.client_assertion_type = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer' - $AuthBody.client_assertion = New-CIPPCertificateAssertion -TenantId $tenantid -AppId $AuthBody.client_id -Certificate $SAMCert.Certificate + $SAMCert = Get-CIPPSAMCertificate -ErrorAction Stop + if (-not $SAMCert) { throw 'No SAM certificate available. Run Update-CIPPSAMCertificate to create one.' } + } + if ($UseCertificate -eq $true -and $AuthBody.grant_type -eq 'client_credentials') { + # App-only with certificate: use the dedicated cert token function + # (has its own cache under the same key and the AADSTS700027 retry) + $AccessToken = Get-GraphTokenFromCert -TenantId $tenantid -AppId $AuthBody.client_id -Scope $scope -Certificate $SAMCert.Certificate -SkipCache:($SkipCache -eq $true) -ErrorAction Stop + if (-not $AccessToken.access_token) { throw "Could not get a token using the SAM certificate for $tenantid" } + } else { + if ($UseCertificate -eq $true) { + # Delegated with certificate: authenticate the app with a signed assertion + # instead of the client secret; the refresh token still provides user context + $null = $AuthBody.Remove('client_secret') + $AuthBody.client_assertion_type = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer' + $AuthBody.client_assertion = New-CIPPCertificateAssertion -TenantId $tenantid -AppId $AuthBody.client_id -Certificate $SAMCert.Certificate + } + $AccessToken = (Invoke-CIPPRestMethod -Method post -Uri "https://login.microsoftonline.com/$($tenantid)/oauth2/v2.0/token" -Body $Authbody -ContentType 'application/x-www-form-urlencoded' -ErrorAction Stop) + } + if ($null -eq $AccessToken.expires_on -and $AccessToken.expires_in) { + $ExpiresOn = [int](Get-Date -UFormat %s -Millisecond 0) + $AccessToken.expires_in + Add-Member -InputObject $AccessToken -NotePropertyName 'expires_on' -NotePropertyValue $ExpiresOn -Force } - $AccessToken = (Invoke-CIPPRestMethod -Method post -Uri "https://login.microsoftonline.com/$($tenantid)/oauth2/v2.0/token" -Body $Authbody -ContentType 'application/x-www-form-urlencoded' -ErrorAction Stop) - } - if ($null -eq $AccessToken.expires_on -and $AccessToken.expires_in) { - $ExpiresOn = [int](Get-Date -UFormat %s -Millisecond 0) + $AccessToken.expires_in - Add-Member -InputObject $AccessToken -NotePropertyName 'expires_on' -NotePropertyValue $ExpiresOn -Force - } - if ($UseSharedTokenCache -and $SharedTokenCacheKey) { - try { - $TokenPayloadJson = $AccessToken | ConvertTo-Json -Depth 20 -Compress - [CIPP.CIPPTokenCache]::Store($SharedTokenCacheKey, $TokenPayloadJson, [int64]$AccessToken.expires_on) - } catch { - # Ignore shared cache write failures + if ($UseSharedTokenCache -and $SharedTokenCacheKey) { + try { + $TokenPayloadJson = $AccessToken | ConvertTo-Json -Depth 20 -Compress + [CIPP.CIPPTokenCache]::Store($SharedTokenCacheKey, $TokenPayloadJson, [int64]$AccessToken.expires_on) + } catch { + # Ignore shared cache write failures + } } - } - if ($ReturnRefresh) { return $AccessToken } - return @{ Authorization = "Bearer $($AccessToken.access_token)" } - } catch { - # Track consecutive Graph API failures - $TenantsTable = Get-CippTable -tablename Tenants - $Filter = "PartitionKey eq 'Tenants' and (defaultDomainName eq '{0}' or customerId eq '{0}')" -f $tenantid - $Tenant = Get-CIPPAzDataTableEntity @TenantsTable -Filter $Filter - if (!$Tenant.RowKey) { - $donotset = $true - $Tenant = [pscustomobject]@{ - GraphErrorCount = 0 - LastGraphTokenError = '' - LastGraphError = '' - PartitionKey = 'TenantFailed' - RowKey = 'Failed' + if ($ReturnRefresh) { return $AccessToken } + return @{ Authorization = "Bearer $($AccessToken.access_token)" } + } catch { + # Track consecutive Graph API failures + $TenantsTable = Get-CippTable -tablename Tenants + $Filter = "PartitionKey eq 'Tenants' and (defaultDomainName eq '{0}' or customerId eq '{0}')" -f $tenantid + $Tenant = Get-CIPPAzDataTableEntity @TenantsTable -Filter $Filter + if (!$Tenant.RowKey) { + $donotset = $true + $Tenant = [pscustomobject]@{ + GraphErrorCount = 0 + LastGraphTokenError = '' + LastGraphError = '' + PartitionKey = 'TenantFailed' + RowKey = 'Failed' + } } - } - $Tenant.LastGraphError = if ( $_.ErrorDetails.Message) { - if (Test-Json $_.ErrorDetails.Message -ErrorAction SilentlyContinue) { - $msg = $_.ErrorDetails.Message | ConvertFrom-Json - "$($msg.error):$($msg.error_description)" + $Tenant.LastGraphError = if ( $_.ErrorDetails.Message) { + if (Test-Json $_.ErrorDetails.Message -ErrorAction SilentlyContinue) { + $msg = $_.ErrorDetails.Message | ConvertFrom-Json + "$($msg.error):$($msg.error_description)" + } else { + "$($_.ErrorDetails.Message)" + } } else { - "$($_.ErrorDetails.Message)" + $_.Exception.Message } - } else { - $_.Exception.Message - } - $Tenant.GraphErrorCount++ + $Tenant.GraphErrorCount++ - if (!$donotset) { Update-AzDataTableEntity -Force @TenantsTable -Entity $Tenant } - throw "Could not get token: $($Tenant.LastGraphError)" - } + if (!$donotset) { Update-AzDataTableEntity -Force @TenantsTable -Entity $Tenant } + throw "Could not get token: $($Tenant.LastGraphError)" + } } finally { # Always release the per-key lock if we acquired it if ($LockAcquired -and $SharedTokenCacheKey) { diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecSetLibraryPermission.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecSetLibraryPermission.ps1 index e18f8793a4274..f5e1a0b08d8b3 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecSetLibraryPermission.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecSetLibraryPermission.ps1 @@ -34,12 +34,6 @@ function Invoke-ExecSetLibraryPermission { } try { - if ([string]::IsNullOrWhiteSpace($SiteUrl) -or [string]::IsNullOrWhiteSpace($ListId)) { - throw 'SiteUrl and ListId are required.' - } - if (-not $RoleDefinitionIds.ContainsKey([string]$PermissionLevel)) { - throw "Invalid permission level '$PermissionLevel'. Valid values are: $($RoleDefinitionIds.Keys -join ', ')." - } $RoleDefId = $RoleDefinitionIds[[string]$PermissionLevel] # Build the claims-encoded logon names for ensureuser. @@ -75,9 +69,9 @@ function Invoke-ExecSetLibraryPermission { $BaseUri = "$($SiteUrl.TrimEnd('/'))/_api" # Break role inheritance (copying the existing permissions) when the library still inherits. - $ListInfo = New-GraphGetRequest -uri "$BaseUri/web/lists(guid'$ListId')?`$select=HasUniqueRoleAssignments" -tenantid $TenantFilter -scope $Scope -extraHeaders $JsonAccept -UseCertificate + $ListInfo = New-GraphGetRequest -uri "$BaseUri/web/lists(guid'$ListId')?`$select=HasUniqueRoleAssignments" -tenantid $TenantFilter -scope $Scope -extraHeaders $JsonAccept -UseCertificate -AsApp $true if (-not $ListInfo.HasUniqueRoleAssignments) { - $null = New-GraphPostRequest -uri "$BaseUri/web/lists(guid'$ListId')/breakroleinheritance(copyRoleAssignments=true,clearSubscopes=false)" -tenantid $TenantFilter -scope $Scope -type POST -body '{}' -AddedHeaders $JsonAccept -UseCertificate + $null = New-GraphPostRequest -uri "$BaseUri/web/lists(guid'$ListId')/breakroleinheritance(copyRoleAssignments=true,clearSubscopes=false)" -tenantid $TenantFilter -scope $Scope -type POST -body '{}' -AddedHeaders $JsonAccept -UseCertificate -AsApp $true } $Granted = [System.Collections.Generic.List[string]]::new() @@ -85,11 +79,11 @@ function Invoke-ExecSetLibraryPermission { foreach ($Principal in $Principals) { try { $EnsureBody = ConvertTo-Json -Compress -InputObject @{ logonName = $Principal.LogonName } - $EnsuredUser = New-GraphPostRequest -uri "$BaseUri/web/ensureuser" -tenantid $TenantFilter -scope $Scope -type POST -body $EnsureBody -AddedHeaders $JsonAccept -UseCertificate + $EnsuredUser = New-GraphPostRequest -uri "$BaseUri/web/ensureuser" -tenantid $TenantFilter -scope $Scope -type POST -body $EnsureBody -AddedHeaders $JsonAccept -UseCertificate -AsApp $true if (-not $EnsuredUser.Id) { throw 'Could not resolve principal on the site.' } - $null = New-GraphPostRequest -uri "$BaseUri/web/lists(guid'$ListId')/roleassignments/addroleassignment(principalid=$($EnsuredUser.Id),roledefid=$RoleDefId)" -tenantid $TenantFilter -scope $Scope -type POST -body '{}' -AddedHeaders $JsonAccept -UseCertificate + $null = New-GraphPostRequest -uri "$BaseUri/web/lists(guid'$ListId')/roleassignments/addroleassignment(principalid=$($EnsuredUser.Id),roledefid=$RoleDefId)" -tenantid $TenantFilter -scope $Scope -type POST -body '{}' -AddedHeaders $JsonAccept -UseCertificate -AsApp $true $Granted.Add($Principal.Label) } catch { $Failed.Add("$($Principal.Label) ($($_.Exception.Message))") From 510236d64767fd0af485260191756a348d851704 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Thu, 9 Jul 2026 22:35:04 +0200 Subject: [PATCH 153/170] fixes cve management issue. --- .../Endpoint/MEM/Invoke-ListCVEManagement.ps1 | 133 ++++++++++-------- 1 file changed, 72 insertions(+), 61 deletions(-) diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListCVEManagement.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListCVEManagement.ps1 index 96850436772b8..f563471d2d3ec 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListCVEManagement.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Endpoint/MEM/Invoke-ListCVEManagement.ps1 @@ -12,54 +12,54 @@ function Invoke-ListCVEManagement { $TenantFilter = $Request.Query.tenantFilter $UseReportDB = $Request.Query.UseReportDB - if ($UseReportDB -eq 'true'){ + if ($UseReportDB -eq 'true') { try { $GraphRequest = Get-CIPPCVEReport -TenantFilter $TenantFilter -ErrorAction Stop $StatusCode = [HttpStatusCode]::OK $SortedCves = $GraphRequest - Write-LogMessage -API 'ListCVEManagement' -tenant $TenantFilter -message "running cve report" -sev 'info' + Write-LogMessage -API 'ListCVEManagement' -tenant $TenantFilter -message 'running cve report' -sev 'info' } catch { Write-Host "Error retrieving CVEs from report database: $($_.Exception.Message)" $StatusCode = [HttpStatusCode]::InternalServerError $GraphRequest = $_.Exception.Message - Write-LogMessage -API 'ListCVEManagement' -tenant $TenantFilter -message "Error retrieving" -sev 'info' + Write-LogMessage -API 'ListCVEManagement' -tenant $TenantFilter -message 'Error retrieving' -sev 'info' } - }else{ + } else { try { - Write-LogMessage -API 'ListCVEManagement' -tenant $TenantFilter -message "retrieving CVEs" -sev 'info' + Write-LogMessage -API 'ListCVEManagement' -tenant $TenantFilter -message 'retrieving CVEs' -sev 'info' $GraphRequest = get-DefenderCVEs -TenantFilter $TenantFilter # Retrieve Exceptions from Exception database $CveExceptionsTable = Get-CIPPTable -TableName 'CveExceptions' - $AllExceptions = Get-CIPPAzDataTableEntity @CveExceptionsTable - $ExceptionsByCve = @{} + $AllExceptions = Get-CIPPAzDataTableEntity @CveExceptionsTable + $ExceptionsByCve = @{} # Retrieve CVEs from database - $RawCveItems = $GraphRequest + $RawCveItems = $GraphRequest $AllCachedCves = $RawCveData - $TenantList = Get-Tenants | Where-Object defaultDomainName -eq $TenantFilter + $TenantList = Get-Tenants | Where-Object defaultDomainName -EQ $TenantFilter if ($RawCveItems.Count -eq 0) { return @() } foreach ($Ex in $AllExceptions) { - if ($TenantList.defaultDomainName -contains $Ex.customerId -or $Ex.customerId -eq 'ALL'){ + 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 - }) + cveId = $Ex.cveId + customerId = $Ex.customerId + exceptionType = $Ex.exceptionType + exceptionSource = $Ex.exceptionSource + exceptionComment = $Ex.exceptionComment + exceptionCreatedBy = $Ex.exceptionCreatedBy + exceptionDate = $Ex.exceptionReadableDate + exceptionExpiry = $Ex.exceptionExpiry + }) } } @@ -98,11 +98,17 @@ function Invoke-ListCVEManagement { if ($Item.deviceDetailsJson) { $Devices = ConvertFrom-Json $Item.deviceDetailsJson | Sort-Object -Property deviceName -Unique foreach ($Dev in $Devices) { - [void]$CveGroup.AffectedDevicesList.Add(@{ deviceName = $Dev.deviceName }) - if($Dev.registryPaths){[void]$CveGroup.RegistryPathList.Add(@{ deviceName = $Dev.deviceName - registryPaths = $Dev.registryPaths })} - if($Dev.diskPaths){[void]$CveGroup.DiskPathList.Add(@{ deviceName = $Dev.deviceName - diskPaths = $Dev.diskPaths })} + [void]$CveGroup.AffectedDevicesList.Add(@{ deviceName = $Dev.deviceName }) + if ($Dev.registryPaths) { + [void]$CveGroup.RegistryPathList.Add(@{ deviceName = $Dev.deviceName + registryPaths = $Dev.registryPaths + }) + } + if ($Dev.diskPaths) { + [void]$CveGroup.DiskPathList.Add(@{ deviceName = $Dev.deviceName + diskPaths = $Dev.diskPaths + }) + } $CveGroup.TotalDeviceCount ++ } } @@ -122,55 +128,60 @@ function Invoke-ListCVEManagement { $ExceptionDate = '' $ExceptionExpiry = '' - if ($ExceptionsByCve.ContainsKey($CveKey)){ - $Exceptions = @($ExceptionsByCve[$CveKey]) - $HasException = $true - $ExceptionStatus = if ($Exceptions.customerId -contains "ALL") { "All" } else { "Partial" } - $ExceptionType = @{ customerId = $Exceptions.customerId - exceptionType = $Exceptions.exceptionType } - $ExceptionComment = @{ customerId = $Exceptions.customerId - exceptionComment = $Exceptions.exceptionComment } + if ($ExceptionsByCve.ContainsKey($CveKey)) { + $Exceptions = @($ExceptionsByCve[$CveKey]) + $HasException = $true + $ExceptionStatus = if ($Exceptions.customerId -contains 'ALL') { 'All' } else { 'Partial' } + $ExceptionType = @{ customerId = $Exceptions.customerId + exceptionType = $Exceptions.exceptionType + } + $ExceptionComment = @{ customerId = $Exceptions.customerId + exceptionComment = $Exceptions.exceptionComment + } $ExceptionCreatedBy = @{ customerId = $Exceptions.customerId - exceptionCreatedBy = $Exceptions.exceptionCreatedBy } - $ExceptionDate = @{ customerId = $Exceptions.customerId - exceptionDate = $Exceptions.exceptionDate } - $ExceptionExpiry = @{ customerId = $Exceptions.customerId - exceptionExpiry = $Exceptions.exceptionExpiry } + exceptionCreatedBy = $Exceptions.exceptionCreatedBy + } + $ExceptionDate = @{ customerId = $Exceptions.customerId + exceptionDate = $Exceptions.exceptionDate + } + $ExceptionExpiry = @{ customerId = $Exceptions.customerId + exceptionExpiry = $Exceptions.exceptionExpiry + } } [void]$SortedCves.Add([PSCustomObject]@{ - cveId = $Target.cveId - vulnerabilitySeverityLevel = $Target.vulnerabilitySeverityLevel - exploitabilityLevel = $Target.exploitabilityLevel - softwareName = $Target.softwareName - softwareVendor = $Target.softwareVendor - softwareVersion = $Target.softwareVersion - deviceCount = $Target.TotalDeviceCount - tenantCount = $Target.TotalTenantGroupCount - registryPaths = $Target.RegistryPathList - diskPaths = $Target.DiskPathList - exceptionStatus = $ExceptionStatus - hasException = $HasException - affectedTenants = $Target.AffectedTenantsList - affectedDevices = $Target.AffectedDevicesList - exceptionType = $ExceptionType - exceptionComment = $ExceptionComment - exceptionCreatedBy = $ExceptionCreatedBy - exceptionDate = $ExceptionDate - exceptionExpiry = $ExceptionExpiry - cacheTimeStamp = $Target.lastUpdated - }) + cveId = $Target.cveId + vulnerabilitySeverityLevel = $Target.vulnerabilitySeverityLevel + exploitabilityLevel = $Target.exploitabilityLevel + softwareName = $Target.softwareName + softwareVendor = $Target.softwareVendor + softwareVersion = $Target.softwareVersion + deviceCount = $Target.TotalDeviceCount + tenantCount = $Target.TotalTenantGroupCount + registryPaths = $Target.RegistryPathList + diskPaths = $Target.DiskPathList + exceptionStatus = $ExceptionStatus + hasException = $HasException + affectedTenants = $Target.AffectedTenantsList + affectedDevices = $Target.AffectedDevicesList + exceptionType = $ExceptionType + exceptionComment = $ExceptionComment + exceptionCreatedBy = $ExceptionCreatedBy + exceptionDate = $ExceptionDate + exceptionExpiry = $ExceptionExpiry + cacheTimeStamp = $Target.lastUpdated + }) $StatusCode = [HttpStatusCode]::OK } } catch { Write-Host "Error retrieving CVEs: $($_.Exception.Message)" $StatusCode = [HttpStatusCode]::InternalServerError - $GraphRequest = $_.Exception.Message + $SortedCves = $_.Exception.Message } } - Return [HttpResponseContext]@{ + return [HttpResponseContext]@{ StatusCode = $StatusCode Body = @($SortedCves | Sort-Object -Property cveId) - } + } } From 3ba77440a3fe9b387f88b1209322dd585ad3d208 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Thu, 9 Jul 2026 22:45:11 +0200 Subject: [PATCH 154/170] add license name to offboarding --- .../CIPPCore/Public/Remove-CIPPLicense.ps1 | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/Modules/CIPPCore/Public/Remove-CIPPLicense.ps1 b/Modules/CIPPCore/Public/Remove-CIPPLicense.ps1 index 3f39bda0c3f18..ce8980fbcd148 100644 --- a/Modules/CIPPCore/Public/Remove-CIPPLicense.ps1 +++ b/Modules/CIPPCore/Public/Remove-CIPPLicense.ps1 @@ -10,6 +10,28 @@ function Remove-CIPPLicense { ) if ($Schedule.IsPresent) { + # Record which licenses the user holds right now, so the offboarding result (and any + # ticket/webhook/email it feeds) documents what was removed - useful for auditing and + # for restoring the account to its previous state. Sku names come from the cached + # license overview, which resolves the best available display name for the tenant. + $LicenseSuffix = '' + try { + $LicenseOverview = @(New-CIPPDbRequest -TenantFilter $TenantFilter -Type 'LicenseOverview') + $UserLicenseState = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/users/$($userid)?`$select=licenseAssignmentStates" -tenantid $TenantFilter + $ActiveSkuIds = @(($UserLicenseState.licenseAssignmentStates | Where-Object { $_.state -eq 'Active' }).skuId | Select-Object -Unique) + $LicenseNames = foreach ($SkuId in $ActiveSkuIds) { + $Sku = $LicenseOverview | Where-Object { $_.skuId -eq $SkuId } | Select-Object -First 1 + if ($Sku.License) { $Sku.License } else { $SkuId } + } + if ($LicenseNames) { + $LicenseSuffix = " Licenses currently assigned that will be removed: $(@($LicenseNames | Sort-Object -Unique) -join ', ')." + } else { + $LicenseSuffix = ' The user currently has no licenses assigned.' + } + } catch { + Write-Information "Could not enumerate current licenses for the scheduled removal message: $($_.Exception.Message)" + } + $ScheduledTask = @{ TenantFilter = $TenantFilter Name = "Remove License: $Username" @@ -30,7 +52,7 @@ function Remove-CIPPLicense { } } Add-CIPPScheduledTask -Task $ScheduledTask -hidden $false -DisallowDuplicateName $true - return "Scheduled license removal for $username" + return "Scheduled license removal for $username.$LicenseSuffix" } else { try { $ConvertTable = [System.IO.File]::ReadAllText((Join-Path $env:CIPPRootPath 'Config\ConversionTable.csv')) | ConvertFrom-Csv From 2b3ec21ddea36adff013b7a5fafa8648be992451 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Thu, 9 Jul 2026 17:06:19 -0400 Subject: [PATCH 155/170] fix: SAM certificate provisioning in authentication load Set SetFromProfile earlier to prevent re-entrancy when Update-CIPPSAMCertificate calls Get-GraphToken. Add logic to provision a missing SAM certificate during authentication initialization, with improved error handling and logging. The weekly token update will retry if provisioning fails. --- .../Public/Get-CIPPAuthentication.ps1 | 30 ++++++++++++++----- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/Modules/CIPPCore/Public/Get-CIPPAuthentication.ps1 b/Modules/CIPPCore/Public/Get-CIPPAuthentication.ps1 index 3a1ef42e624a2..348d103e911a1 100644 --- a/Modules/CIPPCore/Public/Get-CIPPAuthentication.ps1 +++ b/Modules/CIPPCore/Public/Get-CIPPAuthentication.ps1 @@ -26,24 +26,40 @@ function Get-CIPPAuthentication { Set-Item -Path env:$_ -Value (Get-CippKeyVaultSecret -VaultName $keyvaultname -Name $_ -AsPlainText -ErrorAction Stop) -Force } } - # Preload the SAM certificate PFX alongside the other credentials. Non-fatal: the - # certificate does not exist until first bootstrap (setup wizard or weekly timer). + # Set before certificate handling: Update-CIPPSAMCertificate goes through + # Get-GraphToken, which re-enters this function when SetFromProfile is unset + $env:SetFromProfile = $true + + # Preload the SAM certificate PFX alongside the other credentials, provisioning it + # when it does not exist yet. Non-fatal: auth must succeed even when certificate + # handling fails; the weekly token update retries provisioning. try { if ($env:AzureWebJobsStorage -eq 'UseDevelopmentStorage=true' -or $env:NonLocalHostAzurite -eq 'true') { if ($Secret.SAMCertificate) { $env:SAMCertificate = $Secret.SAMCertificate } } else { - $SAMCertificate = Get-CippKeyVaultSecret -VaultName $keyvaultname -Name 'SAMCertificate' -AsPlainText -ErrorAction Stop - if ($SAMCertificate) { - $env:SAMCertificate = $SAMCertificate + try { + $SAMCertificate = Get-CippKeyVaultSecret -VaultName $keyvaultname -Name 'SAMCertificate' -AsPlainText -ErrorAction Stop + if ($SAMCertificate) { + $env:SAMCertificate = $SAMCertificate + } + } catch { + Write-Information "SAM certificate not found in storage: $($_.Exception.Message)" } } + + if (-not $env:SAMCertificate) { + # First run on this instance: provision the certificate now. + # Set-CIPPSAMCertificate refreshes $env:SAMCertificate on success. + Write-Information 'No SAM certificate found, provisioning one now' + $CertResult = Update-CIPPSAMCertificate -ErrorAction Stop + Write-LogMessage -message "Provisioned SAM certificate during authentication load. Thumbprint: $($CertResult.Thumbprint), storage mode: $($CertResult.StorageMode)" -Sev 'Info' -API 'CIPP Authentication' + } } catch { - Write-Information "SAM certificate not preloaded (not created yet?): $($_.Exception.Message)" + Write-LogMessage -message 'Could not preload or provision the SAM certificate. It will be retried by the weekly token update.' -Sev 'Warning' -API 'CIPP Authentication' -LogData (Get-CippException -Exception $_) } - $env:SetFromProfile = $true Write-LogMessage -message 'Reloaded authentication data from KeyVault' -Sev 'debug' -API 'CIPP Authentication' return $true From 5d3befc8387ac18b627d0a35a1347c01ebf4d3fd Mon Sep 17 00:00:00 2001 From: John Duprey Date: Thu, 9 Jul 2026 22:08:40 -0400 Subject: [PATCH 156/170] fix: add onprem sync attributes --- .../Identity/Administration/Users/Invoke-ListUsers.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListUsers.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListUsers.ps1 index 4590a1c0cf432..a24160bb3987b 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListUsers.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListUsers.ps1 @@ -26,7 +26,7 @@ Function Invoke-ListUsers { 'employeeHireDate', 'employeeId', 'employeeLeaveDateTime', 'employeeType', 'faxNumber', 'givenName', 'jobTitle', 'lastPasswordChangeDateTime', 'legalAgeGroupClassification', 'mail', 'mailNickname', 'mobilePhone', 'officeLocation', 'onPremisesDistinguishedName', - 'onPremisesLastSyncDateTime', 'onPremisesSyncEnabled', 'otherMails', 'postalCode', + 'onPremisesImmutableId', 'onPremisesLastSyncDateTime', 'onPremisesSyncEnabled', 'otherMails', 'postalCode', 'preferredLanguage', 'proxyAddresses', 'showInAddressList', 'state', 'streetAddress', 'surname', 'usageLocation', 'userPrincipalName', 'userType' ) From d50b7174741c24ab128120f64c0399a4529863f1 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Thu, 9 Jul 2026 22:47:07 -0400 Subject: [PATCH 157/170] feat: Support non-group SharePoint member updates Extend `Invoke-ExecSetSharePointMember` to handle communication/classic SharePoint sites by using SharePoint REST (`ensureuser`, add/remove in associated member group) with certificate auth, while preserving group-connected behavior via M365 group membership. The change also improves request field reuse, adds endpoint logging for success/failure, normalizes exception handling with `Get-CippException`, and returns clearer error messages and status codes. --- .../Invoke-ExecSetSharePointMember.ps1 | 67 ++++++++++++++++--- 1 file changed, 58 insertions(+), 9 deletions(-) diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecSetSharePointMember.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecSetSharePointMember.ps1 index 1949aac407b29..6d18cdc484ed8 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecSetSharePointMember.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecSetSharePointMember.ps1 @@ -4,14 +4,19 @@ function Invoke-ExecSetSharePointMember { Entrypoint .ROLE Sharepoint.Site.ReadWrite + .DESCRIPTION + Adds or removes a member on a SharePoint site. Group-connected sites are managed through + the backing Microsoft 365 group via Graph; all other site types (communication, classic) + are managed through the site's associated members group via the SharePoint REST API + using certificate authentication. #> [CmdletBinding()] param($Request, $TriggerMetadata) - $Headers = $Request.Headers - - # Interact with query parameters or the body of the request. + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers $TenantFilter = $Request.Body.tenantFilter + $UPN = $Request.Body.user.value try { if ($Request.Body.SharePointType -eq 'Group') { @@ -22,19 +27,63 @@ function Invoke-ExecSetSharePointMember { } if ($Request.Body.Add -eq $true) { - $Results = Add-CIPPGroupMember -GroupType 'Team' -GroupID $GroupID -Member $Request.Body.user.value -TenantFilter $TenantFilter -Headers $Headers + $Results = Add-CIPPGroupMember -GroupType 'Team' -GroupID $GroupID -Member $UPN -TenantFilter $TenantFilter -Headers $Headers } else { - $UserID = (New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/users/$($Request.Body.user.value)" -tenantid $TenantFilter).id + $UserID = (New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/users/$UPN" -tenantid $TenantFilter).id $Results = Remove-CIPPGroupMember -GroupType 'Team' -GroupID $GroupID -Member $UserID -TenantFilter $TenantFilter -Headers $Headers } $StatusCode = [HttpStatusCode]::OK } else { - $StatusCode = [HttpStatusCode]::BadRequest - $Results = 'This type of SharePoint site is not supported.' + # Non group-connected site: manage the site's associated members group via the + # SharePoint REST API with certificate auth. + $SiteUrl = $Request.Body.URL + if (-not $SiteUrl) { throw 'No site URL was provided for this site.' } + if (-not $UPN) { throw 'No user was selected.' } + + $SharePointInfo = Get-SharePointAdminLink -Public $false -tenantFilter $TenantFilter + $Scope = "$($SharePointInfo.SharePointUrl)/.default" + $JsonAccept = @{ Accept = 'application/json;odata=nometadata' } + $BaseUri = "$($SiteUrl.TrimEnd('/'))/_api" + + try { + $EnsureBody = ConvertTo-Json -Compress -InputObject @{ logonName = "i:0#.f|membership|$UPN" } + $EnsuredUser = New-GraphPostRequest -uri "$BaseUri/web/ensureuser" -tenantid $TenantFilter -scope $Scope -type POST -body $EnsureBody -contentType 'application/json;odata=nometadata' -AddedHeaders $JsonAccept -UseCertificate -AsApp $true + } catch { + throw "Could not resolve $UPN on the site (ensureuser): $($_.Exception.Message)" + } + if (-not $EnsuredUser.Id) { + throw "Could not resolve $UPN on the site." + } + + if ($Request.Body.Add -eq $true) { + # Same shape PnP sends: an SP.User entity posted to the group's users + # collection, which requires the odata=verbose content type. + $AddBody = ConvertTo-Json -Compress -Depth 5 -InputObject @{ + '__metadata' = @{ 'type' = 'SP.User' } + 'LoginName' = $EnsuredUser.LoginName + } + try { + $null = New-GraphPostRequest -uri "$BaseUri/web/associatedmembergroup/users" -tenantid $TenantFilter -scope $Scope -type POST -body $AddBody -contentType 'application/json;odata=verbose' -AddedHeaders $JsonAccept -UseCertificate -AsApp $true + } catch { + throw "Could not add $UPN to the site members group: $($_.Exception.Message)" + } + $Results = "Successfully added $UPN as a member of $SiteUrl." + } else { + try { + $null = New-GraphPostRequest -uri "$BaseUri/web/associatedmembergroup/users/removebyid($($EnsuredUser.Id))" -tenantid $TenantFilter -scope $Scope -type POST -body '{}' -contentType 'application/json;odata=nometadata' -AddedHeaders $JsonAccept -UseCertificate -AsApp $true + } catch { + throw "Could not remove $UPN from the site members group: $($_.Exception.Message)" + } + $Results = "Successfully removed $UPN as a member of $SiteUrl." + } + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message $Results -sev Info + $StatusCode = [HttpStatusCode]::OK } } catch { - $Results = $_.Exception.Message - $StatusCode = [HttpStatusCode]::InternalServerError + $ErrorMessage = Get-CippException -Exception $_ + $Results = "Failed to modify members for $($Request.Body.URL ?? $Request.Body.GroupID). Error: $($ErrorMessage.NormalizedError)" + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message $Results -sev Error -LogData $ErrorMessage + $StatusCode = [HttpStatusCode]::BadRequest } From 0fe9645a1b5b6edb4bf7419428b3a9ea964b5cdb Mon Sep 17 00:00:00 2001 From: John Duprey Date: Thu, 9 Jul 2026 23:51:51 -0400 Subject: [PATCH 158/170] feat: Improve SharePoint site role membership handling Refines SharePoint member management to be role-aware across Owners, Members, and Visitors, with smarter routing between Graph-backed M365 group updates and SharePoint role-group REST calls. Removals now honor the selected member type/source so directly added users are removed from the correct role group, and messaging/error text is more specific. Updates site member listing to return actual role-group membership from SharePoint REST, expand M365 group claims into real users on group-connected sites, include site collection admins, and fall back to the User Information List via Graph when REST access is unavailable. --- .../Invoke-ExecSetSharePointMember.ps1 | 81 ++++++++--- .../Invoke-ListSiteMembers.ps1 | 135 ++++++++++++++++-- 2 files changed, 183 insertions(+), 33 deletions(-) diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecSetSharePointMember.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecSetSharePointMember.ps1 index 6d18cdc484ed8..fdffad7a36c74 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecSetSharePointMember.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecSetSharePointMember.ps1 @@ -5,10 +5,13 @@ function Invoke-ExecSetSharePointMember { .ROLE Sharepoint.Site.ReadWrite .DESCRIPTION - Adds or removes a member on a SharePoint site. Group-connected sites are managed through - the backing Microsoft 365 group via Graph; all other site types (communication, classic) - are managed through the site's associated members group via the SharePoint REST API - using certificate authentication. + Adds or removes a user in a SharePoint site role (Owners, Members or Visitors). + Group-connected sites manage Owners/Members through the backing M365 group via Graph; + Visitors (and classic/communication sites entirely) are managed through the site's + associated SharePoint role groups via the SharePoint REST API using certificate + authentication. Removals sourced from ListSiteMembers carry the group and type of the + selected entry, so users directly added to a role group on a group-connected site are + removed from that group rather than from the M365 group. #> [CmdletBinding()] param($Request, $TriggerMetadata) @@ -17,33 +20,68 @@ function Invoke-ExecSetSharePointMember { $Headers = $Request.Headers $TenantFilter = $Request.Body.tenantFilter $UPN = $Request.Body.user.value + $Add = $Request.Body.Add -eq $true + + # Role comes from the removal picker's selected entry when present, else from the form. + $Role = $Request.Body.user.addedFields.Group ?? $Request.Body.Role ?? 'Members' + $MemberType = $Request.Body.user.addedFields.Type + $AssociatedGroups = @{ + 'Owners' = 'associatedownergroup' + 'Members' = 'associatedmembergroup' + 'Visitors' = 'associatedvisitorgroup' + } try { - if ($Request.Body.SharePointType -eq 'Group') { + if (-not $UPN) { throw 'No user was selected.' } + if (-not $AssociatedGroups.ContainsKey([string]$Role)) { + throw "Invalid role '$Role'. Valid roles are: $($AssociatedGroups.Keys -join ', ')." + } + + $IsGroupSite = $Request.Body.SharePointType -eq 'Group' + # Owners/Members of a group-connected site live in the M365 group. Visitors are always + # a SharePoint role group. A removal of a directly-added user (Type 'User') on a group + # site targets the SharePoint role group instead of the M365 group. + $UseGraphGroup = $IsGroupSite -and $Role -ne 'Visitors' -and ($Add -or $MemberType -ne 'User') + + if ($UseGraphGroup) { if ($Request.Body.GroupID -match '^[0-9a-fA-F]{8}(-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}$') { $GroupId = $Request.Body.GroupID } else { $GroupId = (New-GraphGetRequest -uri "https://graph.microsoft.com/beta/groups?`$filter=mail eq '$($Request.Body.GroupID)' or proxyAddresses/any(x:endsWith(x,'$($Request.Body.GroupID)')) or mailNickname eq '$($Request.Body.GroupID)'" -ComplexFilter -tenantid $TenantFilter).id } - if ($Request.Body.Add -eq $true) { - $Results = Add-CIPPGroupMember -GroupType 'Team' -GroupID $GroupID -Member $UPN -TenantFilter $TenantFilter -Headers $Headers + if ($Role -eq 'Owners') { + $UserID = (New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/users/$UPN`?`$select=id" -tenantid $TenantFilter).id + if ($Add) { + $OwnerBody = ConvertTo-Json -Compress -InputObject @{ '@odata.id' = "https://graph.microsoft.com/v1.0/directoryObjects/$UserID" } + $null = New-GraphPostRequest -uri "https://graph.microsoft.com/v1.0/groups/$GroupId/owners/`$ref" -tenantid $TenantFilter -type POST -body $OwnerBody + $Results = "Successfully added $UPN as an owner of the M365 group backing the site." + } else { + $null = New-GraphPostRequest -uri "https://graph.microsoft.com/v1.0/groups/$GroupId/owners/$UserID/`$ref" -tenantid $TenantFilter -type DELETE -body '' + $Results = "Successfully removed $UPN as an owner of the M365 group backing the site." + } + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message $Results -sev Info } else { - $UserID = (New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/users/$UPN" -tenantid $TenantFilter).id - $Results = Remove-CIPPGroupMember -GroupType 'Team' -GroupID $GroupID -Member $UserID -TenantFilter $TenantFilter -Headers $Headers + if ($Add) { + $Results = Add-CIPPGroupMember -GroupType 'Team' -GroupID $GroupID -Member $UPN -TenantFilter $TenantFilter -Headers $Headers + } else { + $UserID = (New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/users/$UPN`?`$select=id" -tenantid $TenantFilter).id + $Results = Remove-CIPPGroupMember -GroupType 'Team' -GroupID $GroupID -Member $UserID -TenantFilter $TenantFilter -Headers $Headers + } } $StatusCode = [HttpStatusCode]::OK } else { - # Non group-connected site: manage the site's associated members group via the - # SharePoint REST API with certificate auth. + # SharePoint role group management via REST with certificate auth. $SiteUrl = $Request.Body.URL if (-not $SiteUrl) { throw 'No site URL was provided for this site.' } - if (-not $UPN) { throw 'No user was selected.' } $SharePointInfo = Get-SharePointAdminLink -Public $false -tenantFilter $TenantFilter $Scope = "$($SharePointInfo.SharePointUrl)/.default" $JsonAccept = @{ Accept = 'application/json;odata=nometadata' } $BaseUri = "$($SiteUrl.TrimEnd('/'))/_api" + $RoleGroup = $AssociatedGroups[[string]$Role] + $RoleLabel = ([string]$Role).ToLower().TrimEnd('s') + $Article = if ($RoleLabel -match '^[aeiou]') { 'an' } else { 'a' } try { $EnsureBody = ConvertTo-Json -Compress -InputObject @{ logonName = "i:0#.f|membership|$UPN" } @@ -55,7 +93,7 @@ function Invoke-ExecSetSharePointMember { throw "Could not resolve $UPN on the site." } - if ($Request.Body.Add -eq $true) { + if ($Add) { # Same shape PnP sends: an SP.User entity posted to the group's users # collection, which requires the odata=verbose content type. $AddBody = ConvertTo-Json -Compress -Depth 5 -InputObject @{ @@ -63,25 +101,28 @@ function Invoke-ExecSetSharePointMember { 'LoginName' = $EnsuredUser.LoginName } try { - $null = New-GraphPostRequest -uri "$BaseUri/web/associatedmembergroup/users" -tenantid $TenantFilter -scope $Scope -type POST -body $AddBody -contentType 'application/json;odata=verbose' -AddedHeaders $JsonAccept -UseCertificate -AsApp $true + $null = New-GraphPostRequest -uri "$BaseUri/web/$RoleGroup/users" -tenantid $TenantFilter -scope $Scope -type POST -body $AddBody -contentType 'application/json;odata=verbose' -AddedHeaders $JsonAccept -UseCertificate -AsApp $true } catch { - throw "Could not add $UPN to the site members group: $($_.Exception.Message)" + throw "Could not add $UPN to the site $Role group: $($_.Exception.Message)" } - $Results = "Successfully added $UPN as a member of $SiteUrl." + $Results = "Successfully added $UPN as $Article $RoleLabel of $SiteUrl." } else { try { - $null = New-GraphPostRequest -uri "$BaseUri/web/associatedmembergroup/users/removebyid($($EnsuredUser.Id))" -tenantid $TenantFilter -scope $Scope -type POST -body '{}' -contentType 'application/json;odata=nometadata' -AddedHeaders $JsonAccept -UseCertificate -AsApp $true + $null = New-GraphPostRequest -uri "$BaseUri/web/$RoleGroup/users/removebyid($($EnsuredUser.Id))" -tenantid $TenantFilter -scope $Scope -type POST -body '{}' -contentType 'application/json;odata=nometadata' -AddedHeaders $JsonAccept -UseCertificate -AsApp $true } catch { - throw "Could not remove $UPN from the site members group: $($_.Exception.Message)" + if ($_.Exception.Message -match 'Can not find the user') { + throw "$UPN is not in the site's $Role group." + } + throw "Could not remove $UPN from the site $Role group: $($_.Exception.Message)" } - $Results = "Successfully removed $UPN as a member of $SiteUrl." + $Results = "Successfully removed $UPN as $Article $RoleLabel of $SiteUrl." } Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message $Results -sev Info $StatusCode = [HttpStatusCode]::OK } } catch { $ErrorMessage = Get-CippException -Exception $_ - $Results = "Failed to modify members for $($Request.Body.URL ?? $Request.Body.GroupID). Error: $($ErrorMessage.NormalizedError)" + $Results = "Failed to modify $Role for $($Request.Body.URL ?? $Request.Body.GroupID). Error: $($ErrorMessage.NormalizedError)" Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message $Results -sev Error -LogData $ErrorMessage $StatusCode = [HttpStatusCode]::BadRequest } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSiteMembers.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSiteMembers.ps1 index d57571cee6b3f..095aa30ef1264 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSiteMembers.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSiteMembers.ps1 @@ -5,34 +5,143 @@ Function Invoke-ListSiteMembers { .ROLE Sharepoint.Site.Read .DESCRIPTION - Lists members of a specific SharePoint site by site ID, including user display names and email addresses. + Lists the actual membership of a SharePoint site: the users in the site's associated + Owners/Members/Visitors role groups via the SharePoint REST API with certificate + authentication, plus site collection admins. On group-connected (Team) sites the role + groups contain the backing M365 group as a claim; those are expanded through Graph so + the real people are returned. Falls back to the site's hidden User Information List + via Graph when the SharePoint REST API is unavailable (e.g. the tenant does not have + the SharePoint application permission consented yet). #> [CmdletBinding()] param($Request, $TriggerMetadata) - $TenantFilter = $Request.Query.TenantFilter + $TenantFilter = $Request.Query.tenantFilter $SiteId = $Request.Query.SiteId + $SiteUrl = $Request.Query.SiteUrl try { - # Find User Information List by template (language independent) - $Lists = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/sites/$SiteId/lists?`$select=id,list,system" -tenantid $TenantFilter -AsApp $true - $UIList = $Lists | Where-Object { $_.list.template -eq 'userInformation' } | Select-Object -First 1 + if (-not $SiteUrl) { + $SiteUrl = (New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/sites/$($SiteId)?`$select=webUrl" -tenantid $TenantFilter -AsApp $true).webUrl + } + + $Members = [System.Collections.Generic.List[object]]::new() + try { + $SharePointInfo = Get-SharePointAdminLink -Public $false -tenantFilter $TenantFilter + $Scope = "$($SharePointInfo.SharePointUrl)/.default" + $JsonAccept = @{ Accept = 'application/json;odata=nometadata' } + $BaseUri = "$($SiteUrl.TrimEnd('/'))/_api" + + $RoleGroups = [ordered]@{ + 'Owners' = 'associatedownergroup' + 'Members' = 'associatedmembergroup' + 'Visitors' = 'associatedvisitorgroup' + } + foreach ($Role in $RoleGroups.Keys) { + $Users = New-GraphGetRequest -uri "$BaseUri/web/$($RoleGroups[$Role])/users?`$select=Id,Title,Email,LoginName,PrincipalType" -tenantid $TenantFilter -scope $Scope -extraHeaders $JsonAccept -UseCertificate -AsApp $true + foreach ($User in $Users) { + if ($User.LoginName -match 'federateddirectoryclaimprovider\|([0-9a-fA-F-]{36})(_o)?$') { + # Group-connected site: the role group holds the backing M365 group as + # a claim ('_o' suffix = the group's owners). Expand it through Graph. + $GroupId = $Matches[1] + $GraphSegment = if ($Matches[2]) { 'owners' } else { 'members' } + try { + $GroupMembers = New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/groups/$GroupId/$($GraphSegment)?`$select=displayName,mail,userPrincipalName&`$top=999" -tenantid $TenantFilter -AsApp $true + foreach ($GroupMember in $GroupMembers) { + $Members.Add([PSCustomObject]@{ + Title = $GroupMember.displayName + Email = $GroupMember.mail ?? $GroupMember.userPrincipalName + LoginName = $GroupMember.userPrincipalName + UserPrincipalName = $GroupMember.userPrincipalName + Group = $Role + Type = 'User (via M365 Group)' + IsSiteAdmin = $false + }) + } + } catch { + # Could not expand the group; show the claim entity itself. + $Members.Add([PSCustomObject]@{ + Title = $User.Title + Email = $User.Email + LoginName = $User.LoginName + UserPrincipalName = $null + Group = $Role + Type = 'M365 Group' + IsSiteAdmin = $false + }) + } + } else { + $Type = switch ($User.PrincipalType) { + 1 { 'User' } + 4 { 'Security Group' } + 8 { 'SharePoint Group' } + default { 'Other' } + } + $Members.Add([PSCustomObject]@{ + Title = $User.Title + Email = $User.Email + LoginName = $User.LoginName + UserPrincipalName = if ($User.PrincipalType -eq 1) { ($User.LoginName -split '\|')[-1] } else { $null } + Group = $Role + Type = $Type + IsSiteAdmin = $false + }) + } + } + } - if ($UIList.id) { - $Items = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/sites/$SiteId/lists/$($UIList.id)/items?`$expand=fields" -tenantid $TenantFilter -AsApp $true - } else { - $Items = @() + # Site collection admins: flag them on existing rows, add rows for admins that + # are not in any role group. + $Admins = New-GraphGetRequest -uri "$BaseUri/web/siteusers?`$filter=IsSiteAdmin eq true&`$select=Id,Title,Email,LoginName,PrincipalType" -tenantid $TenantFilter -scope $Scope -extraHeaders $JsonAccept -UseCertificate -AsApp $true + foreach ($Admin in $Admins) { + $Existing = @($Members | Where-Object { $_.LoginName -eq $Admin.LoginName }) + if ($Existing.Count -gt 0) { + $Existing | ForEach-Object { $_.IsSiteAdmin = $true } + } else { + $Members.Add([PSCustomObject]@{ + Title = $Admin.Title + Email = $Admin.Email + LoginName = $Admin.LoginName + UserPrincipalName = if ($Admin.PrincipalType -eq 1) { ($Admin.LoginName -split '\|')[-1] } else { $null } + Group = 'Site Admins' + Type = if ($Admin.PrincipalType -eq 1) { 'User' } elseif ($Admin.LoginName -match 'federateddirectoryclaimprovider') { 'M365 Group' } else { 'Other' } + IsSiteAdmin = $true + }) + } + } + } catch { + # SharePoint REST unavailable (e.g. no certificate/consent) - fall back to the + # hidden User Information List via Graph. This lists everyone ever resolved on + # the site rather than actual role group membership. + Write-Information "ListSiteMembers falling back to User Information List: $($_.Exception.Message)" + $Members.Clear() + $Lists = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/sites/$SiteId/lists?`$select=id,list,system" -tenantid $TenantFilter -AsApp $true + $UIList = $Lists | Where-Object { $_.list.template -eq 'userInformation' } | Select-Object -First 1 + if ($UIList.id) { + $Items = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/sites/$SiteId/lists/$($UIList.id)/items?`$expand=fields" -tenantid $TenantFilter -AsApp $true + foreach ($Item in $Items) { + $Members.Add([PSCustomObject]@{ + Title = $Item.fields.Title + Email = $Item.fields.EMail + LoginName = $Item.fields.UserName + UserPrincipalName = if ($Item.fields.UserName) { ($Item.fields.UserName -split '\|')[-1] } else { $null } + Group = 'Site Users' + Type = 'User' + IsSiteAdmin = [bool]$Item.fields.IsSiteAdmin + }) + } + } } $StatusCode = [HttpStatusCode]::OK - $Body = @($Items) + $Body = @($Members) } catch { $StatusCode = [HttpStatusCode]::Forbidden $Body = Get-NormalizedError -Message $_.Exception.Message } return ([HttpResponseContext]@{ - StatusCode = $StatusCode - Body = @{ 'Results' = $Body } - }) + StatusCode = $StatusCode + Body = @{ 'Results' = $Body } + }) } From d06d7fc65ecbe7662b8d5dd891c5015b7a5930fd Mon Sep 17 00:00:00 2001 From: John Duprey Date: Fri, 10 Jul 2026 00:01:08 -0400 Subject: [PATCH 159/170] feat: Enhance group member addition and removal logging with group names --- .../CIPPCore/Public/Add-CIPPGroupMember.ps1 | 53 +++++++++++++----- .../Public/Remove-CIPPGroupMember.ps1 | 54 ++++++++++++++----- 2 files changed, 81 insertions(+), 26 deletions(-) diff --git a/Modules/CIPPCore/Public/Add-CIPPGroupMember.ps1 b/Modules/CIPPCore/Public/Add-CIPPGroupMember.ps1 index 3a5ea194fe185..e5f94558e34e6 100644 --- a/Modules/CIPPCore/Public/Add-CIPPGroupMember.ps1 +++ b/Modules/CIPPCore/Public/Add-CIPPGroupMember.ps1 @@ -34,16 +34,29 @@ function Add-CIPPGroupMember { [string]$APIName = 'Add Group Member' ) try { - if ($Member -like '*#EXT#*') { $Member = [System.Web.HttpUtility]::UrlEncode($Member) } $ODataBindString = 'https://graph.microsoft.com/v1.0/directoryObjects/{0}' - $Requests = foreach ($m in $Member) { + $Requests = @( + foreach ($m in $Member) { + if ($m -like '*#EXT#*') { $m = [System.Web.HttpUtility]::UrlEncode($m) } + @{ + id = "users-$m" + url = "users/$($m)?`$select=id,userPrincipalName" + method = 'GET' + } + } @{ - id = $m - url = "users/$($m)?`$select=id,userPrincipalName" + id = 'group' + url = "groups/$($GroupId)?`$select=id,displayName" method = 'GET' } - } - $Users = New-GraphBulkRequest -Requests @($Requests) -tenantid $TenantFilter + ) + $BulkResults = New-GraphBulkRequest -Requests @($Requests) -tenantid $TenantFilter + $Users = @($BulkResults | Where-Object { $_.id -like 'users-*' }) + # Group display name for logging; falls back to the id if the lookup failed + # (e.g. the group was addressed by mail rather than GUID). + $GroupName = ($BulkResults | Where-Object { $_.id -eq 'group' }).body.displayName ?? $GroupId + $SuccessfulUsers = [System.Collections.Generic.List[string]]::new() + $FailedUsers = [System.Collections.Generic.List[string]]::new() if ($GroupType -eq 'Distribution list' -or $GroupType -eq 'Mail-Enabled Security') { $ExoBulkRequests = [System.Collections.Generic.List[object]]::new() @@ -58,7 +71,7 @@ function Add-CIPPGroupMember { } }) $ExoLogs.Add(@{ - message = "Added member $($User.body.userPrincipalName) to $($GroupId) group" + message = "Added member $($User.body.userPrincipalName) to group $($GroupName)" target = $User.body.userPrincipalName }) } @@ -76,6 +89,7 @@ function Add-CIPPGroupMember { $ExoError = $LastError | Where-Object { $ExoLog.target -in $_.target -and $_.error } if (!$LastError -or ($LastError.error -and $LastError.target -notcontains $ExoLog.target)) { Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $ExoLog.message -Sev 'Info' + $SuccessfulUsers.Add($ExoLog.target) } } } @@ -91,25 +105,36 @@ function Add-CIPPGroupMember { } } $AddResults = New-GraphBulkRequest -tenantid $TenantFilter -Requests @($AddRequests) - $SuccessfulUsers = [system.collections.generic.list[string]]::new() foreach ($Result in $AddResults) { + $UserPrincipalName = ($Users | Where-Object { $_.body.id -eq $Result.id }).body.userPrincipalName if ($Result.status -lt 200 -or $Result.status -gt 299) { - $FailedUsername = $Users | Where-Object { $_.body.id -eq $Result.id } | Select-Object -ExpandProperty body | Select-Object -ExpandProperty userPrincipalName - Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Failed to add member $($FailedUsername): $($Result.body.error.message)" -Sev 'Error' + # Select-Object -First 1: Get-NormalizedError can return multiple strings + # when a message matches more than one of its translation patterns. + $ErrorText = Get-NormalizedError -message ($Result.body.error.message ?? "Request failed with status $($Result.status)") | Select-Object -First 1 + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Failed to add member $UserPrincipalName to group $($GroupName): $ErrorText" -Sev 'Error' + $FailedUsers.Add("$UserPrincipalName ($ErrorText)") } else { - $UserPrincipalName = $Users | Where-Object { $_.body.id -eq $Result.id } | Select-Object -ExpandProperty body | Select-Object -ExpandProperty userPrincipalName $SuccessfulUsers.Add($UserPrincipalName) } } } - $UserList = ($SuccessfulUsers -join ', ') - $Results = "Successfully added user $UserList to $($GroupId)." + $Messages = [System.Collections.Generic.List[string]]::new() + if ($SuccessfulUsers.Count -gt 0) { + $Messages.Add("Successfully added user $($SuccessfulUsers -join ', ') to group $($GroupName).") + } + if ($FailedUsers.Count -gt 0) { + $Messages.Add("Failed to add $($FailedUsers -join '; ').") + } + $Results = $Messages -join ' ' + if ($SuccessfulUsers.Count -eq 0 -and $FailedUsers.Count -gt 0) { + throw $Results + } Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $Results -Sev 'Info' return $Results } catch { $ErrorMessage = Get-CippException -Exception $_ $UserList = if ($Users) { ($Users.body.userPrincipalName -join ', ') } else { ($Member -join ', ') } - $Results = "Failed to add user $UserList to $($GroupId) - $($ErrorMessage.NormalizedError)" + $Results = "Failed to add user $UserList to group $($GroupName ?? $GroupId) - $($ErrorMessage.NormalizedError)" Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $Results -Sev 'error' -LogData $ErrorMessage throw $Results } diff --git a/Modules/CIPPCore/Public/Remove-CIPPGroupMember.ps1 b/Modules/CIPPCore/Public/Remove-CIPPGroupMember.ps1 index 1880d4c4960f5..c4f395369f1f5 100644 --- a/Modules/CIPPCore/Public/Remove-CIPPGroupMember.ps1 +++ b/Modules/CIPPCore/Public/Remove-CIPPGroupMember.ps1 @@ -52,15 +52,28 @@ function Remove-CIPPGroupMember { ) try { - $Requests = foreach ($m in $Member) { - if ($m -like '*#EXT#*') { $m = [System.Web.HttpUtility]::UrlEncode($m) } + $Requests = @( + foreach ($m in $Member) { + if ($m -like '*#EXT#*') { $m = [System.Web.HttpUtility]::UrlEncode($m) } + @{ + id = "users-$m" + url = "users/$($m)?`$select=id,userPrincipalName" + method = 'GET' + } + } @{ - id = $m - url = "users/$($m)?`$select=id,userPrincipalName" + id = 'group' + url = "groups/$($GroupId)?`$select=id,displayName" method = 'GET' } - } - $Users = New-GraphBulkRequest -Requests @($Requests) -tenantid $TenantFilter + ) + $BulkResults = New-GraphBulkRequest -Requests @($Requests) -tenantid $TenantFilter + $Users = @($BulkResults | Where-Object { $_.id -like 'users-*' }) + # Group display name for logging; falls back to the id if the lookup failed + # (e.g. the group was addressed by mail rather than GUID). + $GroupName = ($BulkResults | Where-Object { $_.id -eq 'group' }).body.displayName ?? $GroupId + $SuccessfulUsers = [System.Collections.Generic.List[string]]::new() + $FailedUsers = [System.Collections.Generic.List[string]]::new() if ($GroupType -eq 'Distribution list' -or $GroupType -eq 'Mail-Enabled Security') { $ExoBulkRequests = [System.Collections.Generic.List[object]]::new() @@ -75,7 +88,7 @@ function Remove-CIPPGroupMember { } }) $ExoLogs.Add(@{ - message = "Removed member $($User.body.userPrincipalName) from $($GroupId) group" + message = "Removed member $($User.body.userPrincipalName) from group $($GroupName)" target = $User.body.userPrincipalName }) } @@ -93,6 +106,7 @@ function Remove-CIPPGroupMember { $ExoError = $LastError | Where-Object { $ExoLog.target -in $_.target -and $_.error } if (!$LastError -or ($LastError.error -and $LastError.target -notcontains $ExoLog.target)) { Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $ExoLog.message -Sev 'Info' + $SuccessfulUsers.Add($ExoLog.target) } } } @@ -106,20 +120,36 @@ function Remove-CIPPGroupMember { } $RemovalResults = New-GraphBulkRequest -tenantid $TenantFilter -Requests @($RemovalRequests) foreach ($Result in $RemovalResults) { - if ($Result.status -ne 204) { - throw "Failed to remove member $($Result.id): $($Result.body.error.message)" + $UserPrincipalName = ($Users | Where-Object { $_.body.id -eq $Result.id }).body.userPrincipalName + if ($Result.status -lt 200 -or $Result.status -gt 299) { + # Select-Object -First 1: Get-NormalizedError can return multiple strings + # when a message matches more than one of its translation patterns. + $ErrorText = Get-NormalizedError -message ($Result.body.error.message ?? "Request failed with status $($Result.status)") | Select-Object -First 1 + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Failed to remove member $UserPrincipalName from group $($GroupName): $ErrorText" -Sev 'Error' + $FailedUsers.Add("$UserPrincipalName ($ErrorText)") + } else { + $SuccessfulUsers.Add($UserPrincipalName) } } } - $UserList = ($Users.body.userPrincipalName -join ', ') - $Results = "Successfully removed user $UserList from $($GroupId)." + $Messages = [System.Collections.Generic.List[string]]::new() + if ($SuccessfulUsers.Count -gt 0) { + $Messages.Add("Successfully removed user $($SuccessfulUsers -join ', ') from group $($GroupName).") + } + if ($FailedUsers.Count -gt 0) { + $Messages.Add("Failed to remove $($FailedUsers -join '; ').") + } + $Results = $Messages -join ' ' + if ($SuccessfulUsers.Count -eq 0 -and $FailedUsers.Count -gt 0) { + throw $Results + } Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $Results -Sev Info return $Results } catch { $ErrorMessage = Get-CippException -Exception $_ $UserList = if ($Users) { ($Users.body.userPrincipalName -join ', ') } else { ($Member -join ', ') } - $Results = "Failed to remove user $UserList from $($GroupId): $($ErrorMessage.NormalizedError)" + $Results = "Failed to remove user $UserList from group $($GroupName ?? $GroupId): $($ErrorMessage.NormalizedError)" Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $Results -Sev Error -LogData $ErrorMessage throw $Results } From 2bc15bf029d112089fa44377dc37a3efd8cd3c1e Mon Sep 17 00:00:00 2001 From: John Duprey Date: Fri, 10 Jul 2026 00:33:08 -0400 Subject: [PATCH 160/170] feat: Add SiteUrl parameter to Get-CIPPSPOSite and implement single-site retrieval feat: Extend Set-CIPPSPOSite to support Int64 property types feat: Implement Invoke-ExecSetSiteProperties for updating site properties via API feat: Create Invoke-ListSiteProperties to retrieve site properties with friendly enum names --- Modules/CIPPCore/Public/Get-CIPPSPOSite.ps1 | 24 +++- Modules/CIPPCore/Public/Set-CIPPSPOSite.ps1 | 11 +- .../Invoke-ExecSetSiteProperties.ps1 | 113 ++++++++++++++++++ .../Invoke-ListSiteProperties.ps1 | 63 ++++++++++ 4 files changed, 207 insertions(+), 4 deletions(-) create mode 100644 Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecSetSiteProperties.ps1 create mode 100644 Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSiteProperties.ps1 diff --git a/Modules/CIPPCore/Public/Get-CIPPSPOSite.ps1 b/Modules/CIPPCore/Public/Get-CIPPSPOSite.ps1 index 3f4a118c1f9ee..8f35bca98ef3a 100644 --- a/Modules/CIPPCore/Public/Get-CIPPSPOSite.ps1 +++ b/Modules/CIPPCore/Public/Get-CIPPSPOSite.ps1 @@ -10,9 +10,16 @@ function Get-CIPPSPOSite { .PARAMETER TenantFilter Tenant to query + .PARAMETER SiteUrl + When provided, fetches the properties of this single site (CSOM GetSitePropertiesByUrl) + instead of enumerating every site in the tenant. + .EXAMPLE Get-CIPPSPOSite -TenantFilter 'contoso.onmicrosoft.com' + .EXAMPLE + Get-CIPPSPOSite -TenantFilter 'contoso.onmicrosoft.com' -SiteUrl 'https://contoso.sharepoint.com/sites/MySite' + .FUNCTIONALITY Internal @@ -20,12 +27,27 @@ function Get-CIPPSPOSite { [CmdletBinding()] param( [Parameter(Mandatory = $true)] - [string]$TenantFilter + [string]$TenantFilter, + [string]$SiteUrl ) $SharePointInfo = Get-SharePointAdminLink -Public $false -tenantFilter $TenantFilter $AdminUrl = $SharePointInfo.AdminUrl + if ($SiteUrl) { + # Single-site fast path: Tenant Constructor -> GetSitePropertiesByUrl -> Query all properties + $XML = @" +$([System.Security.SecurityElement]::Escape($SiteUrl))true +"@ + $AdditionalHeaders = @{ 'Accept' = 'application/json;odata=verbose' } + $Results = New-GraphPostRequest -scope "$AdminUrl/.default" -tenantid $TenantFilter -Uri "$AdminUrl/_vti_bin/client.svc/ProcessQuery" -Type POST -Body $XML -ContentType 'text/xml' -AddedHeaders $AdditionalHeaders + $Site = $Results | Where-Object { $_._ObjectType_ -match 'SiteProperties' } | Select-Object -First 1 + if (-not $Site) { + throw "Could not retrieve site properties for $SiteUrl" + } + return $Site + } + $XML = @' false '@ diff --git a/Modules/CIPPCore/Public/Set-CIPPSPOSite.ps1 b/Modules/CIPPCore/Public/Set-CIPPSPOSite.ps1 index 4af8e4f6b55a2..df35ab4fd187e 100644 --- a/Modules/CIPPCore/Public/Set-CIPPSPOSite.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPSPOSite.ps1 @@ -43,13 +43,18 @@ function Set-CIPPSPOSite { $SharePointInfo = Get-SharePointAdminLink -Public $false -tenantFilter $TenantFilter $AdminUrl = $SharePointInfo.AdminUrl - $AllowedTypes = @('Boolean', 'String', 'Int32') + $AllowedTypes = @('Boolean', 'String', 'Int32', 'Int64') + # Properties that are CSOM enums; their (numeric) value must be sent as Type="Enum". + $EnumProperties = @('SharingCapability', 'DefaultSharingLinkType', 'DefaultLinkPermission', 'SharingDomainRestrictionMode', 'ConditionalAccessPolicy') $SetProperty = [System.Collections.Generic.List[string]]::new() $x = 106 foreach ($Property in $Properties.Keys) { $PropertyType = $Properties[$Property].GetType().Name - if ($PropertyType -in $AllowedTypes) { - $PropertyToSet = if ($PropertyType -eq 'Boolean') { $Properties[$Property].ToString().ToLower() } else { $Properties[$Property] } + if ($Property -in $EnumProperties) { + $SetProperty.Add("$([int]$Properties[$Property])") + $x++ + } elseif ($PropertyType -in $AllowedTypes) { + $PropertyToSet = if ($PropertyType -eq 'Boolean') { $Properties[$Property].ToString().ToLower() } else { [System.Security.SecurityElement]::Escape([string]$Properties[$Property]) } $SetProperty.Add("$PropertyToSet") $x++ } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecSetSiteProperties.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecSetSiteProperties.ps1 new file mode 100644 index 0000000000000..349c6b3ddf636 --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecSetSiteProperties.ps1 @@ -0,0 +1,113 @@ +function Invoke-ExecSetSiteProperties { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Sharepoint.Site.ReadWrite + .DESCRIPTION + Sets admin-level properties on a single SharePoint site (sharing, lifecycle, version + policy) through Set-CIPPSPOSite. Only whitelisted properties are accepted; enum values + arrive as friendly names (matching Invoke-ListSiteProperties output) and are converted + to their numeric CSOM values here. + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + $TenantFilter = $Request.Body.tenantFilter + $SiteUrl = $Request.Body.SiteUrl + + # Friendly name -> SPO enum value maps + $EnumMaps = @{ + SharingCapability = @{ 'Disabled' = 0; 'ExternalUserSharingOnly' = 1; 'ExternalUserAndGuestSharing' = 2; 'ExistingExternalUserSharingOnly' = 3 } + DefaultSharingLinkType = @{ 'None' = 0; 'Direct' = 1; 'Internal' = 2; 'AnonymousAccess' = 3 } + DefaultLinkPermission = @{ 'None' = 0; 'View' = 1; 'Edit' = 2 } + SharingDomainRestrictionMode = @{ 'None' = 0; 'AllowList' = 1; 'BlockList' = 2 } + } + $StringProperties = @('Title', 'SharingAllowedDomainList', 'SharingBlockedDomainList') + $BoolProperties = @('OverrideTenantAnonymousLinkExpirationPolicy', 'InheritVersionPolicyFromTenant', 'EnableAutoExpirationVersionTrim', 'ApplyToNewDocumentLibraries', 'ApplyToExistingDocumentLibraries') + $IntProperties = @('AnonymousLinkExpirationInDays', 'MajorVersionLimit', 'ExpireVersionsAfterDays') + $Int64Properties = @('StorageMaximumLevel', 'StorageWarningLevel') + $ValidLockStates = @('Unlock', 'ReadOnly', 'NoAccess') + + try { + if (-not $SiteUrl) { throw 'SiteUrl is required.' } + + $Properties = @{} + $Changes = [System.Collections.Generic.List[string]]::new() + + foreach ($Key in $EnumMaps.Keys) { + $Value = $Request.Body.$Key.value ?? $Request.Body.$Key + if ($null -ne $Value -and "$Value" -ne '') { + if (-not $EnumMaps[$Key].ContainsKey([string]$Value)) { + throw "Invalid value '$Value' for $Key. Valid values: $($EnumMaps[$Key].Keys -join ', ')." + } + $Properties[$Key] = [int]$EnumMaps[$Key][[string]$Value] + $Changes.Add("$Key=$Value") + } + } + + $LockState = $Request.Body.LockState.value ?? $Request.Body.LockState + if ($null -ne $LockState -and "$LockState" -ne '') { + if ($LockState -notin $ValidLockStates) { + throw "Invalid LockState '$LockState'. Valid values: $($ValidLockStates -join ', ')." + } + $Properties['LockState'] = [string]$LockState + $Changes.Add("LockState=$LockState") + } + + foreach ($Key in $StringProperties) { + $Value = $Request.Body.$Key + if ($null -ne $Value) { + $Properties[$Key] = [string]$Value + $Changes.Add("$Key=$Value") + } + } + foreach ($Key in $BoolProperties) { + $Value = $Request.Body.$Key + if ($null -ne $Value) { + $Properties[$Key] = [bool]$Value + $Changes.Add("$Key=$Value") + } + } + foreach ($Key in $IntProperties) { + $Value = $Request.Body.$Key + if ($null -ne $Value -and "$Value" -ne '') { + $Properties[$Key] = [int]$Value + $Changes.Add("$Key=$Value") + } + } + foreach ($Key in $Int64Properties) { + $Value = $Request.Body.$Key + if ($null -ne $Value -and "$Value" -ne '') { + $Properties[$Key] = [int64]$Value + $Changes.Add("$Key=$Value") + } + } + + if ($Properties.Count -eq 0) { + throw 'No valid properties were provided to set.' + } + + $Response = Set-CIPPSPOSite -TenantFilter $TenantFilter -SiteUrl $SiteUrl -Properties $Properties + $CsomError = ($Response | Where-Object { $_.ErrorInfo } | Select-Object -First 1).ErrorInfo.ErrorMessage + if ($CsomError) { + throw $CsomError + } + + $Results = "Successfully updated site properties for $($SiteUrl): $($Changes -join ', ')" + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message $Results -sev Info + $StatusCode = [HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Results = "Failed to update site properties for $($SiteUrl): $($ErrorMessage.NormalizedError)" + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message $Results -sev Error -LogData $ErrorMessage + $StatusCode = [HttpStatusCode]::BadRequest + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @{ 'Results' = $Results } + }) +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSiteProperties.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSiteProperties.ps1 new file mode 100644 index 0000000000000..0f11bb9f2855b --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSiteProperties.ps1 @@ -0,0 +1,63 @@ +function Invoke-ListSiteProperties { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Sharepoint.Site.Read + .DESCRIPTION + Returns a single site's admin-level properties (sharing, lifecycle and version policy) + via the SharePoint admin CSOM API, with enum values translated to friendly names so + the Edit Site form can consume and round-trip them. + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $TenantFilter = $Request.Query.tenantFilter + $SiteUrl = $Request.Query.SiteUrl + + # SPO enum value -> friendly name maps (reverse of the maps in Invoke-ExecSetSiteProperties) + $SharingCapabilityNames = @{ 0 = 'Disabled'; 1 = 'ExternalUserSharingOnly'; 2 = 'ExternalUserAndGuestSharing'; 3 = 'ExistingExternalUserSharingOnly' } + $LinkTypeNames = @{ 0 = 'None'; 1 = 'Direct'; 2 = 'Internal'; 3 = 'AnonymousAccess' } + $LinkPermissionNames = @{ 0 = 'None'; 1 = 'View'; 2 = 'Edit' } + $DomainRestrictionNames = @{ 0 = 'None'; 1 = 'AllowList'; 2 = 'BlockList' } + + try { + if (-not $SiteUrl) { throw 'SiteUrl is required.' } + $Site = Get-CIPPSPOSite -TenantFilter $TenantFilter -SiteUrl $SiteUrl + + $Body = [PSCustomObject]@{ + Url = $Site.Url + Title = $Site.Title + Template = $Site.Template + # Sharing + SharingCapability = $SharingCapabilityNames[[int]$Site.SharingCapability] ?? $Site.SharingCapability + DefaultSharingLinkType = $LinkTypeNames[[int]$Site.DefaultSharingLinkType] ?? $Site.DefaultSharingLinkType + DefaultLinkPermission = $LinkPermissionNames[[int]$Site.DefaultLinkPermission] ?? $Site.DefaultLinkPermission + SharingDomainRestrictionMode = $DomainRestrictionNames[[int]$Site.SharingDomainRestrictionMode] ?? $Site.SharingDomainRestrictionMode + SharingAllowedDomainList = $Site.SharingAllowedDomainList + SharingBlockedDomainList = $Site.SharingBlockedDomainList + OverrideTenantAnonymousLinkExpirationPolicy = [bool]$Site.OverrideTenantAnonymousLinkExpirationPolicy + AnonymousLinkExpirationInDays = $Site.AnonymousLinkExpirationInDays + # Lifecycle + LockState = $Site.LockState + StorageMaximumLevel = $Site.StorageMaximumLevel + StorageWarningLevel = $Site.StorageWarningLevel + StorageUsage = $Site.StorageUsage + # Version policy + InheritVersionPolicyFromTenant = [bool]$Site.InheritVersionPolicyFromTenant + EnableAutoExpirationVersionTrim = [bool]$Site.EnableAutoExpirationVersionTrim + MajorVersionLimit = $Site.MajorVersionLimit + ExpireVersionsAfterDays = $Site.ExpireVersionsAfterDays + } + $StatusCode = [HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Body = "Failed to get site properties for $($SiteUrl): $($ErrorMessage.NormalizedError)" + $StatusCode = [HttpStatusCode]::BadRequest + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @{ 'Results' = $Body } + }) +} From 2e8531cf19ccf308fd68ce1cc53b749cec2c3a23 Mon Sep 17 00:00:00 2001 From: John Duprey Date: Fri, 10 Jul 2026 01:41:15 -0400 Subject: [PATCH 161/170] feat: Enhanced SharePoint site management functionality - deleted site restoration - bulk removal of sharing links for a site - site recycle bin management (new cipp permission) - full user removal from all groups in site - guest user detection in list site members --- .../Public/Get-CIPPSPODeletedSites.ps1 | 60 ++++++++++ .../Public/Restore-CIPPSPODeletedSite.ps1 | 46 ++++++++ .../Invoke-DeleteSharepointSite.ps1 | 62 +++++++---- .../Invoke-ExecBulkRemoveSharingLinks.ps1 | 104 ++++++++++++++++++ .../Invoke-ExecRemoveSiteUser.ps1 | 65 +++++++++++ .../Invoke-ExecRestoreDeletedSite.ps1 | 40 +++++++ .../Invoke-ExecRestoreRecycleBinItems.ps1 | 52 +++++++++ .../Invoke-ExecSetSiteProperties.ps1 | 25 ++++- .../Invoke-ListDeletedSites.ps1 | 49 +++++++++ .../Invoke-ListSiteMembers.ps1 | 20 +++- .../Invoke-ListSiteRecycleBin.ps1 | 54 +++++++++ 11 files changed, 551 insertions(+), 26 deletions(-) create mode 100644 Modules/CIPPCore/Public/Get-CIPPSPODeletedSites.ps1 create mode 100644 Modules/CIPPCore/Public/Restore-CIPPSPODeletedSite.ps1 create mode 100644 Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecBulkRemoveSharingLinks.ps1 create mode 100644 Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecRemoveSiteUser.ps1 create mode 100644 Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecRestoreDeletedSite.ps1 create mode 100644 Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecRestoreRecycleBinItems.ps1 create mode 100644 Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListDeletedSites.ps1 create mode 100644 Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSiteRecycleBin.ps1 diff --git a/Modules/CIPPCore/Public/Get-CIPPSPODeletedSites.ps1 b/Modules/CIPPCore/Public/Get-CIPPSPODeletedSites.ps1 new file mode 100644 index 0000000000000..696adeb0ec1d3 --- /dev/null +++ b/Modules/CIPPCore/Public/Get-CIPPSPODeletedSites.ps1 @@ -0,0 +1,60 @@ +function Get-CIPPSPODeletedSites { + <# + .SYNOPSIS + List deleted SharePoint sites (tenant recycle bin) via CSOM + + .DESCRIPTION + Retrieves the deleted site collections still restorable from the SharePoint tenant recycle + bin using the CSOM GetDeletedSitePropertiesFromSharePoint method, following the same paging + pattern as Get-CIPPSPOSite. + + .PARAMETER TenantFilter + Tenant to query + + .EXAMPLE + Get-CIPPSPODeletedSites -TenantFilter 'contoso.onmicrosoft.com' + + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter + ) + + $SharePointInfo = Get-SharePointAdminLink -Public $false -tenantFilter $TenantFilter + $AdminUrl = $SharePointInfo.AdminUrl + $AdditionalHeaders = @{ 'Accept' = 'application/json;odata=verbose' } + + $AllSites = [System.Collections.Generic.List[object]]::new() + $StartIndex = $null + + do { + $StartIndexParameter = if ($null -ne $StartIndex) { + "$([System.Security.SecurityElement]::Escape($StartIndex))" + } else { + '' + } + $XML = @" +$StartIndexParameter +"@ + + $Results = New-GraphPostRequest -scope "$AdminUrl/.default" -tenantid $TenantFilter -Uri "$AdminUrl/_vti_bin/client.svc/ProcessQuery" -Type POST -Body $XML -ContentType 'text/xml' -AddedHeaders $AdditionalHeaders + + $CsomError = ($Results | Where-Object { $_.ErrorInfo } | Select-Object -First 1).ErrorInfo.ErrorMessage + if ($CsomError) { throw $CsomError } + + $SiteCollection = $Results | Where-Object { $_._Child_Items_ } + if ($SiteCollection) { + foreach ($Site in $SiteCollection._Child_Items_) { + [void]$AllSites.Add($Site) + } + $StartIndex = $SiteCollection.NextStartIndexFromSharePoint + } else { + $StartIndex = $null + } + } while ($StartIndex) + + return $AllSites +} diff --git a/Modules/CIPPCore/Public/Restore-CIPPSPODeletedSite.ps1 b/Modules/CIPPCore/Public/Restore-CIPPSPODeletedSite.ps1 new file mode 100644 index 0000000000000..e297c6d140a6d --- /dev/null +++ b/Modules/CIPPCore/Public/Restore-CIPPSPODeletedSite.ps1 @@ -0,0 +1,46 @@ +function Restore-CIPPSPODeletedSite { + <# + .SYNOPSIS + Restore a deleted SharePoint site from the tenant recycle bin via CSOM + + .DESCRIPTION + Restores a deleted site collection using the CSOM RestoreDeletedSite method against the + SharePoint admin endpoint (same ProcessQuery pattern as Set-CIPPSharePointPerms). + + .PARAMETER TenantFilter + Tenant the site belongs to + + .PARAMETER SiteUrl + Full URL of the deleted site to restore + + .EXAMPLE + Restore-CIPPSPODeletedSite -TenantFilter 'contoso.onmicrosoft.com' -SiteUrl 'https://contoso.sharepoint.com/sites/OldSite' + + .FUNCTIONALITY + Internal + #> + [CmdletBinding(SupportsShouldProcess = $true)] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + [Parameter(Mandatory = $true)] + [string]$SiteUrl + ) + + $SharePointInfo = Get-SharePointAdminLink -Public $false -tenantFilter $TenantFilter + $AdminUrl = $SharePointInfo.AdminUrl + $AdditionalHeaders = @{ 'Accept' = 'application/json;odata=verbose' } + + $XML = @" +$([System.Security.SecurityElement]::Escape($SiteUrl)) +"@ + + if ($PSCmdlet.ShouldProcess($SiteUrl, 'Restore deleted site')) { + $Results = New-GraphPostRequest -scope "$AdminUrl/.default" -tenantid $TenantFilter -Uri "$AdminUrl/_vti_bin/client.svc/ProcessQuery" -Type POST -Body $XML -ContentType 'text/xml' -AddedHeaders $AdditionalHeaders + + $CsomError = ($Results | Where-Object { $_.ErrorInfo } | Select-Object -First 1).ErrorInfo.ErrorMessage + if ($CsomError) { throw $CsomError } + + return ($Results | Where-Object { $null -ne $_.IsComplete } | Select-Object -First 1) + } +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-DeleteSharepointSite.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-DeleteSharepointSite.ps1 index 01a8dca2ccf6f..0956440b6995c 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-DeleteSharepointSite.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-DeleteSharepointSite.ps1 @@ -19,15 +19,15 @@ function Invoke-DeleteSharepointSite { try { # Validate required parameters if (-not $SiteId) { - throw "SiteId is required" + throw 'SiteId is required' } if (-not $TenantFilter) { - throw "TenantFilter is required" + throw 'TenantFilter is required' } # Validate SiteId format (GUID) if ($SiteId -notmatch '^(\{)?[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}(\})?$') { - throw "SiteId must be a valid GUID" + throw 'SiteId must be a valid GUID' } $SharePointInfo = Get-SharePointAdminLink -Public $false -tenantFilter $TenantFilter @@ -35,42 +35,58 @@ function Invoke-DeleteSharepointSite { # Get site information using SharePoint admin API $SiteInfoUri = "$($SharePointInfo.AdminUrl)/_api/SPO.Tenant/sites('$SiteId')" - # Add the headers that SharePoint REST API expects - $ExtraHeaders = @{ - 'accept' = 'application/json' - 'content-type' = 'application/json' + # The SPO.Tenant vroute GET needs odata-version 4.0; the manager POST endpoints must + # NOT receive it - sending it flips them onto a pipeline that rejects the call with + # 'Elevated context should be used only to create service asserted level PoP'. + $GetHeaders = @{ + 'accept' = 'application/json' 'odata-version' = '4.0' } + $PostHeaders = @{ + 'accept' = 'application/json' + } - $SiteInfo = New-GraphGETRequest -scope "$($SharePointInfo.AdminUrl)/.default" -uri $SiteInfoUri -tenantid $TenantFilter -extraHeaders $ExtraHeaders + try { + $SiteInfo = New-GraphGETRequest -scope "$($SharePointInfo.AdminUrl)/.default" -uri $SiteInfoUri -tenantid $TenantFilter -extraHeaders $GetHeaders -UseCertificate -AsApp $true + } catch { + throw "Could not retrieve site information from the SharePoint Admin API: $($_.Exception.Message)" + } if (-not $SiteInfo) { - throw "Could not retrieve site information from SharePoint Admin API" + throw 'Could not retrieve site information from SharePoint Admin API' } # Determine if site is group-connected based on GroupId - $IsGroupConnected = $SiteInfo.GroupId -and $SiteInfo.GroupId -ne "00000000-0000-0000-0000-000000000000" + $IsGroupConnected = $SiteInfo.GroupId -and $SiteInfo.GroupId -ne '00000000-0000-0000-0000-000000000000' if ($IsGroupConnected) { - # Use GroupSiteManager/Delete for group-connected sites + # Group-connected sites: GroupSiteManager/Delete soft-deletes the backing M365 + # group (and Team) together with the site and registers it in the SPO deleted + # sites list. Runs with the delegated token (the CIPP service account is a + # SharePoint admin); see the header note above for why odata-version is omitted. $body = @{ siteUrl = $SiteInfo.Url } - $DeleteUri = "$($SharePointInfo.AdminUrl)/_api/GroupSiteManager/Delete" + try { + $null = New-GraphPOSTRequest -scope "$($SharePointInfo.AdminUrl)/.default" -uri "$($SharePointInfo.AdminUrl)/_api/GroupSiteManager/Delete" -body (ConvertTo-Json -Depth 10 -InputObject $body) -tenantid $TenantFilter -contentType 'application/json' -AddedHeaders $PostHeaders + } catch { + throw "Site deletion request failed (GroupSiteManager/Delete): $($_.Exception.Message)" + } + $Results = "Successfully initiated deletion of group-connected SharePoint site $($SiteInfo.Url); the backing M365 group (and Team, if any) is deleted with it. This can take some time to complete in the background." } else { - # Use SPSiteManager/delete for regular sites + # Regular sites: SPSiteManager/delete denies delegated tokens (even with a + # certificate assertion) with E_ACCESSDENIED - it requires app-only cert auth. $body = @{ siteId = $SiteId } - $DeleteUri = "$($SharePointInfo.AdminUrl)/_api/SPSiteManager/delete" + try { + $null = New-GraphPOSTRequest -scope "$($SharePointInfo.AdminUrl)/.default" -uri "$($SharePointInfo.AdminUrl)/_api/SPSiteManager/delete" -body (ConvertTo-Json -Depth 10 -InputObject $body) -tenantid $TenantFilter -contentType 'application/json' -AddedHeaders $PostHeaders -UseCertificate -AsApp $true + } catch { + throw "Site deletion request failed (SPSiteManager/delete): $($_.Exception.Message)" + } + $Results = "Successfully initiated deletion of SharePoint site with ID $SiteId, this process can take some time to complete in the background" } - # Execute the deletion - $DeleteResult = New-GraphPOSTRequest -scope "$($SharePointInfo.AdminUrl)/.default" -uri $DeleteUri -body (ConvertTo-Json -Depth 10 -InputObject $body) -tenantid $TenantFilter -extraHeaders $ExtraHeaders - - $SiteTypeMsg = if ($IsGroupConnected) { "group-connected" } else { "regular" } - $Results = "Successfully initiated deletion of $SiteTypeMsg SharePoint site with ID $SiteId, this process can take some time to complete in the background" - Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $Results -sev Info $StatusCode = [HttpStatusCode]::OK @@ -83,7 +99,7 @@ function Invoke-DeleteSharepointSite { # Associate values to output bindings return ([HttpResponseContext]@{ - StatusCode = $StatusCode - Body = @{ 'Results' = $Results } - }) + StatusCode = $StatusCode + Body = @{ 'Results' = $Results } + }) } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecBulkRemoveSharingLinks.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecBulkRemoveSharingLinks.ps1 new file mode 100644 index 0000000000000..a4608323d50ab --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecBulkRemoveSharingLinks.ps1 @@ -0,0 +1,104 @@ +function Invoke-ExecBulkRemoveSharingLinks { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Sharepoint.Site.ReadWrite + .DESCRIPTION + Revokes all sharing links / external direct grants on one site in bulk, sourced from + the SharePointSharingLinks reporting cache. Scope selects which classifications are + revoked: Anonymous (anyone links only), External (anonymous + external), or All + (every cached link on the site, including internal). Because the source is the + reporting cache, links created after the last sharing sync are not covered. + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + $TenantFilter = $Request.Body.tenantFilter + $SiteUrl = $Request.Body.SiteUrl + $Scope = $Request.Body.Scope.value ?? $Request.Body.Scope ?? 'Anonymous' + + $ScopeClassifications = @{ + 'Anonymous' = @('Anonymous') + 'External' = @('Anonymous', 'External') + 'All' = @('Anonymous', 'External', 'Internal') + } + + try { + if (-not $SiteUrl) { throw 'SiteUrl is required.' } + if (-not $ScopeClassifications.ContainsKey([string]$Scope)) { + throw "Invalid scope '$Scope'. Valid values: $($ScopeClassifications.Keys -join ', ')." + } + $Classifications = $ScopeClassifications[[string]$Scope] + + try { + $AllLinks = @(New-CIPPDbRequest -TenantFilter $TenantFilter -Type 'SharePointSharingLinks') + } catch { + throw "Could not read the sharing links cache: $($_.Exception.Message). Run a sharing report sync first." + } + $NormalizedSite = $SiteUrl.TrimEnd('/') + $Targets = @($AllLinks | Where-Object { + "$($_.siteUrl)".TrimEnd('/') -eq $NormalizedSite -and $_.classification -in $Classifications -and $_.driveId -and $_.itemId -and $_.permissionId + }) + + if ($Targets.Count -eq 0) { + $Results = "No cached $($Scope -eq 'All' ? '' : "$Scope ")sharing links found for $SiteUrl. Links created since the last sharing sync are not in the cache - run a sync from the Sharing Report page to refresh." + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message $Results -sev Info + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::OK + Body = @{ 'Results' = $Results } + }) + } + + $Revoked = [System.Collections.Generic.List[string]]::new() + $Failed = [System.Collections.Generic.List[string]]::new() + foreach ($Link in $Targets) { + try { + $null = New-GraphPostRequest -uri "https://graph.microsoft.com/v1.0/drives/$($Link.driveId)/items/$($Link.itemId)/permissions/$($Link.permissionId)" -tenantid $TenantFilter -type DELETE -asapp $true + $Revoked.Add("$($Link.fileName) ($($Link.classification))") + if ($Link.id) { + try { + Remove-CIPPDbItem -TenantFilter $TenantFilter -Type 'SharePointSharingLinks' -ItemId $Link.id + } catch { + Write-Information "Revoked link but could not update reporting cache row $($Link.id): $($_.Exception.Message)" + } + } + } catch { + # A 404 means the link was already gone; treat as revoked and clean the cache row. + if ($_.Exception.Message -match 'itemNotFound|404') { + $Revoked.Add("$($Link.fileName) (already removed)") + if ($Link.id) { + try { Remove-CIPPDbItem -TenantFilter $TenantFilter -Type 'SharePointSharingLinks' -ItemId $Link.id } catch {} + } + } else { + $Failed.Add("$($Link.fileName): $($_.Exception.Message)") + } + } + } + + $Messages = [System.Collections.Generic.List[string]]::new() + $Messages.Add("Revoked $($Revoked.Count) of $($Targets.Count) $Scope sharing link(s) on $SiteUrl.") + if ($Failed.Count -gt 0) { + $Messages.Add("Failed: $($Failed -join '; ')") + } + $Messages.Add('Note: links created since the last sharing report sync are not covered.') + $Results = $Messages -join ' ' + if ($Revoked.Count -eq 0 -and $Failed.Count -gt 0) { + throw $Results + } + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message $Results -sev Info + $StatusCode = [HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Results = "Failed to bulk revoke sharing links on $($SiteUrl): $($ErrorMessage.NormalizedError)" + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message $Results -sev Error -LogData $ErrorMessage + $StatusCode = [HttpStatusCode]::BadRequest + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @{ 'Results' = $Results } + }) +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecRemoveSiteUser.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecRemoveSiteUser.ps1 new file mode 100644 index 0000000000000..693e8e993b7ab --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecRemoveSiteUser.ps1 @@ -0,0 +1,65 @@ +function Invoke-ExecRemoveSiteUser { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Sharepoint.Site.ReadWrite + .DESCRIPTION + Removes a user from an entire SharePoint site: deleting the site user removes them + from every site group and direct permission grant at once. Uses the SharePoint REST + API with certificate authentication. Note this does not revoke sharing links the user + received by mail; use the sharing link actions for those. + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + $TenantFilter = $Request.Body.tenantFilter + $SiteUrl = $Request.Body.SiteUrl + # The picker supplies the SP claims login via addedFields; fall back to building it from the UPN. + $LoginName = $Request.Body.user.addedFields.LoginName ?? $Request.Body.user.value + $Label = $Request.Body.user.value ?? $LoginName + + try { + if (-not $SiteUrl) { throw 'SiteUrl is required.' } + if (-not $LoginName) { throw 'No user was selected.' } + if ($LoginName -notmatch '\|') { $LoginName = "i:0#.f|membership|$LoginName" } + + $SharePointInfo = Get-SharePointAdminLink -Public $false -tenantFilter $TenantFilter + $Scope = "$($SharePointInfo.SharePointUrl)/.default" + $JsonAccept = @{ Accept = 'application/json;odata=nometadata' } + $BaseUri = "$($SiteUrl.TrimEnd('/'))/_api" + + try { + $EnsureBody = ConvertTo-Json -Compress -InputObject @{ logonName = $LoginName } + $EnsuredUser = New-GraphPostRequest -uri "$BaseUri/web/ensureuser" -tenantid $TenantFilter -scope $Scope -type POST -body $EnsureBody -contentType 'application/json;odata=nometadata' -AddedHeaders $JsonAccept -UseCertificate -AsApp $true + } catch { + throw "Could not resolve $Label on the site (ensureuser): $($_.Exception.Message)" + } + if (-not $EnsuredUser.Id) { throw "Could not resolve $Label on the site." } + if ($EnsuredUser.IsSiteAdmin) { + throw "$Label is a site collection admin. Remove their admin permission first (Remove Site Admin action)." + } + + try { + $null = New-GraphPostRequest -uri "$BaseUri/web/siteusers/removebyid($($EnsuredUser.Id))" -tenantid $TenantFilter -scope $Scope -type POST -body '{}' -contentType 'application/json;odata=nometadata' -AddedHeaders $JsonAccept -UseCertificate -AsApp $true + } catch { + throw "Could not remove $Label from the site: $($_.Exception.Message)" + } + + $Results = "Successfully removed $Label from $SiteUrl (all site groups and direct permissions)." + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message $Results -sev Info + $StatusCode = [HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Results = "Failed to remove $Label from $($SiteUrl): $($ErrorMessage.NormalizedError)" + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message $Results -sev Error -LogData $ErrorMessage + $StatusCode = [HttpStatusCode]::BadRequest + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @{ 'Results' = $Results } + }) +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecRestoreDeletedSite.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecRestoreDeletedSite.ps1 new file mode 100644 index 0000000000000..88009fadf8184 --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecRestoreDeletedSite.ps1 @@ -0,0 +1,40 @@ +function Invoke-ExecRestoreDeletedSite { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Sharepoint.Site.ReadWrite + .DESCRIPTION + Restores a deleted SharePoint site from the tenant recycle bin via the SharePoint + admin CSOM API. + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + $TenantFilter = $Request.Body.tenantFilter + $SiteUrl = $Request.Body.SiteUrl ?? $Request.Body.Url + + try { + if (-not $SiteUrl) { throw 'SiteUrl is required.' } + $Operation = Restore-CIPPSPODeletedSite -TenantFilter $TenantFilter -SiteUrl $SiteUrl + $Results = if ($Operation -and -not $Operation.IsComplete) { + "Restore of $SiteUrl has started. Large sites can take a while to finish restoring." + } else { + "Successfully restored $SiteUrl from the tenant recycle bin." + } + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message $Results -sev Info + $StatusCode = [HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Results = "Failed to restore $($SiteUrl): $($ErrorMessage.NormalizedError)" + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message $Results -sev Error -LogData $ErrorMessage + $StatusCode = [HttpStatusCode]::BadRequest + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @{ 'Results' = $Results } + }) +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecRestoreRecycleBinItems.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecRestoreRecycleBinItems.ps1 new file mode 100644 index 0000000000000..cfad4b5705659 --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecRestoreRecycleBinItems.ps1 @@ -0,0 +1,52 @@ +function Invoke-ExecRestoreRecycleBinItems { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Sharepoint.SiteRecycleBin.ReadWrite + .DESCRIPTION + Restores one or more items from a SharePoint site's recycle bin via the SharePoint + REST API RestoreByIds method with certificate authentication. + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + $TenantFilter = $Request.Body.tenantFilter + $SiteUrl = $Request.Body.SiteUrl + $Ids = @($Request.Body.Ids) | Where-Object { $_ } + $ItemNames = @($Request.Body.ItemNames) | Where-Object { $_ } + + try { + if (-not $SiteUrl) { throw 'SiteUrl is required.' } + if ($Ids.Count -eq 0) { throw 'No recycle bin items were selected.' } + + $SharePointInfo = Get-SharePointAdminLink -Public $false -tenantFilter $TenantFilter + $Scope = "$($SharePointInfo.SharePointUrl)/.default" + $JsonAccept = @{ Accept = 'application/json;odata=nometadata' } + $BaseUri = "$($SiteUrl.TrimEnd('/'))/_api" + + $RestoreBody = ConvertTo-Json -Compress -Depth 5 -InputObject @{ ids = @($Ids) } + try { + $null = New-GraphPostRequest -uri "$BaseUri/site/RecycleBin/RestoreByIds" -tenantid $TenantFilter -scope $Scope -type POST -body $RestoreBody -contentType 'application/json;odata=nometadata' -AddedHeaders $JsonAccept -UseCertificate -AsApp $true + } catch { + throw "RestoreByIds failed: $($_.Exception.Message)" + } + + $Label = if ($ItemNames.Count -gt 0) { $ItemNames -join ', ' } else { "$($Ids.Count) item(s)" } + $Results = "Successfully restored $Label from the recycle bin of $SiteUrl." + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message $Results -sev Info + $StatusCode = [HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Results = "Failed to restore recycle bin items on $($SiteUrl): $($ErrorMessage.NormalizedError)" + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message $Results -sev Error -LogData $ErrorMessage + $StatusCode = [HttpStatusCode]::BadRequest + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @{ 'Results' = $Results } + }) +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecSetSiteProperties.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecSetSiteProperties.ps1 index 349c6b3ddf636..c8395de597814 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecSetSiteProperties.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecSetSiteProperties.ps1 @@ -90,13 +90,36 @@ function Invoke-ExecSetSiteProperties { throw 'No valid properties were provided to set.' } + # Group-connected sites only accept a small subset of tenant site properties; SPO + # rejects the whole request if any other property is included. Filter to the + # supported set and report what was skipped. + $Site = Get-CIPPSPOSite -TenantFilter $TenantFilter -SiteUrl $SiteUrl + $IsGroupSite = $Site.GroupId -and $Site.GroupId -notmatch '^0{8}-' + $Skipped = [System.Collections.Generic.List[string]]::new() + if ($IsGroupSite) { + $GroupSiteAllowed = @('SharingCapability', 'DefaultSharingLinkType', 'DefaultLinkPermission', 'LockState', 'StorageMaximumLevel', 'StorageWarningLevel') + foreach ($Key in @($Properties.Keys)) { + if ($Key -notin $GroupSiteAllowed) { + $Properties.Remove($Key) + $Skipped.Add($Key) + } + } + if ($Properties.Count -eq 0) { + throw "None of the selected properties can be changed on a group-connected site. Supported: $($GroupSiteAllowed -join ', ')." + } + } + $Response = Set-CIPPSPOSite -TenantFilter $TenantFilter -SiteUrl $SiteUrl -Properties $Properties $CsomError = ($Response | Where-Object { $_.ErrorInfo } | Select-Object -First 1).ErrorInfo.ErrorMessage if ($CsomError) { throw $CsomError } - $Results = "Successfully updated site properties for $($SiteUrl): $($Changes -join ', ')" + $AppliedChanges = $Changes | Where-Object { ($_ -split '=')[0] -in $Properties.Keys } + $Results = "Successfully updated site properties for $($SiteUrl): $($AppliedChanges -join ', ')" + if ($Skipped.Count -gt 0) { + $Results += " Skipped (not supported on group-connected sites): $($Skipped -join ', ')." + } Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message $Results -sev Info $StatusCode = [HttpStatusCode]::OK } catch { diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListDeletedSites.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListDeletedSites.ps1 new file mode 100644 index 0000000000000..5020b45d4b2f3 --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListDeletedSites.ps1 @@ -0,0 +1,49 @@ +function Invoke-ListDeletedSites { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Sharepoint.Site.Read + .DESCRIPTION + Lists deleted SharePoint sites still restorable from the tenant recycle bin, via the + SharePoint admin CSOM API. + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $TenantFilter = $Request.Query.tenantFilter + + # CSOM verbose JSON dates arrive as '/Date(year,month,day,h,m,s,ms)/' with a 0-based month. + function ConvertFrom-CsomDate($Value) { + if ("$Value" -match '/Date\((\d+),(\d+),(\d+),(\d+),(\d+),(\d+),(\d+)\)/') { + return ([datetime]::new([int]$Matches[1], ([int]$Matches[2] + 1), [int]$Matches[3], [int]$Matches[4], [int]$Matches[5], [int]$Matches[6], [System.DateTimeKind]::Utc)).ToString('yyyy-MM-ddTHH:mm:ssZ') + } + return $Value + } + + try { + $DeletedSites = Get-CIPPSPODeletedSites -TenantFilter $TenantFilter + $Body = @($DeletedSites | ForEach-Object { + [PSCustomObject]@{ + # Deleted-site CSOM entries carry no Title; derive a display name from the URL. + Name = ([uri]$_.Url).Segments[-1].TrimEnd('/') + Url = $_.Url + SiteId = $_.SiteId + Status = $_.Status + DeletionTime = ConvertFrom-CsomDate $_.DeletionTime + DaysRemaining = $_.DaysRemaining + StorageMaximumLevel = $_.StorageMaximumLevel + } + }) + $StatusCode = [HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Body = "Failed to list deleted sites: $($ErrorMessage.NormalizedError)" + $StatusCode = [HttpStatusCode]::BadRequest + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @{ 'Results' = $Body } + }) +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSiteMembers.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSiteMembers.ps1 index 095aa30ef1264..d1f2627b12d16 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSiteMembers.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSiteMembers.ps1 @@ -19,6 +19,13 @@ Function Invoke-ListSiteMembers { $TenantFilter = $Request.Query.tenantFilter $SiteId = $Request.Query.SiteId $SiteUrl = $Request.Query.SiteUrl + $Filter = $Request.Query.Filter + + # A SharePoint user entity is a guest/external identity when either guest flag is set or + # the claims login carries the external-user or spo-guest marker. + function Test-SPGuestUser($User) { + [bool]$User.IsShareByEmailGuestUser -or [bool]$User.IsEmailAuthenticationGuestUser -or $User.LoginName -match '(?i)#ext#|urn%3aspo%3aguest' + } try { if (-not $SiteUrl) { @@ -38,7 +45,7 @@ Function Invoke-ListSiteMembers { 'Visitors' = 'associatedvisitorgroup' } foreach ($Role in $RoleGroups.Keys) { - $Users = New-GraphGetRequest -uri "$BaseUri/web/$($RoleGroups[$Role])/users?`$select=Id,Title,Email,LoginName,PrincipalType" -tenantid $TenantFilter -scope $Scope -extraHeaders $JsonAccept -UseCertificate -AsApp $true + $Users = New-GraphGetRequest -uri "$BaseUri/web/$($RoleGroups[$Role])/users?`$select=Id,Title,Email,LoginName,PrincipalType,IsShareByEmailGuestUser,IsEmailAuthenticationGuestUser" -tenantid $TenantFilter -scope $Scope -extraHeaders $JsonAccept -UseCertificate -AsApp $true foreach ($User in $Users) { if ($User.LoginName -match 'federateddirectoryclaimprovider\|([0-9a-fA-F-]{36})(_o)?$') { # Group-connected site: the role group holds the backing M365 group as @@ -55,6 +62,7 @@ Function Invoke-ListSiteMembers { UserPrincipalName = $GroupMember.userPrincipalName Group = $Role Type = 'User (via M365 Group)' + IsGuest = ($GroupMember.userPrincipalName -match '(?i)#ext#') IsSiteAdmin = $false }) } @@ -67,6 +75,7 @@ Function Invoke-ListSiteMembers { UserPrincipalName = $null Group = $Role Type = 'M365 Group' + IsGuest = $false IsSiteAdmin = $false }) } @@ -84,6 +93,7 @@ Function Invoke-ListSiteMembers { UserPrincipalName = if ($User.PrincipalType -eq 1) { ($User.LoginName -split '\|')[-1] } else { $null } Group = $Role Type = $Type + IsGuest = (Test-SPGuestUser $User) IsSiteAdmin = $false }) } @@ -92,7 +102,7 @@ Function Invoke-ListSiteMembers { # Site collection admins: flag them on existing rows, add rows for admins that # are not in any role group. - $Admins = New-GraphGetRequest -uri "$BaseUri/web/siteusers?`$filter=IsSiteAdmin eq true&`$select=Id,Title,Email,LoginName,PrincipalType" -tenantid $TenantFilter -scope $Scope -extraHeaders $JsonAccept -UseCertificate -AsApp $true + $Admins = New-GraphGetRequest -uri "$BaseUri/web/siteusers?`$filter=IsSiteAdmin eq true&`$select=Id,Title,Email,LoginName,PrincipalType,IsShareByEmailGuestUser,IsEmailAuthenticationGuestUser" -tenantid $TenantFilter -scope $Scope -extraHeaders $JsonAccept -UseCertificate -AsApp $true foreach ($Admin in $Admins) { $Existing = @($Members | Where-Object { $_.LoginName -eq $Admin.LoginName }) if ($Existing.Count -gt 0) { @@ -104,6 +114,7 @@ Function Invoke-ListSiteMembers { LoginName = $Admin.LoginName UserPrincipalName = if ($Admin.PrincipalType -eq 1) { ($Admin.LoginName -split '\|')[-1] } else { $null } Group = 'Site Admins' + IsGuest = (Test-SPGuestUser $Admin) Type = if ($Admin.PrincipalType -eq 1) { 'User' } elseif ($Admin.LoginName -match 'federateddirectoryclaimprovider') { 'M365 Group' } else { 'Other' } IsSiteAdmin = $true }) @@ -127,12 +138,17 @@ Function Invoke-ListSiteMembers { UserPrincipalName = if ($Item.fields.UserName) { ($Item.fields.UserName -split '\|')[-1] } else { $null } Group = 'Site Users' Type = 'User' + IsGuest = ($Item.fields.UserName -match '(?i)#ext#') IsSiteAdmin = [bool]$Item.fields.IsSiteAdmin }) } } } + if ($Filter -eq 'External') { + $Members = @($Members | Where-Object { $_.IsGuest }) + } + $StatusCode = [HttpStatusCode]::OK $Body = @($Members) } catch { diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSiteRecycleBin.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSiteRecycleBin.ps1 new file mode 100644 index 0000000000000..b0530cb6d2ad8 --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSiteRecycleBin.ps1 @@ -0,0 +1,54 @@ +function Invoke-ListSiteRecycleBin { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Sharepoint.SiteRecycleBin.Read + .DESCRIPTION + Lists the contents of a SharePoint site's recycle bin (first and second stage) via the + SharePoint REST API with certificate authentication. + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $TenantFilter = $Request.Query.tenantFilter + $SiteUrl = $Request.Query.SiteUrl + + $ItemTypeNames = @{ 1 = 'File'; 2 = 'File Version'; 3 = 'List Item'; 4 = 'List'; 5 = 'Folder'; 6 = 'Folder'; 7 = 'Attachment'; 8 = 'List Item Version'; 10 = 'Web' } + $ItemStateNames = @{ 1 = 'First Stage'; 2 = 'Second Stage' } + + try { + if (-not $SiteUrl) { throw 'SiteUrl is required.' } + + $SharePointInfo = Get-SharePointAdminLink -Public $false -tenantFilter $TenantFilter + $Scope = "$($SharePointInfo.SharePointUrl)/.default" + $JsonAccept = @{ Accept = 'application/json;odata=nometadata' } + $BaseUri = "$($SiteUrl.TrimEnd('/'))/_api" + + $Items = New-GraphGetRequest -uri "$BaseUri/site/RecycleBin?`$top=500&`$orderby=DeletedDate desc" -tenantid $TenantFilter -scope $Scope -extraHeaders $JsonAccept -UseCertificate -AsApp $true + + $Body = @($Items | ForEach-Object { + [PSCustomObject]@{ + Id = $_.Id + Title = $_.Title + LeafName = $_.LeafName + DirName = $_.DirName + ItemType = $ItemTypeNames[[int]$_.ItemType] ?? $_.ItemType + ItemState = $ItemStateNames[[int]$_.ItemState] ?? $_.ItemState + Size = $_.Size + DeletedByName = $_.DeletedByName + DeletedDate = $_.DeletedDate + } + }) + $StatusCode = [HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Body = "Failed to list the recycle bin for $($SiteUrl): $($ErrorMessage.NormalizedError)" + $StatusCode = [HttpStatusCode]::BadRequest + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @{ 'Results' = $Body } + }) +} From c1347950045a0c106e79a83ba975728559e9f8fb Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Fri, 10 Jul 2026 14:24:24 +0800 Subject: [PATCH 162/170] Fixes for mem cache performance --- Shared/CIPPSharp/CIPPTestDataCache.cs | 179 +++++++++++++++++++------- Shared/CIPPSharp/bin/CIPPSharp.dll | Bin 47104 -> 48640 bytes 2 files changed, 131 insertions(+), 48 deletions(-) diff --git a/Shared/CIPPSharp/CIPPTestDataCache.cs b/Shared/CIPPSharp/CIPPTestDataCache.cs index d92db61483e75..da2538ec5ba2d 100644 --- a/Shared/CIPPSharp/CIPPTestDataCache.cs +++ b/Shared/CIPPSharp/CIPPTestDataCache.cs @@ -4,7 +4,6 @@ using System.Collections.Generic; using System.Linq; using System.Reflection; -using System.Text.Json; using System.Threading; using System.Threading.Tasks; @@ -14,16 +13,32 @@ namespace CIPP /// Host-scoped, thread-safe LRU cache for test data lookups. The DLL is /// loaded once per Azure Functions host, so every PowerShell worker /// process on that host shares this exact instance. Bounded by both a - /// byte-size cap (default 100 MB) and a short TTL (default 5 minutes) + /// byte-size cap (default 300 MB) and a short TTL (default 5 minutes) /// so that test suites running against a single tenant get fast cache /// hits without accumulating stale Gen2 roots that cause GC thrashing. /// public static class TestDataCache { // ── Configuration ── - private static long _maxBytes = 100L * 1024 * 1024; // 100 MB default + // Defaults are 300 MB / 5 min. Both are overridable per deployment via + // Azure Functions app settings (environment variables) resolved once at + // load, or programmatically via Configure() for tests. The cap now maps to + // real managed-heap bytes (see EstimateValueSize), so 300 MB means ~300 MB. + private static long _maxBytes = 300L * 1024 * 1024; // 300 MB default private static TimeSpan _ttl = TimeSpan.FromMinutes(5); + static TestDataCache() + { + try + { + if (long.TryParse(Environment.GetEnvironmentVariable("CIPPTestCacheMaxMB"), out var maxMB) && maxMB > 0) + _maxBytes = maxMB * 1024L * 1024L; + if (int.TryParse(Environment.GetEnvironmentVariable("CIPPTestCacheTtlSeconds"), out var ttlSec) && ttlSec > 0) + _ttl = TimeSpan.FromSeconds(ttlSec); + } + catch { /* keep defaults if app settings are unreadable */ } + } + // ── State ── private static readonly ConcurrentDictionary _cache = new(); private static readonly LinkedList _lruOrder = new(); // head = oldest @@ -56,7 +71,7 @@ public CacheEntry(object? value, long sizeBytes, DateTime expiresUtc) } /// Configure the cache limits. Call before first use or between test runs. - public static void Configure(long maxBytes = 100L * 1024 * 1024, int ttlSeconds = 300) + public static void Configure(long maxBytes = 300L * 1024 * 1024, int ttlSeconds = 300) { _maxBytes = maxBytes; _ttl = TimeSpan.FromSeconds(ttlSeconds); @@ -258,8 +273,31 @@ private static void TryFireBackgroundSweep() private static PropertyInfo? s_psPropsProp; // PSObject.Properties private static PropertyInfo? s_psPropName; // PSPropertyInfo.Name private static PropertyInfo? s_psPropValue; // PSPropertyInfo.Value - private const int SampleSize = 5; - private const int MaxDepth = 4; + // ── Managed-heap size model (x64 CoreCLR) ── + // The cache stores *parsed PSObject graphs*, whose live heap footprint is + // many times their JSON text size (measured ≈8× on real Graph data). Sizing + // by JSON serialization therefore under-counts by ~8× and lets the byte cap + // balloon far past its nominal limit. Instead we walk the object graph and + // sum approximate per-object heap costs. Constants below are calibrated + // against real GC heap growth (see tools/measure-testcache.ps1). + private const int ObjectHeader = 16; // sync block + method table ptr + private const int RefSlot = 8; // one reference (array/field slot) + private const int StringBaseOverhead = 22; // header(16)+length(4)+terminator(2) + private const int BoxedPrimitive = 24; // header(16)+<=8 payload, aligned + private const int BoxedLarge = 32; // header(16)+16 payload (decimal/Guid) + private const int PSObjectBase = 192; // PSObject wrapper + base + member collections + private const int PSNotePropertyOverhead = 132; // PSNoteProperty + name-lookup entry (excl. name string + value) + private const int DictBase = 80; // buckets + entries baseline + private const int DictEntryOverhead = 48; // per-entry slot incl. capacity slack (excl. key + value) + private const int CollectionBase = 56; // list/array object + backing array baseline + + // Bound the walk on pathological payloads: full-walk small collections for + // accuracy, but stride-sample very large ones so a 100k-item array can't + // turn a single Set() into a multi-second reflection storm. + private const int LargeCollectionThreshold = 512; + private const int LargeCollectionSamples = 256; + private const int MaxDepth = 32; + private const long NodeBudget = 2_000_000; // hard ceiling on nodes visited per Set() private static void EnsurePSResolved() { @@ -289,82 +327,127 @@ private static void EnsurePSResolved() } } + private static long Align8(long bytes) => (bytes + 7) & ~7L; + /// - /// Estimate the serialized size of a cached value. For large collections, - /// samples a few items and extrapolates. Handles PSObject by unwrapping - /// NoteProperties into dictionaries via reflection. + /// Estimate the live managed-heap footprint of a cached value by walking the + /// object graph and summing approximate per-object costs. This tracks real + /// memory (parsed PSObjects, boxed primitives, strings, dictionaries and + /// collections) rather than JSON text size, so the byte cap corresponds to + /// actual process memory. is accepted for call + /// compatibility but the walk recomputes counts itself. /// private static long EstimateValueSize(object? value, int itemCount) { if (value == null) return 0; EnsurePSResolved(); - try { - // Large collection: sample a few items, extrapolate - if (itemCount > SampleSize && value is IEnumerable enumerable) - { - var sample = new List(); - foreach (var item in enumerable) - { - sample.Add(Unwrap(item, 0)); - if (sample.Count >= SampleSize) break; - } - if (sample.Count == 0) return 0; - var sampleBytes = JsonSerializer.SerializeToUtf8Bytes(sample).LongLength; - return sampleBytes * itemCount / sample.Count; - } - - // Small collection or single value: unwrap everything - var unwrapped = Unwrap(value, 0); - return JsonSerializer.SerializeToUtf8Bytes(unwrapped).LongLength; + long budget = NodeBudget; + return SizeOf(value, 0, ref budget); } catch { return 0; } } /// - /// Recursively unwrap PSObject → Dictionary and IEnumerable → List - /// so System.Text.Json can serialize them. + /// Recursively sum the approximate managed-heap bytes of + /// and everything it owns. Returns the object's own heap size; the reference + /// slot that points at it is accounted for by the owning object/collection. /// - private static object? Unwrap(object? value, int depth) + private static long SizeOf(object? value, int depth, ref long budget) { - if (value == null || depth > MaxDepth) return value; + if (value == null) return 0; + if (--budget <= 0 || depth > MaxDepth) return 0; - // PSObject → extract NoteProperties as Dictionary + switch (value) + { + case string s: + return Align8(StringBaseOverhead + 2L * s.Length); + case bool: case byte: case sbyte: case char: + case short: case ushort: case int: case uint: + case long: case ulong: case float: case double: + case DateTime: case DateTimeOffset: case TimeSpan: case Enum: + return BoxedPrimitive; + case decimal: case Guid: + return BoxedLarge; + case byte[] bytes: + return Align8(ObjectHeader + RefSlot + bytes.LongLength); + } + + // PSObject → wrapper cost + each NoteProperty (name string + value + overhead) if (s_psObjectType != null && s_psObjectType.IsInstanceOfType(value)) - return UnwrapPSObject(value, depth); + return SizeOfPSObject(value, depth, ref budget); + + // Dictionary / Hashtable → base + per-entry overhead + keys + values + if (value is IDictionary dict) + { + long total = ObjectHeader + DictBase; + foreach (DictionaryEntry de in dict) + { + total += DictEntryOverhead; + total += SizeOf(de.Key, depth + 1, ref budget); + total += SizeOf(de.Value, depth + 1, ref budget); + if (budget <= 0) break; + } + return total; + } - // Collection → unwrap each element - if (value is IEnumerable enumerable && value is not string && value is not byte[]) + // Other collections (object[], List, …). Stride-sample very large + // ones to bound the walk; full-walk the rest for accuracy. + if (value is IEnumerable enumerable && value is not string) { - var list = new List(); + int count = value is ICollection col ? col.Count : -1; + + if (count > LargeCollectionThreshold && value is IList list) + { + long step = count / (long)LargeCollectionSamples; + if (step < 1) step = 1; + long sampled = 0, sampledBytes = 0; + for (long i = 0; i < count && sampled < LargeCollectionSamples; i += step) + { + sampledBytes += SizeOf(list[(int)i], depth + 1, ref budget); + sampled++; + if (budget <= 0) break; + } + long avgItem = sampled > 0 ? sampledBytes / sampled : 0; + // backing array slots + extrapolated element payloads + return ObjectHeader + CollectionBase + count * (RefSlot + avgItem); + } + + long total = ObjectHeader + CollectionBase; foreach (var item in enumerable) - list.Add(Unwrap(item, depth + 1)); - return list; + { + total += RefSlot; + total += SizeOf(item, depth + 1, ref budget); + if (budget <= 0) break; + } + return total; } - return value; + // Unknown reference type: header plus a small nominal for its fields. + return ObjectHeader + 32; } - private static Dictionary UnwrapPSObject(object psObj, int depth) + private static long SizeOfPSObject(object psObj, int depth, ref long budget) { - var dict = new Dictionary(); + long total = PSObjectBase; if (s_psPropsProp == null || s_psPropName == null || s_psPropValue == null) - return dict; - - if (s_psPropsProp.GetValue(psObj) is not IEnumerable props) return dict; + return total; + if (s_psPropsProp.GetValue(psObj) is not IEnumerable props) return total; foreach (var prop in props) { + if (--budget <= 0) break; + total += PSNotePropertyOverhead; try { - var name = s_psPropName.GetValue(prop)?.ToString(); - if (name != null) - dict[name] = Unwrap(s_psPropValue.GetValue(prop), depth + 1); + if (s_psPropName.GetValue(prop) is string name) + total += Align8(StringBaseOverhead + 2L * name.Length); + total += SizeOf(s_psPropValue.GetValue(prop), depth + 1, ref budget); } catch { /* skip properties that throw on access */ } } - return dict; + return total; } /// diff --git a/Shared/CIPPSharp/bin/CIPPSharp.dll b/Shared/CIPPSharp/bin/CIPPSharp.dll index 0817a1537960420061ec40542e39e7558edd0819..c242eca31f4571e538cfb150b451b87035b8867d 100644 GIT binary patch delta 22184 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 z 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 Wdv`bCt01CR&+0Pv{!QCh<@`VU+T-H@ From c6e67b2beb03501f6c28d661e43567bc58d9c403 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Fri, 10 Jul 2026 12:00:36 +0200 Subject: [PATCH 163/170] tag update --- .../CIPPCore/Public/Add-CIPPGroupMember.ps1 | 53 ++++-- .../Public/Get-CIPPAuthentication.ps1 | 30 ++- .../Public/Get-CIPPSPODeletedSites.ps1 | 60 ++++++ Modules/CIPPCore/Public/Get-CIPPSPOSite.ps1 | 24 ++- .../Public/Remove-CIPPGroupMember.ps1 | 54 ++++-- .../Public/Restore-CIPPSPODeletedSite.ps1 | 46 +++++ Modules/CIPPCore/Public/Set-CIPPSPOSite.ps1 | 11 +- .../Administration/Users/Invoke-ListUsers.ps1 | 2 +- .../Invoke-DeleteSharepointSite.ps1 | 62 +++--- .../Invoke-ExecBulkRemoveSharingLinks.ps1 | 104 ++++++++++ .../Invoke-ExecRemoveSiteUser.ps1 | 65 +++++++ .../Invoke-ExecRestoreDeletedSite.ps1 | 40 ++++ .../Invoke-ExecRestoreRecycleBinItems.ps1 | 52 +++++ .../Invoke-ExecSetSharePointMember.ps1 | 114 +++++++++-- .../Invoke-ExecSetSiteProperties.ps1 | 136 +++++++++++++ .../Invoke-ListDeletedSites.ps1 | 49 +++++ .../Invoke-ListSiteMembers.ps1 | 153 +++++++++++++-- .../Invoke-ListSiteProperties.ps1 | 63 ++++++ .../Invoke-ListSiteRecycleBin.ps1 | 54 ++++++ .../Invoke-ExecUpdateDriftDeviation.ps1 | 137 +++++++++----- Shared/CIPPSharp/CIPPTestDataCache.cs | 179 +++++++++++++----- 21 files changed, 1308 insertions(+), 180 deletions(-) create mode 100644 Modules/CIPPCore/Public/Get-CIPPSPODeletedSites.ps1 create mode 100644 Modules/CIPPCore/Public/Restore-CIPPSPODeletedSite.ps1 create mode 100644 Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecBulkRemoveSharingLinks.ps1 create mode 100644 Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecRemoveSiteUser.ps1 create mode 100644 Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecRestoreDeletedSite.ps1 create mode 100644 Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecRestoreRecycleBinItems.ps1 create mode 100644 Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecSetSiteProperties.ps1 create mode 100644 Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListDeletedSites.ps1 create mode 100644 Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSiteProperties.ps1 create mode 100644 Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSiteRecycleBin.ps1 diff --git a/Modules/CIPPCore/Public/Add-CIPPGroupMember.ps1 b/Modules/CIPPCore/Public/Add-CIPPGroupMember.ps1 index 3a5ea194fe185..e5f94558e34e6 100644 --- a/Modules/CIPPCore/Public/Add-CIPPGroupMember.ps1 +++ b/Modules/CIPPCore/Public/Add-CIPPGroupMember.ps1 @@ -34,16 +34,29 @@ function Add-CIPPGroupMember { [string]$APIName = 'Add Group Member' ) try { - if ($Member -like '*#EXT#*') { $Member = [System.Web.HttpUtility]::UrlEncode($Member) } $ODataBindString = 'https://graph.microsoft.com/v1.0/directoryObjects/{0}' - $Requests = foreach ($m in $Member) { + $Requests = @( + foreach ($m in $Member) { + if ($m -like '*#EXT#*') { $m = [System.Web.HttpUtility]::UrlEncode($m) } + @{ + id = "users-$m" + url = "users/$($m)?`$select=id,userPrincipalName" + method = 'GET' + } + } @{ - id = $m - url = "users/$($m)?`$select=id,userPrincipalName" + id = 'group' + url = "groups/$($GroupId)?`$select=id,displayName" method = 'GET' } - } - $Users = New-GraphBulkRequest -Requests @($Requests) -tenantid $TenantFilter + ) + $BulkResults = New-GraphBulkRequest -Requests @($Requests) -tenantid $TenantFilter + $Users = @($BulkResults | Where-Object { $_.id -like 'users-*' }) + # Group display name for logging; falls back to the id if the lookup failed + # (e.g. the group was addressed by mail rather than GUID). + $GroupName = ($BulkResults | Where-Object { $_.id -eq 'group' }).body.displayName ?? $GroupId + $SuccessfulUsers = [System.Collections.Generic.List[string]]::new() + $FailedUsers = [System.Collections.Generic.List[string]]::new() if ($GroupType -eq 'Distribution list' -or $GroupType -eq 'Mail-Enabled Security') { $ExoBulkRequests = [System.Collections.Generic.List[object]]::new() @@ -58,7 +71,7 @@ function Add-CIPPGroupMember { } }) $ExoLogs.Add(@{ - message = "Added member $($User.body.userPrincipalName) to $($GroupId) group" + message = "Added member $($User.body.userPrincipalName) to group $($GroupName)" target = $User.body.userPrincipalName }) } @@ -76,6 +89,7 @@ function Add-CIPPGroupMember { $ExoError = $LastError | Where-Object { $ExoLog.target -in $_.target -and $_.error } if (!$LastError -or ($LastError.error -and $LastError.target -notcontains $ExoLog.target)) { Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $ExoLog.message -Sev 'Info' + $SuccessfulUsers.Add($ExoLog.target) } } } @@ -91,25 +105,36 @@ function Add-CIPPGroupMember { } } $AddResults = New-GraphBulkRequest -tenantid $TenantFilter -Requests @($AddRequests) - $SuccessfulUsers = [system.collections.generic.list[string]]::new() foreach ($Result in $AddResults) { + $UserPrincipalName = ($Users | Where-Object { $_.body.id -eq $Result.id }).body.userPrincipalName if ($Result.status -lt 200 -or $Result.status -gt 299) { - $FailedUsername = $Users | Where-Object { $_.body.id -eq $Result.id } | Select-Object -ExpandProperty body | Select-Object -ExpandProperty userPrincipalName - Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Failed to add member $($FailedUsername): $($Result.body.error.message)" -Sev 'Error' + # Select-Object -First 1: Get-NormalizedError can return multiple strings + # when a message matches more than one of its translation patterns. + $ErrorText = Get-NormalizedError -message ($Result.body.error.message ?? "Request failed with status $($Result.status)") | Select-Object -First 1 + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Failed to add member $UserPrincipalName to group $($GroupName): $ErrorText" -Sev 'Error' + $FailedUsers.Add("$UserPrincipalName ($ErrorText)") } else { - $UserPrincipalName = $Users | Where-Object { $_.body.id -eq $Result.id } | Select-Object -ExpandProperty body | Select-Object -ExpandProperty userPrincipalName $SuccessfulUsers.Add($UserPrincipalName) } } } - $UserList = ($SuccessfulUsers -join ', ') - $Results = "Successfully added user $UserList to $($GroupId)." + $Messages = [System.Collections.Generic.List[string]]::new() + if ($SuccessfulUsers.Count -gt 0) { + $Messages.Add("Successfully added user $($SuccessfulUsers -join ', ') to group $($GroupName).") + } + if ($FailedUsers.Count -gt 0) { + $Messages.Add("Failed to add $($FailedUsers -join '; ').") + } + $Results = $Messages -join ' ' + if ($SuccessfulUsers.Count -eq 0 -and $FailedUsers.Count -gt 0) { + throw $Results + } Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $Results -Sev 'Info' return $Results } catch { $ErrorMessage = Get-CippException -Exception $_ $UserList = if ($Users) { ($Users.body.userPrincipalName -join ', ') } else { ($Member -join ', ') } - $Results = "Failed to add user $UserList to $($GroupId) - $($ErrorMessage.NormalizedError)" + $Results = "Failed to add user $UserList to group $($GroupName ?? $GroupId) - $($ErrorMessage.NormalizedError)" Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $Results -Sev 'error' -LogData $ErrorMessage throw $Results } diff --git a/Modules/CIPPCore/Public/Get-CIPPAuthentication.ps1 b/Modules/CIPPCore/Public/Get-CIPPAuthentication.ps1 index 3a1ef42e624a2..348d103e911a1 100644 --- a/Modules/CIPPCore/Public/Get-CIPPAuthentication.ps1 +++ b/Modules/CIPPCore/Public/Get-CIPPAuthentication.ps1 @@ -26,24 +26,40 @@ function Get-CIPPAuthentication { Set-Item -Path env:$_ -Value (Get-CippKeyVaultSecret -VaultName $keyvaultname -Name $_ -AsPlainText -ErrorAction Stop) -Force } } - # Preload the SAM certificate PFX alongside the other credentials. Non-fatal: the - # certificate does not exist until first bootstrap (setup wizard or weekly timer). + # Set before certificate handling: Update-CIPPSAMCertificate goes through + # Get-GraphToken, which re-enters this function when SetFromProfile is unset + $env:SetFromProfile = $true + + # Preload the SAM certificate PFX alongside the other credentials, provisioning it + # when it does not exist yet. Non-fatal: auth must succeed even when certificate + # handling fails; the weekly token update retries provisioning. try { if ($env:AzureWebJobsStorage -eq 'UseDevelopmentStorage=true' -or $env:NonLocalHostAzurite -eq 'true') { if ($Secret.SAMCertificate) { $env:SAMCertificate = $Secret.SAMCertificate } } else { - $SAMCertificate = Get-CippKeyVaultSecret -VaultName $keyvaultname -Name 'SAMCertificate' -AsPlainText -ErrorAction Stop - if ($SAMCertificate) { - $env:SAMCertificate = $SAMCertificate + try { + $SAMCertificate = Get-CippKeyVaultSecret -VaultName $keyvaultname -Name 'SAMCertificate' -AsPlainText -ErrorAction Stop + if ($SAMCertificate) { + $env:SAMCertificate = $SAMCertificate + } + } catch { + Write-Information "SAM certificate not found in storage: $($_.Exception.Message)" } } + + if (-not $env:SAMCertificate) { + # First run on this instance: provision the certificate now. + # Set-CIPPSAMCertificate refreshes $env:SAMCertificate on success. + Write-Information 'No SAM certificate found, provisioning one now' + $CertResult = Update-CIPPSAMCertificate -ErrorAction Stop + Write-LogMessage -message "Provisioned SAM certificate during authentication load. Thumbprint: $($CertResult.Thumbprint), storage mode: $($CertResult.StorageMode)" -Sev 'Info' -API 'CIPP Authentication' + } } catch { - Write-Information "SAM certificate not preloaded (not created yet?): $($_.Exception.Message)" + Write-LogMessage -message 'Could not preload or provision the SAM certificate. It will be retried by the weekly token update.' -Sev 'Warning' -API 'CIPP Authentication' -LogData (Get-CippException -Exception $_) } - $env:SetFromProfile = $true Write-LogMessage -message 'Reloaded authentication data from KeyVault' -Sev 'debug' -API 'CIPP Authentication' return $true diff --git a/Modules/CIPPCore/Public/Get-CIPPSPODeletedSites.ps1 b/Modules/CIPPCore/Public/Get-CIPPSPODeletedSites.ps1 new file mode 100644 index 0000000000000..696adeb0ec1d3 --- /dev/null +++ b/Modules/CIPPCore/Public/Get-CIPPSPODeletedSites.ps1 @@ -0,0 +1,60 @@ +function Get-CIPPSPODeletedSites { + <# + .SYNOPSIS + List deleted SharePoint sites (tenant recycle bin) via CSOM + + .DESCRIPTION + Retrieves the deleted site collections still restorable from the SharePoint tenant recycle + bin using the CSOM GetDeletedSitePropertiesFromSharePoint method, following the same paging + pattern as Get-CIPPSPOSite. + + .PARAMETER TenantFilter + Tenant to query + + .EXAMPLE + Get-CIPPSPODeletedSites -TenantFilter 'contoso.onmicrosoft.com' + + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter + ) + + $SharePointInfo = Get-SharePointAdminLink -Public $false -tenantFilter $TenantFilter + $AdminUrl = $SharePointInfo.AdminUrl + $AdditionalHeaders = @{ 'Accept' = 'application/json;odata=verbose' } + + $AllSites = [System.Collections.Generic.List[object]]::new() + $StartIndex = $null + + do { + $StartIndexParameter = if ($null -ne $StartIndex) { + "$([System.Security.SecurityElement]::Escape($StartIndex))" + } else { + '' + } + $XML = @" +$StartIndexParameter +"@ + + $Results = New-GraphPostRequest -scope "$AdminUrl/.default" -tenantid $TenantFilter -Uri "$AdminUrl/_vti_bin/client.svc/ProcessQuery" -Type POST -Body $XML -ContentType 'text/xml' -AddedHeaders $AdditionalHeaders + + $CsomError = ($Results | Where-Object { $_.ErrorInfo } | Select-Object -First 1).ErrorInfo.ErrorMessage + if ($CsomError) { throw $CsomError } + + $SiteCollection = $Results | Where-Object { $_._Child_Items_ } + if ($SiteCollection) { + foreach ($Site in $SiteCollection._Child_Items_) { + [void]$AllSites.Add($Site) + } + $StartIndex = $SiteCollection.NextStartIndexFromSharePoint + } else { + $StartIndex = $null + } + } while ($StartIndex) + + return $AllSites +} diff --git a/Modules/CIPPCore/Public/Get-CIPPSPOSite.ps1 b/Modules/CIPPCore/Public/Get-CIPPSPOSite.ps1 index 3f4a118c1f9ee..8f35bca98ef3a 100644 --- a/Modules/CIPPCore/Public/Get-CIPPSPOSite.ps1 +++ b/Modules/CIPPCore/Public/Get-CIPPSPOSite.ps1 @@ -10,9 +10,16 @@ function Get-CIPPSPOSite { .PARAMETER TenantFilter Tenant to query + .PARAMETER SiteUrl + When provided, fetches the properties of this single site (CSOM GetSitePropertiesByUrl) + instead of enumerating every site in the tenant. + .EXAMPLE Get-CIPPSPOSite -TenantFilter 'contoso.onmicrosoft.com' + .EXAMPLE + Get-CIPPSPOSite -TenantFilter 'contoso.onmicrosoft.com' -SiteUrl 'https://contoso.sharepoint.com/sites/MySite' + .FUNCTIONALITY Internal @@ -20,12 +27,27 @@ function Get-CIPPSPOSite { [CmdletBinding()] param( [Parameter(Mandatory = $true)] - [string]$TenantFilter + [string]$TenantFilter, + [string]$SiteUrl ) $SharePointInfo = Get-SharePointAdminLink -Public $false -tenantFilter $TenantFilter $AdminUrl = $SharePointInfo.AdminUrl + if ($SiteUrl) { + # Single-site fast path: Tenant Constructor -> GetSitePropertiesByUrl -> Query all properties + $XML = @" +$([System.Security.SecurityElement]::Escape($SiteUrl))true +"@ + $AdditionalHeaders = @{ 'Accept' = 'application/json;odata=verbose' } + $Results = New-GraphPostRequest -scope "$AdminUrl/.default" -tenantid $TenantFilter -Uri "$AdminUrl/_vti_bin/client.svc/ProcessQuery" -Type POST -Body $XML -ContentType 'text/xml' -AddedHeaders $AdditionalHeaders + $Site = $Results | Where-Object { $_._ObjectType_ -match 'SiteProperties' } | Select-Object -First 1 + if (-not $Site) { + throw "Could not retrieve site properties for $SiteUrl" + } + return $Site + } + $XML = @' false '@ diff --git a/Modules/CIPPCore/Public/Remove-CIPPGroupMember.ps1 b/Modules/CIPPCore/Public/Remove-CIPPGroupMember.ps1 index 1880d4c4960f5..c4f395369f1f5 100644 --- a/Modules/CIPPCore/Public/Remove-CIPPGroupMember.ps1 +++ b/Modules/CIPPCore/Public/Remove-CIPPGroupMember.ps1 @@ -52,15 +52,28 @@ function Remove-CIPPGroupMember { ) try { - $Requests = foreach ($m in $Member) { - if ($m -like '*#EXT#*') { $m = [System.Web.HttpUtility]::UrlEncode($m) } + $Requests = @( + foreach ($m in $Member) { + if ($m -like '*#EXT#*') { $m = [System.Web.HttpUtility]::UrlEncode($m) } + @{ + id = "users-$m" + url = "users/$($m)?`$select=id,userPrincipalName" + method = 'GET' + } + } @{ - id = $m - url = "users/$($m)?`$select=id,userPrincipalName" + id = 'group' + url = "groups/$($GroupId)?`$select=id,displayName" method = 'GET' } - } - $Users = New-GraphBulkRequest -Requests @($Requests) -tenantid $TenantFilter + ) + $BulkResults = New-GraphBulkRequest -Requests @($Requests) -tenantid $TenantFilter + $Users = @($BulkResults | Where-Object { $_.id -like 'users-*' }) + # Group display name for logging; falls back to the id if the lookup failed + # (e.g. the group was addressed by mail rather than GUID). + $GroupName = ($BulkResults | Where-Object { $_.id -eq 'group' }).body.displayName ?? $GroupId + $SuccessfulUsers = [System.Collections.Generic.List[string]]::new() + $FailedUsers = [System.Collections.Generic.List[string]]::new() if ($GroupType -eq 'Distribution list' -or $GroupType -eq 'Mail-Enabled Security') { $ExoBulkRequests = [System.Collections.Generic.List[object]]::new() @@ -75,7 +88,7 @@ function Remove-CIPPGroupMember { } }) $ExoLogs.Add(@{ - message = "Removed member $($User.body.userPrincipalName) from $($GroupId) group" + message = "Removed member $($User.body.userPrincipalName) from group $($GroupName)" target = $User.body.userPrincipalName }) } @@ -93,6 +106,7 @@ function Remove-CIPPGroupMember { $ExoError = $LastError | Where-Object { $ExoLog.target -in $_.target -and $_.error } if (!$LastError -or ($LastError.error -and $LastError.target -notcontains $ExoLog.target)) { Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $ExoLog.message -Sev 'Info' + $SuccessfulUsers.Add($ExoLog.target) } } } @@ -106,20 +120,36 @@ function Remove-CIPPGroupMember { } $RemovalResults = New-GraphBulkRequest -tenantid $TenantFilter -Requests @($RemovalRequests) foreach ($Result in $RemovalResults) { - if ($Result.status -ne 204) { - throw "Failed to remove member $($Result.id): $($Result.body.error.message)" + $UserPrincipalName = ($Users | Where-Object { $_.body.id -eq $Result.id }).body.userPrincipalName + if ($Result.status -lt 200 -or $Result.status -gt 299) { + # Select-Object -First 1: Get-NormalizedError can return multiple strings + # when a message matches more than one of its translation patterns. + $ErrorText = Get-NormalizedError -message ($Result.body.error.message ?? "Request failed with status $($Result.status)") | Select-Object -First 1 + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Failed to remove member $UserPrincipalName from group $($GroupName): $ErrorText" -Sev 'Error' + $FailedUsers.Add("$UserPrincipalName ($ErrorText)") + } else { + $SuccessfulUsers.Add($UserPrincipalName) } } } - $UserList = ($Users.body.userPrincipalName -join ', ') - $Results = "Successfully removed user $UserList from $($GroupId)." + $Messages = [System.Collections.Generic.List[string]]::new() + if ($SuccessfulUsers.Count -gt 0) { + $Messages.Add("Successfully removed user $($SuccessfulUsers -join ', ') from group $($GroupName).") + } + if ($FailedUsers.Count -gt 0) { + $Messages.Add("Failed to remove $($FailedUsers -join '; ').") + } + $Results = $Messages -join ' ' + if ($SuccessfulUsers.Count -eq 0 -and $FailedUsers.Count -gt 0) { + throw $Results + } Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $Results -Sev Info return $Results } catch { $ErrorMessage = Get-CippException -Exception $_ $UserList = if ($Users) { ($Users.body.userPrincipalName -join ', ') } else { ($Member -join ', ') } - $Results = "Failed to remove user $UserList from $($GroupId): $($ErrorMessage.NormalizedError)" + $Results = "Failed to remove user $UserList from group $($GroupName ?? $GroupId): $($ErrorMessage.NormalizedError)" Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $Results -Sev Error -LogData $ErrorMessage throw $Results } diff --git a/Modules/CIPPCore/Public/Restore-CIPPSPODeletedSite.ps1 b/Modules/CIPPCore/Public/Restore-CIPPSPODeletedSite.ps1 new file mode 100644 index 0000000000000..e297c6d140a6d --- /dev/null +++ b/Modules/CIPPCore/Public/Restore-CIPPSPODeletedSite.ps1 @@ -0,0 +1,46 @@ +function Restore-CIPPSPODeletedSite { + <# + .SYNOPSIS + Restore a deleted SharePoint site from the tenant recycle bin via CSOM + + .DESCRIPTION + Restores a deleted site collection using the CSOM RestoreDeletedSite method against the + SharePoint admin endpoint (same ProcessQuery pattern as Set-CIPPSharePointPerms). + + .PARAMETER TenantFilter + Tenant the site belongs to + + .PARAMETER SiteUrl + Full URL of the deleted site to restore + + .EXAMPLE + Restore-CIPPSPODeletedSite -TenantFilter 'contoso.onmicrosoft.com' -SiteUrl 'https://contoso.sharepoint.com/sites/OldSite' + + .FUNCTIONALITY + Internal + #> + [CmdletBinding(SupportsShouldProcess = $true)] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + [Parameter(Mandatory = $true)] + [string]$SiteUrl + ) + + $SharePointInfo = Get-SharePointAdminLink -Public $false -tenantFilter $TenantFilter + $AdminUrl = $SharePointInfo.AdminUrl + $AdditionalHeaders = @{ 'Accept' = 'application/json;odata=verbose' } + + $XML = @" +$([System.Security.SecurityElement]::Escape($SiteUrl)) +"@ + + if ($PSCmdlet.ShouldProcess($SiteUrl, 'Restore deleted site')) { + $Results = New-GraphPostRequest -scope "$AdminUrl/.default" -tenantid $TenantFilter -Uri "$AdminUrl/_vti_bin/client.svc/ProcessQuery" -Type POST -Body $XML -ContentType 'text/xml' -AddedHeaders $AdditionalHeaders + + $CsomError = ($Results | Where-Object { $_.ErrorInfo } | Select-Object -First 1).ErrorInfo.ErrorMessage + if ($CsomError) { throw $CsomError } + + return ($Results | Where-Object { $null -ne $_.IsComplete } | Select-Object -First 1) + } +} diff --git a/Modules/CIPPCore/Public/Set-CIPPSPOSite.ps1 b/Modules/CIPPCore/Public/Set-CIPPSPOSite.ps1 index 4af8e4f6b55a2..df35ab4fd187e 100644 --- a/Modules/CIPPCore/Public/Set-CIPPSPOSite.ps1 +++ b/Modules/CIPPCore/Public/Set-CIPPSPOSite.ps1 @@ -43,13 +43,18 @@ function Set-CIPPSPOSite { $SharePointInfo = Get-SharePointAdminLink -Public $false -tenantFilter $TenantFilter $AdminUrl = $SharePointInfo.AdminUrl - $AllowedTypes = @('Boolean', 'String', 'Int32') + $AllowedTypes = @('Boolean', 'String', 'Int32', 'Int64') + # Properties that are CSOM enums; their (numeric) value must be sent as Type="Enum". + $EnumProperties = @('SharingCapability', 'DefaultSharingLinkType', 'DefaultLinkPermission', 'SharingDomainRestrictionMode', 'ConditionalAccessPolicy') $SetProperty = [System.Collections.Generic.List[string]]::new() $x = 106 foreach ($Property in $Properties.Keys) { $PropertyType = $Properties[$Property].GetType().Name - if ($PropertyType -in $AllowedTypes) { - $PropertyToSet = if ($PropertyType -eq 'Boolean') { $Properties[$Property].ToString().ToLower() } else { $Properties[$Property] } + if ($Property -in $EnumProperties) { + $SetProperty.Add("$([int]$Properties[$Property])") + $x++ + } elseif ($PropertyType -in $AllowedTypes) { + $PropertyToSet = if ($PropertyType -eq 'Boolean') { $Properties[$Property].ToString().ToLower() } else { [System.Security.SecurityElement]::Escape([string]$Properties[$Property]) } $SetProperty.Add("$PropertyToSet") $x++ } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListUsers.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListUsers.ps1 index 4590a1c0cf432..a24160bb3987b 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListUsers.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListUsers.ps1 @@ -26,7 +26,7 @@ Function Invoke-ListUsers { 'employeeHireDate', 'employeeId', 'employeeLeaveDateTime', 'employeeType', 'faxNumber', 'givenName', 'jobTitle', 'lastPasswordChangeDateTime', 'legalAgeGroupClassification', 'mail', 'mailNickname', 'mobilePhone', 'officeLocation', 'onPremisesDistinguishedName', - 'onPremisesLastSyncDateTime', 'onPremisesSyncEnabled', 'otherMails', 'postalCode', + 'onPremisesImmutableId', 'onPremisesLastSyncDateTime', 'onPremisesSyncEnabled', 'otherMails', 'postalCode', 'preferredLanguage', 'proxyAddresses', 'showInAddressList', 'state', 'streetAddress', 'surname', 'usageLocation', 'userPrincipalName', 'userType' ) diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-DeleteSharepointSite.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-DeleteSharepointSite.ps1 index 01a8dca2ccf6f..0956440b6995c 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-DeleteSharepointSite.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-DeleteSharepointSite.ps1 @@ -19,15 +19,15 @@ function Invoke-DeleteSharepointSite { try { # Validate required parameters if (-not $SiteId) { - throw "SiteId is required" + throw 'SiteId is required' } if (-not $TenantFilter) { - throw "TenantFilter is required" + throw 'TenantFilter is required' } # Validate SiteId format (GUID) if ($SiteId -notmatch '^(\{)?[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}(\})?$') { - throw "SiteId must be a valid GUID" + throw 'SiteId must be a valid GUID' } $SharePointInfo = Get-SharePointAdminLink -Public $false -tenantFilter $TenantFilter @@ -35,42 +35,58 @@ function Invoke-DeleteSharepointSite { # Get site information using SharePoint admin API $SiteInfoUri = "$($SharePointInfo.AdminUrl)/_api/SPO.Tenant/sites('$SiteId')" - # Add the headers that SharePoint REST API expects - $ExtraHeaders = @{ - 'accept' = 'application/json' - 'content-type' = 'application/json' + # The SPO.Tenant vroute GET needs odata-version 4.0; the manager POST endpoints must + # NOT receive it - sending it flips them onto a pipeline that rejects the call with + # 'Elevated context should be used only to create service asserted level PoP'. + $GetHeaders = @{ + 'accept' = 'application/json' 'odata-version' = '4.0' } + $PostHeaders = @{ + 'accept' = 'application/json' + } - $SiteInfo = New-GraphGETRequest -scope "$($SharePointInfo.AdminUrl)/.default" -uri $SiteInfoUri -tenantid $TenantFilter -extraHeaders $ExtraHeaders + try { + $SiteInfo = New-GraphGETRequest -scope "$($SharePointInfo.AdminUrl)/.default" -uri $SiteInfoUri -tenantid $TenantFilter -extraHeaders $GetHeaders -UseCertificate -AsApp $true + } catch { + throw "Could not retrieve site information from the SharePoint Admin API: $($_.Exception.Message)" + } if (-not $SiteInfo) { - throw "Could not retrieve site information from SharePoint Admin API" + throw 'Could not retrieve site information from SharePoint Admin API' } # Determine if site is group-connected based on GroupId - $IsGroupConnected = $SiteInfo.GroupId -and $SiteInfo.GroupId -ne "00000000-0000-0000-0000-000000000000" + $IsGroupConnected = $SiteInfo.GroupId -and $SiteInfo.GroupId -ne '00000000-0000-0000-0000-000000000000' if ($IsGroupConnected) { - # Use GroupSiteManager/Delete for group-connected sites + # Group-connected sites: GroupSiteManager/Delete soft-deletes the backing M365 + # group (and Team) together with the site and registers it in the SPO deleted + # sites list. Runs with the delegated token (the CIPP service account is a + # SharePoint admin); see the header note above for why odata-version is omitted. $body = @{ siteUrl = $SiteInfo.Url } - $DeleteUri = "$($SharePointInfo.AdminUrl)/_api/GroupSiteManager/Delete" + try { + $null = New-GraphPOSTRequest -scope "$($SharePointInfo.AdminUrl)/.default" -uri "$($SharePointInfo.AdminUrl)/_api/GroupSiteManager/Delete" -body (ConvertTo-Json -Depth 10 -InputObject $body) -tenantid $TenantFilter -contentType 'application/json' -AddedHeaders $PostHeaders + } catch { + throw "Site deletion request failed (GroupSiteManager/Delete): $($_.Exception.Message)" + } + $Results = "Successfully initiated deletion of group-connected SharePoint site $($SiteInfo.Url); the backing M365 group (and Team, if any) is deleted with it. This can take some time to complete in the background." } else { - # Use SPSiteManager/delete for regular sites + # Regular sites: SPSiteManager/delete denies delegated tokens (even with a + # certificate assertion) with E_ACCESSDENIED - it requires app-only cert auth. $body = @{ siteId = $SiteId } - $DeleteUri = "$($SharePointInfo.AdminUrl)/_api/SPSiteManager/delete" + try { + $null = New-GraphPOSTRequest -scope "$($SharePointInfo.AdminUrl)/.default" -uri "$($SharePointInfo.AdminUrl)/_api/SPSiteManager/delete" -body (ConvertTo-Json -Depth 10 -InputObject $body) -tenantid $TenantFilter -contentType 'application/json' -AddedHeaders $PostHeaders -UseCertificate -AsApp $true + } catch { + throw "Site deletion request failed (SPSiteManager/delete): $($_.Exception.Message)" + } + $Results = "Successfully initiated deletion of SharePoint site with ID $SiteId, this process can take some time to complete in the background" } - # Execute the deletion - $DeleteResult = New-GraphPOSTRequest -scope "$($SharePointInfo.AdminUrl)/.default" -uri $DeleteUri -body (ConvertTo-Json -Depth 10 -InputObject $body) -tenantid $TenantFilter -extraHeaders $ExtraHeaders - - $SiteTypeMsg = if ($IsGroupConnected) { "group-connected" } else { "regular" } - $Results = "Successfully initiated deletion of $SiteTypeMsg SharePoint site with ID $SiteId, this process can take some time to complete in the background" - Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $Results -sev Info $StatusCode = [HttpStatusCode]::OK @@ -83,7 +99,7 @@ function Invoke-DeleteSharepointSite { # Associate values to output bindings return ([HttpResponseContext]@{ - StatusCode = $StatusCode - Body = @{ 'Results' = $Results } - }) + StatusCode = $StatusCode + Body = @{ 'Results' = $Results } + }) } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecBulkRemoveSharingLinks.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecBulkRemoveSharingLinks.ps1 new file mode 100644 index 0000000000000..a4608323d50ab --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecBulkRemoveSharingLinks.ps1 @@ -0,0 +1,104 @@ +function Invoke-ExecBulkRemoveSharingLinks { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Sharepoint.Site.ReadWrite + .DESCRIPTION + Revokes all sharing links / external direct grants on one site in bulk, sourced from + the SharePointSharingLinks reporting cache. Scope selects which classifications are + revoked: Anonymous (anyone links only), External (anonymous + external), or All + (every cached link on the site, including internal). Because the source is the + reporting cache, links created after the last sharing sync are not covered. + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + $TenantFilter = $Request.Body.tenantFilter + $SiteUrl = $Request.Body.SiteUrl + $Scope = $Request.Body.Scope.value ?? $Request.Body.Scope ?? 'Anonymous' + + $ScopeClassifications = @{ + 'Anonymous' = @('Anonymous') + 'External' = @('Anonymous', 'External') + 'All' = @('Anonymous', 'External', 'Internal') + } + + try { + if (-not $SiteUrl) { throw 'SiteUrl is required.' } + if (-not $ScopeClassifications.ContainsKey([string]$Scope)) { + throw "Invalid scope '$Scope'. Valid values: $($ScopeClassifications.Keys -join ', ')." + } + $Classifications = $ScopeClassifications[[string]$Scope] + + try { + $AllLinks = @(New-CIPPDbRequest -TenantFilter $TenantFilter -Type 'SharePointSharingLinks') + } catch { + throw "Could not read the sharing links cache: $($_.Exception.Message). Run a sharing report sync first." + } + $NormalizedSite = $SiteUrl.TrimEnd('/') + $Targets = @($AllLinks | Where-Object { + "$($_.siteUrl)".TrimEnd('/') -eq $NormalizedSite -and $_.classification -in $Classifications -and $_.driveId -and $_.itemId -and $_.permissionId + }) + + if ($Targets.Count -eq 0) { + $Results = "No cached $($Scope -eq 'All' ? '' : "$Scope ")sharing links found for $SiteUrl. Links created since the last sharing sync are not in the cache - run a sync from the Sharing Report page to refresh." + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message $Results -sev Info + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::OK + Body = @{ 'Results' = $Results } + }) + } + + $Revoked = [System.Collections.Generic.List[string]]::new() + $Failed = [System.Collections.Generic.List[string]]::new() + foreach ($Link in $Targets) { + try { + $null = New-GraphPostRequest -uri "https://graph.microsoft.com/v1.0/drives/$($Link.driveId)/items/$($Link.itemId)/permissions/$($Link.permissionId)" -tenantid $TenantFilter -type DELETE -asapp $true + $Revoked.Add("$($Link.fileName) ($($Link.classification))") + if ($Link.id) { + try { + Remove-CIPPDbItem -TenantFilter $TenantFilter -Type 'SharePointSharingLinks' -ItemId $Link.id + } catch { + Write-Information "Revoked link but could not update reporting cache row $($Link.id): $($_.Exception.Message)" + } + } + } catch { + # A 404 means the link was already gone; treat as revoked and clean the cache row. + if ($_.Exception.Message -match 'itemNotFound|404') { + $Revoked.Add("$($Link.fileName) (already removed)") + if ($Link.id) { + try { Remove-CIPPDbItem -TenantFilter $TenantFilter -Type 'SharePointSharingLinks' -ItemId $Link.id } catch {} + } + } else { + $Failed.Add("$($Link.fileName): $($_.Exception.Message)") + } + } + } + + $Messages = [System.Collections.Generic.List[string]]::new() + $Messages.Add("Revoked $($Revoked.Count) of $($Targets.Count) $Scope sharing link(s) on $SiteUrl.") + if ($Failed.Count -gt 0) { + $Messages.Add("Failed: $($Failed -join '; ')") + } + $Messages.Add('Note: links created since the last sharing report sync are not covered.') + $Results = $Messages -join ' ' + if ($Revoked.Count -eq 0 -and $Failed.Count -gt 0) { + throw $Results + } + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message $Results -sev Info + $StatusCode = [HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Results = "Failed to bulk revoke sharing links on $($SiteUrl): $($ErrorMessage.NormalizedError)" + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message $Results -sev Error -LogData $ErrorMessage + $StatusCode = [HttpStatusCode]::BadRequest + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @{ 'Results' = $Results } + }) +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecRemoveSiteUser.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecRemoveSiteUser.ps1 new file mode 100644 index 0000000000000..693e8e993b7ab --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecRemoveSiteUser.ps1 @@ -0,0 +1,65 @@ +function Invoke-ExecRemoveSiteUser { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Sharepoint.Site.ReadWrite + .DESCRIPTION + Removes a user from an entire SharePoint site: deleting the site user removes them + from every site group and direct permission grant at once. Uses the SharePoint REST + API with certificate authentication. Note this does not revoke sharing links the user + received by mail; use the sharing link actions for those. + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + $TenantFilter = $Request.Body.tenantFilter + $SiteUrl = $Request.Body.SiteUrl + # The picker supplies the SP claims login via addedFields; fall back to building it from the UPN. + $LoginName = $Request.Body.user.addedFields.LoginName ?? $Request.Body.user.value + $Label = $Request.Body.user.value ?? $LoginName + + try { + if (-not $SiteUrl) { throw 'SiteUrl is required.' } + if (-not $LoginName) { throw 'No user was selected.' } + if ($LoginName -notmatch '\|') { $LoginName = "i:0#.f|membership|$LoginName" } + + $SharePointInfo = Get-SharePointAdminLink -Public $false -tenantFilter $TenantFilter + $Scope = "$($SharePointInfo.SharePointUrl)/.default" + $JsonAccept = @{ Accept = 'application/json;odata=nometadata' } + $BaseUri = "$($SiteUrl.TrimEnd('/'))/_api" + + try { + $EnsureBody = ConvertTo-Json -Compress -InputObject @{ logonName = $LoginName } + $EnsuredUser = New-GraphPostRequest -uri "$BaseUri/web/ensureuser" -tenantid $TenantFilter -scope $Scope -type POST -body $EnsureBody -contentType 'application/json;odata=nometadata' -AddedHeaders $JsonAccept -UseCertificate -AsApp $true + } catch { + throw "Could not resolve $Label on the site (ensureuser): $($_.Exception.Message)" + } + if (-not $EnsuredUser.Id) { throw "Could not resolve $Label on the site." } + if ($EnsuredUser.IsSiteAdmin) { + throw "$Label is a site collection admin. Remove their admin permission first (Remove Site Admin action)." + } + + try { + $null = New-GraphPostRequest -uri "$BaseUri/web/siteusers/removebyid($($EnsuredUser.Id))" -tenantid $TenantFilter -scope $Scope -type POST -body '{}' -contentType 'application/json;odata=nometadata' -AddedHeaders $JsonAccept -UseCertificate -AsApp $true + } catch { + throw "Could not remove $Label from the site: $($_.Exception.Message)" + } + + $Results = "Successfully removed $Label from $SiteUrl (all site groups and direct permissions)." + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message $Results -sev Info + $StatusCode = [HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Results = "Failed to remove $Label from $($SiteUrl): $($ErrorMessage.NormalizedError)" + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message $Results -sev Error -LogData $ErrorMessage + $StatusCode = [HttpStatusCode]::BadRequest + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @{ 'Results' = $Results } + }) +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecRestoreDeletedSite.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecRestoreDeletedSite.ps1 new file mode 100644 index 0000000000000..88009fadf8184 --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecRestoreDeletedSite.ps1 @@ -0,0 +1,40 @@ +function Invoke-ExecRestoreDeletedSite { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Sharepoint.Site.ReadWrite + .DESCRIPTION + Restores a deleted SharePoint site from the tenant recycle bin via the SharePoint + admin CSOM API. + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + $TenantFilter = $Request.Body.tenantFilter + $SiteUrl = $Request.Body.SiteUrl ?? $Request.Body.Url + + try { + if (-not $SiteUrl) { throw 'SiteUrl is required.' } + $Operation = Restore-CIPPSPODeletedSite -TenantFilter $TenantFilter -SiteUrl $SiteUrl + $Results = if ($Operation -and -not $Operation.IsComplete) { + "Restore of $SiteUrl has started. Large sites can take a while to finish restoring." + } else { + "Successfully restored $SiteUrl from the tenant recycle bin." + } + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message $Results -sev Info + $StatusCode = [HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Results = "Failed to restore $($SiteUrl): $($ErrorMessage.NormalizedError)" + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message $Results -sev Error -LogData $ErrorMessage + $StatusCode = [HttpStatusCode]::BadRequest + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @{ 'Results' = $Results } + }) +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecRestoreRecycleBinItems.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecRestoreRecycleBinItems.ps1 new file mode 100644 index 0000000000000..cfad4b5705659 --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecRestoreRecycleBinItems.ps1 @@ -0,0 +1,52 @@ +function Invoke-ExecRestoreRecycleBinItems { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Sharepoint.SiteRecycleBin.ReadWrite + .DESCRIPTION + Restores one or more items from a SharePoint site's recycle bin via the SharePoint + REST API RestoreByIds method with certificate authentication. + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + $TenantFilter = $Request.Body.tenantFilter + $SiteUrl = $Request.Body.SiteUrl + $Ids = @($Request.Body.Ids) | Where-Object { $_ } + $ItemNames = @($Request.Body.ItemNames) | Where-Object { $_ } + + try { + if (-not $SiteUrl) { throw 'SiteUrl is required.' } + if ($Ids.Count -eq 0) { throw 'No recycle bin items were selected.' } + + $SharePointInfo = Get-SharePointAdminLink -Public $false -tenantFilter $TenantFilter + $Scope = "$($SharePointInfo.SharePointUrl)/.default" + $JsonAccept = @{ Accept = 'application/json;odata=nometadata' } + $BaseUri = "$($SiteUrl.TrimEnd('/'))/_api" + + $RestoreBody = ConvertTo-Json -Compress -Depth 5 -InputObject @{ ids = @($Ids) } + try { + $null = New-GraphPostRequest -uri "$BaseUri/site/RecycleBin/RestoreByIds" -tenantid $TenantFilter -scope $Scope -type POST -body $RestoreBody -contentType 'application/json;odata=nometadata' -AddedHeaders $JsonAccept -UseCertificate -AsApp $true + } catch { + throw "RestoreByIds failed: $($_.Exception.Message)" + } + + $Label = if ($ItemNames.Count -gt 0) { $ItemNames -join ', ' } else { "$($Ids.Count) item(s)" } + $Results = "Successfully restored $Label from the recycle bin of $SiteUrl." + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message $Results -sev Info + $StatusCode = [HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Results = "Failed to restore recycle bin items on $($SiteUrl): $($ErrorMessage.NormalizedError)" + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message $Results -sev Error -LogData $ErrorMessage + $StatusCode = [HttpStatusCode]::BadRequest + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @{ 'Results' = $Results } + }) +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecSetSharePointMember.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecSetSharePointMember.ps1 index 1949aac407b29..fdffad7a36c74 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecSetSharePointMember.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecSetSharePointMember.ps1 @@ -4,37 +4,127 @@ function Invoke-ExecSetSharePointMember { Entrypoint .ROLE Sharepoint.Site.ReadWrite + .DESCRIPTION + Adds or removes a user in a SharePoint site role (Owners, Members or Visitors). + Group-connected sites manage Owners/Members through the backing M365 group via Graph; + Visitors (and classic/communication sites entirely) are managed through the site's + associated SharePoint role groups via the SharePoint REST API using certificate + authentication. Removals sourced from ListSiteMembers carry the group and type of the + selected entry, so users directly added to a role group on a group-connected site are + removed from that group rather than from the M365 group. #> [CmdletBinding()] param($Request, $TriggerMetadata) - $Headers = $Request.Headers - - # Interact with query parameters or the body of the request. + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers $TenantFilter = $Request.Body.tenantFilter + $UPN = $Request.Body.user.value + $Add = $Request.Body.Add -eq $true + + # Role comes from the removal picker's selected entry when present, else from the form. + $Role = $Request.Body.user.addedFields.Group ?? $Request.Body.Role ?? 'Members' + $MemberType = $Request.Body.user.addedFields.Type + $AssociatedGroups = @{ + 'Owners' = 'associatedownergroup' + 'Members' = 'associatedmembergroup' + 'Visitors' = 'associatedvisitorgroup' + } try { - if ($Request.Body.SharePointType -eq 'Group') { + if (-not $UPN) { throw 'No user was selected.' } + if (-not $AssociatedGroups.ContainsKey([string]$Role)) { + throw "Invalid role '$Role'. Valid roles are: $($AssociatedGroups.Keys -join ', ')." + } + + $IsGroupSite = $Request.Body.SharePointType -eq 'Group' + # Owners/Members of a group-connected site live in the M365 group. Visitors are always + # a SharePoint role group. A removal of a directly-added user (Type 'User') on a group + # site targets the SharePoint role group instead of the M365 group. + $UseGraphGroup = $IsGroupSite -and $Role -ne 'Visitors' -and ($Add -or $MemberType -ne 'User') + + if ($UseGraphGroup) { if ($Request.Body.GroupID -match '^[0-9a-fA-F]{8}(-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}$') { $GroupId = $Request.Body.GroupID } else { $GroupId = (New-GraphGetRequest -uri "https://graph.microsoft.com/beta/groups?`$filter=mail eq '$($Request.Body.GroupID)' or proxyAddresses/any(x:endsWith(x,'$($Request.Body.GroupID)')) or mailNickname eq '$($Request.Body.GroupID)'" -ComplexFilter -tenantid $TenantFilter).id } - if ($Request.Body.Add -eq $true) { - $Results = Add-CIPPGroupMember -GroupType 'Team' -GroupID $GroupID -Member $Request.Body.user.value -TenantFilter $TenantFilter -Headers $Headers + if ($Role -eq 'Owners') { + $UserID = (New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/users/$UPN`?`$select=id" -tenantid $TenantFilter).id + if ($Add) { + $OwnerBody = ConvertTo-Json -Compress -InputObject @{ '@odata.id' = "https://graph.microsoft.com/v1.0/directoryObjects/$UserID" } + $null = New-GraphPostRequest -uri "https://graph.microsoft.com/v1.0/groups/$GroupId/owners/`$ref" -tenantid $TenantFilter -type POST -body $OwnerBody + $Results = "Successfully added $UPN as an owner of the M365 group backing the site." + } else { + $null = New-GraphPostRequest -uri "https://graph.microsoft.com/v1.0/groups/$GroupId/owners/$UserID/`$ref" -tenantid $TenantFilter -type DELETE -body '' + $Results = "Successfully removed $UPN as an owner of the M365 group backing the site." + } + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message $Results -sev Info } else { - $UserID = (New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/users/$($Request.Body.user.value)" -tenantid $TenantFilter).id - $Results = Remove-CIPPGroupMember -GroupType 'Team' -GroupID $GroupID -Member $UserID -TenantFilter $TenantFilter -Headers $Headers + if ($Add) { + $Results = Add-CIPPGroupMember -GroupType 'Team' -GroupID $GroupID -Member $UPN -TenantFilter $TenantFilter -Headers $Headers + } else { + $UserID = (New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/users/$UPN`?`$select=id" -tenantid $TenantFilter).id + $Results = Remove-CIPPGroupMember -GroupType 'Team' -GroupID $GroupID -Member $UserID -TenantFilter $TenantFilter -Headers $Headers + } } $StatusCode = [HttpStatusCode]::OK } else { - $StatusCode = [HttpStatusCode]::BadRequest - $Results = 'This type of SharePoint site is not supported.' + # SharePoint role group management via REST with certificate auth. + $SiteUrl = $Request.Body.URL + if (-not $SiteUrl) { throw 'No site URL was provided for this site.' } + + $SharePointInfo = Get-SharePointAdminLink -Public $false -tenantFilter $TenantFilter + $Scope = "$($SharePointInfo.SharePointUrl)/.default" + $JsonAccept = @{ Accept = 'application/json;odata=nometadata' } + $BaseUri = "$($SiteUrl.TrimEnd('/'))/_api" + $RoleGroup = $AssociatedGroups[[string]$Role] + $RoleLabel = ([string]$Role).ToLower().TrimEnd('s') + $Article = if ($RoleLabel -match '^[aeiou]') { 'an' } else { 'a' } + + try { + $EnsureBody = ConvertTo-Json -Compress -InputObject @{ logonName = "i:0#.f|membership|$UPN" } + $EnsuredUser = New-GraphPostRequest -uri "$BaseUri/web/ensureuser" -tenantid $TenantFilter -scope $Scope -type POST -body $EnsureBody -contentType 'application/json;odata=nometadata' -AddedHeaders $JsonAccept -UseCertificate -AsApp $true + } catch { + throw "Could not resolve $UPN on the site (ensureuser): $($_.Exception.Message)" + } + if (-not $EnsuredUser.Id) { + throw "Could not resolve $UPN on the site." + } + + if ($Add) { + # Same shape PnP sends: an SP.User entity posted to the group's users + # collection, which requires the odata=verbose content type. + $AddBody = ConvertTo-Json -Compress -Depth 5 -InputObject @{ + '__metadata' = @{ 'type' = 'SP.User' } + 'LoginName' = $EnsuredUser.LoginName + } + try { + $null = New-GraphPostRequest -uri "$BaseUri/web/$RoleGroup/users" -tenantid $TenantFilter -scope $Scope -type POST -body $AddBody -contentType 'application/json;odata=verbose' -AddedHeaders $JsonAccept -UseCertificate -AsApp $true + } catch { + throw "Could not add $UPN to the site $Role group: $($_.Exception.Message)" + } + $Results = "Successfully added $UPN as $Article $RoleLabel of $SiteUrl." + } else { + try { + $null = New-GraphPostRequest -uri "$BaseUri/web/$RoleGroup/users/removebyid($($EnsuredUser.Id))" -tenantid $TenantFilter -scope $Scope -type POST -body '{}' -contentType 'application/json;odata=nometadata' -AddedHeaders $JsonAccept -UseCertificate -AsApp $true + } catch { + if ($_.Exception.Message -match 'Can not find the user') { + throw "$UPN is not in the site's $Role group." + } + throw "Could not remove $UPN from the site $Role group: $($_.Exception.Message)" + } + $Results = "Successfully removed $UPN as $Article $RoleLabel of $SiteUrl." + } + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message $Results -sev Info + $StatusCode = [HttpStatusCode]::OK } } catch { - $Results = $_.Exception.Message - $StatusCode = [HttpStatusCode]::InternalServerError + $ErrorMessage = Get-CippException -Exception $_ + $Results = "Failed to modify $Role for $($Request.Body.URL ?? $Request.Body.GroupID). Error: $($ErrorMessage.NormalizedError)" + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message $Results -sev Error -LogData $ErrorMessage + $StatusCode = [HttpStatusCode]::BadRequest } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecSetSiteProperties.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecSetSiteProperties.ps1 new file mode 100644 index 0000000000000..c8395de597814 --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecSetSiteProperties.ps1 @@ -0,0 +1,136 @@ +function Invoke-ExecSetSiteProperties { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Sharepoint.Site.ReadWrite + .DESCRIPTION + Sets admin-level properties on a single SharePoint site (sharing, lifecycle, version + policy) through Set-CIPPSPOSite. Only whitelisted properties are accepted; enum values + arrive as friendly names (matching Invoke-ListSiteProperties output) and are converted + to their numeric CSOM values here. + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + $TenantFilter = $Request.Body.tenantFilter + $SiteUrl = $Request.Body.SiteUrl + + # Friendly name -> SPO enum value maps + $EnumMaps = @{ + SharingCapability = @{ 'Disabled' = 0; 'ExternalUserSharingOnly' = 1; 'ExternalUserAndGuestSharing' = 2; 'ExistingExternalUserSharingOnly' = 3 } + DefaultSharingLinkType = @{ 'None' = 0; 'Direct' = 1; 'Internal' = 2; 'AnonymousAccess' = 3 } + DefaultLinkPermission = @{ 'None' = 0; 'View' = 1; 'Edit' = 2 } + SharingDomainRestrictionMode = @{ 'None' = 0; 'AllowList' = 1; 'BlockList' = 2 } + } + $StringProperties = @('Title', 'SharingAllowedDomainList', 'SharingBlockedDomainList') + $BoolProperties = @('OverrideTenantAnonymousLinkExpirationPolicy', 'InheritVersionPolicyFromTenant', 'EnableAutoExpirationVersionTrim', 'ApplyToNewDocumentLibraries', 'ApplyToExistingDocumentLibraries') + $IntProperties = @('AnonymousLinkExpirationInDays', 'MajorVersionLimit', 'ExpireVersionsAfterDays') + $Int64Properties = @('StorageMaximumLevel', 'StorageWarningLevel') + $ValidLockStates = @('Unlock', 'ReadOnly', 'NoAccess') + + try { + if (-not $SiteUrl) { throw 'SiteUrl is required.' } + + $Properties = @{} + $Changes = [System.Collections.Generic.List[string]]::new() + + foreach ($Key in $EnumMaps.Keys) { + $Value = $Request.Body.$Key.value ?? $Request.Body.$Key + if ($null -ne $Value -and "$Value" -ne '') { + if (-not $EnumMaps[$Key].ContainsKey([string]$Value)) { + throw "Invalid value '$Value' for $Key. Valid values: $($EnumMaps[$Key].Keys -join ', ')." + } + $Properties[$Key] = [int]$EnumMaps[$Key][[string]$Value] + $Changes.Add("$Key=$Value") + } + } + + $LockState = $Request.Body.LockState.value ?? $Request.Body.LockState + if ($null -ne $LockState -and "$LockState" -ne '') { + if ($LockState -notin $ValidLockStates) { + throw "Invalid LockState '$LockState'. Valid values: $($ValidLockStates -join ', ')." + } + $Properties['LockState'] = [string]$LockState + $Changes.Add("LockState=$LockState") + } + + foreach ($Key in $StringProperties) { + $Value = $Request.Body.$Key + if ($null -ne $Value) { + $Properties[$Key] = [string]$Value + $Changes.Add("$Key=$Value") + } + } + foreach ($Key in $BoolProperties) { + $Value = $Request.Body.$Key + if ($null -ne $Value) { + $Properties[$Key] = [bool]$Value + $Changes.Add("$Key=$Value") + } + } + foreach ($Key in $IntProperties) { + $Value = $Request.Body.$Key + if ($null -ne $Value -and "$Value" -ne '') { + $Properties[$Key] = [int]$Value + $Changes.Add("$Key=$Value") + } + } + foreach ($Key in $Int64Properties) { + $Value = $Request.Body.$Key + if ($null -ne $Value -and "$Value" -ne '') { + $Properties[$Key] = [int64]$Value + $Changes.Add("$Key=$Value") + } + } + + if ($Properties.Count -eq 0) { + throw 'No valid properties were provided to set.' + } + + # Group-connected sites only accept a small subset of tenant site properties; SPO + # rejects the whole request if any other property is included. Filter to the + # supported set and report what was skipped. + $Site = Get-CIPPSPOSite -TenantFilter $TenantFilter -SiteUrl $SiteUrl + $IsGroupSite = $Site.GroupId -and $Site.GroupId -notmatch '^0{8}-' + $Skipped = [System.Collections.Generic.List[string]]::new() + if ($IsGroupSite) { + $GroupSiteAllowed = @('SharingCapability', 'DefaultSharingLinkType', 'DefaultLinkPermission', 'LockState', 'StorageMaximumLevel', 'StorageWarningLevel') + foreach ($Key in @($Properties.Keys)) { + if ($Key -notin $GroupSiteAllowed) { + $Properties.Remove($Key) + $Skipped.Add($Key) + } + } + if ($Properties.Count -eq 0) { + throw "None of the selected properties can be changed on a group-connected site. Supported: $($GroupSiteAllowed -join ', ')." + } + } + + $Response = Set-CIPPSPOSite -TenantFilter $TenantFilter -SiteUrl $SiteUrl -Properties $Properties + $CsomError = ($Response | Where-Object { $_.ErrorInfo } | Select-Object -First 1).ErrorInfo.ErrorMessage + if ($CsomError) { + throw $CsomError + } + + $AppliedChanges = $Changes | Where-Object { ($_ -split '=')[0] -in $Properties.Keys } + $Results = "Successfully updated site properties for $($SiteUrl): $($AppliedChanges -join ', ')" + if ($Skipped.Count -gt 0) { + $Results += " Skipped (not supported on group-connected sites): $($Skipped -join ', ')." + } + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message $Results -sev Info + $StatusCode = [HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Results = "Failed to update site properties for $($SiteUrl): $($ErrorMessage.NormalizedError)" + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message $Results -sev Error -LogData $ErrorMessage + $StatusCode = [HttpStatusCode]::BadRequest + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @{ 'Results' = $Results } + }) +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListDeletedSites.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListDeletedSites.ps1 new file mode 100644 index 0000000000000..5020b45d4b2f3 --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListDeletedSites.ps1 @@ -0,0 +1,49 @@ +function Invoke-ListDeletedSites { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Sharepoint.Site.Read + .DESCRIPTION + Lists deleted SharePoint sites still restorable from the tenant recycle bin, via the + SharePoint admin CSOM API. + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $TenantFilter = $Request.Query.tenantFilter + + # CSOM verbose JSON dates arrive as '/Date(year,month,day,h,m,s,ms)/' with a 0-based month. + function ConvertFrom-CsomDate($Value) { + if ("$Value" -match '/Date\((\d+),(\d+),(\d+),(\d+),(\d+),(\d+),(\d+)\)/') { + return ([datetime]::new([int]$Matches[1], ([int]$Matches[2] + 1), [int]$Matches[3], [int]$Matches[4], [int]$Matches[5], [int]$Matches[6], [System.DateTimeKind]::Utc)).ToString('yyyy-MM-ddTHH:mm:ssZ') + } + return $Value + } + + try { + $DeletedSites = Get-CIPPSPODeletedSites -TenantFilter $TenantFilter + $Body = @($DeletedSites | ForEach-Object { + [PSCustomObject]@{ + # Deleted-site CSOM entries carry no Title; derive a display name from the URL. + Name = ([uri]$_.Url).Segments[-1].TrimEnd('/') + Url = $_.Url + SiteId = $_.SiteId + Status = $_.Status + DeletionTime = ConvertFrom-CsomDate $_.DeletionTime + DaysRemaining = $_.DaysRemaining + StorageMaximumLevel = $_.StorageMaximumLevel + } + }) + $StatusCode = [HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Body = "Failed to list deleted sites: $($ErrorMessage.NormalizedError)" + $StatusCode = [HttpStatusCode]::BadRequest + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @{ 'Results' = $Body } + }) +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSiteMembers.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSiteMembers.ps1 index d57571cee6b3f..d1f2627b12d16 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSiteMembers.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSiteMembers.ps1 @@ -5,34 +5,159 @@ Function Invoke-ListSiteMembers { .ROLE Sharepoint.Site.Read .DESCRIPTION - Lists members of a specific SharePoint site by site ID, including user display names and email addresses. + Lists the actual membership of a SharePoint site: the users in the site's associated + Owners/Members/Visitors role groups via the SharePoint REST API with certificate + authentication, plus site collection admins. On group-connected (Team) sites the role + groups contain the backing M365 group as a claim; those are expanded through Graph so + the real people are returned. Falls back to the site's hidden User Information List + via Graph when the SharePoint REST API is unavailable (e.g. the tenant does not have + the SharePoint application permission consented yet). #> [CmdletBinding()] param($Request, $TriggerMetadata) - $TenantFilter = $Request.Query.TenantFilter + $TenantFilter = $Request.Query.tenantFilter $SiteId = $Request.Query.SiteId + $SiteUrl = $Request.Query.SiteUrl + $Filter = $Request.Query.Filter + + # A SharePoint user entity is a guest/external identity when either guest flag is set or + # the claims login carries the external-user or spo-guest marker. + function Test-SPGuestUser($User) { + [bool]$User.IsShareByEmailGuestUser -or [bool]$User.IsEmailAuthenticationGuestUser -or $User.LoginName -match '(?i)#ext#|urn%3aspo%3aguest' + } try { - # Find User Information List by template (language independent) - $Lists = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/sites/$SiteId/lists?`$select=id,list,system" -tenantid $TenantFilter -AsApp $true - $UIList = $Lists | Where-Object { $_.list.template -eq 'userInformation' } | Select-Object -First 1 - - if ($UIList.id) { - $Items = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/sites/$SiteId/lists/$($UIList.id)/items?`$expand=fields" -tenantid $TenantFilter -AsApp $true - } else { - $Items = @() + if (-not $SiteUrl) { + $SiteUrl = (New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/sites/$($SiteId)?`$select=webUrl" -tenantid $TenantFilter -AsApp $true).webUrl + } + + $Members = [System.Collections.Generic.List[object]]::new() + try { + $SharePointInfo = Get-SharePointAdminLink -Public $false -tenantFilter $TenantFilter + $Scope = "$($SharePointInfo.SharePointUrl)/.default" + $JsonAccept = @{ Accept = 'application/json;odata=nometadata' } + $BaseUri = "$($SiteUrl.TrimEnd('/'))/_api" + + $RoleGroups = [ordered]@{ + 'Owners' = 'associatedownergroup' + 'Members' = 'associatedmembergroup' + 'Visitors' = 'associatedvisitorgroup' + } + foreach ($Role in $RoleGroups.Keys) { + $Users = New-GraphGetRequest -uri "$BaseUri/web/$($RoleGroups[$Role])/users?`$select=Id,Title,Email,LoginName,PrincipalType,IsShareByEmailGuestUser,IsEmailAuthenticationGuestUser" -tenantid $TenantFilter -scope $Scope -extraHeaders $JsonAccept -UseCertificate -AsApp $true + foreach ($User in $Users) { + if ($User.LoginName -match 'federateddirectoryclaimprovider\|([0-9a-fA-F-]{36})(_o)?$') { + # Group-connected site: the role group holds the backing M365 group as + # a claim ('_o' suffix = the group's owners). Expand it through Graph. + $GroupId = $Matches[1] + $GraphSegment = if ($Matches[2]) { 'owners' } else { 'members' } + try { + $GroupMembers = New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/groups/$GroupId/$($GraphSegment)?`$select=displayName,mail,userPrincipalName&`$top=999" -tenantid $TenantFilter -AsApp $true + foreach ($GroupMember in $GroupMembers) { + $Members.Add([PSCustomObject]@{ + Title = $GroupMember.displayName + Email = $GroupMember.mail ?? $GroupMember.userPrincipalName + LoginName = $GroupMember.userPrincipalName + UserPrincipalName = $GroupMember.userPrincipalName + Group = $Role + Type = 'User (via M365 Group)' + IsGuest = ($GroupMember.userPrincipalName -match '(?i)#ext#') + IsSiteAdmin = $false + }) + } + } catch { + # Could not expand the group; show the claim entity itself. + $Members.Add([PSCustomObject]@{ + Title = $User.Title + Email = $User.Email + LoginName = $User.LoginName + UserPrincipalName = $null + Group = $Role + Type = 'M365 Group' + IsGuest = $false + IsSiteAdmin = $false + }) + } + } else { + $Type = switch ($User.PrincipalType) { + 1 { 'User' } + 4 { 'Security Group' } + 8 { 'SharePoint Group' } + default { 'Other' } + } + $Members.Add([PSCustomObject]@{ + Title = $User.Title + Email = $User.Email + LoginName = $User.LoginName + UserPrincipalName = if ($User.PrincipalType -eq 1) { ($User.LoginName -split '\|')[-1] } else { $null } + Group = $Role + Type = $Type + IsGuest = (Test-SPGuestUser $User) + IsSiteAdmin = $false + }) + } + } + } + + # Site collection admins: flag them on existing rows, add rows for admins that + # are not in any role group. + $Admins = New-GraphGetRequest -uri "$BaseUri/web/siteusers?`$filter=IsSiteAdmin eq true&`$select=Id,Title,Email,LoginName,PrincipalType,IsShareByEmailGuestUser,IsEmailAuthenticationGuestUser" -tenantid $TenantFilter -scope $Scope -extraHeaders $JsonAccept -UseCertificate -AsApp $true + foreach ($Admin in $Admins) { + $Existing = @($Members | Where-Object { $_.LoginName -eq $Admin.LoginName }) + if ($Existing.Count -gt 0) { + $Existing | ForEach-Object { $_.IsSiteAdmin = $true } + } else { + $Members.Add([PSCustomObject]@{ + Title = $Admin.Title + Email = $Admin.Email + LoginName = $Admin.LoginName + UserPrincipalName = if ($Admin.PrincipalType -eq 1) { ($Admin.LoginName -split '\|')[-1] } else { $null } + Group = 'Site Admins' + IsGuest = (Test-SPGuestUser $Admin) + Type = if ($Admin.PrincipalType -eq 1) { 'User' } elseif ($Admin.LoginName -match 'federateddirectoryclaimprovider') { 'M365 Group' } else { 'Other' } + IsSiteAdmin = $true + }) + } + } + } catch { + # SharePoint REST unavailable (e.g. no certificate/consent) - fall back to the + # hidden User Information List via Graph. This lists everyone ever resolved on + # the site rather than actual role group membership. + Write-Information "ListSiteMembers falling back to User Information List: $($_.Exception.Message)" + $Members.Clear() + $Lists = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/sites/$SiteId/lists?`$select=id,list,system" -tenantid $TenantFilter -AsApp $true + $UIList = $Lists | Where-Object { $_.list.template -eq 'userInformation' } | Select-Object -First 1 + if ($UIList.id) { + $Items = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/sites/$SiteId/lists/$($UIList.id)/items?`$expand=fields" -tenantid $TenantFilter -AsApp $true + foreach ($Item in $Items) { + $Members.Add([PSCustomObject]@{ + Title = $Item.fields.Title + Email = $Item.fields.EMail + LoginName = $Item.fields.UserName + UserPrincipalName = if ($Item.fields.UserName) { ($Item.fields.UserName -split '\|')[-1] } else { $null } + Group = 'Site Users' + Type = 'User' + IsGuest = ($Item.fields.UserName -match '(?i)#ext#') + IsSiteAdmin = [bool]$Item.fields.IsSiteAdmin + }) + } + } + } + + if ($Filter -eq 'External') { + $Members = @($Members | Where-Object { $_.IsGuest }) } $StatusCode = [HttpStatusCode]::OK - $Body = @($Items) + $Body = @($Members) } catch { $StatusCode = [HttpStatusCode]::Forbidden $Body = Get-NormalizedError -Message $_.Exception.Message } return ([HttpResponseContext]@{ - StatusCode = $StatusCode - Body = @{ 'Results' = $Body } - }) + StatusCode = $StatusCode + Body = @{ 'Results' = $Body } + }) } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSiteProperties.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSiteProperties.ps1 new file mode 100644 index 0000000000000..0f11bb9f2855b --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSiteProperties.ps1 @@ -0,0 +1,63 @@ +function Invoke-ListSiteProperties { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Sharepoint.Site.Read + .DESCRIPTION + Returns a single site's admin-level properties (sharing, lifecycle and version policy) + via the SharePoint admin CSOM API, with enum values translated to friendly names so + the Edit Site form can consume and round-trip them. + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $TenantFilter = $Request.Query.tenantFilter + $SiteUrl = $Request.Query.SiteUrl + + # SPO enum value -> friendly name maps (reverse of the maps in Invoke-ExecSetSiteProperties) + $SharingCapabilityNames = @{ 0 = 'Disabled'; 1 = 'ExternalUserSharingOnly'; 2 = 'ExternalUserAndGuestSharing'; 3 = 'ExistingExternalUserSharingOnly' } + $LinkTypeNames = @{ 0 = 'None'; 1 = 'Direct'; 2 = 'Internal'; 3 = 'AnonymousAccess' } + $LinkPermissionNames = @{ 0 = 'None'; 1 = 'View'; 2 = 'Edit' } + $DomainRestrictionNames = @{ 0 = 'None'; 1 = 'AllowList'; 2 = 'BlockList' } + + try { + if (-not $SiteUrl) { throw 'SiteUrl is required.' } + $Site = Get-CIPPSPOSite -TenantFilter $TenantFilter -SiteUrl $SiteUrl + + $Body = [PSCustomObject]@{ + Url = $Site.Url + Title = $Site.Title + Template = $Site.Template + # Sharing + SharingCapability = $SharingCapabilityNames[[int]$Site.SharingCapability] ?? $Site.SharingCapability + DefaultSharingLinkType = $LinkTypeNames[[int]$Site.DefaultSharingLinkType] ?? $Site.DefaultSharingLinkType + DefaultLinkPermission = $LinkPermissionNames[[int]$Site.DefaultLinkPermission] ?? $Site.DefaultLinkPermission + SharingDomainRestrictionMode = $DomainRestrictionNames[[int]$Site.SharingDomainRestrictionMode] ?? $Site.SharingDomainRestrictionMode + SharingAllowedDomainList = $Site.SharingAllowedDomainList + SharingBlockedDomainList = $Site.SharingBlockedDomainList + OverrideTenantAnonymousLinkExpirationPolicy = [bool]$Site.OverrideTenantAnonymousLinkExpirationPolicy + AnonymousLinkExpirationInDays = $Site.AnonymousLinkExpirationInDays + # Lifecycle + LockState = $Site.LockState + StorageMaximumLevel = $Site.StorageMaximumLevel + StorageWarningLevel = $Site.StorageWarningLevel + StorageUsage = $Site.StorageUsage + # Version policy + InheritVersionPolicyFromTenant = [bool]$Site.InheritVersionPolicyFromTenant + EnableAutoExpirationVersionTrim = [bool]$Site.EnableAutoExpirationVersionTrim + MajorVersionLimit = $Site.MajorVersionLimit + ExpireVersionsAfterDays = $Site.ExpireVersionsAfterDays + } + $StatusCode = [HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Body = "Failed to get site properties for $($SiteUrl): $($ErrorMessage.NormalizedError)" + $StatusCode = [HttpStatusCode]::BadRequest + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @{ 'Results' = $Body } + }) +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSiteRecycleBin.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSiteRecycleBin.ps1 new file mode 100644 index 0000000000000..b0530cb6d2ad8 --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSiteRecycleBin.ps1 @@ -0,0 +1,54 @@ +function Invoke-ListSiteRecycleBin { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Sharepoint.SiteRecycleBin.Read + .DESCRIPTION + Lists the contents of a SharePoint site's recycle bin (first and second stage) via the + SharePoint REST API with certificate authentication. + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $TenantFilter = $Request.Query.tenantFilter + $SiteUrl = $Request.Query.SiteUrl + + $ItemTypeNames = @{ 1 = 'File'; 2 = 'File Version'; 3 = 'List Item'; 4 = 'List'; 5 = 'Folder'; 6 = 'Folder'; 7 = 'Attachment'; 8 = 'List Item Version'; 10 = 'Web' } + $ItemStateNames = @{ 1 = 'First Stage'; 2 = 'Second Stage' } + + try { + if (-not $SiteUrl) { throw 'SiteUrl is required.' } + + $SharePointInfo = Get-SharePointAdminLink -Public $false -tenantFilter $TenantFilter + $Scope = "$($SharePointInfo.SharePointUrl)/.default" + $JsonAccept = @{ Accept = 'application/json;odata=nometadata' } + $BaseUri = "$($SiteUrl.TrimEnd('/'))/_api" + + $Items = New-GraphGetRequest -uri "$BaseUri/site/RecycleBin?`$top=500&`$orderby=DeletedDate desc" -tenantid $TenantFilter -scope $Scope -extraHeaders $JsonAccept -UseCertificate -AsApp $true + + $Body = @($Items | ForEach-Object { + [PSCustomObject]@{ + Id = $_.Id + Title = $_.Title + LeafName = $_.LeafName + DirName = $_.DirName + ItemType = $ItemTypeNames[[int]$_.ItemType] ?? $_.ItemType + ItemState = $ItemStateNames[[int]$_.ItemState] ?? $_.ItemState + Size = $_.Size + DeletedByName = $_.DeletedByName + DeletedDate = $_.DeletedDate + } + }) + $StatusCode = [HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Body = "Failed to list the recycle bin for $($SiteUrl): $($ErrorMessage.NormalizedError)" + $StatusCode = [HttpStatusCode]::BadRequest + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @{ 'Results' = $Body } + }) +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecUpdateDriftDeviation.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecUpdateDriftDeviation.ps1 index 78c556cf7abef..5d87424e3b956 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecUpdateDriftDeviation.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecUpdateDriftDeviation.ps1 @@ -11,6 +11,43 @@ function Invoke-ExecUpdateDriftDeviation { $APIName = $TriggerMetadata.FunctionName Write-LogMessage -Headers $Request.Headers -API $APINAME -message 'Accessed this API' -Sev 'Debug' + function Find-CIPPTagBundleEntry { + param($Entries, $TemplateId, $TemplatePartition) + + if (-not $Entries) { return $null } + + $BundleEntry = $null + $TemplatesTable = Get-CippTable -tablename 'templates' + $LiveTemplate = Get-CIPPAzDataTableEntity @TemplatesTable -Filter "PartitionKey eq '$TemplatePartition' and RowKey eq '$TemplateId'" + if ($LiveTemplate.Package) { + $BundleEntry = $Entries | Where-Object { + $TagValues = foreach ($Tag in @($_.'TemplateList-Tags')) { + if ($Tag.value) { $Tag.value } elseif ($Tag -is [string]) { $Tag } + } + @($TagValues) -contains $LiveTemplate.Package + } | Select-Object -First 1 + } + + if (-not $BundleEntry) { + # Fallback: the stored snapshot, for templates whose live row no longer exists + $BundleEntry = $Entries | Where-Object { + ($_.'TemplateList-Tags'.rawData.templates | Where-Object { $_.GUID -like "*$TemplateId*" }) -or + ($_.'TemplateList-Tags'.addedFields.templates | Where-Object { $_.GUID -like "*$TemplateId*" }) + } | Select-Object -First 1 + } + + if ($BundleEntry) { + $Matched = $BundleEntry.PSObject.Copy() + $Matched.PSObject.Properties.Remove('TemplateList-Tags') + $Matched | Add-Member -NotePropertyName TemplateList -NotePropertyValue ([pscustomobject]@{ + label = $TemplateId + value = $TemplateId + }) -Force + return $Matched + } + return $null + } + try { $TenantFilter = $Request.Body.TenantFilter @@ -44,23 +81,15 @@ function Invoke-ExecUpdateDriftDeviation { $Setting = $Deviation.standardName -replace 'standards\.', '' $StandardTemplate = Get-CIPPTenantAlignment -TenantFilter $TenantFilter | Where-Object -Property standardType -EQ 'drift' $DriftTemplateId = $StandardTemplate.StandardId + $Settings = $null if ($Setting -like '*IntuneTemplate*') { $Setting = 'IntuneTemplate' $TemplateId = $Deviation.standardName.split('.') | Select-Object -Index 2 $MatchedTemplate = $StandardTemplate.standardSettings.IntuneTemplate | Where-Object { $_.TemplateList.value -like "*$TemplateId*" } | Select-Object -First 1 if (-not $MatchedTemplate) { - # Template may be inside a TemplateList-Tags bundle, expand it - $BundleEntry = $StandardTemplate.standardSettings.IntuneTemplate | Where-Object { - $_.'TemplateList-Tags'.rawData.templates | Where-Object { $_.GUID -like "*$TemplateId*" } - } | Select-Object -First 1 - if ($BundleEntry) { - $MatchedTemplate = $BundleEntry.PSObject.Copy() - $MatchedTemplate.PSObject.Properties.Remove('TemplateList-Tags') - $MatchedTemplate | Add-Member -NotePropertyName TemplateList -NotePropertyValue ([pscustomobject]@{ - label = $TemplateId - value = $TemplateId - }) -Force - } + # Template may be referenced through a TemplateList-Tags bundle - resolve the + # tag membership live so members added after the bundle was saved still remediate + $MatchedTemplate = Find-CIPPTagBundleEntry -Entries $StandardTemplate.standardSettings.IntuneTemplate -TemplateId $TemplateId -TemplatePartition 'IntuneTemplate' } if (-not $MatchedTemplate) { Write-LogMessage -tenant $TenantFilter -Headers $Request.Headers -API $APINAME -message "Could not find IntuneTemplate $TemplateId in drift standard settings for remediation" -Sev 'Warning' @@ -72,10 +101,18 @@ function Invoke-ExecUpdateDriftDeviation { } elseif ($Setting -like '*ConditionalAccessTemplate*') { $Setting = 'ConditionalAccessTemplate' $TemplateId = $Deviation.standardName.split('.') | Select-Object -Index 2 - $StandardTemplate = $StandardTemplate.standardSettings.ConditionalAccessTemplate | Where-Object { $_.TemplateList.value -like "*$TemplateId*" } - $StandardTemplate | Add-Member -MemberType NoteProperty -Name 'remediate' -Value $true -Force - $StandardTemplate | Add-Member -MemberType NoteProperty -Name 'report' -Value $true -Force - $Settings = $StandardTemplate + $MatchedTemplate = $StandardTemplate.standardSettings.ConditionalAccessTemplate | Where-Object { $_.TemplateList.value -like "*$TemplateId*" } | Select-Object -First 1 + if (-not $MatchedTemplate) { + # CA templates can be referenced through a TemplateList-Tags bundle too + $MatchedTemplate = Find-CIPPTagBundleEntry -Entries $StandardTemplate.standardSettings.ConditionalAccessTemplate -TemplateId $TemplateId -TemplatePartition 'CATemplate' + } + if (-not $MatchedTemplate) { + Write-LogMessage -tenant $TenantFilter -Headers $Request.Headers -API $APINAME -message "Could not find ConditionalAccessTemplate $TemplateId in drift standard settings for remediation" -Sev 'Warning' + } else { + $MatchedTemplate | Add-Member -MemberType NoteProperty -Name 'remediate' -Value $true -Force + $MatchedTemplate | Add-Member -MemberType NoteProperty -Name 'report' -Value $true -Force + $Settings = $MatchedTemplate + } } else { $StandardTemplate = $StandardTemplate.standardSettings.$Setting # If the addedComponent values are stored nested under standards. instead of @@ -90,52 +127,62 @@ function Invoke-ExecUpdateDriftDeviation { $StandardTemplate | Add-Member -MemberType NoteProperty -Name 'report' -Value $true -Force $Settings = $StandardTemplate } - $TaskBody = @{ - TenantFilter = $TenantFilter - Name = "One Off Drift Remediation: $Setting - $TenantFilter - $DriftTemplateId" - Tag = "DriftRemediation_$DriftTemplateId" - Command = @{ - value = "Invoke-CIPPStandard$Setting" - label = "Invoke-CIPPStandard$Setting" - } - - Parameters = [pscustomobject]@{ - Tenant = $TenantFilter - Settings = $Settings - } - ScheduledTime = '0' - PostExecution = @{ - Webhook = $false - Email = $false - PSA = $false - } - } - Add-CIPPScheduledTask -Task $TaskBody -hidden $false - Write-LogMessage -tenant $TenantFilter -Headers $Request.Headers -API $APINAME -message "Scheduled drift remediation task for $Setting" -Sev 'Info' - - if ($PersistentDeny) { - $PersistentTaskBody = @{ + if ($Settings) { + $TaskBody = @{ TenantFilter = $TenantFilter - Name = "Persistent Drift Remediation: $Setting - $TenantFilter - $DriftTemplateId" + Name = "One Off Drift Remediation: $Setting - $TenantFilter - $DriftTemplateId" Tag = "DriftRemediation_$DriftTemplateId" Command = @{ value = "Invoke-CIPPStandard$Setting" label = "Invoke-CIPPStandard$Setting" } + Parameters = [pscustomobject]@{ Tenant = $TenantFilter Settings = $Settings } ScheduledTime = '0' - Recurrence = '12h' PostExecution = @{ Webhook = $false Email = $false PSA = $false } } - Add-CIPPScheduledTask -Task $PersistentTaskBody -hidden $false - Write-LogMessage -tenant $TenantFilter -Headers $Request.Headers -API $APINAME -message "Scheduled persistent drift remediation task (12h recurrence) for $Setting" -Sev 'Info' + Add-CIPPScheduledTask -Task $TaskBody -hidden $false + Write-LogMessage -tenant $TenantFilter -Headers $Request.Headers -API $APINAME -message "Scheduled drift remediation task for $Setting" -Sev 'Info' + + if ($PersistentDeny) { + $PersistentTaskBody = @{ + TenantFilter = $TenantFilter + Name = "Persistent Drift Remediation: $Setting - $TenantFilter - $DriftTemplateId" + Tag = "DriftRemediation_$DriftTemplateId" + Command = @{ + value = "Invoke-CIPPStandard$Setting" + label = "Invoke-CIPPStandard$Setting" + } + Parameters = [pscustomobject]@{ + Tenant = $TenantFilter + Settings = $Settings + } + ScheduledTime = '0' + Recurrence = '12h' + PostExecution = @{ + Webhook = $false + Email = $false + PSA = $false + } + } + Add-CIPPScheduledTask -Task $PersistentTaskBody -hidden $false + Write-LogMessage -tenant $TenantFilter -Headers $Request.Headers -API $APINAME -message "Scheduled persistent drift remediation task (12h recurrence) for $Setting" -Sev 'Info' + } + } else { + # Surface the failure to the caller - previously this silently scheduled a + # remediation task with empty settings that could never do anything + [PSCustomObject]@{ + standardName = $Deviation.standardName + success = $false + error = "The deviation status was updated, but no remediation task was scheduled: the template could not be resolved from the drift template settings. Verify the template still exists in the template library, or re-save the drift template." + } } } if ($Deviation.status -eq 'deniedDelete') { diff --git a/Shared/CIPPSharp/CIPPTestDataCache.cs b/Shared/CIPPSharp/CIPPTestDataCache.cs index d92db61483e75..da2538ec5ba2d 100644 --- a/Shared/CIPPSharp/CIPPTestDataCache.cs +++ b/Shared/CIPPSharp/CIPPTestDataCache.cs @@ -4,7 +4,6 @@ using System.Collections.Generic; using System.Linq; using System.Reflection; -using System.Text.Json; using System.Threading; using System.Threading.Tasks; @@ -14,16 +13,32 @@ namespace CIPP /// Host-scoped, thread-safe LRU cache for test data lookups. The DLL is /// loaded once per Azure Functions host, so every PowerShell worker /// process on that host shares this exact instance. Bounded by both a - /// byte-size cap (default 100 MB) and a short TTL (default 5 minutes) + /// byte-size cap (default 300 MB) and a short TTL (default 5 minutes) /// so that test suites running against a single tenant get fast cache /// hits without accumulating stale Gen2 roots that cause GC thrashing. /// public static class TestDataCache { // ── Configuration ── - private static long _maxBytes = 100L * 1024 * 1024; // 100 MB default + // Defaults are 300 MB / 5 min. Both are overridable per deployment via + // Azure Functions app settings (environment variables) resolved once at + // load, or programmatically via Configure() for tests. The cap now maps to + // real managed-heap bytes (see EstimateValueSize), so 300 MB means ~300 MB. + private static long _maxBytes = 300L * 1024 * 1024; // 300 MB default private static TimeSpan _ttl = TimeSpan.FromMinutes(5); + static TestDataCache() + { + try + { + if (long.TryParse(Environment.GetEnvironmentVariable("CIPPTestCacheMaxMB"), out var maxMB) && maxMB > 0) + _maxBytes = maxMB * 1024L * 1024L; + if (int.TryParse(Environment.GetEnvironmentVariable("CIPPTestCacheTtlSeconds"), out var ttlSec) && ttlSec > 0) + _ttl = TimeSpan.FromSeconds(ttlSec); + } + catch { /* keep defaults if app settings are unreadable */ } + } + // ── State ── private static readonly ConcurrentDictionary _cache = new(); private static readonly LinkedList _lruOrder = new(); // head = oldest @@ -56,7 +71,7 @@ public CacheEntry(object? value, long sizeBytes, DateTime expiresUtc) } /// Configure the cache limits. Call before first use or between test runs. - public static void Configure(long maxBytes = 100L * 1024 * 1024, int ttlSeconds = 300) + public static void Configure(long maxBytes = 300L * 1024 * 1024, int ttlSeconds = 300) { _maxBytes = maxBytes; _ttl = TimeSpan.FromSeconds(ttlSeconds); @@ -258,8 +273,31 @@ private static void TryFireBackgroundSweep() private static PropertyInfo? s_psPropsProp; // PSObject.Properties private static PropertyInfo? s_psPropName; // PSPropertyInfo.Name private static PropertyInfo? s_psPropValue; // PSPropertyInfo.Value - private const int SampleSize = 5; - private const int MaxDepth = 4; + // ── Managed-heap size model (x64 CoreCLR) ── + // The cache stores *parsed PSObject graphs*, whose live heap footprint is + // many times their JSON text size (measured ≈8× on real Graph data). Sizing + // by JSON serialization therefore under-counts by ~8× and lets the byte cap + // balloon far past its nominal limit. Instead we walk the object graph and + // sum approximate per-object heap costs. Constants below are calibrated + // against real GC heap growth (see tools/measure-testcache.ps1). + private const int ObjectHeader = 16; // sync block + method table ptr + private const int RefSlot = 8; // one reference (array/field slot) + private const int StringBaseOverhead = 22; // header(16)+length(4)+terminator(2) + private const int BoxedPrimitive = 24; // header(16)+<=8 payload, aligned + private const int BoxedLarge = 32; // header(16)+16 payload (decimal/Guid) + private const int PSObjectBase = 192; // PSObject wrapper + base + member collections + private const int PSNotePropertyOverhead = 132; // PSNoteProperty + name-lookup entry (excl. name string + value) + private const int DictBase = 80; // buckets + entries baseline + private const int DictEntryOverhead = 48; // per-entry slot incl. capacity slack (excl. key + value) + private const int CollectionBase = 56; // list/array object + backing array baseline + + // Bound the walk on pathological payloads: full-walk small collections for + // accuracy, but stride-sample very large ones so a 100k-item array can't + // turn a single Set() into a multi-second reflection storm. + private const int LargeCollectionThreshold = 512; + private const int LargeCollectionSamples = 256; + private const int MaxDepth = 32; + private const long NodeBudget = 2_000_000; // hard ceiling on nodes visited per Set() private static void EnsurePSResolved() { @@ -289,82 +327,127 @@ private static void EnsurePSResolved() } } + private static long Align8(long bytes) => (bytes + 7) & ~7L; + /// - /// Estimate the serialized size of a cached value. For large collections, - /// samples a few items and extrapolates. Handles PSObject by unwrapping - /// NoteProperties into dictionaries via reflection. + /// Estimate the live managed-heap footprint of a cached value by walking the + /// object graph and summing approximate per-object costs. This tracks real + /// memory (parsed PSObjects, boxed primitives, strings, dictionaries and + /// collections) rather than JSON text size, so the byte cap corresponds to + /// actual process memory. is accepted for call + /// compatibility but the walk recomputes counts itself. /// private static long EstimateValueSize(object? value, int itemCount) { if (value == null) return 0; EnsurePSResolved(); - try { - // Large collection: sample a few items, extrapolate - if (itemCount > SampleSize && value is IEnumerable enumerable) - { - var sample = new List(); - foreach (var item in enumerable) - { - sample.Add(Unwrap(item, 0)); - if (sample.Count >= SampleSize) break; - } - if (sample.Count == 0) return 0; - var sampleBytes = JsonSerializer.SerializeToUtf8Bytes(sample).LongLength; - return sampleBytes * itemCount / sample.Count; - } - - // Small collection or single value: unwrap everything - var unwrapped = Unwrap(value, 0); - return JsonSerializer.SerializeToUtf8Bytes(unwrapped).LongLength; + long budget = NodeBudget; + return SizeOf(value, 0, ref budget); } catch { return 0; } } /// - /// Recursively unwrap PSObject → Dictionary and IEnumerable → List - /// so System.Text.Json can serialize them. + /// Recursively sum the approximate managed-heap bytes of + /// and everything it owns. Returns the object's own heap size; the reference + /// slot that points at it is accounted for by the owning object/collection. /// - private static object? Unwrap(object? value, int depth) + private static long SizeOf(object? value, int depth, ref long budget) { - if (value == null || depth > MaxDepth) return value; + if (value == null) return 0; + if (--budget <= 0 || depth > MaxDepth) return 0; - // PSObject → extract NoteProperties as Dictionary + switch (value) + { + case string s: + return Align8(StringBaseOverhead + 2L * s.Length); + case bool: case byte: case sbyte: case char: + case short: case ushort: case int: case uint: + case long: case ulong: case float: case double: + case DateTime: case DateTimeOffset: case TimeSpan: case Enum: + return BoxedPrimitive; + case decimal: case Guid: + return BoxedLarge; + case byte[] bytes: + return Align8(ObjectHeader + RefSlot + bytes.LongLength); + } + + // PSObject → wrapper cost + each NoteProperty (name string + value + overhead) if (s_psObjectType != null && s_psObjectType.IsInstanceOfType(value)) - return UnwrapPSObject(value, depth); + return SizeOfPSObject(value, depth, ref budget); + + // Dictionary / Hashtable → base + per-entry overhead + keys + values + if (value is IDictionary dict) + { + long total = ObjectHeader + DictBase; + foreach (DictionaryEntry de in dict) + { + total += DictEntryOverhead; + total += SizeOf(de.Key, depth + 1, ref budget); + total += SizeOf(de.Value, depth + 1, ref budget); + if (budget <= 0) break; + } + return total; + } - // Collection → unwrap each element - if (value is IEnumerable enumerable && value is not string && value is not byte[]) + // Other collections (object[], List, …). Stride-sample very large + // ones to bound the walk; full-walk the rest for accuracy. + if (value is IEnumerable enumerable && value is not string) { - var list = new List(); + int count = value is ICollection col ? col.Count : -1; + + if (count > LargeCollectionThreshold && value is IList list) + { + long step = count / (long)LargeCollectionSamples; + if (step < 1) step = 1; + long sampled = 0, sampledBytes = 0; + for (long i = 0; i < count && sampled < LargeCollectionSamples; i += step) + { + sampledBytes += SizeOf(list[(int)i], depth + 1, ref budget); + sampled++; + if (budget <= 0) break; + } + long avgItem = sampled > 0 ? sampledBytes / sampled : 0; + // backing array slots + extrapolated element payloads + return ObjectHeader + CollectionBase + count * (RefSlot + avgItem); + } + + long total = ObjectHeader + CollectionBase; foreach (var item in enumerable) - list.Add(Unwrap(item, depth + 1)); - return list; + { + total += RefSlot; + total += SizeOf(item, depth + 1, ref budget); + if (budget <= 0) break; + } + return total; } - return value; + // Unknown reference type: header plus a small nominal for its fields. + return ObjectHeader + 32; } - private static Dictionary UnwrapPSObject(object psObj, int depth) + private static long SizeOfPSObject(object psObj, int depth, ref long budget) { - var dict = new Dictionary(); + long total = PSObjectBase; if (s_psPropsProp == null || s_psPropName == null || s_psPropValue == null) - return dict; - - if (s_psPropsProp.GetValue(psObj) is not IEnumerable props) return dict; + return total; + if (s_psPropsProp.GetValue(psObj) is not IEnumerable props) return total; foreach (var prop in props) { + if (--budget <= 0) break; + total += PSNotePropertyOverhead; try { - var name = s_psPropName.GetValue(prop)?.ToString(); - if (name != null) - dict[name] = Unwrap(s_psPropValue.GetValue(prop), depth + 1); + if (s_psPropName.GetValue(prop) is string name) + total += Align8(StringBaseOverhead + 2L * name.Length); + total += SizeOf(s_psPropValue.GetValue(prop), depth + 1, ref budget); } catch { /* skip properties that throw on access */ } } - return dict; + return total; } /// From 7c86a6a91399bc116bb81df0a14bcd8b4c982784 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Fri, 10 Jul 2026 12:58:58 +0200 Subject: [PATCH 164/170] fix for template names when cant be found --- .../Public/Functions/Get-CIPPTenantAlignment.ps1 | 9 +++++++-- Modules/CIPPCore/Public/Get-CIPPDrift.ps1 | 10 ++++++++++ .../Standards/Invoke-ExecUpdateDriftDeviation.ps1 | 14 ++++++++++++-- 3 files changed, 29 insertions(+), 4 deletions(-) diff --git a/Modules/CIPPCore/Public/Functions/Get-CIPPTenantAlignment.ps1 b/Modules/CIPPCore/Public/Functions/Get-CIPPTenantAlignment.ps1 index 38c19947804b4..240e77e0aec6d 100644 --- a/Modules/CIPPCore/Public/Functions/Get-CIPPTenantAlignment.ps1 +++ b/Modules/CIPPCore/Public/Functions/Get-CIPPTenantAlignment.ps1 @@ -228,7 +228,10 @@ function Get-CIPPTenantAlignment { $TagValue = if ($Tag.value) { $Tag.value } else { $Tag } $TagTemplate = if ($TagValue -and $TemplatesByPackage.ContainsKey($TagValue)) { $TemplatesByPackage[$TagValue] } else { @() } $TagTemplate | ForEach-Object { - $TagStandardId = "standards.IntuneTemplate.$($_.GUID)" + # RowKey, not the GUID column: the standards engine deploys tag members with + # TemplateList.value = RowKey and writes the compare row under that id, so the + # expected id must match it. The GUID column can be missing or stale. + $TagStandardId = "standards.IntuneTemplate.$($_.RowKey)" [PSCustomObject]@{ StandardId = $TagStandardId ReportingEnabled = $IntuneReportingEnabled @@ -260,7 +263,9 @@ function Get-CIPPTenantAlignment { $TagValue = if ($Tag.value) { $Tag.value } else { $Tag } $TagTemplate = if ($CATemplatesByPackage.ContainsKey($TagValue)) { $CATemplatesByPackage[$TagValue] } else { @() } $TagTemplate | ForEach-Object { - $TagStandardId = "standards.ConditionalAccessTemplate.$($_.GUID)" + # RowKey, not the GUID column - must match the id the standards engine + # deploys with and writes the compare row under (see Intune block above) + $TagStandardId = "standards.ConditionalAccessTemplate.$($_.RowKey)" [PSCustomObject]@{ StandardId = $TagStandardId ReportingEnabled = $CAReportingEnabled diff --git a/Modules/CIPPCore/Public/Get-CIPPDrift.ps1 b/Modules/CIPPCore/Public/Get-CIPPDrift.ps1 index 86f65813db4d6..0a5fc8b318b1a 100644 --- a/Modules/CIPPCore/Public/Get-CIPPDrift.ps1 +++ b/Modules/CIPPCore/Public/Get-CIPPDrift.ps1 @@ -56,6 +56,11 @@ function Get-CIPPDrift { $data | Add-Member -NotePropertyName 'Type' -NotePropertyValue $JSONData.Type -Force $data | Add-Member -NotePropertyName 'GUID' -NotePropertyValue $RawTemplate.RowKey -Force $IntuneTemplatesByGuid[$RawTemplate.RowKey] = $data + # Built-in templates are seeded with RowKey = '.IntuneTemplate.json'; also index + # by the bare guid so display-name lookups that extract the guid from a standard key hit + if ($RawTemplate.RowKey -match '^([0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12})\.' -and -not $IntuneTemplatesByGuid.ContainsKey($Matches[1])) { + $IntuneTemplatesByGuid[$Matches[1]] = $data + } $data } catch { # Skip invalid templates @@ -80,6 +85,11 @@ function Get-CIPPDrift { $data = $RawTemplate.JSON | ConvertFrom-Json -Depth 100 -ErrorAction SilentlyContinue $data | Add-Member -NotePropertyName 'GUID' -NotePropertyValue $RawTemplate.RowKey -Force $CATemplatesByGuid[$RawTemplate.RowKey] = $data + # Built-in templates are seeded with RowKey = '.CATemplate.json'; also index + # by the bare guid so display-name lookups that extract the guid from a standard key hit + if ($RawTemplate.RowKey -match '^([0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12})\.' -and -not $CATemplatesByGuid.ContainsKey($Matches[1])) { + $CATemplatesByGuid[$Matches[1]] = $data + } $data } catch { # Skip invalid templates diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecUpdateDriftDeviation.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecUpdateDriftDeviation.ps1 index 5d87424e3b956..152e87c5515f9 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecUpdateDriftDeviation.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ExecUpdateDriftDeviation.ps1 @@ -19,6 +19,12 @@ function Invoke-ExecUpdateDriftDeviation { $BundleEntry = $null $TemplatesTable = Get-CippTable -tablename 'templates' $LiveTemplate = Get-CIPPAzDataTableEntity @TemplatesTable -Filter "PartitionKey eq '$TemplatePartition' and RowKey eq '$TemplateId'" + if (-not $LiveTemplate) { + # Built-in templates are keyed as '.IntuneTemplate.json' / '.CATemplate.json', + # while deviation ids may carry the bare guid - match by prefix as a fallback + $LiveTemplate = Get-CIPPAzDataTableEntity @TemplatesTable -Filter "PartitionKey eq '$TemplatePartition'" | + Where-Object { $_.RowKey -like "$TemplateId*" } | Select-Object -First 1 + } if ($LiveTemplate.Package) { $BundleEntry = $Entries | Where-Object { $TagValues = foreach ($Tag in @($_.'TemplateList-Tags')) { @@ -84,7 +90,9 @@ function Invoke-ExecUpdateDriftDeviation { $Settings = $null if ($Setting -like '*IntuneTemplate*') { $Setting = 'IntuneTemplate' - $TemplateId = $Deviation.standardName.split('.') | Select-Object -Index 2 + # Take everything after the prefix: template ids can contain dots + # (built-in templates are keyed '.IntuneTemplate.json') + $TemplateId = $Deviation.standardName -replace '^(standards\.)?IntuneTemplates?\.', '' $MatchedTemplate = $StandardTemplate.standardSettings.IntuneTemplate | Where-Object { $_.TemplateList.value -like "*$TemplateId*" } | Select-Object -First 1 if (-not $MatchedTemplate) { # Template may be referenced through a TemplateList-Tags bundle - resolve the @@ -100,7 +108,9 @@ function Invoke-ExecUpdateDriftDeviation { } } elseif ($Setting -like '*ConditionalAccessTemplate*') { $Setting = 'ConditionalAccessTemplate' - $TemplateId = $Deviation.standardName.split('.') | Select-Object -Index 2 + # Take everything after the prefix: template ids can contain dots + # (built-in templates are keyed '.CATemplate.json') + $TemplateId = $Deviation.standardName -replace '^(standards\.)?ConditionalAccessTemplates?\.', '' $MatchedTemplate = $StandardTemplate.standardSettings.ConditionalAccessTemplate | Where-Object { $_.TemplateList.value -like "*$TemplateId*" } | Select-Object -First 1 if (-not $MatchedTemplate) { # CA templates can be referenced through a TemplateList-Tags bundle too From 36759b2d83748b427a32d573f124c5efc549b360 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Fri, 10 Jul 2026 13:05:58 +0200 Subject: [PATCH 165/170] fixed in dev --- .../Standards/Invoke-CIPPStandardDeployMailContact.ps1 | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardDeployMailContact.ps1 b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardDeployMailContact.ps1 index a6ddad102a4dd..f861da92045e9 100644 --- a/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardDeployMailContact.ps1 +++ b/Modules/CIPPStandards/Public/Standards/Invoke-CIPPStandardDeployMailContact.ps1 @@ -120,15 +120,18 @@ function Invoke-CIPPStandardDeployMailContact { # Report if ($Settings.report -eq $true) { + # Email addresses are compared lowercased on both sides: Exchange re-cases the domain part + # when it creates the contact (support@mydomain.com becomes support@Mydomain.com), which + # would otherwise fail the case-sensitive comparison forever. $ContactData = @{ DisplayName = $Settings.DisplayName - ExternalEmailAddress = $Settings.ExternalEmailAddress + ExternalEmailAddress = ([string]$Settings.ExternalEmailAddress).ToLower() FirstName = $Settings.FirstName ?? '' LastName = $Settings.LastName ?? '' } $currentValue = @{ DisplayName = $ExistingContactLookup.displayName - ExternalEmailAddress = ($ExistingContact.ExternalEmailAddress -replace 'SMTP:', '') + ExternalEmailAddress = ([string]($ExistingContact.ExternalEmailAddress -replace 'SMTP:', '')).ToLower() FirstName = $ExistingContactLookup.givenName ?? '' LastName = $ExistingContactLookup.surname ?? '' } From 3777ac22bdeb5c7fbe3e0b46930a714ef91f2787 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Fri, 10 Jul 2026 14:48:07 +0200 Subject: [PATCH 166/170] rollup multiple versions of same tool --- .../Tenant/Standards/Invoke-ListShadowAI.ps1 | 54 ++++++++++++++----- 1 file changed, 41 insertions(+), 13 deletions(-) diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ListShadowAI.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ListShadowAI.ps1 index ba68e810df751..ff806f1c5aaa2 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ListShadowAI.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Standards/Invoke-ListShadowAI.ps1 @@ -68,29 +68,57 @@ function Invoke-ListShadowAI { $EntraSynced = $CacheData['ServicePrincipals'].Count -gt 0 $LastDataRefresh = $CacheTimestamps | Sort-Object | Select-Object -First 1 - # 1) Installed AI tools from the cached Intune detected apps - $DetectedApps = [System.Collections.Generic.List[object]]::new() + # 1) Installed AI tools from the cached Intune detected apps. The inventory reports a separate + # application entry per version (and per install flavor, e.g. 'Copilot' vs 'Microsoft.Copilot'), + # so merge everything that matches the same catalog tool into ONE row: distinct devices only, + # with the observed application names, versions and platforms combined. + $DetectedAppMap = [ordered]@{} foreach ($App in $CacheData['DetectedApps']) { $Match = Get-AiMatch -Text "$($App.displayName) $($App.publisher)" -Catalog $Catalog if (-not $Match) { continue } - $DeviceCount = [int]($App.deviceCount ?? 0) - if ($DeviceCount -eq 0 -and $App.managedDevices) { $DeviceCount = @($App.managedDevices).Count } - $IsSanctioned = $SanctionedTools.ContainsKey($Match.name.ToLower()) + if (-not $DetectedAppMap.Contains($Match.name)) { + $DetectedAppMap[$Match.name] = [PSCustomObject]@{ + Match = $Match + Sanctioned = $SanctionedTools.ContainsKey($Match.name.ToLower()) + Applications = [System.Collections.Generic.List[string]]::new() + Publishers = [System.Collections.Generic.List[string]]::new() + Versions = [System.Collections.Generic.List[string]]::new() + Platforms = [System.Collections.Generic.List[string]]::new() + Devices = [ordered]@{} + } + } + $Entry = $DetectedAppMap[$Match.name] + if ($App.displayName -and $Entry.Applications -notcontains [string]$App.displayName) { $Entry.Applications.Add([string]$App.displayName) } + if ($App.publisher -and $Entry.Publishers -notcontains [string]$App.publisher) { $Entry.Publishers.Add([string]$App.publisher) } + if ($App.version -and $Entry.Versions -notcontains [string]$App.version) { $Entry.Versions.Add([string]$App.version) } + $Platform = if ([string]::IsNullOrWhiteSpace($App.platform)) { 'Unknown' } else { [string]$App.platform } + if ($Entry.Platforms -notcontains $Platform) { $Entry.Platforms.Add($Platform) } + foreach ($Device in @($App.managedDevices ?? @())) { + $DeviceKey = if ($Device.id) { [string]$Device.id } else { [string]$Device.deviceName } + if ($DeviceKey -and -not $Entry.Devices.Contains($DeviceKey)) { $Entry.Devices[$DeviceKey] = $Device } + } + } + + $DetectedApps = [System.Collections.Generic.List[object]]::new() + foreach ($Entry in $DetectedAppMap.Values) { + $Match = $Entry.Match + # Inventory rows mix clean publisher names with full certificate subjects - show the shortest + $Publisher = $Entry.Publishers | Sort-Object -Property Length | Select-Object -First 1 $DetectedApps.Add([PSCustomObject]@{ - application = $App.displayName + application = ($Entry.Applications | Sort-Object) -join ', ' aiTool = $Match.name vendor = $Match.vendor category = $Match.category - risk = if ($IsSanctioned) { 'Informational' } else { $Match.risk } + risk = if ($Entry.Sanctioned) { 'Informational' } else { $Match.risk } catalogRisk = $Match.risk - status = if ($IsSanctioned) { 'Sanctioned' } else { 'Unsanctioned' } + status = if ($Entry.Sanctioned) { 'Sanctioned' } else { 'Unsanctioned' } toolDescription = $Match.description riskReason = $Match.riskReason - publisher = $App.publisher - version = $App.version - platform = if ([string]::IsNullOrWhiteSpace($App.platform)) { 'Unknown' } else { $App.platform } - deviceCount = $DeviceCount - managedDevices = @($App.managedDevices ?? @()) + publisher = $Publisher + version = ($Entry.Versions | Sort-Object) -join ', ' + platform = ($Entry.Platforms | Sort-Object) -join ', ' + deviceCount = $Entry.Devices.Count + managedDevices = @($Entry.Devices.Values) }) } From 06d0bd80f12167223b1fd2e5b12cce7e27e723bf Mon Sep 17 00:00:00 2001 From: John Duprey Date: Fri, 10 Jul 2026 10:03:03 -0400 Subject: [PATCH 167/170] feat: add functions to manage SharePoint external users and site user removal --- .../Public/Get-CIPPSPOExternalUsers.ps1 | 56 ++++++ .../Public/Remove-CIPPSPOSiteUser.ps1 | 74 ++++++++ .../Invoke-ExecRemoveSPOExternalUser.ps1 | 85 +++++++++ .../Invoke-ExecRemoveSiteUser.ps1 | 46 ++--- .../Invoke-ListSharePointExternalUsers.ps1 | 164 ++++++++++++++++++ 5 files changed, 397 insertions(+), 28 deletions(-) create mode 100644 Modules/CIPPCore/Public/Get-CIPPSPOExternalUsers.ps1 create mode 100644 Modules/CIPPCore/Public/Remove-CIPPSPOSiteUser.ps1 create mode 100644 Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecRemoveSPOExternalUser.ps1 create mode 100644 Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSharePointExternalUsers.ps1 diff --git a/Modules/CIPPCore/Public/Get-CIPPSPOExternalUsers.ps1 b/Modules/CIPPCore/Public/Get-CIPPSPOExternalUsers.ps1 new file mode 100644 index 0000000000000..522f788fed179 --- /dev/null +++ b/Modules/CIPPCore/Public/Get-CIPPSPOExternalUsers.ps1 @@ -0,0 +1,56 @@ +function Get-CIPPSPOExternalUsers { + <# + .SYNOPSIS + List the SharePoint tenant external users store via CSOM + + .DESCRIPTION + Enumerates every external user SharePoint Online knows about (the store behind + Get-SPOExternalUser), using the CSOM Office365Tenant.GetExternalUsers method against the + admin endpoint. This includes legacy email-authenticated guests (urn:spo:guest) that have + no backing Entra object, and B2B guests whose Entra user may since have been deleted. + + .PARAMETER TenantFilter + Tenant to query + + .EXAMPLE + Get-CIPPSPOExternalUsers -TenantFilter 'contoso.onmicrosoft.com' + + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter + ) + + $SharePointInfo = Get-SharePointAdminLink -Public $false -tenantFilter $TenantFilter + $AdminUrl = $SharePointInfo.AdminUrl + $AdditionalHeaders = @{ 'Accept' = 'application/json;odata=verbose' } + + $AllUsers = [System.Collections.Generic.List[object]]::new() + $Position = 0 + $PageSize = 50 + + do { + # Office365Tenant (TenantManagement) constructor -> GetExternalUsers(position, pageSize, filter, sortOrder) + $XML = @" +$Position$PageSize0 +"@ + + $Results = New-GraphPostRequest -scope "$AdminUrl/.default" -tenantid $TenantFilter -Uri "$AdminUrl/_vti_bin/client.svc/ProcessQuery" -Type POST -Body $XML -ContentType 'text/xml' -AddedHeaders $AdditionalHeaders + + $CsomError = ($Results | Where-Object { $_.ErrorInfo } | Select-Object -First 1).ErrorInfo.ErrorMessage + if ($CsomError) { throw $CsomError } + + $ResultObject = $Results | Where-Object { $null -ne $_.TotalUserCount } | Select-Object -First 1 + $Batch = @($ResultObject.ExternalUserCollection._Child_Items_) + foreach ($User in $Batch) { + [void]$AllUsers.Add($User) + } + $Position = $ResultObject.UserCollectionPosition + # Continue while SPO reports more users beyond the current position. + } while ($Batch.Count -eq $PageSize -and $Position -ge 0 -and $AllUsers.Count -lt [int]$ResultObject.TotalUserCount) + + return $AllUsers +} diff --git a/Modules/CIPPCore/Public/Remove-CIPPSPOSiteUser.ps1 b/Modules/CIPPCore/Public/Remove-CIPPSPOSiteUser.ps1 new file mode 100644 index 0000000000000..f62defdce7a55 --- /dev/null +++ b/Modules/CIPPCore/Public/Remove-CIPPSPOSiteUser.ps1 @@ -0,0 +1,74 @@ +function Remove-CIPPSPOSiteUser { + <# + .SYNOPSIS + Remove a user from one or more SharePoint sites entirely + + .DESCRIPTION + Deletes the user from each site's user list via the SharePoint REST API with certificate + authentication, which removes them from every site group and direct permission grant on + that site at once. Site collection admins are refused (remove their admin flag first). + + .PARAMETER TenantFilter + Tenant the sites belong to + + .PARAMETER SiteUrls + One or more site URLs to remove the user from + + .PARAMETER LoginName + SharePoint claims login (i:0#.f|membership|upn); a bare UPN is converted automatically + + .EXAMPLE + Remove-CIPPSPOSiteUser -TenantFilter 'contoso.onmicrosoft.com' -SiteUrls @('https://contoso.sharepoint.com/sites/HR') -LoginName 'guest_example.com#ext#@contoso.onmicrosoft.com' + + .FUNCTIONALITY + Internal + #> + [CmdletBinding(SupportsShouldProcess = $true)] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + [Parameter(Mandatory = $true)] + [string[]]$SiteUrls, + [Parameter(Mandatory = $true)] + [string]$LoginName + ) + + if ($LoginName -notmatch '\|') { $LoginName = "i:0#.f|membership|$LoginName" } + + $SharePointInfo = Get-SharePointAdminLink -Public $false -tenantFilter $TenantFilter + $Scope = "$($SharePointInfo.SharePointUrl)/.default" + $JsonAccept = @{ Accept = 'application/json;odata=nometadata' } + + $Succeeded = [System.Collections.Generic.List[string]]::new() + $Failed = [System.Collections.Generic.List[string]]::new() + + foreach ($SiteUrl in $SiteUrls) { + if (-not $PSCmdlet.ShouldProcess($SiteUrl, "Remove $LoginName")) { continue } + $BaseUri = "$($SiteUrl.TrimEnd('/'))/_api" + try { + try { + $EnsureBody = ConvertTo-Json -Compress -InputObject @{ logonName = $LoginName } + $EnsuredUser = New-GraphPostRequest -uri "$BaseUri/web/ensureuser" -tenantid $TenantFilter -scope $Scope -type POST -body $EnsureBody -contentType 'application/json;odata=nometadata' -AddedHeaders $JsonAccept -UseCertificate -AsApp $true + } catch { + throw "could not resolve the user (ensureuser): $($_.Exception.Message)" + } + if (-not $EnsuredUser.Id) { throw 'could not resolve the user on the site.' } + if ($EnsuredUser.IsSiteAdmin) { + throw 'user is a site collection admin; remove their admin permission first (Remove Site Admin action).' + } + try { + $null = New-GraphPostRequest -uri "$BaseUri/web/siteusers/removebyid($($EnsuredUser.Id))" -tenantid $TenantFilter -scope $Scope -type POST -body '{}' -contentType 'application/json;odata=nometadata' -AddedHeaders $JsonAccept -UseCertificate -AsApp $true + } catch { + throw "removal failed: $($_.Exception.Message)" + } + $Succeeded.Add($SiteUrl) + } catch { + $Failed.Add("$($SiteUrl): $($_.Exception.Message)") + } + } + + return [PSCustomObject]@{ + Succeeded = @($Succeeded) + Failed = @($Failed) + } +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecRemoveSPOExternalUser.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecRemoveSPOExternalUser.ps1 new file mode 100644 index 0000000000000..945cd60f15cdf --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecRemoveSPOExternalUser.ps1 @@ -0,0 +1,85 @@ +function Invoke-ExecRemoveSPOExternalUser { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Sharepoint.Site.ReadWrite + .DESCRIPTION + Fully removes an external user's guest access: deletes their Entra guest account (when + one exists) AND removes them from every SharePoint site they hold membership on, in one + pass - so no orphaned accounts or lingering site access are left behind. The inert + SharePoint external-store entry cannot be deleted (Microsoft deprecated + RemoveExternalUsers) and ages out on its own; sharing links the user received are + revoked separately via the Sharing Report. + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + $TenantFilter = $Request.Body.tenantFilter + $EntraUserId = $Request.Body.EntraUserId + $LoginName = $Request.Body.LoginName + $SiteUrls = @($Request.Body.SiteUrls) | Where-Object { $_ } + $DisplayName = $Request.Body.DisplayName ?? $EntraUserId ?? $LoginName + + try { + if (-not $EntraUserId -and $SiteUrls.Count -eq 0) { + throw 'This entry has no Entra guest account and no known site memberships. The remaining SharePoint store entry cannot be removed (Microsoft deprecated the API) and ages out on its own; revoke any sharing links they hold via the Sharing Report.' + } + + $Messages = [System.Collections.Generic.List[string]]::new() + $Errors = [System.Collections.Generic.List[string]]::new() + + # 1. Strip the SharePoint footprint first (needs the login; falls back to the Entra UPN). + if ($SiteUrls.Count -gt 0) { + $RemovalLogin = $LoginName + if (-not $RemovalLogin -and $EntraUserId) { + try { + $RemovalLogin = (New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/users/$($EntraUserId)?`$select=userPrincipalName" -tenantid $TenantFilter -AsApp $true).userPrincipalName + } catch { + $Errors.Add("Could not resolve the user's login for site removal: $($_.Exception.Message)") + } + } + if ($RemovalLogin) { + $Removal = Remove-CIPPSPOSiteUser -TenantFilter $TenantFilter -SiteUrls $SiteUrls -LoginName $RemovalLogin + if ($Removal.Succeeded.Count -gt 0) { + $Messages.Add("Removed from $($Removal.Succeeded.Count) site(s): $($Removal.Succeeded -join ', ').") + } + if ($Removal.Failed.Count -gt 0) { + $Errors.Add("Site removal failed on: $($Removal.Failed -join '; ')") + } + } + } + + # 2. Delete the Entra guest account so the user cannot sign in anywhere. + if ($EntraUserId) { + try { + $null = New-GraphPostRequest -uri "https://graph.microsoft.com/v1.0/users/$EntraUserId" -tenantid $TenantFilter -type DELETE -body '' -asapp $true + $Messages.Add('Deleted the Entra guest account, blocking their sign-in.') + } catch { + $Errors.Add("Deleting the Entra guest account failed: $($_.Exception.Message)") + } + } + + if ($Messages.Count -eq 0) { + throw ($Errors -join ' ') + } + $Results = "Removed guest access for $($DisplayName): $($Messages -join ' ')" + if ($Errors.Count -gt 0) { + $Results += " Issues: $($Errors -join '; ')" + } + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message $Results -sev Info + $StatusCode = [HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Results = "Failed to remove guest access for $($DisplayName): $($ErrorMessage.NormalizedError)" + Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message $Results -sev Error -LogData $ErrorMessage + $StatusCode = [HttpStatusCode]::BadRequest + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @{ 'Results' = $Results } + }) +} diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecRemoveSiteUser.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecRemoveSiteUser.ps1 index 693e8e993b7ab..a2315847ea6ae 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecRemoveSiteUser.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ExecRemoveSiteUser.ps1 @@ -5,10 +5,10 @@ function Invoke-ExecRemoveSiteUser { .ROLE Sharepoint.Site.ReadWrite .DESCRIPTION - Removes a user from an entire SharePoint site: deleting the site user removes them - from every site group and direct permission grant at once. Uses the SharePoint REST - API with certificate authentication. Note this does not revoke sharing links the user - received by mail; use the sharing link actions for those. + Removes a user from one or more SharePoint sites entirely: deleting the site user + removes them from every site group and direct permission grant at once. Uses the + SharePoint REST API with certificate authentication. Note this does not revoke + sharing links the user received by mail; use the sharing link actions for those. #> [CmdletBinding()] param($Request, $TriggerMetadata) @@ -16,44 +16,34 @@ function Invoke-ExecRemoveSiteUser { $APIName = $Request.Params.CIPPEndpoint $Headers = $Request.Headers $TenantFilter = $Request.Body.tenantFilter - $SiteUrl = $Request.Body.SiteUrl + # Single site (SiteUrl) or several at once (SiteUrls, e.g. from the External Users report). + $SiteUrls = @($Request.Body.SiteUrls ?? $Request.Body.SiteUrl) | Where-Object { $_ } # The picker supplies the SP claims login via addedFields; fall back to building it from the UPN. $LoginName = $Request.Body.user.addedFields.LoginName ?? $Request.Body.user.value - $Label = $Request.Body.user.value ?? $LoginName + $Label = $Request.Body.DisplayName ?? $Request.Body.user.value ?? $LoginName try { - if (-not $SiteUrl) { throw 'SiteUrl is required.' } + if ($SiteUrls.Count -eq 0) { throw 'SiteUrl is required.' } if (-not $LoginName) { throw 'No user was selected.' } - if ($LoginName -notmatch '\|') { $LoginName = "i:0#.f|membership|$LoginName" } - $SharePointInfo = Get-SharePointAdminLink -Public $false -tenantFilter $TenantFilter - $Scope = "$($SharePointInfo.SharePointUrl)/.default" - $JsonAccept = @{ Accept = 'application/json;odata=nometadata' } - $BaseUri = "$($SiteUrl.TrimEnd('/'))/_api" + $Removal = Remove-CIPPSPOSiteUser -TenantFilter $TenantFilter -SiteUrls $SiteUrls -LoginName $LoginName - try { - $EnsureBody = ConvertTo-Json -Compress -InputObject @{ logonName = $LoginName } - $EnsuredUser = New-GraphPostRequest -uri "$BaseUri/web/ensureuser" -tenantid $TenantFilter -scope $Scope -type POST -body $EnsureBody -contentType 'application/json;odata=nometadata' -AddedHeaders $JsonAccept -UseCertificate -AsApp $true - } catch { - throw "Could not resolve $Label on the site (ensureuser): $($_.Exception.Message)" + $Messages = [System.Collections.Generic.List[string]]::new() + if ($Removal.Succeeded.Count -gt 0) { + $Messages.Add("Successfully removed $Label (all site groups and direct permissions) from: $($Removal.Succeeded -join ', ').") } - if (-not $EnsuredUser.Id) { throw "Could not resolve $Label on the site." } - if ($EnsuredUser.IsSiteAdmin) { - throw "$Label is a site collection admin. Remove their admin permission first (Remove Site Admin action)." + if ($Removal.Failed.Count -gt 0) { + $Messages.Add("Failed on: $($Removal.Failed -join '; ')") } - - try { - $null = New-GraphPostRequest -uri "$BaseUri/web/siteusers/removebyid($($EnsuredUser.Id))" -tenantid $TenantFilter -scope $Scope -type POST -body '{}' -contentType 'application/json;odata=nometadata' -AddedHeaders $JsonAccept -UseCertificate -AsApp $true - } catch { - throw "Could not remove $Label from the site: $($_.Exception.Message)" + $Results = $Messages -join ' ' + if ($Removal.Succeeded.Count -eq 0) { + throw $Results } - - $Results = "Successfully removed $Label from $SiteUrl (all site groups and direct permissions)." Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message $Results -sev Info $StatusCode = [HttpStatusCode]::OK } catch { $ErrorMessage = Get-CippException -Exception $_ - $Results = "Failed to remove $Label from $($SiteUrl): $($ErrorMessage.NormalizedError)" + $Results = "Failed to remove $Label from the selected site(s): $($ErrorMessage.NormalizedError)" Write-LogMessage -Headers $Headers -API $APIName -tenant $TenantFilter -message $Results -sev Error -LogData $ErrorMessage $StatusCode = [HttpStatusCode]::BadRequest } diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSharePointExternalUsers.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSharePointExternalUsers.ps1 new file mode 100644 index 0000000000000..803dfa847989b --- /dev/null +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Teams-Sharepoint/Invoke-ListSharePointExternalUsers.ps1 @@ -0,0 +1,164 @@ +function Invoke-ListSharePointExternalUsers { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Sharepoint.Site.Read + .DESCRIPTION + Lists external/guest users known to SharePoint from two sources: the tenant external + users store (populated when a guest first redeems a share) and a sweep of every site's + user list (which also catches guests who were granted membership but never signed in). + Every entry is classified against Entra: 'Entra B2B' (live Entra guest), 'Orphaned B2B' + (the Entra guest was deleted but SharePoint still references them) or 'SharePoint-only' + (legacy email-authenticated guest that never had an Entra object). + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $TenantFilter = $Request.Query.tenantFilter + + # CSOM verbose JSON dates arrive as '/Date(year,month,day,h,m,s,ms)/' with a 0-based month. + function ConvertFrom-CsomDate($Value) { + if ("$Value" -match '/Date\((\d+),(\d+),(\d+),(\d+),(\d+),(\d+),(\d+)\)/') { + return ([datetime]::new([int]$Matches[1], ([int]$Matches[2] + 1), [int]$Matches[3], [int]$Matches[4], [int]$Matches[5], [int]$Matches[6], [System.DateTimeKind]::Utc)).ToString('yyyy-MM-ddTHH:mm:ssZ') + } + return $Value + } + + try { + # --- Source 1: tenant external users store --- + try { + $StoreUsers = @(Get-CIPPSPOExternalUsers -TenantFilter $TenantFilter) + } catch { + throw "Could not enumerate the SharePoint external users store: $($_.Exception.Message)" + } + + # --- Source 2: guest entries in every site's user list --- + $SharePointInfo = Get-SharePointAdminLink -Public $false -tenantFilter $TenantFilter + $Scope = "$($SharePointInfo.SharePointUrl)/.default" + $JsonAccept = @{ Accept = 'application/json;odata=nometadata' } + $SiteGuests = @{} + try { + # NB: getAllSites returns an empty set when $select is combined with this $filter. + $Sites = @((New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/sites/getAllSites?`$filter=isPersonalSite eq false&`$top=999" -tenantid $TenantFilter -AsApp $true).webUrl) | Where-Object { $_ } + } catch { + $Sites = @() + Write-Information "Site enumeration failed; external users report is store-only: $($_.Exception.Message)" + } + foreach ($SiteUrl in $Sites) { + try { + $Users = New-GraphGetRequest -uri "$($SiteUrl.TrimEnd('/'))/_api/web/siteusers?`$select=Title,Email,LoginName,IsShareByEmailGuestUser,IsEmailAuthenticationGuestUser" -tenantid $TenantFilter -scope $Scope -extraHeaders $JsonAccept -UseCertificate -AsApp $true + } catch { + continue # sites the app cannot reach (locked etc.) are skipped + } + foreach ($User in $Users) { + $IsGuest = [bool]$User.IsShareByEmailGuestUser -or [bool]$User.IsEmailAuthenticationGuestUser -or $User.LoginName -match '(?i)#ext#|urn%3aspo%3aguest' + if (-not $IsGuest) { continue } + $Key = ($User.LoginName -split '\|')[-1].ToLower() + if (-not $SiteGuests.ContainsKey($Key)) { + $SiteGuests[$Key] = [PSCustomObject]@{ + Title = $User.Title + Email = $User.Email + LoginName = $User.LoginName + Sites = [System.Collections.Generic.List[string]]::new() + } + } + [void]$SiteGuests[$Key].Sites.Add($SiteUrl) + } + } + + # --- Entra guest sweep for the join --- + $EntraGuests = @() + if ($StoreUsers.Count -gt 0 -or $SiteGuests.Count -gt 0) { + try { + $EntraGuests = @(New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/users?`$filter=userType eq 'Guest'&`$select=id,userPrincipalName,mail&`$top=999" -tenantid $TenantFilter -AsApp $true) + } catch { + throw "Could not list Entra guest users for cross-referencing: $($_.Exception.Message)" + } + } + $GuestsByUpn = @{} + $GuestsByMail = @{} + foreach ($Guest in $EntraGuests) { + if ($Guest.userPrincipalName) { $GuestsByUpn[$Guest.userPrincipalName.ToLower()] = $Guest } + if ($Guest.mail) { $GuestsByMail[$Guest.mail.ToLower()] = $Guest } + } + + function Resolve-GuestClassification($LoginName, $AcceptedAs, $InvitedAs) { + if ("$LoginName" -match '(?i)urn(%3a|:)spo(%3a|:)guest') { + return @('SharePoint-only (email authenticated)', $null) + } + $ClaimsUpn = ("$LoginName" -split '\|')[-1].ToLower() + $EntraUser = $GuestsByUpn[$ClaimsUpn] + if (-not $EntraUser -and $AcceptedAs) { $EntraUser = $GuestsByMail[([string]$AcceptedAs).ToLower()] } + if (-not $EntraUser -and $InvitedAs) { $EntraUser = $GuestsByMail[([string]$InvitedAs).ToLower()] } + if ($EntraUser) { return @('Entra B2B', $EntraUser) } + return @('Orphaned B2B (not in Entra)', $null) + } + + $Rows = [System.Collections.Generic.List[object]]::new() + $SeenKeys = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + + foreach ($User in $StoreUsers) { + $GuestType, $EntraUser = Resolve-GuestClassification $User.LoginName $User.AcceptedAs $User.InvitedAs + # Match this store entry to any site membership sweep hit for a Sites column. + $MatchKey = $null + foreach ($Key in $SiteGuests.Keys) { + $SG = $SiteGuests[$Key] + if (($User.AcceptedAs -and $SG.Email -eq $User.AcceptedAs) -or ($EntraUser -and $Key -eq $EntraUser.userPrincipalName.ToLower())) { $MatchKey = $Key; break } + } + if ($MatchKey) { [void]$SeenKeys.Add($MatchKey) } + # Direct assignment keeps the List intact; an if-expression would pipeline-unwrap + # a single-element list to a scalar and the API would emit a string, not an array. + $RowSites = [System.Collections.Generic.List[string]]::new() + if ($MatchKey) { $RowSites = $SiteGuests[$MatchKey].Sites } + $Rows.Add([PSCustomObject]@{ + DisplayName = $User.DisplayName + InvitedAs = $User.InvitedAs + AcceptedAs = $User.AcceptedAs + # Store entries often carry no login; the site sweep's claims login fills the gap. + LoginName = if ($User.LoginName) { $User.LoginName } elseif ($MatchKey) { $SiteGuests[$MatchKey].LoginName } else { $null } + UniqueId = $User.UniqueId + WhenCreated = ConvertFrom-CsomDate $User.WhenCreated + InvitedBy = $User.InvitedBy + GuestType = $GuestType + InEntra = [bool]$EntraUser + EntraUserId = $EntraUser.id + Source = 'External users store' + Sites = $RowSites + }) + } + + # Guests that only exist as site members (never redeemed / store aged out) + foreach ($Key in $SiteGuests.Keys) { + if ($SeenKeys.Contains($Key)) { continue } + $SG = $SiteGuests[$Key] + $GuestType, $EntraUser = Resolve-GuestClassification $SG.LoginName $SG.Email $null + $Rows.Add([PSCustomObject]@{ + DisplayName = $SG.Title + InvitedAs = $null + AcceptedAs = $SG.Email + LoginName = $SG.LoginName + UniqueId = $null + WhenCreated = $null + InvitedBy = $null + GuestType = $GuestType + InEntra = [bool]$EntraUser + EntraUserId = $EntraUser.id + Source = 'Site membership' + Sites = $SG.Sites + }) + } + + $Body = @($Rows) + $StatusCode = [HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Body = "Failed to list SharePoint external users: $($ErrorMessage.NormalizedError)" + $StatusCode = [HttpStatusCode]::BadRequest + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = @{ 'Results' = $Body } + }) +} From e42d8d3d3eda248fd143fbeef7deafd9624fcf3b Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Fri, 10 Jul 2026 16:49:27 +0200 Subject: [PATCH 168/170] version up --- version_latest.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version_latest.txt b/version_latest.txt index 0b09579a68d16..d1dd3f904c728 100644 --- a/version_latest.txt +++ b/version_latest.txt @@ -1 +1 @@ -10.5.8 +10.6.0 From f921f04ecf2c12eff2ad17794000955830eba5d9 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Fri, 10 Jul 2026 16:52:13 +0200 Subject: [PATCH 169/170] version up --- host.json | 2 +- version_latest.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/host.json b/host.json index 9de15356dcb97..a88d17f4bdc9d 100644 --- a/host.json +++ b/host.json @@ -16,7 +16,7 @@ "distributedTracingEnabled": false, "version": "None" }, - "defaultVersion": "10.5.8", + "defaultVersion": "10.6.0", "versionMatchStrategy": "Strict", "versionFailureStrategy": "Fail" } diff --git a/version_latest.txt b/version_latest.txt index 0b09579a68d16..d1dd3f904c728 100644 --- a/version_latest.txt +++ b/version_latest.txt @@ -1 +1 @@ -10.5.8 +10.6.0 From 11beb6dde91a005dc3a36be98d9bcaa7477dfb0a Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Fri, 10 Jul 2026 22:57:21 +0800 Subject: [PATCH 170/170] Conventional Commits Check for PR titles --- .github/workflows/Conventional_Commits.yml | 65 ++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 .github/workflows/Conventional_Commits.yml diff --git a/.github/workflows/Conventional_Commits.yml b/.github/workflows/Conventional_Commits.yml new file mode 100644 index 0000000000000..537e86d6823f9 --- /dev/null +++ b/.github/workflows/Conventional_Commits.yml @@ -0,0 +1,65 @@ +name: Conventional Commits Check + +on: + # Using pull_request_target instead of pull_request for secure handling of fork PRs + pull_request_target: + # Re-run on title edits so a corrected title clears the check + types: [opened, synchronize, reopened, edited] + # Only check PRs targeting the dev branch + branches: + - dev + +permissions: + pull-requests: write + issues: write + +jobs: + conventional-commits: + name: Validate Conventional Commits + runs-on: ubuntu-slim + steps: + - name: Validate PR title + uses: actions/github-script@v9 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const title = context.payload.pull_request.title || ''; + const pattern = /^(feat|fix|docs|style|refactor|perf|test|chore|ci|build|revert)(\(.+\))?!?: .+/; + + if (pattern.test(title)) { + console.log(`✓ PR title follows Conventional Commits format: "${title}"`); + return; + } + + console.log(`❌ PR title does not follow Conventional Commits format: "${title}"`); + + const message = [ + '❌ **This PR title does not follow the [Conventional Commits](https://www.conventionalcommits.org/) format.**', + '', + 'Expected format: `type(scope)?: description`', + '', + 'Allowed types: `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `chore`, `ci`, `build`, `revert`', + '', + 'Examples:', + '- `feat: add SharePoint external user management`', + '- `fix(auth): handle expired refresh tokens`', + '- `docs: update self-hosting guide`', + '', + `Received: \`${title}\``, + '', + '🔒 This PR has been automatically closed. Please update the title to match the format above and reopen the PR.' + ].join('\n'); + + await github.rest.issues.createComment({ + ...context.repo, + issue_number: context.issue.number, + body: message + }); + + await github.rest.pulls.update({ + ...context.repo, + pull_number: context.issue.number, + state: 'closed' + }); + + core.setFailed(`PR title does not follow Conventional Commits format: "${title}"`);