Skip to main content

hyperi_rustlib/logger/
security.rs

1// Project:   hyperi-rustlib
2// File:      src/logger/security.rs
3// Purpose:   Structured security event logging following OWASP Logging Vocabulary
4// Language:  Rust
5//
6// License:   FSL-1.1-ALv2
7// Copyright: (c) 2026 HYPERI PTY LIMITED
8
9//! Structured security event logging following OWASP Logging Vocabulary.
10//!
11//! All security events are emitted with `target: "security"` so operators can
12//! route them separately via `RUST_LOG=security=info` or a dedicated tracing
13//! `Layer` with per-layer filtering.
14//!
15//! ## Example
16//!
17//! ```rust
18//! use hyperi_rustlib::logger::security::{SecurityEvent, SecurityOutcome, auth_failure};
19//! use std::net::{IpAddr, Ipv4Addr};
20//!
21//! // Builder pattern for full control
22//! SecurityEvent::new("auth.failure", "bearer_validate", SecurityOutcome::Failure)
23//!     .actor("svc-collector")
24//!     .source_ip(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)))
25//!     .reason("expired_token")
26//!     .emit();
27//!
28//! // Convenience function for common cases
29//! auth_failure("bearer_validate", "expired_token", Some(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1))));
30//! ```
31
32use std::net::IpAddr;
33
34/// Outcome of a security event.
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum SecurityOutcome {
37    /// Operation succeeded.
38    Success,
39    /// Operation failed (e.g. bad credentials).
40    Failure,
41    /// Access was denied (authorisation).
42    Denied,
43    /// Internal error during security operation.
44    Error,
45}
46
47impl SecurityOutcome {
48    /// Return the outcome as a static string for structured logging.
49    #[must_use]
50    pub fn as_str(self) -> &'static str {
51        match self {
52            Self::Success => "success",
53            Self::Failure => "failure",
54            Self::Denied => "denied",
55            Self::Error => "error",
56        }
57    }
58}
59
60impl std::fmt::Display for SecurityOutcome {
61    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62        f.write_str(self.as_str())
63    }
64}
65
66/// Standard security event types following OWASP Logging Vocabulary.
67///
68/// See: <https://cheatsheetseries.owasp.org/cheatsheets/Logging_Vocabulary_Cheat_Sheet.html>
69///
70/// Uses a builder pattern with mandatory fields in [`SecurityEvent::new`] and
71/// optional context via chained methods. Call [`SecurityEvent::emit`] to write
72/// the event to the `"security"` tracing target.
73pub struct SecurityEvent<'a> {
74    /// Event type (e.g. "auth.login", "access.denied", "config.changed").
75    event_type: &'a str,
76    /// Specific action (e.g. "bearer_validate", "tls_handshake").
77    action: &'a str,
78    /// Whether the action succeeded, failed, was denied, or errored.
79    outcome: SecurityOutcome,
80    /// User or service identity (if known).
81    actor: Option<&'a str>,
82    /// Source IP address of the request.
83    source_ip: Option<IpAddr>,
84    /// Resource that was accessed or modified.
85    resource: Option<&'a str>,
86    /// Reason for failure or denial.
87    reason: Option<&'a str>,
88    /// Additional context.
89    detail: Option<&'a str>,
90}
91
92impl<'a> SecurityEvent<'a> {
93    /// Create a new security event with the required fields.
94    #[must_use]
95    pub fn new(event_type: &'a str, action: &'a str, outcome: SecurityOutcome) -> Self {
96        Self {
97            event_type,
98            action,
99            outcome,
100            actor: None,
101            source_ip: None,
102            resource: None,
103            reason: None,
104            detail: None,
105        }
106    }
107
108    /// Set the actor (user or service identity).
109    #[must_use]
110    pub fn actor(mut self, actor: &'a str) -> Self {
111        self.actor = Some(actor);
112        self
113    }
114
115    /// Set the source IP address.
116    #[must_use]
117    pub fn source_ip(mut self, ip: IpAddr) -> Self {
118        self.source_ip = Some(ip);
119        self
120    }
121
122    /// Set the resource that was accessed or modified.
123    #[must_use]
124    pub fn resource(mut self, resource: &'a str) -> Self {
125        self.resource = Some(resource);
126        self
127    }
128
129    /// Set the reason for failure or denial.
130    #[must_use]
131    pub fn reason(mut self, reason: &'a str) -> Self {
132        self.reason = Some(reason);
133        self
134    }
135
136    /// Set additional context detail.
137    #[must_use]
138    pub fn detail(mut self, detail: &'a str) -> Self {
139        self.detail = Some(detail);
140        self
141    }
142
143    /// Emit the security event via tracing.
144    ///
145    /// Uses `target: "security"` so operators can route security events
146    /// separately via `RUST_LOG=security=info` or a dedicated `Layer` with
147    /// per-layer filtering.
148    ///
149    /// Level mapping:
150    /// - `Success` → `info!`
151    /// - `Failure` / `Denied` → `warn!`
152    /// - `Error` → `error!`
153    pub fn emit(&self) {
154        let source_ip_str = self.source_ip.map(|ip| ip.to_string());
155        let source_ip_ref = source_ip_str.as_deref().unwrap_or("-");
156
157        match self.outcome {
158            SecurityOutcome::Success => {
159                tracing::info!(
160                    target: "security",
161                    event_type = self.event_type,
162                    action = self.action,
163                    outcome = self.outcome.as_str(),
164                    actor = self.actor.unwrap_or("-"),
165                    source_ip = source_ip_ref,
166                    resource = self.resource.unwrap_or("-"),
167                    "security event"
168                );
169            }
170            SecurityOutcome::Failure | SecurityOutcome::Denied => {
171                tracing::warn!(
172                    target: "security",
173                    event_type = self.event_type,
174                    action = self.action,
175                    outcome = self.outcome.as_str(),
176                    actor = self.actor.unwrap_or("-"),
177                    source_ip = source_ip_ref,
178                    resource = self.resource.unwrap_or("-"),
179                    reason = self.reason.unwrap_or("-"),
180                    "security event"
181                );
182            }
183            SecurityOutcome::Error => {
184                tracing::error!(
185                    target: "security",
186                    event_type = self.event_type,
187                    action = self.action,
188                    outcome = self.outcome.as_str(),
189                    actor = self.actor.unwrap_or("-"),
190                    source_ip = source_ip_ref,
191                    resource = self.resource.unwrap_or("-"),
192                    reason = self.reason.unwrap_or("-"),
193                    detail = self.detail.unwrap_or("-"),
194                    "security event"
195                );
196            }
197        }
198    }
199}
200
201// ---------------------------------------------------------------------------
202// Convenience functions for common event types
203// ---------------------------------------------------------------------------
204
205/// Log an authentication success.
206pub fn auth_success(action: &str, actor: &str, source_ip: Option<IpAddr>) {
207    let mut event =
208        SecurityEvent::new("auth.success", action, SecurityOutcome::Success).actor(actor);
209    if let Some(ip) = source_ip {
210        event = event.source_ip(ip);
211    }
212    event.emit();
213}
214
215/// Log an authentication failure.
216pub fn auth_failure(action: &str, reason: &str, source_ip: Option<IpAddr>) {
217    let mut event =
218        SecurityEvent::new("auth.failure", action, SecurityOutcome::Failure).reason(reason);
219    if let Some(ip) = source_ip {
220        event = event.source_ip(ip);
221    }
222    event.emit();
223}
224
225/// Log an access denial.
226pub fn access_denied(action: &str, actor: &str, resource: &str, source_ip: Option<IpAddr>) {
227    let mut event = SecurityEvent::new("access.denied", action, SecurityOutcome::Denied)
228        .actor(actor)
229        .resource(resource);
230    if let Some(ip) = source_ip {
231        event = event.source_ip(ip);
232    }
233    event.emit();
234}
235
236/// Log a configuration change.
237pub fn config_changed(action: &str, actor: &str, detail: &str) {
238    SecurityEvent::new("config.changed", action, SecurityOutcome::Success)
239        .actor(actor)
240        .detail(detail)
241        .emit();
242}
243
244/// Log a TLS/certificate event.
245pub fn tls_event(
246    action: &str,
247    outcome: SecurityOutcome,
248    reason: Option<&str>,
249    source_ip: Option<IpAddr>,
250) {
251    let mut event = SecurityEvent::new("tls.event", action, outcome);
252    if let Some(r) = reason {
253        event = event.reason(r);
254    }
255    if let Some(ip) = source_ip {
256        event = event.source_ip(ip);
257    }
258    event.emit();
259}
260
261/// Log a rate limit trigger.
262pub fn rate_limit_triggered(actor: &str, resource: &str, source_ip: Option<IpAddr>) {
263    let mut event = SecurityEvent::new(
264        "rate_limit.triggered",
265        "rate_limit",
266        SecurityOutcome::Denied,
267    )
268    .actor(actor)
269    .resource(resource);
270    if let Some(ip) = source_ip {
271        event = event.source_ip(ip);
272    }
273    event.emit();
274}
275
276/// Log a token rotation event.
277pub fn token_rotated(action: &str, detail: &str) {
278    SecurityEvent::new("token.rotated", action, SecurityOutcome::Success)
279        .detail(detail)
280        .emit();
281}
282
283/// Log an input validation failure (potential attack indicator per OWASP).
284pub fn input_validation_failure(action: &str, reason: &str, source_ip: Option<IpAddr>) {
285    let mut event =
286        SecurityEvent::new("input.validation_failure", action, SecurityOutcome::Failure)
287            .reason(reason);
288    if let Some(ip) = source_ip {
289        event = event.source_ip(ip);
290    }
291    event.emit();
292}
293
294/// Log a data quality event — record routed to DLQ.
295pub fn record_dlq(action: &str, reason: &str, detail: Option<&str>) {
296    let mut event =
297        SecurityEvent::new("data.dlq_routed", action, SecurityOutcome::Failure).reason(reason);
298    if let Some(d) = detail {
299        event = event.detail(d);
300    }
301    event.emit();
302}
303
304/// Log a data quality event — validation rejection rate threshold exceeded.
305pub fn data_quality_alert(action: &str, detail: &str) {
306    SecurityEvent::new("data.quality_alert", action, SecurityOutcome::Failure)
307        .detail(detail)
308        .emit();
309}
310
311#[cfg(test)]
312mod tests {
313    use super::*;
314    use std::net::{IpAddr, Ipv4Addr};
315
316    #[test]
317    fn test_security_event_builder() {
318        let event = SecurityEvent::new("auth.failure", "bearer_validate", SecurityOutcome::Failure)
319            .actor("user@example.com")
320            .source_ip(IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1)))
321            .resource("/api/data")
322            .reason("invalid_token");
323        // Should not panic
324        event.emit();
325    }
326
327    #[test]
328    fn test_convenience_functions() {
329        let ip = Some(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)));
330        auth_success("bearer_validate", "admin", ip);
331        auth_failure("bearer_validate", "expired_token", ip);
332        access_denied("read", "guest", "/admin", ip);
333        config_changed("reload", "system", "auth config updated");
334        tls_event(
335            "handshake",
336            SecurityOutcome::Failure,
337            Some("cert_expired"),
338            ip,
339        );
340        rate_limit_triggered("client_abc", "/api/ingest", ip);
341        token_rotated("bearer_refresh", "3 tokens loaded from vault");
342        input_validation_failure("json_parse", "invalid_json", ip);
343    }
344
345    #[test]
346    fn test_outcome_as_str() {
347        assert_eq!(SecurityOutcome::Success.as_str(), "success");
348        assert_eq!(SecurityOutcome::Failure.as_str(), "failure");
349        assert_eq!(SecurityOutcome::Denied.as_str(), "denied");
350        assert_eq!(SecurityOutcome::Error.as_str(), "error");
351    }
352
353    #[test]
354    fn test_outcome_display() {
355        assert_eq!(format!("{}", SecurityOutcome::Success), "success");
356        assert_eq!(format!("{}", SecurityOutcome::Error), "error");
357    }
358
359    #[test]
360    fn test_minimal_event() {
361        // Only required fields, no optionals
362        SecurityEvent::new("test.event", "test_action", SecurityOutcome::Success).emit();
363    }
364
365    #[test]
366    fn test_error_outcome_event() {
367        SecurityEvent::new("auth.error", "token_validate", SecurityOutcome::Error)
368            .reason("backend_unavailable")
369            .detail("vault connection timed out after 5s")
370            .source_ip(IpAddr::V4(Ipv4Addr::new(172, 16, 0, 1)))
371            .emit();
372    }
373
374    #[test]
375    fn test_ipv6_source() {
376        let ipv6: IpAddr = "::1".parse().unwrap();
377        SecurityEvent::new("auth.success", "login", SecurityOutcome::Success)
378            .source_ip(ipv6)
379            .actor("admin")
380            .emit();
381    }
382
383    #[test]
384    fn test_no_source_ip() {
385        auth_success("api_key", "svc-internal", None);
386        auth_failure("bearer_validate", "malformed", None);
387    }
388}