diff --git a/docs/docs/sample-scripts/entra/find-obsolete-m365-groups/assets/preview.png b/docs/docs/sample-scripts/entra/find-obsolete-m365-groups/assets/preview.png new file mode 100644 index 0000000000..9098eba2c6 Binary files /dev/null and b/docs/docs/sample-scripts/entra/find-obsolete-m365-groups/assets/preview.png differ diff --git a/docs/docs/sample-scripts/entra/find-obsolete-m365-groups/assets/sample.json b/docs/docs/sample-scripts/entra/find-obsolete-m365-groups/assets/sample.json new file mode 100644 index 0000000000..25ffb0489a --- /dev/null +++ b/docs/docs/sample-scripts/entra/find-obsolete-m365-groups/assets/sample.json @@ -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" + } + ] + } +] diff --git a/docs/docs/sample-scripts/entra/find-obsolete-m365-groups/index.mdx b/docs/docs/sample-scripts/entra/find-obsolete-m365-groups/index.mdx new file mode 100644 index 0000000000..8ed4cb4cab --- /dev/null +++ b/docs/docs/sample-scripts/entra/find-obsolete-m365-groups/index.mdx @@ -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. + + + + + ```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 + Guests = $guests + } + + if ($owners.Count -eq 0) { + Write-Host " ā†’ potentially obsolete (abandoned group: no owner)" -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 { } + return + } + + if ($owners.Count -le 1 -and ($members.Count + $guests.Count) -eq 0) { + 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))/" } + $spoWeb = m365 spo web get --url $spoSite.Url | ConvertFrom-Json + if ($spoWeb.LastItemUserModifiedDate -lt $WarningDate) { + 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 + } + + 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" } + } + + function New-Report { + [CmdletBinding()] + param ( + [Parameter(Mandatory = $true)] [GroupInfo] $Group + ) + + $exportObject = [ordered] @{ + "Group Name" = $Group.Reference.displayName + Description = $Group.Reference.description + "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 + ``` + +