Building a SharePoint Permissions Report That Gets Read
Report where inheritance is broken, filter out Limited Access, and expand groups to people — otherwise the output answers nothing.
Rajiv KumarJune 7, 20225 min readA permissions report covering every item is slow to produce and impossible to act on. Scoping it to broken inheritance and real grants turns it into a working document — here is the PowerShell and the reasoning behind it.
"Who has access to what?" is a question that sounds simple and is not. SharePoint permissions inherit down a hierarchy, break wherever someone shares an individual item, and accumulate through group membership that itself nests. By the time anyone asks, the answer usually lives in several hundred places.
A permissions report is the only practical way to answer it. Here is how to build one that is accurate rather than merely long.
What makes this harder than it looks
Four behaviours account for most of the difficulty:
- Inheritance is the default and the exception is invisible. A site, list or item either inherits from its parent or has unique permissions. Nothing in the interface makes broken inheritance obvious while browsing.
- Groups nest. A SharePoint group contains a security group, which contains other groups. Reporting the SharePoint group tells you almost nothing about who is actually in it.
- Limited access appears everywhere. Granting someone access to one document gives them Limited Access on the library and site above it. Reports that do not filter it are mostly noise.
- Item-level permissions scale badly. A library with unique permissions on thousands of items produces a report nobody reads.
Scope the report before writing it
The instinct is to report everything. Resist it — a report covering every item in every library is slow to produce and impossible to act on.
Decide first:
- Depth. Sites and subsites, or lists too, or items as well? Start at site and list level; go to item level only where inheritance is actually broken.
- Audience. Everyone, or only external users? A guest-access report is far shorter and usually the one that prompted the question.
- Expansion. Resolving nested groups to individual users is what makes a report truthful, and it is also what makes it slow.
A report worth running
PnP PowerShell handles the awkward parts. This walks a site and its subsites, reporting only where inheritance is broken:
Connect-PnPOnline -Url "https://contoso.sharepoint.com/sites/finance" -Interactive
function Get-PermissionRows {
param([string]$Url)
Connect-PnPOnline -Url $Url -Interactive
$web = Get-PnPWeb -Includes HasUniqueRoleAssignments, RoleAssignments, Title
$rows = @()
# Only report where inheritance is broken. Everything else is, by
# definition, the same as its parent and adds only noise.
if ($web.HasUniqueRoleAssignments) {
foreach ($assignment in $web.RoleAssignments) {
Get-PnPProperty -ClientObject $assignment -Property Member, RoleDefinitionBindings | Out-Null
$roles = ($assignment.RoleDefinitionBindings |
Where-Object { $_.Name -ne 'Limited Access' } |
Select-Object -ExpandProperty Name) -join ', '
if (-not $roles) { continue } # Limited Access only — not a real grant
$rows += [pscustomobject]@{
Scope = 'Site'
Url = $Url
Title = $web.Title
Principal = $assignment.Member.Title
LoginName = $assignment.Member.LoginName
Permission = $roles
}
}
}
# Lists with unique permissions, skipping the hidden system lists that
# would otherwise dominate the output.
$lists = Get-PnPList -Includes HasUniqueRoleAssignments, RoleAssignments, Hidden |
Where-Object { -not $_.Hidden -and $_.HasUniqueRoleAssignments }
foreach ($list in $lists) {
foreach ($assignment in $list.RoleAssignments) {
Get-PnPProperty -ClientObject $assignment -Property Member, RoleDefinitionBindings | Out-Null
$roles = ($assignment.RoleDefinitionBindings |
Where-Object { $_.Name -ne 'Limited Access' } |
Select-Object -ExpandProperty Name) -join ', '
if (-not $roles) { continue }
$rows += [pscustomobject]@{
Scope = 'List'
Url = $Url
Title = $list.Title
Principal = $assignment.Member.Title
LoginName = $assignment.Member.LoginName
Permission = $roles
}
}
}
$rows
}
$report = @()
$report += Get-PermissionRows -Url "https://contoso.sharepoint.com/sites/finance"
foreach ($sub in Get-PnPSubWeb -Recurse) {
$report += Get-PermissionRows -Url $sub.Url
}
$report | Export-Csv -Path "./permissions-finance.csv" -NoTypeInformation -Encoding UTF8
Write-Host "$($report.Count) permission assignment(s) written."
Two decisions in that script are worth calling out. Filtering out Limited Access removes rows that describe a side effect rather than a grant. And reporting only where HasUniqueRoleAssignments is true keeps the output to the places where someone made a deliberate decision.
Resolving groups to people
A row saying "Finance Owners has Full Control" does not answer the question. Expanding membership does:
foreach ($group in Get-PnPGroup) {
$members = Get-PnPGroupMember -Identity $group.Title
foreach ($member in $members) {
[pscustomobject]@{
Group = $group.Title
Member = $member.Title
Email = $member.Email
# Security groups appear as a single member and need expanding in
# Entra ID to reach actual people.
PrincipalType = $member.PrincipalType
}
}
}
Where a member's PrincipalType is a security group, the real membership lives in Microsoft Entra ID. Expanding it means querying there, which is why a genuinely complete report usually spans both.
The report most people actually want
Nine times in ten, the underlying question is about external access:
Get-PnPUser | Where-Object { $_.LoginName -like "*#ext#*" -or $_.LoginName -like "*urn:spo:guest*" } |
Select-Object Title, Email, LoginName
This is short, fast, and directly actionable.
Common mistakes and better practice
- Reporting every item. Produces output nobody reads. Report where inheritance is broken.
- Including Limited Access. It is a side effect of item sharing, not a meaningful grant. Filter it out.
- Stopping at group names. Expand to people, or the report answers nothing.
- Forgetting nested security groups. Full expansion needs Entra ID as well as SharePoint.
- Running it once. A permissions report is a snapshot. Schedule it, or compare against the last one.
- Ignoring the threshold. Item-level enumeration on a large list hits the same limit as the interface. Page.
- Storing the output carelessly. A permissions report is a map of who can reach what. Treat it as sensitive.
Troubleshooting
| Symptom | Cause | Resolution |
|---|---|---|
Get-PnPProperty returns nothing | Property not loaded | Add it to -Includes on the original call |
| Report full of Limited Access rows | Item-level sharing side effects | Filter that role out |
| Script very slow on a large site | Enumerating every item | Report only where inheritance is broken |
| 403 on some subsites | Running account lacks access there | Use an account with tenant-wide read, or accept gaps and record them |
| Group members missing | Member is a nested security group | Expand it in Entra ID |
| Threshold error at item level | List over 5,000 items | Page with -PageSize |
Frequently asked questions
Is there a built-in report for this?
The admin centre offers sharing reports covering external access, which answer the most common question. A full permissions map across sites, lists and items is not a built-in report, which is why scripting it is common.
What is Limited Access?
The permission SharePoint grants automatically on parent objects when someone is given access to a child item, so they can traverse to it. It is not a grant anyone chose, and it belongs out of the report.
Should item-level permissions be reported?
Only where they exist and matter. A library with unique permissions on thousands of items usually signals a design problem worth fixing rather than reporting.
How often should this run?
Often enough to catch drift — quarterly suits most organisations, more often where external sharing is active. Comparing consecutive reports is more useful than reading one.
Does this work against SharePoint Server?
The concepts are identical. The cmdlets differ; on-premises farms use the SharePoint Server management shell.
What the report is really for
The output that changes anything is short. A hundred rows showing broken inheritance and external access gets read and acted on; ten thousand rows showing every inherited permission gets filed.
Scope it to where someone made a decision — unique permissions, guest accounts, unexpected Full Control — and the report becomes a working document rather than an artefact.


