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
.tfvarsasgoogle_oidc_client_id. - Inject the Client Secret via
export TF_VAR_google_oidc_client_secret=<value>before runningtofu 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.comaccount). Cliff: >50 seats triggers $7/user/month on Pay-as-you-go. The ledger rowCF Access seatsmoves from 0 to 1. Two new ledger rows should be added:CF Access apps(0 → 3, no quota limit on Free) andCF 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 ininfra/scripts/bootstrap-google-oidc.md(written byinfra-agentin 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 (seeinfra/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 ininfra/CLAUDE.md§Hostname catalog. No operator action required; documented here for audit visibility. - Session duration: Configurable per application via Tofu variable. The default
24happlies to all three apps at initial rollout. Changes to session duration require atofu plan+tofu apply(no CF dashboard edits — keep Tofu as the single source of truth). -
App launcher:
https://marnissi-holdings.cloudflareaccess.comlists all three protected applications after login. No operational maintenance; CF manages the launcher page. -
Security posture:
- No anonymous access to
admin.*,api.*, orlabs.*. 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.comrestricts to Google Workspace org accounts. Accounts outside this domain — including personal Gmail accounts — are rejected by the policy.- No
allow_anypolicy is present.secops-agentmust verify this on every infra PR touchingmodules/cloudflare-access/. - Client Secret (
TF_VAR_google_oidc_client_secret) lives in env vars only. It must not appear in.tfvarsfiles, PR bodies, ADRs, or chat output.secops-agentaudits 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-agentchecklist for every infra PR touching this module: (a) team name ismarnissi-holdingsand matchesinfra/CLAUDE.md; (b) client secret is sourced from env var, not from any file; (c)email_domain = marnissi-holdings.comis the only include rule — noallow_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 = trueis 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 inroles/cloudflared/templates/config.yml.j2and the DNS CNAMEs inmodules/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_providerresource — update thetype,client_id,client_secret, and theallowed_idpsreference 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. |