Skip to content
378 linesCodeBlameRaw

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.

Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca1//! Deploy keys, and who an SSH key belongs to.
2//!
3//! A deploy key is an SSH key one repository's admins add so that a
4//! machine can clone it, or push to it when they allowed write access. The
5//! rules are `g1t_contracts::deploy_keys`; this keeps the keys, beside
6//! people's own (`ssh_keys`, lib.rs). A public key is registered once
7//! across both tables: the services check, and triggers (migration 0035)
8//! refuse a second insert that slips past the check.
9//!
10//! Keys are kept by repository id, so a rename or a transfer keeps them;
11//! the path, and the workspace a key acts as, are looked up each time it
12//! is used. A deleted repository's keys resolve to no one while it is
13//! deleted, and go with it when it is purged (`forget_deploy_keys`, called
14//! with `forget_repo_access`).
15//!
16//! **Last used.** Both kinds of key record when they last signed in, at
17//! most once every 5 minutes, as access tokens do (tokens.rs), and only once
18//! the client proved it holds the private key.
19
20use g1t_contracts::audit::Surface;
21use g1t_contracts::deploy_keys::{
22 self, AddDeployKeyArgs, DeployKey, DeployKeyArgs, DeployKeysArgs, KEY_IN_USE, MAX_PER_REPO, RemoveDeployKeyArgs,
23 SshKeyArgs,
24};
25use g1t_contracts::repos::{PathByIdArgs, Repo, RepoPath};
26use g1t_contracts::time::rfc3339;
27use g1t_contracts::{FailureCode, Outcome, User, Viewer, new_id};
28use g1t_kit::now_ms;
29use serde::Deserialize;
30use worker::Result;
31use worker::wasm_bindgen::JsValue;
32
33use crate::{Identity, crypto};
34
35/// How stale a key's last-used time may get before it is written again.
36const LAST_USED_RESOLUTION_MS: u64 = 5 * 60 * 1000;
37const NOT_FOUND: &str = "Repository not found.";
38const NO_SUCH_KEY: &str = "There is no deploy key with that id on this repository.";
39
40const COLUMNS: &str = "dk.id, dk.title, dk.public_key, dk.fingerprint, dk.read_only, dk.created_at, dk.last_used_at,
41 COALESCE(u.username, w.slug) AS created_by
42 FROM deploy_keys dk
43 LEFT JOIN users u ON u.id = dk.created_by
44 LEFT JOIN workspaces w ON w.id = dk.created_by";
45
46#[derive(Deserialize)]
47struct Row {
48 id: String,
49 title: String,
50 public_key: String,
51 fingerprint: String,
52 read_only: u8,
53 created_at: String,
54 last_used_at: Option<String>,
55 created_by: Option<String>,
56}
57
58impl From<Row> for DeployKey {
59 fn from(row: Row) -> Self {
60 DeployKey {
61 id: row.id,
62 title: row.title,
63 key: row.public_key,
64 fingerprint: row.fingerprint,
65 read_only: row.read_only != 0,
66 created_at: row.created_at,
67 created_by: row.created_by,
68 last_used_at: row.last_used_at,
69 }
70 }
71}
72
73/// The title a key is given: what was typed, else the key's comment, else
74/// `fallback`.
75pub fn title_for(typed: &str, comment: &str, fallback: &str) -> String {
76 [typed.trim(), comment.trim(), fallback]
77 .into_iter()
78 .find(|candidate| !candidate.is_empty())
79 .unwrap_or_default()
80 .chars()
81 .take(100)
82 .collect()
83}
84
85/// Whether a last-used time should be written now.
86fn due(last: Option<&str>) -> bool {
87 deploy_keys::note_use_due(last, &rfc3339(now_ms().saturating_sub(LAST_USED_RESOLUTION_MS)))
88}
89
90impl Identity {
91 /// The repository at `path`, if `viewer` may see and change its deploy
92 /// keys, with the id of its workspace.
93 async fn keys_of(&self, viewer: &Viewer, path: &RepoPath) -> Result<Outcome<(Repo, String)>> {
94 let Some(repo) = self.repo_for(path, viewer).await? else {
95 return Ok(Outcome::fail(FailureCode::NotFound, NOT_FOUND));
96 };
97 if let Some(why) = deploy_keys::refusal(viewer.as_ref(), (&repo).into(), &format!("{}/{}", repo.namespace, repo.name)) {
98 return Ok(Outcome::fail(FailureCode::Forbidden, why));
99 }
100 let Some(workspace_id) = self.workspace_id_of(&repo.namespace).await? else {
101 return Ok(Outcome::fail(FailureCode::NotFound, NOT_FOUND));
102 };
103 Ok(Outcome::Ok((repo, workspace_id)))
104 }
105
106 async fn deploy_key_row(&self, repo_id: &str, id: &str) -> Result<Option<DeployKey>> {
107 Ok(self
108 .db
109 .prepare(format!("SELECT {COLUMNS} WHERE dk.repo_id = ? AND dk.id = ?"))
110 .bind(&[repo_id.into(), id.trim().into()])?
111 .first::<Row>(None)
112 .await?
113 .map(DeployKey::from))
114 }
115
116 /// Whether any account's SSH key, or any repository's deploy key, has
117 /// this fingerprint.
118 pub(crate) async fn key_in_use(&self, fingerprint: &str) -> Result<bool> {
119 Ok(self
120 .db
121 .prepare(
122 "SELECT fingerprint FROM ssh_keys WHERE fingerprint = ?1
123 UNION ALL SELECT fingerprint FROM deploy_keys WHERE fingerprint = ?1 LIMIT 1",
124 )
125 .bind(&[fingerprint.into()])?
126 .first::<serde_json::Value>(None)
127 .await?
128 .is_some())
129 }
130
131 pub async fn list_deploy_keys(&self, a: DeployKeysArgs) -> Result<Outcome<Vec<DeployKey>>> {
132 let (repo, _) = match self.keys_of(&a.viewer, &a.path).await? {
133 Outcome::Ok(found) => found,
134 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
135 };
136 let rows = self
137 .db
138 .prepare(format!("SELECT {COLUMNS} WHERE dk.repo_id = ? ORDER BY dk.created_at, dk.id LIMIT {MAX_PER_REPO}"))
139 .bind(&[repo.id.as_str().into()])?
140 .all()
141 .await?
142 .results::<Row>()?;
143 Ok(Outcome::Ok(rows.into_iter().map(DeployKey::from).collect()))
144 }
145
146 pub async fn get_deploy_key(&self, a: DeployKeyArgs) -> Result<Outcome<DeployKey>> {
147 let (repo, _) = match self.keys_of(&a.viewer, &a.path).await? {
148 Outcome::Ok(found) => found,
149 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
150 };
151 Ok(match self.deploy_key_row(&repo.id, &a.id).await? {
152 Some(key) => Outcome::Ok(key),
153 None => Outcome::fail(FailureCode::NotFound, NO_SUCH_KEY),
154 })
155 }
156
157 pub async fn add_deploy_key(&self, a: AddDeployKeyArgs) -> Result<Outcome<DeployKey>> {
158 let actor = Some(a.actor.clone());
159 let (repo, workspace_id) = match self.keys_of(&actor, &a.path).await? {
160 Outcome::Ok(found) => found,
161 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
162 };
163 if !a.actor.verified {
164 return Ok(Outcome::fail(FailureCode::Forbidden, "Confirm your email address before adding deploy keys."));
165 }
166 let Some(key) = crypto::parse_ssh_key(&a.key) else {
167 return Ok(Outcome::fail(
168 FailureCode::Invalid,
169 "That is not a valid OpenSSH public key. Paste one line, such as the contents of id_ed25519.pub.",
170 ));
171 };
172 if self.key_in_use(&key.fingerprint).await? {
173 return Ok(Outcome::fail(FailureCode::Conflict, KEY_IN_USE));
174 }
175 #[derive(Deserialize)]
176 struct Count {
177 count: u32,
178 }
179 let count = self
180 .db
181 .prepare("SELECT count(*) AS count FROM deploy_keys WHERE repo_id = ?")
182 .bind(&[repo.id.as_str().into()])?
183 .first::<Count>(None)
184 .await?
185 .map_or(0, |row| row.count);
186 if count as usize >= MAX_PER_REPO {
187 return Ok(Outcome::fail(
188 FailureCode::Conflict,
189 format!("A repository can have at most {MAX_PER_REPO} deploy keys. Delete one first."),
190 ));
191 }
192 let now = now_ms();
193 let id = new_id("dk", now);
194 let title = title_for(&a.title, &key.comment, "Deploy key");
195 let inserted = self
196 .db
197 .prepare(
198 "INSERT INTO deploy_keys (id, repo_id, workspace_id, title, public_key, fingerprint, read_only, created_by, created_at)
199 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
200 )
201 .bind(&[
202 id.as_str().into(),
203 repo.id.as_str().into(),
204 workspace_id.as_str().into(),
205 title.as_str().into(),
206 key.public_key.as_str().into(),
207 key.fingerprint.as_str().into(),
208 JsValue::from(u8::from(a.read_only)),
209 a.actor.id.as_str().into(),
210 rfc3339(now).into(),
211 ])?
212 .run()
213 .await;
214 // Added at the same moment elsewhere: the trigger or the unique
215 // index refused it.
216 if let Err(error) = inserted {
217 if self.key_in_use(&key.fingerprint).await? {
218 return Ok(Outcome::fail(FailureCode::Conflict, KEY_IN_USE));
219 }
220 return Err(error);
221 }
222 let access = if a.read_only { "read-only" } else { "read and write" };
223 self.audit(
224 &a.actor,
225 "repo.deploy_key_added",
226 (&repo).into(),
227 a.surface.unwrap_or(Surface::Web),
228 format!("Added the deploy key \"{title}\" ({}, {access})", key.fingerprint),
229 )
230 .await;
231 Ok(match self.deploy_key_row(&repo.id, &id).await? {
232 Some(key) => Outcome::Ok(key),
233 None => Outcome::fail(FailureCode::NotFound, NO_SUCH_KEY),
234 })
235 }
236
237 pub async fn remove_deploy_key(&self, a: RemoveDeployKeyArgs) -> Result<Outcome<bool>> {
238 let actor = Some(a.actor.clone());
239 let (repo, _) = match self.keys_of(&actor, &a.path).await? {
240 Outcome::Ok(found) => found,
241 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
242 };
243 let Some(key) = self.deploy_key_row(&repo.id, &a.id).await? else {
244 return Ok(Outcome::fail(FailureCode::NotFound, NO_SUCH_KEY));
245 };
246 self.db
247 .prepare("DELETE FROM deploy_keys WHERE id = ? AND repo_id = ?")
248 .bind(&[key.id.as_str().into(), repo.id.as_str().into()])?
249 .run()
250 .await?;
251 self.audit(
252 &a.actor,
253 "repo.deploy_key_removed",
254 (&repo).into(),
255 a.surface.unwrap_or(Surface::Web),
256 format!("Removed the deploy key \"{}\" ({})", key.title, key.fingerprint),
257 )
258 .await;
259 Ok(Outcome::Ok(true))
260 }
261
262 /// A purged repository's deploy keys go with it.
263 pub async fn forget_deploy_keys(&self, repo_id: &str) -> Result<()> {
264 self.db
265 .prepare("DELETE FROM deploy_keys WHERE repo_id = ?")
266 .bind(&[repo_id.into()])?
267 .run()
268 .await?;
269 Ok(())
270 }
271
272 /// Who signs in with the SSH key with this fingerprint: the person who
273 /// registered it, with their workspaces and grants as any credential
274 /// resolves them; or, for a deploy key, its repository's workspace
275 /// acting through a token that reaches that repository only
276 /// (`deploy_keys::principal`). Nobody for an unknown key, or a deploy
277 /// key whose repository or workspace is deleted.
278 pub async fn principal_for_ssh_key(&self, a: SshKeyArgs) -> Result<Viewer> {
279 #[derive(Deserialize)]
280 struct Person {
281 key_id: String,
282 last_used_at: Option<String>,
283 id: String,
284 username: String,
285 verified: u8,
286 }
287 let person = self
288 .db
289 .prepare(
290 "SELECT ssh_keys.id AS key_id, ssh_keys.last_used_at, users.id, users.username,
291 users.email_verified_at IS NOT NULL AS verified
292 FROM ssh_keys JOIN users ON users.id = ssh_keys.user_id
293 WHERE ssh_keys.fingerprint = ?",
294 )
295 .bind(&[a.fingerprint.as_str().into()])?
296 .first::<Person>(None)
297 .await?;
298 if let Some(person) = person {
299 if a.used && due(person.last_used_at.as_deref()) {
300 self.note_key_use("ssh_keys", &person.key_id).await?;
301 }
302 let user = User {
303 id: person.id,
304 username: person.username,
305 verified: person.verified != 0,
306 ..User::default()
307 };
308 return self.with_workspaces(Some(user)).await;
309 }
310
311 #[derive(Deserialize)]
312 struct Key {
313 id: String,
314 title: String,
315 repo_id: String,
316 read_only: u8,
317 last_used_at: Option<String>,
318 }
319 let Some(key) = self
320 .db
321 .prepare("SELECT id, title, repo_id, read_only, last_used_at FROM deploy_keys WHERE fingerprint = ?")
322 .bind(&[a.fingerprint.as_str().into()])?
323 .first::<Key>(None)
324 .await?
325 else {
326 return Ok(None);
327 };
328 // Where the repository is now; none while it is deleted.
329 let path: Option<RepoPath> =
330 g1t_kit::call(&self.env.service("REPOS")?, "path_by_id", &PathByIdArgs { id: key.repo_id.clone() }).await?;
331 let Some(path) = path else {
332 return Ok(None);
333 };
334 let Some(workspace_id) = self.workspace_id_of(&path.namespace).await? else {
335 return Ok(None);
336 };
337 let Some(workspace) = self.workspace_principal(&workspace_id).await? else {
338 return Ok(None);
339 };
340 if a.used && due(key.last_used_at.as_deref()) {
341 self.note_key_use("deploy_keys", &key.id).await?;
342 }
343 let repo = format!("{}/{}", path.namespace, path.name);
344 let access = deploy_keys::access(&key.id, &key.title, &repo, key.read_only != 0);
345 Ok(Some(deploy_keys::principal(&workspace.id, &workspace.username, access)))
346 }
347
348 async fn note_key_use(&self, table: &str, id: &str) -> Result<()> {
349 self.db
350 .prepare(format!("UPDATE {table} SET last_used_at = ? WHERE id = ?"))
351 .bind(&[rfc3339(now_ms()).into(), id.into()])?
352 .run()
353 .await?;
354 Ok(())
355 }
356}
357
358#[cfg(test)]
359mod tests {
360 use super::*;
361
362 #[test]
363 fn a_key_is_titled_by_what_was_typed_then_its_comment() {
364 assert_eq!(title_for(" CI ", "ana@laptop", "Deploy key"), "CI");
365 assert_eq!(title_for("", "ana@laptop", "Deploy key"), "ana@laptop");
366 assert_eq!(title_for(" ", " ", "Deploy key"), "Deploy key");
367 assert_eq!(title_for(&"x".repeat(300), "", "Deploy key").len(), 100);
368 }
369
370 #[test]
371 fn a_key_parsed_for_a_repository_is_stored_without_its_comment() {
372 let line = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGb9ECWmEzf6FQbrBZ9w7lshQhqowtrbLDFw4rXAxZuE deploy@ci";
373 let key = crypto::parse_ssh_key(line).unwrap();
374 assert_eq!(key.public_key, "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGb9ECWmEzf6FQbrBZ9w7lshQhqowtrbLDFw4rXAxZuE");
375 assert!(key.fingerprint.starts_with("SHA256:"));
376 assert_eq!(title_for("", &key.comment, "Deploy key"), "deploy@ci");
377 }
378}

This file's history is long; its oldest lines are credited to the oldest commit read.