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 (supports all partitions: aws, aws-cn, aws-us-gov)
65    if alias.starts_with("arn:") {
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        // R6-H4: derive ARN partition from the AWS region so GovCloud/China
83        // users don't get a cryptic STS error from a hardcoded commercial ARN.
84        let region = std::env::var("AWS_REGION")
85            .or_else(|_| std::env::var("AWS_DEFAULT_REGION"))
86            .unwrap_or_default();
87        let partition = if region.starts_with("us-gov-") {
88            "aws-us-gov"
89        } else if region.starts_with("cn-") {
90            "aws-cn"
91        } else {
92            "aws"
93        };
94        return Ok(ResolvedAccount {
95            alias: alias.to_string(),
96            role_arn: format!("arn:{}:iam::{}:role/AudexAgentRole", partition, alias),
97            region: None,
98            external_id: None,
99            gcp_service_account: None,
100            gcp_project: None,
101            azure_subscription: None,
102            azure_tenant: None,
103            max_ttl: None,
104            deny: None,
105        });
106    }
107
108    // Azure subscription ID passthrough (UUID format or /subscriptions/ prefix)
109    if alias.starts_with("/subscriptions/")
110        || (alias.len() == 36
111            && alias.chars().filter(|c| *c == '-').count() == 4
112            && alias.chars().all(|c| c.is_ascii_hexdigit() || c == '-'))
113    {
114        return Ok(ResolvedAccount {
115            alias: alias.to_string(),
116            role_arn: String::new(),
117            region: None,
118            external_id: None,
119            gcp_service_account: None,
120            gcp_project: None,
121            azure_subscription: Some(alias.trim_start_matches("/subscriptions/").to_string()),
122            azure_tenant: None,
123            max_ttl: None,
124            deny: None,
125        });
126    }
127
128    // Named alias lookup
129    if let Some(acct) = config.accounts.get(alias) {
130        return Ok(ResolvedAccount {
131            alias: alias.to_string(),
132            role_arn: acct.role_arn.clone(),
133            region: acct.region.clone(),
134            external_id: acct.external_id.clone(),
135            gcp_service_account: acct.gcp_service_account.clone(),
136            gcp_project: acct.gcp_project.clone(),
137            azure_subscription: acct.azure_subscription.clone(),
138            azure_tenant: acct.azure_tenant.clone(),
139            max_ttl: acct.max_ttl.clone(),
140            deny: acct.deny.clone(),
141        });
142    }
143
144    let available: Vec<&str> = config.accounts.keys().map(|k| k.as_str()).collect();
145    Err(AvError::InvalidPolicy(format!(
146        "Unknown account '{}'. Available: {}. You can also pass a full ARN or 12-digit account ID.",
147        alias,
148        if available.is_empty() {
149            "none configured (add [account.accounts.<name>] to config)".to_string()
150        } else {
151            available.join(", ")
152        }
153    )))
154}
155
156/// List all configured account aliases.
157pub fn list(config: &AccountConfig) -> Vec<(String, String, Option<String>)> {
158    let mut accounts: Vec<(String, String, Option<String>)> = config
159        .accounts
160        .iter()
161        .map(|(alias, def)| (alias.clone(), def.role_arn.clone(), def.description.clone()))
162        .collect();
163    accounts.sort_by(|a, b| a.0.cmp(&b.0));
164    accounts
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170
171    fn test_config() -> AccountConfig {
172        let mut accounts = HashMap::new();
173        accounts.insert(
174            "production".to_string(),
175            AccountDef {
176                role_arn: "arn:aws:iam::111111111111:role/ProdAgentRole".to_string(),
177                description: Some("Production AWS account".to_string()),
178                region: Some("us-east-1".to_string()),
179                external_id: Some("audex-prod-xyz".to_string()),
180                gcp_service_account: None,
181                gcp_project: None,
182                azure_subscription: None,
183                azure_tenant: None,
184                max_ttl: Some("15m".to_string()),
185                deny: Some(vec!["iam:*".to_string()]),
186            },
187        );
188        accounts.insert(
189            "staging".to_string(),
190            AccountDef {
191                role_arn: "arn:aws:iam::222222222222:role/StagingAgentRole".to_string(),
192                description: Some("Staging AWS account".to_string()),
193                region: None,
194                external_id: None,
195                gcp_service_account: None,
196                gcp_project: None,
197                azure_subscription: None,
198                azure_tenant: None,
199                max_ttl: None,
200                deny: None,
201            },
202        );
203        AccountConfig {
204            default: Some("staging".to_string()),
205            accounts,
206        }
207    }
208
209    #[test]
210    fn test_resolve_named_alias() {
211        let config = test_config();
212        let resolved = resolve(&config, "production").unwrap();
213        assert_eq!(resolved.alias, "production");
214        assert_eq!(
215            resolved.role_arn,
216            "arn:aws:iam::111111111111:role/ProdAgentRole"
217        );
218        assert_eq!(resolved.region.as_deref(), Some("us-east-1"));
219        assert_eq!(resolved.external_id.as_deref(), Some("audex-prod-xyz"));
220        assert_eq!(resolved.max_ttl.as_deref(), Some("15m"));
221        assert!(resolved.deny.is_some());
222    }
223
224    #[test]
225    fn test_resolve_arn_passthrough() {
226        let config = test_config();
227        let arn = "arn:aws:iam::999999999999:role/DirectRole";
228        let resolved = resolve(&config, arn).unwrap();
229        assert_eq!(resolved.role_arn, arn);
230    }
231
232    #[test]
233    fn test_resolve_account_id_shorthand() {
234        let config = test_config();
235        let resolved = resolve(&config, "333333333333").unwrap();
236        assert_eq!(
237            resolved.role_arn,
238            "arn:aws:iam::333333333333:role/AudexAgentRole"
239        );
240    }
241
242    #[test]
243    fn test_resolve_unknown_alias() {
244        let config = test_config();
245        assert!(resolve(&config, "nonexistent").is_err());
246    }
247
248    #[test]
249    fn test_list_accounts() {
250        let config = test_config();
251        let accounts = list(&config);
252        assert_eq!(accounts.len(), 2);
253        // Sorted alphabetically
254        assert_eq!(accounts[0].0, "production");
255        assert_eq!(accounts[1].0, "staging");
256    }
257
258    #[test]
259    fn test_config_deserialize() {
260        let toml_str = r#"
261default = "dev"
262
263[accounts.dev]
264role_arn = "arn:aws:iam::123456789012:role/DevRole"
265description = "Development account"
266region = "us-west-2"
267
268[accounts.prod]
269role_arn = "arn:aws:iam::987654321098:role/ProdRole"
270external_id = "audex-12345"
271max_ttl = "10m"
272deny = ["iam:*", "organizations:*"]
273"#;
274        let config: AccountConfig = toml::from_str(toml_str).unwrap();
275        assert_eq!(config.default.as_deref(), Some("dev"));
276        assert_eq!(config.accounts.len(), 2);
277        let dev = config.accounts.get("dev").unwrap();
278        assert_eq!(dev.region.as_deref(), Some("us-west-2"));
279        let prod = config.accounts.get("prod").unwrap();
280        assert_eq!(prod.external_id.as_deref(), Some("audex-12345"));
281        assert_eq!(prod.deny.as_ref().unwrap().len(), 2);
282    }
283
284    #[test]
285    fn test_empty_config() {
286        let config = AccountConfig::default();
287        assert!(resolve(&config, "anything").is_err());
288        assert!(list(&config).is_empty());
289    }
290}