Skip to main content

tryaudex_core/
account.rs

1use std::collections::HashMap;
2
3use serde::{Deserialize, Serialize};
4
5use crate::error::{AvError, Result};
6
7/// Configuration for multi-account support.
8/// Maps account aliases to role ARNs and metadata.
9#[derive(Debug, Clone, Serialize, Deserialize, Default)]
10pub struct AccountConfig {
11    /// Default account alias to use when --account is not specified.
12    pub default: Option<String>,
13    /// Map of alias -> account definition.
14    #[serde(default)]
15    pub accounts: HashMap<String, AccountDef>,
16}
17
18/// Definition of a single account alias.
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct AccountDef {
21    /// The IAM role ARN to assume in this account.
22    pub role_arn: String,
23    /// Optional human-readable description.
24    pub description: Option<String>,
25    /// AWS region override for this account.
26    pub region: Option<String>,
27    /// External ID required for cross-account assumption.
28    pub external_id: Option<String>,
29    /// GCP service account email (for GCP accounts).
30    pub gcp_service_account: Option<String>,
31    /// GCP project ID (for GCP accounts).
32    pub gcp_project: Option<String>,
33    /// Azure subscription ID (for Azure accounts).
34    pub azure_subscription: Option<String>,
35    /// Azure tenant ID (for Azure accounts).
36    pub azure_tenant: Option<String>,
37    /// Max TTL override for this account.
38    pub max_ttl: Option<String>,
39    /// Deny list override for this account.
40    pub deny: Option<Vec<String>>,
41}
42
43/// Resolved account details ready for use in credential issuance.
44#[derive(Debug, Clone)]
45pub struct ResolvedAccount {
46    pub alias: String,
47    pub role_arn: String,
48    pub region: Option<String>,
49    pub external_id: Option<String>,
50    pub gcp_service_account: Option<String>,
51    pub gcp_project: Option<String>,
52    pub azure_subscription: Option<String>,
53    pub azure_tenant: Option<String>,
54    pub max_ttl: Option<String>,
55    pub deny: Option<Vec<String>>,
56}
57
58/// Resolve an account alias to its full definition.
59/// Supports:
60///   - Named aliases from config (e.g. "production", "staging")
61///   - Direct ARN passthrough (if the value looks like an ARN)
62///   - AWS account ID shorthand (12-digit number → uses a role name pattern)
63pub fn resolve(config: &AccountConfig, alias: &str) -> Result<ResolvedAccount> {
64    // Direct ARN passthrough
65    if alias.starts_with("arn:aws:iam:") || alias.starts_with("arn:aws:sts:") {
66        return Ok(ResolvedAccount {
67            alias: alias.to_string(),
68            role_arn: alias.to_string(),
69            region: None,
70            external_id: None,
71            gcp_service_account: None,
72            gcp_project: None,
73            azure_subscription: None,
74            azure_tenant: None,
75            max_ttl: None,
76            deny: None,
77        });
78    }
79
80    // AWS account ID shorthand (12-digit number)
81    if alias.len() == 12 && alias.chars().all(|c| c.is_ascii_digit()) {
82        return Ok(ResolvedAccount {
83            alias: alias.to_string(),
84            role_arn: format!("arn:aws:iam::{}:role/AudexAgentRole", alias),
85            region: None,
86            external_id: None,
87            gcp_service_account: None,
88            gcp_project: None,
89            azure_subscription: None,
90            azure_tenant: None,
91            max_ttl: None,
92            deny: None,
93        });
94    }
95
96    // Named alias lookup
97    if let Some(acct) = config.accounts.get(alias) {
98        return Ok(ResolvedAccount {
99            alias: alias.to_string(),
100            role_arn: acct.role_arn.clone(),
101            region: acct.region.clone(),
102            external_id: acct.external_id.clone(),
103            gcp_service_account: acct.gcp_service_account.clone(),
104            gcp_project: acct.gcp_project.clone(),
105            azure_subscription: acct.azure_subscription.clone(),
106            azure_tenant: acct.azure_tenant.clone(),
107            max_ttl: acct.max_ttl.clone(),
108            deny: acct.deny.clone(),
109        });
110    }
111
112    let available: Vec<&str> = config.accounts.keys().map(|k| k.as_str()).collect();
113    Err(AvError::InvalidPolicy(format!(
114        "Unknown account '{}'. Available: {}. You can also pass a full ARN or 12-digit account ID.",
115        alias,
116        if available.is_empty() {
117            "none configured (add [account.accounts.<name>] to config)".to_string()
118        } else {
119            available.join(", ")
120        }
121    )))
122}
123
124/// List all configured account aliases.
125pub fn list(config: &AccountConfig) -> Vec<(String, String, Option<String>)> {
126    let mut accounts: Vec<(String, String, Option<String>)> = config
127        .accounts
128        .iter()
129        .map(|(alias, def)| {
130            (
131                alias.clone(),
132                def.role_arn.clone(),
133                def.description.clone(),
134            )
135        })
136        .collect();
137    accounts.sort_by(|a, b| a.0.cmp(&b.0));
138    accounts
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144
145    fn test_config() -> AccountConfig {
146        let mut accounts = HashMap::new();
147        accounts.insert(
148            "production".to_string(),
149            AccountDef {
150                role_arn: "arn:aws:iam::111111111111:role/ProdAgentRole".to_string(),
151                description: Some("Production AWS account".to_string()),
152                region: Some("us-east-1".to_string()),
153                external_id: Some("audex-prod-xyz".to_string()),
154                gcp_service_account: None,
155                gcp_project: None,
156                azure_subscription: None,
157                azure_tenant: None,
158                max_ttl: Some("15m".to_string()),
159                deny: Some(vec!["iam:*".to_string()]),
160            },
161        );
162        accounts.insert(
163            "staging".to_string(),
164            AccountDef {
165                role_arn: "arn:aws:iam::222222222222:role/StagingAgentRole".to_string(),
166                description: Some("Staging AWS account".to_string()),
167                region: None,
168                external_id: None,
169                gcp_service_account: None,
170                gcp_project: None,
171                azure_subscription: None,
172                azure_tenant: None,
173                max_ttl: None,
174                deny: None,
175            },
176        );
177        AccountConfig {
178            default: Some("staging".to_string()),
179            accounts,
180        }
181    }
182
183    #[test]
184    fn test_resolve_named_alias() {
185        let config = test_config();
186        let resolved = resolve(&config, "production").unwrap();
187        assert_eq!(resolved.alias, "production");
188        assert_eq!(
189            resolved.role_arn,
190            "arn:aws:iam::111111111111:role/ProdAgentRole"
191        );
192        assert_eq!(resolved.region.as_deref(), Some("us-east-1"));
193        assert_eq!(resolved.external_id.as_deref(), Some("audex-prod-xyz"));
194        assert_eq!(resolved.max_ttl.as_deref(), Some("15m"));
195        assert!(resolved.deny.is_some());
196    }
197
198    #[test]
199    fn test_resolve_arn_passthrough() {
200        let config = test_config();
201        let arn = "arn:aws:iam::999999999999:role/DirectRole";
202        let resolved = resolve(&config, arn).unwrap();
203        assert_eq!(resolved.role_arn, arn);
204    }
205
206    #[test]
207    fn test_resolve_account_id_shorthand() {
208        let config = test_config();
209        let resolved = resolve(&config, "333333333333").unwrap();
210        assert_eq!(
211            resolved.role_arn,
212            "arn:aws:iam::333333333333:role/AudexAgentRole"
213        );
214    }
215
216    #[test]
217    fn test_resolve_unknown_alias() {
218        let config = test_config();
219        assert!(resolve(&config, "nonexistent").is_err());
220    }
221
222    #[test]
223    fn test_list_accounts() {
224        let config = test_config();
225        let accounts = list(&config);
226        assert_eq!(accounts.len(), 2);
227        // Sorted alphabetically
228        assert_eq!(accounts[0].0, "production");
229        assert_eq!(accounts[1].0, "staging");
230    }
231
232    #[test]
233    fn test_config_deserialize() {
234        let toml_str = r#"
235default = "dev"
236
237[accounts.dev]
238role_arn = "arn:aws:iam::123456789012:role/DevRole"
239description = "Development account"
240region = "us-west-2"
241
242[accounts.prod]
243role_arn = "arn:aws:iam::987654321098:role/ProdRole"
244external_id = "audex-12345"
245max_ttl = "10m"
246deny = ["iam:*", "organizations:*"]
247"#;
248        let config: AccountConfig = toml::from_str(toml_str).unwrap();
249        assert_eq!(config.default.as_deref(), Some("dev"));
250        assert_eq!(config.accounts.len(), 2);
251        let dev = config.accounts.get("dev").unwrap();
252        assert_eq!(dev.region.as_deref(), Some("us-west-2"));
253        let prod = config.accounts.get("prod").unwrap();
254        assert_eq!(prod.external_id.as_deref(), Some("audex-12345"));
255        assert_eq!(prod.deny.as_ref().unwrap().len(), 2);
256    }
257
258    #[test]
259    fn test_empty_config() {
260        let config = AccountConfig::default();
261        assert!(resolve(&config, "anything").is_err());
262        assert!(list(&config).is_empty());
263    }
264}