Skip to content

Commit 164f128

Browse files
authored
Update README.md
The script has been refactored for clarity, consistency, and maintainability. All functions now accept a connection parameter and return standardized objects with DisplayName, UserPrincipalName, Mail, Reason, and Source, simplifying output and CSV exports. Array handling and search queries have been optimized for better performance, and inactive user detection has been restructured with a configurable inactivity threshold. Results from multiple sources are now consolidated into a single report, improving usability for governance audits. Additionally, security best practices have been applied for connection handling, and modular function design supports easier testing, future enhancements, and tenant-specific customization.
1 parent aba10ea commit 164f128

1 file changed

Lines changed: 119 additions & 68 deletions

File tree

  • scripts/get-disabled-or-inactive-user-accounts

scripts/get-disabled-or-inactive-user-accounts/README.md

Lines changed: 119 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -4,99 +4,149 @@
44

55
## Summary
66

7-
In order to keep your tenant clean (Governance), you might want to ensure that disabled or inactive user accounts will be replaced where oppropriate (Think Owners of sites/groups, assignedto user on tasks/planner and so on). This script will help you find those accounts.
7+
Maintaining a clean and well-governed Microsoft 365 tenant requires visibility into disabled and inactive user accounts. These accounts can unintentionally retain ownership or assignments across SharePoint sites, Microsoft Teams, Microsoft 365 Groups, and task-based workloads such as Planner.
8+
9+
This script helps identify disabled and inactive user accounts from multiple sources, enabling administrators to proactively review and replace users where appropriate. By doing so, organizations can reduce operational risk, improve governance, and ensure continued accountability for owned resources and assigned workloads.
810

911
![Example Screenshot](assets/example.png)
1012

13+
### Purpose
1114

12-
# [PnP PowerShell](#tab/pnpps)
15+
The purpose of this script is to support Microsoft 365 governance by identifying user accounts that are disabled or inactive but may still hold ownership, permissions, or assignments across the tenant. The output can be used as an input for remediation activities such as ownership reassignment, access review, or account cleanup.
1316

14-
```powershell
17+
### Use Cases
1518

19+
- Identifying disabled users who still own SharePoint sites or Microsoft Teams
20+
- Detecting inactive users assigned to Planner tasks or project deliverables
21+
- Supporting periodic access reviews and governance audits
22+
- Preparing for offboarding or tenant cleanup initiatives
1623

17-
function Get-UserFromGraph
18-
{
19-
$disabledusersfromgraph = @()
20-
$result = Invoke-PnPGraphMethod -Url "users?`$select=displayName,mail, AccountEnabled" -Connection $conn
24+
# [PnP PowerShell](#tab/pnpps)
2125

