Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Actions: OIDC tokens, the toolkit's cache and artifact services, and artifacts in R2 | 1 | //! The toolkit's credentials: a job's runtime token, the signed tokens of |
| 2 | //! the blob URLs the toolkit uploads to and downloads from, and the claims | |
| 3 | //! of a job's OIDC token. | |
| 4 | //! | |
| 5 | //! - The runtime token (`ACTIONS_RUNTIME_TOKEN`) is a JSON Web Token, as | |
| 6 | //! the toolkit expects: `@actions/artifact` reads the run and job from | |
| 7 | //! its `scp` claim. It is signed (HS256) with the hash of the job's own | |
| 8 | //! token as the key, so it is checked against the job's row without a | |
| 9 | //! secret of its own, stops working when the job ends, and never lets | |
| 10 | //! its holder read the job's spec or report for it. | |
| 11 | //! - A blob token is a payload and its HMAC under a key derived from | |
| 12 | //! `ACTIONS_KEY`: which entry, which R2 upload (for an upload), and until | |
| 13 | //! when. The API serves `/actions/toolkit/blobs/{token}` with it. | |
| 14 | //! - OIDC claims follow GitHub's, so cloud providers' trust policies read | |
| 15 | //! them the same way. The API adds `iss`, `aud`, `jti` and the times, | |
| 16 | //! and signs. | |
| 17 | ||
| 18 | use base64::Engine; | |
| 19 | use base64::engine::general_purpose::URL_SAFE_NO_PAD; | |
| 20 | use g1t_actions::events::RunInfo; | |
| 21 | use g1t_contracts::actions::{BlobArgs, BlobGrant, BlobPart, BlobSignArgs, RuntimeAuthArgs, RuntimeJob}; | |
| 22 | use g1t_contracts::time::rfc3339; | |
| 23 | use g1t_contracts::{FailureCode, Outcome}; | |
| 24 | use g1t_kit::now_ms; | |
| 25 | use g1t_secrets::{hmac_sha256_hex, same, sha256_hex}; | |
| 26 | use serde::Deserialize; | |
| 27 | use serde_json::{Value, json}; | |
| 28 | use worker::Result; | |
| 29 | ||
| 30 | use crate::plan::{JobRow, RunRow}; | |
| 31 | use crate::{Actions, check, fail}; | |
| 32 | ||
| 33 | /// How long a blob token for an upload is good: as long as an unfinished | |
| 34 | /// upload is kept (cache.rs). | |
| 35 | pub(crate) const UPLOAD_SECONDS: u64 = 6 * 60 * 60; | |
| 36 | /// How long a blob token for a download is good: long enough to start one. | |
| 37 | pub(crate) const DOWNLOAD_SECONDS: u64 = 60 * 60; | |
| 38 | /// How long a download link the API redirects a person to is good. | |
| 39 | pub(crate) const LINK_SECONDS: u64 = 10 * 60; | |
| 40 | ||
| 41 | fn encode(bytes: &[u8]) -> String { | |
| 42 | URL_SAFE_NO_PAD.encode(bytes) | |
| 43 | } | |
| 44 | ||
| 45 | fn decode_json(part: &str) -> Option<Value> { | |
| 46 | serde_json::from_slice(&URL_SAFE_NO_PAD.decode(part).ok()?).ok() | |
| 47 | } | |
| 48 | ||
| 49 | /// HMAC-SHA256 of `body` under `key`, base64url. | |
| 50 | fn mac(key: &str, body: &str) -> String { | |
| 51 | encode(&hex::decode(hmac_sha256_hex(key, body)).unwrap_or_default()) | |
| 52 | } | |
| 53 | ||
| 54 | // ── The runtime token ─────────────────────────────────────────────────────── | |
| 55 | ||
| 56 | /// The scopes the toolkit reads: `Actions.Results:{run}:{job}` names the | |
| 57 | /// run and job to `@actions/artifact`. | |
| 58 | pub(crate) fn scopes(run: &str, job: &str) -> String { | |
| 59 | format!("Actions.GenericRead:00000000-0000-0000-0000-000000000000 Actions.UploadArtifacts:{run}:{job} Actions.Results:{run}:{job}") | |
| 60 | } | |
| 61 | ||
| 62 | /// A job's runtime token, good for `ttl` seconds from `now` (seconds). | |
| 63 | /// `token_hash` is the hash of the job's own token, as its row keeps it. | |
| 64 | pub(crate) fn runtime_token(job: &str, run: &str, token_hash: &str, now: u64, ttl: u64) -> String { | |
| 65 | let header = encode(br#"{"typ":"JWT","alg":"HS256"}"#); | |
| 66 | let claims = json!({ | |
| 67 | "iss": "g1t", | |
| 68 | "sub": job, | |
| 69 | "job": job, | |
| 70 | "run": run, | |
| 71 | "scp": scopes(run, job), | |
| 72 | "iat": now, | |
| 73 | "nbf": now.saturating_sub(60), | |
| 74 | "exp": now + ttl, | |
| 75 | }); | |
| 76 | let claims = encode(claims.to_string().as_bytes()); | |
| 77 | let signed = format!("{header}.{claims}"); | |
| 78 | let signature = mac(token_hash, &signed); | |
| 79 | format!("{signed}.{signature}") | |
| 80 | } | |
| 81 | ||
| 82 | /// The job a runtime token says it is, unchecked: the API reads it to know | |
| 83 | /// which job to ask about. | |
| 84 | pub fn runtime_job(token: &str) -> Option<String> { | |
| 85 | let claims = decode_json(token.split('.').nth(1)?)?; | |
| 86 | claims["job"].as_str().map(str::to_owned) | |
| 87 | } | |
| 88 | ||
| 89 | /// Whether `token` is a good runtime token for `job`, whose own token | |
| 90 | /// hashes to `token_hash`, at `now` (seconds). | |
| 91 | pub(crate) fn runtime_valid(token: &str, job: &str, token_hash: &str, now: u64) -> bool { | |
| 92 | let mut parts = token.split('.'); | |
| 93 | let (Some(header), Some(claims), Some(signature), None) = (parts.next(), parts.next(), parts.next(), parts.next()) else { | |
| 94 | return false; | |
| 95 | }; | |
| 96 | if !same(&mac(token_hash, &format!("{header}.{claims}")), signature) { | |
| 97 | return false; | |
| 98 | } | |
| 99 | let Some(claims) = decode_json(claims) else { return false }; | |
| 100 | claims["job"].as_str() == Some(job) && claims["exp"].as_u64().is_some_and(|exp| exp > now) | |
| 101 | } | |
| 102 | ||
| 103 | /// Whether `token` looks like a runtime token rather than a job's own | |
| 104 | /// (which is hex). | |
| 105 | pub(crate) fn is_runtime(token: &str) -> bool { | |
| 106 | token.matches('.').count() == 2 | |
| 107 | } | |
| 108 | ||
| 109 | // ── Blob tokens ───────────────────────────────────────────────────────────── | |
| 110 | ||
| 111 | #[derive(Debug, PartialEq, serde::Serialize, Deserialize)] | |
| 112 | pub(crate) struct BlobClaims { | |
| 113 | /// `cache` or `artifact`. | |
| 114 | pub k: String, | |
| 115 | /// The entry's id. | |
| 116 | pub i: String, | |
| 117 | /// Its object in R2. | |
| 118 | pub o: String, | |
| 119 | /// The R2 upload, for an upload. | |
| 120 | #[serde(default)] | |
| 121 | pub u: Option<String>, | |
| 122 | /// Good until, seconds since the epoch. | |
| 123 | pub e: u64, | |
| 124 | } | |
| 125 | ||
| 126 | /// The key blob tokens are signed with, from the service's own key. | |
| 127 | pub(crate) fn blob_key(actions_key: &str) -> String { | |
| 128 | hmac_sha256_hex(actions_key, "g1t actions blob tokens v1") | |
| 129 | } | |
| 130 | ||
| 131 | pub(crate) fn sign_blob(key: &str, claims: &BlobClaims) -> String { | |
| 132 | let payload = encode(serde_json::to_string(claims).unwrap_or_default().as_bytes()); | |
| 133 | let signature = mac(key, &payload); | |
| 134 | format!("{payload}.{signature}") | |
| 135 | } | |
| 136 | ||
| 137 | /// What a blob token grants, if it is signed with `key` and good at `now`. | |
| 138 | pub(crate) fn open_blob(key: &str, token: &str, now: u64) -> Option<BlobClaims> { | |
| 139 | let (payload, signature) = token.split_once('.')?; | |
| 140 | if !same(&mac(key, payload), signature) { | |
| 141 | return None; | |
| 142 | } | |
| 143 | let claims: BlobClaims = serde_json::from_value(decode_json(payload)?).ok()?; | |
| 144 | (claims.e > now).then_some(claims) | |
| 145 | } | |
| 146 | ||
| 147 | // ── OIDC ──────────────────────────────────────────────────────────────────── | |
| 148 | ||
| 149 | /// Whether a job may have an OIDC token: when its permissions, or its | |
| 150 | /// workflow's when it has none of its own, give `id-token: write` | |
| 151 | /// (`write-all` included). Nothing given gives no OIDC token, as GitHub's | |
| 152 | /// default token permissions do not include it. | |
| 153 | /// | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 154 | /// Read with the same model as the job's token (`g1t_actions::permissions`). |
| Actions: OIDC tokens, the toolkit's cache and artifact services, and artifacts in R2 | 155 | pub(crate) fn id_token_permitted(workflow: &Value, job: &Value) -> bool { |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 156 | use g1t_actions::permissions::{self, Access}; |
| 157 | // A job's own `permissions:` replace the workflow's; without either, | |
| 158 | // no default gives `id-token`, as on GitHub. | |
| 159 | let written = |spec: &Value| spec.get("permissions").and_then(|value| permissions::parse(value).ok()).map(|(read, _)| read); | |
| 160 | written(job).or_else(|| written(workflow)).is_some_and(|granted| granted.get("id-token") == Access::Write) | |
| Actions: OIDC tokens, the toolkit's cache and artifact services, and artifacts in R2 | 161 | } |
| 162 | ||
| 163 | /// A job's `environment:` name, when it names one plainly. | |
| 164 | pub(crate) fn environment_name(job: &Value) -> Option<String> { | |
| 165 | match job.get("environment") { | |
| 166 | Some(Value::String(name)) if !name.contains("${{") && !name.trim().is_empty() => Some(name.trim().to_owned()), | |
| 167 | Some(Value::Object(env)) => env.get("name").and_then(Value::as_str).filter(|n| !n.contains("${{") && !n.trim().is_empty()).map(|n| n.trim().to_owned()), | |
| 168 | _ => None, | |
| 169 | } | |
| 170 | } | |
| 171 | ||
| 172 | /// GitHub's subject: by environment, else for a pull request, else by ref. | |
| 173 | pub fn subject(repository: &str, environment: Option<&str>, event: &str, git_ref: &str) -> String { | |
| 174 | if let Some(environment) = environment { | |
| 175 | return format!("repo:{repository}:environment:{environment}"); | |
| 176 | } | |
| 177 | if event == "pull_request" || event == "pull_request_target" { | |
| 178 | return format!("repo:{repository}:pull_request"); | |
| 179 | } | |
| 180 | format!("repo:{repository}:ref:{git_ref}") | |
| 181 | } | |
| 182 | ||
| 183 | /// What an OIDC token says about a job, besides who issued it, for whom | |
| 184 | /// and when. | |
| 185 | pub(crate) struct ClaimFacts<'a> { | |
| 186 | pub info: &'a RunInfo, | |
| 187 | pub environment: Option<&'a str>, | |
| 188 | /// The workflow that defines the job: a called workflow's own path. | |
| 189 | pub job_workflow_path: &'a str, | |
| 190 | pub private: bool, | |
| 191 | pub owner_id: &'a str, | |
| 192 | pub self_hosted: bool, | |
| 193 | } | |
| 194 | ||
| 195 | pub(crate) fn claims(facts: &ClaimFacts) -> Value { | |
| 196 | let info = facts.info; | |
| 197 | let owner = info.repository.split('/').next().unwrap_or_default(); | |
| 198 | let workflow_ref = format!("{}/{}@{}", info.repository, info.workflow_path, info.git_ref); | |
| 199 | let job_workflow_ref = format!("{}/{}@{}", info.repository, facts.job_workflow_path, info.git_ref); | |
| 200 | let mut claims = json!({ | |
| 201 | "sub": subject(&info.repository, facts.environment, &info.event_name, &info.git_ref), | |
| 202 | "ref": info.git_ref, | |
| 203 | "sha": info.sha, | |
| 204 | "repository": info.repository, | |
| 205 | "repository_owner": owner, | |
| 206 | "repository_owner_id": facts.owner_id, | |
| 207 | "repository_id": info.repository_id, | |
| 208 | "repository_visibility": if facts.private { "private" } else { "public" }, | |
| 209 | "run_id": info.run_id, | |
| 210 | "run_number": info.run_number.to_string(), | |
| 211 | "run_attempt": info.run_attempt.to_string(), | |
| 212 | "actor": info.actor, | |
| 213 | "actor_id": info.actor_id, | |
| 214 | "workflow": info.workflow, | |
| 215 | "workflow_ref": workflow_ref, | |
| 216 | "workflow_sha": info.sha, | |
| 217 | "job_workflow_ref": job_workflow_ref, | |
| 218 | "job_workflow_sha": info.sha, | |
| 219 | "head_ref": info.head_ref.clone().unwrap_or_default(), | |
| 220 | "base_ref": info.base_ref.clone().unwrap_or_default(), | |
| 221 | "event_name": info.event_name, | |
| 222 | "ref_type": info.ref_type(), | |
| 223 | "ref_protected": (info.ref_name() == info.default_branch).to_string(), | |
| 224 | "runner_environment": if facts.self_hosted { "self-hosted" } else { "github-hosted" }, | |
| 225 | }); | |
| 226 | if let Some(environment) = facts.environment { | |
| 227 | claims["environment"] = json!(environment); | |
| 228 | } | |
| 229 | claims | |
| 230 | } | |
| 231 | ||
| 232 | impl Actions { | |
| 233 | /// The running job a runtime token is for. | |
| 234 | pub(crate) async fn job_for_runtime(&self, job: &str, token: &str) -> Result<Outcome<JobRow>> { | |
| 235 | let row = self.db.prepare("SELECT * FROM jobs WHERE id = ?").bind(&[job.into()])?.first::<JobRow>(None).await?; | |
| 236 | Ok(match row { | |
| 237 | Some(row) | |
| 238 | if row.status == "in_progress" | |
| 239 | && row.token_hash.as_deref().is_some_and(|hash| runtime_valid(token, job, hash, now_ms() / 1000)) => | |
| 240 | { | |
| 241 | Outcome::Ok(row) | |
| 242 | } | |
| 243 | _ => fail(FailureCode::Unauthenticated, "That job is not running, or the token is not its."), | |
| 244 | }) | |
| 245 | } | |
| 246 | ||
| 247 | /// The running job a sandbox's credential is for: its job's own token, | |
| 248 | /// or its runtime token. Only for the cache and artifacts: a runtime | |
| 249 | /// token never reads a job's spec or reports for it. | |
| 250 | pub(crate) async fn job_for_credential(&self, job: &str, token: &str) -> Result<Outcome<JobRow>> { | |
| 251 | if is_runtime(token) { | |
| 252 | return self.job_for_runtime(job, token).await; | |
| 253 | } | |
| 254 | let row = self.db.prepare("SELECT * FROM jobs WHERE id = ?").bind(&[job.into()])?.first::<JobRow>(None).await?; | |
| 255 | Ok(match row { | |
| 256 | Some(row) if row.status == "in_progress" && row.token_hash.as_deref().is_some_and(|hash| same(hash, &sha256_hex(token))) => Outcome::Ok(row), | |
| 257 | _ => fail(FailureCode::Unauthenticated, "That job is not running, or the token is not its."), | |
| 258 | }) | |
| 259 | } | |
| 260 | ||
| 261 | /// `runtime_auth`. | |
| 262 | pub async fn runtime_auth(&self, a: RuntimeAuthArgs) -> Result<Outcome<RuntimeJob>> { | |
| 263 | let job = check!(self.job_for_runtime(&a.job, &a.token).await?); | |
| 264 | let Some(run) = self.run_row(&job.run_id).await? else { | |
| 265 | return Ok(fail(FailureCode::NotFound, "No such run.")); | |
| 266 | }; | |
| 267 | Ok(Outcome::Ok(RuntimeJob { job: job.id, run: job.run_id, repo_id: job.repo_id, namespace: job.namespace, repository: run.repo })) | |
| 268 | } | |
| 269 | ||
| 270 | /// `oidc_claims`. | |
| 271 | pub async fn oidc_claims(&self, a: RuntimeAuthArgs) -> Result<Outcome<Value>> { | |
| 272 | let job = check!(self.job_for_runtime(&a.job, &a.token).await?); | |
| 273 | let Some(run) = self.run_row(&job.run_id).await? else { | |
| 274 | return Ok(fail(FailureCode::NotFound, "No such run.")); | |
| 275 | }; | |
| 276 | let Some(permitted) = self.oidc_permitted(&run, &job) else { | |
| 277 | return Ok(fail(FailureCode::Forbidden, "The workflow no longer reads.")); | |
| 278 | }; | |
| 279 | if !permitted.0 { | |
| 280 | return Ok(fail( | |
| 281 | FailureCode::Forbidden, | |
| 282 | "This job has no OIDC token: give it `permissions: id-token: write` (a run of a pull request from outside the repository never has one).", | |
| 283 | )); | |
| 284 | } | |
| 285 | let (repo, _) = match self.repo_by_id(&run.repo_id).await? { | |
| 286 | Some(found) => found, | |
| 287 | None => return Ok(fail(FailureCode::NotFound, "There is no such repository.")), | |
| 288 | }; | |
| 289 | let info = run.info(); | |
| 290 | let facts = ClaimFacts { | |
| 291 | info: &info, | |
| 292 | environment: permitted.1.as_deref(), | |
| 293 | job_workflow_path: &permitted.2, | |
| 294 | private: repo.is_private, | |
| 295 | owner_id: &repo.owner_id, | |
| 296 | self_hosted: job.runner_id.is_some(), | |
| 297 | }; | |
| 298 | Ok(Outcome::Ok(claims(&facts))) | |
| 299 | } | |
| 300 | ||
| 301 | /// Whether a job may have an OIDC token, its environment, and the path | |
| 302 | /// of the workflow that defines it. A called workflow's job needs both | |
| 303 | /// its own permissions and its caller's to allow it, as on GitHub. A | |
| 304 | /// run that is not trusted (a pull request from outside) never may. | |
| 305 | /// `None` when the workflow no longer reads. | |
| 306 | fn oidc_permitted(&self, run: &RunRow, job: &JobRow) -> Option<(bool, Option<String>, String)> { | |
| 307 | let caller = g1t_actions::workflow::parse(&run.source).ok()?; | |
| 308 | let trusted = run.trusted != 0; | |
| 309 | match job.callee() { | |
| 310 | Some((called, spec, call)) => { | |
| Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date. | 311 | let parent = crate::plan::top_key(call["parent"].as_str().unwrap_or_default()); |
| Actions: OIDC tokens, the toolkit's cache and artifact services, and artifacts in R2 | 312 | let caller_allows = caller.jobs.iter().find(|j| j.id == parent).is_none_or(|j| id_token_permitted(&caller.raw, &j.raw)); |
| 313 | let path = call["path"].as_str().unwrap_or(&run.path).to_owned(); | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 314 | let environment = job.environment.clone().or_else(|| environment_name(&spec.raw)); |
| 315 | Some((trusted && caller_allows && id_token_permitted(&called.raw, &spec.raw), environment, path)) | |
| Actions: OIDC tokens, the toolkit's cache and artifact services, and artifacts in R2 | 316 | } |
| 317 | None => { | |
| 318 | let spec = caller.jobs.iter().find(|j| j.id == job.key)?; | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 319 | // As read when its needs were done, an expression's included. |
| 320 | let environment = job.environment.clone().or_else(|| environment_name(&spec.raw)); | |
| 321 | Some((trusted && id_token_permitted(&caller.raw, &spec.raw), environment, run.path.clone())) | |
| Actions: OIDC tokens, the toolkit's cache and artifact services, and artifacts in R2 | 322 | } |
| 323 | } | |
| 324 | } | |
| 325 | ||
| 326 | /// Whether a job may have an OIDC token, for its spec. | |
| 327 | pub(crate) fn oidc_allowed(&self, run: &RunRow, job: &JobRow) -> bool { | |
| 328 | self.oidc_permitted(run, job).is_some_and(|p| p.0) | |
| 329 | } | |
| 330 | ||
| 331 | fn blob_signing_key(&self) -> Option<String> { | |
| 332 | self.blob_key.clone() | |
| 333 | } | |
| 334 | ||
| 335 | /// `blob_sign`. | |
| 336 | pub async fn blob_sign(&self, a: BlobSignArgs) -> Result<Outcome<String>> { | |
| 337 | let job = check!(self.job_for_credential(&a.job, &a.token).await?); | |
| 338 | let Some(key) = self.blob_signing_key() else { | |
| 339 | return Ok(fail(FailureCode::Invalid, "The toolkit's storage is not set up here: the actions service has no ACTIONS_KEY.")); | |
| 340 | }; | |
| 341 | let object = match a.kind.as_str() { | |
| 342 | "cache" => { | |
| 343 | #[derive(Deserialize)] | |
| 344 | struct Row { | |
| 345 | object: String, | |
| 346 | } | |
| 347 | let row = self | |
| 348 | .db | |
| 349 | .prepare("UPDATE cache_entries SET upload = ? WHERE id = ? AND repo_id = ? AND status = 'pending' RETURNING object") | |
| 350 | .bind(&[a.upload.as_str().into(), a.id.as_str().into(), job.repo_id.as_str().into()])? | |
| 351 | .first::<Row>(None) | |
| 352 | .await?; | |
| 353 | row.map(|r| r.object) | |
| 354 | } | |
| 355 | "artifact" => { | |
| 356 | #[derive(Deserialize)] | |
| 357 | struct Row { | |
| 358 | object: String, | |
| 359 | } | |
| 360 | let id: f64 = a.id.parse().unwrap_or(-1.0); | |
| 361 | self.db | |
| 362 | .prepare("SELECT object FROM artifacts WHERE id = ? AND run_id = ? AND status = 'pending'") | |
| 363 | .bind(&[id.into(), job.run_id.as_str().into()])? | |
| 364 | .first::<Row>(None) | |
| 365 | .await? | |
| 366 | .map(|r| r.object) | |
| 367 | } | |
| 368 | _ => None, | |
| 369 | }; | |
| 370 | let Some(object) = object else { | |
| 371 | return Ok(fail(FailureCode::NotFound, "No upload of that is in progress.")); | |
| 372 | }; | |
| 373 | let claims = BlobClaims { k: a.kind, i: a.id, o: object, u: Some(a.upload), e: now_ms() / 1000 + UPLOAD_SECONDS }; | |
| 374 | Ok(Outcome::Ok(sign_blob(&key, &claims))) | |
| 375 | } | |
| 376 | ||
| 377 | /// An upload token for an entry whose R2 upload has been started. | |
| 378 | pub(crate) fn upload_token(&self, kind: &str, id: &str, object: &str, upload: &str) -> Option<String> { | |
| 379 | let key = self.blob_signing_key()?; | |
| 380 | let claims = BlobClaims { k: kind.to_owned(), i: id.to_owned(), o: object.to_owned(), u: Some(upload.to_owned()), e: now_ms() / 1000 + UPLOAD_SECONDS }; | |
| 381 | Some(sign_blob(&key, &claims)) | |
| 382 | } | |
| 383 | ||
| 384 | /// A download token for an entry, good for `seconds`. | |
| 385 | pub(crate) fn download_token(&self, kind: &str, id: &str, object: &str, seconds: u64) -> Option<String> { | |
| 386 | let key = self.blob_signing_key()?; | |
| 387 | Some(sign_blob(&key, &BlobClaims { k: kind.to_owned(), i: id.to_owned(), o: object.to_owned(), u: None, e: now_ms() / 1000 + seconds })) | |
| 388 | } | |
| 389 | ||
| 390 | fn opened(&self, token: &str) -> Option<BlobClaims> { | |
| 391 | open_blob(&self.blob_signing_key()?, token, now_ms() / 1000) | |
| 392 | } | |
| 393 | ||
| 394 | /// `blob_open`. | |
| 395 | pub async fn blob_open(&self, a: BlobArgs) -> Result<Outcome<BlobGrant>> { | |
| 396 | let Some(claims) = self.opened(&a.blob) else { | |
| 397 | return Ok(fail(FailureCode::Unauthenticated, "That link has expired or is not one of g1t's.")); | |
| 398 | }; | |
| 399 | // A download needs its entry still there; an upload, still pending. | |
| 400 | let wanted = if claims.u.is_some() { "pending" } else { "ready" }; | |
| 401 | let (exists, filename, content_type) = match claims.k.as_str() { | |
| 402 | "cache" => { | |
| 403 | let row = self | |
| 404 | .db | |
| 405 | .prepare("SELECT id FROM cache_entries WHERE id = ? AND object = ? AND status = ?") | |
| 406 | .bind(&[claims.i.as_str().into(), claims.o.as_str().into(), wanted.into()])? | |
| 407 | .first::<Value>(None) | |
| 408 | .await?; | |
| 409 | (row.is_some(), None, Some("application/octet-stream".to_owned())) | |
| 410 | } | |
| 411 | _ => { | |
| 412 | #[derive(Deserialize)] | |
| 413 | struct Row { | |
| 414 | name: String, | |
| 415 | format: String, | |
| 416 | } | |
| 417 | let id: f64 = claims.i.parse().unwrap_or(-1.0); | |
| 418 | let row = self | |
| 419 | .db | |
| 420 | .prepare("SELECT name, format FROM artifacts WHERE id = ? AND object = ? AND status = ?") | |
| 421 | .bind(&[id.into(), claims.o.as_str().into(), wanted.into()])? | |
| 422 | .first::<Row>(None) | |
| 423 | .await?; | |
| 424 | match row { | |
| 425 | Some(row) => { | |
| 426 | let (extension, kind) = if row.format == "tgz" { ("tar.gz", "application/gzip") } else { ("zip", "application/zip") }; | |
| 427 | (true, Some(format!("{}.{extension}", row.name)), Some(kind.to_owned())) | |
| 428 | } | |
| 429 | None => (false, None, None), | |
| 430 | } | |
| 431 | } | |
| 432 | }; | |
| 433 | if !exists { | |
| 434 | return Ok(fail(FailureCode::NotFound, "That is gone: it expired, was deleted, or its upload finished.")); | |
| 435 | } | |
| 436 | Ok(Outcome::Ok(BlobGrant { kind: claims.k, id: claims.i, object: claims.o, upload: claims.u, filename, content_type })) | |
| 437 | } | |
| 438 | ||
| 439 | /// `blob_part`. | |
| 440 | pub async fn blob_part(&self, a: BlobArgs) -> Result<Outcome<bool>> { | |
| 441 | let Some(BlobClaims { u: Some(upload), .. }) = self.opened(&a.blob) else { | |
| 442 | return Ok(fail(FailureCode::Unauthenticated, "That link has expired or is not an upload.")); | |
| 443 | }; | |
| 444 | self.db | |
| 445 | .prepare( | |
| 446 | "INSERT INTO blob_parts (upload, part, etag, size, created_at) VALUES (?1, ?2, ?3, ?4, ?5) | |
| 447 | ON CONFLICT (upload, part) DO UPDATE SET etag = ?3, size = ?4, created_at = ?5", | |
| 448 | ) | |
| 449 | .bind(&[upload.as_str().into(), f64::from(a.part).into(), a.etag.as_str().into(), (a.size as f64).into(), rfc3339(now_ms()).into()])? | |
| 450 | .run() | |
| 451 | .await?; | |
| 452 | Ok(Outcome::Ok(true)) | |
| 453 | } | |
| 454 | ||
| 455 | /// `blob_parts`. | |
| 456 | pub async fn blob_parts(&self, a: BlobArgs) -> Result<Outcome<Vec<BlobPart>>> { | |
| 457 | let Some(BlobClaims { u: Some(upload), .. }) = self.opened(&a.blob) else { | |
| 458 | return Ok(fail(FailureCode::Unauthenticated, "That link has expired or is not an upload.")); | |
| 459 | }; | |
| 460 | #[derive(Deserialize)] | |
| 461 | struct Row { | |
| 462 | part: f64, | |
| 463 | etag: String, | |
| 464 | size: f64, | |
| 465 | } | |
| 466 | let rows = self | |
| 467 | .db | |
| 468 | .prepare("SELECT part, etag, size FROM blob_parts WHERE upload = ? ORDER BY part") | |
| 469 | .bind(&[upload.as_str().into()])? | |
| 470 | .all() | |
| 471 | .await? | |
| 472 | .results::<Row>()?; | |
| 473 | Ok(Outcome::Ok(rows.into_iter().map(|r| BlobPart { part: r.part as u32, etag: r.etag, size: r.size as u64 }).collect())) | |
| 474 | } | |
| 475 | ||
| 476 | /// `blob_done`: the upload is complete at `size` bytes, as R2 measured | |
| 477 | /// it, which is the size its entry is committed at. | |
| 478 | pub async fn blob_done(&self, a: BlobArgs) -> Result<Outcome<bool>> { | |
| 479 | let Some(BlobClaims { k, i, u: Some(upload), .. }) = self.opened(&a.blob) else { | |
| 480 | return Ok(Outcome::Ok(false)); | |
| 481 | }; | |
| 482 | self.db.prepare("DELETE FROM blob_parts WHERE upload = ?").bind(&[upload.as_str().into()])?.run().await?; | |
| 483 | let size = (a.size as f64).into(); | |
| 484 | if k == "cache" { | |
| 485 | self.db | |
| 486 | .prepare("UPDATE cache_entries SET size = ? WHERE id = ? AND status = 'pending'") | |
| 487 | .bind(&[size, i.as_str().into()])? | |
| 488 | .run() | |
| 489 | .await?; | |
| 490 | } else { | |
| 491 | self.db | |
| 492 | .prepare("UPDATE artifacts SET size = ? WHERE id = ? AND status = 'pending'") | |
| 493 | .bind(&[size, i.parse::<f64>().unwrap_or(-1.0).into()])? | |
| 494 | .run() | |
| 495 | .await?; | |
| 496 | } | |
| 497 | Ok(Outcome::Ok(true)) | |
| 498 | } | |
| 499 | ||
| 500 | /// Hourly: parts of uploads abandoned long ago. | |
| 501 | pub(crate) async fn sweep_blob_parts(&self, now: u64) -> Result<()> { | |
| 502 | self.db | |
| 503 | .prepare("DELETE FROM blob_parts WHERE created_at < ?") | |
| 504 | .bind(&[rfc3339(now.saturating_sub(UPLOAD_SECONDS * 1000 * 2)).into()])? | |
| 505 | .run() | |
| 506 | .await?; | |
| 507 | Ok(()) | |
| 508 | } | |
| 509 | } | |
| 510 | ||
| 511 | #[cfg(test)] | |
| 512 | mod tests { | |
| 513 | use super::*; | |
| 514 | ||
| 515 | const HASH: &str = "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"; | |
| 516 | ||
| 517 | #[test] | |
| 518 | fn a_runtime_token_is_a_jwt_the_toolkit_reads() { | |
| 519 | let token = runtime_token("job_1", "run_1", HASH, 1_700_000_000, 3600); | |
| 520 | let parts: Vec<&str> = token.split('.').collect(); | |
| 521 | assert_eq!(parts.len(), 3); | |
| 522 | let header = decode_json(parts[0]).unwrap(); | |
| 523 | assert_eq!(header["alg"], "HS256"); | |
| 524 | let claims = decode_json(parts[1]).unwrap(); | |
| 525 | // @actions/artifact finds the run and job in the `Actions.Results` | |
| 526 | // scope: three parts split on `:`. | |
| 527 | let results = claims["scp"].as_str().unwrap().split(' ').find(|s| s.starts_with("Actions.Results:")).unwrap(); | |
| 528 | assert_eq!(results.split(':').collect::<Vec<_>>(), ["Actions.Results", "run_1", "job_1"]); | |
| 529 | assert_eq!(runtime_job(&token).as_deref(), Some("job_1")); | |
| 530 | assert!(is_runtime(&token) && !is_runtime(HASH)); | |
| 531 | } | |
| 532 | ||
| 533 | #[test] | |
| 534 | fn a_runtime_token_is_checked_against_its_job() { | |
| 535 | let token = runtime_token("job_1", "run_1", HASH, 1_700_000_000, 3600); | |
| 536 | assert!(runtime_valid(&token, "job_1", HASH, 1_700_000_100)); | |
| 537 | // Another job, another job's key, too late. | |
| 538 | assert!(!runtime_valid(&token, "job_2", HASH, 1_700_000_100)); | |
| 539 | assert!(!runtime_valid(&token, "job_1", &"0".repeat(64), 1_700_000_100)); | |
| 540 | assert!(!runtime_valid(&token, "job_1", HASH, 1_700_003_601)); | |
| 541 | // Claims changed without the key. | |
| 542 | let parts: Vec<&str> = token.split('.').collect(); | |
| 543 | let forged = encode(json!({ "job": "job_1", "run": "run_9", "exp": 9_999_999_999u64 }).to_string().as_bytes()); | |
| 544 | assert!(!runtime_valid(&format!("{}.{forged}.{}", parts[0], parts[2]), "job_1", HASH, 1_700_000_100)); | |
| 545 | assert!(!runtime_valid("a.b", "job_1", HASH, 0)); | |
| 546 | } | |
| 547 | ||
| 548 | #[test] | |
| 549 | fn blob_tokens_are_signed_and_expire() { | |
| 550 | let key = blob_key("00".repeat(32).as_str()); | |
| 551 | let claims = BlobClaims { k: "artifact".into(), i: "12".into(), o: "a/repo_1/12".into(), u: Some("up".into()), e: 1_000 }; | |
| 552 | let token = sign_blob(&key, &claims); | |
| 553 | assert_eq!(open_blob(&key, &token, 999), Some(claims)); | |
| 554 | assert_eq!(open_blob(&key, &token, 1_000), None); | |
| 555 | assert_eq!(open_blob(&blob_key("11".repeat(32).as_str()), &token, 999), None); | |
| 556 | let (payload, _) = token.split_once('.').unwrap(); | |
| 557 | assert_eq!(open_blob(&key, &format!("{payload}.AAAA"), 999), None); | |
| 558 | } | |
| 559 | ||
| 560 | #[test] | |
| 561 | fn id_token_needs_write_in_the_job_or_else_the_workflow() { | |
| 562 | let wf = |p: Value| json!({ "permissions": p }); | |
| 563 | let none = json!({}); | |
| 564 | assert!(!id_token_permitted(&none, &none)); | |
| 565 | assert!(id_token_permitted(&wf(json!({ "id-token": "write", "contents": "read" })), &none)); | |
| 566 | assert!(!id_token_permitted(&wf(json!({ "id-token": "read" })), &none)); | |
| 567 | assert!(id_token_permitted(&wf(json!("write-all")), &none)); | |
| 568 | assert!(!id_token_permitted(&wf(json!("read-all")), &none)); | |
| 569 | // The job's own permissions replace the workflow's entirely. | |
| 570 | assert!(!id_token_permitted(&wf(json!({ "id-token": "write" })), &json!({ "permissions": { "contents": "read" } }))); | |
| 571 | assert!(id_token_permitted(&wf(json!({})), &json!({ "permissions": { "id-token": "write" } }))); | |
| 572 | assert!(!id_token_permitted(&none, &json!({ "permissions": {} }))); | |
| 573 | } | |
| 574 | ||
| 575 | #[test] | |
| 576 | fn subjects_are_github_s() { | |
| 577 | assert_eq!(subject("acme/web", None, "push", "refs/heads/main"), "repo:acme/web:ref:refs/heads/main"); | |
| 578 | assert_eq!(subject("acme/web", None, "push", "refs/tags/v1"), "repo:acme/web:ref:refs/tags/v1"); | |
| 579 | assert_eq!(subject("acme/web", Some("prod"), "push", "refs/heads/main"), "repo:acme/web:environment:prod"); | |
| 580 | assert_eq!(subject("acme/web", None, "pull_request", "refs/pull/3/merge"), "repo:acme/web:pull_request"); | |
| 581 | assert_eq!(subject("acme/web", None, "pull_request_target", "refs/heads/main"), "repo:acme/web:pull_request"); | |
| 582 | // An environment wins over a pull request, as on GitHub. | |
| 583 | assert_eq!(subject("acme/web", Some("preview"), "pull_request", "refs/pull/3/merge"), "repo:acme/web:environment:preview"); | |
| 584 | } | |
| 585 | ||
| 586 | #[test] | |
| 587 | fn environments_are_named_plainly_or_not_at_all() { | |
| 588 | assert_eq!(environment_name(&json!({ "environment": "production" })).as_deref(), Some("production")); | |
| 589 | assert_eq!(environment_name(&json!({ "environment": { "name": "staging", "url": "x" } })).as_deref(), Some("staging")); | |
| 590 | assert_eq!(environment_name(&json!({ "environment": "${{ inputs.env }}" })), None); | |
| 591 | assert_eq!(environment_name(&json!({})), None); | |
| 592 | } | |
| 593 | ||
| 594 | #[test] | |
| 595 | fn claims_carry_what_trust_policies_read() { | |
| 596 | let info = RunInfo { | |
| 597 | repository: "acme/web".into(), | |
| 598 | repository_id: "repo_1".into(), | |
| 599 | default_branch: "main".into(), | |
| 600 | event_name: "push".into(), | |
| 601 | git_ref: "refs/heads/main".into(), | |
| 602 | sha: "abc".into(), | |
| 603 | actor: "ada".into(), | |
| 604 | actor_id: "usr_1".into(), | |
| 605 | run_id: "run_1".into(), | |
| 606 | run_number: 7, | |
| 607 | run_attempt: 2, | |
| 608 | workflow: "Deploy".into(), | |
| 609 | workflow_path: ".g1t/workflows/deploy.yml".into(), | |
| 610 | ..RunInfo::default() | |
| 611 | }; | |
| 612 | let facts = ClaimFacts { | |
| 613 | info: &info, | |
| 614 | environment: Some("production"), | |
| 615 | job_workflow_path: ".g1t/workflows/release.yml", | |
| 616 | private: true, | |
| 617 | owner_id: "ws_1", | |
| 618 | self_hosted: false, | |
| 619 | }; | |
| 620 | let c = claims(&facts); | |
| 621 | assert_eq!(c["sub"], "repo:acme/web:environment:production"); | |
| 622 | assert_eq!(c["environment"], "production"); | |
| 623 | assert_eq!(c["repository_owner"], "acme"); | |
| 624 | assert_eq!(c["repository_owner_id"], "ws_1"); | |
| 625 | assert_eq!(c["repository_visibility"], "private"); | |
| 626 | assert_eq!(c["run_number"], "7"); | |
| 627 | assert_eq!(c["run_attempt"], "2"); | |
| 628 | assert_eq!(c["workflow_ref"], "acme/web/.g1t/workflows/deploy.yml@refs/heads/main"); | |
| 629 | assert_eq!(c["job_workflow_ref"], "acme/web/.g1t/workflows/release.yml@refs/heads/main"); | |
| 630 | assert_eq!(c["ref_type"], "branch"); | |
| 631 | assert_eq!(c["ref_protected"], "true"); | |
| 632 | assert_eq!(c["runner_environment"], "github-hosted"); | |
| 633 | for absent in ["iss", "aud", "exp", "iat", "jti"] { | |
| 634 | assert!(c.get(absent).is_none(), "{absent} is the API's to add"); | |
| 635 | } | |
| 636 | } | |
| 637 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.