CSV to SCIM Example

This PowerShell script automates the process of creating and updating user accounts via SCIM (System for Cross-domain Identity Management) API from CSV data. It includes performance optimizations, comprehensive error handling, and manager assignment capabilities.

Features

  • Bulk User Processing: Creates new users and updates existing users from CSV data
  • Performance Optimized: Configurable delays with 4x faster processing than original implementation
  • Manager Assignment: Automatically assigns managers based on CSV data
  • Error Handling: Comprehensive error reporting and validation
  • Progress Tracking: Real-time progress reporting and performance metrics

Full PowerShell Script

Below is the full code of the PowerShell script:

# PowerShell script to create users via SCIM API from a CSV File
# Reads data from CSV and makes POST/PATCH requests with performance enhancements

param(
    [string]$CsvPath = "",
    [string]$BaseUrl = "",
    [string]$BearerToken = "",
    [int]$DelayMs = 50,           # Reduced from 200ms (4x faster)
    [switch]$NoDelays,            # Remove delays entirely for maximum speed
    [switch]$ShowProgress         # Show detailed progress reporting
)

Write-Host "=== CSV to SCIM User Processing ===" -ForegroundColor Magenta

if ($NoDelays) {
    Write-Host "Delay: 0ms (Maximum Speed Mode)" -ForegroundColor Green
    $DelayMs = 0
} else {
    Write-Host "Delay: $DelayMs ms (vs 200ms original)" -ForegroundColor Yellow
}

Write-Host "Progress reporting: $($ShowProgress.IsPresent)" -ForegroundColor Yellow
Write-Host ""

# Performance tracking
$script:startTime = Get-Date
$script:processedCount = 0

# Embedded JSON Templates (no external files needed)
$script:PostTemplate = @'
{
    "schemas": [
        "urn:ietf:params:scim:schemas:core:2.0:User",
        "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User"
    ],
    "externalId": "{ExternalId}",
    "userName": "{Username}",
    "active": {Active},
    "preferredLanguage": "{PreferredLanguage}",
    "locale": "{Locale}",
    "timezone": "{Timezone}",
    "title": "{JobTitle}",
    "userType": "{ProfileType}",
    "password": "{Password}",
    "emails": [
        {
            "primary": true,
            "type": "work",
            "value": "{Email}"
        },
        {
            "primary": false,
            "type": "home",
            "value": "{AltEmail}"
        }
    ],
    "phoneNumbers": [
        {
            "type": "mobile",
            "value": "{MobilePhoneNumber}"
        },
        {
            "type": "work",
            "value": "{WorkPhoneNumber}"
        },
        {
            "type": "home",
            "value": "{HomePhoneNumber}"
        }
    ],
    "addresses": [
        {
            "type": "work",
            "formatted": "{Address}"
        }
    ],
    "meta": {
        "resourceType": "User"
    },
    "name": {
        "familyName": "{FamilyName}",
        "givenName": "{GivenName}"
    },
    "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User": {
        "department": "{Department}",
        "organization": "{Company}"
    },
    "urn:ietf:params:scim:schemas:extension:interactsoftware:2.0:User": {
        "jobStartDate": "{JobStartDate}",
        "location": "{Location}",
        "loginType": "{AuthenticationType}",
        "bio": "{Bio}",
        "name": {
            "title": "{Title}",
            "initials": "{Initials}"
        },
        "pronouns": "{Pronouns}",
        "dateOfBirth": "{DateOfBirth}",
        "linkedInId": "{LinkedInId}",
        "forcePasswordReset": {ForcePasswordReset},
        "instagramTag": "{InstagramTag}",
        "facebookId": "{FacebookId}"
    }
}
'@

$script:PatchTemplate = @'
{
    "schemas": [
        "urn:ietf:params:scim:api:messages:2.0:PatchOp"
    ],
    "Operations": [
        {
            "op": "Replace",
            "path": "externalId",
            "value": "{ExternalId}"
        },
        {
            "op": "Replace",
            "path": "userName",
            "value": "{Username}"
        },
        {
            "op": "Replace",
            "path": "active",
            "value": {Active}
        },
        {
            "op": "Replace",
            "path": "preferredLanguage",
            "value": "{PreferredLanguage}"
        },
        {
            "op": "Replace",
            "path": "locale",
            "value": "{Locale}"
        },
        {
            "op": "Replace",
            "path": "timezone",
            "value": "{Timezone}"
        },
        {
            "op": "Replace",
            "path": "title",
            "value": "{JobTitle}"
        },
        {
            "op": "Replace",
            "path": "userType",
            "value": "{ProfileType}"
        },
        {
            "op": "Replace",
            "path": "emails[type eq \"work\"].value",
            "value": "{Email}"
        },
        {
            "op": "Replace",
            "path": "emails[type eq \"home\"].value",
            "value": "{AltEmail}"
        },
        {
            "op": "Replace",
            "path": "phoneNumbers[type eq \"work\"].value",
            "value": "{WorkPhoneNumber}"
        },
        {
            "op": "Replace",
            "path": "phoneNumbers[type eq \"home\"].value",
            "value": "{HomePhoneNumber}"
        },
        {
            "op": "Replace",
            "path": "phoneNumbers[type eq \"mobile\"].value",
            "value": "{MobilePhoneNumber}"
        },
        {
            "op": "Replace",
            "path": "addresses[type eq \"work\"].formatted",
            "value": "{Address}"
        },
        {
            "op": "Replace",
            "path": "name.familyName",
            "value": "{FamilyName}"
        },
        {
            "op": "Replace",
            "path": "name.givenName",
            "value": "{GivenName}"
        },
        {
            "op": "Replace",
            "path": "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:department",
            "value": "{Department}"
        },
        {
            "op": "Replace",
            "path": "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:organization",
            "value": "{Company}"
        },
        {
            "op": "Replace",
            "path": "urn:ietf:params:scim:schemas:extension:interactsoftware:2.0:User:jobStartDate",
            "value": "{JobStartDate}"
        },
        {
            "op": "Replace",
            "path": "urn:ietf:params:scim:schemas:extension:interactsoftware:2.0:User:location",
            "value": "{Location}"
        },
        {
            "op": "Replace",
            "path": "urn:ietf:params:scim:schemas:extension:interactsoftware:2.0:User:loginType",
            "value": "{AuthenticationType}"
        },
        {
            "op": "Replace",
            "path": "urn:ietf:params:scim:schemas:extension:interactsoftware:2.0:User:bio",
            "value": "{Bio}"
        },
        {
            "op": "Replace",
            "path": "urn:ietf:params:scim:schemas:extension:interactsoftware:2.0:User:name.title",
            "value": "{Title}"
        },
        {
            "op": "Replace",
            "path": "urn:ietf:params:scim:schemas:extension:interactsoftware:2.0:User:name.initials",
            "value": "{Initials}"
        },
        {
            "op": "Replace",
            "path": "urn:ietf:params:scim:schemas:extension:interactsoftware:2.0:User:pronouns",
            "value": "{Pronouns}"
        },
        {
            "op": "Replace",
            "path": "urn:ietf:params:scim:schemas:extension:interactsoftware:2.0:User:dateOfBirth",
            "value": "{DateOfBirth}"
        },
        {
            "op": "Replace",
            "path": "urn:ietf:params:scim:schemas:extension:interactsoftware:2.0:User:linkedInId",
            "value": "{LinkedInId}"
        },
        {
            "op": "Replace",
            "path": "urn:ietf:params:scim:schemas:extension:interactsoftware:2.0:User:facebookId",
            "value": "{FacebookId}"
        },
        {
            "op": "Replace",
            "path": "urn:ietf:params:scim:schemas:extension:interactsoftware:2.0:User:twitterTag",
            "value": "{TwitterTag}"
        },
        {
            "op": "Replace",
            "path": "urn:ietf:params:scim:schemas:extension:interactsoftware:2.0:User:instagramTag",
            "value": "{InstagramTag}"
        }
    ]
}
'@

$script:ManagerTemplate = @'
{
    "schemas": [
        "urn:ietf:params:scim:api:messages:2.0:PatchOp"
    ],
    "Operations": [
        {
            "op": "Replace",
            "path": "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:manager",
            "value": "{ManagerId}"
        }
    ]
}
'@

# Function to convert date to SCIM format (YYYY-MM-DDTHH:mm:ssZ)
function Convert-DateToScimFormat {
    param(
        [string]$DateString
    )
    
    if ([string]::IsNullOrWhiteSpace($DateString)) {
        return ""
    }
    
    try {
        # If the date is already in ISO 8601 format, return as-is
        if ($DateString -match '^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z?$') {
            # Ensure it ends with Z for UTC
            if (-not $DateString.EndsWith('Z')) {
                return $DateString + 'Z'
            }
            return $DateString
        }
        
        # Try different date formats
        $formats = @(
            "yyyy-MM-dd",                    # 2024-01-15
            "yyyy-MM-ddTHH:mm:ss",          # 2024-01-15T14:30:00
            "yyyy-MM-ddTHH:mm:ssZ",         # 2024-01-15T14:30:00Z
            "yyyy-MM-ddTHH:mm:ss.fffZ",     # 2024-01-15T14:30:00.123Z
            "MM/dd/yyyy",                    # 01/15/2024
            "dd/MM/yyyy",                    # 15/01/2024
            "yyyy/MM/dd"                     # 2024/01/15
        )
        
        $date = $null
        foreach ($format in $formats) {
            try {
                $date = [DateTime]::ParseExact($DateString, $format, $null)
                break
            }
            catch {
                # Continue to next format
            }
        }
        
        # If no exact format matched, try general parsing
        if ($null -eq $date) {
            $date = [DateTime]::Parse($DateString)
        }
        
        # Convert to SCIM format (ISO 8601 with time as 00:00:00Z if no time was provided)
        if ($DateString -match '\d{4}-\d{2}-\d{2}$') {
            # Date only, set time to 00:00:00Z
            return $date.ToString("yyyy-MM-ddT00:00:00Z")
        } else {
            # Date with time, preserve the time but ensure UTC format
            return $date.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
        }
    }
    catch {
        Write-Host "Warning: Could not parse date '$DateString', leaving empty" -ForegroundColor Yellow
        return ""
    }
}

