pr_01m47d15m3e54sn21z27rpy5n9/services/identity/src/run_credentials.rs

218 lines7,749 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API1//! 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,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look8 RunBinding, intersect, intersect_grants, operations_for,
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API9};
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),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look72 // 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),
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API76 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 })
128 .await?;
129 self.db
130 .prepare("UPDATE access_tokens SET agent_scope = ? WHERE id = ?")
131 .bind(&[
132 serde_json::to_string(&scope)?.into(),
133 created.info.id.as_str().into(),
134 ])?
135 .run()
136 .await?;
137 Ok(created)
138 }
139
140 /// Ties a sandbox's credentials to the agent run it recorded. A token
141 /// already bound keeps its run.
142 pub async fn bind_run_credentials(&self, a: BindRunCredentialsArgs) -> Result<bool> {
143 let hashes: Vec<&String> = a
144 .token_hashes
145 .iter()
146 .filter(|hash| is_hash(hash))
147 .take(MAX_HASHES)
148 .collect();
149 if hashes.is_empty() || a.run_id.trim().is_empty() {
150 return Ok(false);
151 }
152 let marks = vec!["?"; hashes.len()].join(", ");
153 let mut values: Vec<JsValue> = vec![a.run_id.as_str().into(), a.run_id.as_str().into()];
154 values.extend(hashes.iter().map(|hash| JsValue::from(hash.as_str())));
155 self.db
156 .prepare(format!(
157 "UPDATE access_tokens
158 SET run_id = ?, agent_scope = json_set(agent_scope, '$.run.runId', ?)
159 WHERE token_hash IN ({marks}) AND run_id IS NULL
160 AND json_extract(agent_scope, '$.run') IS NOT NULL"
161 ))
162 .bind(&values)?
163 .run()
164 .await?;
165 Ok(true)
166 }
167
168 /// Ends a sandbox's credentials: they stop working at once. Only run
169 /// credentials are touched.
170 pub async fn revoke_run_credentials(&self, a: RevokeRunCredentialsArgs) -> Result<bool> {
171 let hashes: Vec<&String> = a
172 .token_hashes
173 .iter()
174 .filter(|hash| is_hash(hash))
175 .take(MAX_HASHES)
176 .collect();
177 let mut conditions = Vec::new();
178 let mut values: Vec<JsValue> = Vec::new();
179 if !hashes.is_empty() {
180 conditions.push(format!(
181 "token_hash IN ({})",
182 vec!["?"; hashes.len()].join(", ")
183 ));
184 values.extend(hashes.iter().map(|hash| JsValue::from(hash.as_str())));
185 }
186 if let Some(run_id) = a.run_id.as_deref().filter(|id| !id.trim().is_empty()) {
187 conditions.push("run_id = ?".to_owned());
188 values.push(run_id.into());
189 }
190 if conditions.is_empty() {
191 return Ok(false);
192 }
193 self.db
194 .prepare(format!(
195 "UPDATE access_tokens SET expires_at = {SQL_NOW}
196 WHERE ({}) AND json_extract(agent_scope, '$.run') IS NOT NULL
197 AND (expires_at IS NULL OR expires_at > {SQL_NOW})",
198 conditions.join(" OR ")
199 ))
200 .bind(&values)?
201 .run()
202 .await?;
203 Ok(true)
204 }
205}
206
207#[cfg(test)]
208mod tests {
209 use super::is_hash;
210
211 #[test]
212 fn only_hashes_are_taken() {
213 assert!(is_hash(&"a1".repeat(32)));
214 assert!(!is_hash(&"A1".repeat(32)));
215 assert!(!is_hash("g1t_0123"));
216 assert!(!is_hash(&"0".repeat(63)));
217 }
218}