Skip to content
275 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 })
173 .await?;
174 self.db
175 .prepare("UPDATE access_tokens SET agent_scope = ? WHERE id = ?")
176 .bind(&[
177 serde_json::to_string(&scope)?.into(),
178 created.info.id.as_str().into(),
179 ])?
180 .run()
181 .await?;
182 Ok(created)
183 }
184
185 /// Ties a sandbox's credentials to the run it recorded. A token
186 /// already bound keeps its run.
187 pub async fn bind_run_credentials(&self, a: BindRunCredentialsArgs) -> Result<bool> {
188 let hashes: Vec<&String> = a
189 .token_hashes
190 .iter()
191 .filter(|hash| is_hash(hash))
192 .take(MAX_HASHES)
193 .collect();
194 if hashes.is_empty() || a.run_id.trim().is_empty() {
195 return Ok(false);
196 }
197 let marks = vec!["?"; hashes.len()].join(", ");
198 let mut values: Vec<JsValue> = vec![a.run_id.as_str().into(), a.run_id.as_str().into()];
199 values.extend(hashes.iter().map(|hash| JsValue::from(hash.as_str())));
200 self.db
201 .prepare(format!(
202 "UPDATE access_tokens
203 SET run_id = ?, agent_scope = json_set(agent_scope, '$.run.runId', ?)
204 WHERE token_hash IN ({marks}) AND run_id IS NULL
205 AND json_extract(agent_scope, '$.run') IS NOT NULL"
206 ))
207 .bind(&values)?
208 .run()
209 .await?;
210 Ok(true)
211 }
212
213 /// Ends a sandbox's credentials: they stop working at once. Only run
214 /// credentials are touched.
215 pub async fn revoke_run_credentials(&self, a: RevokeRunCredentialsArgs) -> Result<bool> {
216 let hashes: Vec<&String> = a
217 .token_hashes
218 .iter()
219 .filter(|hash| is_hash(hash))
220 .take(MAX_HASHES)
221 .collect();
222 let mut conditions = Vec::new();
223 let mut values: Vec<JsValue> = Vec::new();
224 if !hashes.is_empty() {
225 conditions.push(format!(
226 "token_hash IN ({})",
227 vec!["?"; hashes.len()].join(", ")
228 ));
229 values.extend(hashes.iter().map(|hash| JsValue::from(hash.as_str())));
230 }
231 if let Some(run_id) = a.run_id.as_deref().filter(|id| !id.trim().is_empty()) {
232 conditions.push("run_id = ?".to_owned());
233 values.push(run_id.into());
234 }
235 if conditions.is_empty() {
236 return Ok(false);
237 }
238 self.db
239 .prepare(format!(
240 "UPDATE access_tokens SET expires_at = {SQL_NOW}
241 WHERE ({}) AND json_extract(agent_scope, '$.run') IS NOT NULL
242 AND (expires_at IS NULL OR expires_at > {SQL_NOW})",
243 conditions.join(" OR ")
244 ))
245 .bind(&values)?
246 .run()
247 .await?;
248 Ok(true)
249 }
250}
251
252#[cfg(test)]
253mod tests {
254 use super::{CredentialUse, RunCredentialKind, is_hash, issued_for};
255
256 #[test]
257 fn credentials_are_made_only_for_sandbox_work_that_is_not_an_agents() {
258 let issued: Vec<&str> = RunCredentialKind::ALL
259 .iter()
260 .filter(|kind| issued_for(**kind, CredentialUse::Runner))
261 .map(|kind| kind.as_str())
262 .collect();
263 assert_eq!(issued, ["checks", "queue", "mergecheck", "deploy", "bump"]);
264 // A retired hosted agent's tools token is never made.
265 assert!(RunCredentialKind::ALL.iter().all(|kind| !issued_for(*kind, CredentialUse::Tools)));
266 }
267
268 #[test]
269 fn only_hashes_are_taken() {
270 assert!(is_hash(&"a1".repeat(32)));
271 assert!(!is_hash(&"A1".repeat(32)));
272 assert!(!is_hash("g1t_0123"));
273 assert!(!is_hash(&"0".repeat(63)));
274 }
275}