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

248 lines9,130 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());
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily66 // g1t's own run: the workspace's credential, acting as g1t, so the
67 // pull request and working copy g1t opened are its own (run.system).
68 let on_behalf_of = if scope.run.as_ref().is_some_and(|run| run.system) {
69 Principal {
70 id: g1t_contracts::system::ID.to_owned(),
71 username: g1t_contracts::system::USERNAME.to_owned(),
72 }
73 } else {
74 Principal {
75 id: person.id,
76 username: person.username,
77 }
78 };
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API79 Ok(Some(User {
80 id: AGENT_ID.to_owned(),
81 username: AGENT_NAME.to_owned(),
82 kind: PrincipalKind::Agent,
83 verified: person.verified,
84 workspaces: intersect(&person.workspaces, &scope.repo.namespace),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look85 // The person's roles on the workspace's repositories, so an
86 // outside collaborator's agent works where they may, and never
87 // beyond Write (credentials::AGENT_CEILING).
88 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 API89 acting: Some(Box::new(Acting {
90 credential_id: credential_id.to_owned(),
91 agent,
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily92 on_behalf_of,
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API93 scope,
94 })),
95 ..User::default()
96 }))
97 }
98
99 pub async fn create_run_credential(
100 &self,
101 a: CreateRunCredentialArgs,
102 ) -> Result<CreatedAccessToken> {
103 let agent = a
104 .agent
105 .filter(|agent| !agent.trim().is_empty())
106 .unwrap_or_else(|| AGENT_NAME.to_owned());
107 let scope = AgentScope {
108 repo: a.repo.clone(),
109 operations: operations_for(a.kind, a.usage)
110 .into_iter()
111 .map(str::to_owned)
112 .collect(),
113 run: Some(RunBinding {
114 kind: a.kind,
115 usage: a.usage,
116 run_id: None,
117 number: a.number,
118 agent: agent.clone(),
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily119 system: a.on_behalf_of.kind == PrincipalKind::System,
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API120 read: a.read,
121 push: a.push,
122 }),
123 };
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily124 // g1t's own work (a security update, an agent it put on an upgrade)
125 // has no person behind it: its credential acts for the workspace,
126 // as a workspace's own token would.
127 let on_behalf_of = if a.on_behalf_of.kind == PrincipalKind::System {
128 match self.workspace_id_of(&a.repo.namespace).await? {
129 Some(id) => User {
130 id,
131 username: a.repo.namespace.to_lowercase(),
132 kind: PrincipalKind::Workspace,
133 ..User::default()
134 },
135 None => return Err(worker::Error::RustError(format!("no workspace {}", a.repo.namespace))),
136 }
137 } else {
138 a.on_behalf_of
139 };
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API140 let created = self
141 .create_access_token(CreateAccessTokenArgs {
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily142 user: on_behalf_of,
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API143 name: format!(
144 "{agent}: {} run in {}/{}{}",
145 a.kind.as_str(),
146 a.repo.namespace,
147 a.repo.name,
148 a.number.map(|n| format!("#{n}")).unwrap_or_default()
149 ),
150 ttl_seconds: Some(
151 a.ttl_seconds
152 .clamp(MIN_RUN_TTL_SECONDS, MAX_RUN_TTL_SECONDS),
153 ),
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step154 // A run credential's scope is its run's; see agent_scope.
155 scopes: None,
156 listed: false,
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API157 })
158 .await?;
159 self.db
160 .prepare("UPDATE access_tokens SET agent_scope = ? WHERE id = ?")
161 .bind(&[
162 serde_json::to_string(&scope)?.into(),
163 created.info.id.as_str().into(),
164 ])?
165 .run()
166 .await?;
167 Ok(created)
168 }
169
170 /// Ties a sandbox's credentials to the agent run it recorded. A token
171 /// already bound keeps its run.
172 pub async fn bind_run_credentials(&self, a: BindRunCredentialsArgs) -> Result<bool> {
173 let hashes: Vec<&String> = a
174 .token_hashes
175 .iter()
176 .filter(|hash| is_hash(hash))
177 .take(MAX_HASHES)
178 .collect();
179 if hashes.is_empty() || a.run_id.trim().is_empty() {
180 return Ok(false);
181 }
182 let marks = vec!["?"; hashes.len()].join(", ");
183 let mut values: Vec<JsValue> = vec![a.run_id.as_str().into(), a.run_id.as_str().into()];
184 values.extend(hashes.iter().map(|hash| JsValue::from(hash.as_str())));
185 self.db
186 .prepare(format!(
187 "UPDATE access_tokens
188 SET run_id = ?, agent_scope = json_set(agent_scope, '$.run.runId', ?)
189 WHERE token_hash IN ({marks}) AND run_id IS NULL
190 AND json_extract(agent_scope, '$.run') IS NOT NULL"
191 ))
192 .bind(&values)?
193 .run()
194 .await?;
195 Ok(true)
196 }
197
198 /// Ends a sandbox's credentials: they stop working at once. Only run
199 /// credentials are touched.
200 pub async fn revoke_run_credentials(&self, a: RevokeRunCredentialsArgs) -> Result<bool> {
201 let hashes: Vec<&String> = a
202 .token_hashes
203 .iter()
204 .filter(|hash| is_hash(hash))
205 .take(MAX_HASHES)
206 .collect();
207 let mut conditions = Vec::new();
208 let mut values: Vec<JsValue> = Vec::new();
209 if !hashes.is_empty() {
210 conditions.push(format!(
211 "token_hash IN ({})",
212 vec!["?"; hashes.len()].join(", ")
213 ));
214 values.extend(hashes.iter().map(|hash| JsValue::from(hash.as_str())));
215 }
216 if let Some(run_id) = a.run_id.as_deref().filter(|id| !id.trim().is_empty()) {
217 conditions.push("run_id = ?".to_owned());
218 values.push(run_id.into());
219 }
220 if conditions.is_empty() {
221 return Ok(false);
222 }
223 self.db
224 .prepare(format!(
225 "UPDATE access_tokens SET expires_at = {SQL_NOW}
226 WHERE ({}) AND json_extract(agent_scope, '$.run') IS NOT NULL
227 AND (expires_at IS NULL OR expires_at > {SQL_NOW})",
228 conditions.join(" OR ")
229 ))
230 .bind(&values)?
231 .run()
232 .await?;
233 Ok(true)
234 }
235}
236
237#[cfg(test)]
238mod tests {
239 use super::is_hash;
240
241 #[test]
242 fn only_hashes_are_taken() {
243 assert!(is_hash(&"a1".repeat(32)));
244 assert!(!is_hash(&"A1".repeat(32)));
245 assert!(!is_hash("g1t_0123"));
246 assert!(!is_hash(&"0".repeat(63)));
247 }
248}