Skip to content

Commit f4c6150

Browse files
authored
Add files via upload
1 parent 8333df3 commit f4c6150

1 file changed

Lines changed: 173 additions & 0 deletions

File tree

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
#requires -Version 7.0
2+
<#
3+
.SYNOPSIS
4+
Reports disabled Microsoft Entra users who still have assigned licenses.
5+
6+
.DESCRIPTION
7+
Read-only Microsoft Graph audit. Reuses the Microsoft Graph session already connected
8+
in the current PowerShell process and never calls Connect-MgGraph or requests new scopes.
9+
Uses User.Read.All and Directory.Read.All when those permissions are already present.
10+
Exports one row per disabled user and license SKU, with direct/group assignment details
11+
when Microsoft Graph returns licenseAssignmentStates.
12+
13+
.PARAMETER ExportCsv
14+
Detailed CSV output path.
15+
16+
.PARAMETER SummaryCsv
17+
Optional per-user summary CSV path.
18+
19+
.PARAMETER IncludeGuests
20+
Include disabled guest users. Disabled Member users are reported by default.
21+
22+
.EXAMPLE
23+
Connect-MgGraph -Scopes "User.Read.All","Directory.Read.All"
24+
./Get-DisabledUsersLicenses-NoNewConsent.ps1 -ExportCsv ./disabled_licenses.csv
25+
#>
26+
27+
[CmdletBinding()]
28+
param(
29+
[string]$ExportCsv = (Join-Path $PWD 'disabled_licenses.csv'),
30+
[string]$SummaryCsv,
31+
[switch]$IncludeGuests
32+
)
33+
34+
Set-StrictMode -Version Latest
35+
$ErrorActionPreference = 'Stop'
36+
37+
foreach ($commandName in @('Get-MgContext','Get-MgUser','Get-MgSubscribedSku','Get-MgGroup')) {
38+
if (-not (Get-Command $commandName -ErrorAction SilentlyContinue)) {
39+
throw "Required command '$commandName' was not found. Install the relevant Microsoft Graph PowerShell modules."
40+
}
41+
}
42+
43+
$context = Get-MgContext -ErrorAction SilentlyContinue
44+
if ($null -eq $context) {
45+
throw 'No Microsoft Graph context exists in this PowerShell process. Connect-MgGraph first using permissions already approved in your tenant, then rerun this script in the same window.'
46+
}
47+
48+
$currentScopes = @($context.Scopes)
49+
$missingScopes = @(@('User.Read.All','Directory.Read.All') | Where-Object { $_ -notin $currentScopes })
50+
if (@($missingScopes).Count -gt 0) {
51+
throw "The current Graph session is missing: $($missingScopes -join ', '). This script will not request new permissions. Reconnect using permissions already approved in your tenant."
52+
}
53+
54+
Write-Host "Reusing Microsoft Graph session for $($context.Account). No new consent request will be made." -ForegroundColor Cyan
55+
56+
$skuMap = @{}
57+
try {
58+
foreach ($sku in @(Get-MgSubscribedSku -All -Property SkuId,SkuPartNumber -ErrorAction Stop)) {
59+
$skuId = [string]$sku.SkuId
60+
if (-not [string]::IsNullOrWhiteSpace($skuId)) {
61+
$skuMap[$skuId.ToLowerInvariant()] = [string]$sku.SkuPartNumber
62+
}
63+
}
64+
Write-Host "Subscribed SKUs resolved: $($skuMap.Count)" -ForegroundColor DarkGray
65+
}
66+
catch {
67+
Write-Warning "Could not enumerate subscribed SKUs. GUIDs will be retained: $($_.Exception.Message)"
68+
}
69+
70+
$filter = if ($IncludeGuests) { 'accountEnabled eq false' } else { "accountEnabled eq false and userType eq 'Member'" }
71+
$users = @(
72+
Get-MgUser -Filter $filter -All `
73+
-Property Id,DisplayName,UserPrincipalName,Mail,AccountEnabled,UserType,AssignedLicenses,LicenseAssignmentStates,OnPremisesSyncEnabled `
74+
-ErrorAction Stop
75+
)
76+
Write-Host "Disabled users found: $($users.Count)" -ForegroundColor Cyan
77+
78+
$groupCache = @{}
79+
function Resolve-GroupName {
80+
param([string]$GroupId)
81+
if ([string]::IsNullOrWhiteSpace($GroupId)) { return $null }
82+
if ($groupCache.ContainsKey($GroupId)) { return $groupCache[$GroupId] }
83+
try {
84+
$groupCache[$GroupId] = [string](Get-MgGroup -GroupId $GroupId -Property DisplayName -ErrorAction Stop).DisplayName
85+
}
86+
catch {
87+
$groupCache[$GroupId] = '[Unresolved group]'
88+
}
89+
return $groupCache[$GroupId]
90+
}
91+
92+
$details = [System.Collections.Generic.List[object]]::new()
93+
$counter = 0
94+
foreach ($user in $users) {
95+
$counter++
96+
if ($counter % 200 -eq 0) { Write-Host "Processed $counter users..." -ForegroundColor DarkGray }
97+
98+
$states = @($user.LicenseAssignmentStates)
99+
if ($states.Count -eq 0) {
100+
$states = @(
101+
foreach ($license in @($user.AssignedLicenses)) {
102+
[pscustomobject]@{
103+
SkuId = $license.SkuId
104+
AssignedByGroup = $null
105+
State = 'Unknown'
106+
Error = $null
107+
LastUpdatedDateTime = $null
108+
}
109+
}
110+
)
111+
}
112+
113+
foreach ($state in $states) {
114+
$skuId = [string]$state.SkuId
115+
if ([string]::IsNullOrWhiteSpace($skuId)) { continue }
116+
$skuKey = $skuId.ToLowerInvariant()
117+
$groupId = [string]$state.AssignedByGroup
118+
$assignmentType = if ([string]::IsNullOrWhiteSpace($groupId)) { 'Direct' } else { 'Group' }
119+
120+
$details.Add([pscustomobject]@{
121+
DisplayName = $user.DisplayName
122+
UserPrincipalName = $user.UserPrincipalName
123+
Mail = $user.Mail
124+
UserId = $user.Id
125+
UserType = $user.UserType
126+
AccountEnabled = $user.AccountEnabled
127+
OnPremisesSyncEnabled = $user.OnPremisesSyncEnabled
128+
SkuPartNumber = if ($skuMap.ContainsKey($skuKey)) { $skuMap[$skuKey] } else { '[Unknown SKU]' }
129+
SkuId = $skuId
130+
AssignmentType = $assignmentType
131+
AssignedByGroupId = if ($assignmentType -eq 'Group') { $groupId } else { $null }
132+
AssignedByGroupName = if ($assignmentType -eq 'Group') { Resolve-GroupName $groupId } else { $null }
133+
AssignmentState = [string]$state.State
134+
AssignmentError = [string]$state.Error
135+
LastUpdatedDateTime = $state.LastUpdatedDateTime
136+
})
137+
}
138+
}
139+
140+
$output = @($details | Sort-Object UserPrincipalName,SkuPartNumber,AssignmentType,AssignedByGroupName)
141+
$parent = Split-Path -Parent $ExportCsv
142+
if ($parent) { New-Item -Path $parent -ItemType Directory -Force | Out-Null }
143+
$output | Export-Csv -Path $ExportCsv -NoTypeInformation -Encoding utf8
144+
145+
$summary = @(
146+
$output | Group-Object UserId | ForEach-Object {
147+
$rows = @($_.Group)
148+
[pscustomobject]@{
149+
DisplayName = $rows[0].DisplayName
150+
UserPrincipalName = $rows[0].UserPrincipalName
151+
UserId = $rows[0].UserId
152+
LicenseSkuCount = @($rows.SkuId | Sort-Object -Unique).Count
153+
LicenseSkus = @($rows.SkuPartNumber | Sort-Object -Unique) -join ';'
154+
HasDirectAssignment = @($rows | Where-Object AssignmentType -eq 'Direct').Count -gt 0
155+
HasGroupAssignment = @($rows | Where-Object AssignmentType -eq 'Group').Count -gt 0
156+
AssigningGroups = @($rows.AssignedByGroupName | Where-Object { $_ } | Sort-Object -Unique) -join ';'
157+
}
158+
} | Sort-Object UserPrincipalName
159+
)
160+
161+
if ($SummaryCsv) {
162+
$summaryParent = Split-Path -Parent $SummaryCsv
163+
if ($summaryParent) { New-Item -Path $summaryParent -ItemType Directory -Force | Out-Null }
164+
$summary | Export-Csv -Path $SummaryCsv -NoTypeInformation -Encoding utf8
165+
}
166+
167+
Write-Host ''
168+
Write-Host "Disabled users evaluated : $(@($users).Count)"
169+
Write-Host "Disabled users with licenses : $(@($summary).Count)" -ForegroundColor $(if (@($summary).Count -gt 0) { 'Yellow' } else { 'Green' })
170+
Write-Host "License assignment rows : $(@($output).Count)"
171+
Write-Host "Detailed report : $ExportCsv" -ForegroundColor Cyan
172+
if ($SummaryCsv) { Write-Host "Summary report : $SummaryCsv" -ForegroundColor Cyan }
173+
Write-Host 'Read-only audit complete. No directory or licensing changes were made.' -ForegroundColor Green

0 commit comments

Comments
 (0)