vault_audit_tools/commands/
client_activity.rs1use crate::vault_api::{extract_data, should_skip_verify, VaultClient};
39use anyhow::{Context, Result};
40use serde::{Deserialize, Serialize};
41use std::collections::HashMap;
42use std::fs::File;
43use std::io::Read;
44
45#[derive(Debug, Deserialize)]
47struct MountInfo {
48 #[serde(rename = "type")]
49 mount_type: Option<String>,
50 accessor: Option<String>,
51}
52
53#[derive(Debug, Deserialize)]
55struct ActivityRecord {
56 client_id: String,
57 client_type: Option<String>,
58 mount_accessor: Option<String>,
59 mount_path: Option<String>,
60 mount_type: Option<String>,
61 entity_alias_name: Option<String>,
62}
63
64#[derive(Debug, Deserialize)]
65struct EntityMapping {
66 display_name: String,
67 #[allow(dead_code)]
68 mount_path: String,
69 #[allow(dead_code)]
70 mount_accessor: String,
71 #[allow(dead_code)]
72 username: Option<String>,
73 #[allow(dead_code)]
74 login_count: usize,
75 #[allow(dead_code)]
76 first_seen: String,
77 #[allow(dead_code)]
78 last_seen: String,
79}
80
81#[derive(Debug, Serialize)]
82struct MountActivity {
83 mount: String,
84 #[serde(rename = "type")]
85 mount_type: String,
86 accessor: String,
87 #[serde(skip_serializing_if = "Option::is_none")]
88 role: Option<String>,
89 total: usize,
90 entity: usize,
91 non_entity: usize,
92}
93
94pub fn format_number(n: usize) -> String {
95 let s = n.to_string();
96 let mut result = String::new();
97 for (i, c) in s.chars().rev().enumerate() {
98 if i > 0 && i % 3 == 0 {
99 result.push(',');
100 }
101 result.push(c);
102 }
103 result.chars().rev().collect()
104}
105
106#[allow(clippy::too_many_arguments)]
107pub async fn run(
108 start_time: &str,
109 end_time: &str,
110 vault_addr: Option<&str>,
111 vault_token: Option<&str>,
112 insecure: bool,
113 group_by_role: bool,
114 entity_map_path: Option<&str>,
115 output: Option<&str>,
116) -> Result<()> {
117 let skip_verify = should_skip_verify(insecure);
118 let client = VaultClient::from_options(vault_addr, vault_token, skip_verify)?;
119
120 eprintln!("=== Vault Client Activity Analysis ===");
121 eprintln!("Vault Address: {}", client.addr());
122 eprintln!("Time Window: {} to {}", start_time, end_time);
123 if skip_verify {
124 eprintln!("⚠️ TLS certificate verification is DISABLED");
125 }
126 eprintln!();
127
128 let entity_map: Option<HashMap<String, EntityMapping>> = if let Some(path) = entity_map_path {
130 eprintln!("Loading entity mappings from: {}", path);
131 let mut file = File::open(path)
132 .with_context(|| format!("Failed to open entity map file: {}", path))?;
133 let mut contents = String::new();
134 file.read_to_string(&mut contents)?;
135 let map: HashMap<String, EntityMapping> = serde_json::from_str(&contents)
136 .with_context(|| format!("Failed to parse entity map JSON: {}", path))?;
137 eprintln!("Loaded {} entity mappings", map.len());
138 Some(map)
139 } else {
140 None
141 };
142
143 eprintln!("Fetching mount information...");
145 let mount_map = fetch_mount_map(&client).await?;
146 eprintln!("Found {} mounts", mount_map.len());
147
148 eprintln!("Fetching client activity data...");
150 let export_path = format!(
151 "/v1/sys/internal/counters/activity/export?start_time={}&end_time={}&format=json",
152 start_time, end_time
153 );
154
155 let export_text = client.get_text(&export_path).await?;
156
157 let records: Vec<ActivityRecord> = if export_text.trim().starts_with('[') {
159 serde_json::from_str(&export_text)?
161 } else {
162 export_text
164 .lines()
165 .filter(|line| !line.trim().is_empty())
166 .filter_map(|line| serde_json::from_str(line).ok())
167 .collect()
168 };
169
170 if records.is_empty() {
171 eprintln!("No activity data found for the specified time range.");
172 return Ok(());
173 }
174
175 eprintln!(
176 "Processing {} activity records...",
177 format_number(records.len())
178 );
179
180 let mut mount_activities: HashMap<String, MountActivityData> = HashMap::new();
182
183 for record in &records {
184 let accessor = record
185 .mount_accessor
186 .as_deref()
187 .unwrap_or("unknown")
188 .to_string();
189
190 let (mount_path, mount_type) = if let Some(info) = mount_map.get(&accessor) {
191 (info.0.clone(), info.1.clone())
192 } else {
193 (
194 record
195 .mount_path
196 .clone()
197 .unwrap_or_else(|| "unknown".to_string()),
198 record
199 .mount_type
200 .clone()
201 .unwrap_or_else(|| "unknown".to_string()),
202 )
203 };
204
205 let role = if group_by_role {
207 if let Some(alias_name) = &record.entity_alias_name {
209 Some(alias_name.clone())
210 } else if let Some(ref entity_map) = entity_map {
211 entity_map
213 .get(&record.client_id)
214 .map(|e| e.display_name.clone())
215 } else {
216 None
217 }
218 } else {
219 None
220 };
221
222 let key = if group_by_role {
224 format!(
225 "{}|{}|{}|{}",
226 mount_path,
227 mount_type,
228 accessor,
229 role.as_deref().unwrap_or("unknown")
230 )
231 } else {
232 format!("{}|{}|{}", mount_path, mount_type, accessor)
233 };
234
235 let activity = mount_activities
236 .entry(key)
237 .or_insert_with(|| MountActivityData {
238 mount: mount_path,
239 mount_type,
240 accessor,
241 role: role.clone(),
242 total_clients: std::collections::HashSet::new(),
243 entity_clients: std::collections::HashSet::new(),
244 non_entity_clients: std::collections::HashSet::new(),
245 });
246
247 activity.total_clients.insert(record.client_id.clone());
248
249 if record.client_type.as_deref() == Some("entity") {
250 activity.entity_clients.insert(record.client_id.clone());
251 } else {
252 activity.non_entity_clients.insert(record.client_id.clone());
253 }
254 }
255
256 let mut results: Vec<MountActivity> = mount_activities
258 .into_values()
259 .map(|data| {
260 let mount_display = if let Some(ref role) = data.role {
262 format!("{}{}", data.mount, role)
263 } else {
264 data.mount.clone()
265 };
266
267 MountActivity {
268 mount: mount_display,
269 mount_type: data.mount_type,
270 accessor: data.accessor,
271 role: None, total: data.total_clients.len(),
273 entity: data.entity_clients.len(),
274 non_entity: data.non_entity_clients.len(),
275 }
276 })
277 .collect();
278
279 results.sort_by(|a, b| a.mount.cmp(&b.mount));
281
282 let total_clients: usize = results.iter().map(|r| r.total).sum();
284 let total_entity: usize = results.iter().map(|r| r.entity).sum();
285 let total_non_entity: usize = results.iter().map(|r| r.non_entity).sum();
286
287 eprintln!();
288 eprintln!("=== Summary ===");
289 eprintln!("Total Clients: {}", format_number(total_clients));
290 eprintln!(" Entity Clients: {}", format_number(total_entity));
291 eprintln!(" Non-Entity Clients: {}", format_number(total_non_entity));
292 eprintln!("Mounts Analyzed: {}", results.len());
293 eprintln!();
294
295 if let Some(output_path) = output {
297 let file = File::create(output_path)
298 .with_context(|| format!("Failed to create output file: {}", output_path))?;
299 let mut writer = csv::Writer::from_writer(file);
300
301 writer.write_record(["mount", "type", "accessor", "total", "entity", "non_entity"])?;
302 for result in &results {
303 writer.write_record([
304 &result.mount,
305 &result.mount_type,
306 &result.accessor,
307 &result.total.to_string(),
308 &result.entity.to_string(),
309 &result.non_entity.to_string(),
310 ])?;
311 }
312
313 writer.flush()?;
314 eprintln!("CSV written to: {}", output_path);
315 } else {
316 println!("{}", serde_json::to_string_pretty(&results)?);
318 }
319
320 Ok(())
321}
322
323#[derive(Debug)]
324struct MountActivityData {
325 mount: String,
326 mount_type: String,
327 accessor: String,
328 role: Option<String>,
329 total_clients: std::collections::HashSet<String>,
330 entity_clients: std::collections::HashSet<String>,
331 non_entity_clients: std::collections::HashSet<String>,
332}
333
334async fn fetch_mount_map(client: &VaultClient) -> Result<HashMap<String, (String, String)>> {
335 let mut map = HashMap::new();
336
337 if let Ok(mounts_data) = client.get_json("/v1/sys/mounts").await {
339 if let Ok(mounts) = extract_data::<HashMap<String, MountInfo>>(mounts_data) {
340 for (path, info) in mounts {
341 if let Some(accessor) = info.accessor {
342 map.insert(
343 accessor,
344 (
345 path,
346 info.mount_type.unwrap_or_else(|| "unknown".to_string()),
347 ),
348 );
349 }
350 }
351 }
352 }
353
354 if let Ok(auth_data) = client.get_json("/v1/sys/auth").await {
356 if let Ok(auths) = extract_data::<HashMap<String, MountInfo>>(auth_data) {
357 for (path, info) in auths {
358 if let Some(accessor) = info.accessor {
359 map.insert(
360 accessor,
361 (
362 path,
363 info.mount_type.unwrap_or_else(|| "unknown".to_string()),
364 ),
365 );
366 }
367 }
368 }
369 }
370
371 Ok(map)
372}