ADR-0018: Dev tier on Email OTP + per-email allow-list + 48h session¶
- Status: Accepted — implemented 2026-06-07 (CF Access dev-tier auth refactored: new Email OTP IdP + new MGH Org Dev group with 2-email allow-list + 5 dev policies repointed;
cloudflare-accessmodule live resource count 18 → 20; orphan policy cleanup pending in CF dashboard) - Date: 2026-06-07
- Deciders: cloud-architect, secops-agent, cloudflare-expert, marnissi.investments
- Tightens: ADR-0017 (changes the dev-tier IdP from Google Workspace OIDC to Email OTP and changes the dev-tier group from "any Workspace email" to a hand-picked 2-email allow-list; ADR-0017's "every
dev.*hostname behind CF Access" thesis stands)
Context¶
ADR-0017 (2026-06-06) gated every dev.* hostname behind CF Access using the same Google Workspace OIDC IdP and the same MGH Org group as the prod backends — i.e., any @marnissi-holdings.com Workspace user could log in to dev. With dev.quantmods.com going live as the first dev-tier surface, the operator surfaced two distinct tightenings:
-
Explicit per-email allow-list, not Workspace-domain match. The Workspace tenant will hold service accounts, contractor invites, and brand-shared mailboxes over time. Letting any of those reach dev is a leak waiting to happen. The dev surface is operator-only by intent — there are two named people. Codify that.
-
Email OTP verification on every fresh login, 48h session. Each login should require a fresh "proof of inbox access" — a 6-digit code sent to the allowlisted email. This is materially stronger than "Google session is good for a week" because it requires active demonstration that the operator still controls the inbox bound to the allow-list, on each new session. 48h is the operator's stated trade between login friction (dev iteration is bursty — multiple sessions per day during a feature push) and time-to-expire on a compromised session.
Prod backends are a different operational shape — multiple operators, lower-frequency access, and an established pattern (ADR-0008) on Google + 24h. Tightening prod is out of scope for this ADR.
Decision¶
Split the dev-tier CF Access auth posture from prod.
Dev tier (every dev.* hostname across both CF zones):
- IdP: Email One-Time PIN (
cloudflare_zero_trust_access_identity_provider.one_time_pin, typeonetimepin). CF built-in. Noconfigblock needed beyond the empty placeholder the v5 provider requires. - Group:
MGH Org Dev—emailinclude rule with one entry per allowlisted address: skander.marnissi@marnissi-holdings.comoussama.marnissi@marnissi-holdings.com- Future additions: append to
var.dev_allowed_emailsinmodules/cloudflare-access/variables.tfand re-apply. The default list lives in the variable definition. - Session duration: 48h (
var.dev_session_duration). - Allowed IdPs on each dev app: the OTP IdP only. Auto-redirect-to-identity stays
true; with a single allowed IdP, CF skips the IdP picker and routes the user straight to the OTP page.
Prod tier (admin/api/labs on the prod hostname):
- Unchanged: Google Workspace OIDC IdP +
MGH Orggroup (Workspace-domain match) + 24h session. ADR-0008's posture continues to bind.
Module structure. The local.protected_apps map splits into local.prod_apps (3 keys) and local.dev_apps (5 keys); local.protected_apps = merge(local.prod_apps, local.dev_apps) preserves the shared cloudflare_zero_trust_access_application.apps for_each. Inside the app resource and per-tier policy resources, contains(keys(local.dev_apps), each.key) routes each iteration to the right policy + IdP. Two new variables — dev_session_duration (default "48h") and dev_allowed_emails (default = the 2 emails) — parameterize the dev tier.
Consequences¶
-
Cost (delta vs free tier): $0. Email OTP is a free CF Access primitive; CF doesn't meter OTP sends. The new IdP, group, and policies are all within the free-tier ceiling. No SaaS-side change (OTP doesn't go through Google).
-
Operational surface:
cloudflare-accessmodule live resource count moves from 18 (per ADR-0017) to 20: +1 OTP IdP, +1 MGH Org Dev group, +5allow_mgh_org_devpolicies, with the 5 staleallow_mgh_org[*_dev]policies removed from Tofu state.- 5 orphan policies remain in CF (no app references them; harmless but visible). Operator deletes them via Zero Trust → Access → Policies. Policy IDs captured in
infra/PR #20 body. - Dev login flow: visit dev hostname → 302 to
marnissi-holdings.cloudflareaccess.com/cdn-cgi/access/login/<hostname>→ CF asks for email → user supplies an allowlisted email → CF mails 6-digit code → user enters code → 48h session cookie. Session re-prompts every 48h. Browser console will not see the Worker-served page until the cookie is present. -
Email deliverability is now load-bearing for dev access. CF sends from a CF-owned domain (
@noreply.cloudflareaccess.com); workspace inbound filtering must not quarantine it.secops-agentadds a monthly "OTP delivery still works" check to the runbook cadence. -
Security posture:
- Allow-list maintenance becomes the single high-leverage knob. Adding or removing a person is a one-line edit to
var.dev_allowed_emails+tofu apply. No CF dashboard surgery required. - OTP requires control of the named inbox at each fresh login. A stolen Workspace session does not grant dev access on its own — the attacker would also need access to the same email account, which is a separate breach surface. This is the practical realisation of the "MFA email + validation code" model the operator requested.
- The 48h window is longer than the prod 24h. The trade is intentional: dev iteration is bursty (multi-session-per-day during a feature push), and the dev surface is non-customer-facing. If a future incident motivates tighter dev session control, drop
var.dev_session_durationto e.g."12h"and apply — no architectural change required. -
Prod backends' posture remains unchanged; this ADR does not weaken any prod control.
-
Migration path if we revisit:
- To revert to ADR-0017's Workspace-domain posture, edit the application resource's
allowed_idps+policiesconditionals to point all keys atallow_mgh_org/google_workspace, drop the dev-specific group/policies/IdP, and apply. One module change, one apply, no data migration. - To migrate the dev tier onto a different IdP later (e.g., real SAML SSO from a future IdP, or hardware-key WebAuthn via Google), the same single-knob model applies: swap the resource block, point
allowed_idpsat the new IdP, apply. The group-based policy reference is IdP-agnostic.
Alternatives considered¶
| Option | Why rejected |
|---|---|
Keep ADR-0017's MGH Org (Workspace-domain) group for dev |
Loose by design — any future Workspace identity (service accounts, contractors, shared inboxes) inherits dev access by default. The dev surface is operator-only by intent; codifying that lets secops-agent audit drift against a concrete list. |
| Google OIDC + AMR claim require step (true MFA via Google) | Google Workspace's MFA enforcement is a tenant-wide setting, not a per-CF-Access-app condition. CF Access can require specific AMR values via require rules, but the Google flow's MFA prompt is governed by Workspace policy, not CF. The OTP route delivers the operator's "validation code in email" requirement directly without depending on Workspace MFA policy. |
| WebAuthn / hardware key for dev tier | Operator does not yet operate with hardware keys; introducing this as the dev-tier requirement would block daily iteration on hardware procurement and enrollment. Revisit when the wider org adopts WebAuthn. |
| Mobile-app OTP (TOTP) instead of email OTP | Adds a software dependency (Authy/Google Authenticator) and onboarding step. Email OTP works on any allowlisted inbox without app install. Strength is comparable for the operator threat model. |
| CF Service Tokens | Service tokens are non-human credentials, intended for CI / automation paths. They do not solve "operator-only human access" — they enable headless access, which is the opposite of the requirement here. |
| 24h session like prod | Operator-stated trade: 48h reduces login churn during a multi-session dev push. The increase in compromise-window from 24h → 48h is acceptable on the dev surface (no customer data, no production write paths). |
Different session per dev app type (e.g. shorter for api.dev) |
Premature optimisation. Single dev-tier session simplifies the mental model and the audit posture. Revisit only if a specific dev hostname acquires sensitive write paths. |