vault_audit_tools/commands/
token_operations.rs

1//! Token lifecycle operations analysis.
2//!
3//! Tracks token-related operations to understand token usage patterns
4//! and identify entities performing high volumes of token operations.
5//! Supports multi-file analysis for long-term trending.
6//!
7//! # Usage
8//!
9//! ```bash
10//! # Single file analysis
11//! vault-audit token-operations vault_audit.log
12//!
13//! # Week-long analysis
14//! vault-audit token-operations logs/vault_audit.2025-10-*.log
15//! ```
16//!
17//! # Output
18//!
19//! Displays a summary table showing per-entity token operations:
20//! - **lookup-self**: Token self-inspection operations
21//! - **renew-self**: Token renewal operations
22//! - **revoke-self**: Token revocation operations
23//! - **create**: New token creation
24//! - **login**: Authentication operations that create tokens
25//! - **other**: Other token-related operations
26//!
27//! Results are sorted by total operations (descending) to identify
28//! the most active entities.
29
30use crate::audit::types::AuditEntry;
31use crate::utils::progress::ProgressBar;
32use anyhow::Result;
33use std::collections::HashMap;
34use std::fs::File;
35use std::io::{BufRead, BufReader};
36
37/// Token operation statistics for a single entity
38#[derive(Debug, Default)]
39struct TokenOps {
40    lookup_self: usize,
41    renew_self: usize,
42    revoke_self: usize,
43    create: usize,
44    login: usize,
45    other: usize,
46    display_name: Option<String>,
47    username: Option<String>,
48}
49
50fn format_number(n: usize) -> String {
51    let s = n.to_string();
52    let mut result = String::new();
53    for (i, c) in s.chars().rev().enumerate() {
54        if i > 0 && i % 3 == 0 {
55            result.push(',');
56        }
57        result.push(c);
58    }
59    result.chars().rev().collect()
60}
61
62pub fn run(log_files: &[String], output: Option<&str>) -> Result<()> {
63    let mut token_ops: HashMap<String, TokenOps> = HashMap::new();
64    let mut total_lines = 0;
65
66    // Process each log file sequentially
67    for (file_idx, log_file) in log_files.iter().enumerate() {
68        eprintln!(
69            "[{}/{}] Processing: {}",
70            file_idx + 1,
71            log_files.len(),
72            log_file
73        );
74
75        // Get file size for progress tracking
76        let file_size = std::fs::metadata(log_file).ok().map(|m| m.len() as usize);
77        let mut progress = if let Some(size) = file_size {
78            ProgressBar::new(size, "Processing")
79        } else {
80            ProgressBar::new_spinner("Processing")
81        };
82
83        let mut file_lines = 0;
84        let mut bytes_read = 0;
85
86        let file = File::open(log_file)?;
87        let reader = BufReader::new(file);
88
89        for line in reader.lines() {
90            file_lines += 1;
91            total_lines += 1;
92            let line = line?;
93            bytes_read += line.len() + 1; // +1 for newline
94
95            // Update progress every 10k lines for smooth animation
96            if file_lines % 10_000 == 0 {
97                if let Some(size) = file_size {
98                    progress.update(bytes_read.min(size));
99                } else {
100                    progress.update(file_lines);
101                }
102            }
103
104            let entry: AuditEntry = match serde_json::from_str(&line) {
105                Ok(e) => e,
106                Err(_) => continue,
107            };
108
109            // Get entity_id first
110            let entity_id = match &entry.auth {
111                Some(auth) => match &auth.entity_id {
112                    Some(id) => id.as_str(),
113                    None => continue,
114                },
115                None => continue,
116            };
117
118            // Filter for token operations OR login operations
119            let path = match &entry.request {
120                Some(r) => match &r.path {
121                    Some(p) => p.as_str(),
122                    None => continue,
123                },
124                None => continue,
125            };
126
127            let is_token_op = path.starts_with("auth/token/");
128            let is_login = path.starts_with("auth/") && path.contains("/login");
129
130            if !is_token_op && !is_login {
131                continue;
132            }
133
134            let operation = entry
135                .request
136                .as_ref()
137                .and_then(|r| r.operation.as_deref())
138                .unwrap_or("");
139
140            let ops = token_ops.entry(entity_id.to_string()).or_default();
141
142            // Categorize operation
143            if is_login {
144                ops.login += 1;
145            } else if path.contains("lookup-self") {
146                ops.lookup_self += 1;
147            } else if path.contains("renew-self") {
148                ops.renew_self += 1;
149            } else if path.contains("revoke-self") {
150                ops.revoke_self += 1;
151            } else if path.contains("create") || operation == "create" {
152                ops.create += 1;
153            } else {
154                ops.other += 1;
155            }
156
157            // Capture display name and metadata (first occurrence)
158            if ops.display_name.is_none() {
159                ops.display_name = entry
160                    .auth
161                    .as_ref()
162                    .and_then(|a| a.display_name.as_deref())
163                    .map(|s| s.to_string());
164                if let Some(auth) = &entry.auth {
165                    if let Some(metadata) = &auth.metadata {
166                        if let Some(username) = metadata.get("username") {
167                            ops.username = username.as_str().map(|s| s.to_string());
168                        }
169                    }
170                }
171            }
172        }
173
174        // Ensure 100% progress for this file
175        if let Some(size) = file_size {
176            progress.update(size);
177        }
178
179        progress.finish_with_message(&format!(
180            "Processed {} lines from this file",
181            format_number(file_lines)
182        ));
183    }
184
185    eprintln!("\nTotal: Processed {} lines", format_number(total_lines));
186
187    // Calculate totals per entity
188    let mut entity_totals: Vec<_> = token_ops
189        .iter()
190        .map(|(entity_id, ops)| {
191            let total = ops.lookup_self
192                + ops.renew_self
193                + ops.revoke_self
194                + ops.create
195                + ops.login
196                + ops.other;
197            (
198                entity_id.clone(),
199                total,
200                ops.display_name
201                    .clone()
202                    .unwrap_or_else(|| "unknown".to_string()),
203                ops.lookup_self,
204                ops.renew_self,
205                ops.revoke_self,
206                ops.create,
207                ops.login,
208                ops.other,
209                ops.username.clone().unwrap_or_default(),
210            )
211        })
212        .filter(|x| x.1 > 0)
213        .collect();
214
215    // Sort by total operations
216    entity_totals.sort_by(|a, b| b.1.cmp(&a.1));
217
218    // Display results
219    let top = 50;
220    println!("\n{}", "=".repeat(150));
221    println!(
222        "{:<30} {:<25} {:<10} {:<10} {:<10} {:<10} {:<10} {:<10} {:<10}",
223        "Display Name",
224        "Username",
225        "Total",
226        "Lookup",
227        "Renew",
228        "Revoke",
229        "Create",
230        "Login",
231        "Other"
232    );
233    println!("{}", "=".repeat(150));
234
235    let mut grand_total = 0;
236    for (_, total, display_name, lookup, renew, revoke, create, login, other, username) in
237        entity_totals.iter().take(top)
238    {
239        let display_name_trunc = if display_name.len() > 29 {
240            &display_name[..29]
241        } else {
242            display_name
243        };
244        let username_trunc = if username.len() > 24 {
245            &username[..24]
246        } else {
247            username
248        };
249
250        println!(
251            "{:<30} {:<25} {:<10} {:<10} {:<10} {:<10} {:<10} {:<10} {:<10}",
252            display_name_trunc,
253            username_trunc,
254            format_number(*total),
255            format_number(*lookup),
256            format_number(*renew),
257            format_number(*revoke),
258            format_number(*create),
259            format_number(*login),
260            format_number(*other)
261        );
262        grand_total += total;
263    }
264
265    println!("{}", "=".repeat(150));
266    println!(
267        "{:<55} {:<10}",
268        format!("TOTAL (top {})", entity_totals.len().min(top)),
269        format_number(grand_total)
270    );
271    println!(
272        "{:<55} {:<10}",
273        "TOTAL ENTITIES",
274        format_number(entity_totals.len())
275    );
276    println!("{}", "=".repeat(150));
277
278    // Summary by operation type
279    let total_lookup: usize = entity_totals.iter().map(|x| x.3).sum();
280    let total_renew: usize = entity_totals.iter().map(|x| x.4).sum();
281    let total_revoke: usize = entity_totals.iter().map(|x| x.5).sum();
282    let total_create: usize = entity_totals.iter().map(|x| x.6).sum();
283    let total_login: usize = entity_totals.iter().map(|x| x.7).sum();
284    let total_other: usize = entity_totals.iter().map(|x| x.8).sum();
285    let overall_total =
286        total_lookup + total_renew + total_revoke + total_create + total_login + total_other;
287
288    println!("\nOperation Type Breakdown:");
289    println!("{}", "-".repeat(60));
290    println!(
291        "Lookup (lookup-self):  {:>12}  ({:>5.1}%)",
292        format_number(total_lookup),
293        (total_lookup as f64 / overall_total as f64) * 100.0
294    );
295    println!(
296        "Renew (renew-self):    {:>12}  ({:>5.1}%)",
297        format_number(total_renew),
298        (total_renew as f64 / overall_total as f64) * 100.0
299    );
300    println!(
301        "Revoke (revoke-self):  {:>12}  ({:>5.1}%)",
302        format_number(total_revoke),
303        (total_revoke as f64 / overall_total as f64) * 100.0
304    );
305    println!(
306        "Create (child token):  {:>12}  ({:>5.1}%)",
307        format_number(total_create),
308        (total_create as f64 / overall_total as f64) * 100.0
309    );
310    println!(
311        "Login (auth token):    {:>12}  ({:>5.1}%)",
312        format_number(total_login),
313        (total_login as f64 / overall_total as f64) * 100.0
314    );
315    println!(
316        "Other:                 {:>12}  ({:>5.1}%)",
317        format_number(total_other),
318        (total_other as f64 / overall_total as f64) * 100.0
319    );
320    println!("{}", "-".repeat(60));
321    println!(
322        "TOTAL:                 {:>12}",
323        format_number(overall_total)
324    );
325
326    // TODO: CSV output if specified
327    if let Some(_output_path) = output {
328        eprintln!("Note: CSV output not yet implemented");
329    }
330
331    Ok(())
332}