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