-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathInvoke-Leaver.ps1
1475 lines (1354 loc) · 64.8 KB
/
Invoke-Leaver.ps1
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
<#
.SYNOPSIS
Processes a user as a leaver and converts to a shared mailbox.
.DESCRIPTION
Gets users currently in 'Leavers' OU and processes each one sequentially.
Prompts interactively for:
- Whether a user's manager should be delegated OneDrive, mailbox access.
- What to do with the user's mail, forwarding / Out of office / Deny inbound / nothing.
- Whether to add their manager as an owner on Azure AD / Microsoft 365 groups the user owned.
Disables user, resets password to random, hides from Exchange global address list and sets description (note any existing description will be lost).
Depending on interactive choice: For groups the user was the lone owner of replace them with their manager, or all groups they were an owner of, or none.
Removes a user from all local Active Directory (other than exclusions) and Azure AD / Microsoft 365 groups and ownerships.
Converts the mailbox to a shared mailbox and assigns an Exchange Online (Plan 2) license assigned if needed.
Moves to 'Leavers - Shared Mailboxes' OU.
After all users are processed, runs an Azure AD Connect sync and outputs the summary of users.
Currently relies on group based licensing for removing existing licenses and assigning Exchange Online (Plan 2) if needed.
Assumes that all mailboxes are hosted in Exchange Online, and works best with Exchange On-Premises.
.PARAMETER LeaverOU
Name or DistinguishedName of the Organizational Unit containing the users this script should process.
Note that you must use DistinguishedName if more than one OU exists in your Active Directory forest with this name.
.PARAMETER LeaverSharedMailboxOU
Name or DistinguishedName of the Organizational Unit that users should be moved to once processed.
Note that you must use DistinguishedName if more than one OU exists in your Active Directory forest with this name.
.PARAMETER GroupExclusionOUs
Name or DistinguishedName of the Organizational Unit(s), where any groups within should be excluded from removal when processing each user.
Note that you must use DistinguishedName if more than one OU exists in your Active Directory forest with this name.
Explanation of default parameter values:
- Group Writeback:
- If you have the Group Writeback feature of Azure AD Connect enabled, it makes sense to exclude any of those groups from removal when processing as a leaver.
- This is just to reduce chance of confusion as these groups are a read-only representations of the status in Azure AD / Microsoft 365, local changes are overridden on each Azure AD Connect sync.
- Symbolic Groups:
- If you have any groups that are used for keeping track of user permissions or accounts for non-SSO platforms, put them in an OU called 'Symbolic Groups'
- The script will not remove members from these symbolic groups automatically then, as manual action needs performed before removing membership.
.PARAMETER DenyInboundEmailGroup
Name, SamAccountName or DistinguishedName of an Active Directory mail enabled security group or distribution group (synced to Azure AD).
The following Exchange transport rule should be configured (for an example group email alias):
If the message... Is sent to a member of group 'DenyInboundEmail@domain.example'
Do the following... reject the message with the explanation 'Account no longer exists'
.PARAMETER SharedMailboxLicenseGroup
Name, SamAccountName or DistinguishedName of an Active Directory security group (synced to Azure AD).
This group should be configured in Azure AD to assign the appropriate Microsoft 365 license, for example Exchange Online (Plan 2).
Any user objects whose mailbox exceeds 50GB or has an Online Archive / In-Place Archive will be added into this group.
.PARAMETER ExchangeServer
On-Premises Microsoft Exchange Server.
.PARAMETER AzureAdSyncServer
The server running Azure AD Connect.
.PARAMETER NoExchangeOnPremises
Assume no On-Premises Exchange Server is used in this hybrid environment.
Note that Microsoft states this is an unsupported configuration.
.PARAMETER SkipLicenseCheck
Do not check for available licenses, for users who still need a license after conversion to shared mailbox.
.INPUTS
None. You cannot pipe objects to Invoke-Leaver.ps1.
.OUTPUTS
None.
.EXAMPLE
.\Invoke-Leaver.ps1
Welcome To Microsoft Graph!
Initiating Exchange Online PowerShell session
Initiating Exchange On-Premises PowerShell session to 'exc01'
Initiating SharePoint Online PowerShell session to https://contoso-admin.sharepoint.com
Testing User1: Valid remote user mailbox
Testing User1: Valid Exchange Online license
Testing User1: Mailbox has Online Archive / In-Place Archive
Testing User1: Must remain licensed for Exchange Online after conversion to shared mailbox
Testing User1: Warning, only 1 'Exchange Online (Plan 2)' license free
Testing User1: Has manager 'Example Manager'
Testing User1: OneDrive Ownership
Should manager 'Example Manager' be granted OneDrive permission?
[Y] Yes [N] No [T] Terminate [?] Help (default is "Y"):
...
#>
[CmdletBinding(DefaultParameterSetName = "default")]
param (
[ValidateNotNullOrEmpty()]
[String]$LeaverOU = 'Leavers',
[ValidateNotNullOrEmpty()]
[String]$LeaverSharedMailboxOU = 'Leavers - Shared Mailboxes',
[ValidateNotNullOrEmpty()]
[array]$GroupExclusionOUs = ('Group Writeback', 'Symbolic Groups'),
[ValidateNotNullOrEmpty()]
[String]$DenyInboundEmailGroup = 'Deny Inbound Email',
[ValidateNotNullOrEmpty()]
[String]$SharedMailboxLicenseGroup = 'Exchange Online (Plan 2)',
[Parameter(ParameterSetName = 'ExchangeOnPrem')]
[ValidateNotNullOrEmpty()]
[String]$ExchangeServer = 'exc01',
[Alias('AADSync')]
[ValidateNotNullOrEmpty()]
[String]$AzureAdSyncServer = 'sync01',
[switch]$SkipLicenseCheck,
[Parameter(ParameterSetName = 'NoExchangeOnPrem')]
[switch]$NoExchangeOnPremises
)
function Start-ExchangeOnlineSession {
if (Get-PSSession | Where-Object { ($_.State -eq 'Opened') -and $_.ConnectionUri -match 'https://outlook.office365.com/*' }) {
Write-Host 'An Exchange Online PowerShell session is already active' -ForegroundColor Green
}
else {
Write-Host 'Initiating Exchange Online PowerShell session' -ForegroundColor Yellow
# https://docs.microsoft.com/en-us/powershell/exchange/connect-to-exchange-online-powershell
Connect-ExchangeOnline -ShowBanner:$false
}
}
function Start-ExchangeOnPremisesSession {
param (
[Parameter(Mandatory)]
[ValidateNotNullOrEmpty()]
[String]$Server
)
if (Get-PSSession | Where-Object { ($_.State -eq 'Opened') -and $_.ComputerName -match "$Server" }) {
Write-Host "An Exchange On-Premises PowerShell session to '$Server' is already active" -ForegroundColor Green
}
else {
Write-Host "Initiating Exchange On-Premises PowerShell session to '$Server'" -ForegroundColor Yellow
# https://docs.microsoft.com/en-us/powershell/exchange/connect-to-exchange-servers-using-remote-powershell?view=exchange-ps
$global:OnPremisesSession = New-PSSession -ConfigurationName Microsoft.Exchange -ConnectionUri http://$Server/PowerShell/
# Using a prefix which allows distinguishing between On-Premises and Exchange Online
Import-PSSession $OnPremisesSession -DisableNameChecking -Prefix OnPremises -AllowClobber | Out-Null
}
}
function Start-SharePointOnlineSession {
param (
[Parameter(Mandatory)]
[ValidatePattern('^https:\/\/[a-z,A-Z,0-9]*-admin\.sharepoint\.com$')]
[String]$Url
)
try {
Get-SPOSite -Identity $Url -ErrorAction Stop | Out-Null
Write-Host "A SharePoint Online PowerShell session is already active to $Url" -ForegroundColor Green
}
catch {
Write-Host "Initiating SharePoint Online PowerShell session to $Url" -ForegroundColor Yellow
# https://docs.microsoft.com/en-us/powershell/exchange/connect-to-exchange-online-powershell
Connect-SPOService -Url $Url
}
}
function Resolve-LicensePlan {
param (
[ValidateSet('Mailbox')]
[string]$Type,
[System.Object]$AssignedPlans
)
# Minimum license service plan a user must have to count as entitled to a mailbox for our purposes
# Licenses like Microsoft 365 E3, Office 365 E1, Office 365 Business Premium all contain one of these - or bought standalone
# License reference: https://docs.microsoft.com/en-us/azure/active-directory/enterprise-users/licensing-service-plan-reference
$minimumMailboxLicenses = @{
'9aaf7827-d63c-4b61-89c3-182f06f82e5c' = 'Exchange Online (Plan 1)'
'efb87545-963c-4e0d-99df-69c6916d9eb0' = 'EXCHANGE ONLINE (PLAN 2)'
'4a82b400-a79f-41a4-b4e2-e94f5787b113' = 'EXCHANGE ONLINE KIOSK'
'90927877-dcff-4af6-b346-2332c0b15bb7' = 'EXCHANGE ONLINE POP'
'8c3069c0-ccdb-44be-ab77-986203a67df2' = 'EXCHANGE PLAN 2G'
'fc52cc4b-ed7d-472d-bbe7-b081c23ecc56' = 'EXCHANGE ONLINE PLAN 1'
'd42bdbd6-c335-4231-ab3d-c8f348d5aff5' = 'EXCHANGE ONLINE (P1)'
}
if ($Type -eq 'Mailbox') {
if (($AssignedPlans | Where-Object { $_.CapabilityStatus -eq 'Enabled' } | ForEach-Object { $_.servicePlanId -in $minimumMailboxLicenses.Keys }) -contains $true ) {
# Return true if the servicePlanId of $AssignedPlans was found within the keys of $minimumMailboxLicenses
$true
}
else {
$false
}
}
}
function Get-RandomPassword {
# Generate a 24 character password, possible characters: a-z, A-Z, 0-9, {]+-[*=@:)}$^%;(_!&#?>/|.
$charSet = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789{]+-[*=@:)}$^%;(_!&;#?>/|.'.ToCharArray()
(1..24 | ForEach-Object { $charSet | Get-Random }) -join ''
}
function Start-AzureAdSync {
param (
[Parameter(Mandatory)]
[Alias('ComputerName')]
[ValidateNotNullOrEmpty()]
[String]$Server,
[Alias('PolicyType')]
[ValidateSet('Delta', 'Initial')]
[String]$Type = 'Delta',
[Parameter(ValueFromPipelineByPropertyName)]
[PSCredential]$Credential
)
$parameters = @{
ComputerName = $Server
}
if ($Credential) {
# If the 'Credential' parameter is supplied to this function, include it in the parameters for Invoke-Command
$parameters['Credential'] = $Credential
}
Invoke-Command @parameters -ScriptBlock {
Import-Module ADSync
try {
Start-ADSyncSyncCycle -PolicyType $Type -ErrorAction Stop | Out-Null
Write-Host ("{0}: Azure AD Connect {1} sync started" -f $Using:Server, $Using:Type) -ForegroundColor Green
}
catch {
switch ($_) {
{ $_ -match 'Sync is already running' } {
Write-Error ("{0}: Azure AD Connect sync error 'Sync is already running'" -f $Using:Server)
Break
}
{ $_ -match 'AAD is busy' } {
Write-Error ("{0}: Azure AD Connect sync error 'AAD is busy', a sync is likely already in progress" -f $Using:Server)
Break
}
Default {
Write-Error $_
Write-Error ("{0}: Failed to Azure AD Connect {1} sync" -f $Using:Server, $Using:Type)
}
}
}
}
}
$standardOptions = @(
@{
Name = 'Yes'
HelpText = 'Perform this action'
} # Default Option
@{
Name = 'No'
HelpText = 'Skip this action'
}
@{
Name = 'Terminate'
HelpText = 'Stop all actions and end this script'
}
)
function Read-UserChoice {
param (
[Parameter(Mandatory)]
[ValidateNotNullOrEmpty()]
[string]$Title,
[Parameter(Mandatory)]
[ValidateNotNullOrEmpty()]
[string]$Message,
[int]$Default = 0,
[ValidateNotNullOrEmpty()]
[array]$Options = $standardOptions
)
# Build the individual choices and help text from the $Options variable
$choices = $Options | ForEach-Object {
New-Object System.Management.Automation.Host.ChoiceDescription "&$($_.Name)", "$($_.HelpText)"
}
# Combine the choices into $promptOptions
$promptOptions = [System.Management.Automation.Host.ChoiceDescription[]]($choices)
# Invoke our prompt, choice will be stored to $result variable
$result = $host.UI.PromptForChoice($Title, $Message, $promptOptions, $Default)
# Output to pipeline the 'Name' of the chosen option - rather than an integer
$Options.Name[$result]
}
function Set-MailboxOutOfOffice {
param (
[Parameter(Mandatory)]
[ValidateNotNullOrEmpty()]
[object]$User,
[object]$Manager
)
try {
$messageWithManager = "{0} is no longer with {1}, please contact {2} ({3})." -f $User.DisplayName, $User.Company, $Manager.DisplayName, $Manager.EmailAddress
$messageWithoutManager = "{0} is no longer with {1}." -f $User.DisplayName, $User.Company
if ($manager) {
$message = $messageWithManager
}
else {
$message = $messageWithoutManager
}
# Build parameters to splat
$parameters = @{
Identity = $User.UserPrincipalName
AutoReplyState = 'Enabled'
ExternalAudience = 'All'
InternalMessage = $message
ExternalMessage = $message
}
Set-MailboxAutoReplyConfiguration @parameters -ErrorAction Stop
Write-Host ("{0}: Set Out of Office message in Exchange Online" -f $User.DisplayName) -ForegroundColor Green
}
catch {
Write-Error ("{0}: Failed to set Out of Office message in Exchange Online" -f $User.DisplayName)
}
}
function Set-MailboxForwarding {
param (
[Parameter(Mandatory)]
[ValidateNotNullOrEmpty()]
[object]$User,
[Parameter(Mandatory)]
[ValidateNotNullOrEmpty()]
[object]$Manager
)
try {
Set-Mailbox -Identity $User.UserPrincipalName -DeliverToMailboxAndForward $false -ForwardingSMTPAddress $Manager.EmailAddress
Write-Host ("{0}: Set email forwarding to manager '{1}'" -f $User.DisplayName, $Manager.DisplayName) -ForegroundColor Green
}
catch {
Write-Error ("{0}: Failed to set email forwarding to manager '{1}'" -f $User.DisplayName, $Manager.DisplayName)
}
}
function Set-MailboxReadAndManage {
param (
[Parameter(Mandatory)]
[ValidateNotNullOrEmpty()]
[object]$User,
[Parameter(Mandatory)]
[ValidateNotNullOrEmpty()]
[object]$Manager
)
try {
Add-MailboxPermission -Identity $User.UserPrincipalName -User $Manager.UserPrincipalName -AccessRights FullAccess | Out-Null
Write-Host ("{0}: Granted Read & Manage permission to '{1}'" -f $User.DisplayName, $Manager.DisplayName) -ForegroundColor Green
}
catch {
Write-Error ("{0}: Failed to grant Read & Manage permission to '{1}'" -f $User.DisplayName, $Manager.DisplayName)
}
}
function Add-OneDriveSiteAdmin {
param (
[Parameter(Mandatory)]
[ValidateNotNullOrEmpty()]
[object]$AzureAdUser,
[Parameter(Mandatory)]
[ValidateNotNullOrEmpty()]
[object]$Manager
)
try {
Set-SPOUser -Site $AzureAdUser.mySite -LoginName $Manager.UserPrincipalName -IsSiteCollectionAdmin $true -ErrorAction Stop | Out-Null
Write-Host ("{0}: Added OneDrive site collection administrator of '{1}'" -f $AzureAdUser.DisplayName, $Manager.DisplayName) -ForegroundColor Green
Write-Host ("{0}: OneDrive URL: {1}" -f $AzureAdUser.DisplayName, $AzureAdUser.mySite) -ForegroundColor Cyan
}
catch {
Write-Error $_
Write-Error ("{0}: Failed to add OneDrive site collection administrator of '{1}'" -f $AzureAdUser.DisplayName, $Manager.DisplayName)
}
}
function Disable-OneDriveExternalSharing {
param (
[Parameter(Mandatory)]
[ValidateNotNullOrEmpty()]
[object]$AzureAdUser
)
try {
# Remove the trailing '/' from the mySite URL, otherwise we get the error 'The managed path ... is not a managed path in this tenant.'
Set-SPOSite -Identity ($AzureAdUser.mySite -replace '/$', '') -SharingCapability Disabled -ErrorAction Stop | Out-Null
Write-Host ("{0}: Disabled OneDrive external sharing" -f $AzureAdUser.DisplayName) -ForegroundColor Green
}
catch {
Write-Error $_
Write-Error ("{0}: Failed to disable OneDrive external sharing" -f $AzureAdUser.DisplayName)
}
}
function Set-ADGroupMembership {
param (
[Parameter(Mandatory)]
[ValidateSet('Add', 'Remove')]
[string]$Action,
[Parameter(Mandatory)]
[ValidateNotNullOrEmpty()]
[object]$User,
[Parameter(Mandatory)]
[ValidateNotNullOrEmpty()]
[object]$Groups
)
foreach ($group in $Groups) {
$status = $null
try {
switch ($Action) {
'Add' {
$group | Add-ADGroupMember -Members $User
}
'Remove' {
$group | Remove-ADGroupMember -Members $User -Confirm:$false
}
}
$status = 'Success'
}
catch {
Write-Error ("{0}: Failed to {1} group '{2}'" -f $User.DisplayName, $Action.ToLower(), $group.Name)
$status = 'Failed'
}
# Output a PSCustomObject to the pipeline containing the status of the current group in the loop
[PSCustomObject] @{
'Type' = 'Active Directory'
'Relationship' = 'Member'
'Name' = $group.Name
'Action' = $Action
'Status' = $status
}
}
}
function Add-AzureADManagerOwnership {
param (
[Parameter(Mandatory)]
[ValidateNotNullOrEmpty()]
[object]$AzureAdUser,
[Parameter(Mandatory)]
[ValidateNotNullOrEmpty()]
[object]$Manager,
[Parameter(Mandatory)]
[object]$Groups
)
foreach ($group in $Groups) {
try {
$params = @{
"@odata.id" = "https://graph.microsoft.com/v1.0/users/$($Manager.EmailAddress)"
}
New-MgGroupOwnerByRef -GroupId $group.Id -BodyParameter $params -ErrorAction Stop | Out-Null
Write-Host ("{0}: Added manager '{1}' as an owner of group '{2}' in Azure AD / Microsoft 365" -f $AzureAdUser.DisplayName, $manager.DisplayName, $group.DisplayName) -ForegroundColor Green
}
catch {
Write-Error $_
Write-Error ("{0}: Failed to add manager '{1}' as an owner of group '{2}' in Azure AD / Microsoft 365" -f $AzureAdUser.DisplayName, $manager.DisplayName, $group.DisplayName)
}
}
}
function Remove-AzureADMembership {
param (
[Parameter(Mandatory)]
[ValidateNotNullOrEmpty()]
[object]$AzureAdUser,
[object]$Member,
[object]$Owner
)
foreach ($group in $Member) {
$status = $null
try {
# No PowerShell native Cmdlet, so we use Invoke-MgGraphRequest to call the Graph API directly
Invoke-MgGraphRequest -Method DELETE -Uri "v1.0/groups/$($group.Id)/members/$($AzureAdUser.Id)/`$ref" | Out-Null
Write-Host ("{0}: Removed membership from Azure AD / Microsoft 365 group '{1}'" -f $AzureAdUser.DisplayName, $group.DisplayName) -ForegroundColor Green
$status = 'Success'
}
catch {
Write-Error $_
Write-Error ("{0}: Failed to remove membership of Azure AD / Microsoft 365 group '{1}'" -f $AzureAdUser.DisplayName, $group.DisplayName)
$status = 'Failed'
}
[PSCustomObject] @{
'Type' = 'Azure AD / Microsoft 365'
'Relationship' = 'Member'
'Name' = $group.DisplayName
'Action' = 'Remove'
'Status' = $status
}
}
foreach ($group in $Owner) {
$status = $null
try {
# No PowerShell native Cmdlet, so we use Invoke-MgGraphRequest to call the Graph API directly
Invoke-MgGraphRequest -Method DELETE -Uri "v1.0/groups/$($group.Id)/owners/$($AzureAdUser.Id)/`$ref" | Out-Null
Write-Host ("{0}: Removed ownership from Azure AD / Microsoft 365 group '{1}'" -f $AzureAdUser.DisplayName, $group.DisplayName) -ForegroundColor Green
$status = 'Success'
}
catch {
Write-Error $_
Write-Error ("{0}: Failed to remove membership of Azure AD / Microsoft 365 group '{1}'" -f $AzureAdUser.DisplayName, $group.DisplayName)
$status = 'Failed'
}
# Output a PSCustomObject to the pipeline containing the status of the current group in the loop
[PSCustomObject] @{
'Type' = 'Azure AD / Microsoft 365'
'Relationship' = 'Owner'
'Name' = $group.DisplayName
'Action' = 'Remove'
'Status' = $status
}
}
}
function Test-AzureADgroupLoneOwner {
param (
[Parameter(Mandatory)]
[ValidateNotNullOrEmpty()]
[object[]]$Groups,
[Parameter(Mandatory)]
[ValidateNotNullOrEmpty()]
[object]$AzureAdUser
)
foreach ($group in $Groups) {
$status = $null
try {
$groupOwners = Get-MgGroupOwner -GroupId $Group.Id
if (($groupOwners.Count -eq 1) -and ($groupOwners.Id -eq $AzureAdUser.Id) ) {
Write-Host ("{0}: User is the only owner of Azure AD / Microsoft 365 group '{1}'" -f $AzureAdUser.DisplayName, $Group.DisplayName) -ForegroundColor Yellow
$status = $true
}
else {
$status = $false
}
}
catch {
Write-Error $_
Write-Error ("Error getting list of group owners for '{0}'" -f $group.Name)
$status = 'Error'
}
# Output a PSCustomObject to the pipeline containing the owner status of the current group in the loop
[PSCustomObject] @{
'Group' = $Group
'OwnerCount' = $groupOwners.Count
'OnlyOwner' = $status
}
}
}
function Get-GroupLicenseStatus {
param (
[Parameter(Mandatory)]
[ValidateNotNullOrEmpty()]
[object]$Group
)
try {
$mgGroupParams = @{
Filter = "OnPremisesSamAccountName eq '$($Group.SamAccountName)'"
Property = 'DisplayName', 'AssignedLicenses'
ConsistencyLevel = 'eventual'
Count = 'groupCount'
ErrorAction = 'Stop'
}
# Find the Azure AD group object that comes from the Active Directory group (via Azure AD Connect)
$azureAdLicenseGroup = Get-MgGroup @mgGroupParams
# List all licenses the tenant has
$tenantSubscribedSkus = Get-MgSubscribedSku
# Filter the tenant licenses down to only the SkuId that the license group has
$groupLicense = $tenantSubscribedSkus | Where-Object { $_.SkuId -eq $azureAdLicenseGroup.AssignedLicenses.SkuId } | ForEach-Object {
# Expand out properties from PrepaidUnits, and add 'UnassignedUnits'
[PSCustomObject]@{
Id = $_.Id
AppliesTo = $_.AppliesTo
CapabilityStatus = $_.CapabilityStatus
ConsumedUnits = $_.ConsumedUnits
EnabledUnits = $_.PrepaidUnits.Enabled
SuspendedUnits = $_.PrepaidUnits.Suspended
WarningUnits = $_.PrepaidUnits.Warning
UnassignedUnits = ($_.PrepaidUnits.Enabled - $_.ConsumedUnits)
SkuId = $_.SkuId
SkuPartNumber = $_.SkuPartNumber
ServicePlans = $_.ServicePlans
}
}
# Output the groupLicense object to the pipeline
$groupLicense
}
catch {
Write-Error $_
Write-Error ("Error getting license status of group '{0}'" -f $group.Name)
}
}
function Get-StatusObject {
param (
[Parameter(Mandatory)]
[ValidateNotNullOrEmpty()]
[object]$User,
[Parameter(Mandatory)]
[ValidateSet('Completed', 'Skipped', 'Errored')]
[string]$Status,
[ValidateNotNullOrEmpty()]
[string]$Reason
)
[PSCustomObject] @{
'DisplayName' = $User.DisplayName
'UserPrincipalName' = $User.UserPrincipalName
'Status' = $Status
# Take the error message passed in as the 'Reason' parameter and remove the user's DisplayName from the start (if it was present)
'Reason' = ($Reason -replace "$($User.DisplayName): ", '')
}
}
function Get-Object {
param (
[Parameter(Mandatory)]
[ValidateSet('Group', 'OrganizationalUnit')]
[string]$Type,
[Parameter(Mandatory)]
[ValidateNotNullOrEmpty()]
[array]$Identity
)
$commonParameters = @{
Properties = 'DistinguishedName', 'Name', 'SamAccountName', 'ObjectSID'
ErrorAction = 'Stop'
}
foreach ($singleIdentity in $Identity) {
if ($singleIdentity -match $dnRegex) {
# If the $Identity value is a distinguished name use the Identity parameter to get an exact match
$parameters = @{
Identity = "$singleIdentity"
}
$results = Get-ADObject @commonParameters @parameters
}
else {
# If the $singleIdentity value was not a distinguished name assume it is a Name or SamAccountName
# Unfortunately the Identity parameter does not support Name, so we will use the Filter parameter - which might return more than one result
# Note for groups an edge case is possible where one group has the Name of $Identity, and another group has the SamAccountName of $singleIdentity
# In that case you would always have too many objects being returned
$parameters = @{
Filter = "((Name -eq '$singleIdentity') -or (SamAccountName -eq '$singleIdentity')) -and (ObjectClass -eq '$Type')"
}
$results = Get-ADObject @commonParameters @parameters
}
$resultsCount = ($results | Measure-Object).Count
switch ($resultsCount) {
{ $_ -eq 0 } {
Write-Error ("Could not find a {0} with Identity '{1}'" -f $Type, $singleIdentity) -ErrorAction Stop
Break
}
{ $_ -eq 1 } {
$results
Break
}
{ $_ -gt 1 } {
# If more than one object was returned from our Get-ADGroup command
Write-Error ("Too many {0} objects returned ({1}) matching Identity '{2}', max 1" -f $Type, $resultsCount, $singleIdentity) -ErrorAction Stop
Break
}
}
}
}
# https://docs.microsoft.com/en-us/powershell/module/microsoft.graph.users
# https://docs.microsoft.com/en-us/powershell/module/microsoft.graph.groups
# https://docs.microsoft.com/en-us/powershell/module/microsoft.graph.identity.directorymanagement
Import-Module -Name Microsoft.Graph.Users, Microsoft.Graph.Groups, Microsoft.Graph.Identity.DirectoryManagement -ErrorAction Stop
if (-not $NoExchangeOnPremises) {
# Run when using Exchange On-Premises
# https://docs.microsoft.com/en-us/powershell/module/exchange
Import-Module -Name ExchangeOnlineManagement -ErrorAction Stop
}
# https://docs.microsoft.com/en-us/powershell/module/sharepoint-online
Import-Module -Name Microsoft.Online.SharePoint.PowerShell -ErrorAction Stop -WarningAction SilentlyContinue
# https://docs.microsoft.com/en-us/powershell/module/activedirectory
Import-Module -Name ActiveDirectory -ErrorAction Stop
# Start the session for Microsoft Graph with the permission scopes we need and send output to console (interactive modern auth prompt)
Write-Host (Connect-MgGraph -Scopes User.ReadWrite.All, Group.ReadWrite.All, Directory.Read.All -ErrorAction Stop)
# Start the session for Exchange Online (interactive modern auth prompt)
Start-ExchangeOnlineSession
if (-not $NoExchangeOnPremises) {
# Run when using Exchange On-Premises
# Start the session with Exchange On-Premises (authentication from your PowerShell session with Kerberos or basic auth)
# Note that all cmdlets from Exchange On-Premises will be prefixed with 'OnPremises' to allow easy distinguishing between On-Premises and Exchange Online
Start-ExchangeOnPremisesSession -Server $ExchangeServer
}
# Get the current tenant's root SharePoint site, then take the WebUrl and add '-admin' after the tenant name
# Note that this does not work with the legacy 'Vanity SharePoint Domain' feature from BPOS-D / Office 365 Dedicated, eg. sharepoint.contoso.com rather than contoso.sharepoint.com
$rootSiteAdminUrl = (Get-MgSite -SiteId root).WebUrl -replace '.sharepoint.com', '-admin.sharepoint.com'
# Connect to SharePoint Online PowerShell (interactive modern auth prompt)
Start-SharePointOnlineSession -Url $rootSiteAdminUrl
# Regex from Daniele Catanesi https://pscustomobject.github.io/powershell/howto/identity%20management/PowerShell-Check-If-String-Is-A-DN/
[regex]$dnRegex = '^(?:(?<cn>CN=(?<name>(?:[^,]|\,)*)),)?(?:(?<path>(?:(?:CN|OU)=(?:[^,]|\,)+,?)+),)?(?<domain>(?:DC=(?:[^,]|\,)+,?)+)$'
try {
$leaverOuObject = Get-Object -Type OrganizationalUnit -Identity $LeaverOU
$leaverSharedMailboxOuObject = Get-Object -Type OrganizationalUnit -Identity $LeaverSharedMailboxOU
$groupExclusionOuObjects = Get-Object -Type OrganizationalUnit -Identity $GroupExclusionOUs
$denyInboundEmailGroupObject = Get-Object -Type Group -Identity $DenyInboundEmailGroup
$sharedMailboxLicenseGroupObject = Get-Object -Type Group -Identity $SharedMailboxLicenseGroup
}
catch {
Write-Error $_ -ErrorAction Stop
}
# Build a regex expression out of the $groupExclusionOuObjects array
# Regex modified from Rob Campbell / Ed Wilson https://devblogs.microsoft.com/scripting/speed-up-array-comparisons-in-powershell-with-a-runtime-regex/
[regex]$groupExclusionOuDnRegex = '(?i)(' + (($groupExclusionOuObjects.DistinguishedName | ForEach-Object { [regex]::escape($_) }) -join '|') + ')$'
# Get all users from the Leavers OU in Active Directory
$users = Get-ADUser -SearchBase $leaverOuObject -Filter * -Properties Company, DisplayName, DistinguishedName, EmailAddress, Manager, MemberOf, SamAccountName, UserPrincipalName
# Get all Azure AD / Microsoft 365 groups that do not come from Active Directory (via Azure AD Connect)
$azureAdGroups = Get-MgGroup -Filter 'onPremisesSyncEnabled ne true' -ConsistencyLevel eventual -Count groupCount
[System.Collections.Generic.List[Object]]$userStatus = @()
:UserForeach foreach ($user in $users) {
[System.Collections.Generic.List[Object]]$actions = @()
[System.Collections.Generic.List[Object]]$groupStatus = @()
try {
switch ($user) {
{ $null -eq $_.EmailAddress } {
Write-Error ("{0}: EmailAddress is blank" -f $_.DisplayName) -ErrorAction Stop
}
{ $null -eq $_.Company } {
Write-Error ("{0}: Company is blank" -f $_.DisplayName) -ErrorAction Stop
}
}
}
catch {
# Add the current user to $userStatus with the status of Skipped
[void]$userStatus.Add(
$(Get-StatusObject -User $User -Status Skipped -Reason $_)
)
# Skip the current user in the foreach loop
Continue UserForeach
}
try {
$azureAdUser = Get-MgUser -UserId $user.UserPrincipalName -Property id, displayName, userPrincipalName, userType, accountEnabled, assignedPlans, mySite
}
catch {
$errorMessage = "{0}: No Azure AD / Microsoft 365 user" -f $user.DisplayName
Write-Error $errorMessage
# Add the current user to $userStatus with the status of Skipped
[void]$userStatus.Add(
$(Get-StatusObject -User $User -Status Skipped -Reason $errorMessage)
)
# Skip the current user in the foreach loop if this 'catch' condition is met
Continue UserForeach
}
if (-not $NoExchangeOnPremises) {
# Run when using Exchange On-Premises
try {
$remoteMailbox = Get-OnPremisesRemoteMailbox -Identity $user.UserPrincipalName -ErrorAction Stop
}
catch {
$errorMessage = "{0}: No mailbox known by '{1}'" -f $user.DisplayName, $ExchangeServer
Write-Error $errorMessage
# Add the current user to $userStatus with the status of Skipped
[void]$userStatus.Add(
$(Get-StatusObject -User $User -Status Skipped -Reason $errorMessage)
)
# Skip the current user in the foreach loop if this 'catch' condition is met
Continue UserForeach
}
if ($remoteMailbox.RecipientTypeDetails -eq 'RemoteUserMailbox') {
Write-Host ("{0}: Valid remote user mailbox" -f $user.DisplayName) -ForegroundColor Green
}
else {
$errorMessage = "{0}: No valid remote user mailbox. Expected 'RemoteUserMailbox' got '{1}'" -f $user.DisplayName, $remoteMailbox.RecipientTypeDetails
Write-Error $errorMessage
# Add the current user to $userStatus with the status of Skipped
[void]$userStatus.Add(
$(Get-StatusObject -User $User -Status Skipped -Reason $errorMessage)
)
# Skip the current user in the foreach loop if this 'else' condition is met
Continue UserForeach
}
}
if (Resolve-LicensePlan -Type Mailbox -AssignedPlans $azureAdUser.assignedPlans) {
Write-Host ("{0}: Valid Exchange Online license" -f $user.DisplayName) -ForegroundColor Green
}
else {
$errorMessage = "{0}: No valid Exchange Online license. License is required at the time of conversion" -f $user.DisplayName
Write-Error $errorMessage
# Add the current user to $userStatus with the status of Skipped
[void]$userStatus.Add(
$(Get-StatusObject -User $User -Status Skipped -Reason $errorMessage)
)
# Skip the current user in the foreach loop if this 'else' condition is met
Continue UserForeach
}
try {
# Get the Exchange Online mailbox object of the current user
$exoMailbox = Get-EXOMailbox -Identity $user.UserPrincipalName -PropertySets Minimum, Archive -ErrorAction Stop
if ($exoMailbox.RecipientTypeDetails -eq 'UserMailbox') {
Write-Host ("{0}: Valid Exchange Online mailbox" -f $user.DisplayName) -ForegroundColor Green
}
else {
$errorMessage = "{0}: No valid Exchange Online mailbox. Expected 'UserMailbox' got '{1}'" -f $user.DisplayName, $exoMailbox.RecipientTypeDetails
Write-Error $errorMessage
# Add the current user to $userStatus with the status of Skipped
[void]$userStatus.Add(
$(Get-StatusObject -User $User -Status Skipped -Reason $errorMessage)
)
# Skip the current user in the foreach loop if this 'else' condition is met
Continue UserForeach
}
# Get the Exchange Online mailbox statistics and take the value sub-property from TotalItemSize
$exoMailboxSize = ($exoMailbox | Get-EXOMailboxStatistics -ErrorAction Stop).TotalItemSize.Value
}
catch {
Write-Error $_
# Add the current user to $userStatus with the status of Skipped
[void]$userStatus.Add(
$(Get-StatusObject -User $User -Status Skipped -Reason $_)
)
# Skip the current user in the foreach loop
Continue UserForeach
}
if (($exoMailbox.ArchiveStatus -eq 'Active') -or ($exoMailbox.ArchiveState -eq 'Local') -or ($exoMailboxSize -gt 50GB)) {
if ($exoMailboxSize -gt 50GB) {
Write-Host ("{0}: Mailbox larger than 50GB" -f $user.DisplayName) -ForegroundColor Cyan
}
if (($exoMailbox.ArchiveStatus -eq 'Active') -or ($exoMailbox.ArchiveState -eq 'Local')) {
Write-Host ("{0}: Mailbox has Online Archive / In-Place Archive" -f $user.DisplayName) -ForegroundColor Cyan
}
Write-Host ("{0}: Must remain licensed for Exchange Online after conversion to shared mailbox" -f $user.DisplayName) -ForegroundColor Yellow
# Add to $actions
[void]$actions.Add(
@{ 'LicenseRequiredAfterConversion' = $true }
)
if ($SkipLicenseCheck) {
Write-Host ("{0}: Skipping available license check for '{1}'" -f $user.DisplayName, $sharedMailboxLicenseGroupObject.Name)
}
else {
:LicenseLoop while ($true) {
$licenseStatus = Get-GroupLicenseStatus -Group $sharedMailboxLicenseGroupObject
switch ($licenseStatus) {
{ $_.UnassignedUnits -eq 0 } {
$choiceLicenseIssueParams = @{
Title = "{0}: No '{1}' license free" -f $user.DisplayName, $sharedMailboxLicenseGroupObject.Name
Message = "User requires a {0} license, but there are no remaining licenses free" -f $sharedMailboxLicenseGroupObject.Name
Options = @(
@{
Name = 'Retry'
HelpText = 'Check available licenses again, for example after freeing up a license or adding more'
} # Default Option
@{
Name = 'Skip'
HelpText = 'Skip the current user and do not perform any actions on them'
}
)
}
switch (Read-UserChoice @choiceLicenseIssueParams) {
'Retry' { Break }
'Skip' {
Write-Host ("{0}: Skipping user without performing changes" -f $user.DisplayName)
# Add the current user to $userStatus with the status of Skipped. Add a specific Reason
[void]$userStatus.Add(
$(Get-StatusObject -User $User -Status Skipped -Reason ("No remaining {0} licenses free" -f $sharedMailboxLicenseGroupObject.Name))
)
Continue UserForeach
}
}
Break
}
{ $_.UnassignedUnits -eq 1 } {
Write-Host ("{0}: Warning, only 1 '{1}' license free" -f $user.DisplayName, $sharedMailboxLicenseGroupObject.Name) -ForegroundColor Yellow
Break LicenseLoop
}
Default { Break LicenseLoop }
}
}
}
}
else {
Write-Host ("{0}: Can be unlicensed after conversion to shared mailbox" -f $user.DisplayName) -ForegroundColor Green
[void]$actions.Add(
@{ 'LicenseRequiredAfterConversion' = $false }
)
}
if ($user.Manager) {
# User has a manager defined
try {
$manager = Get-AdUser $user.Manager -Properties Enabled, DisplayName, EmailAddress, UserPrincipalName -ErrorAction SilentlyContinue
switch ($manager) {
{ $_.Enabled -eq $false } {
Write-Error ("{0}: Manager '{1}' object is Disabled" -f $user.DisplayName, $_.DisplayName) -ErrorAction Stop
}
{ $null -eq $_.EmailAddress } {
Write-Error ("{0}: Manager '{1}' EmailAddress is blank" -f $user.DisplayName, $_.DisplayName) -ErrorAction Stop
}
}
Write-Host ("{0}: Has manager '{1}'" -f $user.DisplayName, $manager.DisplayName)
}
catch {
Write-Error $_
# Add the current user to $userStatus with the status of Skipped
[void]$userStatus.Add(
$(Get-StatusObject -User $User -Status Skipped -Reason $_)
)
# Skip the current user in the foreach loop if this 'catch' condition is met
Continue UserForeach
}
}
else {
$manager = $null
$choiceManagerParams = @{
Title = "{0}: No manager defined" -f $user.DisplayName
Message = 'Continue without a manager? No forwarding or delegation of Mailbox, OneDrive etc. can be performed'
}
switch (Read-UserChoice @choiceManagerParams) {
'Yes' { Break }
'No' {
Write-Host ("{0}: Skipping user without performing changes" -f $user.DisplayName)
# Add the current user to $userStatus with the status of Skipped. Add a specific Reason
[void]$userStatus.Add(
$(Get-StatusObject -User $User -Status Skipped -Reason 'Skipped by user')
)
Continue UserForeach
}
'Terminate' {
Break UserForeach
}
}
}
if ($manager) {
if ($azureAdUser.mySite) {
$choiceOneDriveParams = @{
Title = "{0}: OneDrive Ownership" -f $user.DisplayName
Message = "Should manager '{0}' be granted OneDrive permission?" -f $manager.DisplayName
}
switch (Read-UserChoice @choiceOneDriveParams) {
'Yes' {
[void]$actions.Add(
@{ 'OneDriveManagerPermission' = $true }
)
}
'No' { Break }
'Terminate' {
Break UserForeach
}
}
}
else {
Write-Host ("{0}: User has no OneDrive provisioned" -f $user.DisplayName)
}
$choiceMailboxPermissionParams = @{
Title = "{0}: Mailbox permission" -f $user.DisplayName
Message = "Should manager '{0}' be granted Read & Manage permission?" -f $manager.DisplayName
}
switch (Read-UserChoice @choiceMailboxPermissionParams) {
'Yes' {
[void]$actions.Add(
@{ 'MailboxManagerPermission' = $true }
)
}
'No' { Break }
'Terminate' {
Break UserForeach
}
}
}
$choiceMailHandlingParams = @{