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    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    // Load entity mappings if provided
119    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    // Build mount lookup map
134    eprintln!("Fetching mount information...");
135    let mount_map = fetch_mount_map(&client).await?;
136    eprintln!("Found {} mounts", mount_map.len());
137
138    // Fetch activity export
139    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    // Parse NDJSON (newline-delimited JSON) or regular JSON
148    let records: Vec<ActivityRecord> = if export_text.trim().starts_with('[') {
149        // Regular JSON array
150        serde_json::from_str(&export_text)?
151    } else {
152        // NDJSON - parse line by line
153        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    // Group by mount and count unique clients
171    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        // Extract role/appcode if grouping by role
196        let role = if group_by_role {
197            // Try entity_alias_name from export first (Vault 1.20+)
198            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                // Fallback to entity map (Vault 1.16 or when alias_name is missing)
202                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        // Create unique key based on grouping mode
213        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    // Convert to output format
247    let mut results: Vec<MountActivity> = mount_activities
248        .into_values()
249        .map(|data| {
250            // Concatenate mount + role for the mount field if role exists
251            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, // Don't include role as separate field anymore
262                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    // Sort by mount path
270    results.sort_by(|a, b| a.mount.cmp(&b.mount));
271
272    // Calculate totals
273    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    // Output results
286    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        // JSON output to stdout
307        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    // Try /sys/mounts (secret engines)
328    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    // Try /sys/auth (auth methods)
345    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}