Skip to main content

libro/
query.rs

1//! Query filters for audit entries.
2//!
3//! Provides a composable [`QueryFilter`] that works across all backends.
4//! In-memory filtering is used for `AuditChain` and `FileStore`;
5//! `SqliteStore` translates filters to SQL WHERE clauses.
6
7use chrono::{DateTime, Utc};
8
9use crate::entry::{AuditEntry, EventSeverity};
10
11/// A composable filter for querying audit entries.
12///
13/// All set fields are ANDed together. Unset fields (None) are ignored.
14///
15/// ```
16/// use libro::query::QueryFilter;
17/// use libro::EventSeverity;
18///
19/// let filter = QueryFilter::new()
20///     .source("daimon")
21///     .severity(EventSeverity::Security);
22/// ```
23#[derive(Debug, Clone, Default)]
24pub struct QueryFilter {
25    pub(crate) source: Option<String>,
26    pub(crate) severity: Option<EventSeverity>,
27    pub(crate) agent_id: Option<String>,
28    pub(crate) after: Option<DateTime<Utc>>,
29    pub(crate) before: Option<DateTime<Utc>>,
30    pub(crate) action: Option<String>,
31    pub(crate) min_severity: Option<EventSeverity>,
32}
33
34impl QueryFilter {
35    pub fn new() -> Self {
36        Self::default()
37    }
38
39    /// Filter by source.
40    pub fn source(mut self, source: impl Into<String>) -> Self {
41        self.source = Some(source.into());
42        self
43    }
44
45    /// Filter by severity level.
46    pub fn severity(mut self, severity: EventSeverity) -> Self {
47        self.severity = Some(severity);
48        self
49    }
50
51    /// Filter by agent ID.
52    pub fn agent_id(mut self, agent_id: impl Into<String>) -> Self {
53        self.agent_id = Some(agent_id.into());
54        self
55    }
56
57    /// Filter to entries after this timestamp (exclusive).
58    pub fn after(mut self, after: DateTime<Utc>) -> Self {
59        self.after = Some(after);
60        self
61    }
62
63    /// Filter to entries before this timestamp (exclusive).
64    pub fn before(mut self, before: DateTime<Utc>) -> Self {
65        self.before = Some(before);
66        self
67    }
68
69    /// Filter to entries with severity >= the given level.
70    pub fn min_severity(mut self, min: EventSeverity) -> Self {
71        self.min_severity = Some(min);
72        self
73    }
74
75    /// Filter by action.
76    pub fn action(mut self, action: impl Into<String>) -> Self {
77        self.action = Some(action.into());
78        self
79    }
80
81    /// Test whether a single entry matches this filter.
82    pub fn matches(&self, entry: &AuditEntry) -> bool {
83        if let Some(ref s) = self.source
84            && entry.source() != s
85        {
86            return false;
87        }
88        if let Some(sev) = self.severity
89            && entry.severity() != sev
90        {
91            return false;
92        }
93        if let Some(ref a) = self.agent_id
94            && entry.agent_id() != Some(a.as_str())
95        {
96            return false;
97        }
98        if let Some(ref a) = self.action
99            && entry.action() != a
100        {
101            return false;
102        }
103        if let Some(min) = self.min_severity
104            && entry.severity() < min
105        {
106            return false;
107        }
108        if let Some(after) = self.after
109            && entry.timestamp() <= after
110        {
111            return false;
112        }
113        if let Some(before) = self.before
114            && entry.timestamp() >= before
115        {
116            return false;
117        }
118        true
119    }
120
121    /// Filter a slice of entries, returning references to matches.
122    pub fn apply<'a>(&self, entries: &'a [AuditEntry]) -> Vec<&'a AuditEntry> {
123        entries.iter().filter(|e| self.matches(e)).collect()
124    }
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130
131    fn make_chain() -> Vec<AuditEntry> {
132        let e1 = AuditEntry::new(
133            EventSeverity::Info,
134            "daimon",
135            "agent.start",
136            serde_json::json!({}),
137            "",
138        )
139        .with_agent("agent-01");
140        let e2 = AuditEntry::new(
141            EventSeverity::Security,
142            "aegis",
143            "alert",
144            serde_json::json!({}),
145            e1.hash(),
146        )
147        .with_agent("agent-01");
148        let e3 = AuditEntry::new(
149            EventSeverity::Info,
150            "daimon",
151            "agent.stop",
152            serde_json::json!({}),
153            e2.hash(),
154        )
155        .with_agent("agent-02");
156        vec![e1, e2, e3]
157    }
158
159    #[test]
160    fn filter_by_source() {
161        let entries = make_chain();
162        let results = QueryFilter::new().source("daimon").apply(&entries);
163        assert_eq!(results.len(), 2);
164    }
165
166    #[test]
167    fn filter_by_severity() {
168        let entries = make_chain();
169        let results = QueryFilter::new()
170            .severity(EventSeverity::Security)
171            .apply(&entries);
172        assert_eq!(results.len(), 1);
173        assert_eq!(results[0].source(), "aegis");
174    }
175
176    #[test]
177    fn filter_by_agent() {
178        let entries = make_chain();
179        let results = QueryFilter::new().agent_id("agent-01").apply(&entries);
180        assert_eq!(results.len(), 2);
181    }
182
183    #[test]
184    fn filter_by_action() {
185        let entries = make_chain();
186        let results = QueryFilter::new().action("alert").apply(&entries);
187        assert_eq!(results.len(), 1);
188    }
189
190    #[test]
191    fn filter_combined() {
192        let entries = make_chain();
193        let results = QueryFilter::new()
194            .source("daimon")
195            .agent_id("agent-01")
196            .apply(&entries);
197        assert_eq!(results.len(), 1);
198        assert_eq!(results[0].action(), "agent.start");
199    }
200
201    #[test]
202    fn filter_by_time_range() {
203        let entries = make_chain();
204        // All entries created in rapid succession — use first entry's timestamp as boundary
205        let after = entries[0].timestamp();
206        let results = QueryFilter::new().after(after).apply(&entries);
207        // Entries after the first (exclusive)
208        assert!(results.iter().all(|e| e.timestamp() > after));
209    }
210
211    #[test]
212    fn filter_no_criteria_matches_all() {
213        let entries = make_chain();
214        let results = QueryFilter::new().apply(&entries);
215        assert_eq!(results.len(), 3);
216    }
217
218    #[test]
219    fn filter_before_timestamp() {
220        let entries = make_chain();
221        let last_ts = entries[2].timestamp();
222        let results = QueryFilter::new().before(last_ts).apply(&entries);
223        assert!(results.iter().all(|e| e.timestamp() < last_ts));
224    }
225
226    #[test]
227    fn filter_min_severity() {
228        let entries = make_chain();
229        // Security is the highest, so min_severity=Security should only match aegis
230        let results = QueryFilter::new()
231            .min_severity(EventSeverity::Security)
232            .apply(&entries);
233        assert_eq!(results.len(), 1);
234        assert_eq!(results[0].source(), "aegis");
235
236        // min_severity=Info should match all (all are Info or Security)
237        let results = QueryFilter::new()
238            .min_severity(EventSeverity::Info)
239            .apply(&entries);
240        assert_eq!(results.len(), 3);
241    }
242
243    #[test]
244    fn filter_no_matches() {
245        let entries = make_chain();
246        let results = QueryFilter::new().source("nonexistent").apply(&entries);
247        assert!(results.is_empty());
248    }
249}