Skip to main content

mockforge_registry_server/handlers/
public_keys.rs

1//! Publisher public-key management for SBOM attestation.
2//!
3//! Routes:
4//!   * `GET    /api/v1/users/me/public-keys` — list keys, with `usage_count`
5//!     and (when `?includeRevoked=true`) revoked rows.
6//!   * `POST   /api/v1/users/me/public-keys` — register a new key. Optional
7//!     `orgId` tags the key to an org; the caller must be Owner/Admin.
8//!     Per-plan quota enforced via `effective_limits`.
9//!   * `POST   /api/v1/users/me/public-keys/{id}/rotate` — atomic rotation:
10//!     register a new key with the same org tag and revoke the old one in a
11//!     single transaction; emits a `PublisherKeyRotated` audit event.
12//!   * `DELETE /api/v1/users/me/public-keys/{id}` — soft-revoke a key the
13//!     caller owns (or, if the key is org-scoped, an org Owner/Admin).
14//!   * `GET    /api/v1/organizations/{org_id}/public-keys` — list keys
15//!     tagged to an org; visible to org Owners/Admins.
16
17use 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    /// Number of plugin versions whose SBOM signature was verified by
43    /// this key. Lets the UI show a "signed N versions" pill.
44    pub usage_count: i64,
45    /// Optional org the key is scoped to. `None` = personal key.
46    /// Serializes as `null` (not omitted) so clients can rely on the
47    /// field being present when reading older records back.
48    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    /// When `true`, the response also includes soft-revoked keys so the
76    /// UI can render a revocation history. Defaults to `false`.
77    #[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    /// Currently only `"ed25519"` is accepted. Kept client-supplied so
109    /// the API shape is ready for additional algorithms without a bump.
110    #[serde(default = "default_algorithm")]
111    pub algorithm: String,
112    /// Raw 32-byte Ed25519 public key, base64-encoded. Accepts both
113    /// standard and URL-safe base64 (no padding).
114    pub public_key_b64: String,
115    /// Short human label the user sees in their key list. Required so
116    /// a returning user can distinguish their own keys.
117    pub label: String,
118    /// Optional org the key should be scoped to. The caller must be
119    /// Owner/Admin of that org.
120    #[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    // Length-check the decoded bytes eagerly so we reject garbage at the
150    // HTTP boundary rather than surfacing it at verification time.
151    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    // Org tag: confirm the caller is Owner/Admin of the requested org
164    // before we let them attach a key to it.
165    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    // Audit the create. org_id on the audit row is the audit log's own
177    // scope (always nil for personal-style events) — independent of the
178    // key's optional org tag, which goes in metadata so org admins can
179    // correlate later.
180    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    // First try the user-scoped revoke. If that misses (returns false)
216    // and the key is tagged to an org the caller administers, fall
217    // through to the org-scoped revoke.
218    let revoked = if state.store.revoke_user_public_key(user_id, key_id).await? {
219        true
220    } else {
221        // Look up the key to learn its org tag, then check the
222        // caller's role on that org.
223        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    /// Caller may pin the algorithm; defaults to ed25519 (the only one
264    /// supported today). Kept symmetric with `CreatePublicKeyRequest`.
265    #[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    // Quota: rotation creates one key but also revokes one in the same
306    // transaction, so net active count doesn't change. We still gate
307    // the create on the quota in case the user is *over* quota due to
308    // a downgrade and is rotating to fix that — they're allowed.
309    // No-op for now.
310
311    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
352/// Resolve a Plan limit on `max_publisher_keys` for the user's owner
353/// org and reject if the user is at or over it. Falls back to the Free
354/// default when the user has no owned org (e.g. before personal-org
355/// backfill ran). `-1` means unlimited.
356async 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        // No owner org yet — apply the Free default conservatively so
368        // users without an org still hit a sane cap.
369        3
370    };
371
372    if limit < 0 {
373        return Ok(());
374    }
375
376    // Count active keys via the existing list helper. Keys per user
377    // typically <50, so the cost of running the JOIN here is fine.
378    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
390/// Bail with `PermissionDenied` unless `user_id` is the org's Owner or
391/// has an Admin/Owner role in `org_members`.
392async 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}