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