Public/Scan/Get-SPCOrphanedUser.ps1
|
# PnP 3.x removed Get-PnPSiteUser; Get-PnPUser now returns all UIL users without a filter. if (-not (Get-Command -Name 'Get-PnPSiteUser' -ErrorAction SilentlyContinue)) { function Get-PnPSiteUser { [CmdletBinding()] param([Parameter()] [object] $Connection) $params = @{}; foreach ($k in $PSBoundParameters.Keys) { $params[$k] = $PSBoundParameters[$k] } & Get-PnPUser @params } } # PS wrappers with [object] Connection so Pester mocks don't enforce PnPConnection type coercion. if (Get-Command -Name 'Get-PnPWeb' -Module PnP.PowerShell -ErrorAction SilentlyContinue) { $__pnpGetWeb = Get-Command 'Get-PnPWeb' -Module PnP.PowerShell function Get-PnPWeb { [CmdletBinding()] param([object]$Connection) & $__pnpGetWeb @PSBoundParameters } } if (Get-Command -Name 'Get-PnPUser' -Module PnP.PowerShell -ErrorAction SilentlyContinue) { $__pnpGetUser = Get-Command 'Get-PnPUser' -Module PnP.PowerShell function Get-PnPUser { [CmdletBinding()] param([object]$Connection, [switch]$WithRightsAssigned, [string]$LoginName) $params = @{}; foreach ($k in $PSBoundParameters.Keys) { $params[$k] = $PSBoundParameters[$k] } if ($params.ContainsKey('LoginName')) { $params['Identity'] = $params['LoginName'] $params.Remove('LoginName') | Out-Null } & $__pnpGetUser @params } } if (Get-Command -Name 'Get-PnPSiteGroup' -Module PnP.PowerShell -ErrorAction SilentlyContinue) { $__pnpGetSiteGroup = Get-Command 'Get-PnPSiteGroup' -Module PnP.PowerShell function Get-PnPSiteGroup { [CmdletBinding()] param([object]$Connection) & $__pnpGetSiteGroup @PSBoundParameters } } if (Get-Command -Name 'Get-PnPGroupMember' -Module PnP.PowerShell -ErrorAction SilentlyContinue) { $__pnpGetGroupMember = Get-Command 'Get-PnPGroupMember' -Module PnP.PowerShell function Get-PnPGroupMember { [CmdletBinding()] param([object]$Group, [object]$Connection) $params = @{}; foreach ($k in $PSBoundParameters.Keys) { $params[$k] = $PSBoundParameters[$k] } $groupKey = if ($Group -is [string] -or $Group -is [int]) { $Group } else { [string]$Group.Title } $params['Group'] = $groupKey & $__pnpGetGroupMember @params } } function Get-SPCOrphanedUser { <# .SYNOPSIS Scans one or more SharePoint Online site collections and returns orphaned user objects. .DESCRIPTION Detects accounts still in the SharePoint User Information List (UIL) whose Entra ID state is Deleted, SoftDeleted, Disabled, or GuestOrphaned. Risk-classifies each result per SRS 3.2.2. .PARAMETER SiteUrl One or more full site collection URLs. Mutually exclusive with -AllSites. Accepts pipeline input. .PARAMETER AllSites Scan all site collections in the tenant. Requires SharePoint Admin role. .PARAMETER IncludeGuests Also detect orphaned external (#EXT#) accounts. .PARAMETER IncludeDisabled Also detect accounts that are disabled in Entra ID (OrphanType = 'Disabled'). .PARAMETER ExcludeSiteUrl Site URLs to skip in an -AllSites scan. Supports wildcards (e.g. '*-my.sharepoint.com/*'). .PARAMETER ThrottleLimit Maximum concurrent site connections. Default 3. Range 1-10; values outside are clamped. .EXAMPLE Get-SPCOrphanedUser -SiteUrl 'https://contoso.sharepoint.com/sites/HR' .EXAMPLE Get-SPCOrphanedUser -AllSites -ExcludeSiteUrl '*-my.sharepoint.com/*' | Export-SPCReport -Path C:\report.html .OUTPUTS SPC.OrphanedUser #> [CmdletBinding(DefaultParameterSetName = 'SingleSite')] [OutputType([PSCustomObject])] param( [Parameter(Mandatory, ParameterSetName = 'SingleSite', ValueFromPipeline, ValueFromPipelineByPropertyName)] [string[]] $SiteUrl, [Parameter(Mandatory, ParameterSetName = 'AllSites')] [switch] $AllSites, [Parameter()] [switch] $IncludeGuests, [Parameter()] [switch] $IncludeDisabled, [Parameter(ParameterSetName = 'AllSites')] [string[]] $ExcludeSiteUrl, [Parameter()] [switch] $AddTempSiteCollectionAdmin, [Parameter()] [int] $ThrottleLimit = 3 ) begin { Test-SPCConnection # SRS 3.2.1: ThrottleLimit range 1-10, clamp with warning $effectiveThrottle = $ThrottleLimit if ($ThrottleLimit -lt 1) { Write-Warning "ThrottleLimit $ThrottleLimit below minimum — clamped to 1." $effectiveThrottle = 1 } elseif ($ThrottleLimit -gt 10) { Write-Warning "ThrottleLimit $ThrottleLimit exceeds maximum 10 — clamped to 10." $effectiveThrottle = 10 } Write-Verbose "Get-SPCOrphanedUser: ThrottleLimit = $effectiveThrottle (sequential in PS 5.1)" $pendingSites = [System.Collections.Generic.List[string]]::new() if ($PSCmdlet.ParameterSetName -eq 'AllSites') { Write-Verbose 'Get-SPCOrphanedUser: Enumerating all tenant sites...' $tenantSites = Get-PnPTenantSite -Connection $script:SPCContext.PnPContext -ErrorAction Stop foreach ($site in $tenantSites) { if ($site.Template -like 'REDIRECTSITE#*') { Write-Verbose "Get-SPCOrphanedUser: Skipping redirect site $($site.Url) ($($site.Template))" continue } $excluded = $false if ($ExcludeSiteUrl) { foreach ($pattern in $ExcludeSiteUrl) { if ($site.Url -like $pattern) { $excluded = $true; break } } } if (-not $excluded) { $pendingSites.Add($site.Url) } } Write-Verbose "Get-SPCOrphanedUser: $($pendingSites.Count) sites to scan." } } process { if ($PSCmdlet.ParameterSetName -eq 'SingleSite' -and $SiteUrl) { foreach ($url in $SiteUrl) { $pendingSites.Add($url) } } } end { # ScriptBlock used to connect to each site collection, reusing stored auth params $connectToSite = { param([string] $SiteUrl, [PSCustomObject] $Ctx) $tenantId = if ($Ctx.TenantName -match '\.') { $Ctx.TenantName } else { "$($Ctx.TenantName).onmicrosoft.com" } switch ($Ctx.AuthMethod) { 'Interactive' { $token = Get-PnPAccessToken -ResourceTypeName SharePoint -Connection $Ctx.PnPContext Connect-PnPOnline -Url $SiteUrl -AccessToken $token -ReturnConnection } 'AppOnly' { if ($Ctx._CertificatePath) { Connect-PnPOnline -Url $SiteUrl -ClientId $Ctx._ClientId ` -Tenant $tenantId ` -CertificatePath $Ctx._CertificatePath ` -CertificatePassword $Ctx._CertificatePassword -ReturnConnection } elseif ($Ctx._CertificateThumbprint) { Connect-PnPOnline -Url $SiteUrl -ClientId $Ctx._ClientId ` -Tenant $tenantId ` -Thumbprint $Ctx._CertificateThumbprint -ReturnConnection } else { $bstr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($Ctx._ClientSecret) try { $plain = [System.Runtime.InteropServices.Marshal]::PtrToStringAuto($bstr) Connect-PnPOnline -Url $SiteUrl -ClientId $Ctx._ClientId ` -ClientSecret $plain -ReturnConnection } finally { [System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr) $plain = $null } } } } } # SRS 3.2.1: system account login name patterns to exclude (step 7) $systemPatterns = @( 'SHAREPOINT\system', 'NT AUTHORITY\authenticated users', 'c:0(.s|true)', 'Everyone except external users', 'NT AUTHORITY\LOCAL SERVICE' ) $ctx = $script:SPCContext $graphToken = $ctx.GraphAccessToken $total = $pendingSites.Count $siteIdx = 0 $showProgress = $AllSites -or $total -gt 1 foreach ($currentSiteUrl in $pendingSites) { $siteIdx++ # Refresh Graph token if older than 50 minutes to avoid expiration during long scans $timeSinceConnect = (Get-Date).ToUniversalTime() - $ctx.ConnectedAt if ($timeSinceConnect.TotalMinutes -gt 50) { Write-Verbose "Get-SPCOrphanedUser: Graph token is older than 50 minutes. Refreshing..." $graphToken = Get-PnPGraphAccessToken -Connection $ctx.PnPContext $ctx.GraphAccessToken = $graphToken $ctx.ConnectedAt = (Get-Date).ToUniversalTime() } if ($showProgress) { Write-Progress -Activity 'Get-SPCOrphanedUser' ` -Status "[$siteIdx/$total] $currentSiteUrl" ` -PercentComplete ([int](($siteIdx / $total) * 100)) } $removeSca = $false $myUPN = $null $siteConn = $null try { $siteConn = & $connectToSite -SiteUrl $currentSiteUrl -Ctx $ctx } catch { Write-Error "Get-SPCOrphanedUser: Cannot connect to '$currentSiteUrl'. $_" continue } try { $uilUsers = $null try { # SRS step 6: retrieve all UIL users Write-Verbose "Get-SPCOrphanedUser: Getting UIL for $currentSiteUrl" $uilUsers = Get-PnPSiteUser -Connection $siteConn -ErrorAction Stop } catch [System.UnauthorizedAccessException], [System.Exception] { if ($_.Exception.Message -match "401" -or $_.Exception.Message -match "403" -or $_.Exception.Message -match "Access denied" -or $_.Exception.Message -match "Unauthorized") { if ($AddTempSiteCollectionAdmin) { if ($ctx.AuthMethod -ne 'Interactive') { Write-Warning "Get-SPCOrphanedUser: Access Denied on $currentSiteUrl. -AddTempSiteCollectionAdmin is only supported for Interactive auth. Skipping site." continue } Write-Verbose "Get-SPCOrphanedUser: Access Denied. Attempting to add temporary Site Collection Admin rights." try { $me = Invoke-RestMethod -Uri "https://graph.microsoft.com/v1.0/me" -Headers @{ Authorization = "Bearer $($ctx.GraphAccessToken)" } -ErrorAction Stop $myUPN = $me.userPrincipalName Set-PnPTenantSite -Connection $ctx.PnPContext -Url $currentSiteUrl -Owners $myUPN -ErrorAction Stop $removeSca = $true Start-Sleep -Seconds 5 $siteConn = & $connectToSite -SiteUrl $currentSiteUrl -Ctx $ctx $uilUsers = Get-PnPSiteUser -Connection $siteConn -ErrorAction Stop } catch { Write-Warning "Get-SPCOrphanedUser: Failed to add temporary SCA or retry on $currentSiteUrl. Skipping. Error: $_" continue } } else { Write-Warning "Get-SPCOrphanedUser: Access Denied on $currentSiteUrl. You must be a Site Collection Administrator or use -AddTempSiteCollectionAdmin to scan." continue } } else { Write-Error "Get-SPCOrphanedUser: Error getting users for $currentSiteUrl. $_" continue } } $siteTitle = (Get-PnPWeb -Connection $siteConn -ErrorAction SilentlyContinue).Title # SRS step 7: filter system accounts # Note: i:0#.f|membership| is ALWAYS a real Entra user claim — never filter by empty # email for this claim type. Entra-deleted users lose their email in the SP UIL. $filteredUsers = $uilUsers | Where-Object { $ln = $_.LoginName $isSystem = $false foreach ($p in $systemPatterns) { if ($ln -eq $p) { $isSystem = $true; break } } -not $isSystem } if (-not $filteredUsers) { continue } # Build permission lookups once per site (O(groups × members)) $directPermSet = @{} $groupMembershipMap = @{} $directPermUsers = Get-PnPUser -WithRightsAssigned -Connection $siteConn ` -ErrorAction SilentlyContinue foreach ($u in $directPermUsers) { $directPermSet[$u.LoginName] = $true } $siteGroups = Get-PnPSiteGroup -Connection $siteConn -ErrorAction SilentlyContinue foreach ($group in $siteGroups) { $members = Get-PnPGroupMember -Group $group -Connection $siteConn ` -ErrorAction SilentlyContinue foreach ($m in $members) { if (-not $groupMembershipMap.ContainsKey($m.LoginName)) { $groupMembershipMap[$m.LoginName] = [System.Collections.Generic.List[string]]::new() } $groupMembershipMap[$m.LoginName].Add($group.Title) } } # SRS step 8: extract UPN per user $requestIdMap = @{} # reqId → { User, UPN } $batchRequests = [System.Collections.Generic.List[hashtable]]::new() $reqId = 1 foreach ($user in $filteredUsers) { $upn = if ($user.LoginName -like 'i:0#.f|membership|*') { $user.LoginName -replace '^i:0#\.f\|membership\|', '' } elseif ($user.Email) { $user.Email } else { $null } if (-not $upn) { continue } $requestIdMap["$reqId"] = @{ User = $user; UPN = $upn } $batchRequests.Add(@{ id = "$reqId" method = 'GET' url = "/users/$([uri]::EscapeDataString($upn))?`$select=id,displayName,accountEnabled,userPrincipalName" }) $reqId++ } if ($batchRequests.Count -eq 0) { continue } # SRS step 9: first Graph batch — /users/{upn} lookups Write-Verbose "Get-SPCOrphanedUser: Graph batch — $($batchRequests.Count) users on $currentSiteUrl" $initialResponses = Invoke-SPCGraphBatch -Requests $batchRequests -AccessToken $graphToken $foundUsers = @{} # reqId → Graph user body (status 200) $notFoundItems = [System.Collections.Generic.List[hashtable]]::new() # 404 entries foreach ($resp in $initialResponses) { if (-not $requestIdMap.ContainsKey($resp.id)) { continue } if ($resp.status -eq 200) { $foundUsers[$resp.id] = $resp.body } elseif ($resp.status -eq 404) { $notFoundItems.Add($requestIdMap[$resp.id]) } } # SRS step 10: SoftDeleted lookup (requires Directory.Read.All) $softDeletedUpns = @{} if ($notFoundItems.Count -gt 0) { Write-Verbose "Get-SPCOrphanedUser: Checking deleted items for $($notFoundItems.Count) missing users" $delRequests = [System.Collections.Generic.List[hashtable]]::new() $seenDelUpns = @{} $delId = 1 foreach ($item in $notFoundItems) { if (-not $seenDelUpns.ContainsKey($item.UPN)) { $seenDelUpns[$item.UPN] = $true $delRequests.Add(@{ id = "del_$delId" method = 'GET' url = "/directory/deletedItems/microsoft.graph.user?`$filter=userPrincipalName eq '$($item.UPN)'&`$select=id,userPrincipalName" }) $delId++ } } $delResponses = Invoke-SPCGraphBatch -Requests $delRequests -AccessToken $graphToken foreach ($resp in $delResponses) { if ($resp.status -eq 200 -and $resp.body.value -and $resp.body.value.Count -gt 0) { $foundDelUpn = $resp.body.value[0].userPrincipalName if ($foundDelUpn) { $softDeletedUpns[$foundDelUpn] = $true } } } } # Sign-in activity batch for found (200) users — requires AuditLog.Read.All $signInMap = @{} # reqId → DateTime $siRequests = [System.Collections.Generic.List[hashtable]]::new() $siReqToId = @{} $siId = 1 foreach ($reqIdStr in $foundUsers.Keys) { $userId = $foundUsers[$reqIdStr].id if ($userId) { $siReqToId["$siId"] = $reqIdStr $siRequests.Add(@{ id = "$siId" method = 'GET' url = "/users/$userId/signInActivity" }) $siId++ } } if ($siRequests.Count -gt 0) { Write-Verbose "Get-SPCOrphanedUser: Sign-in activity for $($siRequests.Count) users" $siResponses = Invoke-SPCGraphBatch -Requests $siRequests -AccessToken $graphToken $warnedSignIn = $false foreach ($resp in $siResponses) { $origId = $siReqToId[$resp.id] if ($resp.status -eq 403 -and -not $warnedSignIn) { Write-Warning "SPClean: Unable to retrieve signInActivity. Missing 'AuditLog.Read.All' permission or Entra ID P1/P2 license. LastActivityDate will be empty." $warnedSignIn = $true } elseif ($origId -and $resp.status -eq 200 -and $resp.body.lastSignInDateTime) { $signInMap[$origId] = [datetime]$resp.body.lastSignInDateTime } } } # SRS step 11: classify and emit SPC.OrphanedUser objects $detectedAt = (Get-Date).ToUniversalTime() foreach ($reqIdStr in $requestIdMap.Keys) { $entry = $requestIdMap[$reqIdStr] $user = $entry.User $upn = $entry.UPN $isGuest = $user.LoginName -like '*#EXT#*' $graphUser = $foundUsers[$reqIdStr] # $null when 404 # SRS 3.2.1 step 10: OrphanType classification $orphanType = $null if ($null -ne $graphUser) { if (-not $graphUser.accountEnabled) { if ($isGuest -and $IncludeGuests) { $orphanType = 'GuestOrphaned' } elseif (-not $isGuest -and $IncludeDisabled) { $orphanType = 'Disabled' } } # accountEnabled = true → active, not orphaned } else { if ($isGuest) { if ($IncludeGuests) { $orphanType = 'GuestOrphaned' } } elseif ($softDeletedUpns.ContainsKey($upn)) { $orphanType = 'SoftDeleted' } else { $orphanType = 'Deleted' } } if ($null -eq $orphanType) { continue } $hasDirectPerms = $directPermSet.ContainsKey($user.LoginName) $userGroups = if ($groupMembershipMap.ContainsKey($user.LoginName)) { @($groupMembershipMap[$user.LoginName]) } else { @() } $riskLevel = Get-SPCRiskLevel ` -OrphanType $orphanType ` -HasDirectPermissions $hasDirectPerms ` -GroupMembershipCount $userGroups.Count $out = [PSCustomObject][ordered]@{ SiteUrl = $currentSiteUrl SiteTitle = $siteTitle UserId = $user.Id LoginName = $user.LoginName DisplayName = $user.Title Email = $user.Email UPN = $upn OrphanType = $orphanType RiskLevel = $riskLevel HasDirectPermissions = $hasDirectPerms GroupMemberships = $userGroups LastActivityDate = $signInMap[$reqIdStr] DetectedAt = $detectedAt } $out.PSObject.TypeNames.Insert(0, 'SPC.OrphanedUser') $out # SRS step 11: write to pipeline } } finally { if ($removeSca -and $myUPN) { Write-Verbose "Get-SPCOrphanedUser: Removing temporary Site Collection Admin rights for $myUPN on $currentSiteUrl" Remove-PnPSiteCollectionAdmin -Connection $siteConn -Owners $myUPN -ErrorAction SilentlyContinue } } } if ($showProgress) { Write-Progress -Activity 'Get-SPCOrphanedUser' -Completed } } } |