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}
12
13/// Scoped identity of the current request, shared by every layer.
14///
15/// This is the single source of truth for "who is acting". The HTTP adapter
16/// establishes the context **before** binding the request (`RequestModel::bind`),
17/// so the binder, the handler, middleware and the persistence layer all read the
18/// very same `Arc<IdentityUser>` — there is no second copy of `sub` and no way
19/// for audit and handler code to disagree.
20///
21/// Background work (`IHostedService`, spawned tasks, timers) has **no** ambient
22/// identity by default: `identity()` and `operator_id()` return `None` there,
23/// which audit code must treat as the System principal. A background job that
24/// acts on behalf of a user scopes that identity explicitly:
25///
26/// ```ignore
27/// let identity = Arc::new(IdentityUser { id: "user-1".into(), ..Default::default() });
28/// RequestContext::run(Some(identity), async move {
29///     // audit here sees user-1
30/// })
31/// .await;
32/// ```
33pub struct RequestContext;
34
35impl RequestContext {
36    /// Run an async block with `identity` in scope.
37    ///
38    /// Pass `None` for an anonymous request (or a system-initiated task); inside
39    /// `f`, `identity()` and `operator_id()` then return `None`.
40    pub async fn run<F, R>(identity: Option<Arc<IdentityUser>>, f: F) -> R
41    where
42        F: Future<Output = R>,
43    {
44        IDENTITY.scope(identity, f).await
45    }
46
47    /// The authenticated identity of the current request, if any.
48    ///
49    /// Returns the very `Arc` the HTTP adapter created, so resolving the
50    /// identity twice never allocates a second copy.
51    pub fn identity() -> Option<Arc<IdentityUser>> {
52        IDENTITY
53            .try_with(|identity| identity.as_ref().map(Arc::clone))
54            .ok()
55            .flatten()
56    }
57
58    /// The current operator id (`IdentityUser::id`), if authenticated.
59    ///
60    /// `None` for anonymous requests and for background work that did not scope
61    /// an identity — audit code should record those as System.
62    pub fn operator_id() -> Option<String> {
63        Self::identity().map(|identity| identity.id.clone())
64    }
65}
66
67#[cfg(test)]
68mod tests {
69    use super::*;
70
71    fn identity(id: &str, tenant: &str) -> Arc<IdentityUser> {
72        Arc::new(IdentityUser {
73            id: id.to_string(),
74            tenant_id: tenant.to_string(),
75            ..Default::default()
76        })
77    }
78
79    #[tokio::test]
80    async fn identity_and_operator_id_are_visible_inside_run() {
81        let user = identity("user-1", "acme");
82
83        RequestContext::run(Some(Arc::clone(&user)), async {
84            let current = RequestContext::identity().expect("identity must be in scope");
85            assert!(
86                Arc::ptr_eq(&current, &user),
87                "identity() must hand back the same Arc, not a second copy"
88            );
89            assert_eq!(current.tenant(), Some("acme"));
90            assert_eq!(RequestContext::operator_id().as_deref(), Some("user-1"));
91        })
92        .await;
93    }
94
95    #[tokio::test]
96    async fn anonymous_scope_has_no_identity() {
97        RequestContext::run(None, async {
98            assert!(RequestContext::identity().is_none());
99            assert!(RequestContext::operator_id().is_none());
100        })
101        .await;
102    }
103
104    #[test]
105    fn outside_run_work_is_system() {
106        // No `run` on this task: background paths default to no identity.
107        assert!(RequestContext::identity().is_none());
108        assert!(RequestContext::operator_id().is_none());
109    }
110
111    #[tokio::test]
112    async fn nested_scope_restores_the_outer_identity() {
113        let outer = identity("outer-user", "");
114
115        RequestContext::run(Some(Arc::clone(&outer)), async {
116            RequestContext::run(None, async {
117                assert!(RequestContext::identity().is_none());
118            })
119            .await;
120
121            assert_eq!(
122                RequestContext::operator_id().as_deref(),
123                Some("outer-user"),
124                "inner scope must not leak into the outer request"
125            );
126        })
127        .await;
128    }
129}