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
11 changes: 6 additions & 5 deletions docs/development/explanation/preview-deployments.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,11 +86,12 @@ token, so the workflow cannot write a comment even to say why there is no
preview. Somebody has to open the run to read it.

That guard is the actual security boundary, and it is worth being blunt about
what it does and does not buy. Cloudflare has no per-script API token scope:
any token that can deploy a preview Worker can also overwrite the production
one. The separate `preview` GitHub Environment gives previews their own
deployment records and somewhere to put a narrower token the day Cloudflare
offers one. It is not isolation. What keeps the token away from unreviewed
what it does and does not buy. Both GitHub environments currently hold the
same account-scoped token, so a preview with that credential can also alter the
production Worker. Cloudflare supports per-Worker roles, but each new preview
Worker needs account-level create rights. The separate `preview` GitHub
Environment records preview deployments; it does not isolate the credential.
What keeps the token away from unreviewed
code is that previews run only for branches pushed to this repository, and
pushing here already requires write access.

Expand Down
4 changes: 3 additions & 1 deletion docs/operations/how-to/analytics.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ initializes it in `apps/web/src/analytics-entry.ts` before React renders.

Only the production deployment provides `PUBLIC_LVBT_CWA_TOKEN` and
`PUBLIC_LVBT_LABS_CWA_TOKEN`, as GitHub Actions variables on the `production`
environment. The hostname selects the map or Labs property. That workflow also
environment. Both variables hold the same token from the shared
`lasvegasfortransit.org` Web Analytics property. Cloudflare accepts that token
on both hostnames because they share the apex domain. That workflow also
sets `LVBT_REQUIRE_ANALYTICS=1`, so a missing or blank token fails the build
instead of deploying an unmeasured production release. Local, pull request
preview, and retired archive builds omit the tokens and initialize no analytics.
Expand Down
8 changes: 3 additions & 5 deletions docs/operations/how-to/operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,10 +158,8 @@ Check which step failed before anything else; they fail for unrelated reasons.
- **Apply D1 migrations** or **Deploy** with
`Authentication error [code: 10000]` — the `CLOUDFLARE_API_TOKEN` secret in
the repository's `production` environment lacks a permission. It needs
everything the **Edit Cloudflare Workers** template grants, which includes
`Account · Workers Scripts · Edit`, `Account · Workers R2 Storage · Edit`,
and `Zone · Workers Routes · Edit`, plus `Account · D1 · Edit`, which the
template lacks. Make a replacement with
the narrowly scoped Workers Scripts, D1, R2, account and zone read, and
Workers Routes permissions in the setup guide. Make a replacement with
[the deploy token steps](set-up-production.md#make-the-deploy-token) and
store it with `pnpm bootstrap --rotate-token`. (That environment also needs a
`CLOUDFLARE_ACCOUNT_ID` **variable** — not a secret — which is easy to miss
Expand All @@ -184,7 +182,7 @@ Check which step failed before anything else; they fail for unrelated reasons.
Production uses the `transitmapper-data` R2 bucket. The refresh workflow checks
for it and creates it through the Cloudflare API before it downloads a feed.
The `production` environment token needs `Account · Workers R2 Storage · Edit`,
which the **Edit Cloudflare Workers** template it is made from already grants.
which the custom token setup explicitly includes.
Do not put that token on a command line; dispatch the workflow instead.

The daily `Refresh GTFS feeds` workflow runs at 09:17 UTC. It downloads each
Expand Down
54 changes: 32 additions & 22 deletions docs/operations/how-to/set-up-production.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ Everything TransitMapper runs on belongs to Las Vegans for Better Transit
for Better Transit** (account ID `2557b5c2e166292ded0f8425b73075e9`), and the
code lives in the **LasVegasForTransit** GitHub organization. Nothing in this
guide should be created under a personal account.
Check the account name in Cloudflare before starting. If it still says "Las Vegas
for Better Transit", change it to "Las Vegans for Better Transit" under the
account's settings.

You need:

Expand Down Expand Up @@ -77,8 +80,9 @@ it. It is 32 letters and digits: `2557b5c2e166292ded0f8425b73075e9`.

### `PUBLIC_LVBT_CWA_TOKEN` and `PUBLIC_LVBT_LABS_CWA_TOKEN`

These are the Cloudflare Web Analytics tokens for `map.lasvegasfortransit.org`
and `labs.lasvegasfortransit.org`. They are public, because every page view
Both variables use the organization's Cloudflare Web Analytics token for
`lasvegasfortransit.org`. That property covers `map` and `labs` because both
share the same apex domain. The tokens are public, because every page view
sends them, so the bootstrap stores them as environment variables on the
`production` environment. Each is 32 letters and digits. Do not skip them:
the production build refuses to deploy without them. What they measure is in
Expand All @@ -103,46 +107,52 @@ can paste it the moment you copy it.
1. Open <https://dash.cloudflare.com/2557b5c2e166292ded0f8425b73075e9/api-tokens>.
In the dashboard this page is **Manage Account → Account API Tokens**.
2. Click **Create Token**.
3. Under **Permission policies**, open the **Custom** dropdown and choose
**Edit Cloudflare Workers**.
3. Under **Permission policies**, choose **Create Custom Token**. Do not use
the personal **Edit Cloudflare Workers** template; it grants permissions
this repository does not use.
4. Name the token `map.lasvegasfortransit.org deploy (GitHub Actions)`.
5. Keep every permission the template fills in. They include Account ·
Workers Scripts · Edit, Account · Workers KV Storage · Edit, Account ·
Workers R2 Storage · Edit, Account · Workers Tail · Read, Account ·
Account Settings · Read, and Zone · Workers Routes · Edit.
6. Add one more permission: Account · D1 · Edit. Every deploy and every
preview applies database migrations, and the template does not include
D1.
5. Add only these permissions: Account · Workers Scripts · Edit; Account ·
Workers R2 Storage · Edit; Account · D1 · Edit; Account · Account Settings ·
Read; Zone · Zone · Read; and Zone · Workers Routes · Edit. Workers Scripts
deploys and removes Workers; D1 applies production and preview migrations;
R2 supports the daily GTFS refresh; the remaining rows read account and zone
details and attach routes.
6. Under Account Resources, choose Include and the LVBT account by name. Leave
All accounts off so the token cannot act on another account.
7. Under Zone Resources, choose Include, then Specific zone, then
`lasvegasfortransit.org`.
`lasvegasfortransit.org`. Leave All zones off so the token cannot change
another zone.
8. Leave the expiration date empty, so deploys keep working.
9. Click **Continue to summary**, then **Create Token**.
10. Copy the token. Cloudflare shows it only once. Paste it into the
terminal when the bootstrap asks; it is not shown as you type.

The daily GTFS refresh needs Account · Workers R2 Storage · Edit, which the
template already grants, so there is nothing to add for it.
The daily GTFS refresh needs the R2 permission listed in step 5.

If the token is ever rolled or deleted in Cloudflare, the stored copy stops
working and every deploy fails. Make a new token with the steps above and
store it with `pnpm bootstrap --rotate-token`.

## Find the Web Analytics tokens

Do this once for `map.lasvegasfortransit.org` and once for
`labs.lasvegasfortransit.org`, in the order the bootstrap asks. Finish all
six steps for one hostname, including the paste, before you start the next.
Both hostnames use the existing `lasvegasfortransit.org` property. The bootstrap
asks for its token twice because the production build currently has two
variables; paste the same public token at each prompt. Finish the first paste
before moving to the second prompt. Do not create a second property for a
subdomain.

1. Open
<https://dash.cloudflare.com/2557b5c2e166292ded0f8425b73075e9/web-analytics>.
2. If the hostname is already listed, click **Manage site** on it and go to
2. If `lasvegasfortransit.org` is already listed, click **Manage site** on it and go to
step 5.
3. Click **Add a site** and type the hostname.
4. Choose **Enable with JS Snippet installation**, not the automatic
**Enable** option. The site loads the analytics script itself.
3. Click **Add a site**, choose `lasvegasfortransit.org`, and click **Done**.
4. Open **Manage site** and choose **Enable with JS Snippet installation**
instead of automatic setup. TransitMapper loads the beacon itself, so
automatic injection would load it twice.
5. In the JS snippet, copy only the token inside
`data-cf-beacon='{"token": "..."}'`. It is 32 letters and digits.
6. Paste it into the terminal when the bootstrap asks for that hostname.
6. Paste it into the terminal at each bootstrap prompt, without copying
another value between the two prompts.

## Running it again

Expand Down
11 changes: 6 additions & 5 deletions docs/security/reference/secrets.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,12 @@ is accurate as of today either way.
| `CLOUDFLARE_API_TOKEN` | GitHub `production` and `preview` environments | Deploy Worker code, alter D1 data, and replace managed R2 archives |
| `CLOUDFLARE_ACCOUNT_ID` | The same two environments, as a variable | Not a secret. An identifier, useless without a token |

The same token in both environments, and this is not an oversight. Cloudflare
has no per-script API token scope: any token that can deploy a pull request
preview Worker can also overwrite the production one. The `preview`
environment gives previews their own deployment records and somewhere to put a
narrower token the day Cloudflare offers one. It is not an isolation boundary.
The same account-scoped token is currently in both environments, so code with
the preview credential can also change the production Worker. The `preview`
environment gives previews their own deployment records; it does not isolate
Cloudflare permissions. Cloudflare now supports per-Worker roles, but these
previews create new Workers dynamically, which requires account-level create
rights. A narrower credential design needs to account for that creation step.
What keeps the token away from unreviewed code is that previews run only for
branches pushed to this repository, which already requires write access — see
[pull request previews](../../development/explanation/preview-deployments.md).
Expand Down
16 changes: 8 additions & 8 deletions scripts/bootstrap/phases/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,7 @@ interface AnalyticsVariable {
}

/**
* One Web Analytics site per hostname TransitMapper is served on, as
* docs/operations/how-to/analytics.md describes. The production workflow sets
* One apex-domain Web Analytics property covers both hostnames. The production workflow sets
* LVBT_REQUIRE_ANALYTICS=1, so a missing token fails the release build: a
* setup that stops before these is a setup whose first deploy fails.
*/
Expand All @@ -42,16 +41,17 @@ function webAnalyticsUrl(account: CloudflareAccount): string {
*/
function analyticsSteps(account: CloudflareAccount, variable: AnalyticsVariable): string {
return [
`This is the Web Analytics token for ${variable.host}. The production`,
'build refuses to deploy without it.',
`This is the Web Analytics token used on ${variable.host}. The production`,
'build refuses to deploy without it. Both map and labs use the existing',
'lasvegasfortransit.org property; do not create a second subdomain property.',
'',
` 1. Open ${webAnalyticsUrl(account)}`,
' (opening it for you now).',
` 2. If ${variable.host} is already listed, click "Manage site" on it`,
' 2. If lasvegasfortransit.org is already listed, click "Manage site" on it',
' and go to step 5.',
` 3. Click "Add a site" and type ${variable.host}`,
' 4. Choose "Enable with JS Snippet installation", not the automatic',
' "Enable" option: the site loads the beacon itself.',
' 3. Click "Add a site", choose lasvegasfortransit.org, and click "Done".',
' 4. Open "Manage site" and choose "Enable with JS Snippet installation"',
' instead of automatic setup: the site loads the beacon itself.',
' 5. In the JS snippet, copy only the token inside',
` data-cf-beacon='{"token": "..."}' (32 letters and digits).`,
' 6. Paste it below.',
Expand Down
45 changes: 20 additions & 25 deletions scripts/bootstrap/phases/ci-secrets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,16 +26,14 @@ function tokenDashboardUrl(account: CloudflareAccount): string {
}

/**
* The account and zone permissions the "Edit Cloudflare Workers" template
* fills in, in the dashboard's words. Listed so the reader can check the
* screen before adding anything, instead of wondering whether one is missing.
* Permissions used by deployment, migrations, preview cleanup, and feed refresh.
*/
const TEMPLATE_PERMISSIONS = [
const DEPLOY_PERMISSIONS = [
'Account · Workers Scripts · Edit',
'Account · Workers KV Storage · Edit',
'Account · Workers R2 Storage · Edit (the daily GTFS refresh needs it)',
'Account · Workers Tail · Read',
'Account · D1 · Edit (production and preview migrations)',
'Account · Account Settings · Read',
'Zone · Zone · Read',
'Zone · Workers Routes · Edit',
];

Expand All @@ -44,10 +42,6 @@ const TEMPLATE_PERMISSIONS = [
* has never made a Cloudflare API token: every name to type, every
* permission to add, and every option to pick, so nothing is guessed.
*
* The one permission the template lacks is D1: the production and preview
* workflows both apply D1 migrations before they deploy. R2 is already in
* the template, which is why it is listed rather than added.
*
* Every copied value is pasted before anything else is copied: the prompt is
* already waiting when the token is shown, and the bootstrap itself writes
* it to both environments, so nobody holds one value while fetching another.
Expand All @@ -61,23 +55,24 @@ function tokenPromptBody(account: CloudflareAccount, target: DeployTarget): stri
'',
'It is an account-owned token: it belongs to the LVBT account, not to',
'you. Cloudflare lets only a Super Administrator of the account make one.',
'Check that this account is named Las Vegans for Better Transit. If it',
'still says Las Vegas for Better Transit, correct it in account settings.',
'',
` 1. Open ${tokenDashboardUrl(account)}`,
' (opening it for you now). In the dashboard this is',
' Manage Account → Account API Tokens.',
' 2. Click "Create Token".',
' 3. Under "Permission policies", open the "Custom" dropdown and',
' choose "Edit Cloudflare Workers".',
' 3. Under "Permission policies", choose "Create Custom Token".',
` 4. Token name: ${site} deploy (GitHub Actions)`,
' 5. Keep every permission the template fills in. They include:',
...TEMPLATE_PERMISSIONS.map((permission) => ` ${permission}`),
' Add one more, because every deploy applies D1 migrations:',
' Account · D1 · Edit',
` 6. Zone Resources: Include → Specific zone → ${zones}`,
' 7. Leave the expiration date empty, so deploys keep working.',
' 8. Click "Continue to summary", then "Create Token".',
' 9. Copy the token. Cloudflare shows it only once.',
' 10. Paste it below. It is not shown on screen as you type.',
' 5. Add only these permissions:',
...DEPLOY_PERMISSIONS.map((permission) => ` ${permission}`),
` 6. Account Resources: Include → ${account.name}. Leave All accounts off.`,
` 7. Zone Resources: Include → Specific zone → ${zones}.`,
' Leave All zones off so the token cannot change other zones.',
' 8. Leave the expiration date empty, so deploys keep working.',
' 9. Click "Continue to summary", then "Create Token".',
' 10. Copy the token. Cloudflare shows it only once.',
' 11. Paste it below. It is not shown on screen as you type.',
'',
'The token is a secret. The bootstrap stores it only as the',
`${TOKEN_SECRET} environment secret on the ${REQUIRED_ENVIRONMENTS.join(' and ')}`,
Expand Down Expand Up @@ -285,10 +280,10 @@ async function writeToken(
* standard input and never touches a command line, the general subprocess
* environment (see the denylist in lib/shell.ts), or any on-disk file.
*
* The same token for every environment, because Cloudflare has no per-script
* token scope: any token that can deploy a preview Worker can also overwrite
* the production one. Separate environments buy separate deployment records
* and somewhere to put a narrower token the day one exists — not isolation.
* The same account-scoped token is currently used in both environments, so a
* preview credential can also change production Worker code. Cloudflare has
* per-Worker roles, but dynamically created preview Workers need account-level
* create rights. Separate environments record deployments, not isolation.
*/
export async function runCiSecretsPhase({
doctor,
Expand Down
Loading