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