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
296 linesCodeBlameRaw
1//! Run credentials: a token per sandbox run (checks, the merge queue, a
2//! merge check, a deployment, a security update's bump, or a session g1t
3//! started on an agent's harness), bound to the
4//! run, its repository and what its kind of work needs, acting as g1t on
5//! behalf of the person who started the work, or of the workspace for
6//! g1t's own. See `g1t_contracts::credentials` for the policy.
7//!
8//! Credentials made for agent runs before g1t stopped running agents keep
9//! resolving until they expire (at most six hours), so nothing in flight
10//! breaks; no new ones are made.
11
12use g1t_contracts::credentials::{
13 Acting, BindRunCredentialsArgs, CreateRunCredentialArgs, CredentialUse, Principal, RevokeRunCredentialsArgs,
14 RunBinding, RunCredentialKind, intersect, intersect_grants, operations_for,
15};
16use g1t_contracts::identity::{
17 AGENT_ID, AGENT_NAME, AgentScope, CreateAccessTokenArgs, CreatedAccessToken,
18};
19use g1t_contracts::time::SQL_NOW;
20use g1t_contracts::{PrincipalKind, User, Viewer};
21use worker::Result;
22use worker::wasm_bindgen::JsValue;
23
24use crate::Identity;
25
26/// No run outlives this, whatever its caller asks for.
27const MAX_RUN_TTL_SECONDS: u64 = 6 * 60 * 60;
28/// Nor a session on an agent's harness this, which is stopped by then
29/// (integrations' agent_sessions.rs) and its credential revoked when it ends.
30const MAX_SESSION_TTL_SECONDS: u64 = 12 * 60 * 60;
31const MIN_RUN_TTL_SECONDS: u64 = 60;
32/// More hashes than one sandbox ever holds.
33const MAX_HASHES: usize = 8;
34
35/// Whether a credential is still made for this kind of run and use: a
36/// runner's, for sandbox work that is not one of the retired hosted
37/// agents' (`RunCredentialKind::retired`).
38fn issued_for(kind: RunCredentialKind, usage: CredentialUse) -> bool {
39 !kind.retired() && usage == CredentialUse::Runner
40}
41
42/// How long a credential of this kind may last.
43fn max_ttl(kind: RunCredentialKind) -> u64 {
44 match kind {
45 RunCredentialKind::Session => MAX_SESSION_TTL_SECONDS,
46 _ => MAX_RUN_TTL_SECONDS,
47 }
48}
49
50/// A SHA-256 in lowercase hex, as tokens are stored.
51fn is_hash(value: &str) -> bool {
52 value.len() == 64
53 && value
54 .bytes()
55 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
56}
57
58impl Identity {
59 /// The composite identity behind an agent's token: the agent, acting
60 /// for the person (or workspace) the token was made for, in the run's
61 /// workspace only, and only while that person still belongs to it.
62 pub(crate) async fn agent_principal(
63 &self,
64 credential_id: &str,
65 user_id: Option<&str>,
66 workspace_id: Option<&str>,
67 scope: AgentScope,
68 ) -> Result<Viewer> {
69 let person = match (user_id, workspace_id) {
70 (Some(id), _) => {
71 self.find_user(
72 "SELECT id, username, email_verified_at IS NOT NULL AS verified
73 FROM users WHERE id = ? AND deleted_at IS NULL",
74 id,
75 )
76 .await?
77 }
78 (None, Some(id)) => self.workspace_principal(id).await?,
79 (None, None) => None,
80 };
81 // The person is gone: so is everything that acted for them.
82 let Some(person) = person else {
83 return Ok(None);
84 };
85 let agent = scope
86 .run
87 .as_ref()
88 .map(|run| run.agent.clone())
89 .unwrap_or_else(|| AGENT_NAME.to_owned());
90 // g1t's own run: the workspace's credential, acting as g1t, so the
91 // pull request and working copy g1t opened are its own (run.system).
92 let on_behalf_of = if scope.run.as_ref().is_some_and(|run| run.system) {
93 Principal {
94 id: g1t_contracts::system::ID.to_owned(),
95 username: g1t_contracts::system::USERNAME.to_owned(),
96 }
97 } else {
98 Principal {
99 id: person.id,
100 username: person.username,
101 }
102 };
103 Ok(Some(User {
104 id: AGENT_ID.to_owned(),
105 username: AGENT_NAME.to_owned(),
106 kind: PrincipalKind::Agent,
107 verified: person.verified,
108 workspaces: intersect(&person.workspaces, &scope.repo.namespace),
109 // The person's roles on the workspace's repositories, so an
110 // outside collaborator's agent works where they may, and never
111 // beyond Write (credentials::AGENT_CEILING).
112 grants: intersect_grants(&person.grants, &scope.repo.namespace),
113 acting: Some(Box::new(Acting {
114 credential_id: credential_id.to_owned(),
115 agent,
116 on_behalf_of,
117 scope,
118 })),
119 ..User::default()
120 }))
121 }
122
123 pub async fn create_run_credential(
124 &self,
125 a: CreateRunCredentialArgs,
126 ) -> Result<CreatedAccessToken> {
127 if !issued_for(a.kind, a.usage) {
128 return Err(worker::Error::RustError(format!(
129 "no run credential is made for {} runs' {}",
130 a.kind.as_str(),
131 a.usage.as_str()
132 )));
133 }
134 let agent = AGENT_NAME.to_owned();
135 let scope = AgentScope {
136 repo: a.repo.clone(),
137 operations: operations_for(a.kind, a.usage)
138 .into_iter()
139 .map(str::to_owned)
140 .collect(),
141 run: Some(RunBinding {
142 kind: a.kind,
143 usage: a.usage,
144 run_id: None,
145 number: a.number,
146 agent: agent.clone(),
147 system: a.on_behalf_of.kind == PrincipalKind::System,
148 read: a.read,
149 push: a.push,
150 }),
151 };
152 // g1t's own work (a security update's bump) has no person behind it: its credential acts for the workspace,
153 // as a workspace's own token would.
154 let on_behalf_of = if a.on_behalf_of.kind == PrincipalKind::System {
155 match self.workspace_id_of(&a.repo.namespace).await? {
156 Some(id) => User {
157 id,
158 username: a.repo.namespace.to_lowercase(),
159 kind: PrincipalKind::Workspace,
160 ..User::default()
161 },
162 None => return Err(worker::Error::RustError(format!("no workspace {}", a.repo.namespace))),
163 }
164 } else {
165 a.on_behalf_of
166 };
167 let created = self
168 .create_access_token(CreateAccessTokenArgs {
169 user: on_behalf_of,
170 name: format!(
171 "{agent}: {} run in {}/{}{}",
172 a.kind.as_str(),
173 a.repo.namespace,
174 a.repo.name,
175 a.number.map(|n| format!("#{n}")).unwrap_or_default()
176 ),
177 ttl_seconds: Some(
178 a.ttl_seconds
179 .clamp(MIN_RUN_TTL_SECONDS, max_ttl(a.kind)),
180 ),
181 // A run credential's scope is its run's; see agent_scope.
182 scopes: None,
183 listed: false,
184 repo: None,
185 })
186 .await?;
187 self.db
188 .prepare("UPDATE access_tokens SET agent_scope = ? WHERE id = ?")
189 .bind(&[
190 serde_json::to_string(&scope)?.into(),
191 created.info.id.as_str().into(),
192 ])?
193 .run()
194 .await?;
195 Ok(created)
196 }
197
198 /// Ties a sandbox's credentials to the run it recorded. A token
199 /// already bound keeps its run.
200 pub async fn bind_run_credentials(&self, a: BindRunCredentialsArgs) -> Result<bool> {
201 let hashes: Vec<&String> = a
202 .token_hashes
203 .iter()
204 .filter(|hash| is_hash(hash))
205 .take(MAX_HASHES)
206 .collect();
207 if hashes.is_empty() || a.run_id.trim().is_empty() {
208 return Ok(false);
209 }
210 let marks = vec!["?"; hashes.len()].join(", ");
211 let mut values: Vec<JsValue> = vec![a.run_id.as_str().into(), a.run_id.as_str().into()];
212 values.extend(hashes.iter().map(|hash| JsValue::from(hash.as_str())));
213 self.db
214 .prepare(format!(
215 "UPDATE access_tokens
216 SET run_id = ?, agent_scope = json_set(agent_scope, '$.run.runId', ?)
217 WHERE token_hash IN ({marks}) AND run_id IS NULL
218 AND json_extract(agent_scope, '$.run') IS NOT NULL"
219 ))
220 .bind(&values)?
221 .run()
222 .await?;
223 Ok(true)
224 }
225
226 /// Ends a sandbox's credentials: they stop working at once. Only run
227 /// credentials are touched.
228 pub async fn revoke_run_credentials(&self, a: RevokeRunCredentialsArgs) -> Result<bool> {
229 let hashes: Vec<&String> = a
230 .token_hashes
231 .iter()
232 .filter(|hash| is_hash(hash))
233 .take(MAX_HASHES)
234 .collect();
235 let mut conditions = Vec::new();
236 let mut values: Vec<JsValue> = Vec::new();
237 if !hashes.is_empty() {
238 conditions.push(format!(
239 "token_hash IN ({})",
240 vec!["?"; hashes.len()].join(", ")
241 ));
242 values.extend(hashes.iter().map(|hash| JsValue::from(hash.as_str())));
243 }
244 if let Some(run_id) = a.run_id.as_deref().filter(|id| !id.trim().is_empty()) {
245 conditions.push("run_id = ?".to_owned());
246 values.push(run_id.into());
247 }
248 if conditions.is_empty() {
249 return Ok(false);
250 }
251 self.db
252 .prepare(format!(
253 "UPDATE access_tokens SET expires_at = {SQL_NOW}
254 WHERE ({}) AND json_extract(agent_scope, '$.run') IS NOT NULL
255 AND (expires_at IS NULL OR expires_at > {SQL_NOW})",
256 conditions.join(" OR ")
257 ))
258 .bind(&values)?
259 .run()
260 .await?;
261 Ok(true)
262 }
263}
264
265#[cfg(test)]
266mod tests {
267 use super::{CredentialUse, RunCredentialKind, is_hash, issued_for};
268
269 #[test]
270 fn credentials_are_made_only_for_sandbox_work_that_is_not_an_agents() {
271 let issued: Vec<&str> = RunCredentialKind::ALL
272 .iter()
273 .filter(|kind| issued_for(**kind, CredentialUse::Runner))
274 .map(|kind| kind.as_str())
275 .collect();
276 assert_eq!(issued, ["checks", "queue", "mergecheck", "deploy", "bump", "session"]);
277 // A retired hosted agent's tools token is never made.
278 assert!(RunCredentialKind::ALL.iter().all(|kind| !issued_for(*kind, CredentialUse::Tools)));
279 }
280
281 /// A session's credential lasts as long as a session may run; every
282 /// other run's no longer than six hours.
283 #[test]
284 fn a_session_s_credential_lasts_as_long_as_it_may_run() {
285 assert_eq!(super::max_ttl(RunCredentialKind::Session), 12 * 60 * 60);
286 assert_eq!(super::max_ttl(RunCredentialKind::Checks), 6 * 60 * 60);
287 }
288
289 #[test]
290 fn only_hashes_are_taken() {
291 assert!(is_hash(&"a1".repeat(32)));
292 assert!(!is_hash(&"A1".repeat(32)));
293 assert!(!is_hash("g1t_0123"));
294 assert!(!is_hash(&"0".repeat(63)));
295 }
296}