Skip to content

envsync — KV-backed .env.local hydration (kill Keeper files)

Date: 2026-07-02 · Status: approved (Mark) · Owner repos: altaforge-libraries (tool), client-verticalscope-{dealdesk,contentgen,moderation} (manifests + docs)

Problem

The 3-file env model (.env committed defaults → .env.local gitignored secrets/overrides → OS env) is clean, but secret distribution still happens by passing Keeper export files between developers. Drift, no audit, no rotation story, and onboarding is "ask someone for the file."

Decision summary

Decision Choice
Source of truth Azure Key Vault. Keeper files die.
Which vault The per-org stg runtime KV (vsstguseast2kvvs01) — dev = stg for this org. No new vault. Runtime/deploy secrets and Terraform untouched.
Dev access Read-only RBAC (Key Vault Secrets User) for devs; existing KV firewall (VPN) gates network access.
Tool home altaforge.envsync in altaforge-libraries, next to altaforge.core.sanity.load_env / env_check. CLI: python -m altaforge.envsync, wrapped as make env per repo.
Secret mapping Committed env.secrets.toml per repo — the single reviewable contract. Replaces .env.example (deleted).
.env.local handling Tool owns a fenced managed block; personal overrides outside the fence are preserved byte-for-byte. Idempotent.
Seeding One-time envsync --seed pushes manifest-listed values from a known-good .env.local to KV. Run once per repo by an admin.

Manifest format — env.secrets.toml

# The secrets contract for this app. Non-secret defaults live in .env (committed).
# `make env` hydrates these from Key Vault into the managed block of .env.local.
[vault]
name = "vsstguseast2kvvs01"

[secrets]
ALTAFORGE_LLM_API_KEY = { kv = "af-llm-api-key",  doc = "shared AF-LLM gateway key" }
ARIZE_API_KEY         = { kv = "arize-api-key",   doc = "Arize ingest key (org Vertical Scope / space dev)" }
ARIZE_DEVELOPER_KEY   = { kv = "shared--arize-developer-key", doc = "Arize GraphQL/API key" }
# File-secret: value is written to `path` (gitignored), env var set to the path.
GOOGLE_APPLICATION_CREDENTIALS = { kv = "dd--bq-sa-key", type = "file", path = "secrets/bq-sa.json", doc = "BigQuery SA key JSON" }
# Optional secret: absent in KV -> warn, don't fail.
APIFY_API_TOKEN       = { kv = "cg--apify-token", optional = true, doc = "fallback page fetch; unset = disabled" }
  • Env-var name is the key; kv is the Key Vault secret name (existing names kept as-is; new ones follow <app>--<kebab> / shared--<kebab>).
  • type = "file": write the secret value to path (relative to the manifest's directory), set the env var to that relative path inside the managed block. Parent dirs created; file mode not tightened on Windows (documented).
  • optional = true: missing KV secret → warning + skipped, not an error.
  • [vault].name is the default vault; --vault CLI flag overrides (future: point at uat).

Tool behavior — python -m altaforge.envsync

  1. Locate manifest: env.secrets.toml in CWD (or --manifest PATH).
  2. Auth via DefaultAzureCredential (rides az login). No secrets in tool config.
  3. Fetch every manifest secret from the vault.
  4. Rewrite ONLY the fenced block in .env.local (same dir as manifest):
# --- BEGIN kv-managed (altaforge.envsync) — do not edit inside this block ---
# hydrated 2026-07-02T22:00:00Z from vsstguseast2kvvs01
ARIZE_API_KEY=ak-...
GOOGLE_APPLICATION_CREDENTIALS=secrets/bq-sa.json
# --- END kv-managed ---
  • File missing → created with just the block (plus a one-line header comment pointing at docs).
  • Fence missing in an existing file → block appended.
  • Corrupt fence (BEGIN without END or vice versa) → hard error with instructions; never guess.
  • Content outside the fence: preserved byte-for-byte. Write is atomic (temp file + replace).
  • A personal override of a managed key outside the fence WINS if it comes later in the file (dotenv last-wins) — documented, not prevented; --check flags it as a shadow.

Modes

  • default — hydrate (fetch + rewrite block).
  • --check — no writes; report missing keys, stale values, shadowed managed keys. Exit non-zero on drift. (For env_check integration/CI parity.)
  • --seed — reverse direction: read values for manifest keys from the CURRENT .env.local (wherever they are in the file) and set-secret each into the vault. Prompts per secret (y/n), prints name-only (never values). Requires write RBAC — admins only, one-time migration. Refuses to overwrite an existing KV value unless --force.

Error UX (explicit, actionable)

Failure Message tells the dev
No credential az login first
KV firewall 403 (ForbiddenByFirewall) connect to the VPN
RBAC denied ask for Key Vault Secrets User on <vault>
Secret not found (non-optional) exact KV name missing — likely not seeded yet

Never logs a secret value. --verbose logs names + lengths only.

App-repo changes (dd, cg, mod)

  1. Add env.secrets.toml (dd: 7 entries incl. BQ file-secret; cg: ~12 incl. 6 WP app-passwords; mod: ~4).
  2. Add make env target → uv run python -m altaforge.envsync (from the backend/app dir where the manifest lives).
  3. Delete .env.example; move any non-secret docs it uniquely held into .env comments.
  4. Update README/CLAUDE.md onboarding: "cp .env.example" → "az login + VPN + make env".
  5. env_check guidance strings: "[SECRET → .env.local]" hints → "run make env".
  6. .gitignore: ensure secrets/ is ignored (dd already ignores it).

Seeding plan (one-time, per app)

Run envsync --seed against a known-good .env.local (Mark's). New KV names created: shared--arize-developer-key, dd--hubspot-token, dd--deckgen-llm-api-key, dd--bq-sa-key (file), cg--apify-token, cg--wp-<site>-app-password ×6, cg--anthropic-api-key (if kept), mod's XenForo/etc as manifested. Existing af-llm-api-key / arize-api-key reused as-is. Verify with make env --check on a second machine (or after moving .env.local aside), then announce: Keeper env files deprecated.

Testing

  • Unit (lib, fake KV client): manifest parse (all field combos, bad TOML, unknown fields), fence rewrite (no file / no fence / normal / corrupt fence / preserve-outside-bytes), file-secrets (path creation, relative path in var), optional-secret skip, --check drift + shadow detection, --seed name-only output + no-overwrite guard, atomic write.
  • Live smoke (manual, VPN): make env in dd against the real vault; boot the app on the hydrated .env.local.
  • Negative: each error-UX row exercised with the fake client raising the matching Azure exceptions.

Rollout order

lib module + tests → PR → seed KV (dd secrets) → dd manifest + make target + docs (pilot, verify boot) → cg → mod → delete .env.examples → onboarding docs → Keeper deprecation note.

Out of scope (YAGNI)

  • uat/prod-flavored hydration (--vault exists; no per-env manifests).
  • Rotation automation, expiry, Keeper API integration.
  • Windows ACL tightening on written secret files.
  • Non-VS orgs (pattern generalizes via manifest; adopt later).