Skip to content
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

Draft
wants to merge 17 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
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 `m365 cli` commands to create a report of all M365 groups that are possibly obsolete."
tmaestrini marked this conversation as resolved.
Show resolved Hide resolved
],
"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"
}
]
}
]
293 changes: 293 additions & 0 deletions docs/docs/sample-scripts/entra/find-obsolete-m365-groups/index.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,293 @@
---
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
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 = @()
$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-{current date}.csv" -ForegroundColor Yellow
tmaestrini marked this conversation as resolved.
Show resolved Hide resolved
tmaestrini marked this conversation as resolved.
Show resolved Hide resolved
}
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 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 = m365 entra m365group user list --groupId $Group.Reference.id --role Owner | ConvertFrom-Json
$members = m365 entra m365group user list --groupId $Group.Reference.id --role Member | ConvertFrom-Json
tmaestrini marked this conversation as resolved.
Show resolved Hide resolved

# Consider guests as users that are not in the $members list
$guests = Compare-Object -ReferenceObject $users $members -PassThru
tmaestrini marked this conversation as resolved.
Show resolved Hide resolved
tmaestrini marked this conversation as resolved.
Show resolved Hide resolved

# Modify the $members list to only contain users that are not in the $owners list
$members = Compare-Object $members $owners -PassThru
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This piece of code is still failing for all company groups which have no owners and no members.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're absolutely right. This would be the case, when every group member has left the company. In this case, any comparison would be obsolete.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As mentioned in the previous review. When you have an all company group, it doesn't have any owners or members.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@milanholemans That's why I've implemented this on line 170:

if($null -ne $owners -and $null -ne $members) {
  $members = Compare-Object $members $owners -PassThru
}

Doesn't it show up in the code?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The last time I reviewed it wasn't there. I'll recheck in my next review.


$Group.Membership = [ordered] @{
Owners = $owners
Members = $members
tmaestrini marked this conversation as resolved.
Show resolved Hide resolved
Guests = $guests
}

if ($owners.Count -eq 1 -and ($members.Count + $guests.Count) -eq 0) {
tmaestrini marked this conversation as resolved.
Show resolved Hide resolved
Write-Host " → potentially obsolete (abandoned group: only 1 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
)

function Get-ParsedDate {
[CmdletBinding()]
param (
[Parameter(Mandatory = $true)] [String] $JavascriptDateString
)

$dateParts = [regex]::Matches($JavascriptDateString, '\d+') | ForEach-Object { $_.Value }

# Convert the parts to integers
$year = [int]$dateParts[0]
$month = [int]$dateParts[1] + 1
$day = [int]$dateParts[2]
$hour = [int]$dateParts[3]
$minute = [int]$dateParts[4]
$second = [int]$dateParts[5]
# $millisecond = [int]$dateParts[6]
tmaestrini marked this conversation as resolved.
Show resolved Hide resolved

# return a DateTime object
$dateObject = New-Object -TypeName DateTime -ArgumentList $year, $month, $day, $hour, $minute, $second
$dateObject
}

$WarningDate = (Get-Date).AddDays(-90)

# Not possible to retrieve property 'LastContentModifiedDate' via command 'm365 spo site get --url $group.siteUrl', so we need to use filtering:
tmaestrini marked this conversation as resolved.
Show resolved Hide resolved
tmaestrini marked this conversation as resolved.
Show resolved Hide resolved
$spoSite = m365 spo site list --filter "Url -eq '$($Group.Reference.siteUrl)'" | ConvertFrom-Json
tmaestrini marked this conversation as resolved.
Show resolved Hide resolved
$spoSite.LastContentModifiedDate = Get-ParsedDate -JavascriptDateString $spoSite.LastContentModifiedDate
if ($spoSite.LastContentModifiedDate -lt $WarningDate) {
tmaestrini marked this conversation as resolved.
Show resolved Hide resolved
Write-Host " → potentially obsolete (SPO last content modified: $($spoSite.LastContentModifiedDate))" -ForegroundColor Yellow
$reason = "Low SharePoint activity ($($spoSite.LastContentModifiedDate))"

$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)

$a = m365 entra m365group conversation list --groupId $Group.Reference.id | ConvertFrom-Json | Where-Object {
tmaestrini marked this conversation as resolved.
Show resolved Hide resolved
[datetime]$_.lastDeliveredDateTime -lt $WarningDate
tmaestrini marked this conversation as resolved.
Show resolved Hide resolved
}
if (!$a -or $a.Length -eq 0) { return }

Write-Host " → potentially obsolete ($($a.Length) conversation item$($a.Length -gt 1 ? 's' : '') created more than 1 year ago)" -ForegroundColor Yellow
$reason = "$($a.Length) conversation item$($a.Length -gt 1 ? 's' : '') created more than 1 year ago)"

$Group.MailboxStatus = @{
OutdatedConversations = $a | Sort-Object -Property lastDeliveredDateTime
Reason = $reason
}
$Group.TestStatus = "🟡 Warning"
$Group.Reasons += $reason

try {
$Global:ObsoleteGroups.Add($Group.Reference.id, $Group)
}
catch { }
tmaestrini marked this conversation as resolved.
Show resolved Hide resolved
}

function New-Report {
[CmdletBinding()]
param (
[Parameter(Mandatory = $true)] [GroupInfo] $Group
)

$exportObject = [ordered] @{
"Group Name" = $Group.Reference.displayName
"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"
Description = $Group.Reference.description
"Conversation Status" = $Group.MailboxStatus.Reason ?? "Normal"
"Number of Conversations" = $Group.MailboxStatus.OutdatedConversations ? $Group.MailboxStatus.OutdatedConversations.Length : "n/a"
"Last Conversation" = $Group.MailboxStatus.OutdatedConversations ? $Group.MailboxStatus.OutdatedConversations[0].lastDeliveredDateTime : ""
"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>