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 RoleMappingConfig {
11 pub default_role: Option<String>,
13 #[serde(default)]
17 pub mappings: HashMap<String, RoleMapping>,
18}
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct RoleMapping {
23 pub role: String,
25 #[serde(default)]
27 pub allowed_profiles: Vec<String>,
28 pub max_ttl: Option<String>,
30 pub description: Option<String>,
32}
33
34#[derive(Debug, Clone)]
36pub struct ResolvedRole {
37 pub role: String,
38 pub allowed_profiles: Vec<String>,
39 pub max_ttl: Option<String>,
40 pub matched_by: String,
41}
42
43pub fn resolve(config: &RoleMappingConfig, identity: &str) -> Result<ResolvedRole> {
46 if let Some(mapping) = config.mappings.get(identity) {
48 return Ok(ResolvedRole {
49 role: mapping.role.clone(),
50 allowed_profiles: mapping.allowed_profiles.clone(),
51 max_ttl: mapping.max_ttl.clone(),
52 matched_by: format!("exact: {}", identity),
53 });
54 }
55
56 for (pattern, mapping) in &config.mappings {
58 if matches_glob(pattern, identity) {
59 return Ok(ResolvedRole {
60 role: mapping.role.clone(),
61 allowed_profiles: mapping.allowed_profiles.clone(),
62 max_ttl: mapping.max_ttl.clone(),
63 matched_by: format!("pattern: {}", pattern),
64 });
65 }
66 }
67
68 if let Some(ref default) = config.default_role {
70 return Ok(ResolvedRole {
71 role: default.clone(),
72 allowed_profiles: Vec::new(),
73 max_ttl: None,
74 matched_by: "default".to_string(),
75 });
76 }
77
78 Err(AvError::InvalidPolicy(format!(
79 "No role mapping found for '{}' and no default_role configured",
80 identity
81 )))
82}
83
84fn matches_glob(pattern: &str, text: &str) -> bool {
86 if !pattern.contains('*') && !pattern.contains('?') {
87 return false; }
89
90 let pattern_chars: Vec<char> = pattern.chars().collect();
91 let text_chars: Vec<char> = text.chars().collect();
92
93 matches_glob_inner(&pattern_chars, &text_chars, 0, 0)
94}
95
96fn matches_glob_inner(pattern: &[char], text: &[char], pi: usize, ti: usize) -> bool {
97 if pi == pattern.len() && ti == text.len() {
98 return true;
99 }
100 if pi == pattern.len() {
101 return false;
102 }
103
104 match pattern[pi] {
105 '*' => {
106 for i in ti..=text.len() {
108 if matches_glob_inner(pattern, text, pi + 1, i) {
109 return true;
110 }
111 }
112 false
113 }
114 '?' => {
115 if ti < text.len() {
117 matches_glob_inner(pattern, text, pi + 1, ti + 1)
118 } else {
119 false
120 }
121 }
122 c => {
123 if ti < text.len() && text[ti] == c {
124 matches_glob_inner(pattern, text, pi + 1, ti + 1)
125 } else {
126 false
127 }
128 }
129 }
130}
131
132pub fn detect_identity() -> Option<String> {
135 if let Ok(id) = std::env::var("AUDEX_IDENTITY") {
137 if !id.is_empty() {
138 return Some(id);
139 }
140 }
141
142 if let Ok(output) = std::process::Command::new("git")
144 .args(["config", "user.email"])
145 .output()
146 {
147 if output.status.success() {
148 let email = String::from_utf8_lossy(&output.stdout).trim().to_string();
149 if !email.is_empty() {
150 return Some(email);
151 }
152 }
153 }
154
155 std::env::var("USER")
157 .or_else(|_| std::env::var("LOGNAME"))
158 .ok()
159}
160
161#[cfg(test)]
162mod tests {
163 use super::*;
164
165 fn test_config() -> RoleMappingConfig {
166 let mut mappings = HashMap::new();
167 mappings.insert(
168 "alice@example.com".to_string(),
169 RoleMapping {
170 role: "arn:aws:iam::123:role/AliceRole".to_string(),
171 allowed_profiles: vec!["s3-readonly".to_string()],
172 max_ttl: Some("30m".to_string()),
173 description: Some("Alice's role".to_string()),
174 },
175 );
176 mappings.insert(
177 "*@backend.example.com".to_string(),
178 RoleMapping {
179 role: "arn:aws:iam::123:role/BackendRole".to_string(),
180 allowed_profiles: vec![],
181 max_ttl: None,
182 description: None,
183 },
184 );
185 mappings.insert(
186 "deploy-*".to_string(),
187 RoleMapping {
188 role: "arn:aws:iam::123:role/DeployRole".to_string(),
189 allowed_profiles: vec!["lambda-deploy".to_string(), "ecr-push".to_string()],
190 max_ttl: Some("1h".to_string()),
191 description: None,
192 },
193 );
194 RoleMappingConfig {
195 default_role: Some("arn:aws:iam::123:role/DefaultRole".to_string()),
196 mappings,
197 }
198 }
199
200 #[test]
201 fn test_exact_match() {
202 let config = test_config();
203 let resolved = resolve(&config, "alice@example.com").unwrap();
204 assert_eq!(resolved.role, "arn:aws:iam::123:role/AliceRole");
205 assert!(resolved.matched_by.starts_with("exact:"));
206 assert_eq!(resolved.allowed_profiles, vec!["s3-readonly"]);
207 assert_eq!(resolved.max_ttl, Some("30m".to_string()));
208 }
209
210 #[test]
211 fn test_glob_email_domain() {
212 let config = test_config();
213 let resolved = resolve(&config, "bob@backend.example.com").unwrap();
214 assert_eq!(resolved.role, "arn:aws:iam::123:role/BackendRole");
215 assert!(resolved.matched_by.starts_with("pattern:"));
216 }
217
218 #[test]
219 fn test_glob_prefix() {
220 let config = test_config();
221 let resolved = resolve(&config, "deploy-staging").unwrap();
222 assert_eq!(resolved.role, "arn:aws:iam::123:role/DeployRole");
223 assert_eq!(resolved.allowed_profiles.len(), 2);
224 }
225
226 #[test]
227 fn test_default_fallback() {
228 let config = test_config();
229 let resolved = resolve(&config, "unknown-user").unwrap();
230 assert_eq!(resolved.role, "arn:aws:iam::123:role/DefaultRole");
231 assert_eq!(resolved.matched_by, "default");
232 }
233
234 #[test]
235 fn test_no_match_no_default() {
236 let config = RoleMappingConfig {
237 default_role: None,
238 mappings: HashMap::new(),
239 };
240 assert!(resolve(&config, "anyone").is_err());
241 }
242
243 #[test]
244 fn test_config_deserialize() {
245 let toml_str = r#"
246default_role = "arn:aws:iam::123:role/DefaultRole"
247
248[mappings.backend-team]
249role = "arn:aws:iam::123:role/BackendRole"
250allowed_profiles = ["s3-readwrite", "dynamodb-query"]
251max_ttl = "1h"
252description = "Backend team role"
253
254[mappings."*@frontend.example.com"]
255role = "arn:aws:iam::123:role/FrontendRole"
256"#;
257 let config: RoleMappingConfig = toml::from_str(toml_str).unwrap();
258 assert_eq!(
259 config.default_role.unwrap(),
260 "arn:aws:iam::123:role/DefaultRole"
261 );
262 assert_eq!(config.mappings.len(), 2);
263 let backend = config.mappings.get("backend-team").unwrap();
264 assert_eq!(backend.allowed_profiles.len(), 2);
265 }
266
267 #[test]
268 fn test_glob_question_mark() {
269 assert!(matches_glob("user?", "user1"));
270 assert!(matches_glob("user?", "userA"));
271 assert!(!matches_glob("user?", "user"));
272 assert!(!matches_glob("user?", "user12"));
273 }
274
275 #[test]
276 fn test_glob_star() {
277 assert!(matches_glob("*@example.com", "alice@example.com"));
278 assert!(matches_glob("deploy-*", "deploy-staging"));
279 assert!(matches_glob("deploy-*", "deploy-"));
280 assert!(!matches_glob("deploy-*", "deploystaging"));
281 }
282}