Skip to main content

tryaudex_core/
compliance.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3
4use crate::audit::{AuditEntry, AuditEvent, AuditLog};
5use crate::error::Result;
6
7/// Compliance report format.
8#[derive(Debug, Clone, Copy)]
9pub enum ReportFormat {
10    /// JSON report with full audit detail
11    Json,
12    /// CSV for spreadsheet import
13    Csv,
14}
15
16/// A flattened audit record suitable for compliance reporting.
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct ComplianceRecord {
19    pub timestamp: DateTime<Utc>,
20    pub session_id: String,
21    pub provider: String,
22    pub event_type: String,
23    pub identity: String,
24    pub ttl_seconds: Option<u64>,
25    pub allowed_actions: Option<String>,
26    pub command: Option<String>,
27    pub status: Option<String>,
28    pub duration_seconds: Option<i64>,
29    pub exit_code: Option<i32>,
30}
31
32/// Compliance report wrapper.
33#[derive(Debug, Serialize, Deserialize)]
34pub struct ComplianceReport {
35    pub generated_at: DateTime<Utc>,
36    pub period_start: DateTime<Utc>,
37    pub period_end: DateTime<Utc>,
38    pub total_records: usize,
39    pub total_sessions: usize,
40    pub failed_sessions: usize,
41    pub providers_used: Vec<String>,
42    pub records: Vec<ComplianceRecord>,
43}
44
45/// Flatten an audit entry into a compliance record.
46fn flatten(entry: &AuditEntry) -> ComplianceRecord {
47    match &entry.event {
48        AuditEvent::SessionCreated {
49            role_arn,
50            ttl_seconds,
51            allowed_actions,
52            command,
53            ..
54        } => ComplianceRecord {
55            timestamp: entry.timestamp,
56            session_id: entry.session_id.clone(),
57            provider: entry.provider.clone(),
58            event_type: "session_created".to_string(),
59            identity: role_arn.clone(),
60            ttl_seconds: Some(*ttl_seconds),
61            allowed_actions: Some(allowed_actions.join(", ")),
62            command: Some(command.join(" ")),
63            status: None,
64            duration_seconds: None,
65            exit_code: None,
66        },
67        AuditEvent::CredentialsIssued {
68            access_key_id,
69            expires_at,
70        } => ComplianceRecord {
71            timestamp: entry.timestamp,
72            session_id: entry.session_id.clone(),
73            provider: entry.provider.clone(),
74            event_type: "credentials_issued".to_string(),
75            identity: access_key_id.clone(),
76            ttl_seconds: None,
77            allowed_actions: None,
78            command: None,
79            status: Some(format!("expires: {}", expires_at.to_rfc3339())),
80            duration_seconds: None,
81            exit_code: None,
82        },
83        AuditEvent::SessionEnded {
84            status,
85            duration_seconds,
86            exit_code,
87        } => ComplianceRecord {
88            timestamp: entry.timestamp,
89            session_id: entry.session_id.clone(),
90            provider: entry.provider.clone(),
91            event_type: "session_ended".to_string(),
92            identity: String::new(),
93            ttl_seconds: None,
94            allowed_actions: None,
95            command: None,
96            status: Some(status.clone()),
97            duration_seconds: Some(*duration_seconds),
98            exit_code: *exit_code,
99        },
100        AuditEvent::BudgetWarning {
101            current_spend,
102            limit,
103        } => ComplianceRecord {
104            timestamp: entry.timestamp,
105            session_id: entry.session_id.clone(),
106            provider: entry.provider.clone(),
107            event_type: "budget_warning".to_string(),
108            identity: String::new(),
109            ttl_seconds: None,
110            allowed_actions: None,
111            command: None,
112            status: Some(format!("${:.2}/${:.2}", current_spend, limit)),
113            duration_seconds: None,
114            exit_code: None,
115        },
116        AuditEvent::BudgetExceeded { final_spend, limit } => ComplianceRecord {
117            timestamp: entry.timestamp,
118            session_id: entry.session_id.clone(),
119            provider: entry.provider.clone(),
120            event_type: "budget_exceeded".to_string(),
121            identity: String::new(),
122            ttl_seconds: None,
123            allowed_actions: None,
124            command: None,
125            status: Some(format!("${:.2}/${:.2}", final_spend, limit)),
126            duration_seconds: None,
127            exit_code: None,
128        },
129        AuditEvent::ResourceCreated {
130            service,
131            resource_type,
132            identifier,
133        } => ComplianceRecord {
134            timestamp: entry.timestamp,
135            session_id: entry.session_id.clone(),
136            provider: entry.provider.clone(),
137            event_type: "resource_created".to_string(),
138            identity: format!("{service}:{resource_type}"),
139            ttl_seconds: None,
140            allowed_actions: None,
141            command: None,
142            status: Some(identifier.clone()),
143            duration_seconds: None,
144            exit_code: None,
145        },
146    }
147}
148
149/// Generate a compliance report from audit logs for a given time period.
150pub fn generate(audit: &AuditLog, days: u32) -> Result<ComplianceReport> {
151    let now = Utc::now();
152    let cutoff = now - chrono::Duration::days(days as i64);
153
154    let entries = audit.read(None)?;
155    let filtered: Vec<&AuditEntry> = entries.iter().filter(|e| e.timestamp >= cutoff).collect();
156
157    let records: Vec<ComplianceRecord> = filtered.iter().map(|e| flatten(e)).collect();
158
159    let mut providers: Vec<String> = filtered
160        .iter()
161        .map(|e| e.provider.clone())
162        .collect::<std::collections::HashSet<_>>()
163        .into_iter()
164        .collect();
165    providers.sort();
166
167    let total_sessions = records
168        .iter()
169        .filter(|r| r.event_type == "session_created")
170        .count();
171    let failed_sessions = records
172        .iter()
173        .filter(|r| r.event_type == "session_ended" && r.status.as_deref() == Some("failed"))
174        .count();
175
176    Ok(ComplianceReport {
177        generated_at: now,
178        period_start: cutoff,
179        period_end: now,
180        total_records: records.len(),
181        total_sessions,
182        failed_sessions,
183        providers_used: providers,
184        records,
185    })
186}
187
188/// Export report as JSON.
189pub fn export_json(report: &ComplianceReport) -> Result<String> {
190    serde_json::to_string_pretty(report).map_err(|e| {
191        crate::error::AvError::InvalidPolicy(format!("Failed to serialize report: {}", e))
192    })
193}
194
195/// Export report as CSV.
196pub fn export_csv(report: &ComplianceReport) -> String {
197    let mut out = String::new();
198    out.push_str("timestamp,session_id,provider,event_type,identity,ttl_seconds,allowed_actions,command,status,duration_seconds,exit_code\n");
199
200    for r in &report.records {
201        out.push_str(&format!(
202            "{},{},{},{},{},{},{},{},{},{},{}\n",
203            r.timestamp.to_rfc3339(),
204            csv_escape(&r.session_id),
205            csv_escape(&r.provider),
206            csv_escape(&r.event_type),
207            csv_escape(&r.identity),
208            r.ttl_seconds.map_or(String::new(), |v| v.to_string()),
209            r.allowed_actions
210                .as_deref()
211                .map_or(String::new(), csv_escape),
212            r.command.as_deref().map_or(String::new(), csv_escape),
213            r.status.as_deref().map_or(String::new(), csv_escape),
214            r.duration_seconds.map_or(String::new(), |v| v.to_string()),
215            r.exit_code.map_or(String::new(), |v| v.to_string()),
216        ));
217    }
218
219    out
220}
221
222fn csv_escape(s: &str) -> String {
223    if s.contains(',') || s.contains('"') || s.contains('\n') {
224        format!("\"{}\"", s.replace('"', "\"\""))
225    } else {
226        s.to_string()
227    }
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233    use crate::audit::{AuditEntry, AuditEvent};
234
235    fn sample_entries() -> Vec<AuditEntry> {
236        vec![
237            AuditEntry {
238                timestamp: Utc::now(),
239                session_id: "sess-001".to_string(),
240                provider: "aws".to_string(),
241                event: AuditEvent::SessionCreated {
242                    role_arn: "arn:aws:iam::123:role/TestRole".to_string(),
243                    ttl_seconds: 900,
244                    budget: None,
245                    allowed_actions: vec!["s3:GetObject".to_string(), "s3:ListBucket".to_string()],
246                    command: vec!["aws".to_string(), "s3".to_string(), "ls".to_string()],
247                    agent_id: None,
248                },
249            },
250            AuditEntry {
251                timestamp: Utc::now(),
252                session_id: "sess-001".to_string(),
253                provider: "aws".to_string(),
254                event: AuditEvent::SessionEnded {
255                    status: "completed".to_string(),
256                    duration_seconds: 45,
257                    exit_code: Some(0),
258                },
259            },
260        ]
261    }
262
263    #[test]
264    fn test_flatten_session_created() {
265        let entry = &sample_entries()[0];
266        let record = flatten(entry);
267        assert_eq!(record.event_type, "session_created");
268        assert_eq!(record.identity, "arn:aws:iam::123:role/TestRole");
269        assert_eq!(record.ttl_seconds, Some(900));
270        assert!(record.allowed_actions.unwrap().contains("s3:GetObject"));
271    }
272
273    #[test]
274    fn test_flatten_session_ended() {
275        let entry = &sample_entries()[1];
276        let record = flatten(entry);
277        assert_eq!(record.event_type, "session_ended");
278        assert_eq!(record.status, Some("completed".to_string()));
279        assert_eq!(record.duration_seconds, Some(45));
280        assert_eq!(record.exit_code, Some(0));
281    }
282
283    #[test]
284    fn test_csv_escape() {
285        assert_eq!(csv_escape("hello"), "hello");
286        assert_eq!(csv_escape("hello,world"), "\"hello,world\"");
287        assert_eq!(csv_escape("he said \"hi\""), "\"he said \"\"hi\"\"\"");
288    }
289
290    #[test]
291    fn test_compliance_report_serialize() {
292        let report = ComplianceReport {
293            generated_at: Utc::now(),
294            period_start: Utc::now() - chrono::Duration::days(90),
295            period_end: Utc::now(),
296            total_records: 2,
297            total_sessions: 1,
298            failed_sessions: 0,
299            providers_used: vec!["aws".to_string()],
300            records: sample_entries().iter().map(flatten).collect(),
301        };
302
303        let json = export_json(&report).unwrap();
304        assert!(json.contains("total_sessions"));
305        assert!(json.contains("sess-001"));
306
307        let csv = export_csv(&report);
308        assert!(csv.starts_with("timestamp,"));
309        assert!(csv.contains("session_created"));
310        assert!(csv.contains("session_ended"));
311    }
312}