Skip to main content

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(&current, &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}