Skip to content

Commit a464102

Browse files
feat(sharepoint): add permissions report and library management
Adds a full SharePoint Permissions report with cached data collection, PDF export, and oversharing signal detection (tenant-wide grants, external grants, direct Full Control, detached libraries). Key additions: - Backend: SharePointPermissions cache type, batched site collection via activity triggers, REST API endpoints for listing/setting/removing library permissions, role definitions, site user access, and multi-queue status - Frontend: Permissions Report page with charts and filters, Library Permissions dialog (add/change/remove/inheritance control), Check User Access dialog, multi-queue progress tracker, query refresh button, reusable PDF primitives - Sharing report enhancements: sprawl signals (anonymous editable links, never-expiring links, folder shares, external recipients), top libraries/recipients charts, PDF export, queue progress tracking Synced from CyberDrain/CIPP@a2156f3
1 parent 36c65e9 commit a464102

16 files changed

Lines changed: 1616 additions & 35 deletions

Config/CIPPDBCacheTypes.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,11 @@
304304
"friendlyName": "SharePoint & OneDrive Sharing Links",
305305
"description": "Sharing links and external grants on SharePoint and OneDrive files and folders"
306306
},
307+
{
308+
"type": "SharePointPermissions",
309+
"friendlyName": "SharePoint Permissions",
310+
"description": "Site and document library permission assignments, including libraries that no longer inherit and grants to tenant-wide claims such as Everyone except external users"
311+
},
307312
{
308313
"type": "OfficeActivations",
309314
"friendlyName": "Office Activations",
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
function Push-DBCacheSharePointPermissionsBatch {
2+
<#
3+
.SYNOPSIS
4+
Collects site and document library permissions for a batch of SharePoint sites.
5+
6+
.DESCRIPTION
7+
Processes up to 20 site seeds per activity via the SharePoint REST API with certificate
8+
authentication. Each site is wrapped in its own try/catch so a batch of N sites always
9+
returns exactly N site results - Push-StoreSharePointPermissions relies on that to verify
10+
completeness before it replaces the cache.
11+
12+
Per site: the root web role assignments, then the visible document libraries. Only
13+
libraries that hold their own permissions (HasUniqueRoleAssignments) are read - a library
14+
that still inherits has exactly the site's permissions, so storing them would fill the
15+
cache with rows carrying no information. Cache size therefore tracks governance drift
16+
rather than tenant size.
17+
18+
Two row types are emitted, discriminated by rowType:
19+
- Site one per scanned site, always exactly one whether collection succeeded or not.
20+
Carries collectionStatus, the library counts and the error when Skipped.
21+
- Assignment one per scope, principal and permission level. A principal holding several
22+
levels on the same scope produces one row per level, because each is granted
23+
and removed separately.
24+
25+
A library with unique permissions but no assignments (possible after breaking inheritance
26+
without copying) still emits one Assignment row with null principal fields, so it is not
27+
silently missing from the library inventory.
28+
29+
broadClaim flags the tenant-wide claims SharePoint exposes as well-known login names -
30+
Everyone, Everyone except external users, and All Users. These are the oversharing
31+
footgun this scan exists to surface.
32+
33+
collectionStatus:
34+
- Full the root web and every library that needed reading were collected
35+
- Skipped SPO REST collection failed for the site; no assignment rows are emitted.
36+
Push-StoreSharePointPermissions may restore this site's rows from the prior
37+
cache (merge-on-Skip) before the tenant write.
38+
39+
Consumer notes:
40+
- Grant paths are not effective access: security groups are stored as principals and are
41+
not expanded to their members
42+
- Libraries that inherit are absent by design; their permissions are the site's
43+
- Folder and file level permissions are out of scope (that would cost a full tree walk)
44+
45+
.FUNCTIONALITY
46+
Entrypoint
47+
#>
48+
[CmdletBinding()]
49+
param($Item)
50+
51+
$TenantFilter = $Item.TenantFilter
52+
$BatchNumber = $Item.BatchNumber
53+
$SiteSeeds = @($Item.Sites)
54+
55+
# Returns $true when a SharePoint principal is a guest/external identity.
56+
function Test-SPGuestPrincipal {
57+
param($Principal)
58+
[bool]$Principal.IsShareByEmailGuestUser -or [bool]$Principal.IsEmailAuthenticationGuestUser -or
59+
($Principal.LoginName -match '(?i)#ext#|urn%3aspo%3aguest')
60+
}
61+
62+
function Get-SPPrincipalType {
63+
param($Entity)
64+
if ($Entity.LoginName -match '(?i)federateddirectoryclaimprovider') { return 'M365 Group' }
65+
switch ($Entity.PrincipalType) {
66+
1 { 'User' }
67+
4 { 'Security Group' }
68+
8 { 'SharePoint Group' }
69+
default { 'Other' }
70+
}
71+
}
72+
73+
# SharePoint expresses tenant-wide audiences as well-known claim login names. -like is used
74+
# rather than -match because these contain regex metacharacters ( | ! ).
75+
function Get-SPBroadClaim {
76+
param([string]$LoginName)
77+
if ([string]::IsNullOrWhiteSpace($LoginName)) { return $null }
78+
if ($LoginName -like 'c:0(.s|true*') { return 'Everyone' }
79+
if ($LoginName -like '*spo-grid-all-users*') { return 'EveryoneExceptExternal' }
80+
if ($LoginName -like 'c:0!.s|windows*') { return 'AllUsers' }
81+
return $null
82+
}
83+
84+
# Flattens one roleassignments response into Assignment rows for a scope.
85+
function ConvertTo-AssignmentRow {
86+
param($Assignments, $Site, [string]$Scope, $Library, [string]$CollectedAt)
87+
88+
$Rows = [System.Collections.Generic.List[object]]::new()
89+
foreach ($Assignment in @($Assignments)) {
90+
$Member = $Assignment.Member
91+
if (-not $Member) { continue }
92+
$BroadClaim = Get-SPBroadClaim -LoginName ([string]$Member.LoginName)
93+
foreach ($Binding in @($Assignment.RoleDefinitionBindings)) {
94+
$Rows.Add([PSCustomObject]@{
95+
rowType = 'Assignment'
96+
id = "$($Site.SiteId)_$($Library.Id ?? 'root')_$($Member.Id)_$($Binding.Id)"
97+
siteId = $Site.SiteId
98+
siteName = $Site.SiteName
99+
siteUrl = $Site.SiteUrl
100+
scope = $Scope
101+
libraryId = $Library.Id
102+
libraryTitle = $Library.Title
103+
libraryUrl = $Library.Url
104+
principalId = [string]$Member.Id
105+
title = $Member.Title
106+
loginName = $Member.LoginName
107+
email = $Member.Email
108+
userPrincipalName = if ($Member.PrincipalType -eq 1 -and $Member.LoginName) { ($Member.LoginName -split '\|')[-1] } else { $null }
109+
principalType = Get-SPPrincipalType -Entity $Member
110+
isGuest = (Test-SPGuestPrincipal $Member)
111+
permissionLevel = $Binding.Name
112+
roleDefinitionId = [string]$Binding.Id
113+
isSystemManaged = ($Binding.RoleTypeKind -eq 1)
114+
broadClaim = $BroadClaim
115+
# Boolean companion to broadClaim: the three claim types share no common
116+
# substring, so a table filter needs a single field to match on.
117+
isTenantWide = [bool]$BroadClaim
118+
collectedAt = $CollectedAt
119+
})
120+
}
121+
}
122+
return $Rows
123+
}
124+
125+
function New-SiteRow {
126+
param($SiteSeed, [string]$Status, [string]$ErrorMessage, [int]$LibrariesScanned, [int]$LibrariesUnique, [string]$CollectedAt)
127+
[PSCustomObject]@{
128+
rowType = 'Site'
129+
id = "$($SiteSeed.id)_site"
130+
siteId = $SiteSeed.id
131+
siteName = $SiteSeed.displayName
132+
siteUrl = $SiteSeed.webUrl
133+
collectionStatus = $Status
134+
collectionError = $ErrorMessage
135+
librariesScanned = $LibrariesScanned
136+
librariesWithUniquePermissions = $LibrariesUnique
137+
collectedAt = $CollectedAt
138+
}
139+
}
140+
141+
$SiteResults = [System.Collections.Generic.List[object]]::new()
142+
143+
try {
144+
Write-Information "Processing SharePoint permissions batch $BatchNumber for tenant $TenantFilter with $($SiteSeeds.Count) sites"
145+
146+
$SharePointInfo = Get-SharePointAdminLink -Public $false -tenantFilter $TenantFilter
147+
$Scope = "$($SharePointInfo.SharePointUrl)/.default"
148+
$JsonAccept = @{ Accept = 'application/json;odata=nometadata' }
149+
150+
foreach ($SiteSeed in $SiteSeeds) {
151+
$CollectedAt = (Get-Date).ToUniversalTime().ToString('o')
152+
$Rows = [System.Collections.Generic.List[object]]::new()
153+
try {
154+
$BaseUri = "$($SiteSeed.webUrl.TrimEnd('/'))/_api"
155+
$SiteContext = [PSCustomObject]@{
156+
SiteId = $SiteSeed.id
157+
SiteName = $SiteSeed.displayName
158+
SiteUrl = $SiteSeed.webUrl
159+
}
160+
$NoLibrary = [PSCustomObject]@{ Id = $null; Title = $null; Url = $null }
161+
162+
# 1) Root web assignments.
163+
$WebAssignments = @(New-GraphGetRequest -uri "$BaseUri/web/roleassignments?`$expand=Member,RoleDefinitionBindings" -tenantid $TenantFilter -scope $Scope -extraHeaders $JsonAccept -UseCertificate -AsApp $true)
164+
foreach ($Row in (ConvertTo-AssignmentRow -Assignments $WebAssignments -Site $SiteContext -Scope 'Site' -Library $NoLibrary -CollectedAt $CollectedAt)) {
165+
$Rows.Add($Row)
166+
}
167+
168+
# 2) Document libraries. BaseTemplate 101 is a document library, 119 the Site Pages
169+
# library - the same pair Invoke-ListSiteLibraries surfaces through Graph.
170+
$Lists = @(New-GraphGetRequest -uri "$BaseUri/web/lists?`$select=Id,Title,Hidden,BaseTemplate,HasUniqueRoleAssignments,RootFolder/ServerRelativeUrl&`$expand=RootFolder" -tenantid $TenantFilter -scope $Scope -extraHeaders $JsonAccept -UseCertificate -AsApp $true)
171+
$Libraries = @($Lists | Where-Object { $_.Hidden -ne $true -and $_.BaseTemplate -in @(101, 119) })
172+
173+
$UniqueCount = 0
174+
foreach ($Library in $Libraries) {
175+
# $select should project HasUniqueRoleAssignments across the collection. If it
176+
# comes back null the property was not projected, so fall back to reading it
177+
# per library - one extra call each, the scan stays proportional to libraries.
178+
$HasUnique = $Library.HasUniqueRoleAssignments
179+
if ($null -eq $HasUnique) {
180+
Write-Information "SharePoint permissions: HasUniqueRoleAssignments not projected on the lists collection for '$($SiteSeed.webUrl)', falling back to a per-library check"
181+
$ListInfo = New-GraphGetRequest -uri "$BaseUri/web/lists(guid'$($Library.Id)')?`$select=HasUniqueRoleAssignments" -tenantid $TenantFilter -scope $Scope -extraHeaders $JsonAccept -UseCertificate -AsApp $true
182+
$HasUnique = $ListInfo.HasUniqueRoleAssignments
183+
}
184+
if ($HasUnique -ne $true) { continue }
185+
186+
$UniqueCount++
187+
$LibraryContext = [PSCustomObject]@{
188+
Id = [string]$Library.Id
189+
Title = $Library.Title
190+
Url = $Library.RootFolder.ServerRelativeUrl
191+
}
192+
$LibraryAssignments = @(New-GraphGetRequest -uri "$BaseUri/web/lists(guid'$($Library.Id)')/roleassignments?`$expand=Member,RoleDefinitionBindings" -tenantid $TenantFilter -scope $Scope -extraHeaders $JsonAccept -UseCertificate -AsApp $true)
193+
$LibraryRows = ConvertTo-AssignmentRow -Assignments $LibraryAssignments -Site $SiteContext -Scope 'Library' -Library $LibraryContext -CollectedAt $CollectedAt
194+
foreach ($Row in $LibraryRows) { $Rows.Add($Row) }
195+
196+
# Unique permissions but nothing granted: keep the library visible in the
197+
# inventory rather than letting it vanish from the counts.
198+
if ($LibraryRows.Count -eq 0) {
199+
$Rows.Add([PSCustomObject]@{
200+
rowType = 'Assignment'
201+
id = "$($SiteSeed.id)_$($Library.Id)_empty"
202+
siteId = $SiteSeed.id
203+
siteName = $SiteSeed.displayName
204+
siteUrl = $SiteSeed.webUrl
205+
scope = 'Library'
206+
libraryId = $LibraryContext.Id
207+
libraryTitle = $LibraryContext.Title
208+
libraryUrl = $LibraryContext.Url
209+
principalId = $null
210+
title = $null
211+
loginName = $null
212+
email = $null
213+
userPrincipalName = $null
214+
principalType = $null
215+
isGuest = $false
216+
permissionLevel = $null
217+
roleDefinitionId = $null
218+
isSystemManaged = $false
219+
broadClaim = $null
220+
isTenantWide = $false
221+
collectedAt = $CollectedAt
222+
})
223+
}
224+
}
225+
226+
$SiteResults.Add([PSCustomObject]@{
227+
SiteId = $SiteSeed.id
228+
CollectionStatus = 'Full'
229+
SiteRow = (New-SiteRow -SiteSeed $SiteSeed -Status 'Full' -ErrorMessage $null -LibrariesScanned $Libraries.Count -LibrariesUnique $UniqueCount -CollectedAt $CollectedAt)
230+
Rows = @($Rows)
231+
})
232+
233+
} catch {
234+
Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "SharePoint permissions: collection failed for '$($SiteSeed.webUrl)': $($_.Exception.Message)" -sev Warning
235+
$SiteResults.Add([PSCustomObject]@{
236+
SiteId = $SiteSeed.id
237+
CollectionStatus = 'Skipped'
238+
SiteRow = (New-SiteRow -SiteSeed $SiteSeed -Status 'Skipped' -ErrorMessage $_.Exception.Message -LibrariesScanned 0 -LibrariesUnique 0 -CollectedAt $CollectedAt)
239+
Rows = @()
240+
})
241+
}
242+
}
243+
244+
if ($SiteResults.Count -ne $SiteSeeds.Count) {
245+
throw "Batch $BatchNumber invariant violated: expected $($SiteSeeds.Count) site results, got $($SiteResults.Count)"
246+
}
247+
248+
return [PSCustomObject]@{
249+
BatchNumber = $BatchNumber
250+
Sites = @($SiteResults)
251+
}
252+
253+
} catch {
254+
$ErrorMsg = "Failed SharePoint permissions batch $BatchNumber for tenant $TenantFilter : $($_.Exception.Message)"
255+
Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message $ErrorMsg -sev Error -LogData (Get-CippException -Exception $_)
256+
throw
257+
}
258+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
function Push-StoreSharePointPermissions {
2+
<#
3+
.SYNOPSIS
4+
Post-execution function that aggregates per-batch SharePoint permission rows and writes the cache.
5+
6+
.DESCRIPTION
7+
Collects the Sites arrays returned by every Push-DBCacheSharePointPermissionsBatch activity,
8+
flattens their Site and Assignment rows into a single row set, and writes
9+
SharePointPermissions once via Add-CIPPDbItem.
10+
11+
Completeness guard: if the number of site results does not match ExpectedSiteCount the
12+
function throws without writing. The cache is written in replace mode, so writing a partial
13+
set would silently discard every site the failed batches were responsible for.
14+
15+
Merge-on-Skip: when a site returns Skipped, its rows are restored from the existing cache
16+
(matched on siteId) so a transient SPO failure does not erase permission data that was
17+
collected successfully on an earlier run. A Skipped site with no prior rows keeps just its
18+
Site row, which carries collectionStatus and the error - the report can then say the site
19+
could not be scanned rather than implying it has no permissions.
20+
21+
Row types written (see Push-DBCacheSharePointPermissionsBatch for the full schema):
22+
- rowType 'Site' one per site, always present, carries collectionStatus and library counts
23+
- rowType 'Assignment' one per scope, principal and permission level
24+
25+
.FUNCTIONALITY
26+
Entrypoint
27+
#>
28+
[CmdletBinding()]
29+
param($Item)
30+
31+
$TenantFilter = $Item.Parameters.TenantFilter
32+
$ExpectedSiteCount = [int]$Item.Parameters.ExpectedSiteCount
33+
34+
try {
35+
$SiteResults = [System.Collections.Generic.List[object]]::new()
36+
foreach ($BatchResult in @($Item.Results)) {
37+
foreach ($SiteResult in @($BatchResult.Sites)) {
38+
if ($SiteResult) { $SiteResults.Add($SiteResult) }
39+
}
40+
}
41+
42+
$ActualCount = $SiteResults.Count
43+
if ($ActualCount -ne $ExpectedSiteCount) {
44+
throw "SharePoint permissions completeness check failed for $TenantFilter : expected $ExpectedSiteCount site results, got $ActualCount"
45+
}
46+
47+
# Restore rows for sites that could not be collected this run.
48+
$SkippedResults = @($SiteResults | Where-Object { $_.CollectionStatus -eq 'Skipped' })
49+
$MergedCount = 0
50+
$PriorRowsBySiteId = @{}
51+
if ($SkippedResults.Count -gt 0) {
52+
foreach ($Existing in @(New-CIPPDbRequest -TenantFilter $TenantFilter -Type 'SharePointPermissions')) {
53+
if ($Existing.rowType -ne 'Assignment') { continue }
54+
$Key = [string]$Existing.siteId
55+
if (-not $Key) { continue }
56+
if (-not $PriorRowsBySiteId.ContainsKey($Key)) {
57+
$PriorRowsBySiteId[$Key] = [System.Collections.Generic.List[object]]::new()
58+
}
59+
$PriorRowsBySiteId[$Key].Add($Existing)
60+
}
61+
}
62+
63+
$AllRows = [System.Collections.Generic.List[object]]::new()
64+
foreach ($SiteResult in $SiteResults) {
65+
if ($SiteResult.SiteRow) { $AllRows.Add($SiteResult.SiteRow) }
66+
67+
if ($SiteResult.CollectionStatus -eq 'Skipped') {
68+
$Key = [string]$SiteResult.SiteId
69+
if ($Key -and $PriorRowsBySiteId.ContainsKey($Key)) {
70+
foreach ($Row in $PriorRowsBySiteId[$Key]) { $AllRows.Add($Row) }
71+
$MergedCount++
72+
}
73+
continue
74+
}
75+
76+
foreach ($Row in @($SiteResult.Rows)) {
77+
if ($Row) { $AllRows.Add($Row) }
78+
}
79+
}
80+
81+
if ($SkippedResults.Count -gt 0) {
82+
$RemainingSkipped = $SkippedResults.Count - $MergedCount
83+
Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "SharePoint permissions: $($SkippedResults.Count) of $ActualCount sites returned Skipped from collection; restored $MergedCount from prior cache; $RemainingSkipped have no permission rows" -sev Warning
84+
}
85+
86+
Add-CIPPDbItem -TenantFilter $TenantFilter -Type 'SharePointPermissions' -Data @($AllRows) -AddCount
87+
88+
$AssignmentCount = @($AllRows | Where-Object { $_.rowType -eq 'Assignment' }).Count
89+
Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Cached $AssignmentCount SharePoint permission assignments across $ActualCount sites ($MergedCount merge-on-Skip) from $(@($Item.Results).Count) batches" -sev Info
90+
return
91+
92+
} catch {
93+
Write-LogMessage -API 'CIPPDBCache' -tenant $TenantFilter -message "Failed to store SharePoint permissions: $($_.Exception.Message)" -sev Error -LogData (Get-CippException -Exception $_)
94+
throw
95+
}
96+
}

0 commit comments

Comments
 (0)