| 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 | } |