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    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    // 3. Default role
69    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
84/// Simple glob matching supporting * and ? wildcards.
85fn matches_glob(pattern: &str, text: &str) -> bool {
86    if !pattern.contains('*') && !pattern.contains('?') {
87        return false; // Not a glob pattern, skip (exact match handled separately)
88    }
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            // * matches zero or more characters
107            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            // ? matches exactly one character
116            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
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}