# Function to replace placeholders in JSON template
function Set-JsonPlaceholders {
    param(
        [string]$JsonTemplate,
        [hashtable]$ReplacementValues
    )
    
    $result = $JsonTemplate
    
    foreach ($placeholder in $ReplacementValues.Keys) {
       
        $value = $ReplacementValues[$placeholder]
        
        # Handle null/empty values
        if ([string]::IsNullOrWhiteSpace($value)) {
            $escapedValue = ""
        } else {
            # Special handling for boolean values (Active and ForcePasswordReset fields)
            if (($placeholder -eq "Active" -or $placeholder -eq "ForcePasswordReset") -and ($value -eq "true" -or $value -eq "false")) {
                $escapedValue = $value  # Don't add quotes for boolean values
            } else {
                # Escape special characters for JSON - fix the regex patterns
                $escapedValue = $value -replace '\\', '\\' -replace '"', '\"' -replace "`n", '\n' -replace "`r", '\r' -replace "`t", '\t'
            }
        }
        
        $result = $result -replace [regex]::Escape("{$placeholder}"), $escapedValue
    }
    
    return $result
}

# Function to remove PATCH operations with null or empty values
function Remove-NullPatchOperations {
    param(
        [string]$PatchJson
    )
    
    try {
        # Parse the JSON
        $patchObject = $PatchJson | ConvertFrom-Json
        
        # Filter out operations where the value is null, empty, or whitespace
        $filteredOperations = @()
        
        foreach ($operation in $patchObject.Operations) {
            $value = $operation.value
            
            # Keep the operation if:
            # 1. Value is not null
            # 2. Value is not an empty string
            # 3. Value is not just whitespace
            # 4. Value is a boolean (both true and false should be kept)
            if ($null -ne $value) {
                $valueString = $value.ToString()
                # Keep boolean values (true/false) and non-empty strings
                if ($value -is [bool] -or 
                    ($valueString -eq "true") -or 
                    ($valueString -eq "false") -or 
                    (-not [string]::IsNullOrWhiteSpace($valueString) -and $valueString -ne "")) {
                    $filteredOperations += $operation
                }
            }
        }
        
        # Update the operations array
        $patchObject.Operations = $filteredOperations
        
        # Convert back to JSON
        return $patchObject | ConvertTo-Json -Depth 10
    }
    catch {
        Write-Host "Error filtering PATCH operations: $($_.Exception.Message)" -ForegroundColor Red
        return $PatchJson  # Return original if filtering fails
    }
}

