Sealed approvals: approval { sealed } and the oxygen pin 2be1fe4ad345
approval { sealed #true } asks for an approval signed with the approver's
own key (oxy seal --flow); on by default for a job that names an
environment. Part of the job's hash (field 11). Pinned at oxygen 51ff10d3
(the approval attestation). Spec 5.3.2. Also: deps/oxygen had been
reformatted by a stray cargo fmt --all (oxygenflow's 140 columns); it was
discarded before the pin moved.
Refs andymccall/oxygenhub#235
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
Claude Code <[email protected]> · delegate for changes, until 2026-11-13 19:00Z · key ea5f78160088
- When
- · 2026-10-16 09:48 UTC
- Signature
- ✔ signed by Claude Code
- Hash
2be1fe4ad345ad40c7ef4bccac95940da5a8809d6697cdc093e0c72b175a6baf- Depends on
- 46fe2c815cc3 89171fca079c a083cc802892 e0918b887850 e20897216221 e2c125610f43 f4e51e63582d fc10d9e80383 fe128fa67a06
- Depended on by
- cd03b9844516 454b853a8f4a
- In bookmarks
- main
- In tags
- v0.2.0 v0.3.0 v0.3.1 v0.4.0 v0.5.0 v0.5.1 v0.5.2 v0.5.3
- Edits
- 20 atoms, 0 tree operations, 5 files
Browse the tree at this change
modified crates/flow-core/src/canonical.rs +1 −1
| … | 108 unchanged lines |
||
| 109 | 109 | // Inputs as [artifact, from, path] sorted by path; absent when none. |
|
| 110 | 110 | // Approval as [by, others-only, expires seconds]; absent without one. |
|
| 111 | 111 | if let Some(a) = &j.approval { |
|
| 112 | - | fields.push((11, Value::Array(vec![text(&a.by), Value::Bool(a.others_only), Value::Uint(a.expires_s)]))); |
|
| 112 | + | fields.push((11, Value::Array(vec![text(&a.by), Value::Bool(a.others_only), Value::Uint(a.expires_s), Value::Bool(a.sealed)]))); |
|
| 113 | 113 | } |
|
| 114 | 114 | // The environment it deploys to; absent without one. |
|
| 115 | 115 | if let Some(e) = &j.environment { |
|
| … | 52 unchanged lines |
modified crates/flow-core/src/pipeline.rs +34 −6
| … | 310 unchanged lines |
||
| 311 | 311 | pub others_only: bool, |
|
| 312 | 312 | /// How long it waits once the jobs it depends on are done, in seconds. |
|
| 313 | 313 | pub expires_s: u64, |
|
| 314 | + | /// The approval must be signed with the approver's own key |
|
| 315 | + | /// (`oxy seal --flow`; oxygenhub#235). Defaults to on for a job that |
|
| 316 | + | /// names an environment. |
|
| 317 | + | #[serde(default, skip_serializing_if = "std::ops::Not::not")] |
|
| 318 | + | pub sealed: bool, |
|
| 314 | 319 | } |
|
| 315 | 320 | ||
| 316 | 321 | /// The longest a job waits for approval: 30 days. |
|
| … | 1016 unchanged lines |
||
| 1333 | 1338 | ||
| 1334 | 1339 | /// `approval { by "maintain"; others-only #true; expires "24h" }`, each |
|
| 1335 | 1340 | /// optional: `maintain`, anyone with the role, 24 hours. |
|
| 1336 | - | fn parse_approval(ctx: &mut Ctx, node: &KdlNode) -> Option<Approval> { |
|
| 1341 | + | /// The approval, and whether it said `sealed` itself (else the job's |
|
| 1342 | + | /// environment decides). |
|
| 1343 | + | fn parse_approval(ctx: &mut Ctx, node: &KdlNode) -> Option<(Approval, bool)> { |
|
| 1337 | 1344 | if !node.entries().is_empty() { |
|
| 1338 | 1345 | ctx.err(node.span(), "`approval` takes a block: approval { by \"maintain\"; others-only #true; expires \"24h\" }"); |
|
| 1339 | 1346 | } |
|
| 1340 | - | let mut a = Approval { by: "maintain".into(), others_only: false, expires_s: 24 * 3600 }; |
|
| 1341 | - | let Some(children) = node.children() else { return Some(a) }; |
|
| 1347 | + | let mut a = Approval { by: "maintain".into(), others_only: false, expires_s: 24 * 3600, sealed: false }; |
|
| 1348 | + | let mut sealed_said = false; |
|
| 1349 | + | let Some(children) = node.children() else { return Some((a, false)) }; |
|
| 1342 | 1350 | for c in children.nodes() { |
|
| 1343 | 1351 | ctx.no_children(c); |
|
| 1344 | 1352 | match c.name().value() { |
|
| … | 8 unchanged lines |
||
| 1353 | 1361 | _ => ctx.err(c.span(), "`others-only` takes #true or #false"), |
|
| 1354 | 1362 | }, |
|
| 1355 | 1363 | _ => ctx.err(c.span(), "`others-only` takes #true or #false"), |
|
| 1364 | + | }, |
|
| 1365 | + | "sealed" => match c.entries() { |
|
| 1366 | + | [e] if e.name().is_none() => match e.value() { |
|
| 1367 | + | KdlValue::Bool(b) => { |
|
| 1368 | + | a.sealed = *b; |
|
| 1369 | + | sealed_said = true; |
|
| 1370 | + | } |
|
| 1371 | + | _ => ctx.err(c.span(), "`sealed` takes #true or #false"), |
|
| 1372 | + | }, |
|
| 1373 | + | _ => ctx.err(c.span(), "`sealed` takes #true or #false"), |
|
| 1356 | 1374 | }, |
|
| 1357 | 1375 | "expires" => { |
|
| 1358 | 1376 | let Some(t) = ctx.one_string(c) else { continue }; |
|
| … | 2 unchanged lines |
||
| 1361 | 1379 | Some(_) => ctx.err(c.span(), format!("expires {t:?} must be from 1m to 720h")), |
|
| 1362 | 1380 | None => ctx.err(c.span(), format!("expires {t:?}: expected hours, minutes and seconds, as \"24h\"")), |
|
| 1363 | 1381 | } |
|
| 1382 | + | } |
|
| 1383 | + | other => { |
|
| 1384 | + | ctx.err(c.span(), format!("unknown approval setting `{other}`; approval takes `by`, `others-only`, `expires` and `sealed`")) |
|
| 1364 | 1385 | } |
|
| 1365 | - | other => ctx.err(c.span(), format!("unknown approval setting `{other}`; approval takes `by`, `others-only` and `expires`")), |
|
| 1366 | 1386 | } |
|
| 1367 | 1387 | } |
|
| 1368 | - | Some(a) |
|
| 1388 | + | Some((a, sealed_said)) |
|
| 1369 | 1389 | } |
|
| 1370 | 1390 | ||
| 1371 | 1391 | /// `input { artifact "NAME" from "JOB"; path "/src/FILE" }`. |
|
| … | 47 unchanged lines |
||
| 1419 | 1439 | fn parse_job(ctx: &mut Ctx, node: &KdlNode) -> Option<Job> { |
|
| 1420 | 1440 | let name = ctx.one_string(node)?; |
|
| 1421 | 1441 | let named = ctx.name_ok(node.span(), "job", &name); |
|
| 1442 | + | let mut approval: Option<(Approval, bool)> = None; |
|
| 1422 | 1443 | let mut job = Job { |
|
| 1423 | 1444 | name: name.clone(), |
|
| 1424 | 1445 | sandbox: String::new(), |
|
| … | 86 unchanged lines |
||
| 1511 | 1532 | parse_output(ctx, c, &mut job); |
|
| 1512 | 1533 | } |
|
| 1513 | 1534 | "input" => parse_input(ctx, c, &mut job), |
|
| 1514 | - | "approval" => job.approval = parse_approval(ctx, c), |
|
| 1535 | + | "approval" => approval = parse_approval(ctx, c), |
|
| 1515 | 1536 | "environment" => { |
|
| 1516 | 1537 | ctx.no_children(c); |
|
| 1517 | 1538 | if let Some(e) = ctx.one_string(c) { |
|
| … | 11 unchanged lines |
||
| 1529 | 1550 | other => ctx.err(c.span(), format!("unknown job setting `{other}`")), |
|
| 1530 | 1551 | } |
|
| 1531 | 1552 | } |
|
| 1553 | + | // A job for an environment takes a sealed approval unless it says not. |
|
| 1554 | + | job.approval = approval.map(|(mut a, said)| { |
|
| 1555 | + | if !said { |
|
| 1556 | + | a.sealed = job.environment.is_some(); |
|
| 1557 | + | } |
|
| 1558 | + | a |
|
| 1559 | + | }); |
|
| 1532 | 1560 | job.mounts.sort_by(|a, b| a.target.cmp(&b.target)); |
|
| 1533 | 1561 | job.outputs.sort_by(|a, b| a.name.cmp(&b.name)); |
|
| 1534 | 1562 | job.inputs.sort_by(|a, b| a.path.cmp(&b.path)); |
|
| … | 26 unchanged lines |
modified crates/flow-core/src/tests.rs +13 −2
| … | 674 unchanged lines |
||
| 675 | 675 | fn a_job_can_wait_for_approval() { |
|
| 676 | 676 | let job = |approval: &str| doc(&format!("job \"apply\" {{\n sandbox \"alpine\"\n{approval} exec \"apply\"\n}}\n")); |
|
| 677 | 677 | let p = Pipeline::parse(&job(" approval { by \"maintain\"; others-only #true; expires \"2h\"; }\n")).unwrap(); |
|
| 678 | - | assert_eq!(p.jobs["apply"].approval, Some(crate::Approval { by: "maintain".into(), others_only: true, expires_s: 7200 })); |
|
| 678 | + | assert_eq!( |
|
| 679 | + | p.jobs["apply"].approval, |
|
| 680 | + | Some(crate::Approval { by: "maintain".into(), others_only: true, expires_s: 7200, sealed: false }) |
|
| 681 | + | ); |
|
| 679 | 682 | let plain = Pipeline::parse(&job(" approval\n")).unwrap(); |
|
| 680 | 683 | assert_eq!( |
|
| 681 | 684 | plain.jobs["apply"].approval, |
|
| 682 | - | Some(crate::Approval { by: "maintain".into(), others_only: false, expires_s: 86400 }), |
|
| 685 | + | Some(crate::Approval { by: "maintain".into(), others_only: false, expires_s: 86400, sealed: false }), |
|
| 683 | 686 | "defaults" |
|
| 684 | 687 | ); |
|
| 688 | + | // Sealed (oxygenhub#235): on by default for a job with an environment, |
|
| 689 | + | // as it says otherwise. |
|
| 690 | + | let sealed = |extra: &str| Pipeline::parse(&job(extra)).unwrap().jobs["apply"].approval.clone().unwrap().sealed; |
|
| 691 | + | assert!(sealed(" environment \"prod\"\n approval\n")); |
|
| 692 | + | assert!(!sealed(" environment \"prod\"\n approval { sealed #false; }\n")); |
|
| 693 | + | assert!(sealed(" approval { sealed #true; }\n")); |
|
| 694 | + | assert_ne!(Pipeline::parse(&job(" approval { sealed #true; }\n")).unwrap().hash(), plain.hash(), "part of the job"); |
|
| 685 | 695 | assert_eq!(Pipeline::parse(&job("")).unwrap().jobs["apply"].approval, None); |
|
| 686 | 696 | assert_ne!(p.hash(), plain.hash(), "the approval is part of the job"); |
|
| 687 | 697 | let has = |a: &str, want: &str| { |
|
| … | 4 unchanged lines |
||
| 692 | 702 | has(" approval { others-only \"yes\"; }\n", "#true or #false"); |
|
| 693 | 703 | has(" approval { expires \"800h\"; }\n", "from 1m to 720h"); |
|
| 694 | 704 | has(" approval { when \"later\"; }\n", "unknown approval setting"); |
|
| 705 | + | has(" approval { sealed \"yes\"; }\n", "#true or #false"); |
|
| 695 | 706 | } |
|
| 696 | 707 | ||
| 697 | 708 | #[test] |
|
| … | 7 unchanged lines |
replaced deps/oxygen +0 −0
No line changes.
modified docs/spec.md +11 −0
| … | 335 unchanged lines |
||
| 336 | 336 | by "maintain" // the least role: "write", "maintain" (default) or "admin" |
|
| 337 | 337 | others-only #true // not the person whose push started the run |
|
| 338 | 338 | expires "24h" // from when the jobs it depends on are done; up to 720h |
|
| 339 | + | sealed #true // signed with the approver's key; default: on with an environment |
|
| 339 | 340 | } |
|
| 340 | 341 | } |
|
| 341 | 342 | ``` |
|
| … | 6 unchanged lines |
||
| 348 | 349 | bookmark cancels the run as before, so a stale plan is never applied. The |
|
| 349 | 350 | approval is part of the job's hash (field 11). |
|
| 350 | 351 | ||
| 352 | + | **Sealed approvals (oxygenhub#235).** With `sealed` (the default for a job |
|
| 353 | + | that names an environment, §5.3.3), pressing Approve is not enough: the |
|
| 354 | + | approver signs an Oxygen `approval` attestation with their own key |
|
| 355 | + | (`oxy seal --flow URL --job NAME`; Oxygen spec §14.4) covering the run, the |
|
| 356 | + | job's hash, the state, the input blobs (the reviewed plan) and the |
|
| 357 | + | environment. The hub starts the job only once it verifies, the key is |
|
| 358 | + | linked to an account with the role, and it is not a delegated key; it keeps |
|
| 359 | + | the approval in the repository, and the job's identity tokens name the |
|
| 360 | + | approver and the approval's hash. Rejecting needs no seal. |
|
| 361 | + | ||
| 351 | 362 | ### 5.3.3 Environments (oxygenflow#59) |
|
| 352 | 363 | ||
| 353 | 364 | `environment "prod"` on a job says where it deploys (1 to 32 of `a-z`, |
|
| … | 364 unchanged lines |