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;
kvis the Key Vault secret name (existing names kept as-is; new ones follow<app>--<kebab>/shared--<kebab>). type = "file": write the secret value topath(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].nameis the default vault;--vaultCLI flag overrides (future: point at uat).
Tool behavior — python -m altaforge.envsync¶
- Locate manifest:
env.secrets.tomlin CWD (or--manifest PATH). - Auth via
DefaultAzureCredential(ridesaz login). No secrets in tool config. - Fetch every manifest secret from the vault.
- 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;
--checkflags 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. (Forenv_checkintegration/CI parity.)--seed— reverse direction: read values for manifest keys from the CURRENT.env.local(wherever they are in the file) andset-secreteach 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)¶
- Add
env.secrets.toml(dd: 7 entries incl. BQ file-secret; cg: ~12 incl. 6 WP app-passwords; mod: ~4). - Add
make envtarget →uv run python -m altaforge.envsync(from the backend/app dir where the manifest lives). - Delete
.env.example; move any non-secret docs it uniquely held into.envcomments. - Update README/CLAUDE.md onboarding: "cp .env.example" → "
az login+ VPN +make env". env_checkguidance strings: "[SECRET → .env.local]" hints → "runmake env"..gitignore: ensuresecrets/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,
--checkdrift + shadow detection,--seedname-only output + no-overwrite guard, atomic write. - Live smoke (manual, VPN):
make envin 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 (
--vaultexists; 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).