66
77Find all the Microsoft 365 Groups that a user is an Owner of and replace them with someone else useful for when an employee leaves and ownership needs to be updated.
88
9+ Example usage of CLI for Microsoft 365 sample:
10+
11+ ![ Example CLI for Microsoft 365] ( assets/exampleCLI.png )
12+
913# [ PnP PowerShell] ( #tab/pnpps )
1014
1115``` powershell
@@ -69,63 +73,167 @@ Disconnect-PnPOnline
6973# [ CLI for Microsoft 365] ( #tab/cli-m365-ps )
7074
7175``` powershell
72- $oldOwnerUPN = Read-Host "Enter the old owner UPN to be replaced with" #testUser1@contose.onmicrosoft.com
73- $newOwnerUPN = Read-Host "Enter the new owner UPN to replace with" #testuser2@contoso.onmicrosoft.com
74-
75- #Get Credentials to connect
76- $m365Status = m365 status
77- if ($m365Status -match "Logged Out") {
78- m365 login
76+ [CmdletBinding(SupportsShouldProcess = $true)]
77+ param(
78+ [Parameter(Mandatory, HelpMessage = "UPN of the current owner to replace.")]
79+ [string]$OldOwnerUpn,
80+
81+ [Parameter(Mandatory, HelpMessage = "UPN of the new owner to add.")]
82+ [string]$NewOwnerUpn,
83+
84+ [Parameter(HelpMessage = "Filter applied to group display names when querying Microsoft 365 groups.")]
85+ [string]$DisplayNameFilter = "Permission",
86+
87+ [Parameter(HelpMessage = "Directory where the CSV report will be created.")]
88+ [string]$OutputDirectory,
89+
90+ [Parameter(HelpMessage = "Prefix for the generated CSV report file name.")]
91+ [string]$ReportNamePrefix = "m365GroupOwnersReport",
92+
93+ [switch]$Force
94+ )
95+
96+ begin {
97+ m365 login --ensure
98+
99+ if (-not $OutputDirectory) {
100+ $OutputDirectory = if ($MyInvocation.MyCommand.Path) {
101+ Join-Path -Path (Split-Path -Path $MyInvocation.MyCommand.Path) -ChildPath 'Logs'
102+ } else {
103+ Join-Path -Path (Get-Location).Path -ChildPath 'Logs'
104+ }
105+ }
106+
107+ if (-not (Test-Path -Path $OutputDirectory -PathType Container)) {
108+ New-Item -ItemType Directory -Path $OutputDirectory -Force | Out-Null
109+ }
110+
111+ $timestamp = Get-Date -Format 'yyyyMMdd-HHmmss'
112+ $script:ReportPath = Join-Path -Path $OutputDirectory -ChildPath ("{0}-{1}.csv" -f $ReportNamePrefix, $timestamp)
113+ $script:ReportItems = [System.Collections.Generic.List[psobject]]::new()
114+ $script:Summary = [ordered]@{
115+ GroupsEvaluated = 0
116+ OwnersReplaced = 0
117+ OwnersSimulated = 0
118+ OwnersNotFound = 0
119+ ReplacementFails = 0
120+ }
121+
122+ Write-Host "Starting ownership replacement audit..."
123+ Write-Host "Report will be saved to $ReportPath"
79124}
80125
81- $dateTime = (Get-Date).toString("dd-MM-yyyy")
82- $invocation = (Get-Variable MyInvocation).Value
83- $directorypath = Split-Path $invocation.MyCommand.Path
84- $fileName = "m365GroupOwnersReport-" + $dateTime + ".csv"
85- $OutPutView = $directorypath + "\Logs\"+ $fileName
86-
87- #Array to Hold Result - PSObjects
88- $m365GroupCollection = @()
89-
90- #Retrieve any M365 group starting with "Permission" (you can use filter as per your requirements)
91- $m365Groups = m365 entra m365group list --displayName Permission | ConvertFrom-Json
92-
93- $m365Groups | ForEach-Object {
94- $ExportVw = New-Object PSObject
95- $ExportVw | Add-Member -MemberType NoteProperty -name "Group Name" -value $_.displayName
96- $m365GroupOwnersName = "";
97-
98- try
99- {
100- #Check if old user is an owner of the group
101- $oldOwner = m365 entra m365group user list --groupId $_.id --role Owner --filter "userPrincipalName eq '$($oldOwnerUPN)'"
102-
103- if($oldOwner)
104- {
105- #Add new user as an owner of the group
106- m365 entra m365group user add --groupId $_.id --userName $newOwnerUPN --role Owner
107-
108- #Remove old user from the group
109- m365 entra m365group user remove --groupId $_.id --userName $oldOwnerUPN --force
110- }
111- }
112- catch
113- {
114- write-host $("Error occured while updating the group " + $_.displayName + $Error)
115- }
116-
117- #For auditing purposes - get owners of the group
118- $m365GroupOwnersName = (m365 entra m365group user list --groupId $_.id --role Owner | ConvertFrom-Json | select -ExpandProperty displayName) -join ";";
119-
120- $ExportVw | Add-Member -MemberType NoteProperty -name " Group Owners" -value $m365GroupOwnersName
121- $m365GroupCollection += $ExportVw
126+ process {
127+ $groupArgs = @('entra', 'm365group', 'list', '--output', 'json')
128+ if ($DisplayNameFilter) {
129+ $groupArgs += @('--displayName', $DisplayNameFilter)
130+ }
131+
132+ $groupsOutput = & m365 @groupArgs 2>&1
133+ if ($LASTEXITCODE -ne 0) {
134+ throw "Failed to retrieve Microsoft 365 groups. CLI output: $groupsOutput"
135+ }
136+
137+ $groups = if ([string]::IsNullOrWhiteSpace($groupsOutput)) { @() } else { $groupsOutput | ConvertFrom-Json }
138+ if (-not $groups) {
139+ Write-Host "No groups matched filter '$DisplayNameFilter'."
140+ return
141+ }
142+
143+ foreach ($group in $groups) {
144+ $Summary.GroupsEvaluated++
145+ Write-Host "Processing group '$($group.displayName)' ($($group.id))"
146+
147+ $action = 'Skipped'
148+ $ownersForReport = @()
149+
150+ do {
151+ $ownersOutput = & m365 entra m365group user list --groupId $group.id --role Owner --output json 2>&1
152+ if ($LASTEXITCODE -ne 0) {
153+ Write-Warning " Unable to retrieve owners for $($group.displayName). CLI: $ownersOutput"
154+ $Summary.ReplacementFails++
155+ $ownersForReport = 'Owners unavailable'
156+ $action = 'Failed - Owners lookup'
157+ break
158+ }
159+
160+ $owners = if ([string]::IsNullOrWhiteSpace($ownersOutput)) { @() } else { $ownersOutput | ConvertFrom-Json }
161+ $ownersForReport = $owners
162+ $oldOwner = $owners | Where-Object { $_.userPrincipalName -eq $OldOwnerUpn }
163+
164+ if (-not $oldOwner) {
165+ Write-Host " Old owner '$OldOwnerUpn' not found; skipping"
166+ $Summary.OwnersNotFound++
167+ $action = 'Original owner missing'
168+ break
169+ }
170+
171+ if ($PSCmdlet.ShouldProcess($group.displayName, "Replace owner '$OldOwnerUpn' with '$NewOwnerUpn'")) {
172+ Write-Host " Adding '$NewOwnerUpn' as owner"
173+ $addOutput = & m365 entra m365group user add --groupId $group.id --userNames $NewOwnerUpn --role Owner --output json 2>&1
174+ if ($LASTEXITCODE -ne 0) {
175+ Write-Warning " Failed to add '$NewOwnerUpn'. CLI: $addOutput"
176+ $Summary.ReplacementFails++
177+ $action = 'Failed - Add owner'
178+ break
179+ }
180+
181+ Write-Host " Removing '$OldOwnerUpn' as owner"
182+ $removeArgs = @('entra', 'm365group', 'user', 'remove', '--groupId', $group.id, '--userNames', $OldOwnerUpn, '--output', 'json')
183+ if ($Force) { $removeArgs += '--force' }
184+
185+ $removeOutput = & m365 @removeArgs 2>&1
186+ if ($LASTEXITCODE -ne 0) {
187+ Write-Warning " Failed to remove '$OldOwnerUpn'. CLI: $removeOutput"
188+ $Summary.ReplacementFails++
189+ $action = 'Failed - Remove owner'
190+ break
191+ }
192+
193+ $Summary.OwnersReplaced++
194+ $action = 'Replaced'
195+
196+ $ownersAfterOutput = & m365 entra m365group user list --groupId $group.id --role Owner --output json 2>&1
197+ if ($LASTEXITCODE -eq 0 -and -not [string]::IsNullOrWhiteSpace($ownersAfterOutput)) {
198+ $ownersForReport = $ownersAfterOutput | ConvertFrom-Json
199+ }
200+ } else {
201+ Write-Host " WhatIf: would add '$NewOwnerUpn' and remove '$OldOwnerUpn'"
202+ $Summary.OwnersSimulated++
203+ $action = 'Simulated'
204+ }
205+ } while ($false)
206+
207+ $ownerNames = if ($ownersForReport -is [string]) {
208+ $ownersForReport
209+ } elseif ($ownersForReport) {
210+ ($ownersForReport | Select-Object -ExpandProperty displayName -ErrorAction SilentlyContinue) -join ';'
211+ } else {
212+ ''
213+ }
214+
215+ $ReportItems.Add([pscustomobject]@{
216+ 'Group Name' = $group.displayName
217+ 'Group Id' = $group.id
218+ 'Owners' = $ownerNames
219+ 'Old Owner UPN' = $OldOwnerUpn
220+ 'New Owner UPN' = $NewOwnerUpn
221+ 'Action' = $action
222+ })
223+ }
122224}
123225
124- #Export the result Array to CSV file
125- $m365GroupCollection | sort "Group Name" |Export-CSV $OutPutView -Force -NoTypeInformation
226+ end {
227+ if ($ReportItems.Count -gt 0) {
228+ $ReportItems | Sort-Object 'Group Name' | Export-Csv -Path $ReportPath -NoTypeInformation -Force
229+ Write-Host "Report exported to $ReportPath"
230+ } else {
231+ Write-Host "No groups matched the criteria; nothing exported."
232+ }
126233
127- #Disconnect online connection
128- m365 logout
234+ Write-Host ("Summary: {0} groups evaluated, {1} owners replaced, {2} simulated, {3} groups missing original owner, {4} failures." -f `
235+ $Summary.GroupsEvaluated, $Summary.OwnersReplaced, $Summary.OwnersSimulated, $Summary.OwnersNotFound, $Summary.ReplacementFails)
236+ }
129237```
130238
131239[ !INCLUDE [ More about CLI for Microsoft 365] ( ../../docfx/includes/MORE-CLIM365.md )]
@@ -138,6 +246,7 @@ m365 logout
138246| -----------|
139247| Reshmee Auckloo |
140248| [ Ganesh Sanap] ( https://ganeshsanapblogs.wordpress.com/ ) |
249+ | Adam Wójcik |
141250
142251
143252[ !INCLUDE [ DISCLAIMER] ( ../../docfx/includes/DISCLAIMER.md )]
0 commit comments