Private/Get-UserAccessDeep.ps1
|
function Get-UserAccessDeep { <# The deep engine: one site, walked all the way down - web -> subwebs -> lists/libraries -> items. The design rests on ONE idea, verified against the lab 23 Jul 2026: permissions inherit, so only objects with HasUniqueRoleAssignments need their own evaluation. Everything else already has the parent's answer. That is not just the cheap path, it is the RIGHT target - breaking inheritance is what sharing links and one-off grants do, so the pruned set is exactly where oversharing lives. Important difference from Get-UserAccessForSite: that function early-exits when web-level access is 'None'. Here we must NOT, because a user granted a single item gets only "Limited Access" on the web - which does not include ViewListItems and so reads as 'None'. Early-exiting there is precisely what makes item-level access invisible. Assumes Connect-PnPOnline has already run for $SiteUrl. Subwebs are connected to in turn, because PnP v2 dropped -Web from these cmdlets. #> param( [Parameter(Mandatory)] [string] $SiteUrl, [Parameter(Mandatory)] [string] $UserLogin, # Passed through to Connect-IfNeeded for each subweb. [hashtable] $ConnectSplat = @{}, # Items are the expensive level - the read pass is O(items) and its # throughput varies a lot (measured 4.4ms/item to 28ms/item on the same # list). Off unless asked for. [switch] $IncludeItems, # Effective-permission checks are queued and sent in one round trip. # Batching measured ~10x faster; this is not an optimisation, it is what # makes item level affordable at all. [int] $BatchSize = 100, # Safety valve for very large lists. 0 = no cap. [int] $MaxItemsPerList = 0, # Per-scan (user,group) -> membership cache, shared across sites. [hashtable] $MembershipCache = @{} ) $userDisplay = ($UserLogin -split '\|')[-1] # ---- helpers ------------------------------------------------------------- function Resolve-Level { param($Perms) if ($Perms.Value.Has('FullMask') -or $Perms.Value.Has('ManagePermissions')) { 'Full Control' } elseif ($Perms.Value.Has('EditListItems')) { 'Edit' } elseif ($Perms.Value.Has('ViewListItems')) { 'Read' } else { 'None' } } function Get-Route { <# Attribute access on ONE securable object to its routes. Same classification as the site-level engine, plus the sharing-link route which only appears below the web. #> param($Object, [string]$ObjectLabel, [hashtable]$MembershipCache = @{}) if (-not $Object) { return } $assignments = Invoke-WithRetry -Because "role assignments on $ObjectLabel" -Action { Get-PnPProperty -ClientObject $Object -Property RoleAssignments } foreach ($ra in $assignments) { $member = Get-PnPProperty -ClientObject $ra -Property Member $mLogin = Get-PnPProperty -ClientObject $member -Property LoginName $mTitle = Get-PnPProperty -ClientObject $member -Property Title $mType = Get-PnPProperty -ClientObject $member -Property PrincipalType if (Test-SystemGroup $mTitle) { continue } $roles = (Get-PnPProperty -ClientObject $ra -Property RoleDefinitionBindings | ForEach-Object { $_.Name }) -join ', ' $route = $null; $routeType = $null if (Test-SharingLinkGroup $mTitle $mLogin) { $kind = Get-SharingLinkKind $mTitle $route = "Sharing link ($kind)" $routeType = 'Overshared' } elseif (Test-EveryoneClaim $mLogin $mTitle) { $route = "Everyone claim ($mTitle)" $routeType = 'Overshared' } elseif ($mType -eq 'User' -and $mLogin -eq $UserLogin) { $route = 'Direct grant' $routeType = 'Granted' } elseif ($mType -eq 'SharePointGroup') { $mem = @(Invoke-WithRetry -Because "members of $mTitle" -Action { Get-PnPGroupMember -Group $mTitle -ErrorAction SilentlyContinue }) # Never $mem.LoginName - on an empty group that throws under # StrictMode exactly like $null.LoginName. if ($mem | Where-Object { $_.LoginName -eq $UserLogin }) { $route = "SharePoint group '$mTitle'" $routeType = 'Granted' } } elseif ($mType -eq 'SecurityGroup') { # Confirm the user is actually in the Entra/M365 group (transitively, # so nesting counts). Drop confirmed non-members; keep the honest # "unconfirmed" label when Graph cannot answer. $gid = Get-GroupIdFromLogin $mLogin $isMember = if ($gid) { Test-UserIsGroupMember -UserUpn $userDisplay -GroupId $gid -Cache $MembershipCache } else { $null } if ($isMember -ne $false) { $suffix = if ($isMember) { '' } else { ' (membership unconfirmed)' } $route = "Entra group '$mTitle'$suffix" $routeType = 'Granted' } } if ($route) { [pscustomobject]@{ Route = $route; Type = $routeType; Permission = $roles } } } } function ConvertTo-AccessRow { param( [string]$WebUrl, [string]$WebTitle, [string]$ObjectType, [string]$ObjectTitle, [string]$ObjectUrl, [string]$Level, $Route, [hashtable]$Meta = @{}, [string]$ObjectKind = '', [int]$ContainerCount = -1, [string]$ListId = '', [int]$ItemId = -1 ) # Deep-link to the classic advanced-permissions page for the object - # verified live against the tenant on all levels: # item/file/folder : obj={ListId},{ItemId},LISTITEM & List={ListId} # list/library : obj={ListId},doclib & List={ListId} # web/site/subsite : the site's own user.aspx # For an item that carries a SHARING LINK this same page shows the link and # a "manage links" action, so it doubles as the Manage-Access entry point - # and unlike sharedialog.aspx it is guaranteed to render. $permUrl = if ($ListId -and $ItemId -ge 0) { "$WebUrl/_layouts/15/user.aspx?obj=$ListId%2C$ItemId%2CLISTITEM&List=$ListId" } elseif ($ListId) { "$WebUrl/_layouts/15/user.aspx?obj=$ListId%2Cdoclib&List=$ListId" } else { "$WebUrl/_layouts/15/user.aspx" } [pscustomobject]@{ User = $userDisplay SiteUrl = $SiteUrl WebUrl = $WebUrl SiteTitle = $WebTitle ObjectType = $ObjectType # Web | List | Item (coarse - kept for compatibility) ObjectKind = $ObjectKind # Site | Subsite | Library | List | Folder | File | Item ContainerCount = $ContainerCount # total items in the containing list/library (-1 = n/a) ObjectTitle = $ObjectTitle ObjectUrl = $ObjectUrl PermUrl = $permUrl # advanced-permissions page for this object EffectiveAccess = $Level GrantedVia = $Route.Route RouteType = $Route.Type Permission = $Route.Permission ItemType = $(if ($Meta.ContainsKey('ItemType')) { $Meta.ItemType } else { $null }) Modified = $(if ($Meta.ContainsKey('Modified')) { $Meta.Modified } else { $null }) ModifiedBy = $(if ($Meta.ContainsKey('ModifiedBy')) { $Meta.ModifiedBy } else { $null }) CreatedBy = $(if ($Meta.ContainsKey('CreatedBy')) { $Meta.CreatedBy } else { $null }) Size = $(if ($Meta.ContainsKey('Size')) { $Meta.Size } else { $null }) } } # ---- 1. the web tree ----------------------------------------------------- $webs = @(Invoke-WithRetry -Because 'Get-PnPSubWeb' -Action { Get-PnPSubWeb -Recurse -IncludeRootWeb }) Write-Verbose "Webs to walk: $($webs.Count)" foreach ($wStub in $webs) { $webUrl = $wStub.Url try { if ($webUrl -ne $SiteUrl) { Connect-IfNeeded -Url $webUrl @ConnectSplat } } catch { Write-Warning "Skipped web $webUrl : $($_.Exception.Message)" continue } $web = Invoke-WithRetry -Because 'Get-PnPWeb' -Action { Get-PnPWeb } $ctx = Get-PnPContext $webTitle = Get-PnPProperty -ClientObject $web -Property Title if ([string]::IsNullOrWhiteSpace($webTitle)) { $webTitle = $webUrl } # ---- web level ------------------------------------------------------ $wPerms = $web.GetUserEffectivePermissions($UserLogin) Invoke-WithRetry -Because 'web effective permissions' -Action { $ctx.ExecuteQuery() } $webLevel = Resolve-Level $wPerms $webKind = if ($webUrl -eq $SiteUrl) { 'Site' } else { 'Subsite' } if ($webLevel -ne 'None') { foreach ($r in (Get-Route -Object $web -ObjectLabel $webTitle -MembershipCache $MembershipCache)) { ConvertTo-AccessRow -WebUrl $webUrl -WebTitle $webTitle -ObjectType 'Web' -ObjectKind $webKind ` -ObjectTitle $webTitle -ObjectUrl $webUrl -Level $webLevel -Route $r } } # NOTE: no early exit. Limited Access reads as 'None' but still permits # item-level access - see the header comment. # ---- lists ---------------------------------------------------------- $lists = @() try { $lists = @(Invoke-WithRetry -Because 'Get-PnPList' -Action { Get-PnPList -Includes HasUniqueRoleAssignments, ItemCount, Hidden, BaseType }) | Where-Object { -not $_.Hidden } } catch { Write-Warning "Cannot read lists on $webUrl : $($_.Exception.Message)" continue } foreach ($list in $lists) { $listUrl = "$webUrl/$($list.Title)" $listKind = if ("$($list.BaseType)" -eq 'DocumentLibrary') { 'Library' } else { 'List' } if ($list.HasUniqueRoleAssignments) { $lPerms = $list.GetUserEffectivePermissions($UserLogin) Invoke-WithRetry -Because "list perms $($list.Title)" -Action { $ctx.ExecuteQuery() } $listLevel = Resolve-Level $lPerms if ($listLevel -ne 'None') { foreach ($r in (Get-Route -Object $list -ObjectLabel $list.Title -MembershipCache $MembershipCache)) { ConvertTo-AccessRow -WebUrl $webUrl -WebTitle $webTitle -ObjectType 'List' -ObjectKind $listKind ` -ObjectTitle $list.Title -ObjectUrl $listUrl -Level $listLevel -Route $r -ContainerCount $list.ItemCount ` -ListId $list.Id } } } if (-not $IncludeItems -or $list.ItemCount -eq 0) { continue } # ---- items: the pruning pass ------------------------------------ $items = @() try { $items = @(Invoke-WithRetry -Because "page items of $($list.Title)" -Action { Get-PnPListItem -List $list -PageSize 2000 -Includes HasUniqueRoleAssignments, Id }) } catch { Write-Warning "Cannot page '$($list.Title)' on $webUrl : $($_.Exception.Message)" continue } if ($MaxItemsPerList -gt 0 -and $items.Count -gt $MaxItemsPerList) { Write-Warning "'$($list.Title)' has $($items.Count) items; capped at $MaxItemsPerList. Results are INCOMPLETE for this list." $items = $items[0..($MaxItemsPerList - 1)] } $unique = @($items | Where-Object { $_.HasUniqueRoleAssignments }) if ($unique.Count -eq 0) { continue } Write-Verbose "$($list.Title): $($unique.Count) of $($items.Count) items broke inheritance" # Batch the permission checks - one round trip per BatchSize items. for ($i = 0; $i -lt $unique.Count; $i += $BatchSize) { $end = [Math]::Min($i + $BatchSize - 1, $unique.Count - 1) $chunk = $unique[$i..$end] $pending = foreach ($it in $chunk) { [pscustomobject]@{ Item = $it; Perms = $it.GetUserEffectivePermissions($UserLogin) } } Invoke-WithRetry -Because 'batched item permissions' -Action { $ctx.ExecuteQuery() } foreach ($p in $pending) { $lvl = Resolve-Level $p.Perms $it = $p.Item $fv = $it.FieldValues $name = if ($fv.ContainsKey('FileLeafRef')) { $fv['FileLeafRef'] } else { "Item $($it.Id)" } $ref = if ($fv.ContainsKey('FileRef')) { $fv['FileRef'] } else { $listUrl } # Metadata is free with the item, but is NOT the same set on a # list and a library - file size simply does not exist on a # list item, so only include what is actually present. $meta = @{ ItemType = "$($it.FileSystemObjectType)" } $itemKind = switch ("$($it.FileSystemObjectType)") { 'Folder' { 'Folder' } 'File' { 'File' } default { 'Item' } } foreach ($pair in @(@('Modified','Modified'), @('Created','Created'))) { if ($fv.ContainsKey($pair[0])) { $meta[$pair[1]] = $fv[$pair[0]] } } foreach ($pair in @(@('Editor','ModifiedBy'), @('Author','CreatedBy'))) { if ($fv.ContainsKey($pair[0]) -and $fv[$pair[0]]) { $v = $fv[$pair[0]] $meta[$pair[1]] = if ($v.PSObject.Properties.Name -contains 'LookupValue') { $v.LookupValue } else { "$v" } } } if ($fv.ContainsKey('File_x0020_Size')) { $meta['Size'] = $fv['File_x0020_Size'] } # Walk the role assignments ONCE, then answer two independent # questions from it - they have different answers. $allRoutes = @(Get-Route -Object $it -ObjectLabel $name -MembershipCache $MembershipCache) $hasLinkGroup = @($allRoutes | Where-Object { $_.Route -like 'Sharing link*' }) # 1. "What can this user do here?" - covers direct grants, # group membership and Everyone claims. Only meaningful if # they actually have effective access. if ($lvl -ne 'None') { foreach ($r in ($allRoutes | Where-Object { $_.Route -notlike 'Sharing link*' })) { ConvertTo-AccessRow -WebUrl $webUrl -WebTitle $webTitle -ObjectType 'Item' -ObjectKind $itemKind ` -ObjectTitle $name -ObjectUrl $ref -Level $lvl -Route $r -Meta $meta -ContainerCount $list.ItemCount ` -ListId $list.Id -ItemId $it.Id } } # 2. "What links does this object carry, and is this user in # the audience?" - sharing links never show up in (1), so # this runs regardless of effective permissions. Only pay # for the extra call when a SharingLinks group is present. if ($hasLinkGroup) { foreach ($sl in (Resolve-SharingLinkAudience -FileRef $ref -UserUpn $userDisplay)) { ConvertTo-AccessRow -WebUrl $webUrl -WebTitle $webTitle -ObjectType 'Item' -ObjectKind $itemKind ` -ObjectTitle $name -ObjectUrl $ref -Level $sl.Level -Route $sl.Route -Meta $meta -ContainerCount $list.ItemCount ` -ListId $list.Id -ItemId $it.Id } } } } } } } |