# Export Microsoft Entra ID (abbr. ME-ID, former Azure AD) to Embrace for profile sync $version = '2.5' # Changelog: # - v2.5: # : BREAKING: $additionalFields, $customProfileFields, $teamMemberships and $roleMemberships are now configured as a hashtable (or $null); the old comma-separated array syntax is rejected with a clear error # : New CONNECT section for connection settings and credentials (never logged); $additionalFields moved to the BASIC section # : All BASIC and ADVANCED configuration is logged as a single SettingsUsed JSON line at script start; added an end-of-run summary to console and log # : Replaced MSAL.PS with Connect-MgGraph -ClientSecretCredential; requires Microsoft.Graph.Authentication 2.0 or higher (checked at startup) # : Dry-run output now clearly states that nothing is sent; the disable phase is skipped during a dry run and no longer re-disables users that are already disabled # : Extended documentation moved to the Help Center article; various improvements in the "do not modify" part of the script, including clearer console output (disable labels, failure recap, warning status) # - v2.4: # : Added support for forced guest users # : Fixed reading IdP error responses on PowerShell 7 (HttpClient/HttpResponseMessage) in syncUser; now version-proof for PS 5.1 and 7 # : Renamed $archiveNonExisting to $disableNonProvidedUsers and changed behaviour from delete to disable; renamed deleteUser() to disableUser() # : Fixed prepareMembership: Select-Object -Unique deduplicated MicrosoftGraphUser objects on ToString(), collapsing all users to one and causing op_Addition errors on the next merge for the same Embrace path. Now dedupes on Id via Sort-Object -Property Id -Unique, guarantees array concatenation with @() # : Renamed loop variable $entraGroup to $mappedGroup to avoid shadowing the global config variable # - v2.3: # : Added support for custom profile fields # : Renamed archiving to deleting # : Fixed duplicate users when nested groups are used # - v2.2: # : Changed $response -ne $null to $null -ne $response (null on the left, value (response) on the right) # : Enhanced Get-RecursiveGroupMembers: # - Skip duplicate users from nested groups # - Adds dry run mode using -Top instead of -All # - v2.1: # : Added displayname in $additionalFields # : Changed behaviour of ArchiveNonExisting. When using this option, users will no longer be disabled, but deleted. This results in showing initials only # - v2.0: Added support for UserExtensions # : Added Version-control # : Added in-script changelog # Documentation, configuration guide and troubleshooting: # https://support.embracecloud.nl/hc/nl/articles/38541650438674-Entra-ID-profielsynchronisatie-Script-inrichten-en-onderhouden # # Required PowerShell modules (checked at startup): Microsoft.Graph.Authentication (2.0 or higher), # Microsoft.Graph.Users, Microsoft.Graph.Groups # # Quick start: # 1. Fill in the CONNECT section below (these values are never logged). # 2. Configure at least $entraGroup and $additionalFields in the BASIC section. # 3. Run with $dryRun = $true to preview the output; check the SettingsUsed line and the JSON sample. # 4. Set $dryRun = $false, run once, verify the results in Embrace, then schedule the script off-hours. ## CONNECT Configuration ## # Connection settings and credentials for Microsoft Entra ID and Embrace (Keycloak). # NOTE: values in this section are NEVER written to the logs. # General configuration for connecting to Microsoft Entra ID $azAppId = '????' $azTenantId = '????' # To reference a Variable from the Azure Automation account, use the first option. # If you prefer plain text, utilize the second option. #$azSecret = Get-AutomationVariable -Name "azureClientSecretValue" $azSecret = '????' # to which Keycloak tenant (realm) should we synchronize? # note: Embrace needs to provide these settings $kcTenantId = '????' # Tenant Realm from tenants-project $authClientSecret = '????' # Client secret for the Realm, from KC ## end CONNECT Configuration ## ## BASIC Configuration ## # check our documentation for detailed information about setting up this sync # https://support.embracecloud.nl/hc/nl/articles/16241360647570-Entra-ID-profielsynchronisatie # Please specify the user group (name) to be utilized and exported to Embrace. $entraGroup = '????' # Use group name, but you can add group id here [] as note to self # Dry run: set $true to only preview the output (no upload), $false for a working sync that sends users to Embrace # increase or decrease the dryRunSampleSize for a bigger or smaller JSON sample in the output during the dry run $dryRun = $true $dryRunSampleSize = 15 # Should we sync the manager of users also? This costs some additional performance so only set this to $true when this field is used. $syncManager = $false # Should we sync the user principal name of users also? $syncUPN = $false # Should we disable non-provided users? Set $true for disabling, $false for keeping them $disableNonProvidedUsers = $false ## Additional fields mapping: # 'Embrace field name' = 'ME-ID field name'; every line stands on its own (no commas between lines). # A list combines multiple ME-ID fields into one Embrace field: 'department' = 'company', 'department' # extensionAttributes require $syncExtensions = $true. Set to $null to sync no additional fields. # See the documentation (link at the top) for all details and examples. ## Map Social profile fields below: $additionalFields = @{ # 'job-title' = 'jobTitle' # 'company-name' = 'companyName' # 'department' = 'department' # 'displayname' = 'displayName' # 'hire-date' = 'employeeHireDate' # 'street-address' = 'streetAddress' # 'office' = 'officeLocation' # 'city' = 'city' # 'country' = 'country' # 'postal-code' = 'postalCode' # 'office-phone' = 'businessPhones' # 'mobile-phone' = 'mobilePhone' } ## end BASIC Configuration ## ## ADVANCED Configuration ## # Assign Social's Members/Guests group based on the ME-ID UserType (Member or Guest) $assignSocialGroups = $true # Override: assign Social's Members group to ALL synced users regardless of UserType (requires $assignSocialGroups = $true) $makeAllUsersSocialMembers = $false # Optional ME-ID group (name) whose members always become Social Guests # Example: # $forcedGuestGroup = 'Groupname of forced Guestusers' $forcedGuestGroup = $null # set this option to $true to enable the synchronization of extension properties. # Note: Please be aware that using this option will result in increased resource usage due to the need for individual Graph queries for each user. $syncExtensions = $false ## Map Custom profile fields below (synced with the 'x-user-attribute-custom-profile-' prefix in Keycloak): # Same hashtable syntax as $additionalFields; extensionAttributes require $syncExtensions = $true. # Example: # $customProfileFields = @{ # 'skills' = 'extensionAttribute1' # } $customProfileFields = $null ## Map Embrace team memberships based upon ME-ID group membership: # 'Embrace team' = 'ME-ID group name' # The Embrace team must exist in the Identity Provider. Set to $null to skip. # Example: # $teamMemberships = @{ # 'Embrace Suite/Finance' = 'F_Finance_users' # 'Embrace Suite/Support' = 'Support_users' # } $teamMemberships = $null ## Map Embrace role memberships based upon ME-ID group membership: # 'Embrace role' = 'ME-ID group name' (or a list); client role as 'client name/role name'. # The Embrace role must exist in the Identity Provider. Set to $null to skip. # Example: # $roleMemberships = @{ # 'Content Reader' = 'F_Finance_users' # 'Social Manager' = 'Intranet_beheerders' # } $roleMemberships = $null ## Assign or remove an Embrace team/role when an ME-ID property has a specific value: ## [PropertyMapping]::new(<[Action]::Assign|Remove>, <[EmbraceType]::Group|Role>, 'Embrace team/role path', 'ME-ID property', 'value', ) # Example: # $customMappings = @( # [PropertyMapping]::new([Action]::Assign, [EmbraceType]::Group, 'Embrace Suite/Finance Department', 'Department', 'Finance', $false) # ) $customMappings = @() ## end ADVANCED Configuration ## function customFields($entraUser, $user) { # Hook for customer-specific transformations (e.g. date or language conversion). # $entraUser = Entra ID record | $user = Embrace export record # See the documentation for examples. } ############################################################################################ ################ ! Do not modify anything below this line ! ################################ ############################################################################################ # These variables are semi-fixed. Do not change unless otherwise instructed $authBaseUrl = 'https://auth.embracecloud.nl' # depends on the environment. $authClientId = 'user-provisioning' # don't change! $authScope = 'identity-provider-user' # don't change! $identityBaseUrl = 'https://identity.embracecloud.nl' # depends on the environment. $loggingFunctionUrl = 'https://identity-production-functions.azurewebsites.net/api/UploadLogFile' # depends on the environment. # authBaseUrl -> Switch to https://auth.beta.embracecloud.nl for beta testing # identityBaseUrl -> Switch to https://identity.beta.embracecloud.nl for beta testing # loggingFunctionUrl -> Switch to https://identity-beta-functions.azurewebsites.net/api/UploadLogFile for beta testing # Do not modify: determines which configuration variables are written to the log at script start (SettingsUsed). # The variable names are read from this script file itself: every variable assigned between the # BASIC and ADVANCED section markers above is included automatically, independent of session state # (so re-running the script in the same PowerShell session also logs correctly). # The CONNECT section is excluded by design and is never logged. # The script source is read from the file on disk, or - when running as an Azure Automation # runbook, where $PSCommandPath is empty - from the invocation itself. $ownSourceLines = $null if ($PSCommandPath -and (Test-Path -LiteralPath $PSCommandPath)) { $ownSourceLines = [System.IO.File]::ReadAllLines($PSCommandPath) } elseif ($MyInvocation.MyCommand.ScriptBlock) { $ownSourceLines = "$($MyInvocation.MyCommand.ScriptBlock)" -split "`r?`n" } elseif ($MyInvocation.MyCommand.Definition) { $ownSourceLines = "$($MyInvocation.MyCommand.Definition)" -split "`r?`n" } $loggedConfigVarNames = @() if ($ownSourceLines) { $inConfigSection = $false foreach ($ownSourceLine in $ownSourceLines) { if ($ownSourceLine.Trim() -eq '## BASIC Configuration ##') { $inConfigSection = $true; continue } if ($ownSourceLine.Trim() -eq '## end ADVANCED Configuration ##') { break } if ($inConfigSection -and $ownSourceLine -match '^\s*\$(\w+)\s*=') { $loggedConfigVarNames += $Matches[1] } } $loggedConfigVarNames = @($loggedConfigVarNames | Sort-Object -Unique) } if ($loggedConfigVarNames.Count -eq 0) { Write-Warning "Could not read the script's own source; the SettingsUsed log line will only contain the script version." } # Convert the hashtable configuration options to the array-of-pairs format used internally. # These options MUST be configured as a hashtable (@{ ... }) or $null: every line in a hashtable # stands on its own, so lines can be commented or uncommented freely without breaking the configuration. function ConvertTo-FieldMappingArray($mapping, $optionName) { # Field mappings: a multi-value entry means multiple ME-ID source fields for one Embrace field if ($null -eq $mapping) { return $null } if ($mapping -isnot [System.Collections.IDictionary]) { throw "$optionName must be configured as a hashtable (@{ 'embrace-field' = 'EntraIdField' }) or `$null. The array syntax from script versions before 2.5 is no longer supported; see the examples in the configuration section." } $result = New-Object System.Collections.ArrayList foreach ($key in $mapping.Keys) { [void]$result.Add(@($key) + @($mapping[$key])) } # the unary comma keeps empty and single-entry arrays intact on every PowerShell version return ,$result.ToArray() } function ConvertTo-MembershipArray($mapping, $optionName) { # Memberships: a multi-value entry means multiple ME-ID groups for one Embrace team or role if ($null -eq $mapping) { return $null } if ($mapping -isnot [System.Collections.IDictionary]) { throw "$optionName must be configured as a hashtable (@{ 'Embrace path' = 'EntraIdGroup' }) or `$null. The array syntax from script versions before 2.5 is no longer supported; see the examples in the configuration section." } $result = New-Object System.Collections.ArrayList foreach ($key in $mapping.Keys) { foreach ($groupName in @($mapping[$key])) { [void]$result.Add(@($key, $groupName)) } } # the unary comma keeps empty and single-entry arrays intact on every PowerShell version return ,$result.ToArray() } $additionalFields = ConvertTo-FieldMappingArray $additionalFields '$additionalFields' $customProfileFields = ConvertTo-FieldMappingArray $customProfileFields '$customProfileFields' $teamMemberships = ConvertTo-MembershipArray $teamMemberships '$teamMemberships' $roleMemberships = ConvertTo-MembershipArray $roleMemberships '$roleMemberships' # These are required fields for Embrace and should be available at all times $requiredFields = ("ExternalId", "Id"), ("FirstName", "GivenName"), ("LastName", "Surname"), ("Email", "Mail"), ("AccountEnabled", "AccountEnabled") # These roles will be assigned to all synced users $requiredRoles = @() enum UserStatus { Active = 0 Archived = 1 Deleted = 2 Disabled = 3 } enum EmbraceType { Role = 0 Group = 1 } enum Action { Assign = 0 Remove = 1 } class PropertyMapping { [Action]$DestAction [EmbraceType]$DestType [String]$DestValue [String]$SourceProperty [String]$SourceValue [bool]$SourceIsRegex PropertyMapping( [Action]$DestAction, [EmbraceType]$DestType, [String]$DestValue, [String]$SourceProperty, [String]$SourceValue, [bool]$SourceIsRegex ) { $this.DestAction = $DestAction; $this.DestType = $DestType; $this.DestValue = $DestValue; $this.SourceProperty = $SourceProperty; $this.SourceValue = $SourceValue; $this.SourceIsRegex = $SourceIsRegex; } } # Constants $SYSTEM_ACCOUNT_ATTR = 'system-account' $EXTERNAL_ID_ATTR = 'oidc-external-userid' $UPN_ATTR = 'upn' $OWNER_ATTR = 'owner' $ROLES_TO_ASSIGN_PROP = 'RolesToAssign' $ROLES_TO_REMOVE_PROP = 'RolesToRemove' $GROUPS_TO_ASSIGN_PROP = 'GroupsToAssign' $GROUPS_TO_REMOVE_PROP = 'GroupsToRemove' $SOCIAL_MEMBERS_PATH = 'social/Members' $SOCIAL_GUESTS_PATH = 'social/Guests' $SUITE_ATTR_VALUE = 'Suite' $ErrorActionPreference = "Stop" [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 # Check that the required PowerShell modules are installed before doing anything else $requiredModules = @( @{ Module = 'Microsoft.Graph.Authentication'; Command = 'Connect-MgGraph' }, @{ Module = 'Microsoft.Graph.Users'; Command = 'Get-MgUser' }, @{ Module = 'Microsoft.Graph.Groups'; Command = 'Get-MgGroup' } ) $missingModules = @($requiredModules | Where-Object { -not (Get-Command $_.Command -ErrorAction SilentlyContinue) }) if ($missingModules.Count -gt 0) { $moduleLines = ($missingModules | ForEach-Object { " - $($_.Module) (install with: Install-Module $($_.Module))" }) -join "`n" Write-Error "The following required PowerShell modules are missing:`n$moduleLines`n When running as an Azure Automation runbook, import these modules into the Automation account instead." } # Connect-MgGraph -ClientSecretCredential requires Microsoft.Graph.Authentication 2.0 or higher $graphAuthModule = Get-Module -ListAvailable Microsoft.Graph.Authentication | Sort-Object Version -Descending | Select-Object -First 1 if ($graphAuthModule -and $graphAuthModule.Version.Major -lt 2) { Write-Error "Microsoft.Graph.Authentication version $($graphAuthModule.Version) was found, but version 2.0 or higher is required. Update the module (Update-Module Microsoft.Graph.Authentication) or import a newer version into the Automation account." } # Configuration validation $requiredConfig = @($azTenantId, $azAppId, $azSecret, $entraGroup, $kcTenantId, $authClientSecret) if ("????" -in $requiredConfig) { $errorMsg = @" Please fill in the following required fields: `- `$azTenantId `- `$azAppId `- `$azSecret `- `$entraGroup `- `$kcTenantId `- `$authClientSecret "@ Write-Error $errorMsg } if (!$authBaseUrl.StartsWith("http://") -and !$authBaseUrl.StartsWith("https://")) { Write-Error "The `$authBaseUrl should start with http:// or https://"; } if (!$identityBaseUrl.StartsWith("http://") -and !$identityBaseUrl.StartsWith("https://")) { Write-Error "The `$identityBaseUrl should start with http:// or https://"; } # Connect to Microsoft Graph with the client secret of the app registration. # The Graph SDK manages the access token itself and refreshes it automatically during long runs. $clientSecretCredential = New-Object System.Management.Automation.PSCredential($azAppId, (ConvertTo-SecureString -AsPlainText -Force $azSecret)) Connect-MgGraph -TenantId $azTenantId -ClientSecretCredential $clientSecretCredential -NoWelcome function checkEntraFieldmapping { $allMgUserProperties = Get-MgUser -Top 1 | Get-Member -MemberType NoteProperty, Property | Select-Object -ExpandProperty Name $availableFields = @("DisplayName", "UserType", "UserPrincipalName") # Add additionalFields, customProfileFields and requiredFields as one $mappedFields = $requiredFields if ($null -ne $additionalFields) { $mappedFields = $mappedFields + $additionalFields } if ($null -ne $customProfileFields) { $mappedFields = $mappedFields + $customProfileFields } foreach ($field in $mappedFields) { $entraField = if ($field.Count -gt 1) { $field[1] } else { $field } if ($entraField -notin $allMgUserProperties) { if ($entraField -match '^extensionAttribute[0-9]+$' -and $syncExtensions -eq $true) { # The field is an extensionAttribute and syncExtensions is enabled. This is allowed. } elseif ($entraField -match '^extensionAttribute[0-9]+$' -and $syncExtensions -eq $false) { # The field is an extensionAttribute but syncExtensions is disabled. This is not allowed. Write-Error "The field '$entraField' requires syncExtensions to be enabled." } else { # The field does not exist in Entra ID and is not a valid extensionAttribute. Write-Error "The field '$entraField' is not available in MS Entra ID" } } else { # The field exists in Entra ID and can be added. $availableFields += $entraField } } return $availableFields } function getPropertyValue($source, $entraName) { if ($entraName.StartsWith('extension_')) { $value = $source.ExtensionProperty[$entraName] } else { $value = $source.$($entraName) } if ($null -eq $value) { $value = "" } $valueType = $value.GetType().Name $isSimpleProperty = ($value.GetType().IsValueType) -or ($valueType -eq "String") -or ($valueType -eq "String[]"); if (-not($isSimpleProperty)) { $message = "WARNING: To provision '$entraName' user attribute of type '$valueType' to Embrace, you should implement customFields() function." Write-Warning $message Write-Output $message $value = "" } if ($valueType -eq "String[]") { $value = $value -join "," } return $value } function addProperty($source, $user, $property, $prefix) { $value = "" if ($property.Count -eq 1) { $embraceName = $property $entraName = @($property) $propValue = getPropertyValue $source $entraName $value = $propValue.ToString() } elseif ($property.Count -eq 2) { $embraceName = $property[0] $entraName = ($property[1]) $propValue = getPropertyValue $source $entraName $value = $propValue.ToString() } else { $embraceName = $property[0] $entraNames = $property | Select-Object -skip 1 foreach ($entraName in $entraNames) { $propValue = getPropertyValue $source $entraName $value += $propValue.ToString() + ", " } } $user.Attributes | Add-Member -type NoteProperty -name "$prefix-$embraceName" -value $value -Force } function mapAdditionalFields($entraUser, $user, $additionalFields, $customProfileFields) { # Add UPN to properties if requested if ($syncUPN) { $upn = getPropertyValue $entraUser "userPrincipalName" $user | Add-Member -type NoteProperty -name "UPN" -value $upn -Force } # Add empty Attributes object $user | Add-Member -type NoteProperty -name "Attributes" -value (New-Object PSObject) # Add manager to attributes if requested if ($syncManager) { $managerEmail = "" try { $manager = Get-MgUserManager -UserId $entraUser.Id if ($null -ne $manager["mail"]) { $managerEmail = $manager["mail"] } } catch { # Catch block intentionally left empty to suppress errors } $user.Attributes | Add-Member -type NoteProperty -name "profile-manager" -value $managerEmail -Force } # Map all extensionAttributes to the entraUser object. if ($syncExtensions) { $userProperties = Get-MgUser -UserId $entraUser.Id -Property 'onPremisesExtensionAttributes' $extensionAttributes = $userProperties.onPremisesExtensionAttributes foreach ($attribute in $extensionAttributes.PSObject.Properties) { $attributeName = $attribute.Name $attributeValue = $attribute.Value if ($attributeName.StartsWith('ExtensionAttribute')) { $entraUser | Add-Member -MemberType NoteProperty -Name $attributeName -Value $attributeValue } } } # Add additional fields to attributes foreach ($property in ($additionalFields)) { addProperty $entraUser $user $property "profile" } # Add custom profile fields to attributes foreach ($property in ($customProfileFields)) { addProperty $entraUser $user $property "custom-profile" } } function mapRequiredFields($entraUser, $user, $requiredFields) { # Set required fields foreach ($property in ($requiredFields)) { $embraceName = $property[0] $entraName = ($property[1]) $propValue = getPropertyValue $entraUser $entraName $value = $propValue.ToString() if (($propValue -eq $true) -or ($propValue -eq $false)) { $value = $propValue } $user | Add-Member -type NoteProperty -name $embraceName -value $value -Force } # Property for other services to know if it is an externally managed user (cannot update all profile information etc.) $user | Add-Member -type NoteProperty -name "ExternalManaged" -value $true -Force # Add Status-node if ($entraUser.AccountEnabled -eq $true) { $user | Add-Member -type NoteProperty -name "Status" -value ([UserStatus]::Active).value__ -Force } else { $user | Add-Member -type NoteProperty -name "Status" -value ([UserStatus]::Disabled).value__ -Force } } function mapRolesToAssign ($user, $requiredRoles) { $rolesToAssign = $requiredRoles if ($rolesToAssign.Count -gt 0) { $user.RolesToAssign += $rolesToAssign } } function mapSocialGroups ($entraUser, $user) { # Forced guest override only if a group is configured if ($forcedGuestGroup -and ($forcedGuestUserIds -contains $entraUser.Id)) { $user.GroupsToAssign += $SOCIAL_GUESTS_PATH $user.GroupsToRemove += $SOCIAL_MEMBERS_PATH return } # All users should become Members in Social if ($makeAllUsersSocialMembers) { $user.GroupsToAssign += $SOCIAL_MEMBERS_PATH $user.GroupsToRemove += $SOCIAL_GUESTS_PATH return } # Default behaviour based on Entra UserType if ($entraUser.UserType -eq "Member") { $user.GroupsToAssign += $SOCIAL_MEMBERS_PATH $user.GroupsToRemove += $SOCIAL_GUESTS_PATH } elseif ($entraUser.UserType -eq "Guest") { $user.GroupsToAssign += $SOCIAL_GUESTS_PATH $user.GroupsToRemove += $SOCIAL_MEMBERS_PATH } } function prepareMembership ($memberships, $configName) { # Check if any membership is available if ($memberships.Count -le 0) { return; } $membershipMap = @{} # Get each group members and store them in a map where: # Key = Embrace group path | Value = Group members foreach ($mapping in $memberships) { $mappedGroup = Get-Group -Name $($mapping[1]) -ConfiguredIn $configName $members = Get-RecursiveGroupMembers -groupId $mappedGroup.Id if ($membershipMap.ContainsKey($mapping[0])) { # merge + deduplicate $membershipMap[$mapping[0]] = @( @($membershipMap[$mapping[0]]) + @($members) ) | Sort-Object -Property Id -Unique } else { $membershipMap.Add($mapping[0], @($members)) } } return $membershipMap } function teamMembership ($groupsMembership, $user, $entraUserId) { # Check if any membership is available if ($groupsMembership.Count -le 0) { return; } foreach ($groupPath in $groupsMembership.Keys) { if ($groupsMembership[$groupPath].Id -contains $entraUserId) { # Add member to group $user.GroupsToAssign += $groupPath } else { # Remove member from group $user.GroupsToRemove += $groupPath } } } function roleMembership ($rolesMembership, $user, $entraUserId) { # Check if any membership is available if ($rolesMembership.Count -le 0) { return; } foreach ($rolePath in $rolesMembership.Keys) { if ($rolesMembership[$rolePath].Id -contains $entraUserId) { # Add role to member $user.RolesToAssign += $rolePath } else { # Remove role from member $user.RolesToRemove += $rolePath } } } function prepareForcedGuestUsers ($forcedGuestGroup) { if (-not $forcedGuestGroup) { return @() } $forcedGuestGroupFound = Get-Group -Name $forcedGuestGroup -ConfiguredIn '$forcedGuestGroup' $forcedGuestUsers = Get-RecursiveGroupMembers -GroupId $forcedGuestGroupFound.Id $forcedGuestUserIds = @() foreach ($user in $forcedGuestUsers) { if ($user.Id) { $forcedGuestUserIds += $user.Id } } return $forcedGuestUserIds } function addNoteProperty($inputObject, $propertyName, $propertyValue) { $hasProperty = [bool]$inputObject.PSObject.Properties[$propertyName] if ($hasProperty) { return; } $inputObject | Add-Member -NotePropertyName $propertyName -NotePropertyValue $propertyValue } function customMapping ($customMappings, $entraUser, $user) { # Check if any mapping is available if ($customMappings.Count -le 0) { return; } foreach ($mapping in $customMappings) { # Cast object to PropertyMapping class [PropertyMapping]$propMapping = $mapping # Get specified Azure property $propValue = $entraUser | Select-Object -ExpandProperty $propMapping.SourceProperty # Skip if property is empty if (-not $propValue) { continue; } if ($propMapping.SourceIsRegex) { # Treat and compare as regular expression if ($propValue -notmatch $propMapping.SourceValue) { continue; } } else { # Treat and compare as simple string if ($propValue -ne $propMapping.SourceValue) { continue; } } # Assign or remove specified role or group if (($propMapping.DestType -eq [EmbraceType]::Role) -and ($propMapping.DestAction -eq [Action]::Assign)) { $user.RolesToAssign += $propMapping.DestValue } elseif (($propMapping.DestType -eq [EmbraceType]::Role) -and ($propMapping.DestAction -eq [Action]::Remove)) { $user.RolesToRemove += $propMapping.DestValue } elseif (($propMapping.DestType -eq [EmbraceType]::Group) -and ($propMapping.DestAction -eq [Action]::Assign)) { $user.GroupsToAssign += $propMapping.DestValue } elseif (($propMapping.DestType -eq [EmbraceType]::Group) -and ($propMapping.DestAction -eq [Action]::Remove)) { $user.GroupsToRemove += $propMapping.DestValue } } } function disableEmbraceUsers () { # Note: this function only runs during a real run; the dry run skips the disable phase entirely $idpUsersCount = getIdpUsersCount $limit = 100 $script:countDisabled = 0 $countEvaluated = 0 $countAlreadyDisabled = 0 $countStillProvided = 0 $countNotManaged = 0 [System.Collections.Generic.HashSet[string]]$entraUserIds = [string[]]$entraUsers.Id for ($currentOffset = 0; $currentOffset -lt $idpUsersCount; $currentOffset += $limit) { $idpUsers = @() try { refreshAccessToken $idpUsers = Get-IdPUsers -AccessToken $token -IdPUrl $identityBaseUrl -Realm $kcTenantId -Offset $currentOffset -Limit $limit } catch { Write-Error "FAILURE: Getting users from the Identity Provider failed: $($_.Exception.Message)" exit; } foreach ($idpUser in $idpUsers) { $countEvaluated++ # Skip users that are already disabled, so they are not disabled again on every run if (-not $idpUser.Enabled) { $countAlreadyDisabled++ continue; } # Skip local (non-Azure) and system users if ((-not $idpUser.ExternalManaged) -or ($idpUser.Attributes.$SYSTEM_ACCOUNT_ATTR -eq 'True')) { $countNotManaged++ continue; } # If a user exists in MS Entra don't disable him $entraUserId = $idpUser.Attributes.$EXTERNAL_ID_ATTR if ($entraUserIds.Contains($entraUserId)) { $countStillProvided++ continue; } $disabledIdpUser = disableUser $idpUser teamMembership $teamMemberships $disabledIdpUser $entraUserId syncUser $disabledIdpUser 'disable' } } $message = "-- Evaluated $countEvaluated Embrace users: $($script:countDisabled) disabled this run, $countAlreadyDisabled already disabled (skipped), $countStillProvided still provided, $countNotManaged not managed by this sync." Add-LogEntry -Message "$message`n" Write-Output $message } function getIdPUsersCount() { try { refreshAccessToken $idpUsersCount = Get-IdPUsersCount -AccessToken $token -IdPUrl $identityBaseUrl -Realm $kcTenantId } catch { Write-Error "FAILURE: Getting the user count from the Identity Provider failed: $($_.Exception.Message)" exit; } return $idpUsersCount } function disableUser($idpUser) { # Prepare Attributes copy $customAttributes = $idpUser.Attributes | ConvertTo-Json | ConvertFrom-Json $customAttributes.PSObject.Properties.Remove($EXTERNAL_ID_ATTR) $customAttributes.PSObject.Properties.Remove($UPN_ATTR) # Create UserProvision DTO object $userProvision = [PSCustomObject]@{ ExternalId = $idpUser.Attributes.$EXTERNAL_ID_ATTR UPN = $idpUser.Attributes.$UPN_ATTR Status = ([UserStatus]::Disabled).value__ Email = $idpUser.Email FirstName = $idpUser.FirstName LastName = $idpUser.LastName ProfileLanguage = $idpUser.ProfileLanguage ExternalManaged = $idpUser.ExternalManaged Attributes = $customAttributes } return $userProvision } function upsertUsers() { $checklist = @() foreach ($entraUser in $entraUsers) { # required fields for Embrace if ((-Not $entraUser.Mail) -or (-Not $entraUser.GivenName) -or (-Not $entraUser.Surname)) { $message = "WARNING: First name, Last name and Email cannot be empty. Skipping user $($entraUser.DisplayName) ($($entraUser.Id))."; Add-LogEntry -Message "$message`n" Write-Warning $message; Write-Output $message; $script:countSkipped++ $script:skippedUsers += "$($entraUser.DisplayName) ($($entraUser.Id))" continue } # Create Embrace user $user = New-Object PSObject mapRequiredFields $entraUser $user $requiredFields mapAdditionalFields $entraUser $user $additionalFields $customProfileFields # Add nodes if missing addNoteProperty $user $ROLES_TO_ASSIGN_PROP @() addNoteProperty $user $ROLES_TO_REMOVE_PROP @() addNoteProperty $user $GROUPS_TO_ASSIGN_PROP @() addNoteProperty $user $GROUPS_TO_REMOVE_PROP @() mapRolesToAssign $user $requiredRoles if ($assignSocialGroups) { mapSocialGroups $entraUser $user $user.Attributes | Add-Member -type NoteProperty -name $OWNER_ATTR -Value $SUITE_ATTR_VALUE -Force } customFields $entraUser $user customMapping $customMappings $entraUser $user teamMembership $groupsMembership $user $entraUser.Id roleMembership $rolesMembership $user $entraUser.Id if ($dryRun) { if ($checklist.Count -lt $dryRunSampleSize) { $checklist += $user $script:countUpserted++ } else { break } } else { syncUser $user } } if ($dryRun) { Write-Output "DRY RUN: showing the first $dryRunSampleSize users that would be created or updated:" Write-Output ($checklist | ConvertTo-Json) } } # $action 'sync' (default) or 'disable': determines the SUCCESS label and which counter is increased function syncUser($user, $action = 'sync') { if ($dryRun) { return } Add-LogEntry -Message ($user | ConvertTo-Json) try { refreshAccessToken $response = Sync-IdPProvisionUser -AccessToken $token -IdPUrl $identityBaseUrl -Realm $kcTenantId -User $user $verb = switch ($response.StatusCode) { 201 { 'Created' } 204 { 'Updated' } default { $null } } if ($verb) { if ($action -eq 'disable') { $verb = 'Disabled' } switch ($verb) { 'Created' { $script:countCreated++ } 'Updated' { $script:countUpdated++ } 'Disabled' { $script:countDisabled++ } } $message = "SUCCESS: $verb user $($user.FirstName) $($user.LastName) ($($user.Email))" Add-LogEntry -Message "$message`n" Write-Output $message } } catch { $ex = $_.Exception $response = $ex.Response $responseBody = "" $statusCode = "N/A" $statusDesc = "No description" # In zowel PowerShell 5.1 als 7 staat de response body meestal in ErrorDetails.Message if ($_.ErrorDetails -and $_.ErrorDetails.Message) { $responseBody = $_.ErrorDetails.Message } if ($null -ne $response) { if ($response -is [System.Net.Http.HttpResponseMessage]) { # PowerShell 7 (Invoke-WebRequest op basis van HttpClient) $statusCode = [int]$response.StatusCode $statusDesc = $response.ReasonPhrase if ([string]::IsNullOrEmpty($responseBody)) { try { $responseBody = $response.Content.ReadAsStringAsync().GetAwaiter().GetResult() } catch { $responseBody = "Failed to read response content: $($_.Exception.Message)" } } } else { # Windows PowerShell 5.1 (Invoke-WebRequest op basis van HttpWebResponse) $statusCode = [int]$response.StatusCode $statusDesc = $response.StatusDescription if ([string]::IsNullOrEmpty($responseBody)) { try { $stream = $response.GetResponseStream() if ($stream) { $reader = New-Object System.IO.StreamReader($stream) $responseBody = $reader.ReadToEnd() $reader.Dispose() } else { $responseBody = "Response stream was null." } } catch { $responseBody = "Failed to read response stream: $($_.Exception.Message)" } } } } elseif ([string]::IsNullOrEmpty($responseBody)) { $responseBody = "No valid response object available." } $script:countSyncFailed++ $script:failedUsers += "$($user.FirstName) $($user.LastName) ($($user.Email)): $statusCode $statusDesc" $message = "FAILURE: IdP responded with $statusCode $statusDesc for user $($user.FirstName) $($user.LastName) ($($user.Email)):`n$responseBody" Add-LogEntry -Message "$message`n" Write-Output $message } } function refreshAccessToken() { if ([string]::IsNullOrEmpty($token) -or ($stopwatch.Elapsed.TotalSeconds -gt 270)) { $script:token = Get-IdPAccessToken -BaseUrl $authBaseUrl -KcTenantId $kcTenantId -ClientId $authClientId -ClientSecret $authClientSecret -Scope $authScope $stopwatch.Reset() $stopwatch.Start() } } function Get-Group { [CmdletBinding()] param ( [Parameter()] [string] $Name, # Name of the configuration option that referenced this group, used in error messages [Parameter()] [string] $ConfiguredIn ) $context = if ($ConfiguredIn) { " (configured in $ConfiguredIn)" } else { "" } $foundGroup = Get-MgGroup -Filter "DisplayName eq '$Name'" if ($null -eq $foundGroup) { Write-Error "ME-ID Group '$Name' not found$context" } if ($foundGroup.Length -gt 1) { Write-Error "Multiple groups found with display name '$Name'$context" } $foundGroup } function Get-RecursiveGroupMembers($groupId) { $userMembers = @() if($dryRun){ $groupMembers = Get-MgGroupMember -GroupId $groupId -Top $dryRunSampleSize }else{ $groupMembers = Get-MgGroupMember -GroupId $groupId -All } $groupMembers | ForEach-Object { if ($_.AdditionalProperties["@odata.type"] -eq "#microsoft.graph.user") { $user = Get-MgUser -UserId $_.Id -Property $entraUserProps $userMembers += $user # Progress for large tenants. Write-Host, because Write-Output would end up in the function result. $script:collectedUserCount++ if ($script:collectedUserCount % 250 -eq 0) { Write-Host "-- Collected $($script:collectedUserCount) users so far ..." } } if ($_.AdditionalProperties["@odata.type"] -eq "#microsoft.graph.group") { $userMembers += @(Get-RecursiveGroupMembers -groupId $_.Id) } } return $userMembers | Sort-Object -Property Id -Unique } function Get-IdPAccessToken { [CmdletBinding()] param ( [Parameter()] [string] $BaseUrl, [Parameter()] [string] $KcTenantId, [Parameter()] [string] $ClientId, [Parameter()] [string] $ClientSecret, [Parameter()] [string] $Scope ) $Uri = "$BaseUrl/auth/realms/$KcTenantId/protocol/openid-connect/token"; $Fields = @{ grant_type = "client_credentials" client_id = $ClientId client_secret = $ClientSecret scope = $Scope } $Token = Invoke-RestMethod -Uri $Uri -Method POST -Body $Fields | Select-Object -ExpandProperty access_token return $Token; } function Sync-IdPProvisionUser { [CmdletBinding()] param ( [Parameter()] [string] $AccessToken, [Parameter()] [string] $IdPUrl, [Parameter()] [string] $Realm, [Parameter()] [Object] $User ) $Headers = @{ 'Authorization' = "Bearer $AccessToken" } $Params = @{ Uri = "$IdPUrl/realms/$Realm/users" Headers = $Headers Method = 'PATCH' ContentType = "application/json; charset=utf-8" Body = ($User | ConvertTo-Json) } return Invoke-WebRequest @Params -UseBasicParsing } function Get-IdPUsersCount { [CmdletBinding()] param ( [Parameter()] [string] $AccessToken, [Parameter()] [string] $IdPUrl, [Parameter()] [string] $Realm ) $Headers = @{ 'Authorization' = "Bearer $AccessToken" } $Params = @{ Uri = "$IdPUrl/realms/$Realm/users/count" Headers = $Headers Method = 'GET' } return Invoke-RestMethod @Params -UseBasicParsing } function Get-IdPUsers { [CmdletBinding()] param ( [Parameter()] [string] $AccessToken, [Parameter()] [string] $IdpUrl, [Parameter()] [string] $Realm, [Parameter()] [int] $Offset, [Parameter()] [int] $Limit ) $Headers = @{ 'Authorization' = "Bearer $AccessToken" } $Params = @{ Uri = "$IdpUrl/realms/$Realm/users" Headers = $Headers Method = 'GET' Body = @{ offset = $Offset limit = $Limit } } return Invoke-RestMethod @Params -UseBasicParsing } # Write a log entry to the stream function Add-LogEntry { [CmdletBinding()] param ( [Parameter()] [string] $Message ) if ($dryRun) { return } $writer.WriteLine($Message) } # Send the in-memory file to the Azure Function function Send-LogFile { [CmdletBinding()] param ( [Parameter()] [string] $Token, [Parameter()] [System.IO.Stream] $Stream ) # Read the file content from the stream $stream.Seek(0, [System.IO.SeekOrigin]::Begin) | Out-Null $fileBytes = New-Object Byte[] ($stream.Length) $stream.Read($fileBytes, 0, $fileBytes.Length) | Out-Null # Prepare boundary for multipart/form-data $boundary = [System.Guid]::NewGuid().ToString() $headers = @{ "Authorization" = "Bearer $token" "Content-Type" = "multipart/form-data; boundary=$boundary" } # Create a file name with timestamp $fileTimestamp = Get-Date -Format "s" $fileName = "Logs_$fileTimestamp.json" # Construct the multipart body manually $body = "--$boundary`r`n" $body += "Content-Disposition: form-data; name=`"file`"; filename=`"$fileName`"`r`n" $body += "Content-Type: text/plain`r`n`r`n" $body += [System.Text.Encoding]::UTF8.GetString($fileBytes) $body += "`r`n--$boundary--`r`n" # Convert the body to a byte array $bodyBytes = [System.Text.Encoding]::UTF8.GetBytes($body) # Make the HTTP POST request using Invoke-WebRequest refreshAccessToken $response = Invoke-WebRequest -Uri $loggingFunctionUrl -Method Post -Headers $headers -Body $bodyBytes -UseBasicParsing if ($response.Content) { Write-Host $response.Content } } # Convert a configuration value to a JSON-friendly representation: # enums become their names, class instances (e.g. PropertyMapping) become property maps function ConvertTo-LoggableValue($value) { if ($null -eq $value) { return $null } if ($value -is [enum]) { return $value.ToString() } if ($value -is [string] -or $value.GetType().IsPrimitive) { return $value } if ($value -is [System.Collections.IDictionary]) { $map = [ordered]@{} foreach ($key in $value.Keys) { $map["$key"] = ConvertTo-LoggableValue $value[$key] } return $map } if ($value -is [System.Collections.IEnumerable]) { $items = New-Object System.Collections.ArrayList foreach ($item in $value) { [void]$items.Add((ConvertTo-LoggableValue $item)) } # the unary comma keeps empty and single-item arrays intact in the JSON output, on every PowerShell version return ,$items.ToArray() } $map = [ordered]@{} foreach ($property in $value.PSObject.Properties) { $map[$property.Name] = ConvertTo-LoggableValue $property.Value } return $map } # Build the SettingsUsed line: a single-line JSON dump of all BASIC and ADVANCED configuration. # The CONNECT section (connection settings and credentials) is excluded by design and must never be logged. function Get-SettingsUsedJson { $configDump = [ordered]@{ scriptVersion = $version } foreach ($configVarName in $loggedConfigVarNames) { $configDump[$configVarName] = ConvertTo-LoggableValue (Get-Variable -Name $configVarName -ValueOnly) } return ConvertTo-Json -InputObject $configDump -Depth 10 -Compress } function Add-ScriptStartLogs { Add-LogEntry -Message "Script version: $($version)" Add-LogEntry -Message "--------------------------------------------" Add-LogEntry -Message "SettingsUsed: $(Get-SettingsUsedJson)" Add-LogEntry -Message "--------------------------------------------" } # Script execution starts here: $token = Get-IdPAccessToken -BaseUrl $authBaseUrl -KcTenantId $kcTenantId -ClientId $authClientId -ClientSecret $authClientSecret -Scope $authScope if ($dryRun) { Write-Output "Script is being executed in dry run mode.`n" } # Show the settings used on the console, also during dry run (CONNECT section excluded) Write-Output "SettingsUsed: $(Get-SettingsUsedJson)`n" $logStream = $null $writer = $null if (-not($dryRun)) { # Open a StreamWriter to write logs directly to a MemoryStream $logStream = [System.IO.MemoryStream]::new() $writer = [System.IO.StreamWriter]::new($logStream) # Log basic sync info Add-ScriptStartLogs } $stopwatch = [System.Diagnostics.Stopwatch]::StartNew() $totalStopwatch = [System.Diagnostics.Stopwatch]::StartNew() # total run duration; $stopwatch is reset by refreshAccessToken if ($dryRun) { Write-Output ">>>>>> User provisioning started (DRY RUN: no changes will be made) <<<<<<" } else { Write-Output ">>>>>> User provisioning started <<<<<<" } Write-Output "- Checking available user properties from within Microsoft Entra ID" $entraUserProps = checkEntraFieldmapping Write-Output "-- Check done. All mapped fields are available`n" $script:collectedUserCount = 0 Write-Output ">>> Start collecting users <<<" Write-Output "- Start collecting users from Microsoft Entra ID" $entraGroupFound = Get-Group -Name $entraGroup -ConfiguredIn '$entraGroup' $entraUsers = Get-RecursiveGroupMembers -GroupId $entraGroupFound.Id Write-Output "-- Found $($entraUsers.Count) users within Microsoft Entra ID" if (($teamMemberships.Count -gt 0) -or ($roleMemberships.Count -gt 0) -or $forcedGuestGroup) { Write-Output "- Resolving team and role memberships from Microsoft Entra ID" } $groupsMembership = prepareMembership $teamMemberships '$teamMemberships' $rolesMembership = prepareMembership $roleMemberships '$roleMemberships' $forcedGuestUserIds = prepareForcedGuestUsers $forcedGuestGroup # Counters and recap lists for the end-of-run summary $script:countUpserted = 0 $script:countCreated = 0 $script:countUpdated = 0 $script:countSkipped = 0 $script:countDisabled = 0 $script:countSyncFailed = 0 $script:failedUsers = @() $script:skippedUsers = @() Write-Output "----------------------------------------------------------------------------------" if ($dryRun) { Write-Output "DRY RUN: previewing which users would be created or updated in Embrace (nothing is sent)...`n" } else { Write-Output "Creating and updating users in Embrace...`n" } # Insert (create) new users or update existing users. upsertUsers if ($dryRun) { Write-Output "`nDRY RUN: preview of creating and updating users is done. No users were created or updated." } else { Write-Output "`nCreation and updating of users is done." } Write-Output "----------------------------------------------------------------------------------`n" if ($disableNonProvidedUsers) { if ($dryRun) { Write-Output "DRY RUN: disableNonProvidedUsers is set to True, but the disable phase is skipped during a dry run." Write-Output "A dry run only fetches a sample of the Entra ID users, so a disable preview would wrongly list users that fall outside the sample." Write-Output "----------------------------------------------------------------------------------`n" } else { Add-LogEntry -Message "Start disabling non-provided users." Write-Output "Since disableNonProvidedUsers is set to True, start disabling non-provided users." Write-Output "This will disable users who are no longer a member of the '$entraGroup' group.`n" # Disable users that are removed from the synced MS Entra Group disableEmbraceUsers Write-Output "Disabling non-provided users completed." Write-Output "----------------------------------------------------------------------------------`n" } } else { Write-Output "Since disableNonProvidedUsers is set to False, disabling of non-provided users was skipped." } # End-of-run summary $totalStopwatch.Stop() $durationText = $totalStopwatch.Elapsed.ToString('hh\:mm\:ss') if ($dryRun) { $summary = "Summary (DRY RUN): $($script:countUpserted) users would be created or updated (sample), $($script:countSkipped) skipped, duration $durationText" } else { $summary = "Summary: $($script:countCreated) created, $($script:countUpdated) updated, $($script:countSyncFailed) failed, $($script:countSkipped) skipped, $($script:countDisabled) disabled, duration $durationText" } Write-Output "$summary`n" Add-LogEntry -Message $summary # Recap of failed and skipped users, so they are easy to find after a long run if ($script:failedUsers.Count -gt 0) { $recap = "FAILED ($($script:failedUsers.Count)):" + (($script:failedUsers | ForEach-Object { "`n- $_" }) -join '') Write-Output "$recap`n" Add-LogEntry -Message "$recap`n" } if ($script:skippedUsers.Count -gt 0) { $recap = "SKIPPED - missing first name, last name or email ($($script:skippedUsers.Count)):" + (($script:skippedUsers | ForEach-Object { "`n- $_" }) -join '') Write-Output "$recap`n" Add-LogEntry -Message "$recap`n" } if (-not($dryRun)) { $writer.Flush() $logStream.Seek(0, [System.IO.SeekOrigin]::Begin) | Out-Null # Upload log file to the storage Send-LogFile -Token $token -Stream $logStream # Cleanup $writer.Close() $logStream.Close() } $warningCount = $script:countSyncFailed + $script:skippedUsers.Count if ($dryRun) { Write-Output ">>>>>> User provisioning finished (DRY RUN: no changes were made) <<<<<<" } elseif ($warningCount -gt 0) { Write-Output ">>>>>> User provisioning finished with warnings <<<<<<" } else { Write-Output ">>>>>> User provisioning finished <<<<<<" } if ($warningCount -gt 0) { # Write-Warning marks the Azure Automation job as Completed with warnings Write-Warning "Completed with warnings: $($script:countSyncFailed) sync calls failed, $($script:skippedUsers.Count) users skipped." } $stopwatch.Stop()