1+ # Report-InactiveGuestUsers-150Days.ps1
2+ # Purpose:
3+ # Read-only report of Microsoft Entra guest users inactive for more than X days.
4+ # Exports CSV sorted from oldest sign-in to newest sign-in.
5+ #
6+ # Default threshold: 150 days
7+ # Recommended action output: Block sign-ins for these users after review.
8+ #
9+ # Required Graph scopes:
10+ # User.Read.All
11+ # AuditLog.Read.All
12+ # Directory.Read.All
13+ #
14+ # Notes:
15+ # - This script does NOT block or delete users.
16+ # - Last sign-in data depends on signInActivity availability in your tenant/licensing.
17+ # - Guests with no sign-in date are included as "Never signed in" if older than threshold by CreatedDateTime.
18+
19+ param (
20+ [int ]$InactiveDays = 150 ,
21+ [string ]$OutputPath = " .\InactiveGuestUsers_150Days.csv"
22+ )
23+
24+ $ErrorActionPreference = " Stop"
25+
26+ function Write-Section {
27+ param ([string ]$Text )
28+
29+ Write-Host " "
30+ Write-Host " ============================================================" - ForegroundColor DarkGray
31+ Write-Host " $Text " - ForegroundColor Cyan
32+ Write-Host " ============================================================" - ForegroundColor DarkGray
33+ }
34+
35+ function Write-Ok {
36+ param ([string ]$Text )
37+ Write-Host " [OK] $Text " - ForegroundColor Green
38+ }
39+
40+ function Write-Info {
41+ param ([string ]$Text )
42+ Write-Host " [INFO] $Text " - ForegroundColor Gray
43+ }
44+
45+ function Write-Warn {
46+ param ([string ]$Text )
47+ Write-Host " [WARN] $Text " - ForegroundColor Yellow
48+ }
49+
50+ function Write-Bad {
51+ param ([string ]$Text )
52+ Write-Host " [ACTION] $Text " - ForegroundColor Red
53+ }
54+
55+ Write-Section " Inactive Microsoft Entra Guest User Report"
56+
57+ $ThresholdDate = (Get-Date ).AddDays(- $InactiveDays )
58+
59+ Write-Info " Inactive threshold: $InactiveDays days"
60+ Write-Info " Threshold date: $ThresholdDate "
61+ Write-Info " Output CSV: $OutputPath "
62+
63+ Write-Section " Connecting to Microsoft Graph"
64+
65+ Connect-MgGraph - Scopes " User.Read.All" , " AuditLog.Read.All" , " Directory.Read.All" - NoWelcome
66+
67+ $ctx = Get-MgContext
68+ Write-Ok " Connected to tenant: $ ( $ctx.TenantId ) "
69+ Write-Info " Scopes: $ ( $ctx.Scopes -join ' , ' ) "
70+
71+ Write-Section " Retrieving guest users"
72+
73+ Write-Info " Querying users where userType eq 'Guest'..."
74+ Write-Info " Retrieving DisplayName, UPN, Mail, CreatedDateTime, AccountEnabled, SignInActivity, ExternalUserState."
75+
76+ $Guests = Get-MgUser `
77+ - Filter " userType eq 'Guest'" `
78+ - Property " id,displayName,userPrincipalName,mail,userType,accountEnabled,createdDateTime,externalUserState,signInActivity" `
79+ - All
80+
81+ Write-Ok " Total guest users found: $ ( $Guests.Count ) "
82+
83+ Write-Section " Evaluating inactivity"
84+
85+ $Report = foreach ($Guest in $Guests ) {
86+
87+ $LastSuccessful = $Guest.SignInActivity.LastSuccessfulSignInDateTime
88+ $LastInteractive = $Guest.SignInActivity.LastSignInDateTime
89+ $LastNonInteractive = $Guest.SignInActivity.LastNonInteractiveSignInDateTime
90+
91+ # Prefer LastSuccessfulSignInDateTime when available.
92+ # Fall back to LastSignInDateTime if LastSuccessfulSignInDateTime is empty.
93+ $EffectiveLastSignIn = $null
94+
95+ if ($LastSuccessful ) {
96+ $EffectiveLastSignIn = [datetime ]$LastSuccessful
97+ }
98+ elseif ($LastInteractive ) {
99+ $EffectiveLastSignIn = [datetime ]$LastInteractive
100+ }
101+
102+ $CreatedDate = if ($Guest.CreatedDateTime ) { [datetime ]$Guest.CreatedDateTime } else { $null }
103+
104+ $Inactive = $false
105+ $DaysSinceLastSignIn = $null
106+ $InactivityReason = $null
107+ $SortDate = $null
108+
109+ if ($EffectiveLastSignIn ) {
110+ $DaysSinceLastSignIn = ((Get-Date ) - $EffectiveLastSignIn ).Days
111+
112+ if ($EffectiveLastSignIn -lt $ThresholdDate ) {
113+ $Inactive = $true
114+ $InactivityReason = " Last sign-in older than $InactiveDays days"
115+ $SortDate = $EffectiveLastSignIn
116+ }
117+ }
118+ else {
119+ # Never signed in. Include only if account was created before threshold date.
120+ if ($CreatedDate -and $CreatedDate -lt $ThresholdDate ) {
121+ $Inactive = $true
122+ $DaysSinceLastSignIn = " Never"
123+ $InactivityReason = " Never signed in and account older than $InactiveDays days"
124+ $SortDate = [datetime ]" 1900-01-01"
125+ }
126+ }
127+
128+ if ($Inactive ) {
129+ [PSCustomObject ]@ {
130+ DisplayName = $Guest.DisplayName
131+ UserPrincipalName = $Guest.UserPrincipalName
132+ Mail = $Guest.Mail
133+ AccountEnabled = $Guest.AccountEnabled
134+ ExternalUserState = $Guest.ExternalUserState
135+ CreatedDateTime = $CreatedDate
136+ LastSuccessfulSignInDateTime = $LastSuccessful
137+ LastInteractiveSignInDateTime = $LastInteractive
138+ LastNonInteractiveSignInDateTime = $LastNonInteractive
139+ EffectiveLastSignInDateTime = $EffectiveLastSignIn
140+ DaysSinceLastSignIn = $DaysSinceLastSignIn
141+ InactivityReason = $InactivityReason
142+ RecommendedAction = " Review owner/business need, then block sign-ins"
143+ ObjectId = $Guest.Id
144+ SortDate = $SortDate
145+ }
146+ }
147+ }
148+
149+ $ReportSorted = $Report |
150+ Sort-Object SortDate, DisplayName |
151+ Select-Object `
152+ DisplayName,
153+ UserPrincipalName,
154+ Mail,
155+ AccountEnabled,
156+ ExternalUserState,
157+ CreatedDateTime,
158+ LastSuccessfulSignInDateTime,
159+ LastInteractiveSignInDateTime,
160+ LastNonInteractiveSignInDateTime,
161+ EffectiveLastSignInDateTime,
162+ DaysSinceLastSignIn,
163+ InactivityReason,
164+ RecommendedAction,
165+ ObjectId
166+
167+ Write-Ok " Inactive guest users found: $ ( $ReportSorted.Count ) "
168+
169+ Write-Section " Exporting CSV"
170+
171+ if ($ReportSorted.Count -gt 0 ) {
172+ $ReportSorted |
173+ Export-Csv - Path $OutputPath - NoTypeInformation - Encoding UTF8
174+
175+ Write-Ok " CSV exported successfully:"
176+ Write-Host " $OutputPath " - ForegroundColor White
177+ }
178+ else {
179+ Write-Ok " No inactive guest users found over $InactiveDays days. No CSV rows to export."
180+
181+ # Still create an empty CSV with headers for audit consistency
182+ $ReportSorted |
183+ Export-Csv - Path $OutputPath - NoTypeInformation - Encoding UTF8
184+
185+ Write-Info " Empty CSV created for audit record:"
186+ Write-Host " $OutputPath " - ForegroundColor White
187+ }
188+
189+ Write-Section " Summary"
190+
191+ Write-Host " Guest users evaluated: " - NoNewline
192+ Write-Host $Guests.Count - ForegroundColor White
193+
194+ Write-Host " Inactive threshold: " - NoNewline
195+ Write-Host " $InactiveDays days" - ForegroundColor White
196+
197+ Write-Host " Inactive guest users found: " - NoNewline
198+ if ($ReportSorted.Count -gt 0 ) {
199+ Write-Host $ReportSorted.Count - ForegroundColor Red
200+ }
201+ else {
202+ Write-Host " 0" - ForegroundColor Green
203+ }
204+
205+ if ($ReportSorted.Count -gt 0 ) {
206+ Write-Bad " Recommended action: Review these users, then block sign-ins for accounts with no current business justification."
207+ Write-Warn " This script is read-only and did NOT block sign-ins."
208+ Write-Warn " Do not delete immediately. Recommended lifecycle is: review/attest -> block sign-in -> delete after grace period."
209+ }
210+ else {
211+ Write-Ok " No action required based on the $InactiveDays -day inactivity threshold."
212+ }
213+
214+ Write-Host " "
215+ Write-Host " Suggested next step:" - ForegroundColor Cyan
216+ Write-Host " Review the CSV with app/site/group owners. For confirmed stale guests, block sign-in first, then delete later after a grace period." - ForegroundColor White
217+
218+ Write-Host " "
219+ Write-Host " Done." - ForegroundColor Green
0 commit comments