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)| (alias.clone(), def.role_arn.clone(), def.description.clone()))
130        .collect();
131    accounts.sort_by(|a, b| a.0.cmp(&b.0));
132    accounts
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138
139    fn test_config() -> AccountConfig {
140        let mut accounts = HashMap::new();
141        accounts.insert(
142            "production".to_string(),
143            AccountDef {
144                role_arn: "arn:aws:iam::111111111111:role/ProdAgentRole".to_string(),
145                description: Some("Production AWS account".to_string()),
146                region: Some("us-east-1".to_string()),
147                external_id: Some("audex-prod-xyz".to_string()),
148                gcp_service_account: None,
149                gcp_project: None,
150                azure_subscription: None,
151                azure_tenant: None,
152                max_ttl: Some("15m".to_string()),
153                deny: Some(vec!["iam:*".to_string()]),
154            },
155        );
156        accounts.insert(
157            "staging".to_string(),
158            AccountDef {
159                role_arn: "arn:aws:iam::222222222222:role/StagingAgentRole".to_string(),
160                description: Some("Staging AWS account".to_string()),
161                region: None,
162                external_id: None,
163                gcp_service_account: None,
164                gcp_project: None,
165                azure_subscription: None,
166                azure_tenant: None,
167                max_ttl: None,
168                deny: None,
169            },
170        );
171        AccountConfig {
172            default: Some("staging".to_string()),
173            accounts,
174        }
175    }
176
177    #[test]
178    fn test_resolve_named_alias() {
179        let config = test_config();
180        let resolved = resolve(&config, "production").unwrap();
181        assert_eq!(resolved.alias, "production");
182        assert_eq!(
183            resolved.role_arn,
184            "arn:aws:iam::111111111111:role/ProdAgentRole"
185        );
186        assert_eq!(resolved.region.as_deref(), Some("us-east-1"));
187        assert_eq!(resolved.external_id.as_deref(), Some("audex-prod-xyz"));
188        assert_eq!(resolved.max_ttl.as_deref(), Some("15m"));
189        assert!(resolved.deny.is_some());
190    }
191
192    #[test]
193    fn test_resolve_arn_passthrough() {
194        let config = test_config();
195        let arn = "arn:aws:iam::999999999999:role/DirectRole";
196        let resolved = resolve(&config, arn).unwrap();
197        assert_eq!(resolved.role_arn, arn);
198    }
199
200    #[test]
201    fn test_resolve_account_id_shorthand() {
202        let config = test_config();
203        let resolved = resolve(&config, "333333333333").unwrap();
204        assert_eq!(
205            resolved.role_arn,
206            "arn:aws:iam::333333333333:role/AudexAgentRole"
207        );
208    }
209
210    #[test]
211    fn test_resolve_unknown_alias() {
212        let config = test_config();
213        assert!(resolve(&config, "nonexistent").is_err());
214    }
215
216    #[test]
217    fn test_list_accounts() {
218        let config = test_config();
219        let accounts = list(&config);
220        assert_eq!(accounts.len(), 2);
221        // Sorted alphabetically
222        assert_eq!(accounts[0].0, "production");
223        assert_eq!(accounts[1].0, "staging");
224    }
225
226    #[test]
227    fn test_config_deserialize() {
228        let toml_str = r#"
229default = "dev"
230
231[accounts.dev]
232role_arn = "arn:aws:iam::123456789012:role/DevRole"
233description = "Development account"
234region = "us-west-2"
235
236[accounts.prod]
237role_arn = "arn:aws:iam::987654321098:role/ProdRole"
238external_id = "audex-12345"
239max_ttl = "10m"
240deny = ["iam:*", "organizations:*"]
241"#;
242        let config: AccountConfig = toml::from_str(toml_str).unwrap();
243        assert_eq!(config.default.as_deref(), Some("dev"));
244        assert_eq!(config.accounts.len(), 2);
245        let dev = config.accounts.get("dev").unwrap();
246        assert_eq!(dev.region.as_deref(), Some("us-west-2"));
247        let prod = config.accounts.get("prod").unwrap();
248        assert_eq!(prod.external_id.as_deref(), Some("audex-12345"));
249        assert_eq!(prod.deny.as_ref().unwrap().len(), 2);
250    }
251
252    #[test]
253    fn test_empty_config() {
254        let config = AccountConfig::default();
255        assert!(resolve(&config, "anything").is_err());
256        assert!(list(&config).is_empty());
257    }
258}