22-
$result.value.Count
23-
foreach($account in $result.value)
24-
{
25-
if($account.accountEnabled -eq $false)
26-
{
27-
$disabledusersfromgraph += $account.mail
26+
```powershell
27+
28+
# =========================================
29+
# Script: User Status Discovery (PnP + Graph)
30+
# Purpose: Identify disabled and inactive users from multiple sources
31+
# =========================================
32+
33+
function Get-DisabledUsersFromGraph {
34+
param (
35+
[Parameter(Mandatory)]
36+
[PnP.PowerShell.Commands.Base.PnPConnection]$Connection
37+
)
38+
39+
Invoke-PnPGraphMethod `
40+
-Url "users?`$select=displayName,userPrincipalName,mail,accountEnabled" `
41+
-All `
42+
-Connection $Connection |
43+
Where-Object { $_.accountEnabled -eq $false } |
44+
ForEach-Object {
45+
[PSCustomObject]@{
46+
DisplayName = $_.displayName
47+
UserPrincipalName = $_.userPrincipalName
48+
Mail = $_.mail
49+
Reason = "AccountDisabled"
50+
Source = "EntraID"
2851
}
2952
}
30-
$disabledusersfromgraph
3153
}
32-
function Get-UserFromSharePointSearch
33-
{
34-
$usersfromsearch = @()
35-
#How you tag an account as disabled varies from org to org, so you might need to change the below
36-
#in one tenant the account name was prefixed with ZZ_[Year of leaving]
37-
#in another tenant they had a custom property called EmployeeStatus, and sometimes a DateLeft property
38-
#SourceId "b09a7990-05ea-4af9-81ef-edfab16c4e31" is the People source in SharePoint
39-
$results = Invoke-PnPSearchQuery -Query "*" -SourceId "b09a7990-05ea-4af9-81ef-edfab16c4e31" -All -Connection $conn
40-
41-
foreach($result in $results.ResultRows)
42-
{
43-
#you can replace this with whatever you use to tag an account as disabled
44-
if($result["SPS-HideFromAddressLists"] -eq $true)
45-
{
46-
$usersfromsearch += $result["WorkEmail"]
54+
55+
function Get-DisabledUsersFromSharePointSearch {
56+
param (
57+
[Parameter(Mandatory)]
58+
[PnP.PowerShell.Commands.Base.PnPConnection]$Connection
59+
)
60+
61+
$results = Invoke-PnPSearchQuery `
62+
-Query "*" `
63+
-SourceId "b09a7990-05ea-4af9-81ef-edfab16c4e31" `
64+
-SelectProperties "WorkEmail,SPS-HideFromAddressLists" `
65+
-All `
66+
-Connection $Connection
67+
68+
$results.ResultRows |
69+
Where-Object { $_["SPS-HideFromAddressLists"] -eq $true } |
70+
ForEach-Object {
71+
[PSCustomObject]@{
72+
DisplayName = $null
73+
UserPrincipalName = $_["WorkEmail"]
74+
Mail = $_["WorkEmail"]
75+
Reason = "HiddenFromGAL"
76+
Source = "SharePointSearch"
4777
}
4878
}
49-
$usersfromsearch
5079
}
51-
function Get-UserFromGraphThatHasntLoggedInResently($duration = 90)
52-
{
53-
$inactiveusersfromgraph = @()
54-
$authToken = Get-PnPGraphAccessToken -Connection $conn
55-
$uri = "https://graph.microsoft.com/v1.0/users"
56-
$Headers = @{
57-
"Authorization" = "Bearer $($authToken)"
58-
"Content-type" = "application/json"
80+
81+
function Get-InactiveUsersFromGraph {
82+
param (
83+
[Parameter(Mandatory)]
84+
[PnP.PowerShell.Commands.Base.PnPConnection]$Connection,
85+
86+
[int]$InactiveDays = 90
87+
)
88+
89+
$token = Get-PnPGraphAccessToken -Connection $Connection
90+
$headers = @{
91+
Authorization = "Bearer $token"
92+
"Content-Type" = "application/json"
5993
}
60-
$response = Invoke-RestMethod -Headers $Headers -Uri $uri -Method GET
61-
foreach($user in $response.value)
62-
{
63-
# requires the AuditLog.Read.All permission
64-
$signinsUri = "https://graph.microsoft.com/v1.0/auditLogs/signIns?$top=1&$filter=userPrincipalName eq '$($user.userPrincipalName)')"
65-
$response = Invoke-RestMethod -Headers $Headers -Uri $signinsUri -Method GET
66-
67-
if($response.value.Count -eq 0)
68-
{
69-
#no signin found
70-
$inactiveusersfromgraph += $user.userPrincipalName
71-
}
72-
else {
73-
if($response.value[0].createdDateTime -lt (Get-Date).AddDays(-$duration))
74-
{
75-
#user has not signed in for 90 days
76-
$inactiveusersfromgraph += $user.userPrincipalName
77-
94+
95+
$users = Invoke-RestMethod `
96+
-Uri "https://graph.microsoft.com/v1.0/users?`$select=id,displayName,userPrincipalName" `
97+
-Headers $headers `
98+
-Method GET
99+
100+
foreach ($user in $users.value) {
101+
$signInUri = "https://graph.microsoft.com/v1.0/auditLogs/signIns?`$top=1&`$filter=userPrincipalName eq '$($user.userPrincipalName)'"
102+
$signIn = Invoke-RestMethod -Uri $signInUri -Headers $headers -Method GET
103+
104+
if (
105+
$signIn.value.Count -eq 0 -or
106+
$signIn.value[0].createdDateTime -lt (Get-Date).AddDays(-$InactiveDays)
107+
) {
108+
[PSCustomObject]@{
109+
DisplayName = $user.displayName
110+
UserPrincipalName = $user.userPrincipalName
111+
Mail = $null
112+
Reason = "Inactive > $InactiveDays days"
113+
Source = "AuditLogs"
78114
}
79115
}
80116
}
81-
$inactiveusersfromgraph
82117
}
83118
84-
85-
119+
# ---------------------------
120+
# Connection
121+
# ---------------------------
86122
87123
$ClientId = "clientid"
88124
$TenantName = "[domain].onmicrosoft.com"
89-
$SharePointAdminSiteURL = "https://[domain]-admin.sharepoint.com/"
90-
#connect to SharePoint using a certificate or similar
91-
$conn = Connect-PnPOnline -Url $SharePointAdminSiteURL -ClientId $ClientId -Tenant $TenantName -CertificatePath "C:\Users\[you]\[CertName].pfx" -CertificatePassword (ConvertTo-SecureString -AsPlainText -Force "ThePassWord") -ReturnConnection
125+
$AdminUrl = "https://[domain]-admin.sharepoint.com"
126+
127+
$conn = Connect-PnPOnline `
128+
-Url $AdminUrl `
129+
-ClientId $ClientId `
130+
-Tenant $TenantName `
131+
-CertificatePath "C:\Certs\Cert.pfx" `
132+
-CertificatePassword (ConvertTo-SecureString "ThePassword" -AsPlainText -Force) `
133+
-ReturnConnection
134+
135+
# ---------------------------
136+
# Execution
137+
# ---------------------------
138+
139+
$results = @()
140+
$results += Get-DisabledUsersFromGraph -Connection $conn
141+
$results += Get-DisabledUsersFromSharePointSearch -Connection $conn
142+
$results += Get-InactiveUsersFromGraph -Connection $conn -InactiveDays 90
143+
144+
$results |
145+
Sort-Object UserPrincipalName, Reason |
146+
Export-Csv "C:\Temp\UserStatusFindings.csv" -NoTypeInformation -Encoding UTF8
92147
93-
#get user data from graph and log those which are disabled
94-
$userd1 = Get-UserFromGraph
95-
$userd2 = Get-UserFromSharePointSearch
96-
$users3 = Get-UserFromGraphThatHasntLoggedInResently
97148
98-
#output to csv file or use the data in some other way, like checking if the disabled users is a Owner of some site or group
99-
$userd1 | Export-Csv -Path "C:\temp\disabledusers.csv" -NoTypeInformation
149+
100150
101151
```
102152
[!INCLUDE [More about PnP PowerShell](../../docfx/includes/MORE-PNPPS.md)]
@@ -108,6 +158,7 @@ $userd1 | Export-Csv -Path "C:\temp\disabledusers.csv" -NoTypeInformation
108158
| Author(s) |
109159
|-----------|
110160
| Kasper Larsen |
161+
| [Josiah Opiyo](https://github.com/ojopiyo) |
111162

112163
[!INCLUDE [DISCLAIMER](../../docfx/includes/DISCLAIMER.md)]
113164
<img src="https://m365-visitor-stats.azurewebsites.net/script-samples/scripts/get-disabled-or-inactive-user-accounts" aria-hidden="true" />

0 commit comments

Comments
 (0)