1+ <#
2+ Group-aware disabled-user license exporter.
3+ Run in the SAME PowerShell session where you already ran Connect-MgGraph.
4+ Something like: Connect-MgGraph -Scopes "User.Read.All","Directory.Read.All" -UseDeviceAuthentication
5+ works great for me.
6+ Usage: pwsh -NoProfile .\Get-DisabledUsers-Licenses-GroupAware.ps1 -ExportCsv .\disabled_licenses.csv
7+ #>
8+
9+ param (
10+ [string ]$ExportCsv = " .\disabled_licenses.csv"
11+ )
12+
13+ function Info { param ($m ) Write-Host $m - ForegroundColor Cyan }
14+ function Warn { param ($m ) Write-Host $m - ForegroundColor Yellow }
15+ function Err { param ($m ) Write-Host $m - ForegroundColor Red }
16+
17+ # Require existing Graph context (reuse same session)
18+ if (-not (Get-MgContext - ErrorAction SilentlyContinue)) {
19+ Err " No Graph context found in this session. Run Connect-MgGraph -Scopes <scopes> first in this window."
20+ return
21+ }
22+
23+ Info " Building SKU maps (SkuId/AccountSkuId -> SkuPartNumber)..."
24+ $guidToPart = @ {}
25+ $acctToPart = @ {}
26+ try {
27+ $skus = Get-MgSubscribedSku - All - ErrorAction Stop
28+ foreach ($s in $skus ) {
29+ if ($s.SkuId ) { $guidToPart [$s.SkuId.Guid.ToString ().Trim (' {}' )] = $s.SkuPartNumber }
30+ if ($s.AccountSkuId ) { $acctToPart [$s.AccountSkuId ] = $s.SkuPartNumber }
31+ if ($s.SkuPartNumber ) {
32+ $guidToPart [$s.SkuPartNumber ] = $s.SkuPartNumber
33+ $acctToPart [$s.SkuPartNumber ] = $s.SkuPartNumber
34+ }
35+ }
36+ Info (" Subscribed SKUs loaded: {0}" -f $skus.Count )
37+ } catch {
38+ Warn " Could not enumerate subscribed SKUs: $ ( $_.Exception.Message ) . Name resolution may be limited."
39+ }
40+
41+ Info " Enumerating disabled users..."
42+ $users = Get-MgUser - Filter " accountEnabled eq false" - Property " displayName,userPrincipalName,id" - All - ErrorAction Stop
43+ Info (" Total disabled users found: {0}" -f $users.Count )
44+
45+ $results = @ ()
46+ $counter = 0
47+
48+ foreach ($u in $users ) {
49+ $counter ++
50+ if ($counter % 200 -eq 0 ) { Info " Processed $counter users..." }
51+
52+ $licenseNames = @ ()
53+ $source = " "
54+
55+ # 1) Try licenseDetails (effective licenses)
56+ try {
57+ $ld = Get-MgUserLicenseDetail - UserId $u.Id - ErrorAction Stop
58+ } catch {
59+ $ld = @ ()
60+ }
61+
62+ if ($ld -and $ld.Count -gt 0 ) {
63+ $licenseNames = $ld | ForEach-Object { $_.SkuPartNumber } | Where-Object { $_ } | Select-Object - Unique
64+ $source = " licenseDetails"
65+ } else {
66+ # 2) Fallback: check group-based licensing
67+ # Get groups the user is a member of (transitive membership)
68+ try {
69+ $memberOf = Get-MgUserMemberOf - UserId $u.Id - All - ErrorAction Stop
70+ } catch {
71+ $memberOf = @ ()
72+ }
73+
74+ if ($memberOf -and $memberOf.Count -gt 0 ) {
75+ # For each group object, if it's a group, get group's assignedLicenses
76+ foreach ($m in $memberOf ) {
77+ # memberOf returns directoryObject shapes; check objectType or @odata.type
78+ $isGroup = $false
79+ if ($m.AdditionalProperties.ContainsKey (' @odata.type' )) {
80+ $odata = $m.AdditionalProperties [' @odata.type' ]
81+ if ($odata -like ' *group*' ) { $isGroup = $true }
82+ }
83+ # fallback: check if object has 'displayName' and 'group' like properties
84+ if (-not $isGroup -and $m.PSObject.Properties.Match (' DisplayName' ).Count -gt 0 -and $m.PSObject.Properties.Match (' Id' ).Count -gt 0 ) {
85+ # treat as group candidate
86+ $isGroup = $true
87+ }
88+
89+ if ($isGroup ) {
90+ $groupId = $m.Id
91+ if (-not $groupId ) { continue }
92+ try {
93+ $g = Get-MgGroup - GroupId $groupId - Property " displayName,assignedLicenses" - ErrorAction Stop
94+ } catch {
95+ continue
96+ }
97+ $gAssigned = $null
98+ if ($g.AdditionalProperties.ContainsKey (' assignedLicenses' )) { $gAssigned = $g.AdditionalProperties [' assignedLicenses' ] }
99+ if ($gAssigned -and $gAssigned.Count -gt 0 ) {
100+ foreach ($gal in $gAssigned ) {
101+ $resolved = $null
102+ if ($null -ne $gal.skuId ) {
103+ $gid = $gal.skuId.ToString ().Trim(' {}' )
104+ if ($guidToPart.ContainsKey ($gid )) { $resolved = $guidToPart [$gid ] }
105+ }
106+ if (-not $resolved -and $null -ne $gal.skuPartNumber ) { $resolved = $gal.skuPartNumber }
107+ if (-not $resolved -and $null -ne $gal.accountSkuId ) {
108+ if ($acctToPart.ContainsKey ($gal.accountSkuId )) { $resolved = $acctToPart [$gal.accountSkuId ] }
109+ else { $resolved = ($gal.accountSkuId -split ' :' )[-1 ] }
110+ }
111+ if (-not $resolved ) { $resolved = ($gal | ConvertTo-Json - Compress) }
112+ if ($resolved ) { $licenseNames += $resolved }
113+ }
114+ }
115+ }
116+ } # end foreach memberOf
117+
118+ if ($licenseNames.Count -gt 0 ) {
119+ $licenseNames = $licenseNames | Select-Object - Unique
120+ $source = " groupAssigned (memberOf)"
121+ }
122+ } # end if memberOf
123+ } # end fallback
124+
125+ if ($licenseNames.Count -gt 0 ) {
126+ $results += [PSCustomObject ]@ {
127+ DisplayName = $u.DisplayName
128+ UserPrincipalName = $u.UserPrincipalName
129+ LicenseNames = , $licenseNames
130+ Source = $source
131+ }
132+ }
133+ }
134+
135+ Info (" Total disabled users with discovered licenses: {0}" -f $results.Count )
136+
137+ if ($results.Count -eq 0 ) {
138+ Warn " No licenses discovered via licenseDetails or group assignedLicenses. If the original script still shows users, run it now and capture one UPN it reports; then re-run this script for that UPN specifically so we can compare."
139+ return
140+ }
141+
142+ # Build columns License1..N
143+ $max = ($results | ForEach-Object { $_.LicenseNames.Count } | Measure-Object - Maximum).Maximum
144+ if (-not $max ) { $max = 0 }
145+
146+ $output = foreach ($r in $results ) {
147+ $props = @ {
148+ DisplayName = $r.DisplayName
149+ UserPrincipalName = $r.UserPrincipalName
150+ Source = $r.Source
151+ }
152+ for ($i = 0 ; $i -lt $max ; $i ++ ) {
153+ $col = " License$ ( [int ]($i + 1 )) "
154+ $props [$col ] = if ($i -lt $r.LicenseNames.Count ) { $r.LicenseNames [$i ] } else { " " }
155+ }
156+ [PSCustomObject ]$props
157+ }
158+
159+ # Print sample and export CSV
160+ $output | Select-Object - First 20 | Format-Table - AutoSize
161+
162+ if ($ExportCsv ) {
163+ try {
164+ $output | Export-Csv - Path $ExportCsv - NoTypeInformation - Force
165+ Info " `n Exported results to $ExportCsv "
166+ } catch {
167+ Warn " Failed to export CSV: $ ( $_.Exception.Message ) "
168+ }
169+ }
170+
171+ Info " `n Done."
0 commit comments