GpadTools.psm1
<#
Disclaimer: The scripts are not supported under any Microsoft standard support program or service. The scripts are provided AS IS without warranty of any kind. Microsoft further disclaims all implied warranties including, without limitation, any implied warranties of merchantability or of fitness for a particular purpose. The entire risk arising out of the use or performance of the scripts and documentation remains with you. In no event shall Microsoft, its authors, or anyone else involved in the creation, production, or delivery of the scripts be liable for any damages whatsoever (including, without limitation, damages for loss of business profits, business interruption, loss of business information, or other pecuniary loss) arising out of the use of or inability to use the scripts or documentation, even if Microsoft has been advised of the possibility of such damages. #> #---------------------------------------------------------------------------------------------------------- # # Copyright © 2024 Microsoft Corporation. All rights reserved. # #---------------------------------------------------------------------------------------------------------- # # NAME: # Group Provisioning to AD (GPAD) Module for Entra CloudSync # #---------------------------------------------------------------------------------------------------------- # # RELEASE NOTES # # Version 0.0.1 - 2024-10-14 # - Beta Release: # Get-GpadGroups # Get-GpadSyncedGroupMembers # Get-GpadSynchronizationIdentifiers # Get-GpadWritebackEnabledExtensionName # Get-GpadWritebackEnabledGroups # Set-GpadWritebackEnabledExtension # Start-GpadOnDemandProvisionGroup # Confirm-GpadReconciliationNeeded # Update-GpadWritebackEnabledExtension # #---------------------------------------------------------------------------------------------------------- <# .SYNOPSIS Group Provisioning to Active Directory (Gpad) Tools Module. .DESCRIPTION This module provides functions to manage group provisioning from Microsoft Entra ID to Active Directory using Microsoft Graph PowerShell SDK. .PREREQUISITES - Microsoft Graph PowerShell SDK: Install-Module Microsoft.Graph - Microsoft Active Directory PowerShell Module: Install-WindowsFeature RSAT-AD-Tools .USAGE Connect to Microsoft Graph before using the functions: Connect-MgGraph -Scopes "Application.ReadWrite.All, Directory.ReadWrite.All, Synchronization.ReadWrite.All" #> $ErrorActionPreference = [System.Management.Automation.ActionPreference]::Stop <# .SYNOPSIS Get the name of the custom extension property for writeback enabled. .DESCRIPTION Retrieves the name of the custom extension property that indicates if writeback is enabled. .EXAMPLE Get-GpadWritebackEnabledExtensionName .OUTPUTS System.String. The name of the custom extension property. #> function Get-GpadWritebackEnabledExtensionName { [CmdletBinding()] param () # Import Graph modules try { Import-Module Microsoft.Graph.Authentication Import-Module Microsoft.Graph.Applications } catch { Throw "'CloudSyncCustomExtensionsApp' application not found. Error details: $($_.Exception.Message)" } # Get CloudSyncCustomExtensionsApp try { $tenantId = (Get-MgOrganization).Id $cloudSyncCustomExtApp = Get-MgApplication -Filter "identifierUris/any(uri:uri eq 'api://$tenantId/CloudSyncCustomExtensionsApp')" } catch { Throw "'CloudSyncCustomExtensionsApp' application not found. Error details: $($_.Exception.Message)" } # Get Custom Extension Property Name try { $gwbEnabledExtAttrib = Get-MgBetaApplicationExtensionProperty -ApplicationId $cloudSyncCustomExtApp.Id | Where-Object { $_.Name -Like '*WritebackEnabled' } | Select-Object -First 1 if (-not $gwbEnabledExtAttrib) { Throw "Failed to retrieve 'WritebackEnabled' custom extension property." } $gwbEnabledExtName = $gwbEnabledExtAttrib.Name } catch { Throw "Failed to get 'WritebackEnabled' custom extension property. Error details: $($_.Exception.Message)" } return $gwbEnabledExtName } <# .SYNOPSIS Get synchronization identifiers for Microsoft Entra ID to Active Directory Provisioning. .DESCRIPTION Retrieves the service principal ID, synchronization job ID, and synchronization rules ID for a given domain name. .PARAMETER DomainName The domain name to get the synchronization identifiers for. .EXAMPLE Get-GpadSynchronizationIdentifiers -DomainName Contoso.com .OUTPUTS PSCustomObject. Contains ServicePrincipalId, SynchronizationJobId, and SynchronizationRulesId. #> function Get-GpadSynchronizationIdentifiers { [CmdletBinding()] param ( # Group Identifier [Parameter(Mandatory=$true, ValueFromPipelineByPropertyName=$true, Position=0)] [string] $DomainName ) # Get "Microsoft Entra ID to Active Directory Provisioning" Application from App template Id # Note: Getting service principal (SP) by display name is not feasible because all Cloud Sync SPs use the domain name so it's not possible to determine which SP is # for Entra ID to Active Directory Provisioning" # Reference: https://learn.microsoft.com/en-us/graph/api/resources/synchronization-overview?view=graph-rest-beta&preserve-view=true $entraIDToADapp = Get-MgApplication -Filter "ApplicationTemplateId eq 'fb81332f-3eca-4ecf-a939-4278e501d330'" -Property id, AppId, DisplayName | Where-Object { $_.DisplayName -eq $DomainName } if ($entraIDToADapp) { # Get the Service Principal $entraIDToADsp = Get-MgServicePrincipal -Filter "AppId eq '$($entraIDToADapp.AppId)'" # Get the Synchronization Job $entraIDToADjob = Get-MgServicePrincipalSynchronizationJob -ServicePrincipalId $entraIDToADsp.Id -Property Id # Get the Synchronization Rule Id $entraIDToADschema = Get-MgServicePrincipalSynchronizationJobSchema -ServicePrincipalId $entraIDToADsp.Id -SynchronizationJobId $entraIDToADjob.Id -Property * $result = [PSCustomObject]@{ ServicePrincipalId = $entraIDToADsp.Id SynchronizationJobId = $entraIDToADjob.Id SynchronizationRulesId = $entraIDToADschema.SynchronizationRules.id } } else { Throw "Microsoft Entra ID to Active Directory Provisioning for '$DomainName' domain not found." } Return $result } <# .SYNOPSIS Retrieves security groups with WritebackEnabled extension set to true. .DESCRIPTION This function retrieves all security groups where the WritebackEnabled extension is set to true. It excludes cloud M365/Unified groups that have WritebackEnabled extension set to False/null. .EXAMPLE Get-GpadWritebackEnabledGroups #> function Get-GpadWritebackEnabledGroups { [CmdletBinding()] param () # Get all Security groups with WritebackEnabled extension == True $gwbEnabledExtName = Get-GpadWritebackEnabledExtensionName $p = @('DisplayName', 'Id', 'GroupTypes', 'OnPremisesSyncEnabled', 'WritebackConfiguration', $gwbEnabledExtName) $groups = Get-MgBetaGroup -Property $p -ConsistencyLevel eventual -All | Where-Object {$_.OnPremisesSyncEnabled -ne 'True'} foreach ($group in $groups) { if ((-not ($group.GroupTypes -Match 'Unified')) -and $group.AdditionalProperties.$gwbEnabledExtName) { $group | Select-Object DisplayName, Id, ` @{Name = 'WritebackConfiguration_IsEnabled'; Expression = { $_.WritebackConfiguration.IsEnabled } }, ` @{Name = $gwbEnabledExtName; Expression = { $_.AdditionalProperties.$gwbEnabledExtName } } } } } <# .SYNOPSIS Get members of a group that are synced from on-premises AD. .DESCRIPTION Retrieves the members of a specified group that are synchronized from on-premises Active Directory. .PARAMETER GroupId The ID of the group to get members for. .EXAMPLE Get-GpadSyncedGroupMembers -GroupId '3b100a44-2fdc-48d6-a72e-aefa9835c3e0' .OUTPUTS System.Collections.ArrayList. A list of members with object ID and type. #> function Get-GpadSyncedGroupMembers { [CmdletBinding()] param ( # Group Identifier [Parameter(Mandatory=$true, ValueFromPipelineByPropertyName=$true, Position=0)] [string] $GroupId ) Write-Host "Getting membership for group '$GroupId'..." -ForegroundColor Cyan $members = Get-MgGroupMember -GroupId $GroupId -All -Property Id, onPremisesSyncEnabled $membersHt = [System.Collections.ArrayList] @() ForEach ($m in $members) { if ($m.AdditionalProperties.onPremisesSyncEnabled) { switch ($m.AdditionalProperties.'@odata.type') { '#microsoft.graph.user' { $type = 'User'} '#microsoft.graph.group' { $type = 'Group' } Default { continue } # unknown object type, skipped! } $entry = @{ objectId = $m.Id objectTypeName = $type } $membersHt += $entry } } return $membersHt } <# .SYNOPSIS Updates the WritebackEnabled extension for security groups. .DESCRIPTION This function updates the WritebackEnabled extension property for all security groups based on their WritebackConfiguration property. It only processes groups where WritebackConfiguration property differs from WritebackEnabled extension. .EXAMPLE Update-GpadWritebackEnabledExtension #> function Update-GpadWritebackEnabledExtension { [CmdletBinding()] param () Write-Host "Updating security groups' WritebackEnabled extension property. Please wait..." -ForegroundColor Cyan # Properties to fetch from groups $gwbEnabledExtName = Get-GpadWritebackEnabledExtensionName $p = @('DisplayName', 'Id', 'MailNickname', 'Description', 'GroupTypes', 'OnPremisesSyncEnabled', 'WritebackConfiguration', $gwbEnabledExtName) # Get cloud groups try { $groups = Get-MgBetaGroup -Property $p -ConsistencyLevel eventual -All | Where-Object {$_.OnPremisesSyncEnabled -ne 'True'} Write-Verbose "$($groups.count) cloud groups returned..." } catch { Throw "Failed to retrieve groups. Error details: $($_.Exception.Message)" } # Output groups with Writeback property and WritebackEnabled extension foreach ($group in $groups) { # Filter security groups only if (-not ($group.GroupTypes -Match 'Unified')) { $shouldUpdate = ($group.WritebackConfiguration.IsEnabled -and (-not $group.AdditionalProperties.$gwbEnabledExtName)) -or ((-not $group.WritebackConfiguration.IsEnabled) -and $group.AdditionalProperties.$gwbEnabledExtName) Write-Verbose "Group '$($group.DisplayName)' needs update: $shouldUpdate" if ($shouldUpdate) { $updatedValue = [bool]$group.WritebackConfiguration.IsEnabled try { Update-MgGroup -GroupId $group.Id -AdditionalProperties @{$gwbEnabledExtName = $updatedValue } # show update $group.AdditionalProperties.$gwbEnabledExtName = $updatedValue $group | Select-Object DisplayName, Id, ` @{Name = 'WritebackConfiguration_IsEnabled'; Expression = { $_.WritebackConfiguration.IsEnabled } }, ` @{Name = $gwbEnabledExtName; Expression = { $_.AdditionalProperties.$gwbEnabledExtName } } } catch { Write-Error "There was an error updating group '$($group.Id)'. Error Details: $($_.Exception.Message)" } } } } } <# .SYNOPSIS Lists security groups and outputs WritebackConfiguration property and WritebackEnabled extension. .DESCRIPTION This function retrieves and lists all security groups showing their WritebackConfiguration property and WritebackEnabled extension. .EXAMPLE Get-GpadGroups #> function Get-GpadGroups { [CmdletBinding()] param () # Get WritebackEnabled Extension name $gwbEnabledExtName = Get-GpadWritebackEnabledExtensionName $p = @('DisplayName', 'Id', 'GroupTypes', 'OnPremisesSyncEnabled', 'WritebackConfiguration', $gwbEnabledExtName) try { # NOTE: Cannot use Get-MgGroup (non-beta) as it doesn't retrieve WritebackConfiguration.IsEnabled property $groups = Get-MgBetaGroup -Property $p -ConsistencyLevel eventual -All | Where-Object {$_.OnPremisesSyncEnabled -ne 'True'} } catch { Throw "Failed to retrieve groups. Error details: $($_.Exception.Message)" } foreach ($group in $groups) { # Ensure the group is not a Unified group if (-not ($group.GroupTypes -Match 'Unified')) { $group | Select-Object DisplayName, Id, ` @{Name = 'WritebackConfiguration_IsEnabled'; Expression = { $_.WritebackConfiguration.IsEnabled } }, ` @{Name = $gwbEnabledExtName; Expression = { $_.AdditionalProperties.$gwbEnabledExtName } } } } } <# .SYNOPSIS Checks if reconciliation is needed between an Entra ID group and its corresponding AD group. .DESCRIPTION This function retrieves the members of an Entra ID group and its corresponding AD group, compares its members, and determines if there are any discrepancies. If discrepancies are found, it indicates that reconciliation is needed. .PARAMETER GroupId The identifier(s) of the Entra ID group(s) to be checked. .PARAMETER GroupWritebackOU (Optional) The organizational unit for the group writeback. This is used as the search base when looking for written back groups in AD. .EXAMPLE PS C:\> Confirm-GpadReconciliationNeeded -GroupId "07b12a06-8f02-4526-9739-b3f9fae3c9fb" .EXAMPLE PS C:\> Confirm-GpadReconciliationNeeded -GroupId "07b12a06-8f02-4526-9739-b3f9fae3c9fb" -GroupWritebackOU "OU=Groups,DC=example,DC=com" .EXAMPLE PS C:\> $groups = '3b100a44-2fdc-48d6-a72e-aefa9835c3e0', '07b12a06-8f02-4526-9739-b3f9fae3c9fb' | Confirm-GpadReconciliationNeeded -Verbose .NOTES This function uses the Microsoft Graph module to retrieve Entra ID group members and the Active Directory module to retrieve AD group members. #> function Confirm-GpadReconciliationNeeded { [CmdletBinding()] param ( # Group Identifier [Parameter(Mandatory=$true, ValueFromPipelineByPropertyName=$true, ValueFromPipeline=$true, Position=0)] [string[]] $GroupId, # Cloud Sync Group Writeback OU in Active Directory [Parameter(Mandatory=$false, ValueFromPipelineByPropertyName=$false, Position=1)] [string] $GroupWritebackOU ) begin {} process { Write-Verbose "Getting membership for Entra ID group '$GroupId'..." $allCloudGroupMembers = Get-MgGroupMember -GroupId $GroupId[0] -All -Property Id, onPremisesSyncEnabled, onPremisesSecurityIdentifier $allCloudGroupSyncedMembersSid = [System.Collections.ArrayList] @() ForEach ($m in $allCloudGroupMembers) { if ($m.AdditionalProperties.onPremisesSyncEnabled) { $allCloudGroupSyncedMembersSid += $m.AdditionalProperties.onPremisesSecurityIdentifier } } Write-Verbose "Entra ID group '$GroupId' has $($allCloudGroupSyncedMembersSid.Count) synced members." # Get the respetive written back group from AD Write-Verbose "Getting membership for AD group with 'Group_$GroupId' anchor..." try { if ([string]::IsNullOrEmpty($GroupWritebackOU)) { $adGroup = Get-ADObject -Filter "adminDescription -eq 'Group_$($GroupId[0])'" -Properties member } else { $adGroup = Get-ADObject -Filter "adminDescription -eq 'Group_$($GroupId[0])'" -SearchBase $GroupWritebackOU -Properties member } } catch { Throw "Failed to retrieve members from AD group with 'Group_$GroupId' anchor. Error details: $($_.Exception.Message)" } if ($adGroup -eq $null) { Write-Verbose "Failed to find AD group with 'Group_$GroupId' anchor. Group needs reprovisoning." [bool] $groupNotProvisioned = $true } else { try { $allAdGroupMembersSid = ($adGroup.member | Get-ADObject -Properties objectSid | select -ExpandProperty objectSid).Value } catch { Throw "Failed to retrieve objectSid from AD group members with 'Group_$GroupId' anchor. Error details: $($_.Exception.Message)" } Write-Verbose "AD group with 'Group_$GroupId' anchor has $($allAdGroupMembersSid.Count) synced members." # Get the members that are not present in both groups $membersNotInCommon = [System.Collections.ArrayList] @() $membersNotInCommon = ($allAdGroupMembersSid | Where {$allCloudGroupSyncedMembersSid -NotContains $_}) $membersNotInCommon += ($allCloudGroupSyncedMembersSid | Where {$allAdGroupMembersSid -NotContains $_}) } $result = "" | select Id, ReconciliationNeeded $result.Id = $GroupId[0] if ($membersNotInCommon.Count) { Write-Warning "Entra ID group '$GroupId' has $($membersNotInCommon.Count) synced members diverging." $result.ReconciliationNeeded = $true } elseif ($groupNotProvisioned) { Write-Warning "Entra ID group '$GroupId' is not provisioned in Active Directory." $result.ReconciliationNeeded = $true } else { Write-Verbose "Entra ID group '$GroupId' is consistent." $result.ReconciliationNeeded = $false } return $result } end {} } <# .SYNOPSIS Call synchronization job on-demand for Entra ID groups. .DESCRIPTION Triggers synchronization job on-demand for groups. .PARAMETER GroupId The ID of the group to trigger synchronization for. Accepts multiple group IDs from the pipeline. .PARAMETER ServicePrincipalId The service principal ID for the synchronization job. .PARAMETER SynchronizationJobId The synchronization job ID. .PARAMETER SynchronizationRulesId The synchronization rules ID. .PARAMETER GroupWritebackOU (Optional) The organizational unit for the group writeback. This is used as the search base when looking for written back groups in AD. .EXAMPLE $onDemandProv = Get-GpadSynchronizationIdentifiers -DomainName 'Contoso.com' Start-GpadOnDemandProvisionGroup -GroupId '3b100a44-2fdc-48d6-a72e-aefa9835c3e0' -ServicePrincipalId $onDemandProv.ServicePrincipalId ` -SynchronizationJobId $onDemandProv.SynchronizationJobId ` -SynchronizationRulesId $onDemandProv.SynchronizationRulesId ` -GroupWritebackOU 'OU=GWB,OU=SYNC,DC=Contoso,DC=com' .EXAMPLE $onDemandProv = Get-GpadSynchronizationIdentifiers -DomainName 'Contoso.com' '3b100a44-2fdc-48d6-a72e-aefa9835c3e0', 'b92b5d22-e5d4-497e-b4b2-2b288c6fba8d' | Start-GpadOnDemandProvisionGroup ` -ServicePrincipalId $onDemandProv.ServicePrincipalId ` -SynchronizationJobId $onDemandProv.SynchronizationJobId ` -SynchronizationRulesId $onDemandProv.SynchronizationRulesId #> function Start-GpadOnDemandProvisionGroup { [CmdletBinding()] param ( # Group Identifier [Parameter(Mandatory=$true, ValueFromPipelineByPropertyName=$true, ValueFromPipeline=$true, Position=0)] [string[]] $GroupId, # Cloud Sync ServicePrincipalId [Parameter(Mandatory=$true, ValueFromPipelineByPropertyName=$false, Position=1)] [string] $ServicePrincipalId, # Cloud Sync SynchronizationJobId [Parameter(Mandatory=$true, ValueFromPipelineByPropertyName=$false, Position=2)] [string] $SynchronizationJobId, # Cloud Sync SynchronizationJobId [Parameter(Mandatory=$true, ValueFromPipelineByPropertyName=$false, Position=3)] [string] $SynchronizationRulesId, # Cloud Sync Group Writeback OU in Active Directory [Parameter(Mandatory=$false, ValueFromPipelineByPropertyName=$false, Position=4)] [string] $GroupWritebackOU ) begin {} process { # Get cloud group members from Entra ID $allGroupMembersHt = Get-GpadSyncedGroupMembers -GroupId $GroupId[0] Write-Host "Processing group '$GroupId' with '$($allGroupMembersHt.count)' members. Please wait..." -ForegroundColor Cyan # Get the respetive written back group from AD try { if ([string]::IsNullOrEmpty($GroupWritebackOU)) { $adGroup = Get-ADObject -Filter "adminDescription -eq 'Group_$($GroupId[0])'" -Properties member } else { $adGroup = Get-ADObject -Filter "adminDescription -eq 'Group_$($GroupId[0])'" -SearchBase $GroupWritebackOU -Properties member } } catch { Throw "Failed to retrieve members from AD group with 'Group_$GroupId' anchor. Error: $($_.Exception.Message)" } # Obsolete members in group written back to AD try { if ($adGroup.member.count) { Remove-ADGroupMember -Identity $adGroup -Members $adGroup.member -Confirm:$false } } catch { Throw "Failed to remove members from AD group with 'Group_$GroupId' anchor. Error: $($_.Exception.Message)" } # Reconcile members, split by membersLimit $membersLimit = 5 $i = 0 while ($i -lt $allGroupMembersHt.Count) { $groupMembersHt = [System.Collections.ArrayList] @() for ($j = 0; $j -lt $membersLimit; $j++) { if ($i -lt $allGroupMembersHt.Count) { $groupMembersHt += $allGroupMembersHt[$i] $i++ } } # Build body parameter with members Write-Host "Processing $($groupMembersHt.Count) members... " -ForegroundColor Cyan $params = @{ parameters = @( @{ ruleId = $SynchronizationRulesId subjects = @( @{ objectId = $GroupId[0] objectTypeName = "Group" links = @{ members = @($groupMembersHt) } } ) } ) } # Call Synchronization Job On-Demand $result = New-MgServicePrincipalSynchronizationJobOnDemand ` -ServicePrincipalId $ServicePrincipalId ` -SynchronizationJobId $SynchronizationJobId ` -BodyParameter $params $result.Value | ConvertFrom-Json } } end {} } <# .SYNOPSIS Sets the WritebackEnabled property and extension for a cloud group. .DESCRIPTION This function updates (True/False) the WritebackEnabled property from group schema and the WritebackEnabled extension in directory schema for a given cloud group. Accepts multiple Group Ids from the pipeline. .EXAMPLE Set-GpadWritebackEnabledExtension -GrouppId 'b92b5d22-e5d4-497e-b4b2-2b288c6fba8d' -Value $true #> function Set-GpadWritebackEnabledExtension { [CmdletBinding()] param ( # Group Identifier [Parameter(Mandatory=$true, ValueFromPipelineByPropertyName=$true, ValueFromPipeline=$true, Position=0)] [string[]] $GroupId, # Group Identifier [Parameter(Mandatory=$true, ValueFromPipelineByPropertyName=$true, ValueFromPipeline=$false, Position=1)] [string] $Value ) begin { $gwbEnabledExtName = Get-GpadWritebackEnabledExtensionName } process { # Set Group Writeback flag Update-MgGroup -GroupId $GroupId[0] -AdditionalProperties @{$gwbEnabledExtName = $Value} # Set Group Writeback flag Update-MgBetaGroup -GroupId $GroupId[0] -WritebackConfiguration @{isEnabled = $Value} } end{} } #---------------------------------------------------------------------------------------------------------- # TODO: # - Start-GpadOnDemandProvisionGroup is not accepting PropertyName from Get-GpadSynchronizationIdentifiers # using pipelining (issue). E.g., Get-GpadSynchronizationIdentifiers Contoso.com | # Start-GpadOnDemandProvisionGroup - GroupId 3b100a44-2fdc-48d6-a72e-aefa9835c3e0 # #---------------------------------------------------------------------------------------------------------- #---------------------------------------------------------------------------------------------------------- # --- MAIN ---- $introMsg = @" Welcome to Group Provisioning for Active Directory module (BETA). This module has the following pre-requisites: - Microsoft Graph PowerShell SDK: Install-Module Microsoft.Graph - Microsoft Active Directory PowerShell Module: Install-WindowsFeature RSAT-AD-Tools Before starting, connect to Microsoft Graph: Connect-MgGraph -Scopes "Application.ReadWrite.All, Directory.ReadWrite.All, Synchronization.ReadWrite.All" "@ Write-Host $introMsg -ForegroundColor Cyan |