Skip to content

Commit

Deploy keys: SSH keys that reach one repository

A repository's admins add deploy keys under Settings > Deploy keys, or through GET/POST /repos/{owner}/{name}/keys and GET/DELETE /repos/{owner}/{name}/keys/{id} (MCP: access list_deploy_keys, get_deploy_key, add_deploy_key, remove_deploy_key; access:read and access:admin). Keys are read-only unless write access is allowed. - identity migration 0035: deploy_keys, ssh_keys.last_used_at, and triggers so a public key is registered once across both tables - principal_for_ssh_key resolves a fingerprint to the person, or for a deploy key to its repository's workspace acting through a token that reaches that one repository (repo:read, code:read; code:write and workflow_files:write with write access); last use recorded at most every 5 minutes, only once the client proved the key - repos: git_token_refusal keeps a deploy key to its repository, refuses a read-only key's push, and never creates a repository - audit: repo.deploy_key_added/removed; security log: ssh_key_added/removed - SSH keys show Last used; sshd sends the fingerprint and `used` - docs: git (Deploy keys), access and roles, authentication, audit log, MCP

syntaqxcommitted Parentb3f0d2cBrowse files
41 files+471−150/41 viewed
+162−0
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+}
+1−0
1313 mod blobs;
1414 mod checks;
1515 mod deployments;
16+mod deploy_keys;
1617 mod mcp;
1718 mod notifications;
1819 mod oauth;
+7−1
99
1010 use crate::about::AboutOp;
1111 use crate::artifacts::ArtifactsOp;
12+use crate::deploy_keys::DeployKeysOp;
1213 use crate::deployments::DeploymentsOp;
1314 use crate::protection::ProtectionOp;
1415 use crate::operations::Op;
142143 ),
143144 (
144145 "Access",
145− "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.",
146+ "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.",
146147 &[
147148 Op::ListCollaborators,
148149 Op::AddCollaborator,
156157 Op::DeclineRepoInvitation,
157158 Op::SetBasePermission,
158159 Op::ListOutsideCollaborators,
160+ Op::DeployKeys(DeployKeysOp::ListDeployKeys),
161+ Op::DeployKeys(DeployKeysOp::GetDeployKey),
162+ Op::DeployKeys(DeployKeysOp::CreateDeployKey),
163+ Op::DeployKeys(DeployKeysOp::DeleteDeployKey),
159164 ],
160165 ),
161166 (
662667 Op::Deployments(op) => op.title(),
663668 Op::Protection(op) => op.title(),
664669 Op::Artifacts(op) => op.title(),
670+ Op::DeployKeys(op) => op.title(),
665671 }
666672 }
667673
+17−2
2929 use crate::checks::ChecksOp;
3030 use crate::about::AboutOp;
3131 use crate::artifacts::ArtifactsOp;
32+use crate::deploy_keys::DeployKeysOp;
3233 use crate::deployments::DeploymentsOp;
3334 use crate::protection::ProtectionOp;
3435 use crate::rules::RulesOp;
290291 Protection(ProtectionOp),
291292 /// Workflow run artifacts, and how long they are kept: artifacts.rs.
292293 Artifacts(ArtifactsOp),
294+ /// A repository's deploy keys: deploy_keys.rs.
295+ DeployKeys(DeployKeysOp),
293296 }
294297
295298 fn failed(code: FailureCode, message: &str) -> Result<Outcome<Value>> {
652655 }
653656
654657 impl Op {
655− pub const ALL: [Op; 276] = [
658+ pub const ALL: [Op; 280] = [
656659 Op::Whoami,
657660 Op::GetWorkspace,
658661 Op::CreateWorkspace,
917920 Op::Artifacts(ArtifactsOp::DeleteArtifact),
918921 Op::Artifacts(ArtifactsOp::GetArtifactRetention),
919922 Op::Artifacts(ArtifactsOp::SetArtifactRetention),
923+ Op::DeployKeys(DeployKeysOp::ListDeployKeys),
924+ Op::DeployKeys(DeployKeysOp::GetDeployKey),
925+ Op::DeployKeys(DeployKeysOp::CreateDeployKey),
926+ Op::DeployKeys(DeployKeysOp::DeleteDeployKey),
920927 Op::Protection(ProtectionOp::UpdateEnvironment),
921928 Op::Protection(ProtectionOp::DeleteEnvironment),
922929 Op::Protection(ProtectionOp::GetPendingDeployments),
11241131 Op::Deployments(op) => op.name(),
11251132 Op::Protection(op) => op.name(),
11261133 Op::Artifacts(op) => op.name(),
1134+ Op::DeployKeys(op) => op.name(),
11271135 }
11281136 }
11291137
16391647 Op::Deployments(op) => op.description(),
16401648 Op::Protection(op) => op.description(),
16411649 Op::Artifacts(op) => op.description(),
1650+ Op::DeployKeys(op) => op.description(),
16421651 }
16431652 }
16441653
30123021 Op::Deployments(op) => op.input(),
30133022 Op::Protection(op) => op.input(),
30143023 Op::Artifacts(op) => op.input(),
3024+ Op::DeployKeys(op) => op.input(),
30153025 }
30163026 }
30173027
50835093 Op::Deployments(op) => crate::deployments::run(op, services, viewer, input).await,
50845094 Op::Protection(op) => crate::protection::run(op, services, viewer, input).await,
50855095 Op::Artifacts(op) => crate::artifacts::run(op, services, viewer, input).await,
5096+ Op::DeployKeys(op) => crate::deploy_keys::run(op, services, viewer, input).await,
50865097 Op::ReopenSecurityAlert => {
50875098 let changed: Outcome<AlertChange> = call(
50885099 &services.security,
53685379 assert_eq!(integer(&json!({}), "number"), None);
53695380 }
53705381
5371− const ACCESS: [Op; 12] = [
5382+ const ACCESS: [Op; 16] = [
53725383 Op::ListCollaborators,
53735384 Op::AddCollaborator,
53745385 Op::UpdateCollaborator,
53815392 Op::DeclineRepoInvitation,
53825393 Op::SetBasePermission,
53835394 Op::ListOutsideCollaborators,
5395+ Op::DeployKeys(DeployKeysOp::ListDeployKeys),
5396+ Op::DeployKeys(DeployKeysOp::GetDeployKey),
5397+ Op::DeployKeys(DeployKeysOp::CreateDeployKey),
5398+ Op::DeployKeys(DeployKeysOp::DeleteDeployKey),
53845399 ];
53855400
53865401 /// Who has access is for people: no run's scope lists these, and the
+78−0
44194419 ],
44204420 "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/)."
44214421 },
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+ },
44224500 "list_teams": {
44234501 "params": {
44244502 "workspace": "flagon-io"
+7−0
1717 use crate::operations::Op;
1818 use crate::checks::ChecksOp;
1919 use crate::rules::RulesOp;
20+use crate::deploy_keys::DeployKeysOp;
2021
2122 /// A key as `#[serde(rename_all = "camelCase")]` writes it.
2223 fn camel_key(key: &str) -> String {
114115 Op::Deployments(_) => return as_is,
115116 // Artifacts are shaped by the API itself, in `snake_case`.
116117 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),
117124 // Built by the API itself, in `snake_case`.
118125 Op::ListSecurityAlerts => return through::<Vec<crate::alerts::SecurityAlert>>(op, as_is),
119126 Op::DismissSecurityAlert | Op::ReopenSecurityAlert => {
+6−0
44
55 use crate::about::AboutOp;
66 use crate::artifacts::ArtifactsOp;
7+use crate::deploy_keys::DeployKeysOp;
78 use crate::deployments::DeploymentsOp;
89 use crate::protection::ProtectionOp;
910 use crate::operations::Op;
6465 route("GET", "/repos/:owner/:name/invitations", Op::ListRepoInvitations, &[]),
6566 route("DELETE", "/repos/:owner/:name/invitations/:id", Op::RevokeRepoInvitation, &[]),
6667 route("GET", "/user/repository_invitations", Op::ListMyRepoInvitations, &[]),
68+ // Deploy keys, at GitHub's addresses.
69+ route("GET", "/repos/:owner/:name/keys", Op::DeployKeys(DeployKeysOp::ListDeployKeys), &[]),
70+ route("POST", "/repos/:owner/:name/keys", Op::DeployKeys(DeployKeysOp::CreateDeployKey), &[]),
71+ route("GET", "/repos/:owner/:name/keys/:id", Op::DeployKeys(DeployKeysOp::GetDeployKey), &[]),
72+ route("DELETE", "/repos/:owner/:name/keys/:id", Op::DeployKeys(DeployKeysOp::DeleteDeployKey), &[]),
6773 // Your notifications: threads, marking them, and what you subscribe
6874 // to and watch. GitHub's addresses, with g1t's saved and snoozed.
6975 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")]),
+6−1
2020
2121 use crate::about::AboutOp;
2222 use crate::artifacts::ArtifactsOp;
23+use crate::deploy_keys::DeployKeysOp;
2324 use crate::deployments::DeploymentsOp;
2425 use crate::protection::ProtectionOp;
2526 use crate::operations::Op;
294295 Tool {
295296 name: "access",
296297 title: "Who has access",
297− description: "Who has access to a repository and with which role (read, triage, write, maintain, admin), outside collaborators, and a workspace's base permission.",
298+ 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.",
298299 default_action: None,
299300 actions: &[
300301 a("list_collaborators", Op::ListCollaborators, "Everyone with a role, and pending invitations"),
306307 a("revoke_invitation", Op::RevokeRepoInvitation, "Withdraw one"),
307308 a("set_base_permission", Op::SetBasePermission, "What every member gets on each repository"),
308309 a("list_outside_collaborators", Op::ListOutsideCollaborators, "People with roles who are not members"),
310+ a("list_deploy_keys", Op::DeployKeys(DeployKeysOp::ListDeployKeys), "SSH keys that reach this one repository"),
311+ a("get_deploy_key", Op::DeployKeys(DeployKeysOp::GetDeployKey), "One deploy key, by id"),
312+ a("add_deploy_key", Op::DeployKeys(DeployKeysOp::CreateDeployKey), "Add one; read-only unless read_only is false"),
313+ a("remove_deploy_key", Op::DeployKeys(DeployKeysOp::DeleteDeployKey), "Delete one"),
309314 ],
310315 },
311316 Tool {
+29−4
11 ---
22 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.
44 ---
55
66 Everyone who can work in a repository has a role on it. The role says what
3535 | Change the description, topics, and pull request and agent settings | | | | Yes | Yes |
3636 | Change branch protection and guardrails | | | | Yes | Yes |
3737 | 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 |
3939 | Rename, archive, change visibility and the default branch | | | | | Yes |
4040 | Transfer or delete the repository | | | | | Owners only |
4141
238238 Read or Triage who mentions or assigns an agent is told so, and nothing
239239 starts.
240240
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+
241260 ## Through the API
242261
243262 Every route is in the [API reference](/reference/api/). Each is also an
244263 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.
246265
247266 | Route | MCP tool and action | What it does | Who |
248267 | --- | --- | --- | --- |
258277 | `DELETE /user/repository_invitations/{id}` | `account` `decline_repository_invitation` | Decline one. | You |
259278 | `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 |
260279 | `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 |
261284
262285 Changing who has access, answering an invitation and setting the base
263286 permission are for people, signed in or with a personal access token.
299322
300323 The workspace's [audit log](/guides/audit-log/) records the same changes
301324 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).
303328
304329 A team's role on a repository changing is sent as `team.repo_added`,
305330 `team.repo_role_changed` or `team.repo_removed`; see
+1−0
3030 | `repo.deleted`, `repo.restored`, `repo.purged` | It was deleted, restored, or removed for good. |
3131 | `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/). |
3232 | `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. |
3334 | `workspace.base_permission_changed` | An owner changed what members get on every repository. |
3435 | `workspace.team_creation_changed` | An owner changed who can create teams. See [who can create teams](/guides/teams/#who-can-create-teams). |
3536 | `team.created`, `team.edited`, `team.deleted` | A [team](/guides/teams/) was created, changed or deleted. |
+3−3
2626 | Profile | [`/settings/profile`](https://g1t.sh/settings/profile) | Your picture, and your [public profile](/guides/workspaces/#profiles): name, pronouns, bio, location and website. |
2727 | 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). |
2828 | 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. |
3030 | Access tokens | [`/settings/tokens`](https://g1t.sh/settings/tokens) | Your [personal access tokens](#access-tokens). |
3131 | GitHub | [`/settings/github`](https://g1t.sh/settings/github) | [Linking and unlinking GitHub](/guides/github/#link-and-unlink-github). |
3232 | 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. |
596596
597597 [Settings → Security log](https://g1t.sh/settings/security-log) lists what
598598 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
601601 address someone else needed, say so and why.
602602
603603 ## What g1t stores
+141−2
11 ---
22 title: Git
3−description: Remotes, credentials, private repositories and limits.
3+description: Remotes, credentials, private repositories, deploy keys and limits.
44 ---
55
66 g1t speaks git's smart HTTP protocol. Any git client works.
261261 into Workers is in a beta from Cloudflare that g1t has applied for and is
262262 waiting on. SSH keys can already be added under
263263 [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.
+5−0
117117 href="/guides/access-and-roles/"
118118 />
119119 <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
120125 title="Teams"
121126 description="Group members into teams, give a team a role on repositories, mention it, and have it pick who reviews."
122127 href="/guides/teams/"
+7−2
548548
549549 Who can do what in a repository: its people and their
550550 [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.
553554
554555 | Action | What it does | Required | Scope |
555556 | --- | --- | --- | --- |
562563 | [`revoke_invitation`](/reference/api/access/revoke-repo-invitation/) | Withdraw a pending invitation. Needs the Admin role. | `repo`, `id` | `access:admin` |
563564 | [`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` |
564565 | [`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` |
565570
566571 ## `team`
567572
+0−0

Binary or large file; its contents are not shown.

+1−0
1010 guardrails: { title: "Guardrails", about: "What agents may reach, run and spend while they work here." },
1111 repository: { title: "Repository", about: "Its name, details and default branch, who can see it, and archiving, moving or deleting it." },
1212 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." },
1314 branches: { title: "Branches and merging", about: "How pull requests merge, what g1t's agents do with theirs, and who owns which files." },
1415 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." },
1516 secrets: { title: "Secrets and variables", about: "Values workflows, builds and deployments read at run time." },
+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

This change is too large to show in full.