kipuka/config/audit.rs
1//! Audit configuration.
2//!
3//! The `[audit]` section controls the audit trail that satisfies NIAP CA PP
4//! FAU family requirements:
5//!
6//! - **FAU_GEN.1** — the server generates audit records for all security-relevant
7//! events (enrollment, authentication, key operations, admin actions).
8//! - **FAU_STG.1** — the audit trail is append-only at the application level.
9//! - **FAU_STG.4** — when `overflow_policy = "halt"`, EST operations are rejected
10//! if the audit trail storage is exhausted.
11//! - **FAU_ARP.1** — when the alarm threshold is reached, the configured alarm
12//! action is triggered.
13
14use serde::Deserialize;
15
16use super::SecretRef;
17
18/// Audit log rotation policy.
19#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
20#[serde(rename_all = "lowercase")]
21#[derive(Default)]
22pub enum RotationPolicy {
23 /// Rotate based on file size.
24 Size,
25 /// Rotate daily.
26 #[default]
27 Daily,
28 /// Rotate weekly.
29 Weekly,
30 /// Never rotate (rely on external log management).
31 Never,
32}
33
34/// What to do when audit storage is exhausted (FAU_STG.4).
35#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
36#[serde(rename_all = "snake_case")]
37#[derive(Default)]
38pub enum OverflowPolicy {
39 /// Drop the oldest audit records to make room.
40 #[default]
41 DropOldest,
42 /// Halt EST operations until audit storage is cleared.
43 ///
44 /// NIAP CA PP FAU_STG.4: "The TSF shall prevent audited events,
45 /// except those taken by the authorised administrator, if the
46 /// audit trail is full."
47 Halt,
48}
49
50/// `[audit]` section — audit trail configuration.
51///
52/// ```toml
53/// [audit]
54/// enabled = true
55/// log_path = "/var/log/kipuka/audit.log"
56/// signed = true
57/// rotation_policy = "daily"
58/// overflow_policy = "halt"
59/// max_rows = 1000000
60/// ```
61#[derive(Debug, Clone, Deserialize)]
62#[serde(deny_unknown_fields)]
63pub struct AuditConfig {
64 /// Enable audit logging. Default: `true`.
65 #[serde(default = "bool_true")]
66 pub enabled: bool,
67
68 /// Path to the audit log file.
69 ///
70 /// When using database-backed audit (`log_to_db = true`), this path
71 /// is used for the file-based backup copy.
72 #[serde(default = "default_log_path")]
73 pub log_path: String,
74
75 /// Enable keyed (HMAC) integrity protection of the audit hash chain.
76 ///
77 /// Every audit row stores `record_hash = H(prev_hash || record_bytes)`,
78 /// forming a tamper-evident chain (NIAP CA PP FAU_STG.1). When `signed`
79 /// is `false` (the default), `H` is plain SHA-256 — this detects in-place
80 /// edits, deletions, and reordering as long as the chain head is anchored
81 /// out-of-band. When `signed` is `true`, `H` is HMAC-SHA256 keyed by
82 /// [`integrity_key`](Self::integrity_key), which additionally prevents an
83 /// attacker who can write to the database from forging a valid chain.
84 #[serde(default)]
85 pub signed: bool,
86
87 /// Secret reference for the HMAC integrity key (required when
88 /// `signed = true`).
89 ///
90 /// Resolved at startup via the standard [`SecretRef`] backends
91 /// (`env:`, `file:`, `keyring:`, `systemd-creds:`). Never store the raw
92 /// key inline in the config file.
93 #[serde(default)]
94 pub integrity_key: Option<SecretRef>,
95
96 /// Log rotation policy.
97 #[serde(default)]
98 pub rotation_policy: RotationPolicy,
99
100 /// Maximum rotation file size in bytes (when `rotation_policy = "size"`).
101 /// Default: 100 MiB.
102 #[serde(default = "default_max_file_size")]
103 pub max_file_size: u64,
104
105 /// Number of rotated log files to retain.
106 /// Default: 10.
107 #[serde(default = "default_retention_count")]
108 pub retention_count: u32,
109
110 /// Store audit events in the database in addition to the log file.
111 #[serde(default = "bool_true")]
112 pub log_to_db: bool,
113
114 /// Overflow policy when audit storage is full (FAU_STG.4).
115 #[serde(default)]
116 pub overflow_policy: OverflowPolicy,
117
118 /// Maximum number of audit rows in the database.
119 ///
120 /// When this limit is reached, the `overflow_policy` determines
121 /// whether old rows are dropped or EST operations are halted.
122 /// `None` means no limit (rely on disk space monitoring).
123 pub max_rows: Option<u64>,
124
125 /// Number of consecutive security violations before the alarm
126 /// action fires (FAU_ARP.1).
127 ///
128 /// Default: 10.
129 #[serde(default = "default_alarm_threshold")]
130 pub alarm_threshold: u32,
131
132 /// Action taken when the alarm threshold is reached.
133 ///
134 /// - `"syslog"` — emit a syslog alert.
135 /// - `"halt"` — halt EST operations.
136 ///
137 /// Default: `"syslog"`.
138 #[serde(default = "default_alarm_action")]
139 pub alarm_action: String,
140
141 /// NIAP CA PP FAU_GEN.1: list of auditable event types.
142 ///
143 /// When non-empty, only these event types are recorded.
144 /// When empty (default), all events are audited.
145 #[serde(default)]
146 pub auditable_events: Vec<String>,
147
148 /// Override the interval (in seconds) between rotation checks.
149 ///
150 /// When `None` (the default), the interval is derived from the
151 /// `rotation_policy`: 3600s for `size`, 86400s for `daily`/`weekly`.
152 #[serde(default)]
153 pub rotation_check_interval_secs: Option<u64>,
154}
155
156fn bool_true() -> bool {
157 true
158}
159
160fn default_log_path() -> String {
161 "/var/log/kipuka/audit.log".to_string()
162}
163
164fn default_max_file_size() -> u64 {
165 100 * 1024 * 1024 // 100 MiB
166}
167
168fn default_retention_count() -> u32 {
169 10
170}
171
172fn default_alarm_threshold() -> u32 {
173 10
174}
175
176fn default_alarm_action() -> String {
177 "syslog".to_string()
178}
179
180impl Default for AuditConfig {
181 fn default() -> Self {
182 Self {
183 enabled: true,
184 log_path: default_log_path(),
185 signed: false,
186 integrity_key: None,
187 rotation_policy: RotationPolicy::default(),
188 max_file_size: default_max_file_size(),
189 retention_count: default_retention_count(),
190 log_to_db: true,
191 overflow_policy: OverflowPolicy::default(),
192 max_rows: None,
193 alarm_threshold: default_alarm_threshold(),
194 alarm_action: default_alarm_action(),
195 auditable_events: Vec::new(),
196 rotation_check_interval_secs: None,
197 }
198 }
199}
200
201impl AuditConfig {
202 /// Validate audit configuration constraints.
203 pub fn validate(&self) -> std::result::Result<(), String> {
204 if !self.enabled {
205 return Ok(());
206 }
207
208 if self.log_path != default_log_path()
209 || self.rotation_policy != RotationPolicy::Daily
210 || self.max_file_size != default_max_file_size()
211 || self.retention_count != default_retention_count()
212 || self.rotation_check_interval_secs.is_some()
213 {
214 return Err("file audit output/rotation settings are unsupported; configure the external logging sink instead".into());
215 }
216 if !self.auditable_events.is_empty() {
217 return Err(
218 "audit event filtering is unsupported; all security events must be recorded".into(),
219 );
220 }
221 if self.max_rows.is_some_and(|n| n > i64::MAX as u64) {
222 return Err("[audit].max_rows exceeds the database integer range".into());
223 }
224 // Keyed integrity (HMAC) requires a resolvable, non-empty key. The
225 // unkeyed SHA-256 chain is always active and needs no configuration;
226 // `signed = true` upgrades it to HMAC-SHA256 keyed by `integrity_key`.
227 let integrity_key_missing = match &self.integrity_key {
228 None => true,
229 Some(k) => k.is_empty(),
230 };
231 if self.signed && integrity_key_missing {
232 return Err(
233 "[audit].integrity_key is required (and must be non-empty) when signed = true"
234 .into(),
235 );
236 }
237 if self.max_rows == Some(0) {
238 return Err("[audit].max_rows must be positive".into());
239 }
240 if !self.log_to_db {
241 return Err("[audit].log_to_db must be true: file-only audit is unsupported".into());
242 }
243 match self.alarm_action.as_str() {
244 "syslog" | "halt" => {}
245 other => {
246 return Err(format!(
247 "[audit].alarm_action must be \"syslog\" or \"halt\", got {other:?}"
248 ));
249 }
250 }
251
252 if self.alarm_threshold == 0 {
253 return Err("[audit].alarm_threshold must be at least 1".into());
254 }
255
256 if self.rotation_policy == RotationPolicy::Size && self.max_file_size == 0 {
257 return Err(
258 "[audit].max_file_size must be at least 1 when rotation_policy = \"size\"".into(),
259 );
260 }
261
262 if let Some(0) = self.rotation_check_interval_secs {
263 return Err("[audit].rotation_check_interval_secs must be at least 1 when set".into());
264 }
265
266 Ok(())
267 }
268}