From ffd1d6f16e65a13112437d3612b55d1f625f4721 Mon Sep 17 00:00:00 2001 From: Dylan Steele Date: Wed, 10 Jun 2026 20:16:54 -0400 Subject: [PATCH 1/4] Improve hosted smoke verification --- docs/17-ci-and-quality-gates.md | 3 +- docs/21-hosting-ui-setup.md | 12 ++- docs/23-deployment-automation.md | 3 +- scripts/check-hosted-smoke.mjs | 141 ++++++++++++++++++++++++++----- 4 files changed, 135 insertions(+), 24 deletions(-) diff --git a/docs/17-ci-and-quality-gates.md b/docs/17-ci-and-quality-gates.md index 7383440..68b7160 100644 --- a/docs/17-ci-and-quality-gates.md +++ b/docs/17-ci-and-quality-gates.md @@ -48,7 +48,8 @@ Current Worker API contract coverage: Hosted smoke command: - Script: `scripts/check-hosted-smoke.mjs` - Command: `FORAGE_WEB_ORIGIN=https://forage.example.com FORAGE_WORKER_ORIGIN=https://api.forage.example.com pnpm smoke:hosted` -- Purpose: verify deployed Worker health, Worker CORS/preflight, Pages headers, CSP Worker origin, and basic rendered app HTML +- Purpose: verify deployed Worker health, Worker CORS/preflight, unauthenticated session shape, OAuth start redirect/PKCE setup, Pages headers, CSP Worker origin, and basic rendered app HTML +- Staging can use `FORAGE_WEB_SMOKE_MODE=access-protected` when the web hostname is behind Cloudflare Access. - This is not part of the default CI gate because it requires live hosted domains. Local developer hooks: diff --git a/docs/21-hosting-ui-setup.md b/docs/21-hosting-ui-setup.md index cdab1a8..ff2ce14 100644 --- a/docs/21-hosting-ui-setup.md +++ b/docs/21-hosting-ui-setup.md @@ -207,7 +207,17 @@ FORAGE_WORKER_ORIGIN=https://api.forage.example.com \ pnpm smoke:hosted ``` -For staging, use the staging web and API origins and set `FORAGE_SMOKE_EXPECT_PRODUCTION=false`. This script verifies Worker health, CORS, preflight headers, Pages security headers, CSP Worker origin, and basic app HTML. +For staging, use the staging web and API origins, set `FORAGE_SMOKE_EXPECT_PRODUCTION=false`, and set `FORAGE_WEB_SMOKE_MODE=access-protected` when Cloudflare Access protects the staging web hostname: + +```sh +FORAGE_WEB_ORIGIN=https://forage-staging.shrimpworks.dev \ +FORAGE_WORKER_ORIGIN=https://api-staging.forage.shrimpworks.dev \ +FORAGE_SMOKE_EXPECT_PRODUCTION=false \ +FORAGE_WEB_SMOKE_MODE=access-protected \ +pnpm smoke:hosted +``` + +This script verifies Worker health, CORS, unauthenticated session shape, GitHub OAuth start redirect/PKCE setup, preflight headers, and either public Pages HTML/security headers or Cloudflare Access protection depending on `FORAGE_WEB_SMOKE_MODE`. Verify Worker health: - `GET https://api.forage.example.com/api/health` diff --git a/docs/23-deployment-automation.md b/docs/23-deployment-automation.md index 0c23899..2e47cc7 100644 --- a/docs/23-deployment-automation.md +++ b/docs/23-deployment-automation.md @@ -77,10 +77,11 @@ Hosted smoke: FORAGE_WEB_ORIGIN=https://staging.forage.example.com \ FORAGE_WORKER_ORIGIN=https://api-staging.forage.example.com \ FORAGE_SMOKE_EXPECT_PRODUCTION=false \ +FORAGE_WEB_SMOKE_MODE=access-protected \ pnpm smoke:hosted ``` -Set `FORAGE_SMOKE_EXPECT_PRODUCTION=false` for staging because staging Worker config intentionally exposes non-secret setup diagnostics. Leave it unset for production so the smoke check verifies that production config hides those diagnostics. +Set `FORAGE_SMOKE_EXPECT_PRODUCTION=false` for staging because staging Worker config intentionally exposes non-secret setup diagnostics. Set `FORAGE_WEB_SMOKE_MODE=access-protected` when Cloudflare Access protects the staging web hostname. Leave both unset for production so the smoke check verifies public Pages HTML/security headers and confirms production config hides diagnostics. When using Cloudflare Pages custom branch domains, the staging branch name should match the OpenTofu environment key. For example, the `staging` environment maps to `staging..pages.dev` and can be connected to a custom hostname such as `forage-staging.example.com`. diff --git a/scripts/check-hosted-smoke.mjs b/scripts/check-hosted-smoke.mjs index 25bc36a..c76c2a6 100644 --- a/scripts/check-hosted-smoke.mjs +++ b/scripts/check-hosted-smoke.mjs @@ -9,6 +9,8 @@ const untrustedOrigin = normalizeOrigin( process.env.FORAGE_UNTRUSTED_ORIGIN ?? "https://forage-smoke.invalid", ); const expectProductionConfig = parseBooleanEnv(process.env.FORAGE_SMOKE_EXPECT_PRODUCTION, true); +const webSmokeMode = process.env.FORAGE_WEB_SMOKE_MODE ?? "public"; +const skipOAuthStart = parseBooleanEnv(process.env.FORAGE_SMOKE_SKIP_OAUTH_START, false); const failures = []; @@ -21,10 +23,17 @@ Set: Optional: FORAGE_UNTRUSTED_ORIGIN=https://untrusted.example.com + FORAGE_WEB_SMOKE_MODE=public + FORAGE_SMOKE_SKIP_OAUTH_START=false `); process.exit(1); } +if (!["public", "access-protected"].includes(webSmokeMode)) { + console.error("FORAGE_WEB_SMOKE_MODE must be public or access-protected."); + process.exit(1); +} + const webIsHttps = webOrigin.startsWith("https://"); const workerIsHttps = workerOrigin.startsWith("https://"); @@ -81,6 +90,78 @@ if (rejectedConfig.response.headers.has("access-control-allow-origin")) { failures.push("Worker config returned Access-Control-Allow-Origin for an untrusted origin."); } +const session = await fetchJson(`${workerOrigin}/api/session`, { + label: "Worker unauthenticated session", + headers: { + Origin: webOrigin, + }, +}); +assertHeader( + session.response, + "access-control-allow-origin", + webOrigin, + "Worker unauthenticated session", +); +assertHeader( + session.response, + "access-control-allow-credentials", + /^true$/i, + "Worker unauthenticated session", +); +if (session.payload?.authenticated !== false) { + failures.push("Worker unauthenticated session response must report authenticated: false."); +} + +if (!skipOAuthStart) { + const authStart = await fetch(`${workerOrigin}/auth/github`, { + redirect: "manual", + headers: { + Origin: webOrigin, + }, + }); + if (authStart.status !== 302) { + failures.push(`GitHub OAuth start returned ${authStart.status}; expected 302.`); + } + assertHeader( + authStart, + "location", + /^https:\/\/github\.com\/login\/oauth\/authorize\?/i, + "GitHub OAuth start", + ); + assertHeader(authStart, "set-cookie", /forage_oauth_state=/i, "GitHub OAuth start"); + + const authLocation = authStart.headers.get("location"); + if (authLocation) { + const authUrl = new URL(authLocation); + const redirectUri = authUrl.searchParams.get("redirect_uri"); + if (redirectUri !== `${workerOrigin}/auth/github/callback`) { + failures.push( + `GitHub OAuth start redirect_uri was ${redirectUri}; expected ${workerOrigin}/auth/github/callback.`, + ); + } + if (authUrl.searchParams.get("code_challenge_method") !== "S256") { + failures.push("GitHub OAuth start is missing PKCE code_challenge_method=S256."); + } + if (!authUrl.searchParams.get("code_challenge")) { + failures.push("GitHub OAuth start is missing PKCE code_challenge."); + } + if (!authUrl.searchParams.get("state")) { + failures.push("GitHub OAuth start is missing state."); + } + } + + const oauthCookie = authStart.headers.get("set-cookie") ?? ""; + if (!/HttpOnly/i.test(oauthCookie)) { + failures.push("GitHub OAuth start cookie is missing HttpOnly."); + } + if (!/SameSite=Lax/i.test(oauthCookie)) { + failures.push("GitHub OAuth start cookie is missing SameSite=Lax."); + } + if (workerIsHttps && !/Secure/i.test(oauthCookie)) { + failures.push("GitHub OAuth start cookie is missing Secure."); + } +} + const preflight = await fetch(`${workerOrigin}/api/settings`, { method: "OPTIONS", headers: { @@ -100,28 +181,46 @@ assertHeader( "Worker settings preflight", ); -const webResponse = await fetch(webOrigin); -if (!webResponse.ok) { - failures.push(`Web app returned ${webResponse.status}; expected 2xx.`); -} -const webHtml = await webResponse.text(); -if (!webHtml.includes('id="forage-app"')) { - failures.push("Web app HTML is missing the Forage app root."); -} -if (!webHtml.includes("Starred repos, ready to sort through.")) { - failures.push("Web app HTML is missing the expected heading."); -} -if (!webHtml.includes(`connect-src 'self' ${workerOrigin}`)) { - failures.push("Web app CSP meta tag does not include the configured Worker origin."); -} -assertHeader(webResponse, "x-content-type-options", /^nosniff$/i, "Web app"); -assertHeader(webResponse, "x-frame-options", /^DENY$/i, "Web app"); -assertHeader(webResponse, "referrer-policy", /^strict-origin-when-cross-origin$/i, "Web app"); -assertHeader(webResponse, "permissions-policy", /camera=\(\)/i, "Web app"); -assertHeader(webResponse, "content-security-policy", /frame-ancestors 'none'/i, "Web app"); +if (webSmokeMode === "public") { + const webResponse = await fetch(webOrigin); + if (!webResponse.ok) { + failures.push(`Web app returned ${webResponse.status}; expected 2xx.`); + } + const webHtml = await webResponse.text(); + if (!webHtml.includes('id="forage-app"')) { + failures.push("Web app HTML is missing the Forage app root."); + } + if (!webHtml.includes("Starred repos, ready to sort through.")) { + failures.push("Web app HTML is missing the expected heading."); + } + if (!webHtml.includes(`connect-src 'self' ${workerOrigin}`)) { + failures.push("Web app CSP meta tag does not include the configured Worker origin."); + } + assertHeader(webResponse, "x-content-type-options", /^nosniff$/i, "Web app"); + assertHeader(webResponse, "x-frame-options", /^DENY$/i, "Web app"); + assertHeader(webResponse, "referrer-policy", /^strict-origin-when-cross-origin$/i, "Web app"); + assertHeader(webResponse, "permissions-policy", /camera=\(\)/i, "Web app"); + assertHeader(webResponse, "content-security-policy", /frame-ancestors 'none'/i, "Web app"); + + if (webIsHttps) { + assertHeader(webResponse, "strict-transport-security", /max-age=/i, "Web app"); + } +} else { + const webResponse = await fetch(webOrigin, { redirect: "manual" }); + const location = webResponse.headers.get("location") ?? ""; + const body = await webResponse.text().catch(() => ""); + const accessRedirect = + [301, 302, 303, 307, 308].includes(webResponse.status) && + (/cloudflareaccess\.com/i.test(location) || /\/cdn-cgi\/access/i.test(location)); + const accessDenied = + [401, 403].includes(webResponse.status) && + (/cloudflare access/i.test(body) || /\/cdn-cgi\/access/i.test(body)); -if (webIsHttps) { - assertHeader(webResponse, "strict-transport-security", /max-age=/i, "Web app"); + if (!accessRedirect && !accessDenied) { + failures.push( + `Access-protected web returned ${webResponse.status}; expected Cloudflare Access redirect or denial.`, + ); + } } if (workerIsHttps) { assertHeader(health.response, "strict-transport-security", /max-age=/i, "Worker health", { From d748bf9dd1886b8f3796fa6b7c32386ef856c1b3 Mon Sep 17 00:00:00 2001 From: Dylan Steele Date: Wed, 10 Jun 2026 20:22:31 -0400 Subject: [PATCH 2/4] Run hosted smoke from deploy workflow --- .github/workflows/deploy.yml | 10 +++++++++- docs/23-deployment-automation.md | 4 ++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index bbb2528..a0a9cf0 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -24,7 +24,7 @@ on: description: "Run hosted smoke checks after deploy" required: true type: boolean - default: false + default: true permissions: contents: read @@ -82,12 +82,16 @@ jobs: worker_origin="${{ vars.FORAGE_PRODUCTION_WORKER_ORIGIN }}" pages_branch="${{ vars.FORAGE_PRODUCTION_PAGES_BRANCH }}" default_pages_branch="main" + smoke_expect_production="true" + web_smoke_mode="public" ;; staging) web_origin="${{ vars.FORAGE_STAGING_WEB_ORIGIN }}" worker_origin="${{ vars.FORAGE_STAGING_WORKER_ORIGIN }}" pages_branch="${{ vars.FORAGE_STAGING_PAGES_BRANCH }}" default_pages_branch="staging" + smoke_expect_production="false" + web_smoke_mode="access-protected" ;; *) echo "::error::Unsupported deployment environment: $environment_name" @@ -114,6 +118,8 @@ jobs: echo "web_origin=$web_origin" echo "worker_origin=$worker_origin" echo "pages_branch=$pages_branch" + echo "smoke_expect_production=$smoke_expect_production" + echo "web_smoke_mode=$web_smoke_mode" } >> "$GITHUB_OUTPUT" - name: Check infra formatting @@ -145,4 +151,6 @@ jobs: env: FORAGE_WEB_ORIGIN: ${{ steps.resolve.outputs.web_origin }} FORAGE_WORKER_ORIGIN: ${{ steps.resolve.outputs.worker_origin }} + FORAGE_SMOKE_EXPECT_PRODUCTION: ${{ steps.resolve.outputs.smoke_expect_production }} + FORAGE_WEB_SMOKE_MODE: ${{ steps.resolve.outputs.web_smoke_mode }} run: pnpm smoke:hosted diff --git a/docs/23-deployment-automation.md b/docs/23-deployment-automation.md index 2e47cc7..904f102 100644 --- a/docs/23-deployment-automation.md +++ b/docs/23-deployment-automation.md @@ -10,7 +10,7 @@ Forage deploys through `.github/workflows/deploy.yml`. The workflow is manual-on - `environment`: `staging` or `production` - `deploy_worker`: deploy `apps/worker` with Wrangler - `deploy_pages`: deploy `apps/web/dist` to Cloudflare Pages with Wrangler direct upload -- `run_hosted_smoke`: run `pnpm smoke:hosted` after deployment +- `run_hosted_smoke`: run `pnpm smoke:hosted` after deployment, enabled by default ## GitHub Repository Secrets @@ -81,7 +81,7 @@ FORAGE_WEB_SMOKE_MODE=access-protected \ pnpm smoke:hosted ``` -Set `FORAGE_SMOKE_EXPECT_PRODUCTION=false` for staging because staging Worker config intentionally exposes non-secret setup diagnostics. Set `FORAGE_WEB_SMOKE_MODE=access-protected` when Cloudflare Access protects the staging web hostname. Leave both unset for production so the smoke check verifies public Pages HTML/security headers and confirms production config hides diagnostics. +The deploy workflow sets `FORAGE_SMOKE_EXPECT_PRODUCTION=false` and `FORAGE_WEB_SMOKE_MODE=access-protected` automatically for staging because staging Worker config intentionally exposes non-secret setup diagnostics and the staging web hostname is behind Cloudflare Access. For production, the workflow sets `FORAGE_SMOKE_EXPECT_PRODUCTION=true` and `FORAGE_WEB_SMOKE_MODE=public` so the smoke check verifies public Pages HTML/security headers and confirms production config hides diagnostics. When using Cloudflare Pages custom branch domains, the staging branch name should match the OpenTofu environment key. For example, the `staging` environment maps to `staging..pages.dev` and can be connected to a custom hostname such as `forage-staging.example.com`. From e905f32dd4773d25cd7ec9f02bfbba81515b373b Mon Sep 17 00:00:00 2001 From: Dylan Steele Date: Wed, 10 Jun 2026 20:35:43 -0400 Subject: [PATCH 3/4] Stabilize staging Access redirects --- docs/20-hosting-and-security.md | 5 +++-- docs/21-hosting-ui-setup.md | 5 +++-- docs/22-infrastructure-as-code.md | 2 ++ infra/opentofu/security.tf | 2 +- infra/opentofu/terraform.tfvars.example | 7 +++---- infra/opentofu/variables.tf | 11 +++++++++++ 6 files changed, 23 insertions(+), 9 deletions(-) diff --git a/docs/20-hosting-and-security.md b/docs/20-hosting-and-security.md index d5b18f1..0dac9e1 100644 --- a/docs/20-hosting-and-security.md +++ b/docs/20-hosting-and-security.md @@ -130,6 +130,7 @@ Staging web should be private by default: - Enable `manage_staging_access`. - Set `staging_access_allowed_emails` to the operator/tester email addresses. - Cloudflare Access protects the staging web hostname before the app is reached. +- Use `SameSite=Lax` for Access cookies so GitHub OAuth redirect chains do not lose the Access session before returning to the web app. - Do not put the staging API hostname behind Access initially because OAuth callbacks, cookies, and CORS are easier to validate with WAF and rate limits first. Hosted web and API domains should use WAF/rate-limit controls: @@ -140,8 +141,8 @@ Hosted web and API domains should use WAF/rate-limit controls: - Use short rate-limit blocks when the Cloudflare plan does not allow managed challenges in `http_ratelimit`. Cloudflare Pages branch URLs require separate attention: -- Add the branch hostname to `staging_access_extra_hostnames` if it should be covered by the same Access app. -- Also verify Pages preview access settings in the Cloudflare UI because Pages preview URLs can remain reachable outside custom-domain routing. +- Keep the staging Access app scoped to the canonical custom hostname by default. +- Protect Pages preview URLs separately in the Cloudflare Pages UI because mixing the custom hostname and `pages.dev` branch hostname in one Access app can create confusing cross-host Access session behavior. ## Security Headers diff --git a/docs/21-hosting-ui-setup.md b/docs/21-hosting-ui-setup.md index ff2ce14..60e22dc 100644 --- a/docs/21-hosting-ui-setup.md +++ b/docs/21-hosting-ui-setup.md @@ -158,6 +158,7 @@ If using the same token for the full OpenTofu root, include read/write permissio Recommended staging posture: - Protect `forage-staging.example.com` with Cloudflare Access. - Allow only explicit tester email addresses. +- Keep Access cookies at `SameSite=Lax` so OAuth callback redirects can return to staging without requiring a hard refresh. - Do not put `api-staging.forage.example.com` behind Access initially. - Use WAF and rate limiting for the API hostname so GitHub OAuth callbacks and credentialed CORS remain straightforward to test. @@ -167,8 +168,8 @@ Recommended production posture: - Keep the combined API/auth rate limit enabled. Pages preview URLs: -- Add the Pages branch hostname to `staging_access_extra_hostnames` if OpenTofu should include it in the Access app. -- Also check Cloudflare Pages preview access settings in the dashboard because preview URLs can be exposed independently from custom domains. +- Keep `staging_access_extra_hostnames` empty by default so the staging Access app protects only the canonical custom hostname. +- Check Cloudflare Pages preview access settings in the dashboard if branch URLs need protection because preview URLs can be exposed independently from custom domains. ## GitHub Repository UI diff --git a/docs/22-infrastructure-as-code.md b/docs/22-infrastructure-as-code.md index a715b63..588b310 100644 --- a/docs/22-infrastructure-as-code.md +++ b/docs/22-infrastructure-as-code.md @@ -80,6 +80,8 @@ staging_access_allowed_emails = [ `manage_security_controls` creates zone-level WAF rules and a combined API/auth rate-limit rule for the configured hosted domains. `manage_staging_access` creates a Cloudflare Access self-hosted application for the staging web hostname when at least one allowed email is configured. +The staging Access app defaults to `SameSite=Lax` cookies and the canonical custom staging hostname only. Keep `staging_access_extra_hostnames` empty unless a second hostname has been explicitly tested, because mixing the custom hostname and `pages.dev` branch hostname in one Access app can create confusing cross-host redirect/session behavior. + The default combined rate limit uses a 10-second period, 10-second mitigation timeout, and `block` action because some Cloudflare plans only allow one rate-limit rule per zone with those `http_ratelimit` values. Raise the entitlement-specific values only after confirming the active zone plan allows them. For a temporary local token named `TEMP_CLOUDFLARE_API_TOKEN`, run plans with: diff --git a/infra/opentofu/security.tf b/infra/opentofu/security.tf index 1dcc7fd..602e65c 100644 --- a/infra/opentofu/security.tf +++ b/infra/opentofu/security.tf @@ -103,7 +103,7 @@ resource "cloudflare_zero_trust_access_application" "staging_web" { app_launcher_visible = false enable_binding_cookie = true http_only_cookie_attribute = true - same_site_cookie_attribute = "strict" + same_site_cookie_attribute = var.staging_access_same_site_cookie_attribute session_duration = var.staging_access_session_duration destinations = [ diff --git a/infra/opentofu/terraform.tfvars.example b/infra/opentofu/terraform.tfvars.example index b53861c..370828e 100644 --- a/infra/opentofu/terraform.tfvars.example +++ b/infra/opentofu/terraform.tfvars.example @@ -20,10 +20,9 @@ staging_access_allowed_emails = [ "replace-with-your-email@example.com", ] -# Optional: protect the Cloudflare Pages branch URL as well as the custom staging hostname. -staging_access_extra_hostnames = [ - "staging.forage-web.pages.dev", -] +# Keep the staging Access app scoped to the canonical custom hostname by default. +# Protect Pages preview URLs separately in the Cloudflare Pages UI if needed. +staging_access_extra_hostnames = [] environments = { staging = { diff --git a/infra/opentofu/variables.tf b/infra/opentofu/variables.tf index 082c607..92292f2 100644 --- a/infra/opentofu/variables.tf +++ b/infra/opentofu/variables.tf @@ -116,6 +116,17 @@ variable "staging_access_session_duration" { default = "8h" } +variable "staging_access_same_site_cookie_attribute" { + description = "SameSite attribute for Cloudflare Access staging cookies. Lax avoids losing Access cookies during OAuth redirect chains." + type = string + default = "lax" + + validation { + condition = contains(["lax", "strict", "none"], var.staging_access_same_site_cookie_attribute) + error_message = "staging_access_same_site_cookie_attribute must be lax, strict, or none." + } +} + variable "security_allowed_countries" { description = "Country codes treated as primary expected traffic sources for hosted Forage domains." type = set(string) From 4508289d623b86a67f1f88676c17ea4c904e8daa Mon Sep 17 00:00:00 2001 From: Dylan Steele Date: Wed, 10 Jun 2026 20:42:19 -0400 Subject: [PATCH 4/4] Document Cloudflare token permissions --- docs/21-hosting-ui-setup.md | 2 + docs/22-infrastructure-as-code.md | 1 + docs/23-deployment-automation.md | 2 + docs/24-cloudflare-token-permissions.md | 123 ++++++++++++++++++++++++ docs/README.md | 1 + infra/opentofu/README.md | 1 + 6 files changed, 130 insertions(+) create mode 100644 docs/24-cloudflare-token-permissions.md diff --git a/docs/21-hosting-ui-setup.md b/docs/21-hosting-ui-setup.md index 60e22dc..83a6fef 100644 --- a/docs/21-hosting-ui-setup.md +++ b/docs/21-hosting-ui-setup.md @@ -155,6 +155,8 @@ Minimum security-token permissions: If using the same token for the full OpenTofu root, include read/write permissions for the resources already managed in state, such as Pages, Workers KV, Worker scripts/custom domains, and DNS. +Use [Cloudflare Token Permissions](./24-cloudflare-token-permissions.md) for the full local infra, deploy workflow, and temporary recovery token permission matrix. + Recommended staging posture: - Protect `forage-staging.example.com` with Cloudflare Access. - Allow only explicit tester email addresses. diff --git a/docs/22-infrastructure-as-code.md b/docs/22-infrastructure-as-code.md index 588b310..57cfe02 100644 --- a/docs/22-infrastructure-as-code.md +++ b/docs/22-infrastructure-as-code.md @@ -93,6 +93,7 @@ CLOUDFLARE_API_TOKEN="$TEMP_CLOUDFLARE_API_TOKEN" tofu plan If the same OpenTofu state already manages Pages, KV, DNS, or Worker custom domains, a normal `tofu plan` refreshes those resources too. The API token must therefore be able to read existing managed resources, not only the new security resources. A narrow security-only token can validate the new resources with `-refresh=false`, but use a full infra/deploy token for normal apply runs. Use [Deployment Automation](./23-deployment-automation.md) for the GitHub Actions workflow, repository secrets, GitHub environment variables, and first deployment order. +Use [Cloudflare Token Permissions](./24-cloudflare-token-permissions.md) for the exact full infra, deploy, and temporary recovery token permission profiles. ## Sources Checked diff --git a/docs/23-deployment-automation.md b/docs/23-deployment-automation.md index 904f102..4834a8c 100644 --- a/docs/23-deployment-automation.md +++ b/docs/23-deployment-automation.md @@ -21,6 +21,8 @@ Configure these as repository or environment secrets: The Cloudflare API token should be scoped narrowly to the Forage account and resources needed for Worker and Pages deployment. Keep GitHub App client secrets in Cloudflare Worker secrets, not GitHub repository variables. +Use [Cloudflare Token Permissions](./24-cloudflare-token-permissions.md) for the deploy token profile. The deploy token does not need the full local OpenTofu permission set unless the workflow is changed to manage infrastructure. + ## GitHub Environment Variables Configure these on the `staging` and `production` GitHub environments: diff --git a/docs/24-cloudflare-token-permissions.md b/docs/24-cloudflare-token-permissions.md new file mode 100644 index 0000000..743ca23 --- /dev/null +++ b/docs/24-cloudflare-token-permissions.md @@ -0,0 +1,123 @@ +# Cloudflare Token Permissions + +Status: +Operator reference for local OpenTofu, deploy workflow, and temporary recovery tokens + +Cloudflare API tokens should be short-lived when used locally and scoped as narrowly as the Cloudflare UI allows. Prefer zone-scoped policies for `shrimpworks.dev` over all-zone policies. Revoke temporary tokens after the apply or recovery task is complete. + +Do not commit token values. Use local `.env`, GitHub Actions secrets, or a password manager. + +## Token Types + +Use three token profiles: + +- Full infra token: local OpenTofu plan/apply for all resources in `infra/opentofu`. +- Deploy token: GitHub Actions or local Wrangler deploys for Worker and Pages code deployment. +- Security recovery token: temporary targeted updates to WAF, rate limits, or Access resources. + +The full infra token can do everything the security recovery token can do. The security recovery token may require `tofu apply -refresh=false -target=...` because a normal OpenTofu plan refreshes every resource already in state. + +## Full Infra Token + +Use this for normal local `tofu plan` and `tofu apply` from `infra/opentofu`. + +Account-level permissions: +- Pages Read +- Pages Write +- Workers KV Storage Read +- Workers KV Storage Write +- Workers Scripts Read +- Workers Scripts Write +- Access Apps and Policies Read +- Access Apps and Policies Write +- Access Organizations Read +- Zero Trust Read + +Zone-level permissions for the Forage zone: +- Zone Read +- DNS Read +- DNS Write +- Zone WAF Read +- Zone WAF Write + +Why it needs broad read access: +- OpenTofu refreshes every resource in state before planning changes. +- Current state includes Pages, KV namespaces, Worker custom domains, DNS records, WAF rulesets, rate-limit rulesets, and Access resources. +- A token that can write only Access or WAF may still fail a normal plan while refreshing Pages, KV, or Worker domain resources. + +Recommended local command: + +```sh +cd infra/opentofu +CLOUDFLARE_API_TOKEN="$TEMP_CLOUDFLARE_API_TOKEN" tofu plan +``` + +## Deploy Token + +Use this as GitHub secret `CLOUDFLARE_API_TOKEN` for `.github/workflows/deploy.yml`. + +Account-level permissions: +- Pages Read +- Pages Write +- Workers Scripts Read +- Workers Scripts Write + +Zone-level permissions for the Forage zone: +- Zone Read +- DNS Read + +Notes: +- The deploy workflow uploads Worker code and Pages assets. +- It does not need to manage WAF, rate limits, Access policies, KV namespaces, or DNS records directly. +- Keep GitHub App client secrets in Cloudflare Worker secrets, not GitHub repository variables. + +## Security Recovery Token + +Use this for short-lived local fixes to Cloudflare Access, WAF, or rate limiting. + +Account-level permissions: +- Access Apps and Policies Read +- Access Apps and Policies Write +- Access Organizations Read +- Zero Trust Read + +Zone-level permissions for the Forage zone: +- Zone Read +- Zone WAF Read +- Zone WAF Write + +If this token does not include Pages, KV, Worker, and DNS read permissions, use targeted applies only after reviewing the targeted plan: + +```sh +cd infra/opentofu +CLOUDFLARE_API_TOKEN="$TEMP_CLOUDFLARE_API_TOKEN" \ + tofu plan -refresh=false -target='cloudflare_zero_trust_access_application.staging_web[0]' +``` + +```sh +cd infra/opentofu +CLOUDFLARE_API_TOKEN="$TEMP_CLOUDFLARE_API_TOKEN" \ + tofu apply -refresh=false -target='cloudflare_zero_trust_access_application.staging_web[0]' +``` + +Use targeted applies only for recovery. Follow with a normal full-token `tofu plan` when possible to confirm full state convergence. + +## Current Cloudflare Plan Constraints + +The active Forage Cloudflare plan currently requires: + +- One `http_ratelimit` rule per zone. +- 10-second rate-limit period. +- 10-second mitigation timeout. +- `block` action for rate limiting. + +Do not change these defaults unless the Cloudflare zone plan is upgraded and the new entitlement is verified with `tofu plan` and `tofu apply`. + +## Temporary Token Cleanup + +After local setup or recovery: + +1. Run a final `tofu plan` with the full infra token when available. +2. Confirm the plan reports `No changes`. +3. Revoke the temporary token in Cloudflare. +4. Remove `TEMP_CLOUDFLARE_API_TOKEN` from local `.env` if it is no longer needed. diff --git a/docs/README.md b/docs/README.md index 06f1d0a..87f5054 100644 --- a/docs/README.md +++ b/docs/README.md @@ -31,6 +31,7 @@ This directory contains the planning and technical decision material for Forage. - [Hosting UI Setup](./21-hosting-ui-setup.md) - [Infrastructure As Code](./22-infrastructure-as-code.md) - [Deployment Automation](./23-deployment-automation.md) +- [Cloudflare Token Permissions](./24-cloudflare-token-permissions.md) ## Architecture Decisions diff --git a/infra/opentofu/README.md b/infra/opentofu/README.md index 08958eb..4935f77 100644 --- a/infra/opentofu/README.md +++ b/infra/opentofu/README.md @@ -32,6 +32,7 @@ export CLOUDFLARE_API_TOKEN=... ``` The token needs enough access to manage Pages, Workers custom domains, and Workers KV in the selected account and zone. +See `../../docs/24-cloudflare-token-permissions.md` for the full infra, deploy, and temporary recovery token permission profiles. Formatting: