Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh

Commit

Brokered secrets keep working after a job's token is swapped. The runner Worker looks up a job's stand-ins (brokered_secrets) with the token the job started with, which a harness that asks for rotation spends on its first spec fetch. The swap now keeps that token's hash beside the new one (first_token_hash, migration 0020), and brokered_secrets alone accepts it; reporting, logs, artifacts, cache, job_auth, actions and the spec itself take only the current token, and no API route passes brokered_secrets on, so the kept token is reachable only through g1t's own service bindings. It is cleared when the job ends, is cancelled, is claimed again or is re-run. The self-hosted runners guide says which lookup still takes it.

syntaqxcommitted Parent47627cfBrowse files
5 files+85−220/5 viewed
+4−1
342342 folder Windows Sandbox reads, and in a container's environment. The
343343 first thing the runner in the sandbox does is fetch the job with it,
344344 and g1t answers with a new token that only the sandbox has; the first
345− stops working at once, for that and for everything else. A copy left
345+ stops working at once for everything that can be asked through g1t's
346+ API (inside g1t, only the lookup of [brokered
347+ secrets](/guides/secrets-and-variables/#brokered-secrets) still takes it, and that is not reachable
348+ from your computer or a job). A copy left
346349 behind can't fetch the job, its secrets or a `G1T_TOKEN` again. A job's
347350 folder is deleted when the job ends, and a runner that was stopped
348351 mid-job (a crash, or `taskkill`) deletes what it left when it next
+7−0
1+-- A self-hosted runner's harness spends the job's token on fetching the
2+-- job, and gets a new one in its place (plan.rs `job_spec` with `rotate`).
3+-- The token the job started with stops working for everything but
4+-- `brokered_secrets`, which the runner Worker asks with the token it was
5+-- given at the start and which no API route passes on: its hash is kept
6+-- here from the swap until the job ends or is claimed again.
7+ALTER TABLE jobs ADD COLUMN first_token_hash TEXT;
+5−2
186186 /// `brokered_secrets`: a running job's stand-ins and what they stand
187187 /// for, for the runner Worker to fill in on the job's requests. Asked
188188 /// with the job's own token, which only the runner and the job hold;
189− /// the API never passes this method on, so the job cannot ask it.
189+ /// the API never passes this method on, so the job cannot ask it. The
190+ /// Worker holds the token the job started with, so that one still
191+ /// answers here after the job's harness swapped it for another
192+ /// (`job_spec` with `rotate`), and nowhere else.
190193 pub async fn brokered_secrets(&self, a: JobCallArgs) -> Result<Outcome<Vec<BrokeredSecret>>> {
191− let job = check!(self.job_for_token(&a).await?);
194+ let job = check!(self.job_for_started_token(&a).await?);
192195 Ok(Outcome::Ok(self.stand_ins_of(&job.id).await?))
193196 }
194197 }
+68−18
3131 const MAX_CHUNK_BYTES: usize = 256 * 1024;
3232 const MAX_ANNOTATIONS: usize = 50;
3333 /// Swaps a running job's token for a new one, only while it still has the
34−/// one the caller showed (`job_spec` with `rotate`).
35−const TOKEN_ROTATE: &str = "UPDATE jobs SET token_hash = ?1 WHERE id = ?2 AND status = 'in_progress' AND token_hash = ?3 RETURNING id";
34+/// one the caller showed (`job_spec` with `rotate`), keeping the hash of
35+/// the one it started with for `brokered_secrets` (migration 0020).
36+const TOKEN_ROTATE: &str = "UPDATE jobs SET first_token_hash = COALESCE(first_token_hash, token_hash), token_hash = ?1
37+ WHERE id = ?2 AND status = 'in_progress' AND token_hash = ?3 RETURNING id";
38+
39+/// Whether `token` is a running job's: its current one, or with `started`
40+/// (`brokered_secrets` only) the one it started with before its harness
41+/// swapped it.
42+fn token_opens(current: Option<&str>, first: Option<&str>, token: &str, started: bool) -> bool {
43+ let hash = sha256_hex(token);
44+ current.is_some_and(|h| same(h, &hash)) || (started && first.is_some_and(|h| same(h, &hash)))
45+}
3646 /// A batch of a job's log, under the next `seq` of that job's, worked out
3747 /// in the same statement (SQLite runs one write at a time). `?4` is the
3848 /// runner's number for the batch (null from runners that send none): a
239249 /// 0019), so its log is masked for their values too.
240250 #[serde(default)]
241251 pub brokered: u32,
252+ /// The hash of the token it started with, once its harness swapped that
253+ /// for another (migration 0020): good for `brokered_secrets` only.
254+ #[serde(default)]
255+ pub first_token_hash: Option<String>,
242256 }
243257
244258 /// How long a cancelled job has to run its `if: always()` and `cancelled()`
16111625 let claimed = self
16121626 .db
16131627 .prepare(
1614− "UPDATE jobs SET status = 'in_progress', token_hash = ?, started_at = ?, seen_at = ? WHERE id = ? AND status = 'queued' RETURNING id",
1628+ "UPDATE jobs SET status = 'in_progress', token_hash = ?, first_token_hash = NULL, started_at = ?, seen_at = ? WHERE id = ? AND status = 'queued' RETURNING id",
16151629 )
16161630 .bind(&[sha256_hex(&token).into(), at.as_str().into(), at.as_str().into(), job.id.as_str().into()])?
16171631 .first::<Value>(None)
16781692 .db
16791693 .prepare(
16801694 "UPDATE jobs SET status = 'completed', conclusion = ?, reason = COALESCE(?, reason), outputs = COALESCE(?, outputs),
1681− finished_at = ?, token_hash = NULL WHERE id = ? AND status != 'completed' RETURNING *",
1695+ finished_at = ?, token_hash = NULL, first_token_hash = NULL WHERE id = ? AND status != 'completed' RETURNING *",
16821696 )
16831697 .bind(&[
16841698 conclusion.into(),
17861800 }
17871801 let stopped = self
17881802 .db
1789− .prepare("UPDATE jobs SET status = 'completed', conclusion = 'cancelled', reason = ?, finished_at = ?, token_hash = NULL WHERE id = ? AND status != 'completed' RETURNING *")
1803+ .prepare("UPDATE jobs SET status = 'completed', conclusion = 'cancelled', reason = ?, finished_at = ?, token_hash = NULL, first_token_hash = NULL WHERE id = ? AND status != 'completed' RETURNING *")
17901804 .bind(&[reason.into(), now().into(), job.id.as_str().into()])?
17911805 .first::<JobRow>(None)
17921806 .await?;
22192233 self.db
22202234 .prepare(
22212235 "UPDATE jobs SET status = 'waiting', conclusion = NULL, steps = '[]', annotations = '[]', outputs = '{}', reason = NULL,
2222− matrix = NULL, call = NULL, token_hash = NULL, seen_at = NULL, started_at = NULL, finished_at = NULL,
2236+ matrix = NULL, call = NULL, token_hash = NULL, first_token_hash = NULL, seen_at = NULL, started_at = NULL, finished_at = NULL,
22232237 labels = NULL, queued_at = NULL, runner_id = NULL, runner_name = NULL, environment = NULL,
22242238 concurrency_group = NULL, cancel_in_progress = 0, cancel_requested_at = NULL WHERE run_id = ? AND key = ?",
22252239 )
22602274 // --- The sandbox's side -----------------------------------------------------
22612275
22622276 pub(crate) async fn job_for_token(&self, a: &JobCallArgs) -> Result<Outcome<JobRow>> {
2277+ self.job_for(a, false).await
2278+ }
2279+
2280+ /// As [`Self::job_for_token`], and also with the token the job started
2281+ /// with after its harness swapped it (`job_spec` with `rotate`): for
2282+ /// `brokered_secrets` alone, which the runner Worker asks with the
2283+ /// token it was given at the start, and which no API route passes on.
2284+ pub(crate) async fn job_for_started_token(&self, a: &JobCallArgs) -> Result<Outcome<JobRow>> {
2285+ self.job_for(a, true).await
2286+ }
2287+
2288+ async fn job_for(&self, a: &JobCallArgs, started: bool) -> Result<Outcome<JobRow>> {
22632289 let job = self.db.prepare("SELECT * FROM jobs WHERE id = ?").bind(&[a.job.as_str().into()])?.first::<JobRow>(None).await?;
22642290 Ok(match job {
2265− Some(job) if job.status == "in_progress" && job.token_hash.as_deref().is_some_and(|hash| same(hash, &sha256_hex(&a.token))) => {
2266− Outcome::Ok(job)
2267− }
2291+ Some(job) if job.status == "in_progress" && token_opens(job.token_hash.as_deref(), job.first_token_hash.as_deref(), &a.token, started) => Outcome::Ok(job),
22682292 _ => fail(FailureCode::Unauthenticated, "That job is not running, or the token is not its."),
22692293 })
22702294 }
28762900 use g1t_secrets::sha256_hex;
28772901 use rusqlite::{Connection, OptionalExtension, params};
28782902
2879− use super::TOKEN_ROTATE;
2903+ use super::{TOKEN_ROTATE, token_opens};
28802904
28812905 fn database() -> Connection {
28822906 let db = Connection::open_in_memory().unwrap();
2883− let path = format!("{}/migrations/0001_init.sql", env!("CARGO_MANIFEST_DIR"));
2884− db.execute_batch(&std::fs::read_to_string(path).unwrap()).unwrap();
2907+ for name in ["0001_init.sql", "0020_job_token_rotation.sql"] {
2908+ let path = format!("{}/migrations/{name}", env!("CARGO_MANIFEST_DIR"));
2909+ db.execute_batch(&std::fs::read_to_string(path).unwrap()).unwrap();
2910+ }
28852911 db.execute(
28862912 "INSERT INTO jobs (id, run_id, repo_id, namespace, key, name, status, token_hash) VALUES ('job_1', 'run_1', 'repo_1', 'acme', 'build', 'build', 'in_progress', ?1)",
28872913 [sha256_hex("first")],
28972923 .is_some()
28982924 }
28992925
2900− fn hash(db: &Connection) -> Option<String> {
2901− db.query_row("SELECT token_hash FROM jobs WHERE id = 'job_1'", [], |row| row.get(0)).unwrap()
2926+ /// The job's current token's hash, and the one it started with.
2927+ fn hashes(db: &Connection) -> (Option<String>, Option<String>) {
2928+ db.query_row("SELECT token_hash, first_token_hash FROM jobs WHERE id = 'job_1'", [], |row| Ok((row.get(0)?, row.get(1)?))).unwrap()
29022929 }
29032930
29042931 #[test]
29052932 fn a_job_token_fetches_its_spec_once() {
29062933 let db = database();
29072934 assert!(rotate(&db, "second", "first"));
2908− assert_eq!(hash(&db), Some(sha256_hex("second")));
2935+ assert_eq!(hashes(&db), (Some(sha256_hex("second")), Some(sha256_hex("first"))));
29092936 // The first token, shown again (a copy left on the computer, or a
29102937 // second fetch racing the first), swaps nothing and is refused.
29112938 assert!(!rotate(&db, "third", "first"));
2912− assert_eq!(hash(&db), Some(sha256_hex("second")));
2939+ assert_eq!(hashes(&db).0, Some(sha256_hex("second")));
29132940 // A job that ended has no token to swap.
2914− db.execute("UPDATE jobs SET status = 'completed', token_hash = NULL", []).unwrap();
2941+ db.execute("UPDATE jobs SET status = 'completed', token_hash = NULL, first_token_hash = NULL", []).unwrap();
29152942 assert!(!rotate(&db, "fourth", "second"));
2916− assert_eq!(hash(&db), None);
2943+ assert_eq!(hashes(&db), (None, None));
2944+ }
2945+
2946+ #[test]
2947+ fn the_worker_finds_brokered_secrets_with_the_token_the_job_started_with() {
2948+ let db = database();
2949+ let opens = |db: &Connection, token: &str, started: bool| {
2950+ let (current, first) = hashes(db);
2951+ token_opens(current.as_deref(), first.as_deref(), token, started)
2952+ };
2953+ // Before any swap, the job's one token opens everything.
2954+ assert!(opens(&db, "first", false) && opens(&db, "first", true));
2955+ assert!(rotate(&db, "second", "first"));
2956+ // The harness's new token opens everything.
2957+ assert!(opens(&db, "second", false) && opens(&db, "second", true));
2958+ // The token it started with, which the runner Worker holds, still
2959+ // finds the job's brokered secrets, and opens nothing else.
2960+ assert!(opens(&db, "first", true));
2961+ assert!(!opens(&db, "first", false));
2962+ assert!(!opens(&db, "other", true));
2963+ // Another swap keeps the token it started with, not the one between.
2964+ assert!(rotate(&db, "third", "second"));
2965+ assert!(opens(&db, "first", true));
2966+ assert!(!opens(&db, "second", true));
29172967 }
29182968
29192969 #[test]
+1−1
12121212 let claimed = self
12131213 .db
12141214 .prepare(
1215− "UPDATE jobs SET status = 'in_progress', token_hash = ?, runner_id = ?, runner_name = ?, reason = NULL, started_at = ?, seen_at = ?
1215+ "UPDATE jobs SET status = 'in_progress', token_hash = ?, first_token_hash = NULL, runner_id = ?, runner_name = ?, reason = NULL, started_at = ?, seen_at = ?
12161216 WHERE id = ? AND status = 'queued' RETURNING id",
12171217 )
12181218 .bind(&[

Comments

Sign in to comment.