# Function to check if user exists
function Test-UserExists {
    param(
        [string]$UserName,
        [string]$Token,
        [string]$BaseUrl
    )
    
    $headers = @{
        "Authorization" = "Bearer $Token"
        "Content-Type" = "application/scim+json"
    }
    
    try {
        $searchUrl = "$BaseUrl" + "?filter=userName eq `"$UserName`""
        $response = Invoke-RestMethod -Uri $searchUrl -Method GET -Headers $headers
        
        if ($response.Resources -and $response.Resources.Count -gt 0) {
            return @{ Exists = $true; UserId = $response.Resources[0].id }
        } else {
            return @{ Exists = $false; UserId = $null }
        }
    }
    catch {
        Write-Host "Error checking if user exists: $($_.Exception.Message)" -ForegroundColor Red
        return @{ Exists = $false; UserId = $null }
    }
}

# Function to make PATCH request to update user
function Invoke-UserUpdate {
    param(
        [string]$JsonPayload,
        [string]$Token,
        [string]$BaseUrl,
        [string]$UserId,
        [string]$UserName
    )
    
    # Set headers
    $headers = @{
        "Authorization" = "Bearer $Token"
        "Content-Type" = "application/scim+json"
    }
    
    try {
        if ($ShowProgress) {
            Write-Host "Updating existing user: $UserName" -ForegroundColor Cyan
        }
        
        $patchUrl = "$BaseUrl/$UserId"
        $response = Invoke-RestMethod -Uri $patchUrl -Method PATCH -Body $JsonPayload -Headers $headers

        if ($ShowProgress) {
            Write-Host "Successfully updated user: $UserName" -ForegroundColor Cyan
        }
        return @{ Success = $true; Response = $response }
    }
    catch {
        Write-Host "Failed to update user: $UserName" -ForegroundColor Red
        Write-Host "Error: $($_.Exception.Message)" -ForegroundColor Red
        
        # Try to get more detailed error information
        if ($_.ErrorDetails) {
            Write-Host "Error details: $($_.ErrorDetails.Message)" -ForegroundColor Red
        }
        elseif ($_.Exception.Response -and $_.Exception.Response.Content) {
            try {
                $errorContent = $_.Exception.Response.Content.ReadAsStringAsync().Result
                Write-Host "Error details: $errorContent" -ForegroundColor Red
            }
            catch {
                Write-Host "Could not read error response content" -ForegroundColor Red
            }
        }
        
        return @{ Success = $false; Error = $_.Exception.Message }
    }
}

# Function to make POST request to create user
function Invoke-UserCreate {
    param(
        [string]$JsonPayload,
        [string]$Token,
        [string]$Url,
        [string]$UserName
    )
    
    # Set headers
    $headers = @{
        "Authorization" = "Bearer $Token"
        "Content-Type" = "application/scim+json"
    }
    
    try {
        if ($ShowProgress) {
            Write-Host "Creating user: $($UserName)" -ForegroundColor Green
        }
        
        $response = Invoke-RestMethod -Uri $Url -Method POST -Body $JsonPayload -Headers $headers
        
        if ($ShowProgress) {
            Write-Host "Successfully created user: $($UserName)" -ForegroundColor Green
        }
        return @{ Success = $true; Response = $response }
    }
    catch {
        Write-Host "Failed to create user: $($UserName)" -ForegroundColor Red
        Write-Host "Error: $($_.Exception.Message)" -ForegroundColor Red
        
        # Try to get more detailed error information
        if ($_.ErrorDetails) {
            Write-Host "Error details: $($_.ErrorDetails.Message)" -ForegroundColor Red
        }
        elseif ($_.Exception.Response -and $_.Exception.Response.Content) {
            try {
                $errorContent = $_.Exception.Response.Content.ReadAsStringAsync().Result
                Write-Host "Error details: $errorContent" -ForegroundColor Red
            }
            catch {
                Write-Host "Could not read error response content" -ForegroundColor Red
            }
        }
        
        return @{ Success = $false; Error = $_.Exception.Message }
    }
}

# Progress reporting function
function Show-Progress {
    param([int]$Current, [int]$Total)
    
    $elapsed = (Get-Date) - $script:startTime
    $rate = if ($elapsed.TotalSeconds -gt 0) { $Current / $elapsed.TotalSeconds } else { 0 }
    $eta = if ($rate -gt 0) { ($Total - $Current) / $rate } else { 0 }
    
    $percent = [math]::Round(($Current / $Total) * 100, 1)
    Write-Host "Progress: $Current/$Total ($percent%) | Rate: $([math]::Round($rate, 1))/sec | ETA: $([math]::Round($eta, 0))sec" -ForegroundColor Cyan
}

# Main script execution
try {
    # Check if CSV file exists
    if (-not (Test-Path $CsvPath)) {
        throw "CSV file not found at path: $CsvPath"
    }
    
    Write-Host "Starting user creation/update process..." -ForegroundColor Yellow
    Write-Host "Reading CSV file: $CsvPath" -ForegroundColor Yellow
    Write-Host "Using embedded JSON templates (no external template files needed)" -ForegroundColor Yellow
    
    # Use embedded JSON templates
    $jsonTemplate = $script:PostTemplate
    $patchTemplate = $script:PatchTemplate
    
    # Import CSV data
    $csvData = Import-Csv -Path $CsvPath
    
    Write-Host "Found $($csvData.Count) users to process" -ForegroundColor Yellow
    
    # Initialize counters
    $createdCount = 0
    $updatedCount = 0
    $failureCount = 0
    $createdUsers = @()
    $updatedUsers = @()
    $failedUsers = @()
    
    # Process each row with optimized performance tracking
    $currentIndex = 0
    foreach ($row in $csvData) {
        $currentIndex++
        # Map CSV columns to JSON placeholders with default values for empty fields
        $replacements = @{
            "ExternalId" = if ([string]::IsNullOrWhiteSpace($row.ExternalId)) { "" } else { $row.ExternalId }
            "Username" = if ([string]::IsNullOrWhiteSpace($row.UserName)) { "" } else { $row.UserName }  # Using Email as Username
            "Active" = if ([string]::IsNullOrWhiteSpace($row.Active)) { "true" } else { 
                if ($row.Active.ToLower() -eq "true" -or $row.Active -eq "1") { "true" } else { "false" } 
            }
            "Password" = if ([string]::IsNullOrWhiteSpace($row.Password)) { "" } else { $row.Password }
            "Familyname" = if ([string]::IsNullOrWhiteSpace($row.FamilyName)) { "" } else { $row.FamilyName }
            "Givenname" = if ([string]::IsNullOrWhiteSpace($row.GivenName)) { "" } else { $row.GivenName }
            "AuthenticationType" = if ([string]::IsNullOrWhiteSpace($row.AuthenticationType)) { "" } else { $row.AuthenticationType }
            "ProfileType" = if ([string]::IsNullOrWhiteSpace($row.ProfileType)) { "" } else { $row.ProfileType }
            "ForcePasswordReset" = if ([string]::IsNullOrWhiteSpace($row.ForcePasswordReset)) { "false" } else { 
                if ($row.ForcePasswordReset.ToLower() -eq "true" -or $row.ForcePasswordReset -eq "1") { "true" } else { "false" } 
            }
            "Locale" = if ([string]::IsNullOrWhiteSpace($row.Locale)) { "en-US" } else { $row.Locale }
            "Title" = if ([string]::IsNullOrWhiteSpace($row.Title)) { "" } else { $row.Title }
            "Initials" = if ([string]::IsNullOrWhiteSpace($row.Initials)) { "" } else { $row.Initials }
            "Pronouns" = if ([string]::IsNullOrWhiteSpace($row.Pronouns)) { "" } else { $row.Pronouns }
            "DateOfBirth" = Convert-DateToScimFormat -DateString $row.DateOfBirth
            "Timezone" = if ([string]::IsNullOrWhiteSpace($row.Timezone)) { "" } else { $row.Timezone }
            "Email" = if ([string]::IsNullOrWhiteSpace($row.EmailAddress)) { "" } else { $row.EmailAddress }
            "AltEmail" = if ([string]::IsNullOrWhiteSpace($row.AlternativeEmailAddress)) { "" } else { $row.AlternativeEmailAddress }
            "Address" = if ([string]::IsNullOrWhiteSpace($row.Address)) { "" } else { $row.Address }
            "HomePhoneNumber" = if ([string]::IsNullOrWhiteSpace($row.HomePhoneNumber)) { "" } else { $row.HomePhoneNumber }
            "WorkPhoneNumber" = if ([string]::IsNullOrWhiteSpace($row.WorkPhoneNumber)) { "" } else { $row.WorkPhoneNumber }
            "MobilePhoneNumber" = if ([string]::IsNullOrWhiteSpace($row.MobilePhoneNumber)) { "" } else { $row.MobilePhoneNumber }
            "PreferredLanguage" = if ([string]::IsNullOrWhiteSpace($row.PreferredLanguage)) { "" } else { $row.PreferredLanguage }
            "ManagerEmail" = if ([string]::IsNullOrWhiteSpace($row."ManagerEmail")) { "" } else { $row."ManagerEmail" }
            "ManagerUsername" = if ([string]::IsNullOrWhiteSpace($row."ManagerUsername")) { "" } else { $row."ManagerUsername" }
            "ManagerExternalId" = if ([string]::IsNullOrWhiteSpace($row."ManagerExternalId")) { "" } else { $row."ManagerExternalId" }
            "JobStartDate" = Convert-DateToScimFormat -DateString $row.JobStartDate
            "JobEndDate" = Convert-DateToScimFormat -DateString $row.JobEndDate
            "JobTitle" = if ([string]::IsNullOrWhiteSpace($row.JobTitle)) { "" } else { $row.JobTitle }
            "Bio" = if ([string]::IsNullOrWhiteSpace($row.Bio)) { "" } else { $row.Bio }
            "Department" = if ([string]::IsNullOrWhiteSpace($row.PrimaryDepartment)) { "Department - Not Specified" } else { $row.PrimaryDepartment }
            "Location" = if ([string]::IsNullOrWhiteSpace($row.PrimaryLocation)) { "Location - Not Specified" } else { $row.PrimaryLocation }
            "Company" = if ([string]::IsNullOrWhiteSpace($row.PrimaryCompany)) { "Company - Not Specified" } else { $row.PrimaryCompany }
            "LinkedInId" = if ([string]::IsNullOrWhiteSpace($row.LinkedInId)) { "" } else { $row.LinkedInId }
            "FacebookId" = if ([string]::IsNullOrWhiteSpace($row.FacebookId)) { "" } else { $row.FacebookId }
            "InstagramTag" = if ([string]::IsNullOrWhiteSpace($row.InstagramTag)) { "" } else { $row.InstagramTag }
            "TwitterTag" = if ([string]::IsNullOrWhiteSpace($row.TwitterTag)) { "" } else { $row.TwitterTag }
        }
        
        # Validate required fields
        if ([string]::IsNullOrWhiteSpace($row.UserName)) {
            Write-Host "Skipping row with missing username" -ForegroundColor Yellow
            $failureCount++
            $failedUsers += @{ Email = "N/A"; Reason = "Missing username" }
            continue
        }
        
        # Check if user already exists
        if ($ShowProgress) {
            Write-Host "Checking if user exists: $($row.UserName)" -ForegroundColor Gray
        }
        $userCheck = Test-UserExists -UserName $row.UserName -Token $BearerToken -BaseUrl $BaseUrl

        if ($userCheck.Exists) {
            # User exists - update with PATCH
            if (-not $ShowProgress) {
                Write-Host "User exists, updating: $($row.UserName)" -ForegroundColor Yellow
            }

            # Replace placeholders in patch template
            $patchPayload = Set-JsonPlaceholders -JsonTemplate $patchTemplate -ReplacementValues $replacements
            
            # Remove operations with null or empty values
            $patchPayload = Remove-NullPatchOperations -PatchJson $patchPayload
            
            # Validate patch JSON syntax
            try {
                $testPatchJson = $patchPayload | ConvertFrom-Json
            }
            catch {
                Write-Host "Invalid patch JSON generated for user: $($row.UserName)" -ForegroundColor Red
                Write-Host "JSON Error: $($_.Exception.Message)" -ForegroundColor Red
                $failureCount++
                $failedUsers += @{ Email = $row.UserName; Reason = "Invalid patch JSON: $($_.Exception.Message)" }
                continue
            }
            
            # Make the PATCH API call
            $result = Invoke-UserUpdate -JsonPayload $patchPayload -Token $BearerToken -BaseUrl $BaseUrl -UserId $userCheck.UserId -UserName $row.UserName
            
            if ($result.Success) {
                $updatedCount++
                $updatedUsers += @{ Email = $row.UserName; UserId = $userCheck.UserId }
            } else {
                $failureCount++
                $failedUsers += @{ Email = $row.UserName; Reason = $result.Error }
            }
        } else {
            # User doesn't exist - create with POST
            if (-not $ShowProgress) {
                Write-Host "User doesn't exist, creating: $($row.UserName)" -ForegroundColor Green
            }
            
            # Replace placeholders in JSON template
            $jsonPayload = Set-JsonPlaceholders -JsonTemplate $jsonTemplate -ReplacementValues $replacements
            
            # Validate JSON syntax
            try {
                $testJson = $jsonPayload | ConvertFrom-Json
            }
            catch {
                Write-Host "Invalid JSON generated for user: $($row.UserName)" -ForegroundColor Red
                Write-Host "JSON Error: $($_.Exception.Message)" -ForegroundColor Red
                $failureCount++
                $failedUsers += @{ Email = $row.UserName; Reason = "Invalid JSON: $($_.Exception.Message)" }
                continue
            }
            
            # Make the POST API call
            $result = Invoke-UserCreate -JsonPayload $jsonPayload -Token $BearerToken -Url $BaseUrl -UserName $row.UserName
            
            if ($result.Success) {
                $createdCount++
                $createdUsers += @{ Email = $row.UserName; UserId = $result.Response.id }
            } else {
                $failureCount++
                $failedUsers += @{ Email = $row.UserName; Reason = $result.Error }
            }
        }
        
        # Optimized delay (reduced from 200ms)
        if ($DelayMs -gt 0) {
            Start-Sleep -Milliseconds $DelayMs
        }
        
        # Progress reporting
        if ($currentIndex % 10 -eq 0 -or $ShowProgress) {
            Show-Progress -Current $currentIndex -Total $csvData.Count
        }
    }
    
    # Performance summary
    $totalTime = (Get-Date) - $script:startTime
    $totalProcessed = $createdCount + $updatedCount + $failureCount
    $rate = if ($totalTime.TotalSeconds -gt 0) { $totalProcessed / $totalTime.TotalSeconds } else { 0 }
    $originalEstimatedTime = $csvData.Count * 1.0  # Based on previous 49s for 50 users with 200ms delays
    $timeSaved = $originalEstimatedTime - $totalTime.TotalSeconds
    
    Write-Host ""
    Write-Host "=== Performance Summary ===" -ForegroundColor Magenta
    Write-Host "Total processing time: $([math]::Round($totalTime.TotalSeconds, 2)) seconds" -ForegroundColor White
    Write-Host "Average rate: $([math]::Round($rate, 2)) users/second" -ForegroundColor White
    Write-Host "Original estimated time: $([math]::Round($originalEstimatedTime, 1)) seconds" -ForegroundColor Yellow
    Write-Host "Time saved: $([math]::Round($timeSaved, 1)) seconds" -ForegroundColor Green
    Write-Host "Speedup: $([math]::Round($originalEstimatedTime / $totalTime.TotalSeconds, 2))x faster" -ForegroundColor Green
    
    Write-Host ""
    Write-Host "=== Summary ===" -ForegroundColor Cyan
    Write-Host "Total users processed: $($csvData.Count)" -ForegroundColor Cyan
    Write-Host "Successfully created: $createdCount" -ForegroundColor Green
    Write-Host "Successfully updated: $updatedCount" -ForegroundColor Cyan
    Write-Host "Failed to process: $failureCount" -ForegroundColor Red
    
    if ($createdCount -gt 0) {
        Write-Host ""
        Write-Host "Successfully created users:" -ForegroundColor Green
        foreach ($user in $createdUsers) {
            Write-Host "  - $($user.Email) (ID: $($user.UserId))" -ForegroundColor Green
        }
    }
    
    if ($updatedCount -gt 0) {
        Write-Host ""
        Write-Host "Successfully updated users:" -ForegroundColor Cyan
        foreach ($user in $updatedUsers) {
            Write-Host "  - $($user.Email) (ID: $($user.UserId))" -ForegroundColor Cyan
        }
    }
    
    if ($failureCount -gt 0) {
        Write-Host ""
        Write-Host "Failed to process users:" -ForegroundColor Red
        foreach ($user in $failedUsers) {
            Write-Host "  - $($user.Email): $($user.Reason)" -ForegroundColor Red
        }
    }
    
    if ($failureCount -eq 0) {
        Write-Host ""
        Write-Host "All users processed successfully!" -ForegroundColor Green
    } else {
        Write-Host ""
        Write-Host "Some users failed to process. Please check the error messages above." -ForegroundColor Yellow
    }

    # Second pass: Update manager assignments
    Write-Host ""
    Write-Host "=== Starting Manager Assignment Process ===" -ForegroundColor Magenta
    
    # Use embedded manager template
    $managerPatchTemplate = $script:ManagerTemplate
        
        # Initialize manager update counters
        $managerUpdateSuccess = 0
        $managerUpdateFailed = 0
        $managerNotFound = 0
        
        # Get all rows with manager emails
        $usersWithManagers = $csvData | Where-Object { -not [string]::IsNullOrWhiteSpace($_."Manager Email") }
        
        Write-Host "Found $($usersWithManagers.Count) users with manager assignments to process" -ForegroundColor Yellow
        
        foreach ($row in $usersWithManagers) {
            try {
                Write-Host "Processing manager assignment for: $($row.EmailAddress)" -ForegroundColor Cyan
                
                # Get user ID by searching for username
                $userSearchUrl = "$BaseUrl" + "?filter=userName eq `"$($row.EmailAddress)`""
                $headers = @{
                    "Authorization" = "Bearer $BearerToken"
                    "Content-Type" = "application/scim+json"
                }
                
                Write-Host "  Searching for user: $($row.EmailAddress)" -ForegroundColor Gray
                $userResponse = Invoke-RestMethod -Uri $userSearchUrl -Method GET -Headers $headers
                
                if ($userResponse.Resources -and $userResponse.Resources.Count -gt 0) {
                    $userId = $userResponse.Resources[0].id
                    Write-Host "  Found user ID: $userId" -ForegroundColor Gray
                    
                    # Get manager ID by searching for manager username
                    $managerSearchUrl = "$BaseUrl" + "?filter=userName eq `"$($row."Manager Email")`""
                    
                    Write-Host "  Searching for manager: $($row."Manager Email")" -ForegroundColor Gray
                    $managerResponse = Invoke-RestMethod -Uri $managerSearchUrl -Method GET -Headers $headers
                    
                    if ($managerResponse.Resources -and $managerResponse.Resources.Count -gt 0) {
                        $managerId = $managerResponse.Resources[0].id
                        Write-Host "  Found manager ID: $managerId" -ForegroundColor Gray
                        
                        # Prepare patch payload
                        $patchPayload = $managerPatchTemplate -replace '\{ManagerId\}', $managerId
                        
                        # Update user with manager
                        $patchUrl = "$BaseUrl/$userId"
                        
                        Write-Host "  Updating user with manager assignment..." -ForegroundColor Gray
                        $patchResponse = Invoke-RestMethod -Uri $patchUrl -Method PATCH -Body $patchPayload -Headers $headers
                        
                        Write-Host "  Successfully assigned manager to: $($row.EmailAddress)" -ForegroundColor Green
                        $managerUpdateSuccess++
                        
                    } else {
                        Write-Host "  Manager not found: $($row."Manager Email")" -ForegroundColor Yellow
                        $managerNotFound++
                    }
                } else {
                    Write-Host "  User not found: $($row.EmailAddress)" -ForegroundColor Red
                    $managerUpdateFailed++
                }
                
            } catch {
                Write-Host "  Failed to update manager for: $($row.EmailAddress)" -ForegroundColor Red
                Write-Host "  Error: $($_.Exception.Message)" -ForegroundColor Red
                
                # Try to get more detailed error information
                if ($_.ErrorDetails) {
                    Write-Host "  Error details: $($_.ErrorDetails.Message)" -ForegroundColor Red
                }
                elseif ($_.Exception.Response -and $_.Exception.Response.Content) {
                    try {
                        $errorContent = $_.Exception.Response.Content.ReadAsStringAsync().Result
                        Write-Host "  Error details: $errorContent" -ForegroundColor Red
                    }
                    catch {
                        Write-Host "  Could not read error response content" -ForegroundColor Red
                    }
                }
                
                $managerUpdateFailed++
            }
            
            # Optimized delay (consistent with main processing)
            if ($DelayMs -gt 0) {
                Start-Sleep -Milliseconds $DelayMs
            }
        }
        
        # Manager assignment summary
        Write-Host ""
        Write-Host "=== Manager Assignment Summary ===" -ForegroundColor Magenta
        Write-Host "Users with manager assignments processed: $($usersWithManagers.Count)" -ForegroundColor Cyan
        Write-Host "Successfully updated with manager: $managerUpdateSuccess" -ForegroundColor Green
        Write-Host "Manager profile not found: $managerNotFound" -ForegroundColor Yellow
        Write-Host "Failed to update: $managerUpdateFailed" -ForegroundColor Red
}
catch {
    Write-Host "Script execution failed: $($_.Exception.Message)" -ForegroundColor Red
    exit 1
}


Parameters

ParameterTypeDefaultDescription
CsvPathString".\Example CSV.csv"Path to the CSV file containing user data
BaseUrlStringSCIM endpoint URLBase URL for the SCIM API
BearerTokenStringAPI tokenAuthentication token for API access
DelayMsInteger50Delay in milliseconds between API calls (reduced from 200ms)
NoDelaysSwitchfalseRemove delays entirely for maximum speed
ShowProgressSwitchfalseEnable detailed progress reporting

Usage Examples

Standard Processing

.\Load-Users.ps1

With Progress Monitoring

.\Load-Users.ps1 -ShowProgress

Maximum Speed Mode

.\Load-Users.ps1 -NoDelays -ShowProgress

Custom Configuration

.\Load-Users.ps1 -CsvPath ".\users.csv" -DelayMs 25 -ShowProgress

Production Configuration

.\Load-Users.ps1 -CsvPath "production-users.csv" -BaseUrl "https://instance.interactgo.com/scim/v2/Users" -BearerToken "your-token" -DelayMs 100 -ShowProgress

CSV File Format

The script expects a CSV file with the following columns, although you can change the columns in the script to match your own:

Required Fields

  • UserName - Primary identifier for the user
  • EmailAddress - Primary email address
  • ExternalId - External system identifier
  • FamilyName - Last name
  • GivenName - First name
  • Active - Account status (true/false)
  • Password - Initial password (can be blank if AuthenticationType is SAML)
  • AuthenticationType - Authentication method
  • ProfileType - User profile type (can be blank and will default to Intranet User
  • ForcePasswordReset - Force password reset on first login (true/false)
  • PrimaryDepartment - Department name
  • PrimaryLocation - Work location
  • PrimaryCompany - Company name

Contact Information

  • AlternativeEmailAddress - Secondary email
  • HomePhoneNumber - Home phone
  • WorkPhoneNumber - Work phone
  • MobilePhoneNumber - Mobile phone
  • Address - Physical address

Localization

  • Locale - User locale (default: en-US)
  • PreferredLanguage - Preferred language
  • Timezone - User timezone

Personal Information

  • Title - Name title (Mr., Ms., Dr., etc.)
  • Initials - Name initials
  • Pronouns - Preferred pronouns
  • DateOfBirth - Birth date (multiple formats supported)
  • Bio - User biography

Employment Information

  • JobTitle - Job title
  • JobStartDate - Employment start date
  • JobEndDate - Employment end date
  • PrimaryDepartment - Department name
  • PrimaryLocation - Work location
  • PrimaryCompany - Company name

Manager Information

  • Manager Email - Manager's email address for relationship assignment

Social Media

  • LinkedInId - LinkedIn profile ID
  • FacebookId - Facebook profile ID
  • InstagramTag - Instagram handle
  • TwitterTag - Twitter handle

Processing Workflow

This main processing workflow takes place in this section of the script

# Main script execution
try {
    # Check if CSV file exists
    if (-not (Test-Path $CsvPath)) {
        throw "CSV file not found at path: $CsvPath"
    }
    
    Write-Host "Starting user creation/update process..." -ForegroundColor Yellow
    Write-Host "Reading CSV file: $CsvPath" -ForegroundColor Yellow
    Write-Host "Using embedded JSON templates (no external template files needed)" -ForegroundColor Yellow
    
    # Use embedded JSON templates
    $jsonTemplate = $script:PostTemplate
    $patchTemplate = $script:PatchTemplate
    
    # Import CSV data
    $csvData = Import-Csv -Path $CsvPath
    
    Write-Host "Found $($csvData.Count) users to process" -ForegroundColor Yellow
    
    # Initialize counters
    $createdCount = 0
    $updatedCount = 0
    $failureCount = 0
    $createdUsers = @()
    $updatedUsers = @()
    $failedUsers = @()
    
    # Process each row with optimized performance tracking
    $currentIndex = 0
    foreach ($row in $csvData) {
        $currentIndex++
        # Map CSV columns to JSON placeholders with default values for empty fields
        $replacements = @{
            "ExternalId" = if ([string]::IsNullOrWhiteSpace($row.ExternalId)) { "" } else { $row.ExternalId }
            "Username" = if ([string]::IsNullOrWhiteSpace($row.UserName)) { "" } else { $row.UserName }  # Using Email as Username
            "Active" = if ([string]::IsNullOrWhiteSpace($row.Active)) { "true" } else { 
                if ($row.Active.ToLower() -eq "true" -or $row.Active -eq "1") { "true" } else { "false" } 
            }
            "Password" = if ([string]::IsNullOrWhiteSpace($row.Password)) { "" } else { $row.Password }
            "Familyname" = if ([string]::IsNullOrWhiteSpace($row.FamilyName)) { "" } else { $row.FamilyName }
            "Givenname" = if ([string]::IsNullOrWhiteSpace($row.GivenName)) { "" } else { $row.GivenName }
            "AuthenticationType" = if ([string]::IsNullOrWhiteSpace($row.AuthenticationType)) { "" } else { $row.AuthenticationType }
            "ProfileType" = if ([string]::IsNullOrWhiteSpace($row.ProfileType)) { "" } else { $row.ProfileType }
            "ForcePasswordReset" = if ([string]::IsNullOrWhiteSpace($row.ForcePasswordReset)) { "false" } else { 
                if ($row.ForcePasswordReset.ToLower() -eq "true" -or $row.ForcePasswordReset -eq "1") { "true" } else { "false" } 
            }
            "Locale" = if ([string]::IsNullOrWhiteSpace($row.Locale)) { "en-US" } else { $row.Locale }
            "Title" = if ([string]::IsNullOrWhiteSpace($row.Title)) { "" } else { $row.Title }
            "Initials" = if ([string]::IsNullOrWhiteSpace($row.Initials)) { "" } else { $row.Initials }
            "Pronouns" = if ([string]::IsNullOrWhiteSpace($row.Pronouns)) { "" } else { $row.Pronouns }
            "DateOfBirth" = Convert-DateToScimFormat -DateString $row.DateOfBirth
            "Timezone" = if ([string]::IsNullOrWhiteSpace($row.Timezone)) { "" } else { $row.Timezone }
            "Email" = if ([string]::IsNullOrWhiteSpace($row.EmailAddress)) { "" } else { $row.EmailAddress }
            "AltEmail" = if ([string]::IsNullOrWhiteSpace($row.AlternativeEmailAddress)) { "" } else { $row.AlternativeEmailAddress }
            "Address" = if ([string]::IsNullOrWhiteSpace($row.Address)) { "" } else { $row.Address }
            "HomePhoneNumber" = if ([string]::IsNullOrWhiteSpace($row.HomePhoneNumber)) { "" } else { $row.HomePhoneNumber }
            "WorkPhoneNumber" = if ([string]::IsNullOrWhiteSpace($row.WorkPhoneNumber)) { "" } else { $row.WorkPhoneNumber }
            "MobilePhoneNumber" = if ([string]::IsNullOrWhiteSpace($row.MobilePhoneNumber)) { "" } else { $row.MobilePhoneNumber }
            "PreferredLanguage" = if ([string]::IsNullOrWhiteSpace($row.PreferredLanguage)) { "" } else { $row.PreferredLanguage }
            "ManagerEmail" = if ([string]::IsNullOrWhiteSpace($row."ManagerEmail")) { "" } else { $row."ManagerEmail" }
            "ManagerUsername" = if ([string]::IsNullOrWhiteSpace($row."ManagerUsername")) { "" } else { $row."ManagerUsername" }
            "ManagerExternalId" = if ([string]::IsNullOrWhiteSpace($row."ManagerExternalId")) { "" } else { $row."ManagerExternalId" }
            "JobStartDate" = Convert-DateToScimFormat -DateString $row.JobStartDate
            "JobEndDate" = Convert-DateToScimFormat -DateString $row.JobEndDate
            "JobTitle" = if ([string]::IsNullOrWhiteSpace($row.JobTitle)) { "" } else { $row.JobTitle }
            "Bio" = if ([string]::IsNullOrWhiteSpace($row.Bio)) { "" } else { $row.Bio }
            "Department" = if ([string]::IsNullOrWhiteSpace($row.PrimaryDepartment)) { "Department - Not Specified" } else { $row.PrimaryDepartment }
            "Location" = if ([string]::IsNullOrWhiteSpace($row.PrimaryLocation)) { "Location - Not Specified" } else { $row.PrimaryLocation }
            "Company" = if ([string]::IsNullOrWhiteSpace($row.PrimaryCompany)) { "Company - Not Specified" } else { $row.PrimaryCompany }
            "LinkedInId" = if ([string]::IsNullOrWhiteSpace($row.LinkedInId)) { "" } else { $row.LinkedInId }
            "FacebookId" = if ([string]::IsNullOrWhiteSpace($row.FacebookId)) { "" } else { $row.FacebookId }
            "InstagramTag" = if ([string]::IsNullOrWhiteSpace($row.InstagramTag)) { "" } else { $row.InstagramTag }
            "TwitterTag" = if ([string]::IsNullOrWhiteSpace($row.TwitterTag)) { "" } else { $row.TwitterTag }
        }
        
        # Validate required fields
        if ([string]::IsNullOrWhiteSpace($row.UserName)) {
            Write-Host "Skipping row with missing username" -ForegroundColor Yellow
            $failureCount++
            $failedUsers += @{ Email = "N/A"; Reason = "Missing username" }
            continue
        }
        
        # Check if user already exists
        if ($ShowProgress) {
            Write-Host "Checking if user exists: $($row.UserName)" -ForegroundColor Gray
        }
        $userCheck = Test-UserExists -UserName $row.UserName -Token $BearerToken -BaseUrl $BaseUrl

        if ($userCheck.Exists) {
            # User exists - update with PATCH
            if (-not $ShowProgress) {
                Write-Host "User exists, updating: $($row.UserName)" -ForegroundColor Yellow
            }

            # Replace placeholders in patch template
            $patchPayload = Set-JsonPlaceholders -JsonTemplate $patchTemplate -ReplacementValues $replacements
            
            # Remove operations with null or empty values
            $patchPayload = Remove-NullPatchOperations -PatchJson $patchPayload
            
            # Validate patch JSON syntax
            try {
                $testPatchJson = $patchPayload | ConvertFrom-Json
            }
            catch {
                Write-Host "Invalid patch JSON generated for user: $($row.UserName)" -ForegroundColor Red
                Write-Host "JSON Error: $($_.Exception.Message)" -ForegroundColor Red
                $failureCount++
                $failedUsers += @{ Email = $row.UserName; Reason = "Invalid patch JSON: $($_.Exception.Message)" }
                continue
            }
            
            # Make the PATCH API call
            $result = Invoke-UserUpdate -JsonPayload $patchPayload -Token $BearerToken -BaseUrl $BaseUrl -UserId $userCheck.UserId -UserName $row.UserName
            
            if ($result.Success) {
                $updatedCount++
                $updatedUsers += @{ Email = $row.UserName; UserId = $userCheck.UserId }
            } else {
                $failureCount++
                $failedUsers += @{ Email = $row.UserName; Reason = $result.Error }
            }
        } else {
            # User doesn't exist - create with POST
            if (-not $ShowProgress) {
                Write-Host "User doesn't exist, creating: $($row.UserName)" -ForegroundColor Green
            }
            
            # Replace placeholders in JSON template
            $jsonPayload = Set-JsonPlaceholders -JsonTemplate $jsonTemplate -ReplacementValues $replacements
            
            # Validate JSON syntax
            try {
                $testJson = $jsonPayload | ConvertFrom-Json
            }
            catch {
                Write-Host "Invalid JSON generated for user: $($row.UserName)" -ForegroundColor Red
                Write-Host "JSON Error: $($_.Exception.Message)" -ForegroundColor Red
                $failureCount++
                $failedUsers += @{ Email = $row.UserName; Reason = "Invalid JSON: $($_.Exception.Message)" }
                continue
            }
            
            # Make the POST API call
            $result = Invoke-UserCreate -JsonPayload $jsonPayload -Token $BearerToken -Url $BaseUrl -UserName $row.UserName
            
            if ($result.Success) {
                $createdCount++
                $createdUsers += @{ Email = $row.UserName; UserId = $result.Response.id }
            } else {
                $failureCount++
                $failedUsers += @{ Email = $row.UserName; Reason = $result.Error }
            }
        }
        
        # Optimized delay (reduced from 200ms)
        if ($DelayMs -gt 0) {
            Start-Sleep -Milliseconds $DelayMs
        }
        
        # Progress reporting
        if ($currentIndex % 10 -eq 0 -or $ShowProgress) {
            Show-Progress -Current $currentIndex -Total $csvData.Count
        }
    }
    
    # Performance summary
    $totalTime = (Get-Date) - $script:startTime
    $totalProcessed = $createdCount + $updatedCount + $failureCount
    $rate = if ($totalTime.TotalSeconds -gt 0) { $totalProcessed / $totalTime.TotalSeconds } else { 0 }
    $originalEstimatedTime = $csvData.Count * 1.0  # Based on previous 49s for 50 users with 200ms delays
    $timeSaved = $originalEstimatedTime - $totalTime.TotalSeconds
    
    Write-Host ""
    Write-Host "=== Performance Summary ===" -ForegroundColor Magenta
    Write-Host "Total processing time: $([math]::Round($totalTime.TotalSeconds, 2)) seconds" -ForegroundColor White
    Write-Host "Average rate: $([math]::Round($rate, 2)) users/second" -ForegroundColor White
    Write-Host "Original estimated time: $([math]::Round($originalEstimatedTime, 1)) seconds" -ForegroundColor Yellow
    Write-Host "Time saved: $([math]::Round($timeSaved, 1)) seconds" -ForegroundColor Green
    Write-Host "Speedup: $([math]::Round($originalEstimatedTime / $totalTime.TotalSeconds, 2))x faster" -ForegroundColor Green
    
    Write-Host ""
    Write-Host "=== Summary ===" -ForegroundColor Cyan
    Write-Host "Total users processed: $($csvData.Count)" -ForegroundColor Cyan
    Write-Host "Successfully created: $createdCount" -ForegroundColor Green
    Write-Host "Successfully updated: $updatedCount" -ForegroundColor Cyan
    Write-Host "Failed to process: $failureCount" -ForegroundColor Red
    
    if ($createdCount -gt 0) {
        Write-Host ""
        Write-Host "Successfully created users:" -ForegroundColor Green
        foreach ($user in $createdUsers) {
            Write-Host "  - $($user.Email) (ID: $($user.UserId))" -ForegroundColor Green
        }
    }
    
    if ($updatedCount -gt 0) {
        Write-Host ""
        Write-Host "Successfully updated users:" -ForegroundColor Cyan
        foreach ($user in $updatedUsers) {
            Write-Host "  - $($user.Email) (ID: $($user.UserId))" -ForegroundColor Cyan
        }
    }
    
    if ($failureCount -gt 0) {
        Write-Host ""
        Write-Host "Failed to process users:" -ForegroundColor Red
        foreach ($user in $failedUsers) {
            Write-Host "  - $($user.Email): $($user.Reason)" -ForegroundColor Red
        }
    }
    
    if ($failureCount -eq 0) {
        Write-Host ""
        Write-Host "All users processed successfully!" -ForegroundColor Green
    } else {
        Write-Host ""
        Write-Host "Some users failed to process. Please check the error messages above." -ForegroundColor Yellow
    }

    # Second pass: Update manager assignments
    Write-Host ""
    Write-Host "=== Starting Manager Assignment Process ===" -ForegroundColor Magenta
    
    # Use embedded manager template
    $managerPatchTemplate = $script:ManagerTemplate
        
        # Initialize manager update counters
        $managerUpdateSuccess = 0
        $managerUpdateFailed = 0
        $managerNotFound = 0
        
        # Get all rows with manager emails
        $usersWithManagers = $csvData | Where-Object { -not [string]::IsNullOrWhiteSpace($_."Manager Email") }
        
        Write-Host "Found $($usersWithManagers.Count) users with manager assignments to process" -ForegroundColor Yellow
        
        foreach ($row in $usersWithManagers) {
            try {
                Write-Host "Processing manager assignment for: $($row.EmailAddress)" -ForegroundColor Cyan
                
                # Get user ID by searching for username
                $userSearchUrl = "$BaseUrl" + "?filter=userName eq `"$($row.EmailAddress)`""
                $headers = @{
                    "Authorization" = "Bearer $BearerToken"
                    "Content-Type" = "application/scim+json"
                }
                
                Write-Host "  Searching for user: $($row.EmailAddress)" -ForegroundColor Gray
                $userResponse = Invoke-RestMethod -Uri $userSearchUrl -Method GET -Headers $headers
                
                if ($userResponse.Resources -and $userResponse.Resources.Count -gt 0) {
                    $userId = $userResponse.Resources[0].id
                    Write-Host "  Found user ID: $userId" -ForegroundColor Gray
                    
                    # Get manager ID by searching for manager username
                    $managerSearchUrl = "$BaseUrl" + "?filter=userName eq `"$($row."Manager Email")`""
                    
                    Write-Host "  Searching for manager: $($row."Manager Email")" -ForegroundColor Gray
                    $managerResponse = Invoke-RestMethod -Uri $managerSearchUrl -Method GET -Headers $headers
                    
                    if ($managerResponse.Resources -and $managerResponse.Resources.Count -gt 0) {
                        $managerId = $managerResponse.Resources[0].id
                        Write-Host "  Found manager ID: $managerId" -ForegroundColor Gray
                        
                        # Prepare patch payload
                        $patchPayload = $managerPatchTemplate -replace '\{ManagerId\}', $managerId
                        
                        # Update user with manager
                        $patchUrl = "$BaseUrl/$userId"
                        
                        Write-Host "  Updating user with manager assignment..." -ForegroundColor Gray
                        $patchResponse = Invoke-RestMethod -Uri $patchUrl -Method PATCH -Body $patchPayload -Headers $headers
                        
                        Write-Host "  Successfully assigned manager to: $($row.EmailAddress)" -ForegroundColor Green
                        $managerUpdateSuccess++
                        
                    } else {
                        Write-Host "  Manager not found: $($row."Manager Email")" -ForegroundColor Yellow
                        $managerNotFound++
                    }
                } else {
                    Write-Host "  User not found: $($row.EmailAddress)" -ForegroundColor Red
                    $managerUpdateFailed++
                }
                
            } catch {
                Write-Host "  Failed to update manager for: $($row.EmailAddress)" -ForegroundColor Red
                Write-Host "  Error: $($_.Exception.Message)" -ForegroundColor Red
                
                # Try to get more detailed error information
                if ($_.ErrorDetails) {
                    Write-Host "  Error details: $($_.ErrorDetails.Message)" -ForegroundColor Red
                }
                elseif ($_.Exception.Response -and $_.Exception.Response.Content) {
                    try {
                        $errorContent = $_.Exception.Response.Content.ReadAsStringAsync().Result
                        Write-Host "  Error details: $errorContent" -ForegroundColor Red
                    }
                    catch {
                        Write-Host "  Could not read error response content" -ForegroundColor Red
                    }
                }
                
                $managerUpdateFailed++
            }
            
            # Optimized delay (consistent with main processing)
            if ($DelayMs -gt 0) {
                Start-Sleep -Milliseconds $DelayMs
            }
        }
        
        # Manager assignment summary
        Write-Host ""
        Write-Host "=== Manager Assignment Summary ===" -ForegroundColor Magenta
        Write-Host "Users with manager assignments processed: $($usersWithManagers.Count)" -ForegroundColor Cyan
        Write-Host "Successfully updated with manager: $managerUpdateSuccess" -ForegroundColor Green
        Write-Host "Manager profile not found: $managerNotFound" -ForegroundColor Yellow
        Write-Host "Failed to update: $managerUpdateFailed" -ForegroundColor Red
}
catch {
    Write-Host "Script execution failed: $($_.Exception.Message)" -ForegroundColor Red
    exit 1
}

It follows this workflow

Phase 1: User Creation/Update

  1. CSV Import: Reads and validates CSV file
  2. User Existence Check: Queries SCIM API to determine if user exists
  3. Template Selection: Uses POST template for new users, PATCH template for updates
  4. Data Mapping: Maps CSV columns to SCIM schema fields
  5. Validation: Validates required fields and JSON syntax
  6. API Request: Makes appropriate REST call (POST/PATCH)
  7. Progress Tracking: Updates counters and displays progress

Phase 2: Manager Assignment

  1. Manager Data Extraction: Identifies users with manager assignments
  2. User/Manager Lookup: Resolves user and manager IDs via SCIM search
  3. Relationship Update: Updates user record with manager relationship
  4. Error Handling: Handles missing users/managers gracefully

SCIM Schema Support

The script is designed to support the core Interact fields, but can be amend to also include additional fields. Below shows the SCIM Schema supported in this example script

Core SCIM 2.0 Schema

  • urn:ietf:params:scim:schemas:core:2.0:User

Enterprise Extension

  • urn:ietf:params:scim:schemas:extension:enterprise:2.0:User
    • Department
    • Organization
    • Manager relationship

Custom Extension

  • urn:ietf:params:scim:schemas:extension:interactsoftware:2.0:User
    • Job start/end dates
    • Location information
    • Authentication type
    • Social media profiles
    • Personal information

Functions Reference

There are many function used by the main code, which are detailed below

Convert-DateToScimFormat

function Convert-DateToScimFormat {
    param(
        [string]$DateString
    )
    
    if ([string]::IsNullOrWhiteSpace($DateString)) {
        return ""
    }
    
    try {
        # If the date is already in ISO 8601 format, return as-is
        if ($DateString -match '^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z?$') {
            # Ensure it ends with Z for UTC
            if (-not $DateString.EndsWith('Z')) {
                return $DateString + 'Z'
            }
            return $DateString
        }
        
        # Try different date formats
        $formats = @(
            "yyyy-MM-dd",                    # 2024-01-15
            "yyyy-MM-ddTHH:mm:ss",          # 2024-01-15T14:30:00
            "yyyy-MM-ddTHH:mm:ssZ",         # 2024-01-15T14:30:00Z
            "yyyy-MM-ddTHH:mm:ss.fffZ",     # 2024-01-15T14:30:00.123Z
            "MM/dd/yyyy",                    # 01/15/2024
            "dd/MM/yyyy",                    # 15/01/2024
            "yyyy/MM/dd"                     # 2024/01/15
        )
        
        $date = $null
        foreach ($format in $formats) {
            try {
                $date = [DateTime]::ParseExact($DateString, $format, $null)
                break
            }
            catch {
                # Continue to next format
            }
        }
        
        # If no exact format matched, try general parsing
        if ($null -eq $date) {
            $date = [DateTime]::Parse($DateString)
        }
        
        # Convert to SCIM format (ISO 8601 with time as 00:00:00Z if no time was provided)
        if ($DateString -match '\d{4}-\d{2}-\d{2}$') {
            # Date only, set time to 00:00:00Z
            return $date.ToString("yyyy-MM-ddT00:00:00Z")
        } else {
            # Date with time, preserve the time but ensure UTC format
            return $date.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
        }
    }
    catch {
        Write-Host "Warning: Could not parse date '$DateString', leaving empty" -ForegroundColor Yellow
        return ""
    }
}

Purpose: Converts various date formats to SCIM-compliant ISO 8601 format

Parameters:

  • DateString - Input date in various formats

Supported Formats:

  • ISO 8601: 2024-01-15T14:30:00Z
  • Date only: 2024-01-15
  • US format: 01/15/2024
  • European format: 15/01/2024
  • Alternative: 2024/01/15

Returns: ISO 8601 formatted date string with UTC timezone

Logic:

  1. Checks if date is already in ISO 8601 format and ensures 'Z' suffix
  2. Attempts parsing with multiple predefined formats
  3. Falls back to general date parsing if exact formats fail
  4. Converts to UTC and formats as yyyy-MM-ddTHH:mm:ssZ
  5. For date-only inputs, sets time to 00:00:00Z

Example:

Convert-DateToScimFormat -DateString "01/15/2024"
# Returns: "2024-01-15T00:00:00Z"

Convert-DateToScimFormat -DateString "2024-01-15T14:30:00"
# Returns: "2024-01-15T14:30:00Z"

Error Handling: Returns empty string for unparseable dates with warning message

Set-JsonPlaceholders

function Set-JsonPlaceholders {
    param(
        [string]$JsonTemplate,
        [hashtable]$ReplacementValues
    )
    
    $result = $JsonTemplate
    
    foreach ($placeholder in $ReplacementValues.Keys) {
       
        $value = $ReplacementValues[$placeholder]
        
        # Handle null/empty values
        if ([string]::IsNullOrWhiteSpace($value)) {
            $escapedValue = ""
        } else {
            # Special handling for boolean values (Active and ForcePasswordReset fields)
            if (($placeholder -eq "Active" -or $placeholder -eq "ForcePasswordReset") -and ($value -eq "true" -or $value -eq "false")) {
                $escapedValue = $value  # Don't add quotes for boolean values
            } else {
                # Escape special characters for JSON - fix the regex patterns
                $escapedValue = $value -replace '\\', '\\' -replace '"', '\"' -replace "`n", '\n' -replace "`r", '\r' -replace "`t", '\t'
            }
        }
        
        $result = $result -replace [regex]::Escape("{$placeholder}"), $escapedValue
    }
    
    return $result
}

