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: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 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 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
124pub 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 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}