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 { 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
126pub 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
168pub 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
175pub 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}