INFRA-0010: Cross-Root State, App-Setting Ownership, and the RBAC Module
Status
Accepted — 2026-06-22
Date
2026-06-22
Context
The rebuilt estate is split into many OpenTofu roots (control-plane-core,
control-plane-dns, control-plane-dns-zones, workload-network, workload-core,
workload-env, and the vms/* roots), each with its own state. Roots communicate via
terraform_remote_state. Three recurring failure modes during the rebuild all trace back to
which root owns a given value and how stored state propagates between roots. This
decision records the rules that resolve them.
Decision
1. Each app setting has exactly one owning root
The tier-level web app and function app are created by workload-core (via modules/web-app
/ modules/function-app); the per-env slots and their per-env settings are created by
workload-env. When both roots write the same app_settings key, they fight on every
apply — each apply reverts the other.
Rule: a per-env value is owned by workload-env; workload-core must not write it at all.
Concretely, for keys whose value is per-env (e.g. SecureSettingsKeyVault = the per-env Key
Vault name):
modules/web-appemits the key only when its input is non-null (workload-corepasseskey_vault_name = null→ the key is omitted from core’s output entirely — it does not write an empty string).- The key is in the root app’s
lifecycle.ignore_changes, so even a futureworkload-coreapply can never blankworkload-env’s value. workload-envis the sole writer, on the slot, with the real per-env KV name.
This is the general pattern for any shared key. Tier-invariant keys (App Insights extension version, time zone) may live in core; anything that varies per environment belongs to env.
2. terraform_remote_state reads stored state, not code
A consumer root reads the last-applied outputs of the producer root, not the producer’s current source. Therefore:
Adding or changing an output in a producer root has no effect on consumers until the producer is re-applied (which rewrites its state blob). A consumer planned against the old state sees the old/absent value.
Two concrete bites from the rebuild:
- The SSRS App Gateway backend (
workload-core) is gated on report-server’sprivate_ip_address. It registered only after report-server’s state had the IP and workload-core was re-applied to re-read it. - The jobs-MI Key Vault grant (
workload-env) is gated on job-scheduler’sprincipal_idoutput. The grant silently skipped because job-scheduler’s state hadoutputs: [](written before that output existed in code). Fix: re-apply job-scheduler (0 resource changes — it just writes outputs to state), then re-apply workload-env.
Operational rule: after adding an output to a root, re-apply that root before expecting any downstream consumer to see it.
3. RBAC is a shared module fed a per-root grant list
modules/rbac takes grants = list({ key, scope, role, principal_id }), for_each’s
azurerm_role_assignment, and filters out null-principal entries. This replaces the old
count = principal == null ? 0 : 1 gates with a single, uniform pattern.
- Instantiated once per root — state is per-root, so a single estate-wide grant map is not possible.
- A null
principal_id(e.g. a remote-state principal not yet populated — see rule 2) means that grant is simply skipped this apply. This is the graceful-bootstrap behaviour: applies don’t fail when a dependency isn’t ready, but the grant will not exist until the principal is non-null and the root is re-applied. It is silent — no error prompts the re-apply. - Migration onto the module used
moved {}blocks so the ~38 live grants moved in state with 0 destroy (verified — recreating a grant would briefly revoke access on a running estate).
Some grants stay inline (they don’t fit a flat list): conditional grants gated on a feature
flag (deploy_app_gateway), self-referential grants, slot fan-out grants (per-slot KV/storage,
driven by the app identity not the deploy identity), and module-internal grants.
Known gaps and follow-ups (from the 2026-06-22 estate-wide scan)
A four-way scan (remote-state wiring, legacy references, count-gate skips, app-setting/hostname alignment) found the nonprod rebuild clean on the active path. Items still open:
| Finding | Status | Action |
|---|---|---|
| Two RBAC gaps: ops MI Website Contributor on the web app; jobs MI KV Secrets User per-env | Closed | Both grants added; ops grant verified live |
scalr/main.tf hardcodes the legacy saesharstfstate for every workspace backend | Closed (2026-07) | Repointed: the scalr root now maps each tier to its own account (sattfnonprodcus / sattfprodcus), and its own state lives in sattfprodcus |
vms/tmhp-sftp/environments/prod.tfvars references legacy rg-eshars-prod / db-eshars-prod / kv-eshars-tmhp | Deferred (prod round) | Correct for now — legacy prod SQL/KV are still live; tmhp-sftp prod not yet applied |
vms/tmhp-sftp/main.tf unguarded private_dns_zone_ids[...] map subscript | Open (low) | Wrap in try(); bundle with prod-round tmhp work |
| Prod-tier App Gateway / report ingress not deployed | Deferred (prod round) | deploy_app_gateway = true + appgw_backends/ssrs_host_name for prod |
| Legacy VNet peerings, custom-DB legacy DNS link | By design | Migration bridges; remove post-cutover |
github_sp_* OIDC grants | By design — now permanent | Superseded by the foundation/workload identity split (eshars-ops #79): the app CD auths via the ops VM MI, but the infrastructure workflows deliberately auth as two OIDC SPs (github-eshars-platform, github-eshars-deploy) — the grants stay. See Stand-up Workflows |
Consequences
- Plans that show “0 changes” when you expected a new grant/backend are usually a stale-state problem (rule 2), not a code bug. Re-apply the producer first.
- A grant silently missing at runtime (a 403 from an app/MI) is usually a null-principal skip (rule 3). Check the producer’s state has the principal output, then re-apply.
- App-setting churn on every apply means a shared-ownership key (rule 1) escaped the omit-or-ignore_changes pattern.
Related
- INFRA-0003 — slot Key Vault + App Gateway pattern
- INFRA-0004 — dual app-setting keys for MvcReportViewer
- Report Server — consumes the cross-root + AG-backend pattern