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/adminand/_emdash/api; public routes get a soft-auth that never blocks. So Access is scoped to the/_emdashpath, 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.orgattached to the worker (Access cannot front a*.workers.devURL — the hostname must be a zone on the same account). Attached via the CF API on the personal1ceb…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_DOMAINdefaulted inastro.config.mjs(fcr-copysheet-pages.cloudflareaccess.com), overridable by env for the eventual fcrmedia-account move;CF_ACCESS_AUDIENCEas 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 withTypeError: groups is not iterable; andsyncRoles: true+defaultRole: 50is a retroactive privilege-escalation footgun (flagged by the commit security review, since narrowed todefaultRole: 30, nosyncRoles). - 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
roleMappinginstead of a flatdefaultRole. - 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)