vault_audit_tools/commands/
client_activity.rs

1//! Client activity metrics from Vault API.
2//!
3//! Queries Vault's activity log API to retrieve client usage metrics
4//! broken down by mount point and authentication method.
5//!
6//! # Usage
7//!
8//! ```bash
9//! # Export client activity for a time range
10//! vault-audit client-activity \
11//!   --start-time 2025-10-01T00:00:00Z \
12//!   --end-time 2025-10-31T23:59:59Z \
13//!   --output client-activity.csv
14//!
15//! # Skip TLS verification (dev/test only)
16//! vault-audit client-activity --start-time ... --insecure
17//! ```
18//!
19//! # Requirements
20//!
21//! Requires environment variables:
22//! - `VAULT_ADDR`: Vault server URL
23//! - `VAULT_TOKEN`: Token with activity read permissions
24//!
25//! # Output
26//!
27//! Generates CSV with:
28//! - Client ID
29//! - Client type (entity or non-entity)
30//! - Mount accessor and path
31//! - Namespace (if applicable)
32//!
33//! Useful for:
34//! - License compliance tracking
35//! - Understanding client distribution
36//! - Capacity planning
37
38use 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/// Mount point configuration from Vault
47#[derive(Debug, Deserialize)]
48struct MountInfo {
49    #[serde(rename = "type")]
50    mount_type: Option<String>,
51    accessor: Option<String>,
52}
53
54/// Client activity record from Vault API
55#[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    // Load entity mappings if provided
118    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    // Build mount lookup map
133    eprintln!("Fetching mount information...");
134    let mount_map = fetch_mount_map(&client).await?;
135    eprintln!("Found {} mounts", mount_map.len());
136
137    // Fetch activity export
138    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    // Parse NDJSON (newline-delimited JSON) or regular JSON
147    let records: Vec<ActivityRecord> = if export_text.trim().starts_with('[') {
148        // Regular JSON array
149        serde_json::from_str(&export_text)?
150    } else {
151        // NDJSON - parse line by line
152        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    // Group by mount and count unique clients
170    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        // Extract role/appcode if grouping by role
195        let role = if group_by_role {
196            // Try entity_alias_name from export first (Vault 1.20+)
197            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                // Fallback to entity map (Vault 1.16 or when alias_name is missing)
201                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        // Create unique key based on grouping mode
212        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    // Convert to output format
246    let mut results: Vec<MountActivity> = mount_activities
247        .into_values()
248        .map(|data| {
249            // Concatenate mount + role for the mount field if role exists
250            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, // Don't include role as separate field anymore
261                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    // Sort by mount path
269    results.sort_by(|a, b| a.mount.cmp(&b.mount));
270
271    // Calculate totals
272    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    // Output results
285    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        // JSON output to stdout
306        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    // Try /sys/mounts (secret engines)
327    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    // Try /sys/auth (auth methods)
344    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}