Example: Membership Runbook

A Step-by-Step Guide for Azure Automation with Hybrid Workers and PowerShell 7.2

This tutorial provides guidance for implementing an Azure Automation runbook that synchronizes membership changes from ServiceChanger to on-premises Active Directory and Entra ID using Azure Arc-enabled Hybrid Worker(s).

System Requirements

Required PowerShell Modules (on all Arc VMs)

  • Active Directory Module: Install-WindowsFeature -Name RSAT-AD-PowerShell

  • Azure Arc Agent: Installed and connected to Azure

  • WinRM Configuration: Enabled for Azure AD Connect remoting

Optional Configuration (for Email Alerts)

  • SMTP Service Account: Access to email server (Office 365, Exchange, etc.)

Arc-enabled Architecture Benefits

  • Enhanced security through Azure managed identity integration

  • Improved connectivity and reliability via Azure Arc agent

  • Centralized management through Azure portal

  • Multiple VMs provide automatic redundancy and load balancing


Step 1: Configure Azure Automation Variables

Navigate to your Azure Automation Account and create the following variables under Shared Resources > Variables:

Core Configuration Variables

Configuration Details:

  • Name: SC_ApiKey

  • Type: String

  • Encrypted: Yes

  • Value: [Your ServiceChanger API Key]

  • Description: ServiceChanger API authentication key

Obtain your API key from ServiceChanger application: Settings > ServiceChanger API Key

Azure AD Connect Variables

Configuration Details:

  • Name: SC_AADConnectServer

  • Type: String

  • Encrypted: No

  • Value: ADCONNECT-CLUSTER.domain.local (server name or cluster VIP)

  • Description: Azure AD Connect server where delta sync will be executed

Email Alert Variables (Optional)

  • Name: SC_SmtpUsername

  • Type: String

  • Encrypted: Yes

  • Value: [SMTP service account email address]


Step 2: Create and Configure the Runbook

Navigation Path: Azure Portal → Automation Account → Process Automation → Runbooks

  1. Select "+ Create Runbook"

Runbook Configuration

Setting
Value

Name

ServiceChanger-AD-MembershipSync

Runbook Type

PowerShell

Runtime Version

7.2

Description

Enterprise AD membership synchronization with ServiceChanger API using Arc Workers

Code Deployment

  1. Open the newly created runbook

  2. Insert the runbook code below

  3. Save your changes

  4. Publish the runbook

<#
.SYNOPSIS
    Enterprise Azure Automation Runbook: ServiceChanger AD Membership Synchronization

.DESCRIPTION
    This runbook synchronizes membership changes from ServiceChanger API to on-premises Active Directory.
    It polls the ServiceChanger API for membership changes where onPremisesSyncEnabled is true,
    then applies those changes to local AD groups using a Hybrid Runbook Worker.

.FEATURES
    • Delta synchronization using persistent LastSyncDate tracking
    • Batch processing for optimal performance
    • AD object caching to minimize directory queries
    • Automatic Azure AD Connect delta sync to Entra ID via PowerShell remoting
    • Comprehensive error handling with retry logic
    • Optional email alerting on failures
    • Idempotent operations to prevent duplicate changes
    • Detailed logging with UTC timestamps

.PREREQUISITES
    • Azure Automation Account with Hybrid Runbook Worker (ARC-enabled recommended)
    • PowerShell 7 runtime
    • Active Directory PowerShell module on Hybrid Worker
    • Azure AD Connect server with PowerShell remoting enabled
    • Valid ServiceChanger API key
    • SMTP service account (if email alerts enabled)
    • Hybrid Worker service account with AD group management permissions
    • Dedicated service account for Azure AD Connect operations

