Public/Get-InactiveLicensedUsers.ps1
|
function Get-InactiveLicensedUsers { <# .SYNOPSIS Identifies licensed users who have not signed in within the specified threshold. .DESCRIPTION Finds Microsoft 365 licensed users who are inactive (no sign-in), disabled but still licensed, guests with licenses, or users who have never signed in. Each user's total license cost is calculated to quantify waste. .PARAMETER DaysInactive Number of days without sign-in to consider a user inactive. Default: 90. .PARAMETER IncludeGuests Include guest (external) users in the analysis. .PARAMETER IncludeDisabled Include disabled user accounts in the analysis. Default: included automatically as DISABLED WITH LICENSE findings. .EXAMPLE Get-InactiveLicensedUsers -DaysInactive 60 -IncludeGuests Finds all inactive licensed users (including guests) with no sign-in in 60 days. .EXAMPLE Get-InactiveLicensedUsers | Measure-Object -Property LicenseCost -Sum Calculates the total monthly cost of inactive licensed users. .OUTPUTS PSCustomObject with properties: UserPrincipalName, DisplayName, AccountEnabled, UserType, AssignedLicenses, LicenseCost, LastSignIn, DaysSinceSignIn, Finding #> [CmdletBinding()] param( [Parameter(Mandatory = $false)] [ValidateRange(1, 365)] [int]$DaysInactive = 90, [Parameter(Mandatory = $false)] [switch]$IncludeGuests, [Parameter(Mandatory = $false)] [switch]$IncludeDisabled ) Test-GraphConnection $friendlyNames = Get-LicenseFriendlyName $cutoffDate = (Get-Date).AddDays(-$DaysInactive) Write-Verbose "Retrieving licensed users with sign-in activity..." try { $filter = "assignedLicenses/`$count ne 0" $users = Get-MgUser -All -Property 'Id,DisplayName,UserPrincipalName,SignInActivity,AssignedLicenses,AccountEnabled,UserType' ` -Filter $filter -ErrorAction Stop } catch { throw "Failed to retrieve users: $_" } # Build a SkuId-to-SkuPartNumber mapping from subscribed SKUs try { $subscribedSkus = Get-MgSubscribedSku -All -ErrorAction Stop } catch { Write-Warning "Could not retrieve subscribed SKUs for cost lookup: $_" $subscribedSkus = @() } $skuIdToPartNumber = @{} foreach ($sku in $subscribedSkus) { $skuIdToPartNumber[$sku.SkuId] = $sku.SkuPartNumber } $results = foreach ($user in $users) { # Filter guests unless requested if ($user.UserType -eq 'Guest' -and -not $IncludeGuests) { continue } # Get sign-in data $lastSignIn = $user.SignInActivity.LastSignInDateTime $daysSinceSign = if ($lastSignIn) { [math]::Round(((Get-Date) - [datetime]$lastSignIn).TotalDays, 0) } else { -1 } # Calculate total license cost for this user $userLicenseNames = @() $totalCost = 0 foreach ($license in $user.AssignedLicenses) { $partNumber = $skuIdToPartNumber[$license.SkuId] if ($partNumber) { $lookup = $friendlyNames[$partNumber] if ($lookup) { $userLicenseNames += $lookup.FriendlyName $totalCost += $lookup.EstimatedMonthlyCost } else { $userLicenseNames += $partNumber } } else { $userLicenseNames += $license.SkuId } } $assignedLicenseList = $userLicenseNames -join '; ' $totalCost = [math]::Round($totalCost, 2) # Determine finding $finding = $null # Priority 1: Disabled account with active license if (-not $user.AccountEnabled) { $finding = 'DISABLED WITH LICENSE' } # Priority 2: Guest with license elseif ($user.UserType -eq 'Guest') { $finding = 'GUEST WITH LICENSE' } # Priority 3: Never signed in elseif ($daysSinceSign -eq -1) { $finding = 'NEVER SIGNED IN' } # Priority 4: Inactive beyond threshold elseif ($daysSinceSign -ge $DaysInactive) { $finding = 'INACTIVE LICENSED USER' } # Only emit results with findings if ($finding) { $lastSignInDisplay = if ($lastSignIn) { ([datetime]$lastSignIn).ToString('yyyy-MM-dd') } else { 'Never' } [PSCustomObject]@{ UserPrincipalName = $user.UserPrincipalName DisplayName = $user.DisplayName AccountEnabled = $user.AccountEnabled UserType = $user.UserType AssignedLicenses = $assignedLicenseList LicenseCost = $totalCost LastSignIn = $lastSignInDisplay DaysSinceSignIn = if ($daysSinceSign -ge 0) { $daysSinceSign } else { 'Never' } Finding = $finding } } } return $results | Sort-Object LicenseCost -Descending } |