Worth a direct correction before anything else: a script that connects with Connect-SPOService and then reads $site.RootWeb and $web.RoleAssignments was never going to work, independent of the auth method — Connect-SPOService connects to the SharePoint Online Management Shell’s tenant-admin cmdlets, and Get-SPOSite returns a simple property object with no RootWeb on it, not a live CSOM site object. Reading permissions off a site requires PnP PowerShell’s actual CSOM-backed cmdlets, connected directly to the site in question. This post covers the pattern that actually works, including the parts that fail silently rather than throwing an error if you get them slightly wrong.
In this post: Connecting directly to the site · Getting the site’s role assignments · Expanding SharePoint group membership · Exporting to CSV, correctly · Finding which lists and items have broken inheritance first · A real bug worth knowing about in recursive functions · Related reading
Connecting directly to the site
Reading permissions is a per-site operation, so connect PnP PowerShell to the specific site rather than the tenant admin URL. Interactive sign-in, not a username/password object — legacy username/password authentication was fully retired tenant-wide as of May 1, 2026 and no longer works at all, independent of this post’s other corrections:
Connect-PnPOnline -Url "https://yourtenant.sharepoint.com/sites/yoursite" -Interactive
Getting the site’s role assignments
Get-PnPWeb -Includes RoleAssignments is the real starting point — it loads the web object with its role assignments explicitly included, which matters because CSOM objects load lazily by default and an un-included property comes back empty rather than throwing an error, a common silent-failure trap:
$web = Get-PnPWeb -Includes RoleAssignments
foreach ($roleAssignment in $web.RoleAssignments) {
Get-PnPProperty -ClientObject $roleAssignment -Property RoleDefinitionBindings, Member
[PSCustomObject]@{
Member = $roleAssignment.Member.Title
LoginName = $roleAssignment.Member.LoginName
Permissions = ($roleAssignment.RoleDefinitionBindings | Select-Object -ExpandProperty Name) -join ", "
}
}
The `Get-PnPProperty` call inside the loop is doing real, necessary work, not defensive boilerplate — `RoleDefinitionBindings` and `Member` are both properties that need to be explicitly requested per role assignment, separately from the initial `-Includes` on the web object itself. Skipping it doesn’t error either; it just returns empty values for both, which is a genuinely confusing failure mode to debug without knowing this pattern ahead of time.
Select-Object Name on a RoleDefinitionBindings collection, then joined with -join, produces garbled object-notation text — -ExpandProperty Name is what actually extracts the plain role names.
Expanding SharePoint group membership
A role assignment’s `Member` is very often a SharePoint group, not an individual person — a top-level permission report showing “Marketing Team Members – Contribute” doesn’t answer the actual question of who that ends up meaning in practice. Where the member is a group, its user list needs a separate, explicit load, the same lazy-loading pattern as above:
if ($roleAssignment.Member.PrincipalType -eq "SharePointGroup") {
Get-PnPProperty -ClientObject $roleAssignment.Member -Property Users
$roleAssignment.Member.Users | ForEach-Object {
[PSCustomObject]@{
Group = $roleAssignment.Member.Title
Member = $_.Title
LoginName = $_.LoginName
}
}
}
Worth noting as a real, separate limitation rather than something this script can just fix: a SharePoint group can itself contain an Entra security group as a member, not just individual users — expanding that level requires a Graph call against the security group’s membership, a genuinely different API from anything covered above, not a deeper CSOM property to include.
Exporting to CSV, correctly
Putting the site-level and group-expansion pieces together into one export:
$web = Get-PnPWeb -Includes RoleAssignments
$results = foreach ($roleAssignment in $web.RoleAssignments) {
Get-PnPProperty -ClientObject $roleAssignment -Property RoleDefinitionBindings, Member
$permissions = ($roleAssignment.RoleDefinitionBindings | Select-Object -ExpandProperty Name) -join ", "
if ($roleAssignment.Member.PrincipalType -eq "SharePointGroup") {
Get-PnPProperty -ClientObject $roleAssignment.Member -Property Users
foreach ($user in $roleAssignment.Member.Users) {
[PSCustomObject]@{ Group = $roleAssignment.Member.Title; Member = $user.Title; LoginName = $user.LoginName; Permissions = $permissions }
}
} else {
[PSCustomObject]@{ Group = ""; Member = $roleAssignment.Member.Title; LoginName = $roleAssignment.Member.LoginName; Permissions = $permissions }
}
}
$results | Export-Csv -Path "C:\Path\To\Export\Permissions.csv" -NoTypeInformation
Finding which lists and items have broken inheritance first
Everything above extracts a site’s own top-level permissions — worth knowing before assuming that’s the whole picture: any list, folder, or item can have permission inheritance broken individually, at which point its own permissions diverge entirely from the site’s. Checking for this before running a full extraction is worth doing, since a broken-inheritance item won’t show up at all in a site-level-only report:
$list = Get-PnPList -Identity "Documents" -Includes HasUniqueRoleAssignments
if ($list.HasUniqueRoleAssignments) {
Write-Host "The list itself has broken inheritance -- check its own role assignments separately."
}
Get-PnPListItem -List "Documents" -PageSize 500 | ForEach-Object {
Get-PnPProperty -ClientObject $_ -Property HasUniqueRoleAssignments
if ($_.HasUniqueRoleAssignments) {
Write-Host "Item $($_.Id) has broken inheritance"
}
}
On a large library, checking every item’s `HasUniqueRoleAssignments` individually is a real, meaningful amount of extra work — each check is its own round trip against the lazily-loaded property. Worth running as a deliberate, occasional audit rather than baking into every routine extraction, and worth pairing with `-PageSize` the same way any other large-list operation needs it to avoid the 5,000-item list view threshold.
A real bug worth knowing about in recursive functions
Worth flagging as a genuinely common PowerShell mistake, not specific to SharePoint: a function that builds up results by taking an array parameter and appending to it with `$outputArray += $item` doesn’t actually return anything to the caller. PowerShell arrays are fixed-size and passed by value — `+=` inside the function creates a new local array and discards it when the function returns, silently. A function meant to accumulate results across recursive calls needs to either emit objects to the pipeline (letting the caller collect them with `$results = MyFunction …`) or use a genuinely mutable reference type like `[System.Collections.Generic.List[object]]` passed in explicitly. The script above sidesteps the whole issue by using `foreach` with pipeline output instead of a manually-accumulated array, which is the simpler fix in most cases.
Related reading
- SharePoint Permissions: A Comprehensive Guide — the full conceptual picture this post’s extraction script sits underneath.
- Check What Permission a User Has in SharePoint — the single-user lookup version of this same problem.
The pattern above — explicit property loading, expanding group membership deliberately, and pipeline output instead of array mutation — generalizes to most CSOM-backed permission scripts, not just this one; worth internalizing the shape of it rather than treating each script as a one-off.
App Catalog Authentication Automation Backup Compliance Content Type CSS Flows Google Javascript Limitations List Metadata MFA Microsoft Node NodeJs O365 OneDrive Permissions PnP PnPJS Policy PowerApps Power Automate PowerAutomate PowerPlatform PowerShell React ReactJs Rest API Rest Endpoint Security Send an HTTP Request to SharePoint SharePoint SharePoint List SharePoint Modern SharePoint Online SPFX SPO Sync Tags Teams Termstore Versioning



Terrific post however , I was wanting to know if you
could write a litte more on this topic? I’d be very thankful if you could elaborate a little bit more.
Thanks!