Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ on:
description: "Run hosted smoke checks after deploy"
required: true
type: boolean
default: false
default: true

permissions:
contents: read
Expand Down Expand Up @@ -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"
Expand All @@ -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
Expand Down Expand Up @@ -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
3 changes: 2 additions & 1 deletion docs/17-ci-and-quality-gates.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
5 changes: 3 additions & 2 deletions docs/20-hosting-and-security.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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

Expand Down
19 changes: 16 additions & 3 deletions docs/21-hosting-ui-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,9 +155,12 @@ 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.
- 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.

Expand All @@ -167,8 +170,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

Expand Down Expand Up @@ -207,7 +210,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`
Expand Down
3 changes: 3 additions & 0 deletions docs/22-infrastructure-as-code.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -91,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

Expand Down
7 changes: 5 additions & 2 deletions docs/23-deployment-automation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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:
Expand Down Expand Up @@ -77,10 +79,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.
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_project_name>.pages.dev` and can be connected to a custom hostname such as `forage-staging.example.com`.

Expand Down
123 changes: 123 additions & 0 deletions docs/24-cloudflare-token-permissions.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions infra/opentofu/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
2 changes: 1 addition & 1 deletion infra/opentofu/security.tf
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
7 changes: 3 additions & 4 deletions infra/opentofu/terraform.tfvars.example
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
11 changes: 11 additions & 0 deletions infra/opentofu/variables.tf
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading