vault_audit_tools/commands/
entity_list.rs

1//! Entity list export command.
2//!
3//! Queries the Vault API to export a complete list of entities with their
4//! aliases, useful for establishing baselines for entity churn analysis.
5//!
6//! # Usage
7//!
8//! ```bash
9//! # Export all entities as CSV (default)
10//! vault-audit entity-list --output entities.csv
11//!
12//! # Export as JSON
13//! vault-audit entity-list --output entities.json --format json
14//!
15//! # Skip TLS verification (dev/test only)
16//! vault-audit entity-list --output entities.csv --insecure
17//! ```
18//!
19//! # Requirements
20//!
21//! Requires environment variables:
22//! - `VAULT_ADDR`: Vault server URL
23//! - `VAULT_TOKEN`: Token with entity read permissions
24//!
25//! # Output
26//!
27//! Generates CSV or JSON with entity information:
28//! - Entity ID
29//! - Display name
30//! - Alias names and mount paths
31//! - Creation timestamp
32//!
33//! This data can be used as a baseline for the `entity-churn` and `entity-creation` commands.
34
35use crate::vault_api::{extract_data, should_skip_verify, VaultClient};
36use anyhow::{Context, Result};
37use serde::{Deserialize, Serialize};
38use std::collections::HashMap;
39use std::fs::File;
40
41/// Authentication mount configuration
42#[derive(Debug, Deserialize)]
43struct AuthMount {
44    #[serde(rename = "type")]
45    mount_type: Option<String>,
46    accessor: Option<String>,
47}
48
49/// Response from entity list API
50#[derive(Debug, Deserialize)]
51struct EntityListResponse {
52    keys: Vec<String>,
53}
54
55/// Entity data from Vault API
56#[derive(Debug, Deserialize)]
57struct EntityData {
58    id: String,
59    name: Option<String>,
60    disabled: bool,
61    creation_time: Option<String>,
62    last_update_time: Option<String>,
63    aliases: Option<Vec<AliasData>>,
64}
65
66#[derive(Debug, Deserialize, Clone)]
67struct AliasData {
68    id: String,
69    name: String,
70    mount_accessor: String,
71    creation_time: Option<String>,
72    last_update_time: Option<String>,
73    metadata: Option<HashMap<String, String>>,
74}
75
76#[derive(Debug, Serialize)]
77struct EntityOutput {
78    entity_id: String,
79    entity_name: String,
80    entity_disabled: bool,
81    entity_created: String,
82    entity_updated: String,
83    alias_id: String,
84    alias_name: String,
85    mount_path: String,
86    mount_type: String,
87    mount_accessor: String,
88    alias_created: String,
89    alias_updated: String,
90    alias_metadata: String,
91}
92
93pub fn format_number(n: usize) -> String {
94    let s = n.to_string();
95    let mut result = String::new();
96    for (i, c) in s.chars().rev().enumerate() {
97        if i > 0 && i % 3 == 0 {
98            result.push(',');
99        }
100        result.push(c);
101    }
102    result.chars().rev().collect()
103}
104
105pub async fn run(
106    vault_addr: Option<&str>,
107    vault_token: Option<&str>,
108    insecure: bool,
109    output: Option<&str>,
110    format: &str,
111    filter_mount: Option<&str>,
112) -> Result<()> {
113    let skip_verify = should_skip_verify(insecure);
114    let client = VaultClient::from_options(vault_addr, vault_token, skip_verify)?;
115
116    eprintln!("=== Vault Entity Analysis ===");
117    eprintln!("Vault Address: {}", client.addr());
118    if let Some(mount) = filter_mount {
119        eprintln!("Filtering by mount: {}", mount);
120    }
121    if skip_verify {
122        eprintln!("⚠️  TLS certificate verification is DISABLED");
123    }
124    eprintln!();
125
126    // Build mount lookup map
127    eprintln!("Building mount map...");
128    let mount_map = fetch_auth_mount_map(&client).await?;
129    eprintln!("Found {} auth mounts", mount_map.len());
130
131    // List all entity IDs
132    eprintln!("Fetching entity list...");
133    let entity_list: EntityListResponse =
134        extract_data(client.get_json("/v1/identity/entity/id?list=true").await?)?;
135
136    let entity_count = entity_list.keys.len();
137    eprintln!("Found {} entities", format_number(entity_count));
138    eprintln!();
139
140    // Fetch each entity's details
141    eprintln!("Fetching entity details...");
142    let mut entities_data = Vec::new();
143    let mut processed = 0;
144
145    for entity_id in &entity_list.keys {
146        processed += 1;
147        if processed % 100 == 0 || processed == entity_count {
148            eprint!("\rProcessing entity {}/{}...", processed, entity_count);
149        }
150
151        let entity_path = format!("/v1/identity/entity/id/{}", entity_id);
152        if let Ok(entity_json) = client.get_json(&entity_path).await {
153            if let Ok(entity) = extract_data::<EntityData>(entity_json) {
154                entities_data.push(entity);
155            }
156        }
157    }
158    eprintln!("\n");
159
160    // Convert to output format
161    let mut output_rows = Vec::new();
162
163    for entity in &entities_data {
164        let entity_name = entity.name.clone().unwrap_or_default();
165        let entity_created = entity.creation_time.clone().unwrap_or_default();
166        let entity_updated = entity.last_update_time.clone().unwrap_or_default();
167
168        if let Some(aliases) = &entity.aliases {
169            let mut filtered_aliases: Vec<&AliasData> = aliases.iter().collect();
170
171            // Apply mount filter if specified
172            if let Some(filter) = filter_mount {
173                filtered_aliases.retain(|alias| {
174                    if let Some((path, _)) = mount_map.get(&alias.mount_accessor) {
175                        path == filter
176                    } else {
177                        false
178                    }
179                });
180            }
181
182            if filtered_aliases.is_empty() && filter_mount.is_some() {
183                continue; // Skip entities with no matching aliases
184            }
185
186            for alias in filtered_aliases {
187                let (mount_path, mount_type) = mount_map
188                    .get(&alias.mount_accessor)
189                    .cloned()
190                    .unwrap_or_else(|| ("unknown".to_string(), "unknown".to_string()));
191
192                let metadata_str = alias
193                    .metadata
194                    .as_ref()
195                    .map(|m| {
196                        m.iter()
197                            .map(|(k, v)| format!("{}={}", k, v))
198                            .collect::<Vec<_>>()
199                            .join("; ")
200                    })
201                    .unwrap_or_default();
202
203                output_rows.push(EntityOutput {
204                    entity_id: entity.id.clone(),
205                    entity_name: entity_name.clone(),
206                    entity_disabled: entity.disabled,
207                    entity_created: entity_created.clone(),
208                    entity_updated: entity_updated.clone(),
209                    alias_id: alias.id.clone(),
210                    alias_name: alias.name.clone(),
211                    mount_path,
212                    mount_type,
213                    mount_accessor: alias.mount_accessor.clone(),
214                    alias_created: alias.creation_time.clone().unwrap_or_default(),
215                    alias_updated: alias.last_update_time.clone().unwrap_or_default(),
216                    alias_metadata: metadata_str,
217                });
218            }
219        } else if filter_mount.is_none() {
220            // Include entities with no aliases only if not filtering
221            output_rows.push(EntityOutput {
222                entity_id: entity.id.clone(),
223                entity_name,
224                entity_disabled: entity.disabled,
225                entity_created,
226                entity_updated,
227                alias_id: String::new(),
228                alias_name: String::new(),
229                mount_path: String::new(),
230                mount_type: String::new(),
231                mount_accessor: String::new(),
232                alias_created: String::new(),
233                alias_updated: String::new(),
234                alias_metadata: String::new(),
235            });
236        }
237    }
238
239    // Print summary
240    eprintln!("=== Summary ===");
241    eprintln!("Total entities: {}", format_number(entities_data.len()));
242    eprintln!("Total aliases: {}", format_number(output_rows.len()));
243    eprintln!();
244
245    // Count aliases by mount
246    let mut mount_counts: HashMap<String, usize> = HashMap::new();
247    for row in &output_rows {
248        if !row.mount_path.is_empty() {
249            *mount_counts.entry(row.mount_path.clone()).or_insert(0) += 1;
250        }
251    }
252
253    if !mount_counts.is_empty() {
254        eprintln!("Aliases by mount:");
255        let mut counts: Vec<_> = mount_counts.into_iter().collect();
256        counts.sort_by(|a, b| b.1.cmp(&a.1));
257        for (mount, count) in counts {
258            eprintln!("  {}: {}", mount, format_number(count));
259        }
260        eprintln!();
261    }
262
263    // Output results
264    if let Some(output_path) = output {
265        let file = File::create(output_path)
266            .with_context(|| format!("Failed to create output file: {}", output_path))?;
267
268        match format.to_lowercase().as_str() {
269            "json" => {
270                serde_json::to_writer_pretty(file, &output_rows)
271                    .with_context(|| format!("Failed to write JSON to: {}", output_path))?;
272                eprintln!("JSON written to: {}", output_path);
273            }
274            "csv" => {
275                let mut writer = csv::Writer::from_writer(file);
276
277                writer.write_record([
278                    "entity_id",
279                    "entity_name",
280                    "entity_disabled",
281                    "entity_created",
282                    "entity_updated",
283                    "alias_id",
284                    "alias_name",
285                    "mount_path",
286                    "mount_type",
287                    "mount_accessor",
288                    "alias_created",
289                    "alias_updated",
290                    "alias_metadata",
291                ])?;
292
293                for row in &output_rows {
294                    writer.write_record([
295                        &row.entity_id,
296                        &row.entity_name,
297                        &row.entity_disabled.to_string(),
298                        &row.entity_created,
299                        &row.entity_updated,
300                        &row.alias_id,
301                        &row.alias_name,
302                        &row.mount_path,
303                        &row.mount_type,
304                        &row.mount_accessor,
305                        &row.alias_created,
306                        &row.alias_updated,
307                        &row.alias_metadata,
308                    ])?;
309                }
310
311                writer.flush()?;
312                eprintln!("CSV written to: {}", output_path);
313            }
314            _ => {
315                anyhow::bail!("Invalid format '{}'. Use 'csv' or 'json'", format);
316            }
317        }
318    } else {
319        // No output file specified - print to stdout based on format
320        match format.to_lowercase().as_str() {
321            "json" => {
322                println!("{}", serde_json::to_string_pretty(&output_rows)?);
323            }
324            "csv" => {
325                let mut writer = csv::Writer::from_writer(std::io::stdout());
326                writer.write_record([
327                    "entity_id",
328                    "entity_name",
329                    "entity_disabled",
330                    "entity_created",
331                    "entity_updated",
332                    "alias_id",
333                    "alias_name",
334                    "mount_path",
335                    "mount_type",
336                    "mount_accessor",
337                    "alias_created",
338                    "alias_updated",
339                    "alias_metadata",
340                ])?;
341
342                for row in &output_rows {
343                    writer.write_record([
344                        &row.entity_id,
345                        &row.entity_name,
346                        &row.entity_disabled.to_string(),
347                        &row.entity_created,
348                        &row.entity_updated,
349                        &row.alias_id,
350                        &row.alias_name,
351                        &row.mount_path,
352                        &row.mount_type,
353                        &row.mount_accessor,
354                        &row.alias_created,
355                        &row.alias_updated,
356                        &row.alias_metadata,
357                    ])?;
358                }
359
360                writer.flush()?;
361            }
362            _ => {
363                anyhow::bail!("Invalid format '{}'. Use 'csv' or 'json'", format);
364            }
365        }
366    }
367
368    Ok(())
369}
370
371async fn fetch_auth_mount_map(client: &VaultClient) -> Result<HashMap<String, (String, String)>> {
372    let mut map = HashMap::new();
373
374    if let Ok(auth_data) = client.get_json("/v1/sys/auth").await {
375        if let Ok(auths) = extract_data::<HashMap<String, AuthMount>>(auth_data) {
376            for (path, info) in auths {
377                if let Some(accessor) = info.accessor {
378                    map.insert(
379                        accessor,
380                        (
381                            path,
382                            info.mount_type.unwrap_or_else(|| "unknown".to_string()),
383                        ),
384                    );
385                }
386            }
387        }
388    }
389
390    Ok(map)
391}