# Export Microsoft Entra ID (abbr. ME-ID, former Azure AD) to Embrace for profile sync $version = '2.3.1' # Changelog: # - v2.3: # : Added support for custom profilefields # : Renamed archiving to deleting # : Fixed duplicate users when nested groups are used # : .1 Fixed multiple group mapping # - v2.2: # : Changed $response -ne $null to $null -ne $response, null should be left or value (response) # : 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 # ### BASIC Configuration steps: ### ## Set the following variables: # - $azTenantId => Id (guid) of the Azure Portal directory # - $azAppId => Id (guid) of the Azure App Registration which is connected to the MS Entra ID where the users for provisioning reside # - $azSecret => Secret generated for the Azure App Registration defined above # # - $entraGroup => Set the MS Entra group (name) that contains all Embrace users (including archived users). # # - $kcTenantId => This value will be provided by Embrace: Name of the Keycloak tenant (realm) where users will be created or updated # - $authClientSecret => This value will be provided by Embrace: Secret value for syncing to keycloak # - $dryRun => Variable that allows testing the output of the script. Once the output is satisfactory, set $dryRun to false, and the users will be sent to Embrace. # - $dryRunSampleSize => Set the sample size when using dryrun # # - $syncManager => Syncing the manager field from MS Entra ID incurs additional performance costs. Therefore, set this to $true only when this field is needed. # - $archiveNonExisting => $true: This will archive all Embrace users with an ExternalId, that are present in Embrace but not found in the list of users supplied in this script. # => $false: Embrace will only process users that are supplied with this script # ### ADVANCED Configuration steps ### ## Set the following variables: # - $assignSocialGroups => Set to true to make guest users guests. Needs to be in line with $makeAllUsersSocialMembers # # - $makeAllUsersSocialMembers => can be used to override the guest status of a user in Azure. Needs to be in line with $assignSocialGroups # # - $syncExtensions => Set tot true if you need to synchronize information from the Extensionattributes # Note: uses an additional Graph api request per user # - $additionalFields => Custom Embrace profile fields for syncronization. # Embrace implementation team will advise if this is needed. # - $customProfileFields => Sync Entra ID information to custom Embrace profile fields # Embrace implementation team will advise if this is needed. # - $additionalRoles => Add additional roles to users # Embrace implementation team will advise if this is needed # - $teamMemberships => Make users members of Embrace teams based upon MS Entra group membership # - $roleMemberships => Make users members of Embrace roles based upon MS Entra group membership # - $customMappings => Assign or remove Embrace team or role if users have specified value for the specified MS Entra property # # To run the script: # - run the script until desired configuration is ready (output is written to console) # - change the $dryRun variable to $false and schedule task during off-hours # # - When mutation of data is needed within this script (for example birthdate conversion), adjust the customFields function. # NOTE: to run this script, you'll need to install (or in case of a runbook import) the following modules. #Install-Module Microsoft.Graph #Install-Module Microsoft.Graph.Users #Install-Module Microsoft.Graph.Groups #Install-Module MSAL.PS ## BASIC Configuration ## # check our documentation for detailed information about setting up this sync # https://support.embracecloud.nl/hc/nl/articles/16241360647570-Entra-ID-profielsynchronisatie # General configuration for connecting to Microsoft Entra ID $azTenantId = '????' $azAppId = '????' # 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 = '????' # Please specify the user group (name) to be utilized and exported to Embrace. $entraGroup = '????' # 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 # Should we upload the export? Set to $true for debugging, $false for a working sync # increase or decrease the dryRunSampleSize for a bigger or smaller JSON sample in the output during the dry run $dryRun = $true $dryRunSampleSize = 5 # 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 delete non-provided users? Set $true for deleting, $false for keeping them $archiveNonExisting = $false ## end BASIC Configuration ## ## ADVANCED Configuration ## # Should we assign Social's groups to all synced users? # Users from Microsoft Entra ID that are of type Member users will be assigned to Social's Members group # Users from Microsoft Entra ID that are of type Guest users will be assigned to Social's Guests group $assignSocialGroups = $true # Should we assign Social Members group to all Microsoft Entra ID users synced? # All synced users from Microsoft Entra ID regardless of their type (Member or Guest) will be assigned to Social's Members group # NOTE: Works only if $assignSocialGroups is set to $true $makeAllUsersSocialMembers = $false # 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 ## Additional fields mapping: # # Default field mapping (if Embrace field name matches ME-ID fieldname): # $additionalFields = "DisplayName", "GivenName", "MiddleName", "Surname" # # Mapping from different ME-ID field: ("Embrace field name", "ME-ID field name") # For example, this will set the value of extension_f946aada8c064232b6753f91f2ca3bf4_MyNewProperty in the birthdate field in Embrace: # $additionalFields = ("birthdate", "extension_f946aada8c064232b6753f91f2ca3bf4_MyNewProperty"), ("city", "l") # # For department, multiple fields can be mapped: ("department", "ME-ID field 1", "ME-ID field 2", "ME-ID field etc.") # For example: # $additionalFields = ("department", "company", "department") # # NOTE: If there is only one entry then start with a comma. # For example: $additionalFields = ,("Embrace field", "ME-ID field") # # NOTE: Make sure every field in the list is followed by a comma, except the last field. # # Fields below are already included and should not be changed: # $requiredFields = # ("ExternalId", "Id"), # ("FirstName", "GivenName"), # ("LastName", "Surname"), # ("Email", "Mail") # # WARNING! Make sure these fields match the configuration of the login-provider (eg. ADFS). Not doing so, may # result in overwriting fields or make it impossible for users to log-in. # ## Example: #$additionalFields = $null # or: #$additionalFields = #("azureId", "Id"), #"userprincipalname", #("enabled", "AccountEnabled"), #"DisplayName", #("title", "JobTitle"), #"city" ## Map Social profile fields below: $additionalFields = $null # $additionalFields = # ("job-title", "jobTitle"), # ("company-name", "companyName"), # "department", # "displayname", # ("hire-date", "employeeHireDate"), # ("street-address", "streetAddress"), # ("office", "officeLocation"), # "city", # "country", # ("postal-code", "postalCode"), # ("office-phone", "businessPhones"), # ("mobile-phone", "mobilePhone") ## Map Custom profile fields below: ## These fields will be synced with 'x-user-attribute-custom-profile-' prefix in Keycloak ## Example configuration: ## $customProfileFields = ## ("skills", "extensionAttribute1"), # Maps extensionAttribute1 to custom-profile-skills ## ("certification", "extensionAttribute2"), # Maps extensionAttribute2 to custom-profile-certification ## "businessPhones", # Maps businessPhones to custom-profile-businessPhones ## ("custom-field", "extensionAttribute3") # Maps extensionAttribute3 to custom-profile-custom-field ## $customProfileFields = $null ## syntax for tenant (realm) role: "Embrace realm role name" ## syntax for client role: "Embrace client name/Embrace client role name" # Example: # @( # "Suite Guest User", # "Content Reader", # "broker/read-token", # "portal/portal-creator" # ) # # As shown in the example above, client role should be specified as # a combination of the client name and name of a role within that client. # The delimiter between those two names must be the '/'. # For the tenant (realm) role just the name of a role should be specified. # # Leave $additionalRoles as @() (empty array) if no additional roles should be assigned. # ## NOTE: Embrace client name must represent an existing client in the Identity Provider. # Embrace role name must represent an existing role in the Identity Provider. $additionalRoles = @() ## syntax ("Embrace teamname", "ME-ID group name") # Example: # @( # ("Finance", "F_Finance_users"), # ("Sales", "F_Sales_users") # ) # # If the destination team is a subgroup, the Embrace teamname should be specified # as a path to that subgroup, where the delimiter is '/'. For example, # if the destination team is a subgroup of some subgroup of the root group, # name should be specified like this: # ("Root group name/Subgroup lvl 1 name/Subgroup lvl 2 name", "ME-ID group name") # # If there is only one entry then start with a comma. For example: # $teamMemberships = ,("Finance", "F_Finance_users") # ## NOTE: Embrace team must be an existing group in the Identity Provider. #$teamMemberships = ("SupportTeam", "In en extern test sync"), ("AndereEmbraceGroep", "Communicatie") $teamMemberships = $null ## syntax for tenant (realm) role: ("Embrace realm role name", "ME-ID group name") ## syntax for client role: ("Embrace client role name", "ME-ID group name") # Example: # @( # ("Content Reader", "F_Finance_users"), # ("broker/read-token", "F_Sales_users") # ) # # As shown in the example above, client role should be specified as # a combination of the client name and name of a role within that client. # The delimiter between those two names must be the '/'. # For the tenant (realm) role just the name of a role should be specified. # # If there is only one entry then start with a comma. For example: # $roleMemberships = ,("Content Reader", "F_Finance_users") # ## NOTE: Embrace role must be an existing role in the Identity Provider. $roleMemberships = $null ## syntax: ## @( ## [PropertyMapping]::new([Action]::Assign OR [Action]::Remove, [EmbraceType]::Group OR [EmbraceType]::Role, Embrace role/team path, ME-ID property name, ME-ID property value, Is property value a regular expression), ## ) # Example: # @( # [PropertyMapping]::new([Action]::Assign, [EmbraceType]::Group, 'Embrace Suite/Finance Department', 'Department', 'Finance', $false), # [PropertyMapping]::new([Action]::Remove, [EmbraceType]::Role, 'Content Reader', 'Mail', '@embracecloud.nl', $true) # ) # # If destination is Embrace role: # - syntax for tenant (realm) role: "Embrace realm role name" # - syntax for client role: "Embrace client name/Embrace client role name" # If destination is Embrace team: # - syntax for root group: "Embrace root group name" # - syntax for subgroup: "Embrace root group name/Subgroup lvl 1 name/Subgroup lvl 2 name" # Therefore, if the destination team is a subgroup, the Embrace teamname should be specified # as a path to that subgroup, where the delimiter is '/'. ## NOTE: Specified Embrace role/team must exist in the Identity Provider. $customMappings = @() ## end ADVANCED Configuration ## function customFields($entraUser, $user) { # # add customizations in this function # # $entraUser = Active Directory record # $user = Embrace export record # # example birthday conversion #if (![string]::IsNullOrEmpty($entraUser.ExtensionProperty['extension_f946aada8c064232b6753f91f2ca3bf4_MyNewProperty'])) #{ # $user.Attributes | Add-Member -type NoteProperty -name "BirthDate" -Value ([datetime]::ParseExact($entraUser.ExtensionProperty['extension_f946aada8c064232b6753f91f2ca3bf4_MyNewProperty'], 'dd-MM-yyyy', $null)).ToString("o") -Force #} # example language conversion #$lang = "nl" #if (![string]::IsNullOrEmpty($entraUser.Country)) #{ # switch ($entraUser.Country) { # "GB" { $lang = "en" } # "DE" { $lang = "de" } # } #} #$user.Attributes | Add-Member -type NoteProperty -name "language" -Value $lang -Force } ############################################################################################ ################ ! 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 # This are required fields for Embrace and should be available at all times $requiredFields = ("ExternalId", "Id"), ("FirstName", "GivenName"), ("LastName", "Surname"), ("Email", "Mail"), ("AccountEnabled", "AccountEnabled") # This 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 # 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 using access token $MsalToken = Get-MsalToken -ForceRefresh -TenantId $azTenantId -ClientId $azAppId -ClientSecret ($azSecret | ConvertTo-SecureString -AsPlainText -Force) $passwordSecure = ConvertTo-SecureString -AsPlainText -Force $MsalToken.AccessToken Connect-Graph -AccessToken $passwordSecure -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) { # Het veld is een extensionAttribute en syncExtensions staat aan. Dit is toegestaan. } elseif ($entraField -match '^extensionAttribute[0-9]+$' -and $syncExtensions -eq $false) { # Het veld is een extensionAttribute maar syncExtensions staat uit. Dit mag niet. Write-Error "The field '$entraField' requires syncExtensions to be enabled." } else { # Het veld bestaat niet in Entra ID en is geen geldig extensionAttribute. Write-Error "The field '$entraField' is not available in MS Entra ID" } } else { # Het veld bestaat in Entra ID en kan worden toegevoegd. $availableFields += $entraField } } return $availableFields } function getPropertyValue($source, $entraName) { if ($entraName.StartsWith('extension_')) { $value = $source.ExtensionProperty[$entraName] } else { $value = $source.$($entraName) } if (-not($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 Attibutes 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 } # Propery for other services to know if it is an external 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, $additionalRoles) { $rolesToAssign = $requiredRoles + $additionalRoles if ($rolesToAssign.Count -gt 0) { $user.RolesToAssign += $rolesToAssign } } function mapSocialGroups ($entraUser, $user) { # All users should become Members in Social if ($makeAllUsersSocialMembers) { $user.GroupsToAssign += $SOCIAL_MEMBERS_PATH $user.GroupsToRemove += $SOCIAL_GUESTS_PATH return } # Otherwise, assign Members or Guests Social group respectively 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) { # 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) { $entraGroup = Get-Group -Name $($mapping[1]) $members = Get-RecursiveGroupMembers -groupId $entraGroup.Id # if a groupmapping already exists, add unique members to group if ($membershipMap.ContainsKey($mapping[0])) { $membershipMap[$mapping[0]] = @( $membershipMap[$mapping[0]] + $members ) | Select-Object -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 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 () { $idpUsersCount = getIdpUsersCount $limit = 100 $checklist = @() 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 failed." exit; } $countDisabled = 0 foreach ($idpUser in $idpUsers) { # Skip already disabled users #if (-not $idpUser.Enabled) { # continue; #} # Skip local (non-Azure) and system users if ((-not $idpUser.ExternalManaged) -or ($idpUser.Attributes.$SYSTEM_ACCOUNT_ATTR -eq 'True')) { continue; } # If a user exists in MS Entra don't disable him $entraUserId = $idpUser.Attributes.$EXTERNAL_ID_ATTR [System.Collections.Generic.HashSet[string]]$entraUserIds = $entraUsers.Id if ($entraUserIds.Contains($entraUserId)) { continue; } $disabledIdpUser = deleteUser $idpUser $countDisabled++ teamMembership $teamMemberships $disabledIdpUser $entraUserId if ($dryRun) { if ($checklist.Count -lt $dryRunSampleSize) { $checklist += $disabledIdpUser } else { break; } } else { syncUser $disabledIdpUser } } } if ($countDisabled -eq 0) { $message = "-- No users required archiving." Add-LogEntry -Message "$message`n" Write-Output $message return; } if ($dryRun) { Write-Output "Dryrun enabled: Showing first $dryRunSampleSize disabled users:" Write-Output ($checklist | ConvertTo-Json) } } function getIdPUsersCount() { try { refreshAccessToken $idpUsersCount = Get-IdPUsersCount -AccessToken $token -IdPUrl $identityBaseUrl -Realm $kcTenantId } catch { Write-Error "FAILURE: Getting users count failed." exit; } return $idpUsersCount } function deleteUser($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]::Deleted).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; 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 $additionalRoles 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 } else { break } } else { syncUser $user } } if ($dryRun) { Write-Output "Dryrun enabled: Showing first $dryRunSampleSize updated or inserted users:" Write-Output ($checklist | ConvertTo-Json) } } function syncUser($user) { if ($dryRun) { return } Add-LogEntry -Message ($user | ConvertTo-Json) try { refreshAccessToken $response = Sync-IdPProvisionUser -AccessToken $token -IdPUrl $identityBaseUrl -Realm $kcTenantId -User $user switch ($response.StatusCode) { 201 { $message = "SUCCESS: Created user $($user.FirstName) $($user.LastName) ($($user.Email))" Add-LogEntry -Message "$message`n" Write-Output $message } 204 { $message = "SUCCESS: Updated user $($user.FirstName) $($user.LastName) ($($user.Email))" Add-LogEntry -Message "$message`n" Write-Output $message } } } catch { $response = $_.Exception.Response $responseBody = "" if ($null -ne $response) { try { $stream = $response.GetResponseStream() if ($stream) { $reader = New-Object System.IO.StreamReader($stream) $responseBody = $reader.ReadToEnd() } else { $responseBody = "Response stream was null." } } catch { $responseBody = "Failed to read response stream: $($_.Exception.Message)" } } else { $responseBody = "No valid response object available." } $statusCode = if ($response -and $response.StatusCode) { $response.StatusCode.value__ } else { "N/A" } $statusDesc = if ($response -and $response.StatusDescription) { $response.StatusDescription } else { "No description" } $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)) { $global: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 ) $foundGroup = Get-MgGroup -Filter "DisplayName eq '$Name'" if ($null -eq $foundGroup) { Write-Error "ME-ID Group '$Name' not found" } if ($foundGroup.Length -gt 1) { Write-Error "Multiple groups found with display name $Name" } $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 } 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 } function Add-ScriptStartLogs { Add-LogEntry -Message "Source: $($entraGroup)" Add-LogEntry -Message "Destination: $($kcTenantId)" Add-LogEntry -Message "--------------------------------------------" Add-LogEntry -Message "Script version: $($version)" Add-LogEntry -Message "--------------------------------------------" Add-LogEntry -Message "Sync manager: $($syncManager)" Add-LogEntry -Message "Archive non-existing users: $($archiveNonExisting)" 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" } $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() 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" Write-Output ">>> Start collecting users <<<" Write-Output "- Start collecting users from Microsoft Entra ID" $entraGroupFound = Get-Group -Name $entraGroup $entraUsers = Get-RecursiveGroupMembers -GroupId $entraGroupFound.Id Write-Output "-- Found $($entraUsers.Count) users within Microsoft Entra ID" $groupsMembership = prepareMembership $teamMemberships $rolesMembership = prepareMembership $roleMemberships Write-Output "----------------------------------------------------------------------------------" Write-Output "Creating and updating users in Embrace...`n" # Insert (create) new users or update existing users. upsertUsers Write-Output "`nCreation and updating of users is done." Write-Output "----------------------------------------------------------------------------------`n" if ($archiveNonExisting) { Add-LogEntry -Message "Start deleting non-provided users." Write-Output "Since archiveNonExisting is set to True, start deleting non-provided users." Write-Output "`nDeleting non-provided users ...`n" # Disable users that are removed from the synced MS Entra Group disableEmbraceUsers Write-Output "`nDeleting non-provided users completed." Write-Output "----------------------------------------------------------------------------------`n" } else { Write-Output "Since archiveNonExisting is set to False, deleting of non-provided users was skipped." } 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() } Write-Output ">>>>>> User provisioning finished <<<<<<" $stopwatch.Stop()