Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca
# Conflicts: # apps/api/src/operations.rs
| 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 | + | } |
| 13 | 13 | mod blobs; | |
| 14 | 14 | mod checks; | |
| 15 | 15 | mod deployments; | |
| 16 | + | mod deploy_keys; | |
| 16 | 17 | mod mcp; | |
| 17 | 18 | mod notifications; | |
| 18 | 19 | mod oauth; |
| 9 | 9 | ||
| 10 | 10 | use crate::about::AboutOp; | |
| 11 | 11 | use crate::artifacts::ArtifactsOp; | |
| 12 | + | use crate::deploy_keys::DeployKeysOp; | |
| 12 | 13 | use crate::deployments::DeploymentsOp; | |
| 13 | 14 | use crate::protection::ProtectionOp; | |
| 14 | 15 | use crate::token_policy::TokenOp; | |
| ⋯ | |||
| 155 | 156 | ), | |
| 156 | 157 | ( | |
| 157 | 158 | "Access", | |
| 158 | − | "Who can do what in a repository: repository roles, people given a role on one repository (outside collaborators when they are not members), invitations, and a workspace's base permission.", | |
| 159 | + | "Who can do what in a repository: repository roles, people given a role on one repository (outside collaborators when they are not members), invitations, a workspace's base permission, and deploy keys: SSH keys that reach one repository.", | |
| 159 | 160 | &[ | |
| 160 | 161 | Op::ListCollaborators, | |
| 161 | 162 | Op::AddCollaborator, | |
| ⋯ | |||
| 169 | 170 | Op::DeclineRepoInvitation, | |
| 170 | 171 | Op::SetBasePermission, | |
| 171 | 172 | Op::ListOutsideCollaborators, | |
| 173 | + | Op::DeployKeys(DeployKeysOp::ListDeployKeys), | |
| 174 | + | Op::DeployKeys(DeployKeysOp::GetDeployKey), | |
| 175 | + | Op::DeployKeys(DeployKeysOp::CreateDeployKey), | |
| 176 | + | Op::DeployKeys(DeployKeysOp::DeleteDeployKey), | |
| 172 | 177 | ], | |
| 173 | 178 | ), | |
| 174 | 179 | ( | |
| ⋯ | |||
| 676 | 681 | Op::Protection(op) => op.title(), | |
| 677 | 682 | Op::Tokens(op) => op.title(), | |
| 678 | 683 | Op::Artifacts(op) => op.title(), | |
| 684 | + | Op::DeployKeys(op) => op.title(), | |
| 679 | 685 | } | |
| 680 | 686 | } | |
| 681 | 687 | ||
| 29 | 29 | use crate::checks::ChecksOp; | |
| 30 | 30 | use crate::about::AboutOp; | |
| 31 | 31 | use crate::artifacts::ArtifactsOp; | |
| 32 | + | use crate::deploy_keys::DeployKeysOp; | |
| 32 | 33 | use crate::deployments::DeploymentsOp; | |
| 33 | 34 | use crate::protection::ProtectionOp; | |
| 34 | 35 | use crate::token_policy::TokenOp; | |
| ⋯ | |||
| 294 | 295 | Tokens(TokenOp), | |
| 295 | 296 | /// Workflow run artifacts, and how long they are kept: artifacts.rs. | |
| 296 | 297 | Artifacts(ArtifactsOp), | |
| 298 | + | /// A repository's deploy keys: deploy_keys.rs. | |
| 299 | + | DeployKeys(DeployKeysOp), | |
| 297 | 300 | } | |
| 298 | 301 | ||
| 299 | 302 | fn failed(code: FailureCode, message: &str) -> Result<Outcome<Value>> { | |
| ⋯ | |||
| 656 | 659 | } | |
| 657 | 660 | ||
| 658 | 661 | impl Op { | |
| 659 | − | pub const ALL: [Op; 282] = [ | |
| 662 | + | pub const ALL: [Op; 286] = [ | |
| 660 | 663 | Op::Whoami, | |
| 661 | 664 | Op::GetWorkspace, | |
| 662 | 665 | Op::CreateWorkspace, | |
| ⋯ | |||
| 921 | 924 | Op::Artifacts(ArtifactsOp::DeleteArtifact), | |
| 922 | 925 | Op::Artifacts(ArtifactsOp::GetArtifactRetention), | |
| 923 | 926 | Op::Artifacts(ArtifactsOp::SetArtifactRetention), | |
| 927 | + | Op::DeployKeys(DeployKeysOp::ListDeployKeys), | |
| 928 | + | Op::DeployKeys(DeployKeysOp::GetDeployKey), | |
| 929 | + | Op::DeployKeys(DeployKeysOp::CreateDeployKey), | |
| 930 | + | Op::DeployKeys(DeployKeysOp::DeleteDeployKey), | |
| 924 | 931 | Op::Protection(ProtectionOp::UpdateEnvironment), | |
| 925 | 932 | Op::Protection(ProtectionOp::DeleteEnvironment), | |
| 926 | 933 | Op::Protection(ProtectionOp::GetPendingDeployments), | |
| ⋯ | |||
| 1135 | 1142 | Op::Protection(op) => op.name(), | |
| 1136 | 1143 | Op::Tokens(op) => op.name(), | |
| 1137 | 1144 | Op::Artifacts(op) => op.name(), | |
| 1145 | + | Op::DeployKeys(op) => op.name(), | |
| 1138 | 1146 | } | |
| 1139 | 1147 | } | |
| 1140 | 1148 | ||
| ⋯ | |||
| 1651 | 1659 | Op::Protection(op) => op.description(), | |
| 1652 | 1660 | Op::Tokens(op) => op.description(), | |
| 1653 | 1661 | Op::Artifacts(op) => op.description(), | |
| 1662 | + | Op::DeployKeys(op) => op.description(), | |
| 1654 | 1663 | } | |
| 1655 | 1664 | } | |
| 1656 | 1665 | ||
| ⋯ | |||
| 3025 | 3034 | Op::Protection(op) => op.input(), | |
| 3026 | 3035 | Op::Tokens(op) => op.input(), | |
| 3027 | 3036 | Op::Artifacts(op) => op.input(), | |
| 3037 | + | Op::DeployKeys(op) => op.input(), | |
| 3028 | 3038 | } | |
| 3029 | 3039 | } | |
| 3030 | 3040 | ||
| ⋯ | |||
| 5101 | 5111 | Op::Protection(op) => crate::protection::run(op, services, viewer, input).await, | |
| 5102 | 5112 | Op::Tokens(op) => crate::token_policy::run(op, services, viewer, input).await, | |
| 5103 | 5113 | Op::Artifacts(op) => crate::artifacts::run(op, services, viewer, input).await, | |
| 5114 | + | Op::DeployKeys(op) => crate::deploy_keys::run(op, services, viewer, input).await, | |
| 5104 | 5115 | Op::ReopenSecurityAlert => { | |
| 5105 | 5116 | let changed: Outcome<AlertChange> = call( | |
| 5106 | 5117 | &services.security, | |
| ⋯ | |||
| 5386 | 5397 | assert_eq!(integer(&json!({}), "number"), None); | |
| 5387 | 5398 | } | |
| 5388 | 5399 | ||
| 5389 | − | const ACCESS: [Op; 12] = [ | |
| 5400 | + | const ACCESS: [Op; 16] = [ | |
| 5390 | 5401 | Op::ListCollaborators, | |
| 5391 | 5402 | Op::AddCollaborator, | |
| 5392 | 5403 | Op::UpdateCollaborator, | |
| ⋯ | |||
| 5399 | 5410 | Op::DeclineRepoInvitation, | |
| 5400 | 5411 | Op::SetBasePermission, | |
| 5401 | 5412 | Op::ListOutsideCollaborators, | |
| 5413 | + | Op::DeployKeys(DeployKeysOp::ListDeployKeys), | |
| 5414 | + | Op::DeployKeys(DeployKeysOp::GetDeployKey), | |
| 5415 | + | Op::DeployKeys(DeployKeysOp::CreateDeployKey), | |
| 5416 | + | Op::DeployKeys(DeployKeysOp::DeleteDeployKey), | |
| 5402 | 5417 | ]; | |
| 5403 | 5418 | ||
| 5404 | 5419 | /// Who has access is for people: no run's scope lists these, and the | |
| 4419 | 4419 | ], | |
| 4420 | 4420 | "notes": "By username, each with the repositories they have a role on. Refused with `403` for anyone but an owner. See [Access and roles](/guides/access-and-roles/)." | |
| 4421 | 4421 | }, | |
| 4422 | + | "list_deploy_keys": { | |
| 4423 | + | "params": { | |
| 4424 | + | "owner": "flagon-io", | |
| 4425 | + | "name": "hello" | |
| 4426 | + | }, | |
| 4427 | + | "response": [ | |
| 4428 | + | { | |
| 4429 | + | "id": "dk_01kp3f2g3h4j5k6m7n8p9q0r1s", | |
| 4430 | + | "title": "Docs build", | |
| 4431 | + | "key": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGb9ECWmEzf6FQbrBZ9w7lshQhqowtrbLDFw4rXAxZuE", | |
| 4432 | + | "fingerprint": "SHA256:ubxEl41fJDnUoEPKSZE0y6R0ZjjAQf/wV5vZgeBV8qk", | |
| 4433 | + | "read_only": true, | |
| 4434 | + | "created_at": "2026-10-06T09:12:00.000Z", | |
| 4435 | + | "created_by": "syntaqx", | |
| 4436 | + | "last_used_at": "2026-10-08T07:40:00.000Z" | |
| 4437 | + | }, | |
| 4438 | + | { | |
| 4439 | + | "id": "dk_01kp3g4h5j6k7m8n9p0q1r2s3t", | |
| 4440 | + | "title": "Release bot", | |
| 4441 | + | "key": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJ8Vq2nLr5Xw0sT3yK7mP1cH4dF6gB9aE2uZ5oN8iR0j", | |
| 4442 | + | "fingerprint": "SHA256:2h7VyLkcqBHwKVtfFUchGsE5pDwpgBPV/VvJgYiS27M", | |
| 4443 | + | "read_only": false, | |
| 4444 | + | "created_at": "2026-10-07T15:30:00.000Z", | |
| 4445 | + | "created_by": "syntaqx", | |
| 4446 | + | "last_used_at": null | |
| 4447 | + | } | |
| 4448 | + | ], | |
| 4449 | + | "notes": "Oldest first. `read_only` false means the key may push, workflow files included. `last_used_at` is when it last signed in over SSH, to within 5 minutes, and null when it never has. Refused with `403` without the Admin role, and for an agent's token or a workspace token without Admin; `404` for a private repository you cannot see. See [Deploy keys](/guides/git/#deploy-keys)." | |
| 4450 | + | }, | |
| 4451 | + | "get_deploy_key": { | |
| 4452 | + | "params": { | |
| 4453 | + | "owner": "flagon-io", | |
| 4454 | + | "name": "hello", | |
| 4455 | + | "id": "dk_01kp3f2g3h4j5k6m7n8p9q0r1s" | |
| 4456 | + | }, | |
| 4457 | + | "response": { | |
| 4458 | + | "id": "dk_01kp3f2g3h4j5k6m7n8p9q0r1s", | |
| 4459 | + | "title": "Docs build", | |
| 4460 | + | "key": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGb9ECWmEzf6FQbrBZ9w7lshQhqowtrbLDFw4rXAxZuE", | |
| 4461 | + | "fingerprint": "SHA256:ubxEl41fJDnUoEPKSZE0y6R0ZjjAQf/wV5vZgeBV8qk", | |
| 4462 | + | "read_only": true, | |
| 4463 | + | "created_at": "2026-10-06T09:12:00.000Z", | |
| 4464 | + | "created_by": "syntaqx", | |
| 4465 | + | "last_used_at": "2026-10-08T07:40:00.000Z" | |
| 4466 | + | }, | |
| 4467 | + | "notes": "`404` when the repository has no deploy key with that id." | |
| 4468 | + | }, | |
| 4469 | + | "create_deploy_key": { | |
| 4470 | + | "params": { | |
| 4471 | + | "owner": "flagon-io", | |
| 4472 | + | "name": "hello" | |
| 4473 | + | }, | |
| 4474 | + | "request": { | |
| 4475 | + | "title": "Release bot", | |
| 4476 | + | "key": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJ8Vq2nLr5Xw0sT3yK7mP1cH4dF6gB9aE2uZ5oN8iR0j release@ci", | |
| 4477 | + | "read_only": false | |
| 4478 | + | }, | |
| 4479 | + | "response": { | |
| 4480 | + | "id": "dk_01kp3g4h5j6k7m8n9p0q1r2s3t", | |
| 4481 | + | "title": "Release bot", | |
| 4482 | + | "key": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJ8Vq2nLr5Xw0sT3yK7mP1cH4dF6gB9aE2uZ5oN8iR0j", | |
| 4483 | + | "fingerprint": "SHA256:2h7VyLkcqBHwKVtfFUchGsE5pDwpgBPV/VvJgYiS27M", | |
| 4484 | + | "read_only": false, | |
| 4485 | + | "created_at": "2026-10-07T15:30:00.000Z", | |
| 4486 | + | "created_by": "syntaqx", | |
| 4487 | + | "last_used_at": null | |
| 4488 | + | }, | |
| 4489 | + | "notes": "`read_only` is true unless you send false. The key's comment is not kept; it becomes the title when you send none. Refused with `400` for a line that is not an OpenSSH public key, `409` with `Key is already in use.` when the key is registered already (as anyone's SSH key or as a deploy key anywhere), `409` past 100 keys, and `403` without the Admin role, from an agent's token, from a workspace token without Admin, or before your email address is confirmed. Recorded in the workspace's audit log as `repo.deploy_key_added`." | |
| 4490 | + | }, | |
| 4491 | + | "delete_deploy_key": { | |
| 4492 | + | "params": { | |
| 4493 | + | "owner": "flagon-io", | |
| 4494 | + | "name": "hello", | |
| 4495 | + | "id": "dk_01kp3g4h5j6k7m8n9p0q1r2s3t" | |
| 4496 | + | }, | |
| 4497 | + | "response": true, | |
| 4498 | + | "notes": "The key stops working at once. `404` when the repository has no deploy key with that id. Recorded in the workspace's audit log as `repo.deploy_key_removed`." | |
| 4499 | + | }, | |
| 4422 | 4500 | "list_teams": { | |
| 4423 | 4501 | "params": { | |
| 4424 | 4502 | "workspace": "flagon-io" |
| 17 | 17 | use crate::operations::Op; | |
| 18 | 18 | use crate::checks::ChecksOp; | |
| 19 | 19 | use crate::rules::RulesOp; | |
| 20 | + | use crate::deploy_keys::DeployKeysOp; | |
| 20 | 21 | ||
| 21 | 22 | /// A key as `#[serde(rename_all = "camelCase")]` writes it. | |
| 22 | 23 | fn camel_key(key: &str) -> String { | |
| ⋯ | |||
| 114 | 115 | Op::Deployments(_) => return as_is, | |
| 115 | 116 | // Artifacts are shaped by the API itself, in `snake_case`. | |
| 116 | 117 | Op::Artifacts(_) => return as_is, | |
| 118 | + | // Deploy keys travel in `snake_case` from identity. | |
| 119 | + | Op::DeployKeys(DeployKeysOp::ListDeployKeys) => return through::<Vec<g1t_contracts::deploy_keys::DeployKey>>(op, as_is), | |
| 120 | + | Op::DeployKeys(DeployKeysOp::GetDeployKey | DeployKeysOp::CreateDeployKey) => { | |
| 121 | + | return through::<g1t_contracts::deploy_keys::DeployKey>(op, as_is); | |
| 122 | + | } | |
| 123 | + | Op::DeployKeys(DeployKeysOp::DeleteDeployKey) => return through::<bool>(op, as_is), | |
| 117 | 124 | // Built by the API itself, in `snake_case`. | |
| 118 | 125 | Op::ListSecurityAlerts => return through::<Vec<crate::alerts::SecurityAlert>>(op, as_is), | |
| 119 | 126 | Op::DismissSecurityAlert | Op::ReopenSecurityAlert => { | |
| 4 | 4 | ||
| 5 | 5 | use crate::about::AboutOp; | |
| 6 | 6 | use crate::artifacts::ArtifactsOp; | |
| 7 | + | use crate::deploy_keys::DeployKeysOp; | |
| 7 | 8 | use crate::deployments::DeploymentsOp; | |
| 8 | 9 | use crate::protection::ProtectionOp; | |
| 9 | 10 | use crate::token_policy::TokenOp; | |
| ⋯ | |||
| 72 | 73 | route("GET", "/repos/:owner/:name/invitations", Op::ListRepoInvitations, &[]), | |
| 73 | 74 | route("DELETE", "/repos/:owner/:name/invitations/:id", Op::RevokeRepoInvitation, &[]), | |
| 74 | 75 | route("GET", "/user/repository_invitations", Op::ListMyRepoInvitations, &[]), | |
| 76 | + | // Deploy keys, at GitHub's addresses. | |
| 77 | + | route("GET", "/repos/:owner/:name/keys", Op::DeployKeys(DeployKeysOp::ListDeployKeys), &[]), | |
| 78 | + | route("POST", "/repos/:owner/:name/keys", Op::DeployKeys(DeployKeysOp::CreateDeployKey), &[]), | |
| 79 | + | route("GET", "/repos/:owner/:name/keys/:id", Op::DeployKeys(DeployKeysOp::GetDeployKey), &[]), | |
| 80 | + | route("DELETE", "/repos/:owner/:name/keys/:id", Op::DeployKeys(DeployKeysOp::DeleteDeployKey), &[]), | |
| 75 | 81 | // Your notifications: threads, marking them, and what you subscribe | |
| 76 | 82 | // to and watch. GitHub's addresses, with g1t's saved and snoozed. | |
| 77 | 83 | route("GET", "/notifications", Op::ListNotifications, &[("all", "all"), ("participating", "participating"), ("view", "view"), ("reason", "reason"), ("severity", "severity"), ("since", "since"), ("before", "before"), ("cursor", "cursor"), ("per_page", "per_page")]), | |
| 20 | 20 | ||
| 21 | 21 | use crate::about::AboutOp; | |
| 22 | 22 | use crate::artifacts::ArtifactsOp; | |
| 23 | + | use crate::deploy_keys::DeployKeysOp; | |
| 23 | 24 | use crate::deployments::DeploymentsOp; | |
| 24 | 25 | use crate::protection::ProtectionOp; | |
| 25 | 26 | use crate::token_policy::TokenOp; | |
| ⋯ | |||
| 295 | 296 | Tool { | |
| 296 | 297 | name: "access", | |
| 297 | 298 | title: "Who has access", | |
| 298 | − | description: "Who has access to a repository and with which role (read, triage, write, maintain, admin), outside collaborators, and a workspace's base permission.", | |
| 299 | + | description: "Who has access to a repository and with which role (read, triage, write, maintain, admin), outside collaborators, a workspace's base permission, and a repository's deploy keys.", | |
| 299 | 300 | default_action: None, | |
| 300 | 301 | actions: &[ | |
| 301 | 302 | a("list_collaborators", Op::ListCollaborators, "Everyone with a role, and pending invitations"), | |
| ⋯ | |||
| 307 | 308 | a("revoke_invitation", Op::RevokeRepoInvitation, "Withdraw one"), | |
| 308 | 309 | a("set_base_permission", Op::SetBasePermission, "What every member gets on each repository"), | |
| 309 | 310 | a("list_outside_collaborators", Op::ListOutsideCollaborators, "People with roles who are not members"), | |
| 311 | + | a("list_deploy_keys", Op::DeployKeys(DeployKeysOp::ListDeployKeys), "SSH keys that reach this one repository"), | |
| 312 | + | a("get_deploy_key", Op::DeployKeys(DeployKeysOp::GetDeployKey), "One deploy key, by id"), | |
| 313 | + | a("add_deploy_key", Op::DeployKeys(DeployKeysOp::CreateDeployKey), "Add one; read-only unless read_only is false"), | |
| 314 | + | a("remove_deploy_key", Op::DeployKeys(DeployKeysOp::DeleteDeployKey), "Delete one"), | |
| 310 | 315 | ], | |
| 311 | 316 | }, | |
| 312 | 317 | Tool { | |
| 1 | 1 | --- | |
| 2 | 2 | title: Access and roles | |
| 3 | − | description: The five repository roles and what each can do, the base permission members get, roles through teams, outside collaborators and invitations, and what agents may do on a person's behalf. | |
| 3 | + | description: The five repository roles and what each can do, the base permission members get, roles through teams, outside collaborators and invitations, who may manage deploy keys, and what agents may do on a person's behalf. | |
| 4 | 4 | --- | |
| 5 | 5 | ||
| 6 | 6 | Everyone who can work in a repository has a role on it. The role says what | |
| ⋯ | |||
| 35 | 35 | | Change the description, topics, and pull request and agent settings | | | | Yes | Yes | | |
| 36 | 36 | | Change branch protection and guardrails | | | | Yes | Yes | | |
| 37 | 37 | | Manage webhooks, secrets, variables, deployments and domains | | | | | Yes | | |
| 38 | − | | Manage who has access, and invitations | | | | | Yes | | |
| 38 | + | | Manage who has access, invitations and deploy keys | | | | | Yes | | |
| 39 | 39 | | Rename, archive, change visibility and the default branch | | | | | Yes | | |
| 40 | 40 | | Transfer or delete the repository | | | | | Owners only | | |
| 41 | 41 | ||
| ⋯ | |||
| 238 | 238 | Read or Triage who mentions or assigns an agent is told so, and nothing | |
| 239 | 239 | starts. | |
| 240 | 240 | ||
| 241 | + | ## Deploy keys | |
| 242 | + | ||
| 243 | + | A [deploy key](/guides/git/#deploy-keys) is an SSH key that lets a machine | |
| 244 | + | reach one repository: Read, or Write when it was added with write access, | |
| 245 | + | on that repository and no other. It is part of who has access, so managing | |
| 246 | + | deploy keys needs the Admin role: | |
| 247 | + | ||
| 248 | + | | Who | Can list, add and delete a repository's deploy keys | | |
| 249 | + | | --- | --- | | |
| 250 | + | | Someone with the Admin role on it, owners included | Yes | | |
| 251 | + | | Someone with Maintain or less | No | | |
| 252 | + | | An agent, whoever it works for | No | | |
| 253 | + | | A workspace's [access token](/guides/workspaces/#workspace-access-tokens) | Only when an owner gave it Admin | | |
| 254 | + | | A deploy key | No | | |
| 255 | + | ||
| 256 | + | Adding one also needs a confirmed email address. A personal access token | |
| 257 | + | needs the `access:read` scope to list and read them, and `access:admin` to | |
| 258 | + | add and delete them. | |
| 259 | + | ||
| 241 | 260 | ## Through the API | |
| 242 | 261 | ||
| 243 | 262 | Every route is in the [API reference](/reference/api/). Each is also an | |
| 244 | 263 | action of an [MCP tool](/reference/mcp/): `access` for a repository's | |
| 245 | − | people and the base permission, `account` for invitations to you. | |
| 264 | + | people, its deploy keys and the base permission, `account` for invitations to you. | |
| 246 | 265 | ||
| 247 | 266 | | Route | MCP tool and action | What it does | Who | | |
| 248 | 267 | | --- | --- | --- | --- | | |
| ⋯ | |||
| 258 | 277 | | `DELETE /user/repository_invitations/{id}` | `account` `decline_repository_invitation` | Decline one. | You | | |
| 259 | 278 | | `PUT /workspaces/{workspace}/base_permission` | `access` `set_base_permission` | Set the base permission. Body: `base_permission`: `none`, `read`, `write` or `admin`. `PATCH /workspaces/{workspace}` (`workspace` `update`) takes `base_permission` too, with the `access:admin` scope. | Owners | | |
| 260 | 279 | | `GET /workspaces/{workspace}/outside_collaborators` | `access` `list_outside_collaborators` | A workspace's outside collaborators and the repositories each can reach. | Owners | | |
| 280 | + | | `GET /repos/{owner}/{name}/keys` | `access` `list_deploy_keys` | A repository's [deploy keys](/guides/git/#deploy-keys). | Admin | | |
| 281 | + | | `GET /repos/{owner}/{name}/keys/{id}` | `access` `get_deploy_key` | One deploy key. | Admin | | |
| 282 | + | | `POST /repos/{owner}/{name}/keys` | `access` `add_deploy_key` | Add a deploy key. Body: `title`, `key` and `read_only` (true unless you send false). | Admin | | |
| 283 | + | | `DELETE /repos/{owner}/{name}/keys/{id}` | `access` `remove_deploy_key` | Delete a deploy key. | Admin | | |
| 261 | 284 | ||
| 262 | 285 | Changing who has access, answering an invitation and setting the base | |
| 263 | 286 | permission are for people, signed in or with a personal access token. | |
| ⋯ | |||
| 299 | 322 | ||
| 300 | 323 | The workspace's [audit log](/guides/audit-log/) records the same changes | |
| 301 | 324 | under those names, and also `repo.invitation_created`, | |
| 302 | − | `repo.invitation_revoked` and `workspace.base_permission_changed`. | |
| 325 | + | `repo.invitation_revoked`, `workspace.base_permission_changed`, and | |
| 326 | + | `repo.deploy_key_added` and `repo.deploy_key_removed` for | |
| 327 | + | [deploy keys](#deploy-keys). | |
| 303 | 328 | ||
| 304 | 329 | A team's role on a repository changing is sent as `team.repo_added`, | |
| 305 | 330 | `team.repo_role_changed` or `team.repo_removed`; see | |
| 30 | 30 | | `repo.deleted`, `repo.restored`, `repo.purged` | It was deleted, restored, or removed for good. | | |
| 31 | 31 | | `repo.collaborator_added`, `repo.collaborator_role_changed`, `repo.collaborator_removed` | Someone was given a role on it, had it changed, or lost it. See [access and roles](/guides/access-and-roles/). | | |
| 32 | 32 | | `repo.invitation_created`, `repo.invitation_revoked` | Someone was invited to it, or an invitation was withdrawn. | | |
| 33 | + | | `repo.deploy_key_added`, `repo.deploy_key_removed` | A [deploy key](/guides/git/#deploy-keys) was added to it, saying whether it may write, or deleted. | | |
| 33 | 34 | | `workspace.base_permission_changed` | An owner changed what members get on every repository. | | |
| 34 | 35 | | `workspace.team_creation_changed` | An owner changed who can create teams. See [who can create teams](/guides/teams/#who-can-create-teams). | | |
| 35 | 36 | | `team.created`, `team.edited`, `team.deleted` | A [team](/guides/teams/) was created, changed or deleted. | |
| 26 | 26 | | Profile | [`/settings/profile`](https://g1t.sh/settings/profile) | Your picture, and your [public profile](/guides/workspaces/#profiles): name, pronouns, bio, location and website. | | |
| 27 | 27 | | Emails | [`/settings/emails`](https://g1t.sh/settings/emails) | Your [email addresses](#email-addresses), the backup address, and [keeping your address private](#keeping-your-address-private). | | |
| 28 | 28 | | Invites | [`/settings/invites`](https://g1t.sh/settings/invites) | [Making, copying and revoking invites](#invites). | | |
| 29 | − | | SSH keys | [`/settings/keys`](https://g1t.sh/settings/keys) | Public keys for [git over SSH](/guides/git/). | | |
| 29 | + | | SSH keys | [`/settings/keys`](https://g1t.sh/settings/keys) | Public keys for [git over SSH](/guides/git/#ssh), each with when it was added and last used. | | |
| 30 | 30 | | Access tokens | [`/settings/tokens`](https://g1t.sh/settings/tokens) | Your [personal access tokens](#access-tokens). | | |
| 31 | 31 | | GitHub | [`/settings/github`](https://g1t.sh/settings/github) | [Linking and unlinking GitHub](/guides/github/#link-and-unlink-github). | | |
| 32 | 32 | | Connected applications | [`/settings/applications`](https://g1t.sh/settings/applications) | Tools you [signed in to with OAuth](#signing-in-with-oauth), such as an agent using the MCP server. | | |
| ⋯ | |||
| 596 | 596 | ||
| 597 | 597 | [Settings → Security log](https://g1t.sh/settings/security-log) lists what | |
| 598 | 598 | happened to your account: addresses added, confirmed, removed or made | |
| 599 | − | primary, your backup and privacy settings, password changes, and pauses | |
| 600 | − | after too many wrong passwords. Changes g1t staff made, such as removing an | |
| 599 | + | primary, your backup and privacy settings, password changes, SSH keys | |
| 600 | + | added and removed, and pauses after too many wrong passwords. Changes g1t staff made, such as removing an | |
| 601 | 601 | address someone else needed, say so and why. | |
| 602 | 602 | ||
| 603 | 603 | ## What g1t stores | |
| 1 | 1 | --- | |
| 2 | 2 | title: Git | |
| 3 | − | description: Remotes, credentials, private repositories and limits. | |
| 3 | + | description: Remotes, credentials, private repositories, deploy keys and limits. | |
| 4 | 4 | --- | |
| 5 | 5 | ||
| 6 | 6 | g1t speaks git's smart HTTP protocol. Any git client works. | |
| ⋯ | |||
| 261 | 261 | into Workers is in a beta from Cloudflare that g1t has applied for and is | |
| 262 | 262 | waiting on. SSH keys can already be added under | |
| 263 | 263 | [Settings → SSH keys](https://g1t.sh/settings/keys), | |
| 264 | − | and will be used once SSH is on. | |
| 264 | + | and will be used once SSH is on. So can [deploy keys](#deploy-keys). | |
| 265 | + | ||
| 266 | + | ## Deploy keys | |
| 267 | + | ||
| 268 | + | A deploy key is an SSH key that reaches one repository and nothing else. | |
| 269 | + | Give one to a server or a pipeline that needs to clone a repository, or | |
| 270 | + | push to it, without a person's account behind it. A deploy key belongs to | |
| 271 | + | the repository: it keeps working when the person who added it leaves the | |
| 272 | + | workspace, and it is not tied to anyone's role. | |
| 273 | + | ||
| 274 | + | :::note | |
| 275 | + | Deploy keys are used over SSH, which is [not on yet](#ssh). You can add | |
| 276 | + | them now, and they will work as soon as SSH is. Until then, a machine can | |
| 277 | + | clone and push over HTTPS with a | |
| 278 | + | [workspace access token](/guides/workspaces/#workspace-access-tokens). | |
| 279 | + | ::: | |
| 280 | + | ||
| 281 | + | ### Read-only or read and write | |
| 282 | + | ||
| 283 | + | A deploy key is read-only unless you choose **Allow write access** when | |
| 284 | + | you add it: | |
| 285 | + | ||
| 286 | + | | Access | It can | | |
| 287 | + | | --- | --- | | |
| 288 | + | | **Read-only** (the default) | Clone and fetch the repository, private or not. | | |
| 289 | + | | **Read and write** | Clone, fetch and push, workflow files under `.g1t/workflows/` and `.github/workflows/` included. | | |
| 290 | + | ||
| 291 | + | Either way it reaches only its own repository: any other address is | |
| 292 | + | refused, in its workspace or anywhere else, and so is pushing to an | |
| 293 | + | address with no repository, which would otherwise make one. | |
| 294 | + | ||
| 295 | + | A key with write access can change workflows, and workflows run with the | |
| 296 | + | repository's secrets. Allow it only for a machine that must push. You | |
| 297 | + | cannot change a key's access later: delete it and add it again. | |
| 298 | + | ||
| 299 | + | ### Add a deploy key | |
| 300 | + | ||
| 301 | + | You need the Admin role on the repository and a confirmed email address. | |
| 302 | + | ||
| 303 | + | 1. Make a key pair on the machine that will use it, without a passphrase | |
| 304 | + | if it runs unattended: | |
| 305 | + | ||
| 306 | + | ```sh | |
| 307 | + | ssh-keygen -t ed25519 -C "deploy@build-server" -f ~/.ssh/g1t_deploy -N "" | |
| 308 | + | ``` | |
| 309 | + | ||
| 310 | + | 2. Open the repository's **Settings → Deploy keys**, | |
| 311 | + | `g1t.sh/<workspace>/<repo>/settings/keys`. | |
| 312 | + | 3. Under **Add a deploy key**, give it a **Title**, such as the machine that | |
| 313 | + | uses it, and paste the public key (`~/.ssh/g1t_deploy.pub`) into **Key**. | |
| 314 | + | Left without a title, it takes the key's comment. | |
| 315 | + | 4. Choose **Allow write access** only if the machine must push. | |
| 316 | + | 5. Choose **Add deploy key**. | |
| 317 | + | ||
| 318 | + | g1t takes `ssh-ed25519`, `ecdsa-sha2-nistp256`, `ecdsa-sha2-nistp384`, | |
| 319 | + | `ecdsa-sha2-nistp521` and `ssh-rsa` keys. A public key can be registered | |
| 320 | + | once on g1t: a key that is already someone's SSH key, or a deploy key on | |
| 321 | + | any repository, is refused with **Key is already in use.** Give each | |
| 322 | + | machine, and each repository, its own key. A repository can have up to | |
| 323 | + | 100 deploy keys. | |
| 324 | + | ||
| 325 | + | ### Manage deploy keys | |
| 326 | + | ||
| 327 | + | **Settings → Deploy keys** lists every key on the repository, oldest | |
| 328 | + | first, with its title, fingerprint, whether it is **Read-only** or **Read | |
| 329 | + | and write**, who added it and when, and when it was last used. **Last | |
| 330 | + | used** is when the key last signed in over SSH, to within 5 minutes; | |
| 331 | + | **Never used** means it never has. Use it to find keys nothing uses any | |
| 332 | + | more. | |
| 333 | + | ||
| 334 | + | To remove a key, choose **Delete** beside it and confirm. Anything using | |
| 335 | + | it stops at once. | |
| 336 | + | ||
| 337 | + | Only people with the Admin role on the repository see the page and | |
| 338 | + | manage its keys. An agent's token never can, and neither can a deploy key. | |
| 339 | + | A workspace's own access token can only when an owner gave it Admin. See | |
| 340 | + | [access and roles](/guides/access-and-roles/#deploy-keys). Adding and | |
| 341 | + | deleting a key is recorded in the workspace's | |
| 342 | + | [audit log](/guides/audit-log/) as `repo.deploy_key_added` and | |
| 343 | + | `repo.deploy_key_removed`. | |
| 344 | + | ||
| 345 | + | Deploy keys are kept by repository, so renaming or transferring the | |
| 346 | + | repository keeps them. While a repository is deleted its keys do not work, | |
| 347 | + | and when it is removed for good they go with it. | |
| 348 | + | ||
| 349 | + | ### Use a deploy key with git | |
| 350 | + | ||
| 351 | + | Once SSH is on, point git at the key for the repository's remote: | |
| 352 | + | ||
| 353 | + | ```sh | |
| 354 | + | GIT_SSH_COMMAND="ssh -i ~/.ssh/g1t_deploy -o IdentitiesOnly=yes" \ | |
| 355 | + | git clone git@g1t.sh:<workspace>/<repo>.git | |
| 356 | + | ``` | |
| 357 | + | ||
| 358 | + | Or name it in `~/.ssh/config` for every git command on that machine: | |
| 359 | + | ||
| 360 | + | ```text | |
| 361 | + | Host g1t.sh | |
| 362 | + | User git | |
| 363 | + | IdentityFile ~/.ssh/g1t_deploy | |
| 364 | + | IdentitiesOnly yes | |
| 365 | + | ``` | |
| 366 | + | ||
| 367 | + | A machine that needs several repositories needs a key for each; give each | |
| 368 | + | a `Host` alias with its own `IdentityFile`. | |
| 369 | + | ||
| 370 | + | ### Through the API | |
| 371 | + | ||
| 372 | + | | Route | MCP tool and action | What it does | | |
| 373 | + | | --- | --- | --- | | |
| 374 | + | | `GET /repos/{owner}/{name}/keys` | `access` `list_deploy_keys` | The repository's deploy keys. | | |
| 375 | + | | `GET /repos/{owner}/{name}/keys/{id}` | `access` `get_deploy_key` | One key, by its `id`. | | |
| 376 | + | | `POST /repos/{owner}/{name}/keys` | `access` `add_deploy_key` | Add a key. Body: `title`, `key` and `read_only` (true unless you send false). | | |
| 377 | + | | `DELETE /repos/{owner}/{name}/keys/{id}` | `access` `remove_deploy_key` | Delete a key. | | |
| 378 | + | ||
| 379 | + | Listing and reading need the `access:read` scope; adding and deleting | |
| 380 | + | need `access:admin`. Each needs the Admin role on the repository. | |
| 381 | + | ||
| 382 | + | ```sh | |
| 383 | + | curl https://api.g1t.sh/repos/acme/rocket/keys \ | |
| 384 | + | -H "Authorization: Bearer $G1T_TOKEN" \ | |
| 385 | + | -H "Content-Type: application/json" \ | |
| 386 | + | -d '{"title": "Build server", "key": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGb9ECWmEzf6FQbrBZ9w7lshQhqowtrbLDFw4rXAxZuE", "read_only": true}' | |
| 387 | + | ``` | |
| 388 | + | ||
| 389 | + | ```json | |
| 390 | + | { | |
| 391 | + | "id": "dk_01kp3f2g3h4j5k6m7n8p9q0r1s", | |
| 392 | + | "title": "Build server", | |
| 393 | + | "key": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGb9ECWmEzf6FQbrBZ9w7lshQhqowtrbLDFw4rXAxZuE", | |
| 394 | + | "fingerprint": "SHA256:ubxEl41fJDnUoEPKSZE0y6R0ZjjAQf/wV5vZgeBV8qk", | |
| 395 | + | "read_only": true, | |
| 396 | + | "created_at": "2026-10-08T09:12:00.000Z", | |
| 397 | + | "created_by": "ada", | |
| 398 | + | "last_used_at": null | |
| 399 | + | } | |
| 400 | + | ``` | |
| 401 | + | ||
| 402 | + | See [create a deploy key](/reference/api/access/create-deploy-key/) in the | |
| 403 | + | API reference. | |
| 117 | 117 | href="/guides/access-and-roles/" | |
| 118 | 118 | /> | |
| 119 | 119 | <LinkCard | |
| 120 | + | title="Deploy keys" | |
| 121 | + | description="Let a server or a pipeline clone one repository, or push to it, with an SSH key that reaches nothing else." | |
| 122 | + | href="/guides/git/#deploy-keys" | |
| 123 | + | /> | |
| 124 | + | <LinkCard | |
| 120 | 125 | title="Teams" | |
| 121 | 126 | description="Group members into teams, give a team a role on repositories, mention it, and have it pick who reviews." | |
| 122 | 127 | href="/guides/teams/" |
| 548 | 548 | ||
| 549 | 549 | Who can do what in a repository: its people and their | |
| 550 | 550 | [roles](/guides/access-and-roles/) (read, triage, write, maintain and | |
| 551 | − | admin), invitations, outside collaborators, and a workspace's base | |
| 552 | − | permission. An agent's token cannot use any of these. | |
| 551 | + | admin), invitations, outside collaborators, a workspace's base | |
| 552 | + | permission, and a repository's [deploy keys](/guides/git/#deploy-keys). | |
| 553 | + | An agent's token cannot use any of these. | |
| 553 | 554 | ||
| 554 | 555 | | Action | What it does | Required | Scope | | |
| 555 | 556 | | --- | --- | --- | --- | | |
| ⋯ | |||
| 562 | 563 | | [`revoke_invitation`](/reference/api/access/revoke-repo-invitation/) | Withdraw a pending invitation. Needs the Admin role. | `repo`, `id` | `access:admin` | | |
| 563 | 564 | | [`set_base_permission`](/reference/api/access/set-base-permission/) | What every member gets on each repository: `none`, `read`, `write` (the default) or `admin`. Owners only. | `workspace`, `base_permission` | `access:admin` | | |
| 564 | 565 | | [`list_outside_collaborators`](/reference/api/access/list-outside-collaborators/) | People with roles on its repositories who are not members, and what they can reach. Owners only. | `workspace` | `access:read` | | |
| 566 | + | | [`list_deploy_keys`](/reference/api/access/list-deploy-keys/) | Its deploy keys: SSH keys that reach this one repository, each with its `fingerprint`, `read_only`, who added it and `last_used_at`. Needs the Admin role. | `repo` | `access:read` | | |
| 567 | + | | [`get_deploy_key`](/reference/api/access/get-deploy-key/) | One deploy key. Needs the Admin role. | `repo`, `id` | `access:read` | | |
| 568 | + | | [`add_deploy_key`](/reference/api/access/create-deploy-key/) | Add a deploy key: `key` (an OpenSSH public key), `title`, and `read_only`, true unless you send false. A key registered anywhere already is refused. Needs the Admin role. | `repo`, `key` | `access:admin` | | |
| 569 | + | | [`remove_deploy_key`](/reference/api/access/delete-deploy-key/) | Delete a deploy key; anything using it stops at once. Needs the Admin role. | `repo`, `id` | `access:admin` | | |
| 565 | 570 | ||
| 566 | 571 | ## `team` | |
| 567 | 572 | ||
Binary or large file; its contents are not shown.
| 10 | 10 | guardrails: { title: "Guardrails", about: "What agents may reach, run and spend while they work here." }, | |
| 11 | 11 | repository: { title: "Repository", about: "Its name, details and default branch, who can see it, and archiving, moving or deleting it." }, | |
| 12 | 12 | access: { title: "Access", about: "Who can see and change the repository, with which role, and invitations to it." }, | |
| 13 | + | keys: { title: "Deploy keys", about: "SSH keys that let a machine clone this repository, or push to it, and reach nothing else." }, | |
| 13 | 14 | branches: { title: "Branches and merging", about: "How pull requests merge, what g1t's agents do with theirs, and who owns which files." }, | |
| 14 | 15 | rules: { title: "Rules", about: "Rulesets: what may happen to branches and tags, what a pull request needs before it merges, and how the rules judged each push and merge." }, | |
| 15 | 16 | secrets: { title: "Secrets and variables", about: "Values workflows, builds and deployments read at run time." }, |
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
Binary or large file; its contents are not shown.
This change is too large to show in full.