0069 — MFA-exempt the pinned smoke/test account
- Status: Accepted
- Date: 2026-06-24
- Deciders: Fede
Context
Site-wide MFA enforcement (ADR-era #1403, May 2026) requires every authenticated
session to clear two gates in src/middleware.ts: an enrollment gate (a method
must be registered, else → /setup/mfa) and a per-session challenge gate
(mfaSatisfied, else → /login/mfa).
The shared automation identity smoke@propflowai.co — used by the /browse
skill and e2e/auth.setup.ts — has no inbox/SMS a script can read, so its only
satisfiable second factor was TOTP via a shared secret stored in four places
(DynamoDB twoFactor row, Vercel env SMOKE_TOTP_SECRET, GitHub Actions secret,
local Keychain/file). That secret drifted repeatedly through May–June 2026:
any flow that re-enrolled or reset smoke@'s 2FA (the MFA e2e suite, a manual
/setup/mfa, scripts/enroll-smoke-totp.ts, /api/test/reset-mfa) rewrote the
DynamoDB row with a fresh secret while the four stored copies kept the old one,
breaking /browse with 401 INVALID_CODE. A string of band-aids followed
(enroll script, double-encryption fix, repair-smoke-totp.ts, moving nightly to
smoke-nightly@, moving the MFA e2e suite to mfa-e2e@). Each treated the
symptom; none removed the driftable secret itself. As of this writing
smoke@ has zero twoFactor rows — it had been wiped again and was being
bounced to /setup/mfa.
Decision
Exempt one hard-pinned account from the middleware MFA gates instead of trying to keep a shared TOTP secret in sync.
In the authoritative session-resolve block of src/middleware.ts, when
process.env.SMOKE_MFA_EXEMPT_USER_ID is set and the resolved
direct.userId is strictly equal to it, force mfaEnabled = true and
mfaSatisfied = true. That clears both gates so the account is never routed to
/setup/mfa or /login/mfa, regardless of its actual 2FA enrollment state — so
there is no second factor to keep in sync and nothing to drift.
The guard is:
- String equality only against a single env-pinned
userId— never a list, prefix, regex, role, or email pattern. - Inert unless the env var is set.
SMOKE_MFA_EXEMPT_USER_IDis set only in PropFlow's own Vercel project (production + preview). It must never be set in a customer-facing / customer-tenant environment. This is recorded in the infra runbook alongside the env var.
smoke@propflowai.co is a read-only admin on the demo tenant with no customer
data; the worst case if the env var ever leaked or were mis-set is access to
that one demo account — the same blast radius as a leaked TOTP secret, minus the
recurring breakage.
This ADR proposes no new export interface, so the entity-classification table
is omitted.
Consequences
/browseande2e/auth.setup.tssign in with first factor only — noverify-totp, nomark-satisfied. The skill and setup scripts drop their TOTP steps.SMOKE_TOTP_SECRET(thesmoke@secret) becomes dead and is removed from Vercel env, GitHub Actions secrets, Keychain, and the local file.scripts/enroll-smoke-totp.tsnow refusessmoke@(guarded;FORCE=1to override) so the driftable secret can't be silently re-introduced. The dedicated nightly identitysmoke-nightly@propflowai.costill uses TOTP legitimately and is allowed through (SMOKE_EMAIL=smoke-nightly@…).- The MFA enforcement posture for real users is unchanged — the exemption is
scoped to exactly one pinned
userIdand only when the env var is present. - New coupling: the env var must be kept set in PropFlow's Vercel project and kept unset everywhere customer-facing. Documented in the runbook + the middleware comment.
Alternatives considered
- Idempotent enroll guard / keep TOTP, stop the clobbers. Still leaves a live secret that can drift if any new enroll path appears or a guard is bypassed; still needs repair tooling. Rejected — doesn't remove the secret.
- Signed test-bypass header. Adds a new high-value signing key that bypasses auth for the account; more attack surface than a single pinned userId with no advantage. Rejected.
- Disable 2FA in the DynamoDB row only. Necessary-but-insufficient: with
2FA disabled the account is
mfaEnabled=falseand the enrollment gate then forces it to/setup/mfa. The middleware exemption is what actually lets it through, and it works regardless of the DB row, so we don't mutate the row.