Skip to content

Commit ea9f747

Browse files
authored
Add files via upload
1 parent 7792e9e commit ea9f747

1 file changed

Lines changed: 259 additions & 0 deletions

File tree

Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
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

Comments
 (0)