Skip to content

Commit 4736f5a

Browse files
authored
Add files via upload
1 parent bcfc3c7 commit 4736f5a

1 file changed

Lines changed: 349 additions & 0 deletions

File tree

Lines changed: 349 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,349 @@
1+
#requires -Version 5.1
2+
<#
3+
.SYNOPSIS
4+
Audits disabled Microsoft Entra users and optionally removes directly assigned licenses.
5+
6+
.DESCRIPTION
7+
Reuses the current Microsoft Graph PowerShell session. By default, the script performs
8+
an audit only and exports detailed license and action reports. No license is removed
9+
unless -Execute is explicitly supplied.
10+
11+
License assignment source is determined from licenseAssignmentStates.assignedByGroup.
12+
Only directly assigned SKU IDs are eligible for removal. Group-based assignments are
13+
reported but are never removed by this script.
14+
15+
.PARAMETER Execute
16+
Enables removal of directly assigned licenses. Without this switch, the script is read-only.
17+
18+
.PARAMETER UserPrincipalName
19+
Limits the run to one or more specified user principal names.
20+
21+
.PARAMETER ApprovedUsersCsv
22+
Limits the run to users in a reviewed CSV containing a UserPrincipalName column.
23+
24+
.PARAMETER OutputPath
25+
Directory for timestamped CSV reports. Defaults to the script directory.
26+
27+
.PARAMETER IncludeGuests
28+
Includes disabled guest users. Guests are excluded by default.
29+
30+
.EXAMPLE
31+
.\RemoveM365LicensesfromDisabledUsers.ps1
32+
33+
.EXAMPLE
34+
.\RemoveM365LicensesfromDisabledUsers.ps1 -ApprovedUsersCsv .\approved-users.csv -Execute -WhatIf
35+
36+
.EXAMPLE
37+
.\RemoveM365LicensesfromDisabledUsers.ps1 -ApprovedUsersCsv .\approved-users.csv -Execute
38+
39+
.NOTES
40+
The script does not initiate authentication or request consent. License removal requires
41+
an existing Graph session with sufficient permission and a supported Entra administrative role.
42+
#>
43+
44+
[CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')]
45+
param(
46+
[Parameter()]
47+
[switch]$Execute,
48+
49+
[Parameter()]
50+
[ValidateNotNullOrEmpty()]
51+
[string[]]$UserPrincipalName,
52+
53+
[Parameter()]
54+
[ValidateScript({ Test-Path -LiteralPath $_ -PathType Leaf })]
55+
[string]$ApprovedUsersCsv,
56+
57+
[Parameter()]
58+
[ValidateNotNullOrEmpty()]
59+
[string]$OutputPath = $PSScriptRoot,
60+
61+
[Parameter()]
62+
[switch]$IncludeGuests
63+
)
64+
65+
Set-StrictMode -Version Latest
66+
$ErrorActionPreference = 'Stop'
67+
68+
function Get-SafeProperty {
69+
[CmdletBinding()]
70+
param(
71+
[AllowNull()][object]$InputObject,
72+
[Parameter(Mandatory)][string]$Name
73+
)
74+
75+
if ($null -eq $InputObject) { return $null }
76+
77+
$property = $InputObject.PSObject.Properties[$Name]
78+
if ($null -ne $property) { return $property.Value }
79+
80+
$additional = $InputObject.PSObject.Properties['AdditionalProperties']
81+
if ($null -ne $additional -and $null -ne $additional.Value) {
82+
foreach ($key in @($additional.Value.Keys)) {
83+
if ([string]::Equals([string]$key, $Name, [System.StringComparison]::OrdinalIgnoreCase)) {
84+
return $additional.Value[$key]
85+
}
86+
}
87+
}
88+
89+
return $null
90+
}
91+
92+
function ConvertTo-SkuIdText {
93+
[CmdletBinding()]
94+
param([AllowNull()][object]$Value)
95+
96+
if ($null -eq $Value) { return '' }
97+
return ([string]$Value).Trim('{}').ToLowerInvariant()
98+
}
99+
100+
$requiredCommands = @(
101+
'Get-MgContext',
102+
'Get-MgUser',
103+
'Get-MgSubscribedSku',
104+
'Set-MgUserLicense'
105+
)
106+
107+
$missingCommands = @(
108+
foreach ($command in $requiredCommands) {
109+
if (-not (Get-Command $command -ErrorAction SilentlyContinue)) { $command }
110+
}
111+
)
112+
113+
if (@($missingCommands).Count -gt 0) {
114+
throw "Missing Microsoft Graph PowerShell command(s): $($missingCommands -join ', '). Install the Microsoft.Graph module before running this script."
115+
}
116+
117+
$context = Get-MgContext
118+
if ($null -eq $context) {
119+
throw 'No active Microsoft Graph session was found. Connect with permissions already approved for your environment, then rerun the script.'
120+
}
121+
122+
if (-not (Test-Path -LiteralPath $OutputPath)) {
123+
$null = New-Item -ItemType Directory -Path $OutputPath -Force
124+
}
125+
126+
$resolvedOutputPath = (Resolve-Path -LiteralPath $OutputPath).Path
127+
$timestamp = Get-Date -Format 'yyyyMMdd-HHmmss'
128+
$detailPath = Join-Path $resolvedOutputPath "disabled-user-license-details-$timestamp.csv"
129+
$summaryPath = Join-Path $resolvedOutputPath "disabled-user-license-actions-$timestamp.csv"
130+
131+
$approvedUpns = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
132+
foreach ($upn in @($UserPrincipalName)) {
133+
if (-not [string]::IsNullOrWhiteSpace($upn)) { $null = $approvedUpns.Add($upn.Trim()) }
134+
}
135+
136+
if ($ApprovedUsersCsv) {
137+
$approvedRows = @(Import-Csv -LiteralPath $ApprovedUsersCsv)
138+
if (@($approvedRows).Count -gt 0 -and $approvedRows[0].PSObject.Properties.Name -notcontains 'UserPrincipalName') {
139+
throw "The approved users CSV must contain a UserPrincipalName column: $ApprovedUsersCsv"
140+
}
141+
142+
foreach ($row in $approvedRows) {
143+
if (-not [string]::IsNullOrWhiteSpace($row.UserPrincipalName)) {
144+
$null = $approvedUpns.Add($row.UserPrincipalName.Trim())
145+
}
146+
}
147+
}
148+
149+
Write-Host "Reusing Microsoft Graph session for $($context.Account)." -ForegroundColor Cyan
150+
Write-Host "Tenant ID: $($context.TenantId) | Authentication: $($context.AuthType)" -ForegroundColor DarkGray
151+
Write-Host "Mode: $(if ($Execute) { 'EXECUTE' } else { 'AUDIT ONLY' })" -ForegroundColor $(if ($Execute) { 'Yellow' } else { 'Green' })
152+
153+
Write-Host 'Retrieving subscribed SKUs...' -ForegroundColor Cyan
154+
$skuNameById = @{}
155+
$subscribedSkus = @(Get-MgSubscribedSku -All -Property @('skuId', 'skuPartNumber'))
156+
foreach ($sku in $subscribedSkus) {
157+
$skuId = ConvertTo-SkuIdText (Get-SafeProperty -InputObject $sku -Name 'SkuId')
158+
if (-not $skuId) { continue }
159+
160+
$skuPartNumber = Get-SafeProperty -InputObject $sku -Name 'SkuPartNumber'
161+
$skuNameById[$skuId] = if ($skuPartNumber) { [string]$skuPartNumber } else { $skuId }
162+
}
163+
164+
Write-Host 'Retrieving disabled users with effective license assignments...' -ForegroundColor Cyan
165+
$userFilter = if ($IncludeGuests) {
166+
'accountEnabled eq false and assignedLicenses/$count ne 0'
167+
} else {
168+
"accountEnabled eq false and userType eq 'Member' and assignedLicenses/`$count ne 0"
169+
}
170+
171+
$disabledUsers = @(Get-MgUser -All `
172+
-Filter $userFilter `
173+
-ConsistencyLevel eventual `
174+
-CountVariable disabledLicensedUserCount `
175+
-Property @(
176+
'id', 'displayName', 'userPrincipalName', 'userType', 'accountEnabled',
177+
'assignedLicenses', 'licenseAssignmentStates'
178+
))
179+
180+
if ($approvedUpns.Count -gt 0) {
181+
$disabledUsers = @($disabledUsers | Where-Object {
182+
$upn = [string](Get-SafeProperty -InputObject $_ -Name 'UserPrincipalName')
183+
$approvedUpns.Contains($upn)
184+
})
185+
}
186+
187+
$detailRows = [System.Collections.Generic.List[object]]::new()
188+
$summaryRows = [System.Collections.Generic.List[object]]::new()
189+
$processed = 0
190+
$eligibleUsers = 0
191+
$successfulUsers = 0
192+
$failedUsers = 0
193+
194+
foreach ($user in @($disabledUsers)) {
195+
$processed++
196+
if (($processed % 200) -eq 0) {
197+
Write-Host "Processed $processed of $(@($disabledUsers).Count) users..." -ForegroundColor DarkGray
198+
}
199+
200+
$userId = [string](Get-SafeProperty -InputObject $user -Name 'Id')
201+
$upn = [string](Get-SafeProperty -InputObject $user -Name 'UserPrincipalName')
202+
$displayName = [string](Get-SafeProperty -InputObject $user -Name 'DisplayName')
203+
$userType = [string](Get-SafeProperty -InputObject $user -Name 'UserType')
204+
$assignmentStates = @(Get-SafeProperty -InputObject $user -Name 'LicenseAssignmentStates')
205+
$assignedLicenses = @(Get-SafeProperty -InputObject $user -Name 'AssignedLicenses')
206+
207+
$stateBySku = @{}
208+
foreach ($state in $assignmentStates) {
209+
$stateSkuId = ConvertTo-SkuIdText (Get-SafeProperty -InputObject $state -Name 'SkuId')
210+
if (-not $stateSkuId) { continue }
211+
212+
if (-not $stateBySku.ContainsKey($stateSkuId)) {
213+
$stateBySku[$stateSkuId] = [System.Collections.Generic.List[object]]::new()
214+
}
215+
$stateBySku[$stateSkuId].Add($state)
216+
}
217+
218+
$effectiveSkuIds = @(
219+
@(
220+
foreach ($license in $assignedLicenses) {
221+
$id = ConvertTo-SkuIdText (Get-SafeProperty -InputObject $license -Name 'SkuId')
222+
if ($id) { $id }
223+
}
224+
) | Sort-Object -Unique
225+
)
226+
227+
$directSkuIds = [System.Collections.Generic.List[string]]::new()
228+
229+
foreach ($skuId in $effectiveSkuIds) {
230+
$skuStates = if ($stateBySku.ContainsKey($skuId)) { @($stateBySku[$skuId]) } else { @() }
231+
$directStates = @($skuStates | Where-Object {
232+
[string]::IsNullOrWhiteSpace([string](Get-SafeProperty -InputObject $_ -Name 'AssignedByGroup'))
233+
})
234+
$groupStates = @($skuStates | Where-Object {
235+
-not [string]::IsNullOrWhiteSpace([string](Get-SafeProperty -InputObject $_ -Name 'AssignedByGroup'))
236+
})
237+
238+
$assignmentSource = if (@($directStates).Count -gt 0 -and @($groupStates).Count -gt 0) {
239+
'DirectAndGroup'
240+
} elseif (@($directStates).Count -gt 0) {
241+
'Direct'
242+
} elseif (@($groupStates).Count -gt 0) {
243+
'Group'
244+
} else {
245+
'Unknown'
246+
}
247+
248+
if (@($directStates).Count -gt 0) { $directSkuIds.Add($skuId) }
249+
250+
$groupIds = @(
251+
$groupStates | ForEach-Object { Get-SafeProperty -InputObject $_ -Name 'AssignedByGroup' } |
252+
Where-Object { $_ } | Sort-Object -Unique
253+
)
254+
$stateValues = @(
255+
$skuStates | ForEach-Object { Get-SafeProperty -InputObject $_ -Name 'State' } |
256+
Where-Object { $_ } | Sort-Object -Unique
257+
)
258+
$errorValues = @(
259+
$skuStates | ForEach-Object { Get-SafeProperty -InputObject $_ -Name 'Error' } |
260+
Where-Object { $_ -and $_ -ne 'None' } | Sort-Object -Unique
261+
)
262+
263+
$detailRows.Add([pscustomobject][ordered]@{
264+
DisplayName = $displayName
265+
UserPrincipalName = $upn
266+
UserId = $userId
267+
UserType = $userType
268+
AccountEnabled = $false
269+
SkuPartNumber = if ($skuNameById.ContainsKey($skuId)) { $skuNameById[$skuId] } else { $skuId }
270+
SkuId = $skuId
271+
AssignmentSource = $assignmentSource
272+
EligibleForRemoval = (@($directStates).Count -gt 0)
273+
AssignedByGroupIds = ($groupIds -join ';')
274+
AssignmentState = ($stateValues -join ';')
275+
AssignmentErrors = ($errorValues -join ';')
276+
})
277+
}
278+
279+
$directSkuIds = @($directSkuIds | Sort-Object -Unique)
280+
$action = 'AuditOnly'
281+
$result = 'NoChange'
282+
$message = ''
283+
284+
if (@($directSkuIds).Count -eq 0) {
285+
$action = 'Skipped'
286+
$result = 'GroupAssignedOrUnknownOnly'
287+
$message = 'No directly assigned license was identified.'
288+
} else {
289+
$eligibleUsers++
290+
$licenseNames = @($directSkuIds | ForEach-Object {
291+
if ($skuNameById.ContainsKey($_)) { $skuNameById[$_] } else { $_ }
292+
})
293+
294+
if (-not $Execute) {
295+
$message = "Would remove: $($licenseNames -join '; ')"
296+
} elseif ($PSCmdlet.ShouldProcess($upn, "Remove $(@($directSkuIds).Count) directly assigned license(s): $($licenseNames -join ', ')")) {
297+
$action = 'RemoveDirectLicenses'
298+
try {
299+
$null = Set-MgUserLicense `
300+
-UserId $userId `
301+
-AddLicenses @() `
302+
-RemoveLicenses $directSkuIds `
303+
-ErrorAction Stop
304+
$result = 'Success'
305+
$message = "Removed: $($licenseNames -join '; ')"
306+
$successfulUsers++
307+
Write-Host "Removed $(@($directSkuIds).Count) direct license(s) from $upn." -ForegroundColor Green
308+
} catch {
309+
$result = 'Failed'
310+
$message = $_.Exception.Message
311+
$failedUsers++
312+
Write-Warning "Failed to remove licenses from ${upn}: $message"
313+
}
314+
} else {
315+
$action = 'WhatIfOrDeclined'
316+
$message = "Planned removal: $($licenseNames -join '; ')"
317+
}
318+
}
319+
320+
$summaryRows.Add([pscustomobject][ordered]@{
321+
TimestampUtc = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')
322+
DisplayName = $displayName
323+
UserPrincipalName = $upn
324+
UserId = $userId
325+
EffectiveLicenseCount = @($effectiveSkuIds).Count
326+
DirectLicenseCount = @($directSkuIds).Count
327+
Action = $action
328+
Result = $result
329+
Message = $message
330+
})
331+
}
332+
333+
$detailRows | Sort-Object UserPrincipalName, SkuPartNumber |
334+
Export-Csv -LiteralPath $detailPath -NoTypeInformation -Encoding UTF8
335+
$summaryRows | Sort-Object UserPrincipalName |
336+
Export-Csv -LiteralPath $summaryPath -NoTypeInformation -Encoding UTF8
337+
338+
Write-Host ''
339+
Write-Host 'Processing complete.' -ForegroundColor Green
340+
Write-Host "Disabled licensed users evaluated : $(@($disabledUsers).Count)"
341+
Write-Host "Users with direct licenses : $eligibleUsers"
342+
Write-Host "Successful remediation actions : $successfulUsers"
343+
Write-Host "Failed remediation actions : $failedUsers"
344+
Write-Host "Detailed license report : $detailPath"
345+
Write-Host "Action summary : $summaryPath"
346+
347+
if (-not $Execute) {
348+
Write-Host 'Audit-only mode was used. No licenses were removed.' -ForegroundColor Green
349+
}

0 commit comments

Comments
 (0)