├── .gitignore ├── LICENSE ├── README.md └── Get-SharePointTenantPermissions.ps1 /.gitignore: -------------------------------------------------------------------------------- 1 | test* 2 | *.pfx 3 | *.csv 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Scott McKendry 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SharePoint Online Permissions Audit Script 2 | 3 | It is well known that SharePoint permissions are notoriously difficult to manage. This script is designed to help you audit permissions across your SharePoint Online sites. 4 | 5 | ## ✨ Features 6 | 7 | - Audit permissions for all sites in a SharePoint Online tenant - all the way down to list and library level. 8 | - Capture permissions granted to Security (Entra ID) and Microsoft 365 groups. 9 | - Uses a modern authentication flow that does not require a user to be logged in or have access to all sites in the tenant. 10 | 11 | ## 📝 Output 12 | 13 | The script will output a CSV file with the following columns: 14 | 15 | | Column Name | Description | 16 | | ----------------- | ------------------------------------------------------------------------------------------------- | 17 | | UserPrincipalName | The user's UPN/email address | 18 | | SiteUrl | The URL of the site | 19 | | SiteAdmin | Is the user a site admin? | 20 | | GroupName | If the user is not a site admin, what SharePoint group are they in? (also captures sharing links) | 21 | | PermissionLevel | The permission level granted to the SharePoint group, e.g full control, read, edit etc. | 22 | | ListName | The title of a list or library where the user has unique permissions. | 23 | | ListPermission | The permission level granted to the user on the list or library. | 24 | 25 | ## 🚀 Getting Started 26 | 27 | ### Prerequisites 28 | 29 | - Global Adminstrator Role 30 | - PowerShell 7 or later with the latest versions of [PnP.PowerShell](https://pnp.github.io/powershell/) and [MSAL.PS](https://github.com/AzureAD/MSAL.PS/) modules installed. 31 | - A self-signed certificate for use with the app registration. See [this article](https://docs.microsoft.com/en-us/sharepoint/dev/solution-guidance/security-apponly-azuread) for more information. 32 | 33 | ```powershell 34 | Install-Module -Name PnP.PowerShell -Scope CurrentUser 35 | Install-Module -Name MSAL.PS -Scope CurrentUser 36 | ``` 37 | 38 | ### Create an Entra ID App Registration 39 | 40 | Follow the steps in [this article](https://docs.microsoft.com/en-us/sharepoint/dev/solution-guidance/security-apponly-azuread) to create an app registration in Azure AD. Make sure you grant the app the following permissions. 41 | 42 | **Graph API** 43 | 44 | - Sites.Read.All 45 | - Directory.Read.All 46 | 47 | **SharePoint API** 48 | 49 | - Sites.FullControl.All 50 | - User.Read.All 51 | 52 | ## Usage 53 | 54 | The intention is for this script to be called by a parent script that will pass in the required parameters. This allows you to run the script against multiple users and potentially multiple tenants. 55 | Below is an example of how you might call the script. 56 | 57 | ```powershell 58 | # audit.ps1 - Create in the same directory as Get-SharePointOnlinePermissions.ps1 59 | 60 | $tenantName = "contoso" # The name of your tenant, e.g. contoso.sharepoint.com 61 | $csvPath = "C:\temp\permissions.csv" # The path to the output CSV file 62 | $clientID = "00000000-0000-0000-0000-000000000000" # The client ID of the app registration 63 | $certificatePath = "C:\temp\certificate.pfx" # The path to the certificate file 64 | $append = $true # Should the script append to the CSV file or overwrite it? 65 | 66 | $users = @( 67 | "john@contoso.com", 68 | "jane@contoso.com" 69 | ) 70 | 71 | foreach ($user in $users) { 72 | .\Get-SharePointTenantPermissions.ps1 -TenantName $tenantName -CsvPath $csvPath -ClientID $clientID -CertificatePath $certificatePath -Append:$append -UserEmail $user 73 | } 74 | 75 | ``` 76 | 77 | ## 🤝 Contributing 78 | 79 | Contributions, issues and feature requests are welcome! 80 | 81 | TODO: 82 | 83 | - [ ] Replace [MSAL.PS](https://github.com/AzureAD/MSAL.PS) cmdlets with a non-deprecated alternative 84 | -------------------------------------------------------------------------------- /Get-SharePointTenantPermissions.ps1: -------------------------------------------------------------------------------- 1 | # Get-SharePointTenantPermissions.ps1 2 | # Description: This script will get all the permissions for a given user or users in a SharePoint Online tenant and export them to a CSV file. 3 | 4 | #requires -Modules PnP.PowerShell, MSAL.PS 5 | param ( 6 | [Parameter(Mandatory = $true)] 7 | [string] $TenantName, 8 | [Parameter(Mandatory = $true)] 9 | [string] $UserEmail, 10 | [Parameter(Mandatory = $true)] 11 | [string] $CSVPath, 12 | [Parameter(Mandatory = $true)] 13 | [string] $ClientId, 14 | [Parameter(Mandatory = $true)] 15 | [string] $CertificatePath, 16 | [Parameter(Mandatory = $false)] 17 | [switch] $Append = $false 18 | ) 19 | 20 | function Connect-TenantSite { 21 | <# 22 | .SYNOPSIS 23 | Connects to a SharePoint Online site using certificate-based authentication via PnP PowerShell. 24 | #> 25 | param ( 26 | [Parameter(Mandatory = $true)] 27 | [string] $SiteUrl 28 | ) 29 | 30 | $connectionAttempts = 3 31 | for ($i = 0; $i -lt $connectionAttempts; $i++) { 32 | try { 33 | Connect-PnPOnline -Url $SiteUrl -ClientId $ClientId -CertificatePath $CertificatePath -Tenant "$TenantName.onmicrosoft.com" 34 | break 35 | } 36 | catch { 37 | if ($i -eq $connectionAttempts - 1) { 38 | Write-Error $_.Exception.Message 39 | throw $_ 40 | } 41 | continue 42 | } 43 | } 44 | } 45 | 46 | function Get-GraphToken { 47 | <# 48 | .SYNOPSIS 49 | Gets a bearer token for the Microsoft Graph API using certificate-based authentication. 50 | #> 51 | $connectionParameters = @{ 52 | 'TenantId' = "$TenantName.onmicrosoft.com" 53 | 'ClientId' = $ClientId 54 | 'ClientCertificate' = $CertificatePath 55 | } 56 | 57 | try { 58 | return Get-MsalToken @connectionParameters 59 | } 60 | catch { 61 | Write-Error $_.Exception.Message 62 | throw $_ 63 | } 64 | } 65 | 66 | function Get-UserGroupMembership { 67 | <# 68 | .SYNOPSIS 69 | Gets the group membership for a given user. Returns an array of objects containing the group name and group id. 70 | #> 71 | param ( 72 | [Parameter(Mandatory = $true)] 73 | [string] $UserEmail 74 | ) 75 | 76 | $accessToken = Get-GraphToken 77 | $encodedUserEmail = [System.Web.HttpUtility]::UrlEncode($UserEmail) 78 | 79 | try { 80 | $groupMemberShipResponse = Invoke-WebRequest -Uri "https://graph.microsoft.com/v1.0/users/$encodedUserEmail/memberOf" -Method GET -Headers @{ 81 | Authorization = "Bearer $($accessToken.AccessToken)" 82 | } | ConvertFrom-Json 83 | 84 | # If @odata.nextLink exists, get next page of results 85 | while ($groupMemberShipResponse.'@odata.nextLink') { 86 | $appendGroupMembershipResponse = Invoke-WebRequest -Uri $groupMemberShipResponse.'@odata.nextLink' -Method GET -Headers @{ 87 | Authorization = "Bearer $($accessToken.AccessToken)" 88 | } 89 | $graphGroupMembership.value += $appendGroupMembershipResponse.value 90 | $graphGroupMembership.'@odata.nextLink' = $appendGroupMembershipResponse.'@odata.nextLink' 91 | } 92 | } 93 | catch { 94 | Write-Error $_.Exception.Message 95 | throw $_ 96 | } 97 | 98 | $groupMembership = @() 99 | foreach ($group in $groupMemberShipResponse.value) { 100 | $groupMembership += [PSCustomObject]@{ 101 | GroupName = $group.displayName 102 | GroupId = $group.id 103 | } 104 | } 105 | 106 | return $groupMembership 107 | } 108 | 109 | function New-CsvFile { 110 | <# 111 | .SYNOPSIS 112 | Creates a new CSV file. 113 | #> 114 | param ( 115 | [Parameter(Mandatory = $true)] 116 | [string] $Path 117 | ) 118 | 119 | $csv = [PSCustomObject]@{ 120 | UserPrincipalName = $null 121 | SiteUrl = $null 122 | SiteAdmin = $null 123 | GroupName = $null 124 | PermissionLevel = $null 125 | ListName = $null 126 | ListPermission = $null 127 | } 128 | 129 | if (Test-Path $Path) { 130 | Remove-Item $Path 131 | } 132 | 133 | $csv | Export-Csv -Path $Path -NoTypeInformation 134 | 135 | # Remove the first (empty) line of the CSV file 136 | $csvFile = Get-Content $Path 137 | $csvFile = $csvFile[0..($csvFile.Length - 2)] 138 | Set-Content -Path $Path -Value $csvFile 139 | } 140 | 141 | function Test-UserIsSiteCollectionAdmin { 142 | <# 143 | .SYNOPSIS 144 | Checks if a given user is a site collection admin for a given site collection. 145 | #> 146 | param ( 147 | [Parameter(Mandatory = $true)] 148 | [string] $UserEmail, 149 | [Parameter(Mandatory = $false)] 150 | [array] $GraphGroups 151 | ) 152 | 153 | $siteAdmins = Get-PnPSiteCollectionAdmin 154 | foreach ($siteAdmin in $siteAdmins) { 155 | $siteAdminLogin = $siteAdmin.LoginName.Split('|')[2] 156 | 157 | if ($UserEmail -eq $siteAdminLogin) { 158 | return $true 159 | } 160 | 161 | # Check if user is a member of a group that is a site collection admin 162 | if ($null -ne $GraphGroups) { 163 | if ($userGroupMembership.GroupId -contains $siteAdminLogin) { 164 | return $true 165 | } 166 | } 167 | } 168 | 169 | return $false 170 | } 171 | 172 | function Get-UserSharePointGroups { 173 | <# 174 | .SYNOPSIS 175 | Returns an array of SharePoint groups that a given user is a member of for a given site collection. 176 | #> 177 | param ( 178 | [Parameter(Mandatory = $true)] 179 | [string] $UserEmail, 180 | [Parameter(Mandatory = $false)] 181 | [array] $GraphGroups 182 | ) 183 | 184 | $siteGroups = Get-PnPGroup 185 | 186 | $groupMembership = @() 187 | foreach ($siteGroup in $siteGroups) { 188 | $groupMembers = Get-PnPGroupMember -Identity $siteGroup.Title 189 | 190 | foreach ($groupMember in $groupMembers) { 191 | $groupMemberLogin = $groupMember.LoginName.Split('|')[2] 192 | if ($UserEmail -eq $groupMemberLogin) { 193 | $groupPermissionLevel = Get-PnPGroupPermissions -Identity $siteGroup 194 | $permissionLevelString = "" 195 | foreach ($permissionLevel in $groupPermissionLevel) { 196 | $permissionLevelString += $permissionLevel.Name + " | " 197 | } 198 | 199 | if ($permissionLevelString -eq "") { 200 | $permissionLevelString = "No Permissions" 201 | } 202 | else { 203 | # remove trailing " | " 204 | $permissionLevelString = $permissionLevelString.Substring(0, $permissionLevelString.Length - 3) 205 | } 206 | 207 | $groupMembership += [PSCustomObject]@{ 208 | GroupName = $siteGroup.Title 209 | PermissionLevel = $permissionLevelString 210 | } 211 | 212 | } 213 | elseif ($null -ne $GraphGroups) { 214 | if ($userGroupMembership.GroupId -contains $groupMemberLogin) { 215 | $groupPermissionLevel = Get-PnPGroupPermissions -Identity $siteGroup 216 | $permissionLevelString = "" 217 | foreach ($permissionLevel in $groupPermissionLevel) { 218 | $permissionLevelString += $permissionLevel.Name + " | " 219 | } 220 | 221 | if ($permissionLevelString -eq "") { 222 | $permissionLevelString = "No Permissions" 223 | } 224 | else { 225 | # remove trailing " | " 226 | $permissionLevelString = $permissionLevelString.Substring(0, $permissionLevelString.Length - 3) 227 | } 228 | 229 | $groupMembership += [PSCustomObject]@{ 230 | GroupName = $siteGroup.Title 231 | PermissionLevel = $permissionLevelString 232 | } 233 | } 234 | } 235 | } 236 | } 237 | 238 | return $groupMembership 239 | } 240 | 241 | function Get-UniqueListPermissions { 242 | <# 243 | .SYNOPSIS 244 | Gets the unique permissions at the list level for a given user for a given site collection. 245 | #> 246 | param ( 247 | [Parameter(Mandatory = $true)] 248 | [string] $UserEmail, 249 | [Parameter(Mandatory = $false)] 250 | [array] $GraphGroups 251 | ) 252 | 253 | $ctx = Get-PnPContext 254 | $web = $ctx.Web 255 | $ctx.Load($web) 256 | $ctx.ExecuteQuery() 257 | 258 | $lists = $web.Lists 259 | $ctx.Load($lists) 260 | $ctx.ExecuteQuery() 261 | 262 | # Exlude built-in lists 263 | $excludedLists = @("App Packages", "appdata", "appfiles", "Apps in Testing", "Cache Profiles", "Composed Looks", "Content and Structure Reports", "Content type publishing error log", "Converted Forms", "Device Channels", "Form Templates", "fpdatasources", "Get started with Apps for Office and SharePoint", "List Template Gallery", "Long Running Operation Status", "Maintenance Log Library", "Style Library", , "Master Docs", "Master Page Gallery", "MicroFeed", "NintexFormXml", "Quick Deploy Items", "Relationships List", "Reusable Content", "Search Config List", "Solution Gallery", "Site Collection Images", "Suggested Content Browser Locations", "TaxonomyHiddenList", "User Information List", "Web Part Gallery", "wfpub", "wfsvc", "Workflow History", "Workflow Tasks", "Preservation Hold Library", "SharePointHomeCacheList") 264 | $siteListPermissions = @() 265 | foreach ($list in $lists) { 266 | $ctx.Load($list) 267 | $ctx.ExecuteQuery() 268 | 269 | if ($excludedLists -contains $list.Title) { 270 | continue 271 | } 272 | 273 | $list.Retrieve("HasUniqueRoleAssignments") 274 | $ctx.ExecuteQuery() 275 | 276 | if ($list.HasUniqueRoleAssignments) { 277 | $listPermissions = $list.RoleAssignments 278 | $ctx.Load($listPermissions) 279 | $ctx.ExecuteQuery() 280 | 281 | foreach ($roleassignment in $listPermissions) { 282 | $ctx.Load($roleassignment.Member) 283 | $ctx.Load($roleassignment.RoleDefinitionBindings) 284 | $ctx.ExecuteQuery() 285 | 286 | if ($UserEmail -eq ($roleassignment.Member.LoginName.Split('|')[2])) { 287 | $listPermission = [PSCustomObject]@{ 288 | Name = $list.Title 289 | PermissionLevel = $roleassignment.RoleDefinitionBindings.Name 290 | } 291 | 292 | $siteListPermissions += $listPermission 293 | } 294 | elseif ($null -ne $GraphGroups) { 295 | if ( $GraphGroups.GroupId -contains ($roleassignment.Member.LoginName.Split('|')[2])) { 296 | $listPermission = [PSCustomObject]@{ 297 | Name = $list.Title 298 | PermissionLevel = $roleassignment.RoleDefinitionBindings.Name 299 | } 300 | 301 | $siteListPermissions += $listPermission 302 | } 303 | } 304 | } 305 | } 306 | } 307 | return $siteListPermissions 308 | } 309 | 310 | Set-Location $PSScriptRoot 311 | 312 | Write-Host "$(Get-Date) INFO: Connecting to tenant admin site..." 313 | Connect-TenantSite -SiteUrl "https://$TenantName-admin.sharepoint.com" -ErrorAction Stop 314 | 315 | Write-Host "$(Get-Date) INFO: Getting all site collections..." 316 | $siteCollections = Get-PnPTenantSite -ErrorAction Stop 317 | Write-Host "$(Get-Date) INFO: `tFound $($siteCollections.Count) site collections." 318 | Disconnect-PnPOnline 319 | 320 | Write-Host "$(Get-Date) INFO: Getting group membership for $UserEmail..." 321 | $userGroupMembership = Get-UserGroupMembership -UserEmail $UserEmail -ErrorAction Stop 322 | Write-Host "$(Get-Date) INFO: `tFound $($userGroupMembership.Count) groups." 323 | 324 | if (!$Append) { 325 | New-CsvFile -Path $CSVPath 326 | } 327 | 328 | $siteCounter = 1 329 | foreach ($siteCollection in $siteCollections) { 330 | Write-Host "$(Get-Date) INFO: Connecting to $($siteCollection.Url)`t ($siteCounter of $($siteCollections.Count))..." 331 | $siteCounter++ 332 | Connect-TenantSite -SiteUrl $siteCollection.Url 333 | 334 | if (Test-UserIsSiteCollectionAdmin -UserEmail $UserEmail) { 335 | Write-Host "$(Get-Date) INFO: `t$UserEmail is a site collection admin for $($siteCollection.Url)." 336 | $csvLineObject = [PSCustomObject]@{ 337 | UserPrincipalName = $UserEmail 338 | SiteUrl = $siteCollection.Url 339 | SiteAdmin = $true 340 | GroupName = $null 341 | PermissionLevel = $null 342 | ListName = $null 343 | ListPermission = $null 344 | } 345 | $csvLineObject | Export-Csv -Path $CSVPath -Append -NoTypeInformation 346 | continue 347 | } 348 | 349 | # Check if user is a member of any SharePoint groups 350 | $sharepointGroupMembership = Get-UserSharePointGroups -UserEmail $UserEmail -GraphGroups $userGroupMembership 351 | if ($sharepointGroupMembership) { 352 | foreach ($group in $sharepointGroupMembership) { 353 | Write-Host "$(Get-Date) INFO: `t$UserEmail is a member of $($group.GroupName) with $($group.PermissionLevel) permissions." 354 | $csvLineObject = [PSCustomObject]@{ 355 | UserPrincipalName = $UserEmail 356 | SiteUrl = $siteCollection.Url 357 | SiteAdmin = $false 358 | GroupName = $group.GroupName 359 | PermissionLevel = $group.PermissionLevel 360 | ListName = $null 361 | ListPermission = $null 362 | } 363 | $csvLineObject | Export-Csv -Path $CSVPath -Append -NoTypeInformation 364 | } 365 | } 366 | 367 | # Check if user has unique permissions at the list level 368 | $listPermissions = Get-UniqueListPermissions -UserEmail $UserEmail -GraphGroups $userGroupMembership 369 | if ($listPermissions.Count -gt 0) { 370 | foreach ($listPermission in $listPermissions) { 371 | Write-Host "$(Get-Date) INFO: `t$UserEmail has $($listPermission.PermissionLevel) permissions on $($listPermission.Name)." 372 | $csvLineObject = [PSCustomObject]@{ 373 | UserPrincipalName = $UserEmail 374 | SiteUrl = $siteCollection.Url 375 | SiteAdmin = $false 376 | GroupName = $null 377 | PermissionLevel = $null 378 | ListName = $listPermission.Name 379 | ListPermission = $listPermission.PermissionLevel 380 | } 381 | $csvLineObject | Export-Csv -Path $CSVPath -Append -NoTypeInformation 382 | } 383 | } 384 | 385 | # Reset user ID - Prevents false positives and extra work being done on sites the user has never visited 386 | Disconnect-PnPOnline 387 | } 388 | --------------------------------------------------------------------------------