Skip to main content

kipuka/routes/admin/
mod.rs

1//! Admin API router with separate authentication.
2//!
3//! The admin interface is independent of the EST enrollment endpoints
4//! and uses its own authentication (Bearer token, admin mTLS, or GSSAPI).
5//!
6//! Admin endpoints provide:
7//! - OTP management for EST enrollment
8//! - CA health monitoring and management
9//! - Certificate listing and revocation
10//! - System health checks
11
12pub mod audit;
13pub mod cas;
14pub mod certs;
15pub mod health;
16pub mod otp;
17
18use std::sync::Arc;
19
20use axum::Router;
21use axum::extract::{FromRef, FromRequestParts};
22use axum::http::StatusCode;
23use axum::http::request::Parts;
24use axum::response::{IntoResponse, Response};
25use axum::routing::{delete, get, post};
26
27use subtle::ConstantTimeEq;
28
29use crate::state::AppState;
30
31/// Build the admin API sub-router.
32///
33/// All admin routes require admin authentication, which is separate
34/// from the EST enrollment authentication.
35///
36/// # Route structure
37///
38/// ```text
39/// /admin/
40///     health           GET   — overall system health
41///     health/db        GET   — database connectivity
42///     health/hsm       GET   — HSM connectivity
43///     health/ca        GET   — CA backend health
44///     cas              GET   — list configured CAs
45///     cas/{id}         GET   — CA details
46///     cas/{id}/health  GET   — CA health check
47///     otp/generate     POST  — generate new OTP
48///     otp              GET   — list active OTPs
49///     otp/{id}         DELETE — revoke OTP
50///     certs            GET   — list issued certificates
51///     certs/{serial}   GET   — certificate details
52///     certs/{serial}/revoke POST — revoke certificate
53///     audit            GET   — review audit trail (operator or auditor)
54/// ```
55pub fn admin_router() -> Router<Arc<AppState>> {
56    Router::new()
57        // Health checks
58        .route("/health", get(health::get_health))
59        .route("/health/db", get(health::get_health_db))
60        .route("/health/hsm", get(health::get_health_hsm))
61        .route("/health/ca", get(health::get_health_ca))
62        // CA management
63        .route("/cas", get(cas::list_cas))
64        .route("/cas/{id}", get(cas::get_ca))
65        .route("/cas/{id}/health", get(cas::get_ca_health))
66        // OTP management
67        .route("/otp/generate", post(otp::generate_otp))
68        .route("/otp", get(otp::list_otps))
69        .route("/otp/{id}", delete(otp::revoke_otp))
70        // Certificate management
71        .route("/certs", get(certs::list_certs))
72        .route("/certs/{serial}", get(certs::get_cert))
73        .route("/certs/{serial}/revoke", post(certs::revoke_cert))
74        // Audit trail review (read-only; operators and auditors)
75        .route("/audit", get(audit::list_audit_events))
76}
77
78/// Administrative role governing which management functions are permitted.
79///
80/// NIAP CA PP FMT_SMR.1 / FMT_SMF.1: the TSF must distinguish an operator
81/// (full management authority) from an auditor (read-only observer of the
82/// audit trail and system state).  An `Auditor` may call read endpoints but
83/// is refused any mutating operation (OTP issuance/revocation, certificate
84/// revocation) with HTTP 403.
85#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86pub enum AdminRole {
87    /// Full administrative authority (all read and mutating endpoints).
88    Operator,
89    /// Read-only observer — audit and status endpoints only.
90    Auditor,
91}
92
93/// Authenticated admin context extracted from request headers.
94///
95/// Verifies admin credentials (Bearer token or admin mTLS) before
96/// the handler runs.  On failure, returns 401 or 403.
97///
98/// Carries the caller's [`AdminRole`]; mutating handlers must call
99/// [`AdminAuth::require_operator`] before performing any change.
100#[derive(Debug, Clone)]
101pub struct AdminAuth {
102    /// The authenticated admin identity (username or cert subject).
103    pub identity: String,
104    /// The role granted to this identity.
105    pub role: AdminRole,
106}
107
108impl AdminAuth {
109    /// Enforce that the caller holds the [`AdminRole::Operator`] role.
110    ///
111    /// Returns `Some(403)` for an auditor (or any non-operator) and `None`
112    /// when the caller is an operator.  Call this at the top of every mutating
113    /// admin handler so read-only roles cannot alter state (FMT_SMR.1):
114    ///
115    /// ```ignore
116    /// if let Some(resp) = admin.require_operator() {
117    ///     return resp;
118    /// }
119    /// ```
120    pub fn require_operator(&self) -> Option<Response> {
121        if self.role == AdminRole::Operator {
122            None
123        } else {
124            tracing::warn!(
125                identity = %self.identity,
126                "admin operation denied: operator role required"
127            );
128            Some(
129                (
130                    StatusCode::FORBIDDEN,
131                    "operator role required for this operation",
132                )
133                    .into_response(),
134            )
135        }
136    }
137}
138
139impl<S> FromRequestParts<S> for AdminAuth
140where
141    S: Send + Sync,
142    Arc<AppState>: FromRef<S>,
143{
144    type Rejection = Response;
145
146    async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Response> {
147        let _app = Arc::<AppState>::from_ref(state);
148        let Some(admin_cfg) = _app.config.admin.as_ref().filter(|c| c.enabled) else {
149            return Err(StatusCode::NOT_FOUND.into_response());
150        };
151
152        // Check for Bearer token in the Authorization header.
153        if admin_cfg.auth_method == crate::config::AdminAuthMethod::Bearer
154            && let Some(auth_header) = parts
155                .headers
156                .get(axum::http::header::AUTHORIZATION)
157                .and_then(|v| v.to_str().ok())
158            && let Some(token) = auth_header.strip_prefix("Bearer ")
159            && !token.is_empty()
160        {
161            // Validate against the resolved admin bearer token (Operator).
162            //
163            // Constant-time comparison to prevent timing attacks.  Do not
164            // pre-check lengths — ct_eq safely returns 0 for mismatched
165            // lengths, and a length guard would leak the configured token
166            // length via timing.  We evaluate *both* tokens so a caller
167            // cannot infer from response timing which token slot matched.
168            let token_bytes = token.as_bytes();
169            let mut matched: Option<AdminRole> = None;
170            if let Some(ref operator_token) = _app.secrets.admin_bearer_token
171                && token_bytes.ct_eq(operator_token.as_bytes()).into()
172            {
173                matched = Some(AdminRole::Operator);
174            }
175            if let Some(ref auditor_token) = _app.secrets.auditor_bearer_token
176                && token_bytes.ct_eq(auditor_token.as_bytes()).into()
177            {
178                // Operator precedence: never downgrade an operator match.
179                matched.get_or_insert(AdminRole::Auditor);
180            }
181            if let Some(role) = matched {
182                let identity = match role {
183                    AdminRole::Operator => "admin",
184                    AdminRole::Auditor => "auditor",
185                };
186                return Ok(AdminAuth {
187                    identity: identity.to_string(),
188                    role,
189                });
190            }
191            // Token did not match either slot — fall through to 401.
192        }
193
194        // Check for admin mTLS client certificate.
195        if admin_cfg.auth_method == crate::config::AdminAuthMethod::Mtls
196            && let Some(cert) = parts.extensions.get::<crate::auth::mtls::PeerCertificate>()
197            && !cert.0.is_empty()
198        {
199            // Validate the cert against the admin truststore
200            // (separate from the EST truststore per RHELBU-3536 R18).
201            if let Some(ref admin_cfg) = _app.config.admin {
202                match validate_admin_cert(&cert.0, admin_cfg) {
203                    Ok((identity, role)) => {
204                        crate::auth::mtls::check_revocation(&cert.0, &_app)
205                            .await
206                            .map_err(|e| crate::error::KipukaError::Auth(e).into_response())?;
207                        return Ok(AdminAuth { identity, role });
208                    }
209                    Err(reason) => {
210                        tracing::warn!(
211                            reason = %reason,
212                            "admin mTLS certificate validation failed"
213                        );
214                        return Err((
215                            StatusCode::FORBIDDEN,
216                            format!("admin mTLS validation failed: {reason}"),
217                        )
218                            .into_response());
219                    }
220                }
221            }
222            // No admin config — reject mTLS auth.
223        }
224
225        Err((
226            StatusCode::UNAUTHORIZED,
227            "admin authentication required: Bearer token or mTLS certificate",
228        )
229            .into_response())
230    }
231}
232
233/// Cached admin trust anchors, loaded once and reused across requests.
234///
235/// Avoids blocking I/O on every admin mTLS validation by caching the
236/// parsed DER certificates from the admin CA file.
237static ADMIN_TRUST_ANCHORS: std::sync::OnceLock<Vec<Vec<u8>>> = std::sync::OnceLock::new();
238
239/// Validate an admin mTLS client certificate against the admin truststore.
240///
241/// RHELBU-3536 R18: the admin truststore is separate from the EST enrollment
242/// truststore.  This function:
243///
244/// 1. Loads admin CA trust anchors from the configured PEM file (cached via `OnceLock`).
245/// 2. Verifies the client certificate signature chains to a trust anchor
246///    using `synta_certificate::default_signature_verifier()`.
247/// 3. Validates certificate temporal validity (notBefore/notAfter).
248/// 4. Checks the client subject DN against `allowed_operators` and
249///    `allowed_auditors` patterns using case-insensitive exact matching,
250///    resolving the caller's [`AdminRole`].
251///
252/// Returns the authenticated identity (subject DN) and role on success.
253fn validate_admin_cert(
254    client_cert_der: &[u8],
255    admin_cfg: &crate::config::AdminConfig,
256) -> Result<(String, AdminRole), String> {
257    use std::io::BufReader;
258    use synta_certificate::SignatureVerifier;
259
260    // 1. Parse the client certificate.
261    let client_cert = synta_certificate::Certificate::from_der(client_cert_der)
262        .map_err(|e| format!("failed to parse admin client certificate: {e}"))?;
263    let client_dn = synta_certificate::format_dn(client_cert.tbs_certificate.subject.0);
264
265    if client_dn.is_empty() || client_dn == "<invalid>" {
266        return Err("admin client certificate has no valid subject DN".to_string());
267    }
268
269    // 2. Load admin CA trust anchors (cached after first load).
270    let ca_file = admin_cfg
271        .admin_ca_file
272        .as_deref()
273        .ok_or_else(|| "admin_ca_file not configured for mTLS validation".to_string())?;
274
275    // Load and cache the trust anchors on first success.  A transient read
276    // failure must NOT be cached: `get_or_init` would memoise the empty Vec
277    // permanently and reject every admin mTLS request until process restart.
278    // Instead, return an error for this request and retry on the next one.
279    let trust_certs_der = match ADMIN_TRUST_ANCHORS.get() {
280        Some(cached) => cached,
281        None => {
282            let pem_data = std::fs::read(ca_file)
283                .map_err(|e| format!("failed to read admin CA file '{ca_file}': {e}"))?;
284            let mut reader = BufReader::new(&pem_data[..]);
285            let certs: Vec<Vec<u8>> = rustls_pemfile::certs(&mut reader)
286                .filter_map(|r| r.ok())
287                .map(|c| c.to_vec())
288                .collect();
289            if certs.is_empty() {
290                return Err(format!(
291                    "no CA certificates found in admin CA file '{ca_file}'"
292                ));
293            }
294            // Cache for reuse.  If a concurrent request populated it first, the
295            // set is a no-op and we use whichever value is now stored.
296            let _ = ADMIN_TRUST_ANCHORS.set(certs);
297            ADMIN_TRUST_ANCHORS
298                .get()
299                .expect("admin trust anchors were just set")
300        }
301    };
302
303    // 3. Verify the client certificate signature against trust anchors.
304    //
305    //    Extract the client cert's TBS and signature algorithm via
306    //    `cert_byte_ranges()`, and the signature bits from the parsed
307    //    `Certificate` struct.  Verify against each trust anchor's SPKI
308    //    until one succeeds.
309    let client_ranges = synta_certificate::cert_byte_ranges(client_cert_der)
310        .ok_or_else(|| "failed to extract byte ranges from admin client certificate".to_string())?;
311
312    let tbs_bytes = &client_cert_der[client_ranges.tbs.clone()];
313    let sig_alg_bytes = &client_cert_der[client_ranges.signature_algorithm.clone()];
314    let sig_bytes = client_cert.signature_value.as_bytes();
315
316    let verifier = synta_certificate::default_signature_verifier();
317
318    let mut verified = false;
319    for anchor_der in trust_certs_der {
320        let anchor_ranges = match synta_certificate::cert_byte_ranges(anchor_der) {
321            Some(r) => r,
322            None => continue,
323        };
324        let anchor_spki = &anchor_der[anchor_ranges.subject_public_key_info.clone()];
325
326        if verifier
327            .verify_certificate_signature(tbs_bytes, sig_alg_bytes, sig_bytes, anchor_spki)
328            .is_ok()
329        {
330            verified = true;
331            break;
332        }
333    }
334
335    if !verified {
336        return Err(format!(
337            "admin client certificate (subject: {client_dn}) does not chain \
338             to any trust anchor in '{ca_file}'"
339        ));
340    }
341
342    tracing::info!(
343        subject = %client_dn,
344        "admin mTLS certificate verified against admin truststore"
345    );
346
347    // 4. Validate certificate temporal validity (notBefore / notAfter).
348    {
349        let validity = &client_cert.tbs_certificate.validity;
350        let now = chrono::Utc::now();
351
352        // Convert a synta Time (UtcTime or GeneralizedTime) to chrono DateTime.
353        let time_to_chrono =
354            |t: &synta_certificate::Time| -> Result<chrono::DateTime<chrono::Utc>, String> {
355                let (year, month, day, hour, minute, second) = match t {
356                    synta_certificate::Time::UtcTime(ut) => {
357                        (ut.year, ut.month, ut.day, ut.hour, ut.minute, ut.second)
358                    }
359                    synta_certificate::Time::GeneralTime(gt) => {
360                        (gt.year, gt.month, gt.day, gt.hour, gt.minute, gt.second)
361                    }
362                };
363                let naive = chrono::NaiveDate::from_ymd_opt(year.into(), month.into(), day.into())
364                    .and_then(|d| d.and_hms_opt(hour.into(), minute.into(), second.into()))
365                    .ok_or_else(|| {
366                        format!("invalid date components: {year}-{month:02}-{day:02} {hour:02}:{minute:02}:{second:02}")
367                    })?;
368                Ok(naive.and_utc())
369            };
370
371        let nb = time_to_chrono(&validity.not_before)?;
372        let na = time_to_chrono(&validity.not_after)?;
373
374        if now < nb {
375            return Err(format!(
376                "admin client certificate is not yet valid (notBefore: {nb})"
377            ));
378        }
379        if now > na {
380            return Err(format!(
381                "admin client certificate has expired (notAfter: {na})"
382            ));
383        }
384
385        tracing::debug!(
386            subject = %client_dn,
387            not_before = %nb,
388            not_after = %na,
389            "admin certificate validity check passed"
390        );
391    }
392
393    // 5. Resolve the role from `allowed_operators` / `allowed_auditors`.
394    let role = resolve_admin_role(
395        &client_dn,
396        &admin_cfg.allowed_operators,
397        &admin_cfg.allowed_auditors,
398    )
399    .ok_or_else(|| {
400        format!(
401            "admin client DN '{client_dn}' does not match any allowed operator or auditor pattern"
402        )
403    })?;
404
405    tracing::debug!(
406        subject = %client_dn,
407        ?role,
408        "admin identity matched allow-list"
409    );
410
411    Ok((client_dn, role))
412}
413
414/// Resolve an [`AdminRole`] for a subject DN against the operator/auditor
415/// allow-lists.
416///
417/// Matching is case-insensitive exact.  The rules, in order:
418/// 1. A DN in `operators` is an [`AdminRole::Operator`] (operator precedence —
419///    a DN listed in both lists is still an operator).
420/// 2. Otherwise a DN in `auditors` is an [`AdminRole::Auditor`].
421/// 3. Otherwise, when *both* lists are empty, the DN is an operator — this
422///    preserves the pre-RBAC behavior where any admin-truststore cert had full
423///    authority.
424/// 4. Otherwise `None` — the DN is not authorised for any role.
425fn resolve_admin_role(dn: &str, operators: &[String], auditors: &[String]) -> Option<AdminRole> {
426    let dn_lower = dn.to_lowercase();
427    if operators.iter().any(|p| p.to_lowercase() == dn_lower) {
428        Some(AdminRole::Operator)
429    } else if auditors.iter().any(|p| p.to_lowercase() == dn_lower) {
430        Some(AdminRole::Auditor)
431    } else if operators.is_empty() && auditors.is_empty() {
432        // Backward compat: no allow-lists configured → operator.
433        Some(AdminRole::Operator)
434    } else {
435        None
436    }
437}
438
439#[cfg(test)]
440mod tests {
441    use super::*;
442
443    fn auth(role: AdminRole) -> AdminAuth {
444        AdminAuth {
445            identity: "test".to_string(),
446            role,
447        }
448    }
449
450    #[test]
451    fn require_operator_allows_operator() {
452        assert!(auth(AdminRole::Operator).require_operator().is_none());
453    }
454
455    #[test]
456    fn require_operator_denies_auditor() {
457        // An auditor is refused (Some(403 response) returned).
458        assert!(auth(AdminRole::Auditor).require_operator().is_some());
459    }
460
461    #[test]
462    fn role_operator_precedence_over_auditor() {
463        let ops = vec!["CN=admin".to_string()];
464        let auds = vec!["CN=admin".to_string()];
465        assert_eq!(
466            resolve_admin_role("CN=admin", &ops, &auds),
467            Some(AdminRole::Operator)
468        );
469    }
470
471    #[test]
472    fn role_auditor_match() {
473        let ops = vec!["CN=boss".to_string()];
474        let auds = vec!["CN=watcher".to_string()];
475        assert_eq!(
476            resolve_admin_role("CN=watcher", &ops, &auds),
477            Some(AdminRole::Auditor)
478        );
479    }
480
481    #[test]
482    fn role_match_is_case_insensitive() {
483        let ops = vec!["CN=Admin".to_string()];
484        assert_eq!(
485            resolve_admin_role("cn=admin", &ops, &[]),
486            Some(AdminRole::Operator)
487        );
488    }
489
490    #[test]
491    fn role_backward_compat_empty_lists_is_operator() {
492        assert_eq!(
493            resolve_admin_role("CN=anyone", &[], &[]),
494            Some(AdminRole::Operator)
495        );
496    }
497
498    #[test]
499    fn role_unlisted_dn_is_denied_when_lists_present() {
500        let ops = vec!["CN=admin".to_string()];
501        assert_eq!(resolve_admin_role("CN=stranger", &ops, &[]), None);
502    }
503}