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