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;
7mod system;
8
9#[cfg(test)]
10mod tests;
11
12use std::collections::BTreeMap;
13
14use serde::{Deserialize, Serialize};
15
16use crate::error::CoolError;
17use crate::value::Value;
18
19pub use identity::CoolAuthIdentity;
20pub use principal::{PrincipalContext, PrincipalFacet};
21pub use system::SystemContext;
22
23use principal::lookup_value_path_in_map;
24
25#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
26pub struct CoolContext {
27    pub auth: Option<CoolAuthIdentity>,
28    pub principal: Option<PrincipalContext>,
29    pub extensions: BTreeMap<String, Value>,
30    /// Backing flag for the `auth().isSystem()` policy builtin (issue
31    /// #486 / ADR 0038 blocker B1).
32    ///
33    /// Deliberately **private** and `#[serde(skip)]` — this is the
34    /// entire forgery boundary the feature depends on, so both
35    /// properties matter independently:
36    ///
37    /// - Private, so no crate outside this module can flip it on a
38    ///   context it already holds. The only public way to obtain a
39    ///   `CoolContext` with this set is [`SystemContext`], which has no
40    ///   `From`/`TryFrom<CoolContext>` and no constructor that accepts
41    ///   an existing (e.g. request-derived) `CoolContext` — see that
42    ///   type's docs for why that upgrade path cannot exist even by
43    ///   accident. Every `AuthProvider` impl in every consuming crate
44    ///   can only ever produce a `CoolContext` through the public
45    ///   constructors below, all of which set this to `false`.
46    /// - `#[serde(skip)]`, so the flag cannot cross a wire. On
47    ///   deserialization serde leaves it at `bool::default()` (`false`)
48    ///   regardless of what a payload contains — including a payload
49    ///   that explicitly sets `"system": true`, which is the exact
50    ///   forgery this guards against. See
51    ///   `context::system::tests::forged_system_field_in_payload_is_ignored`.
52    #[serde(skip)]
53    system: bool,
54}
55
56#[derive(Debug, Clone, Copy)]
57pub struct RequestContext<'a> {
58    pub method: &'a str,
59    pub path: &'a str,
60    pub query: Option<&'a str>,
61    pub headers: &'a http::HeaderMap,
62    pub body: &'a [u8],
63}
64
65pub trait AuthProvider: Clone + Send + Sync + 'static {
66    type Error: Into<CoolError> + Send;
67
68    fn authenticate(
69        &self,
70        request: &RequestContext<'_>,
71    ) -> impl ::core::future::Future<Output = Result<CoolContext, Self::Error>> + Send;
72}
73
74impl<F, E> AuthProvider for F
75where
76    F: Clone + Send + Sync + 'static + for<'a> Fn(&'a http::HeaderMap) -> Result<CoolContext, E>,
77    E: Into<CoolError> + Send,
78{
79    type Error = E;
80
81    fn authenticate(
82        &self,
83        request: &RequestContext<'_>,
84    ) -> impl ::core::future::Future<Output = Result<CoolContext, Self::Error>> + Send {
85        let result = (self)(request.headers);
86        ::core::future::ready(result)
87    }
88}
89
90impl CoolContext {
91    pub fn anonymous() -> Self {
92        Self::default()
93    }
94
95    pub fn authenticated(fields: impl IntoIterator<Item = (String, Value)>) -> Self {
96        let fields = fields.into_iter().collect::<BTreeMap<_, _>>();
97        Self {
98            auth: Some(CoolAuthIdentity {
99                fields: fields.clone(),
100            }),
101            principal: Some(PrincipalContext::from_claims(fields)),
102            extensions: BTreeMap::new(),
103            system: false,
104        }
105    }
106
107    pub fn is_authenticated(&self) -> bool {
108        self.auth.is_some() || self.principal.is_some()
109    }
110
111    /// Backs the `auth().isSystem()` policy builtin. `true` only for a
112    /// context minted through [`SystemContext`]; never `true` for
113    /// anything an [`AuthProvider`] produced from a request, and never
114    /// `true` after a deserialization round-trip. See the field doc on
115    /// `CoolContext::system` for the structural argument, not just the
116    /// convention, behind that guarantee.
117    pub fn is_system(&self) -> bool {
118        self.system
119    }
120
121    pub fn auth_field(&self, name: &str) -> Option<&Value> {
122        if let Some(auth) = self.auth.as_ref()
123            && let Some(value) = auth
124                .fields
125                .get(name)
126                .or_else(|| lookup_value_path_in_map(&auth.fields, name))
127        {
128            return Some(value);
129        }
130
131        self.principal
132            .as_ref()
133            .and_then(|principal| principal.field(name))
134    }
135
136    pub fn from_principal<P: Serialize>(principal: Option<P>) -> Result<Self, CoolError> {
137        let Some(principal) = principal else {
138            return Ok(Self::anonymous());
139        };
140
141        let principal = PrincipalContext::from_principal(principal)?;
142        let auth = principal.as_auth_identity();
143        Ok(Self {
144            auth: Some(auth),
145            principal: Some(principal),
146            extensions: BTreeMap::new(),
147            system: false,
148        })
149    }
150
151    pub fn with_principal(principal: PrincipalContext) -> Self {
152        Self {
153            auth: Some(principal.as_auth_identity()),
154            principal: Some(principal),
155            extensions: BTreeMap::new(),
156            system: false,
157        }
158    }
159
160    /// Convenience accessor for the principal's actor id. Falls back
161    /// from `principal.actor.id` to `principal.claims.id` to
162    /// `auth.fields.id` so audit rows capture an identity regardless
163    /// of which builder the caller used.
164    pub fn principal_actor_id(&self) -> Option<&str> {
165        let from_facet = self
166            .principal
167            .as_ref()
168            .and_then(|p| p.actor.as_ref())
169            .and_then(|facet| facet.fields.get("id"));
170        let from_claims = self.principal.as_ref().and_then(|p| p.claims.get("id"));
171        let from_auth = self.auth.as_ref().and_then(|auth| auth.fields.get("id"));
172        from_facet
173            .or(from_claims)
174            .or(from_auth)
175            .and_then(|v| match v {
176                Value::String(s) => Some(s.as_str()),
177                _ => None,
178            })
179    }
180
181    /// Tenant id surfaced for audit/log scoping.
182    pub fn tenant_id(&self) -> Option<&str> {
183        self.principal
184            .as_ref()
185            .and_then(|p| p.tenant.as_ref())
186            .and_then(|facet| facet.fields.get("id"))
187            .and_then(|v| match v {
188                Value::String(s) => Some(s.as_str()),
189                _ => None,
190            })
191    }
192
193    /// Client IP, if the auth provider injected one (e.g. from
194    /// `X-Forwarded-For` or the socket remote-addr).
195    pub fn client_ip(&self) -> Option<&str> {
196        self.extensions.get("client_ip").and_then(|v| match v {
197            Value::String(s) => Some(s.as_str()),
198            _ => None,
199        })
200    }
201
202    /// W3C `traceparent` value, if surfaced into the context by the
203    /// correlation-id middleware.
204    pub fn request_id(&self) -> Option<&str> {
205        self.extensions.get("request_id").and_then(|v| match v {
206            Value::String(s) => Some(s.as_str()),
207            _ => None,
208        })
209    }
210
211    /// Snapshot of principal claims for audit recording — full map
212    /// regardless of nesting depth. Empty for anonymous contexts.
213    pub fn audit_claims_snapshot(&self) -> BTreeMap<String, Value> {
214        self.principal
215            .as_ref()
216            .map(|p| p.claims.clone())
217            .unwrap_or_default()
218    }
219
220    /// Attach a W3C `traceparent`-style request id to the context.
221    /// Surfaces in tracing spans and is recorded on audit events so
222    /// SIEM tools can stitch the trail across systems.
223    pub fn with_request_id(mut self, request_id: impl Into<String>) -> Self {
224        self.extensions
225            .insert("request_id".to_owned(), Value::String(request_id.into()));
226        self
227    }
228
229    /// Attach a client IP for the same reasons as
230    /// [`Self::with_request_id`]. Banks generally derive this from
231    /// `X-Forwarded-For` or the socket address inside the auth
232    /// provider.
233    pub fn with_client_ip(mut self, ip: impl Into<String>) -> Self {
234        self.extensions
235            .insert("client_ip".to_owned(), Value::String(ip.into()));
236        self
237    }
238}