oxify_authz/
anomaly.rs

1//! Anomaly Detection for Authorization
2//!
3//! This module provides ML-powered anomaly detection to identify suspicious access patterns
4//! such as unusual permission checks, privilege escalation attempts, and abnormal behavior.
5//!
6//! # Features
7//! - Track access patterns (frequency, time, resources)
8//! - Build baseline models of normal behavior
9//! - Detect deviations using statistical methods
10//! - Generate alerts for suspicious patterns
11//!
12//! # Example
13//! ```rust,ignore
14//! use oxify_authz::anomaly::{AnomalyDetector, AnomalyConfig, AccessEvent};
15//! use std::time::Duration;
16//!
17//! let config = AnomalyConfig::default();
18//! let mut detector = AnomalyDetector::new(config);
19//!
20//! // Record access events
21//! let event = AccessEvent {
22//!     subject_id: "user:alice".to_string(),
23//!     resource_id: "doc:sensitive".to_string(),
24//!     relation: "read".to_string(),
25//!     granted: true,
26//!     timestamp: std::time::SystemTime::now(),
27//! };
28//!
29//! // Check for anomalies
30//! if let Some(anomaly) = detector.check_anomaly(&event) {
31//!     println!("Anomaly detected: {:?}", anomaly);
32//! }
33//! ```
34
35use serde::{Deserialize, Serialize};
36use std::collections::HashMap;
37use std::time::{Duration, SystemTime};
38
39/// Configuration for anomaly detection
40#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct AnomalyConfig {
42    /// Minimum number of events before anomaly detection kicks in
43    pub min_baseline_events: usize,
44
45    /// Z-score threshold for statistical anomalies (typically 2.0-3.0)
46    pub zscore_threshold: f64,
47
48    /// Time window for frequency analysis
49    pub frequency_window: Duration,
50
51    /// Maximum allowed access rate (events per minute)
52    pub max_access_rate: f64,
53
54    /// Enable temporal anomaly detection (unusual hours)
55    pub enable_temporal_detection: bool,
56
57    /// Enable privilege escalation detection
58    pub enable_privilege_escalation: bool,
59
60    /// Retention period for historical data
61    pub retention_period: Duration,
62}
63
64impl Default for AnomalyConfig {
65    fn default() -> Self {
66        Self {
67            min_baseline_events: 100,
68            zscore_threshold: 2.5,
69            frequency_window: Duration::from_secs(3600), // 1 hour
70            max_access_rate: 100.0,                      // 100 requests per minute
71            enable_temporal_detection: true,
72            enable_privilege_escalation: true,
73            retention_period: Duration::from_secs(30 * 24 * 3600), // 30 days
74        }
75    }
76}
77
78/// An access event to be analyzed
79#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct AccessEvent {
81    pub subject_id: String,
82    pub resource_id: String,
83    pub relation: String,
84    pub granted: bool,
85    pub timestamp: SystemTime,
86}
87
88/// Type of anomaly detected
89#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
90pub enum AnomalyType {
91    /// Unusual access frequency (statistical outlier)
92    UnusualFrequency,
93
94    /// Access at unusual time (e.g., 3 AM when user normally works 9-5)
95    UnusualTime,
96
97    /// Accessing resource user rarely or never accessed before
98    UnusualResource,
99
100    /// Too many denied permission checks (potential privilege escalation)
101    PrivilegeEscalation,
102
103    /// Burst of requests exceeding rate limit
104    RateLimitExceeded,
105
106    /// Multiple anomaly indicators combined
107    Combined(Vec<AnomalyType>),
108}
109
110/// Details about a detected anomaly
111#[derive(Debug, Clone, Serialize, Deserialize)]
112pub struct Anomaly {
113    pub anomaly_type: AnomalyType,
114    pub subject_id: String,
115    pub resource_id: String,
116    pub severity: f64, // 0.0 to 1.0
117    pub description: String,
118    pub timestamp: SystemTime,
119}
120
121/// Statistics for a subject's access pattern
122#[derive(Debug, Clone)]
123struct SubjectStats {
124    total_events: usize,
125    denied_events: usize,
126    resource_access_count: HashMap<String, usize>,
127    hourly_distribution: [usize; 24],
128    recent_events: Vec<SystemTime>,
129}
130
131impl SubjectStats {
132    fn new() -> Self {
133        Self {
134            total_events: 0,
135            denied_events: 0,
136            resource_access_count: HashMap::new(),
137            hourly_distribution: [0; 24],
138            recent_events: Vec::new(),
139        }
140    }
141}
142
143/// Main anomaly detector
144pub struct AnomalyDetector {
145    config: AnomalyConfig,
146    subject_stats: HashMap<String, SubjectStats>,
147}
148
149impl AnomalyDetector {
150    /// Create a new anomaly detector with the given configuration
151    pub fn new(config: AnomalyConfig) -> Self {
152        Self {
153            config,
154            subject_stats: HashMap::new(),
155        }
156    }
157
158    /// Record an access event and check for anomalies
159    pub fn check_anomaly(&mut self, event: &AccessEvent) -> Option<Anomaly> {
160        // Update statistics
161        self.update_stats(event);
162
163        // Get or create stats for this subject
164        let stats = self.subject_stats.get(&event.subject_id)?;
165
166        // Collect detected anomalies
167        let mut detected = Vec::new();
168
169        // Skip anomaly detection if we don't have enough baseline data
170        if stats.total_events < self.config.min_baseline_events {
171            return None;
172        }
173
174        // 1. Check for unusual frequency
175        if let Some(freq_anomaly) = self.check_frequency_anomaly(event, stats) {
176            detected.push(freq_anomaly);
177        }
178
179        // 2. Check for unusual time
180        if self.config.enable_temporal_detection {
181            if let Some(time_anomaly) = self.check_temporal_anomaly(event, stats) {
182                detected.push(time_anomaly);
183            }
184        }
185
186        // 3. Check for unusual resource access
187        if let Some(resource_anomaly) = self.check_resource_anomaly(event, stats) {
188            detected.push(resource_anomaly);
189        }
190
191        // 4. Check for privilege escalation
192        if self.config.enable_privilege_escalation {
193            if let Some(privesc_anomaly) = self.check_privilege_escalation(event, stats) {
194                detected.push(privesc_anomaly);
195            }
196        }
197
198        // 5. Check for rate limit exceeded
199        if let Some(rate_anomaly) = self.check_rate_limit(event, stats) {
200            detected.push(rate_anomaly);
201        }
202
203        // Return combined anomaly if any detected
204        if !detected.is_empty() {
205            let severity = detected
206                .iter()
207                .map(|a| a.severity)
208                .max_by(|a, b| a.partial_cmp(b).unwrap())
209                .unwrap_or(0.0);
210
211            let anomaly_type = if detected.len() == 1 {
212                detected[0].anomaly_type.clone()
213            } else {
214                AnomalyType::Combined(detected.iter().map(|a| a.anomaly_type.clone()).collect())
215            };
216
217            Some(Anomaly {
218                anomaly_type,
219                subject_id: event.subject_id.clone(),
220                resource_id: event.resource_id.clone(),
221                severity,
222                description: format!("Multiple anomalies detected: {} indicators", detected.len()),
223                timestamp: event.timestamp,
224            })
225        } else {
226            None
227        }
228    }
229
230    fn update_stats(&mut self, event: &AccessEvent) {
231        let stats = self
232            .subject_stats
233            .entry(event.subject_id.clone())
234            .or_insert_with(SubjectStats::new);
235
236        stats.total_events += 1;
237        if !event.granted {
238            stats.denied_events += 1;
239        }
240
241        *stats
242            .resource_access_count
243            .entry(event.resource_id.clone())
244            .or_insert(0) += 1;
245
246        // Update hourly distribution
247        if let Ok(duration) = event.timestamp.duration_since(SystemTime::UNIX_EPOCH) {
248            let hour = ((duration.as_secs() / 3600) % 24) as usize;
249            stats.hourly_distribution[hour] += 1;
250        }
251
252        // Track recent events for rate limiting
253        stats.recent_events.push(event.timestamp);
254
255        // Clean up old events
256        let cutoff = event
257            .timestamp
258            .checked_sub(self.config.retention_period)
259            .unwrap_or(SystemTime::UNIX_EPOCH);
260        stats.recent_events.retain(|&t| t >= cutoff);
261    }
262
263    fn check_frequency_anomaly(
264        &self,
265        event: &AccessEvent,
266        stats: &SubjectStats,
267    ) -> Option<Anomaly> {
268        // Calculate access frequency for this resource
269        let resource_count = *stats
270            .resource_access_count
271            .get(&event.resource_id)
272            .unwrap_or(&0);
273
274        // Calculate mean and std dev of all resource access counts
275        let counts: Vec<usize> = stats.resource_access_count.values().copied().collect();
276        if counts.is_empty() {
277            return None;
278        }
279
280        let mean = counts.iter().sum::<usize>() as f64 / counts.len() as f64;
281        let variance = counts
282            .iter()
283            .map(|&c| {
284                let diff = c as f64 - mean;
285                diff * diff
286            })
287            .sum::<f64>()
288            / counts.len() as f64;
289        let std_dev = variance.sqrt();
290
291        // Calculate z-score
292        if std_dev == 0.0 {
293            return None;
294        }
295
296        let zscore = (resource_count as f64 - mean) / std_dev;
297
298        if zscore.abs() > self.config.zscore_threshold {
299            let severity = (zscore.abs() / self.config.zscore_threshold).min(1.0);
300            Some(Anomaly {
301                anomaly_type: AnomalyType::UnusualFrequency,
302                subject_id: event.subject_id.clone(),
303                resource_id: event.resource_id.clone(),
304                severity,
305                description: format!("Unusual access frequency (z-score: {:.2})", zscore),
306                timestamp: event.timestamp,
307            })
308        } else {
309            None
310        }
311    }
312
313    fn check_temporal_anomaly(&self, event: &AccessEvent, stats: &SubjectStats) -> Option<Anomaly> {
314        // Extract hour from timestamp
315        let hour = if let Ok(duration) = event.timestamp.duration_since(SystemTime::UNIX_EPOCH) {
316            ((duration.as_secs() / 3600) % 24) as usize
317        } else {
318            return None;
319        };
320
321        // Check if this hour is unusual for this user
322        let total_accesses: usize = stats.hourly_distribution.iter().sum();
323        if total_accesses == 0 {
324            return None;
325        }
326
327        let expected_proportion = stats.hourly_distribution[hour] as f64 / total_accesses as f64;
328
329        // If user has never or rarely accessed at this hour (< 5% of accesses)
330        if expected_proportion < 0.05 {
331            Some(Anomaly {
332                anomaly_type: AnomalyType::UnusualTime,
333                subject_id: event.subject_id.clone(),
334                resource_id: event.resource_id.clone(),
335                severity: 1.0 - expected_proportion,
336                description: format!(
337                    "Access at unusual hour: {}:00 (only {:.1}% of normal activity)",
338                    hour,
339                    expected_proportion * 100.0
340                ),
341                timestamp: event.timestamp,
342            })
343        } else {
344            None
345        }
346    }
347
348    fn check_resource_anomaly(&self, event: &AccessEvent, stats: &SubjectStats) -> Option<Anomaly> {
349        let resource_count = *stats
350            .resource_access_count
351            .get(&event.resource_id)
352            .unwrap_or(&0);
353
354        // First-time access to a new resource
355        if resource_count <= 1 && stats.total_events > self.config.min_baseline_events {
356            Some(Anomaly {
357                anomaly_type: AnomalyType::UnusualResource,
358                subject_id: event.subject_id.clone(),
359                resource_id: event.resource_id.clone(),
360                severity: 0.6,
361                description: "First-time access to this resource".to_string(),
362                timestamp: event.timestamp,
363            })
364        } else {
365            None
366        }
367    }
368
369    fn check_privilege_escalation(
370        &self,
371        event: &AccessEvent,
372        stats: &SubjectStats,
373    ) -> Option<Anomaly> {
374        if !event.granted && stats.total_events > 0 {
375            let denial_rate = stats.denied_events as f64 / stats.total_events as f64;
376
377            // High denial rate indicates potential privilege escalation attempts
378            if denial_rate > 0.3 {
379                Some(Anomaly {
380                    anomaly_type: AnomalyType::PrivilegeEscalation,
381                    subject_id: event.subject_id.clone(),
382                    resource_id: event.resource_id.clone(),
383                    severity: denial_rate.min(1.0),
384                    description: format!(
385                        "High denial rate: {:.1}% of checks denied",
386                        denial_rate * 100.0
387                    ),
388                    timestamp: event.timestamp,
389                })
390            } else {
391                None
392            }
393        } else {
394            None
395        }
396    }
397
398    fn check_rate_limit(&self, event: &AccessEvent, stats: &SubjectStats) -> Option<Anomaly> {
399        // Count events in the last minute
400        let one_minute_ago = event
401            .timestamp
402            .checked_sub(Duration::from_secs(60))
403            .unwrap_or(SystemTime::UNIX_EPOCH);
404        let recent_count = stats
405            .recent_events
406            .iter()
407            .filter(|&&t| t >= one_minute_ago)
408            .count();
409
410        if recent_count as f64 > self.config.max_access_rate {
411            Some(Anomaly {
412                anomaly_type: AnomalyType::RateLimitExceeded,
413                subject_id: event.subject_id.clone(),
414                resource_id: event.resource_id.clone(),
415                severity: ((recent_count as f64 / self.config.max_access_rate) - 1.0).min(1.0),
416                description: format!(
417                    "Rate limit exceeded: {} requests in last minute",
418                    recent_count
419                ),
420                timestamp: event.timestamp,
421            })
422        } else {
423            None
424        }
425    }
426
427    /// Get statistics for a specific subject
428    pub fn get_subject_stats(&self, subject_id: &str) -> Option<AnomalyStats> {
429        let stats = self.subject_stats.get(subject_id)?;
430
431        Some(AnomalyStats {
432            total_events: stats.total_events,
433            denied_events: stats.denied_events,
434            unique_resources: stats.resource_access_count.len(),
435            denial_rate: if stats.total_events > 0 {
436                stats.denied_events as f64 / stats.total_events as f64
437            } else {
438                0.0
439            },
440        })
441    }
442
443    /// Clear old statistics to free memory
444    pub fn cleanup(&mut self, cutoff: SystemTime) {
445        for stats in self.subject_stats.values_mut() {
446            stats.recent_events.retain(|&t| t >= cutoff);
447        }
448
449        // Remove subjects with no recent activity
450        self.subject_stats
451            .retain(|_, stats| !stats.recent_events.is_empty());
452    }
453}
454
455/// Aggregated statistics for a subject
456#[derive(Debug, Clone, Serialize, Deserialize)]
457pub struct AnomalyStats {
458    pub total_events: usize,
459    pub denied_events: usize,
460    pub unique_resources: usize,
461    pub denial_rate: f64,
462}
463
464#[cfg(test)]
465mod tests {
466    use super::*;
467
468    #[test]
469    fn test_anomaly_detector_creation() {
470        let config = AnomalyConfig::default();
471        let detector = AnomalyDetector::new(config);
472        assert_eq!(detector.subject_stats.len(), 0);
473    }
474
475    #[test]
476    fn test_baseline_building() {
477        let config = AnomalyConfig {
478            min_baseline_events: 10,
479            ..Default::default()
480        };
481        let mut detector = AnomalyDetector::new(config);
482
483        // Build baseline with normal events
484        for i in 0..20 {
485            let event = AccessEvent {
486                subject_id: "user:alice".to_string(),
487                resource_id: format!("doc:{}", i % 5),
488                relation: "read".to_string(),
489                granted: true,
490                timestamp: SystemTime::now(),
491            };
492            let _result = detector.check_anomaly(&event);
493        }
494
495        let stats = detector.get_subject_stats("user:alice").unwrap();
496        assert_eq!(stats.total_events, 20);
497        assert_eq!(stats.unique_resources, 5);
498    }
499
500    #[test]
501    fn test_unusual_resource_detection() {
502        let config = AnomalyConfig {
503            min_baseline_events: 10,
504            ..Default::default()
505        };
506        let mut detector = AnomalyDetector::new(config);
507
508        // Build baseline with normal events
509        for _i in 0..15 {
510            let event = AccessEvent {
511                subject_id: "user:bob".to_string(),
512                resource_id: "doc:normal".to_string(),
513                relation: "read".to_string(),
514                granted: true,
515                timestamp: SystemTime::now(),
516            };
517            let _result = detector.check_anomaly(&event);
518            std::thread::sleep(Duration::from_millis(10));
519        }
520
521        // Access unusual resource
522        let unusual_event = AccessEvent {
523            subject_id: "user:bob".to_string(),
524            resource_id: "doc:sensitive".to_string(),
525            relation: "read".to_string(),
526            granted: true,
527            timestamp: SystemTime::now(),
528        };
529
530        let anomaly = detector.check_anomaly(&unusual_event);
531        assert!(anomaly.is_some());
532        let anomaly = anomaly.unwrap();
533        assert_eq!(anomaly.anomaly_type, AnomalyType::UnusualResource);
534    }
535
536    #[test]
537    fn test_privilege_escalation_detection() {
538        let config = AnomalyConfig {
539            min_baseline_events: 10,
540            enable_privilege_escalation: true,
541            ..Default::default()
542        };
543        let mut detector = AnomalyDetector::new(config);
544
545        // Build baseline with many denied events
546        for _i in 0..15 {
547            let event = AccessEvent {
548                subject_id: "user:eve".to_string(),
549                resource_id: format!("doc:{}", _i),
550                relation: "admin".to_string(),
551                granted: _i % 2 == 0, // 50% denial rate
552                timestamp: SystemTime::now(),
553            };
554            let _result = detector.check_anomaly(&event);
555        }
556
557        // Another denied event should trigger anomaly
558        let denied_event = AccessEvent {
559            subject_id: "user:eve".to_string(),
560            resource_id: "doc:admin".to_string(),
561            relation: "admin".to_string(),
562            granted: false,
563            timestamp: SystemTime::now(),
564        };
565
566        let anomaly = detector.check_anomaly(&denied_event);
567        assert!(anomaly.is_some());
568        let anomaly = anomaly.unwrap();
569        match anomaly.anomaly_type {
570            AnomalyType::PrivilegeEscalation => {}
571            AnomalyType::Combined(types) => {
572                assert!(types.contains(&AnomalyType::PrivilegeEscalation));
573            }
574            _ => panic!("Expected PrivilegeEscalation anomaly"),
575        }
576    }
577
578    #[test]
579    fn test_rate_limit_detection() {
580        let config = AnomalyConfig {
581            min_baseline_events: 10,
582            max_access_rate: 5.0,
583            ..Default::default()
584        };
585        let mut detector = AnomalyDetector::new(config);
586
587        // Build baseline
588        for _ in 0..15 {
589            let event = AccessEvent {
590                subject_id: "user:charlie".to_string(),
591                resource_id: "doc:test".to_string(),
592                relation: "read".to_string(),
593                granted: true,
594                timestamp: SystemTime::now(),
595            };
596            detector.check_anomaly(&event);
597            std::thread::sleep(Duration::from_millis(200)); // Spread out over time
598        }
599
600        // Send burst of requests
601        for _ in 0..10 {
602            let event = AccessEvent {
603                subject_id: "user:charlie".to_string(),
604                resource_id: "doc:test".to_string(),
605                relation: "read".to_string(),
606                granted: true,
607                timestamp: SystemTime::now(),
608            };
609            let anomaly = detector.check_anomaly(&event);
610            if let Some(anomaly) = anomaly {
611                match anomaly.anomaly_type {
612                    AnomalyType::RateLimitExceeded => return,
613                    AnomalyType::Combined(types) => {
614                        if types.contains(&AnomalyType::RateLimitExceeded) {
615                            return;
616                        }
617                    }
618                    _ => {}
619                }
620            }
621        }
622
623        panic!("Expected RateLimitExceeded anomaly");
624    }
625
626    #[test]
627    fn test_cleanup() {
628        let config = AnomalyConfig::default();
629        let mut detector = AnomalyDetector::new(config);
630
631        // Add some events
632        for i in 0..10 {
633            let event = AccessEvent {
634                subject_id: format!("user:{}", i),
635                resource_id: "doc:test".to_string(),
636                relation: "read".to_string(),
637                granted: true,
638                timestamp: SystemTime::now(),
639            };
640            detector.check_anomaly(&event);
641        }
642
643        assert_eq!(detector.subject_stats.len(), 10);
644
645        // Cleanup old events
646        let future = SystemTime::now() + Duration::from_secs(3600);
647        detector.cleanup(future);
648
649        assert_eq!(detector.subject_stats.len(), 0);
650    }
651}