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:
@ -21,8 +21,9 @@ import {
|
||||
} from '@mui/material'
|
||||
import DescriptionIcon from '@mui/icons-material/Description'
|
||||
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf'
|
||||
import { reportsApi } from '../api/client'
|
||||
import type { ReportType, ReportFormat } from '../types'
|
||||
import VerifiedUserIcon from '@mui/icons-material/VerifiedUser'
|
||||
import { reportsApi, settingsApi } from '../api/client'
|
||||
import type { ReportType, ReportFormat, AuditIntegrityResult } from '../types'
|
||||
|
||||
// ── Report metadata ───────────────────────────────────────────────────────────
|
||||
|
||||
@ -98,6 +99,8 @@ export default function ReportsPage() {
|
||||
const [groupId, setGroupId] = useState<string>('')
|
||||
const [downloading, setDownloading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [verifyingIntegrity, setVerifyingIntegrity] = useState(false)
|
||||
const [integrityResult, setIntegrityResult] = useState<AuditIntegrityResult | null>(null)
|
||||
|
||||
const info = REPORT_INFO[reportType]
|
||||
|
||||
@ -130,6 +133,20 @@ export default function ReportsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleVerifyIntegrity = async () => {
|
||||
setVerifyingIntegrity(true)
|
||||
setIntegrityResult(null)
|
||||
try {
|
||||
const { data } = await settingsApi.auditIntegrity()
|
||||
setIntegrityResult(data)
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : 'Verification failed'
|
||||
setIntegrityResult({ intact: false, rows_checked: 0, errors: [{ row_id: 0, expected_hash: '', actual_hash: msg }] })
|
||||
} finally {
|
||||
setVerifyingIntegrity(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Container maxWidth="xl" sx={{ mt: 3 }}>
|
||||
{/* ── Page header ── */}
|
||||
@ -224,6 +241,44 @@ export default function ReportsPage() {
|
||||
</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
{/* ── Audit Integrity card ── */}
|
||||
<Paper variant="outlined" sx={{ p: 3, mt: 3 }}>
|
||||
<Typography variant="subtitle1" fontWeight={600} sx={{ mb: 1 }}>
|
||||
Audit Integrity Verification
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
Verify the audit log hash chain has not been tampered with. Each entry is cryptographically linked to the previous one.
|
||||
</Typography>
|
||||
<Button
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
startIcon={verifyingIntegrity ? <CircularProgress size={20} /> : <VerifiedUserIcon />}
|
||||
onClick={handleVerifyIntegrity}
|
||||
disabled={verifyingIntegrity}
|
||||
>
|
||||
Verify Integrity
|
||||
</Button>
|
||||
{integrityResult && (
|
||||
<Alert severity={integrityResult.intact ? 'success' : 'error'} sx={{ mt: 2 }}>
|
||||
{integrityResult.intact
|
||||
? `✓ Chain intact — ${integrityResult.rows_checked} rows verified`
|
||||
: `✗ Chain compromised! ${integrityResult.errors.length} error(s) in ${integrityResult.rows_checked} rows`}
|
||||
{integrityResult.errors.length > 0 && (
|
||||
<Box sx={{ mt: 1 }}>
|
||||
{integrityResult.errors.slice(0, 5).map((e, i) => (
|
||||
<Typography key={i} variant="body2">
|
||||
Row {e.row_id}: expected {e.expected_hash.substring(0, 16)}… got {e.actual_hash.substring(0, 16)}…
|
||||
</Typography>
|
||||
))}
|
||||
{integrityResult.errors.length > 5 && (
|
||||
<Typography variant="body2">…and {integrityResult.errors.length - 5} more</Typography>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Alert>
|
||||
)}
|
||||
</Paper>
|
||||
</Grid>
|
||||
|
||||
{/* ── Info card ── */}
|
||||
|
||||
@ -11,8 +11,9 @@ import DeleteIcon from '@mui/icons-material/Delete'
|
||||
import AddIcon from '@mui/icons-material/Add'
|
||||
import CloudIcon from '@mui/icons-material/Cloud'
|
||||
import EmailIcon from '@mui/icons-material/Email'
|
||||
import VerifiedUserIcon from '@mui/icons-material/VerifiedUser'
|
||||
import { settingsApi } from '../api/client'
|
||||
import type { AzureSsoConfig, SmtpConfig, PollingConfig } from '../types'
|
||||
import type { AzureSsoConfig, SmtpConfig, PollingConfig, NotificationConfig } from '../types'
|
||||
|
||||
type AzureSsoForm = AzureSsoConfig & { client_secret?: string }
|
||||
type SmtpForm = SmtpConfig & { password?: string }
|
||||
@ -29,10 +30,15 @@ export default function SettingsPage() {
|
||||
})
|
||||
const [ipWhitelist, setIpWhitelist] = useState<string[]>([])
|
||||
const [webTlsStrategy, setWebTlsStrategy] = useState('internal_ca')
|
||||
const [notification, setNotification] = useState<NotificationConfig>({
|
||||
email_enabled: false, email_from: 'patch-manager@localhost', recipients: [],
|
||||
})
|
||||
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [testingAzure, setTestingAzure] = useState(false)
|
||||
const [testingSmtp, setTestingSmtp] = useState(false)
|
||||
const [testingIntegrity, setTestingIntegrity] = useState(false)
|
||||
const [integrityResult, setIntegrityResult] = useState<{ intact: boolean; rows_checked: number; errors: Array<{ row_id: number; expected_hash: string; actual_hash: string }> } | null>(null)
|
||||
const [azureSsoTestResult, setAzureSsoTestResult] = useState<{ success: boolean; message: string } | null>(null)
|
||||
const [smtpTestResult, setSmtpTestResult] = useState<{ success: boolean; message: string } | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
@ -48,6 +54,7 @@ export default function SettingsPage() {
|
||||
setPolling(data.polling)
|
||||
setIpWhitelist(data.ip_whitelist)
|
||||
setWebTlsStrategy(data.web_tls_strategy)
|
||||
setNotification(data.notification)
|
||||
} catch {
|
||||
setError('Failed to load settings')
|
||||
} finally {
|
||||
@ -68,6 +75,7 @@ export default function SettingsPage() {
|
||||
polling,
|
||||
ip_whitelist: ipWhitelist,
|
||||
web_tls_strategy: webTlsStrategy,
|
||||
notification,
|
||||
})
|
||||
setSuccess('Settings saved successfully')
|
||||
} catch {
|
||||
@ -77,6 +85,20 @@ export default function SettingsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleAuditIntegrity = async () => {
|
||||
setTestingIntegrity(true)
|
||||
setIntegrityResult(null)
|
||||
try {
|
||||
const { data } = await settingsApi.auditIntegrity()
|
||||
setIntegrityResult(data)
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : 'Verification failed'
|
||||
setIntegrityResult({ intact: false, rows_checked: 0, errors: [{ row_id: 0, expected_hash: '', actual_hash: msg }] })
|
||||
} finally {
|
||||
setTestingIntegrity(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleTestAzureSso = async () => {
|
||||
setTestingAzure(true)
|
||||
setAzureSsoTestResult(null)
|
||||
@ -279,6 +301,77 @@ export default function SettingsPage() {
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
|
||||
{/* Section 6: Email Notification Settings */}
|
||||
<Accordion>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography fontWeight={600}>Email Notifications</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Grid container spacing={2}>
|
||||
<Grid size={12}>
|
||||
<FormControlLabel
|
||||
control={<Switch checked={notification.email_enabled} onChange={(e) => setNotification({ ...notification, email_enabled: e.target.checked })} />}
|
||||
label="Enable Email Notifications"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={6}>
|
||||
<TextField fullWidth label="From Address" value={notification.email_from} onChange={(e) => setNotification({ ...notification, email_from: e.target.value })} helperText="Sender address for notifications" />
|
||||
</Grid>
|
||||
<Grid size={12}>
|
||||
<Typography variant="subtitle2" sx={{ mt: 1, mb: 1 }}>Recipients</Typography>
|
||||
{notification.recipients.map((email, idx) => (
|
||||
<Box key={idx} sx={{ display: 'flex', gap: 1, mb: 1 }}>
|
||||
<TextField size="small" value={email} onChange={(e) => {
|
||||
const updated = [...notification.recipients]
|
||||
updated[idx] = e.target.value
|
||||
setNotification({ ...notification, recipients: updated })
|
||||
}} placeholder="admin@example.com" sx={{ flexGrow: 1 }} />
|
||||
<IconButton onClick={() => {
|
||||
setNotification({ ...notification, recipients: notification.recipients.filter((_, i) => i !== idx) })
|
||||
}}><DeleteIcon /></IconButton>
|
||||
</Box>
|
||||
))}
|
||||
<Button variant="outlined" startIcon={<AddIcon />} onClick={() => {
|
||||
setNotification({ ...notification, recipients: [...notification.recipients, ''] })
|
||||
}}>Add Recipient</Button>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
|
||||
{/* Section 7: Audit Integrity Verification */}
|
||||
<Accordion>
|
||||
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
|
||||
<Typography fontWeight={600}>Audit Integrity Verification</Typography>
|
||||
</AccordionSummary>
|
||||
<AccordionDetails>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
Verify the integrity of the audit log hash chain. This checks that all audit entries are properly linked and have not been tampered with.
|
||||
</Typography>
|
||||
<Button variant="outlined" onClick={handleAuditIntegrity} disabled={testingIntegrity} startIcon={testingIntegrity ? <CircularProgress size={20} /> : <VerifiedUserIcon />}>
|
||||
Verify Audit Integrity
|
||||
</Button>
|
||||
{integrityResult && (
|
||||
<Alert severity={integrityResult.intact ? 'success' : 'error'} sx={{ mt: 2 }}>
|
||||
{integrityResult.intact
|
||||
? `Audit chain intact — ${integrityResult.rows_checked} rows verified`
|
||||
: `Audit chain compromised! ${integrityResult.errors.length} error(s) found across ${integrityResult.rows_checked} rows checked`}
|
||||
{integrityResult.errors.length > 0 && (
|
||||
<Box sx={{ mt: 1 }}>
|
||||
{integrityResult.errors.slice(0, 5).map((e, i) => (
|
||||
<Typography key={i} variant="body2">
|
||||
Row {e.row_id}: expected {e.expected_hash.substring(0, 16)}... got {e.actual_hash.substring(0, 16)}...
|
||||
</Typography>
|
||||
))}
|
||||
{integrityResult.errors.length > 5 && (
|
||||
<Typography variant="body2">...and {integrityResult.errors.length - 5} more errors</Typography>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Alert>
|
||||
)}
|
||||
</AccordionDetails>
|
||||
</Accordion>
|
||||
<Snackbar
|
||||
open={!!success}
|
||||
autoHideDuration={4000}
|
||||
|
||||
Reference in New Issue
Block a user