Skip to main content

kipuka/routes/admin/
certs.rs

1//! Certificate management endpoints for the admin API.
2//!
3//! Provides listing, detail retrieval, and revocation of certificates
4//! issued by the Kipuka EST server.
5
6use 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/// Query parameters for certificate listing.
18#[derive(Deserialize)]
19pub struct ListCertsQuery {
20    /// Filter by CA identifier.
21    pub ca_id: Option<String>,
22
23    /// Filter by certificate status.
24    pub status: Option<String>,
25
26    /// Maximum number of results to return.
27    #[serde(default = "default_limit")]
28    pub limit: u32,
29
30    /// Offset for pagination.
31    #[serde(default)]
32    pub offset: u32,
33}
34
35fn default_limit() -> u32 {
36    50
37}
38
39/// Certificate summary for listing.
40#[derive(Serialize)]
41pub struct CertSummary {
42    /// Certificate serial number (hex-encoded).
43    pub serial: String,
44
45    /// Subject DN of the certificate.
46    pub subject: String,
47
48    /// Which CA issued this certificate.
49    pub ca_id: String,
50
51    /// When the certificate was issued (RFC 3339).
52    pub issued_at: String,
53
54    /// When the certificate expires (RFC 3339).
55    pub expires_at: String,
56
57    /// Certificate status: "valid", "revoked", or "expired".
58    pub status: String,
59}
60
61/// Detailed certificate information.
62#[derive(Serialize)]
63pub struct CertDetail {
64    #[serde(flatten)]
65    pub summary: CertSummary,
66
67    /// Subject Alternative Names.
68    #[serde(skip_serializing_if = "Vec::is_empty")]
69    pub sans: Vec<String>,
70
71    /// Key algorithm (e.g., "EC P-256", "RSA 2048").
72    #[serde(skip_serializing_if = "String::is_empty")]
73    pub key_algorithm: String,
74
75    /// Signature algorithm (e.g., "SHA256withECDSA").
76    #[serde(skip_serializing_if = "String::is_empty")]
77    pub signature_algorithm: String,
78
79    /// How the client authenticated for enrollment.
80    #[serde(skip_serializing_if = "String::is_empty")]
81    pub auth_method: String,
82
83    /// Revocation reason (if revoked), per RFC 5280 §5.3.1.
84    pub revocation_reason: Option<String>,
85
86    /// When the certificate was revoked (RFC 3339), if applicable.
87    pub revoked_at: Option<String>,
88}
89
90/// Request body for certificate revocation.
91#[derive(Deserialize)]
92pub struct RevokeCertRequest {
93    /// Revocation reason code (RFC 5280 §5.3.1).
94    ///
95    /// Common values:
96    /// - 0: unspecified
97    /// - 1: keyCompromise
98    /// - 3: affiliationChanged
99    /// - 4: superseded
100    /// - 5: cessationOfOperation
101    #[serde(default)]
102    pub reason: u32,
103}
104
105/// `GET /admin/certs` — List issued certificates.
106///
107/// Returns a paginated list of certificates issued by this server.
108/// Supports filtering by CA and status.
109pub 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    // Cap the limit to prevent excessive queries.
125    let limit = query.limit.min(1000);
126
127    // Query the certificate database with optional filters.
128    //
129    // We build the SQL dynamically based on which filters are present.
130    // The `certificates` table schema (from db/schema.rs) has columns:
131    //   serial, subject_dn, issuer_dn, not_before, not_after, ca_id,
132    //   status ('active', 'revoked', 'expired'), revocation_reason,
133    //   revocation_time, created_at
134    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
172/// `GET /admin/certs/{serial}` — Certificate details.
173///
174/// Returns detailed information about a specific certificate,
175/// identified by its hex-encoded serial number.
176pub 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    // Query the certificate by serial number from the read-only pool.
186    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, key algorithm, signature algorithm, and auth method
230        // require parsing the DER certificate — omitted until the
231        // DER blob is fetched alongside the metadata.
232        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
247/// `POST /admin/certs/{serial}/revoke` — Revoke a certificate.
248///
249/// Marks the certificate as revoked with the given reason code.
250/// The CRL is regenerated to include the revoked certificate.
251///
252/// # Request
253///
254/// ```json
255/// { "reason": 4 }
256/// ```
257///
258/// # Reason Codes (RFC 5280 §5.3.1)
259///
260/// | Code | Meaning              |
261/// |------|----------------------|
262/// | 0    | unspecified          |
263/// | 1    | keyCompromise        |
264/// | 2    | cACompromise         |
265/// | 3    | affiliationChanged   |
266/// | 4    | superseded           |
267/// | 5    | cessationOfOperation |
268/// | 6    | certificateHold      |
269/// | 9    | privilegeWithdrawn   |
270/// | 10   | aACompromise         |
271pub 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    // FMT_SMR.1: only operators may revoke certificates.
278    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    // Validate reason code per RFC 5280 §5.3.1.
289    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    // Update the certificate status to 'revoked' in the database.
302    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        // Either the certificate doesn't exist or is already revoked.
330        // Check which case it is.
331        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        // Already revoked — return success idempotently.
363    }
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
375// ── Database helpers ────────────────────────────────────────────────────────
376
377/// Query certificates from the database with optional filters.
378///
379/// Uses the read-only pool (`db_ro`) to avoid contention with write
380/// operations.  The query is built dynamically based on which filters
381/// are present, with bind parameters for SQL injection safety.
382async 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    // Build WHERE clause dynamically.  We collect conditions and bind
390    // values, then format the final SQL.  sqlx's `Any` driver uses `?`
391    // placeholders (rewritten for PostgreSQL by `pg_sql` if needed).
392    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    // Bind filter values in the same order as the WHERE conditions.
417    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/// Row type for certificate listing queries.
444#[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/// Row type for certificate detail queries (includes revocation fields).
457#[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}