Private
Public Access
1
0
Files
linux_patch_manager/crates/pm-web/src/routes/enrollment.rs
Draco-Lunaris-Echo 5aec9e629c
All checks were successful
CI Pipeline / Rust Format Check (push) Successful in 6s
CI Pipeline / Clippy Lints (push) Successful in 52s
CI Pipeline / Rust Unit Tests (push) Successful in 1m10s
CI Pipeline / Security Audit (push) Successful in 1m26s
CI Pipeline / Frontend Lint & Type Check (push) Successful in 15s
CI Pipeline / Build .deb & Release (push) Has been skipped
feat(pki): add CRL generation, distribution endpoint, and enrollment bundle extension (#26)
* feat(pki): add CRL generation, distribution endpoint, and enrollment bundle extension

Implements manager-side CRL infrastructure for issue #7:
- Add CertAuthority::generate_crl() using rcgen 0.13
- Add GET /api/v1/pki/crl.pem public endpoint
- Extend PkiBundle with ca_chain and crl_pem fields
- Update enrollment route to include CRL in bundle
- Mount pki route as public endpoint
- Add proptest dev-dependency

* style: fix cargo fmt in enrollment.rs

---------

Co-authored-by: Draco Lunaris <331325+Draco-Lunaris@users.noreply.github.com>
2026-06-05 12:54:14 -05:00

353 lines
12 KiB
Rust

use crate::AppState;
use axum::{
extract::{Path, State},
http::StatusCode,
response::{IntoResponse, Response},
routing::{delete, get, post},
Json, Router,
};
use chrono::Utc;
use pm_auth::AuthUser;
use pm_core::{
db,
models::{
ApprovedEntry, CreateEnrollmentRequest, EnrollmentRequest, EnrollmentStatusResponse, Host,
PkiBundle,
},
};
use rand::{distributions::Alphanumeric, Rng};
use serde::Serialize;
#[derive(Debug, Clone, Serialize)]
pub struct HostConflict {
pub existing_host: Host,
pub message: String,
}
/// Define public enrollment routes.
pub fn router() -> Router<AppState> {
Router::new()
.route("/enroll", post(enroll_host))
.route("/enroll/status/{token}", get(enroll_status))
}
/// POST /api/v1/enroll
/// Initiates host self-enrollment.
/// Rate limiting is handled by tower-governor middleware (per-IP, configurable).
async fn enroll_host(
State(state): State<AppState>,
Json(payload): Json<CreateEnrollmentRequest>,
) -> Result<Response, (StatusCode, Json<serde_json::Value>)> {
// Generate secure random polling token
let polling_token: String = rand::thread_rng()
.sample_iter(&Alphanumeric)
.take(64)
.map(char::from)
.collect();
// For database storage, we'll hash the token (spec says hashed)
// Using a simple SHA256 or similar for the hash storage
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(polling_token.as_bytes());
let token_hash = hex::encode(hasher.finalize());
// 3. Store in DB
db::create_enrollment_request(&state.db, payload, token_hash)
.await
.map_err(|e| {
tracing::error!(error = %e, "Failed to create enrollment request");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "Database error" })),
)
})?;
// 4. Return the raw token to the client
Ok((
StatusCode::ACCEPTED,
Json(serde_json::json!({ "polling_token": polling_token })),
)
.into_response())
}
/// GET /api/v1/enroll/status/{token}
/// Returns status of enrollment (pending/approved/denied/not_found).
async fn enroll_status(
State(state): State<AppState>,
Path(token): Path<String>,
) -> Result<Json<EnrollmentStatusResponse>, (StatusCode, Json<serde_json::Value>)> {
// Hash the provided token to match DB.
// Security note: the raw polling token is intentionally never logged.
// Only the SHA-256 hash is stored and compared; all tracing calls in
// this module log error contexts only, never the token itself.
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(token.as_bytes());
let token_hash = hex::encode(hasher.finalize());
// 1. Check enrollment_requests table
let requests = db::list_enrollment_requests(&state.db).await.map_err(|_| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "Database error" })),
)
})?;
if let Some(req) = requests.into_iter().find(|r| r.polling_token == token_hash) {
if req.expires_at < Utc::now() {
return Ok(Json(EnrollmentStatusResponse::NotFound));
}
return Ok(Json(EnrollmentStatusResponse::Pending));
}
// 2. If not in pending, check if it was recently approved.
// Single-retrieval: remove() atomically consumes the entry, ensuring
// the private key can only be fetched once regardless of concurrent requests.
if let Some((_, entry)) = state.approved_enrollments.remove(&token_hash) {
if entry.is_expired() {
// Bundle TTL expired — treat as not found. Entry is already removed.
return Ok(Json(EnrollmentStatusResponse::NotFound));
}
return Ok(Json(EnrollmentStatusResponse::Approved {
ca_crt: entry.pki.ca_crt.clone(),
server_crt: entry.pki.server_crt.clone(),
server_key: entry.pki.server_key.clone(),
}));
}
Ok(Json(EnrollmentStatusResponse::NotFound))
}
/// Define admin enrollment routes.
pub fn admin_router() -> Router<AppState> {
Router::new()
.route("/enrollments", get(list_admin_enrollments))
.route("/enrollments/{id}/approve", post(approve_enrollment))
.route("/enrollments/{id}/deny", delete(deny_enrollment))
}
/// GET /api/v1/admin/enrollments
/// Lists all pending enrollment requests.
async fn list_admin_enrollments(
State(state): State<AppState>,
auth: AuthUser,
) -> Result<Json<Vec<EnrollmentRequest>>, (StatusCode, Json<serde_json::Value>)> {
if !auth.role.is_admin() {
return Err((
StatusCode::FORBIDDEN,
Json(
serde_json::json!({ "error": { "code": "forbidden", "message": "Admin role required" } }),
),
));
}
db::list_enrollment_requests(&state.db)
.await
.map(Json)
.map_err(|e| {
tracing::error!(error = %e, "Failed to list enrollment requests");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "Database error" })),
)
})
}
/// POST /api/v1/admin/enrollments/{id}/approve
/// Approves a pending enrollment request, generates PKI, and moves to hosts table.
async fn approve_enrollment(
State(state): State<AppState>,
Path(id): Path<uuid::Uuid>,
auth: AuthUser,
) -> Result<StatusCode, (StatusCode, Json<serde_json::Value>)> {
if !auth.role.is_admin() {
return Err((
StatusCode::FORBIDDEN,
Json(
serde_json::json!({ "error": { "code": "forbidden", "message": "Admin role required" } }),
),
));
}
// Fetch the enrollment request
let mut requests = db::list_enrollment_requests(&state.db).await.map_err(|e| {
tracing::error!(error = %e, "Failed to list enrollment requests for approval");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "Database error" })),
)
})?;
let enrollment_request = match requests.iter().position(|r| r.id == id) {
Some(idx) => requests.remove(idx),
None => return Ok(StatusCode::NOT_FOUND),
};
// Check for FQDN/IP collision in hosts table
if let Some(existing_host) = sqlx::query_as::<_, Host>(
"SELECT id, fqdn, ip_address::text, display_name, os_family, os_name, arch, agent_version, health_status, last_health_at, last_patch_at, agent_port, notes, registered_at, updated_at FROM hosts WHERE fqdn = $1 OR ip_address = $2::inet"
)
.bind(&enrollment_request.fqdn)
.bind(enrollment_request.ip_address.to_string())
.fetch_optional(&state.db)
.await
.map_err(|e| {
tracing::error!(error = %e, "Failed to check for host collision");
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "Database error" })))
})? {
return Err((
StatusCode::CONFLICT,
Json(serde_json::json!({ "error": "Host collision detected", "conflict": HostConflict { existing_host, message: "FQDN or IP already exists".to_string() } }))
));
}
// Move to hosts table FIRST (certificates table has FK reference to hosts)
let os_family = enrollment_request
.os_details
.get("os")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let os_name = enrollment_request
.os_details
.get("name")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.or_else(|| {
// Build os_name from os + os_version if "name" is absent
let os = enrollment_request
.os_details
.get("os")
.and_then(|v| v.as_str())?;
let ver = enrollment_request
.os_details
.get("os_version")
.and_then(|v| v.as_str())
.unwrap_or("");
Some(format!("{} {}", os, ver).trim().to_string())
});
let arch = enrollment_request
.os_details
.get("architecture")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let display_name = enrollment_request
.hostname
.clone()
.unwrap_or_else(|| enrollment_request.fqdn.clone());
sqlx::query(
r#"
INSERT INTO hosts (id, fqdn, ip_address, os_family, os_name, arch, display_name, registered_at, updated_at)
VALUES ($1, $2, $3::inet, $4, $5, $6, $7, NOW(), NOW())
"#,
)
.bind(enrollment_request.id)
.bind(&enrollment_request.fqdn)
.bind(enrollment_request.ip_address.to_string())
.bind(&os_family)
.bind(&os_name)
.bind(&arch)
.bind(&display_name)
.execute(&state.db)
.await
.map_err(|e| {
tracing::error!(error = %e, "Failed to insert host after approval");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "Database error" })),
)
})?;
// Generate PKI bundle using CA (after host row exists)
let issued = state
.ca
.issue_client_cert(
enrollment_request.id,
&enrollment_request.fqdn,
&enrollment_request.ip_address.to_string(),
&state.db,
)
.await
.map_err(|e| {
tracing::error!(error = %e, "Failed to issue client certificate");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "Certificate generation failed" })),
)
})?;
// Delete from enrollment_requests table
db::delete_enrollment_request(&state.db, id)
.await
.map_err(|e| {
tracing::error!(error = %e, "Failed to delete enrollment request after approval");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "Database error" })),
)
})?;
// Store PKI bundle in cache for single-use client retrieval.
//
// Design decision — server-generated keys vs CSR-based enrollment:
// Currently the server generates the agent's private key and transmits it
// over the (already mTLS-secured) polling endpoint. This approach was chosen
// for initial implementation simplicity: the agent only needs to poll one
// endpoint and receives a complete PKI bundle without an extra round-trip.
//
// A future enhancement should adopt CSR-based enrollment where the agent
// generates its own key pair locally and submits a Certificate Signing
// Request, eliminating the need for the server to ever hold or transmit
// the agent's private key. This reduces the attack surface significantly
// — the private key never traverses the network and never resides in
// server memory beyond the signing operation.
//
// See: https://github.com/Draco-Lunaris/Linux-Patch-Manager/issues/9
//
// Include the full CA chain (for root mode, same as ca_crt; for sub-CA,
// includes intermediate + root) and the current CRL.
let ca_chain = issued.ca_root_pem.clone(); // Root mode: chain is just the root cert
let crl_pem = state.ca.generate_crl(&state.db).await.unwrap_or_default(); // Empty string on failure: agent falls back to WebPKI-only
let pki = PkiBundle {
ca_crt: issued.ca_root_pem,
ca_chain,
server_crt: issued.server_cert_pem,
server_key: issued.server_key_pem,
crl_pem,
};
state.approved_enrollments.insert(
enrollment_request.polling_token.clone(),
ApprovedEntry::new(pki),
);
Ok(StatusCode::OK)
}
/// DELETE /api/v1/admin/enrollments/{id}/deny
/// Denies and purges a pending enrollment request.
async fn deny_enrollment(
State(state): State<AppState>,
Path(id): Path<uuid::Uuid>,
auth: AuthUser,
) -> Result<StatusCode, (StatusCode, Json<serde_json::Value>)> {
if !auth.role.is_admin() {
return Err((
StatusCode::FORBIDDEN,
Json(
serde_json::json!({ "error": { "code": "forbidden", "message": "Admin role required" } }),
),
));
}
db::delete_enrollment_request(&state.db, id)
.await
.map(|_| StatusCode::NO_CONTENT)
.map_err(|e| {
tracing::error!(error = %e, "Failed to deny enrollment request");
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "Database error" })),
)
})
}