ADR-0040: Console access: Cloudflare Tunnel, Access and a tailnet break-glass¶
- Status: Accepted
- Date: 2026-10-11
- Deciders: El
- Supersedes: ADR-0022 in part (the
consoleroute no longer goes through Caddy) and ADR-0029 §2 in part ("behind Caddy"). The hostnameconsole.octopodia.com.auand Ansible deployment both stand. - Relates to: ADR-0027, ADR-0031, ADR-0037, ADR-0038, ADR-0039
Context¶
ADR-0022 routed every hostname, the Console's included, through Caddy on public 443. The Console is now the place the estate is run from, including approving provisioning jobs (ADR-0039). An operator-only surface with that reach should not be a public listener that relies on its own login page, and it must stay reachable when the usual path is down.
Decision¶
flowchart LR
OP(["El, Lachlan"]) -->|"Entra SSO + MFA"| ACC["Cloudflare Access"]
ACC --> CF["Cloudflare edge"]
CFD["cloudflared<br/>container on the host"] -->|"dials out<br/>no inbound port"| CF
CFD --> CON["Console"]
OP2(["El, Lachlan<br/>break-glass"]) -->|"tailnet only"| TS["tailscale serve"] --> CON
CLIENT(["Clients"]) --> CADDY["Caddy, public 443"] --> APP["Review app,<br/>vanity domains"]
1. console.octopodia.com.au through a Cloudflare Tunnel¶
cloudflaredruns on the host as its own container. It dials out to Cloudflare; the host needs no inbound port for the Console.- Ansible deploys it, and the tunnel token lives in the vault under the
corevault ID (a machine credential, ADR-0027 §7). - Caddy stays the front door for anything a client reaches: the review app, vanity domains and
/insights(ADR-0022 stands for those).
2. Cloudflare Access in front¶
| Identity | Entra SSO with MFA, Octopodia's Microsoft 365 tenant |
| Who | El and Lachlan only, by explicit allow list |
| Documentation | The docs site's Access policy uses the same identity (ADR-0029 §1) |
3. The Console validates the Access token itself¶
Access sends a signed token with every request. The Console verifies it on every request: signature against the team's published keys, audience, issuer and expiry, and the email against the allow list. A request that reaches the Console any other way, without a valid token, is refused. Access is the outer gate, not the only one.
4. Approving a job needs a fresh MFA: a passkey step-up in the Console¶
A short Access session is not enough on its own. When Access's session ends it sends the operator back to Entra, and Entra signs them in again silently from its own session: Microsoft's default is not to ask for credentials again while the session's security posture is unchanged. The operator sees no MFA prompt, so a five-minute Access session proves nothing about who is at the keyboard.
Two mechanisms genuinely force a fresh MFA at approval:
| A. Passkey step-up in the Console (chosen) | B. Entra sign-in frequency "every time" (fallback) | |
|---|---|---|
| How | At Approve, the Console issues a WebAuthn challenge bound to the job (its ID and a hash of its inputs). The operator's registered passkey must answer with user verification required: a PIN or biometric on the authenticator, every time. The Console checks the signature against the stored public key and records the credential and time with the approval | An Entra authentication context ("Console approval") with a Conditional Access policy that requires MFA and sets sign-in frequency to every time. Access imports it through Entra policy sync, and a second Access application covering only the approval path requires that context |
| Settled by documentation | Yes. User verification on each assertion is part of the WebAuthn standard, and the check runs in our code | Not fully. Microsoft documents that "every time" requires full reauthentication, including MFA, whenever the session is evaluated (with five minutes' clock skew), for OIDC apps that redirect back to Entra. Cloudflare documents the authentication-context integration but not whether a session control like "every time" is honoured on each Access sign-in. Unproven (OI-117) |
| Licence | None | Microsoft Entra ID P1, needed for any Conditional Access. Included in Microsoft 365 Business Premium, E3 and E5; not in Business Basic or Business Standard. Which plan Octopodia is on is not recorded (OI-117) |
| Binds the approval to the job | Yes: the signature covers that job's ID and inputs | No: it proves a recent sign-in, not consent to this job |
| Costs | Building it: passkey enrolment and the step-up check. Each operator enrols two passkeys (a device and a hardware key). Only public keys are stored. Enrolling a replacement for a lost passkey needs the other operator's step-up to approve it. If an operator loses both passkeys while the other operator is away, a break-glass runbook re-enrols them by hand on the host (Console passkey recovery), recorded in the history as break-glass, so a lost pair never locks us out for good | No code. Microsoft warns that "every time" on too many apps causes MFA fatigue, and loops without MFA, so it is kept to the approval path only |
Recommendation: A, with B as the fallback if the passkey step-up cannot ship, or as a second layer later if OI-117 confirms both the licence and the behaviour. The Console refuses an approval without a valid step-up, and the runner refuses a job whose approval record lacks one (ADR-0039 §4).
5. Machine clients use their own path¶
The tenant link (ADR-0038) and the runner (ADR-0039) are machines, not people. They reach the Console API's link endpoints through the same tunnel on a separate hostname, with a Cloudflare Access service token as the outer gate and their own per-tenant or runner token checked by the Console. They never use the operator paths, and operators' tokens never work on the link endpoints. The exact route, including whether same-host tenants should use the tailnet instead, is OI-109.
6. Break-glass: the tailnet¶
When Cloudflare, Access or the tunnel is down, the Console is also reachable over the tailnet, admins only, served on the host's tailnet name by tailscale serve, as n8n is today.
| On the break-glass path | |
|---|---|
| Who | El and Lachlan, by their tailnet identity, once the tailnet enforces it (below) |
| Allowed | Viewing, editing settings, and pausing the runner |
| Not allowed | Approving jobs: no fresh MFA is possible here, so provisioning waits for the main path |
| Recorded | Every change, with the tailnet identity and "break-glass" marked in the history |
Break-glass depends on the tailnet being locked down first, and it is not yet. The tailnet policy is still the default allow all, and lite-dev is shared into El's personal tailnet (OI-086). Break-glass therefore depends on OI-086's least-privilege grants (admins only to tag:server) and on removing the share. Until both are done, the break-glass listener is not enabled on any host carrying client data. On such a host, the only way in while Cloudflare is down is SSH over the tailnet and the provider's web console, as today.
How the Console trusts the tailnet identity safely (only on the tailnet listener, never from the internet) is OI-113.
7. Account¶
The tunnel and the Access applications sit on El's personal Cloudflare account for now. Moving them, with the zone, to a company account is part of the later Cloudflare migration (OI-110).
Consequences¶
The Console has no public listener of its own: an attacker meets Entra SSO with MFA before a single request reaches it, and then the Console's own token check. Approving a job costs a passkey touch with a PIN or biometric, bound to that job, which is the point: a stolen session alone cannot approve anything.
Two front doors now exist: Caddy for clients, the tunnel for operators. That split is deliberate. Client surfaces need vanity domains and per-tenant certificates, which Caddy does well; the operator surface needs identity in front, which Access does well.
The Console now depends on Cloudflare for normal access; the tailnet path covers its outages, minus approvals, once OI-086 is closed. Until then a Cloudflare outage means SSH and the runbooks on any host with client data. Lachlan must be on the company tailnet for break-glass to work for him (OI-086).
Alternatives considered¶
- Keep the Console behind Caddy with its own login is rejected: a public listener guarded only by the application.
- Tailnet only, no Cloudflare is rejected for normal use: every operator device on the tailnet, and no identity-aware MFA in front. Kept as break-glass.
- Access without the Console checking the token is rejected: a misconfigured route or a request from inside the host would bypass Access.
- Cloudflare Tunnel for client surfaces too is rejected for now: vanity domains and per-tenant certificates already work through Caddy (ADR-0022).
- Approving jobs on the break-glass path is rejected for v1: it keeps the approval gate to one route while the break-glass path is new.
- A short Access session as the approval gate is rejected: Entra re-signs the operator in silently from its own session, so no MFA is asked for.
- Cloudflare's
prompt=loginon the Entra identity provider is rejected: it is set for the whole identity provider, so it would apply to every Access application, and Cloudflare documents it for device-client sessions rather than Access applications.