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

@ -197,7 +197,6 @@ export const reportsApi = {
timeout: 120_000, // reports can take a while
}),
}
// ── Settings API (M10) ────────────────────────────────────────────────────
export interface AzureSsoConfig {
enabled: boolean
@ -221,12 +220,19 @@ export interface PollingConfig {
patch_poll_interval_secs: number
}
export interface NotificationConfig {
email_enabled: boolean
email_from: string
recipients: string[]
}
export interface SettingsResponse {
azure_sso: AzureSsoConfig
smtp: SmtpConfig
polling: PollingConfig
ip_whitelist: string[]
web_tls_strategy: string
notification: NotificationConfig
}
export interface TestResult {
@ -234,14 +240,26 @@ export interface TestResult {
message: string
}
export interface AuditIntegrityResult {
intact: boolean
rows_checked: number
errors: Array<{
row_id: number
expected_hash: string
actual_hash: string
}>
}
export const settingsApi = {
get: () => apiClient.get<SettingsResponse>('/settings'),
update: (data: Partial<SettingsResponse> & {
azure_sso?: AzureSsoConfig & { client_secret?: string }
smtp?: SmtpConfig & { password?: string }
notification?: NotificationConfig
}) => apiClient.put<SettingsResponse>('/settings', data),
testAzureSso: () => apiClient.post<TestResult>('/settings/azure-sso/test'),
testSmtp: () => apiClient.post<TestResult>('/settings/smtp/test'),
getIpWhitelist: () => apiClient.get<{ entries: string[] }>('/settings/ip-whitelist'),
updateIpWhitelist: (entries: string[]) => apiClient.put<{ entries: string[] }>('/settings/ip-whitelist', { entries }),
auditIntegrity: () => apiClient.post<AuditIntegrityResult>('/settings/audit-integrity'),
}

View File

@ -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 ── */}

View File

@ -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}

View File

@ -216,6 +216,16 @@ export interface PollingConfig {
health_poll_interval_secs: number
patch_poll_interval_secs: number
}
export interface PollingConfig {
health_poll_interval_secs: number
patch_poll_interval_secs: number
}
export interface NotificationConfig {
email_enabled: boolean
email_from: string
recipients: string[]
}
export interface SettingsResponse {
azure_sso: AzureSsoConfig
@ -223,5 +233,17 @@ export interface SettingsResponse {
polling: PollingConfig
ip_whitelist: string[]
web_tls_strategy: string
notification: NotificationConfig
}
export interface AuditIntegrityResult {
intact: boolean
rows_checked: number
errors: Array<{
row_id: number
expected_hash: string
actual_hash: string
}>
}
export type ReportFormat = 'csv' | 'pdf'