Employee Sync Azure Function
PowerShell Azure Function implementation for the employee sync endpoint.
Reference implementation for the employee sync HTTP endpoint.
using namespace System.Net
param($Request, $TriggerMetadata)
# =========================================================================
# Config (app settings — set these on the Function App)
# =========================================================================
# CiaEtlSecret -> Key Vault reference to kv-cia-employee-sync/ciaETLsecret
# e.g. @Microsoft.KeyVault(SecretUri=https://kv-cia-employee-sync.vault.azure.net/secrets/ciaETLsecret/)
# TableStorageAccountName -> plain string, e.g. "mystorageacct"
# EmployeeTableName -> plain string, e.g. "Employees" (defaults below if unset)
# OuMappingTableName -> plain string, e.g. "OrganisationUnitMappings" (defaults below if unset)
# =========================================================================
$expectedToken = $env:CiaEtlSecret
$storageAccountName = $env:TableStorageAccountName
$tableName = $env:EmployeeTableName
if (-not $tableName) { $tableName = 'Employees' }
$ouMappingTableName = $env:OuMappingTableName
if (-not $ouMappingTableName) { $ouMappingTableName = 'OrganisationUnitMappings' }
function New-JsonResponse {
param(
[int]$StatusCode,
[hashtable]$Body
)
[HttpResponseContext]@{
StatusCode = $StatusCode
Headers = @{ 'Content-Type' = 'application/json' }
Body = ($Body | ConvertTo-Json -Compress -Depth 5)
}
}
# -------------------------------------------------------------------------
# 1. Verify the bearer token before doing anything else with the payload.
# This is a static shared secret (from Key Vault), compared in constant
# time — not a signature, so there's nothing to compute, just compare.
# -------------------------------------------------------------------------
if (-not $expectedToken) {
Write-Error 'CiaEtlSecret app setting is not configured.'
Push-OutputBinding -Name Response -Value (New-JsonResponse -StatusCode 500 -Body @{ error = 'Server misconfigured' })
return
}
$authHeader = $Request.Headers['Authorization']
if (-not $authHeader -or $authHeader -notmatch '^Bearer\s+(.+)$') {
Push-OutputBinding -Name Response -Value (New-JsonResponse -StatusCode 401 -Body @{ error = 'Missing or malformed Authorization header' })
return
}
$providedToken = $Matches[1].Trim()
$providedBytes = [System.Text.Encoding]::UTF8.GetBytes($providedToken)
$expectedBytes = [System.Text.Encoding]::UTF8.GetBytes($expectedToken)
$isValid = ($providedBytes.Length -eq $expectedBytes.Length) -and
[System.Security.Cryptography.CryptographicOperations]::FixedTimeEquals($providedBytes, $expectedBytes)
if (-not $isValid) {
Push-OutputBinding -Name Response -Value (New-JsonResponse -StatusCode 401 -Body @{ error = 'Invalid token' })
return
}
$rawBody = [string]$Request.Body
# -------------------------------------------------------------------------
# 2. Parse the XML generically — walks whatever the repeating element is
# named, so it doesn't break if the wrapper/record element names differ
# from what we assumed.
# -------------------------------------------------------------------------
try {
[xml]$xmlDoc = $rawBody
}
catch {
Push-OutputBinding -Name Response -Value (New-JsonResponse -StatusCode 400 -Body @{ error = "Body is not valid XML: $($_.Exception.Message)" })
return
}
$recordNodes = $xmlDoc.DocumentElement.ChildNodes | Where-Object { $_.NodeType -eq 'Element' }
$records = foreach ($node in $recordNodes) {
$entity = [ordered]@{}
foreach ($child in $node.ChildNodes) {
if ($child.NodeType -eq 'Element') {
$entity[$child.Name] = $child.InnerText
}
}
[pscustomobject]$entity
}
if (-not $records) {
Push-OutputBinding -Name Response -Value (New-JsonResponse -StatusCode 400 -Body @{ error = 'No records found in payload' })
return
}
# -------------------------------------------------------------------------
# 3. Get a Table Storage data-plane token via the Function App's managed
# identity (Az context was already established in profile.ps1)
# -------------------------------------------------------------------------
$tokenResult = Get-AzAccessToken -ResourceUrl 'https://storage.azure.com'
$accessToken = $tokenResult.Token
if ($accessToken -is [System.Security.SecureString]) {
$accessToken = [System.Net.NetworkCredential]::new('', $accessToken).Password
}
$tableEndpoint = "https://$storageAccountName.table.core.windows.net"
$apiVersion = '2020-12-06'
$headers = @{
Authorization = "Bearer $accessToken"
Accept = 'application/json;odata=nometadata'
'x-ms-version' = $apiVersion
}
# -------------------------------------------------------------------------
# 3b. Load the organisationUnit -> GUID lookup table ONCE per invocation
# (not once per record). Stored as its own table:
# PartitionKey = 'Mapping' (fixed — keeps it a single-partition query)
# RowKey = 3-char prefix, e.g. 'GRO'
# MappedGuid = the GUID that prefix resolves to
# -------------------------------------------------------------------------
$ouLookup = @{}
try {
$filterExpr = [Uri]::EscapeDataString("PartitionKey eq 'Mapping'")
$selectExpr = [Uri]::EscapeDataString('RowKey,MappedGuid')
$mappingUri = "$tableEndpoint/$ouMappingTableName()?`$filter=$filterExpr&`$select=$selectExpr"
$mappingResult = Invoke-RestMethod -Method Get -Uri $mappingUri -Headers $headers -ErrorAction Stop
foreach ($row in $mappingResult.value) {
$ouLookup[$row.RowKey.ToUpperInvariant()] = $row.MappedGuid
}
}
catch {
Write-Error "Failed to load organisationUnit mapping table '$ouMappingTableName': $($_.Exception.Message)"
Push-OutputBinding -Name Response -Value (New-JsonResponse -StatusCode 502 -Body @{ error = 'Could not load organisationUnit mapping table' })
return
}
# -------------------------------------------------------------------------
# 4. Upsert each record — PartitionKey = paylocation, RowKey = employeeid.
# whenmodified is checked first so we skip writing rows that haven't
# actually changed since the last sync.
# -------------------------------------------------------------------------
$upserted = 0
$skipped = 0
$failed = 0
foreach ($record in $records) {
$partitionKey = $record.paylocation
$rowKey = $record.employeeid
if (-not $partitionKey -or -not $rowKey) {
Write-Warning "Skipping record missing paylocation/employeeid: $($record | ConvertTo-Json -Compress)"
$failed++
continue
}
$encodedPK = [Uri]::EscapeDataString($partitionKey)
$encodedRK = [Uri]::EscapeDataString($rowKey)
$entityUri = "$tableEndpoint/$tableName(PartitionKey='$encodedPK',RowKey='$encodedRK')"
# Check whether the stored row is already current
$existingModified = $null
try {
$existing = Invoke-RestMethod -Method Get -Uri "$entityUri`?`$select=whenmodified" -Headers $headers -ErrorAction Stop
$existingModified = $existing.whenmodified
}
catch {
$statusCode = $_.Exception.Response.StatusCode.value__
if ($statusCode -ne 404) {
Write-Warning "Lookup failed for RowKey '$rowKey': $($_.Exception.Message)"
}
}
if ($existingModified -and $record.whenmodified) {
try {
if ([datetime]$existingModified -ge [datetime]$record.whenmodified) {
$skipped++
continue
}
}
catch {
# Unparsable date on either side — fall through and overwrite rather than silently drop the update
}
}
# Map organisationUnit's first 3 characters to a fixed GUID via the lookup
# table loaded above (e.g. GROVES / GROVES OSHC / GROVE / GROCC all -> 'GRO'
# -> the same guid). Unmatched prefixes are logged and left unmapped rather
# than written as blank/null.
if ($record.organisationunit -and $record.organisationunit.Length -ge 3) {
$ouPrefix = $record.organisationunit.Substring(0, 3).ToUpperInvariant()
if ($ouLookup.ContainsKey($ouPrefix)) {
$record | Add-Member -NotePropertyName 'organisationUnitId' -NotePropertyValue $ouLookup[$ouPrefix] -Force
}
else {
Write-Warning "No organisationUnit mapping for prefix '$ouPrefix' (RowKey '$rowKey', value '$($record.organisationunit)')"
}
}
# Table Storage is schemaless per-entity, so any property present here —
# including new fields that show up in the XML in the future, or the
# organisationUnitId we just added — becomes a column automatically.
# No code change needed when the upstream data adds a new field.
$entityHash = [ordered]@{
PartitionKey = $partitionKey
RowKey = $rowKey
}
foreach ($prop in $record.PSObject.Properties) {
if ($prop.Name -notin @('PartitionKey', 'RowKey')) {
$entityHash[$prop.Name] = $prop.Value
}
}
$entityBody = $entityHash | ConvertTo-Json -Compress
try {
# PUT = insert-or-replace (upsert); no If-Match header needed
Invoke-RestMethod -Method Put -Uri $entityUri -Headers ($headers + @{ 'Content-Type' = 'application/json' }) -Body $entityBody -ErrorAction Stop | Out-Null
$upserted++
}
catch {
Write-Error "Upsert failed for RowKey '$rowKey': $($_.Exception.Message)"
$failed++
}
}
$summary = @{
totalRecords = $records.Count
upserted = $upserted
skipped = $skipped
failed = $failed
}
Push-OutputBinding -Name Response -Value (New-JsonResponse -StatusCode 200 -Body $summary)