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