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