Find Broken Permission Inheritance in SharePoint with PowerShell
Oversharing lives under broken inheritance, a case where the website may look neat from above, yet a single folder has access to everyone after being shared 18 months ago. There is nothing within the user interface to show them all at once.
Identifying each of the items that are not inheriting, and who is allowed in there, requires four different tests. The following was executed on a live website using PnP.PowerShell 2.12.0
Permissions break at four levels
Site, list, folder and item. You have to check all four, and the checks are different at each.
The site
$web = Get-PnPWeb
Get-PnPProperty -ClientObject $web -Property HasUniqueRoleAssignments
$web.HasUniqueRoleAssignments
True
That shouldn’t scare you. There is no parent web to this site collection’s top level web, and hence it always returns True. This is relevant only for subsites.
Notice the line Get-PnPProperty. Most properties related to permissions are not available when you retrieve an object; they have to be requested separately. Skip this line and HasUniqueRoleAssignments won’t even return anything – which is far worse.
Lists and libraries
Get-PnPList | Where-Object { -not $_.Hidden } | ForEach-Object {
[pscustomobject]@{
List = $_.Title
Unique = Get-PnPProperty -ClientObject $_ -Property HasUniqueRoleAssignments
}
}
List Unique
---- ------
Departments False
Documents False
Events False
Project Documents False
Projects False
Site Pages False
Style Library False
Items, and why this is the slow part
Get-PnPListItem -List 'Projects' -PageSize 500 | ForEach-Object {
$unique = Get-PnPProperty -ClientObject $_ -Property HasUniqueRoleAssignments
if ($unique) { [pscustomobject]@{ Id = $_.Id; Title = $_['Title'] } }
}
Id Title
-- -----
3 Project 03
That took 6.2 seconds for 25 items.
This is a unique round-trip to the server for each item. Bulk asking is not an option. Thus, a collection of 10,000 items would take about forty minutes, while a huge tenant would require an overnight job. This is the way the API works, and not an error in the script, and that is why it is advised to scope such reports to a site rather than to everything.
Folders, and the one that breaks your script
The obvious way to check folders looks like this, and it does not work:
Get-PnPFolderItem -FolderSiteRelativeUrl 'Project Documents' -ItemType Folder | ForEach-Object {
$folderItem = Get-PnPProperty -ClientObject $_ -Property ListItemAllFields
Get-PnPProperty -ClientObject $folderItem -Property HasUniqueRoleAssignments
}
Object reference not set to an instance of an object on server.
The object is associated with property ListItemAllFields.
Form is to blame. Each document library has it, Form is a SharePoint feature, it contains the forms pages for the library and it has no list item backing. So when you try to ask about ListItemAllFields from Form, you don’t get anything and the following line throws an exception.
Please note that the exception does not indicate which folder was causing the problem. You have an object reference exception thrown for a folder that you didn’t even create.
Ask the list for its folders instead:
Get-PnPListItem -List 'Project Documents' -PageSize 500 |
Where-Object { $_.FileSystemObjectType -eq 'Folder' } |
ForEach-Object {
[pscustomobject]@{
Folder = $_['FileLeafRef']
Unique = Get-PnPProperty -ClientObject $_ -Property HasUniqueRoleAssignments
}
}
Folder Unique
------ ------
Contracts False
Reports False
Archive True
2024 False
2025 False
Forms never appears, because it is not a list item. Problem gone rather than worked around.
Who actually has access
Knowing something is broken is half of it. You want to know who that let in:
Get-PnPListItemPermission -List 'Projects' -Identity 3
HasUniqueRoleAssignments Permissions
------------------------ -----------
True {PowerShell Demo Owners, PowerShell Demo Visitors, PowerShell Demo Members}
For the permission levels as well as the names, walk the role assignments:
$item = Get-PnPListItem -List 'Projects' -Id 3
$assignments = Get-PnPProperty -ClientObject $item -Property RoleAssignments
$rows = foreach ($ra in $assignments) {
$member = Get-PnPProperty -ClientObject $ra -Property Member
$roles = Get-PnPProperty -ClientObject $ra -Property RoleDefinitionBindings
[pscustomobject]@{
Principal = $member.Title
Type = $member.PrincipalType
Roles = ($roles | ForEach-Object { $_.Name }) -join ', '
}
}
$rows | Format-Table -AutoSize
Principal Type Roles
--------- ---- -----
PowerShell Demo Owners SharePointGroup Full Control
PowerShell Demo Visitors SharePointGroup Read
PowerShell Demo Members SharePointGroup Edit
One to look out for here is $rows = foreach. The foreach construct is not pipeable, only the ForEach-Object cmdlet is pipeable. You will see “An empty pipe element is not allowed” once you place a pipe right after the closing brace, and it will not directly pinpoint your error..
A warning about fixing what you find
While writing this I tried to break inheritance on purpose to have something to detect:
Set-PnPListItemPermission -List 'Projects' -Identity 3 -InheritPermissions:$false
It reported success. It did nothing at all.
-InheritPermissions restores inheritance. There is no switch that breaks it, and passing $false is a silent no-op. If you write a script to lock something down that way, you get a green light and no change. Use the CSOM method instead:
$item = Get-PnPListItem -List 'Projects' -Id 3
$item.BreakRoleInheritance($true, $false)
Invoke-PnPQuery
First argument keeps the existing permissions so you do not lock yourself out, second clears sub-scopes.
The whole script
Parameters instead of hardcoded URLs, progress while it runs, and a CSV if you want one:
[CmdletBinding()]
param(
[Parameter(Mandatory)] [string] $SiteUrl,
[Parameter(Mandatory)] [string] $ClientId,
[string] $OutputCsv,
[int] $MaxItems = 5000
)
$ErrorActionPreference = 'Stop'
Connect-PnPOnline -Url $SiteUrl -ClientId $ClientId -Interactive
$results = [System.Collections.Generic.List[object]]::new()
function Get-AccessSummary {
param($ClientObject)
try {
$assignments = Get-PnPProperty -ClientObject $ClientObject -Property RoleAssignments
$parts = foreach ($ra in $assignments) {
$member = Get-PnPProperty -ClientObject $ra -Property Member
$roles = Get-PnPProperty -ClientObject $ra -Property RoleDefinitionBindings
"$($member.Title) [$(($roles | ForEach-Object { $_.Name }) -join '/')]"
}
return ($parts -join '; ')
}
catch { return "could not read: $($_.Exception.Message)" }
}
Write-Host "Checking site..." -ForegroundColor Cyan
$web = Get-PnPWeb
$null = Get-PnPProperty -ClientObject $web -Property HasUniqueRoleAssignments
if ($web.HasUniqueRoleAssignments) {
$results.Add([pscustomobject]@{
Scope = 'Site'; Name = $web.Title
Path = $web.ServerRelativeUrl; Access = Get-AccessSummary $web
})
}
$lists = Get-PnPList | Where-Object { -not $_.Hidden }
$listNumber = 0
foreach ($list in $lists) {
$listNumber++
Write-Progress -Activity 'Scanning for broken inheritance' `
-Status "$($list.Title) ($listNumber of $($lists.Count))" `
-PercentComplete (($listNumber / $lists.Count) * 100)
$null = Get-PnPProperty -ClientObject $list -Property HasUniqueRoleAssignments
if ($list.HasUniqueRoleAssignments) {
$results.Add([pscustomobject]@{
Scope = 'List'; Name = $list.Title
Path = $list.RootFolder.ServerRelativeUrl; Access = Get-AccessSummary $list
})
}
if ($list.ItemCount -eq 0) { continue }
if ($list.ItemCount -gt $MaxItems) {
Write-Warning "Skipping items in '$($list.Title)' - $($list.ItemCount) exceeds -MaxItems $MaxItems."
continue
}
# Asking the list for its items also gives us folders, and avoids
# Get-PnPFolderItem returning the system Forms folder, which has no
# backing list item and throws when you ask about its permissions.
foreach ($item in Get-PnPListItem -List $list -PageSize 500) {
$null = Get-PnPProperty -ClientObject $item -Property HasUniqueRoleAssignments
if (-not $item.HasUniqueRoleAssignments) { continue }
$isFolder = $item.FileSystemObjectType -eq 'Folder'
$results.Add([pscustomobject]@{
Scope = if ($isFolder) { 'Folder' } else { 'Item' }
Name = if ($isFolder) { $item['FileLeafRef'] } else { $item['Title'] }
Path = $item['FileRef']
Access = Get-AccessSummary $item
})
}
}
Write-Progress -Activity 'Scanning for broken inheritance' -Completed
if ($results.Count -eq 0) {
Write-Host "`nNothing found. Everything in this site inherits its permissions." -ForegroundColor Green
Disconnect-PnPOnline
return
}
Write-Host "`nFound $($results.Count) object(s) with unique permissions.`n" -ForegroundColor Yellow
if ($OutputCsv) {
$results | Export-Csv -Path $OutputCsv -NoTypeInformation -Encoding UTF8
Write-Host "Written to $OutputCsv" -ForegroundColor Green
} else {
$results | Format-Table Scope, Name, Path -AutoSize
$results | Format-List Scope, Name, Access
}
Disconnect-PnPOnline
Save it as Get-BrokenInheritance.ps1 and run it:
.\Get-BrokenInheritance.ps1 `
-SiteUrl "https://contoso.sharepoint.com/sites/Demo" `
-ClientId "11111111-2222-3333-4444-555555555555" `
-OutputCsv .\permissions.csv
Checking site...
Found 3 object(s) with unique permissions.
Scope Name Path
----- ---- ----
Site PowerShell Demo /sites/Demo
Folder Archive /sites/Demo/Project Documents/Archive
Item Project 03 /sites/Demo/Lists/Projects/3_.000
Scope : Folder
Name : Archive
Access : Demo Owners [Full Control]; Demo Visitors [Read]; Demo Members [Edit]
If a site is clean it tells you so rather than printing an empty table and leaving you wondering whether it worked or you mistyped the URL
One Comment
Comments are closed.