Private
Public Access
1
0

feat(M8+M9): CA certificates page + Reporting CSV/PDF with charts

This commit is contained in:
2026-04-23 18:56:11 +00:00
parent a5d52ffab0
commit 7b7fac315e
22 changed files with 3210 additions and 70 deletions

View File

@ -42,6 +42,8 @@ pub struct AppState {
pub auth_config: Arc<AuthConfig>,
/// In-memory store for single-use WebSocket authentication tickets.
pub ws_tickets: Arc<DashMap<String, WsTicket>>,
/// Internal certificate authority for mTLS client cert issuance.
pub ca: Arc<pm_ca::CertAuthority>,
}
#[tokio::main]
@ -77,6 +79,16 @@ async fn main() -> anyhow::Result<()> {
let pool = db::init_pool(&config.database).await?;
db::run_migrations(&pool).await?;
// Initialise the internal CA. Panics in production if CA files are missing
// or corrupt — this is intentional; the service cannot operate without mTLS.
let ca_base = std::path::Path::new("/etc/patch-manager/ca");
let ca = pm_ca::CertAuthority::init(ca_base, &pool)
.await
.unwrap_or_else(|e| {
tracing::warn!(error = %e, "CA init failed (dev mode)");
panic!("CA initialization failed: {}", e);
});
let ws_tickets: Arc<DashMap<String, WsTicket>> = Arc::new(DashMap::new());
// Background task: purge expired WS tickets every 30 seconds.
@ -103,6 +115,7 @@ async fn main() -> anyhow::Result<()> {
signing_key_pem,
auth_config,
ws_tickets,
ca: Arc::new(ca),
};
let app = build_router(state);
@ -128,6 +141,8 @@ pub fn build_router(state: AppState) -> Router {
.merge(routes::auth::protected_router())
// Hosts
.nest("/hosts", routes::hosts::router())
// Host-scoped certificate endpoints (merged separately to avoid conflict)
.nest("/hosts", routes::ca::host_cert_router())
// Groups
.nest("/groups", routes::groups::router())
// Users
@ -140,8 +155,14 @@ pub fn build_router(state: AppState) -> Router {
.nest("/jobs", routes::jobs::router())
// Maintenance windows (nested under hosts path param)
.nest("/hosts/:host_id/maintenance-windows", routes::maintenance_windows::router())
// CA root certificate download
.nest("/ca", routes::ca::ca_router())
// Certificate list / renew / revoke
.nest("/certificates", routes::ca::certs_router())
// WS ticket issuance (JWT-protected — ticket returned to browser, then used for WS upgrade)
.merge(routes::ws::ticket_router())
// Reports
.nest("/reports", routes::reports::router())
// Apply auth middleware to all the above
.route_layer(middleware::from_fn(move |req, next| {
let auth_config = auth_config.clone();

View File

@ -0,0 +1,349 @@
//! CA / certificate management routes.
//!
//! ca_router() → mounted at /api/v1/ca
//! GET /root.crt download_root_ca (any authed role)
//!
//! certs_router() → mounted at /api/v1/certificates
//! GET / list_certificates (any authed role)
//! POST /:cert_id/renew renew_cert (admin only)
//! DELETE /:cert_id revoke_cert (admin only)
//!
//! host_cert_router() → merged under /api/v1/hosts
//! GET /:host_id/client.crt download_client_cert (admin only)
//! POST /:host_id/certificates issue_client_cert (admin only)
use axum::{
body::Body,
extract::{Path, Query, State},
http::{header, Response, StatusCode},
response::Json,
routing::{delete, get, post},
Router,
};
use chrono::{DateTime, Utc};
use pm_auth::rbac::AuthUser;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use uuid::Uuid;
use crate::AppState;
// ── Router constructors ───────────────────────────────────────────────────────
/// Handles routes mounted at /api/v1/ca
pub fn ca_router() -> Router<AppState> {
Router::new()
.route("/root.crt", get(download_root_ca))
}
/// Handles routes mounted at /api/v1/certificates
pub fn certs_router() -> Router<AppState> {
Router::new()
.route("/", get(list_certificates))
.route("/:cert_id/renew", post(renew_cert))
.route("/:cert_id", delete(revoke_cert))
}
/// Handles cert-specific paths merged under /api/v1/hosts.
/// Only adds paths not already claimed by the hosts router.
pub fn host_cert_router() -> Router<AppState> {
Router::new()
.route("/:host_id/client.crt", get(download_client_cert))
.route("/:host_id/certificates", post(issue_client_cert))
}
// ── Shared types ──────────────────────────────────────────────────────────────
/// Row returned from the `certificates` table.
#[derive(Debug, Serialize, sqlx::FromRow)]
struct CertRow {
id: Uuid,
host_id: Option<Uuid>,
serial_number: String,
common_name: String,
/// Cast to TEXT in all queries to avoid custom-enum decode.
status: String,
issued_at: DateTime<Utc>,
expires_at: DateTime<Utc>,
revoked_at: Option<DateTime<Utc>>,
}
/// Query params for `list_certificates`.
#[derive(Debug, Deserialize)]
struct CertListQuery {
host_id: Option<Uuid>,
status: Option<String>,
}
/// Request body for `issue_client_cert`.
#[derive(Debug, Deserialize)]
struct IssueCertRequest {
hostname: String,
}
// ── Helper: build PEM download response ──────────────────────────────────────
fn pem_response(
pem: String,
filename: &str,
) -> Result<Response<Body>, (StatusCode, Json<Value>)> {
let disposition = format!("attachment; filename=\"{filename}\"");
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "application/x-pem-file")
.header(header::CONTENT_DISPOSITION, disposition)
.body(Body::from(pem))
.map_err(|e| {
tracing::error!(error = %e, "Failed to build PEM response");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": { "code": "internal_error", "message": "Response build error" } })),
)
})
}
// ── Helper: admin-only guard ──────────────────────────────────────────────────
fn require_admin(user: &AuthUser) -> Result<(), (StatusCode, Json<Value>)> {
if !user.role.is_admin() {
return Err((
StatusCode::FORBIDDEN,
Json(json!({ "error": { "code": "forbidden", "message": "Admin role required" } })),
));
}
Ok(())
}
// ── Helper: map sqlx error to 500 ─────────────────────────────────────────────
fn db_error(e: sqlx::Error) -> (StatusCode, Json<Value>) {
tracing::error!(error = %e, "Database error");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": { "code": "internal_error", "message": "Database error" } })),
)
}
// ── GET /api/v1/ca/root.crt ───────────────────────────────────────────────────
/// Download the root CA certificate as a PEM file.
async fn download_root_ca(
State(state): State<AppState>,
_auth: AuthUser,
) -> Result<Response<Body>, (StatusCode, Json<Value>)> {
let pem = state.ca.root_cert_pem().to_owned();
pem_response(pem, "ca.crt")
}
// ── GET /api/v1/certificates ──────────────────────────────────────────────────
/// List certificates with optional `?host_id=` and `?status=` filters.
async fn list_certificates(
State(state): State<AppState>,
_auth: AuthUser,
Query(q): Query<CertListQuery>,
) -> Result<Json<Vec<CertRow>>, (StatusCode, Json<Value>)> {
// Use the non-macro query_as form — avoids needing DATABASE_URL at compile
// time. status is cast to TEXT so sqlx decodes it into String directly.
let rows: Vec<CertRow> = match (q.host_id, q.status.as_deref()) {
(Some(hid), Some(st)) => {
sqlx::query_as::<_, CertRow>(
r#"SELECT id, host_id, serial_number, common_name,
status::text AS status,
issued_at, expires_at, revoked_at
FROM certificates
WHERE host_id = $1 AND status::text = $2
ORDER BY issued_at DESC"#,
)
.bind(hid)
.bind(st)
.fetch_all(&state.db)
.await
}
(Some(hid), None) => {
sqlx::query_as::<_, CertRow>(
r#"SELECT id, host_id, serial_number, common_name,
status::text AS status,
issued_at, expires_at, revoked_at
FROM certificates
WHERE host_id = $1
ORDER BY issued_at DESC"#,
)
.bind(hid)
.fetch_all(&state.db)
.await
}
(None, Some(st)) => {
sqlx::query_as::<_, CertRow>(
r#"SELECT id, host_id, serial_number, common_name,
status::text AS status,
issued_at, expires_at, revoked_at
FROM certificates
WHERE status::text = $1
ORDER BY issued_at DESC"#,
)
.bind(st)
.fetch_all(&state.db)
.await
}
(None, None) => {
sqlx::query_as::<_, CertRow>(
r#"SELECT id, host_id, serial_number, common_name,
status::text AS status,
issued_at, expires_at, revoked_at
FROM certificates
ORDER BY issued_at DESC"#,
)
.fetch_all(&state.db)
.await
}
}
.map_err(db_error)?;
Ok(Json(rows))
}
// ── GET /api/v1/hosts/:host_id/client.crt ────────────────────────────────────
/// Download the most recent active client certificate PEM for a host.
async fn download_client_cert(
State(state): State<AppState>,
auth: AuthUser,
Path(host_id): Path<Uuid>,
) -> Result<Response<Body>, (StatusCode, Json<Value>)> {
require_admin(&auth)?;
let cert_pem: Option<String> = sqlx::query_scalar(
r#"SELECT cert_pem
FROM certificates
WHERE host_id = $1
AND status = 'active'::cert_status
ORDER BY issued_at DESC
LIMIT 1"#,
)
.bind(host_id)
.fetch_optional(&state.db)
.await
.map_err(|e| {
tracing::error!(error = %e, %host_id, "Failed to fetch client cert");
db_error(e)
})?;
match cert_pem {
Some(pem) => pem_response(pem, "client.crt"),
None => Err((
StatusCode::NOT_FOUND,
Json(json!({
"error": {
"code": "not_found",
"message": "No active certificate found for this host"
}
})),
)),
}
}
// ── POST /api/v1/hosts/:host_id/certificates ─────────────────────────────────
/// Issue a new mTLS client certificate for a host.
/// **The private key is returned only once — the caller must save it.**
async fn issue_client_cert(
State(state): State<AppState>,
auth: AuthUser,
Path(host_id): Path<Uuid>,
Json(req): Json<IssueCertRequest>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
require_admin(&auth)?;
let issued = state
.ca
.issue_client_cert(host_id, &req.hostname, &state.db)
.await
.map_err(|e| {
tracing::error!(error = %e, %host_id, hostname = %req.hostname,
"Failed to issue client cert");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": { "code": "internal_error", "message": e.to_string() } })),
)
})?;
Ok(Json(json!({
"cert_pem": issued.cert_pem,
"key_pem": issued.key_pem,
"serial_number": issued.serial_number,
"expires_at": issued.expires_at,
})))
}
// ── POST /api/v1/certificates/:cert_id/renew ─────────────────────────────────
/// Revoke the specified certificate and issue a replacement with the same CN.
async fn renew_cert(
State(state): State<AppState>,
auth: AuthUser,
Path(cert_id): Path<Uuid>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
require_admin(&auth)?;
let issued = state
.ca
.renew_cert(cert_id, &state.db)
.await
.map_err(|e| {
let msg = e.to_string();
tracing::error!(error = %e, %cert_id, "Failed to renew cert");
if msg.contains("not found") {
(
StatusCode::NOT_FOUND,
Json(json!({ "error": { "code": "not_found", "message": "Certificate not found" } })),
)
} else {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": { "code": "internal_error", "message": msg } })),
)
}
})?;
Ok(Json(json!({
"cert_pem": issued.cert_pem,
"key_pem": issued.key_pem,
"serial_number": issued.serial_number,
"expires_at": issued.expires_at,
})))
}
// ── DELETE /api/v1/certificates/:cert_id ─────────────────────────────────────
/// Revoke a certificate by ID. Sets status to 'revoked' in the database.
async fn revoke_cert(
State(state): State<AppState>,
auth: AuthUser,
Path(cert_id): Path<Uuid>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
require_admin(&auth)?;
state
.ca
.revoke_cert(cert_id, &state.db)
.await
.map_err(|e| {
let msg = e.to_string();
tracing::error!(error = %e, %cert_id, "Failed to revoke cert");
if msg.contains("not found") {
(
StatusCode::NOT_FOUND,
Json(json!({ "error": { "code": "not_found", "message": "Certificate not found" } })),
)
} else {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": { "code": "internal_error", "message": msg } })),
)
}
})?;
tracing::info!(%cert_id, "Certificate revoked via API");
Ok(Json(json!({ "revoked": true })))
}

