GpadTools.psm1
<#
.SYNOPSIS 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. Group Provisioning to AD (GPAD) PowerShell Module for Microsoft Entra Cloud Sync GitHub: https://github.com/NuAlex/GpadTools Copyright © 2025 Microsoft Corporation. All rights reserved. .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" #> #======================================================================================= #region Internal Functions #======================================================================================= <# .SYNOPSIS INTERNAL - Call Graph API #> function Get-GpadToolsGraphInvoke { [CmdletBinding()] param ( # URI string [Parameter(Mandatory=$true, ValueFromPipelineByPropertyName=$true, ValueFromPipeline=$true, Position=0)] [string] $Uri, # Filter string [Parameter(Mandatory=$false, Position=1)] [string] $Filter, # Headers string [Parameter(Mandatory=$false, Position=2)] [hashtable] $Headers, # Properties to select [Parameter(Mandatory=$false, Position=3)] [string] $Property ) #begin if (-not [string]::IsNullOrEmpty($Filter)) { $Uri += '?$filter=' + $Filter } if (-not [string]::IsNullOrEmpty($Property)) { If ($Uri.Contains('?')) { $Uri += '&$select=' + $Property } else { $Uri += '?$select=' + $Property } } Write-Verbose "GET $Uri" #process do { if ($null -eq $Headers) { $response = Invoke-MgGraphRequest GET $Uri -OutputType psobject } else { $response = Invoke-MgGraphRequest GET $Uri -OutputType psobject -Headers $Headers } $Uri = $response.'@odata.nextLink' if ($response.PSobject.Properties.name -match 'value') { $response.value } else { $response | Select-Object $($Property -split ',') } } while ($Uri) #end } <# .SYNOPSIS INTERNAL - Import Microsoft Graph PowerShell SDK #> function Import-GpadToolsGraphModule { [CmdletBinding()] param () # Import Graph module try { Import-Module Microsoft.Graph.Authentication Import-Module Microsoft.Graph.Applications } catch { Throw "Unable to import Microsoft.Graph SDK module: $($_.Exception.Message)" } } <# .SYNOPSIS INTERNAL - Import Active Directory module #> function Import-GpadToolsActiveDirectoryModule { [CmdletBinding()] param () # Import Active Directory module try { Import-Module ActiveDirectory } catch { Throw "Unable to import Active Directory module: $($_.Exception.Message)" } } #endregion #======================================================================================= #region Writeback Configuration and Custom Extensions Functions #======================================================================================= <# .SYNOPSIS Retrieves custom extension properties from the Cloud Sync custom extensions application. .DESCRIPTION Gets all defined directory extension properties for the Cloud Sync custom extensions application in Microsoft Entra ID. Use this to retrieve the custom attribute 'WritebackEnabled' (the group writeback flag) and any other extension properties associated with the Cloud Sync custom extensions app. .PARAMETER CustomExtApplicationName The name of the custom extensions application. If not specified, the default Cloud Sync custom extensions application name is used: CloudSyncCustomExtensionsApp .EXAMPLE Get-GpadCustomExtensionProperty .OUTPUTS System.Object[]. An array of extension property objects defined on the custom extensions application (each includes details like Name, DataType, and TargetObjects). #> function Get-GpadCustomExtensionProperty { [CmdletBinding()] param ( # Custom Extensions Application Name [Parameter(Mandatory=$false, ValueFromPipelineByPropertyName=$true, Position=0)] [string] $CustomExtApplicationName = $DefaultCustomExtApplicationName ) # Get the custom extensions application for Cloud Sync $cloudSyncCustomExtApp = Get-GpadCustomExtensionApplication -CustomExtApplicationName $CustomExtApplicationName -ThrowIfNull # Get Custom Extension Property Name try { $extAttributes = Get-MgApplicationExtensionProperty -ApplicationId $cloudSyncCustomExtApp.Id } catch { Throw "Unable to get custom extension properties. Error details: $($_.Exception.Message)" } return $extAttributes } <# .SYNOPSIS Retrieves the Microsoft Entra ID application used for Cloud Sync custom extensions. .DESCRIPTION Gets the Microsoft Entra ID application object that is used to store custom directory extension attributes for Cloud Sync (group writeback). By default, it searches for the application by the module's predefined name (i.e., CloudSyncCustomExtensionsApp). .PARAMETER CustomExtApplicationName The name of the custom extensions application to look up. If not provided, the default Cloud Sync custom extensions application name is used: CloudSyncCustomExtensionsApp .PARAMETER ThrowIfNull Internal use only. When set to $true, throws an error if the application is not found. When $false (default), returns $null if not found. .EXAMPLE Get-GpadCustomExtensionApplication .OUTPUTS System.Object. The Microsoft Entra ID Application object for the custom extensions app. #> function Get-GpadCustomExtensionApplication { [CmdletBinding()] param ( # Custom Extensions Application Name [Parameter(Mandatory=$false, ValueFromPipelineByPropertyName=$true, Position=0)] [string] $CustomExtApplicationName = $DefaultCustomExtApplicationName, [Parameter(Mandatory=$false, ValueFromPipelineByPropertyName=$true, Position=1)] [switch] $ThrowIfNull = $false ) Import-GpadToolsGraphModule # Get CloudSyncCustomExtensionsApp $tenantId = (Get-MgOrganization).Id Write-Verbose "Get-GpadCustomExtensionApplication: Calling Get-MgApplication -Filter `"identifierUris/any(uri:uri eq 'api://$tenantId/$CustomExtApplicationName')`"" $cloudSyncCustomExtApp = Get-MgApplication -Filter "identifierUris/any(uri:uri eq 'api://$tenantId/$CustomExtApplicationName')" | Select-Object -First 1 if ($null -eq $cloudSyncCustomExtApp -and $ThrowIfNull -eq $true) { # If the application is not found and ThrowIfNull is true, throw an error Throw "Custom extensions application '$CustomExtApplicationName' not found." } else { return $cloudSyncCustomExtApp } } <# .SYNOPSIS Retrieves the service principal for the Cloud Sync custom extensions application. .DESCRIPTION Gets the Microsoft Entra ID service principal associated with the Cloud Sync custom extensions application. The function first finds the custom extensions application (using Get-GpadCustomExtensionApplication). If the application exists, its AppId is used to fetch the corresponding service principal from Microsoft Entra ID. .PARAMETER CustomExtApplicationName The name of the custom extensions application. If not specified, the default Cloud Sync custom extensions application name is used: CloudSyncCustomExtensionsApp .EXAMPLE Get-GpadCustomExtensionAppServicePrincipal .OUTPUTS System.Object. The Microsoft Entra ID ServicePrincipal object for the custom extensions application. #> function Get-GpadCustomExtensionAppServicePrincipal { [CmdletBinding()] param ( # Custom Extensions Application Name [Parameter(Mandatory=$false, ValueFromPipelineByPropertyName=$true, Position=0)] [string] $CustomExtApplicationName = $DefaultCustomExtApplicationName ) # Get the custom extensions application for Cloud Sync $cloudSyncCustomExtApp = Get-GpadCustomExtensionApplication -CustomExtApplicationName $CustomExtApplicationName -ThrowIfNull Get-MgServicePrincipal -Filter "AppId eq '$($cloudSyncCustomExtApp.AppId)'" } <# .SYNOPSIS Get the name of the custom extension property for writeback enabled. .DESCRIPTION Retrieves the name of the custom extension property used to determine if the group is enabled for writeback. .PARAMETER CustomExtAttributeName The name of the custom extension attribute to search for. If not provided, the default name is used. .PARAMETER CustomExtApplicationName The name of the custom extensions application. If not provided, the default name is used. .EXAMPLE Get-GpadWritebackEnabledExtensionName .OUTPUTS System.String. The name of the custom extension property. #> function Get-GpadWritebackEnabledExtensionName { [CmdletBinding()] param ( # Custom Extensions Attribute Name [Parameter(Mandatory=$false, ValueFromPipelineByPropertyName=$true, Position=0)] [string] $CustomExtAttributeName = $DefaultCustomExtAttributeName, # Custom Extensions Application Name [Parameter(Mandatory=$false, ValueFromPipelineByPropertyName=$true, Position=1)] [string] $CustomExtApplicationName = $DefaultCustomExtApplicationName ) # Get the custom extensions application for Cloud Sync $cloudSyncCustomExtApp = Get-GpadCustomExtensionApplication -CustomExtApplicationName $CustomExtApplicationName -ThrowIfNull # Get Custom Extension Property Name try { $gwbEnabledExtAttrib = Get-MgApplicationExtensionProperty -ApplicationId $cloudSyncCustomExtApp.Id | Where-Object { $_.Name -Like "*$CustomExtAttributeName" } | Select-Object -First 1 if (-not $gwbEnabledExtAttrib) { Write-Verbose "Custom extension attribute 'WritebackEnabled' not found." return $null } $gwbEnabledExtName = $gwbEnabledExtAttrib.Name } catch { Throw "Unable to get 'WritebackEnabled' custom extension property. Error details: $($_.Exception.Message)" } return $gwbEnabledExtName } <# .SYNOPSIS Creates a new Cloud Sync custom extensions application in Microsoft Entra ID. .DESCRIPTION Creates a new Microsoft Entra ID application to be used for Cloud Sync custom extension attributes (for group writeback), if one does not already exist. If an application with the specified name or identifier URI is already present, the function will not create a duplicate: it returns the existing identifier URI if found, or throws an error if a conflicting name exists. .PARAMETER CustomExtApplicationName The name of the custom extensions application to create. If not provided, the default Cloud Sync custom extensions application name is used: CloudSyncCustomExtensionsApp .EXAMPLE New-GpadCustomExtensionApplication .OUTPUTS System.Object. The Microsoft Entra ID Application object that was created, or the existing application object if it already existed. #> function New-GpadCustomExtensionApplication { [CmdletBinding()] param ( # Custom Extensions Application Name [Parameter(Mandatory=$false, ValueFromPipelineByPropertyName=$true, Position=0)] [string] $CustomExtApplicationName = $DefaultCustomExtApplicationName ) # Get the custom extensions application for Cloud Sync $cloudSyncCustomExtApp = Get-GpadCustomExtensionApplication -CustomExtApplicationName $CustomExtApplicationName # If the application already exists, return it If ($null -ne $cloudSyncCustomExtApp) { Write-Verbose "Custom extensions application '$CustomExtApplicationName' already exists." return $cloudSyncCustomExtApp } # If the application name already exists, throw an error $cloudSyncCustomExtApp = Get-MgApplication -ConsistencyLevel eventual -Count appCount -Search "DisplayName:$CustomExtApplicationName" If ($null -ne $cloudSyncCustomExtApp) { Throw "Custom extensions application '$CustomExtApplicationName' already exists." } Write-Verbose "Creating new custom extensions application '$CustomExtApplicationName'..." $tenantId = (Get-MgOrganization).Id New-MgApplication -DisplayName $CustomExtApplicationName -IdentifierUris "API://$tenantId/$CustomExtApplicationName" } <# .SYNOPSIS Creates a new custom extension property on the Cloud Sync custom extensions application. .DESCRIPTION Adds a new directory extension property to the Cloud Sync custom extensions application in Microsoft Entra ID. By default, this will create the standard Cloud Sync group writeback attribute (a Boolean on group objects), unless custom parameters are provided. Use this function after the custom extensions application and its service principal have been created. Learn more: https://learn.microsoft.com/en-us/graph/api/application-post-extensionproperty?view=graph-rest-1.0&tabs=http#request-body .PARAMETER CustomExtAttributeName The name of the new custom extension attribute to create. If not specified, the default Cloud Sync custom extension attribute name is used: CloudSyncCustomExtensionsApp .PARAMETER CustomExtAttributeType The data type of the new extension attribute (e.g., String, Integer, Boolean). Defaults to 'Boolean'. .PARAMETER CustomExtAttributeTargetObjects The directory object types the new extension property applies to. Defaults to 'Group' (since group writeback uses a group-scoped attribute). .PARAMETER CustomExtApplicationName The name of the custom extensions application. If not specified, the default Cloud Sync custom extensions application name is used. .EXAMPLE New-GpadCustomExtensionProperty .OUTPUTS System.Object. The extension property object that was created on the application (contains the new attribute’s details). #> function New-GpadCustomExtensionProperty { [CmdletBinding()] param ( # Custom Extensions Attribute Name [Parameter(Mandatory=$false, ValueFromPipelineByPropertyName=$true, Position=0)] [string] $CustomExtAttributeName = $DefaultCustomExtAttributeName, # Custom Extensions Attribute Type [Parameter(Mandatory=$false, ValueFromPipelineByPropertyName=$true, Position=1)] [ValidateSet("Binary", "Boolean", "DateTime", "Integer", "LargeInteger", "String")] [string] $CustomExtAttributeType = 'Boolean', # Custom Extensions Attribute Target Object Type [Parameter(Mandatory=$false, ValueFromPipelineByPropertyName=$true, Position=2)] [ValidateSet("User", "Group", "AdministrativeUnit", "Application", "Device", "Organization")] [string] $CustomExtAttributeTargetObjects = 'Group', # Custom Extensions Application Name [Parameter(Mandatory=$false, ValueFromPipelineByPropertyName=$true, Position=3)] [string] $CustomExtApplicationName = $DefaultCustomExtApplicationName ) # Get the custom extensions application for Cloud Sync $cloudSyncCustomExtApp = Get-GpadCustomExtensionApplication -CustomExtApplicationName $CustomExtApplicationName -ThrowIfNull New-MgApplicationExtensionProperty -ApplicationId $cloudSyncCustomExtApp.Id -Name $CustomExtAttributeName -DataType $CustomExtAttributeType -TargetObjects $CustomExtAttributeTargetObjects } <# .SYNOPSIS Creates a service principal for the Cloud Sync custom extensions application. .DESCRIPTION Registers a new service principal in Microsoft Entra ID for the Cloud Sync custom extensions application. This allows the custom extension attributes to be used in directory operations. The custom extensions application must already exist (otherwise an error is thrown). .PARAMETER CustomExtApplicationName The name of the custom extensions application. If not specified, the default Cloud Sync custom extensions application name is used: CloudSyncCustomExtensionsApp .EXAMPLE New-GpadCustomExtensionAppServicePrincipal .OUTPUTS System.Object. The Microsoft Entra ID ServicePrincipal object created for the custom extensions application. #> function New-GpadCustomExtensionAppServicePrincipal { [CmdletBinding()] param ( # Custom Extensions Application Name [Parameter(Mandatory=$false, ValueFromPipelineByPropertyName=$true, Position=0)] [string] $CustomExtApplicationName = $DefaultCustomExtApplicationName ) # Get the custom extensions application for Cloud Sync $cloudSyncCustomExtApp = Get-GpadCustomExtensionApplication -CustomExtApplicationName $CustomExtApplicationName -ThrowIfNull # Create the service principal for the custom extensions application Write-Verbose "Creating service principal for custom extensions application '$CustomExtApplicationName'..." New-MgServicePrincipal -AppId $cloudSyncCustomExtApp.AppId } <# .SYNOPSIS Removes a custom extension property from the Cloud Sync custom extensions application. .DESCRIPTION Removes one directory extension property from the Cloud Sync custom extensions application in Microsoft Entra ID. It identifies extension properties by name (using the provided name or the default) and deletes them. Use with caution, as this action is irreversible. This cmdlet supports -WhatIf and -Confirm (ShouldProcess) for safety. .PARAMETER CustomExtAttributeName The name of the custom extension attribute to remove. If not provided, the module’s default Cloud Sync custom extension attribute name is used. If multiple extension properties match this name, all of them will be removed. .PARAMETER CustomExtApplicationName The name of the custom extensions application. If not specified, the default Cloud Sync custom extensions application name is used. .EXAMPLE Remove-GpadCustomExtensionProperty -CustomExtAttributeName "GroupWritebackEnabled" .OUTPUTS None. By default, this cmdlet does not output any object. #> function Remove-GpadCustomExtensionProperty { #[CmdletBinding(SupportsShouldProcess=$true)] [CmdletBinding(SupportsShouldProcess=$true,ConfirmImpact="High")] param ( # Custom Extensions Attribute Name [Parameter(Mandatory=$false, ValueFromPipelineByPropertyName=$true, Position=0)] [string] $CustomExtAttributeName = $DefaultCustomExtAttributeName, # Custom Extensions Application Name [Parameter(Mandatory=$false, ValueFromPipelineByPropertyName=$true, Position=2)] [string] $CustomExtApplicationName = $DefaultCustomExtApplicationName ) # Get the custom extensions application for Cloud Sync $cloudSyncCustomExtApp = Get-GpadCustomExtensionApplication -CustomExtApplicationName $CustomExtApplicationName -ThrowIfNull # Get the custom extensions property for Cloud Sync $customExtensions = Get-GpadCustomExtensionProperty -CustomExtApplicationName $CustomExtApplicationName | Where-Object { $_.Name -match $CustomExtAttributeName} if ($customExtensions) { # Show matching custom extensions found $customExtensions | Select-Object Name, Id, DataType, TargetObjects | Format-Table -AutoSize -Wrap -Property Name, Id, DataType, TargetObjects # Remove custom extension property ForEach ($extension in $customExtensions) { if ($PSCmdlet.ShouldProcess($CustomExtApplicationName, ("Removing custom extension property '{0}' ({1})" -f $extension.Name, $extension.Id))) { try { Remove-MgApplicationExtensionProperty -ApplicationId $cloudSyncCustomExtApp.Id -ExtensionPropertyId $extension.Id Write-Host "Custom extension property '$($extension.Name)' ($($extension.Id)) removed successfully." -ForegroundColor Cyan } catch { Write-Error "Failed to remove custom extension property '$($extension.Name)' ($($extension.Id)). Error details: $($_.Exception.Message)" } } } } else { Write-Verbose "Custom extension property named '$CustomExtAttributeName' not found in '$CustomExtApplicationName'." } } <# .SYNOPSIS Sets the WritebackEnabled customer extension for a cloud group. .DESCRIPTION This function updates (True/False) the WritebackEnabled custom extension (directory schema) for a given group. To get this group property use Get-GpadGroupFromEntra .PARAMETER GroupId The identifier(s) of the Entra ID group(s) to set the WritebackEnabled extension for. .PARAMETER IsEnabled The boolean value (True/False) to set for the WritebackEnabled extension. .EXAMPLE Set-GpadWritebackEnabledExtension -GroupId 'b92b####-####-####-####-####8c6fba8d' -IsEnabled $true .EXAMPLE 'b92b####-####-####-####-####8c6fba8d', 'defc####-####-####-####-####b684b71a' | Set-GpadWritebackEnabledExtension -IsEnabled $True #> function Set-GpadWritebackEnabledExtension { [CmdletBinding()] param ( # Group Identifier [Parameter(Mandatory=$true, ValueFromPipelineByPropertyName=$true, ValueFromPipeline=$true, Position=0)] [string[]] $GroupId, # WritebackEnabled Value (True/False) [Parameter(Mandatory=$true, ValueFromPipelineByPropertyName=$true, ValueFromPipeline=$false, Position=1)] [bool] $IsEnabled ) begin { $gwbEnabledExtName = Get-GpadWritebackEnabledExtensionName if ([string]::IsNullOrEmpty($gwbEnabledExtName)) { Throw "Custom extension attribute 'WritebackEnabled' not found." } } process { # Set WritebackEnabled customer extension Write-Verbose "Updating Group '$($GroupId[0])' with '$gwbEnabledExtName' = '$IsEnabled'..." Update-MgGroup -GroupId $GroupId[0] -AdditionalProperties @{$gwbEnabledExtName = $IsEnabled} } end {} } <# .SYNOPSIS Set IsEnabled True/False property in WritebackConfiguration .DESCRIPTION This function sets the WritebackConfiguration.IsEnabled property (Group schema) for a group in Entra ID To get this group property use Get-GpadGroupFromEntra .PARAMETER GroupId The identifier(s) of the Entra ID group(s) to set the WritebackConfiguration for. .PARAMETER IsEnabled The boolean value (True/False) to set for the WritebackConfiguration.IsEnabled property. .EXAMPLE Set-GpadWritebackConfiguration -GroupId 'b92b####-####-####-####-####8c6fba8d' -IsEnabled $true .EXAMPLE 'b92b####-####-####-####-####8c6fba8d', 'defc####-####-####-####-####b684b71a' | Set-GpadWritebackConfiguration -IsEnabled $True #> function Set-GpadWritebackConfiguration { [CmdletBinding()] param ( # Group Identifier [Parameter(Mandatory=$true, ValueFromPipelineByPropertyName=$true, ValueFromPipeline=$true, Position=0)] [string[]] $GroupId, # WritebackConfiguration IsEnabled Value (True/False) [Parameter(Mandatory=$true, ValueFromPipelineByPropertyName=$true, Position=1)] [bool] $IsEnabled ) begin { # Set target endpoint/resource # Note: Beta endpoint is required for WritebackConfiguration property $baseUriBeta = "https://graph.microsoft.com/beta/groups/" # JSON Body $writebackConfiguration = "{""writebackConfiguration"": {""isEnabled"": $($IsEnabled.ToString().ToLower())}}" } process { Write-Verbose "Updating Group '$GroupId' with '$($writebackConfiguration.toString())'..." Invoke-MgGraphRequest -uri "$baseUriBeta$GroupId" -Method PATCH -Body $writebackConfiguration } end {} } <# .SYNOPSIS Updates the WritebackEnabled extension for security groups. .DESCRIPTION This function updates the WritebackEnabled extension property (directory schema) for all security groups based on their WritebackConfiguration property (group schema). It only processes groups where WritebackConfiguration property differs from WritebackEnabled extension. To get these group properties use Get-GpadGroupFromEntra .EXAMPLE Update-GpadWritebackEnabledExtension #> function Update-GpadWritebackEnabledExtension { [CmdletBinding()] param () # Get Custom extension attribute to update $gwbEnabledExtName = Get-GpadWritebackEnabledExtensionName if ([string]::IsNullOrEmpty($gwbEnabledExtName)) { Throw "Custom extension attribute 'WritebackEnabled' not found." } Write-Host "Updating security groups' WritebackEnabled extension. Please wait..." -ForegroundColor Cyan # Properties: &$select=DisplayName,Id,GroupTypes,OnPremisesSyncEnabled,WritebackConfiguration[,extension_<appID>_WritebackEnabled] $propertyList = @('DisplayName', 'Id', 'MailNickname', 'Description', 'GroupTypes', 'OnPremisesSyncEnabled', 'WritebackConfiguration', $gwbEnabledExtName) # Set target endpoint/resource # Note: Beta endpoint is required for WritebackConfiguration property $baseUriBeta = "https://graph.microsoft.com/beta/groups/" # Headers to prevent: Filter operator 'NotEqualsMatch' is not supported. $headers = @{'ConsistencyLevel'= 'eventual'} # Filter: Cloud only groups # Note: add filter to exclude Unified groups (... and groupTypes ne 'Unified') [string] $filter = '?$filter=onPremisesSyncEnabled ne true&$count=true' # Properties to fetch from groups [string] $select = '&$select=' + $($propertyList -join ',') # Call Graph - Get all Security groups with OnPremisesSyncEnabled != True $uri = $baseUriBeta + $filter + $select Write-Verbose "URI: '$uri'" try { # Get cloud groups $groups = Invoke-MgGraphRequest -Method GET -Uri $uri -Headers $headers -OutputType PSObject Write-Verbose "$($groups.value.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.value) { # Filter security groups only if (-not ($group.GroupTypes -Match 'Unified')) { $shouldUpdate = ($group.WritebackConfiguration.IsEnabled -and (-not $group.$gwbEnabledExtName)) -or ((-not $group.WritebackConfiguration.IsEnabled) -and $group.$gwbEnabledExtName) Write-Verbose "Group '$($group.DisplayName)' ($($group.Id)) needs update: $shouldUpdate" if ($shouldUpdate) { $updatedValue = [bool]$group.WritebackConfiguration.IsEnabled try { Write-Verbose "Updating group: Update-MgGroup -GroupId $($group.Id) -AdditionalProperties @{$gwbEnabledExtName = $updatedValue }" Update-MgGroup -GroupId $group.Id -AdditionalProperties @{$gwbEnabledExtName = $updatedValue } # show update $group.$gwbEnabledExtName = $updatedValue $group | Select-Object DisplayName, Id, ` @{Name = 'WritebackConfiguration_IsEnabled'; Expression = { $_.WritebackConfiguration.IsEnabled } }, ` @{Name = $gwbEnabledExtName; Expression = { $_.$gwbEnabledExtName } } } catch { Write-Error "There was an error updating group '$($group.Id)'. Error Details: $($_.Exception.Message)" } } } } } #endregion #======================================================================================= #region Group Reconciliation Functions #======================================================================================= <# .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 'b92b####-####-####-####-####8c6fba8d' .EXAMPLE PS C:\> Confirm-GpadReconciliationNeeded -GroupId 'b92b####-####-####-####-####8c6fba8d' -GroupWritebackOU "OU=Groups,DC=example,DC=com" .EXAMPLE PS C:\> 'b92b####-####-####-####-####8c6fba8d', 'defc####-####-####-####-####b684b71a' | Confirm-GpadReconciliationNeeded -Verbose .EXAMPLE PS C:\> Get-GpadGroupFromEntra | Select-Object -ExpandProperty Id | Confirm-GpadReconciliationNeeded .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 - used as SearchBase for finding groups in AD [Parameter(Mandatory=$false, ValueFromPipelineByPropertyName=$false, Position=1)] [string] $GroupWritebackOU ) begin { Import-GpadToolsGraphModule Import-GpadToolsActiveDirectoryModule } process { # Get group members from Entra ID $groupSyncedMembersInEntra = Get-GpadGroupMembersFromEntra -GroupId $GroupId[0] #-onPremisesSyncEnabledMembers # Get group from AD based on adminDescription anchor try { $groupInAD = Get-GpadGroupFromAD -GroupId $GroupId[0] -GroupWritebackOU $GroupWritebackOU } catch { # Catch issues calling AD - Group not found returns null, it doesn't throw an exception Throw "Failed to get AD group with 'Group_$($GroupId[0])' anchor. Error details: $($_.Exception.Message)" } # Get group members from AD [bool] $groupNotFoundInAD = $false if ($null -ne $groupInAD) { $groupMembersInAD = Get-GpadGroupMembersFromAD -GroupFromAD $groupInAD } else { $groupNotFoundInAD = $true } # Get the onPremisesSecurityIdentifier for Cloud groups that have been written back to AD $i = 0 while ($i -lt $groupSyncedMembersInEntra.count) { # Check if cloud group if ($groupSyncedMembersInEntra[$i].onPremisesSyncEnabled -ne $true) { # Check if it exists onprem $cloudGroupInAD = Get-GpadGroupFromAD -GroupId $($groupSyncedMembersInEntra[$i].Id) -GroupWritebackOU $GroupWritebackOU if ($cloudGroupInAD) { $groupSyncedMembersInEntra[$i].onPremisesSecurityIdentifier = $cloudGroupInAD.objectSid.Value } } $i++ } # Get group membership differences $membersInADSidList = $groupMembersInAD | Select-Object -ExpandProperty objectSid $membersInEntraSidList = $groupSyncedMembersInEntra | Select-Object -ExpandProperty onPremisesSecurityIdentifier $extraMembersInAD = ($membersInADSidList | Where-Object {$membersInEntraSidList -NotContains $_}).Value $missingMembersInAD = $membersInEntraSidList | Where-Object {$membersInADSidList -NotContains $_} # Flag reconciliation needed $groupMemberReconciliationNeeded = $extraMembersInAD.Count -or $missingMembersInAD.Count # Build Results $result = "" | Select-Object Id, Result, ExtraMembers, MissingMembers $result.Id = $GroupId[0] If ($groupNotFoundInAD) { $result.Result = "Not found on-premises - Check if out of sync scope, restore it from AD Recycle Bin, or reprovision the Group." } elseIf ($groupMemberReconciliationNeeded) { # Get the properties for each extra member $extraMembersProperties = [System.Collections.ArrayList] @() foreach ($m in $extraMembersInAD) { $extraMembersProperties += $groupMembersInAD | Where-Object objectSid -eq $m } # Get the properties for each missing member $missingMembersProperties = [System.Collections.ArrayList] @() foreach ($m in $missingMembersInAD) { $missingMembersProperties += $groupSyncedMembersInEntra | Where-Object onPremisesSecurityIdentifier -eq $m } $result.Result = "Diverging membership - Reconciliation needed" $result.ExtraMembers = $extraMembersProperties $result.MissingMembers = $missingMembersProperties } else { $result.Result = "Consistent - OK" } return $result } end {} } <# .SYNOPSIS NOT SUPPORTED - MICROSOFT INTERNAL USE ONLY 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 'b92b####-####-####-####-####8c6fba8d' ` -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' 'b92b####-####-####-####-####8c6fba8d', 'defc####-####-####-####-####b684b71a' | 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 Write-Verbose "Getting group $($GroupId[0]) from Entra ID..." $allGroupMembersHt = Get-GpadGroupMembersFromEntraHT -GroupId $GroupId[0] # Get the respetive written back group from AD Write-Verbose "Getting group $($GroupId[0]) from AD..." $adGroup = Get-GpadGroupFromAD -GroupId $GroupId[0] Write-Verbose "Processing group $($GroupId[0]) with '$($allGroupMembersHt.count)' members. Please wait..." # 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[0])' anchor. Error: $($_.Exception.Message)" } # Reconcile members, split by MaxMemberBatchSize $MaxMemberBatchSize = 5 $i = 0 while ($i -lt $allGroupMembersHt.Count) { $groupMembersHt = [System.Collections.ArrayList] @() for ($j = 0; $j -lt $MaxMemberBatchSize; $j++) { if ($i -lt $allGroupMembersHt.Count) { Write-Verbose "Adding member $($allGroupMembersHt[$i].Values -join "_")..." $groupMembersHt += $allGroupMembersHt[$i] $i++ } } # Build body parameter with members Write-Host "Updating group with batch of $($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 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 ( # Domain Name [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 $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 } #endregion #======================================================================================= #region Group Functions #======================================================================================= <# .SYNOPSIS Retrieves cloud security groups from Entra ID. .DESCRIPTION This function retrieves one or all security groups from Entra ID, excluding cloud M365 / Unified groups. Exposes the WritebackConfiguration property (group schema) and the WritebackEnabled custom extension (directory schema). .EXAMPLE Get-GpadGroupFromEntra .EXAMPLE Get-GpadGroupFromEntra -GroupId 'b92b####-####-####-####-####8c6fba8d' .EXAMPLE 'b92b####-####-####-####-####8c6fba8d', 'defc####-####-####-####-####b684b71a' | Get-GpadGroupFromEntra -IsEnabled $True #> function Get-GpadGroupFromEntra { [CmdletBinding()] param ( # Group Identifier [Parameter(Mandatory=$false, ValueFromPipelineByPropertyName=$true, ValueFromPipeline=$true, Position=0)] [string[]] $GroupId ) begin { # Set Graph endpoint - beta is required for WritebackConfiguration property $baseUriBeta = "https://graph.microsoft.com/beta/groups/" # Headers to prevent: Filter operator 'NotEqualsMatch' is not supported. $headers = @{'ConsistencyLevel'= 'eventual'} # Get the GroupWritebackEnabled extension name $gwbEnabledExtName = Get-GpadWritebackEnabledExtensionName # Build property list If ([string]::IsNullOrEmpty($gwbEnabledExtName)) { # No GroupWritebackEnabled extension found $propertyList = @('DisplayName', 'Id', 'GroupTypes', 'OnPremisesSyncEnabled', 'WritebackConfiguration') $selectList = @('DisplayName', 'Id', 'GroupTypes', @{Name = 'WritebackConfiguration_IsEnabled'; Expression = { $_.WritebackConfiguration.IsEnabled } }, @{Name = 'WritebackConfiguration_onPremisesGroupType'; Expression = { $_.WritebackConfiguration.onPremisesGroupType } }) } else { # Include GroupWritebackEnabled extension $propertyList = @('DisplayName', 'Id', 'GroupTypes', 'OnPremisesSyncEnabled', 'WritebackConfiguration', $gwbEnabledExtName) $selectList = @('DisplayName', 'Id', 'GroupTypes', $gwbEnabledExtName, @{Name = 'WritebackConfiguration_IsEnabled'; Expression = { $_.WritebackConfiguration.IsEnabled } }, @{Name = 'WritebackConfiguration_onPremisesGroupType'; Expression = { $_.WritebackConfiguration.onPremisesGroupType } }) } # Properties: &$select=DisplayName,Id,GroupTypes,OnPremisesSyncEnabled,WritebackConfiguration[,extension_<appID>_WritebackEnabled] [string] $select = '?$select=' + $($propertyList -join ',') # Filter: Cloud only groups # (Improvement) add filter to exclude Unified groups (and groupTypes ne 'Unified') [string] $filter = '&$filter=onPremisesSyncEnabled ne true&$count=true' } process { if ($GroupId) { # Call Graph - Get groups with WritebackConfiguration and custom extension properties $uri = $baseUriBeta + $GroupId + $select Write-Verbose "URI: '$uri'" $group = Invoke-MgGraphRequest -Method GET -Uri $uri -OutputType PSObject $group | Select-Object $selectList } else { # Call Graph - Get all Security groups with OnPremisesSyncEnabled != True $uri = $baseUriBeta + $select + $filter Write-Verbose "URI: $uri" $groups = Invoke-MgGraphRequest -Method GET -Uri $uri -Headers $headers -OutputType PSObject # List Groups foreach ($group in $groups.value) { if (-not ($group.GroupTypes -Match 'Unified')) { $group | Select-Object $selectList } } } } end {} } <# .SYNOPSIS Retrieves the members of a specified group from Entra ID. .DESCRIPTION With onPremisesSyncEnabledMembers switch, gets only the members that are synced from on-premises AD. .PARAMETER GroupId The ID of the group to get members for. .EXAMPLE Get-GpadGroupMembersFromEntra -GroupId 'b92b####-####-####-####-####8c6fba8d' .EXAMPLE Get-GpadGroupMembersFromEntra -GroupId 'b92b####-####-####-####-####8c6fba8d' -onPremisesSyncEnabledMembers .OUTPUTS System.Collections.ArrayList. A list of members with DisplayName, Mail, UserPrincipalName, Id, ObjectType, onPremisesSyncEnabled, onPremisesSecurityIdentifier #> function Get-GpadGroupMembersFromEntra { [CmdletBinding()] param ( # Group Identifier [Parameter(Mandatory=$true, ValueFromPipelineByPropertyName=$true, ValueFromPipeline=$true, Position=0)] [string] $GroupId, # Include only members that are synced from on-premises AD [Parameter(Mandatory=$false, ValueFromPipelineByPropertyName=$true, ValueFromPipeline=$true, Position=1)] [switch] $onPremisesSyncEnabledMembers = $false ) Write-Verbose "Getting membership from group '$GroupId' in Entra ID..." $groupMembersInEntra = Get-MgGroupMember -GroupId $GroupId -All -Property Id, onPremisesSyncEnabled, onPremisesSecurityIdentifier $groupSyncedMembersInEntra = [System.Collections.ArrayList] @() ForEach ($m in $groupMembersInEntra) { if (($m.AdditionalProperties.onPremisesSyncEnabled -and $onPremisesSyncEnabledMembers) ` -or (-not $onPremisesSyncEnabledMembers)) { $groupObj = Get-MgDirectoryObject -DirectoryObjectId $m.Id $member = [PSCustomObject]@{ DisplayName = $groupObj.AdditionalProperties.displayName Mail = $groupObj.AdditionalProperties.mail UserPrincipalName = $groupObj.AdditionalProperties.userPrincipalName Id = $m.Id ObjectType = $m.AdditionalProperties.'@odata.type' -replace '#microsoft.graph.','' onPremisesSyncEnabled = $m.AdditionalProperties.onPremisesSyncEnabled onPremisesSecurityIdentifier = $m.AdditionalProperties.onPremisesSecurityIdentifier } $groupSyncedMembersInEntra += $member } } Write-Verbose "Entra ID group '$GroupId' has $($groupSyncedMembersInEntra.Count) synced members." return $groupSyncedMembersInEntra } <# .SYNOPSIS INTERNAL - Get the members for body parameter in Graph call Get the members of a group from Entra ID in hashtable format .DESCRIPTION Retrieves the members of a specified group from Entra ID. .PARAMETER GroupId The ID of the group to get members for. .EXAMPLE Get-GpadGroupMembersFromEntraHT -GroupId 'b92b####-####-####-####-####8c6fba8d' .OUTPUTS System.Collections.ArrayList. A hashtable list of members with objectTypeName and objectId #> function Get-GpadGroupMembersFromEntraHT { [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, skip! } $entry = @{ objectId = $m.Id objectTypeName = $type } $membersHt += $entry } } return $membersHt } <# .SYNOPSIS Retrieves groups from Active Directory .DESCRIPTION Retrieves a group from Active Directory using the adminDescription property as an anchor. .PARAMETER GroupId The identifier(s) of the Entra ID group(s) which will have the anchor 'Group_<GroupId>' in adminDescription. .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 Get-GpadGroupFromAD -GroupId 'b92b####-####-####-####-####8c6fba8d' .EXAMPLE Get-GpadGroupFromAD -GroupId 'b92b####-####-####-####-####8c6fba8d' -GroupWritebackOU "OU=Groups,DC=example,DC=com" #> function Get-GpadGroupFromAD { [CmdletBinding()] param ( # Group Identifier from Entra ID [Parameter(Mandatory=$true, ValueFromPipelineByPropertyName=$true, ValueFromPipeline=$true, Position=0)] [string] $GroupId, # Cloud Sync Group Writeback OU in Active Directory - used as SearchBase for finding groups in AD [Parameter(Mandatory=$false, ValueFromPipelineByPropertyName=$false, Position=1)] [string] $GroupWritebackOU ) Write-Verbose "Getting AD group with adminDescription -eq 'Group_$($GroupId)'..." $propertiesFromAD = 'member,adminDescription,objectSid' -split ',' try { if ([string]::IsNullOrEmpty($GroupWritebackOU)) { $adGroup = Get-ADObject -Filter "adminDescription -eq 'Group_$($GroupId)'" -Properties $propertiesFromAD } else { $adGroup = Get-ADObject -Filter "adminDescription -eq 'Group_$($GroupId)'" -SearchBase $GroupWritebackOU -Properties $propertiesFromAD } } catch { Throw "Failed to get AD group with 'Group_$GroupId' anchor. Error details: $($_.Exception.Message)" } return $adGroup } <# .SYNOPSIS Retrieves group members from an Active Directory Group .DESCRIPTION This function retrieves all the group members from Active Directory. .PARAMETER GroupFromAD The group object from Active Directory, including the member property. You can get this object using Get-GpadGroupFromAD, Get-ADObject, or similar cmdlets. .EXAMPLE Get-GpadGroupMembersFromAD GroupFromAD <group object> #> function Get-GpadGroupMembersFromAD { [CmdletBinding()] param ( # Group object from AD including member property [Parameter(Mandatory=$true, ValueFromPipelineByPropertyName=$true, ValueFromPipeline=$true, Position=0)] $GroupFromAD ) try { $groupMembersInAD = [System.Collections.ArrayList] @() $p = 'DisplayName,Mail,UserPrincipalName,ObjectGuid,ObjectClass,objectSid' -split ',' $groupMembersInAD = $GroupFromAD.member | Get-ADObject -Properties $p | Select-Object $p } catch { Throw "Failed to retrieve objectSid from AD group members with '$($GroupFromAD.adminDescription)' anchor. Error details: $($_.Exception.Message)" } Write-Verbose "Group '$($GroupFromAD.adminDescription)' has $($groupMembersInAD.Count) members in AD." return $groupMembersInAD } #endregion #======================================================================================= #region Global Variables #======================================================================================= # Set error action preference to stop on errors $ErrorActionPreference = [System.Management.Automation.ActionPreference]::Stop # Module information message $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" "@ $DefaultCustomExtApplicationName = "CloudSyncCustomExtensionsApp" # Custom Extensions Application Name $DefaultCustomExtAttributeName = "WritebackEnabled" # Custom Extension Property Name #endregion #======================================================================================= #region MAIN #======================================================================================= Write-Host $introMsg -ForegroundColor Cyan #endregion |