|
| 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 | +} |
0 commit comments