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:
@ -1,7 +1,14 @@
|
||||
//! Audit log helper functions.
|
||||
//!
|
||||
//! Writes tamper-evident, hash-chained audit events to the `audit_log` table.
|
||||
//! The hash chain: each row's `row_hash` = SHA-256(prev_row_hash || action || target_id || created_at).
|
||||
//! The hash chain: each row's `row_hash` = SHA-256(
|
||||
//! prev_hash || action || actor_user_id || actor_username ||
|
||||
//! target_type || target_id || details_json || ip_address ||
|
||||
//! request_id || created_at
|
||||
//! ).
|
||||
//!
|
||||
//! The `prev_hash` column stores the previous row's `row_hash` for chain
|
||||
//! verification. The first row has `prev_hash = ''`.
|
||||
|
||||
use sha2::{Digest, Sha256};
|
||||
use sqlx::PgPool;
|
||||
@ -34,6 +41,12 @@ pub enum AuditAction {
|
||||
CertificateDownloaded,
|
||||
ConfigChanged,
|
||||
DiscoveryScanStarted,
|
||||
// M11 additions
|
||||
AuditIntegrityVerified,
|
||||
EmailNotificationSent,
|
||||
PatchJobCompleted,
|
||||
PatchJobFailed,
|
||||
MaintenanceWindowReminder,
|
||||
}
|
||||
|
||||
impl AuditAction {
|
||||
@ -62,6 +75,11 @@ impl AuditAction {
|
||||
Self::CertificateDownloaded => "certificate_downloaded",
|
||||
Self::ConfigChanged => "config_changed",
|
||||
Self::DiscoveryScanStarted => "discovery_scan_started",
|
||||
Self::AuditIntegrityVerified => "audit_integrity_verified",
|
||||
Self::EmailNotificationSent => "email_notification_sent",
|
||||
Self::PatchJobCompleted => "patch_job_completed",
|
||||
Self::PatchJobFailed => "patch_job_failed",
|
||||
Self::MaintenanceWindowReminder => "maintenance_window_reminder",
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -114,25 +132,39 @@ async fn write_audit_row(
|
||||
let prev = prev_hash.unwrap_or_default();
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
let action_str = action.as_str();
|
||||
let uid_str = actor_user_id.map(|u| u.to_string()).unwrap_or_default();
|
||||
let uname = actor_username.unwrap_or("");
|
||||
let ttype = target_type.unwrap_or("");
|
||||
let tid = target_id.unwrap_or("");
|
||||
let details_str = serde_json::to_string(&details).unwrap_or_default();
|
||||
let ip_str = ip_address.map(|ip| ip.to_string()).unwrap_or_default();
|
||||
let rid = request_id.unwrap_or("");
|
||||
|
||||
// Hash: SHA-256(prev_hash + action + target_id + timestamp)
|
||||
// Hash: SHA-256(prev_hash + action + actor_user_id + actor_username +
|
||||
// target_type + target_id + details_json + ip_address +
|
||||
// request_id + created_at)
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(prev.as_bytes());
|
||||
hasher.update(action_str.as_bytes());
|
||||
hasher.update(uid_str.as_bytes());
|
||||
hasher.update(uname.as_bytes());
|
||||
hasher.update(ttype.as_bytes());
|
||||
hasher.update(tid.as_bytes());
|
||||
hasher.update(details_str.as_bytes());
|
||||
hasher.update(ip_str.as_bytes());
|
||||
hasher.update(rid.as_bytes());
|
||||
hasher.update(now.as_bytes());
|
||||
let row_hash = hex::encode(hasher.finalize());
|
||||
|
||||
let ip_str = ip_address.map(|ip| ip.to_string());
|
||||
let ip_for_db = ip_address.map(|ip| ip.to_string());
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO audit_log
|
||||
(action, actor_user_id, actor_username, target_type, target_id,
|
||||
details, ip_address, request_id, row_hash)
|
||||
details, ip_address, request_id, created_at, row_hash, prev_hash)
|
||||
VALUES
|
||||
($1::audit_action, $2, $3, $4, $5, $6, $7::inet, $8, $9)
|
||||
($1::audit_action, $2, $3, $4, $5, $6, $7::inet, $8, $9::timestamptz, $10, $11)
|
||||
"#,
|
||||
)
|
||||
.bind(action_str)
|
||||
@ -141,11 +173,142 @@ async fn write_audit_row(
|
||||
.bind(target_type)
|
||||
.bind(target_id)
|
||||
.bind(details)
|
||||
.bind(ip_str)
|
||||
.bind(ip_for_db)
|
||||
.bind(request_id)
|
||||
.bind(&now)
|
||||
.bind(&row_hash)
|
||||
.bind(&prev)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Result of an audit integrity verification pass.
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
pub struct IntegrityResult {
|
||||
/// Whether the chain is intact (no tampering detected).
|
||||
pub intact: bool,
|
||||
/// Total number of rows checked.
|
||||
pub rows_checked: i64,
|
||||
/// List of errors found (row id, expected hash, actual hash).
|
||||
pub errors: Vec<IntegrityError>,
|
||||
}
|
||||
|
||||
/// A single integrity error detected in the audit chain.
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
pub struct IntegrityError {
|
||||
pub row_id: i64,
|
||||
pub expected_hash: String,
|
||||
pub actual_hash: String,
|
||||
}
|
||||
|
||||
/// Row read from audit_log for integrity verification.
|
||||
#[derive(Debug, sqlx::FromRow)]
|
||||
struct AuditRow {
|
||||
id: i64,
|
||||
action: String,
|
||||
actor_user_id: Option<uuid::Uuid>,
|
||||
actor_username: Option<String>,
|
||||
target_type: Option<String>,
|
||||
target_id: Option<String>,
|
||||
details: Option<serde_json::Value>,
|
||||
ip_address: Option<String>,
|
||||
request_id: Option<String>,
|
||||
created_at: Option<chrono::DateTime<chrono::Utc>>,
|
||||
row_hash: String,
|
||||
prev_hash: String,
|
||||
}
|
||||
|
||||
/// Walk the audit_log rows ordered by id and verify each row_hash matches
|
||||
/// the recomputed hash. Returns an [`IntegrityResult`] describing any
|
||||
/// tampering detected.
|
||||
pub async fn verify_integrity(pool: &PgPool) -> IntegrityResult {
|
||||
let rows: Vec<AuditRow> = match sqlx::query_as(
|
||||
r#"
|
||||
SELECT id, action::text AS action, actor_user_id, actor_username,
|
||||
target_type, target_id, details,
|
||||
host(ip_address) AS ip_address,
|
||||
request_id, created_at, row_hash, prev_hash
|
||||
FROM audit_log
|
||||
ORDER BY id ASC
|
||||
"#,
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
{
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "verify_integrity: failed to fetch audit rows");
|
||||
return IntegrityResult {
|
||||
intact: false,
|
||||
rows_checked: 0,
|
||||
errors: vec![],
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
let mut errors = Vec::new();
|
||||
let mut expected_prev_hash = String::new();
|
||||
|
||||
for row in &rows {
|
||||
// Verify prev_hash linkage
|
||||
if row.prev_hash != expected_prev_hash {
|
||||
errors.push(IntegrityError {
|
||||
row_id: row.id,
|
||||
expected_hash: expected_prev_hash.clone(),
|
||||
actual_hash: row.prev_hash.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
// Recompute the row hash from all fields
|
||||
let uid_str = row.actor_user_id.map(|u| u.to_string()).unwrap_or_default();
|
||||
let uname = row.actor_username.as_deref().unwrap_or("");
|
||||
let ttype = row.target_type.as_deref().unwrap_or("");
|
||||
let tid = row.target_id.as_deref().unwrap_or("");
|
||||
let details_str = row
|
||||
.details
|
||||
.as_ref()
|
||||
.and_then(|v| serde_json::to_string(v).ok())
|
||||
.unwrap_or_default();
|
||||
let ip_str = row.ip_address.as_deref().unwrap_or("");
|
||||
let rid = row.request_id.as_deref().unwrap_or("");
|
||||
let created_str = row
|
||||
.created_at
|
||||
.map(|c| c.to_rfc3339())
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(row.prev_hash.as_bytes());
|
||||
hasher.update(row.action.as_bytes());
|
||||
hasher.update(uid_str.as_bytes());
|
||||
hasher.update(uname.as_bytes());
|
||||
hasher.update(ttype.as_bytes());
|
||||
hasher.update(tid.as_bytes());
|
||||
hasher.update(details_str.as_bytes());
|
||||
hasher.update(ip_str.as_bytes());
|
||||
hasher.update(rid.as_bytes());
|
||||
hasher.update(created_str.as_bytes());
|
||||
let computed_hash = hex::encode(hasher.finalize());
|
||||
|
||||
if row.row_hash != computed_hash {
|
||||
errors.push(IntegrityError {
|
||||
row_id: row.id,
|
||||
expected_hash: computed_hash,
|
||||
actual_hash: row.row_hash.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
// Next row should have this row's hash as prev_hash
|
||||
expected_prev_hash = row.row_hash.clone();
|
||||
}
|
||||
|
||||
let intact = errors.is_empty();
|
||||
let rows_checked = rows.len() as i64;
|
||||
|
||||
IntegrityResult {
|
||||
intact,
|
||||
rows_checked,
|
||||
errors,
|
||||
}
|
||||
}
|
||||
|
||||
@ -15,3 +15,6 @@ pub use models::{
|
||||
User, UserRole as DbUserRole, AuthProvider, CreateUserRequest, UpdateUserRequest,
|
||||
DiscoveryResult, DiscoveryCidrRequest, RegisterDiscoveredRequest,
|
||||
};
|
||||
|
||||
// Re-export audit integrity types
|
||||
pub use audit::{verify_integrity, IntegrityResult, IntegrityError};
|
||||
|
||||
Reference in New Issue
Block a user