# 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:

```powershell
# 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:

```powershell
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):

```powershell
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.

| Resource | If RBAC | If Access Policy |
|---|---|---|
| `kv-cia-employee-sync` | Access control (IAM) → Add role assignment → **Key Vault Secrets User** → the Function App's identity | Access policies → Add access policy → Secret permissions: **Get** → the Function App's identity |
| Storage Account | Access control (IAM) → Add role assignment → **Storage Table Data Contributor** → the Function App's identity | *(Storage accounts only use RBAC)* |

## 6. Add the app settings

Function App → Configuration → New application setting, one at a time:

| Name | Value |
|---|---|
| `CiaEtlSecret` | `@Microsoft.KeyVault(VaultName=kv-cia-employee-sync;SecretName=ciaETLsecret)` |
| `TableStorageAccountName` | your storage account's name |
| `EmployeeTableName` | `Employees` |
| `OuMappingTableName` | `OrganisationUnitMappings` (optional — this is the default if unset) |

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/preserves `SyncCreatedUtc` (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-in `Timestamp` property 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:

  | PartitionKey | RowKey | MappedGuid |
  |---|---|---|
  | `Mapping` | `GRO` | `<guid>` |

  `PartitionKey` is always the literal string `Mapping` (keeps the function's lookup a
  cheap single-partition query); `RowKey` is the uppercase 3-char prefix; `MappedGuid` is
  what it resolves to. Any `organisationunit` value 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

```powershell
$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`
  (or `text/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 (`@{}`).
