Une licence sans activité récente n’est pas automatiquement inutile
Une licence peut protéger une boîte conservée, donner accès à une application rarement utilisée, soutenir un compte en congé, une salle, un appareil partagé ou une exigence de conformité. À l’inverse, un compte désactivé peut encore consommer plusieurs produits. La décision doit croiser l’attribution, sa source, l’activité disponible, le propriétaire et les dépendances métier.
Les dix collectes suivantes sont en lecture seule. Elles produisent une liste de candidats à réviser, pas une autorisation de retrait. Les identités et domaines sont fictifs; utilisez un poste d’administration protégé et un dossier de sortie contrôlé.
Préparer Microsoft Graph avec les permissions minimales
User.Read.All lit les utilisateurs et leurs licences, Organization.Read.All les abonnements, Group.Read.All les groupes, Reports.Read.All les rapports d’usage et AuditLog.Read.All la propriété signInActivity. Cette dernière exige aussi Microsoft Entra ID P1 ou P2.
Installez les modules Microsoft.Graph selon votre processus de gestion des postes. Commencez sans LicenseAssignment.ReadWrite.All : aucun des dix diagnostics n’en a besoin. Le rôle Entra de l’opérateur doit aussi autoriser les lectures.
$scopes = @(
'User.Read.All'
'Organization.Read.All'
'Group.Read.All'
'Reports.Read.All'
'AuditLog.Read.All'
)
Connect-MgGraph -Scopes $scopes -NoWelcome
Get-MgContext | Select-Object TenantId,Account,AuthType,Scopes1. Mesurer la capacité disponible par SKU
SkuPartNumber est plus lisible que SkuId, mais ce n’est pas un nom commercial garanti. Les unités actives, en avertissement et consommées montrent la pression sur un SKU; elles n’indiquent pas qui peut le perdre.
Get-MgSubscribedSku -All | ForEach-Object {
[pscustomobject]@{
SkuPartNumber = $_.SkuPartNumber
SkuId = $_.SkuId
Active = $_.PrepaidUnits.Enabled
Warning = $_.PrepaidUnits.Warning
Consumed = $_.ConsumedUnits
Available = $_.PrepaidUnits.Enabled - $_.PrepaidUnits.Warning - $_.ConsumedUnits
}
} | Sort-Object SkuPartNumber2. Inventorier tous les membres licenciés
Le filtre avancé exige ConsistencyLevel eventual. Exclure les invités réduit le bruit, mais ne décidez rien sur un compte synchronisé, une ressource ou un utilisateur de service sans confirmer son usage.
$licensedUsers = Get-MgUser -All -Filter "assignedLicenses/$count ne 0 and userType eq 'Member'" -ConsistencyLevel eventual -Property Id,DisplayName,UserPrincipalName,AccountEnabled,UserType,AssignedLicenses
$licensedUsers | Select-Object DisplayName,UserPrincipalName,AccountEnabled,
@{n='LicenseCount';e={$_.AssignedLicenses.Count}}3. Isoler les comptes désactivés encore licenciés
C’est une file de révision, jamais une liste de nettoyage automatique. Confirmez départ, conservation de boîte, OneDrive, eDiscovery, applications, appareils et délégations.
$disabledLicensed = $licensedUsers |
Where-Object AccountEnabled -eq $false |
Select-Object Id,DisplayName,UserPrincipalName,
@{n='LicenseCount';e={$_.AssignedLicenses.Count}}
$disabledLicensed | Sort-Object UserPrincipalName4. Traduire les SKU en noms exploitables
Cette jointure permet de discuter d’un produit plutôt que d’un GUID. Une même capacité peut provenir de plusieurs SKU; évitez de compter une fonction comme plusieurs licences récupérables.
$skuMap = @{}
Get-MgSubscribedSku -All | ForEach-Object { $skuMap[$_.SkuId.Guid] = $_.SkuPartNumber }
$licenseInventory = foreach ($user in $licensedUsers) {
foreach ($license in $user.AssignedLicenses) {
[pscustomobject]@{
UserPrincipalName = $user.UserPrincipalName
AccountEnabled = $user.AccountEnabled
SkuId = $license.SkuId
SkuPartNumber = $skuMap[$license.SkuId.Guid]
}
}
}
$licenseInventory | Sort-Object SkuPartNumber,UserPrincipalName5. Vérifier les plans de service d’un candidat
Get-MgUserLicenseDetail montre les plans et leurs états. Un plan désactivé ne prouve pas que tout le SKU est superflu; comparez les autres plans et les fonctions réellement requises.
$candidate = 'alex.tremblay@contoso.example'
Get-MgUserLicenseDetail -UserId $candidate -All |
Select-Object SkuPartNumber,SkuId,
@{n='ServicePlans';e={($_.ServicePlans | ForEach-Object {
"$($_.ServicePlanName):$($_.ProvisioningStatus)"
}) -join '; '}}6. Distinguer attribution directe et héritée
AssignedByGroup vide indique normalement une attribution directe; un GUID indique le groupe source. Un utilisateur peut recevoir le même SKU de plusieurs sources. Retirer directement une licence héritée ne corrige pas l’appartenance qui la réattribuera.
$assignmentStates = foreach ($user in $licensedUsers) {
$detail = Get-MgUser -UserId $user.Id -Property UserPrincipalName,LicenseAssignmentStates
foreach ($state in $detail.LicenseAssignmentStates) {
[pscustomobject]@{
UserPrincipalName = $detail.UserPrincipalName
SkuPartNumber = $skuMap[$state.SkuId.Guid]
Source = if ($state.AssignedByGroup) { "Group: $($state.AssignedByGroup)" } else { 'Direct' }
State = $state.State
Error = $state.Error
}
}
}
$assignmentStates | Sort-Object UserPrincipalName,SkuPartNumber7. Inventorier les groupes qui attribuent des licences
Les licences de groupe simplifient la gouvernance, mais un groupe trop large peut gaspiller des sièges. Les groupes imbriqués ne transmettent pas les licences aux membres des sous-groupes; validez les membres directs et le processus RH qui les gère.
$licensedGroups = Get-MgGroup -All -Property Id,DisplayName,GroupTypes,AssignedLicenses |
Where-Object { $_.AssignedLicenses.Count -gt 0 }
$licensedGroups | ForEach-Object {
[pscustomobject]@{
GroupName = $_.DisplayName
GroupId = $_.Id
AssignedSkus = ($_.AssignedLicenses | ForEach-Object {
$skuMap[$_.SkuId.Guid]
}) -join ', '
}
}8. Relever les erreurs avant d’optimiser
Une erreur peut signaler un manque de licences, une localisation d’usage absente ou des plans incompatibles. Ce n’est ni une licence récupérable ni une preuve d’inactivité; corrigez la cause ou documentez l’exception.
$licenseErrors = $assignmentStates |
Where-Object { $_.State -ne 'Active' -or $_.Error }
$licenseErrors |
Select-Object UserPrincipalName,SkuPartNumber,Source,State,Error |
Sort-Object SkuPartNumber,UserPrincipalName9. Exporter l’activité Microsoft 365 disponible
Le rapport D180 couvre au plus 180 jours et ses champs varient selon les produits. La dernière activité représente une action volontaire observée par le service, pas une mesure universelle de valeur. Les identités sont masquées par défaut dans plusieurs tenants; ne changez ce réglage de confidentialité que si la gouvernance l’autorise.
$reportPath = Join-Path $env:TEMP 'M365-ActiveUsers-D180.csv'
Get-MgReportOffice365ActiveUserDetail -Period D180 -OutFile $reportPath
$usage = Import-Csv $reportPath
$usage | Select-Object 'User Principal Name','Assigned Products',
'Exchange Last Activity Date','OneDrive Last Activity Date',
'SharePoint Last Activity Date','Teams Last Activity Date'10. Comparer la dernière connexion réussie Entra
LastSuccessfulSignInDateTime inclut les connexions interactives et non interactives réussies, mais les données ne sont pas rétroalimentées avant le 1er décembre 2023. Une valeur vide peut signifier jamais utilisé ou donnée indisponible. La propriété exige Entra ID P1/P2 et AuditLog.Read.All; sa mise à jour peut être retardée.
$signIns = Get-MgUser -All -Filter 'assignedLicenses/$count ne 0' -ConsistencyLevel eventual -Property Id,DisplayName,UserPrincipalName,AccountEnabled,SignInActivity
$signIns | Select-Object DisplayName,UserPrincipalName,AccountEnabled,
@{n='LastSuccessfulSignInUtc';e={$_.SignInActivity.LastSuccessfulSignInDateTime}} |
Sort-Object LastSuccessfulSignInUtcConstruire une liste de révision, pas un robot de suppression
Exportez un inventaire horodaté, puis ajoutez propriétaire, décision, justification et échéance. Faites approuver chaque cas par le responsable métier et les propriétaires de conservation ou de sécurité. L’absence d’activité ne doit jamais être le seul critère.
| Signal | Lecture prudente | Validation requise |
|---|---|---|
| Compte désactivé + licence directe | Candidat prioritaire | Départ, conservation, boîte, OneDrive, applications |
| Aucune activité D180 | Activité non observée dans la fenêtre | Gestionnaire, saisonnalité, produit utilisé |
| Dernière connexion ancienne | Indice complémentaire | Compte de service, clients non interactifs, couverture |
| Licence héritée | Corriger la source | Propriétaire du groupe, règle, impact collectif |
| Erreur d’attribution | Problème de provisionnement | UsageLocation, capacité, plans, besoin |
$stamp = Get-Date -Format 'yyyyMMdd-HHmmss'
$licenseInventory | Export-Csv ".\license-inventory-$stamp.csv" -NoTypeInformation -Encoding utf8
$assignmentStates | Export-Csv ".\license-sources-$stamp.csv" -NoTypeInformation -Encoding utf8Retrait pilote : changement explicite et retour arrière
La commande suivante modifie le tenant et ne fait pas partie des dix collectes. Exécutez-la seulement après confirmation d’une attribution directe, des dépendances Exchange, OneDrive, Teams et Purview, de la conservation et du propriétaire. Pour une licence héritée, révisez plutôt l’appartenance au groupe dans son processus propriétaire.
Testez un compte non critique, conservez les SKU avant l’action, attendez la propagation et validez connexion et services. Le retour arrière réattribue le SKU si la capacité et les plans restent compatibles; il ne garantit pas la récupération de données supprimées par leur cycle de vie.
$userId = 'alex.tremblay@contoso.example'
$skuId = [guid]'00000000-0000-0000-0000-000000000000'
# Capture before the approved change
$before = Get-MgUserLicenseDetail -UserId $userId -All
$before | Select-Object SkuPartNumber,SkuId |
Export-Csv '.\before-license-change.csv' -NoTypeInformation
Set-MgUserLicense -UserId $userId -AddLicenses @() -RemoveLicenses @($skuId)$addLicense = @{ SkuId = $skuId }
Set-MgUserLicense -UserId $userId -AddLicenses @($addLicense) -RemoveLicenses @()Ce que cette démarche ne remplace pas
Ces commandes indiquent où concentrer la révision. Elles ne calculent pas seules le coût contractuel, les engagements, remises, add-ons, exigences de conformité ou le bon SKU cible. Un audit Microsoft 365 relie inventaire, activité, identités, groupes, rétention, sécurité et contrat pour produire un plan validé plutôt qu’une suppression massive.