Set Value: [ServiceChanger API Key](Grab your ServiceChanger API Key from the app under Settings > ServiceChanger API Key.)
Set Encrypted to Yes.
Click Create.
1.3 Create the SC_LastMembershipSyncTimestamp Variable
Click on "+ Add Variable" again.
Set Name to SC_LastMembershipSyncTimestamp.
Select Type: String.
Set Value: 2025-03-14T12:00:00.000Z.
Set Encrypted to No.
Click Create.
Step 2: Create the Runbook
Now that you have your variables set up, you can create the runbook.
2.1 Navigate to Runbooks
In the Azure portal, go to Automation Account > Process Automation > Runbooks.
2.2 Create a New Runbook
Click on "+ Create Runbook".
Fill in the details:
Name: SC_API_Memberships
Runbook Type: PowerShell
Runbook Version: 7.2
Click Review + Create.
Click Create to finalize the creation.
2.3 Add the Code below
Open the newly created runbook.
Paste the code provided below into the runbook editor.
Save your changes.
Publish the runbook.
Step 3: Schedule the Runbook
Once the runbook is published, you can add a schedule to run it automatically at desired times.
Note: If you encounter any issues during setup, verify that your Hybrid Worker is correctly configured and that PowerShell 7.2 is installed on your server.
Use the script below at your own risk. We strongly recommend thoroughly reading and testing it before execution.
<#
.SYNOPSIS
Synchronizes on-premises AD group membership with API-provided membership changes and triggers Entra sync.
.DESCRIPTION
This runbook:
1. Retrieves the last synchronization timestamp (stored in UTC ISO8601 format).
2. Fetches membership changes from the ServiceChanger API.
3. Retrieves complete group and user details from the API (via bulk endpoints).
4. Groups membership changes by group, compares with current AD membership,
and updates AD groups accordingly.
5. Updates the last sync timestamp based on the latest membership change timestamp from the API.
6. Triggers an Entra (Azure AD) synchronization on the Hybrid Worker using AD Connect.
API calls are made with proper URI encoding to avoid invalid URI errors.
Logging is provided throughout for clarity.
.NOTES
- Run on a Hybrid Worker with the ActiveDirectory module installed.
- API credentials and last sync timestamp are stored as Automation variables.
- All timestamps use UTC in ISO8601 (e.g. 2025-03-13T05:47:02.561Z).
- The Entra sync is triggered by the Start-ADSyncSyncCycle command (available if AD Connect is installed).
#>
# Import required modules
Import-Module ActiveDirectory
# --- Configuration ---
$apiBaseUrl = "https://api.servicechanger.com/public/directory-changes"
$apiKey = Get-AutomationVariable -Name "SC_APIKEY" # API key stored securely in Azure Automation
$batchSize = 1000 # Number of updates to process per batch
$throttleDelay = 5 # Seconds to wait between batches
# Prepare headers for API calls
$headers = @{
"x-api-key" = $apiKey
"Content-Type" = "application/json"
}
# Use Write-Host in Write-Log so logging messages don't interfere with function return values.
function Write-Log {
param ($message)
$utcTime = (Get-Date).ToUniversalTime().ToString('yyyy-MM-dd HH:mm:ss')
Write-Host "$utcTime - $message"
}
# Retrieves the last sync timestamp as a UTC ISO8601 string (with milliseconds)
function Get-LastSyncTimestamp {
$timestamp = Get-AutomationVariable -Name "SC_LastMembershipSyncTimestamp" -ErrorAction SilentlyContinue
if (-not $timestamp) {
return "1970-01-01T00:00:00.000Z"
}
if ($timestamp -is [datetime]) {
return $timestamp.ToUniversalTime().ToString("o")
}
elseif ($timestamp -is [string]) {
if ($timestamp.Trim().EndsWith("Z")) {
return $timestamp.Trim()
}
else {
try {
$dt = [datetime]::Parse($timestamp)
return $dt.ToUniversalTime().ToString("o")
} catch {
Write-Log "[WARN] Failed to parse last sync timestamp '$timestamp'. Defaulting to 1970-01-01T00:00:00.000Z."
return "1970-01-01T00:00:00.000Z"
}
}
}
else {
try {
$dt = [datetime]::Parse($timestamp.ToString())
return $dt.ToUniversalTime().ToString("o")
} catch {
Write-Log "[WARN] Failed to parse last sync timestamp '$timestamp'. Defaulting to 1970-01-01T00:00:00.000Z."
return "1970-01-01T00:00:00.000Z"
}
}
}
# Retrieves data from the API for a given resource type and starting sync date.
function Get-ApiData {
param (
[string]$resourceType,
[string]$lastSyncDate
)
$queryParams = @{
"onPremisesSyncEnabled" = "true"
"resourceType" = $resourceType
"lastSyncDate" = $lastSyncDate
}
$encodedParams = ($queryParams.GetEnumerator() | ForEach-Object {
"$($_.Key)=" + [uri]::EscapeDataString($_.Value)
}) -join "&"
$uri = "${apiBaseUrl}?${encodedParams}"
# Only log debug for membership resource
if ($resourceType -eq "membership") {
Write-Log "[DEBUG] Fetching membership data using URI: $uri"
}
try {
$response = Invoke-RestMethod -Uri $uri -Method Get -Headers $headers
}
catch {
Write-Log "[ERROR] Failed to retrieve $resourceType data: $($_.Exception.Message)"
return $null
}
if (-not $response.data) {
Write-Log "[WARN] No $resourceType data received from API"
return $null
}
Write-Log "[INFO] Retrieved $($response.data.Count) $resourceType record(s) from API"
return $response.data
}
# Processes updates grouped by groupId. For each group, it compares current AD membership to the required changes.
# Returns the effective number of modifications performed (sum of users added and removed).
function Process-Updates {
param (
$updates,
$groupMap,
$userMap
)
$effectiveCount = 0
foreach ($group in ($updates | Group-Object -Property groupId)) {
$groupId = $group.Name
$groupSamAccountName = $groupMap[$groupId]
if (-not $groupSamAccountName) {
Write-Log "[WARN] AD group not found for groupId '$groupId'. Skipping."
continue
}
Write-Log "[DEBUG] Processing updates for AD group '$groupSamAccountName' (SaaS groupId: $groupId)"
try {
$currentMembers = Get-ADGroupMember -Identity $groupSamAccountName -ErrorAction Stop |
Select-Object -ExpandProperty SamAccountName |
ForEach-Object { $_.ToLower() }
$usersToAdd = $group.Group |
Where-Object { $_.action -eq "add" } |
ForEach-Object { $userMap[$_.userId] } |
Where-Object { $_ -and ($_ -notin $currentMembers) }
$usersToRemove = $group.Group |
Where-Object { $_.action -eq "remove" } |
ForEach-Object { $userMap[$_.userId] } |
Where-Object { $_ -and ($_ -in $currentMembers) }
Write-Log "[INFO] Group '$groupSamAccountName': To Add = $($usersToAdd.Count), To Remove = $($usersToRemove.Count)"
if ($usersToAdd) {
Add-ADGroupMember -Identity $groupSamAccountName -Members $usersToAdd -ErrorAction Stop
Write-Log "[SUCCESS] Added $($usersToAdd.Count) user(s) to '$groupSamAccountName'"
$effectiveCount += $usersToAdd.Count
}
if ($usersToRemove) {
Remove-ADGroupMember -Identity $groupSamAccountName -Members $usersToRemove -Confirm:$false -ErrorAction Stop
Write-Log "[SUCCESS] Removed $($usersToRemove.Count) user(s) from '$groupSamAccountName'"
$effectiveCount += $usersToRemove.Count
}
}
catch {
Write-Log "[ERROR] Failed processing group '$groupSamAccountName': $($_.Exception.Message)"
}
}
return $effectiveCount
}
# Function to trigger Entra (Azure AD) sync via AD Connect on the Hybrid Worker.
function Trigger-EntraSync {
Write-Log "[INFO] Triggering Entra (Azure AD) sync..."
try {
if ($PSVersionTable.PSVersion.Major -ge 7) {
Write-Log "[DEBUG] Running on PowerShell 7+. Importing ADSync module using -UseWindowsPowerShell..."
$oldWarningPreference = $WarningPreference
$WarningPreference = 'SilentlyContinue'
Import-Module ADSync -UseWindowsPowerShell -ErrorAction Stop
$WarningPreference = $oldWarningPreference
}
else {
Write-Log "[DEBUG] Running on Windows PowerShell 5.1. Importing ADSync module normally..."
Import-Module ADSync -ErrorAction Stop
}
if (Get-Command Start-ADSyncSyncCycle -ErrorAction SilentlyContinue) {
$result = Start-ADSyncSyncCycle -PolicyType Delta
Write-Log "[SUCCESS] Entra sync triggered successfully using delta sync."
# Detailed result output is suppressed.
}
else {
Write-Log "[WARN] Start-ADSyncSyncCycle command not found. Ensure AD Connect is installed on the Hybrid Worker."
}
}
catch {
Write-Log "[ERROR] Failed to trigger Entra sync: $($_.Exception.Message)"
}
}
# Main execution
try {
Write-Log "[INFO] Script execution started"
$lastSyncTimestamp = Get-LastSyncTimestamp
Write-Log "[INFO] Starting sync from timestamp: $lastSyncTimestamp"
# Get membership changes from the API
$membershipData = Get-ApiData -resourceType "membership" -lastSyncDate $lastSyncTimestamp
if (-not $membershipData) {
Write-Log "[INFO] No membership changes to process. Exiting."
return
}
# Process membership changes and build update list.
$updates = @()
$latestTimestamp = $null
foreach ($change in $membershipData) {
if ($change.resourceType -eq "membership" -and $change.onPremisesSyncEnabled) {
$action = switch ($change.changeType) {
"created" { "add" }
"deleted" { "remove" }
default { $null }
}
if ($action) {
$updates += @{
groupId = $change.resource.groupId
userId = $change.resource.userId
action = $action
}
if (-not $latestTimestamp -or $change.timestamp -gt $latestTimestamp) {
$latestTimestamp = $change.timestamp
}
}
}
}
Write-Log "[INFO] Found $($updates.Count) membership change event(s) to process"
if ($updates.Count -eq 0) {
Write-Log "[INFO] No updates found after processing membership changes. Exiting."
return
}
# Retrieve complete group and user data from bulk API endpoints (from the beginning of time)
$groupData = Get-ApiData -resourceType "group" -lastSyncDate "1970-01-01T00:00:00.000Z"
$userData = Get-ApiData -resourceType "user" -lastSyncDate "1970-01-01T00:00:00.000Z"
# Build lookup maps for AD group and user SamAccountNames keyed by their SaaS IDs.
$groupMap = @{}
$userMap = @{}
foreach ($group in $groupData) {
if ($group.resource.id -and $group.resource.onPremisesSamAccountName) {
$groupMap[$group.resource.id] = $group.resource.onPremisesSamAccountName
}
}
foreach ($user in $userData) {
if ($user.resource.id -and $user.resource.onPremisesSamAccountName) {
$userMap[$user.resource.id] = $user.resource.onPremisesSamAccountName
}
}
Write-Log "[INFO] Loaded $($groupMap.Count) group(s) and $($userMap.Count) user(s)"
# Process updates in batches to control load on AD and sum effective modifications.
$totalBatches = [Math]::Ceiling($updates.Count / $batchSize)
$totalEffectiveChanges = 0
for ($i = 0; $i -lt $updates.Count; $i += $batchSize) {
$endIndex = [Math]::Min($i + $batchSize - 1, $updates.Count - 1)
$batch = $updates[$i..$endIndex]
$batchNumber = [Math]::Ceiling(($i + 1) / $batchSize)
Write-Log "[INFO] Processing batch $batchNumber of $totalBatches ($($batch.Count) update event(s))"
$batchEffective = Process-Updates -updates $batch -groupMap $groupMap -userMap $userMap
$totalEffectiveChanges += $batchEffective
if ($i + $batchSize -lt $updates.Count) {
Write-Log "[DEBUG] Waiting for $throttleDelay seconds before next batch..."
Start-Sleep -Seconds $throttleDelay
}
}
Write-Log "[INFO] Processed a total of $totalEffectiveChanges effective membership modification(s) out of $($updates.Count) membership event(s)."
# Update the last sync timestamp to the latest membership change timestamp (copied directly from the API)
if ($latestTimestamp) {
Set-AutomationVariable -Name "SC_LastMembershipSyncTimestamp" -Value $latestTimestamp
Write-Log "[INFO] Updated last sync timestamp to $latestTimestamp"
}
# Trigger Entra (Azure AD) sync on the Hybrid Worker.
Trigger-EntraSync
}
catch {
Write-Log "[ERROR] Script execution failed: $($_.Exception.Message)"
}
Write-Log "[INFO] Script completed"