1+ # Review-TeamsLifecycleCleanupCandidates.ps1
2+ # Purpose:
3+ # Read-only Microsoft Teams lifecycle hygiene review.
4+ # Identifies Teams with:
5+ # - No members
6+ # - No enabled members / only disabled members
7+ # - Disabled members present
8+ # - External members present
9+ # - Excessive owners
10+ # - No owners / no enabled owners
11+ #
12+ # Output:
13+ # TeamsLifecycleCleanupCandidates.csv
14+ #
15+ # This script does NOT delete, archive, or modify anything.
16+
17+ param (
18+ [int ]$OwnerThreshold = 5 ,
19+ [string ]$OutputPath = " .\TeamsLifecycleCleanupCandidates.csv"
20+ )
21+
22+ $ErrorActionPreference = " Stop"
23+
24+ function Write-Section {
25+ param ([string ]$Text )
26+ Write-Host " "
27+ Write-Host " ============================================================" - ForegroundColor DarkGray
28+ Write-Host " $Text " - ForegroundColor Cyan
29+ Write-Host " ============================================================" - ForegroundColor DarkGray
30+ }
31+
32+ function Write-Ok {
33+ param ([string ]$Text )
34+ Write-Host " [OK] $Text " - ForegroundColor Green
35+ }
36+
37+ function Write-Info {
38+ param ([string ]$Text )
39+ Write-Host " [INFO] $Text " - ForegroundColor Gray
40+ }
41+
42+ function Write-Warn {
43+ param ([string ]$Text )
44+ Write-Host " [WARN] $Text " - ForegroundColor Yellow
45+ }
46+
47+ function Write-Bad {
48+ param ([string ]$Text )
49+ Write-Host " [REVIEW] $Text " - ForegroundColor Red
50+ }
51+
52+ Write-Section " Connecting to Microsoft Graph"
53+
54+ Connect-MgGraph - Scopes `
55+ " Group.Read.All" ,
56+ " Directory.Read.All" ,
57+ " User.Read.All"
58+
59+
60+ $ctx = Get-MgContext
61+ Write-Ok " Connected to tenant: $ ( $ctx.TenantId ) "
62+
63+ Write-Section " Retrieving Microsoft Teams-backed groups"
64+
65+ # Teams are Microsoft 365 Groups with resourceProvisioningOptions containing "Team"
66+ $uri = " https://graph.microsoft.com/v1.0/groups?`$ filter=resourceProvisioningOptions/Any(x:x eq 'Team')&`$ select=id,displayName,createdDateTime,visibility,resourceProvisioningOptions&`$ top=999"
67+
68+ $allTeams = @ ()
69+
70+ do {
71+ $response = Invoke-MgGraphRequest - Method GET - Uri $uri - Headers @ {ConsistencyLevel = " eventual" }
72+ $allTeams += $response.value
73+ $uri = $response .' @odata.nextLink'
74+ } while ($uri )
75+
76+ Write-Ok " Teams found: $ ( $allTeams.Count ) "
77+
78+ Write-Section " Reviewing Team membership and ownership"
79+
80+ $results = New-Object System.Collections.Generic.List[object ]
81+ $counter = 0
82+
83+ foreach ($team in $allTeams ) {
84+ $counter ++
85+ Write-Host " [$counter /$ ( $allTeams.Count ) ] Reviewing: $ ( $team.displayName ) " - ForegroundColor Cyan
86+
87+ $membersUri = " https://graph.microsoft.com/v1.0/groups/$ ( $team.id ) /members/microsoft.graph.user?`$ select=id,displayName,userPrincipalName,accountEnabled,userType&`$ top=999"
88+ $ownersUri = " https://graph.microsoft.com/v1.0/groups/$ ( $team.id ) /owners/microsoft.graph.user?`$ select=id,displayName,userPrincipalName,accountEnabled,userType&`$ top=999"
89+
90+ $members = @ ()
91+ $owners = @ ()
92+
93+ try {
94+ do {
95+ $mResp = Invoke-MgGraphRequest - Method GET - Uri $membersUri
96+ $members += $mResp.value
97+ $membersUri = $mResp .' @odata.nextLink'
98+ } while ($membersUri )
99+ }
100+ catch {
101+ Write-Warn " Could not retrieve members for $ ( $team.displayName ) : $ ( $_.Exception.Message ) "
102+ }
103+
104+ try {
105+ do {
106+ $oResp = Invoke-MgGraphRequest - Method GET - Uri $ownersUri
107+ $owners += $oResp.value
108+ $ownersUri = $oResp .' @odata.nextLink'
109+ } while ($ownersUri )
110+ }
111+ catch {
112+ Write-Warn " Could not retrieve owners for $ ( $team.displayName ) : $ ( $_.Exception.Message ) "
113+ }
114+
115+ $memberCount = $members.Count
116+ $enabledMembers = @ ($members | Where-Object { $_.accountEnabled -eq $true })
117+ $disabledMembers = @ ($members | Where-Object { $_.accountEnabled -eq $false })
118+ $guestMembers = @ ($members | Where-Object { $_.userType -eq " Guest" })
119+
120+ $ownerCount = $owners.Count
121+ $enabledOwners = @ ($owners | Where-Object { $_.accountEnabled -eq $true })
122+ $disabledOwners = @ ($owners | Where-Object { $_.accountEnabled -eq $false })
123+
124+ $findings = New-Object System.Collections.Generic.List[string ]
125+ $recommendedAction = New-Object System.Collections.Generic.List[string ]
126+
127+ if ($memberCount -eq 0 ) {
128+ $findings.Add (" NoMembers" )
129+ $recommendedAction.Add (" Move to cleanup review; likely archive/delete candidate after owner validation" )
130+ }
131+
132+ if ($memberCount -gt 0 -and $enabledMembers.Count -eq 0 ) {
133+ $findings.Add (" NoEnabledMembers" )
134+ $recommendedAction.Add (" Move to cleanup review; all members are disabled/blocked" )
135+ }
136+
137+ if ($disabledMembers.Count -gt 0 -and $enabledMembers.Count -gt 0 ) {
138+ $findings.Add (" DisabledMembersPresent" )
139+ $recommendedAction.Add (" Review and remove disabled users from membership" )
140+ }
141+
142+ if ($ownerCount -eq 0 ) {
143+ $findings.Add (" NoOwners" )
144+ $recommendedAction.Add (" Assign valid owner or archive/delete after validation" )
145+ }
146+
147+ if ($ownerCount -gt 0 -and $enabledOwners.Count -eq 0 ) {
148+ $findings.Add (" NoEnabledOwners" )
149+ $recommendedAction.Add (" Assign an enabled owner or archive/delete after validation" )
150+ }
151+
152+ if ($ownerCount -gt $OwnerThreshold ) {
153+ $findings.Add (" ExcessiveOwners" )
154+ $recommendedAction.Add (" Review owner list and reduce to required owners" )
155+ }
156+
157+ if ($guestMembers.Count -gt 0 ) {
158+ $findings.Add (" ExternalMembersPresent" )
159+ $recommendedAction.Add (" Validate external access and remove stale guests" )
160+ }
161+
162+ if ($findings.Count -eq 0 ) {
163+ $findings.Add (" NoIssueFromThisCheck" )
164+ $recommendedAction.Add (" No cleanup action from membership/owner state" )
165+ }
166+
167+ $severity = " Info"
168+
169+ if ($findings -contains " NoMembers" -or $findings -contains " NoEnabledMembers" -or $findings -contains " NoOwners" -or $findings -contains " NoEnabledOwners" ) {
170+ $severity = " High"
171+ Write-Bad " $ ( $team.displayName ) : $ ( $findings -join ' , ' ) "
172+ }
173+ elseif ($findings -contains " ExternalMembersPresent" -or $findings -contains " ExcessiveOwners" -or $findings -contains " DisabledMembersPresent" ) {
174+ $severity = " Medium"
175+ Write-Warn " $ ( $team.displayName ) : $ ( $findings -join ' , ' ) "
176+ }
177+ else {
178+ Write-Ok " $ ( $team.displayName ) : no membership/ownership issue from this check"
179+ }
180+
181+ $results.Add ([PSCustomObject ]@ {
182+ TeamName = $team.displayName
183+ TeamId = $team.id
184+ CreatedDateTime = $team.createdDateTime
185+ Visibility = $team.visibility
186+ MemberCount = $memberCount
187+ EnabledMemberCount = $enabledMembers.Count
188+ DisabledMemberCount = $disabledMembers.Count
189+ GuestMemberCount = $guestMembers.Count
190+ OwnerCount = $ownerCount
191+ EnabledOwnerCount = $enabledOwners.Count
192+ DisabledOwnerCount = $disabledOwners.Count
193+ Findings = ($findings -join " ; " )
194+ Severity = $severity
195+ RecommendedAction = (($recommendedAction | Select-Object - Unique) -join " ; " )
196+ MemberUPNs = (($members | ForEach-Object { $_.userPrincipalName }) -join " , " )
197+ DisabledMemberUPNs = (($disabledMembers | ForEach-Object { $_.userPrincipalName }) -join " , " )
198+ GuestMemberUPNs = (($guestMembers | ForEach-Object { $_.userPrincipalName }) -join " , " )
199+ OwnerUPNs = (($owners | ForEach-Object { $_.userPrincipalName }) -join " , " )
200+ DisabledOwnerUPNs = (($disabledOwners | ForEach-Object { $_.userPrincipalName }) -join " , " )
201+ })
202+ }
203+
204+ Write-Section " Exporting report"
205+
206+ $sorted = $results |
207+ Sort-Object `
208+ @ {Expression = {
209+ switch ($_.Severity ) {
210+ " High" { 1 }
211+ " Medium" { 2 }
212+ " Info" { 3 }
213+ default { 4 }
214+ }
215+ }},
216+ TeamName
217+
218+ $sorted | Export-Csv - Path $OutputPath - NoTypeInformation - Encoding UTF8
219+
220+ Write-Ok " Report exported to: $OutputPath "
221+
222+ Write-Section " Summary"
223+
224+ $total = $results.Count
225+ $high = @ ($results | Where-Object { $_.Severity -eq " High" }).Count
226+ $medium = @ ($results | Where-Object { $_.Severity -eq " Medium" }).Count
227+ $noMembers = @ ($results | Where-Object { $_.Findings -match " NoMembers" }).Count
228+ $noEnabledMembers = @ ($results | Where-Object { $_.Findings -match " NoEnabledMembers" }).Count
229+ $external = @ ($results | Where-Object { $_.Findings -match " ExternalMembersPresent" }).Count
230+ $manyOwners = @ ($results | Where-Object { $_.Findings -match " ExcessiveOwners" }).Count
231+
232+ Write-Host " Teams reviewed: " - NoNewline
233+ Write-Host $total - ForegroundColor White
234+
235+ Write-Host " High priority cleanup candidates: " - NoNewline
236+ Write-Host $high - ForegroundColor Red
237+
238+ Write-Host " Medium priority review candidates: " - NoNewline
239+ Write-Host $medium - ForegroundColor Yellow
240+
241+ Write-Host " Teams with no members: " - NoNewline
242+ Write-Host $noMembers - ForegroundColor Red
243+
244+ Write-Host " Teams with no enabled members: " - NoNewline
245+ Write-Host $noEnabledMembers - ForegroundColor Red
246+
247+ Write-Host " Teams with external guests: " - NoNewline
248+ Write-Host $external - ForegroundColor Yellow
249+
250+ Write-Host " Teams with more than $OwnerThreshold owners: " - NoNewline
251+ Write-Host $manyOwners - ForegroundColor Yellow
252+
253+ Write-Host " "
254+ Write-Host " Recommended lifecycle action:" - ForegroundColor Cyan
255+ Write-Host " 1. Validate with Team owner/business sponsor." - ForegroundColor White
256+ Write-Host " 2. Archive inactive/no-member/no-enabled-member Teams first." - ForegroundColor White
257+ Write-Host " 3. Delete later only after retention/business validation." - ForegroundColor White
258+ Write-Host " "
259+ Write-Host " Done." - ForegroundColor Green
0 commit comments