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 let mut patterns: Vec<_> = config.mappings.iter().collect();
59 patterns.sort_by_key(|(k, _)| (*k).clone());
60 for (pattern, mapping) in patterns {
61 if matches_glob(pattern, identity) {
62 return Ok(ResolvedRole {
63 role: mapping.role.clone(),
64 allowed_profiles: mapping.allowed_profiles.clone(),
65 max_ttl: mapping.max_ttl.clone(),
66 matched_by: format!("pattern: {}", pattern),
67 });
68 }
69 }
70
71 if let Some(ref default) = config.default_role {
73 return Ok(ResolvedRole {
74 role: default.clone(),
75 allowed_profiles: Vec::new(),
76 max_ttl: None,
77 matched_by: "default".to_string(),
78 });
79 }
80
81 Err(AvError::InvalidPolicy(format!(
82 "No role mapping found for '{}' and no default_role configured",
83 identity
84 )))
85}
86
87fn matches_glob(pattern: &str, text: &str) -> bool {
89 if !pattern.contains('*') && !pattern.contains('?') {
90 return false; }
92
93 let pattern_chars: Vec<char> = pattern.chars().collect();
94 let text_chars: Vec<char> = text.chars().collect();
95
96 matches_glob_inner(&pattern_chars, &text_chars, 0, 0)
97}
98
99fn matches_glob_inner(pattern: &[char], text: &[char], mut pi: usize, mut ti: usize) -> bool {
103 let mut star_pi: Option<usize> = None;
104 let mut star_ti: usize = 0;
105
106 while ti < text.len() {
107 if pi < pattern.len() && (pattern[pi] == '?' || pattern[pi] == text[ti]) {
108 pi += 1;
109 ti += 1;
110 } else if pi < pattern.len() && pattern[pi] == '*' {
111 star_pi = Some(pi);
112 star_ti = ti;
113 pi += 1; } else if let Some(sp) = star_pi {
115 pi = sp + 1;
117 star_ti += 1;
118 ti = star_ti;
119 } else {
120 return false;
121 }
122 }
123
124 while pi < pattern.len() && pattern[pi] == '*' {
126 pi += 1;
127 }
128
129 pi == pattern.len()
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}