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

221 lines7,885 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, intersect_grants, 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 // The person's roles on the workspace's repositories, so an
73 // outside collaborator's agent works where they may, and never
74 // beyond Write (credentials::AGENT_CEILING).
75 grants: intersect_grants(&person.grants, &scope.repo.namespace),
76 acting: Some(Box::new(Acting {
77 credential_id: credential_id.to_owned(),
78 agent,
79 on_behalf_of: Principal {
80 id: person.id,
81 username: person.username,
82 },
83 scope,
84 })),
85 ..User::default()
86 }))
87 }
88
89 pub async fn create_run_credential(
90 &self,
91 a: CreateRunCredentialArgs,
92 ) -> Result<CreatedAccessToken> {
93 let agent = a
94 .agent
95 .filter(|agent| !agent.trim().is_empty())
96 .unwrap_or_else(|| AGENT_NAME.to_owned());
97 let scope = AgentScope {
98 repo: a.repo.clone(),
99 operations: operations_for(a.kind, a.usage)
100 .into_iter()
101 .map(str::to_owned)
102 .collect(),
103 run: Some(RunBinding {
104 kind: a.kind,
105 usage: a.usage,
106 run_id: None,
107 number: a.number,
108 agent: agent.clone(),
109 read: a.read,
110 push: a.push,
111 }),
112 };
113 let created = self
114 .create_access_token(CreateAccessTokenArgs {
115 user: a.on_behalf_of,
116 name: format!(
117 "{agent}: {} run in {}/{}{}",
118 a.kind.as_str(),
119 a.repo.namespace,
120 a.repo.name,
121 a.number.map(|n| format!("#{n}")).unwrap_or_default()
122 ),
123 ttl_seconds: Some(
124 a.ttl_seconds
125 .clamp(MIN_RUN_TTL_SECONDS, MAX_RUN_TTL_SECONDS),
126 ),
127 // A run credential's scope is its run's; see agent_scope.
128 scopes: None,
129 listed: false,
130 })
131 .await?;
132 self.db
133 .prepare("UPDATE access_tokens SET agent_scope = ? WHERE id = ?")
134 .bind(&[
135 serde_json::to_string(&scope)?.into(),
136 created.info.id.as_str().into(),
137 ])?
138 .run()
139 .await?;
140 Ok(created)
141 }
142
143 /// Ties a sandbox's credentials to the agent run it recorded. A token
144 /// already bound keeps its run.
145 pub async fn bind_run_credentials(&self, a: BindRunCredentialsArgs) -> Result<bool> {
146 let hashes: Vec<&String> = a
147 .token_hashes
148 .iter()
149 .filter(|hash| is_hash(hash))
150 .take(MAX_HASHES)
151 .collect();
152 if hashes.is_empty() || a.run_id.trim().is_empty() {
153 return Ok(false);
154 }
155 let marks = vec!["?"; hashes.len()].join(", ");
156 let mut values: Vec<JsValue> = vec![a.run_id.as_str().into(), a.run_id.as_str().into()];
157 values.extend(hashes.iter().map(|hash| JsValue::from(hash.as_str())));
158 self.db
159 .prepare(format!(
160 "UPDATE access_tokens
161 SET run_id = ?, agent_scope = json_set(agent_scope, '$.run.runId', ?)
162 WHERE token_hash IN ({marks}) AND run_id IS NULL
163 AND json_extract(agent_scope, '$.run') IS NOT NULL"
164 ))
165 .bind(&values)?
166 .run()
167 .await?;
168 Ok(true)
169 }
170
171 /// Ends a sandbox's credentials: they stop working at once. Only run
172 /// credentials are touched.
173 pub async fn revoke_run_credentials(&self, a: RevokeRunCredentialsArgs) -> Result<bool> {
174 let hashes: Vec<&String> = a
175 .token_hashes
176 .iter()
177 .filter(|hash| is_hash(hash))
178 .take(MAX_HASHES)
179 .collect();
180 let mut conditions = Vec::new();
181 let mut values: Vec<JsValue> = Vec::new();
182 if !hashes.is_empty() {
183 conditions.push(format!(
184 "token_hash IN ({})",
185 vec!["?"; hashes.len()].join(", ")
186 ));
187 values.extend(hashes.iter().map(|hash| JsValue::from(hash.as_str())));
188 }
189 if let Some(run_id) = a.run_id.as_deref().filter(|id| !id.trim().is_empty()) {
190 conditions.push("run_id = ?".to_owned());
191 values.push(run_id.into());
192 }
193 if conditions.is_empty() {
194 return Ok(false);
195 }
196 self.db
197 .prepare(format!(
198 "UPDATE access_tokens SET expires_at = {SQL_NOW}
199 WHERE ({}) AND json_extract(agent_scope, '$.run') IS NOT NULL
200 AND (expires_at IS NULL OR expires_at > {SQL_NOW})",
201 conditions.join(" OR ")
202 ))
203 .bind(&values)?
204 .run()
205 .await?;
206 Ok(true)
207 }
208}
209
210#[cfg(test)]
211mod tests {
212 use super::is_hash;
213
214 #[test]
215 fn only_hashes_are_taken() {
216 assert!(is_hash(&"a1".repeat(32)));
217 assert!(!is_hash(&"A1".repeat(32)));
218 assert!(!is_hash("g1t_0123"));
219 assert!(!is_hash(&"0".repeat(63)));
220 }
221}