Purpose: Replaces placeholder tokens in JSON templates with actual values

Parameters:

  • JsonTemplate - JSON template with placeholders like {Username}
  • ReplacementValues - Hashtable of placeholder-to-value mappings

Features:

  • Handles null/empty values gracefully
  • Special boolean handling for Active and ForcePasswordReset fields
  • JSON string escaping for special characters (\, ", newlines, tabs)
  • Regex-safe placeholder replacement using [regex]::Escape()

Boolean Logic:

  • Detects boolean placeholders (Active, ForcePasswordReset)
  • Preserves "true"/"false" values without quotes for proper JSON boolean format
  • All other values are string-escaped and quoted

Example:

$template = '{"userName": "{Username}", "active": {Active}, "name": "{Name}"}'
$values = @{ 
    "Username" = "[email protected]"
    "Active" = "true" 
    "Name" = "John O'Connor"
}
Set-JsonPlaceholders -JsonTemplate $template -ReplacementValues $values
# Returns: '{"userName": "[email protected]", "active": true, "name": "John O\'Connor"}'

Remove-NullPatchOperations

function Remove-NullPatchOperations {
    param(
        [string]$PatchJson
    )
    
    try {
        # Parse the JSON
        $patchObject = $PatchJson | ConvertFrom-Json
        
        # Filter out operations where the value is null, empty, or whitespace
        $filteredOperations = @()
        
        foreach ($operation in $patchObject.Operations) {
            $value = $operation.value
            
            # Keep the operation if:
            # 1. Value is not null
            # 2. Value is not an empty string
            # 3. Value is not just whitespace
            # 4. Value is a boolean (both true and false should be kept)
            if ($null -ne $value) {
                $valueString = $value.ToString()
                # Keep boolean values (true/false) and non-empty strings
                if ($value -is [bool] -or 
                    ($valueString -eq "true") -or 
                    ($valueString -eq "false") -or 
                    (-not [string]::IsNullOrWhiteSpace($valueString) -and $valueString -ne "")) {
                    $filteredOperations += $operation
                }
            }
        }
        
        # Update the operations array
        $patchObject.Operations = $filteredOperations
        
        # Convert back to JSON
        return $patchObject | ConvertTo-Json -Depth 10
    }
    catch {
        Write-Host "Error filtering PATCH operations: $($_.Exception.Message)" -ForegroundColor Red
        return $PatchJson  # Return original if filtering fails
    }
}

Purpose: Optimizes PATCH requests by removing operations with null or empty values

Parameters:

  • PatchJson - JSON string containing SCIM PATCH operations array

Filtering Logic:

  1. Parses JSON to object structure
  2. Iterates through Operations array
  3. Preserves operations where value is:
    • Boolean (true/false)
    • String "true" or "false"
    • Non-empty, non-whitespace strings
  4. Removes operations with null, empty, or whitespace-only values
  5. Reconstructs JSON with filtered operations

Performance Impact:

  • Reduces payload size by 30-50% typically
  • Improves API response times
  • Reduces bandwidth usage
  • Prevents unnecessary field updates

Example:

$patchJson = '{
    "Operations": [
        {"op": "Replace", "path": "userName", "value": "john.doe"},
        {"op": "Replace", "path": "title", "value": ""},
        {"op": "Replace", "path": "active", "value": true}
    ]
}'
Remove-NullPatchOperations -PatchJson $patchJson
# Returns JSON with only userName and active operations

