//! PKI endpoints for certificate revocation list (CRL) distribution. //! //! This module exposes the CRL endpoint that agents poll every 24 hours to //! check for revoked certificates. The CRL is signed by the internal CA and //! is publicly accessible (CRLs are self-authenticating — they carry the CA //! signature and do not require client authentication). use crate::AppState; use axum::{ extract::State, http::{header, StatusCode}, response::IntoResponse, routing::get, Router, }; /// Define public PKI routes. /// /// These endpoints are **unauthenticated** because CRLs are self-authenticating: /// the agent verifies the CRL signature against its pinned CA certificate. /// No client certificate or API key is required. pub fn router() -> Router { Router::new().route("/pki/crl.pem", get(get_crl)) } /// `GET /api/v1/pki/crl.pem` /// /// Returns the current Certificate Revocation List (CRL) as a PEM-encoded /// X.509 CRL. The CRL is signed by the internal CA and contains the serial /// numbers of all revoked certificates that have not yet expired. /// /// # Cache headers /// /// The response includes `Cache-Control: max-age=3600` (1 hour) to allow /// intermediate caches to serve the CRL. Agents refresh every 24 hours, /// so a 1-hour cache is a reasonable balance between freshness and load. /// /// # CRL generation /// /// The CRL is generated on demand from the `certificates` table. For our /// target scale (max ~2500 clients), this is a fast query and the resulting /// CRL is KB-range. If performance becomes a concern, the CRL can be cached /// in memory and regenerated on a schedule (see background task in main.rs). async fn get_crl(State(state): State) -> impl IntoResponse { match state.ca.generate_crl(&state.db).await { Ok(crl_pem) => ( StatusCode::OK, [( header::CONTENT_TYPE, "application/x-pem-file; charset=utf-8", )], [(header::CACHE_CONTROL, "max-age=3600")], crl_pem, ) .into_response(), Err(e) => { tracing::error!(error = %e, "Failed to generate CRL"); (StatusCode::INTERNAL_SERVER_ERROR, "Failed to generate CRL").into_response() }, } } #[cfg(test)] mod tests { use super::*; use axum::body::Body; use axum::http::{Request, StatusCode}; use axum::Router; use dashmap::DashMap; use pm_auth::rbac::AuthConfig; use pm_core::config::AppConfig; use sqlx::PgPool; use std::sync::Arc; use tokio::sync::Mutex; use tower::ServiceExt; /// Helper: create a test AppState with a real CA and database pool. /// Returns None if TEST_DATABASE_URL is not set (tests are skipped). async fn setup_app_state() -> Option<(PgPool, AppState)> { let db_url = std::env::var("TEST_DATABASE_URL").ok()?; let pool = PgPool::connect(&db_url).await.ok()?; // Run migrations to ensure schema is up to date. sqlx::migrate!("../../migrations").run(&pool).await.ok()?; // Create a temp directory for the CA. let tmp_dir = tempfile::tempdir().ok()?; let ca_dir = tmp_dir.path().to_path_buf(); let ca = pm_ca::CertAuthority::init(&ca_dir, &pool).await.ok()?; let config = Arc::new(AppConfig::default()); use crate::routes::sso::OidcCache; let state = AppState { db: pool.clone(), config, signing_key_pem: String::new(), auth_config: Arc::new(AuthConfig::new(String::new(), &[], &[])), ws_tickets: Arc::new(DashMap::new()), sso_sessions: Arc::new(DashMap::new()), sso_handoffs: Arc::new(DashMap::new()), oidc_cache: Arc::new(Mutex::new(OidcCache::default())), ca: Arc::new(ca), approved_enrollments: Arc::new(DashMap::new()), }; Some((pool, state)) } /// Build an Axum app with just the PKI routes for testing. fn test_app(state: AppState) -> Router { Router::new().nest("/api/v1", router()).with_state(state) } #[tokio::test] async fn crl_endpoint_returns_200_with_valid_pem() { let Some((pool, state)) = setup_app_state().await else { eprintln!("skipping: TEST_DATABASE_URL not set"); return; }; let app = test_app(state); let response = app .oneshot( Request::builder() .uri("/api/v1/pki/crl.pem") .body(Body::empty()) .unwrap(), ) .await .expect("request should succeed"); assert_eq!( response.status(), StatusCode::OK, "CRL endpoint should return 200 OK" ); let body = axum::body::to_bytes(response.into_body(), 10_000) .await .expect("body should be readable"); let body_str = String::from_utf8(body.to_vec()).expect("body should be UTF-8"); assert!( body_str.contains("-----BEGIN X509 CRL-----"), "Response should contain CRL PEM header" ); assert!( body_str.contains("-----END X509 CRL-----"), "Response should contain CRL PEM footer" ); pool.close().await; } #[tokio::test] async fn crl_endpoint_returns_cache_control_header() { let Some((pool, state)) = setup_app_state().await else { eprintln!("skipping: TEST_DATABASE_URL not set"); return; }; let app = test_app(state); let response = app .oneshot( Request::builder() .uri("/api/v1/pki/crl.pem") .body(Body::empty()) .unwrap(), ) .await .expect("request should succeed"); assert_eq!(response.status(), StatusCode::OK); let cache_control = response .headers() .get("cache-control") .expect("Cache-Control header should be present"); assert_eq!( cache_control.to_str().unwrap(), "max-age=3600", "Cache-Control should be max-age=3600" ); pool.close().await; } #[tokio::test] async fn crl_endpoint_works_without_authentication() { let Some((pool, state)) = setup_app_state().await else { eprintln!("skipping: TEST_DATABASE_URL not set"); return; }; let app = test_app(state); // Make request without any auth headers — CRL endpoint is public. let response = app .oneshot( Request::builder() .uri("/api/v1/pki/crl.pem") .body(Body::empty()) .unwrap(), ) .await .expect("request should succeed"); // Should return 200, not 401 Unauthorized. assert_eq!( response.status(), StatusCode::OK, "CRL endpoint should be accessible without authentication" ); pool.close().await; } #[tokio::test] async fn crl_endpoint_returns_pem_content_type() { let Some((pool, state)) = setup_app_state().await else { eprintln!("skipping: TEST_DATABASE_URL not set"); return; }; let app = test_app(state); let response = app .oneshot( Request::builder() .uri("/api/v1/pki/crl.pem") .body(Body::empty()) .unwrap(), ) .await .expect("request should succeed"); assert_eq!(response.status(), StatusCode::OK); let content_type = response .headers() .get("content-type") .expect("Content-Type header should be present"); assert!( content_type .to_str() .unwrap() .contains("application/x-pem-file"), "Content-Type should be application/x-pem-file, got: {:?}", content_type ); pool.close().await; } }