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 | //! A repository's deploy keys over REST and MCP, at GitHub's addresses: |
| 2 | //! `GET`/`POST /repos/{owner}/{name}/keys` and | |
| 3 | //! `GET`/`DELETE /repos/{owner}/{name}/keys/{id}`, and as actions of the | |
| 4 | //! MCP `access` tool. | |
| 5 | //! | |
| 6 | //! Identity keeps them and decides who may see and change them | |
| 7 | //! (`g1t_contracts::deploy_keys`): the Admin role on the repository, never | |
| 8 | //! an agent, a workspace's token only when it was given Admin. | |
| 9 | ||
| 10 | use g1t_contracts::deploy_keys::{AddDeployKeyArgs, DeployKeyArgs, DeployKeysArgs, RemoveDeployKeyArgs}; | |
| 11 | use g1t_contracts::{FailureCode, Outcome, Viewer}; | |
| 12 | use serde_json::{Value, json}; | |
| 13 | use worker::Result; | |
| 14 | ||
| 15 | use crate::operations::{Services, repo_path}; | |
| 16 | ||
| 17 | /// One operation on deploy keys. | |
| 18 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] | |
| 19 | pub enum DeployKeysOp { | |
| 20 | ListDeployKeys, | |
| 21 | GetDeployKey, | |
| 22 | CreateDeployKey, | |
| 23 | DeleteDeployKey, | |
| 24 | } | |
| 25 | ||
| 26 | impl DeployKeysOp { | |
| 27 | /// Every one: `Op::ALL` lists each as `Op::DeployKeys(…)`, which a test | |
| 28 | /// checks against this. | |
| 29 | #[cfg(test)] | |
| 30 | pub const ALL: [DeployKeysOp; 4] = [ | |
| 31 | DeployKeysOp::ListDeployKeys, | |
| 32 | DeployKeysOp::GetDeployKey, | |
| 33 | DeployKeysOp::CreateDeployKey, | |
| 34 | DeployKeysOp::DeleteDeployKey, | |
| 35 | ]; | |
| 36 | ||
| 37 | pub fn name(self) -> &'static str { | |
| 38 | match self { | |
| 39 | DeployKeysOp::ListDeployKeys => "list_deploy_keys", | |
| 40 | DeployKeysOp::GetDeployKey => "get_deploy_key", | |
| 41 | DeployKeysOp::CreateDeployKey => "create_deploy_key", | |
| 42 | DeployKeysOp::DeleteDeployKey => "delete_deploy_key", | |
| 43 | } | |
| 44 | } | |
| 45 | ||
| 46 | /// For the API reference. | |
| 47 | pub fn title(self) -> &'static str { | |
| 48 | match self { | |
| 49 | DeployKeysOp::ListDeployKeys => "List deploy keys", | |
| 50 | DeployKeysOp::GetDeployKey => "Get a deploy key", | |
| 51 | DeployKeysOp::CreateDeployKey => "Create a deploy key", | |
| 52 | DeployKeysOp::DeleteDeployKey => "Delete a deploy key", | |
| 53 | } | |
| 54 | } | |
| 55 | ||
| 56 | pub fn description(self) -> &'static str { | |
| 57 | match self { | |
| 58 | DeployKeysOp::ListDeployKeys => "List a repository's deploy keys, oldest first: SSH keys that reach this one repository, for a server or a pipeline. Each has its `id` (`dk_…`), `title`, public `key`, `fingerprint` (`SHA256:…`), `read_only` (false when it may push), `created_at`, `created_by` (who added it) and `last_used_at` (null when it never signed in). Needs the Admin role on the repository; agents' tokens are refused.", | |
| 59 | DeployKeysOp::GetDeployKey => "Get one of a repository's deploy keys by its `id`, in the shape list_deploy_keys gives. Needs the Admin role on the repository.", | |
| 60 | DeployKeysOp::CreateDeployKey => "Add a deploy key to a repository: `key`, one line in OpenSSH public key format (ssh-ed25519, ecdsa-sha2-nistp256/384/521 or ssh-rsa), and a `title`. It is read-only unless `read_only` is false, which lets it push, workflow files included. A key registered anywhere already, as a person's SSH key or another deploy key, is refused with `409`: give each machine its own. At most 100 keys a repository. Needs the Admin role on the repository and a confirmed email address; agents' tokens and workspace tokens without Admin are refused. Recorded in the workspace's audit log.", | |
| 61 | DeployKeysOp::DeleteDeployKey => "Delete one of a repository's deploy keys by its `id`. A machine using it can no longer clone or push. There is no editing a key: to change its title or access, delete it and add it again. Needs the Admin role on the repository. Recorded in the workspace's audit log.", | |
| 62 | } | |
| 63 | } | |
| 64 | ||
| 65 | pub fn input(self) -> Value { | |
| 66 | let repo = json!({ "type": "string", "description": "Repository as \"owner/name\", e.g. \"flagon-io/hello\"." }); | |
| 67 | let id = json!({ "type": "string", "description": "The deploy key's id (dk_…), from list_deploy_keys." }); | |
| 68 | let (properties, required): (Value, &[&str]) = match self { | |
| 69 | DeployKeysOp::ListDeployKeys => (json!({ "repo": repo }), &["repo"]), | |
| 70 | DeployKeysOp::GetDeployKey | DeployKeysOp::DeleteDeployKey => (json!({ "repo": repo, "id": id }), &["repo", "id"]), | |
| 71 | DeployKeysOp::CreateDeployKey => ( | |
| 72 | json!({ | |
| 73 | "repo": repo, | |
| 74 | "title": { "type": "string", "description": "A name for it, such as the machine that uses it. Left out, the key's comment, else \"Deploy key\"." }, | |
| 75 | "key": { "type": "string", "description": "The public key, one line in OpenSSH format: the contents of a .pub file." }, | |
| 76 | "read_only": { "type": "boolean", "description": "False lets it push, workflow files included. True (read-only) unless you say." }, | |
| 77 | }), | |
| 78 | &["repo", "key"], | |
| 79 | ), | |
| 80 | }; | |
| 81 | json!({ "type": "object", "properties": properties, "required": required }) | |
| 82 | } | |
| 83 | } | |
| 84 | ||
| 85 | fn text(input: &Value, key: &str) -> String { | |
| 86 | input[key].as_str().map(str::trim).unwrap_or_default().to_owned() | |
| 87 | } | |
| 88 | ||
| 89 | /// `read_only` as sent: a boolean, or a word from a form. True unless said. | |
| 90 | fn read_only(input: &Value) -> Option<bool> { | |
| 91 | match &input["read_only"] { | |
| 92 | Value::Null => Some(true), | |
| 93 | Value::Bool(read_only) => Some(*read_only), | |
| 94 | Value::String(word) => match word.trim().to_ascii_lowercase().as_str() { | |
| 95 | "true" | "1" => Some(true), | |
| 96 | "false" | "0" => Some(false), | |
| 97 | _ => None, | |
| 98 | }, | |
| 99 | _ => None, | |
| 100 | } | |
| 101 | } | |
| 102 | ||
| 103 | pub async fn run(op: DeployKeysOp, services: &Services, viewer: &Viewer, input: &Value) -> Result<Outcome<Value>> { | |
| 104 | let Some(path) = repo_path(input) else { | |
| 105 | return Ok(Outcome::fail(FailureCode::Invalid, "Give the repository as \"owner/name\".")); | |
| 106 | }; | |
| 107 | let identity = &services.identity; | |
| 108 | let id = text(input, "id"); | |
| 109 | if matches!(op, DeployKeysOp::GetDeployKey | DeployKeysOp::DeleteDeployKey) && id.is_empty() { | |
| 110 | return Ok(Outcome::fail(FailureCode::Invalid, "Name the deploy key by its id (dk_…).")); | |
| 111 | } | |
| 112 | let actor = || viewer.clone().unwrap_or_default(); | |
| 113 | let surface = Some(services.audit.surface); | |
| 114 | match op { | |
| 115 | DeployKeysOp::ListDeployKeys => { | |
| 116 | g1t_kit::call(identity, "list_deploy_keys", &DeployKeysArgs { viewer: viewer.clone(), path }).await | |
| 117 | } | |
| 118 | DeployKeysOp::GetDeployKey => { | |
| 119 | g1t_kit::call(identity, "get_deploy_key", &DeployKeyArgs { viewer: viewer.clone(), path, id }).await | |
| 120 | } | |
| 121 | DeployKeysOp::CreateDeployKey => { | |
| 122 | let key = text(input, "key"); | |
| 123 | if key.is_empty() { | |
| 124 | return Ok(Outcome::fail(FailureCode::Invalid, "Give key: one line in OpenSSH public key format.")); | |
| 125 | } | |
| 126 | let Some(read_only) = read_only(input) else { | |
| 127 | return Ok(Outcome::fail(FailureCode::Invalid, "read_only is true or false.")); | |
| 128 | }; | |
| 129 | let args = AddDeployKeyArgs { actor: actor(), path, title: text(input, "title"), key, read_only, surface }; | |
| 130 | g1t_kit::call(identity, "add_deploy_key", &args).await | |
| 131 | } | |
| 132 | DeployKeysOp::DeleteDeployKey => { | |
| 133 | g1t_kit::call(identity, "remove_deploy_key", &RemoveDeployKeyArgs { actor: actor(), path, id, surface }).await | |
| 134 | } | |
| 135 | } | |
| 136 | } | |
| 137 | ||
| 138 | #[cfg(test)] | |
| 139 | mod tests { | |
| 140 | use super::*; | |
| 141 | use g1t_contracts::scopes::{Level, scope_for}; | |
| 142 | ||
| 143 | #[test] | |
| 144 | fn read_only_is_true_unless_said() { | |
| 145 | assert_eq!(read_only(&json!({})), Some(true)); | |
| 146 | assert_eq!(read_only(&json!({ "read_only": false })), Some(false)); | |
| 147 | assert_eq!(read_only(&json!({ "read_only": "false" })), Some(false)); | |
| 148 | assert_eq!(read_only(&json!({ "read_only": "maybe" })), None); | |
| 149 | } | |
| 150 | ||
| 151 | #[test] | |
| 152 | fn each_operation_is_described_and_scoped() { | |
| 153 | for op in DeployKeysOp::ALL { | |
| 154 | assert!(crate::operations::Op::ALL.contains(&crate::operations::Op::DeployKeys(op)), "{}", op.name()); | |
| 155 | assert!(!op.title().is_empty() && op.description().len() > 40, "{}", op.name()); | |
| 156 | assert!(op.input()["required"].as_array().unwrap().contains(&json!("repo")), "{}", op.name()); | |
| 157 | let level = scope_for(op.name()).unwrap().level(); | |
| 158 | let changes = matches!(op, DeployKeysOp::CreateDeployKey | DeployKeysOp::DeleteDeployKey); | |
| 159 | assert_eq!(level, if changes { Level::Admin } else { Level::Read }, "{}", op.name()); | |
| 160 | } | |
| 161 | } | |
| 162 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.