Skip to content

ADR-0006 — CMS admin authentication: Cloudflare Access

Status: Accepted Date: 2026-07-04 Deciders: Cathal Dempsey Related: ADR-0004 (adapter pattern / the CMS worker), scope docs/pipeline/scope-2026-07-04-one-site-operational.md, runbook docs/pipeline/spike-0-auth-cloudflare-access.md

Context

The multi-tenant CMS worker (apps/cms/) had no working admin login on the deployed worker. emdash defaults to passkey auth; its dev-bypass door returns 403 outside import.meta.env.DEV, self-signup needs an email provider (503, none configured) and an enabled allowed-domain row, and no script provisioned a user on remote D1. Net: /_emdash/admin rendered a login nobody — not even the developer — could pass. This was step 0 of "materially operational" and it wasn't a workstream until the 2026-07-04 review surfaced it.

Decision

Gate the emdash admin with Cloudflare Access (self-hosted application), not passkey. emdash ships a first-class transparent-auth adapter: access() from @emdash-cms/cloudflare builds an AuthDescriptor; emdash({ auth: access({...}) }) wires it. The adapter validates the Cloudflare Access JWT and provisions/loads the emdash user.

Key facts that made this the right call (all grounded in the emdash source, node_modules/emdash/dist/astro/middleware/auth.mjs):

  • Public site is never gated. The auth middleware runs external auth only on /_emdash/admin and /_emdash/api; public routes get a soft-auth that never blocks. So Access is scoped to the /_emdash path, and visitors see the site normally.
  • First Access login is auto-provisioned as Admin (role 50); this removed the "no provisioning script" gap entirely — no SQL user seed.
  • Access engages in production only — in DEV the middleware falls back to passkey/dev-bypass, so Gate-1 is inherently a deployed-worker test.

Configuration (as deployed)

  • Custom domain emdash.dcathal.org attached to the worker (Access cannot front a *.workers.dev URL — the hostname must be a zone on the same account). Attached via the CF API on the personal 1ceb… account, same account as the zone + stack.
  • Access self-hosted app scoped to path _emdash; One-time PIN login method; Allow policy on operator emails.
  • CF_ACCESS_TEAM_DOMAIN defaulted in astro.config.mjs (fcr-copysheet-pages.cloudflareaccess.com), overridable by env for the eventual fcrmedia-account move; CF_ACCESS_AUDIENCE as a worker var (public — it appears in the Access login redirect).

Consequences

Positive

  • No login system to build; edge auth, no email pipeline, no passkey bootstrap. First-login-as-Admin means zero user provisioning.
  • The auth mode generalises to N sites (Access, or per-collection RBAC), so the one-site milestone doesn't cement a single hand-inserted user.

Negative / risks

  • Two gotchas cost a deploy each (both now in known-patterns): passing an empty roleMapping: {} crashes One-time-PIN logins with TypeError: groups is not iterable; and syncRoles: true + defaultRole: 50 is a retroactive privilege-escalation footgun (flagged by the commit security review, since narrowed to defaultRole: 30, no syncRoles).
  • The Access policy currently admits whole domains (@fcrmedia.ie / @goldenpages.ie). Tighten to specific emails (or wire an IdP with groups + roleMapping) before wider use — the policy is the real gate.
  • Corporate mail gateways may quarantine the One-time-PIN email (noreply@notify.cloudflare.com); allowlist it or use SSO to use those addresses. Personal Gmail receives it fine.
  • Account move to fcrmedia is a config swap for auth (team domain env
  • AUD var) but a full stack move for the worker/D1/R2 (separate task).

When to revisit

  • Moving the stack to the fcrmedia Cloudflare account (recreate the Access app there; swap the two env values).
  • Multiple operators / real RBAC needed → connect a Google/Microsoft IdP with groups and use roleMapping instead of a flat defaultRole.
  • Custom domain finalised (production hostname replaces emdash.dcathal.org).

References

  • Runbook: docs/pipeline/spike-0-auth-cloudflare-access.md
  • emdash auth: node_modules/emdash/dist/astro/middleware/auth.mjs, @emdash-cms/cloudflare/auth
  • Commits: WS-0 (7ea21b5), role/security narrowing (a41544c, dde9cf2)