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