View File

@ -1,5 +1,6 @@
//! Route modules for the pm-web API.
pub mod auth;
pub mod ca;
pub mod discovery;
pub mod groups;
pub mod hosts;
@ -8,3 +9,5 @@ pub mod jobs;
pub mod status;
pub mod users;
pub mod ws;
pub mod reports;

View File

@ -0,0 +1,153 @@
//! Report generation endpoints.
//!
//! GET /api/v1/reports/compliance?format=csv|pdf&from=...&to=...&group_id=...
//! GET /api/v1/reports/patch-history?format=csv|pdf&from=...&to=...
//! GET /api/v1/reports/vulnerability?format=csv|pdf&from=...&to=...
//! GET /api/v1/reports/audit?format=csv|pdf&from=...&to=...
use axum::{
body::Bytes,
extract::{Query, State},
http::{header, HeaderMap, HeaderValue, StatusCode},
response::{IntoResponse, Response},
routing::get,
Router,
};
use pm_reports::{ReportParams, ReportType};
use crate::AppState;
#[derive(serde::Deserialize)]
struct ReportQuery {
/// "csv" or "pdf" (defaults to "csv")
format: Option<String>,
from: Option<chrono::DateTime<chrono::Utc>>,
to: Option<chrono::DateTime<chrono::Utc>>,
group_id: Option<uuid::Uuid>,
}
pub fn router() -> Router<AppState> {
Router::new()
.route("/compliance", get(compliance_report))
.route("/patch-history", get(patch_history_report))
.route("/vulnerability", get(vulnerability_report))
.route("/audit", get(audit_report))
}
// ---------------------------------------------------------------------------
// Internal helper
// ---------------------------------------------------------------------------
async fn run_report(
db: sqlx::PgPool,
params: ReportParams,
use_pdf: bool,
csv_name: &'static str,
pdf_name: &'static str,
) -> Response {
let (ct, disposition, result) = if use_pdf {
let disp = format!("attachment; filename=\"{}\"", pdf_name);
let data = pm_reports::generate_pdf(&db, &params).await;
("application/pdf", disp, data)
} else {
let disp = format!("attachment; filename=\"{}\"", csv_name);
let data = pm_reports::generate_csv(&db, &params).await;
("text/csv; charset=utf-8", disp, data)
};
match result {
Ok(bytes) => {
let mut headers = HeaderMap::new();
headers.insert(
header::CONTENT_TYPE,
HeaderValue::from_static(ct),
);
headers.insert(
header::CONTENT_DISPOSITION,
HeaderValue::from_str(&disposition)
.unwrap_or_else(|_| HeaderValue::from_static("attachment")),
);
(headers, Bytes::from(bytes)).into_response()
}
Err(e) => {
tracing::error!(error = %e, "report generation failed");
(StatusCode::INTERNAL_SERVER_ERROR, format!("Report error: {}", e)).into_response()
}
}
}
// ---------------------------------------------------------------------------
// Handlers
// ---------------------------------------------------------------------------
async fn compliance_report(
State(state): State<AppState>,
Query(q): Query<ReportQuery>,
) -> Response {
let params = ReportParams {
report_type: ReportType::Compliance,
from: q.from,
to: q.to,
group_id: q.group_id,
};
let use_pdf = matches!(q.format.as_deref(), Some("pdf"));
run_report(
state.db, params, use_pdf,
"compliance-report.csv",
"compliance-report.pdf",
).await
}
async fn patch_history_report(
State(state): State<AppState>,
Query(q): Query<ReportQuery>,
) -> Response {
let params = ReportParams {
report_type: ReportType::PatchHistory,
from: q.from,
to: q.to,
group_id: q.group_id,
};
let use_pdf = matches!(q.format.as_deref(), Some("pdf"));
run_report(
state.db, params, use_pdf,
"patch-history-report.csv",
"patch-history-report.pdf",
).await
}
async fn vulnerability_report(
State(state): State<AppState>,
Query(q): Query<ReportQuery>,
) -> Response {
let params = ReportParams {
report_type: ReportType::Vulnerability,
from: q.from,
to: q.to,
group_id: q.group_id,
};
let use_pdf = matches!(q.format.as_deref(), Some("pdf"));
run_report(
state.db, params, use_pdf,
"vulnerability-report.csv",
"vulnerability-report.pdf",
).await
}
async fn audit_report(
State(state): State<AppState>,
Query(q): Query<ReportQuery>,
) -> Response {
let params = ReportParams {
report_type: ReportType::Audit,
from: q.from,
to: q.to,
group_id: q.group_id,
};
let use_pdf = matches!(q.format.as_deref(), Some("pdf"));
run_report(
state.db, params, use_pdf,
"audit-report.csv",
"audit-report.pdf",
).await
}