From 3f0b2a1ff419cb07c5e3d3dcda7a4c008ee78c63 Mon Sep 17 00:00:00 2001 From: "@permadeath.com" Date: Thu, 17 Sep 2026 09:45:35 -0400 Subject: [PATCH] test(ownership): run a GitHub pipeline and two cloud pools The claim sets GitHub, Google and Kubernetes actually mint, against the real binaries: an environment-scoped pipeline, twenty instances registering at once, and a pod named by its nested claims. Co-Authored-By: Claude Opus 5 Change-Id: Ia9a0dc264556e1585955cdc3b0e9b3bd9e0d8a5d --- crates/didbot/tests/scenarios.rs | 430 ++++++++++++++++++++++++++++++- 1 file changed, 429 insertions(+), 1 deletion(-) diff --git a/crates/didbot/tests/scenarios.rs b/crates/didbot/tests/scenarios.rs index aed779f3..9c2da7bb 100644 --- a/crates/didbot/tests/scenarios.rs +++ b/crates/didbot/tests/scenarios.rs @@ -674,7 +674,12 @@ impl Issuer { "jti": format!("{}", now.unix_timestamp_nanos()), }); for (name, value) in claims.as_object().expect("claims are an object") { - payload[name] = value.clone(); + // A platform that mints no `jti` is spelled by setting it null. + if value.is_null() { + payload.as_object_mut().expect("an object").remove(name); + } else { + payload[name] = value.clone(); + } } let input = format!( "{}.{}", @@ -4292,3 +4297,426 @@ fn vouch(human: &Human, name: &str) { }), ); } + +// --------------------------------------------------------------------------- +// A GitHub Actions pipeline, ported from AWS OIDC +// --------------------------------------------------------------------------- + +/// The claim set GitHub mints for a run, as `docs/pipelines.md` shows it. +fn github_claims(repository: &str, environment: Option<&str>) -> Value { + let owner = repository.split('/').next().unwrap_or_default(); + let mut claims = json!({ + "sub": match environment { + Some(environment) => format!("repo:{repository}:environment:{environment}"), + None => format!("repo:{repository}:ref:refs/heads/main"), + }, + "repository": repository, + "repository_owner": owner, + "ref": "refs/heads/main", + "ref_type": "branch", + "workflow": "deploy", + "job_workflow_ref": format!("{repository}/.github/workflows/deploy.yml@refs/heads/main"), + "actor": "octocat", + "run_id": "12345", + "nbf": OffsetDateTime::now_utc().unix_timestamp() - 30, + "exp": (OffsetDateTime::now_utc() + time::Duration::minutes(10)).unix_timestamp(), + }); + if let Some(environment) = environment { + claims["environment"] = Value::String(environment.to_owned()); + } + claims +} + +/// `deploy` admits every run of one repository, `deploy-prod` only a run +/// in its `production` environment; each run's token is one of the two +/// and nobody else's; a token is one command, and a stale one is refused. +#[tokio::test(flavor = "multi_thread")] +async fn a_github_pipeline_ported_from_aws() { + let stack = Stack::start(&["gh"]).await; + let server = stack.server(); + let human = stack.human.did(); + let deploy = format!("deploy.{}", server.host); + let prod = format!("deploy-prod.{}", server.host); + let (laptop, issuer) = (&stack.laptop, stack.issuer.base.as_str()); + let operate = |name: &str, claims: &[&str]| { + let mut args = vec![ + "operate", name, human, "--kind", "pipeline", "--oidc", issuer, + ]; + args.extend_from_slice(claims); + args.push("--json"); + let args: Vec = args.into_iter().map(str::to_owned).collect(); + async move { + let args: Vec<&str> = args.iter().map(String::as_str).collect(); + laptop.didbot(&args, &[]).await.ok() + } + }; + assert_eq!( + operate(&deploy, &["repository=org/repo"]).await["did"], + did_of(&deploy) + ); + assert_eq!( + operate(&prod, &["repository=org/repo", "environment=production"]).await["did"], + did_of(&prod) + ); + + let pds = [("DIDBOT_PDS", server.origin.as_str())]; + let run = Machine::new(&stack.work, "run-41"); + let mint = |claims: Value| stack.issuer.id_token(&server.did, claims); + + // Step one of the workflow: the run's token is a session as `deploy`. + let token = run.secret_file("id-token-1", &mint(github_claims("org/repo", None))); + run.didbot( + &[ + "oauth", + "pending", + "--token-file", + token.to_str().expect("utf-8"), + ], + &pds, + ) + .await + .ok(); + // The same token, again, is a replay: a token is one command. + let refusal = run + .didbot( + &[ + "oauth", + "pending", + "--token-file", + token.to_str().expect("utf-8"), + ], + &pds, + ) + .await + .refused(); + eprintln!("replayed: {}", refusal.trim()); + + // Step two: a second token creates the run's agent beneath `deploy`. + let token = run.secret_file("id-token-2", &mint(github_claims("org/repo", None))); + let agent = format!("run-41.{}", server.host); + let created = run + .didbot( + &[ + "register", + "agent", + &agent, + "--under", + &deploy, + "--server", + &server.host, + "--token-file", + token.to_str().expect("utf-8"), + "--json", + ], + &[], + ) + .await + .ok(); + assert_eq!(created["did"], did_of(&agent), "{created}"); + assert_eq!( + stack.walk(&agent).await, + vec![did_of(&deploy), human.to_owned()] + ); + + // A production run carries `environment`, and is the narrower account. + let session = |token: String| async move { + post_json( + &format!("{}/xrpc/bot.did.createSession", server.origin), + Some(&token), + json!({}), + ) + .await + }; + let (status, body) = session(mint(github_claims("org/repo", Some("production")))).await; + assert_eq!(status, 200, "{body}"); + assert_eq!(body["did"], did_of(&prod), "{body}"); + let (status, body) = session(mint(github_claims("org/repo", Some("staging")))).await; + assert_eq!(status, 200, "{body}"); + assert_eq!( + body["did"], + did_of(&deploy), + "a staging run is only `deploy`: {body}" + ); + + // Another repository, even under the same owner, is nobody here. + let (status, body) = session(mint(github_claims("org/other", None))).await; + assert_eq!(status, 401, "{body}"); + eprintln!("other repository: {}", body["message"]); + + // A token minted six minutes ago is stale, whatever its `exp`. + let mut old = github_claims("org/repo", None); + old["iat"] = json!(OffsetDateTime::now_utc().unix_timestamp() - 6 * 60); + let (status, body) = session(mint(old)).await; + assert_eq!(status, 401, "{body}"); + eprintln!("six minutes old: {}", body["message"]); +} + +// --------------------------------------------------------------------------- +// A Google service account and a Kubernetes service account +// --------------------------------------------------------------------------- + +/// Google's shape: a numeric `sub`, `email`, `email_verified`, no `jti`, +/// an hour long. `instance` is what `format=full` adds and what tells two +/// instances of one service account apart; without it their tokens are the +/// same bytes and the second is a replay. +fn google_claims(email: &str, instance: Option<&str>) -> Value { + let mut claims = json!({ + "sub": "104521389012345678901", + "email": email, + "email_verified": true, + "azp": "104521389012345678901", + "jti": null, + "exp": (OffsetDateTime::now_utc() + time::Duration::hours(1)).unix_timestamp(), + }); + if let Some(instance) = instance { + claims["google"] = json!({ "compute_engine": { "instance_id": instance } }); + } + claims +} + +/// Kubernetes' shape: `aud` as an array, `sub` naming the service account, +/// the pod and account under a nested `kubernetes.io`, an hour long. +fn kubernetes_claims(aud: &str, namespace: &str, account: &str, pod: &str) -> Value { + json!({ + "aud": [aud], + "sub": format!("system:serviceaccount:{namespace}:{account}"), + // A projected token carries one, so two pods' tokens differ. + "jti": OffsetDateTime::now_utc().unix_timestamp_nanos().to_string(), + "kubernetes.io": { + "namespace": namespace, + "pod": { "name": pod, "uid": "b6e1…" }, + "serviceaccount": { "name": account, "uid": "6f2a…" }, + }, + "exp": (OffsetDateTime::now_utc() + time::Duration::hours(1)).unix_timestamp(), + }) +} + +/// Twenty instances of a Google-identified service register at once; a +/// Kubernetes service account registers by `sub` and by a nested claim; +/// the token a metadata server hands out twice is a replay the second +/// time and stale after five minutes. +#[tokio::test(flavor = "multi_thread")] +async fn google_and_kubernetes_pools() { + let stack = Stack::start(&["pools"]).await; + let server = stack.server(); + let human = stack.human.did(); + let email = "web@project.iam.gserviceaccount.com"; + let web = format!("web.{}", server.host); + let k8s = format!("k8s.{}", server.host); + let pod = format!("pod.{}", server.host); + for (name, claims) in [ + (&web, vec![format!("email={email}")]), + ( + &k8s, + vec!["sub=system:serviceaccount:agents:runner".to_owned()], + ), + ( + &pod, + vec![ + "/kubernetes.io/namespace=agents".to_owned(), + "/kubernetes.io/pod/name=runner-0".to_owned(), + ], + ), + ] { + let mut args = vec![ + "operate", + name, + human, + "--kind", + "service", + "--oidc", + &stack.issuer.base, + ]; + args.extend(claims.iter().map(String::as_str)); + args.push("--json"); + let admitted = stack.laptop.didbot(&args, &[]).await.ok(); + assert_eq!(admitted["did"], did_of(name), "{admitted}"); + } + + // Twenty instances boot at once, each with the token its metadata + // server minted for it. + let mut boots = Vec::new(); + for i in 1..=20 { + let machine = Machine::new(&stack.work, &format!("g-{i}")); + let name = format!("g-{i}.{}", server.host); + let token = machine.secret_file( + "id-token", + &stack + .issuer + .id_token(&server.did, google_claims(email, Some(&format!("i-{i}")))), + ); + let (web, host) = (web.clone(), server.host.clone()); + boots.push(tokio::spawn(async move { + let ran = machine + .didbot( + &[ + "register", + "host", + &name, + "--under", + &web, + "--server", + &host, + "--token-file", + token.to_str().expect("utf-8"), + "--json", + ], + &[], + ) + .await; + (name, ran) + })); + } + let mut names = Vec::new(); + for boot in boots { + let (name, ran) = boot.await.expect("the boot task ran"); + assert_eq!(ran.ok()["did"], did_of(&name)); + names.push(rkey_of(&name)); + } + names.sort(); + assert_eq!( + list_record_keys(&server.origin, &did_of(&web), "bot.did.operator").await, + names + ); + assert_eq!( + stack.walk(&format!("g-7.{}", server.host)).await, + vec![did_of(&web), human.to_owned()] + ); + + // The metadata server hands the same token out again within the hour: + // the second boot with it is a replay. Six minutes on, it is stale. + let again = Machine::new(&stack.work, "g-again"); + let token = stack + .issuer + .id_token(&server.did, google_claims(email, Some("i-again"))); + let file = again.secret_file("id-token", &token); + let register = |machine: &Machine, name: String, file: PathBuf| { + let (web, host) = (web.clone(), server.host.clone()); + let machine = Machine { + home: machine.home.clone(), + config: machine.config.clone(), + state: machine.state.clone(), + }; + async move { + machine + .didbot( + &[ + "register", + "host", + &name, + "--under", + &web, + "--server", + &host, + "--token-file", + file.to_str().expect("utf-8"), + "--json", + ], + &[], + ) + .await + } + }; + register(&again, format!("g-again.{}", server.host), file.clone()) + .await + .ok(); + let refusal = register(&again, format!("g-again-2.{}", server.host), file) + .await + .refused(); + eprintln!("same token twice: {}", refusal.trim()); + let mut stale = google_claims(email, Some("i-stale")); + stale["iat"] = json!(OffsetDateTime::now_utc().unix_timestamp() - 6 * 60); + let file = again.secret_file("id-token-stale", &stack.issuer.id_token(&server.did, stale)); + let refusal = register(&again, format!("g-stale.{}", server.host), file) + .await + .refused(); + eprintln!("six minutes old: {}", refusal.trim()); + + // Two instances of one service account whose tokens carry nothing to + // tell them apart mint the same bytes, and the same bytes are one + // credential: the second is a replay. `format=full` is what avoids it. + let plain = stack + .issuer + .id_token(&server.did, google_claims(email, None)); + assert_eq!( + plain, + stack + .issuer + .id_token(&server.did, google_claims(email, None)), + "two instances of one service account mint the same token" + ); + + // A Kubernetes pod's projected token. `runner-0`'s carries both the + // service account's `sub` and the pod's nested name, so it is the + // account naming both; naming the other as `--under` is refused and + // says which account the token actually is. + // One `didbot register host` per machine: a node that already + // registered is already a host. + let register_k8s = |node: &'static str, claims: Value, name: String, under: String| { + let (host, machine) = (server.host.clone(), Machine::new(&stack.work, node)); + let file = machine.secret_file("token", &stack.issuer.id_token(&server.did, claims)); + async move { + machine + .didbot( + &[ + "register", + "host", + &name, + "--under", + &under, + "--server", + &host, + "--token-file", + file.to_str().expect("utf-8"), + "--json", + ], + &[], + ) + .await + } + }; + let name = format!("runner-0.{}", server.host); + let created = register_k8s( + "node-1", + kubernetes_claims(&server.did, "agents", "runner", "runner-0"), + name.clone(), + pod.clone(), + ) + .await + .ok(); + assert_eq!(created["did"], did_of(&name), "{created}"); + assert_eq!( + registration(server, &did_of(&name)).await["operator"], + did_of(&pod) + ); + + let refusal = register_k8s( + "node-2", + kubernetes_claims(&server.did, "agents", "runner", "runner-0"), + format!("runner-0b.{}", server.host), + k8s.clone(), + ) + .await + .refused(); + eprintln!( + "the pod's token, named under the service account: {}", + refusal.trim() + ); + + // Another pod of the same service account matches `sub` alone, so it + // is the service account's own instance. + let name = format!("runner-9.{}", server.host); + let created = register_k8s( + "node-3", + kubernetes_claims(&server.did, "agents", "runner", "runner-9"), + name.clone(), + k8s.clone(), + ) + .await + .ok(); + assert_eq!(created["did"], did_of(&name), "{created}"); + assert_eq!( + registration(server, &did_of(&name)).await["operator"], + did_of(&k8s) + ); +} -- 2.51.2