1use std::sync::Arc;
7
8use axum::Json;
9use axum::extract::{Path, Query, State};
10use axum::http::StatusCode;
11use axum::response::{IntoResponse, Response};
12use serde::{Deserialize, Serialize};
13
14use super::AdminAuth;
15use crate::state::AppState;
16
17#[derive(Deserialize)]
19pub struct ListCertsQuery {
20 pub ca_id: Option<String>,
22
23 pub status: Option<String>,
25
26 #[serde(default = "default_limit")]
28 pub limit: u32,
29
30 #[serde(default)]
32 pub offset: u32,
33}
34
35fn default_limit() -> u32 {
36 50
37}
38
39#[derive(Serialize)]
41pub struct CertSummary {
42 pub serial: String,
44
45 pub subject: String,
47
48 pub ca_id: String,
50
51 pub issued_at: String,
53
54 pub expires_at: String,
56
57 pub status: String,
59}
60
61#[derive(Serialize)]
63pub struct CertDetail {
64 #[serde(flatten)]
65 pub summary: CertSummary,
66
67 #[serde(skip_serializing_if = "Vec::is_empty")]
69 pub sans: Vec<String>,
70
71 #[serde(skip_serializing_if = "String::is_empty")]
73 pub key_algorithm: String,
74
75 #[serde(skip_serializing_if = "String::is_empty")]
77 pub signature_algorithm: String,
78
79 #[serde(skip_serializing_if = "String::is_empty")]
81 pub auth_method: String,
82
83 pub revocation_reason: Option<String>,
85
86 pub revoked_at: Option<String>,
88}
89
90#[derive(Deserialize)]
92pub struct RevokeCertRequest {
93 #[serde(default)]
102 pub reason: u32,
103}
104
105pub async fn list_certs(
110 _admin: AdminAuth,
111 Query(query): Query<ListCertsQuery>,
112 State(state): State<Arc<AppState>>,
113) -> Response {
114 let _ = &state;
115
116 tracing::debug!(
117 ca_id = ?query.ca_id,
118 status = ?query.status,
119 limit = query.limit,
120 offset = query.offset,
121 "listing certificates"
122 );
123
124 let limit = query.limit.min(1000);
126
127 let certs = match list_certs_from_db(
135 &state.db_ro,
136 query.ca_id.as_deref(),
137 query.status.as_deref(),
138 limit,
139 query.offset,
140 )
141 .await
142 {
143 Ok(c) => c,
144 Err(e) => {
145 tracing::error!(error = %e, "failed to list certificates from database");
146 return (
147 StatusCode::INTERNAL_SERVER_ERROR,
148 Json(serde_json::json!({
149 "error": "database_error",
150 "detail": "failed to query certificate database"
151 })),
152 )
153 .into_response();
154 }
155 };
156
157 state
158 .record_audit_event(
159 "admin_cert_list",
160 &format!(
161 "count={}, ca_id={:?}, status={:?}",
162 certs.len(),
163 query.ca_id,
164 query.status
165 ),
166 )
167 .await;
168
169 (StatusCode::OK, Json(certs)).into_response()
170}
171
172pub async fn get_cert(
177 _admin: AdminAuth,
178 Path(serial): Path<String>,
179 State(state): State<Arc<AppState>>,
180) -> Response {
181 let _ = &state;
182
183 tracing::debug!(serial = %serial, "retrieving certificate details");
184
185 let row = match sqlx::query_as::<_, CertDetailRow>(crate::db::pg_sql(
187 "SELECT serial, subject_dn, issuer_dn, ca_id, \
188 not_before, not_after, status, \
189 revocation_reason, revocation_time \
190 FROM certificates WHERE serial = ?",
191 ))
192 .bind(&serial)
193 .fetch_optional(&state.db_ro)
194 .await
195 {
196 Ok(Some(row)) => row,
197 Ok(None) => {
198 return (
199 StatusCode::NOT_FOUND,
200 Json(serde_json::json!({
201 "error": "not_found",
202 "detail": format!("certificate with serial {serial} not found")
203 })),
204 )
205 .into_response();
206 }
207 Err(e) => {
208 tracing::error!(error = %e, serial = %serial, "failed to query certificate");
209 return (
210 StatusCode::INTERNAL_SERVER_ERROR,
211 Json(serde_json::json!({
212 "error": "database_error",
213 "detail": "failed to query certificate database"
214 })),
215 )
216 .into_response();
217 }
218 };
219
220 let detail = CertDetail {
221 summary: CertSummary {
222 serial: row.serial,
223 subject: row.subject_dn,
224 ca_id: row.ca_id,
225 issued_at: row.not_before,
226 expires_at: row.not_after,
227 status: row.status,
228 },
229 sans: Vec::new(),
233 key_algorithm: String::new(),
234 signature_algorithm: String::new(),
235 auth_method: String::new(),
236 revocation_reason: row.revocation_reason,
237 revoked_at: row.revocation_time,
238 };
239
240 state
241 .record_audit_event("admin_cert_detail", &format!("serial={serial}"))
242 .await;
243
244 (StatusCode::OK, Json(detail)).into_response()
245}
246
247pub async fn revoke_cert(
272 admin: AdminAuth,
273 Path(serial): Path<String>,
274 State(state): State<Arc<AppState>>,
275 Json(req): Json<RevokeCertRequest>,
276) -> Response {
277 if let Some(resp) = admin.require_operator() {
279 return resp;
280 }
281
282 tracing::info!(
283 serial = %serial,
284 reason = req.reason,
285 "revoking certificate"
286 );
287
288 let valid_reasons = [0, 1, 2, 3, 4, 5, 6, 9, 10];
290 if !valid_reasons.contains(&req.reason) {
291 return (
292 StatusCode::BAD_REQUEST,
293 Json(serde_json::json!({
294 "error": "invalid_reason",
295 "detail": format!("reason code {} is not a valid CRL reason", req.reason)
296 })),
297 )
298 .into_response();
299 }
300
301 let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
303 let result = match sqlx::query(crate::db::pg_sql(
304 "UPDATE certificates SET status = 'revoked', \
305 revocation_reason = ?, revocation_time = ? \
306 WHERE serial = ? AND status != 'revoked'",
307 ))
308 .bind(req.reason.to_string())
309 .bind(&now)
310 .bind(&serial)
311 .execute(&state.db)
312 .await
313 {
314 Ok(r) => r,
315 Err(e) => {
316 tracing::error!(error = %e, serial = %serial, "failed to revoke certificate in DB");
317 return (
318 StatusCode::INTERNAL_SERVER_ERROR,
319 Json(serde_json::json!({
320 "error": "database_error",
321 "detail": "failed to update certificate status"
322 })),
323 )
324 .into_response();
325 }
326 };
327
328 if result.rows_affected() == 0 {
329 let exists = match sqlx::query_scalar::<_, i64>(crate::db::pg_sql(
332 "SELECT COUNT(*) FROM certificates WHERE serial = ?",
333 ))
334 .bind(&serial)
335 .fetch_one(&state.db_ro)
336 .await
337 {
338 Ok(count) => count,
339 Err(e) => {
340 tracing::error!(error = %e, serial = %serial, "failed to check certificate existence");
341 return (
342 StatusCode::INTERNAL_SERVER_ERROR,
343 Json(serde_json::json!({
344 "error": "database_error",
345 "detail": "failed to query certificate database"
346 })),
347 )
348 .into_response();
349 }
350 };
351
352 if exists == 0 {
353 return (
354 StatusCode::NOT_FOUND,
355 Json(serde_json::json!({
356 "error": "not_found",
357 "detail": format!("certificate with serial {serial} not found")
358 })),
359 )
360 .into_response();
361 }
362 }
364
365 state
366 .record_audit_event(
367 "cert_revoked",
368 &format!("serial={serial}, reason={}", req.reason),
369 )
370 .await;
371
372 (StatusCode::NO_CONTENT,).into_response()
373}
374
375async fn list_certs_from_db(
383 db: &sqlx::AnyPool,
384 ca_id: Option<&str>,
385 status: Option<&str>,
386 limit: u32,
387 offset: u32,
388) -> Result<Vec<CertSummary>, String> {
389 let mut conditions = Vec::new();
393 if ca_id.is_some() {
394 conditions.push("ca_id = ?");
395 }
396 if status.is_some() {
397 conditions.push("status = ?");
398 }
399
400 let where_clause = if conditions.is_empty() {
401 String::new()
402 } else {
403 format!("WHERE {}", conditions.join(" AND "))
404 };
405
406 let sql = crate::db::pg_sql_dynamic(format!(
407 "SELECT serial, subject_dn, issuer_dn, ca_id, \
408 not_before AS issued_at, not_after AS expires_at, status \
409 FROM certificates {where_clause} \
410 ORDER BY created_at DESC \
411 LIMIT ? OFFSET ?"
412 ));
413
414 let mut query = sqlx::query_as::<_, CertRow>(sqlx::AssertSqlSafe(sql));
415
416 if let Some(ca) = ca_id {
418 query = query.bind(ca.to_string());
419 }
420 if let Some(st) = status {
421 query = query.bind(st.to_string());
422 }
423 query = query.bind(limit as i64).bind(offset as i64);
424
425 let rows: Vec<CertRow> = query
426 .fetch_all(db)
427 .await
428 .map_err(|e| format!("certificate query failed: {e}"))?;
429
430 Ok(rows
431 .into_iter()
432 .map(|r| CertSummary {
433 serial: r.serial,
434 subject: r.subject_dn,
435 ca_id: r.ca_id,
436 issued_at: r.issued_at,
437 expires_at: r.expires_at,
438 status: r.status,
439 })
440 .collect())
441}
442
443#[derive(sqlx::FromRow)]
445struct CertRow {
446 serial: String,
447 subject_dn: String,
448 #[allow(dead_code)]
449 issuer_dn: String,
450 ca_id: String,
451 issued_at: String,
452 expires_at: String,
453 status: String,
454}
455
456#[derive(sqlx::FromRow)]
458struct CertDetailRow {
459 serial: String,
460 subject_dn: String,
461 #[allow(dead_code)]
462 issuer_dn: String,
463 ca_id: String,
464 not_before: String,
465 not_after: String,
466 status: String,
467 revocation_reason: Option<String>,
468 revocation_time: Option<String>,
469}