Skip to content

Commit 4a64d18

Browse files
committed
Add Convert-HybridGroupToCloud function to manage group synchronization from hybrid to cloud
1 parent 60d1c4e commit 4a64d18

File tree

1 file changed

+248
-0
lines changed

1 file changed

+248
-0
lines changed
Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
function Convert-HybridGroupToCloud {
2+
<#
3+
.SYNOPSIS
4+
Convert synchronized Active Directory groups from hybrid to cloud-managed.
5+
6+
.DESCRIPTION
7+
Reads group identifiers from a text file or resolves them by display name, checks each group's
8+
OnPremisesSyncEnabled property, and if applicable, calls the onPremisesSyncBehavior API to set
9+
isCloudManaged to true. This effectively changes the source of authority (SOA) for groups from Active Directory
10+
to Entra ID.
11+
12+
.PARAMETER FilePath
13+
Path to a text file that contains one group Id (GUID) per line.
14+
Cannot be used with the Group parameter.
15+
16+
.PARAMETER Group
17+
One or more group display names to target.
18+
Accepts input from the pipeline.
19+
Cannot be used with the FilePath parameter.
20+
21+
.EXAMPLE
22+
Convert-HybridGroupToCloud -FilePath "C:\temp\groups.txt"
23+
24+
Converts the SOA for list of group IDs in the groups.txt file.
25+
26+
.EXAMPLE
27+
'HR Team','Finance' | Convert-HybridGroupToCloud -Confirm
28+
29+
Converts the SOA for one or more groups (referenced by display name), with confirmation prompts.
30+
31+
.NOTES
32+
Requires Microsoft.Graph.Groups PowerShell module and appropriate permissions:
33+
34+
Group.ReadWrite.All
35+
Group-OnPremisesSyncBehavior.ReadWrite.All
36+
37+
The input file should contain one group ID (GUID) per line.
38+
39+
Author: Sam Erde, Sentinel Technologies
40+
Modified: 2025/11/09
41+
Version: 1.0.0
42+
43+
.OUTPUTS
44+
PSCustomObject with properties: Id, DisplayName, OnPremisesSyncEnabled, Updated, Status, ErrorMessage
45+
#>
46+
47+
[CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')]
48+
[OutputType([PSCustomObject])]
49+
param(
50+
[Parameter(
51+
ParameterSetName = 'FromTextFile',
52+
Mandatory = $true,
53+
HelpMessage = 'The path to a text file that contains group IDs on individual lines.'
54+
)]
55+
[ValidateScript({ Test-Path -Path $_ -PathType Leaf })]
56+
[ValidateNotNullOrEmpty()]
57+
[string]$FilePath,
58+
59+
[Parameter(
60+
ParameterSetName = 'FromGroupName',
61+
Mandatory = $true,
62+
ValueFromPipeline = $true,
63+
HelpMessage = 'One or more group display names to convert from synced to cloud-managed.'
64+
)]
65+
[ValidateNotNullOrEmpty()]
66+
[string[]]$Group
67+
)
68+
69+
begin {
70+
# Import Microsoft Graph Groups module
71+
try {
72+
$ModuleName = 'Microsoft.Graph.Groups'
73+
if (-not (Get-Module -Name $ModuleName)) {
74+
Import-Module -Name $ModuleName -ErrorAction Stop
75+
}
76+
Write-Verbose 'Microsoft Graph Groups module imported.'
77+
} catch {
78+
Write-Error "Failed to import the Microsoft Graph Groups module. Install with: Install-Module Microsoft.Graph.Groups -Scope CurrentUser`n$_"
79+
break
80+
}
81+
82+
# Connect to Microsoft Graph if not already connected
83+
$Scopes = @('Group.ReadWrite.All', 'Group-OnPremisesSyncBehavior.ReadWrite.All')
84+
try {
85+
$Context = Get-MgContext
86+
if (-not $Context) {
87+
Connect-MgGraph -Scopes $Scopes -ErrorAction Stop
88+
Write-Verbose 'Connected to Microsoft Graph.'
89+
}
90+
} catch {
91+
Write-Error "Failed to connect to Microsoft Graph. $_"
92+
break
93+
}
94+
95+
# Initialize collections and counters
96+
$GroupGUIDs = New-Object System.Collections.Generic.List[string]
97+
$Results = New-Object System.Collections.Generic.List[object]
98+
$ProcessedCount = 0
99+
$UpdatedCount = 0
100+
$SkippedCount = 0
101+
$ErrorCount = 0
102+
103+
# From file: read and validate GUIDs immediately
104+
if ($PSCmdlet.ParameterSetName -eq 'FromTextFile') {
105+
# Get all non-blank lines. To do: validate group IDs or provide alternative processing of group names.
106+
$Lines = Get-Content -Path $FilePath -ErrorAction Stop | Where-Object { $_ -and $_.Trim() -ne '' }
107+
if (-not $Lines -or $Lines.Count -eq 0) {
108+
Write-Error 'No group IDs found in the input file.'
109+
break
110+
}
111+
112+
# Trim leading and trailing whitepace from each line and add the group GUID to the list.
113+
foreach ($Line in $Lines) {
114+
$Trimmed = $Line.Trim()
115+
try {
116+
#[void][guid]$Trimmed
117+
[void]$GroupGuids.Add($Trimmed)
118+
} catch {
119+
Write-Warning "Skipping invalid GUID in input file: $Trimmed"
120+
}
121+
} # end foreach line
122+
} # end if FromTextFile
123+
124+
# Resolve names to IDs if needed.
125+
if ($PSCmdlet.ParameterSetName -eq 'FromGroupName' -and $Group.Count -gt 0) {
126+
try {
127+
foreach ($GetGroup in $Group) {
128+
# Escape single quotation marks in group names.
129+
$EscapedGroupName = $GetGroup.Replace("'", "''")
130+
# Should I use [regex]::Escape($GetGroup) instead?
131+
$ResolvedGroupId = Get-MgGroup -Filter "DisplayName eq '$EscapedGroupName'" -ConsistencyLevel eventual -CountVariable _ -ErrorAction Stop |
132+
Select-Object -ExpandProperty Id -ErrorAction Stop
133+
# If group IDs are found online, add them to the GroupGUIDs list.
134+
if ($ResolvedGroupId) {
135+
foreach ($Id in @($Resolved)) { [void]$GroupGuids.Add([string]$Id) }
136+
} else {
137+
Write-Warning "No group found with display name: $GetGroup"
138+
}
139+
} # end foreach GroupNameBuffer
140+
} catch {
141+
Write-Error "A problem occurred while getting groups from Entra ID. $_"
142+
}
143+
} # end if FromGroupName
144+
145+
if ($GroupGUIDs.Count -eq 0) {
146+
Write-Error 'No valid group IDs to process.'
147+
return
148+
}
149+
150+
Write-Information "Found $($GroupGUIDs.Count) group Id(s) to process." -InformationAction Continue
151+
$TotalGroups = $GroupGuids.Count
152+
153+
} # end begin block
154+
155+
process {
156+
foreach ($GroupId in $GroupGUIDs) {
157+
$ProcessedCount++
158+
Write-Verbose "Processing $GroupId ($ProcessedCount/$TotalGroups)"
159+
160+
try {
161+
$GroupObject = Get-MgGroup -GroupId $GroupId -Property 'Id,DisplayName,OnPremisesSyncEnabled' -ErrorAction Stop
162+
163+
Write-Verbose "Group Name: $($GroupObject.DisplayName)"
164+
Write-Verbose "OnPremisesSyncEnabled: $($GroupObject.OnPremisesSyncEnabled)"
165+
166+
if ($GroupObject.OnPremisesSyncEnabled -eq $true) {
167+
# OnPremisesSyncEnabled is true for this group ID.
168+
$Action = 'Set isCloudManaged to true'
169+
$Target = $GroupObject.DisplayName
170+
171+
if ($PSCmdlet.ShouldProcess($Target, $Action)) {
172+
try {
173+
$Body = @{ isCloudManaged = $true }
174+
$Uri = "https://graph.microsoft.com/v1.0/groups/$GroupId/onPremisesSyncBehavior"
175+
Invoke-MgGraphRequest -Uri $Uri -Method PATCH -Body ($Body | ConvertTo-Json) -ContentType 'application/json' -ErrorAction Stop
176+
177+
$UpdatedCount++
178+
$Results.Add([PSCustomObject]@{
179+
Id = $GroupObject.Id
180+
DisplayName = $GroupObject.DisplayName
181+
OnPremisesSyncEnabled = $GroupObject.OnPremisesSyncEnabled
182+
Updated = $true
183+
Status = 'Updated'
184+
ErrorMessage = $null
185+
}) | Out-Null
186+
Write-Information "SUCCESS: Updated '$Target' to cloud-managed." -InformationAction Continue
187+
} catch {
188+
$ErrorCount++
189+
$Results.Add([PSCustomObject]@{
190+
Id = $GroupObject.Id
191+
DisplayName = $GroupObject.DisplayName
192+
OnPremisesSyncEnabled = $GroupObject.OnPremisesSyncEnabled
193+
Updated = $false
194+
Status = 'Error'
195+
ErrorMessage = $_.Exception.Message
196+
}) | Out-Null
197+
Write-Error "Failed to update group '$Target'. $_"
198+
}
199+
} else {
200+
$SkippedCount++
201+
$Results.Add([PSCustomObject]@{
202+
Id = $GroupObject.Id
203+
DisplayName = $GroupObject.DisplayName
204+
OnPremisesSyncEnabled = $GroupObject.OnPremisesSyncEnabled
205+
Updated = $false
206+
Status = 'WhatIf/Skipped'
207+
ErrorMessage = $null
208+
}) | Out-Null
209+
}
210+
} else {
211+
# OnPremisesSyncEnabled is false for this group ID.
212+
$SkippedCount++
213+
$Results.Add([PSCustomObject]@{
214+
Id = $GroupObject.Id
215+
DisplayName = $GroupObject.DisplayName
216+
OnPremisesSyncEnabled = $GroupObject.OnPremisesSyncEnabled
217+
Updated = $false
218+
Status = 'NotSyncEnabled'
219+
ErrorMessage = $null
220+
}) | Out-Null
221+
Write-Verbose 'SKIPPED: Group is not on-premises synchronized.'
222+
}
223+
} catch {
224+
$ErrorCount++
225+
$Results.Add([PSCustomObject]@{
226+
Id = $GroupId
227+
DisplayName = $null
228+
OnPremisesSyncEnabled = $null
229+
Updated = $false
230+
Status = 'LookupError'
231+
ErrorMessage = $_.Exception.Message
232+
}) | Out-Null
233+
Write-Error "Failed to retrieve group information for $GroupId. $_"
234+
}
235+
} # end foreach GroupGUIDs
236+
237+
Write-Information "`n-----------------------------------" -InformationAction Continue
238+
Write-Information 'SUMMARY' -InformationAction Continue
239+
Write-Information '-----------------------------------' -InformationAction Continue
240+
Write-Information "Total groups processed: $TotalGroups" -InformationAction Continue
241+
Write-Information "Successfully updated: $UpdatedCount" -InformationAction Continue
242+
Write-Information "Skipped (not sync-enabled or WhatIf): $SkippedCount" -InformationAction Continue
243+
Write-Information "Errors encountered: $ErrorCount" -InformationAction Continue
244+
245+
# Emit results to the pipeline
246+
$Results
247+
} # end process block
248+
} # end Convert-HybridGroupToCloud function

0 commit comments

Comments
 (0)