OXYGENHUB

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