Entra/Get-InactiveUsers.ps1
|
<#
.SYNOPSIS Finds Entra ID users who have not signed in for a specified number of days. .DESCRIPTION Queries Microsoft Graph for user accounts and their last sign-in activity. Returns users whose last interactive or non-interactive sign-in exceeds the specified threshold. Useful for identifying stale accounts during security reviews and tenant cleanups. Requires Microsoft.Graph.Users module and User.Read.All permission. .PARAMETER DaysInactive Number of days since last sign-in to consider a user inactive. Defaults to 90. .PARAMETER IncludeGuests Include guest (B2B) accounts in the results. By default only members are returned. .PARAMETER OutputPath Optional path to export results as CSV. If not specified, results are returned to the pipeline. .EXAMPLE PS> . .\Common\Connect-Service.ps1 PS> Connect-Service -Service Graph -Scopes 'User.Read.All','AuditLog.Read.All' PS> .\Entra\Get-InactiveUsers.ps1 -DaysInactive 90 Lists all member users who have not signed in for 90+ days. .EXAMPLE PS> .\Entra\Get-InactiveUsers.ps1 -DaysInactive 30 -IncludeGuests -OutputPath '.\inactive-users.csv' Exports all users (members and guests) inactive for 30+ days to CSV. #> [CmdletBinding()] param( [Parameter()] [ValidateRange(1, 365)] [int]$DaysInactive = 90, [Parameter()] [switch]$IncludeGuests, [Parameter()] [string]$OutputPath ) $ErrorActionPreference = 'Stop' # Verify Graph connection if (-not (Assert-GraphConnection)) { return } $cutoffDate = (Get-Date).AddDays(-$DaysInactive).ToString('yyyy-MM-ddTHH:mm:ssZ') # Build the filter $filter = "accountEnabled eq true" if (-not $IncludeGuests) { $filter += " and userType eq 'Member'" } Write-Verbose "Querying users with filter: $filter" Write-Verbose "Inactive threshold: $DaysInactive days (before $cutoffDate)" try { $selectProperties = @( 'Id' 'DisplayName' 'UserPrincipalName' 'UserType' 'AccountEnabled' 'CreatedDateTime' 'SignInActivity' ) $users = Get-MgUser -Filter $filter -Property $selectProperties -All } catch { Write-Error "Failed to query users from Microsoft Graph: $_" return } $inactiveUsers = foreach ($user in $users) { $lastSignIn = $user.SignInActivity.LastSignInDateTime $lastNonInteractive = $user.SignInActivity.LastNonInteractiveSignInDateTime # Use the most recent of interactive or non-interactive sign-in $lastActivity = @($lastSignIn, $lastNonInteractive) | Where-Object { $_ } | Sort-Object -Descending | Select-Object -First 1 # Include if never signed in or last activity before cutoff $isInactive = (-not $lastActivity) -or ($lastActivity -lt $cutoffDate) if ($isInactive) { [PSCustomObject]@{ DisplayName = $user.DisplayName UserPrincipalName = $user.UserPrincipalName UserType = $user.UserType AccountEnabled = $user.AccountEnabled LastSignIn = $lastSignIn LastNonInteractiveSignIn = $lastNonInteractive LastActivity = $lastActivity DaysSinceActivity = if ($lastActivity) { [math]::Round(((Get-Date) - [datetime]$lastActivity).TotalDays) } else { 'Never' } CreatedDate = $user.CreatedDateTime } } } $inactiveUsers = @($inactiveUsers) | Sort-Object -Property DaysSinceActivity -Descending Write-Verbose "Found $($inactiveUsers.Count) inactive users" if ($OutputPath) { $inactiveUsers | Export-Csv -Path $OutputPath -NoTypeInformation -Encoding UTF8 Write-Output "Exported $($inactiveUsers.Count) inactive users to $OutputPath" } else { Write-Output $inactiveUsers } |