-
Notifications
You must be signed in to change notification settings - Fork 330
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" (replaces old PR) #6538
Open
tmaestrini
wants to merge
30
commits into
pnp:main
Choose a base branch
from
tmaestrini:2475-find-obsolete-m365-groups
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 22 commits
Commits
Show all changes
30 commits
Select commit
Hold shift + click to select a range
b150ee4
Add new sample to find obsolete m365 groups in the tenant. Closes #2475
c3c96bb
add preview image to assets
9643937
fix typos and change several minor implementations due to readabiltiy
793b05b
change implementation after rewiew
b2ae47f
Merge branch 'pnp:main' into main
tmaestrini 34bdeaa
change console output to make the output path more precise for the user
bf41310
change retrieval of owners and members to increase script performance
c8ca5af
fix error related to comparison in case that group is empty
2b723ff
fix handling of groups with 0 or only 1 owner
bc31a5b
change logging behavior (reverted) in order to avoid overflooding the…
0c3779d
change member retrieval by excluding guests from the members' list
1aadf57
change retrieval of groups and procedure to check whether the group i…
e43a560
add additional information to exported object (group conversation infos)
310bd82
change sample preview image
692a8ad
fix formatting
988904f
Merge branch 'pnp:main' into main
tmaestrini 329ef17
Merge branch 'pnp:main' into main
tmaestrini 022577a
change timestamp format in report filename due to file system compati…
73fadb6
change date of last content modification on SPO site in order to reve…
943636d
change reason for potentially obsolete groups to reflect low owner co…
22f909c
remove unnecessary information in catch block
1784418
remove redundant SPO site retrieval
f7d10d4
change CLI version from v8.0.0 to v10.0.0
67cfdec
remove redundant tag 'provisioning' from documentation
ec29e7e
add preamble to enforce strict mode on script run
b91a229
initialize global Path variable
ef34467
change type declarations and null value handling to avoid runtime errors
20c067b
add new attribute to membership property in case of potential obsolet…
f726029
remove strict mode
3247a14
change type declaration for ordered object to avoid possible runtime …
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Binary file added
BIN
+67.2 KB
docs/docs/sample-scripts/entra/find-obsolete-m365-groups/assets/preview.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
59 changes: 59 additions & 0 deletions
59
docs/docs/sample-scripts/entra/find-obsolete-m365-groups/assets/sample.json
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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" | ||
tmaestrini marked this conversation as resolved.
Show resolved
Hide resolved
|
||
} | ||
], | ||
"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" | ||
} | ||
] | ||
} | ||
] |
330 changes: 330 additions & 0 deletions
330
docs/docs/sample-scripts/entra/find-obsolete-m365-groups/index.mdx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,330 @@ | ||
--- | ||
tags: | ||
- provisioning | ||
tmaestrini marked this conversation as resolved.
Show resolved
Hide resolved
|
||
- 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("yyyyMMdd-HHmmss") | ||
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] @{ | ||
tmaestrini marked this conversation as resolved.
Show resolved
Hide resolved
|
||
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 owner 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] | ||
|
||
# return a DateTime object | ||
$dateObject = New-Object -TypeName DateTime -ArgumentList $year, $month, $day, $hour, $minute, $second | ||
$dateObject | ||
} | ||
|
||
$WarningDate = (Get-Date).AddDays(-90) | ||
|
||
$spoSite = $Script:TeamSites | Where-Object { $_.GroupId -eq "/Guid($($Group.Reference.id))/" } | ||
$spoSite.LastContentModifiedDate = Get-ParsedDate -JavascriptDateString $spoSite.LastContentModifiedDate | ||
if ($spoSite.LastContentModifiedDate -lt $WarningDate) { | ||
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) | ||
|
||
$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 { } | ||
} | ||
|
||
function New-Report { | ||
[CmdletBinding()] | ||
param ( | ||
[Parameter(Mandatory = $true)] [GroupInfo] $Group | ||
) | ||
|
||
$exportObject = [ordered] @{ | ||
tmaestrini marked this conversation as resolved.
Show resolved
Hide resolved
|
||
"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 | ||
``` | ||
</TabItem> | ||
</Tabs> |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
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.