1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3
4use crate::audit::{AuditEntry, AuditEvent, AuditLog};
5use crate::error::Result;
6
7#[derive(Debug, Clone, Copy)]
9pub enum ReportFormat {
10 Json,
12 Csv,
14}
15
16#[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#[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
45fn 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
149pub 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
188pub 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
195pub 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}