mockforge_registry_server/handlers/
trust_roots.rs1use 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
40const ED25519_PUBLIC_KEY_LEN: usize = 32;
44
45const MAX_NAME_LEN: usize = 128;
47
48const MAX_REVOKE_REASON_LEN: usize = 1_000;
51
52#[derive(Debug, Serialize)]
55#[serde(rename_all = "camelCase")]
56pub struct TrustRootResponse {
57 pub id: Uuid,
58 pub org_id: Uuid,
59 pub public_key_b64: String,
62 pub name: String,
63 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 pub public_key_b64: String,
103 pub name: String,
105}
106
107#[derive(Debug, Default, Deserialize)]
108#[serde(rename_all = "camelCase")]
109pub struct RevokeTrustRootRequest {
110 #[serde(default)]
113 pub reason: Option<String>,
114}
115
116pub 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
139pub 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
185pub 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 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 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 .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
248async 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
266fn 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
282fn 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}