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 vault_namespace: Option<&str>,
102 insecure: bool,
103 group_by_role: bool,
104 entity_map_path: Option<&str>,
105 output: Option<&str>,
106) -> Result<()> {
107 let skip_verify = should_skip_verify(insecure);
108 let client = VaultClient::from_options(vault_addr, vault_token, vault_namespace, skip_verify)?;
109
110 eprintln!("=== Vault Client Activity Analysis ===");
111 eprintln!("Vault Address: {}", client.addr());
112 eprintln!("Time Window: {} to {}", start_time, end_time);
113 if skip_verify {
114 eprintln!("⚠️ TLS certificate verification is DISABLED");
115 }
116 eprintln!();
117
118 let entity_map: Option<HashMap<String, EntityMapping>> = if let Some(path) = entity_map_path {
120 eprintln!("Loading entity mappings from: {}", path);
121 let mut file = File::open(path)
122 .with_context(|| format!("Failed to open entity map file: {}", path))?;
123 let mut contents = String::new();
124 file.read_to_string(&mut contents)?;
125 let map: HashMap<String, EntityMapping> = serde_json::from_str(&contents)
126 .with_context(|| format!("Failed to parse entity map JSON: {}", path))?;
127 eprintln!("Loaded {} entity mappings", map.len());
128 Some(map)
129 } else {
130 None
131 };
132
133 eprintln!("Fetching mount information...");
135 let mount_map = fetch_mount_map(&client).await?;
136 eprintln!("Found {} mounts", mount_map.len());
137
138 eprintln!("Fetching client activity data...");
140 let export_path = format!(
141 "/v1/sys/internal/counters/activity/export?start_time={}&end_time={}&format=json",
142 start_time, end_time
143 );
144
145 let export_text = client.get_text(&export_path).await?;
146
147 let records: Vec<ActivityRecord> = if export_text.trim().starts_with('[') {
149 serde_json::from_str(&export_text)?
151 } else {
152 export_text
154 .lines()
155 .filter(|line| !line.trim().is_empty())
156 .filter_map(|line| serde_json::from_str(line).ok())
157 .collect()
158 };
159
160 if records.is_empty() {
161 eprintln!("No activity data found for the specified time range.");
162 return Ok(());
163 }
164
165 eprintln!(
166 "Processing {} activity records...",
167 format_number(records.len())
168 );
169
170 let mut mount_activities: HashMap<String, MountActivityData> = HashMap::new();
172
173 for record in &records {
174 let accessor = record
175 .mount_accessor
176 .as_deref()
177 .unwrap_or("unknown")
178 .to_string();
179
180 let (mount_path, mount_type) = if let Some(info) = mount_map.get(&accessor) {
181 (info.0.clone(), info.1.clone())
182 } else {
183 (
184 record
185 .mount_path
186 .clone()
187 .unwrap_or_else(|| "unknown".to_string()),
188 record
189 .mount_type
190 .clone()
191 .unwrap_or_else(|| "unknown".to_string()),
192 )
193 };
194
195 let role = if group_by_role {
197 if let Some(alias_name) = &record.entity_alias_name {
199 Some(alias_name.clone())
200 } else if let Some(ref entity_map) = entity_map {
201 entity_map
203 .get(&record.client_id)
204 .map(|e| e.display_name.clone())
205 } else {
206 None
207 }
208 } else {
209 None
210 };
211
212 let key = if group_by_role {
214 format!(
215 "{}|{}|{}|{}",
216 mount_path,
217 mount_type,
218 accessor,
219 role.as_deref().unwrap_or("unknown")
220 )
221 } else {
222 format!("{}|{}|{}", mount_path, mount_type, accessor)
223 };
224
225 let activity = mount_activities
226 .entry(key)
227 .or_insert_with(|| MountActivityData {
228 mount: mount_path,
229 mount_type,
230 accessor,
231 role: role.clone(),
232 total_clients: std::collections::HashSet::new(),
233 entity_clients: std::collections::HashSet::new(),
234 non_entity_clients: std::collections::HashSet::new(),
235 });
236
237 activity.total_clients.insert(record.client_id.clone());
238
239 if record.client_type.as_deref() == Some("entity") {
240 activity.entity_clients.insert(record.client_id.clone());
241 } else {
242 activity.non_entity_clients.insert(record.client_id.clone());
243 }
244 }
245
246 let mut results: Vec<MountActivity> = mount_activities
248 .into_values()
249 .map(|data| {
250 let mount_display = if let Some(ref role) = data.role {
252 format!("{}{}", data.mount, role)
253 } else {
254 data.mount.clone()
255 };
256
257 MountActivity {
258 mount: mount_display,
259 mount_type: data.mount_type,
260 accessor: data.accessor,
261 role: None, total: data.total_clients.len(),
263 entity: data.entity_clients.len(),
264 non_entity: data.non_entity_clients.len(),
265 }
266 })
267 .collect();
268
269 results.sort_by(|a, b| a.mount.cmp(&b.mount));
271
272 let total_clients: usize = results.iter().map(|r| r.total).sum();
274 let total_entity: usize = results.iter().map(|r| r.entity).sum();
275 let total_non_entity: usize = results.iter().map(|r| r.non_entity).sum();
276
277 eprintln!();
278 eprintln!("=== Summary ===");
279 eprintln!("Total Clients: {}", format_number(total_clients));
280 eprintln!(" Entity Clients: {}", format_number(total_entity));
281 eprintln!(" Non-Entity Clients: {}", format_number(total_non_entity));
282 eprintln!("Mounts Analyzed: {}", results.len());
283 eprintln!();
284
285 if let Some(output_path) = output {
287 let file = File::create(output_path)
288 .with_context(|| format!("Failed to create output file: {}", output_path))?;
289 let mut writer = csv::Writer::from_writer(file);
290
291 writer.write_record(["mount", "type", "accessor", "total", "entity", "non_entity"])?;
292 for result in &results {
293 writer.write_record([
294 &result.mount,
295 &result.mount_type,
296 &result.accessor,
297 &result.total.to_string(),
298 &result.entity.to_string(),
299 &result.non_entity.to_string(),
300 ])?;
301 }
302
303 writer.flush()?;
304 eprintln!("CSV written to: {}", output_path);
305 } else {
306 println!("{}", serde_json::to_string_pretty(&results)?);
308 }
309
310 Ok(())
311}
312
313#[derive(Debug)]
314struct MountActivityData {
315 mount: String,
316 mount_type: String,
317 accessor: String,
318 role: Option<String>,
319 total_clients: std::collections::HashSet<String>,
320 entity_clients: std::collections::HashSet<String>,
321 non_entity_clients: std::collections::HashSet<String>,
322}
323
324async fn fetch_mount_map(client: &VaultClient) -> Result<HashMap<String, (String, String)>> {
325 let mut map = HashMap::new();
326
327 if let Ok(mounts_data) = client.get_json("/v1/sys/mounts").await {
329 if let Ok(mounts) = extract_data::<HashMap<String, MountInfo>>(mounts_data) {
330 for (path, info) in mounts {
331 if let Some(accessor) = info.accessor {
332 map.insert(
333 accessor,
334 (
335 path,
336 info.mount_type.unwrap_or_else(|| "unknown".to_string()),
337 ),
338 );
339 }
340 }
341 }
342 }
343
344 if let Ok(auth_data) = client.get_json("/v1/sys/auth").await {
346 if let Ok(auths) = extract_data::<HashMap<String, MountInfo>>(auth_data) {
347 for (path, info) in auths {
348 if let Some(accessor) = info.accessor {
349 map.insert(
350 accessor,
351 (
352 path,
353 info.mount_type.unwrap_or_else(|| "unknown".to_string()),
354 ),
355 );
356 }
357 }
358 }
359 }
360
361 Ok(map)
362}