.ARC HYBRID WORKERS
    This runbook is fully compatible with Azure Arc-enabled Hybrid Workers, which provide:
    • Enhanced security through Azure managed identity integration
    • Improved connectivity and reliability via Azure Arc agent
    • Centralized management through Azure portal
    • Better monitoring and diagnostics capabilities
    
    For ARC-enabled deployments:
    • Ensure ActiveDirectory PowerShell module is installed on all ARC machines
    • Verify outbound HTTPS connectivity to ServiceChanger API (api.servicechanger.com)
    • Confirm PowerShell remoting access to Azure AD Connect server from all VMs
    • Consider using managed identity for future authentication enhancements
    • Multiple ARC-enabled VMs provide automatic redundancy and load balancing
    
    Network Requirements for ARC Workers:
    • HTTPS 443 outbound to Azure (handled by ARC agent)
    • Domain connectivity for Active Directory operations
    • Network access to Azure AD Connect server for PowerShell remoting
    • SMTP connectivity if email alerts are enabled

.AZURE AUTOMATION VARIABLES
    Required (Encrypted):
    • SC_ApiKey: ServiceChanger API authentication key
    • SC_AADConnectUsername: Azure AD Connect service account username
    • SC_AADConnectPassword: Azure AD Connect service account password
    
    Required (Not Encrypted):
    • SC_LastSyncDate: ISO 8601 UTC timestamp of last successful sync
    • SC_AADConnectServer: Server name where Azure AD Connect is installed
    
    Optional (Encrypted - for email alerts):
    • SC_SmtpUsername: SMTP service account username
    • SC_SmtpPassword: SMTP service account password
    • SC_SmtpTo: Comma-separated email addresses for alerts

.NOTES
    Version: 1.2
    Author: ServiceChanger BV - Ruben van der Graaf
    Last Modified: 2025-07-31
    
    This runbook automatically triggers Azure AD Connect delta sync after making AD changes
    using PowerShell remoting to execute commands on the designated Azure AD Connect server.
    The Hybrid Worker must have network connectivity to the AD Connect server and appropriate
    service account permissions for remote execution.
    
.PARAMETER EnableEmailAlerts
    Enable email notifications on errors. Default: $false
#>

[CmdletBinding()]
param(
    [Parameter(Mandatory = $false)]
    [bool]$EnableEmailAlerts = $false
)

#region Configuration
# ServiceChanger API Configuration
$ApiBaseUrl = "https://api.servicechanger.com/public/directory-changes"

# SMTP Configuration (customize for your environment)
$SmtpServer = "smtp.office365.com"
$SmtpPort = 587
$SmtpFrom = "serviceaccount@yourdomain.com"
$EnableSsl = $true

# Processing Configuration
$RetryCount = 3
$BatchSize = 100
$LogFilePath = "$env:TEMP\MembershipSyncLog_$(Get-Date -Format 'yyyyMMdd_HHmmss').txt"
#endregion

#region Azure Automation Variables
try {
    # Core ServiceChanger variables
    $ApiKey = Get-AutomationVariable -Name 'SC_ApiKey'
    $lastSyncDate = Get-AutomationVariable -Name 'SC_LastSyncDate'
    
    # Azure AD Connect variables
    $AADConnectServer = Get-AutomationVariable -Name 'SC_AADConnectServer'
    $AADConnectUsername = Get-AutomationVariable -Name 'SC_AADConnectUsername'
    $AADConnectPassword = Get-AutomationVariable -Name 'SC_AADConnectPassword'
    
    # SMTP variables (only load if email alerts enabled)
    if ($EnableEmailAlerts) {
        $SmtpUsername = Get-AutomationVariable -Name 'SC_SmtpUsername'
        $SmtpPassword = Get-AutomationVariable -Name 'SC_SmtpPassword'
        $SmtpToVar = Get-AutomationVariable -Name 'SC_SmtpTo'
        $SmtpTo = if ($SmtpToVar) { $SmtpToVar.Split(',') | ForEach-Object { $_.Trim() } } else { @() }
    } else {
        # Initialize SMTP variables as null for safety
        $SmtpUsername = $null
        $SmtpPassword = $null
        $SmtpTo = @()
    }
    
} catch {
    throw "Failed to load Azure Automation Variables. Ensure all required variables are configured: $_"
}
#endregion

