Sealed approvals: a gate released only by a signature of the approver's key 47e374020b59
Pinned at oxygenflow 51826a15 (oxygen 30440111). A gate that takes a
sealed approval (default for a job with an environment) is not released by
Approve: GET .../flow/runs/{n}/jobs/{job}/approval says what to sign (this
repository, the run, the job's hash with its sandbox, the state, the input
blobs, the environment) and POST .../seal takes the signed approval, which
must verify, cover exactly that statement and be signed by a key linked to
an account (a delegated agent key is not) that may decide; it is kept in the
repository (Repo::add_approval) and recorded on the job (approval_seal,
migration 0072). The job's identity tokens carry approved_by and approval.
The run page shows the oxy seal command, and 'sealed with their key' once
it is. Test: refused plainly, refused for another plan or an unlinked key,
released by the seal, kept, named in the token.
Closes #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 10:07 UTC
- Signature
- ✔ signed by Claude Code
- Hash
47e374020b596d7b77dd8549aa97540dd916426a9378baefe1798608444411e0- Depends on
- 380996db84e5 417184c73f4e 42fa95540468 67be8ac00c24 961c10bee174 b644e1757711 d1727c573f30 d23464c0ac60 dc59d1b56c3d
- Depended on by
- 84e3c09656b2 4a68f1064b08 e97500b70f9a a7aa76ac52d7 317d776c725b 05f1a85a2842 965b950f9b96 ed3a36b6414c 55673c650986 5d90e0e045e2
- In bookmarks
- api-unknown-fields cancel-landed-runs diffusion-pin-bookmark draft-ready main oh-comment-body-file proposal-list-fast
- In tags
- v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
- Edits
- 33 atoms, 1 tree operation, 9 files
Browse the tree at this change
added crates/hub/migrations/0072_flow_approval_seals.sql +3 −0
| 1 | + | -- Sealed approvals (oxygenhub#235): the approval attestation, kept in the |
|
| 2 | + | -- repository, that released a job; empty when it was approved unsealed. |
|
| 3 | + | ALTER TABLE flow_jobs ADD COLUMN approval_seal TEXT NOT NULL DEFAULT ''; |
modified crates/hub/src/api/mod.rs +46 −0
| … | 154 unchanged lines |
||
| 155 | 155 | .route("/repos/{owner}/{name}/deployments", get(list_deployments)) |
|
| 156 | 156 | .route("/repos/{owner}/{name}/flow/runs/{number}/jobs/{job}/approve", post(approve_flow_job)) |
|
| 157 | 157 | .route("/repos/{owner}/{name}/flow/runs/{number}/jobs/{job}/reject", post(reject_flow_job)) |
|
| 158 | + | .route("/repos/{owner}/{name}/flow/runs/{number}/jobs/{job}/approval", get(flow_job_statement)) |
|
| 159 | + | .route("/repos/{owner}/{name}/flow/runs/{number}/jobs/{job}/seal", post(seal_flow_job)) |
|
| 158 | 160 | .route("/repos/{owner}/{name}/checks/required", get(required_checks).put(set_required_checks)) |
|
| 159 | 161 | .route("/repos/{owner}/{name}/checks/{state}", get(checks_for_state)) |
|
| 160 | 162 | .route("/repos/{owner}/{name}/releases", get(releases).post(create_release)) |
|
| … | 2070 unchanged lines |
||
| 2231 | 2233 | decide_flow_job(&app, &headers, &owner, &name, number, &job, false).await |
|
| 2232 | 2234 | } |
|
| 2233 | 2235 | ||
| 2236 | + | /// `GET .../flow/runs/{n}/jobs/{job}/approval`: what approving the job |
|
| 2237 | + | /// means, for `oxy seal --flow` to sign (oxygenhub#235). |
|
| 2238 | + | async fn flow_job_statement( |
|
| 2239 | + | State(app): State<Shared>, |
|
| 2240 | + | headers: HeaderMap, |
|
| 2241 | + | Path((owner, name, number, job)): Path<(String, String, i64, String)>, |
|
| 2242 | + | ) -> Result<Json<serde_json::Value>, ApiError> { |
|
| 2243 | + | let c = caller(&app, &headers).await?; |
|
| 2244 | + | let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?; |
|
| 2245 | + | let run = db::flow::run(&app.db, row.id, number).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such run".into()))?; |
|
| 2246 | + | let (s, j) = crate::flow::approval_statement(&app, &row, &run, &job).await.map_err(|m| ApiError(StatusCode::CONFLICT, m))?; |
|
| 2247 | + | Ok(Json(serde_json::json!({ |
|
| 2248 | + | "repo": hex::encode(s.repo), |
|
| 2249 | + | "run": s.run, |
|
| 2250 | + | "job": s.job, |
|
| 2251 | + | "job_hash": s.job_hash.to_hex(), |
|
| 2252 | + | "state": s.state.to_hex(), |
|
| 2253 | + | "inputs": s.inputs.iter().map(|i| i.to_hex()).collect::<Vec<_>>(), |
|
| 2254 | + | "environment": s.environment, |
|
| 2255 | + | "waiting": j.approval == "waiting" && j.status == "queued", |
|
| 2256 | + | }))) |
|
| 2257 | + | } |
|
| 2258 | + | ||
| 2259 | + | #[derive(Deserialize)] |
|
| 2260 | + | struct SealBody { |
|
| 2261 | + | signed: String, |
|
| 2262 | + | } |
|
| 2263 | + | ||
| 2264 | + | /// `POST .../flow/runs/{n}/jobs/{job}/seal` `{"signed": hex}`: approve or |
|
| 2265 | + | /// reject with a signed approval (oxygenhub#235). The signature, not the |
|
| 2266 | + | /// caller, says who decided; the caller must be signed in. |
|
| 2267 | + | async fn seal_flow_job( |
|
| 2268 | + | State(app): State<Shared>, |
|
| 2269 | + | headers: HeaderMap, |
|
| 2270 | + | Path((owner, name, number, job)): Path<(String, String, i64, String)>, |
|
| 2271 | + | Json(body): Json<SealBody>, |
|
| 2272 | + | ) -> Result<Json<serde_json::Value>, ApiError> { |
|
| 2273 | + | let c = caller(&app, &headers).await?; |
|
| 2274 | + | let (_, row, _) = reach(&app, &c, &owner, &name, Access::Read).await?; |
|
| 2275 | + | let run = db::flow::run(&app.db, row.id, number).await?.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "no such run".into()))?; |
|
| 2276 | + | let said = crate::flow::decide_sealed(&app, &row, &run, &job, &body.signed).await.map_err(|m| ApiError(StatusCode::CONFLICT, m))?; |
|
| 2277 | + | Ok(Json(serde_json::json!({ "number": number, "job": job, "said": said }))) |
|
| 2278 | + | } |
|
| 2279 | + | ||
| 2234 | 2280 | async fn decide_flow_job( |
|
| 2235 | 2281 | app: &Shared, |
|
| 2236 | 2282 | headers: &HeaderMap, |
|
| … | 398 unchanged lines |
modified crates/hub/src/db/flow.rs +7 −3
| … | 103 unchanged lines |
||
| 104 | 104 | pub approved_ms: Option<i64>, |
|
| 105 | 105 | /// The environment it deploys to, or empty (oxygenflow#59). |
|
| 106 | 106 | pub environment: String, |
|
| 107 | + | /// The approval attestation that released it, when sealed (#235). |
|
| 108 | + | pub approval_seal: String, |
|
| 107 | 109 | } |
|
| 108 | 110 | ||
| 109 | 111 | impl Job { |
|
| … | 846 unchanged lines |
||
| 956 | 958 | exit_code = NULL, error = '', receipt = '', receipt_hash = '', log_count = 0, also_read = '', receipt_attest = '', |
|
| 957 | 959 | receipt_signer = '', receipt_note = '', runner_id = NULL, waiting = '', placed = '', peak_disk_mib = NULL, |
|
| 958 | 960 | peak_memory_mib = NULL, approval = CASE WHEN approval = '' THEN '' ELSE 'waiting' END, approved_by = '', |
|
| 959 | - | approved_ms = NULL WHERE id = ?", |
|
| 961 | + | approved_ms = NULL, approval_seal = '' WHERE id = ?", |
|
| 960 | 962 | ) |
|
| 961 | 963 | .bind(id) |
|
| 962 | 964 | .execute(&mut *tx) |
|
| … | 116 unchanged lines |
||
| 1079 | 1081 | ||
| 1080 | 1082 | /// Approve job `id` if it is still waiting for a person (oxygenflow#57); |
|
| 1081 | 1083 | /// whether it was. |
|
| 1082 | - | pub async fn approve_job(pool: &Pool, id: i64, who: &str) -> sqlx::Result<bool> { |
|
| 1084 | + | pub async fn approve_job(pool: &Pool, id: i64, who: &str, seal: &str) -> sqlx::Result<bool> { |
|
| 1083 | 1085 | let r = sqlx::query( |
|
| 1084 | - | "UPDATE flow_jobs SET approval = 'approved', approved_by = ?, approved_ms = ? WHERE id = ? AND status = 'queued' AND approval = 'waiting'", |
|
| 1086 | + | "UPDATE flow_jobs SET approval = 'approved', approved_by = ?, approved_ms = ?, approval_seal = ? \ |
|
| 1087 | + | WHERE id = ? AND status = 'queued' AND approval = 'waiting'", |
|
| 1085 | 1088 | ) |
|
| 1086 | 1089 | .bind(who) |
|
| 1087 | 1090 | .bind(now_ms()) |
|
| 1091 | + | .bind(seal) |
|
| 1088 | 1092 | .bind(id) |
|
| 1089 | 1093 | .execute(pool) |
|
| 1090 | 1094 | .await?; |
|
| … | 49 unchanged lines |
modified crates/hub/src/flow.rs +97 −5
| … | 199 unchanged lines |
||
| 200 | 200 | who: &db::accounts::Account, |
|
| 201 | 201 | approve: bool, |
|
| 202 | 202 | ) -> Result<String, String> { |
|
| 203 | + | decide_with(app, row, run, name, who, approve, None).await |
|
| 204 | + | } |
|
| 205 | + | ||
| 206 | + | /// [`decide`], from a verified seal (`Some(approval ref)`) or without one; |
|
| 207 | + | /// a gate that takes a sealed approval is not approved without. |
|
| 208 | + | async fn decide_with( |
|
| 209 | + | app: &Shared, |
|
| 210 | + | row: &db::repos::RepoRow, |
|
| 211 | + | run: &fdb::Run, |
|
| 212 | + | name: &str, |
|
| 213 | + | who: &db::accounts::Account, |
|
| 214 | + | approve: bool, |
|
| 215 | + | seal: Option<&str>, |
|
| 216 | + | ) -> Result<String, String> { |
|
| 203 | 217 | let jobs = fdb::jobs(&app.db, run.id).await.map_err(|e| e.to_string())?; |
|
| 204 | 218 | let Some(job) = jobs.into_iter().find(|j| j.name == name) else { return Err(format!("run #{} has no job {name}", run.number)) }; |
|
| 205 | - | may_decide(app, row, run, &job, who).await?; |
|
| 219 | + | let approval = may_decide(app, row, run, &job, who).await?; |
|
| 220 | + | if approve && approval.sealed && seal.is_none() { |
|
| 221 | + | return Err(format!( |
|
| 222 | + | "{name} takes an approval signed with your key: in a checkout of {}/{}, run oxy seal --flow {}/{}/{}/flow/{} --job {name}", |
|
| 223 | + | row.owner, |
|
| 224 | + | row.name, |
|
| 225 | + | app.config.base_url(), |
|
| 226 | + | row.owner, |
|
| 227 | + | row.name, |
|
| 228 | + | run.number |
|
| 229 | + | )); |
|
| 230 | + | } |
|
| 206 | 231 | let target = format!("{}/{}#{} {name}", row.owner, row.name, run.number); |
|
| 232 | + | let sealed = seal.map(|r| format!(", sealed (approval {})", &r[..12.min(r.len())])).unwrap_or_default(); |
|
| 207 | 233 | let said = if approve { |
|
| 208 | - | if !fdb::approve_job(&app.db, job.id, &who.username).await.map_err(|e| e.to_string())? { |
|
| 234 | + | if !fdb::approve_job(&app.db, job.id, &who.username, seal.unwrap_or("")).await.map_err(|e| e.to_string())? { |
|
| 209 | 235 | return Err(format!("{name} was decided on meanwhile")); |
|
| 210 | 236 | } |
|
| 211 | - | db::accounts::audit(&app.db, Some(who), "flow.approved", &target, "").await.ok(); |
|
| 212 | - | format!("approved by {}", who.username) |
|
| 237 | + | db::accounts::audit(&app.db, Some(who), "flow.approved", &target, seal.unwrap_or("")).await.ok(); |
|
| 238 | + | format!("approved by {}{sealed}", who.username) |
|
| 213 | 239 | } else { |
|
| 214 | 240 | fdb::note_rejection(&app.db, job.id, &who.username).await.map_err(|e| e.to_string())?; |
|
| 215 | - | let why = format!("rejected by {}", who.username); |
|
| 241 | + | let why = format!("rejected by {}{sealed}", who.username); |
|
| 216 | 242 | let changed = |
|
| 217 | 243 | fdb::finish(&app.db, job.id, fdb::Outcome { status: "cancelled", exit_code: None, error: &why, receipt: "", receipt_hash: "" }) |
|
| 218 | 244 | .await |
|
| … | 33 unchanged lines |
||
| 252 | 278 | let url = format!("/{}/{}/flow/{}", row.owner, row.name, run.number); |
|
| 253 | 279 | crate::notify::tell(app, "flow.approval", &title, &url, &run.actor, Some(row.id), targets).await; |
|
| 254 | 280 | } |
|
| 281 | + | } |
|
| 282 | + | ||
| 283 | + | /// What approving job `name` of `run` means, for the approver to sign |
|
| 284 | + | /// (oxygenhub#235): this repository, the run's URL, the job's hash with its |
|
| 285 | + | /// sandbox, the run's state, the blobs it takes, its environment. |
|
| 286 | + | pub async fn approval_statement( |
|
| 287 | + | app: &Shared, |
|
| 288 | + | row: &db::repos::RepoRow, |
|
| 289 | + | run: &fdb::Run, |
|
| 290 | + | name: &str, |
|
| 291 | + | ) -> Result<(oxy_core::approval::Statement, fdb::Job), String> { |
|
| 292 | + | let jobs = fdb::jobs(&app.db, run.id).await.map_err(|e| e.to_string())?; |
|
| 293 | + | let Some(job) = jobs.into_iter().find(|j| j.name == name) else { return Err(format!("run #{} has no job {name}", run.number)) }; |
|
| 294 | + | let spec: flow_core::Job = serde_json::from_str(&job.spec).map_err(|e| format!("stored job: {e}"))?; |
|
| 295 | + | let sandbox: flow_core::Sandbox = serde_json::from_str(&job.sandbox).map_err(|e| format!("stored sandbox: {e}"))?; |
|
| 296 | + | let repo_name = RepoName::new(&row.owner, &row.name).ok_or("bad repository name")?; |
|
| 297 | + | let repo = app.repos.read(&repo_name, |r, _| Ok(r.id()?)).await.map_err(|e| e.to_string())?; |
|
| 298 | + | let state = oxy_core::hash::ObjectRef::from_hex(&run.state).ok_or("the run has no state")?; |
|
| 299 | + | let inputs = artifact_inputs(app, &job, &spec).await?.iter().filter_map(|i| oxy_core::hash::ObjectRef::from_hex(&i.blob)).collect(); |
|
| 300 | + | let statement = oxy_core::approval::Statement { |
|
| 301 | + | repo, |
|
| 302 | + | run: format!("{}/{}/{}/flow/{}", app.config.base_url(), row.owner, row.name, run.number), |
|
| 303 | + | job: name.to_string(), |
|
| 304 | + | job_hash: oxy_core::hash::ObjectRef(spec.hash(&sandbox)), |
|
| 305 | + | state, |
|
| 306 | + | inputs, |
|
| 307 | + | environment: spec.environment.clone().unwrap_or_default(), |
|
| 308 | + | approve: true, |
|
| 309 | + | }; |
|
| 310 | + | Ok((statement, job)) |
|
| 311 | + | } |
|
| 312 | + | ||
| 313 | + | /// Decide on job `name` of `run` from a signed approval (oxygenhub#235): it |
|
| 314 | + | /// must verify, cover exactly this job's statement, and be signed by a key |
|
| 315 | + | /// linked to an account (a delegated agent key is not) that may decide; it |
|
| 316 | + | /// is kept in the repository. What happened, or why not. |
|
| 317 | + | pub async fn decide_sealed(app: &Shared, row: &db::repos::RepoRow, run: &fdb::Run, name: &str, signed_hex: &str) -> Result<String, String> { |
|
| 318 | + | let bytes = hex::decode(signed_hex.trim()).map_err(|_| "the signed approval is not hex".to_string())?; |
|
| 319 | + | let sa = oxy_core::approval::SignedApproval::decode(&bytes).map_err(|e| format!("not a signed approval: {e}"))?; |
|
| 320 | + | sa.verify().map_err(|e| format!("it does not verify: {e}"))?; |
|
| 321 | + | let (mut expected, _) = approval_statement(app, row, run, name).await?; |
|
| 322 | + | let given = sa.statement().map_err(|e| e.to_string())?; |
|
| 323 | + | expected.approve = given.approve; |
|
| 324 | + | if !sa.covers(&expected) { |
|
| 325 | + | return Err(format!( |
|
| 326 | + | "it approves something else than job {name} of run #{} as it is now (another run, state, plan or environment): sign again", |
|
| 327 | + | run.number |
|
| 328 | + | )); |
|
| 329 | + | } |
|
| 330 | + | let key = sa.signer.hash().to_hex(); |
|
| 331 | + | let links = db::accounts::identity_accounts(&app.db).await.map_err(|e| e.to_string())?; |
|
| 332 | + | let Some((_, username)) = links.iter().find(|(k, _)| *k == key) else { |
|
| 333 | + | return Err(format!( |
|
| 334 | + | "key {} is not linked to an account here (link it under Settings › Keys; an agent's delegated key cannot approve)", |
|
| 335 | + | &key[..12] |
|
| 336 | + | )); |
|
| 337 | + | }; |
|
| 338 | + | let who = db::accounts::account_by_name(&app.db, username).await.map_err(|e| e.to_string())?.ok_or("the key's account is gone")?; |
|
| 339 | + | let repo_name = RepoName::new(&row.owner, &row.name).ok_or("bad repository name")?; |
|
| 340 | + | let jobs = fdb::jobs(&app.db, run.id).await.map_err(|e| e.to_string())?; |
|
| 341 | + | let job = jobs.iter().find(|j| j.name == name).ok_or("no such job")?; |
|
| 342 | + | may_decide(app, row, run, job, &who).await?; |
|
| 343 | + | let kept = app.repos.write(&repo_name, move |r, _| Ok(r.add_approval(&sa)?)).await.map_err(|e| e.to_string())?; |
|
| 344 | + | decide_with(app, row, run, name, &who, given.approve, Some(&kept.to_hex())).await |
|
| 255 | 345 | } |
|
| 256 | 346 | ||
| 257 | 347 | /// End the approvals that waited too long (oxygenflow#57): from when the |
|
| … | 619 unchanged lines |
||
| 877 | 967 | runner: &runner, |
|
| 878 | 968 | actor: &run.actor, |
|
| 879 | 969 | environment: environment.as_deref(), |
|
| 970 | + | approved_by: (job.approval == "approved").then_some(job.approved_by.as_str()), |
|
| 971 | + | approval: (!job.approval_seal.is_empty()).then_some(job.approval_seal.as_str()), |
|
| 880 | 972 | now_ms: db::now_ms(), |
|
| 881 | 973 | ttl_s: timeout_s, |
|
| 882 | 974 | }); |
|
| … | 1514 unchanged lines |
modified crates/hub/src/oidc.rs +14 −2
| … | 49 unchanged lines |
||
| 50 | 50 | /// The environment the job deploys to, only once it was approved |
|
| 51 | 51 | /// (oxygenflow#59). |
|
| 52 | 52 | pub environment: Option<&'a str>, |
|
| 53 | + | /// Who approved the job, and the sealed approval's hash (#235). |
|
| 54 | + | pub approved_by: Option<&'a str>, |
|
| 55 | + | pub approval: Option<&'a str>, |
|
| 53 | 56 | pub now_ms: i64, |
|
| 54 | 57 | pub ttl_s: u64, |
|
| 55 | 58 | } |
|
| … | 43 unchanged lines |
||
| 99 | 102 | "subject_types_supported": ["public"], |
|
| 100 | 103 | "id_token_signing_alg_values_supported": ["RS256"], |
|
| 101 | 104 | "claims_supported": ["iss", "sub", "aud", "exp", "iat", "nbf", "jti", "repository", "repository_owner", |
|
| 102 | - | "repository_visibility", "bookmark", "run_number", "job", "state", "runner", "actor", "environment"], |
|
| 105 | + | "repository_visibility", "bookmark", "run_number", "job", "state", "runner", "actor", "environment", "approved_by", "approval"], |
|
| 103 | 106 | "scopes_supported": ["openid"], |
|
| 104 | 107 | }) |
|
| 105 | 108 | } |
|
| … | 30 unchanged lines |
||
| 136 | 139 | if let Some(e) = c.environment { |
|
| 137 | 140 | payload["environment"] = serde_json::Value::from(e); |
|
| 138 | 141 | } |
|
| 142 | + | if let Some(a) = c.approved_by { |
|
| 143 | + | payload["approved_by"] = serde_json::Value::from(a); |
|
| 144 | + | } |
|
| 145 | + | if let Some(a) = c.approval { |
|
| 146 | + | payload["approval"] = serde_json::Value::from(a); |
|
| 147 | + | } |
|
| 139 | 148 | let signing_input = format!("{}.{}", b64url(header.to_string().as_bytes()), b64url(payload.to_string().as_bytes())); |
|
| 140 | 149 | let sig = self.signing.sign(signing_input.as_bytes()); |
|
| 141 | 150 | format!("{signing_input}.{}", b64url(&sig.to_bytes())) |
|
| … | 147 unchanged lines |
||
| 289 | 298 | runner: "earth-1", |
|
| 290 | 299 | actor: "andy", |
|
| 291 | 300 | environment: Some("prod"), |
|
| 301 | + | approved_by: Some("andy"), |
|
| 302 | + | approval: Some("ab12"), |
|
| 292 | 303 | now_ms: 1_800_000_000_000, |
|
| 293 | 304 | ttl_s: 900, |
|
| 294 | 305 | }; |
|
| … | 3 unchanged lines |
||
| 298 | 309 | assert_eq!(got["sub"], "repo:andy/demo:bookmark:main"); |
|
| 299 | 310 | assert_eq!(got["run_number"], 7); |
|
| 300 | 311 | assert_eq!(got["environment"], "prod"); |
|
| 301 | - | let plain = issuer.mint(&JobClaims { environment: None, ..claims }); |
|
| 312 | + | assert_eq!((got["approved_by"].as_str(), got["approval"].as_str()), (Some("andy"), Some("ab12"))); |
|
| 313 | + | let plain = issuer.mint(&JobClaims { environment: None, approved_by: None, approval: None, ..claims }); |
|
| 302 | 314 | assert!(again.verify(&plain, "sts.amazonaws.com", 1_800_000_500_000).unwrap().get("environment").is_none(), "only when approved"); |
|
| 303 | 315 | assert_eq!(got["exp"].as_i64().unwrap() - got["iat"].as_i64().unwrap(), 900); |
|
| 304 | 316 | assert!(again.verify(&token, "other", 1_800_000_500_000).is_none(), "another audience"); |
|
| … | 12 unchanged lines |
modified crates/hub/src/web/flow.rs +8 −0
| … | 386 unchanged lines |
||
| 387 | 387 | pub when_ms: i64, |
|
| 388 | 388 | /// Whether the viewer may approve or reject it now, else why not. |
|
| 389 | 389 | pub may: Result<(), String>, |
|
| 390 | + | /// It takes an approval signed with the approver's key (#235): the |
|
| 391 | + | /// command that signs it, and the approval's hash once sealed. |
|
| 392 | + | pub sealed: bool, |
|
| 393 | + | pub command: String, |
|
| 394 | + | pub seal: String, |
|
| 390 | 395 | } |
|
| 391 | 396 | ||
| 392 | 397 | impl ApprovalView { |
|
| … | 59 unchanged lines |
||
| 452 | 457 | _ => Err(String::new()), |
|
| 453 | 458 | }; |
|
| 454 | 459 | approvals.push(ApprovalView { |
|
| 460 | + | sealed: a.sealed, |
|
| 461 | + | command: format!("oxy seal --flow {}/{owner}/{name}/flow/{} --job {}", app.config.base_url(), run.number, j.name), |
|
| 462 | + | seal: j.approval_seal.clone(), |
|
| 455 | 463 | job: j.name.clone(), |
|
| 456 | 464 | by: a.by, |
|
| 457 | 465 | others_only: a.others_only, |
|
| … | 433 unchanged lines |
modified crates/hub/tests/flow.rs +96 −1
| … | 4293 unchanged lines |
||
| 4294 | 4294 | work.path(), |
|
| 4295 | 4295 | ".flow/pipeline.kdl", |
|
| 4296 | 4296 | &format!( |
|
| 4297 | - | "schema-version 1\nsandbox \"alpine\" {{\n rootfs \"cas://blob/{ROOTFS}\"\n}}\njob \"plan\" {{\n sandbox \"alpine\"\n exec \"plan\"\n}}\njob \"apply\" {{\n sandbox \"alpine\"\n depends-on \"plan\"\n approval {{\n by \"maintain\"\n others-only #true\n }}\n environment \"prod\"\n identity \"https://sts.example\" {{\n mount-path \"/run/secrets/cloud\"\n }}\n exec \"apply\"\n}}\n" |
|
| 4297 | + | "schema-version 1\nsandbox \"alpine\" {{\n rootfs \"cas://blob/{ROOTFS}\"\n}}\njob \"plan\" {{\n sandbox \"alpine\"\n exec \"plan\"\n}}\njob \"apply\" {{\n sandbox \"alpine\"\n depends-on \"plan\"\n approval {{\n by \"maintain\"\n others-only #true\n sealed #false\n }}\n environment \"prod\"\n identity \"https://sts.example\" {{\n mount-path \"/run/secrets/cloud\"\n }}\n exec \"apply\"\n}}\n" |
|
| 4298 | 4298 | ), |
|
| 4299 | 4299 | ); |
|
| 4300 | 4300 | let mut n = 0; |
|
| … | 148 unchanged lines |
||
| 4449 | 4449 | let (_, text) = read(topics, Some(first.id)).await; |
|
| 4450 | 4450 | assert!(text.contains("event: reload"), "{text}"); |
|
| 4451 | 4451 | } |
|
| 4452 | + | ||
| 4453 | + | #[tokio::test(flavor = "multi_thread")] |
|
| 4454 | + | async fn a_sealed_approval_releases_exactly_the_reviewed_plan() { |
|
| 4455 | + | use oxy_core::approval::{SignedApproval, Statement}; |
|
| 4456 | + | use oxy_core::hash::ObjectRef; |
|
| 4457 | + | use oxy_core::identity::SecretKey; |
|
| 4458 | + | let h = hub().await; |
|
| 4459 | + | db::accounts::create_account(&h.app.db, "andy", "[email protected]", "x", "salt", true).await.unwrap(); |
|
| 4460 | + | let carol = db::accounts::create_account(&h.app.db, "carol", "[email protected]", "x", "salt", true).await.unwrap(); |
|
| 4461 | + | let sk = SecretKey::generate(); |
|
| 4462 | + | let carol_id = sk.identity("Carol", "[email protected]", 1); |
|
| 4463 | + | db::accounts::link_identity(&h.app.db, carol.id, &carol_id.hash().to_hex(), "Carol", "[email protected]", &carol_id.encode()) |
|
| 4464 | + | .await |
|
| 4465 | + | .unwrap(); |
|
| 4466 | + | create(&h.app, "andy", "infra").await; |
|
| 4467 | + | let row = db::repos::get(&h.app.db, "andy", "infra").await.unwrap().unwrap(); |
|
| 4468 | + | let work = tempfile::tempdir().unwrap(); |
|
| 4469 | + | let c = client(&format!("{}/andy/infra", h.base), work.path()); |
|
| 4470 | + | write( |
|
| 4471 | + | work.path(), |
|
| 4472 | + | ".flow/pipeline.kdl", |
|
| 4473 | + | &format!( |
|
| 4474 | + | "schema-version 1\nsandbox \"alpine\" {{\n rootfs \"cas://blob/{ROOTFS}\"\n}}\njob \"plan\" {{\n sandbox \"alpine\"\n exec \"plan\"\n output {{\n artifact \"tfplan\" {{\n path \"/src/out/tfplan\"\n }}\n }}\n}}\njob \"apply\" {{\n sandbox \"alpine\"\n depends-on \"plan\"\n environment \"prod\"\n approval\n input {{\n artifact \"tfplan\" from \"plan\"\n path \"/src/tfplan\"\n }}\n identity \"https://sts.example\" {{\n mount-path \"/run/secrets/cloud\"\n }}\n exec \"apply\"\n}}\n" |
|
| 4475 | + | ), |
|
| 4476 | + | ); |
|
| 4477 | + | c.snapshot("test").unwrap(); |
|
| 4478 | + | c.commit("Plan, then apply", &[]).unwrap(); |
|
| 4479 | + | assert!(c.push("origin", false).unwrap().iter().all(|r| r.ok)); |
|
| 4480 | + | let run = runs_of(&h.app, "andy", "infra").await[0].clone(); |
|
| 4481 | + | let p = claim(&h.app).await.unwrap(); |
|
| 4482 | + | let plan_id = p["job_id"].as_i64().unwrap(); |
|
| 4483 | + | let plan = b"the reviewed plan".to_vec(); |
|
| 4484 | + | upload(&h.app, plan_id, "tfplan", &plan).await; |
|
| 4485 | + | let mut r = receipt("plan", p["pipeline_hash"].as_str().unwrap(), 0); |
|
| 4486 | + | r["outputs"] = serde_json::json!([oxy_core::repo::Repo::blob_ref(&plan).to_hex()]); |
|
| 4487 | + | finish(&h.app, plan_id, serde_json::json!({ "exit_code": 0, "duration_ms": 10, "receipt": r })).await; |
|
| 4488 | + | let run = db::flow::run(&h.app.db, row.id, run.number).await.unwrap().unwrap(); |
|
| 4489 | + | ||
| 4490 | + | // An environment's gate takes a sealed approval: pressing Approve is not enough. |
|
| 4491 | + | let plain = oxygenhub::flow::decide(&h.app, &row, &run, "apply", &carol, true).await.unwrap_err(); |
|
| 4492 | + | assert!(plain.contains(&format!("oxy seal --flow {}/andy/infra/flow/{} --job apply", h.base, run.number)), "{plain}"); |
|
| 4493 | + | // What there is to sign. |
|
| 4494 | + | let (st, body) = |
|
| 4495 | + | call(&h.app, "GET", &format!("/api/v1/repos/andy/infra/flow/runs/{}/jobs/apply/approval", run.number), Some("site"), None).await; |
|
| 4496 | + | assert_eq!(st, StatusCode::OK, "{body}"); |
|
| 4497 | + | let v: serde_json::Value = serde_json::from_str(&body).unwrap(); |
|
| 4498 | + | assert_eq!(v["environment"], "prod"); |
|
| 4499 | + | assert_eq!(v["inputs"], serde_json::json!([oxy_core::repo::Repo::blob_ref(&plan).to_hex()]), "the reviewed plan"); |
|
| 4500 | + | let mut repo = [0u8; 16]; |
|
| 4501 | + | repo.copy_from_slice(&hex::decode(v["repo"].as_str().unwrap()).unwrap()); |
|
| 4502 | + | let oref = |k: &str| ObjectRef::from_hex(v[k].as_str().unwrap()).unwrap(); |
|
| 4503 | + | let statement = Statement { |
|
| 4504 | + | repo, |
|
| 4505 | + | run: v["run"].as_str().unwrap().into(), |
|
| 4506 | + | job: "apply".into(), |
|
| 4507 | + | job_hash: oref("job_hash"), |
|
| 4508 | + | state: oref("state"), |
|
| 4509 | + | inputs: v["inputs"].as_array().unwrap().iter().map(|i| ObjectRef::from_hex(i.as_str().unwrap()).unwrap()).collect(), |
|
| 4510 | + | environment: "prod".into(), |
|
| 4511 | + | approve: true, |
|
| 4512 | + | }; |
|
| 4513 | + | let seal = |s: &Statement, key: &SecretKey, id: &oxy_core::identity::Identity| { |
|
| 4514 | + | let body = serde_json::json!({ "signed": hex::encode(SignedApproval::sign(s, id, key).encode()) }).to_string(); |
|
| 4515 | + | let app = h.app.clone(); |
|
| 4516 | + | let path = format!("/api/v1/repos/andy/infra/flow/runs/{}/jobs/apply/seal", run.number); |
|
| 4517 | + | async move { call(&app, "POST", &path, Some("site"), Some(body)).await } |
|
| 4518 | + | }; |
|
| 4519 | + | // Another plan, or a key linked to nobody (an agent's), is refused. |
|
| 4520 | + | let other_plan = Statement { inputs: vec![ObjectRef([9; 32])], ..statement.clone() }; |
|
| 4521 | + | let (st, body) = seal(&other_plan, &sk, &carol_id).await; |
|
| 4522 | + | assert!(st == StatusCode::CONFLICT && body.contains("approves something else"), "{st} {body}"); |
|
| 4523 | + | let agent = SecretKey::generate(); |
|
| 4524 | + | let agent_id = agent.identity("Claude Code", "[email protected]", 1); |
|
| 4525 | + | let (st, body) = seal(&statement, &agent, &agent_id).await; |
|
| 4526 | + | assert!(st == StatusCode::CONFLICT && body.contains("not linked to an account"), "{st} {body}"); |
|
| 4527 | + | assert!(claim(&h.app).await.is_none(), "still waiting"); |
|
| 4528 | + | ||
| 4529 | + | // Carol's seal releases it; it is kept in the repository; the token names her and it. |
|
| 4530 | + | let (st, body) = seal(&statement, &sk, &carol_id).await; |
|
| 4531 | + | assert_eq!(st, StatusCode::OK, "{body}"); |
|
| 4532 | + | assert!(body.contains("approved by carol, sealed"), "{body}"); |
|
| 4533 | + | let name = oxygenhub::repos::RepoName::parse("andy/infra").unwrap(); |
|
| 4534 | + | let state = statement.state; |
|
| 4535 | + | let kept = h.app.repos.read(&name, move |r, _| Ok(r.store().approvals_for(&state)?)).await.unwrap(); |
|
| 4536 | + | assert_eq!(kept.len(), 1, "the approval is in the repository"); |
|
| 4537 | + | let j = claim(&h.app).await.expect("released"); |
|
| 4538 | + | assert_eq!(j["job"]["name"], "apply"); |
|
| 4539 | + | let token = |
|
| 4540 | + | j["secrets"].as_array().unwrap().iter().find(|s| s["path"] == "/run/secrets/cloud").unwrap()["value"].as_str().unwrap().to_string(); |
|
| 4541 | + | let claims = h.app.oidc().await.unwrap().verify(&token, "https://sts.example", db::now_ms()).unwrap(); |
|
| 4542 | + | assert_eq!((claims["environment"].as_str(), claims["approved_by"].as_str()), (Some("prod"), Some("carol"))); |
|
| 4543 | + | assert_eq!(claims["approval"].as_str(), Some(kept[0].to_hex().as_str())); |
|
| 4544 | + | let (_, page) = call(&h.app, "GET", &format!("/andy/infra/flow/{}", run.number), None, None).await; |
|
| 4545 | + | assert!(page.contains("sealed with their key"), "the run page says so"); |
|
| 4546 | + | } |
replaced deps/oxygenflow +0 −0
No line changes.
modified templates/flow_run.html +4 −2
| … | 20 unchanged lines |
||
| 21 | 21 | <h2>Approvals</h2> |
|
| 22 | 22 | <ul class="plain">{% for a in approvals %}<li class="approval st-{{ a.state }}"><strong>{{ a.job }}</strong> |
|
| 23 | 23 | {% if a.state == "waiting" %}waits for approval by someone with the <em>{{ a.by }}</em> role{% if a.others_only %}, other than {{ run.actor }}{% endif %}. |
|
| 24 | - | {% if a.may_decide() %}<form class="inline" method="post" action="{{ repo.url }}/flow/{{ run.number }}/jobs/{{ a.job }}/decide"><input type="hidden" name="_csrf" value="{{ shell.csrf }}"><button type="submit" name="action" value="approve" class="small">APPROVE</button> <button type="submit" name="action" value="reject" class="small danger-btn">REJECT</button></form> |
|
| 24 | + | {% if a.sealed %}<p class="small">It takes an approval signed with your own key. In a checkout of {{ repo.owner }}/{{ repo.name }}:</p> |
|
| 25 | + | <div class="clone"><input type="text" readonly value="{{ a.command }}" onfocus="this.select()" aria-label="The command that approves it"><button type="button" class="small" data-copy="{{ a.command }}">copy</button></div>{% endif %} |
|
| 26 | + | {% if a.may_decide() %}<form class="inline" method="post" action="{{ repo.url }}/flow/{{ run.number }}/jobs/{{ a.job }}/decide"><input type="hidden" name="_csrf" value="{{ shell.csrf }}">{% if !a.sealed %}<button type="submit" name="action" value="approve" class="small">APPROVE</button> {% endif %}<button type="submit" name="action" value="reject" class="small danger-btn">REJECT</button></form> |
|
| 25 | 27 | {% else %}<span class="small mute">{{ a.why_not() }}</span>{% endif %} |
|
| 26 | - | {% else if a.state == "approved" %}approved by {{ a.who }} <time title="{{ a.when_ms|when }}">{{ a.when_ms|ago }}</time>. |
|
| 28 | + | {% else if a.state == "approved" %}approved by {{ a.who }} <time title="{{ a.when_ms|when }}">{{ a.when_ms|ago }}</time>{% if !a.seal.is_empty() %}, <span class="sig sig-valid">◈</span> sealed with their key (approval <code title="{{ a.seal }}">{{ a.seal|short }}</code>){% else if a.sealed %}{% endif %}. |
|
| 27 | 29 | {% else if a.state == "rejected" %}rejected by {{ a.who }} <time title="{{ a.when_ms|when }}">{{ a.when_ms|ago }}</time>. |
|
| 28 | 30 | {% else %}not approved in time.{% endif %}</li>{% endfor %}</ul> |
|
| 29 | 31 | </section>{% endif %} |
|
| … | 36 unchanged lines |