Skip to content
162 linesCodeBlameRaw
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
10use g1t_contracts::deploy_keys::{AddDeployKeyArgs, DeployKeyArgs, DeployKeysArgs, RemoveDeployKeyArgs};
11use g1t_contracts::{FailureCode, Outcome, Viewer};
12use serde_json::{Value, json};
13use worker::Result;
14
15use crate::operations::{Services, repo_path};
16
17/// One operation on deploy keys.
18#[derive(Clone, Copy, Debug, PartialEq, Eq)]
19pub enum DeployKeysOp {
20 ListDeployKeys,
21 GetDeployKey,
22 CreateDeployKey,
23 DeleteDeployKey,
24}
25
26impl 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
85fn 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.
90fn 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
103pub 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)]
139mod 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}