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::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/// Mount point configuration from Vault
46#[derive(Debug, Deserialize)]
47struct MountInfo {
48    #[serde(rename = "type")]
49    mount_type: Option<String>,
50    accessor: Option<String>,
51}
52
53/// Client activity record from Vault API
54#[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    // Load entity mappings if provided
129    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    // Build mount lookup map
144    eprintln!("Fetching mount information...");
145    let mount_map = fetch_mount_map(&client).await?;
146    eprintln!("Found {} mounts", mount_map.len());
147
148    // Fetch activity export
149    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    // Parse NDJSON (newline-delimited JSON) or regular JSON
158    let records: Vec<ActivityRecord> = if export_text.trim().starts_with('[') {
159        // Regular JSON array
160        serde_json::from_str(&export_text)?
161    } else {
162        // NDJSON - parse line by line
163        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    // Group by mount and count unique clients
181    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        // Extract role/appcode if grouping by role
206        let role = if group_by_role {
207            // Try entity_alias_name from export first (Vault 1.20+)
208            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                // Fallback to entity map (Vault 1.16 or when alias_name is missing)
212                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        // Create unique key based on grouping mode
223        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    // Convert to output format
257    let mut results: Vec<MountActivity> = mount_activities
258        .into_values()
259        .map(|data| {
260            // Concatenate mount + role for the mount field if role exists
261            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, // Don't include role as separate field anymore
272                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    // Sort by mount path
280    results.sort_by(|a, b| a.mount.cmp(&b.mount));
281
282    // Calculate totals
283    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    // Output results
296    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        // JSON output to stdout
317        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    // Try /sys/mounts (secret engines)
338    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    // Try /sys/auth (auth methods)
355    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}