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)| {
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 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}