Skip to content

ADR-0008: Cloudflare Access with Google Workspace OIDC gates all three internal hostnames

  • Status: Accepted
  • Date: 2026-05-18
  • Deciders: cloud-architect, cloudflare-expert, secops-agent, finops-agent, marnissi.investments

Context

Three internal hostnames defined in infra/CLAUDE.md §Hostname catalog require authentication before any request reaches the origin:

Hostname App
admin.marnissi-holdings.com admin-frontend/ React app
api.marnissi-holdings.com admin-backend/ FastAPI service
labs.marnissi-holdings.com labs/ experimental surface

DNS CNAMEs for these hostnames point at the Cloudflare Tunnel (ADR-0006, PR5). The tunnel already routes requests to localhost services on the OCI ARM A1 VPS (ADR-0007, PR5). Neither the DNS module nor the tunnel module enforces authentication — that gap is what this ADR closes.

Google Workspace is already the operator's identity provider (infra/CLAUDE.md §Stack: "Google Workspace — SSO + MFA (identity provider for CF Access via OIDC)"). The workspace CLAUDE.md cloud-stack spec names CF Access as the zero-trust gateway. No alternative IdP is in use. CF Access Zero Trust Free tier provides 50 seats; current usage is 1 operator.

No prior ADR has committed the Cloudflare Access topology, IdP wiring, application definitions, or policy model. This ADR provides that specification so infra-agent can implement modules/cloudflare-access/.

Decision

Implement modules/cloudflare-access/ with the following resources. infra-agent authors the HCL; cloudflare-expert verifies provider resource names and attribute values before the PR is merged.

Identity provider

One cloudflare_zero_trust_access_identity_provider resource, type google-apps, scoped to the CF account (not zone-scoped — Access IdPs are account-level resources):

Attribute Value
Name Google Workspace
Type google (Google Workspace OIDC)
Client ID var.google_oidc_client_id (non-secret; safe in .tfvars)
Client Secret var.google_oidc_client_secret (sensitive; sourced from TF_VAR_google_oidc_client_secret env var only — never in .tfvars, ADRs, or PR bodies)

The Google OIDC client must be created manually in Google Cloud Console before tofu apply. This is a Phase A operator step. infra-agent will document the procedure in infra/scripts/bootstrap-google-oidc.md (to be written in the same PR as the module):

  • Create an OAuth 2.0 client ID of type Web application in the Google Cloud project linked to the Google Workspace organisation.
  • Authorised redirect URI: https://marnissi-holdings.cloudflareaccess.com/cdn-cgi/access/callback
  • Scopes: openid email profile
  • Copy the Client ID into .tfvars as google_oidc_client_id.
  • Inject the Client Secret via export TF_VAR_google_oidc_client_secret=<value> before running tofu plan / tofu apply. Never persist the secret value in any file.

Google Workspace groups are not used in the initial policy (see §Policies). Groups require the Admin SDK API to be enabled and an additional OAuth scope. Deferred to a later iteration when fine-grained RBAC is needed.

CF Access team name

The Cloudflare Zero Trust team name is marnissi-holdings (fixed; set at Zero Trust organisation creation time; appears in marnissi-holdings.cloudflareaccess.com). This value is recorded in infra/CLAUDE.md §Hostname catalog: "CF Access app launcher — marnissi-holdings.cloudflareaccess.com — fixed by CF (team name marnissi-holdings)". The team name is immutable post-create — renaming requires destroying and recreating the Zero Trust organisation, which invalidates all issued sessions.

App launcher is enabled at https://marnissi-holdings.cloudflareaccess.com.

Access applications

Three cloudflare_zero_trust_access_application resources, one per internal hostname. All are self_hosted type (request proxied through the tunnel, not a SaaS connector):

Application name Domain Session duration IdP restriction
admin admin.marnissi-holdings.com 24h Google Workspace IdP (by allowed_idps referencing the IdP resource ID)
api api.marnissi-holdings.com 24h Google Workspace IdP
labs labs.marnissi-holdings.com 24h Google Workspace IdP

Session duration is configurable per application via a Tofu variable. Default 24h is deliberate: long enough for a full working day, short enough that a compromised session expires without manual intervention. If an application requires shorter sessions (e.g. api accessed by a service consumer), change the variable and redeploy — no ADR needed for session length tuning.

