webx_core/request_context.rs
1//! Per-request ambient context for handler and persistence layers.
2
3use std::future::Future;
4use std::sync::Arc;
5
6use crate::auth::IdentityUser;
7
8tokio::task_local! {
9 /// The full authenticated identity of the current request, if any.
10 static IDENTITY: Option<Arc<IdentityUser>>;
11 /// The trace id of the current request, if the host assigned one.
12 static TRACE_ID: Option<Arc<str>>;
13}
14
15/// Scoped identity **and trace id** of the current request, shared by every
16/// layer.
17///
18/// This is the single source of truth for "who is acting" and "which request is
19/// this". The HTTP adapter establishes the context **before** binding the
20/// request (`RequestModel::bind`), so the binder, the handler, middleware and
21/// the persistence layer all read the very same `Arc<IdentityUser>` and the very
22/// same trace id — there is no second copy of `sub` and no way for audit and
23/// handler code to disagree.
24///
25/// The trace id is the one the tracing middleware echoes in the `x-trace-id`
26/// response header, so a handler that stamps it onto an audit row can be joined
27/// with the access log line for the same request:
28///
29/// ```ignore
30/// let trace_id = RequestContext::trace_id().map(|id| id.to_string());
31/// audit.trace_id = trace_id;
32/// ```
33///
34/// Background work (`IHostedService`, spawned tasks, timers) has **no** ambient
35/// identity by default: `identity()` and `operator_id()` return `None` there,
36/// which audit code must treat as the System principal. A background job that
37/// acts on behalf of a user scopes that identity explicitly:
38///
39/// ```ignore
40/// let identity = Arc::new(IdentityUser { id: "user-1".into(), ..Default::default() });
41/// RequestContext::run(Some(identity), async move {
42/// // audit here sees user-1
43/// })
44/// .await;
45/// ```
46pub struct RequestContext;
47
48impl RequestContext {
49 /// Run an async block with `identity` in scope.
50 ///
51 /// Pass `None` for an anonymous request (or a system-initiated task); inside
52 /// `f`, `identity()` and `operator_id()` then return `None`.
53 ///
54 /// An enclosing trace id is preserved: nesting a narrower identity scope
55 /// (audit helpers do this) must not make the trace id disappear.
56 pub async fn run<F, R>(identity: Option<Arc<IdentityUser>>, f: F) -> R
57 where
58 F: Future<Output = R>,
59 {
60 let trace_id = Self::trace_id();
61 Self::run_with_trace(identity, trace_id, f).await
62 }
63
64 /// Run an async block with `identity` and `trace_id` in scope.
65 ///
66 /// The HTTP adapter uses this: it passes the context's
67 /// [`IHttpContext::trace_id`](crate::http::IHttpContext::trace_id) so the
68 /// handler reads exactly the id emitted in the `x-trace-id` response header.
69 /// Pass `None` for a request that has no trace id.
70 pub async fn run_with_trace<F, R>(
71 identity: Option<Arc<IdentityUser>>,
72 trace_id: Option<Arc<str>>,
73 f: F,
74 ) -> R
75 where
76 F: Future<Output = R>,
77 {
78 IDENTITY.scope(identity, TRACE_ID.scope(trace_id, f)).await
79 }
80
81 /// The authenticated identity of the current request, if any.
82 ///
83 /// Returns the very `Arc` the HTTP adapter created, so resolving the
84 /// identity twice never allocates a second copy.
85 pub fn identity() -> Option<Arc<IdentityUser>> {
86 IDENTITY
87 .try_with(|identity| identity.as_ref().map(Arc::clone))
88 .ok()
89 .flatten()
90 }
91
92 /// The trace id of the current request, if the host assigned one.
93 ///
94 /// Identical to the `x-trace-id` response header. `None` outside a request
95 /// scope (background work) and for hosts that do not run the tracing
96 /// middleware.
97 pub fn trace_id() -> Option<Arc<str>> {
98 TRACE_ID.try_with(|trace| trace.clone()).ok().flatten()
99 }
100
101 /// The current operator id (`IdentityUser::id`), if authenticated.
102 ///
103 /// `None` for anonymous requests and for background work that did not scope
104 /// an identity — audit code should record those as System.
105 pub fn operator_id() -> Option<String> {
106 Self::identity().map(|identity| identity.id.clone())
107 }
108}
109
110#[cfg(test)]
111mod tests {
112 use super::*;
113
114 fn identity(id: &str, tenant: &str) -> Arc<IdentityUser> {
115 Arc::new(IdentityUser {
116 id: id.to_string(),
117 tenant_id: tenant.to_string(),
118 ..Default::default()
119 })
120 }
121
122 #[tokio::test]
123 async fn identity_and_operator_id_are_visible_inside_run() {
124 let user = identity("user-1", "acme");
125
126 RequestContext::run(Some(Arc::clone(&user)), async {
127 let current = RequestContext::identity().expect("identity must be in scope");
128 assert!(
129 Arc::ptr_eq(¤t, &user),
130 "identity() must hand back the same Arc, not a second copy"
131 );
132 assert_eq!(current.tenant(), Some("acme"));
133 assert_eq!(RequestContext::operator_id().as_deref(), Some("user-1"));
134 })
135 .await;
136 }
137
138 #[tokio::test]
139 async fn anonymous_scope_has_no_identity() {
140 RequestContext::run(None, async {
141 assert!(RequestContext::identity().is_none());
142 assert!(RequestContext::operator_id().is_none());
143 })
144 .await;
145 }
146
147 #[test]
148 fn outside_run_work_is_system() {
149 // No `run` on this task: background paths default to no identity.
150 assert!(RequestContext::identity().is_none());
151 assert!(RequestContext::operator_id().is_none());
152 }
153
154 #[tokio::test]
155 async fn nested_scope_restores_the_outer_identity() {
156 let outer = identity("outer-user", "");
157
158 RequestContext::run(Some(Arc::clone(&outer)), async {
159 RequestContext::run(None, async {
160 assert!(RequestContext::identity().is_none());
161 })
162 .await;
163
164 assert_eq!(
165 RequestContext::operator_id().as_deref(),
166 Some("outer-user"),
167 "inner scope must not leak into the outer request"
168 );
169 })
170 .await;
171 }
172
173 #[tokio::test]
174 async fn trace_id_is_visible_and_matches_the_scoped_value() {
175 RequestContext::run_with_trace(None, Some(Arc::from("trace-42")), async {
176 assert_eq!(RequestContext::trace_id().as_deref(), Some("trace-42"));
177 })
178 .await;
179
180 assert!(
181 RequestContext::trace_id().is_none(),
182 "the trace id must not outlive its scope"
183 );
184 }
185
186 #[tokio::test]
187 async fn nested_run_keeps_the_enclosing_trace_id() {
188 RequestContext::run_with_trace(
189 Some(identity("user-1", "acme")),
190 Some(Arc::from("t-1")),
191 async {
192 // A narrower identity scope (an audit helper, a background step)
193 // must not lose the request's trace id.
194 RequestContext::run(None, async {
195 assert_eq!(RequestContext::trace_id().as_deref(), Some("t-1"));
196 })
197 .await;
198
199 assert_eq!(RequestContext::trace_id().as_deref(), Some("t-1"));
200 },
201 )
202 .await;
203 }
204
205 #[tokio::test]
206 async fn anonymous_request_without_tracing_has_no_trace_id() {
207 RequestContext::run(None, async {
208 assert!(RequestContext::trace_id().is_none());
209 })
210 .await;
211 }
212}