-
Notifications
You must be signed in to change notification settings - Fork 326
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Adds new sample "find-obsolete-m365-groups" #6250
Changes from all commits
b150ee4
c3c96bb
9643937
793b05b
b2ae47f
34bdeaa
bf41310
c8ca5af
2b723ff
bc31a5b
0c3779d
1aadf57
e43a560
310bd82
692a8ad
988904f
329ef17
3d0335a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,59 @@ | ||
[ | ||
{ | ||
"name": "pnp-find-obsolete-m365-groups", | ||
"source": "pnp", | ||
"title": "Finding Obsolete Microsoft 365 Groups with PowerShell", | ||
"url": "https://pnp.github.io/cli-microsoft365/sample-scripts/entra/find-obsolete-m365-groups", | ||
"creationDateTime": "2024-08-14", | ||
"updateDateTime": "2024-08-14", | ||
"shortDescription": "Understand to what extent the Microsoft 365 groups in your tenant are being used or even not.", | ||
"longDescription": [ | ||
"Like any resource within your Microsoft 365 tenant, M365 Groups can become unused over time. This routine uses PowerShell with CLI for Microsoft 365 to create a report of all M365 groups that are possibly obsolete." | ||
], | ||
"products": ["SharePoint", "M365 Groups", "Teams", "Exchange Online"], | ||
"categories": [], | ||
"tags": [ | ||
"provisioning", | ||
"libraries", | ||
"group mailbox", | ||
"governance", | ||
"m365 groups", | ||
"teams", | ||
"usage", | ||
"insights" | ||
], | ||
"metadata": [ | ||
{ | ||
"key": "CLI-FOR-MICROSOFT365", | ||
"value": "v8.0.0" | ||
} | ||
], | ||
"thumbnails": [ | ||
{ | ||
"type": "image", | ||
"order": 100, | ||
"url": "https://raw.githubusercontent.com/pnp/cli-microsoft365/main/docs/docs/sample-scripts/find-obsolete-m365-groups/assets/preview.png", | ||
"alt": "preview image for the sample" | ||
} | ||
], | ||
"authors": [ | ||
{ | ||
"gitHubAccount": "tmaestrini", | ||
"pictureUrl": "https://avatars.githubusercontent.com/u/69770609?v=4", | ||
"name": "Tobias Maestrini" | ||
} | ||
], | ||
"references": [ | ||
{ | ||
"name": "Want to learn more about CLI for Microsoft 365 and the commands", | ||
"description": "Check out the CLI for Microsoft 365 site to get started and for the reference to the commands.", | ||
"url": "https://aka.ms/cli-m365" | ||
}, | ||
{ | ||
"name": "Original article by Tony Redmond", | ||
"description": "Check out the original article on which this script is based.", | ||
"url": "https://petri.com/identifying-obsolete-office-365-groups-powershell" | ||
} | ||
] | ||
} | ||
] |
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
@@ -0,0 +1,309 @@ | ||||||
--- | ||||||
tags: | ||||||
- provisioning | ||||||
- libraries | ||||||
- group mailbox | ||||||
- governance | ||||||
- teams | ||||||
- m365 groups | ||||||
--- | ||||||
|
||||||
import Tabs from '@theme/Tabs'; | ||||||
import TabItem from '@theme/TabItem'; | ||||||
|
||||||
# Finding obsolete Microsoft 365 groups with PowerShell | ||||||
|
||||||
Author: [Tobias Maestrini](https://github.com/tmaestrini) | ||||||
|
||||||
This script is based on the [original article](https://petri.com/identifying-obsolete-office-365-groups-powershell) written by [Tony Redmond](https://twitter.com/12Knocksinna). | ||||||
|
||||||
Like any resource within your Microsoft 365 tenant, M365 Groups can become unused over time. | ||||||
|
||||||
This routine uses PowerShell with CLI for Microsoft 365 | ||||||
- To gather insights about SharePoint file activity within the related SharePoint site. | ||||||
- To do a check against conversation items in the group mailbox. | ||||||
- To denote the amount of active people (group owners, members and guests) in the group. | ||||||
|
||||||
These metrics can help us understand the extent to which the resource is being used from a governance perspective – or even not. | ||||||
Use this script to create a report of all M365 groups that are possibly obsolete. | ||||||
|
||||||
<Tabs> | ||||||
<TabItem value="PowerShell"> | ||||||
|
||||||
```powershell | ||||||
$ErrorActionPreference = "Stop" | ||||||
|
||||||
class GroupInfo { | ||||||
[PSCustomObject] $Reference | ||||||
[PSCustomObject] $Membership | ||||||
[PSCustomObject] $SharePointStatus | ||||||
[PSCustomObject] $MailboxStatus | ||||||
[PSCustomObject] $ChatStatus | ||||||
[string] $TestStatus | ||||||
[string[]] $Reasons | ||||||
} | ||||||
|
||||||
function Start-Routine { | ||||||
# START ROUTINE | ||||||
[CmdletBinding()] | ||||||
param ( | ||||||
[Parameter(Mandatory = $false)] [Switch] $KeepConnectionsAlive, | ||||||
[Parameter(Mandatory = $false)] [Switch] $KeepOutputPath | ||||||
) | ||||||
|
||||||
try { | ||||||
Initialize-Params | ||||||
if ($KeepOutputPath.IsPresent) { Initialize-ExportPath -KeepOutputPath } | ||||||
else { Initialize-ExportPath } | ||||||
Get-AllM365Groups | ||||||
Get-AllGuestUsers | ||||||
Get-AllTeamSites | ||||||
Start-GroupInsightsTests | ||||||
|
||||||
Write-Host "`n✔︎ Routine terminated" -ForegroundColor Green | ||||||
if (!$KeepConnectionsAlive.IsPresent) { | ||||||
m365 logout | ||||||
} | ||||||
} | ||||||
catch { | ||||||
Write-Error $_.Exception.Message | ||||||
} | ||||||
} | ||||||
|
||||||
function Initialize-Params { | ||||||
Write-Host "🚀 Generating report of obsolete M365 groups within your organization" | ||||||
|
||||||
# define globals | ||||||
$Global:Path | ||||||
$Script:ReportPath = $null | ||||||
$Script:Groups = @() | ||||||
$Script:Guests = @() | ||||||
$Script:TeamSites = @() | ||||||
$Global:ObsoleteGroups = [System.Collections.Generic.Dictionary[string, GroupInfo]]::new() | ||||||
|
||||||
Write-Output "Connecting to M365 tenant: please follow the instructions." | ||||||
Write-output "IMPORTANT: You'll need to have at least global reader permissions!`n" | ||||||
if ((m365 status --output text) -eq "Logged out") { | ||||||
m365 login | ||||||
} | ||||||
} | ||||||
|
||||||
function Initialize-ExportPath { | ||||||
[CmdletBinding()] | ||||||
param ( | ||||||
[Parameter(Mandatory = $false)] [Switch] $KeepOutputPath | ||||||
) | ||||||
|
||||||
if (!$KeepOutputPath.IsPresent -or $null -eq $Global:Path) { | ||||||
$Script:Path = Read-Host "Set the path to the folder where you want to export the report data as csv file" | ||||||
} | ||||||
|
||||||
$TestPath = Test-Path -Path $Script:Path | ||||||
$tStamp = (Get-Date).ToString("yyyy-MM-dd HH:mm:ss") | ||||||
if ($TestPath -ne $true) { | ||||||
New-Item -ItemType directory -Path $Script:Path | Out-Null | ||||||
Write-Host "Will create file in $($Script:Path): M365GroupsReport-$tStamp.csv" -ForegroundColor Yellow | ||||||
} | ||||||
else { | ||||||
Write-Host "Following report file will be created in $($Script:Path): 'M365GroupsReport-$($tStamp).csv'." | ||||||
Write-Host "`nAll data will be exported to $($Script:Path): M365GroupsReport-$($tStamp).csv." -ForegroundColor Blue | ||||||
Write-Host "Do not edit this file during the scan." -ForegroundColor Blue | ||||||
} | ||||||
$Script:ReportPath = "$($Script:Path)/M365GroupsReport-$($tStamp).csv" | ||||||
} | ||||||
|
||||||
function Get-AllM365Groups { | ||||||
$groups = m365 entra m365group list --includeSiteUrl | ConvertFrom-Json | ||||||
$Script:Groups = $groups | Where-Object { $null -ne $_.siteUrl } | ||||||
} | ||||||
|
||||||
function Get-AllGuestUsers { | ||||||
$Script:Guests = m365 entra user list --type Guest | ConvertFrom-Json | ||||||
} | ||||||
|
||||||
function Get-AllTeamSites { | ||||||
$Script:TeamSites = m365 spo site list --type TeamSite | ConvertFrom-Json | ||||||
} | ||||||
|
||||||
function Start-GroupInsightsTests { | ||||||
Write-Host "Checking $($Script:Groups.Count) groups for activity" | ||||||
|
||||||
$Script:Groups | ForEach-Object { | ||||||
$groupInfo = [GroupInfo]::new() | ||||||
$groupInfo.Reference = $_ | ||||||
$groupInfo.Membership = @{Owners = 0; Members = 0; Guests = 0 } | ||||||
$groupInfo.TestStatus = "🟢 OK" | ||||||
|
||||||
Write-Host "☀︎ $($groupInfo.Reference.displayName)" | ||||||
|
||||||
# Tests | ||||||
Test-GroupMembership -Group $groupInfo | ||||||
Test-SharePointActivity -Group $groupInfo | ||||||
Test-ConversationActivity -Group $groupInfo | ||||||
|
||||||
# Report | ||||||
New-Report -Group $groupInfo | ||||||
} | ||||||
|
||||||
#Give feedback to user | ||||||
Write-Host "`n-------------------------------------------------------------------" | ||||||
Write-Host "`SUMMARY" -ForegroundColor DarkGreen | ||||||
Write-Host "`-------------------------------------------------------------------" | ||||||
Write-Host "`n👉 Found $($Global:ObsoleteGroups.Count) group$($Global:ObsoleteGroups.Count -gt 1 ? 's' : '') with possibly low activity." | ||||||
Write-Host "` Please review the report: " -NoNewline | ||||||
Write-Host "$($Script:ReportPath)" -ForegroundColor DarkBlue | ||||||
} | ||||||
|
||||||
function Test-GroupMembership { | ||||||
[CmdletBinding()] | ||||||
param ( | ||||||
[Parameter(Mandatory = $true)] [GroupInfo] $Group | ||||||
) | ||||||
|
||||||
# Original lists | ||||||
$users = m365 entra m365group user list --groupId $Group.Reference.id | ConvertFrom-Json | ||||||
$owners = $users | Where-Object { $_.roles -contains "Owner" } | ||||||
$members = $users | Where-Object { $_.roles -contains "Member" -and $_.id -notin $Script:Guests.id } | ||||||
$guests = $users | Where-Object { $_.id -in $Script:Guests.id } | ||||||
|
||||||
# Modify the $members list to only contain users that are not in the $owners list | ||||||
if($null -ne $owners -and $null -ne $members) { | ||||||
$members = Compare-Object $members $owners -PassThru | ||||||
} | ||||||
|
||||||
$Group.Membership = [ordered] @{ | ||||||
Owners = $owners | ||||||
Members = $members | ||||||
tmaestrini marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||
Guests = $guests | ||||||
} | ||||||
|
||||||
if ($owners.Count -eq 0) { | ||||||
Write-Host " → potentially obsolete (abandoned group: no owner)" -ForegroundColor Yellow | ||||||
$reason = "Low user count" | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
|
||||||
$Group.Membership.Status = "Abandoned ($reason)" | ||||||
$Group.TestStatus = "🟡 Warning" | ||||||
$Group.Reasons += $reason | ||||||
|
||||||
try { | ||||||
$Global:ObsoleteGroups.Add($Group.Reference.id, $Group) | ||||||
} | ||||||
catch { } | ||||||
return | ||||||
} | ||||||
|
||||||
if ($owners.Count -le 1 -and ($members.Count + $guests.Count) -eq 0) { | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Does it matter whether there's only 1 owner? I think every group without a member has some problems no? Let's also personalize the reasons, right now we're always logging "low user count". |
||||||
Write-Host " → potentially obsolete (abandoned group: only $($owners.Count) owner left)" -ForegroundColor Yellow | ||||||
$reason = "Low user count" | ||||||
|
||||||
$Group.Membership.Status = "Abandoned ($reason)" | ||||||
$Group.TestStatus = "🟡 Warning" | ||||||
$Group.Reasons += $reason | ||||||
|
||||||
try { | ||||||
$Global:ObsoleteGroups.Add($Group.Reference.id, $Group) | ||||||
} | ||||||
catch { } | ||||||
} | ||||||
} | ||||||
|
||||||
function Test-SharePointActivity { | ||||||
[CmdletBinding()] | ||||||
param ( | ||||||
[Parameter(Mandatory = $true)] [GroupInfo] $Group | ||||||
) | ||||||
|
||||||
$WarningDate = (Get-Date).AddDays(-90) | ||||||
|
||||||
$spoSite = $Script:TeamSites | Where-Object { $_.GroupId -eq "/Guid($($Group.Reference.id))/" } | ||||||
tmaestrini marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||
$spoWeb = m365 spo web get --url $spoSite.Url | ConvertFrom-Json | ||||||
tmaestrini marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||
if ($spoWeb.LastItemUserModifiedDate -lt $WarningDate) { | ||||||
tmaestrini marked this conversation as resolved.
Show resolved
Hide resolved
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. As discussed before, let's remove the |
||||||
Write-Host " → potentially obsolete (SPO last content modified: $($spoWeb.LastItemUserModifiedDate))" -ForegroundColor Yellow | ||||||
$reason = "Low SharePoint activity ($($spoWeb.LastItemUserModifiedDate))" | ||||||
|
||||||
$Group.SharePointStatus = @{ | ||||||
Reason = $reason | ||||||
} | ||||||
$Group.TestStatus = "🟡 Warning" | ||||||
$Group.Reasons += $reason | ||||||
|
||||||
try { | ||||||
$Global:ObsoleteGroups.Add($Group.Reference.id, $Group) | ||||||
} | ||||||
catch { } | ||||||
} | ||||||
} | ||||||
|
||||||
function Test-ConversationActivity { | ||||||
[CmdletBinding()] | ||||||
param ( | ||||||
[Parameter(Mandatory = $true)] [GroupInfo] $Group | ||||||
) | ||||||
|
||||||
$WarningDate = (Get-Date).AddDays(-365) | ||||||
|
||||||
$conversations = m365 entra m365group conversation list --groupId $Group.Reference.id | ConvertFrom-Json | Sort-Object -Property lastDeliveredDateTime -Descending | ||||||
$latestConversation = $conversations | Where-Object { | ||||||
[datetime]$_.lastDeliveredDateTime -gt $WarningDate.Date | ||||||
} | Select-Object -First 1 | ||||||
|
||||||
$Group.MailboxStatus = @{ | ||||||
NumberOfConversations = $conversations.Length | ||||||
LastConversation = $conversations ? $conversations[0].lastDeliveredDateTime : "n/a" | ||||||
OutdatedConversations = 0 | ||||||
Reason = "" | ||||||
} | ||||||
|
||||||
# Return if there are no conversations or the latest conversation is not outdated | ||||||
if (!$conversations -or $latestConversation.Count -eq 1) { return } | ||||||
|
||||||
$outdatedConversations = $conversations | Where-Object { | ||||||
[datetime]$_.lastDeliveredDateTime -lt $WarningDate | ||||||
tmaestrini marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||
} | ||||||
|
||||||
Write-Host " → potentially obsolete ($($outdatedConversations.Length) conversation item$($outdatedConversations.Length -gt 1 ? 's' : '') created more than 1 year ago)" -ForegroundColor Yellow | ||||||
$reason = "$($outdatedConversations.Length) conversation item$($outdatedConversations.Length -gt 1 ? 's' : '') created more than 1 year ago" | ||||||
|
||||||
$Group.MailboxStatus.OutdatedConversations = $outdatedConversations | Sort-Object -Property lastDeliveredDateTime | ||||||
$Group.MailboxStatus.Reason = $reason | ||||||
$Group.TestStatus = "🟡 Warning" | ||||||
$Group.Reasons += $reason | ||||||
|
||||||
try { | ||||||
$Global:ObsoleteGroups.Add($Group.Reference.id, $Group) | ||||||
} | ||||||
catch { Write-Information "Group was already added to the list of potentially obsolete groups" } | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
} | ||||||
|
||||||
function New-Report { | ||||||
[CmdletBinding()] | ||||||
param ( | ||||||
[Parameter(Mandatory = $true)] [GroupInfo] $Group | ||||||
) | ||||||
|
||||||
$exportObject = [ordered] @{ | ||||||
"Group Name" = $Group.Reference.displayName | ||||||
Description = $Group.Reference.description | ||||||
tmaestrini marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||
"Managed by" = $Group.Membership.Owners ? $Group.Membership.Owners.displayName -join ", " : "n/a" | ||||||
Owners = $Group.Membership.Owners.Count | ||||||
Members = $Group.Membership.Members.Count | ||||||
Guests = $Group.Membership.Guests.Count | ||||||
"Group Status" = $Group.Membership.Status ?? "Normal" | ||||||
"Number of Conversations" = $Group.MailboxStatus.NumberOfConversations ? $Group.MailboxStatus.NumberOfConversations : "n/a" | ||||||
"Last Conversation" = $Group.MailboxStatus.LastConversation | ||||||
"Conversation Status" = $Group.MailboxStatus.Reason ?? "Normal" | ||||||
"Team enabled" = $Group.Reference.resourceProvisioningOptions -contains 'Team' ? "True" : "False" | ||||||
"SPO Status" = $Group.SharePointStatus.Reason ?? "Normal" | ||||||
"SPO Activity" = $Group.SharePointStatus ? "Low / No document library usage" : "Document library in use" | ||||||
"Number of warnings" = $Group.Reasons.Count | ||||||
Status = $Group.TestStatus | ||||||
} | ||||||
|
||||||
$exportObject | Export-Csv -Path $Script:ReportPath -Append -NoTypeInformation | ||||||
} | ||||||
|
||||||
# START the report generation | ||||||
Start-Routine #-KeepConnectionsAlive -KeepOutputPath | ||||||
tmaestrini marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||
``` | ||||||
</TabItem> | ||||||
</Tabs> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm getting an error:
Obviously, colons are not supported as characters in Windows. Let's strip the file name from illegal characters.