auto_redirect_to_identity = true for all three applications: unauthenticated requests are redirected directly to Google OIDC rather than showing the CF Access login page first. This removes one unnecessary click for the operator.

Access policies

Three cloudflare_zero_trust_access_policy resources, one per application:

Policy name Application Decision Include rule
allow-mgh-org-admin admin allow group = cloudflare_zero_trust_access_group.mgh_org.id
allow-mgh-org-api api allow group = cloudflare_zero_trust_access_group.mgh_org.id
allow-mgh-org-labs labs allow group = cloudflare_zero_trust_access_group.mgh_org.id

The three policies reference a single cloudflare_zero_trust_access_group.mgh_org whose include is email_domain = marnissi-holdings.com. This is the "Option B" pattern (group + group-reference policies) — adding a second domain or a service-token bypass becomes one group edit instead of three parallel policy edits.

Policy logic: a request is allowed if and only if the authenticated Google account belongs to the org group (i.e. email domain is marnissi-holdings.com). No allow_any policy exists; no wildcard email rule exists. There is no require block (no additional CF-level WARP or service-token requirement) and no exclude block.

MFA enforcement is upstream at Google Workspace: all organisation accounts have 2FA enforced at the IdP level. CF Access does not add a second factor at the CF layer. This is intentional — Google Workspace MFA is the control; duplicating it at CF would add UX friction without changing the effective security posture (a compromised Google session already bypasses CF's second-factor challenge).

Service tokens

Service tokens are not provisioned in this ADR. They are needed when an automated agent (e.g. a GitHub Actions workflow calling api.marnissi-holdings.com) must bypass the browser-based OIDC flow. Revisit when a confirmed CI integration requires programmatic API access; at that point, a new ADR or an amendment will specify the token scope and rotation cadence.

Inter-module dependency

modules/cloudflare-access/ depends on modules/cloudflare-dns/ (ADR-0006, PR5) for the hostname CNAME records that make the domains resolvable. It is independent of modules/cloudflare-tunnel/ at the Tofu resource level: CF Access policy enforcement happens at the Cloudflare edge before a request is forwarded to the tunnel. Even if the tunnel ingress is misconfigured to forward a request that should be blocked, CF Access intercepts at the edge first — defence in depth.

Dependency chain for envs/<env>/main.tf:

modules/cloudflare-dns/      (ADR-0006, PR5 — must be applied first; hostnames must exist)
  └── hostnames resolvable
modules/cloudflare-access/   (this ADR — enforces authn on those hostnames at the CF edge)
  └── IdP resource ID referenced by all three access application resources

Consequences

  • Cost (delta vs free tier): $0. CF Zero Trust Free tier covers 50 seats. Current usage: 1 operator (the marnissi.investments@gmail.com account). Cliff: >50 seats triggers $7/user/month on Pay-as-you-go. The ledger row CF Access seats moves from 0 to 1. Two new ledger rows should be added: CF Access apps (0 → 3, no quota limit on Free) and CF Access policies (0 → 3, no quota limit on Free).

  • Operational surface:

  • Google OIDC client: Must be created manually in Google Cloud Console before tofu apply (Phase A). Procedure in infra/scripts/bootstrap-google-oidc.md (written by infra-agent in the module PR). Client ID is stable (safe to version-control in .tfvars); Client Secret must be rotated on any suspected compromise and on the standard 6-month secret rotation cadence (see infra/scripts/rotate-secrets.md). Recommended posture: set a calendar reminder at 7 days before expiry if the Google OAuth client is configured with an expiry date (Google Cloud Console allows optional expiry; check during client creation).
  • Team name marnissi-holdings: Immutable after CF Zero Trust organisation creation. The team name is already recorded as the canonical value in infra/CLAUDE.md §Hostname catalog. No operator action required; documented here for audit visibility.
  • Session duration: Configurable per application via Tofu variable. The default 24h applies to all three apps at initial rollout. Changes to session duration require a tofu plan + tofu apply (no CF dashboard edits — keep Tofu as the single source of truth).
  • App launcher: https://marnissi-holdings.cloudflareaccess.com lists all three protected applications after login. No operational maintenance; CF manages the launcher page.

  • Security posture:

  • No anonymous access to admin.*, api.*, or labs.*. CF Access blocks unauthenticated requests at the edge before they enter the tunnel.
  • Defence in depth: CF Access is enforced at the CF edge (before the tunnel), and the tunnel itself carries no public-ingress exposure on OCI (ADR-0007). Two independent mechanisms must both fail for an unauthenticated request to reach the origin.
  • email_domain = marnissi-holdings.com restricts to Google Workspace org accounts. Accounts outside this domain — including personal Gmail accounts — are rejected by the policy.
  • No allow_any policy is present. secops-agent must verify this on every infra PR touching modules/cloudflare-access/.
  • Client Secret (TF_VAR_google_oidc_client_secret) lives in env vars only. It must not appear in .tfvars files, PR bodies, ADRs, or chat output. secops-agent audits this on every infra PR.
  • MFA is enforced upstream at Google Workspace. CF does not duplicate this — see §Policies for rationale.
  • OIDC session refresh is handled by Google. Token expiry and revocation propagate to CF Access via the IdP session state. If an account is suspended in Google Workspace, the corresponding CF Access session is invalidated on next token refresh.
  • secops-agent checklist for every infra PR touching this module: (a) team name is marnissi-holdings and matches infra/CLAUDE.md; (b) client secret is sourced from env var, not from any file; (c) email_domain = marnissi-holdings.com is the only include rule — no allow_any, no wildcard domain; (d) no service token has been added without a corresponding ADR amendment; (e) MFA enforcement documented as upstream at Google Workspace level; (f) auto_redirect_to_identity = true is set (no CF login page bypass).

  • Reversibility: Deleting modules/cloudflare-access/ resources reverts all three hostnames to unauthenticated access. This must always be paired with removing the corresponding tunnel ingress rules in roles/cloudflared/templates/config.yml.j2 and the DNS CNAMEs in modules/cloudflare-dns/ if the hostname is being decommissioned — otherwise the hostname becomes publicly accessible. The module is reversible; the coordination discipline is the risk.

  • Migration path if we revisit: CF Access supports multiple IdP types on the same Zero Trust account. Replacing Google Workspace OIDC with another provider (Microsoft Entra ID, GitHub OAuth, Okta, or a self-hosted OIDC issuer) is a drop-in replacement for the cloudflare_zero_trust_access_identity_provider resource — update the type, client_id, client_secret, and the allowed_idps reference on each application resource. Application and policy resources do not change. Migration effort: one PR, no application code changes, no DNS changes.

Alternatives considered

Option Why rejected
Cloudflare One Login (CF-native IdP) Adds a second identity system alongside Google Workspace. ADR-0002 establishes Google Workspace as the single IdP boundary for the MGH stack (infra/CLAUDE.md §Stack). Running a parallel CF-native identity store fragments user management and introduces a second credential set to lifecycle.
Self-hosted Keycloak on OCI VPS Ops burden is disproportionate for a 1-person organisation at v1 scale: Keycloak requires a dedicated JVM container, a PostgreSQL database, TLS certificates, HA consideration, and upgrade maintenance. The ARM VPS has 0 OCPU headroom (infra/finops/ledger.md: OCI ARM A1 OCPU fully consumed). No free-tier equivalent of Google Workspace's 50-seat IdP at this operational maturity.
Cloudflare Service Auth via mTLS mTLS is appropriate for machine-to-machine service authentication, not human operator SSO. Browser-based users do not carry client certificates; requiring mTLS for admin.* or labs.* would require certificate distribution and management per user device. Worse UX than SSO for the same security result.
No authentication (public access to internal hostnames) Directly violates the internal-hostname specification in infra/CLAUDE.md §Hostname catalog ("Internal — behind CF Access + Google Workspace SSO"). Not a viable option.
Per-user IP allowlist Requires VPN-grade client configuration or static IP assignment per user. Operationally expensive to maintain as operator locations change. Provides no identity signal — only network location. Does not integrate with Google Workspace session lifecycle or revocation. Effectively a 2003-era VPN posture without the identity benefit.