Employee Sync Function — complete setup guide
This walks through everything, start to finish: getting the code into the (currently empty) Function App, wiring up the trigger, identity, secrets, and the two Table Storage tables, and testing the whole path before pointing the real ETL step at it.
Starting point: a resource group already containing a Storage Account, Key Vault
(kv-cia-employee-sync), an empty Function App (Flex Consumption, Linux), Application
Insights, a Smart Detector alert rule, and the App Service Plan.
1. Confirm the runtime version
Function App → Configuration → check FUNCTIONS_WORKER_RUNTIME_VERSION (or the runtime
version shown on the Overview blade). Flex Consumption's generally-available PowerShell
version is 7.4. This code has nothing 7.6-specific in it, but confirm what's actually
configured so there are no surprises later.
2. Get the code into the Function App
The project has no module dependencies (see the "Why no Az module" note at the bottom), so deployment is just the five files as-is — no build step, no vendoring.
Recommended: Azure Functions Core Tools, since it detects the right deployment mechanism for Flex Consumption automatically rather than you having to pick the right CLI command yourself:
# from the EmployeeSyncFunction folder, containing host.json etc.
func azure functionapp publish <your-function-app-name>
Make sure Core Tools is a recent version (func --version — v4.x) so it understands Flex
Consumption's deployment model.
Alternative: Azure CLI zip deploy. Microsoft's own documentation is inconsistent about
which command Flex Consumption expects — some current docs show config-zip, others say
Flex requires the newer unified deploy command. If one fails, try the other:
Compress-Archive -Path * -DestinationPath ../EmployeeSyncFunction.zip -Force
# Try this first
az functionapp deploy --resource-group <rg-name> --name <your-function-app-name> `
--src-path ../EmployeeSyncFunction.zip --type zip
# If that errors, fall back to
az functionapp deployment source config-zip --resource-group <rg-name> `
--name <your-function-app-name> --src ../EmployeeSyncFunction.zip
3. Confirm the trigger registered
Function App → Functions blade should now list HttpTrigger. A quick reachability check
(expect a 401, not a 404 — that confirms routing works even before auth is configured):
Invoke-RestMethod -Method Post -Uri "https://<your-function-app-name>.azurewebsites.net/api/employees/sync"
4. Enable a system-assigned managed identity
Function App → Identity → System assigned → On → Save. This identity is what lets the function read the Key Vault secret and read/write Table Storage — no keys or connection strings anywhere in this design.
5. Grant that identity two roles
First check kv-cia-employee-sync → Access configuration, to see whether it uses Azure
RBAC or Vault access policy — the two grant permissions differently.
6. Add the app settings
Function App → Configuration → New application setting, one at a time:
After saving, confirm CiaEtlSecret shows a resolved (green) Key Vault reference status
on the Configuration blade — a red/warning icon there means step 5's role grant hasn't
propagated yet, or the vault/secret name is wrong.
Note on rotation: the platform caches this resolved value and refreshes it periodically (roughly daily, or on restart/scale) — not on every request. If you rotate the secret in Key Vault and need the Function to pick it up immediately, restart the Function App.
7. Create the two tables
Same storage account, two tables (Storage Explorer, portal, or New-AzStorageTable):
-
Employees— no need to pre-define columns; each upsert just writes whatever properties are present on that record.PartitionKey=paylocation,RowKey=employeeid. Every write also adds/preservesSyncCreatedUtc(set once, on first insert, and carried forward unchanged on every later update). There's no separate "last updated" field to maintain — Table Storage automatically stamps every entity with a built-inTimestampproperty on every write, so that's already available for free. Query it like any other property, e.g. to find everything touched since a given point:$filter=Timestamp gt datetime'2026-07-29T00:00:00Z'. -
OrganisationUnitMappings— seed with one row per 3-character prefix:PartitionKeyis always the literal stringMapping(keeps the function's lookup a cheap single-partition query);RowKeyis the uppercase 3-char prefix;MappedGuidis what it resolves to. Anyorganisationunitvalue whose prefix isn't in this table gets logged as a warning and left unmapped, rather than written with a blank id.
8. Test end to end
$uri = 'https://<your-function-app-name>.azurewebsites.net/api/employees/sync'
$secret = 'the-actual-secret-value' # from Key Vault, local testing only
$body = Get-Content ./sample-payload.xml -Raw
# Expect 200 with a JSON summary: totalRecords / upserted / skipped / failed
Invoke-RestMethod -Method Post -Uri $uri -Headers @{ Authorization = "Bearer $secret" } `
-ContentType 'application/xml' -Body $body
# Expect a 401 — confirms bad tokens are actually rejected, not just accepted
try {
Invoke-RestMethod -Method Post -Uri $uri -Headers @{ Authorization = 'Bearer wrong-secret' } `
-ContentType 'application/xml' -Body $body
} catch {
$_.Exception.Response.StatusCode
$_.ErrorDetails.Message
}
Check the Employees table afterwards to confirm rows landed with the expected
PartitionKey/RowKey/organisationUnitId.
9. Point the real ETL step at it
-
Method: POST
-
URL:
https://<your-function-app-name>.azurewebsites.net/api/employees/sync -
Headers:
Authorization: Bearer <ciaETLsecret value>,Content-Type: application/xml(ortext/xml) -
Body: the XML array, unmodified
No function key needed — the bearer token check is the actual gate. (Consider also
setting authLevel + a function key as defense-in-depth if you want belt-and-suspenders,
though it's not required for this to work.)
Why no Az module
Earlier versions of this used Az.Accounts (Connect-AzAccount -Identity /
Get-AzAccessToken) to get a Table Storage token via managed identity. That's now a
direct Invoke-RestMethod call to the Functions platform's local token endpoint
($env:IDENTITY_ENDPOINT / $env:IDENTITY_HEADER, both set automatically once step 4 is
done) — same result, but with nothing to vendor and nothing pinned to a module version.
requirements.psd1 is intentionally empty (@{}).