Microsoft 365 : 10 commandes PowerShell pour trouver les licences inutilisées

Croisez inventaire, comptes désactivés, attribution directe ou par groupe, erreurs et activité avec dix collectes commentées avant tout retrait de licence.

01

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é.

02

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.

Connexion de lecture seule
$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,Scopes
03

1. 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.

Commande 1 — abonnements
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 SkuPartNumber
04

2. 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.

Commande 2 — membres licenciés
$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}}
05

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.

Commande 3 — candidats désactivés
$disabledLicensed = $licensedUsers |
  Where-Object AccountEnabled -eq $false |
  Select-Object Id,DisplayName,UserPrincipalName,
    @{n='LicenseCount';e={$_.AssignedLicenses.Count}}

$disabledLicensed | Sort-Object UserPrincipalName
06

4. 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.

Commande 4 — détail par compte
$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,UserPrincipalName
07

5. 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.

Commande 5 — utilisateur fictif
$candidate = 'alex.tremblay@contoso.example'
Get-MgUserLicenseDetail -UserId $candidate -All |
  Select-Object SkuPartNumber,SkuId,
    @{n='ServicePlans';e={($_.ServicePlans | ForEach-Object {
      "$($_.ServicePlanName):$($_.ProvisioningStatus)"
    }) -join '; '}}
08

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.

Commande 6 — source de l’attribution
$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,SkuPartNumber
09

7. 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.

Commande 7 — groupes licenciés
$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 ', '
  }
}
10

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.

Commande 8 — états en erreur
$licenseErrors = $assignmentStates |
  Where-Object { $_.State -ne 'Active' -or $_.Error }

$licenseErrors |
  Select-Object UserPrincipalName,SkuPartNumber,Source,State,Error |
  Sort-Object SkuPartNumber,UserPrincipalName
11

9. 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.

Commande 9 — activité D180
$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'
12

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.

Commande 10 — activité de connexion
$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 LastSuccessfulSignInUtc
13

Construire 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.

SignalLecture prudenteValidation requise
Compte désactivé + licence directeCandidat prioritaireDépart, conservation, boîte, OneDrive, applications
Aucune activité D180Activité non observée dans la fenêtreGestionnaire, saisonnalité, produit utilisé
Dernière connexion ancienneIndice complémentaireCompte de service, clients non interactifs, couverture
Licence héritéeCorriger la sourcePropriétaire du groupe, règle, impact collectif
Erreur d’attributionProblème de provisionnementUsageLocation, capacité, plans, besoin
Export contrôlé
$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 utf8
14

Retrait 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.

CHANGEMENT — retirer un SKU direct approuvé
$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)
RETOUR ARRIÈRE — réattribuer le SKU
$addLicense = @{ SkuId = $skuId }
Set-MgUserLicense -UserId $userId -AddLicenses @($addLicense) -RemoveLicenses @()
15

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.