flagon-io/g1t

public

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/services/identity/src/run_credentials.rs

214 lines7,447 bytesCodeBlame
1//! Run credentials: a token per sandbox run, bound to the run, its
2//! repository and what its kind of work needs, acting as an agent on
3//! behalf of the person who started the work. See
4//! `g1t_contracts::credentials` for the policy.
5
6use g1t_contracts::credentials::{
7 Acting, BindRunCredentialsArgs, CreateRunCredentialArgs, Principal, RevokeRunCredentialsArgs,
8 RunBinding, intersect, operations_for,
9};
10use g1t_contracts::identity::{
11 AGENT_ID, AGENT_NAME, AgentScope, CreateAccessTokenArgs, CreatedAccessToken,
12};
13use g1t_contracts::time::SQL_NOW;
14use g1t_contracts::{PrincipalKind, User, Viewer};
15use worker::Result;
16use worker::wasm_bindgen::JsValue;
17
18use crate::Identity;
19
20/// No run outlives this, whatever its caller asks for.
21const MAX_RUN_TTL_SECONDS: u64 = 6 * 60 * 60;
22const MIN_RUN_TTL_SECONDS: u64 = 60;
23/// More hashes than one sandbox ever holds.
24const MAX_HASHES: usize = 8;
25
26/// A SHA-256 in lowercase hex, as tokens are stored.
27fn is_hash(value: &str) -> bool {
28 value.len() == 64
29 && value
30 .bytes()
31 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
32}
33
34impl Identity {
35 /// The composite identity behind an agent's token: the agent, acting
36 /// for the person (or workspace) the token was made for, in the run's
37 /// workspace only, and only while that person still belongs to it.
38 pub(crate) async fn agent_principal(
39 &self,
40 credential_id: &str,
41 user_id: Option<&str>,
42 workspace_id: Option<&str>,
43 scope: AgentScope,
44 ) -> Result<Viewer> {
45 let person = match (user_id, workspace_id) {
46 (Some(id), _) => {
47 self.find_user(
48 "SELECT id, username, email_verified_at IS NOT NULL AS verified
49 FROM users WHERE id = ?",
50 id,
51 )
52 .await?
53 }
54 (None, Some(id)) => self.workspace_principal(id).await?,
55 (None, None) => None,
56 };
57 // The person is gone: so is everything that acted for them.
58 let Some(person) = person else {
59 return Ok(None);
60 };
61 let agent = scope
62 .run
63 .as_ref()
64 .map(|run| run.agent.clone())
65 .unwrap_or_else(|| AGENT_NAME.to_owned());
66 Ok(Some(User {
67 id: AGENT_ID.to_owned(),
68 username: AGENT_NAME.to_owned(),
69 kind: PrincipalKind::Agent,
70 verified: person.verified,
71 workspaces: intersect(&person.workspaces, &scope.repo.namespace),
72 acting: Some(Box::new(Acting {
73 credential_id: credential_id.to_owned(),
74 agent,
75 on_behalf_of: Principal {
76 id: person.id,
77 username: person.username,
78 },
79 scope,
80 })),
81 ..User::default()
82 }))
83 }
84
85 pub async fn create_run_credential(
86 &self,
87 a: CreateRunCredentialArgs,
88 ) -> Result<CreatedAccessToken> {
89 let agent = a
90 .agent
91 .filter(|agent| !agent.trim().is_empty())
92 .unwrap_or_else(|| AGENT_NAME.to_owned());
93 let scope = AgentScope {
94 repo: a.repo.clone(),
95 operations: operations_for(a.kind, a.usage)
96 .into_iter()
97 .map(str::to_owned)
98 .collect(),
99 run: Some(RunBinding {
100 kind: a.kind,
101 usage: a.usage,
102 run_id: None,
103 number: a.number,
104 agent: agent.clone(),
105 read: a.read,
106 push: a.push,
107 }),
108 };
109 let created = self
110 .create_access_token(CreateAccessTokenArgs {
111 user: a.on_behalf_of,
112 name: format!(
113 "{agent}: {} run in {}/{}{}",
114 a.kind.as_str(),
115 a.repo.namespace,
116 a.repo.name,
117 a.number.map(|n| format!("#{n}")).unwrap_or_default()
118 ),
119 ttl_seconds: Some(
120 a.ttl_seconds
121 .clamp(MIN_RUN_TTL_SECONDS, MAX_RUN_TTL_SECONDS),
122 ),
123 })
124 .await?;
125 self.db
126 .prepare("UPDATE access_tokens SET agent_scope = ? WHERE id = ?")
127 .bind(&[
128 serde_json::to_string(&scope)?.into(),
129 created.info.id.as_str().into(),
130 ])?
131 .run()
132 .await?;
133 Ok(created)
134 }
135
136 /// Ties a sandbox's credentials to the agent run it recorded. A token
137 /// already bound keeps its run.
138 pub async fn bind_run_credentials(&self, a: BindRunCredentialsArgs) -> Result<bool> {
139 let hashes: Vec<&String> = a
140 .token_hashes
141 .iter()
142 .filter(|hash| is_hash(hash))
143 .take(MAX_HASHES)
144 .collect();
145 if hashes.is_empty() || a.run_id.trim().is_empty() {
146 return Ok(false);
147 }
148 let marks = vec!["?"; hashes.len()].join(", ");
149 let mut values: Vec<JsValue> = vec![a.run_id.as_str().into(), a.run_id.as_str().into()];
150 values.extend(hashes.iter().map(|hash| JsValue::from(hash.as_str())));
151 self.db
152 .prepare(format!(
153 "UPDATE access_tokens
154 SET run_id = ?, agent_scope = json_set(agent_scope, '$.run.runId', ?)
155 WHERE token_hash IN ({marks}) AND run_id IS NULL
156 AND json_extract(agent_scope, '$.run') IS NOT NULL"
157 ))
158 .bind(&values)?
159 .run()
160 .await?;
161 Ok(true)
162 }
163
164 /// Ends a sandbox's credentials: they stop working at once. Only run
165 /// credentials are touched.
166 pub async fn revoke_run_credentials(&self, a: RevokeRunCredentialsArgs) -> Result<bool> {
167 let hashes: Vec<&String> = a
168 .token_hashes
169 .iter()
170 .filter(|hash| is_hash(hash))
171 .take(MAX_HASHES)
172 .collect();
173 let mut conditions = Vec::new();
174 let mut values: Vec<JsValue> = Vec::new();
175 if !hashes.is_empty() {
176 conditions.push(format!(
177 "token_hash IN ({})",
178 vec!["?"; hashes.len()].join(", ")
179 ));
180 values.extend(hashes.iter().map(|hash| JsValue::from(hash.as_str())));
181 }
182 if let Some(run_id) = a.run_id.as_deref().filter(|id| !id.trim().is_empty()) {
183 conditions.push("run_id = ?".to_owned());
184 values.push(run_id.into());
185 }
186 if conditions.is_empty() {
187 return Ok(false);
188 }
189 self.db
190 .prepare(format!(
191 "UPDATE access_tokens SET expires_at = {SQL_NOW}
192 WHERE ({}) AND json_extract(agent_scope, '$.run') IS NOT NULL
193 AND (expires_at IS NULL OR expires_at > {SQL_NOW})",
194 conditions.join(" OR ")
195 ))
196 .bind(&values)?
197 .run()
198 .await?;
199 Ok(true)
200 }
201}
202
203#[cfg(test)]
204mod tests {
205 use super::is_hash;
206
207 #[test]
208 fn only_hashes_are_taken() {
209 assert!(is_hash(&"a1".repeat(32)));
210 assert!(!is_hash(&"A1".repeat(32)));
211 assert!(!is_hash("g1t_0123"));
212 assert!(!is_hash(&"0".repeat(63)));
213 }
214}