mockforge_registry_server/handlers/
public_keys.rs1use axum::{
18 extract::{Path, Query, State},
19 Json,
20};
21use base64::Engine;
22use mockforge_registry_core::models::{AuditEventType, OrgRole};
23use serde::{Deserialize, Serialize};
24use uuid::Uuid;
25
26use crate::{
27 error::{ApiError, ApiResult},
28 handlers::usage::effective_limits,
29 middleware::AuthUser,
30 AppState,
31};
32
33#[derive(Debug, Serialize)]
34#[serde(rename_all = "camelCase")]
35pub struct PublicKeyResponse {
36 pub id: Uuid,
37 pub algorithm: String,
38 pub public_key_b64: String,
39 pub label: String,
40 pub created_at: String,
41 pub revoked_at: Option<String>,
42 pub usage_count: i64,
45 pub org_id: Option<Uuid>,
49}
50
51impl From<mockforge_registry_core::models::UserPublicKeyWithUsage> for PublicKeyResponse {
52 fn from(k: mockforge_registry_core::models::UserPublicKeyWithUsage) -> Self {
53 Self {
54 id: k.key.id,
55 algorithm: k.key.algorithm,
56 public_key_b64: k.key.public_key_b64,
57 label: k.key.label,
58 created_at: k.key.created_at.to_rfc3339(),
59 revoked_at: k.key.revoked_at.map(|dt| dt.to_rfc3339()),
60 usage_count: k.usage_count,
61 org_id: k.key.org_id,
62 }
63 }
64}
65
66#[derive(Debug, Serialize)]
67#[serde(rename_all = "camelCase")]
68pub struct ListPublicKeysResponse {
69 pub keys: Vec<PublicKeyResponse>,
70}
71
72#[derive(Debug, Default, Deserialize)]
73#[serde(rename_all = "camelCase")]
74pub struct ListPublicKeysQuery {
75 #[serde(default)]
78 pub include_revoked: bool,
79}
80
81pub async fn list_my_public_keys(
82 AuthUser(user_id): AuthUser,
83 State(state): State<AppState>,
84 Query(q): Query<ListPublicKeysQuery>,
85) -> ApiResult<Json<ListPublicKeysResponse>> {
86 let keys = state.store.list_user_public_keys_with_usage(user_id, q.include_revoked).await?;
87 Ok(Json(ListPublicKeysResponse {
88 keys: keys.into_iter().map(Into::into).collect(),
89 }))
90}
91
92pub async fn list_org_public_keys(
93 AuthUser(user_id): AuthUser,
94 State(state): State<AppState>,
95 Path(org_id): Path<Uuid>,
96 Query(q): Query<ListPublicKeysQuery>,
97) -> ApiResult<Json<ListPublicKeysResponse>> {
98 require_org_admin(&state, org_id, user_id).await?;
99 let keys = state.store.list_org_public_keys_with_usage(org_id, q.include_revoked).await?;
100 Ok(Json(ListPublicKeysResponse {
101 keys: keys.into_iter().map(Into::into).collect(),
102 }))
103}
104
105#[derive(Debug, Deserialize)]
106#[serde(rename_all = "camelCase")]
107pub struct CreatePublicKeyRequest {
108 #[serde(default = "default_algorithm")]
111 pub algorithm: String,
112 pub public_key_b64: String,
115 pub label: String,
118 #[serde(default)]
121 pub org_id: Option<Uuid>,
122}
123
124fn default_algorithm() -> String {
125 "ed25519".to_string()
126}
127
128pub async fn create_my_public_key(
129 AuthUser(user_id): AuthUser,
130 State(state): State<AppState>,
131 Json(request): Json<CreatePublicKeyRequest>,
132) -> ApiResult<Json<PublicKeyResponse>> {
133 let algorithm = request.algorithm.trim().to_ascii_lowercase();
134 if algorithm != "ed25519" {
135 return Err(ApiError::InvalidRequest(format!(
136 "unsupported key algorithm '{}': only 'ed25519' is accepted",
137 algorithm
138 )));
139 }
140
141 let label = request.label.trim();
142 if label.is_empty() || label.len() > 128 {
143 return Err(ApiError::InvalidRequest(
144 "label must be between 1 and 128 characters".to_string(),
145 ));
146 }
147
148 let key_b64 = request.public_key_b64.trim();
149 let decoded = base64::engine::general_purpose::STANDARD
152 .decode(key_b64)
153 .or_else(|_| base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(key_b64))
154 .map_err(|e| ApiError::InvalidRequest(format!("public_key_b64 is not base64: {}", e)))?;
155 if decoded.len() != ed25519_dalek::PUBLIC_KEY_LENGTH {
156 return Err(ApiError::InvalidRequest(format!(
157 "ed25519 public key must be {} bytes, got {}",
158 ed25519_dalek::PUBLIC_KEY_LENGTH,
159 decoded.len()
160 )));
161 }
162
163 if let Some(org_id) = request.org_id {
166 require_org_admin(&state, org_id, user_id).await?;
167 }
168
169 enforce_publisher_key_quota(&state, user_id).await?;
170
171 let saved = state
172 .store
173 .create_user_public_key(user_id, &algorithm, key_b64, label, request.org_id)
174 .await?;
175
176 state
181 .store
182 .record_audit_event(
183 Uuid::nil(),
184 Some(user_id),
185 AuditEventType::PublisherKeyCreated,
186 format!("Publisher key '{}' created", saved.label),
187 Some(serde_json::json!({
188 "key_id": saved.id,
189 "label": saved.label,
190 "algorithm": saved.algorithm,
191 "key_org_id": saved.org_id,
192 })),
193 None,
194 None,
195 )
196 .await;
197
198 Ok(Json(PublicKeyResponse {
199 id: saved.id,
200 algorithm: saved.algorithm,
201 public_key_b64: saved.public_key_b64,
202 label: saved.label,
203 created_at: saved.created_at.to_rfc3339(),
204 revoked_at: saved.revoked_at.map(|dt| dt.to_rfc3339()),
205 usage_count: 0,
206 org_id: saved.org_id,
207 }))
208}
209
210pub async fn revoke_my_public_key(
211 AuthUser(user_id): AuthUser,
212 State(state): State<AppState>,
213 Path(key_id): Path<Uuid>,
214) -> ApiResult<Json<serde_json::Value>> {
215 let revoked = if state.store.revoke_user_public_key(user_id, key_id).await? {
219 true
220 } else {
221 match state.store.find_user_public_key_by_id(key_id).await? {
224 Some(k) => match k.org_id {
225 Some(org_id) => {
226 require_org_admin(&state, org_id, user_id).await?;
227 state.store.revoke_org_public_key(org_id, key_id).await?
228 }
229 None => false,
230 },
231 None => false,
232 }
233 };
234
235 if !revoked {
236 return Err(ApiError::InvalidRequest(
237 "key does not exist, is already revoked, or you don't have permission to revoke it"
238 .to_string(),
239 ));
240 }
241
242 state
243 .store
244 .record_audit_event(
245 Uuid::nil(),
246 Some(user_id),
247 AuditEventType::PublisherKeyRevoked,
248 format!("Publisher key {} revoked", key_id),
249 Some(serde_json::json!({"key_id": key_id})),
250 None,
251 None,
252 )
253 .await;
254
255 Ok(Json(serde_json::json!({ "revoked": true, "id": key_id })))
256}
257
258#[derive(Debug, Deserialize)]
259#[serde(rename_all = "camelCase")]
260pub struct RotatePublicKeyRequest {
261 pub new_public_key_b64: String,
262 pub new_label: String,
263 #[serde(default = "default_algorithm")]
266 pub algorithm: String,
267}
268
269pub async fn rotate_my_public_key(
270 AuthUser(user_id): AuthUser,
271 State(state): State<AppState>,
272 Path(old_key_id): Path<Uuid>,
273 Json(request): Json<RotatePublicKeyRequest>,
274) -> ApiResult<Json<PublicKeyResponse>> {
275 let algorithm = request.algorithm.trim().to_ascii_lowercase();
276 if algorithm != "ed25519" {
277 return Err(ApiError::InvalidRequest(format!(
278 "unsupported key algorithm '{}': only 'ed25519' is accepted",
279 algorithm
280 )));
281 }
282
283 let new_label = request.new_label.trim();
284 if new_label.is_empty() || new_label.len() > 128 {
285 return Err(ApiError::InvalidRequest(
286 "new_label must be between 1 and 128 characters".to_string(),
287 ));
288 }
289
290 let new_key_b64 = request.new_public_key_b64.trim();
291 let decoded = base64::engine::general_purpose::STANDARD
292 .decode(new_key_b64)
293 .or_else(|_| base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(new_key_b64))
294 .map_err(|e| {
295 ApiError::InvalidRequest(format!("new_public_key_b64 is not base64: {}", e))
296 })?;
297 if decoded.len() != ed25519_dalek::PUBLIC_KEY_LENGTH {
298 return Err(ApiError::InvalidRequest(format!(
299 "ed25519 public key must be {} bytes, got {}",
300 ed25519_dalek::PUBLIC_KEY_LENGTH,
301 decoded.len()
302 )));
303 }
304
305 let new_key = state
312 .store
313 .rotate_user_public_key(user_id, old_key_id, &algorithm, new_key_b64, new_label)
314 .await
315 .map_err(|e| match e {
316 mockforge_registry_core::error::StoreError::NotFound => ApiError::InvalidRequest(
317 "key does not exist, is already revoked, or doesn't belong to you".to_string(),
318 ),
319 other => other.into(),
320 })?;
321
322 state
323 .store
324 .record_audit_event(
325 Uuid::nil(),
326 Some(user_id),
327 AuditEventType::PublisherKeyRotated,
328 format!("Publisher key rotated from {} to {}", old_key_id, new_key.id),
329 Some(serde_json::json!({
330 "old_key_id": old_key_id,
331 "new_key_id": new_key.id,
332 "label": new_key.label,
333 "key_org_id": new_key.org_id,
334 })),
335 None,
336 None,
337 )
338 .await;
339
340 Ok(Json(PublicKeyResponse {
341 id: new_key.id,
342 algorithm: new_key.algorithm,
343 public_key_b64: new_key.public_key_b64,
344 label: new_key.label,
345 created_at: new_key.created_at.to_rfc3339(),
346 revoked_at: new_key.revoked_at.map(|dt| dt.to_rfc3339()),
347 usage_count: 0,
348 org_id: new_key.org_id,
349 }))
350}
351
352async fn enforce_publisher_key_quota(state: &AppState, user_id: Uuid) -> ApiResult<()> {
357 let pool = state.db.pool();
358 let orgs = mockforge_registry_core::models::Organization::find_by_user(pool, user_id)
359 .await
360 .map_err(ApiError::Database)?;
361 let owner_org = orgs.into_iter().find(|o| o.owner_id == user_id);
362
363 let limit = if let Some(org) = owner_org.as_ref() {
364 let limits = effective_limits(state, org).await?;
365 limits.get("max_publisher_keys").and_then(|v| v.as_i64()).unwrap_or(3)
366 } else {
367 3
370 };
371
372 if limit < 0 {
373 return Ok(());
374 }
375
376 let active = state.store.list_user_public_keys(user_id).await?;
379 if active.len() as i64 >= limit {
380 return Err(ApiError::ResourceLimitExceeded(format!(
381 "publisher key limit reached ({}/{}) — revoke an old key or upgrade your plan",
382 active.len(),
383 limit
384 )));
385 }
386
387 Ok(())
388}
389
390async fn require_org_admin(state: &AppState, org_id: Uuid, user_id: Uuid) -> ApiResult<()> {
393 let org = state
394 .store
395 .find_organization_by_id(org_id)
396 .await?
397 .ok_or(ApiError::OrganizationNotFound)?;
398 if org.owner_id == user_id {
399 return Ok(());
400 }
401 let member = state.store.find_org_member(org_id, user_id).await?;
402 let role = member.as_ref().map(|m| m.role());
403 if matches!(role, Some(OrgRole::Owner) | Some(OrgRole::Admin)) {
404 Ok(())
405 } else {
406 Err(ApiError::PermissionDenied)
407 }
408}