Skip to main content

lean_ctx/core/context_kernel/
identity_resolver.rs

1//! Header-based caller identity resolution.
2
3use super::identity::{CallerIdentity, CallerRole};
4
5/// Header names used to resolve caller identity attributes.
6#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
7pub struct ResolverConfig {
8    /// Header containing the caller's user identifier.
9    pub user_header: String,
10    /// Header containing the caller's team identifier.
11    pub team_header: String,
12    /// Header containing the caller's cost center.
13    pub cost_center_header: String,
14    /// Header containing the caller's role.
15    pub role_header: String,
16    /// Header containing the caller's session identifier.
17    pub session_header: String,
18}
19
20impl Default for ResolverConfig {
21    fn default() -> Self {
22        Self {
23            user_header: "x-user-id".to_string(),
24            team_header: "x-team-id".to_string(),
25            cost_center_header: "x-cost-center".to_string(),
26            role_header: "x-caller-role".to_string(),
27            session_header: "x-session-id".to_string(),
28        }
29    }
30}
31
32/// Resolves a caller identity from headers using the supplied configuration.
33pub fn resolve_from_headers(
34    config: &ResolverConfig,
35    headers: &[(String, String)],
36) -> CallerIdentity {
37    let mut identity = CallerIdentity::default();
38    enrich_identity(&mut identity, headers, config);
39    identity
40}
41
42/// Parses a case-insensitive caller role, defaulting to developer.
43pub fn parse_role(value: &str) -> CallerRole {
44    match value.trim().to_ascii_lowercase().as_str() {
45        "reviewer" => CallerRole::Reviewer,
46        "agent" => CallerRole::Agent,
47        "system" => CallerRole::System,
48        "admin" => CallerRole::Admin,
49        _ => CallerRole::Developer,
50    }
51}
52
53/// Resolves a caller identity using the default header names.
54pub fn resolve_with_defaults(headers: &[(String, String)]) -> CallerIdentity {
55    resolve_from_headers(&ResolverConfig::default(), headers)
56}
57
58/// Adds header-derived attributes without replacing populated identity fields.
59pub fn enrich_identity(
60    base: &mut CallerIdentity,
61    headers: &[(String, String)],
62    config: &ResolverConfig,
63) {
64    if base.user_id.is_none() {
65        base.user_id = header_value(headers, &config.user_header);
66    }
67    if base.team_id.is_none() {
68        base.team_id = header_value(headers, &config.team_header);
69    }
70    if base.cost_center.is_none() {
71        base.cost_center = header_value(headers, &config.cost_center_header);
72    }
73    if base.session_id.is_none() {
74        base.session_id = header_value(headers, &config.session_header);
75    }
76    if base.role == CallerRole::default()
77        && let Some(role) = header_value(headers, &config.role_header)
78    {
79        base.role = parse_role(&role);
80    }
81}
82
83fn header_value(headers: &[(String, String)], name: &str) -> Option<String> {
84    headers
85        .iter()
86        .find(|(header, _)| header.eq_ignore_ascii_case(name))
87        .map(|(_, value)| value)
88        .cloned()
89}
90
91#[cfg(test)]
92mod tests {
93    use super::{
94        CallerIdentity, CallerRole, ResolverConfig, enrich_identity, parse_role,
95        resolve_from_headers, resolve_with_defaults,
96    };
97
98    fn headers(values: &[(&str, &str)]) -> Vec<(String, String)> {
99        values
100            .iter()
101            .map(|(name, value)| ((*name).to_string(), (*value).to_string()))
102            .collect()
103    }
104
105    #[test]
106    fn resolve_empty_headers() {
107        assert_eq!(resolve_with_defaults(&[]), CallerIdentity::default());
108    }
109
110    #[test]
111    fn resolve_with_user_and_team() {
112        let identity = resolve_with_defaults(&headers(&[
113            ("x-user-id", "user-1"),
114            ("x-team-id", "team-1"),
115        ]));
116
117        assert_eq!(identity.user_id.as_deref(), Some("user-1"));
118        assert_eq!(identity.team_id.as_deref(), Some("team-1"));
119    }
120
121    #[test]
122    fn parse_role_case_insensitive() {
123        for value in ["Agent", "AGENT", "agent"] {
124            assert_eq!(parse_role(value), CallerRole::Agent);
125        }
126    }
127
128    #[test]
129    fn parse_role_unknown_defaults() {
130        assert_eq!(parse_role("unknown"), CallerRole::Developer);
131    }
132
133    #[test]
134    fn enrich_does_not_overwrite() {
135        let mut identity = CallerIdentity {
136            user_id: Some("existing".to_string()),
137            ..CallerIdentity::default()
138        };
139
140        enrich_identity(
141            &mut identity,
142            &headers(&[("x-user-id", "replacement"), ("x-team-id", "team-1")]),
143            &ResolverConfig::default(),
144        );
145
146        assert_eq!(identity.user_id.as_deref(), Some("existing"));
147        assert_eq!(identity.team_id.as_deref(), Some("team-1"));
148    }
149
150    #[test]
151    fn custom_header_names() {
152        let config = ResolverConfig {
153            user_header: "caller".to_string(),
154            team_header: "group".to_string(),
155            ..ResolverConfig::default()
156        };
157        let identity = resolve_from_headers(
158            &config,
159            &headers(&[("Caller", "user-2"), ("GROUP", "team-2")]),
160        );
161
162        assert_eq!(identity.user_id.as_deref(), Some("user-2"));
163        assert_eq!(identity.team_id.as_deref(), Some("team-2"));
164    }
165}