zuka
zuka/src/api/keys.rs

worklyn / zukapublic

Agent-first git hosting. One Rust binary: git over HTTP and SSH, a REST API, MCP, CI, and multi-tenant isolation.

Get a copy: git clone https://zuka.worklyn.com/worklyn/zuka.git
zuka/src/api/keys.rs
RSkeys.rs4.7 KBDownload
1// SSH public key resource.
2//
3// A key is a credential with the reach of a `repo:write` token, so it carries the
4// same confinement fields and the same account-admin gate to manage.
5
6use crate::account::key::{self, KeyRecord};
7use crate::account::token::now_secs;
8use crate::account::Identity;
9use crate::error::{Error, Result};
10use crate::git::validate;
11use crate::http::response::Body;
12use crate::http::{request, response, AppState};
13use hyper::body::Incoming;
14use hyper::{Method, Request, Response, StatusCode};
15use serde::{Deserialize, Serialize};
16use std::sync::Arc;
17
18/// A public key line is a few hundred bytes; anything larger is not a key.
19const MAX_KEY_BYTES: u64 = 16 * 1024;
20
21#[derive(Debug, Deserialize)]
22struct AddKey {
23 title: String,
24 key: String,
25 #[serde(default)]
26 read_only: bool,
27 #[serde(default)]
28 repos: Vec<String>,
29}
30
31#[derive(Debug, Serialize)]
32struct KeyView {
33 id: String,
34 title: String,
35 fingerprint: String,
36 read_only: bool,
37 repos: Vec<String>,
38 created_at: u64,
39 #[serde(skip_serializing_if = "Option::is_none")]
40 last_used_at: Option<u64>,
41}
42
43impl From<&KeyRecord> for KeyView {
44 fn from(record: &KeyRecord) -> Self {
45 KeyView {
46 id: record.id.clone(),
47 title: record.title.clone(),
48 fingerprint: record.fingerprint.clone(),
49 read_only: record.read_only,
50 repos: record.repos.clone(),
51 created_at: record.created_at,
52 last_used_at: record.last_used_at,
53 }
54 }
55}
56
57pub async fn route(
58 request: Request<Incoming>,
59 state: Arc<AppState>,
60 identity: Identity,
61 tail: &[&str],
62) -> Result<Response<Body>> {
63 let method = request.method().clone();
64
65 match (&method, tail) {
66 (&Method::GET, []) => list(&state, &identity),
67 (&Method::POST, []) => add(request, &state, &identity).await,
68 (&Method::DELETE, [id]) => remove(&state, &identity, id),
69 (_, []) | (_, [_]) => Err(Error::MethodNotAllowed),
70 _ => Err(Error::NotFound("route")),
71 }
72}
73
74fn list(state: &AppState, identity: &Identity) -> Result<Response<Body>> {
75 identity.require_account_admin(&identity.account)?;
76
77 let keys = state.ssh_keys();
78 let items: Vec<KeyView> = keys
79 .keys
80 .iter()
81 .filter(|k| k.account == identity.account.as_str())
82 .map(KeyView::from)
83 .collect();
84
85 Ok(response::json(
86 StatusCode::OK,
87 &crate::api::repos::page(items, false),
88 ))
89}
90
91async fn add(
92 request: Request<Incoming>,
93 state: &AppState,
94 identity: &Identity,
95) -> Result<Response<Body>> {
96 identity.require_account_admin(&identity.account)?;
97
98 let body: AddKey = request::read_json(request.into_body(), MAX_KEY_BYTES).await?;
99 if body.title.trim().is_empty() || body.title.len() > 128 {
100 return Err(Error::invalid(
101 "invalid-key",
102 "title must be between 1 and 128 characters",
103 ));
104 }
105
106 let (algorithm, blob) = key::parse_authorized_key(&body.key)?;
107 let fingerprint = key::fingerprint(&blob);
108 let public_key = key::encode_public_key(&algorithm, &blob);
109
110 let repos = body
111 .repos
112 .iter()
113 .map(|r| validate::name(r).map(|n| n.as_str().to_string()))
114 .collect::<Result<Vec<_>>>()?;
115
116 let mut keys = state.ssh_keys().clone();
117
118 // A key registered twice would make the account it authenticates depend on
119 // iteration order.
120 if keys.keys.iter().any(|k| k.public_key == public_key) {
121 return Err(Error::conflict(
122 "key-exists",
123 "that public key is already registered",
124 ));
125 }
126
127 let record = KeyRecord {
128 id: uuid::Uuid::new_v4().simple().to_string(),
129 account: identity.account.as_str().to_string(),
130 title: body.title.trim().to_string(),
131 public_key,
132 fingerprint,
133 read_only: body.read_only,
134 repos,
135 created_at: now_secs(),
136 last_used_at: None,
137 };
138 let view = KeyView::from(&record);
139 let location = format!("/v1/keys/{}", record.id);
140
141 keys.keys.push(record);
142 state.put_ssh_keys(keys)?;
143
144 Ok(response::created(&location, &view))
145}
146
147fn remove(state: &AppState, identity: &Identity, id: &str) -> Result<Response<Body>> {
148 identity.require_account_admin(&identity.account)?;
149
150 let mut keys = state.ssh_keys().clone();
151
152 // A key belonging to another account reads as absent, so the endpoint cannot be
153 // used to probe which key ids exist.
154 let found = keys
155 .find_by_id(id)
156 .filter(|k| k.account == identity.account.as_str())
157 .is_some();
158 if !found {
159 return Err(Error::NotFound("key"));
160 }
161
162 keys.keys.retain(|k| k.id != id);
163 state.put_ssh_keys(keys)?;
164 Ok(response::no_content())
165}