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 + ``` + + diff --git a/package.json b/package.json index 7133ed1428..ee9cbcfef2 100644 --- a/package.json +++ b/package.json @@ -177,7 +177,7 @@ "Levert, Sebastien ", "Lingstuyl, Martin ", "Macháček, Martin ", - "Maestrini Tobias ", + "Maestrini, Tobias ", "Maillot, Michaël ", "Mastykarz, Waldek ", "McDonnell, Kevin ", diff --git a/src/m365/spo/commands/list/list-get.spec.ts b/src/m365/spo/commands/list/list-get.spec.ts index ad2786adfd..4dec616c95 100644 --- a/src/m365/spo/commands/list/list-get.spec.ts +++ b/src/m365/spo/commands/list/list-get.spec.ts @@ -1281,4 +1281,24 @@ describe(commands.LIST_GET, () => { const actual = await command.validate({ options: { webUrl: 'https://contoso.sharepoint.com', id: '0CD891EF-AFCE-4E55-B836-FCE03286CCCF' } }, commandInfo); assert(actual); }); -}); + + it('retrieves the default list in the specified site by providing a webUrl', async () => { + const defaultSiteList = { ...listResponse, BaseTemplate: 101, ParentWebUrl: "/", ListItemEntityTypeFullName: "SP.Data.Shared_x0020_DocumentsItem" }; + + sinon.stub(request, 'get').resolves({ + ...defaultSiteList + }); + + await command.action(logger, { + options: { + webUrl: 'https://contoso.sharepoint.com' + } + }); + + assert(loggerLogSpy.calledWithMatch({ + BaseTemplate: 101, + ParentWebUrl: "/", + ListItemEntityTypeFullName: "SP.Data.Shared_x0020_DocumentsItem" + })); + }); +}); \ No newline at end of file diff --git a/src/m365/spo/commands/list/list-get.ts b/src/m365/spo/commands/list/list-get.ts index 53476a01f5..5165480ede 100644 --- a/src/m365/spo/commands/list/list-get.ts +++ b/src/m365/spo/commands/list/list-get.ts @@ -35,7 +35,7 @@ class SpoListGetCommand extends SpoCommand { } public get description(): string { - return 'Gets information about the specific list'; + return 'Gets information about the specific list or returns information about the default list in a site'; } constructor() { @@ -44,7 +44,6 @@ class SpoListGetCommand extends SpoCommand { this.#initTelemetry(); this.#initOptions(); this.#initValidators(); - this.#initOptionSets(); } #initTelemetry(): void { @@ -101,10 +100,6 @@ class SpoListGetCommand extends SpoCommand { ); } - #initOptionSets(): void { - this.optionSets.push({ options: ['id', 'title', 'url'] }); - } - public async commandAction(logger: Logger, args: CommandArgs): Promise { if (this.verbose) { await logger.logToStderr(`Retrieving information for list in site at ${args.options.webUrl}...`); @@ -112,6 +107,9 @@ class SpoListGetCommand extends SpoCommand { let requestUrl: string = `${args.options.webUrl}/_api/web/`; + if (!args.options.id && !args.options.title && !args.options.url) { + requestUrl += `DefaultDocumentLibrary`; + } if (args.options.id) { requestUrl += `lists(guid'${formatting.encodeQueryParameter(args.options.id)}')`; }