#region Helper Functions
function Write-Log {
    <#
    .SYNOPSIS
        Writes timestamped log messages with color coding for Azure Automation console
    #>
    param (
        [Parameter(Mandatory = $true)]
        [string]$Message,
        
        [Parameter(Mandatory = $false)]
        [ValidateSet("INFO", "WARNING", "ERROR", "SUCCESS")]
        [string]$Level = "INFO"
    )
    
    $timestamp = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
    $logMessage = "[$timestamp] [$Level] $Message"
    
    # Azure Automation console output with ANSI color codes
    switch ($Level) {
        "INFO"    { Write-Output "`e[32m$logMessage`e[0m" }
        "WARNING" { Write-Warning "`e[33m$logMessage`e[0m" }
        "ERROR"   { Write-Error "`e[31m$logMessage`e[0m" }
        "SUCCESS" { Write-Output "`e[36m$logMessage`e[0m" }
    }
    
    # Append to log file for email attachments
    Add-Content -Path $LogFilePath -Value $logMessage -ErrorAction SilentlyContinue
}

function Send-ErrorAlert {
    <#
    .SYNOPSIS
        Sends email alert with log attachment when runbook fails after all retries
    #>
    param (
        [Parameter(Mandatory = $true)]
        [string]$ErrorMessage
    )
    
    if (-not $EnableEmailAlerts) { return }
    
    # Validate SMTP variables are available
    if (-not $SmtpUsername -or -not $SmtpPassword -or $SmtpTo.Count -eq 0) {
        Write-Log "Email alerts enabled but SMTP variables not properly configured" "WARNING"
        return
    }
    
    Write-Log "Sending email alert to $($SmtpTo -join ', ')..." "INFO"
    
    try {
        $securePassword = ConvertTo-SecureString $SmtpPassword -AsPlainText -Force
        $credential = New-Object System.Management.Automation.PSCredential ($SmtpUsername, $securePassword)
        
        $subject = "ServiceChanger AD Sync - Error Alert"
        $body = @"
ServiceChanger AD Synchronization Runbook failed after $RetryCount retries.

Error Details:
$ErrorMessage

Timestamp: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss UTC')

Please review the attached log file for detailed information and take appropriate action.

This is an automated alert from Azure Automation.
"@
        
        Send-MailMessage -SmtpServer $SmtpServer -Port $SmtpPort -UseSsl:$EnableSsl `
                         -Credential $credential -From $SmtpFrom -To $SmtpTo `
                         -Subject $subject -Body $body -Attachments $LogFilePath
        
        Write-Log "Email alert sent successfully" "SUCCESS"
    } catch {
        Write-Log "Failed to send email alert: $_" "ERROR"
    }
}

function Invoke-AADConnectSync {
    <#
    .SYNOPSIS
        Triggers Azure AD Connect delta sync on remote server using PowerShell remoting
    #>
    param (
        [Parameter(Mandatory = $true)]
        [int]$ChangesProcessed
    )
    
    if ($ChangesProcessed -eq 0) {
        Write-Log "No changes processed - skipping Azure AD Connect sync" "INFO"
        return
    }
    
    Write-Log "Triggering Azure AD Connect delta sync to push $ChangesProcessed changes to Entra ID..." "INFO"
    
    try {
        # Validate required variables
        if (-not $AADConnectServer) {
            throw "SC_AADConnectServer variable not configured"
        }
        if (-not $AADConnectUsername) {
            throw "SC_AADConnectUsername variable not configured"
        }
        if (-not $AADConnectPassword) {
            throw "SC_AADConnectPassword variable not configured"
        }
        
        # Create credential object for remote connection
        $securePassword = ConvertTo-SecureString $AADConnectPassword -AsPlainText -Force
        $credential = New-Object System.Management.Automation.PSCredential ($AADConnectUsername, $securePassword)
        
        Write-Log "Executing sync command on remote server: $AADConnectServer" "INFO"
        
        # Execute Azure AD Connect sync on remote server
        $syncResult = Invoke-Command -ComputerName $AADConnectServer -Credential $credential -ScriptBlock {
            try {
                Import-Module ADSync -ErrorAction Stop
                $result = Start-ADSyncSyncCycle -PolicyType Delta -ErrorAction Stop
                return $result
            } catch {
                throw "Remote sync execution failed: $($_.Exception.Message)"
            }
        } -ErrorAction Stop
        
        if ($syncResult) {
            Write-Log "Azure AD Connect delta sync initiated successfully on $AADConnectServer - Result: $($syncResult.Result)" "SUCCESS"
        } else {
            Write-Log "Azure AD Connect sync command executed but no result returned from $AADConnectServer" "WARNING"
        }
        
    } catch {
        Write-Log "Failed to trigger Azure AD Connect sync: $_" "WARNING"
        Write-Log "Changes were applied to local AD but may not sync to Entra ID immediately" "WARNING"
        Write-Log "Verify network connectivity to $AADConnectServer and service account permissions" "WARNING"
    }
}
#endregion

