Skip to content

feat: per-app MFA guard in the proxy (#1005 slice 4) - #1011

Merged
milkway merged 3 commits into
mainfrom
feat/1005-mfa-guard
Jul 18, 2026
Merged

feat: per-app MFA guard in the proxy (#1005 slice 4)#1011
milkway merged 3 commits into
mainfrom
feat/1005-mfa-guard

Conversation

@milkway

@milkway milkway commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

Slice 4 of 4 — the final slice: enforcement. Stacked on #1009 (slice 3) — merge order 1→2→3→4. Closes the epic.

What changed

  • The guard is step 2e in forward(): after access control (2d), BEFORE backend check / splash / sticky selection / pick-spawn — so a blocked request can never select, wake or spawn a replica.
Route State Result
/app/* anonymous 303 login (safe, encoded next, base-path aware)
/app/* unenrolled 303 enrollment
/app/* stale/absent proof 303 challenge
/app/* blocked WS handshake 401 (no redirect on upgrades)
/api/* anonymous 401, CORS-wrapped, no Location
/api/* MFA unsatisfied 403, CORS-wrapped, no Location
either valid grant normal proxy flow
either break-glass Admin bypass + warn! + audit mfa.break_glass_bypass (deduped per session/spec, 15 min)
  • Zero cost for specs without require-mfa (no DB reads).
  • Staged-rollout scaffolding retired: the temporary MfaNotYetEnforced warning (+ CLI formatter, tests), the form's staged note ×4 locales, and the docs' "not yet enforced" caveat are gone; YAML_SCHEMA/SECURITY/admin guide describe the real semantics.

Review hardening (3 findings across rounds, all fixed → r3 clean)

  1. P1 require-mfa on an External spec did nothing (guard never runs on a link) → validation warning MfaOnExternalSpec.
  2. P2 break-glass audit dedup reserved the key before the write; a transient failure suppressed the audit for 15 min → reservation rolled back on failure.
  3. P2 (from slice-3 review, fixed in slice 3) enrollment didn't establish the initial proof → redundant challenge; enrollment now mints the first grant.

Validation

  • Full gate green + postgres-it green.
  • tests/mfa_guard.rs: 8 scenarios incl. the no-spawn guarantee (mock backend panics on any spawn), 0-day session binding through the guard, break-glass audit dedup, API-vs-app behavior.
  • Live smoke end-to-end: visit protected /app → redirected to enrollment → QR → challenge → app opens (container born only after the grant, confirmed by timestamp); protected /api → 401/403; break-glass → 200 + exactly one audit row; a blocked request never spawned a container.

🤖 Generated with Claude Code

@milkway
milkway force-pushed the feat/1005-mfa-guard branch 2 times, most recently from e459b65 to ebd7cad Compare July 18, 2026 03:49
@milkway
milkway marked this pull request as ready for review July 18, 2026 03:50
@milkway
milkway force-pushed the feat/1005-mfa-challenge branch from c1def60 to 1b0cfca Compare July 18, 2026 03:51
@milkway
milkway changed the base branch from feat/1005-mfa-challenge to main July 18, 2026 03:52
milkway and others added 3 commits July 18, 2026 00:52
The enforcement slice — the epic's last. The guard is step 2e in
forward(): after access control (2d), BEFORE backend check, splash,
sticky selection and pick/spawn — a blocked request can never select,
wake or spawn a replica.

- /app/*: anonymous → 303 login (safe, encoded next, base-path aware);
  unenrolled → 303 enrollment; stale/absent proof → 303 challenge;
  blocked WS handshake → 401 (no redirect on upgrades).
- /api/*: fail closed and explicit — 401 (no session) / 403 (MFA
  unsatisfied), CORS-wrapped, never a Location header.
- Break-glass (token session, no actor): bypasses so an operator can't
  be locked out — but LOUD: warn! on every bypass + audit
  mfa.break_glass_bypass deduplicated per (session, spec) for 15 min.
- Zero cost for specs without require-mfa (no DB reads).
- Staged-rollout scaffolding retired: MfaNotYetEnforced warning (+CLI
  formatter, tests), the form's staged note ×4 locales, and the docs'
  "not yet enforced" caveat are gone; YAML_SCHEMA/SECURITY/admin guide
  describe the real semantics.
- tests/mfa_guard.rs: 8 scenarios incl. the no-spawn guarantee (mock
  backend flags any spawn attempt), 0-day session binding through the
  guard, break-glass audit dedup, and API-vs-app behavior.

Implemented by a Codex (gpt-5.6, high) agent; full gate green in its
sandbox; verification (rebase onto the fixed slice 3, cold-build gate,
postgres-it, live end-to-end smoke, independent /code-review) follows
in the next commits.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…#1005 slice 4 review)

Two codex-review findings:

- P1: require-mfa on an External-link spec did nothing — External
  specs are plain links Ruscker never proxies, so the guard (which
  lives in forward()) is never reached, yet the schema/form accepted
  the flag with no signal. An operator could believe the linked app is
  protected while anonymous users reach it freely. New validation
  warning MfaOnExternalSpec (CLI formatter + test + YAML_SCHEMA note);
  the guard genuinely can't run there, so flagging config is the
  correct fix.
- P2: the break-glass audit dedup reserved the (session, spec) key
  BEFORE the DB write; a transient write failure left the key
  timestamped, suppressing the audit for the whole 15-min cooldown —
  a persistent gap even after the DB recovered. The reservation is now
  rolled back (removed) on write failure and on the no-DB path, so the
  next bypass retries. Over-auditing is safe; a gap is not.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Slice 3's UPSERT redesign dropped issue()'s replace_grant_id argument;
the guard test's grant helper is updated to match.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@milkway
milkway force-pushed the feat/1005-mfa-guard branch from ebd7cad to 5ad85f6 Compare July 18, 2026 03:52
@milkway
milkway merged commit 369905a into main Jul 18, 2026
5 checks passed
@milkway
milkway deleted the feat/1005-mfa-guard branch July 18, 2026 03:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant