blueprint_auth/
request_auth.rs

1use std::collections::HashMap;
2
3/// Lightweight auth context derived from canonical headers set by the proxy
4#[derive(Clone, Debug, Default)]
5pub struct AuthContext {
6    inner: HashMap<String, String>,
7}
8
9impl AuthContext {
10    /// Create AuthContext from a generic header map to avoid axum dependency
11    pub fn from_headers<H>(headers: &H) -> Self
12    where
13        H: std::ops::Deref<Target = std::collections::HashMap<String, String>>,
14    {
15        let mut inner = HashMap::new();
16
17        // x-tenant-id is set by proxy after PII hashing
18        if let Some(tenant_hash) = headers.get("x-tenant-id") {
19            inner.insert("tenant_hash".to_string(), tenant_hash.clone());
20        }
21
22        // x-scopes is a space-delimited list; normalize to lowercase
23        if let Some(scopes_str) = headers.get("x-scopes") {
24            inner.insert("scopes".to_string(), scopes_str.clone());
25        }
26
27        AuthContext { inner }
28    }
29
30    /// Create AuthContext from axum HeaderMap for backward compatibility
31    pub fn from_axum_headers(headers: &axum::http::HeaderMap) -> Self {
32        let mut inner = HashMap::new();
33
34        // x-tenant-id is set by proxy after PII hashing
35        if let Some(tenant_hash) = headers.get("x-tenant-id").and_then(|v| v.to_str().ok()) {
36            inner.insert("tenant_hash".to_string(), tenant_hash.to_string());
37        }
38
39        // x-scopes is a space-delimited list; normalize to lowercase
40        if let Some(scopes_str) = headers.get("x-scopes").and_then(|v| v.to_str().ok()) {
41            inner.insert("scopes".to_string(), scopes_str.to_string());
42        }
43
44        AuthContext { inner }
45    }
46
47    pub fn tenant_hash(&self) -> Option<&str> {
48        self.inner.get("tenant_hash").map(|s| s.as_str())
49    }
50
51    pub fn scopes(&self) -> Vec<String> {
52        self.inner
53            .get("scopes")
54            .map(|s| {
55                s.split(' ')
56                    .filter(|p| !p.is_empty())
57                    .map(|p| p.to_ascii_lowercase())
58                    .collect()
59            })
60            .unwrap_or_default()
61    }
62
63    pub fn has_scope(&self, scope: &str) -> bool {
64        let scope = scope.to_ascii_lowercase();
65        self.scopes().contains(&scope)
66    }
67
68    pub fn has_any_scope<'a>(&self, names_or_prefixes: impl IntoIterator<Item = &'a str>) -> bool {
69        let scopes = self.scopes();
70        for n in names_or_prefixes {
71            let n = n.to_ascii_lowercase();
72            if n.ends_with(':') {
73                // prefix match
74                if scopes.iter().any(|s| s.starts_with(&n)) {
75                    return true;
76                }
77            } else if scopes.contains(&n) {
78                return true;
79            }
80        }
81        false
82    }
83}