From ad9caf3ff01b903a998354958e4dea2a2bea0d0b Mon Sep 17 00:00:00 2001 From: Roel van der Wegen Date: Tue, 14 Jul 2026 00:23:11 +0200 Subject: [PATCH 1/6] Boilerplate onedrive root permissions cache (not implemented yet) --- ...sh-DBCacheOneDriveRootPermissionsBatch.ps1 | 563 ++++++++++++++++++ .../Push-StoreOneDriveRootPermissions.ps1 | 99 +++ ...Set-CIPPDBCacheOneDriveRootPermissions.ps1 | 129 ++++ 3 files changed, 791 insertions(+) create mode 100644 Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/OneDrive Root Permissions/Push-DBCacheOneDriveRootPermissionsBatch.ps1 create mode 100644 Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/OneDrive Root Permissions/Push-StoreOneDriveRootPermissions.ps1 create mode 100644 Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheOneDriveRootPermissions.ps1 diff --git a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/OneDrive Root Permissions/Push-DBCacheOneDriveRootPermissionsBatch.ps1 b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/OneDrive Root Permissions/Push-DBCacheOneDriveRootPermissionsBatch.ps1 new file mode 100644 index 0000000000000..47e9f69b92c17 --- /dev/null +++ b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/OneDrive Root Permissions/Push-DBCacheOneDriveRootPermissionsBatch.ps1 @@ -0,0 +1,563 @@ +function Push-DBCacheOneDriveRootPermissionsBatch { + <# + .SYNOPSIS + Collects OneDrive root permissions for a batch of personal sites. + + .DESCRIPTION + Processes up to 20 personal site seeds per activity. Each site is wrapped in its own + try/catch so a batch of N sites always returns exactly N cache row objects. + + Six permission paths are indexed into permissionsJson (a pre-serialized JSON string): + SiteAdmin, SiteRoleGroup, WebRoleAssignment, LibraryRoleAssignment, DriveRootGrant, + DriveRootLink. Groups are stored as principals and are not expanded to users. + + collectionStatus: + - Full — all SPO REST paths and Graph drive root permissions succeeded + - Skipped — drive/owner resolution failed, SPO or Graph collection failed, or unexpected error; + permissionsJson = '[]'. Push-StoreOneDriveRootPermissions may replace Skipped rows + with prior Full cache data (merge-on-Skip) before the tenant write. + + hasNonStandardAccess is nullable ($true | $false | $null). Use -eq $true / -eq $false; + never truthy checks. $null on batch Skipped rows; after merge-on-Skip at store, merged sites + retain prior hasNonStandardAccess from the cached Full row. + + Test-IsOwnerPrincipal compares grants to the provisioned owner (drive.owner.user.id): + principalObjectId match, principalUpn -ieq, or LoginName claim suffix -ieq owner UPN. + + Dedup inside permissionsJson: + - WebRoleAssignment: skip Member.Id matching associated Owner/Member/Visitor group Ids + - LibraryRoleAssignment: only when HasUniqueRoleAssignments is true + - DriveRootGrant: skip siteGroup.displayName matching associated group Title (case-insensitive); + skip implicit owner grant (roles contains 'owner' + Test-IsOwnerPrincipal) + - Intentional cross-path duplicates remain (e.g. same user on SiteAdmin and SiteRoleGroup) + + Graph root permissions: skip inheritedFrom; paginate via New-GraphGetRequest; DriveRootGrant + and named DriveRootLink recipients emit one grant per person; anonymous DriveRootLink + emits one grant per permission.id. + + Anonymous DriveRootLink shape: principalType=Link, sharedWith=@(), linkScope/linkType populated. + + Consumer notes: + - Grant paths != effective access (security groups need Entra membership join) + - Unprovisioned OneDrives absent from getAllSites + - Batch Skipped rows have permissionsJson = '[]' and hasNonStandardAccess $null; after store, + merge-on-Skip may replace them with prior Full rows — query the cache, not batch output + - Count DriveRootLink sharing links by distinct permissionId, not grant row count (named + recipients share the same permissionId across multiple grant rows) + - Child folder/file sharing is out of scope (SharePointSharingLinks cache) + - Localized/renamed group titles may miss siteGroup dedup (grant retained, warning logged) + + Never uses User Information List fallback. + + .FUNCTIONALITY + Entrypoint + #> + [CmdletBinding()] + param($Item) + + $TenantFilter = $Item.TenantFilter + $BatchNumber = $Item.BatchNumber + $SiteSeeds = @($Item.Sites) + + # Returns $true when a SharePoint user entity is a guest/external identity. + function Test-SPGuestUser { + param($User) + [bool]$User.IsShareByEmailGuestUser -or [bool]$User.IsEmailAuthenticationGuestUser -or + ($User.LoginName -match '(?i)#ext#|urn%3aspo%3aguest') + } + + # Compares a grant principal to the provisioned OneDrive owner (drive.owner). + function Test-IsOwnerPrincipal { + param($Grant, $OwnerObjectId, $OwnerPrincipalName) + if ($OwnerObjectId -and $Grant.principalObjectId -and + $Grant.principalObjectId -eq $OwnerObjectId) { return $true } + if ($OwnerPrincipalName -and $Grant.principalUpn -and + $Grant.principalUpn -ieq $OwnerPrincipalName) { return $true } + if ($OwnerPrincipalName -and $Grant.principalLoginName -and + ($Grant.principalLoginName -split '\|')[-1] -ieq $OwnerPrincipalName) { return $true } + return $false + } + + function Get-CIPPSpoPrincipalType { + param($Entity) + if ($Entity.LoginName -match '(?i)federateddirectoryclaimprovider') { return 'M365 Group' } + switch ($Entity.PrincipalType) { + 1 { 'User' } + 4 { 'Security Group' } + 8 { 'SharePoint Group' } + default { 'Other' } + } + } + + function Get-CIPPSpoUserUpn { + param($User) + if ($User.PrincipalType -eq 1 -and $User.LoginName) { + return ($User.LoginName -split '\|')[-1] + } + $null + } + + function New-CIPPSpoUserGrant { + param( + $User, + [string]$PermissionSource, + [string]$Group, + $RoleBinding = $null, + [bool]$IsSiteAdmin = $false, + [string]$LibraryTitle = $null + ) + [PSCustomObject]@{ + permissionSource = $PermissionSource + group = $Group + principalId = [string]$User.Id + principalObjectId = $null + principalUpn = Get-CIPPSpoUserUpn -User $User + principalDisplayName = $User.Title + principalLoginName = $User.LoginName + principalEmail = $User.Email + principalType = Get-CIPPSpoPrincipalType -Entity $User + permissionLevel = if ($RoleBinding) { $RoleBinding.Name } else { $null } + roleDefinitionId = if ($RoleBinding) { $RoleBinding.Id } else { $null } + roles = @() + isSiteAdmin = $IsSiteAdmin + isGuest = (Test-SPGuestUser -User $User) + permissionId = $null + linkScope = $null + linkType = $null + linkUrl = $null + hasPassword = $null + expirationDateTime = $null + sharedWith = @() + libraryTitle = $LibraryTitle + } + } + + function New-CIPPSpoRoleAssignmentGrant { + param($Member, $RoleBinding, [string]$PermissionSource, [string]$LibraryTitle = $null) + $principalUpn = if ($Member.PrincipalType -eq 1 -and $Member.LoginName) { + ($Member.LoginName -split '\|')[-1] + } else { $null } + [PSCustomObject]@{ + permissionSource = $PermissionSource + group = $RoleBinding.Name + principalId = [string]$Member.Id + principalObjectId = $null + principalUpn = $principalUpn + principalDisplayName = $Member.Title + principalLoginName = $Member.LoginName + principalEmail = $Member.Email + principalType = Get-CIPPSpoPrincipalType -Entity $Member + permissionLevel = $RoleBinding.Name + roleDefinitionId = $RoleBinding.Id + roles = @() + isSiteAdmin = $false + isGuest = (Test-SPGuestUser -User $Member) + permissionId = $null + linkScope = $null + linkType = $null + linkUrl = $null + hasPassword = $null + expirationDateTime = $null + sharedWith = @() + libraryTitle = $LibraryTitle + } + } + + function Get-CIPPGraphIdentityLabel { + 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 ?? + $Identity.application.displayName + } + + function Test-CIPPGraphGuestIdentity { + param($Identity) + $LoginName = [string]($Identity.siteUser.loginName ?? $Identity.user.loginName ?? '') + if ($LoginName -match '(?i)#ext#|urn%3aspo%3aguest|urn:spo:guest') { return $true } + $Email = [string]($Identity.user.email ?? $Identity.user.userPrincipalName ?? $Identity.siteUser.email ?? '') + $Email -match '(?i)#EXT#' + } + + function New-CIPPGraphIdentityGrant { + param( + $Identity, + [string]$PermissionSource, + [string]$PermissionId, + [array]$Roles, + [array]$SharedWith, + $LinkProps = $null + ) + $principalType = 'Other' + $principalObjectId = $null + $principalUpn = $null + $principalDisplayName = $null + $principalLoginName = $null + $principalEmail = $null + $principalId = $null + + if ($Identity.user) { + $principalType = 'User' + $principalObjectId = $Identity.user.id + $principalUpn = $Identity.user.userPrincipalName ?? $Identity.user.email + $principalDisplayName = $Identity.user.displayName + $principalEmail = $Identity.user.email ?? $Identity.user.userPrincipalName + $principalId = [string]($Identity.user.id ?? $principalUpn) + } elseif ($Identity.siteUser) { + $principalType = 'User' + $principalLoginName = $Identity.siteUser.loginName + $principalDisplayName = $Identity.siteUser.displayName + $principalEmail = $Identity.siteUser.email + $principalUpn = if ($principalLoginName) { ($principalLoginName -split '\|')[-1] } else { $null } + $principalId = [string]($principalLoginName ?? $principalEmail ?? $principalDisplayName) + } elseif ($Identity.group) { + $principalType = 'Security Group' + $principalObjectId = $Identity.group.id + $principalDisplayName = $Identity.group.displayName + $principalEmail = $Identity.group.email + $principalId = [string]$Identity.group.id + } elseif ($Identity.siteGroup) { + $principalType = 'SharePoint Group' + $principalDisplayName = $Identity.siteGroup.displayName + $principalId = [string]($Identity.siteGroup.id ?? $Identity.siteGroup.displayName) + } elseif ($Identity.application) { + $principalType = 'Application' + $principalDisplayName = $Identity.application.displayName + $principalId = [string]$Identity.application.id + } + + $grant = [PSCustomObject]@{ + permissionSource = $PermissionSource + group = $null + principalId = $principalId + principalObjectId = $principalObjectId + principalUpn = $principalUpn + principalDisplayName = $principalDisplayName + principalLoginName = $principalLoginName + principalEmail = $principalEmail + principalType = $principalType + permissionLevel = $null + roleDefinitionId = $null + roles = @($Roles) + isSiteAdmin = $false + isGuest = (Test-CIPPGraphGuestIdentity -Identity $Identity) + permissionId = $PermissionId + linkScope = $null + linkType = $null + linkUrl = $null + hasPassword = $null + expirationDateTime = $null + sharedWith = @($SharedWith) + libraryTitle = $null + } + + if ($LinkProps) { + $grant.linkScope = $LinkProps.linkScope + $grant.linkType = $LinkProps.linkType + $grant.linkUrl = $LinkProps.linkUrl + $grant.hasPassword = $LinkProps.hasPassword + $grant.expirationDateTime = $LinkProps.expirationDateTime + $grant.principalType = 'Link' + } + + $grant + } + + function Get-CIPPHasNonStandardAccess { + param($Grants, $OwnerObjectId, $OwnerPrincipalName, $LibraryHasUniquePermissions, $CollectionStatus) + if ($CollectionStatus -eq 'Skipped') { return $null } + if ($LibraryHasUniquePermissions) { return $true } + + foreach ($Grant in $Grants) { + if ($Grant.permissionSource -eq 'SiteAdmin' -and -not (Test-IsOwnerPrincipal -Grant $Grant -OwnerObjectId $OwnerObjectId -OwnerPrincipalName $OwnerPrincipalName)) { + return $true + } + if ($Grant.permissionSource -eq 'SiteRoleGroup' -and $Grant.group -in @('Members', 'Visitors')) { + return $true + } + if ($Grant.permissionSource -eq 'SiteRoleGroup' -and $Grant.group -eq 'Owners' -and + -not (Test-IsOwnerPrincipal -Grant $Grant -OwnerObjectId $OwnerObjectId -OwnerPrincipalName $OwnerPrincipalName)) { + return $true + } + if ($Grant.principalType -in @('Security Group', 'M365 Group', 'SharePoint Group')) { + return $true + } + if ($Grant.isGuest) { return $true } + if ($Grant.permissionSource -eq 'DriveRootLink') { return $true } + if ($Grant.permissionSource -eq 'DriveRootGrant' -and + -not (Test-IsOwnerPrincipal -Grant $Grant -OwnerObjectId $OwnerObjectId -OwnerPrincipalName $OwnerPrincipalName)) { + return $true + } + if ($Grant.permissionSource -in @('WebRoleAssignment', 'LibraryRoleAssignment') -and + -not (Test-IsOwnerPrincipal -Grant $Grant -OwnerObjectId $OwnerObjectId -OwnerPrincipalName $OwnerPrincipalName)) { + return $true + } + } + return $false + } + + function New-CIPPSkippedSiteRow { + param($SiteSeed, $CollectionError) + [PSCustomObject]@{ + id = $SiteSeed.id + siteId = $SiteSeed.id + siteUrl = $SiteSeed.webUrl + siteDisplayName = $SiteSeed.displayName + ownerPrincipalName = $null + ownerObjectId = $null + ownerDisplayName = $null + driveId = $null + driveWebUrl = $null + libraryId = $null + libraryHasUniquePermissions = $false + collectionStatus = 'Skipped' + collectionError = $CollectionError + hasNonStandardAccess = $null + permissionsJson = '[]' + grantCount = 0 + collectedAt = (Get-Date).ToUniversalTime().ToString('o') + } + } + + function Get-CIPPOneDriveSiteRow { + param($SiteSeed, $TenantFilter) + + $SiteId = $SiteSeed.id + $SiteUrl = $SiteSeed.webUrl + $SiteDisplayName = $SiteSeed.displayName + $CollectedAt = (Get-Date).ToUniversalTime().ToString('o') + + $Drive = $null + try { + $Drive = New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/sites/$SiteId/drive?`$select=id,owner,webUrl,sharepointIds" -tenantid $TenantFilter -asapp $true + } catch { + return (New-CIPPSkippedSiteRow -SiteSeed $SiteSeed -CollectionError "Drive resolution failed: $($_.Exception.Message)") + } + + if (-not $Drive -or -not $Drive.id) { + return (New-CIPPSkippedSiteRow -SiteSeed $SiteSeed -CollectionError 'Drive resolution returned no drive') + } + + $OwnerObjectId = $Drive.owner.user.id + $OwnerDisplayName = $Drive.owner.user.displayName + $OwnerPrincipalName = $Drive.owner.user.userPrincipalName ?? $Drive.owner.user.email + if (-not $OwnerPrincipalName -and $OwnerObjectId) { + try { + $OwnerUser = New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/users/$OwnerObjectId?`$select=userPrincipalName" -tenantid $TenantFilter -asapp $true + $OwnerPrincipalName = $OwnerUser.userPrincipalName + } catch { + $OwnerPrincipalName = $null + } + } + + $DriveId = $Drive.id + $DriveWebUrl = $Drive.webUrl + $LibraryId = $Drive.sharepointIds.listId + $LibraryHasUniquePermissions = $false + $LibraryTitle = $null + + $SpoGrants = [System.Collections.Generic.List[object]]::new() + $AssociatedGroupIds = [System.Collections.Generic.HashSet[string]]::new() + $AssociatedGroupTitles = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + + try { + $SharePointInfo = Get-SharePointAdminLink -Public $false -tenantFilter $TenantFilter + $Scope = "$($SharePointInfo.SharePointUrl)/.default" + $JsonAccept = @{ Accept = 'application/json;odata=nometadata' } + $BaseUri = "$($SiteUrl.TrimEnd('/'))/_api" + + $AssociatedEndpoints = [ordered]@{ + 'Owners' = 'associatedownergroup' + 'Members' = 'associatedmembergroup' + 'Visitors' = 'associatedvisitorgroup' + } + foreach ($RoleName in $AssociatedEndpoints.Keys) { + $GroupEntity = New-GraphGetRequest -uri "$BaseUri/web/$($AssociatedEndpoints[$RoleName])?`$select=Id,Title" -tenantid $TenantFilter -scope $Scope -extraHeaders $JsonAccept -UseCertificate -AsApp $true + if ($GroupEntity.Id) { + [void]$AssociatedGroupIds.Add([string]$GroupEntity.Id) + if ($GroupEntity.Title) { [void]$AssociatedGroupTitles.Add([string]$GroupEntity.Title) } + } + } + + $SiteAdmins = @(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 $SiteAdmins) { + $SpoGrants.Add((New-CIPPSpoUserGrant -User $Admin -PermissionSource 'SiteAdmin' -Group 'Site Admins' -IsSiteAdmin $true)) + } + + foreach ($RoleName in $AssociatedEndpoints.Keys) { + $Users = @(New-GraphGetRequest -uri "$BaseUri/web/$($AssociatedEndpoints[$RoleName])/users?`$select=Id,Title,Email,LoginName,PrincipalType,IsShareByEmailGuestUser,IsEmailAuthenticationGuestUser" -tenantid $TenantFilter -scope $Scope -extraHeaders $JsonAccept -UseCertificate -AsApp $true) + foreach ($User in $Users) { + $SpoGrants.Add((New-CIPPSpoUserGrant -User $User -PermissionSource 'SiteRoleGroup' -Group $RoleName)) + } + } + + $WebAssignments = @(New-GraphGetRequest -uri "$BaseUri/web/roleassignments?`$expand=Member,RoleDefinitionBindings" -tenantid $TenantFilter -scope $Scope -extraHeaders $JsonAccept -UseCertificate -AsApp $true) + foreach ($Assignment in $WebAssignments) { + if ($Assignment.Member.Id -and $AssociatedGroupIds.Contains([string]$Assignment.Member.Id)) { continue } + foreach ($Binding in @($Assignment.RoleDefinitionBindings)) { + $SpoGrants.Add((New-CIPPSpoRoleAssignmentGrant -Member $Assignment.Member -RoleBinding $Binding -PermissionSource 'WebRoleAssignment')) + } + } + + if ($LibraryId) { + $ListInfo = New-GraphGetRequest -uri "$BaseUri/web/lists(guid'$LibraryId')?`$select=HasUniqueRoleAssignments,Title" -tenantid $TenantFilter -scope $Scope -extraHeaders $JsonAccept -UseCertificate -AsApp $true + $LibraryHasUniquePermissions = [bool]$ListInfo.HasUniqueRoleAssignments + $LibraryTitle = $ListInfo.Title + if ($LibraryHasUniquePermissions) { + $LibraryAssignments = @(New-GraphGetRequest -uri "$BaseUri/web/lists(guid'$LibraryId')/roleassignments?`$expand=Member,RoleDefinitionBindings" -tenantid $TenantFilter -scope $Scope -extraHeaders $JsonAccept -UseCertificate -AsApp $true) + foreach ($Assignment in $LibraryAssignments) { + if ($Assignment.Member.Id -and $AssociatedGroupIds.Contains([string]$Assignment.Member.Id)) { continue } + foreach ($Binding in @($Assignment.RoleDefinitionBindings)) { + $SpoGrants.Add((New-CIPPSpoRoleAssignmentGrant -Member $Assignment.Member -RoleBinding $Binding -PermissionSource 'LibraryRoleAssignment' -LibraryTitle $LibraryTitle)) + } + } + } + } + + } catch { + $SpoError = $_.Exception.Message + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "OneDrive root permissions: SPO collection failed for '$SiteUrl': $SpoError" -sev Warning + return (New-CIPPSkippedSiteRow -SiteSeed $SiteSeed -CollectionError "SPO collection failed: $SpoError") + } + + $GraphGrants = [System.Collections.Generic.List[object]]::new() + $InheritedSkipCount = 0 + try { + $Permissions = @(New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/sites/$SiteId/drive/root/permissions" -tenantid $TenantFilter -asapp $true) + foreach ($Permission in $Permissions) { + if ($Permission.inheritedFrom) { + $InheritedSkipCount++ + continue + } + + if ($Permission.link) { + $Recipients = @($Permission.grantedToIdentitiesV2 ?? $Permission.grantedToIdentities ?? @()) + $SharedWith = @($Recipients | ForEach-Object { Get-CIPPGraphIdentityLabel -Identity $_ } | Where-Object { $_ } | Sort-Object -Unique) + $LinkProps = @{ + linkScope = $Permission.link.scope ?? 'users' + linkType = $Permission.link.type ?? 'view' + linkUrl = $Permission.link.webUrl + hasPassword = [bool]($Permission.hasPassword ?? $false) + expirationDateTime = $Permission.expirationDateTime + } + if ($Recipients.Count -eq 0) { + $GraphGrants.Add([PSCustomObject]@{ + permissionSource = 'DriveRootLink' + group = $null + principalId = [string]$Permission.id + principalObjectId = $null + principalUpn = $null + principalDisplayName = $null + principalLoginName = $null + principalEmail = $null + principalType = 'Link' + permissionLevel = $null + roleDefinitionId = $null + roles = @($Permission.roles) + isSiteAdmin = $false + isGuest = $false + permissionId = [string]$Permission.id + linkScope = $LinkProps.linkScope + linkType = $LinkProps.linkType + linkUrl = $LinkProps.linkUrl + hasPassword = $LinkProps.hasPassword + expirationDateTime = $LinkProps.expirationDateTime + sharedWith = @() + libraryTitle = $null + }) + } else { + foreach ($Recipient in $Recipients) { + $GraphGrants.Add((New-CIPPGraphIdentityGrant -Identity $Recipient -PermissionSource 'DriveRootLink' -PermissionId $Permission.id -Roles @($Permission.roles) -SharedWith $SharedWith -LinkProps $LinkProps)) + } + } + continue + } + + $Recipients = @($Permission.grantedToIdentitiesV2 ?? @()) + if ($Recipients.Count -eq 0 -and $Permission.grantedToV2) { + $Recipients = @($Permission.grantedToV2) + } + if ($Recipients.Count -eq 0) { continue } + + $SharedWith = @($Recipients | ForEach-Object { Get-CIPPGraphIdentityLabel -Identity $_ } | Where-Object { $_ } | Sort-Object -Unique) + foreach ($Recipient in $Recipients) { + if ($Recipient.siteGroup -and $Recipient.siteGroup.displayName -and + $AssociatedGroupTitles.Contains([string]$Recipient.siteGroup.displayName)) { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "OneDrive root permissions: siteGroup dedup matched '$($Recipient.siteGroup.displayName)' on '$SiteUrl'" -sev Debug + continue + } + $Candidate = New-CIPPGraphIdentityGrant -Identity $Recipient -PermissionSource 'DriveRootGrant' -PermissionId $Permission.id -Roles @($Permission.roles) -SharedWith $SharedWith + if ($Permission.roles -contains 'owner' -and + (Test-IsOwnerPrincipal -Grant $Candidate -OwnerObjectId $OwnerObjectId -OwnerPrincipalName $OwnerPrincipalName)) { + continue + } + $GraphGrants.Add($Candidate) + } + } + if ($InheritedSkipCount -gt 0) { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "OneDrive root permissions: skipped $InheritedSkipCount inherited root permissions on '$SiteUrl'" -sev Debug + } + } catch { + return (New-CIPPSkippedSiteRow -SiteSeed $SiteSeed -CollectionError "Graph root permissions failed: $($_.Exception.Message)") + } + + $AllGrants = @($SpoGrants) + @($GraphGrants) + + $HasNonStandardAccess = Get-CIPPHasNonStandardAccess -Grants $AllGrants -OwnerObjectId $OwnerObjectId -OwnerPrincipalName $OwnerPrincipalName -LibraryHasUniquePermissions $LibraryHasUniquePermissions -CollectionStatus 'Full' + $PermissionsJson = if ($AllGrants.Count -gt 0) { + ConvertTo-Json -InputObject @($AllGrants) -Compress -Depth 10 + } else { + '[]' + } + + [PSCustomObject]@{ + id = $SiteId + siteId = $SiteId + siteUrl = $SiteUrl + siteDisplayName = $SiteDisplayName + ownerPrincipalName = $OwnerPrincipalName + ownerObjectId = $OwnerObjectId + ownerDisplayName = $OwnerDisplayName + driveId = $DriveId + driveWebUrl = $DriveWebUrl + libraryId = $LibraryId + libraryHasUniquePermissions = $LibraryHasUniquePermissions + collectionStatus = 'Full' + collectionError = $null + hasNonStandardAccess = $HasNonStandardAccess + permissionsJson = $PermissionsJson + grantCount = $AllGrants.Count + collectedAt = $CollectedAt + } + } + + $SiteRows = [System.Collections.Generic.List[object]]::new() + + try { + Write-Information "Processing OneDrive root permissions batch $BatchNumber for tenant $TenantFilter with $($SiteSeeds.Count) sites" + + foreach ($SiteSeed in $SiteSeeds) { + try { + $SiteRows.Add((Get-CIPPOneDriveSiteRow -SiteSeed $SiteSeed -TenantFilter $TenantFilter)) + } catch { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "OneDrive root permissions: unexpected site error for '$($SiteSeed.webUrl)': $($_.Exception.Message)" -sev Warning -LogData (Get-CippException -Exception $_) + $SiteRows.Add((New-CIPPSkippedSiteRow -SiteSeed $SiteSeed -CollectionError $_.Exception.Message)) + } + } + + if ($SiteRows.Count -ne $SiteSeeds.Count) { + throw "Batch $BatchNumber invariant violated: expected $($SiteSeeds.Count) site rows, got $($SiteRows.Count)" + } + + return [PSCustomObject]@{ + BatchNumber = $BatchNumber + Sites = @($SiteRows) + } + + } catch { + $ErrorMsg = "Failed OneDrive root permissions batch $BatchNumber for tenant $TenantFilter : $($_.Exception.Message)" + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message $ErrorMsg -sev Error -LogData (Get-CippException -Exception $_) + throw + } +} diff --git a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/OneDrive Root Permissions/Push-StoreOneDriveRootPermissions.ps1 b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/OneDrive Root Permissions/Push-StoreOneDriveRootPermissions.ps1 new file mode 100644 index 0000000000000..ec5721eb90935 --- /dev/null +++ b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/OneDrive Root Permissions/Push-StoreOneDriveRootPermissions.ps1 @@ -0,0 +1,99 @@ +function Push-StoreOneDriveRootPermissions { + <# + .SYNOPSIS + Post-execution function that aggregates per-batch OneDrive root permission rows and writes the cache. + + .DESCRIPTION + Collects the Sites arrays returned by every Push-DBCacheOneDriveRootPermissionsBatch activity, + flattens them into a single row set, and writes OneDriveRootPermissions once via Add-CIPPDbItem. + + Completeness guard: if ActualCount -ne ExpectedSiteCount the function throws and does not + call Add-CIPPDbItem (prevents replace-mode wipe on partial orchestrator failure). When counts + match (including 0 for empty tenants handled by the orchestrator parent), a single full-replace + write is performed. + + Merge-on-Skip: when batch collection returns Skipped for a site, loads existing + OneDriveRootPermissions via New-CIPPDbRequest and replaces the Skipped row with the prior + Full row (matched by siteId) before writing. Transient SPO/Graph failures therefore do not + wipe previously collected grant data. Skipped rows with no prior Full row are written as-is. + DB read is skipped when no Skipped rows exist in the run. + + Logs Skipped count from collection and how many were preserved from prior Full cache. + + Cache row schema (one per personal site): + id/siteId, siteUrl, siteDisplayName, ownerPrincipalName, ownerObjectId, ownerDisplayName, + driveId, driveWebUrl, libraryId, libraryHasUniquePermissions, collectionStatus, + collectionError, hasNonStandardAccess (nullable boolean), permissionsJson (string), + grantCount, collectedAt. + + permissionsJson is a pre-serialized JSON string of grant objects — consumers must + ConvertFrom-Json before querying grants. Grant identity for dedup: + {permissionSource}_{principalId}_{roleDefinitionId}_{permissionId} + + Consumer notes: + - Rows in the cache reflect merge-on-Skip: a site that failed collection this run may still + show collectionStatus Full with prior permissionsJson if a previous Full row existed + - hasNonStandardAccess: use -eq $true / -eq $false; $null means Skipped with no prior Full + row to merge. Never use truthy checks + - DriveRootLink with named recipients produces one grant per person; count sharing links + by distinct permissionId within permissionsJson, not by grant row count (same permissionId + may appear on multiple recipient grants) + + .FUNCTIONALITY + Entrypoint + #> + [CmdletBinding()] + param($Item) + + $TenantFilter = $Item.Parameters.TenantFilter + $ExpectedSiteCount = [int]$Item.Parameters.ExpectedSiteCount + + try { + $AllRows = [System.Collections.Generic.List[object]]::new() + foreach ($BatchResult in @($Item.Results)) { + $Sites = if ($BatchResult.Sites) { @($BatchResult.Sites) } else { @() } + foreach ($Row in $Sites) { + if ($Row) { $AllRows.Add($Row) } + } + } + + $ActualCount = $AllRows.Count + if ($ActualCount -ne $ExpectedSiteCount) { + throw "OneDrive root permissions completeness check failed for $TenantFilter : expected $ExpectedSiteCount site rows, got $ActualCount" + } + + $SkippedCount = @($AllRows | Where-Object { $_.collectionStatus -eq 'Skipped' }).Count + $MergedCount = 0 + if ($SkippedCount -gt 0) { + $ExistingBySiteId = @{} + foreach ($Existing in @(New-CIPPDbRequest -TenantFilter $TenantFilter -Type 'OneDriveRootPermissions')) { + $Key = [string]($Existing.siteId ?? $Existing.id) + if ($Key -and $Existing.collectionStatus -eq 'Full') { + $ExistingBySiteId[$Key] = $Existing + } + } + for ($i = 0; $i -lt $AllRows.Count; $i++) { + $Row = $AllRows[$i] + if ($Row.collectionStatus -ne 'Skipped') { continue } + $Key = [string]($Row.siteId ?? $Row.id) + if ($Key -and $ExistingBySiteId.ContainsKey($Key)) { + $AllRows[$i] = $ExistingBySiteId[$Key] + $MergedCount++ + } + } + } + + $RemainingSkippedCount = $SkippedCount - $MergedCount + if ($SkippedCount -gt 0) { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "OneDrive root permissions: $SkippedCount of $ActualCount sites returned Skipped from collection; preserved $MergedCount from prior Full cache; $RemainingSkippedCount written as Skipped" -sev Warning + } + + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'OneDriveRootPermissions' -Data @($AllRows) -AddCount + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Cached $ActualCount OneDrive root permission site rows ($MergedCount merge-on-Skip) across $(@($Item.Results).Count) batches" -sev Info + return + + } catch { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to store OneDrive root permissions: $($_.Exception.Message)" -sev Error -LogData (Get-CippException -Exception $_) + throw + } +} diff --git a/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheOneDriveRootPermissions.ps1 b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheOneDriveRootPermissions.ps1 new file mode 100644 index 0000000000000..6d8aefb5ffbc9 --- /dev/null +++ b/Modules/CIPPDB/Public/DBCache/Set-CIPPDBCacheOneDriveRootPermissions.ps1 @@ -0,0 +1,129 @@ +function Set-CIPPDBCacheOneDriveRootPermissions { + <# + .SYNOPSIS + Caches OneDrive root permissions for every personal site in a tenant. + + .DESCRIPTION + Self-contained cache writer: enumerates personal sites via paginated Graph getAllSites, + fans out batched collection activities (20 sites each), and aggregates results in + Push-StoreOneDriveRootPermissions (PostExecution). + + One row per OneDrive site in OneDriveRootPermissions with permissionsJson (compressed grant + array string), nullable hasNonStandardAccess, and collectionStatus (Full | Skipped). + + Does not read OneDriveUsage or other CIPPDB caches. Requires SharePoint/OneDrive license + (Test-CIPPStandardLicense -Preset SharePoint); unlicensed tenants exit before enumeration. + Scheduling via CIPPDBCacheTypes.json is deferred — manual test: + Invoke-ExecCIPPDBCache?TenantFilter={tenant}&Name=OneDriveRootPermissions + + Site enumeration dedupes getAllSites results by siteId before batching. Empty tenants write + an empty cache directly (PostExecution is skipped when there are zero batches). + + PostExecution passes ExpectedSiteCount; Push-StoreOneDriveRootPermissions throws without writing + if flattened row count does not match (prevents partial replace-mode wipe). Skipped site rows + are merged with prior Full cache data when available (merge-on-Skip) so transient failures + do not overwrite good grant data. + + Owner resolution uses drive.owner (not Owners group or OneDriveUsage). permissionsJson grant + paths: SiteAdmin, SiteRoleGroup, WebRoleAssignment, LibraryRoleAssignment, DriveRootGrant, + DriveRootLink. Groups are stored as principals without Entra expansion. + + Consumer limitations: grant paths != effective access; unprovisioned OneDrives absent; + Skipped sites without a prior Full row have empty permissionsJson; merge-on-Skip may + restore prior Full data after transient failures; count DriveRootLink entries by distinct + permissionId (named recipients produce multiple grant rows per link); child folder/file + sharing is out of scope (SharePointSharingLinks cache). + + .PARAMETER TenantFilter + Tenant to cache OneDrive root permissions for + + .PARAMETER QueueId + Optional queue ID for progress tracking + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + [string]$QueueId + ) + + $BatchSize = 20 + + try { + $LicenseCheck = Test-CIPPStandardLicense -StandardName 'OneDriveRootPermissionsCache' -TenantFilter $TenantFilter -Preset SharePoint -SkipLog + if ($LicenseCheck -eq $false) { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Tenant does not have SharePoint/OneDrive license, skipping OneDrive root permissions cache' -sev Debug + return + } + + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'Starting OneDrive root permissions collection' -sev Debug + + $RawSites = @(New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/sites/getAllSites?`$filter=isPersonalSite eq true&`$select=id,webUrl,displayName" -tenantid $TenantFilter -asapp $true) + + $SiteById = @{} + foreach ($Site in $RawSites) { + if ($Site.id) { $SiteById[$Site.id] = $Site } + } + $Sites = @($SiteById.Values) + $ExpectedSiteCount = $Sites.Count + + if ($ExpectedSiteCount -eq 0) { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message 'No personal sites found; writing empty OneDriveRootPermissions cache' -sev Debug + Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'OneDriveRootPermissions' -Data @() -AddCount + return + } + + $Batches = [System.Collections.Generic.List[object]]::new() + $TotalBatches = [Math]::Ceiling($Sites.Count / $BatchSize) + for ($i = 0; $i -lt $Sites.Count; $i += $BatchSize) { + $BatchSites = $Sites[$i..[Math]::Min($i + $BatchSize - 1, $Sites.Count - 1)] + $BatchNumber = [Math]::Floor($i / $BatchSize) + 1 + $SiteSeeds = foreach ($Site in $BatchSites) { + [PSCustomObject]@{ + id = $Site.id + webUrl = $Site.webUrl + displayName = $Site.displayName + } + } + $BatchItem = [PSCustomObject]@{ + FunctionName = 'DBCacheOneDriveRootPermissionsBatch' + TenantFilter = $TenantFilter + QueueName = "OneDrive Root Permissions Batch $BatchNumber/$TotalBatches - $TenantFilter" + BatchNumber = $BatchNumber + TotalBatches = $TotalBatches + Sites = @($SiteSeeds) + } + if ($QueueId) { + $BatchItem | Add-Member -NotePropertyName 'QueueId' -NotePropertyValue $QueueId -Force + } + [void]$Batches.Add($BatchItem) + } + + if ($QueueId -and $Batches.Count -gt 0) { + try { + Update-CippQueueEntry -RowKey $QueueId -TotalTasks $Batches.Count -IncrementTotalTasks + } catch { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Could not update queue $QueueId with OneDrive root permission batch tasks: $($_.Exception.Message)" -sev Warning + } + } + + $InputObject = [PSCustomObject]@{ + Batch = @($Batches) + OrchestratorName = "OneDriveRootPermissions_$TenantFilter" + PostExecution = @{ + FunctionName = 'StoreOneDriveRootPermissions' + Parameters = @{ + TenantFilter = $TenantFilter + ExpectedSiteCount = $ExpectedSiteCount + } + } + } + + $null = Start-CIPPOrchestrator -InputObject $InputObject + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Started OneDrive root permissions collection across $ExpectedSiteCount sites in $($Batches.Count) batches" -sev Debug + + } catch { + Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to start OneDrive root permissions collection: $($_.Exception.Message)" -sev Error -LogData (Get-CippException -Exception $_) + throw + } +} From 610ec61e7ace0c1d9963e5afe64b72149e41875e Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Tue, 14 Jul 2026 14:03:15 +0800 Subject: [PATCH 2/6] fix(graph): reconcile AppCache app id drift When AppCache.ApplicationId differs from the Key Vault-backed ApplicationID, Get-GraphToken now reloads auth and updates the AppCache marker to the KV value. This prevents repeated auth reloads on every token request when AppCache and KV are out of sync. --- .../CIPPCore/Public/GraphHelper/Get-GraphToken.ps1 | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/Modules/CIPPCore/Public/GraphHelper/Get-GraphToken.ps1 b/Modules/CIPPCore/Public/GraphHelper/Get-GraphToken.ps1 index 5628ffc486ebd..fa9b948eebf34 100644 --- a/Modules/CIPPCore/Public/GraphHelper/Get-GraphToken.ps1 +++ b/Modules/CIPPCore/Public/GraphHelper/Get-GraphToken.ps1 @@ -67,8 +67,15 @@ function Get-GraphToken { $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 + $CIPPAuth = Get-CIPPAuthentication # reload creds from KV (source of truth) + if ($env:ApplicationID -and $env:ApplicationID -ne $AppCache.ApplicationId) { + # KV and AppCache genuinely diverged — reconcile the marker to KV so we + # don't reload on every subsequent token call. + Write-Host "AppCache ApplicationId ($($AppCache.ApplicationId)) differs from KV ($env:ApplicationID); reconciling AppCache." + $null = Add-CIPPAzDataTableEntity @ConfigTable -Entity @{ + PartitionKey = 'AppCache'; RowKey = 'AppCache'; ApplicationId = "$env:ApplicationID" + } -Force + } } $refreshToken = $env:RefreshToken #Get list of tenants that have 'directTenant' set to true From 176d43baa79735b87352c61f56d72888f7221056 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Tue, 14 Jul 2026 18:16:50 +0200 Subject: [PATCH 3/6] force external uri to be partner center --- .../Webhooks/Invoke-CIPPPartnerWebhookProcessing.ps1 | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/Modules/CIPPCore/Public/Webhooks/Invoke-CIPPPartnerWebhookProcessing.ps1 b/Modules/CIPPCore/Public/Webhooks/Invoke-CIPPPartnerWebhookProcessing.ps1 index 528d0bdf07355..c13c4a18eb526 100644 --- a/Modules/CIPPCore/Public/Webhooks/Invoke-CIPPPartnerWebhookProcessing.ps1 +++ b/Modules/CIPPCore/Public/Webhooks/Invoke-CIPPPartnerWebhookProcessing.ps1 @@ -6,7 +6,15 @@ function Invoke-CippPartnerWebhookProcessing { try { if ($Data.AuditUri) { - $AuditLog = New-GraphGetRequest -uri $Data.AuditUri -tenantid $env:TenantID -NoAuthCheck $true -scope 'https://api.partnercenter.microsoft.com/.default' + $ParsedAuditUri = $null + if ([System.Uri]::TryCreate([string]$Data.AuditUri, [System.UriKind]::Absolute, [ref]$ParsedAuditUri) -and + $ParsedAuditUri.Scheme -eq 'https' -and + $ParsedAuditUri.Host -eq 'api.partnercenter.microsoft.com') { + $AuditLog = New-GraphGetRequest -uri $ParsedAuditUri.AbsoluteUri -tenantid $env:TenantID -NoAuthCheck $true -scope 'https://api.partnercenter.microsoft.com/.default' + } else { + Write-LogMessage -API 'Webhooks' -message "Partner Center webhook rejected: AuditUri is not a Partner Center API URL ($($Data.AuditUri))" -Sev 'Alert' + return + } } switch ($Data.EventName) { From 8c4cf8d946a3660ad2434a123a847036ce1e1db5 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Tue, 14 Jul 2026 18:35:20 +0200 Subject: [PATCH 4/6] fixes resolver for MCP --- Modules/CIPPCore/Public/MCP/Get-CippMcpToolList.ps1 | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Modules/CIPPCore/Public/MCP/Get-CippMcpToolList.ps1 b/Modules/CIPPCore/Public/MCP/Get-CippMcpToolList.ps1 index 5852d6d966e89..8abadfc81dd88 100644 --- a/Modules/CIPPCore/Public/MCP/Get-CippMcpToolList.ps1 +++ b/Modules/CIPPCore/Public/MCP/Get-CippMcpToolList.ps1 @@ -155,7 +155,8 @@ function Resolve-CippMcpNode { } if ($Node -is [System.Collections.IEnumerable]) { - return @($Node | ForEach-Object { Resolve-CippMcpNode -Node $_ -Spec $Spec -Depth ($Depth + 1) -Seen $Seen }) + $Resolved = @($Node | ForEach-Object { Resolve-CippMcpNode -Node $_ -Spec $Spec -Depth ($Depth + 1) -Seen $Seen }) + return , $Resolved } return $Node From 32ab1c860ee605f2facec2d2cc3127f6672d522c Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Tue, 14 Jul 2026 18:39:25 +0200 Subject: [PATCH 5/6] API HF --- version_latest.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version_latest.txt b/version_latest.txt index 04ada833e886e..6842906c6fc24 100644 --- a/version_latest.txt +++ b/version_latest.txt @@ -1 +1 @@ -10.6.1 +10.6.2 From 2da1872186d613926c1f37d0ff5542ada91da904 Mon Sep 17 00:00:00 2001 From: KelvinTegelaar <49186168+KelvinTegelaar@users.noreply.github.com> Date: Tue, 14 Jul 2026 18:44:28 +0200 Subject: [PATCH 6/6] move snooze to new permission --- .../HTTP Functions/CIPP/Core/Invoke-ExecRemoveSnooze.ps1 | 2 +- .../HTTP Functions/CIPP/Core/Invoke-ExecSnoozeAlert.ps1 | 2 +- .../HTTP Functions/CIPP/Core/Invoke-ListSnoozedAlerts.ps1 | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecRemoveSnooze.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecRemoveSnooze.ps1 index 3c9c50adc18fb..506af62287af0 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecRemoveSnooze.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecRemoveSnooze.ps1 @@ -3,7 +3,7 @@ function Invoke-ExecRemoveSnooze { .FUNCTIONALITY Entrypoint,AnyTenant .ROLE - CIPP.Alert.ReadWrite + CIPP.AlertSnooze.ReadWrite #> [CmdletBinding()] param($Request, $TriggerMetadata) diff --git a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecSnoozeAlert.ps1 b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecSnoozeAlert.ps1 index 262bf2fe87b4b..1cc28caaf8a49 100644 --- a/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecSnoozeAlert.ps1 +++ b/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/CIPP/Core/Invoke-ExecSnoozeAlert.ps1 @@ -3,7 +3,7 @@ function Invoke-ExecSnoozeAlert { .FUNCTIONALITY Entrypoint,AnyTenant .ROLE - CIPP.Alert.ReadWrite + CIPP.AlertSnooze.ReadWrite #> [CmdletBinding()] param($Request, $TriggerMetadata) 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 e0841c6eeced1..0f689058aa179 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 @@ -3,7 +3,7 @@ function Invoke-ListSnoozedAlerts { .FUNCTIONALITY Entrypoint,AnyTenant .ROLE - CIPP.Alert.Read + CIPP.AlertSnooze.Read .DESCRIPTION Lists alerts that have been snoozed (temporarily suppressed), filterable by cmdlet name. Returns snooze duration and scope details. #>