Permissions

Find Broken Permission Inheritance in SharePoint with PowerShell

In this post, I am going to show you how to find everything in a SharePoint site that does not inherit its permissions, and who can actually get at it. There is a full script at the end if you just want to run something.

Broken inheritance is where oversharing hides. A site can look tidy at the top and still have one folder shared with everyone, because somebody clicked Share eighteen months ago. Nothing in the interface shows you all of them at once.

Run against a real site on PnP.PowerShell 2.12.0. Output below is what came back.

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

Do not panic at that. The top web of a site collection has nothing above it to inherit from, so it always reports True. It only means something on a subsite.

The important thing to note is Get-PnPProperty. Most permission properties are not loaded when you fetch an object – you have to ask for them separately. Skip that line and HasUniqueRoleAssignments comes back empty rather than throwing, which is worse, because your report quietly finds nothing.

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.

Each item is a separate round trip to the server. There is no bulk way to ask. So a 10,000 item library is roughly forty minutes, and a large tenant is an overnight job. That is not a fault in the script, it is how the API works, and it is why you should scope these reports to a site rather than pointing them at everything and hoping.

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.

The culprit is Forms. Every document library has one, SharePoint made it, it holds the library’s form pages, and it is not backed by a list item at all. So asking it about ListItemAllFields returns nothing and the next line falls over.

Note the error does not tell you which folder caused it. You get an object reference exception on a folder you did not create and never see in the browser.

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

The one to watch there is $rows = foreach. A foreach statement cannot be piped – only the ForEach-Object cmdlet can. Put a pipe straight after the closing brace and you get “An empty pipe element is not allowed”, which does not obviously point at the mistake you made.

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 🙂