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 }
130}
131
132pub 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
171pub 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
178pub 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}