Quand Teams est bien adopté, la création d’équipes explose. Et c’est une bonne nouvelle : les équipes terrain gagnent en autonomie, les projets démarrent vite, les échanges se structurent naturellement. Le problème arrive quand l’IT découvre, des semaines plus tard, qu’une équipe a été créée en public “par défaut”, qu’elle n’a pas de classification, ou que des invités se retrouvent dans un espace qui ne devrait pas en contenir.
La gouvernance Teams ne devrait pas être un frein. Elle devrait être un filet de sécurité discret : assez léger pour ne pas gêner, assez visible pour corriger tôt. L’une des façons les plus simples d’y arriver est de générer automatiquement un rapport des nouvelles équipes créées, basé sur les journaux d’audit Microsoft 365, puis de l’envoyer par courriel aux personnes concernées.
Pourquoi un rapport de création Teams est si efficace
Chaque équipe Teams repose sur un groupe Microsoft 365. Et ce groupe entraîne des permissions, un site SharePoint, des fichiers, des membres, parfois des invités. Sur le terrain, les dérives les plus fréquentes ne sont pas “malveillantes”, elles sont accidentelles :
- création d’une équipe publique alors qu’elle devait être privée
- absence de classification (ou mauvaise classification)
- ajout d’invités dans un contexte sensible
- multiplication d’équipes redondantes (projets, comités, tests)
Un rapport régulier sert de radar. Plutôt que de découvrir le désordre plus tard, on le voit apparaître dès le départ et on corrige avec un coût minimal : un message au créateur, une suggestion de renommage, un rappel de bonnes pratiques, un ajustement de politiques.
Ce type d’approche sert à plusieurs profils, sans complexité inutile :
- Admins Teams/M365 : visibilité opérationnelle et hygiène du tenant
- Gouvernance / centre de compétences : suivi du sprawl et des usages réels
- Sécurité & conformité : repérage des équipes publiques, invités, classification manquante
- Responsables collaboration / adoption : accompagnement ciblé plutôt que contrôle lourd
Dans une petite organisation, c’est un garde-fou. Dans un grand tenant, c’est un outil de pilotage.
Le cœur de la méthode repose sur une idée simple :
- Microsoft 365 enregistre l’événement TeamCreated dans les journaux d’audit
- On récupère ces événements sur une période (7, 30, 90 jours…)
- On enrichit avec les propriétés du groupe Microsoft 365 associé : public/privé, classification, membres, invités, propriétaires
- On génère un rapport HTML lisible
- On envoie le rapport par email
Là où beaucoup d’implémentations se fragilisent, c’est sur deux aspects : l’authentification et l’envoi de mail. Une solution robuste évite le SMTP “historique” et privilégie un envoi moderne (Graph), tout en traitant correctement les volumes (au-delà de 1000 événements) et l’identification fiable du groupe.
Le code ci-dessous suit exactement cette approche “propre” :
- collecte complète des événements via ReturnLargeSet (pas limité à 1000)
- résolution du groupe par ID (GUID) si disponible, avec fallback sur le nom
- envoi du rapport via Microsoft Graph (pas de SMTP basic)
- gestion des cas limites (zéro résultat, groupe non résolu)
<#
- Uses modern auth for Exchange Online (no Get-Credential)
- Handles >1000 audit records (ReturnLargeSet paging)
- Tries to resolve the M365 Group by GUID/GroupId first (more reliable than name)
- Sends the email via Microsoft Graph (no SMTP basic auth)
#>
# ---------------------------
# CONFIG
# ---------------------------
# Reporting window
$StartDate = (Get-Date).AddDays(-90)
$EndDate = (Get-Date).AddDays(1)
# Recipient(s)
$EmailRecipients = @(
"someone@tenant.com"
)
# Sender (must exist in tenant; can be a shared mailbox/user)
$MailSenderUpn = "m365-governance@tenant.com"
# Exchange Online connection (choose ONE option)
# Option A (interactive): easiest for manual runs
$UseInteractiveExoLogin = $true
# Option B (automation): app-only for EXO (recommended for scheduled runs)
# Requires: App registration + certificate + Exchange Online application permissions + admin consent
$ExoAppId = "00000000-0000-0000-0000-000000000000"
$ExoTenantId = "00000000-0000-0000-0000-000000000000"
$ExoCertThumbprint = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
# Microsoft Graph (for mail)
# Requires: App registration + certificate + Mail.Send (Application) + admin consent
$GraphAppId = $ExoAppId
$GraphTenantId = $ExoTenantId
$GraphCertThumbprint = $ExoCertThumbprint
# ---------------------------
# HTML TEMPLATE
# ---------------------------
$htmlHead = @"
<style>
BODY{font-family: Arial; font-size: 10pt;}
H1{font-size: 22px;}
H2{font-size: 18px; padding-top: 10px;}
H3{font-size: 16px; padding-top: 8px;}
.warn{color:#b00020; font-weight:bold;}
.ok{color:#0b6e0b; font-weight:bold;}
</style>
"@
$HtmlBody = @"
<h1>Teams Creation Report</h1>
<p><strong>Period:</strong> $(Get-Date $StartDate -Format g) → $(Get-Date $EndDate -Format g)</p>
<p><strong>Generated:</strong> $(Get-Date -Format g)</p>
<h2><u>Details of Teams Created</u></h2>
"@
# ---------------------------
# CONNECT: EXCHANGE ONLINE
# ---------------------------
Import-Module ExchangeOnlineManagement -ErrorAction Stop
if ($UseInteractiveExoLogin) {
Connect-ExchangeOnline -ShowBanner:$false -ErrorAction Stop
} else {
Connect-ExchangeOnline -AppId $ExoAppId -Organization $ExoTenantId -CertificateThumbprint $ExoCertThumbprint -ShowBanner:$false -ErrorAction Stop
}
# ---------------------------
# COLLECT AUDIT RECORDS (paging)
# ---------------------------
Write-Host "Looking for TeamCreated audit records..." -ForegroundColor Cyan
$AllRecords = New-Object System.Collections.Generic.List[Object]
$SessionId = [Guid]::NewGuid().ToString()
$SessionCommand = "ReturnLargeSet"
do {
$Batch = Search-UnifiedAuditLog `
-StartDate $StartDate `
-EndDate $EndDate `
-Operations "TeamCreated" `
-SessionId $SessionId `
-SessionCommand $SessionCommand `
-ResultSize 5000 `
-ErrorAction Stop
if ($Batch) { $AllRecords.AddRange($Batch) }
Write-Host ("Fetched {0} records (batch: {1})" -f $AllRecords.Count, ($Batch | Measure-Object).Count)
# ReturnLargeSet returns an empty set when done
$SessionCommand = "ReturnLargeSet"
} while ($Batch -and $Batch.Count -gt 0)
if ($AllRecords.Count -eq 0) {
Write-Host "No TeamCreated records found in the selected period." -ForegroundColor Yellow
Disconnect-ExchangeOnline -Confirm:$false
return
}
# ---------------------------
# BUILD REPORT (robust group resolution)
# ---------------------------
Write-Host "Processing audit records..." -ForegroundColor Cyan
$Report = New-Object System.Collections.Generic.List[Object]
foreach ($Rec in $AllRecords) {
try {
$AuditData = $Rec.AuditData | ConvertFrom-Json
} catch {
continue
}
# Try to find a stable ID (these fields can vary by record shape)
$TeamName = $AuditData.TeamName
$TeamGuid = $AuditData.TeamGuid
$GroupId = $AuditData.GroupId
$ExtId = $null
if ($GroupId -and $GroupId -match '^[0-9a-fA-F-]{36}$') { $ExtId = $GroupId }
elseif ($TeamGuid -and $TeamGuid -match '^[0-9a-fA-F-]{36}$') { $ExtId = $TeamGuid }
$O365Group = $null
# 1) Prefer GUID-based lookup
if ($ExtId) {
try {
$O365Group = Get-UnifiedGroup -Identity $ExtId -ErrorAction Stop
} catch {
$O365Group = $null
}
}
# 2) Fallback to name-based lookup (less reliable)
if (-not $O365Group -and $TeamName) {
try {
$O365Group = Get-UnifiedGroup -Identity $TeamName -ErrorAction Stop
} catch {
$O365Group = $null
}
}
$ReportLine = [PSCustomObject]@{
TimeStamp = (Get-Date $AuditData.CreationTime -Format g)
User = $AuditData.UserId
Action = $AuditData.Operation
TeamName = $TeamName
GroupId = $ExtId
Privacy = $O365Group.AccessType
Classification = $O365Group.Classification
MemberCount = $O365Group.GroupMemberCount
GuestCount = $O365Group.GroupExternalMemberCount
ManagedBy = ($O365Group.ManagedBy -join "; ")
ResolvedGroup = [bool]$O365Group
}
$Report.Add($ReportLine)
}
# ---------------------------
# RENDER HTML
# ---------------------------
$UniqueTeams = $Report | Sort-Object TeamName -Unique
foreach ($t in $UniqueTeams) {
$status = if ($t.ResolvedGroup) { "<span class='ok'>OK</span>" } else { "<span class='warn'>Group not resolved</span>" }
$HtmlBody += "<h2>$($t.TeamName) – $status</h2>"
$HtmlBody += "<p>Created on <b>$($t.TimeStamp)</b> by: $($t.User)</p>"
if ($t.ResolvedGroup) {
$HtmlBody += "<p>Privacy: <b>$($t.Privacy)</b> Classification: <b>$($t.Classification)</b></p>"
$HtmlBody += "<p>Members: <b>$($t.MemberCount)</b> Guests: <b>$($t.GuestCount)</b></p>"
$HtmlBody += "<p>Owners: <b>$($t.ManagedBy)</b></p>"
} else {
$HtmlBody += "<p class='warn'>Unable to retrieve group properties (privacy/classification/members/guests/owners). Check if the Team was renamed or if the audit record did not include an ID.</p>"
}
$HtmlBody += "<hr/>"
}
$HtmlMsg = $htmlHead + $HtmlBody
# Disconnect EXO now (clean)
Disconnect-ExchangeOnline -Confirm:$false
# ---------------------------
# SEND EMAIL VIA GRAPH (no SMTP)
# ---------------------------
Import-Module Microsoft.Graph.Authentication -ErrorAction Stop
Import-Module Microsoft.Graph.Users.Actions -ErrorAction Stop
Connect-MgGraph -TenantId $GraphTenantId -ClientId $GraphAppId -CertificateThumbprint $GraphCertThumbprint -NoWelcome -ErrorAction Stop
$Message = @{
subject = "Teams Creation Report"
body = @{
contentType = "HTML"
content = $HtmlMsg
}
toRecipients = @(
$EmailRecipients | ForEach-Object { @{ emailAddress = @{ address = $_ } } }
)
}
Send-MgUserMail -UserId $MailSenderUpn -Message $Message -SaveToSentItems:$true -ErrorAction Stop
Disconnect-MgGraph
Write-Host "Teams Creation Report sent to: $($EmailRecipients -join ', ')" -ForegroundColor Green
Tu n’as pas besoin de tout industrialiser dès le jour 1. L’approche la plus rentable est progressive :
- Démarrage : fenêtre de 30 ou 90 jours pour mesurer la création réelle
- Régime stable : exécution hebdomadaire (7 jours) pour garder le rapport actionnable
- Destinataires : une boîte partagée “M365 Governance”, ou un petit groupe d’admins
- Automatisation : Task Scheduler / runbook / pipeline… selon ton standard interne
Le rapport est un filet : il doit arriver au bon endroit, au bon rythme, et être lisible en 30 secondes.
Rendre le rapport actionnable
La meilleure évolution consiste à mettre l’attention sur les exceptions :
- équipe publique
- présence d’invités
- classification vide
- propriétaires absents ou insuffisants
Même sans automatiser la remédiation, ces signaux permettent d’agir vite.
Pour les exécutions planifiées, privilégie un compte/app dédié, droits minimaux, et des secrets/certificats gérés proprement. C’est la différence entre un outil “qui marche” et un outil “qui marche encore dans 12 mois”.
Si ton tenant crée beaucoup d’équipes, la collecte doit être capable de tout récupérer. C’est la raison d’être du mode ReturnLargeSet : pas de surprise, pas de trou dans le reporting.
