Skip to content
325 linesCodeBlameRaw
1//! Deploy keys: SSH keys that reach one repository, for a server or a
2//! pipeline that needs that repository and nothing else.
3//!
4//! A deploy key is read-only unless whoever adds it allows write access.
5//! It belongs to the repository, not to a person: it keeps working when the
6//! person who added it leaves, and it never reaches another repository.
7//! Identity keeps them beside people's SSH keys; a public key is registered
8//! once across both, so a key always means one thing.
9//!
10//! Over SSH a deploy key resolves (identity's `principal_for_ssh_key`) to
11//! the repository's workspace acting through a token that reaches that one
12//! repository, with `code:read`, and `code:write` and `workflow_files:write`
13//! when it may write. Git's checks then need nothing of their own: the
14//! token's `repo` refuses every other repository (`scopes::decide_repo`),
15//! and its scopes refuse a push from a read-only key (`scopes::decide_git`).
16//!
17//! Methods of the identity service, served at `POST /rpc/<method>`:
18//!
19//! - `list_deploy_keys` takes [`DeployKeysArgs`], returns `Outcome<Vec<DeployKey>>`.
20//! - `get_deploy_key` takes [`DeployKeyArgs`], returns `Outcome<DeployKey>`.
21//! - `add_deploy_key` takes [`AddDeployKeyArgs`], returns `Outcome<DeployKey>`.
22//! - `remove_deploy_key` takes [`RemoveDeployKeyArgs`], returns `Outcome<bool>`.
23//! - `principal_for_ssh_key` takes [`SshKeyArgs`], returns `Viewer`: the
24//! person who registered the key, or what a deploy key resolves to.
25//!
26//! Every one of them answers in `snake_case`, as the API does.
27
28use serde::{Deserialize, Serialize};
29
30use crate::access::{Capability, RepoRef, can};
31use crate::audit::Surface;
32use crate::repos::RepoPath;
33use crate::scopes::{Scope, TokenAccess};
34use crate::{PrincipalKind, User, Viewer};
35
36/// The most deploy keys one repository may have.
37pub const MAX_PER_REPO: usize = 100;
38
39/// The answer when a public key is registered already, as anyone's SSH key
40/// or as a deploy key anywhere.
41pub const KEY_IN_USE: &str = "Key is already in use.";
42
43/// One deploy key, as the API shows it.
44#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
45pub struct DeployKey {
46 /// `dk_…`.
47 pub id: String,
48 pub title: String,
49 /// The public key, `<type> <base64>`, without its comment.
50 pub key: String,
51 /// `SHA256:…`, as `ssh-keygen -lf` prints it.
52 pub fingerprint: String,
53 /// False when it may push.
54 pub read_only: bool,
55 /// RFC 3339.
56 pub created_at: String,
57 /// The username (or workspace slug, for a workspace's token) that
58 /// added it; null once that account is gone.
59 #[serde(default)]
60 pub created_by: Option<String>,
61 /// RFC 3339; null when it was never used. Kept to within 5 minutes.
62 #[serde(default)]
63 pub last_used_at: Option<String>,
64}
65
66/// `list_deploy_keys`.
67#[derive(Debug, Serialize, Deserialize)]
68pub struct DeployKeysArgs {
69 pub viewer: Viewer,
70 pub path: RepoPath,
71}
72
73/// `get_deploy_key`.
74#[derive(Debug, Serialize, Deserialize)]
75pub struct DeployKeyArgs {
76 pub viewer: Viewer,
77 pub path: RepoPath,
78 pub id: String,
79}
80
81/// `add_deploy_key`.
82#[derive(Debug, Serialize, Deserialize)]
83pub struct AddDeployKeyArgs {
84 pub actor: User,
85 pub path: RepoPath,
86 /// Empty takes the key's comment, else "Deploy key".
87 #[serde(default)]
88 pub title: String,
89 /// One line in OpenSSH public key format.
90 pub key: String,
91 /// Read-only unless said otherwise.
92 #[serde(default = "read_only_by_default")]
93 pub read_only: bool,
94 #[serde(default)]
95 pub surface: Option<Surface>,
96}
97
98fn read_only_by_default() -> bool {
99 true
100}
101
102/// `principal_for_ssh_key`: who an SSH client signing in with the key
103/// with this fingerprint (`SHA256:…`) is.
104#[derive(Debug, Default, Serialize, Deserialize)]
105pub struct SshKeyArgs {
106 pub fingerprint: String,
107 /// True once the client has proved it holds the private key: only then
108 /// is the key's last use recorded. False while a client only offers a
109 /// key, which anyone holding the public key can do.
110 #[serde(default)]
111 pub used: bool,
112}
113
114/// `remove_deploy_key`.
115#[derive(Debug, Serialize, Deserialize)]
116pub struct RemoveDeployKeyArgs {
117 pub actor: User,
118 pub path: RepoPath,
119 pub id: String,
120 #[serde(default)]
121 pub surface: Option<Surface>,
122}
123
124/// The scopes a deploy key's token carries.
125pub fn scopes(read_only: bool) -> Vec<String> {
126 let mut scopes = vec![Scope::RepoRead, Scope::CodeRead];
127 if !read_only {
128 // As on GitHub, a key that may push may change workflow files too.
129 scopes.extend([Scope::CodeWrite, Scope::WorkflowFilesWrite]);
130 }
131 scopes.into_iter().map(|scope| scope.as_str().to_owned()).collect()
132}
133
134/// What a deploy key acts with: a token of its repository's workspace that
135/// reaches `repo` (`owner/name`, where it is now) and nothing else.
136pub fn access(id: &str, title: &str, repo: &str, read_only: bool) -> TokenAccess {
137 TokenAccess {
138 token_id: id.to_owned(),
139 name: Some(title.to_owned()),
140 scopes: Some(scopes(read_only)),
141 repo: Some(repo.to_owned()),
142 deploy_key: Some(id.to_owned()),
143 ..TokenAccess::default()
144 }
145}
146
147/// The principal a deploy key resolves to: `workspace` (its id and slug
148/// now) acting through [`access`].
149pub fn principal(workspace_id: &str, workspace_slug: &str, token: TokenAccess) -> User {
150 User {
151 id: workspace_id.to_owned(),
152 username: workspace_slug.to_owned(),
153 kind: PrincipalKind::Workspace,
154 verified: true,
155 workspaces: vec![crate::Membership::member(workspace_slug.to_lowercase())],
156 token: Some(Box::new(token)),
157 ..User::default()
158 }
159}
160
161/// Whether `actor` may add and remove a repository's deploy keys, and see
162/// them: the Admin role on it (`Capability::ManageAccess`). Never a deploy
163/// key, and never an agent, whatever it acts for; a workspace's token only
164/// when an owner gave it Admin. `None` when they may, else why not.
165pub fn refusal(actor: Option<&User>, repo: RepoRef<'_>, full_name: &str) -> Option<String> {
166 let Some(actor) = actor else {
167 return Some("Sign in to manage deploy keys.".to_owned());
168 };
169 if actor.token.as_deref().is_some_and(|token| token.deploy_key.is_some()) {
170 return Some("A deploy key cannot manage deploy keys.".to_owned());
171 }
172 if actor.kind == PrincipalKind::Agent || actor.acting.is_some() {
173 return Some("An agent cannot manage deploy keys: they decide who reaches a repository.".to_owned());
174 }
175 if !can(Some(actor), repo, Capability::ManageAccess) {
176 return Some(crate::access::needs(Capability::ManageAccess, full_name));
177 }
178 None
179}
180
181/// Whether a last-used time `last` (RFC 3339) is old enough at `now`
182/// (`rfc3339` too) to be written again: once every 5 minutes at most, as
183/// access tokens' are, so a busy key does not write on every fetch.
184pub fn note_use_due(last: Option<&str>, stale_before: &str) -> bool {
185 last.is_none_or(|at| at < stale_before)
186}
187
188#[cfg(test)]
189mod tests {
190 use super::*;
191 use crate::access::{BasePermission, RepoGrant, RepoRole};
192 use crate::scopes::{decide_git, decide_repo};
193 use crate::{Membership, Role};
194
195 fn repo() -> RepoRef<'static> {
196 RepoRef { id: "rep_web", namespace: "acme", private: true }
197 }
198
199 fn key(read_only: bool) -> User {
200 principal("wsp_acme", "acme", access("dk_1", "CI", "acme/web", read_only))
201 }
202
203 #[test]
204 fn a_read_only_key_reads_its_repository_and_pushes_nowhere() {
205 let user = key(true);
206 let token = user.token.as_deref().unwrap();
207 assert!(decide_repo(token, "acme/web").is_none());
208 assert!(decide_git(token, false, false).allowed, "clones a private repository");
209 assert!(!decide_git(token, true, false).allowed, "never pushes");
210 assert!(can(Some(&user), repo(), Capability::Read));
211 }
212
213 #[test]
214 fn a_key_with_write_access_pushes_and_may_change_workflow_files() {
215 let user = key(false);
216 let token = user.token.as_deref().unwrap();
217 assert!(decide_git(token, true, false).allowed);
218 assert!(crate::scopes::decide_workflow_files(Some(token), [".g1t/workflows/ci.yml"]).is_none());
219 assert!(can(Some(&user), repo(), Capability::Push));
220 // Write, never more: settings and access stay with people.
221 assert!(!can(Some(&user), repo(), Capability::ManageSettings));
222 let read_only = key(true);
223 assert!(crate::scopes::decide_workflow_files(read_only.token.as_deref(), [".g1t/workflows/ci.yml"]).is_some());
224 }
225
226 #[test]
227 fn a_key_reaches_no_other_repository() {
228 let user = key(false);
229 let token = user.token.as_deref().unwrap();
230 for other in ["acme/api", "other/web", "acme/web-2"] {
231 let refused = decide_repo(token, other).expect(other);
232 assert!(!refused.allowed);
233 assert!(refused.reason.unwrap().contains("deploy key"), "{other}");
234 }
235 // Names compare without case, as paths do.
236 assert!(decide_repo(token, "Acme/Web").is_none());
237 }
238
239 #[test]
240 fn its_scopes_are_code_and_nothing_else() {
241 assert_eq!(scopes(true), ["repo:read", "code:read"]);
242 assert_eq!(scopes(false), ["repo:read", "code:read", "code:write", "workflow_files:write"]);
243 let token = access("dk_1", "CI", "acme/web", true);
244 assert_eq!(token.deploy_key.as_deref(), Some("dk_1"));
245 assert!(!token.admin);
246 assert!(!token.allows(Scope::IssuesWrite));
247 assert!(!token.allows(Scope::AccessAdmin));
248 }
249
250 #[test]
251 fn only_admins_manage_deploy_keys() {
252 let person = |role: Option<RepoRole>, base: Option<BasePermission>| User {
253 id: "usr_ada".into(),
254 username: "ada".into(),
255 verified: true,
256 workspaces: vec![Membership { base_permission: base, ..Membership::member("acme") }],
257 grants: role
258 .map(|role| vec![RepoGrant { repo_id: "rep_web".into(), workspace: "acme".into(), role, team: None }])
259 .unwrap_or_default(),
260 ..User::default()
261 };
262 assert!(refusal(Some(&person(Some(RepoRole::Admin), None)), repo(), "acme/web").is_none());
263 let writer = person(None, Some(BasePermission::Write));
264 assert!(refusal(Some(&writer), repo(), "acme/web").unwrap().contains("Admin"));
265 assert!(refusal(Some(&person(Some(RepoRole::Maintain), None)), repo(), "acme/web").is_some());
266 assert!(refusal(None, repo(), "acme/web").is_some());
267 let owner = User {
268 workspaces: vec![Membership { role: Role::Owner, ..Membership::member("acme") }],
269 ..person(None, None)
270 };
271 assert!(refusal(Some(&owner), repo(), "acme/web").is_none());
272 }
273
274 #[test]
275 fn tokens_agents_and_keys_do_not_manage_deploy_keys() {
276 // A workspace's own token: Write unless an owner gave it Admin.
277 let workspace = |admin: bool| User {
278 token: Some(Box::new(TokenAccess { admin, ..TokenAccess::default() })),
279 ..principal("wsp_acme", "acme", TokenAccess::default())
280 };
281 assert!(refusal(Some(&workspace(false)), repo(), "acme/web").is_some());
282 assert!(refusal(Some(&workspace(true)), repo(), "acme/web").is_none());
283 // A deploy key, even one that may push.
284 assert_eq!(refusal(Some(&key(false)), repo(), "acme/web").unwrap(), "A deploy key cannot manage deploy keys.");
285 // An agent.
286 let agent = User { kind: PrincipalKind::Agent, ..User::system("acme") };
287 assert!(refusal(Some(&agent), repo(), "acme/web").unwrap().contains("agent"));
288 }
289
290 #[test]
291 fn reading_deploy_keys_needs_access_read_and_changing_them_access_admin() {
292 use crate::scopes::scope_for;
293 assert_eq!(scope_for("list_deploy_keys"), Some(Scope::AccessRead));
294 assert_eq!(scope_for("get_deploy_key"), Some(Scope::AccessRead));
295 assert_eq!(scope_for("create_deploy_key"), Some(Scope::AccessAdmin));
296 assert_eq!(scope_for("delete_deploy_key"), Some(Scope::AccessAdmin));
297 for operation in ["list_deploy_keys", "get_deploy_key", "create_deploy_key", "delete_deploy_key"] {
298 assert!(crate::credentials::NEVER.contains(&operation), "no agent run manages {operation}");
299 }
300 }
301
302 #[test]
303 fn last_used_is_written_at_most_every_five_minutes() {
304 let stale_before = "2026-10-08T11:55:00.000Z";
305 assert!(note_use_due(None, stale_before), "never used");
306 assert!(note_use_due(Some("2026-10-08T11:00:00.000Z"), stale_before));
307 assert!(!note_use_due(Some("2026-10-08T11:58:00.000Z"), stale_before));
308 assert!(!note_use_due(Some(stale_before), stale_before));
309 }
310
311 #[test]
312 fn a_deploy_key_travels_in_snake_case() {
313 let shown = serde_json::to_value(DeployKey { read_only: true, ..DeployKey::default() }).unwrap();
314 for field in ["id", "title", "key", "fingerprint", "read_only", "created_at", "created_by", "last_used_at"] {
315 assert!(shown.get(field).is_some(), "{field}");
316 }
317 let args: AddDeployKeyArgs = serde_json::from_value(serde_json::json!({
318 "actor": { "id": "usr_ada", "username": "ada" },
319 "path": { "namespace": "acme", "name": "web" },
320 "key": "ssh-ed25519 AAAA",
321 }))
322 .unwrap();
323 assert!(args.read_only, "read-only unless said otherwise");
324 }
325}