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 " `n Fetching 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 " `n Processing 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 " `n Processing 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 " `n Exported 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 " `n Script completed." - ForegroundColor Cyan
0 commit comments