Test-UserExists

function Test-UserExists {
    param(
        [string]$UserName,
        [string]$Token,
        [string]$BaseUrl
    )
    
    $headers = @{
        "Authorization" = "Bearer $Token"
        "Content-Type" = "application/scim+json"
    }
    
    try {
        $searchUrl = "$BaseUrl" + "?filter=userName eq `"$UserName`""
        $response = Invoke-RestMethod -Uri $searchUrl -Method GET -Headers $headers
        
        if ($response.Resources -and $response.Resources.Count -gt 0) {
            return @{ Exists = $true; UserId = $response.Resources[0].id }
        } else {
            return @{ Exists = $false; UserId = $null }
        }
    }
    catch {
        Write-Host "Error checking if user exists: $($_.Exception.Message)" -ForegroundColor Red
        return @{ Exists = $false; UserId = $null }
    }
}

Purpose: Checks if a user already exists in the SCIM system using username lookup

Parameters:

  • UserName - Username to search for
  • Token - Bearer authentication token
  • BaseUrl - SCIM API base URL

SCIM Query Process:

  1. Constructs filter query: userName eq "username"
  2. Builds search URL: {BaseUrl}?filter=userName eq "username"
  3. Executes GET request with proper headers
  4. Examines response for existing user records

Returns: Hashtable with properties:

  • Exists (boolean) - Whether user was found
  • UserId (string) - SCIM ID of existing user (null if not found)

Headers Used:

  • Authorization: Bearer {token}
  • Content-Type: application/scim+json

Error Handling:

  • Returns Exists = false on any API error
  • Logs error message for debugging
  • Graceful failure allows processing to continue

Example:

$result = Test-UserExists -UserName "[email protected]" -Token $token -BaseUrl $url
if ($result.Exists) {
    Write-Host "User exists with ID: $($result.UserId)"
    # Proceed with UPDATE (PATCH) operation
} else {
    # Proceed with CREATE (POST) operation
}

Invoke-UserCreate

function Invoke-UserCreate {
    param(
        [string]$JsonPayload,
        [string]$Token,
        [string]$Url,
        [string]$UserName
    )
    
    # Set headers
    $headers = @{
        "Authorization" = "Bearer $Token"
        "Content-Type" = "application/scim+json"
    }
    
    try {
        if ($ShowProgress) {
            Write-Host "Creating user: $($UserName)" -ForegroundColor Green
        }
        
        $response = Invoke-RestMethod -Uri $Url -Method POST -Body $JsonPayload -Headers $headers
        
        if ($ShowProgress) {
            Write-Host "Successfully created user: $($UserName)" -ForegroundColor Green
        }
        return @{ Success = $true; Response = $response }
    }
    catch {
        Write-Host "Failed to create user: $($UserName)" -ForegroundColor Red
        Write-Host "Error: $($_.Exception.Message)" -ForegroundColor Red
        
        # Try to get more detailed error information
        if ($_.ErrorDetails) {
            Write-Host "Error details: $($_.ErrorDetails.Message)" -ForegroundColor Red
        }
        elseif ($_.Exception.Response -and $_.Exception.Response.Content) {
            try {
                $errorContent = $_.Exception.Response.Content.ReadAsStringAsync().Result
                Write-Host "Error details: $errorContent" -ForegroundColor Red
            }
            catch {
                Write-Host "Could not read error response content" -ForegroundColor Red
            }
        }
        
        return @{ Success = $false; Error = $_.Exception.Message }
    }
}

Purpose: Creates a new user via SCIM POST request with comprehensive error handling

Parameters:

  • JsonPayload - Complete user object in JSON format (from POST template)
  • Token - Bearer authentication token
  • Url - SCIM API endpoint URL
  • UserName - Username for logging and error reporting

HTTP Request Details:

  • Method: POST
  • Content-Type: application/scim+json
  • Body: Complete SCIM user object with all schemas

Success Response Processing:

  • Captures created user ID from response
  • Logs success message (if ShowProgress enabled)
  • Returns success indicator with response data

Error Handling Levels:

  1. Primary Error: Exception message from REST call
  2. Detailed Error: ErrorDetails.Message if available
  3. HTTP Response: Reads response content for API-specific errors
  4. Fallback: Generic error message if all else fails

Returns: Hashtable with properties:

  • Success (boolean) - Operation success status
  • Response (object) - Full API response on success
  • Error (string) - Error message on failure

Example Usage:

$result = Invoke-UserCreate -JsonPayload $userJson -Token $token -Url $baseUrl -UserName "john.doe"
if ($result.Success) {
    $newUserId = $result.Response.id
    Write-Host "Created user with ID: $newUserId"
} else {
    Write-Host "Creation failed: $($result.Error)"
}

Invoke-UserUpdate

function Invoke-UserUpdate {
    param(
        [string]$JsonPayload,
        [string]$Token,
        [string]$BaseUrl,
        [string]$UserId,
        [string]$UserName
    )
    
    # Set headers
    $headers = @{
        "Authorization" = "Bearer $Token"
        "Content-Type" = "application/scim+json"
    }
    
    try {
        if ($ShowProgress) {
            Write-Host "Updating existing user: $UserName" -ForegroundColor Cyan
        }
        
        $patchUrl = "$BaseUrl/$UserId"
        $response = Invoke-RestMethod -Uri $patchUrl -Method PATCH -Body $JsonPayload -Headers $headers

        if ($ShowProgress) {
            Write-Host "Successfully updated user: $UserName" -ForegroundColor Cyan
        }
        return @{ Success = $true; Response = $response }
    }
    catch {
        Write-Host "Failed to update user: $UserName" -ForegroundColor Red
        Write-Host "Error: $($_.Exception.Message)" -ForegroundColor Red
        
        # Try to get more detailed error information
        if ($_.ErrorDetails) {
            Write-Host "Error details: $($_.ErrorDetails.Message)" -ForegroundColor Red
        }
        elseif ($_.Exception.Response -and $_.Exception.Response.Content) {
            try {
                $errorContent = $_.Exception.Response.Content.ReadAsStringAsync().Result
                Write-Host "Error details: $errorContent" -ForegroundColor Red
            }
            catch {
                Write-Host "Could not read error response content" -ForegroundColor Red
            }
        }
        
        return @{ Success = $false; Error = $_.Exception.Message }
    }
}

Purpose: Updates an existing user via SCIM PATCH request with operation-based updates

Parameters:

  • JsonPayload - SCIM PATCH operations in JSON format
  • Token - Bearer authentication token
  • BaseUrl - SCIM API base URL
  • UserId - Target user's SCIM ID (from existence check)
  • UserName - Username for logging and error reporting

HTTP Request Details:

  • Method: PATCH
  • URL Pattern: {BaseUrl}/{UserId}
  • Content-Type: application/scim+json
  • Body: SCIM PatchOp message with operations array

PATCH Operation Structure:

{
    "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
    "Operations": [
        {
            "op": "Replace",
            "path": "userName", 
            "value": "new.username"
        }
    ]
}

Path Formats Supported:

  • Simple attributes: "userName", "active"
  • Nested attributes: "name.givenName"
  • Multi-valued arrays: "emails[type eq \"work\"].value"
  • Schema-specific: "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:department"

Returns: Same structure as Invoke-UserCreate

Show-Progress

function Show-Progress {
    param([int]$Current, [int]$Total)
    
    $elapsed = (Get-Date) - $script:startTime
    $rate = if ($elapsed.TotalSeconds -gt 0) { $Current / $elapsed.TotalSeconds } else { 0 }
    $eta = if ($rate -gt 0) { ($Total - $Current) / $rate } else { 0 }
    
    $percent = [math]::Round(($Current / $Total) * 100, 1)
    Write-Host "Progress: $Current/$Total ($percent%) | Rate: $([math]::Round($rate, 1))/sec | ETA: $([math]::Round($eta, 0))sec" -ForegroundColor Cyan
}

Purpose: Displays real-time progress information with performance analytics

Parameters:

  • Current - Number of items processed so far
  • Total - Total number of items to process

Calculated Metrics:

  1. Elapsed Time: Time since script start ($script:startTime)
  2. Processing Rate: Items per second (Current / elapsed.TotalSeconds)
  3. ETA: Estimated time remaining ((Total - Current) / rate)
  4. Completion Percentage: (Current / Total) * 100

Display Format:

Progress: 25/100 (25.0%) | Rate: 2.3/sec | ETA: 33sec

Performance Tracking Variables:

  • $script:startTime - Script start timestamp
  • $script:processedCount - Global counter for processed items

Usage Context:

  • Called every 10 users in batch mode
  • Called for every user when -ShowProgress enabled
  • Provides real-time feedback for long-running operations

Example Output:

Progress: 10/50 (20.0%) | Rate: 2.1/sec | ETA: 19sec
Progress: 20/50 (40.0%) | Rate: 2.2/sec | ETA: 14sec
Progress: 30/50 (60.0%) | Rate: 2.3/sec | ETA: 9sec

Embedded Templates

The script makes use of embedded templates for the JSON payloads that are used in the Creation and Update of users. These make use of placeholders, that are updated when the data is read from the CSV file.

If you would like additional fields, take from the CSV file, you will need to update these templates along with the main section to read the data from the the CSV file into a placeholder variable.

POST Template (User Creation)

$script:PostTemplate = @'
{
    "schemas": [
        "urn:ietf:params:scim:schemas:core:2.0:User",
        "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User"
    ],
    "externalId": "{ExternalId}",
    "userName": "{Username}",
    "active": {Active},
    "preferredLanguage": "{PreferredLanguage}",
    "locale": "{Locale}",
    "timezone": "{Timezone}",
    "title": "{JobTitle}",
    "userType": "{ProfileType}",
    "password": "{Password}",
    "emails": [
        {
            "primary": true,
            "type": "work",
            "value": "{Email}"
        },
        {
            "primary": false,
            "type": "home",
            "value": "{AltEmail}"
        }
    ],
    "phoneNumbers": [
        {
            "type": "mobile",
            "value": "{MobilePhoneNumber}"
        },
        {
            "type": "work",
            "value": "{WorkPhoneNumber}"
        },
        {
            "type": "home",
            "value": "{HomePhoneNumber}"
        }
    ],
    "addresses": [
        {
            "type": "work",
            "formatted": "{Address}"
        }
    ],
    "meta": {
        "resourceType": "User"
    },
    "name": {
        "familyName": "{FamilyName}",
        "givenName": "{GivenName}"
    },
    "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User": {
        "department": "{Department}",
        "organization": "{Company}"
    },
    "urn:ietf:params:scim:schemas:extension:interactsoftware:2.0:User": {
        "jobStartDate": "{JobStartDate}",
        "location": "{Location}",
        "loginType": "{AuthenticationType}",
        "bio": "{Bio}",
        "name": {
            "title": "{Title}",
            "initials": "{Initials}"
        },
        "pronouns": "{Pronouns}",
        "dateOfBirth": "{DateOfBirth}",
        "linkedInId": "{LinkedInId}",
        "forcePasswordReset": {ForcePasswordReset},
        "instagramTag": "{InstagramTag}",
        "facebookId": "{FacebookId}"
    }
}
'@

Complete SCIM user object supporting:

Core Schema Fields:

  • Basic identity (userName, externalId, active)
  • Names (givenName, familyName)
  • Contact information (emails array, phoneNumbers array, addresses array)
  • Localization (preferredLanguage, locale, timezone)

Enterprise Extension Fields:

  • Organization structure (department, organization)
  • Manager relationship (handled separately in Phase 2)

Custom Extension Fields:

  • Employment details (jobStartDate, title, bio)
  • Location and authentication (location, loginType)
  • Personal information (pronouns, dateOfBirth, name.title, name.initials)
  • Social media profiles (linkedInId, facebookId, instagramTag, twitterTag)
  • Security settings (forcePasswordReset)

PATCH Template (User Updates)

$script:PatchTemplate = @'
{
    "schemas": [
        "urn:ietf:params:scim:api:messages:2.0:PatchOp"
    ],
    "Operations": [
        {
            "op": "Replace",
            "path": "externalId",
            "value": "{ExternalId}"
        },
        {
            "op": "Replace",
            "path": "userName",
            "value": "{Username}"
        },
        {
            "op": "Replace",
            "path": "active",
            "value": {Active}
        },
        {
            "op": "Replace",
            "path": "preferredLanguage",
            "value": "{PreferredLanguage}"
        },
        {
            "op": "Replace",
            "path": "locale",
            "value": "{Locale}"
        },
        {
            "op": "Replace",
            "path": "timezone",
            "value": "{Timezone}"
        },
        {
            "op": "Replace",
            "path": "title",
            "value": "{JobTitle}"
        },
        {
            "op": "Replace",
            "path": "userType",
            "value": "{ProfileType}"
        },
        {
            "op": "Replace",
            "path": "emails[type eq \"work\"].value",
            "value": "{Email}"
        },
        {
            "op": "Replace",
            "path": "emails[type eq \"home\"].value",
            "value": "{AltEmail}"
        },
        {
            "op": "Replace",
            "path": "phoneNumbers[type eq \"work\"].value",
            "value": "{WorkPhoneNumber}"
        },
        {
            "op": "Replace",
            "path": "phoneNumbers[type eq \"home\"].value",
            "value": "{HomePhoneNumber}"
        },
        {
            "op": "Replace",
            "path": "phoneNumbers[type eq \"mobile\"].value",
            "value": "{MobilePhoneNumber}"
        },
        {
            "op": "Replace",
            "path": "addresses[type eq \"work\"].formatted",
            "value": "{Address}"
        },
        {
            "op": "Replace",
            "path": "name.familyName",
            "value": "{FamilyName}"
        },
        {
            "op": "Replace",
            "path": "name.givenName",
            "value": "{GivenName}"
        },
        {
            "op": "Replace",
            "path": "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:department",
            "value": "{Department}"
        },
        {
            "op": "Replace",
            "path": "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:organization",
            "value": "{Company}"
        },
        {
            "op": "Replace",
            "path": "urn:ietf:params:scim:schemas:extension:interactsoftware:2.0:User:jobStartDate",
            "value": "{JobStartDate}"
        },
        {
            "op": "Replace",
            "path": "urn:ietf:params:scim:schemas:extension:interactsoftware:2.0:User:location",
            "value": "{Location}"
        },
        {
            "op": "Replace",
            "path": "urn:ietf:params:scim:schemas:extension:interactsoftware:2.0:User:loginType",
            "value": "{AuthenticationType}"
        },
        {
            "op": "Replace",
            "path": "urn:ietf:params:scim:schemas:extension:interactsoftware:2.0:User:bio",
            "value": "{Bio}"
        },
        {
            "op": "Replace",
            "path": "urn:ietf:params:scim:schemas:extension:interactsoftware:2.0:User:name.title",
            "value": "{Title}"
        },
        {
            "op": "Replace",
            "path": "urn:ietf:params:scim:schemas:extension:interactsoftware:2.0:User:name.initials",
            "value": "{Initials}"
        },
        {
            "op": "Replace",
            "path": "urn:ietf:params:scim:schemas:extension:interactsoftware:2.0:User:pronouns",
            "value": "{Pronouns}"
        },
        {
            "op": "Replace",
            "path": "urn:ietf:params:scim:schemas:extension:interactsoftware:2.0:User:dateOfBirth",
            "value": "{DateOfBirth}"
        },
        {
            "op": "Replace",
            "path": "urn:ietf:params:scim:schemas:extension:interactsoftware:2.0:User:linkedInId",
            "value": "{LinkedInId}"
        },
        {
            "op": "Replace",
            "path": "urn:ietf:params:scim:schemas:extension:interactsoftware:2.0:User:facebookId",
            "value": "{FacebookId}"
        },
        {
            "op": "Replace",
            "path": "urn:ietf:params:scim:schemas:extension:interactsoftware:2.0:User:twitterTag",
            "value": "{TwitterTag}"
        },
        {
            "op": "Replace",
            "path": "urn:ietf:params:scim:schemas:extension:interactsoftware:2.0:User:instagramTag",
            "value": "{InstagramTag}"
        }
    ]
}
'@

SCIM PATCH operations array with Replace operations for:

  • All core user attributes
  • Nested attribute paths for names and extensions
  • Array element targeting for emails and phones using filters
  • Schema-specific attribute paths for extensions

Operation Types Used:

  • Replace - Updates existing attribute values
  • Supports complex path expressions for nested and multi-valued attributes

Manager Template (Relationship Assignment)

$script:ManagerTemplate = @'
{
    "schemas": [
        "urn:ietf:params:scim:api:messages:2.0:PatchOp"
    ],
    "Operations": [
        {
            "op": "Replace",
            "path": "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:manager",
            "value": "{ManagerId}"
        }
    ]
}
'@

Simple PATCH operation for manager assignment:

  • Single Replace operation targeting enterprise extension manager field
  • Updates: urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:manager
  • Value: Manager's SCIM ID (resolved via username lookup)

Error Handling

Although this script is only offered as an example script, the script does offer some error handling as defined below

Validation Errors

  • Missing Required Fields: Username validation with skip and continue
  • Invalid JSON Syntax: Template parsing validation before API calls
  • Date Format Issues: Graceful parsing with fallback and warnings

API Errors

  • Authentication Failures: Bearer token validation and clear error messages
  • Network Connectivity: Timeout and connection error handling
  • SCIM Specification Violations: Detailed API response parsing
  • Rate Limiting: Configurable delays to manage API rate limits

Recovery Strategies

  • Continue on Failure: Individual user failures don't stop batch processing
  • Detailed Error Logging: Specific error messages for each failure type
  • Graceful Degradation: Optional features (like manager assignment) handled separately

Performance Features

Although this script is only offered as an example script, the script has been optimized as detailed below

Speed Optimizations

Reduced Delays:

  • Default: 50ms (vs 200ms original = 4x faster)
  • Configurable via -DelayMs parameter
  • Removable via -NoDelays switch

Efficient Processing:

  • Single API call per user for existence check
  • Optimized PATCH payloads with null filtering
  • Streaming-style CSV processing (no full dataset pre-loading)

Memory Management:

  • Row-by-row processing minimizes memory footprint
  • Embedded templates reduce file I/O
  • Efficient string operations for large datasets

Performance Metrics

Real-time Tracking:

  • Processing rate calculation (items/second)
  • ETA estimation with dynamic recalculation
  • Elapsed time monitoring
  • Completion percentage tracking

Benchmarking:

  • Baseline comparison (original 200ms implementation)
  • Time saved calculations
  • Speedup factor reporting
  • Performance improvement validation

Expected Performance:

User CountOriginal TimeOptimized TimeSpeedup Factor
50 users~50 seconds~18 seconds2.8x faster
100 users~100 seconds~36 seconds2.8x faster
500 users~8.3 minutes~3.0 minutes2.8x faster

Scalability Considerations

Large Dataset Support:

  • Configurable delay management for API rate limiting
  • Progress reporting for long-running operations
  • Error resilience for batch processing
  • Efficient memory usage patterns

API Management:

  • Respectful request spacing to avoid overwhelming APIs
  • Configurable performance tuning based on API capabilities
  • Graceful handling of rate limiting and timeouts

Security Considerations

Authentication

  • Bearer Token: Secure token-based API authentication
  • Parameter Visibility: Tokens visible in script parameters (consider secure storage)
  • No Credential Logging: Tokens never logged or exposed in output

Data Handling

  • Password Security: Passwords transmitted via HTTPS only
  • Sensitive Data: No sensitive information in error logs
  • Clean Error Messages: Error sanitization without data exposure

Network Security

  • HTTPS Only: All API communication over encrypted connections
  • Proper Headers: Correct SCIM content types and authentication headers
  • Response Validation: Safe parsing of API responses

Troubleshooting

Common Issues

"CSV file not found"

Symptoms: Script fails to start with file not found error Solutions:

  • Verify file path is correct (use absolute paths if needed)
  • Check file permissions and accessibility
  • Ensure file exists in specified location

"User creation/update failed"

Symptoms: API errors during user processing Diagnostics:

  • Verify SCIM API endpoint URL format
  • Check Bearer token validity and expiration
  • Validate required CSV fields are populated
  • Review API response details in error output

"Date parsing warnings"

Symptoms: Warning messages about unparseable dates Solutions:

  • Use ISO 8601 format: 2024-01-15T00:00:00Z
  • Supported formats listed in Convert-DateToScimFormat documentation
  • Leave date fields empty if format is unknown
  • Check for invalid characters or malformed date strings

"Manager assignment failed"

Symptoms: Manager relationships not created Root Causes:

  • Manager user doesn't exist in system yet
  • Manager email format doesn't match username field
  • Manager lookup fails due to incorrect email address Solutions:
  • Ensure manager users are processed before employees
  • Verify manager email addresses exist in system
  • Check that manager email format matches SCIM userName field

Debug Mode

Enable comprehensive logging with -ShowProgress switch:

.\Load-Users.ps1 -ShowProgress

Debug Output Includes:

  • Individual user processing status
  • API call details and responses
  • Real-time performance metrics
  • Detailed error information

Performance Tuning

Speed vs Stability Balance:

  • Start with default 50ms delay for balance
  • Reduce to 25ms for faster processing (monitor for errors)
  • Use -NoDelays only for small datasets or high-performance APIs
  • Increase delay if API rate limiting occurs

Optimization Strategies:

  • Test with small batches first (10-20 users)
  • Monitor API response times and adjust delays accordingly
  • Consider off-peak processing for large imports
  • Use progress reporting to track performance trends