Skip to main content

mockforge_registry_server/handlers/
trust_roots.rs

1//! Per-org Ed25519 trust roots — control plane (Issue #416).
2//!
3//! The schema (`organization_trust_roots`) and audit-log enum variants
4//! shipped in migration `20250101000074`; this is the HTTP surface that
5//! org admins use to register and revoke them. Trust roots authorize
6//! org-private cloud-plugin signatures (RFC §7.1, two-tier trust).
7//!
8//! Routes (org_id is path-scoped, mirroring `public_keys::list_org_public_keys`
9//! rather than the header-scoped pattern used by hosted-mock handlers):
10//!   GET    /api/v1/organizations/{org_id}/trust-roots
11//!   POST   /api/v1/organizations/{org_id}/trust-roots
12//!   POST   /api/v1/organizations/{org_id}/trust-roots/{root_id}/revoke
13//!
14//! Authorization: caller must be a member of the org and hold
15//! `Permission::OrgUpdate`. Missing org returns 404; non-member returns
16//! 403 (the `PermissionChecker` distinguishes the two).
17//!
18//! Trust-roots are not metered — no `feature_usage` emission, only audit
19//! events (`AuditEventType::OrgTrustRoot{Created,Revoked}`).
20
21use axum::{
22    extract::{Path, State},
23    http::HeaderMap,
24    Json,
25};
26use base64::Engine;
27use chrono::{DateTime, Utc};
28use mockforge_registry_core::models::{
29    organization_trust_root::CreateOrganizationTrustRoot, AuditEventType, OrganizationTrustRoot,
30};
31use serde::{Deserialize, Serialize};
32use uuid::Uuid;
33
34use crate::{
35    error::{ApiError, ApiResult},
36    middleware::{permission_check::PermissionChecker, permissions::Permission, AuthUser},
37    AppState,
38};
39
40/// Ed25519 raw public-key length. Hard-coded rather than pulled from
41/// `ed25519_dalek::PUBLIC_KEY_LENGTH` because adding the dep just for a
42/// constant isn't worth the compile-time cost.
43const ED25519_PUBLIC_KEY_LEN: usize = 32;
44
45/// Matches `VARCHAR(128)` on the `name` column.
46const MAX_NAME_LEN: usize = 128;
47
48/// Hard cap on the free-text revoke reason. Keeps the audit-log payload
49/// bounded — the column itself is `TEXT` (unbounded).
50const MAX_REVOKE_REASON_LEN: usize = 1_000;
51
52// ─── Request / response shapes ───────────────────────────────────────
53
54#[derive(Debug, Serialize)]
55#[serde(rename_all = "camelCase")]
56pub struct TrustRootResponse {
57    pub id: Uuid,
58    pub org_id: Uuid,
59    /// Standard-base64 encoding of the raw 32-byte public key. UI
60    /// renders this in a copy-to-clipboard chip; CLI fingerprints it.
61    pub public_key_b64: String,
62    pub name: String,
63    /// Convenience: matches the partial index `idx_org_trust_roots_active`.
64    /// Equivalent to `revoked_at.is_none()`.
65    pub active: bool,
66    pub created_at: DateTime<Utc>,
67    pub created_by: Option<Uuid>,
68    pub revoked_at: Option<DateTime<Utc>>,
69    pub revoked_reason: Option<String>,
70    pub revoked_by: Option<Uuid>,
71}
72
73impl From<OrganizationTrustRoot> for TrustRootResponse {
74    fn from(row: OrganizationTrustRoot) -> Self {
75        Self {
76            active: row.is_active(),
77            id: row.id,
78            org_id: row.org_id,
79            public_key_b64: base64::engine::general_purpose::STANDARD.encode(&row.public_key),
80            name: row.name,
81            created_at: row.created_at,
82            created_by: row.created_by,
83            revoked_at: row.revoked_at,
84            revoked_reason: row.revoked_reason,
85            revoked_by: row.revoked_by,
86        }
87    }
88}
89
90#[derive(Debug, Serialize)]
91#[serde(rename_all = "camelCase")]
92pub struct ListTrustRootsResponse {
93    pub trust_roots: Vec<TrustRootResponse>,
94}
95
96#[derive(Debug, Deserialize)]
97#[serde(rename_all = "camelCase")]
98pub struct CreateTrustRootRequest {
99    /// Raw 32-byte Ed25519 public key, base64-encoded. Accepts both
100    /// standard and URL-safe (no-padding) variants — same lenience
101    /// `public_keys::create_my_public_key` applies.
102    pub public_key_b64: String,
103    /// Human-readable label shown in the org-settings UI.
104    pub name: String,
105}
106
107#[derive(Debug, Default, Deserialize)]
108#[serde(rename_all = "camelCase")]
109pub struct RevokeTrustRootRequest {
110    /// Optional free-text reason persisted on the row and echoed to the
111    /// audit log. Trimmed; empty/whitespace-only collapses to `None`.
112    #[serde(default)]
113    pub reason: Option<String>,
114}
115
116// ─── Routes ──────────────────────────────────────────────────────────
117
118/// `GET /api/v1/organizations/{org_id}/trust-roots`
119///
120/// Returns active and revoked roots, ordered newest-first. Revoked rows
121/// stay in the response so the UI can render a revocation history (the
122/// row's `active` flag distinguishes the two).
123pub async fn list_trust_roots(
124    State(state): State<AppState>,
125    AuthUser(user_id): AuthUser,
126    Path(org_id): Path<Uuid>,
127) -> ApiResult<Json<ListTrustRootsResponse>> {
128    authorize(&state, user_id, org_id).await?;
129
130    let rows = OrganizationTrustRoot::list_by_org(state.db.pool(), org_id)
131        .await
132        .map_err(ApiError::Database)?;
133
134    Ok(Json(ListTrustRootsResponse {
135        trust_roots: rows.into_iter().map(TrustRootResponse::from).collect(),
136    }))
137}
138
139/// `POST /api/v1/organizations/{org_id}/trust-roots`
140pub async fn create_trust_root(
141    State(state): State<AppState>,
142    AuthUser(user_id): AuthUser,
143    Path(org_id): Path<Uuid>,
144    headers: HeaderMap,
145    Json(request): Json<CreateTrustRootRequest>,
146) -> ApiResult<Json<TrustRootResponse>> {
147    authorize(&state, user_id, org_id).await?;
148
149    let name = validate_name(&request.name)?;
150    let public_key = decode_public_key(&request.public_key_b64)?;
151
152    let row = OrganizationTrustRoot::create(
153        state.db.pool(),
154        CreateOrganizationTrustRoot {
155            org_id,
156            public_key: &public_key,
157            name: &name,
158            created_by: Some(user_id),
159        },
160    )
161    .await
162    .map_err(ApiError::Database)?;
163
164    let (ip, ua) = client_metadata(&headers);
165    state
166        .store
167        .record_audit_event(
168            org_id,
169            Some(user_id),
170            AuditEventType::OrgTrustRootCreated,
171            format!("Trust root '{}' registered for org {}", row.name, org_id),
172            Some(serde_json::json!({
173                "trust_root_id": row.id,
174                "name": row.name,
175                "public_key_b64": base64::engine::general_purpose::STANDARD.encode(&row.public_key),
176            })),
177            ip.as_deref(),
178            ua.as_deref(),
179        )
180        .await;
181
182    Ok(Json(TrustRootResponse::from(row)))
183}
184
185/// `POST /api/v1/organizations/{org_id}/trust-roots/{root_id}/revoke`
186pub async fn revoke_trust_root(
187    State(state): State<AppState>,
188    AuthUser(user_id): AuthUser,
189    Path((org_id, root_id)): Path<(Uuid, Uuid)>,
190    headers: HeaderMap,
191    request: Option<Json<RevokeTrustRootRequest>>,
192) -> ApiResult<Json<TrustRootResponse>> {
193    authorize(&state, user_id, org_id).await?;
194
195    // Fetch first so we can disambiguate "not found" from "wrong org" and
196    // "already revoked" — the SQL UPDATE in `OrganizationTrustRoot::revoke`
197    // returns `None` for either of the latter two, which we want to treat
198    // as 409 (already revoked) rather than 404.
199    let existing = OrganizationTrustRoot::find_by_id(state.db.pool(), root_id)
200        .await
201        .map_err(ApiError::Database)?
202        .ok_or_else(|| ApiError::InvalidRequest("Trust root not found".into()))?;
203
204    if existing.org_id != org_id {
205        // Cross-org access surfaces as not-found to avoid leaking
206        // existence (matches `cloud_plugin_attachments::load_authorized_attachment`).
207        return Err(ApiError::InvalidRequest("Trust root not found".into()));
208    }
209    if existing.revoked_at.is_some() {
210        return Err(ApiError::Conflict("Trust root is already revoked".into()));
211    }
212
213    let reason = sanitize_reason(request.and_then(|Json(r)| r.reason).as_deref())?;
214
215    let row = OrganizationTrustRoot::revoke(
216        state.db.pool(),
217        root_id,
218        reason.as_deref(),
219        Some(user_id),
220    )
221    .await
222    .map_err(ApiError::Database)?
223    // Lost a race with another concurrent revoke — same end-state as a
224    // double-revoke, surface as 409 too.
225    .ok_or_else(|| ApiError::Conflict("Trust root is already revoked".into()))?;
226
227    let (ip, ua) = client_metadata(&headers);
228    state
229        .store
230        .record_audit_event(
231            org_id,
232            Some(user_id),
233            AuditEventType::OrgTrustRootRevoked,
234            format!("Trust root '{}' revoked", row.name),
235            Some(serde_json::json!({
236                "trust_root_id": row.id,
237                "name": row.name,
238                "reason": reason,
239            })),
240            ip.as_deref(),
241            ua.as_deref(),
242        )
243        .await;
244
245    Ok(Json(TrustRootResponse::from(row)))
246}
247
248// ─── Helpers ─────────────────────────────────────────────────────────
249
250/// Verify (a) the org exists and (b) the caller has `OrgUpdate` on it.
251/// Existence comes first so a non-member of a *missing* org sees 404
252/// rather than 403 — matches the convention `OrganizationNotFound`
253/// uses elsewhere.
254async fn authorize(state: &AppState, user_id: Uuid, org_id: Uuid) -> ApiResult<()> {
255    let _org = state
256        .store
257        .find_organization_by_id(org_id)
258        .await?
259        .ok_or(ApiError::OrganizationNotFound)?;
260
261    let checker = PermissionChecker::new(state);
262    checker.require_permission(user_id, org_id, Permission::OrgUpdate).await?;
263    Ok(())
264}
265
266/// Trim, reject empty/whitespace-only, cap at `MAX_NAME_LEN` *characters*
267/// (not bytes — same lenience `cloud_plugins::sanitize_use_case` applies).
268fn validate_name(raw: &str) -> ApiResult<String> {
269    let trimmed = raw.trim();
270    if trimmed.is_empty() {
271        return Err(ApiError::InvalidRequest("name must not be empty".into()));
272    }
273    if trimmed.chars().count() > MAX_NAME_LEN {
274        return Err(ApiError::InvalidRequest(format!(
275            "name must be {} characters or fewer",
276            MAX_NAME_LEN
277        )));
278    }
279    Ok(trimmed.to_string())
280}
281
282/// Decode standard or URL-safe (no-padding) base64 and reject anything
283/// that doesn't decode to exactly 32 bytes.
284fn decode_public_key(raw: &str) -> ApiResult<Vec<u8>> {
285    let trimmed = raw.trim();
286    let bytes = base64::engine::general_purpose::STANDARD
287        .decode(trimmed)
288        .or_else(|_| base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(trimmed))
289        .map_err(|e| ApiError::InvalidRequest(format!("public_key_b64 is not base64: {}", e)))?;
290    if bytes.len() != ED25519_PUBLIC_KEY_LEN {
291        return Err(ApiError::ValidationFailed(format!(
292            "ed25519 public key must be {} bytes, got {}",
293            ED25519_PUBLIC_KEY_LEN,
294            bytes.len()
295        )));
296    }
297    Ok(bytes)
298}
299
300fn sanitize_reason(raw: Option<&str>) -> ApiResult<Option<String>> {
301    let Some(text) = raw else {
302        return Ok(None);
303    };
304    let trimmed = text.trim();
305    if trimmed.is_empty() {
306        return Ok(None);
307    }
308    if trimmed.chars().count() > MAX_REVOKE_REASON_LEN {
309        return Err(ApiError::InvalidRequest(format!(
310            "reason must be {} characters or fewer",
311            MAX_REVOKE_REASON_LEN
312        )));
313    }
314    Ok(Some(trimmed.to_string()))
315}
316
317fn client_metadata(headers: &HeaderMap) -> (Option<String>, Option<String>) {
318    let ip = headers
319        .get("x-forwarded-for")
320        .or_else(|| headers.get("x-real-ip"))
321        .and_then(|h| h.to_str().ok())
322        .map(|s| s.split(',').next().unwrap_or(s).trim().to_string());
323    let ua = headers.get("user-agent").and_then(|h| h.to_str().ok()).map(|s| s.to_string());
324    (ip, ua)
325}
326
327#[cfg(test)]
328mod tests {
329    use super::*;
330
331    #[test]
332    fn validate_name_trims_and_rejects_empty() {
333        assert!(matches!(validate_name(""), Err(ApiError::InvalidRequest(_))));
334        assert!(matches!(validate_name("   "), Err(ApiError::InvalidRequest(_))));
335        assert_eq!(validate_name("  CI signing key  ").unwrap(), "CI signing key");
336    }
337
338    #[test]
339    fn validate_name_rejects_too_long() {
340        let too_long: String = "x".repeat(MAX_NAME_LEN + 1);
341        assert!(matches!(validate_name(&too_long), Err(ApiError::InvalidRequest(_))));
342    }
343
344    #[test]
345    fn validate_name_accepts_max_length() {
346        let exact: String = "x".repeat(MAX_NAME_LEN);
347        assert_eq!(validate_name(&exact).unwrap(), exact);
348    }
349
350    #[test]
351    fn validate_name_counts_chars_not_bytes() {
352        let s: String = "🔐".repeat(MAX_NAME_LEN);
353        assert_eq!(validate_name(&s).unwrap(), s);
354    }
355
356    #[test]
357    fn decode_public_key_accepts_standard_b64() {
358        let bytes: Vec<u8> = (0..32u8).collect();
359        let s = base64::engine::general_purpose::STANDARD.encode(&bytes);
360        assert_eq!(decode_public_key(&s).unwrap(), bytes);
361    }
362
363    #[test]
364    fn decode_public_key_accepts_url_safe_b64() {
365        let bytes: Vec<u8> = (0..32u8).collect();
366        let s = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&bytes);
367        assert_eq!(decode_public_key(&s).unwrap(), bytes);
368    }
369
370    #[test]
371    fn decode_public_key_rejects_short() {
372        let bytes = vec![0u8; 31];
373        let s = base64::engine::general_purpose::STANDARD.encode(&bytes);
374        match decode_public_key(&s).unwrap_err() {
375            ApiError::ValidationFailed(msg) => assert!(msg.contains("32 bytes")),
376            other => panic!("expected ValidationFailed, got {:?}", other),
377        }
378    }
379
380    #[test]
381    fn decode_public_key_rejects_long() {
382        let bytes = vec![0u8; 64];
383        let s = base64::engine::general_purpose::STANDARD.encode(&bytes);
384        assert!(matches!(decode_public_key(&s), Err(ApiError::ValidationFailed(_))));
385    }
386
387    #[test]
388    fn decode_public_key_rejects_non_base64() {
389        assert!(matches!(decode_public_key("not-base64-!!!"), Err(ApiError::InvalidRequest(_))));
390    }
391
392    #[test]
393    fn sanitize_reason_trims_and_collapses_empty() {
394        assert_eq!(sanitize_reason(None).unwrap(), None);
395        assert_eq!(sanitize_reason(Some("")).unwrap(), None);
396        assert_eq!(sanitize_reason(Some("   ")).unwrap(), None);
397        assert_eq!(
398            sanitize_reason(Some("  key compromise  ")).unwrap(),
399            Some("key compromise".to_string())
400        );
401    }
402
403    #[test]
404    fn sanitize_reason_rejects_too_long() {
405        let too_long: String = "x".repeat(MAX_REVOKE_REASON_LEN + 1);
406        assert!(matches!(sanitize_reason(Some(&too_long)), Err(ApiError::InvalidRequest(_))));
407    }
408}