Private
Public Access
1
0
Files
linux_patch_manager/crates/pm-web/src/routes/ca.rs
Echo 6c72dc3ac6
Some checks failed
CI Pipeline / Rust Format Check (push) Failing after 2s
CI Pipeline / Clippy Lints (push) Failing after 1s
CI Pipeline / Rust Unit Tests (push) Failing after 2s
CI Pipeline / Security Audit (push) Failing after 2s
CI Pipeline / Frontend Lint & Type Check (push) Failing after 3s
CI Pipeline / Build .deb & Release (push) Has been skipped
feat: populate os_family, os_name, arch, agent_version from health poller and enrollment
- health_poller: persist agent_version from HealthData.version
- health_poller: call /system/info to update os_family, os_name, arch
- enrollment: set os_family and arch from os_details during approval
- enrollment: build os_name from os+os_version when name field absent
- COALESCE in UPDATE preserves existing values when new data unavailable
- version bump 0.1.7 -> 0.1.8
2026-05-21 00:09:57 +00:00

517 lines
18 KiB
Rust
Executable File

//! 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)
//! POST /:host_id/certificates/reissue reissue_host_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 pm_core::audit::{log_event, AuditAction};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use sqlx::Row;
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))
.route("/{host_id}/certificates/reissue", post(reissue_host_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_write_access(user: &AuthUser) -> Result<(), (StatusCode, Json<Value>)> {
if !user.role.can_write() {
return Err((
StatusCode::FORBIDDEN,
Json(json!({ "error": { "code": "forbidden", "message": "Write access 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" } })),
)
}
// ── Helper: build the full IssuedCert JSON response ──────────────────────────
fn issued_cert_json(issued: &pm_ca::IssuedCert) -> Value {
json!({
"cert_pem": issued.cert_pem,
"key_pem": issued.key_pem,
"serial_number": issued.serial_number,
"expires_at": issued.expires_at,
"server_cert_pem": issued.server_cert_pem,
"server_key_pem": issued.server_key_pem,
"server_serial_number": issued.server_serial_number,
"ca_root_pem": issued.ca_root_pem,
})
}
// ── 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();
log_event(
&state.db,
AuditAction::CertificateDownloaded,
Some(auth.user_id),
Some(&auth.username),
Some("certificate"),
Some("root_ca"),
json!({ "operation": "download_root_ca" }),
None,
None,
)
.await;
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_write_access(&auth)?;
let cert_pem: Option<String> = sqlx::query_scalar(
r#"SELECT cert_pem
FROM certificates
WHERE host_id = $1
AND status = 'active'::cert_status
AND common_name NOT LIKE '%-server'
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) => {
log_event(
&state.db,
AuditAction::CertificateDownloaded,
Some(auth.user_id),
Some(&auth.username),
Some("certificate"),
Some(&host_id.to_string()),
json!({ "operation": "download_client_cert" }),
None,
None,
)
.await;
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 (and server certificate) for a host.
/// **The private keys are returned only once — the caller must save them.**
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_write_access(&auth)?;
// Look up the host's IP address from the database.
let ip_address: String = sqlx::query_scalar("SELECT host(ip_address) FROM hosts WHERE id = $1")
.bind(host_id)
.fetch_one(&state.db)
.await
.map_err(|e| {
tracing::error!(error = %e, %host_id, "Failed to fetch host IP address");
if e.to_string().contains("no rows") {
(
StatusCode::NOT_FOUND,
Json(json!({ "error": { "code": "not_found", "message": "Host not found" } })),
)
} else {
db_error(e)
}
})?;
let issued = state
.ca
.issue_client_cert(host_id, &req.hostname, &ip_address, &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() } })),
)
})?;
log_event(
&state.db,
AuditAction::CertificateIssued,
Some(auth.user_id),
Some(&auth.username),
Some("certificate"),
Some(&host_id.to_string()),
json!({ "hostname": req.hostname, "serial_number": issued.serial_number, "server_serial_number": issued.server_serial_number }),
None,
None,
)
.await;
Ok(Json(issued_cert_json(&issued)))
}
// ── 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_write_access(&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 } })),
)
}
})?;
log_event(
&state.db,
AuditAction::CertificateRenewed,
Some(auth.user_id),
Some(&auth.username),
Some("certificate"),
Some(&cert_id.to_string()),
json!({ "serial_number": issued.serial_number, "server_serial_number": issued.server_serial_number }),
None,
None,
)
.await;
Ok(Json(issued_cert_json(&issued)))
}
// ── POST /api/v1/hosts/:host_id/certificates/reissue ────────────────────────
/// Revoke ALL active certificates for a host and issue new ones.
/// The private keys are returned only once — the caller must save them.
async fn reissue_host_cert(
State(state): State<AppState>,
auth: AuthUser,
Path(host_id): Path<Uuid>,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
require_write_access(&auth)?;
// Look up the host's FQDN and IP address for the new certificate CN and SANs.
let row = sqlx::query("SELECT fqdn, host(ip_address) AS ip_address FROM hosts WHERE id = $1")
.bind(host_id)
.fetch_one(&state.db)
.await
.map_err(|e| {
tracing::error!(error = %e, %host_id, "Failed to fetch host FQDN/IP");
if e.to_string().contains("no rows") {
(
StatusCode::NOT_FOUND,
Json(json!({ "error": { "code": "not_found", "message": "Host not found" } })),
)
} else {
db_error(e)
}
})?;
let fqdn: String = row.try_get("fqdn").map_err(|e| {
tracing::error!(error = %e, %host_id, "Failed to read fqdn");
db_error(e)
})?;
let ip_address: String = row.try_get("ip_address").map_err(|e| {
tracing::error!(error = %e, %host_id, "Failed to read ip_address");
db_error(e)
})?;
// Revoke all active certificates for this host.
let revoked = sqlx::query(
"UPDATE certificates SET status = 'revoked'::cert_status, revoked_at = NOW() \
WHERE host_id = $1 AND status = 'active'::cert_status",
)
.bind(host_id)
.execute(&state.db)
.await
.map_err(db_error)?;
tracing::info!(%host_id, rows_revoked = revoked.rows_affected(), "Revoked all active certs for host");
// Issue a new certificate bundle using the host's FQDN and IP.
let issued = state
.ca
.issue_client_cert(host_id, &fqdn, &ip_address, &state.db)
.await
.map_err(|e| {
tracing::error!(error = %e, %host_id, "Failed to issue new cert during reissue");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({ "error": { "code": "internal_error", "message": e.to_string() } })),
)
})?;
log_event(
&state.db,
AuditAction::CertificateReissued,
Some(auth.user_id),
Some(&auth.username),
Some("certificate"),
Some(&host_id.to_string()),
json!({ "hostname": &fqdn, "serial_number": issued.serial_number, "server_serial_number": issued.server_serial_number, "rows_revoked": revoked.rows_affected() }),
None,
None,
)
.await;
Ok(Json(issued_cert_json(&issued)))
}
// ── 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_write_access(&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");
log_event(
&state.db,
AuditAction::CertificateRevoked,
Some(auth.user_id),
Some(&auth.username),
Some("certificate"),
Some(&cert_id.to_string()),
json!({ "operation": "revoke" }),
None,
None,
)
.await;
Ok(Json(json!({ "revoked": true })))
}