#region Main Execution
try {
    # Initialize execution
    "" | Out-File $LogFilePath
    Write-Log "Starting ServiceChanger AD membership synchronization" "INFO"
    Write-Log "Runbook version: 1.2 | Batch size: $BatchSize | Retry count: $RetryCount" "INFO"
    Write-Log "Azure AD Connect server: $AADConnectServer" "INFO"
    
    # Load required PowerShell module
    Import-Module ActiveDirectory -ErrorAction Stop
    Write-Log "Active Directory PowerShell module loaded successfully" "SUCCESS"
    
    # Prepare API request parameters
    $utcNow = (Get-Date).ToUniversalTime()
    $queryParams = @{
        resourceType = "membership"
        onPremisesSyncEnabled = "true"
    }
    if ($lastSyncDate) { 
        $queryParams['lastSyncDate'] = $lastSyncDate
        Write-Log "Querying changes since last sync: $lastSyncDate" "INFO"
    } else {
        Write-Log "No previous sync date found - performing full synchronization" "INFO"
    }
    
    # Build API request URL with proper encoding
    $queryString = ($queryParams.GetEnumerator() | ForEach-Object { "$($_.Key)=" + [uri]::EscapeDataString($_.Value) }) -join "&"
    $fullUrl = $ApiBaseUrl + "?" + $queryString
    Write-Log "API endpoint: $fullUrl" "INFO"
    
    # Execute API call with retry logic
    $apiResponse = $null
    for ($attempt = 1; $attempt -le $RetryCount; $attempt++) {
        try {
            Write-Log "API call attempt $attempt of $RetryCount" "INFO"
            
            $headers = @{
                "x-api-key" = $ApiKey
                "Content-Type" = "application/json"
            }
            
            $response = Invoke-RestMethod -Uri $fullUrl -Method Get -Headers $headers -ErrorAction Stop
            
            # Validate and extract ServiceChanger API response structure
            if (-not $response.data) {
                throw "Invalid API response structure. Expected 'data' property."
            }
            $apiResponse = $response.data
            
            Write-Log "API call successful - Retrieved $($apiResponse.Count) membership changes" "SUCCESS"
            break
        } catch {
            Write-Log "API call attempt $attempt failed: $_" "WARNING"
            if ($attempt -eq $RetryCount) { 
                throw "API call failed after $RetryCount attempts: $_"
            }
            Start-Sleep -Seconds (5 * $attempt)
        }
    }
    
    # Early exit if no changes to process
    if ($apiResponse.Count -eq 0) {
        $newSyncDate = $utcNow.ToString('yyyy-MM-ddTHH:mm:ssZ')
        Write-Log "No membership changes found - Updating LastSyncDate to $newSyncDate" "SUCCESS"
        Set-AutomationVariable -Name 'SC_LastSyncDate' -Value $newSyncDate
        Write-Log "Synchronization completed successfully" "SUCCESS"
        return
    }
    
    Write-Log "Processing $($apiResponse.Count) membership changes from ServiceChanger API" "INFO"
    
    # Pre-validate AD objects and build cache for performance optimization
    Write-Log "Pre-validating Active Directory objects for performance optimization..." "INFO"
    $groupCache = @{}
    $userCache = @{}
    $validChanges = @()
    
    foreach ($change in $apiResponse) {
        $groupId = $change.resource.groupId
        $userId = $change.resource.userId
        
        # Cache group lookups to minimize AD queries
        if (-not $groupCache.ContainsKey($groupId)) {
            $localGroup = Get-ADGroup -Identity $groupId -ErrorAction SilentlyContinue
            $groupCache[$groupId] = $localGroup
        }
        
        # Cache user lookups to minimize AD queries
        if (-not $userCache.ContainsKey($userId)) {
            $localUser = Get-ADUser -Identity $userId -ErrorAction SilentlyContinue
            $userCache[$userId] = $localUser
        }
        
        # Only process changes where both AD objects exist
        if ($groupCache[$groupId] -and $userCache[$userId]) {
            $validChanges += $change
        } else {
            $missingGroup = if (-not $groupCache[$groupId]) { "group '$groupId'" } else { "" }
            $missingUser = if (-not $userCache[$userId]) { "user '$userId'" } else { "" }
            $missing = ($missingGroup, $missingUser | Where-Object { $_ }) -join " and "
            Write-Log "Skipping change: Local $missing not found in Active Directory" "WARNING"
        }
    }
    
    Write-Log "Validation complete: $($validChanges.Count) valid changes, $($apiResponse.Count - $validChanges.Count) skipped" "INFO"
    
    # Early exit if no valid changes after AD validation
    if ($validChanges.Count -eq 0) {
        $newSyncDate = $utcNow.ToString('yyyy-MM-ddTHH:mm:ssZ')
        Write-Log "No valid changes to process (all changes reference missing AD objects)" "WARNING"
        Set-AutomationVariable -Name 'SC_LastSyncDate' -Value $newSyncDate
        Write-Log "LastSyncDate updated to prevent reprocessing these changes" "INFO"
        return
    }
    
    # Process changes in batches for optimal performance
    Write-Log "Processing $($validChanges.Count) valid changes in batches of $BatchSize" "INFO"
    $processedCount = 0
    $errorCount = 0
    
    for ($i = 0; $i -lt $validChanges.Count; $i += $BatchSize) {
        $batchEnd = [Math]::Min($i + $BatchSize, $validChanges.Count)
        $batch = $validChanges[$i..($batchEnd - 1)]
        Write-Log "Processing batch: items $($i + 1) to $batchEnd" "INFO"
        
        foreach ($change in $batch) {
            try {
                # Extract change details from ServiceChanger API response
                $groupId = $change.resource.groupId
                $userId = $change.resource.userId
                $action = $change.changeType
                $timestamp = $change.timestamp
                
                # Retrieve cached AD objects for performance
                $localGroup = $groupCache[$groupId]
                $localUser = $userCache[$userId]
                
                # Get human-readable names for logging
                $groupName = $localGroup.Name
                $userName = $localUser.DisplayName ?? $localUser.SamAccountName
                
                Write-Log "Processing $action - '$userName' in group '$groupName'" "INFO"
                
                # Apply membership changes with idempotent operations
                $actualChangePerformed = $false
                switch ($action) {
                    "created" {
                        $currentMembers = Get-ADGroupMember -Identity $localGroup -ErrorAction SilentlyContinue
                        $memberNames = if ($currentMembers) { $currentMembers.SamAccountName } else { @() }
                        if ($memberNames -notcontains $localUser.SamAccountName) {
                            Add-ADGroupMember -Identity $localGroup -Members $localUser -ErrorAction Stop
                            Write-Log "Added '$userName' to group '$groupName' (timestamp: $timestamp)" "SUCCESS"
                            $actualChangePerformed = $true
                        } else {
                            Write-Log "User '$userName' already member of group '$groupName' - No action required" "INFO"
                        }
                    }
                    
                    "deleted" {
                        $currentMembers = Get-ADGroupMember -Identity $localGroup -ErrorAction SilentlyContinue
                        $memberNames = if ($currentMembers) { $currentMembers.SamAccountName } else { @() }
                        if ($memberNames -contains $localUser.SamAccountName) {
                            Remove-ADGroupMember -Identity $localGroup -Members $localUser -Confirm:$false -ErrorAction Stop
                            Write-Log "Removed '$userName' from group '$groupName' (timestamp: $timestamp)" "SUCCESS"
                            $actualChangePerformed = $true
                        } else {
                            Write-Log "User '$userName' not member of group '$groupName' - No action required" "INFO"
                        }
                    }
                    
                    "updated" {
                        Write-Log "Membership update detected for '$userName' in group '$groupName' - Manual review recommended" "WARNING"
                    }
                    
                    default {
                        Write-Log "Unknown change type '$action' for user '$userName' in group '$groupName' - Skipping" "WARNING"
                    }
                }
                
                if ($actualChangePerformed) {
                    $processedCount++
                }
                
            } catch {
                $errorCount++
                $userDisplayName = if ($userName) { "'$userName'" } else { $userId }
                $groupDisplayName = if ($groupName) { "'$groupName'" } else { $groupId }
                Write-Log "Failed to process change for user $userDisplayName in group $groupDisplayName - Error: $_" "ERROR"
            }
        }
    }
    
    # Trigger Azure AD Connect delta sync via PowerShell remoting
    Invoke-AADConnectSync -ChangesProcessed $processedCount
    
    # Update LastSyncDate to current UTC time
    $newSyncDate = $utcNow.ToString('yyyy-MM-ddTHH:mm:ssZ')
    
    # Provide comprehensive completion summary
    $skippedChanges = $apiResponse.Count - $validChanges.Count
    $reviewedChanges = $validChanges.Count - $errorCount
    if ($skippedChanges -gt 0) {
        Write-Log "Synchronization completed: $processedCount actual changes, $reviewedChanges reviewed, $errorCount errors, $skippedChanges skipped (missing AD objects)" "INFO"
    } else {
        Write-Log "Synchronization completed successfully: $processedCount actual changes, $reviewedChanges reviewed, $errorCount errors" "SUCCESS"
    }
    
    Write-Log "LastSyncDate updated to $newSyncDate for next synchronization cycle" "SUCCESS"
    Set-AutomationVariable -Name 'SC_LastSyncDate' -Value $newSyncDate
    
} catch {
    Write-Log "Critical error in runbook execution: $_" "ERROR"
    Send-ErrorAlert $_
    throw $_
} finally {
    # Cleanup temporary log file if email alerts are disabled
    if (-not $EnableEmailAlerts) { 
        Remove-Item $LogFilePath -Force -ErrorAction SilentlyContinue
    }
    Write-Log "ServiceChanger AD membership synchronization completed" "INFO"
}
#endregion 

