Skip to main content

cratestack_core/
context.rs

1//! Request-scoped context: authenticated identity, structured
2//! principal, transport extensions, plus the [`AuthProvider`] trait
3//! that auth middlewares implement.
4
5mod identity;
6mod principal;
7
8#[cfg(test)]
9mod tests;
10
11use std::collections::BTreeMap;
12
13use serde::{Deserialize, Serialize};
14
15use crate::error::CoolError;
16use crate::value::Value;
17
18pub use identity::CoolAuthIdentity;
19pub use principal::{PrincipalContext, PrincipalFacet};
20
21use principal::lookup_value_path_in_map;
22
23#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
24pub struct CoolContext {
25    pub auth: Option<CoolAuthIdentity>,
26    pub principal: Option<PrincipalContext>,
27    pub extensions: BTreeMap<String, Value>,
28}
29
30#[derive(Debug, Clone, Copy)]
31pub struct RequestContext<'a> {
32    pub method: &'a str,
33    pub path: &'a str,
34    pub query: Option<&'a str>,
35    pub headers: &'a http::HeaderMap,
36    pub body: &'a [u8],
37}
38
39pub trait AuthProvider: Clone + Send + Sync + 'static {
40    type Error: Into<CoolError> + Send;
41
42    fn authenticate(
43        &self,
44        request: &RequestContext<'_>,
45    ) -> impl ::core::future::Future<Output = Result<CoolContext, Self::Error>> + Send;
46}
47
48impl<F, E> AuthProvider for F
49where
50    F: Clone + Send + Sync + 'static + for<'a> Fn(&'a http::HeaderMap) -> Result<CoolContext, E>,
51    E: Into<CoolError> + Send,
52{
53    type Error = E;
54
55    fn authenticate(
56        &self,
57        request: &RequestContext<'_>,
58    ) -> impl ::core::future::Future<Output = Result<CoolContext, Self::Error>> + Send {
59        let result = (self)(request.headers);
60        ::core::future::ready(result)
61    }
62}
63
64impl CoolContext {
65    pub fn anonymous() -> Self {
66        Self::default()
67    }
68
69    pub fn authenticated(fields: impl IntoIterator<Item = (String, Value)>) -> Self {
70        let fields = fields.into_iter().collect::<BTreeMap<_, _>>();
71        Self {
72            auth: Some(CoolAuthIdentity {
73                fields: fields.clone(),
74            }),
75            principal: Some(PrincipalContext::from_claims(fields)),
76            extensions: BTreeMap::new(),
77        }
78    }
79
80    pub fn is_authenticated(&self) -> bool {
81        self.auth.is_some() || self.principal.is_some()
82    }
83
84    pub fn auth_field(&self, name: &str) -> Option<&Value> {
85        if let Some(auth) = self.auth.as_ref()
86            && let Some(value) = auth
87                .fields
88                .get(name)
89                .or_else(|| lookup_value_path_in_map(&auth.fields, name))
90        {
91            return Some(value);
92        }
93
94        self.principal
95            .as_ref()
96            .and_then(|principal| principal.field(name))
97    }
98
99    pub fn from_principal<P: Serialize>(principal: Option<P>) -> Result<Self, CoolError> {
100        let Some(principal) = principal else {
101            return Ok(Self::anonymous());
102        };
103
104        let principal = PrincipalContext::from_principal(principal)?;
105        let auth = principal.as_auth_identity();
106        Ok(Self {
107            auth: Some(auth),
108            principal: Some(principal),
109            extensions: BTreeMap::new(),
110        })
111    }
112
113    pub fn with_principal(principal: PrincipalContext) -> Self {
114        Self {
115            auth: Some(principal.as_auth_identity()),
116            principal: Some(principal),
117            extensions: BTreeMap::new(),
118        }
119    }
120
121    /// Convenience accessor for the principal's actor id. Falls back
122    /// from `principal.actor.id` to `principal.claims.id` to
123    /// `auth.fields.id` so audit rows capture an identity regardless
124    /// of which builder the caller used.
125    pub fn principal_actor_id(&self) -> Option<&str> {
126        let from_facet = self
127            .principal
128            .as_ref()
129            .and_then(|p| p.actor.as_ref())
130            .and_then(|facet| facet.fields.get("id"));
131        let from_claims = self.principal.as_ref().and_then(|p| p.claims.get("id"));
132        let from_auth = self.auth.as_ref().and_then(|auth| auth.fields.get("id"));
133        from_facet
134            .or(from_claims)
135            .or(from_auth)
136            .and_then(|v| match v {
137                Value::String(s) => Some(s.as_str()),
138                _ => None,
139            })
140    }
141
142    /// Tenant id surfaced for audit/log scoping.
143    pub fn tenant_id(&self) -> Option<&str> {
144        self.principal
145            .as_ref()
146            .and_then(|p| p.tenant.as_ref())
147            .and_then(|facet| facet.fields.get("id"))
148            .and_then(|v| match v {
149                Value::String(s) => Some(s.as_str()),
150                _ => None,
151            })
152    }
153
154    /// Client IP, if the auth provider injected one (e.g. from
155    /// `X-Forwarded-For` or the socket remote-addr).
156    pub fn client_ip(&self) -> Option<&str> {
157        self.extensions.get("client_ip").and_then(|v| match v {
158            Value::String(s) => Some(s.as_str()),
159            _ => None,
160        })
161    }
162
163    /// W3C `traceparent` value, if surfaced into the context by the
164    /// correlation-id middleware.
165    pub fn request_id(&self) -> Option<&str> {
166        self.extensions.get("request_id").and_then(|v| match v {
167            Value::String(s) => Some(s.as_str()),
168            _ => None,
169        })
170    }
171
172    /// Snapshot of principal claims for audit recording — full map
173    /// regardless of nesting depth. Empty for anonymous contexts.
174    pub fn audit_claims_snapshot(&self) -> BTreeMap<String, Value> {
175        self.principal
176            .as_ref()
177            .map(|p| p.claims.clone())
178            .unwrap_or_default()
179    }
180
181    /// Attach a W3C `traceparent`-style request id to the context.
182    /// Surfaces in tracing spans and is recorded on audit events so
183    /// SIEM tools can stitch the trail across systems.
184    pub fn with_request_id(mut self, request_id: impl Into<String>) -> Self {
185        self.extensions
186            .insert("request_id".to_owned(), Value::String(request_id.into()));
187        self
188    }
189
190    /// Attach a client IP for the same reasons as
191    /// [`Self::with_request_id`]. Banks generally derive this from
192    /// `X-Forwarded-For` or the socket address inside the auth
193    /// provider.
194    pub fn with_client_ip(mut self, ip: impl Into<String>) -> Self {
195        self.extensions
196            .insert("client_ip".to_owned(), Value::String(ip.into()));
197        self
198    }
199}