Merge the OIDC docs: deploying to AWS, Google Cloud or Azure with a job's short-lived identity token now says which of the cloud's hosts to allow under Workflow-only domains, for the deploying workflow and environment, since a job on g1t's machines reaches only the hosts its project allows; and tests for the audience a job asks for and for the token variables reaching each step.
5 files+110−120/5 viewed
| 205 | 205 | claims | |
| 206 | 206 | } | |
| 207 | 207 | ||
| 208 | + | /// The audience a job asked for with `audience=` (as `core.getIDToken` | |
| 209 | + | /// appends it, percent-encoded), or, when it asked for none, the owner's | |
| 210 | + | /// address on the site, which is the default trust policies expect. | |
| 211 | + | pub fn audience(url: &worker::Url, site: &str, owner: &str) -> String { | |
| 212 | + | url.query_pairs() | |
| 213 | + | .find(|(k, _)| k == "audience") | |
| 214 | + | .map(|(_, v)| v.into_owned()) | |
| 215 | + | .filter(|a| !a.trim().is_empty()) | |
| 216 | + | .unwrap_or_else(|| format!("{site}/{owner}")) | |
| 217 | + | } | |
| 218 | + | ||
| 208 | 219 | /// The header and claims, base64url-encoded and joined: what is signed. | |
| 209 | 220 | pub fn signing_input(kid: &str, claims: &Value) -> String { | |
| 210 | 221 | let header = json!({ "typ": "JWT", "alg": "RS256", "kid": kid, "x5t": kid }); | |
| ⋯ | |||
| 264 | 275 | return error(status, &refused.message); | |
| 265 | 276 | } | |
| 266 | 277 | }; | |
| 267 | − | let url = request.url()?; | |
| 268 | − | let owner = claims["repository_owner"].as_str().unwrap_or_default().to_owned(); | |
| 269 | − | let audience = url | |
| 270 | − | .query_pairs() | |
| 271 | − | .find(|(k, _)| k == "audience") | |
| 272 | − | .map(|(_, v)| v.into_owned()) | |
| 273 | − | .filter(|a| !a.trim().is_empty()) | |
| 274 | − | // GitHub's default: the owner's address. | |
| 275 | − | .unwrap_or_else(|| format!("{}/{owner}", services.addresses.site)); | |
| 278 | + | let owner = claims["repository_owner"].as_str().unwrap_or_default(); | |
| 279 | + | let audience = audience(&request.url()?, &services.addresses.site, owner); | |
| 276 | 280 | let now = g1t_kit::now_ms() / 1000; | |
| 277 | 281 | let jti = g1t_contracts::new_id("oidc", g1t_kit::now_ms()); | |
| 278 | 282 | let claims = full_claims(claims, &issuer(&api), &audience, &jti, now); | |
| ⋯ | |||
| 323 | 327 | } | |
| 324 | 328 | ||
| 325 | 329 | #[test] | |
| 330 | + | fn the_audience_is_the_one_asked_for_or_the_owners_address() { | |
| 331 | + | let url = |q: &str| worker::Url::parse(&format!("https://api.g1t.sh/actions/oidc/token?api-version=2.0{q}")).unwrap(); | |
| 332 | + | assert_eq!(audience(&url("&audience=sts.amazonaws.com"), "https://g1t.sh", "acme"), "sts.amazonaws.com"); | |
| 333 | + | // Percent-encoded, as the toolkit sends it. | |
| 334 | + | assert_eq!(audience(&url("&audience=api%3A%2F%2FAzureADTokenExchange"), "https://g1t.sh", "acme"), "api://AzureADTokenExchange"); | |
| 335 | + | assert_eq!( | |
| 336 | + | audience(&url("&audience=%2F%2Fiam.googleapis.com%2Fprojects%2F1%2Fproviders%2Fg1t"), "https://g1t.sh", "acme"), | |
| 337 | + | "//iam.googleapis.com/projects/1/providers/g1t" | |
| 338 | + | ); | |
| 339 | + | // None, or an empty one: the owner's address. | |
| 340 | + | assert_eq!(audience(&url(""), "https://g1t.sh", "acme"), "https://g1t.sh/acme"); | |
| 341 | + | assert_eq!(audience(&url("&audience="), "https://g1t.sh", "acme"), "https://g1t.sh/acme"); | |
| 342 | + | } | |
| 343 | + | ||
| 344 | + | #[test] | |
| 326 | 345 | fn a_key_that_is_not_one_is_refused() { | |
| 327 | 346 | assert!(SigningKey::from_pem("hello").is_err()); | |
| 328 | 347 | let pem = format!("{}\nAAAA\n{}", concat!("-----BEGIN ", "PRIVATE KEY-----"), concat!("-----END ", "PRIVATE KEY-----")); | |
| 1068 | 1068 | | Lifetime | 5 minutes | | |
| 1069 | 1069 | | Audience | What the job asks for; `https://g1t.sh/<owner>` when it asks for none | | |
| 1070 | 1070 | ||
| 1071 | + | A job without `id-token: write` has no `ACTIONS_ID_TOKEN_REQUEST_URL`, so | |
| 1072 | + | a login action says it cannot find one; a token asked for anyway is | |
| 1073 | + | refused with `403` and `This job has no OIDC token`. | |
| 1074 | + | ||
| 1075 | + | ### Reaching your cloud | |
| 1076 | + | ||
| 1077 | + | Asking for the token goes to `api.g1t.sh`, which every job reaches. Trading | |
| 1078 | + | it for credentials goes to your cloud's token service, which a job reaches | |
| 1079 | + | only when its project allows it (see [what a job can reach](#what-a-job-can-reach)). | |
| 1080 | + | Add the hosts under **Settings → Guardrails → Workflow-only domains**, | |
| 1081 | + | limited to the workflow and environment that deploy, so agents and other | |
| 1082 | + | workflows never reach them: | |
| 1083 | + | ||
| 1084 | + | ```text | |
| 1085 | + | sts.us-east-1.amazonaws.com | deploy.yml | production | |
| 1086 | + | s3.us-east-1.amazonaws.com | deploy.yml | production | |
| 1087 | + | sts.googleapis.com | deploy.yml | production | |
| 1088 | + | iamcredentials.googleapis.com | deploy.yml | production | |
| 1089 | + | login.microsoftonline.com | deploy.yml | production | |
| 1090 | + | management.azure.com | deploy.yml | production | |
| 1091 | + | ``` | |
| 1092 | + | ||
| 1093 | + | | Cloud | Token exchange | Then | | |
| 1094 | + | | --- | --- | --- | | |
| 1095 | + | | AWS | `sts.<region>.amazonaws.com`, the region `aws-region` names | Each service the job uses, such as `s3.<region>.amazonaws.com`, or `*.amazonaws.com` for all of them | | |
| 1096 | + | | Google Cloud | `sts.googleapis.com`, `iamcredentials.googleapis.com` | Each API the job uses, such as `run.googleapis.com` | | |
| 1097 | + | | Azure | `login.microsoftonline.com` | `management.azure.com`, and each service the job uses | | |
| 1098 | + | ||
| 1099 | + | Without them, the login step fails with `403` and a line naming the host | |
| 1100 | + | that is not allowed. | |
| 1101 | + | ||
| 1071 | 1102 | ### Claims | |
| 1072 | 1103 | ||
| 1073 | 1104 | Each token carries the claims GitHub's do, so trust policies written for | |
| ⋯ | |||
| 1121 | 1152 | } | |
| 1122 | 1153 | ``` | |
| 1123 | 1154 | ||
| 1124 | − | 3. Use `aws-actions/configure-aws-credentials@v4` with `role-to-assume`, | |
| 1155 | + | 3. Allow `sts.<region>.amazonaws.com`, and what the job deploys to, as | |
| 1156 | + | [workflow-only domains](#reaching-your-cloud). | |
| 1157 | + | 4. Use `aws-actions/configure-aws-credentials@v4` with `role-to-assume`, | |
| 1125 | 1158 | as above. | |
| 1126 | 1159 | ||
| 1127 | 1160 | ### Google Cloud | |
| ⋯ | |||
| 1145 | 1178 | --member="principalSet://iam.googleapis.com/projects/123456789/locations/global/workloadIdentityPools/g1t/attribute.repository/acme/web" | |
| 1146 | 1179 | ``` | |
| 1147 | 1180 | ||
| 1148 | − | 3. Use `google-github-actions/auth@v2` with | |
| 1181 | + | 3. Allow `sts.googleapis.com` and `iamcredentials.googleapis.com`, and | |
| 1182 | + | the APIs the job uses, as [workflow-only domains](#reaching-your-cloud). | |
| 1183 | + | 4. Use `google-github-actions/auth@v2` with | |
| 1149 | 1184 | `workload_identity_provider: projects/123456789/locations/global/workloadIdentityPools/g1t/providers/g1t` | |
| 1150 | 1185 | and `service_account`. It asks for the provider's own name as the | |
| 1151 | 1186 | audience, which the provider accepts unless you change its allowed | |
| ⋯ | |||
| 1169 | 1204 | ``` | |
| 1170 | 1205 | ||
| 1171 | 1206 | 2. Give it a role on what it deploys to, as for any identity. | |
| 1172 | − | 3. Use `azure/login@v2` with `client-id`, `tenant-id` and | |
| 1207 | + | 3. Allow `login.microsoftonline.com` and `management.azure.com` as | |
| 1208 | + | [workflow-only domains](#reaching-your-cloud). | |
| 1209 | + | 4. Use `azure/login@v2` with `client-id`, `tenant-id` and | |
| 1173 | 1210 | `subscription-id`, and no secret. | |
| 1174 | 1211 | ||
| 1175 | 1212 | A federated credential matches the subject exactly: add one per | |
| ⋯ | |||
| 1681 | 1718 | [Secrets and variables](/guides/secrets-and-variables/) for how rows, | |
| 1682 | 1719 | environments and the workspace's rows work. | |
| 1683 | 1720 | ||
| 1721 | + | To deploy to AWS, Google Cloud or Azure, or to a server of your own, | |
| 1722 | + | you need no secret at all: give the job `permissions: id-token: write` | |
| 1723 | + | and trade its [OIDC token](#oidc-tokens) for short-lived credentials. | |
| 1724 | + | ||
| 1684 | 1725 | Every job also gets `${{ secrets.G1T_TOKEN }}`, [its own token](#the-jobs-token), | |
| 1685 | 1726 | with `GITHUB_TOKEN` as its alias. A pull request's runs get secrets only | |
| 1686 | 1727 | when its author has the Write [role](/guides/access-and-roles/) or higher | |
| 161 | 161 | `update_guardrails`, saying which domains were added or removed and what | |
| 162 | 162 | they were limited to. | |
| 163 | 163 | ||
| 164 | + | A deploy that signs in to AWS, Google Cloud or Azure with the job's | |
| 165 | + | [OIDC token](/guides/actions/#oidc-tokens), rather than a key kept in a | |
| 166 | + | secret, needs its cloud's token service listed here, such as | |
| 167 | + | `sts.us-east-1.amazonaws.com | deploy.yml | production`. See | |
| 168 | + | [reaching your cloud](/guides/actions/#reaching-your-cloud) for each | |
| 169 | + | cloud's hosts. | |
| 170 | + | ||
| 164 | 171 | ## Commands | |
| 165 | 172 | ||
| 166 | 173 | The agent's harness checks every tool call the agent makes before it runs. |
| 17 | 17 | values included, are for Admins of its repository, and the page is not | |
| 18 | 18 | shown to anyone else. | |
| 19 | 19 | ||
| 20 | + | A workflow that deploys to AWS, Google Cloud or Azure needs no cloud key | |
| 21 | + | here: it can sign in with the job's short-lived | |
| 22 | + | [OIDC token](/guides/actions/#oidc-tokens) instead. | |
| 23 | + | ||
| 20 | 24 | ## A row | |
| 21 | 25 | ||
| 22 | 26 | | Field | | |
| 1420 | 1420 | assert_eq!(step_timeout(None), Duration::from_secs(6 * 3600)); | |
| 1421 | 1421 | assert_eq!(step_timeout(Some(-1.0)), Duration::ZERO); | |
| 1422 | 1422 | } | |
| 1423 | + | ||
| 1424 | + | /// `core.getIDToken()` reads the OIDC variables from the step's own | |
| 1425 | + | /// environment, so they have to reach every step's process as the API | |
| 1426 | + | /// put them in the spec: the URL untouched by path relocation, and the | |
| 1427 | + | /// token not dropped with the runner's own credentials. | |
| 1428 | + | #[test] | |
| 1429 | + | fn a_steps_process_gets_the_oidc_request_variables() { | |
| 1430 | + | let dir = std::env::temp_dir().join(format!("g1t-oidc-env-{}-{}", std::process::id(), rand_id())); | |
| 1431 | + | let url = "https://api.g1t.sh/actions/oidc/token?api-version=2.0"; | |
| 1432 | + | let spec = json!({ | |
| 1433 | + | "variables": { | |
| 1434 | + | "ACTIONS_RUNTIME_TOKEN": "h.p.s", | |
| 1435 | + | "ACTIONS_ID_TOKEN_REQUEST_URL": url, | |
| 1436 | + | "ACTIONS_ID_TOKEN_REQUEST_TOKEN": "h.p.s", | |
| 1437 | + | }, | |
| 1438 | + | "github": {}, | |
| 1439 | + | "event": {}, | |
| 1440 | + | "masks": ["h.p.s"], | |
| 1441 | + | }); | |
| 1442 | + | let job = setup_in(spec, Api::offline(), dir.join("work"), dir.join("temp")).unwrap(); | |
| 1443 | + | let files = StepFiles::new(&job.temp, "oidc").unwrap(); | |
| 1444 | + | let env = job.process_env(&BTreeMap::new(), &files); | |
| 1445 | + | assert_eq!(env.get("ACTIONS_ID_TOKEN_REQUEST_URL").map(String::as_str), Some(url)); | |
| 1446 | + | assert_eq!(env.get("ACTIONS_ID_TOKEN_REQUEST_TOKEN").map(String::as_str), Some("h.p.s")); | |
| 1447 | + | assert_eq!(env.get("ACTIONS_RUNTIME_TOKEN").map(String::as_str), Some("h.p.s")); | |
| 1448 | + | let _ = std::fs::remove_dir_all(&dir); | |
| 1449 | + | } | |
| 1423 | 1450 | } |