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-a16631325aecf58ca | 1 | //! 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 | ||
| 20 | use g1t_contracts::audit::Surface; | |
| 21 | use g1t_contracts::deploy_keys::{ | |
| 22 | self, AddDeployKeyArgs, DeployKey, DeployKeyArgs, DeployKeysArgs, KEY_IN_USE, MAX_PER_REPO, RemoveDeployKeyArgs, | |
| 23 | SshKeyArgs, | |
| 24 | }; | |
| 25 | use g1t_contracts::repos::{PathByIdArgs, Repo, RepoPath}; | |
| 26 | use g1t_contracts::time::rfc3339; | |
| 27 | use g1t_contracts::{FailureCode, Outcome, User, Viewer, new_id}; | |
| 28 | use g1t_kit::now_ms; | |
| 29 | use serde::Deserialize; | |
| 30 | use worker::Result; | |
| 31 | use worker::wasm_bindgen::JsValue; | |
| 32 | ||
| 33 | use crate::{Identity, crypto}; | |
| 34 | ||
| 35 | /// How stale a key's last-used time may get before it is written again. | |
| 36 | const LAST_USED_RESOLUTION_MS: u64 = 5 * 60 * 1000; | |
| 37 | const NOT_FOUND: &str = "Repository not found."; | |
| 38 | const NO_SUCH_KEY: &str = "There is no deploy key with that id on this repository."; | |
| 39 | ||
| 40 | const 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)] | |
| 47 | struct 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 | ||
| 58 | impl 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`. | |
| 75 | pub 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. | |
| 86 | fn due(last: Option<&str>) -> bool { | |
| 87 | deploy_keys::note_use_due(last, &rfc3339(now_ms().saturating_sub(LAST_USED_RESOLUTION_MS))) | |
| 88 | } | |
| 89 | ||
| 90 | impl 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)] | |
| 359 | mod 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.