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