12 useful recipes, not a complete audit
A visible license does not always explain its assignment source or why a service fails to provision. These 12 PowerShell recipes connect users, SKUs, service plans, and groups. The first ten read data; the last two illustrate individual changes behind a disabled execution switch.
Run them in order in a dedicated session. All identifiers are fictional. This targeted check measures neither actual service usage nor overall Microsoft 365 security and does not replace a complete assessment.
Prepare PowerShell, identity, and permissions
Use stable Microsoft Graph modules with v1.0 APIs, not legacy AzureAD/MSOnline. PowerShell 7 is recommended. Install submodules in an approved setup step, pin tested versions, and record loaded versions; automatically updating modules is not a collection prerequisite.
This is an interactive delegated workflow: named administrative account, MFA, and correct tenant. OAuth scopes, admin consent, and Entra roles are separate controls. Start with necessary reads. Directory Readers is supported for SKU and license-detail reads; some operations require specific roles.
# PowerShell 7; approved, tested module versions
Import-Module Microsoft.Graph.Authentication
Import-Module Microsoft.Graph.Users
Import-Module Microsoft.Graph.Users.Actions
Import-Module Microsoft.Graph.Groups
Import-Module Microsoft.Graph.Identity.DirectoryManagement
Get-Module Microsoft.Graph.* | Select-Object Name,Version
$tenantId = '33333333-3333-3333-3333-333333333333' # fictional tenant
Connect-MgGraph -TenantId $tenantId -Scopes 'User.Read.All','LicenseAssignment.Read.All' -ContextScope Process
Get-MgContext | Select-Object TenantId,Account,AuthType,Scopes
# Only for checks 8–10, reconnect with approved group read scopes:
# Connect-MgGraph -TenantId $tenantId -Scopes 'User.Read.All','LicenseAssignment.Read.All','Group.Read.All','GroupMember.Read.All' -ContextScope Process1. Inventory users with useful properties
Delegated permission : User.Read.All.
-All follows pages; -Property requests fields absent by default. Large tenants need planned collection and throttling recovery. Inventory is a snapshot, not an activity log.
$users = @(Get-MgUser -All -Property Id,DisplayName,UserPrincipalName,AccountEnabled,UsageLocation,UserType,AssignedLicenses,LicenseAssignmentStates -ErrorAction Stop)
$users | Select-Object Id,DisplayName,UserPrincipalName,AccountEnabled,UsageLocation,UserType2. Inspect one precise pilot account
Delegated permission : User.Read.All.
Prefer object ID to avoid ambiguity after renaming. Confirm UPN and owner before changing anything. A field omitted from -Property cannot be assumed empty in Entra.
$userId = '11111111-1111-1111-1111-111111111111' # fictional OBJECT ID
$user = Get-MgUser -UserId $userId -Property Id,DisplayName,UserPrincipalName,AccountEnabled,UsageLocation,AssignedLicenses,LicenseAssignmentStates -ErrorAction Stop
$user | Select-Object Id,DisplayName,UserPrincipalName,AccountEnabled,UsageLocation,AssignedLicenses3. Find disabled accounts with assigned licenses
Delegated permission : User.Read.All.
This local selection reuses check 1. Disabled users may need licenses for data, retention, or business handover. Validate the requirement; never remove licenses based solely on this filter.
$users | Where-Object { $_.AccountEnabled -eq $false -and @($_.AssignedLicenses).Count -gt 0 } |
Select-Object Id,UserPrincipalName,UsageLocation,
@{Name='SkuIds';Expression={($_.AssignedLicenses.SkuId -join ';')}}4. List subscribed SKUs and counters
Delegated permission : LicenseAssignment.Read.All.
Use SkuId for operations and SkuPartNumber for identification; verify commercial names in Microsoft's mapping. Counters do not replace contract terms or reserve capacity. Retain subscription status.
$skus = @(Get-MgSubscribedSku -All -ErrorAction Stop)
$skus | Select-Object SkuId,SkuPartNumber,CapabilityStatus,ConsumedUnits,
@{Name='EnabledUnits';Expression={$_.PrepaidUnits.Enabled}}5. Read a user's license and service-plan details
Delegated permission : LicenseAssignment.Read.All.
Inspect each plan's ProvisioningStatus. A present license does not imply every service is available. This endpoint is used in delegated mode here; do not assume app-only support. Directory Readers is among supported roles for this read.
Get-MgUserLicenseDetail -UserId $userId -All -ErrorAction Stop |
Select-Object SkuId,SkuPartNumber,ServicePlans |
ConvertTo-Json -Depth 106. Separate direct and group assignment
Delegated permission : User.Read.All.
Null AssignedByGroup indicates direct assignment; otherwise resolve the group ID. One SKU can have several assignment paths. AssignedLicenses alone does not identify the source or policy needing correction.
$user.LicenseAssignmentStates |
Select-Object SkuId,AssignedByGroup,State,Error,DisabledPlans,LastUpdatedDateTime7. Isolate assignment errors
Delegated permission : User.Read.All.
Correlate state and code: capacity, plan dependencies, incompatible offers, or usageLocation may matter. Do not repeatedly reassign without fixing the cause. No snapshot error does not prove application availability.
foreach ($u in $users) {
foreach ($a in $u.LicenseAssignmentStates) {
if ($a.State -in @('Error','ActiveWithError') -or
($a.Error -and $a.Error -ne 'None')) {
[pscustomobject]@{
UserId=$u.Id; UPN=$u.UserPrincipalName; SkuId=$a.SkuId
GroupId=$a.AssignedByGroup; State=$a.State; Error=$a.Error
}
}
}
}8. Inventory groups carrying licenses
Delegated permission : Group.Read.All.
The group may be the correct remediation unit. A change may affect every member. Check dynamic rules, disabled plans, and licensing requirements for group-based licensing and dynamic membership; Graph output alone does not establish entitlement.
$groups = @(Get-MgGroup -All -Property Id,DisplayName,GroupTypes,AssignedLicenses,MembershipRule,MembershipRuleProcessingState -ErrorAction Stop)
$groups | Where-Object { @($_.AssignedLicenses).Count -gt 0 } |
Select-Object Id,DisplayName,GroupTypes,AssignedLicenses,MembershipRule,MembershipRuleProcessingState |
ConvertTo-Json -Depth 109. List a pilot group's direct members
Delegated permission : GroupMember.Read.All.
Results may contain multiple object types. These are direct members; do not assume transitive expansion or licensing through nested groups. Hidden membership requires additional justified permission.
$groupId = '22222222-2222-2222-2222-222222222222' # fictional group ID
Get-MgGroupMember -GroupId $groupId -All -ErrorAction Stop |
Select-Object Id,@{Name='Type';Expression={$_.AdditionalProperties['@odata.type']}}10. List the pilot user's direct memberships
Delegated permission : User.Read.All.
Objects may include groups and directory roles. Resolve only needed IDs with appropriate permission; limited details do not mean the object is absent. Compare groups against check 6 sources before removing direct assignments.
Get-MgUserMemberOf -UserId $userId -All -ErrorAction Stop |
Select-Object Id,@{Name='Type';Expression={$_.AdditionalProperties['@odata.type']}}Before changes: reduce the blast radius
Obtain business-owner approval, confirm license entitlements, and choose one nonprivileged pilot user. Export object ID, location, SKUs, DisabledPlans, and every assignment source to a restricted location. Exports contain personal data: restrict access and define retention.
Reading Graph does not grant product entitlements. Check offer and group-licensing conditions, especially for dynamic groups. Inherited licensing is asynchronous; nested groups do not distribute licenses to indirect members. Do not widen permissions merely to bypass a 403.
11. Correct the pilot's UsageLocation
Explicit change: User.ReadWrite.All and a role authorized to update this user, such as User Administrator for this nonadmin pilot. UsageLocation is a two-letter ISO country code, not an address or language preference. Confirm the applicable country; CA is only an example.
The example rejects an initially missing value: this property is not nullable, so setting it back to null is not a valid rollback. The false switch only displays the proposal. Preserve the previous value outside the session before authorizing the write.
# CHANGE: existing location correction on a non-admin pilot
# Separate approved write session; refresh check 2 first
Connect-MgGraph -TenantId $tenantId -Scopes 'User.ReadWrite.All' -ContextScope Process
$before = Get-MgUser -UserId $userId -Property Id,UserPrincipalName,UsageLocation -ErrorAction Stop
if ([string]::IsNullOrWhiteSpace($before.UsageLocation)) {
throw 'Missing previous location: define an initial-population recovery plan first.'
}
$previousLocation = $before.UsageLocation
$applyLocation = $false
[pscustomobject]@{User=$before.UserPrincipalName; Before=$previousLocation; After='CA'}
if ($applyLocation) {
Update-MgUser -UserId $userId -UsageLocation 'CA' -ErrorAction Stop
Get-MgUser -UserId $userId -Property Id,UserPrincipalName,UsageLocation -ErrorAction Stop
}
# Approved rollback: Update-MgUser -UserId $userId -UsageLocation $previousLocation12. Add a new direct license to the pilot
Explicit change: LicenseAssignment.ReadWrite.All, plus User.Read.All for the reread, and an authorized License Administrator role. Choose a real SkuId from check 4; verify capacity, dependencies, country, and business need. The guard rejects an existing SKU regardless of assignment source.
Empty DisabledPlans enables all available plans in this assignment; approve that selection before switching to true. Empty RemoveLicenses removes no licenses. HTTP success does not guarantee completed provisioning: repeat checks 5, 6, and 7 and test the intended service.
# CHANGE: new direct assignment, never a bulk removal
Connect-MgGraph -TenantId $tenantId -Scopes 'User.Read.All','LicenseAssignment.ReadWrite.All' -ContextScope Process
$skuId = [guid]'44444444-4444-4444-4444-444444444444' # replace from check 4
$baseline = Get-MgUser -UserId $userId -Property Id,UserPrincipalName,UsageLocation,AssignedLicenses,LicenseAssignmentStates -ErrorAction Stop
if ([string]::IsNullOrWhiteSpace($baseline.UsageLocation)) { throw 'Confirm usage location first.' }
if (@($baseline.AssignedLicenses | Where-Object { $_.SkuId -eq $skuId }).Count -gt 0) {
throw 'SKU already assigned: inspect source and plans; do not overwrite.'
}
$applyLicense = $false
$approvedDisabledPlans = @() # explicitly enables all available plans: review first
[pscustomobject]@{User=$baseline.UserPrincipalName; SkuId=$skuId; DisabledPlans=$approvedDisabledPlans}
if ($applyLicense) {
Set-MgUserLicense -UserId $userId -AddLicenses @(@{
SkuId=$skuId; DisabledPlans=$approvedDisabledPlans
}) -RemoveLicenses @() -ErrorAction Stop
}Validate, troubleshoot, and roll back
- Recollect the pilot after processing. Compare assignment sources and plans with baseline, then validate sign-in and the intended service with its owner.
- For 403, separate consent, scope, role, and administrative scope. For 404, check object ID and tenant. For 429, honor Retry-After and space collection; do not run aggressive loops.
- For licensing errors, inspect capacity, plan dependencies, and geographic availability. Record outcomes and operational timestamps without exposing data in public tickets.
- Location rollback: restore the recorded value if still correct. License rollback: consider removing only the new direct assignment created by this pilot, after business and data-retention validation. No automatic removal is provided.
- Manage inherited assignments at their source; group changes can affect every member. Reassigning a license does not restore already deleted data.
- Finish with Disconnect-MgGraph. This closes the session but does not revoke previously granted OAuth consent. Review unnecessary permissions separately.
Turn collection into an action plan
A useful result has four columns: observation, impact, owner, next validation. For example, a pilot capacity error becomes an entitlement and business-need review, not license removal elsewhere. Microsoft 365 consulting can help standardize permissions, groups, and account lifecycle beyond these commands.