Skip to content

Commit 77b62d3

Browse files
authored
Add files via upload
1 parent 2a0a4be commit 77b62d3

1 file changed

Lines changed: 210 additions & 0 deletions

File tree

Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
# Remove M365 Licenses from Disabled Users (patched)
2+
# - Reuses original Connect-MgGraph flow
3+
# - Resolves license names via licenseDetails and group assignedLicenses
4+
# - Builds CSV with DisplayName, UserPrincipalName, Source, License1..N
5+
# - Preserves DryRun behavior (default = $true)
6+
7+
# Set to $true to simulate, $false to actually remove licenses
8+
$DryRun = $true
9+
10+
# Optional CSV export path (set to $null to skip export)
11+
$ExportCsv = ".\disabled_licenses_with_names.csv"
12+
13+
# Required Graph scopes (same as original)
14+
$Scopes = @(
15+
"User.ReadWrite.All",
16+
"Directory.ReadWrite.All"
17+
)
18+
19+
# Connect to Microsoft Graph (same as original)
20+
Connect-MgGraph -Scopes $Scopes
21+
22+
Write-Host "`nFetching disabled users..." -ForegroundColor Cyan
23+
24+
# Get all disabled users (do not filter by AssignedLicenses here; we'll detect licenses later)
25+
$DisabledUsers = Get-MgUser -All `
26+
-Filter "accountEnabled eq false" `
27+
-Property Id,DisplayName,UserPrincipalName
28+
29+
if (-not $DisabledUsers -or $DisabledUsers.Count -eq 0) {
30+
Write-Host "No disabled users found." -ForegroundColor Green
31+
return
32+
}
33+
34+
# Build SKU maps for name resolution (best-effort)
35+
$guidToPart = @{}
36+
$acctToPart = @{}
37+
try {
38+
$skus = Get-MgSubscribedSku -All -ErrorAction Stop
39+
foreach ($s in $skus) {
40+
if ($s.SkuId) { $guidToPart[$s.SkuId.Guid.ToString().Trim('{}')] = $s.SkuPartNumber }
41+
if ($s.AccountSkuId) { $acctToPart[$s.AccountSkuId] = $s.SkuPartNumber }
42+
if ($s.SkuPartNumber) {
43+
$guidToPart[$s.SkuPartNumber] = $s.SkuPartNumber
44+
$acctToPart[$s.SkuPartNumber] = $s.SkuPartNumber
45+
}
46+
}
47+
} catch {
48+
Write-Host "Warning: Could not enumerate subscribed SKUs: $($_.Exception.Message)" -ForegroundColor Yellow
49+
}
50+
51+
# Container for CSV/export
52+
$ExportRows = @()
53+
54+
Write-Host "`nProcessing users (this may take a while)..." -ForegroundColor Cyan
55+
$counter = 0
56+
57+
foreach ($User in $DisabledUsers) {
58+
$counter++
59+
if ($counter % 200 -eq 0) { Write-Host "Processed $counter users..." -ForegroundColor Cyan }
60+
61+
$licenseNames = @()
62+
$source = ""
63+
64+
# 1) Try licenseDetails (effective licenses)
65+
try {
66+
$ld = Get-MgUserLicenseDetail -UserId $User.Id -ErrorAction Stop
67+
} catch {
68+
$ld = @()
69+
}
70+
71+
if ($ld -and $ld.Count -gt 0) {
72+
$licenseNames = $ld | ForEach-Object { $_.SkuPartNumber } | Where-Object { $_ } | Select-Object -Unique
73+
$source = "licenseDetails"
74+
} else {
75+
# 2) Fallback: check group-based licensing via transitive memberOf
76+
try {
77+
$memberOf = Get-MgUserMemberOf -UserId $User.Id -All -ErrorAction Stop
78+
} catch {
79+
$memberOf = @()
80+
}
81+
82+
if ($memberOf -and $memberOf.Count -gt 0) {
83+
foreach ($m in $memberOf) {
84+
# Only inspect groups; memberOf can include directory roles, etc.
85+
$isGroup = $false
86+
if ($m.AdditionalProperties.ContainsKey('@odata.type')) {
87+
$odata = $m.AdditionalProperties['@odata.type']
88+
if ($odata -like '*group*') { $isGroup = $true }
89+
}
90+
if (-not $isGroup -and $m.PSObject.Properties.Match('DisplayName').Count -gt 0 -and $m.PSObject.Properties.Match('Id').Count -gt 0) {
91+
$isGroup = $true
92+
}
93+
94+
if ($isGroup) {
95+
$groupId = $m.Id
96+
if (-not $groupId) { continue }
97+
try {
98+
$g = Get-MgGroup -GroupId $groupId -Property "displayName,assignedLicenses" -ErrorAction Stop
99+
} catch {
100+
continue
101+
}
102+
$gAssigned = $null
103+
if ($g.AdditionalProperties.ContainsKey('assignedLicenses')) { $gAssigned = $g.AdditionalProperties['assignedLicenses'] }
104+
if ($gAssigned -and $gAssigned.Count -gt 0) {
105+
foreach ($gal in $gAssigned) {
106+
$resolved = $null
107+
if ($null -ne $gal.skuId) {
108+
$gid = $gal.skuId.ToString().Trim('{}')
109+
if ($guidToPart.ContainsKey($gid)) { $resolved = $guidToPart[$gid] }
110+
}
111+
if (-not $resolved -and $null -ne $gal.skuPartNumber) { $resolved = $gal.skuPartNumber }
112+
if (-not $resolved -and $null -ne $gal.accountSkuId) {
113+
if ($acctToPart.ContainsKey($gal.accountSkuId)) { $resolved = $acctToPart[$gal.accountSkuId] }
114+
else { $resolved = ($gal.accountSkuId -split ':')[-1] }
115+
}
116+
if (-not $resolved) { $resolved = ($gal | ConvertTo-Json -Compress) }
117+
if ($resolved) { $licenseNames += $resolved }
118+
}
119+
}
120+
}
121+
} # end foreach memberOf
122+
123+
if ($licenseNames.Count -gt 0) {
124+
$licenseNames = $licenseNames | Select-Object -Unique
125+
$source = "groupAssigned (memberOf)"
126+
}
127+
} # end if memberOf
128+
} # end fallback
129+
130+
# If we discovered license names, proceed (matches original behavior of only acting on users with licenses)
131+
if ($licenseNames.Count -gt 0) {
132+
# Prepare license ids for removal if needed (original removed AssignedLicenses.SkuId)
133+
# Try to get AssignedLicenses.SkuId from user object (may be empty for group-assigned)
134+
$assignedSkuIds = @()
135+
try {
136+
$uWithAssigned = Get-MgUser -UserId $User.Id -Property "assignedLicenses" -ErrorAction Stop
137+
if ($uWithAssigned.AdditionalProperties.ContainsKey('assignedLicenses')) {
138+
$als = $uWithAssigned.AdditionalProperties['assignedLicenses']
139+
foreach ($al in $als) {
140+
if ($al.skuId) { $assignedSkuIds += $al.skuId.ToString().Trim('{}') }
141+
}
142+
}
143+
} catch {
144+
# ignore
145+
}
146+
147+
# If no assignedSkuIds (group-assigned), we cannot remove via Set-MgUserLicense (removal of group assignment requires group change)
148+
if ($DryRun) {
149+
if ($assignedSkuIds.Count -gt 0) {
150+
Write-Host "[DRY RUN] Would remove $($assignedSkuIds.Count) license(s) from $($User.UserPrincipalName) (source: $source)" -ForegroundColor Yellow
151+
} else {
152+
Write-Host "[DRY RUN] User $($User.UserPrincipalName) has licenses via $source; no direct AssignedLicenses to remove (group-assigned)" -ForegroundColor Yellow
153+
}
154+
} else {
155+
if ($assignedSkuIds.Count -gt 0) {
156+
try {
157+
Set-MgUserLicense `
158+
-UserId $User.Id `
159+
-AddLicenses @() `
160+
-RemoveLicenses $assignedSkuIds
161+
Write-Host "Removed $($assignedSkuIds.Count) license(s) from $($User.UserPrincipalName)" -ForegroundColor Green
162+
} catch {
163+
Write-Host "Failed to remove licenses for $($User.UserPrincipalName): $($_.Exception.Message)" -ForegroundColor Red
164+
}
165+
} else {
166+
Write-Host "Skipping removal for $($User.UserPrincipalName) because licenses are group-assigned (modify group assignments instead)" -ForegroundColor Yellow
167+
}
168+
}
169+
170+
# Add row for CSV/export
171+
$ExportRows += [PSCustomObject]@{
172+
DisplayName = $User.DisplayName
173+
UserPrincipalName = $User.UserPrincipalName
174+
Source = $source
175+
LicenseNames = ,($licenseNames) # store as array for later expansion
176+
}
177+
}
178+
}
179+
180+
Write-Host "`nProcessing complete." -ForegroundColor Cyan
181+
182+
# Build CSV-ready objects with License1..N columns
183+
if ($ExportRows.Count -gt 0 -and $ExportCsv) {
184+
$max = ($ExportRows | ForEach-Object { $_.LicenseNames.Count } | Measure-Object -Maximum).Maximum
185+
if (-not $max) { $max = 0 }
186+
187+
$csvOutput = foreach ($r in $ExportRows) {
188+
$props = @{
189+
DisplayName = $r.DisplayName
190+
UserPrincipalName = $r.UserPrincipalName
191+
Source = $r.Source
192+
}
193+
for ($i = 0; $i -lt $max; $i++) {
194+
$col = "License$([int]($i+1))"
195+
$props[$col] = if ($i -lt $r.LicenseNames.Count) { $r.LicenseNames[$i] } else { "" }
196+
}
197+
[PSCustomObject]$props
198+
}
199+
200+
try {
201+
$csvOutput | Export-Csv -Path $ExportCsv -NoTypeInformation -Force
202+
Write-Host "`nExported results to ${ExportCsv}" -ForegroundColor Green
203+
} catch {
204+
Write-Host "Failed to export CSV to ${ExportCsv}: $($_.Exception.Message)" -ForegroundColor Red
205+
}
206+
} else {
207+
Write-Host "No license-bearing disabled users discovered or CSV export disabled." -ForegroundColor Yellow
208+
}
209+
210+
Write-Host "`nScript completed." -ForegroundColor Cyan

0 commit comments

Comments
 (0)