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