1use std::collections::HashMap;
2
3use serde::{Deserialize, Serialize};
4
5use crate::error::{AvError, Result};
6
7#[derive(Debug, Clone, Serialize, Deserialize, Default)]
10pub struct AccountConfig {
11 pub default: Option<String>,
13 #[serde(default)]
15 pub accounts: HashMap<String, AccountDef>,
16}
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct AccountDef {
21 pub role_arn: String,
23 pub description: Option<String>,
25 pub region: Option<String>,
27 pub external_id: Option<String>,
29 pub gcp_service_account: Option<String>,
31 pub gcp_project: Option<String>,
33 pub azure_subscription: Option<String>,
35 pub azure_tenant: Option<String>,
37 pub max_ttl: Option<String>,
39 pub deny: Option<Vec<String>>,
41}
42
43#[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
58pub fn resolve(config: &AccountConfig, alias: &str) -> Result<ResolvedAccount> {
64 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 if alias.len() == 12 && alias.chars().all(|c| c.is_ascii_digit()) {
82 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 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 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
156pub 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 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}