Private
Public Access
1
0

feat(M11+M12): Email notifications, audit hardening, deployment packaging, backup/DR, integration testing

M11 - Email Notifications + Audit Logging Hardening:
- Email notifier (lettre crate) with templates for patch failure, job completion, maintenance reminders
- Audit log hash chaining (prev_hash + row_hash) for tamper-evident logging
- Periodic + on-demand audit integrity verification
- Audit logging for all config changes and certificate operations
- Frontend: email settings integration, audit integrity verification action

M12 - Deployment Packaging, Backup/DR, Integration Testing:
- scripts/backup.sh: Nightly pg_dump, CA backup (GPG), config backup (secrets excluded unless encrypted)
- scripts/setup.sh: Enhanced with backup dir, seed migration, backup cron, systemd target install
- systemd units: Restart=always, WatchdogSec, ReadWritePaths, security hardening
- systemd/patch-manager.target: Service target for coordinated lifecycle
- docs/runbooks/restore.md: Full DR runbook with RPO 24h / RTO 4h targets
- scripts/integration-test.sh: 9 test suites covering full API lifecycle
- scripts/performance-test.sh: NFR validation (dashboard <5s, CIDR /22 <10s, API <2s)
- docs/security-review.md: Comprehensive security control verification
- docs/compliance-mapping.md: HIPAA (6 sections) + PCI-DSS v4.0 (9 requirements) mapped
This commit is contained in:
2026-04-24 00:45:51 +00:00
parent 84ab92f4f0
commit 297bf1bd83
26 changed files with 2651 additions and 65 deletions

View File

@ -22,6 +22,7 @@ use axum::{
};
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 uuid::Uuid;
@ -129,9 +130,23 @@ fn db_error(e: sqlx::Error) -> (StatusCode, Json<Value>) {
/// Download the root CA certificate as a PEM file.
async fn download_root_ca(
State(state): State<AppState>,
_auth: AuthUser,
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")
}
@ -230,7 +245,21 @@ async fn download_client_cert(
})?;
match cert_pem {
Some(pem) => pem_response(pem, "client.crt"),
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!({
@ -268,6 +297,19 @@ async fn issue_client_cert(
)
})?;
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 }),
None,
None,
)
.await;
Ok(Json(json!({
"cert_pem": issued.cert_pem,
"key_pem": issued.key_pem,
@ -306,6 +348,19 @@ async fn renew_cert(
}
})?;
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 }),
None,
None,
)
.await;
Ok(Json(json!({
"cert_pem": issued.cert_pem,
"key_pem": issued.key_pem,
@ -345,5 +400,19 @@ async fn revoke_cert(
})?;
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 })))
}

View File

@ -6,6 +6,7 @@
//! POST /api/v1/settings/smtp/test — send test email (admin only)
//! GET /api/v1/settings/ip-whitelist — get IP whitelist (admin only)
//! PUT /api/v1/settings/ip-whitelist — update IP whitelist (admin only)
//! POST /api/v1/settings/audit-integrity — verify audit log integrity (admin only)
use axum::{
extract::State,
@ -19,7 +20,7 @@ use lettre::{
transport::smtp::authentication::Credentials,
AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor,
};
use pm_core::audit::{log_event, AuditAction};
use pm_core::audit::{log_event, verify_integrity, AuditAction};
use pm_auth::rbac::AuthUser;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
@ -38,6 +39,7 @@ pub struct SettingsResponse {
pub polling: PollingConfig,
pub ip_whitelist: Vec<String>,
pub web_tls_strategy: String,
pub notification: NotificationConfig,
}
#[derive(Debug, Serialize, Deserialize)]
@ -72,6 +74,21 @@ pub struct UpdateSettingsRequest {
pub polling: Option<PollingConfigUpdate>,
pub ip_whitelist: Option<Vec<String>>,
pub web_tls_strategy: Option<String>,
pub notification: Option<NotificationConfigUpdate>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct NotificationConfig {
pub email_enabled: bool,
pub email_from: String,
pub recipients: Vec<String>,
}
#[derive(Debug, Deserialize)]
pub struct NotificationConfigUpdate {
pub email_enabled: Option<bool>,
pub email_from: Option<String>,
pub recipients: Option<Vec<String>>,
}
#[derive(Debug, Deserialize)]
@ -116,6 +133,7 @@ pub fn router() -> Router<AppState> {
.route("/azure-sso/test", post(test_azure_sso))
.route("/smtp/test", post(test_smtp))
.route("/ip-whitelist", get(get_ip_whitelist).put(update_ip_whitelist))
.route("/audit-integrity", post(audit_integrity))
}
// ============================================================
@ -156,6 +174,8 @@ async fn load_system_config(
fn build_settings_response(cfg: &HashMap<String, String>, azure: AzureSsoConfig) -> SettingsResponse {
let get = |key: &str| -> String { cfg.get(key).cloned().unwrap_or_default() };
let recipients: Vec<String> = serde_json::from_str(&get("notification_email_recipients")).unwrap_or_default();
SettingsResponse {
azure_sso: azure,
smtp: SmtpConfig {
@ -172,6 +192,11 @@ fn build_settings_response(cfg: &HashMap<String, String>, azure: AzureSsoConfig)
},
ip_whitelist: serde_json::from_str(&get("ip_whitelist")).unwrap_or_default(),
web_tls_strategy: get("web_tls_strategy"),
notification: NotificationConfig {
email_enabled: get("notification_email_enabled") == "true",
email_from: get("notification_email_from"),
recipients,
},
}
}
@ -429,6 +454,33 @@ async fn update_settings(
.await;
}
// Update notification config
if let Some(notif) = &req.notification {
if let Some(v) = notif.email_enabled {
update_config_key(&state.db, "notification_email_enabled", &v.to_string()).await?;
}
if let Some(ref v) = notif.email_from {
update_config_key(&state.db, "notification_email_from", v).await?;
}
if let Some(ref v) = notif.recipients {
let json_str = serde_json::to_string(v).unwrap_or_else(|_| "[]".to_string());
update_config_key(&state.db, "notification_email_recipients", &json_str).await?;
}
log_event(
&state.db,
AuditAction::ConfigChanged,
Some(auth.user_id),
Some(&auth.username),
Some("notification"),
Some("system_config"),
json!({ "section": "notification" }),
None,
None,
)
.await;
}
// Return updated settings
let cfg = load_system_config(&state.db).await?;
let azure = fetch_azure_sso_config(&state.db).await?;
@ -689,6 +741,47 @@ async fn update_ip_whitelist(
None,
)
.await;
Ok(Json(json!({ "entries": req.entries })))
}
// ============================================================
// POST /api/v1/settings/audit-integrity
// ============================================================
/// Verify audit log hash chain integrity.
/// Returns whether the chain is intact, rows checked, and any errors.
async fn audit_integrity(
State(state): State<AppState>,
auth: AuthUser,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
admin_only(&auth)?;
let result = verify_integrity(&state.db).await;
log_event(
&state.db,
AuditAction::AuditIntegrityVerified,
Some(auth.user_id),
Some(&auth.username),
Some("audit_log"),
None,
json!({
"intact": result.intact,
"rows_checked": result.rows_checked,
"error_count": result.errors.len(),
}),
None,
None,
)
.await;
Ok(Json(json!({
"intact": result.intact,
"rows_checked": result.rows_checked,
"errors": result.errors.iter().map(|e| json!({
"row_id": e.row_id,
"expected_hash": e.expected_hash,
"actual_hash": e.actual_hash,
})).collect::<Vec<_>>(),
})))
}