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:
@ -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 })))
|
||||
}
|
||||
|
||||
@ -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<_>>(),
|
||||
})))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user