vault_audit_tools/commands/
auth_mounts.rs

1//! Auth mount enumeration and listing.
2//!
3//! This command queries the Vault API to enumerate all authentication mounts and their
4//! configuration, with optional depth-based traversal to discover roles, users, and other
5//! configurations within each auth method.
6//!
7//! # Features
8//!
9//! - **Automatic Discovery**: Discovers all auth mounts without needing to know mount names
10//! - **Multi-Type Support**: Handles kubernetes, approle, userpass, jwt/oidc, ldap, and token auth
11//! - **Role Enumeration**: Lists roles, users, and groups within each auth mount (when depth > 0)
12//! - **Multiple Output Formats**: CSV (flattened with depth), JSON (nested structure), or stdout (visual tree)
13//!
14//! # Usage Examples
15//!
16//! ```bash
17//! # List all auth mounts with role enumeration (default)
18//! vault-audit auth-mounts --format stdout
19//!
20//! # List only the auth mounts themselves (no roles)
21//! vault-audit auth-mounts --depth 0 --format csv
22//!
23//! # List mounts with roles in JSON format
24//! vault-audit auth-mounts --format json --output auth-inventory.json
25//! ```
26//!
27//! # Supported Auth Types
28//!
29//! - **kubernetes**: Lists roles configured for K8s service accounts
30//! - **approle**: Lists `AppRole` roles for application authentication
31//! - **userpass**: Lists configured users
32//! - **jwt/oidc**: Lists JWT/OIDC roles
33//! - **ldap**: Lists LDAP users and groups (prefixed with `user:`/`group:`)
34//! - **token**: No enumerable configuration
35//!
36//! # Output Formats
37//!
38//! - **CSV**: Flattened format with mount info repeated for each role (depth column: 0=mount, 1=role)
39//! - **JSON**: Nested structure with roles array within each mount object
40//! - **stdout**: Visual tree with mount details and indented role list (├──, └──)
41//!
42//! # Depth Parameter
43//!
44//! - `--depth 0`: Show only mount points (no role enumeration)
45//! - `--depth 1` or higher: Include roles/users within each mount
46//! - No flag: Unlimited depth (enumerates all roles/users)
47//!
48//! # API Endpoints Used
49//!
50//! - `/v1/sys/auth` - Discover all auth mounts
51//! - `/v1/auth/{mount}/role` - List roles (kubernetes, approle, jwt/oidc)
52//! - `/v1/auth/{mount}/users` - List users (userpass, ldap)
53//! - `/v1/auth/{mount}/groups` - List groups (ldap)
54
55use anyhow::{Context, Result};
56use serde::{Deserialize, Serialize};
57use serde_json::Value;
58use std::collections::HashMap;
59use std::fs::File;
60use std::io::Write;
61
62use crate::vault_api::VaultClient;
63
64#[derive(Debug, Serialize, Deserialize)]
65struct AuthMountInfo {
66    #[serde(rename = "type")]
67    auth_type: String,
68    #[serde(default)]
69    description: String,
70    #[serde(default)]
71    accessor: String,
72    #[serde(default, deserialize_with = "deserialize_null_default")]
73    config: HashMap<String, Value>,
74    #[serde(default, deserialize_with = "deserialize_null_default")]
75    options: HashMap<String, Value>,
76    #[serde(default)]
77    local: bool,
78    #[serde(default)]
79    seal_wrap: bool,
80}
81
82fn deserialize_null_default<'de, D, T>(deserializer: D) -> Result<T, D::Error>
83where
84    T: Default + Deserialize<'de>,
85    D: serde::Deserializer<'de>,
86{
87    let opt = Option::deserialize(deserializer)?;
88    Ok(opt.unwrap_or_default())
89}
90
91#[derive(Debug, Serialize, Clone)]
92struct RoleEntry {
93    name: String,
94    #[serde(skip_serializing_if = "Vec::is_empty", default)]
95    children: Vec<RoleEntry>,
96}
97
98#[derive(Debug, Serialize)]
99struct AuthMountOutput {
100    path: String,
101    auth_type: String,
102    description: String,
103    accessor: String,
104    local: bool,
105    seal_wrap: bool,
106    default_lease_ttl: String,
107    max_lease_ttl: String,
108    #[serde(skip_serializing_if = "Vec::is_empty", default)]
109    roles: Vec<RoleEntry>,
110}
111
112/// List roles for kubernetes auth mounts
113async fn list_k8s_roles(client: &VaultClient, mount_path: &str) -> Result<Vec<RoleEntry>> {
114    let list_path = format!("/v1/auth/{}/role", mount_path.trim_end_matches('/'));
115
116    match client.list_json(&list_path).await {
117        Ok(response) => {
118            if let Some(keys) = response
119                .get("data")
120                .and_then(|d| d.get("keys"))
121                .and_then(|k| k.as_array())
122            {
123                let mut roles = Vec::new();
124                for key in keys {
125                    if let Some(role_name) = key.as_str() {
126                        roles.push(RoleEntry {
127                            name: role_name.to_string(),
128                            children: vec![],
129                        });
130                    }
131                }
132                Ok(roles)
133            } else {
134                Ok(vec![])
135            }
136        }
137        Err(_) => Ok(vec![]), // If we can't list, just return empty
138    }
139}
140
141/// List roles for approle auth mounts
142async fn list_approle_roles(client: &VaultClient, mount_path: &str) -> Result<Vec<RoleEntry>> {
143    let list_path = format!("/v1/auth/{}/role", mount_path.trim_end_matches('/'));
144
145    match client.list_json(&list_path).await {
146        Ok(response) => {
147            if let Some(keys) = response
148                .get("data")
149                .and_then(|d| d.get("keys"))
150                .and_then(|k| k.as_array())
151            {
152                let mut roles = Vec::new();
153                for key in keys {
154                    if let Some(role_name) = key.as_str() {
155                        roles.push(RoleEntry {
156                            name: role_name.to_string(),
157                            children: vec![],
158                        });
159                    }
160                }
161                Ok(roles)
162            } else {
163                Ok(vec![])
164            }
165        }
166        Err(_) => Ok(vec![]), // If we can't list, just return empty
167    }
168}
169
170/// List users for userpass auth mounts
171async fn list_userpass_users(client: &VaultClient, mount_path: &str) -> Result<Vec<RoleEntry>> {
172    let list_path = format!("/v1/auth/{}/users", mount_path.trim_end_matches('/'));
173
174    match client.list_json(&list_path).await {
175        Ok(response) => {
176            if let Some(keys) = response
177                .get("data")
178                .and_then(|d| d.get("keys"))
179                .and_then(|k| k.as_array())
180            {
181                let mut users = Vec::new();
182                for key in keys {
183                    if let Some(user_name) = key.as_str() {
184                        users.push(RoleEntry {
185                            name: user_name.to_string(),
186                            children: vec![],
187                        });
188                    }
189                }
190                Ok(users)
191            } else {
192                Ok(vec![])
193            }
194        }
195        Err(_) => Ok(vec![]), // If we can't list, just return empty
196    }
197}
198
199/// List roles for JWT/OIDC auth mounts
200async fn list_jwt_roles(client: &VaultClient, mount_path: &str) -> Result<Vec<RoleEntry>> {
201    let list_path = format!("/v1/auth/{}/role", mount_path.trim_end_matches('/'));
202
203    match client.list_json(&list_path).await {
204        Ok(response) => {
205            if let Some(keys) = response
206                .get("data")
207                .and_then(|d| d.get("keys"))
208                .and_then(|k| k.as_array())
209            {
210                let mut roles = Vec::new();
211                for key in keys {
212                    if let Some(role_name) = key.as_str() {
213                        roles.push(RoleEntry {
214                            name: role_name.to_string(),
215                            children: vec![],
216                        });
217                    }
218                }
219                Ok(roles)
220            } else {
221                Ok(vec![])
222            }
223        }
224        Err(_) => Ok(vec![]), // If we can't list, just return empty
225    }
226}
227
228/// List users/groups for LDAP auth mounts
229async fn list_ldap_config(client: &VaultClient, mount_path: &str) -> Result<Vec<RoleEntry>> {
230    let users_path = format!("/v1/auth/{}/users", mount_path.trim_end_matches('/'));
231    let groups_path = format!("/v1/auth/{}/groups", mount_path.trim_end_matches('/'));
232
233    let mut entries = Vec::new();
234
235    // Try to list users
236    if let Ok(response) = client.list_json(&users_path).await {
237        if let Some(keys) = response
238            .get("data")
239            .and_then(|d| d.get("keys"))
240            .and_then(|k| k.as_array())
241        {
242            for key in keys {
243                if let Some(user_name) = key.as_str() {
244                    entries.push(RoleEntry {
245                        name: format!("user:{}", user_name),
246                        children: vec![],
247                    });
248                }
249            }
250        }
251    }
252
253    // Try to list groups
254    if let Ok(response) = client.list_json(&groups_path).await {
255        if let Some(keys) = response
256            .get("data")
257            .and_then(|d| d.get("keys"))
258            .and_then(|k| k.as_array())
259        {
260            for key in keys {
261                if let Some(group_name) = key.as_str() {
262                    entries.push(RoleEntry {
263                        name: format!("group:{}", group_name),
264                        children: vec![],
265                    });
266                }
267            }
268        }
269    }
270
271    Ok(entries)
272}
273
274/// Enumerate roles/users based on auth mount type
275async fn enumerate_auth_configs(
276    client: &VaultClient,
277    mount_path: &str,
278    auth_type: &str,
279    depth: usize,
280) -> Result<Vec<RoleEntry>> {
281    if depth == 0 {
282        return Ok(vec![]);
283    }
284
285    match auth_type {
286        "kubernetes" => list_k8s_roles(client, mount_path).await,
287        "approle" => list_approle_roles(client, mount_path).await,
288        "userpass" => list_userpass_users(client, mount_path).await,
289        "jwt" | "oidc" => list_jwt_roles(client, mount_path).await,
290        "ldap" => list_ldap_config(client, mount_path).await,
291        _ => Ok(vec![]), // Unsupported auth types return empty
292    }
293}
294
295/// Run the auth mount enumeration command
296pub async fn run(
297    vault_addr: Option<&str>,
298    vault_token: Option<&str>,
299    vault_namespace: Option<&str>,
300    insecure: bool,
301    output: Option<&str>,
302    format: &str,
303    depth: usize,
304) -> Result<()> {
305    let client = VaultClient::from_options(vault_addr, vault_token, vault_namespace, insecure)?;
306
307    eprintln!("Querying Vault API for auth mounts...");
308    eprintln!("   Vault Address: {}", client.addr());
309
310    // Query /sys/auth to get all auth mounts
311    let response: Value = client
312        .get("/v1/sys/auth")
313        .await
314        .context("Failed to query /v1/sys/auth")?;
315
316    // Extract the data field which contains the actual mounts
317    let mounts_data = response
318        .get("data")
319        .or(Some(&response)) // Fallback to root if no data field
320        .context("Failed to get auth mounts data")?;
321
322    let mounts = mounts_data
323        .as_object()
324        .context("Expected object response from /v1/sys/auth")?;
325
326    let mut auth_mounts = Vec::new();
327
328    for (path, mount_data) in mounts {
329        // Skip metadata fields like "request_id"
330        if path == "request_id"
331            || path == "lease_id"
332            || path == "renewable"
333            || path == "lease_duration"
334            || path == "data"
335            || path == "wrap_info"
336            || path == "warnings"
337            || path == "auth"
338        {
339            continue;
340        }
341
342        let mount_info: AuthMountInfo = serde_json::from_value(mount_data.clone())
343            .with_context(|| format!("Failed to parse auth mount info for {}", path))?;
344
345        let default_lease_ttl = mount_info
346            .config
347            .get("default_lease_ttl")
348            .and_then(serde_json::Value::as_i64)
349            .map_or_else(|| "0s".to_string(), |v| format!("{}s", v));
350
351        let max_lease_ttl = mount_info
352            .config
353            .get("max_lease_ttl")
354            .and_then(serde_json::Value::as_i64)
355            .map_or_else(|| "0s".to_string(), |v| format!("{}s", v));
356
357        // Enumerate roles/users if depth > 0
358        let roles = enumerate_auth_configs(&client, path, &mount_info.auth_type, depth)
359            .await
360            .unwrap_or_else(|_| vec![]);
361
362        auth_mounts.push(AuthMountOutput {
363            path: path.clone(),
364            auth_type: mount_info.auth_type.clone(),
365            description: mount_info.description.clone(),
366            accessor: mount_info.accessor.clone(),
367            local: mount_info.local,
368            seal_wrap: mount_info.seal_wrap,
369            default_lease_ttl,
370            max_lease_ttl,
371            roles,
372        });
373    }
374
375    eprintln!("Found {} auth mounts", auth_mounts.len());
376
377    // Output results
378    match format {
379        "json" => {
380            let json_output = serde_json::to_string_pretty(&auth_mounts)
381                .context("Failed to serialize to JSON")?;
382
383            if let Some(output_path) = output {
384                let mut file = File::create(output_path).context("Failed to create output file")?;
385                file.write_all(json_output.as_bytes())
386                    .context("Failed to write JSON to file")?;
387                eprintln!("Output written to: {}", output_path);
388            } else {
389                println!("{}", json_output);
390            }
391        }
392        "csv" => {
393            use std::fmt::Write as _;
394            let mut csv_output = String::new();
395            csv_output.push_str("path,type,description,accessor,role_name,depth\n");
396
397            for mount in &auth_mounts {
398                // First write the mount itself
399                let _ = writeln!(
400                    csv_output,
401                    "\"{}\",\"{}\",\"{}\",\"{}\",\"\",0",
402                    mount.path.replace('"', "\"\""),
403                    mount.auth_type,
404                    mount.description.replace('"', "\"\""),
405                    mount.accessor,
406                );
407
408                // Then write each role/user
409                for role in &mount.roles {
410                    let _ = writeln!(
411                        csv_output,
412                        "\"{}\",\"{}\",\"{}\",\"{}\",\"{}\",1",
413                        mount.path.replace('"', "\"\""),
414                        mount.auth_type,
415                        mount.description.replace('"', "\"\""),
416                        mount.accessor,
417                        role.name.replace('"', "\"\""),
418                    );
419                }
420            }
421
422            if let Some(output_path) = output {
423                let mut file = File::create(output_path).context("Failed to create output file")?;
424                file.write_all(csv_output.as_bytes())
425                    .context("Failed to write CSV to file")?;
426                eprintln!("Output written to: {}", output_path);
427            } else {
428                print!("{}", csv_output);
429            }
430        }
431        "stdout" => {
432            println!("\nAuth Mounts:");
433            println!("{}", "=".repeat(80));
434            for mount in &auth_mounts {
435                println!("Path: {}", mount.path);
436                println!("  Type: {}", mount.auth_type);
437                println!("  Description: {}", mount.description);
438                println!("  Accessor: {}", mount.accessor);
439                println!("  Local: {}", mount.local);
440                println!("  Seal Wrap: {}", mount.seal_wrap);
441                println!("  Default Lease TTL: {}", mount.default_lease_ttl);
442                println!("  Max Lease TTL: {}", mount.max_lease_ttl);
443
444                if !mount.roles.is_empty() {
445                    println!("  Roles/Users ({}):", mount.roles.len());
446                    for (i, role) in mount.roles.iter().enumerate() {
447                        let prefix = if i == mount.roles.len() - 1 {
448                            "└──"
449                        } else {
450                            "├──"
451                        };
452                        println!("    {} {}", prefix, role.name);
453                    }
454                }
455                println!();
456            }
457        }
458        _ => {
459            return Err(anyhow::anyhow!(
460                "Invalid format: {}. Must be one of: csv, json, stdout",
461                format
462            ));
463        }
464    }
465
466    Ok(())
467}