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    }
130}
131
132/// Generate a compliance report from audit logs for a given time period.
133pub fn generate(audit: &AuditLog, days: u32) -> Result<ComplianceReport> {
134    let now = Utc::now();
135    let cutoff = now - chrono::Duration::days(days as i64);
136
137    let entries = audit.read(None)?;
138    let filtered: Vec<&AuditEntry> = entries.iter().filter(|e| e.timestamp >= cutoff).collect();
139
140    let records: Vec<ComplianceRecord> = filtered.iter().map(|e| flatten(e)).collect();
141
142    let mut providers: Vec<String> = filtered
143        .iter()
144        .map(|e| e.provider.clone())
145        .collect::<std::collections::HashSet<_>>()
146        .into_iter()
147        .collect();
148    providers.sort();
149
150    let total_sessions = records
151        .iter()
152        .filter(|r| r.event_type == "session_created")
153        .count();
154    let failed_sessions = records
155        .iter()
156        .filter(|r| r.event_type == "session_ended" && r.status.as_deref() == Some("failed"))
157        .count();
158
159    Ok(ComplianceReport {
160        generated_at: now,
161        period_start: cutoff,
162        period_end: now,
163        total_records: records.len(),
164        total_sessions,
165        failed_sessions,
166        providers_used: providers,
167        records,
168    })
169}
170
171/// Export report as JSON.
172pub fn export_json(report: &ComplianceReport) -> Result<String> {
173    serde_json::to_string_pretty(report).map_err(|e| {
174        crate::error::AvError::InvalidPolicy(format!("Failed to serialize report: {}", e))
175    })
176}
177
178/// Export report as CSV.
179pub fn export_csv(report: &ComplianceReport) -> String {
180    let mut out = String::new();
181    out.push_str("timestamp,session_id,provider,event_type,identity,ttl_seconds,allowed_actions,command,status,duration_seconds,exit_code\n");
182
183    for r in &report.records {
184        out.push_str(&format!(
185            "{},{},{},{},{},{},{},{},{},{},{}\n",
186            r.timestamp.to_rfc3339(),
187            csv_escape(&r.session_id),
188            csv_escape(&r.provider),
189            csv_escape(&r.event_type),
190            csv_escape(&r.identity),
191            r.ttl_seconds.map_or(String::new(), |v| v.to_string()),
192            r.allowed_actions
193                .as_deref()
194                .map_or(String::new(), csv_escape),
195            r.command.as_deref().map_or(String::new(), csv_escape),
196            r.status.as_deref().map_or(String::new(), csv_escape),
197            r.duration_seconds.map_or(String::new(), |v| v.to_string()),
198            r.exit_code.map_or(String::new(), |v| v.to_string()),
199        ));
200    }
201
202    out
203}
204
205fn csv_escape(s: &str) -> String {
206    if s.contains(',') || s.contains('"') || s.contains('\n') {
207        format!("\"{}\"", s.replace('"', "\"\""))
208    } else {
209        s.to_string()
210    }
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216    use crate::audit::{AuditEntry, AuditEvent};
217
218    fn sample_entries() -> Vec<AuditEntry> {
219        vec![
220            AuditEntry {
221                timestamp: Utc::now(),
222                session_id: "sess-001".to_string(),
223                provider: "aws".to_string(),
224                event: AuditEvent::SessionCreated {
225                    role_arn: "arn:aws:iam::123:role/TestRole".to_string(),
226                    ttl_seconds: 900,
227                    budget: None,
228                    allowed_actions: vec!["s3:GetObject".to_string(), "s3:ListBucket".to_string()],
229                    command: vec!["aws".to_string(), "s3".to_string(), "ls".to_string()],
230                    agent_id: None,
231                },
232            },
233            AuditEntry {
234                timestamp: Utc::now(),
235                session_id: "sess-001".to_string(),
236                provider: "aws".to_string(),
237                event: AuditEvent::SessionEnded {
238                    status: "completed".to_string(),
239                    duration_seconds: 45,
240                    exit_code: Some(0),
241                },
242            },
243        ]
244    }
245
246    #[test]
247    fn test_flatten_session_created() {
248        let entry = &sample_entries()[0];
249        let record = flatten(entry);
250        assert_eq!(record.event_type, "session_created");
251        assert_eq!(record.identity, "arn:aws:iam::123:role/TestRole");
252        assert_eq!(record.ttl_seconds, Some(900));
253        assert!(record.allowed_actions.unwrap().contains("s3:GetObject"));
254    }
255
256    #[test]
257    fn test_flatten_session_ended() {
258        let entry = &sample_entries()[1];
259        let record = flatten(entry);
260        assert_eq!(record.event_type, "session_ended");
261        assert_eq!(record.status, Some("completed".to_string()));
262        assert_eq!(record.duration_seconds, Some(45));
263        assert_eq!(record.exit_code, Some(0));
264    }
265
266    #[test]
267    fn test_csv_escape() {
268        assert_eq!(csv_escape("hello"), "hello");
269        assert_eq!(csv_escape("hello,world"), "\"hello,world\"");
270        assert_eq!(csv_escape("he said \"hi\""), "\"he said \"\"hi\"\"\"");
271    }
272
273    #[test]
274    fn test_compliance_report_serialize() {
275        let report = ComplianceReport {
276            generated_at: Utc::now(),
277            period_start: Utc::now() - chrono::Duration::days(90),
278            period_end: Utc::now(),
279            total_records: 2,
280            total_sessions: 1,
281            failed_sessions: 0,
282            providers_used: vec!["aws".to_string()],
283            records: sample_entries().iter().map(flatten).collect(),
284        };
285
286        let json = export_json(&report).unwrap();
287        assert!(json.contains("total_sessions"));
288        assert!(json.contains("sess-001"));
289
290        let csv = export_csv(&report);
291        assert!(csv.starts_with("timestamp,"));
292        assert!(csv.contains("session_created"));
293        assert!(csv.contains("session_ended"));
294    }
295}