Step 3: Email Alert Configuration (Optional)

Email notifications are triggered only for critical runbook failures that prevent completion

To enable email notifications:

  1. Edit the runbook

  2. Customize environment-specific settings:

SMTP Configuration (Lines 57-60)
# Line 57: Configure SMTP server for your environment
$SmtpServer = "smtp.office365.com"

# Line 59: Set sender email address
$SmtpFrom = "serviceaccount@yourdomain.com"
  1. Modify the parameter at the top of the script:

Enable Email Alerts
# Change from:
[bool]$EnableEmailAlerts = $false

# Change to:
[bool]$EnableEmailAlerts = $true

Alternative: Pass the parameter when starting the runbook manually or configure it in the schedule.

  1. Save and republish the runbook


Step 4: Schedule Configuration

Schedule Creation

  1. Select "+ Link to schedule"

  2. Choose "Link a schedule to your runbook"

  3. Configure the new schedule:

Setting
Recommended Value

Name

ServiceChanger-Sync-Schedule

Frequency

Recurring

Recur every

1 hour

Time zone

Your organizational timezone

Execution Settings

  • Run on: Hybrid Worker

  • Hybrid Worker Group: [Your configured group name]


Step 5: Testing and Validation

Initial Test Execution

  1. Execute "Start" within the runbook interface

  2. Monitor the Output tab for execution logs and status

  3. Verify successful completion and review log entries

Validation Checklist


Troubleshooting Guide

Common Error Scenarios

Error Message: "Failed to load Azure Automation Variables"

Resolution Steps:

  • Verify all required variables exist with correct naming (case-sensitive)

  • Confirm encrypted variables are properly marked as encrypted

  • Validate variable names for typographical errors

  • Ensure the Automation Account has proper permissions

  • Check specifically for new Azure AD Connect variables (SC_AADConnectServer, SC_AADConnectUsername, SC_AADConnectPassword)

Support Resources

Technical Support: The runbook includes comprehensive error handling and detailed logging. All operations generate timestamped log entries with specific error descriptions for troubleshooting purposes.


Last updated