From f7d73d2ae53354adc7de8e2bcc2e44e7615fc7ba Mon Sep 17 00:00:00 2001 From: Josiah Opiyo <122151392+ojopiyo@users.noreply.github.com> Date: Sun, 23 Aug 2026 16:22:58 +0100 Subject: [PATCH 1/3] Update README.md --- scripts/aad-inactive-guest-delete/README.md | 580 ++++++++++++++++++-- 1 file changed, 542 insertions(+), 38 deletions(-) diff --git a/scripts/aad-inactive-guest-delete/README.md b/scripts/aad-inactive-guest-delete/README.md index f655d3b41..8df8aa86a 100644 --- a/scripts/aad-inactive-guest-delete/README.md +++ b/scripts/aad-inactive-guest-delete/README.md @@ -1,76 +1,580 @@ +# Delete inactive Guest User +## Summary -# Delete inactive Guest User +This PowerShell script audits Microsoft Entra ID guest accounts for inactivity using the Microsoft Graph PowerShell SDK. -The script will report inactive users for x days and provides an option to delete them. +The script: +- Retrieves all users with userType set to Guest. +- Evaluates inactivity using LastSuccessfulSignInDateTime. +- Optionally identifies guests who have never successfully signed in using CreatedDateTime. +- Applies configurable user and domain exclusions. +- Generates a timestamped CSV audit report. +- Maintains an execution log for operational traceability. +- Runs in REPORT ONLY mode by default. +- Supports optional deletion of identified inactive guest accounts following administrative review and approval. +- Records deletion attempts, successful deletions, and errors in the final audit report. +The default inactivity threshold is 90 days, but this can be changed through the configuration section. -## Summary +## Why It Matters + +Guest accounts can accumulate in Microsoft 365 environments as external collaborators, suppliers, consultants, partners, and temporary project users leave the organisation. + +Without periodic review, inactive guest accounts can: + +- Increase the external identity attack surface. +- Retain access that is no longer required. +- Complicate access governance and entitlement reviews. +- Increase the number of identities administrators must maintain. +- Create unnecessary audit and compliance exposure. +This script provides a repeatable PROD process for identifying potentially stale guest identities and producing an auditable dataset for review before remediation. + +A typical operational workflow is: + +1. Run the script in REPORT ONLY mode. +2. Review the generated CSV report. +3. Validate exclusions and business ownership. +4. Obtain the appropriate PROD/change approval. +5. Enable deletion if remediation has been authorised. +6. Retain the generated reports and execution logs according to organisational retention requirements. + +## Requirements + +### PowerShell Module -This PowerShell script identifies and optionally deletes inactive guest users in Microsoft 365. It connects to Microsoft Graph, retrieves guest users, checks their last sign-in date, and lists those who have been inactive for a specified number of days. The script then prompts the user to confirm whether to delete these inactive users. +The script requires: +> #requires -Modules Microsoft.Graph.Users -- Open Windows PowerShell ISE or VS Code -- Copy script below to your clipboard -- Modify the $daysInactive variable as needed. -- Run the script to identify and optionally delete inactive guest users. +Install the Microsoft Graph PowerShell SDK module if required: -[!INCLUDE [Delete Warning](../../docfx/includes/DELETE-WARN.md)] +> Install-Module Microsoft.Graph.Users -Scope CurrentUser -# [Microsoft Graph PowerShell](#tab/graphps) +### Microsoft Graph Permissions + +For audit/reporting: + +- **User.Read.All** +- **AuditLog.Read.All** +Deletion requires additional Microsoft Graph permissions appropriate to the organisation's identity governance and delegated/application access model. + +The executing identity must also be authorised to perform the requested operations. + +## Configuration + +The primary configuration is controlled through `$Config`: + +| Setting | Default | Purpose | +|---|---|---| +| `InactiveDays` | `90` | Number of days before a guest is considered inactive | +| `IncludeNeverUsedGuests` | `$true` | Includes guests who have never successfully signed in | +| `ExecuteDeletion` | `$false` | Controls whether inactive guests are deleted | +| `ReportPath` | `C:\Temp\GuestAccountAudit` | CSV report directory | +| `LogPath` | `C:\Temp\GuestAccountAudit\GuestAccountAudit.log` | Execution log location | +| `ExcludedUserPrincipalNames` | `Empty` | Specific guest UPNs excluded from processing | +| `ExcludedDomains` | `Empty` | Guest domains excluded from processing | + +# [PnP PowerShell](#tab/pnpps) ```powershell -#Install-Module Microsoft.Graph -# Define the number of days of inactivity -$daysInactive = 30 +#requires -Modules Microsoft.Graph.Users + +<# +.SYNOPSIS + Identifies inactive Microsoft Entra ID guest accounts and optionally deletes them. + +.DESCRIPTION + This script: + - Retrieves all guest accounts + - Determines inactivity using lastSuccessfulSignInDateTime + - Handles guests who have never successfully signed in + - Produces an audit CSV report + - Runs in REPORT-ONLY mode by default + - Supports exclusions + - Includes error handling and execution logging + - Supports optional deletion after review + + IMPORTANT: + Deletion is disabled by default. + Set $Config.ExecuteDeletion = $true only after appropriate PROD approval. + +.NOTES + Recommended permissions: + - User.Read.All + - AuditLog.Read.All + + Deletion additionally requires appropriate write/delete permissions. +#> + +# ============================================================ +# CONFIGURATION +# ============================================================ + +$Config = @{ + # Number of days after which a guest is considered inactive + InactiveDays = 90 + + # If a guest has never successfully signed in, + # use CreatedDateTime to determine inactivity + IncludeNeverUsedGuests = $true + + # Safety control: + # FALSE = report only + # TRUE = perform deletion + ExecuteDeletion = $false + + # Output locations + ReportPath = "C:\Temp\GuestAccountAudit" + LogPath = "C:\Temp\GuestAccountAudit\GuestAccountAudit.log" + + # Optional exclusions + ExcludedUserPrincipalNames = @( + # "important.external.user@external.com" + ) + + # Optional excluded domains + ExcludedDomains = @( + # "trustedpartner.com" + ) +} + +# ============================================================ +# INITIALISE +# ============================================================ + +$ErrorActionPreference = "Stop" + +$ExecutionStart = Get-Date +$CalcDate = $ExecutionStart.AddDays(-$Config.InactiveDays) + +# Ensure output directory exists +if (-not (Test-Path -Path $Config.ReportPath)) { + New-Item -Path $Config.ReportPath -ItemType Directory -Force | Out-Null +} + +# ============================================================ +# LOGGING FUNCTION +# ============================================================ + +function Write-Log { + param ( + [Parameter(Mandatory)] + [string]$Message, + + [ValidateSet("INFO", "WARNING", "ERROR")] + [string]$Level = "INFO" + ) + + $Timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" + $LogEntry = "$Timestamp [$Level] $Message" + + Write-Host $LogEntry + + Add-Content -Path $Config.LogPath -Value $LogEntry +} + +# ============================================================ +# START +# ============================================================ + +Write-Log "============================================================" +Write-Log "Guest account inactivity audit started" +Write-Log "Execution mode: $(if ($Config.ExecuteDeletion) { 'DELETE' } else { 'REPORT ONLY' })" +Write-Log "Inactivity threshold: $($Config.InactiveDays) days" +Write-Log "Cut-off date: $($CalcDate.ToString('yyyy-MM-dd HH:mm:ss'))" +Write-Log "============================================================" + +# ============================================================ +# CONNECT TO MICROSOFT GRAPH +# ============================================================ + +try { + + Write-Log "Connecting to Microsoft Graph..." + + Connect-MgGraph ` + -Scopes "User.Read.All", "AuditLog.Read.All" ` + -NoWelcome + + Write-Log "Successfully connected to Microsoft Graph." + +} +catch { + + Write-Log "Failed to connect to Microsoft Graph: $($_.Exception.Message)" "ERROR" + throw +} + +# ============================================================ +# RETRIEVE GUEST USERS +# ============================================================ + +try { + + Write-Log "Retrieving guest accounts..." + + $GuestUsers = Get-MgUser ` + -Filter "userType eq 'Guest'" ` + -All ` + -Property Id, + DisplayName, + Mail, + UserPrincipalName, + UserType, + CreatedDateTime, + AccountEnabled, + SignInActivity + + Write-Log "Guest accounts retrieved: $($GuestUsers.Count)" + +} +catch { + + Write-Log "Failed to retrieve guest accounts: $($_.Exception.Message)" "ERROR" + + Disconnect-MgGraph | Out-Null + throw +} + +# ============================================================ +# IDENTIFY INACTIVE USERS +# ============================================================ + +$InactiveUsers = [System.Collections.Generic.List[object]]::new() + +$ExcludedUsers = 0 +$ActiveUsers = 0 +$NeverUsedUsers = 0 + +foreach ($User in $GuestUsers) { + + $UPN = $User.UserPrincipalName + $Domain = if ($UPN -and $UPN.Contains("@")) { + $UPN.Split("@")[1].ToLower() + } + else { + "" + } + + # -------------------------------------------------------- + # EXCLUSIONS + # -------------------------------------------------------- + + if ($Config.ExcludedUserPrincipalNames -contains $UPN) { + + $ExcludedUsers++ + + Write-Log "Excluded user: $UPN" + + continue + } + + if ($Domain -and ($Config.ExcludedDomains -contains $Domain)) { + + $ExcludedUsers++ + + Write-Log "Excluded domain user: $UPN" + + continue + } + + # -------------------------------------------------------- + # DETERMINE LAST SUCCESSFUL ACTIVITY + # -------------------------------------------------------- + + $LastSuccessfulSignIn = $null + + if ($User.SignInActivity) { + + $LastSuccessfulSignIn = + $User.SignInActivity.LastSuccessfulSignInDateTime + } -Connect-MgGraph -Scopes "User.Read.All", "User.ReadWrite.All","AuditLog.Read.All" + $ActivityDate = $null + $ActivitySource = $null + $IsInactive = $false -$calcDate = (Get-Date).AddDays($daysInactive * -1) + # -------------------------------------------------------- + # USER HAS SUCCESSFUL SIGN-IN HISTORY + # -------------------------------------------------------- -$guestUsers = Get-MgUser -Filter "userType eq 'Guest'" -All -Property id,displayName,mail,signInActivity,UserPrincipalName + if ($LastSuccessfulSignIn) { -$inactiveUsers = @() + $ActivityDate = [DateTime]$LastSuccessfulSignIn + $ActivitySource = "LastSuccessfulSignInDateTime" -foreach ($user in $guestUsers) { - if ($user.SignInActivity.LastSignInDateTime -ge $calcDate) { - $inactiveUsers += $user + if ($ActivityDate -lt $CalcDate) { + + $IsInactive = $true + } + else { + + $ActiveUsers++ } + } + + # -------------------------------------------------------- + # USER HAS NEVER SUCCESSFULLY SIGNED IN + # -------------------------------------------------------- + + elseif ($Config.IncludeNeverUsedGuests) { + + if ($User.CreatedDateTime) { + + $ActivityDate = [DateTime]$User.CreatedDateTime + $ActivitySource = "CreatedDateTime - Never Successfully Signed In" + + if ($ActivityDate -lt $CalcDate) { + + $IsInactive = $true + $NeverUsedUsers++ + } + } + } + + # -------------------------------------------------------- + # ADD INACTIVE USER TO REPORT + # -------------------------------------------------------- + + if ($IsInactive) { + + $DaysInactive = [math]::Floor( + ($ExecutionStart - $ActivityDate).TotalDays + ) + + $InactiveUsers.Add( + [PSCustomObject]@{ + Id = $User.Id + DisplayName = $User.DisplayName + UserPrincipalName = $User.UserPrincipalName + Mail = $User.Mail + AccountEnabled = $User.AccountEnabled + CreatedDateTime = $User.CreatedDateTime + LastSuccessfulSignIn = $LastSuccessfulSignIn + ActivityDateUsed = $ActivityDate + ActivitySource = $ActivitySource + DaysInactive = $DaysInactive + InactivityThresholdDays = $Config.InactiveDays + RecommendedAction = "Review" + DeletionAttempted = $false + DeletionSuccessful = $false + Error = $null + } + ) + } } -if ($inactiveUsers.Count -gt 0) { - Write-Host "The following guest users have been inactive for $daysInactive days or more:" - $inactiveUsers | ForEach-Object { - Write-Host "$($_.DisplayName) ($($_.UserPrincipalName))" +# ============================================================ +# SUMMARY +# ============================================================ + +Write-Log "Guest accounts assessed: $($GuestUsers.Count)" +Write-Log "Active accounts: $ActiveUsers" +Write-Log "Excluded accounts: $ExcludedUsers" +Write-Log "Never-used inactive accounts: $NeverUsedUsers" +Write-Log "Inactive accounts identified: $($InactiveUsers.Count)" + +# ============================================================ +# EXPORT REPORT +# ============================================================ + +$ReportTimestamp = Get-Date -Format "yyyyMMdd_HHmmss" + +$ReportPath = Join-Path ` + $Config.ReportPath ` + "GuestAccountAudit_$ReportTimestamp.csv" + +try { + + if ($InactiveUsers.Count -gt 0) { + + $InactiveUsers | + Export-Csv ` + -Path $ReportPath ` + -NoTypeInformation ` + -Encoding UTF8 + + Write-Log "Audit report created: $ReportPath" } + else { + + Write-Log "No inactive guest accounts identified." + + # Still create a report so that every execution + # has an auditable output. + $InactiveUsers | + Export-Csv ` + -Path $ReportPath ` + -NoTypeInformation ` + -Encoding UTF8 + + Write-Log "Empty audit report created: $ReportPath" + } + +} +catch { + + Write-Log "Failed to create audit report: $($_.Exception.Message)" "ERROR" +} + +# ============================================================ +# DELETE USERS +# ============================================================ + +if ($Config.ExecuteDeletion -and $InactiveUsers.Count -gt 0) { - # Ask if the user wants to delete the inactive users - $delete = Read-Host "Do you want to delete these users? (y/n)" - if ($delete -eq 'y') { - $inactiveUsers | ForEach-Object { - Remove-MgUser -UserId $_.Id -Confirm:$false - Write-Host "Deleted user: $($_.DisplayName) ($($_.UserPrincipalName))" + Write-Log "============================================================" + Write-Log "DELETION MODE ENABLED" + Write-Log "Inactive users scheduled for deletion: $($InactiveUsers.Count)" + Write-Log "============================================================" + + foreach ($InactiveUser in $InactiveUsers) { + + try { + + Write-Log "Attempting deletion: $($InactiveUser.UserPrincipalName)" + + $InactiveUser.DeletionAttempted = $true + + Remove-MgUser ` + -UserId $InactiveUser.Id ` + -Confirm:$false ` + -ErrorAction Stop + + $InactiveUser.DeletionSuccessful = $true + $InactiveUser.RecommendedAction = "Deleted" + + Write-Log "Successfully deleted: $($InactiveUser.UserPrincipalName)" + + } + catch { + + $InactiveUser.DeletionSuccessful = $false + $InactiveUser.RecommendedAction = "Deletion Failed" + $InactiveUser.Error = $_.Exception.Message + + Write-Log ` + "Failed to delete $($InactiveUser.UserPrincipalName): $($_.Exception.Message)" ` + "ERROR" } } -} else { - Write-Host "No inactive guest users found." + + # Re-export report with deletion results + try { + + $InactiveUsers | + Export-Csv ` + -Path $ReportPath ` + -NoTypeInformation ` + -Encoding UTF8 + + Write-Log "Final deletion audit report updated." + + } + catch { + + Write-Log "Failed to update final audit report: $($_.Exception.Message)" "ERROR" + } +} +else { + + if ($InactiveUsers.Count -gt 0) { + + Write-Log "REPORT ONLY mode enabled." + Write-Log "No accounts were deleted." + Write-Log "Review the audit report before any remediation." + } +} + +# ============================================================ +# DISCONNECT +# ============================================================ + +try { + + Disconnect-MgGraph | Out-Null + + Write-Log "Disconnected from Microsoft Graph." + +} +catch { + + Write-Log "Unable to disconnect cleanly from Microsoft Graph." "WARNING" } +# ============================================================ +# FINAL SUMMARY +# ============================================================ + +$ExecutionEnd = Get-Date +$Duration = $ExecutionEnd - $ExecutionStart + +Write-Log "============================================================" +Write-Log "Guest account inactivity audit completed" +Write-Log "Execution duration: $($Duration.ToString())" +Write-Log "Inactive accounts identified: $($InactiveUsers.Count)" +Write-Log "Report: $ReportPath" +Write-Log "============================================================" + -Disconnect-MgGraph ``` -[!INCLUDE [More about Microsoft Graph PowerShell SDK](../../docfx/includes/MORE-GRAPHSDK.md)] +## Output + +### CSV Audit Report + +Default output: *C:\Temp\GuestAccountAudit_yyyyMMdd_HHmmss.csv* -*** +The CSV contains: +- Entra ID object ID +- Display name +- User principal name +- Mail address +- Account enabled state +- Account creation date +- Last successful sign-in +- Activity date used for inactivity evaluation +- Activity source +- Calculated days inactive +- Configured inactivity threshold +- Recommended action +- Whether deletion was attempted +- Whether deletion succeeded +- Error information + +### Execution Log + +The execution log records: + +- Script start and completion +- Execution mode +- Inactivity threshold +- Number of guests retrieved +- Number of active guests +- Number of excluded guests +- Number of never-used guests +- Number of inactive guests +- Report location +- Deletion attempts and outcomes +- Graph connection and disconnection events +- Errors encountered during execution ## Contributors -| Author(s) | +|Author(s)| |-----------| -| [Peter Paul Kirschner](https://github.com/petkir) | +|[Peter Paul Kirschner](https://github.com/petkir)| +|[Josiah Opiyo](https://github.com/ojopiyo)| + +*Built with a focus on automation, governance, least privilege, and clean Microsoft 365 tenants-helping M365 admins gain visibility and reduce operational risk.* + +## Version history +|Version|Date|Comments| +|-------|----|--------| +|1.0|October 02, 2024|Initial release| +|2.0|August 23, 2026|Refactored and improved version| [!INCLUDE [DISCLAIMER](../../docfx/includes/DISCLAIMER.md)] - \ No newline at end of file From 0e52249736d1dfa205bba895991295960bd3a00a Mon Sep 17 00:00:00 2001 From: Paul Bullock Date: Mon, 24 Aug 2026 21:58:45 +0100 Subject: [PATCH 2/3] Split into v1, v2 sample --- scripts/aad-inactive-guest-delete/README.md | 55 ++++++++++++++++++++- 1 file changed, 53 insertions(+), 2 deletions(-) diff --git a/scripts/aad-inactive-guest-delete/README.md b/scripts/aad-inactive-guest-delete/README.md index 8df8aa86a..a0b3d8d37 100644 --- a/scripts/aad-inactive-guest-delete/README.md +++ b/scripts/aad-inactive-guest-delete/README.md @@ -39,6 +39,9 @@ A typical operational workflow is: 5. Enable deletion if remediation has been authorised. 6. Retain the generated reports and execution logs according to organisational retention requirements. + +[!INCLUDE [Delete Warning](../../docfx/includes/DELETE-WARN.md)] + ## Requirements ### PowerShell Module @@ -75,7 +78,7 @@ The primary configuration is controlled through `$Config`: | `ExcludedUserPrincipalNames` | `Empty` | Specific guest UPNs excluded from processing | | `ExcludedDomains` | `Empty` | Guest domains excluded from processing | -# [PnP PowerShell](#tab/pnpps) +# [PnP PowerShell V2](#tab/pnpps2) ```powershell #requires -Modules Microsoft.Graph.Users @@ -517,8 +520,8 @@ Write-Log "Inactive accounts identified: $($InactiveUsers.Count)" Write-Log "Report: $ReportPath" Write-Log "============================================================" - ``` +[!INCLUDE [More about Microsoft Graph PowerShell SDK](../../docfx/includes/MORE-GRAPHSDK.md)] ## Output @@ -561,6 +564,54 @@ The execution log records: - Graph connection and disconnection events - Errors encountered during execution + +# [Microsoft Graph PowerShell](#tab/graphps) + +```powershell +#Install-Module Microsoft.Graph +# Define the number of days of inactivity +$daysInactive = 30 + +Connect-MgGraph -Scopes "User.Read.All", "User.ReadWrite.All","AuditLog.Read.All" + +$calcDate = (Get-Date).AddDays($daysInactive * -1) + +$guestUsers = Get-MgUser -Filter "userType eq 'Guest'" -All -Property id,displayName,mail,signInActivity,UserPrincipalName + +$inactiveUsers = @() + +foreach ($user in $guestUsers) { + if ($user.SignInActivity.LastSignInDateTime -ge $calcDate) { + $inactiveUsers += $user + } +} + +if ($inactiveUsers.Count -gt 0) { + Write-Host "The following guest users have been inactive for $daysInactive days or more:" + $inactiveUsers | ForEach-Object { + Write-Host "$($_.DisplayName) ($($_.UserPrincipalName))" + } + + # Ask if the user wants to delete the inactive users + $delete = Read-Host "Do you want to delete these users? (y/n)" + if ($delete -eq 'y') { + $inactiveUsers | ForEach-Object { + Remove-MgUser -UserId $_.Id -Confirm:$false + Write-Host "Deleted user: $($_.DisplayName) ($($_.UserPrincipalName))" + } + } +} else { + Write-Host "No inactive guest users found." +} + + +Disconnect-MgGraph +``` +[!INCLUDE [More about Microsoft Graph PowerShell SDK](../../docfx/includes/MORE-GRAPHSDK.md)] +*** + + + ## Contributors |Author(s)| From 60dd89a709f2da917213587fface6fb3ca9ab16c Mon Sep 17 00:00:00 2001 From: Paul Bullock Date: Mon, 24 Aug 2026 21:58:57 +0100 Subject: [PATCH 3/3] Update metadata to give credit --- scripts/aad-inactive-guest-delete/assets/sample.json | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/scripts/aad-inactive-guest-delete/assets/sample.json b/scripts/aad-inactive-guest-delete/assets/sample.json index 2197b0aea..2fbd879a5 100644 --- a/scripts/aad-inactive-guest-delete/assets/sample.json +++ b/scripts/aad-inactive-guest-delete/assets/sample.json @@ -9,7 +9,7 @@ "" ], "creationDateTime": "2024-10-02", - "updateDateTime": "2024-10-02", + "updateDateTime": "2026-08-22", "products": [ "Graph" ], @@ -39,6 +39,12 @@ } ], "authors": [ + { + "gitHubAccount": "ojopiyo", + "company": "", + "pictureUrl": "https://github.com/ojopiyo.png", + "name": "Josiah Opiyo" + }, { "gitHubAccount": "petkir", "company": "ACP CUBIDO Digital Solutions GmbH",