A secret a job uses only to call an API can be brokered, so its value never enters the job's sandbox. Under Settings, Secrets and variables, a secret's new Brokered choice (Only for these hosts) takes the hosts it is for, written as allowed domains are (registry.npmjs.org, *.example.com); the list shows a Brokered badge whose hint names them. A workflow job then gets a stand-in in its place, g1tsec_ and 48 random hex characters, made for that job and that secret, wherever the value would have gone: as the secret, under any key a called workflow passes it as, and inside a value built from it. As the job's requests pass the runner Worker, a stand-in on a request to one of the secret's hosts becomes the real value in any header (inside Authorization: Basic it is decoded, filled in and encoded again) and in the query string, never the path or the body; a filled-in request is not followed through redirects outside the sandbox, and an answer that echoes the value has it swapped back for the stand-in in its headers, and in its body when that is small, uncompressed text. A stand-in sent to any other host goes as it is and the job's log says once that NPM_TOKEN was sent to example.com, which it is not for, so g1t did not fill it in.
The actions service keeps each stand-in in a new table (migration 0019), its value sealed and bound to the stand-in, until the job's time limit and ten minutes are up; the runner Worker asks brokered_secrets for a running job's stand-ins with the job's own token and keeps the answer a minute per job. The API never passes that method on. A job whose network is open but whose project or workspace has a brokered secret still sends its HTTP and HTTPS through the Worker, which reaches every host for it and leaves other protocols alone, and it trusts the re-signing certificate as a restricted sandbox does. The stand-in is masked in the job's log, and the real value is masked as the log arrives, should an answer have carried it in. A job on a self-hosted runner gets the stand-in too, never the value, and a warning at the top of its log that brokered secrets are filled in only on g1t's machines for now. A brokered secret is read by workflows only, since a deployment's app needs the value itself; security updates' private registries go without it; its value is at least 8 characters, so it is not found inside other secrets by chance. The API and the MCP secret tool take and return brokered_hosts. The secrets guide has a Brokered secrets section (how to set one, where the value is filled in, where it works, and what it cannot cover: database passwords over raw TCP, signing keys, keys to files the job decrypts, tests that need the value, a value in a body), and the actions and guardrails guides describe it where they describe the network.
| 1687 | 1687 | } | |
| 1688 | 1688 | Op::UpdateWorkflow => "Turn a workflow on or off without changing its file. Needs the Maintain role or higher.", | |
| 1689 | 1689 | Op::ListActionsSecrets => { | |
| 1690 | − | "The secrets of a repository (with the workspace's rows that reach it) or of a workspace: each row's key, the environments it applies to, and whether workflows (`secrets.NAME`), deployments, or both read it. Values are never returned. A repository's need the Admin role on it; a workspace's, a member." | |
| 1690 | + | "The secrets of a repository (with the workspace's rows that reach it) or of a workspace: each row's key, the environments it applies to, whether workflows (`secrets.NAME`), deployments, or both read it, and for a brokered secret the hosts g1t fills it in for (`brokered_hosts`). Values are never returned. A repository's need the Admin role on it; a workspace's, a member." | |
| 1691 | 1691 | } | |
| 1692 | 1692 | Op::SetActionsSecret => { | |
| 1693 | − | "Add or change a secret's row. Without `id` or `environments`, the key's row for every environment, as GitHub's API addresses a secret. `available_to` is workflows and/or deployments (both, for a new row); `environments` limits it to some, such as production or preview, so a key can hold a value per environment. A variable's row can become a secret this way; a secret never becomes a variable. A repository's need the Admin role on it; a workspace's, an owner. Workspace tokens, G1T_TOKEN included, cannot change them." | |
| 1693 | + | "Add or change a secret's row. Without `id` or `environments`, the key's row for every environment, as GitHub's API addresses a secret. `available_to` is workflows and/or deployments (both, for a new row); `environments` limits it to some, such as production or preview, so a key can hold a value per environment. A variable's row can become a secret this way; a secret never becomes a variable. `brokered_hosts` brokers the secret: workflow jobs on g1t's machines get a stand-in in place of its value, and g1t puts the real value on the job's requests to those hosts (in headers, Basic auth included, and the query string) as they leave its sandbox, so the value never enters it. A brokered secret is read by workflows only and its value is at least 8 characters; an empty list stops brokering it. A repository's need the Admin role on it; a workspace's, an owner. Workspace tokens, G1T_TOKEN included, cannot change them." | |
| 1694 | 1694 | } | |
| 1695 | 1695 | Op::DeleteActionsSecret => "Remove a secret: one row by `id`, or every row of the key.", | |
| 1696 | 1696 | Op::ListActionsVariables => { | |
| ⋯ | |||
| 2899 | 2899 | "description": "A workspace's row: the projects it reaches, by slug. Empty is every one." | |
| 2900 | 2900 | }, | |
| 2901 | 2901 | "note": { "type": "string", "description": "Where to rotate it, or who to ask." }, | |
| 2902 | + | "brokered_hosts": { | |
| 2903 | + | "type": "array", | |
| 2904 | + | "items": { "type": "string" }, | |
| 2905 | + | "description": "A secret's: the hosts g1t fills it in for, such as api.example.com or *.example.com. Jobs get a stand-in in place of its value. Empty stops brokering it; left out, unchanged." | |
| 2906 | + | }, | |
| 2902 | 2907 | })), | |
| 2903 | 2908 | &["setting"], | |
| 2904 | 2909 | ), | |
| ⋯ | |||
| 5180 | 5185 | } | |
| 5181 | 5186 | // Request bodies arrive in snake_case; the actions service | |
| 5182 | 5187 | // takes `availableTo`. | |
| 5183 | − | for (key, to) in [("available_to", "availableTo"), ("environments", "environments"), ("repositories", "projects"), ("projects", "projects")] { | |
| 5188 | + | for (key, to) in [ | |
| 5189 | + | ("available_to", "availableTo"), | |
| 5190 | + | ("environments", "environments"), | |
| 5191 | + | ("repositories", "projects"), | |
| 5192 | + | ("projects", "projects"), | |
| 5193 | + | ("brokered_hosts", "brokeredHosts"), | |
| 5194 | + | ] { | |
| 5184 | 5195 | if let Some(list) = strings(input, key) { | |
| 5185 | 5196 | args[to] = json!(list); | |
| 5186 | 5197 | } | |
| 3180 | 3180 | "environments": [], | |
| 3181 | 3181 | "projects": [], | |
| 3182 | 3182 | "note": null, | |
| 3183 | − | "updated_by": "syntaqx" | |
| 3183 | + | "updated_by": "syntaqx", | |
| 3184 | + | "brokered_hosts": [ | |
| 3185 | + | "registry.npmjs.org" | |
| 3186 | + | ] | |
| 3184 | 3187 | }, | |
| 3185 | 3188 | { | |
| 3186 | 3189 | "id": "set_01kpv9q4r7s0t3v6w9x2y5z8ac", | |
| ⋯ | |||
| 3198 | 3201 | ], | |
| 3199 | 3202 | "projects": [], | |
| 3200 | 3203 | "note": "Rotate in the Stripe dashboard.", | |
| 3201 | − | "updated_by": "syntaqx" | |
| 3204 | + | "updated_by": "syntaqx", | |
| 3205 | + | "brokered_hosts": [] | |
| 3202 | 3206 | } | |
| 3203 | 3207 | ], | |
| 3204 | − | "notes": "Values are never returned. A repository's list includes the workspace's rows that reach it, with `scope` set to `workspace`. Empty `environments` means every environment." | |
| 3208 | + | "notes": "Values are never returned. A repository's list includes the workspace's rows that reach it, with `scope` set to `workspace`. Empty `environments` means every environment; empty `brokered_hosts`, a secret that is not brokered." | |
| 3205 | 3209 | }, | |
| 3206 | 3210 | "list_actions_secrets_for_workspace": { | |
| 3207 | 3211 | "params": { | |
| ⋯ | |||
| 3221 | 3225 | "environments": [], | |
| 3222 | 3226 | "projects": [], | |
| 3223 | 3227 | "note": null, | |
| 3224 | − | "updated_by": "syntaqx" | |
| 3228 | + | "updated_by": "syntaqx", | |
| 3229 | + | "brokered_hosts": [ | |
| 3230 | + | "registry.npmjs.org" | |
| 3231 | + | ] | |
| 3225 | 3232 | } | |
| 3226 | 3233 | ], | |
| 3227 | − | "notes": "Values are never returned. A repository's list includes the workspace's rows that reach it, with `scope` set to `workspace`. Empty `environments` means every environment." | |
| 3234 | + | "notes": "Values are never returned. A repository's list includes the workspace's rows that reach it, with `scope` set to `workspace`. Empty `environments` means every environment; empty `brokered_hosts`, a secret that is not brokered." | |
| 3228 | 3235 | }, | |
| 3229 | 3236 | "set_actions_secret": { | |
| 3230 | 3237 | "params": { | |
| ⋯ | |||
| 3256 | 3263 | ], | |
| 3257 | 3264 | "projects": [], | |
| 3258 | 3265 | "note": null, | |
| 3259 | − | "updated_by": "syntaqx" | |
| 3266 | + | "updated_by": "syntaqx", | |
| 3267 | + | "brokered_hosts": [] | |
| 3260 | 3268 | }, | |
| 3261 | 3269 | "notes": "Keys are uppercased. See [secrets and variables](/guides/secrets-and-variables/)." | |
| 3262 | 3270 | }, | |
| ⋯ | |||
| 3269 | 3277 | "value": "npm_…", | |
| 3270 | 3278 | "projects": [ | |
| 3271 | 3279 | "hello" | |
| 3280 | + | ], | |
| 3281 | + | "brokered_hosts": [ | |
| 3282 | + | "registry.npmjs.org" | |
| 3272 | 3283 | ] | |
| 3273 | 3284 | }, | |
| 3274 | 3285 | "response": { | |
| ⋯ | |||
| 3286 | 3297 | "hello" | |
| 3287 | 3298 | ], | |
| 3288 | 3299 | "note": null, | |
| 3289 | − | "updated_by": "syntaqx" | |
| 3300 | + | "updated_by": "syntaqx", | |
| 3301 | + | "brokered_hosts": [ | |
| 3302 | + | "registry.npmjs.org" | |
| 3303 | + | ] | |
| 3290 | 3304 | } | |
| 3291 | 3305 | }, | |
| 3292 | 3306 | "delete_actions_secret": { | |
| 619 | 619 | Agents never reach those hosts, and neither do runs of pull requests from | |
| 620 | 620 | forks. See [workflow-only domains](/guides/guardrails/#workflow-only-domains). | |
| 621 | 621 | ||
| 622 | + | ### Brokered secrets | |
| 623 | + | ||
| 624 | + | A secret a job uses only to call an API can be brokered: the job gets a | |
| 625 | + | stand-in (`g1tsec_…`) in place of `${{ secrets.KEY }}`, and g1t puts the | |
| 626 | + | real value on the job's requests to the hosts the secret names as they | |
| 627 | + | leave its sandbox, in headers (Basic auth included) and the query string. | |
| 628 | + | The value never enters the job, so neither its steps nor a compromised | |
| 629 | + | dependency can read it. A stand-in sent to any other host goes as it is, | |
| 630 | + | and the log says so once. This works on g1t's machines, whether the | |
| 631 | + | project's network is restricted or open; a job on a self-hosted runner | |
| 632 | + | gets the stand-in too, never the value, and its log warns that it will | |
| 633 | + | not work there yet. A secret the job must read itself (a database | |
| 634 | + | password over raw TCP, a signing key, a key to files it decrypts) cannot | |
| 635 | + | be brokered. See | |
| 636 | + | [brokered secrets](/guides/secrets-and-variables/#brokered-secrets). | |
| 637 | + | ||
| 622 | 638 | g1t does not run cryptocurrency miners: a step that names one (`xmrig`, | |
| 623 | 639 | a `stratum+tcp://` pool, `--donate-level`) is not run, and a job that | |
| 624 | 640 | looks like it is mining is stopped. See | |
| ⋯ | |||
| 1484 | 1500 | A value of one character is not masked: it would hide that character | |
| 1485 | 1501 | everywhere. | |
| 1486 | 1502 | ||
| 1503 | + | A [brokered secret](#brokered-secrets)'s stand-in is masked the same way. | |
| 1504 | + | Its real value never reaches the job, so the job cannot mask it; g1t | |
| 1505 | + | masks it in the log as the log arrives, should an answer have carried it | |
| 1506 | + | in. | |
| 1507 | + | ||
| 1487 | 1508 | ## Pull requests | |
| 1488 | 1509 | ||
| 1489 | 1510 | A pull request's workflows run on each new head: when it is opened, when | |
| 161 | 161 | `update_guardrails`, saying which domains were added or removed and what | |
| 162 | 162 | they were limited to. | |
| 163 | 163 | ||
| 164 | + | ### Brokered secrets | |
| 165 | + | ||
| 166 | + | The same interception keeps a workflow's API keys out of its jobs. A | |
| 167 | + | [brokered secret](/guides/secrets-and-variables/#brokered-secrets) names | |
| 168 | + | the hosts it is for, and a job is given a stand-in in place of its value. | |
| 169 | + | As a request to one of those hosts passes the runner Worker, the Worker | |
| 170 | + | puts the real value in place of the stand-in, in the request's headers | |
| 171 | + | (inside `Authorization: Basic` too) and its query string, and swaps it | |
| 172 | + | back should the answer echo it. A request to any other host goes with the | |
| 173 | + | stand-in, and the job's log notes it once. The Worker asks for a job's | |
| 174 | + | stand-ins with that job's own token and keeps them only while the job | |
| 175 | + | runs. | |
| 176 | + | ||
| 177 | + | Brokering does not widen the network: a secret's hosts must still be ones | |
| 178 | + | the job may reach. A job whose project's network is open, but which may be | |
| 179 | + | given a brokered secret, still sends its HTTP and HTTPS through the | |
| 180 | + | Worker, which reaches every host for it, and trusts the certificate as a | |
| 181 | + | restricted sandbox does; its other protocols go straight out, as before. | |
| 182 | + | Self-hosted runners' traffic does not pass through g1t, so brokered | |
| 183 | + | secrets stay stand-ins there. | |
| 184 | + | ||
| 164 | 185 | ## Commands | |
| 165 | 186 | ||
| 166 | 187 | The agent's harness checks every tool call the agent makes before it runs. |
| 24 | 24 | | **Type** | **Secret**: sealed when saved and never shown again, hidden in logs. For passwords, API keys and tokens. **Config**: readable by whoever may see the list. For values that are not sensitive. Config can be changed to a secret; a secret can never become config. | | |
| 25 | 25 | | **Key** | Letters, digits and underscores, not starting with a digit, upper-cased: `STRIPE_KEY`. Keys starting with `G1T_` or `GITHUB_` are g1t's own. | | |
| 26 | 26 | | **Value** | Up to 48 KB. | | |
| 27 | + | | **Brokered** | A secret only: the hosts it is for. Jobs get a stand-in, and g1t fills in the value on their requests to those hosts. See [brokered secrets](#brokered-secrets). | | |
| 27 | 28 | | **Note** | Optional: where to rotate it, or who to ask. | | |
| 28 | 29 | | **Environments** | **All environments**, or only some: **Production**, **Preview**, or any name a workflow job uses in `environment:`, such as `staging`. | | |
| 29 | 30 | | **Available to** | **Workflows**, **Deployments**, or both (the default). | | |
| ⋯ | |||
| 92 | 93 | the environment's [protection rules](/guides/actions/#environments) let it | |
| 93 | 94 | through: required reviewers, a wait timer, and which branches may deploy. | |
| 94 | 95 | ||
| 96 | + | ## Brokered secrets | |
| 97 | + | ||
| 98 | + | A secret a job uses only to call an API need not enter the job at all. | |
| 99 | + | Broker it, and the job gets a stand-in in its place: a token such as | |
| 100 | + | `g1tsec_3f9c…` (48 random characters after the prefix), made for that job | |
| 101 | + | and that secret alone. As the job's requests leave its sandbox, g1t puts | |
| 102 | + | the real value in place of the stand-in on requests to the hosts the | |
| 103 | + | secret is for. Code in the job, a compromised dependency included, can | |
| 104 | + | read only the stand-in, which works nowhere else and stops working when | |
| 105 | + | the job ends. | |
| 106 | + | ||
| 107 | + | To broker a secret: | |
| 108 | + | ||
| 109 | + | 1. Open **Settings → Secrets and variables**, and add or edit the secret. | |
| 110 | + | 2. Under **Brokered**, tick **Only for these hosts**. | |
| 111 | + | 3. List the hosts it is for, one per line, as for | |
| 112 | + | [allowed domains](/guides/guardrails/#network): `registry.npmjs.org`, | |
| 113 | + | or `*.example.com` for every subdomain of `example.com`. | |
| 114 | + | 4. Save. | |
| 115 | + | ||
| 116 | + | Workflows use it as they would any secret: `${{ secrets.NPM_TOKEN }}` in an | |
| 117 | + | `Authorization` header, an `.npmrc`, or a tool's settings. | |
| 118 | + | ||
| 119 | + | ```yaml | |
| 120 | + | - run: npm publish | |
| 121 | + | env: | |
| 122 | + | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} | |
| 123 | + | ``` | |
| 124 | + | ||
| 125 | + | ### Where the value is filled in | |
| 126 | + | ||
| 127 | + | | In the request | | | |
| 128 | + | | --- | --- | | |
| 129 | + | | Any header | As it is: `Authorization: Bearer …`, `Authorization: token …`, `X-Api-Key: …` | | |
| 130 | + | | `Authorization: Basic` | Decoded, filled in, and encoded again, so `user:$TOKEN` works | | |
| 131 | + | | The query string | `?key=…`, encoded for the URL | | |
| 132 | + | | The path and the body | Never: the stand-in goes as it is | | |
| 133 | + | ||
| 134 | + | A request to a host the secret is not for goes out with the stand-in, | |
| 135 | + | which is useless to whoever receives it, and the job's log says so once: | |
| 136 | + | `NPM_TOKEN was sent to example.com, which it is not for, so g1t did not | |
| 137 | + | fill it in.` A host must also be one the job may reach (its project's | |
| 138 | + | allowed domains, what builds need, or a | |
| 139 | + | [workflow-only domain](/guides/guardrails/#workflow-only-domains)): | |
| 140 | + | brokering a secret for a host does not open the network to it. | |
| 141 | + | ||
| 142 | + | If an answer echoes the value back, it is swapped for the stand-in again | |
| 143 | + | in the answer's headers, and in its body when that is plain text (JSON, | |
| 144 | + | XML, text) of at most 256 KB sent uncompressed. A request with a filled-in | |
| 145 | + | value is not followed through redirects outside the sandbox: the job | |
| 146 | + | follows them itself, and each is filled in only for the secret's hosts. | |
| 147 | + | ||
| 148 | + | ### Where it works | |
| 149 | + | ||
| 150 | + | - **Workflow jobs on g1t's machines.** Brokering works whether the | |
| 151 | + | project's network is restricted or open: a job that may be given a | |
| 152 | + | brokered secret sends its HTTP and HTTPS through g1t either way, and | |
| 153 | + | trusts the certificate HTTPS is re-signed with, as a restricted one does. | |
| 154 | + | Other protocols are not affected. | |
| 155 | + | - **Self-hosted runners: not yet.** Their traffic never passes through | |
| 156 | + | g1t, so a job there gets the stand-in too, never the value, and its log | |
| 157 | + | warns that requests using it will not be signed in. Keep a separate, | |
| 158 | + | unbrokered secret for jobs that must run there. | |
| 159 | + | - **Deployments** never read a brokered secret: a running app needs the | |
| 160 | + | value itself. A brokered secret is available to workflows only. | |
| 161 | + | - **Security updates' private registries** do not get a brokered secret. | |
| 162 | + | ||
| 163 | + | The value is masked in the job's log as any secret is, should it ever | |
| 164 | + | appear there, and so is the stand-in. | |
| 165 | + | ||
| 166 | + | ### What brokering cannot cover | |
| 167 | + | ||
| 168 | + | A secret the job itself must read cannot be brokered. Keep these as | |
| 169 | + | ordinary secrets: | |
| 170 | + | ||
| 171 | + | - passwords for a database or service reached over raw TCP rather than | |
| 172 | + | HTTP, such as PostgreSQL or Redis; | |
| 173 | + | - signing keys (code signing, GPG, SSH, a release's private key), which | |
| 174 | + | the job uses to sign rather than sends; | |
| 175 | + | - keys to files the job decrypts; | |
| 176 | + | - tests that need the real value inside the job; | |
| 177 | + | - a value sent in a request's body or path, such as an OAuth client | |
| 178 | + | secret posted in a form. | |
| 179 | + | ||
| 180 | + | A brokered value is at least 8 characters, so it is not found inside | |
| 181 | + | other secrets by chance. | |
| 182 | + | ||
| 95 | 183 | ## G1T_TOKEN | |
| 96 | 184 | ||
| 97 | 185 | Every workflow job gets `${{ secrets.G1T_TOKEN }}`: a token for that job | |
| ⋯ | |||
| 140 | 228 | | `environments` | `["production"]`, `["preview", "staging"]`; `[]` for all. | | |
| 141 | 229 | | `projects` | A workspace's row: project names; `[]` for every one. (`repositories` is read the same way.) | | |
| 142 | 230 | | `note` | Where to rotate it, or who to ask. | | |
| 231 | + | | `brokered_hosts` | A secret's: `["registry.npmjs.org"]`, `["*.example.com"]` to [broker it](#brokered-secrets); `[]` stops brokering it. Listed rows return it too. | | |
| 143 | 232 | ||
| 144 | 233 | ```sh | |
| 145 | 234 | curl -X PUT https://api.g1t.sh/repos/acme/web/actions/secrets/STRIPE_KEY \ | |
| 561 | 561 | | Action | What it does | Required | Scope | | |
| 562 | 562 | | --- | --- | --- | --- | | |
| 563 | 563 | | [`list_secrets`](/reference/api/secrets-and-variables/list-actions-secrets/) | Secrets' rows: key, environments, who reads them. Never values. | None | `secrets:read` | | |
| 564 | − | | [`set_secret`](/reference/api/secrets-and-variables/set-actions-secret/) | Add or change a secret's row: `value`, and optionally `id`, `environments`, `available_to`, `projects`, `note`. | `setting` | `secrets:admin` | | |
| 564 | + | | [`set_secret`](/reference/api/secrets-and-variables/set-actions-secret/) | Add or change a secret's row: `value`, and optionally `id`, `environments`, `available_to`, `projects`, `note`, and `brokered_hosts` to broker it (jobs get a stand-in; g1t fills in the value on requests to those hosts). | `setting` | `secrets:admin` | | |
| 565 | 565 | | [`delete_secret`](/reference/api/secrets-and-variables/delete-actions-secret/) | Remove one row (`id`) or every row of the key. | `setting` | `secrets:admin` | | |
| 566 | 566 | | [`list_variables`](/reference/api/secrets-and-variables/list-actions-variables/) | Config rows with their values. | None | `secrets:read` | | |
| 567 | 567 | | [`set_variable`](/reference/api/secrets-and-variables/set-actions-variable/) | Add or change a config row, as for secrets. | `setting` | `secrets:admin` | |
Binary or large file; its contents are not shown.
| 13 | 13 | ||
| 14 | 14 | import type { SecretsAction, SecretsData } from "../lib/secrets.server"; | |
| 15 | 15 | import { ButtonLink, EmptyState, ErrorText, SubmitButton, TimeAgo } from "./ui"; | |
| 16 | + | import { Badge } from "./ui/badge"; | |
| 16 | 17 | import { Card } from "./ui/card"; | |
| 17 | 18 | import { CheckboxOption } from "./ui/checkbox"; | |
| 18 | 19 | import { Hint } from "./ui/hint"; | |
| ⋯ | |||
| 165 | 166 | ||
| 166 | 167 | function Row({ row, inherited, manage }: { row: Setting; inherited: boolean; manage: boolean }) { | |
| 167 | 168 | const secret = row.kind === "secret"; | |
| 169 | + | const hosts = row.brokeredHosts ?? []; | |
| 168 | 170 | return ( | |
| 169 | 171 | <li className="grid grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)_auto] items-center gap-x-4 gap-y-1 border-t border-line px-4 py-3 text-sm first:border-t-0 md:grid-cols-[minmax(0,1.4fr)_minmax(0,1fr)_minmax(0,1fr)_6rem_6rem_auto]"> | |
| 170 | 172 | <div className="min-w-0"> | |
| 171 | − | <p className="truncate font-mono text-[0.8125rem]">{row.name}</p> | |
| 173 | + | <div className="flex min-w-0 items-center gap-2"> | |
| 174 | + | <p className="truncate font-mono text-[0.8125rem]">{row.name}</p> | |
| 175 | + | {secret && hosts.length > 0 && ( | |
| 176 | + | <Hint label={`Filled in for ${hosts.join(", ")}`}> | |
| 177 | + | <Badge tone="accent"> | |
| 178 | + | Brokered | |
| 179 | + | <span className="sr-only">: filled in for {hosts.join(", ")}</span> | |
| 180 | + | </Badge> | |
| 181 | + | </Hint> | |
| 182 | + | )} | |
| 183 | + | </div> | |
| 172 | 184 | {row.note && <p className="truncate text-xs text-faint">{row.note}</p>} | |
| 173 | 185 | {!secret && row.value != null && <p className="truncate font-mono text-xs text-muted">{row.value}</p>} | |
| 174 | 186 | </div> | |
| ⋯ | |||
| 244 | 256 | const [type, setType] = useState<"secret" | "config">(row?.kind === "variable" ? "config" : "secret"); | |
| 245 | 257 | const [some, setSome] = useState(!!row && row.environments.length > 0); | |
| 246 | 258 | const [reach, setReach] = useState(row && row.projects.length > 0 ? "some" : "all"); | |
| 259 | + | const [brokered, setBrokered] = useState((row?.brokeredHosts?.length ?? 0) > 0); | |
| 260 | + | // Only a secret is brokered, and only workflows read one. | |
| 261 | + | const brokering = type === "secret" && brokered; | |
| 247 | 262 | const custom = row?.environments.filter((env) => !KNOWN_ENVIRONMENTS.includes(env)) ?? []; | |
| 248 | 263 | const field = | |
| 249 | 264 | "w-full rounded-md border border-line bg-bg px-3 py-2 text-sm outline-none transition-colors placeholder:text-faint hover:border-line-strong focus:border-accent-dim"; | |
| ⋯ | |||
| 324 | 339 | /> | |
| 325 | 340 | </label> | |
| 326 | 341 | ||
| 342 | + | {type === "secret" && ( | |
| 343 | + | <fieldset> | |
| 344 | + | <legend className="mb-2 text-sm font-medium text-muted">Brokered</legend> | |
| 345 | + | <div className="space-y-2 text-sm"> | |
| 346 | + | <CheckboxOption | |
| 347 | + | name="brokered" | |
| 348 | + | checked={brokered} | |
| 349 | + | onCheckedChange={(checked) => setBrokered(checked === true)} | |
| 350 | + | label="Only for these hosts" | |
| 351 | + | description="The job gets a stand-in; g1t puts the real value on requests to these hosts, so it never enters the sandbox." | |
| 352 | + | /> | |
| 353 | + | {brokered && ( | |
| 354 | + | <div className="ml-6 space-y-2"> | |
| 355 | + | <textarea | |
| 356 | + | name="brokeredHosts" | |
| 357 | + | rows={2} | |
| 358 | + | required | |
| 359 | + | defaultValue={row?.brokeredHosts?.join("\n") ?? ""} | |
| 360 | + | placeholder={"api.example.com\n*.example.com"} | |
| 361 | + | autoComplete="off" | |
| 362 | + | spellCheck={false} | |
| 363 | + | aria-label="Hosts" | |
| 364 | + | className={`${field} font-mono`} | |
| 365 | + | /> | |
| 366 | + | <p className="text-xs text-faint"> | |
| 367 | + | One per line. Filled in in headers and the query string, for workflow jobs on g1t's machines.{" "} | |
| 368 | + | <a href="https://docs.g1t.sh/guides/secrets-and-variables/#brokered-secrets" className="text-fg hover:underline"> | |
| 369 | + | What it covers | |
| 370 | + | </a> | |
| 371 | + | </p> | |
| 372 | + | </div> | |
| 373 | + | )} | |
| 374 | + | </div> | |
| 375 | + | </fieldset> | |
| 376 | + | )} | |
| 377 | + | ||
| 327 | 378 | <label className="block"> | |
| 328 | 379 | <span className="mb-1.5 block text-sm font-medium text-muted">Note (optional)</span> | |
| 329 | 380 | <input name="note" defaultValue={row?.note ?? ""} placeholder="Where to rotate it, or who to ask" className={field} /> | |
| ⋯ | |||
| 370 | 421 | ["workflows", "Workflows", "secrets.KEY or vars.KEY in GitHub Actions workflows"], | |
| 371 | 422 | ["deployments", "Deployments", "The build's environment, and env.KEY in the running app"], | |
| 372 | 423 | ] as const | |
| 373 | − | ).map(([value, title, text]) => ( | |
| 374 | − | <CheckboxOption | |
| 375 | − | key={value} | |
| 376 | − | name="availableTo" | |
| 377 | − | value={value} | |
| 378 | − | defaultChecked={row ? row.availableTo.includes(value) : true} | |
| 379 | − | label={title} | |
| 380 | − | description={text} | |
| 381 | − | /> | |
| 382 | − | ))} | |
| 424 | + | ).map(([value, title, text]) => { | |
| 425 | + | // A deployment's app needs the value itself, so a brokered | |
| 426 | + | // secret is never one of its. | |
| 427 | + | const closed = brokering && value === "deployments"; | |
| 428 | + | return ( | |
| 429 | + | <CheckboxOption | |
| 430 | + | key={`${value}${closed ? "-closed" : ""}`} | |
| 431 | + | name="availableTo" | |
| 432 | + | value={value} | |
| 433 | + | defaultChecked={closed ? false : row ? row.availableTo.includes(value) : true} | |
| 434 | + | disabled={closed} | |
| 435 | + | label={title} | |
| 436 | + | description={closed ? "Not for a brokered secret: a deployment's app needs the value itself." : text} | |
| 437 | + | /> | |
| 438 | + | ); | |
| 439 | + | })} | |
| 383 | 440 | </div> | |
| 384 | 441 | </fieldset> | |
| 385 | 442 | ||
| 71 | 71 | if (availableTo.length === 0) return { error: "Choose who reads it: Workflows, Deployments, or both." }; | |
| 72 | 72 | const linked = | |
| 73 | 73 | "workspace" in owner && form.get("reach") === "some" ? form.getAll("project").map(String) : []; | |
| 74 | + | // A secret is brokered when it names hosts; unticked, it stops being. | |
| 75 | + | const brokeredHosts = | |
| 76 | + | kind === "secret" | |
| 77 | + | ? form.get("brokered") === "on" | |
| 78 | + | ? String(form.get("brokeredHosts") ?? "") | |
| 79 | + | .split(/[\s,]+/) | |
| 80 | + | .map((host) => host.trim()) | |
| 81 | + | .filter(Boolean) | |
| 82 | + | : [] | |
| 83 | + | : undefined; | |
| 84 | + | if (brokeredHosts && form.get("brokered") === "on" && brokeredHosts.length === 0) { | |
| 85 | + | return { error: "Name the hosts a brokered secret is for, such as api.example.com." }; | |
| 86 | + | } | |
| 74 | 87 | const note = String(form.get("note") ?? ""); | |
| 75 | 88 | const key = String(form.get("key") ?? "").trim(); | |
| 76 | 89 | const value = String(form.get("value") ?? ""); | |
| ⋯ | |||
| 86 | 99 | environments, | |
| 87 | 100 | projects: "workspace" in owner ? linked : undefined, | |
| 88 | 101 | note, | |
| 102 | + | brokeredHosts, | |
| 89 | 103 | }); | |
| 90 | 104 | if (!saved.ok) return { error: pasted.length > 0 ? `${name}: ${saved.error.message}` : saved.error.message }; | |
| 91 | 105 | } | |
| 595 | 595 | pub note: Option<String>, | |
| 596 | 596 | #[serde(default)] | |
| 597 | 597 | pub updated_by: Option<String>, | |
| 598 | + | /// A brokered secret's hosts: a job gets a stand-in for it, and g1t | |
| 599 | + | /// puts the real value on the job's requests to these hosts as they | |
| 600 | + | /// leave its sandbox. Empty for a secret that is not brokered, and for | |
| 601 | + | /// config. | |
| 602 | + | #[serde(default)] | |
| 603 | + | pub brokered_hosts: Vec<String>, | |
| 598 | 604 | } | |
| 599 | 605 | ||
| 606 | + | /// How a brokered secret's stand-in starts. The rest is 48 random hex | |
| 607 | + | /// characters, so the whole is shaped like a token and tools take it. | |
| 608 | + | pub const STAND_IN_PREFIX: &str = "g1tsec_"; | |
| 609 | + | ||
| 610 | + | /// A stand-in a running job was given for a brokered secret, as the runner | |
| 611 | + | /// Worker asks for them (`brokered_secrets`) to fill in the real value on | |
| 612 | + | /// the job's requests to `hosts`. | |
| 613 | + | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 614 | + | #[serde(rename_all = "camelCase")] | |
| 615 | + | pub struct BrokeredSecret { | |
| 616 | + | pub stand_in: String, | |
| 617 | + | /// The secret's key, for the job's log. | |
| 618 | + | pub name: String, | |
| 619 | + | pub value: String, | |
| 620 | + | pub hosts: Vec<String>, | |
| 621 | + | } | |
| 622 | + | ||
| 600 | 623 | // --- Methods --------------------------------------------------------------- | |
| 601 | 624 | ||
| 602 | 625 | /// `workflows`. Returns `Outcome<Vec<Workflow>>`. | |
| ⋯ | |||
| 783 | 806 | pub projects: Option<Vec<String>>, | |
| 784 | 807 | #[serde(default)] | |
| 785 | 808 | pub note: Option<String>, | |
| 809 | + | /// A secret's brokered hosts (`Setting::brokered_hosts`); empty stops | |
| 810 | + | /// brokering it. Left out, unchanged. | |
| 811 | + | #[serde(default, rename = "brokeredHosts")] | |
| 812 | + | pub brokered_hosts: Option<Vec<String>>, | |
| 786 | 813 | } | |
| 787 | 814 | ||
| 788 | 815 | /// `set_managed_workflow`: installs, or replaces, a managed workflow in a | |
| ⋯ | |||
| 991 | 1018 | /// `g1t-2core` or `g1t-4core`; absent, the standard one. | |
| 992 | 1019 | #[serde(default)] | |
| 993 | 1020 | pub instance: Option<String>, | |
| 1021 | + | /// Whether the job may be given brokered secrets, so the runner sends | |
| 1022 | + | /// its HTTP and HTTPS through the Worker even when its network is open. | |
| 1023 | + | #[serde(default)] | |
| 1024 | + | pub brokered: bool, | |
| 994 | 1025 | } | |
| 995 | 1026 | ||
| 996 | 1027 | /// A size of machine g1t runs workflow jobs on, asked for by a label in | |
| 340 | 340 | /** Where to rotate it, or who to ask. */ | |
| 341 | 341 | note: string | null; | |
| 342 | 342 | updatedBy: string | null; | |
| 343 | + | /** | |
| 344 | + | * A brokered secret's hosts (`api.example.com`, `*.example.com`): jobs get | |
| 345 | + | * a stand-in in place of its value, and g1t puts the real value on their | |
| 346 | + | * requests to these hosts as they leave the sandbox. Empty when it is not | |
| 347 | + | * brokered. Absent from a service older than brokering. | |
| 348 | + | */ | |
| 349 | + | brokeredHosts?: string[]; | |
| 343 | 350 | }; | |
| 344 | 351 | ||
| 352 | + | /** | |
| 353 | + | * A stand-in a running job was given for a brokered secret (`g1tsec_` and | |
| 354 | + | * 48 hex characters), as the actions service's `brokered_secrets` answers | |
| 355 | + | * the runner Worker, which fills in `value` on the job's requests to | |
| 356 | + | * `hosts`. | |
| 357 | + | */ | |
| 358 | + | export type BrokeredSecret = { standIn: string; name: string; value: string; hosts: string[] }; | |
| 359 | + | ||
| 345 | 360 | /** What saving a row sets beyond its value; left out is unchanged. */ | |
| 346 | 361 | export type SettingOptions = { | |
| 347 | 362 | /** The row to change; left out, the key's row for every environment. */ | |
| ⋯ | |||
| 351 | 366 | /** A workspace's row: project slugs; empty for every one. */ | |
| 352 | 367 | projects?: string[]; | |
| 353 | 368 | note?: string; | |
| 369 | + | /** A secret's brokered hosts; empty stops brokering it. */ | |
| 370 | + | brokeredHosts?: string[]; | |
| 354 | 371 | }; | |
| 355 | 372 | ||
| 356 | 373 | export type SettingsOwner = { repo: RepoPath } | { workspace: string }; | |
| 1 | + | -- Brokered secrets. A secret row with hosts is brokered: a workflow job is | |
| 2 | + | -- given a stand-in for it in place of its value, and the runner Worker puts | |
| 3 | + | -- the value on the job's requests to those hosts as they leave its sandbox. | |
| 4 | + | -- The hosts are a JSON array of domains (`api.example.com`, | |
| 5 | + | -- `*.example.com`); null for a secret that is not brokered. | |
| 6 | + | ALTER TABLE settings ADD COLUMN brokered_hosts TEXT; | |
| 7 | + | ||
| 8 | + | -- The stand-ins jobs were given: each a random token unique to one job and | |
| 9 | + | -- one secret, its value sealed and bound to the stand-in, the hosts it is | |
| 10 | + | -- filled in for, and when it stops working (the job's time limit and ten | |
| 11 | + | -- minutes, in milliseconds since the epoch). Only a running job's own are | |
| 12 | + | -- ever answered, with its token. | |
| 13 | + | CREATE TABLE brokered_secrets ( | |
| 14 | + | stand_in TEXT PRIMARY KEY, | |
| 15 | + | job_id TEXT NOT NULL, | |
| 16 | + | name TEXT NOT NULL, | |
| 17 | + | value TEXT NOT NULL, | |
| 18 | + | hosts TEXT NOT NULL, | |
| 19 | + | expires_at INTEGER NOT NULL | |
| 20 | + | ); | |
| 21 | + | CREATE INDEX brokered_secrets_by_job ON brokered_secrets (job_id); | |
| 22 | + | CREATE INDEX brokered_secrets_by_expiry ON brokered_secrets (expires_at); | |
| 23 | + | ||
| 24 | + | -- Whether a job was given any, so its log is masked for their values too | |
| 25 | + | -- without asking on every report. | |
| 26 | + | ALTER TABLE jobs ADD COLUMN brokered INTEGER NOT NULL DEFAULT 0; |
| 1 | + | //! Brokered secrets: a secret a job uses only to call an API never enters | |
| 2 | + | //! its sandbox. Its row names the hosts it is for (settings.rs); when a job | |
| 3 | + | //! starts, `job_spec` gives the job a stand-in in its place, one per job and | |
| 4 | + | //! secret, and keeps here what the stand-in stands for, sealed and bound to | |
| 5 | + | //! it, until the job's time is up. The runner Worker sees every HTTP and | |
| 6 | + | //! HTTPS request a job on g1t's machines makes (services/runner broker.ts), | |
| 7 | + | //! asks `brokered_secrets` with the job's token for its stand-ins, and puts | |
| 8 | + | //! the real value in place of a stand-in on requests to the secret's hosts | |
| 9 | + | //! as they leave. Anywhere else the stand-in goes as it is, which is | |
| 10 | + | //! useless to whoever receives it. | |
| 11 | + | //! | |
| 12 | + | //! A self-hosted runner's traffic never passes the Worker, so a job there | |
| 13 | + | //! gets the stand-in too, and a line in its log saying why it will not | |
| 14 | + | //! work: the real value is never sent to a machine g1t does not run. | |
| 15 | + | ||
| 16 | + | use g1t_contracts::actions::{BrokeredSecret, JobCallArgs, STAND_IN_PREFIX}; | |
| 17 | + | use g1t_contracts::time::rfc3339; | |
| 18 | + | use g1t_contracts::Outcome; | |
| 19 | + | use g1t_kit::now_ms; | |
| 20 | + | use g1t_secrets::random_hex; | |
| 21 | + | use serde::Deserialize; | |
| 22 | + | use serde_json::{Map, Value}; | |
| 23 | + | use worker::Result; | |
| 24 | + | use worker::wasm_bindgen::JsValue; | |
| 25 | + | ||
| 26 | + | use crate::plan::LOG_INSERT; | |
| 27 | + | use crate::{Actions, Count, check}; | |
| 28 | + | ||
| 29 | + | /// The most hosts one brokered secret names. | |
| 30 | + | pub(crate) const MAX_HOSTS: usize = 20; | |
| 31 | + | /// The shortest value a brokered secret may have: a shorter one would be | |
| 32 | + | /// found, and swapped, inside unrelated secrets. | |
| 33 | + | pub(crate) const MIN_VALUE_CHARS: usize = 8; | |
| 34 | + | ||
| 35 | + | /// Keeps a job's stand-in: what it stands for, sealed and bound to it. | |
| 36 | + | pub(crate) const STAND_IN_INSERT: &str = | |
| 37 | + | "INSERT OR REPLACE INTO brokered_secrets (stand_in, job_id, name, value, hosts, expires_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)"; | |
| 38 | + | /// A job's stand-ins that still work at `?2`. | |
| 39 | + | pub(crate) const STAND_INS_OF_JOB: &str = | |
| 40 | + | "SELECT stand_in, name, value, hosts FROM brokered_secrets WHERE job_id = ?1 AND expires_at > ?2"; | |
| 41 | + | /// Stand-ins whose jobs' time is up. | |
| 42 | + | pub(crate) const STAND_INS_EXPIRED: &str = "DELETE FROM brokered_secrets WHERE expires_at <= ?1"; | |
| 43 | + | ||
| 44 | + | /// A brokered secret as one reader resolved it: its key, real value and | |
| 45 | + | /// the hosts it is for. | |
| 46 | + | #[derive(Clone, Debug, PartialEq)] | |
| 47 | + | pub(crate) struct Broker { | |
| 48 | + | pub name: String, | |
| 49 | + | pub value: String, | |
| 50 | + | pub hosts: Vec<String>, | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | /// A stand-in made for one job. | |
| 54 | + | #[derive(Clone, Debug, PartialEq)] | |
| 55 | + | pub(crate) struct StandIn { | |
| 56 | + | pub stand_in: String, | |
| 57 | + | pub broker: Broker, | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | /// A new stand-in: the prefix and 48 random hex characters. | |
| 61 | + | pub(crate) fn stand_in() -> String { | |
| 62 | + | format!("{STAND_IN_PREFIX}{}", random_hex(24)) | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | /// Puts a stand-in in place of each brokered value wherever it appears in | |
| 66 | + | /// `secrets`, as a whole value or inside one a called workflow built from | |
| 67 | + | /// it (`${{ secrets.USER }}:${{ secrets.TOKEN }}`), and returns the | |
| 68 | + | /// stand-ins made: one per brokered value found, whatever keys it is under. | |
| 69 | + | /// Longer values go first, so one that holds another is replaced whole. | |
| 70 | + | pub(crate) fn substitute(secrets: &mut Map<String, Value>, brokers: &[Broker], mut make: impl FnMut() -> String) -> Vec<StandIn> { | |
| 71 | + | let mut ordered: Vec<&Broker> = brokers.iter().filter(|b| b.value.chars().count() >= MIN_VALUE_CHARS).collect(); | |
| 72 | + | ordered.sort_by_key(|b| std::cmp::Reverse(b.value.len())); | |
| 73 | + | let mut made: Vec<StandIn> = Vec::new(); | |
| 74 | + | for broker in ordered { | |
| 75 | + | if made.iter().any(|m| m.broker.value == broker.value) { | |
| 76 | + | continue; | |
| 77 | + | } | |
| 78 | + | let mut stand_in: Option<String> = None; | |
| 79 | + | for value in secrets.values_mut() { | |
| 80 | + | let Value::String(text) = value else { continue }; | |
| 81 | + | if text.contains(&broker.value) { | |
| 82 | + | let with = stand_in.get_or_insert_with(&mut make); | |
| 83 | + | *text = text.replace(&broker.value, with); | |
| 84 | + | } | |
| 85 | + | } | |
| 86 | + | if let Some(stand_in) = stand_in { | |
| 87 | + | made.push(StandIn { stand_in, broker: broker.clone() }); | |
| 88 | + | } | |
| 89 | + | } | |
| 90 | + | made | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | /// What a job on a self-hosted runner is told for each brokered secret it | |
| 94 | + | /// was given. | |
| 95 | + | pub(crate) fn self_hosted_note(name: &str) -> String { | |
| 96 | + | format!( | |
| 97 | + | "##[warning]{name} is a brokered secret: this job has a stand-in for it, which g1t fills in only on its own machines for now. On a self-hosted runner, requests that use it will not be signed in." | |
| 98 | + | ) | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | /// A line of a job's log, stamped as the runner stamps its own. | |
| 102 | + | pub(crate) fn stamped(text: &str) -> String { | |
| 103 | + | let at = rfc3339(now_ms()); | |
| 104 | + | text.lines().map(|line| format!("{at} {line}\n")).collect() | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | #[derive(Deserialize)] | |
| 108 | + | struct StandInRow { | |
| 109 | + | stand_in: String, | |
| 110 | + | name: String, | |
| 111 | + | value: String, | |
| 112 | + | hosts: String, | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | impl Actions { | |
| 116 | + | /// Keeps a job's stand-ins until its time is up, and marks the job so | |
| 117 | + | /// its log is masked for their values. Stand-ins whose time is up go. | |
| 118 | + | pub(crate) async fn keep_stand_ins(&self, job_id: &str, timeout_minutes: u32, stand_ins: &[StandIn]) -> Result<()> { | |
| 119 | + | let Some(sealer) = &self.sealer else { return Ok(()) }; | |
| 120 | + | let now = now_ms(); | |
| 121 | + | let expires = now + (u64::from(timeout_minutes) * 60 + 600) * 1000; | |
| 122 | + | let mut statements = vec![self.db.prepare(STAND_INS_EXPIRED).bind(&[(now as f64).into()])?]; | |
| 123 | + | for made in stand_ins { | |
| 124 | + | statements.push(self.db.prepare(STAND_IN_INSERT).bind(&[ | |
| 125 | + | made.stand_in.as_str().into(), | |
| 126 | + | job_id.into(), | |
| 127 | + | made.broker.name.as_str().into(), | |
| 128 | + | sealer.seal(&made.broker.value, &made.stand_in).into(), | |
| 129 | + | serde_json::to_string(&made.broker.hosts)?.into(), | |
| 130 | + | (expires as f64).into(), | |
| 131 | + | ])?); | |
| 132 | + | } | |
| 133 | + | statements.push(self.db.prepare("UPDATE jobs SET brokered = 1 WHERE id = ?").bind(&[job_id.into()])?); | |
| 134 | + | self.db.batch(statements).await?; | |
| 135 | + | Ok(()) | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | /// A job's stand-ins that still work, opened. | |
| 139 | + | pub(crate) async fn stand_ins_of(&self, job_id: &str) -> Result<Vec<BrokeredSecret>> { | |
| 140 | + | let Some(sealer) = &self.sealer else { return Ok(Vec::new()) }; | |
| 141 | + | let rows = self | |
| 142 | + | .db | |
| 143 | + | .prepare(STAND_INS_OF_JOB) | |
| 144 | + | .bind(&[job_id.into(), (now_ms() as f64).into()])? | |
| 145 | + | .all() | |
| 146 | + | .await? | |
| 147 | + | .results::<StandInRow>()?; | |
| 148 | + | Ok(rows | |
| 149 | + | .into_iter() | |
| 150 | + | .filter_map(|row| { | |
| 151 | + | Some(BrokeredSecret { | |
| 152 | + | value: sealer.open(&row.value, &row.stand_in)?, | |
| 153 | + | hosts: serde_json::from_str(&row.hosts).unwrap_or_default(), | |
| 154 | + | name: row.name, | |
| 155 | + | stand_in: row.stand_in, | |
| 156 | + | }) | |
| 157 | + | }) | |
| 158 | + | .collect()) | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | /// Whether a job in the repository could be given a brokered secret: | |
| 162 | + | /// its project or its workspace has one. Asked as a job starts, so the | |
| 163 | + | /// runner sends the job's HTTP through the Worker even when its network | |
| 164 | + | /// is open; a job that turns out to get none just goes the long way. | |
| 165 | + | pub(crate) async fn may_broker(&self, repo_id: &str, namespace: &str) -> Result<bool> { | |
| 166 | + | let Some(project) = self.project_of(repo_id).await? else { return Ok(false) }; | |
| 167 | + | let found = self | |
| 168 | + | .db | |
| 169 | + | .prepare("SELECT COUNT(*) AS n FROM settings WHERE kind = 'secret' AND brokered_hosts IS NOT NULL AND owner IN (?, ?)") | |
| 170 | + | .bind(&[project.id.as_str().into(), namespace.to_lowercase().into()])? | |
| 171 | + | .first::<Count>(None) | |
| 172 | + | .await?; | |
| 173 | + | Ok(found.is_some_and(|count| count.n > 0)) | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | /// Adds g1t's own lines to a job's log, under `step` (0 for its setup). | |
| 177 | + | pub(crate) async fn note_log(&self, job_id: &str, step: u32, text: &str) -> Result<()> { | |
| 178 | + | self.db | |
| 179 | + | .prepare(LOG_INSERT) | |
| 180 | + | .bind(&[job_id.into(), step.into(), stamped(text).into(), JsValue::NULL])? | |
| 181 | + | .run() | |
| 182 | + | .await?; | |
| 183 | + | Ok(()) | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | /// `brokered_secrets`: a running job's stand-ins and what they stand | |
| 187 | + | /// for, for the runner Worker to fill in on the job's requests. Asked | |
| 188 | + | /// with the job's own token, which only the runner and the job hold; | |
| 189 | + | /// the API never passes this method on, so the job cannot ask it. | |
| 190 | + | pub async fn brokered_secrets(&self, a: JobCallArgs) -> Result<Outcome<Vec<BrokeredSecret>>> { | |
| 191 | + | let job = check!(self.job_for_token(&a).await?); | |
| 192 | + | Ok(Outcome::Ok(self.stand_ins_of(&job.id).await?)) | |
| 193 | + | } | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | #[cfg(test)] | |
| 197 | + | mod tests { | |
| 198 | + | use rusqlite::{Connection, params}; | |
| 199 | + | use serde_json::{Map, Value, json}; | |
| 200 | + | ||
| 201 | + | use super::{Broker, STAND_IN_INSERT, STAND_INS_EXPIRED, STAND_INS_OF_JOB, self_hosted_note, stand_in, substitute}; | |
| 202 | + | ||
| 203 | + | fn broker(name: &str, value: &str, hosts: &[&str]) -> Broker { | |
| 204 | + | Broker { name: name.into(), value: value.into(), hosts: hosts.iter().map(|h| (*h).to_owned()).collect() } | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | fn counter() -> impl FnMut() -> String { | |
| 208 | + | let mut n = 0; | |
| 209 | + | move || { | |
| 210 | + | n += 1; | |
| 211 | + | format!("g1tsec_{n}") | |
| 212 | + | } | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | #[test] | |
| 216 | + | fn stand_ins_are_long_random_tokens() { | |
| 217 | + | let one = stand_in(); | |
| 218 | + | assert!(one.starts_with("g1tsec_")); | |
| 219 | + | assert_eq!(one.len(), "g1tsec_".len() + 48); | |
| 220 | + | assert!(one["g1tsec_".len()..].chars().all(|c| c.is_ascii_hexdigit())); | |
| 221 | + | assert_ne!(one, stand_in()); | |
| 222 | + | } | |
| 223 | + | ||
| 224 | + | #[test] | |
| 225 | + | fn a_brokered_value_never_reaches_the_job() { | |
| 226 | + | let mut secrets: Map<String, Value> = json!({ | |
| 227 | + | "NPM_TOKEN": "npm_realvalue123", | |
| 228 | + | "ALSO_NPM": "npm_realvalue123", | |
| 229 | + | "BASIC": "me:npm_realvalue123", | |
| 230 | + | "PLAIN": "not-brokered-value", | |
| 231 | + | }) | |
| 232 | + | .as_object() | |
| 233 | + | .cloned() | |
| 234 | + | .unwrap(); | |
| 235 | + | let made = substitute(&mut secrets, &[broker("NPM_TOKEN", "npm_realvalue123", &["registry.npmjs.org"])], counter()); | |
| 236 | + | assert_eq!(made.len(), 1); | |
| 237 | + | assert_eq!(made[0].stand_in, "g1tsec_1"); | |
| 238 | + | assert_eq!(made[0].broker.hosts, ["registry.npmjs.org"]); | |
| 239 | + | assert_eq!(secrets["NPM_TOKEN"], "g1tsec_1"); | |
| 240 | + | assert_eq!(secrets["ALSO_NPM"], "g1tsec_1"); | |
| 241 | + | assert_eq!(secrets["BASIC"], "me:g1tsec_1"); | |
| 242 | + | assert_eq!(secrets["PLAIN"], "not-brokered-value"); | |
| 243 | + | assert!(!serde_json::to_string(&secrets).unwrap().contains("npm_realvalue123")); | |
| 244 | + | } | |
| 245 | + | ||
| 246 | + | #[test] | |
| 247 | + | fn each_secret_gets_its_own_stand_in_and_longer_values_go_first() { | |
| 248 | + | let mut secrets: Map<String, Value> = | |
| 249 | + | json!({ "SHORT": "abcdefgh", "LONG": "abcdefgh-and-more" }).as_object().cloned().unwrap(); | |
| 250 | + | let made = substitute( | |
| 251 | + | &mut secrets, | |
| 252 | + | &[broker("SHORT", "abcdefgh", &["a.example.com"]), broker("LONG", "abcdefgh-and-more", &["b.example.com"])], | |
| 253 | + | counter(), | |
| 254 | + | ); | |
| 255 | + | assert_eq!(made.len(), 2); | |
| 256 | + | assert_eq!(made[0].broker.name, "LONG"); | |
| 257 | + | assert_eq!(secrets["LONG"], "g1tsec_1"); | |
| 258 | + | assert_eq!(secrets["SHORT"], "g1tsec_2"); | |
| 259 | + | } | |
| 260 | + | ||
| 261 | + | #[test] | |
| 262 | + | fn values_too_short_or_not_given_make_no_stand_in() { | |
| 263 | + | let mut secrets: Map<String, Value> = json!({ "PIN": "1234" }).as_object().cloned().unwrap(); | |
| 264 | + | assert!(substitute(&mut secrets, &[broker("PIN", "1234", &["x.example.com"])], counter()).is_empty()); | |
| 265 | + | assert!(substitute(&mut secrets, &[broker("GONE", "absent-value", &["x.example.com"])], counter()).is_empty()); | |
| 266 | + | assert_eq!(secrets["PIN"], "1234"); | |
| 267 | + | } | |
| 268 | + | ||
| 269 | + | #[test] | |
| 270 | + | fn the_self_hosted_note_is_a_warning_naming_the_secret() { | |
| 271 | + | let note = self_hosted_note("NPM_TOKEN"); | |
| 272 | + | assert!(note.starts_with("##[warning]NPM_TOKEN")); | |
| 273 | + | assert!(note.contains("only on its own machines")); | |
| 274 | + | } | |
| 275 | + | ||
| 276 | + | fn database() -> Connection { | |
| 277 | + | let db = Connection::open_in_memory().unwrap(); | |
| 278 | + | for name in ["0001_init.sql", "0019_brokered_secrets.sql"] { | |
| 279 | + | let path = format!("{}/migrations/{name}", env!("CARGO_MANIFEST_DIR")); | |
| 280 | + | db.execute_batch(&std::fs::read_to_string(path).unwrap()).unwrap(); | |
| 281 | + | } | |
| 282 | + | db | |
| 283 | + | } | |
| 284 | + | ||
| 285 | + | #[test] | |
| 286 | + | fn a_job_finds_only_its_own_stand_ins_while_they_work() { | |
| 287 | + | let db = database(); | |
| 288 | + | let keep = |stand_in: &str, job: &str, expires: i64| { | |
| 289 | + | db.execute(STAND_IN_INSERT, params![stand_in, job, "NPM_TOKEN", "sealed", "[\"registry.npmjs.org\"]", expires]).unwrap() | |
| 290 | + | }; | |
| 291 | + | keep("g1tsec_a", "job_1", 2_000); | |
| 292 | + | keep("g1tsec_b", "job_2", 2_000); | |
| 293 | + | keep("g1tsec_c", "job_1", 500); | |
| 294 | + | let of = |job: &str, now: i64| -> Vec<String> { | |
| 295 | + | let mut rows = db.prepare(STAND_INS_OF_JOB).unwrap(); | |
| 296 | + | rows.query_map(params![job, now], |row| row.get::<_, String>(0)).unwrap().map(Result::unwrap).collect() | |
| 297 | + | }; | |
| 298 | + | assert_eq!(of("job_1", 1_000), ["g1tsec_a"]); | |
| 299 | + | assert_eq!(of("job_2", 1_000), ["g1tsec_b"]); | |
| 300 | + | assert!(of("job_1", 3_000).is_empty()); | |
| 301 | + | assert_eq!(db.execute(STAND_INS_EXPIRED, params![1_000]).unwrap(), 1); | |
| 302 | + | // Settings rows and jobs carry the new columns. | |
| 303 | + | db.execute("UPDATE settings SET brokered_hosts = '[]'", []).unwrap(); | |
| 304 | + | db.execute("UPDATE jobs SET brokered = 1", []).unwrap(); | |
| 305 | + | } | |
| 306 | + | } |
| 9 | 9 | //! is skipped or expanded into its matrix, queued, started in a sandbox | |
| 10 | 10 | //! when the workspace has room, and finished by the sandbox's report. | |
| 11 | 11 | //! - [`payload`] builds the webhook-shaped `github.event`. | |
| 12 | − | //! - [`settings`] keeps secrets and variables. | |
| 12 | + | //! - [`settings`] keeps secrets and variables, and [`broker`] the stand-ins | |
| 13 | + | //! jobs are given for brokered secrets. | |
| 13 | 14 | //! - [`protection`] keeps a repository's workflow settings and its | |
| 14 | 15 | //! environments' protection rules, holds jobs at those rules until they | |
| 15 | 16 | //! pass, and lets pull requests' runs from outside wait for approval. | |
| ⋯ | |||
| 26 | 27 | //! `permissions:` give it, revoked when the job ends. | |
| 27 | 28 | ||
| 28 | 29 | mod run_artifacts; | |
| 30 | + | mod broker; | |
| 29 | 31 | mod cache; | |
| 30 | 32 | mod managed; | |
| 31 | 33 | mod mirrored; | |
| ⋯ | |||
| 271 | 273 | "job_action_archive" => reply(&service.job_action_archive(args(body)?).await?), | |
| 272 | 274 | "check_runs" => reply(&service.check_runs(args(body)?).await?), | |
| 273 | 275 | "job_report" => reply(&service.job_report(args(body)?).await?), | |
| 276 | + | // A running job's stand-ins for brokered secrets: the runner | |
| 277 | + | // Worker's alone, never passed on by the API. | |
| 278 | + | "brokered_secrets" => reply(&service.brokered_secrets(args(body)?).await?), | |
| 274 | 279 | // actions/cache, through the API with the job's token. | |
| 275 | 280 | "cache_lookup" => reply(&service.cache_lookup(args(body)?).await?), | |
| 276 | 281 | "cache_reserve" => reply(&service.cache_reserve(args(body)?).await?), | |
| 19 | 19 | use serde_json::{Map, Value, json}; | |
| 20 | 20 | use worker::Result; | |
| 21 | 21 | ||
| 22 | + | use crate::broker::{self, Broker}; | |
| 22 | 23 | use crate::protection::Gate; | |
| 23 | 24 | use crate::sync::WorkflowRow; | |
| 24 | 25 | use crate::{Actions, Count, MAX_TIMEOUT_MINUTES, RUNNING_PER_WORKSPACE, SELF_HOSTED_MAX_TIMEOUT_MINUTES, SILENT_MS, SITE, check, fail, optional, repo_path}; | |
| ⋯ | |||
| 34 | 35 | /// runner's number for the batch (null from runners that send none): a | |
| 35 | 36 | /// batch sent again, after an answer the runner did not hear, has the | |
| 36 | 37 | /// number of one already kept, and is not kept twice (migration 0016). | |
| 37 | − | const LOG_INSERT: &str = "INSERT INTO logs (job_id, seq, step, text, chunk) | |
| 38 | + | pub(crate) const LOG_INSERT: &str = "INSERT INTO logs (job_id, seq, step, text, chunk) | |
| 38 | 39 | SELECT ?1, COALESCE(MAX(seq), 0) + 1, ?2, ?3, ?4 FROM logs WHERE job_id = ?1 | |
| 39 | 40 | ON CONFLICT (job_id, chunk) DO NOTHING"; | |
| 40 | 41 | /// The most one step's job summary keeps, in bytes, and how many steps of a | |
| ⋯ | |||
| 231 | 232 | /// (migration 0009). | |
| 232 | 233 | #[serde(default)] | |
| 233 | 234 | pub cancel_requested_at: Option<String>, | |
| 235 | + | /// Whether it was given stand-ins for brokered secrets (migration | |
| 236 | + | /// 0019), so its log is masked for their values too. | |
| 237 | + | #[serde(default)] | |
| 238 | + | pub brokered: u32, | |
| 234 | 239 | } | |
| 235 | 240 | ||
| 236 | 241 | /// How long a cancelled job has to run its `if: always()` and `cancelled()` | |
| ⋯ | |||
| 1624 | 1629 | }); | |
| 1625 | 1630 | // Its environment and the machine it asked for, for the runner. | |
| 1626 | 1631 | let details = run.as_ref().map(|run| start_details(run, &job)).unwrap_or_default(); | |
| 1632 | + | let trusted = run.as_ref().is_some_and(|run| run.trusted != 0); | |
| 1633 | + | // Only a trusted run's jobs get secrets, brokered ones included. | |
| 1634 | + | let brokered = trusted && self.may_broker(&job.repo_id, &job.namespace).await?; | |
| 1627 | 1635 | let started: Outcome<Value> = g1t_kit::call( | |
| 1628 | 1636 | &self.runner, | |
| 1629 | 1637 | "start_actions_job", | |
| ⋯ | |||
| 1634 | 1642 | timeout_minutes: job.timeout_minutes, | |
| 1635 | 1643 | workflow: run.as_ref().map(|run| run.path.clone()), | |
| 1636 | 1644 | environment: details.environment, | |
| 1637 | − | trusted: run.as_ref().is_some_and(|run| run.trusted != 0), | |
| 1645 | + | trusted, | |
| 1638 | 1646 | instance: details.instance, | |
| 1647 | + | brokered, | |
| 1639 | 1648 | }, | |
| 1640 | 1649 | ) | |
| 1641 | 1650 | .await | |
| ⋯ | |||
| 2333 | 2342 | // A run that is not trusted (a pull request from outside the | |
| 2334 | 2343 | // workspace) gets no secrets and an empty token. | |
| 2335 | 2344 | let passed = job.call().filter(|c| c["role"] == "callee").and_then(|c| c.get("secrets").cloned()); | |
| 2345 | + | // Brokered secrets among them (broker.rs), whatever keys they reach | |
| 2346 | + | // the job under. | |
| 2347 | + | let mut brokers: Vec<Broker> = Vec::new(); | |
| 2336 | 2348 | let mut secrets = match (trusted, passed) { | |
| 2337 | 2349 | (false, _) => Map::new(), | |
| 2338 | − | (true, None) => self.secrets_for(&run.repo_id, &run.repo, environment.as_deref(), true).await?, | |
| 2350 | + | (true, None) => { | |
| 2351 | + | let resolved = self.secrets_for(&run.repo_id, &run.repo, environment.as_deref(), true).await?; | |
| 2352 | + | brokers.extend(resolved.brokers()); | |
| 2353 | + | resolved.values | |
| 2354 | + | } | |
| 2339 | 2355 | // A called workflow's job: what its callers passed it (reach.rs), | |
| 2340 | 2356 | // and its own environment's secrets over them. | |
| 2341 | 2357 | (true, Some(plan)) => { | |
| 2342 | 2358 | let base = self.secrets_for(&run.repo_id, &run.repo, None, true).await?; | |
| 2359 | + | brokers.extend(base.brokers()); | |
| 2360 | + | let base = base.values; | |
| 2343 | 2361 | let vars = self.variables_for(&run.repo_id, &run.repo, None, true).await?; | |
| 2344 | 2362 | let github = run.info().context(&job.key, "", run.action.as_deref()); | |
| 2345 | 2363 | let mut passed = crate::reach::resolve_secrets(&plan, &base, &github, &vars); | |
| 2346 | 2364 | if let Some(name) = environment.as_deref() { | |
| 2347 | 2365 | let own = self.secrets_for(&run.repo_id, &run.repo, Some(name), true).await?; | |
| 2348 | − | for (key, value) in own { | |
| 2366 | + | brokers.extend(own.brokers()); | |
| 2367 | + | for (key, value) in own.values { | |
| 2349 | 2368 | if base.get(&key) != Some(&value) { | |
| 2350 | 2369 | passed.insert(key, value); | |
| 2351 | 2370 | } | |
| ⋯ | |||
| 2354 | 2373 | passed | |
| 2355 | 2374 | } | |
| 2356 | 2375 | }; | |
| 2376 | + | // A brokered secret's value never reaches the sandbox: the job gets | |
| 2377 | + | // a stand-in, which the runner Worker fills in on its requests to | |
| 2378 | + | // the secret's hosts. A self-hosted runner's requests never pass | |
| 2379 | + | // the Worker, so there it stays a stand-in, and the log says so. | |
| 2380 | + | let stand_ins = broker::substitute(&mut secrets, &brokers, broker::stand_in); | |
| 2381 | + | if !stand_ins.is_empty() { | |
| 2382 | + | self.keep_stand_ins(&job.id, job.timeout_minutes, &stand_ins).await?; | |
| 2383 | + | if job.runner_id.is_some() { | |
| 2384 | + | let mut names: Vec<&str> = stand_ins.iter().map(|made| made.broker.name.as_str()).collect(); | |
| 2385 | + | names.sort_unstable(); | |
| 2386 | + | let notes: String = names.iter().map(|name| broker::self_hosted_note(name) + "\n").collect(); | |
| 2387 | + | self.note_log(&job.id, 0, ¬es).await?; | |
| 2388 | + | } | |
| 2389 | + | } | |
| 2357 | 2390 | secrets.insert("G1T_TOKEN".into(), Value::String(token.clone())); | |
| 2358 | 2391 | secrets.insert("GITHUB_TOKEN".into(), Value::String(token.clone())); | |
| 2359 | 2392 | // Each secret as it is, a line at a time, base64 and JSON-escaped. | |
| ⋯ | |||
| 2535 | 2568 | .run() | |
| 2536 | 2569 | .await?; | |
| 2537 | 2570 | } | |
| 2571 | + | // A line from g1t itself, not the job: the runner Worker's note | |
| 2572 | + | // that a brokered secret went to a host it is not for. Put | |
| 2573 | + | // under the step that is running. | |
| 2574 | + | "note" => { | |
| 2575 | + | let text = report["text"].as_str().unwrap_or_default().chars().take(1000).collect::<String>(); | |
| 2576 | + | let steps: Vec<Value> = serde_json::from_str(&job.steps).unwrap_or_default(); | |
| 2577 | + | let step = steps.iter().rev().find(|step| step["status"] == "in_progress").and_then(|step| step["number"].as_u64()).unwrap_or(0); | |
| 2578 | + | if !text.trim().is_empty() { | |
| 2579 | + | self.note_log(&job.id, step as u32, &text).await?; | |
| 2580 | + | } | |
| 2581 | + | } | |
| 2538 | 2582 | "log" => { | |
| 2539 | 2583 | let mut text = report["text"].as_str().unwrap_or_default().to_owned(); | |
| 2584 | + | // A brokered secret's value never enters the sandbox, but an | |
| 2585 | + | // answer that echoed it whole could carry it in; it is | |
| 2586 | + | // masked here, where the value is known. | |
| 2587 | + | if job.brokered != 0 { | |
| 2588 | + | let values: Vec<String> = self.stand_ins_of(&job.id).await?.into_iter().map(|secret| secret.value).collect(); | |
| 2589 | + | if !values.is_empty() { | |
| 2590 | + | text = g1t_actions::mask::apply(&text, &g1t_actions::mask::all_variants(values.iter().map(String::as_str))); | |
| 2591 | + | } | |
| 2592 | + | } | |
| 2540 | 2593 | if text.len() > MAX_CHUNK_BYTES { | |
| 2541 | 2594 | let mut cut = MAX_CHUNK_BYTES; | |
| 2542 | 2595 | while !text.is_char_boundary(cut) { | |
| 14 | 14 | //! are the project's. Rows from before projects move over the first time | |
| 15 | 15 | //! they are touched. Names are upper-cased, as GitHub treats | |
| 16 | 16 | //! them without regard to case. Agents never read any. | |
| 17 | + | //! | |
| 18 | + | //! A secret may be brokered (broker.rs): its row names the hosts it is for, | |
| 19 | + | //! and workflow jobs get a stand-in in place of its value. Only workflows | |
| 20 | + | //! read a brokered secret: a deployment's app needs the value itself. | |
| 21 | + | ||
| 22 | + | use std::collections::BTreeMap; | |
| 17 | 23 | ||
| 18 | 24 | use g1t_contracts::access::Capability; | |
| 19 | 25 | use g1t_contracts::actions::{ | |
| 20 | 26 | CONSUMERS, DeleteSettingArgs, ResolveSettingsArgs, ResolvedSettings, SetSettingArgs, Setting, SettingsArgs, | |
| 21 | 27 | SettingsOwner, | |
| 22 | 28 | }; | |
| 29 | + | use g1t_contracts::guardrails::normalize_domain; | |
| 23 | 30 | use g1t_contracts::projects::{ByRepoArgs, ProjectRef}; | |
| 24 | 31 | use g1t_contracts::time::rfc3339; | |
| 25 | 32 | use g1t_contracts::{FailureCode, Outcome, PrincipalKind, Role, User, new_id}; | |
| ⋯ | |||
| 29 | 36 | use worker::Result; | |
| 30 | 37 | use worker::wasm_bindgen::JsValue; | |
| 31 | 38 | ||
| 39 | + | use crate::broker::{Broker, MAX_HOSTS, MIN_VALUE_CHARS}; | |
| 32 | 40 | use crate::{Actions, check, fail}; | |
| 33 | 41 | ||
| 34 | 42 | /// The largest value, as on GitHub. | |
| ⋯ | |||
| 49 | 57 | repositories: Option<String>, | |
| 50 | 58 | note: Option<String>, | |
| 51 | 59 | updated_by: Option<String>, | |
| 60 | + | /// A brokered secret's hosts, as a JSON array (migration 0019). | |
| 61 | + | #[serde(default)] | |
| 62 | + | brokered_hosts: Option<String>, | |
| 52 | 63 | } | |
| 53 | 64 | ||
| 54 | 65 | /// A name GitHub would accept: letters, digits and `_`, not starting with | |
| ⋯ | |||
| 107 | 118 | Ok(out) | |
| 108 | 119 | } | |
| 109 | 120 | ||
| 121 | + | /// A brokered secret's hosts, tidied as allowed domains are: each once, at | |
| 122 | + | /// most `MAX_HOSTS`. | |
| 123 | + | fn valid_hosts(list: &[String]) -> Result<Vec<String>, String> { | |
| 124 | + | let mut out: Vec<String> = Vec::new(); | |
| 125 | + | for host in list { | |
| 126 | + | if host.trim().is_empty() { | |
| 127 | + | continue; | |
| 128 | + | } | |
| 129 | + | let host = normalize_domain(host)?; | |
| 130 | + | if !out.contains(&host) { | |
| 131 | + | out.push(host); | |
| 132 | + | } | |
| 133 | + | } | |
| 134 | + | if out.len() > MAX_HOSTS { | |
| 135 | + | return Err(format!("A brokered secret is for at most {MAX_HOSTS} hosts.")); | |
| 136 | + | } | |
| 137 | + | Ok(out) | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | /// What readers of one kind get: each key's value, and the hosts of the | |
| 141 | + | /// brokered secrets among them. | |
| 142 | + | #[derive(Default)] | |
| 143 | + | pub(crate) struct Resolved { | |
| 144 | + | pub values: Map<String, Value>, | |
| 145 | + | pub brokered: BTreeMap<String, Vec<String>>, | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | impl Resolved { | |
| 149 | + | /// The brokered secrets among the values. | |
| 150 | + | pub(crate) fn brokers(&self) -> Vec<Broker> { | |
| 151 | + | self.brokered | |
| 152 | + | .iter() | |
| 153 | + | .filter_map(|(name, hosts)| { | |
| 154 | + | let value = self.values.get(name)?.as_str()?; | |
| 155 | + | Some(Broker { name: name.clone(), value: value.to_owned(), hosts: hosts.clone() }) | |
| 156 | + | }) | |
| 157 | + | .collect() | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | /// The values, less the brokered secrets: for readers that would put | |
| 161 | + | /// them where no stand-in can work. | |
| 162 | + | fn unbrokered(mut self) -> Map<String, Value> { | |
| 163 | + | for name in self.brokered.keys() { | |
| 164 | + | self.values.remove(name); | |
| 165 | + | } | |
| 166 | + | self.values | |
| 167 | + | } | |
| 168 | + | } | |
| 169 | + | ||
| 110 | 170 | fn split(list: &str) -> Vec<String> { | |
| 111 | 171 | list.split(',').filter(|s| !s.is_empty()).map(str::to_owned).collect() | |
| 112 | 172 | } | |
| ⋯ | |||
| 122 | 182 | self.repositories.as_deref().and_then(|json| serde_json::from_str(json).ok()).unwrap_or_default() | |
| 123 | 183 | } | |
| 124 | 184 | ||
| 185 | + | fn brokered_hosts(&self) -> Vec<String> { | |
| 186 | + | self.brokered_hosts.as_deref().and_then(|json| serde_json::from_str(json).ok()).unwrap_or_default() | |
| 187 | + | } | |
| 188 | + | ||
| 125 | 189 | fn reaches(&self, project: &str) -> bool { | |
| 126 | 190 | let list = self.projects(); | |
| 127 | 191 | list.is_empty() || list.iter().any(|p| p.eq_ignore_ascii_case(project)) | |
| ⋯ | |||
| 135 | 199 | ||
| 136 | 200 | fn describe(self) -> Setting { | |
| 137 | 201 | Setting { | |
| 202 | + | brokered_hosts: if self.kind == "secret" { self.brokered_hosts() } else { Vec::new() }, | |
| 138 | 203 | available_to: split(&self.available_to), | |
| 139 | 204 | environments: self.environments(), | |
| 140 | 205 | projects: self.projects(), | |
| ⋯ | |||
| 299 | 364 | Ok(environments) => environments, | |
| 300 | 365 | Err(problem) => return Ok(fail(FailureCode::Invalid, problem)), | |
| 301 | 366 | }; | |
| 367 | + | let brokered = match a.brokered_hosts.as_deref().map(valid_hosts).transpose() { | |
| 368 | + | Ok(hosts) => hosts, | |
| 369 | + | Err(problem) => return Ok(fail(FailureCode::Invalid, problem)), | |
| 370 | + | }; | |
| 371 | + | if kind == "variable" && brokered.as_ref().is_some_and(|hosts| !hosts.is_empty()) { | |
| 372 | + | return Ok(fail(FailureCode::Invalid, "Only a secret can be brokered.")); | |
| 373 | + | } | |
| 302 | 374 | let place = check!(self.place(&a.actor, &a.owner, true).await?); | |
| 303 | 375 | if a.projects.as_ref().is_some_and(|r| !r.is_empty()) && place.scope != "workspace" { | |
| 304 | 376 | return Ok(fail(FailureCode::Invalid, "Only a workspace's rows choose projects.")); | |
| ⋯ | |||
| 345 | 417 | (None, Some(row)) => row.value.clone(), | |
| 346 | 418 | (None, None) => return Ok(fail(FailureCode::Invalid, "A new row needs a `value`.")), | |
| 347 | 419 | }; | |
| 420 | + | let brokered: Vec<String> = match brokered { | |
| 421 | + | Some(hosts) => hosts, | |
| 422 | + | None => existing.filter(|_| kind == "secret").map(SettingRow::brokered_hosts).unwrap_or_default(), | |
| 423 | + | }; | |
| 424 | + | // A new brokered secret is read by workflows alone, unless it says | |
| 425 | + | // otherwise, which is refused below. | |
| 426 | + | let default_readers = if brokered.is_empty() { CONSUMERS.join(",") } else { "workflows".to_owned() }; | |
| 348 | 427 | let available_to = readers | |
| 349 | 428 | .map(|r| r.join(",")) | |
| 350 | 429 | .or_else(|| existing.map(|row| row.available_to.clone())) | |
| 351 | − | .unwrap_or_else(|| CONSUMERS.join(",")); | |
| 430 | + | .unwrap_or(default_readers); | |
| 431 | + | if !brokered.is_empty() { | |
| 432 | + | if split(&available_to).iter().any(|r| r == "deployments") { | |
| 433 | + | return Ok(fail( | |
| 434 | + | FailureCode::Invalid, | |
| 435 | + | "A brokered secret is read by workflows only: a deployment's app needs the value itself. Leave Deployments out, or stop brokering it.", | |
| 436 | + | )); | |
| 437 | + | } | |
| 438 | + | // Too short a value would be found inside other secrets. | |
| 439 | + | let plain = match (&a.value, existing) { | |
| 440 | + | (Some(value), _) => Some(value.clone()), | |
| 441 | + | (None, Some(row)) if row.kind == "secret" => self.sealer.as_ref().and_then(|sealer| sealer.open(&row.value, &row.id)), | |
| 442 | + | (None, Some(row)) => Some(row.value.clone()), | |
| 443 | + | (None, None) => None, | |
| 444 | + | }; | |
| 445 | + | if plain.is_some_and(|value| value.trim().chars().count() < MIN_VALUE_CHARS) { | |
| 446 | + | return Ok(fail(FailureCode::Invalid, format!("A brokered secret's value is at least {MIN_VALUE_CHARS} characters."))); | |
| 447 | + | } | |
| 448 | + | } | |
| 352 | 449 | let repositories: Option<String> = match &a.projects { | |
| 353 | 450 | Some(list) if list.is_empty() => None, | |
| 354 | 451 | Some(list) => Some(serde_json::to_string(list).unwrap_or_default()), | |
| ⋯ | |||
| 363 | 460 | let optional = |v: Option<&str>| v.map_or(JsValue::NULL, JsValue::from); | |
| 364 | 461 | self.db | |
| 365 | 462 | .prepare( | |
| 366 | − | "INSERT INTO settings (id, scope, owner, kind, name, value, updated_at, available_to, environments, repositories, note, updated_by) | |
| 367 | − | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) | |
| 463 | + | "INSERT INTO settings (id, scope, owner, kind, name, value, updated_at, available_to, environments, repositories, note, updated_by, brokered_hosts) | |
| 464 | + | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) | |
| 368 | 465 | ON CONFLICT (id) DO UPDATE SET kind = excluded.kind, value = excluded.value, updated_at = excluded.updated_at, | |
| 369 | 466 | available_to = excluded.available_to, environments = excluded.environments, | |
| 370 | − | repositories = excluded.repositories, note = excluded.note, updated_by = excluded.updated_by", | |
| 467 | + | repositories = excluded.repositories, note = excluded.note, updated_by = excluded.updated_by, | |
| 468 | + | brokered_hosts = excluded.brokered_hosts", | |
| 371 | 469 | ) | |
| 372 | 470 | .bind(&[ | |
| 373 | 471 | id.as_str().into(), | |
| ⋯ | |||
| 382 | 480 | optional(repositories.as_deref()), | |
| 383 | 481 | optional(note.as_deref()), | |
| 384 | 482 | a.actor.username.as_str().into(), | |
| 483 | + | optional((!brokered.is_empty()).then(|| serde_json::to_string(&brokered).unwrap_or_default()).as_deref()), | |
| 385 | 484 | ])? | |
| 386 | 485 | .run() | |
| 387 | 486 | .await?; | |
| ⋯ | |||
| 433 | 532 | consumer: &str, | |
| 434 | 533 | environment: Option<&str>, | |
| 435 | 534 | trusted: bool, | |
| 436 | − | ) -> Result<Map<String, Value>> { | |
| 535 | + | ) -> Result<Resolved> { | |
| 437 | 536 | if kind == "secret" && !trusted { | |
| 438 | − | return Ok(Map::new()); | |
| 537 | + | return Ok(Resolved::default()); | |
| 439 | 538 | } | |
| 440 | 539 | let environment = environment.map(str::to_ascii_lowercase); | |
| 441 | − | let mut out = Map::new(); | |
| 540 | + | let mut out = Resolved::default(); | |
| 442 | 541 | for owner in [namespace.to_lowercase(), project_id.to_owned()] { | |
| 443 | 542 | let rows: Vec<SettingRow> = self | |
| 444 | 543 | .rows(&owner) | |
| ⋯ | |||
| 459 | 558 | let Some(row) = chosen else { | |
| 460 | 559 | // Rows only for other environments: this reader gets | |
| 461 | 560 | // none, nor the workspace's. | |
| 462 | − | out.remove(name); | |
| 561 | + | out.values.remove(name); | |
| 562 | + | out.brokered.remove(name); | |
| 463 | 563 | continue; | |
| 464 | 564 | }; | |
| 465 | 565 | let value = if kind == "secret" { | |
| ⋯ | |||
| 470 | 570 | } else { | |
| 471 | 571 | row.value.clone() | |
| 472 | 572 | }; | |
| 473 | − | out.insert(name.to_owned(), Value::String(value)); | |
| 573 | + | out.values.insert(name.to_owned(), Value::String(value)); | |
| 574 | + | // The row that wins decides whether the key is brokered. | |
| 575 | + | match row.brokered_hosts() { | |
| 576 | + | hosts if kind == "secret" && !hosts.is_empty() => out.brokered.insert(name.to_owned(), hosts), | |
| 577 | + | _ => out.brokered.remove(name), | |
| 578 | + | }; | |
| 474 | 579 | } | |
| 475 | 580 | } | |
| 476 | 581 | Ok(out) | |
| ⋯ | |||
| 483 | 588 | let Some(project) = self.project_of(repo_id).await? else { | |
| 484 | 589 | return Ok(Map::new()); | |
| 485 | 590 | }; | |
| 486 | − | self.resolved(&project.id, &project.slug, namespace, "variable", "workflows", environment, trusted).await | |
| 591 | + | Ok(self.resolved(&project.id, &project.slug, namespace, "variable", "workflows", environment, trusted).await?.values) | |
| 487 | 592 | } | |
| 488 | 593 | ||
| 489 | − | /// The `secrets` context of a repository's runs, opened. | |
| 490 | − | pub async fn secrets_for(&self, repo_id: &str, repo: &str, environment: Option<&str>, trusted: bool) -> Result<Map<String, Value>> { | |
| 594 | + | /// The `secrets` context of a repository's runs, opened, with which of | |
| 595 | + | /// them are brokered: `job_spec` gives the job stand-ins for those. | |
| 596 | + | pub(crate) async fn secrets_for(&self, repo_id: &str, repo: &str, environment: Option<&str>, trusted: bool) -> Result<Resolved> { | |
| 491 | 597 | let (namespace, _) = repo.split_once('/').unwrap_or((repo, "")); | |
| 492 | 598 | let Some(project) = self.project_of(repo_id).await? else { | |
| 493 | − | return Ok(Map::new()); | |
| 599 | + | return Ok(Resolved::default()); | |
| 494 | 600 | }; | |
| 495 | 601 | self.resolved(&project.id, &project.slug, namespace, "secret", "workflows", environment, trusted).await | |
| 496 | 602 | } | |
| ⋯ | |||
| 508 | 614 | _ => return Ok(ResolvedSettings::default()), | |
| 509 | 615 | }; | |
| 510 | 616 | Ok(ResolvedSettings { | |
| 617 | + | // A brokered secret's value goes only to workflow jobs, as a | |
| 618 | + | // stand-in; a version update's registries go without it. | |
| 511 | 619 | secrets: self | |
| 512 | 620 | .resolved(&project_id, &slug, &a.repo.namespace, "secret", &a.consumer, environment, a.trusted) | |
| 513 | − | .await?, | |
| 621 | + | .await? | |
| 622 | + | .unbrokered(), | |
| 514 | 623 | variables: self | |
| 515 | 624 | .resolved(&project_id, &slug, &a.repo.namespace, "variable", &a.consumer, environment, a.trusted) | |
| 516 | − | .await?, | |
| 625 | + | .await? | |
| 626 | + | .values, | |
| 517 | 627 | }) | |
| 518 | 628 | } | |
| 519 | 629 | } | |
| 520 | 630 | ||
| 521 | 631 | #[cfg(test)] | |
| 522 | 632 | mod tests { | |
| 523 | − | use super::{SettingRow, consumers, valid_environments, valid_name}; | |
| 633 | + | use std::collections::BTreeMap; | |
| 634 | + | ||
| 635 | + | use serde_json::json; | |
| 636 | + | ||
| 637 | + | use super::{Resolved, SettingRow, consumers, valid_environments, valid_hosts, valid_name}; | |
| 524 | 638 | ||
| 525 | 639 | fn row(environments: &str) -> SettingRow { | |
| 526 | 640 | SettingRow { | |
| ⋯ | |||
| 535 | 649 | repositories: None, | |
| 536 | 650 | note: None, | |
| 537 | 651 | updated_by: None, | |
| 652 | + | brokered_hosts: None, | |
| 538 | 653 | } | |
| 539 | 654 | } | |
| 540 | 655 | ||
| ⋯ | |||
| 565 | 680 | assert!(!row("").overlaps(&["preview".into()])); | |
| 566 | 681 | assert!(row("").overlaps(&[])); | |
| 567 | 682 | } | |
| 683 | + | ||
| 684 | + | #[test] | |
| 685 | + | fn brokered_hosts_are_domains_each_once() { | |
| 686 | + | let hosts = valid_hosts(&["HTTPS://Registry.npmjs.org/".into(), "*.example.com".into(), "registry.npmjs.org".into(), " ".into()]).unwrap(); | |
| 687 | + | assert_eq!(hosts, ["registry.npmjs.org", "*.example.com"]); | |
| 688 | + | assert!(valid_hosts(&["localhost".into()]).is_err()); | |
| 689 | + | assert!(valid_hosts(&(0..21).map(|n| format!("h{n}.example.com")).collect::<Vec<_>>()).is_err()); | |
| 690 | + | assert!(valid_hosts(&[]).unwrap().is_empty()); | |
| 691 | + | } | |
| 692 | + | ||
| 693 | + | #[test] | |
| 694 | + | fn readers_outside_workflows_never_get_a_brokered_value() { | |
| 695 | + | let resolved = Resolved { | |
| 696 | + | values: json!({ "NPM_TOKEN": "npm_realvalue123", "PLAIN": "value" }).as_object().cloned().unwrap(), | |
| 697 | + | brokered: BTreeMap::from([("NPM_TOKEN".to_owned(), vec!["registry.npmjs.org".to_owned()])]), | |
| 698 | + | }; | |
| 699 | + | let brokers = resolved.brokers(); | |
| 700 | + | assert_eq!(brokers.len(), 1); | |
| 701 | + | assert_eq!(brokers[0].value, "npm_realvalue123"); | |
| 702 | + | let plain = resolved.unbrokered(); | |
| 703 | + | assert!(!plain.contains_key("NPM_TOKEN")); | |
| 704 | + | assert_eq!(plain["PLAIN"], "value"); | |
| 705 | + | } | |
| 568 | 706 | } | |
Comments
Sign in to comment.