Skip to main content

tryaudex_core/
roles.rs

1use std::collections::HashMap;
2
3use serde::{Deserialize, Serialize};
4
5use crate::error::{AvError, Result};
6
7/// Role mapping configuration in `[roles]` config section.
8/// Maps developer identities (email, team, username) to IAM roles.
9#[derive(Debug, Clone, Serialize, Deserialize, Default)]
10pub struct RoleMappingConfig {
11    /// Default role ARN used when no mapping matches
12    pub default_role: Option<String>,
13    /// Map of identity patterns to role ARNs.
14    /// Keys can be emails, team names, or glob patterns.
15    /// Example: { "backend-team" = "arn:aws:iam::123:role/BackendAgentRole" }
16    #[serde(default)]
17    pub mappings: HashMap<String, RoleMapping>,
18}
19
20/// A single role mapping entry.
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct RoleMapping {
23    /// IAM role ARN (AWS), service account email (GCP), or role name (Azure)
24    pub role: String,
25    /// Optional list of allowed profiles for this role
26    #[serde(default)]
27    pub allowed_profiles: Vec<String>,
28    /// Optional max TTL override (e.g. "30m")
29    pub max_ttl: Option<String>,
30    /// Optional description
31    pub description: Option<String>,
32}
33
34/// Resolved role for a given identity.
35#[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
43/// Resolve the IAM role for a given identity.
44/// Checks in order: exact email match, team/group match, glob patterns, default.
45pub fn resolve(config: &RoleMappingConfig, identity: &str) -> Result<ResolvedRole> {
46    // 1. Exact match
47    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    // 2. Glob pattern match (e.g. "*@backend.team.com", "deploy-*")
57    // Sort patterns for deterministic matching when multiple patterns could match.
58    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    // 3. Default role
72    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
87/// Simple glob matching supporting * and ? wildcards.
88fn matches_glob(pattern: &str, text: &str) -> bool {
89    if !pattern.contains('*') && !pattern.contains('?') {
90        return false; // Not a glob pattern, skip (exact match handled separately)
91    }
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
99/// Iterative glob matching using a two-pointer approach. The previous
100/// recursive implementation was O(n^k) on patterns with multiple `*`
101/// wildcards (e.g. `*a*a*a*b`), causing ReDoS on pathological input.
102fn 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; // try matching * with zero chars first
114        } else if let Some(sp) = star_pi {
115            // backtrack: let the last * consume one more char
116            pi = sp + 1;
117            star_ti += 1;
118            ti = star_ti;
119        } else {
120            return false;
121        }
122    }
123
124    // Consume trailing *s in the pattern
125    while pi < pattern.len() && pattern[pi] == '*' {
126        pi += 1;
127    }
128
129    pi == pattern.len()
130}
131
132/// Detect the current user identity from environment.
133/// Checks: AUDEX_IDENTITY, USER, LOGNAME, git config user.email.
134pub fn detect_identity() -> Option<String> {
135    // Explicit override
136    if let Ok(id) = std::env::var("AUDEX_IDENTITY") {
137        if !id.is_empty() {
138            return Some(id);
139        }
140    }
141
142    // Git user email
143    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    // System username
156    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}