umbral_core/db/route_context.rs
1//! The request-scoped routing context: a `tokio::task_local!` value the
2//! `DatabaseRouter` reads to make per-request (per-tenant) decisions. The
3//! per-request twin of umbral's ambient-`OnceLock` pool pattern.
4
5use std::future::Future;
6use std::sync::Arc;
7
8/// An opaque tenant identifier. Apps that don't do multitenancy never set it.
9#[derive(Debug, Clone, PartialEq, Eq, Hash)]
10pub struct TenantKey(String);
11
12impl TenantKey {
13 pub fn new(s: impl Into<String>) -> Self {
14 TenantKey(s.into())
15 }
16 pub fn as_str(&self) -> &str {
17 &self.0
18 }
19}
20
21/// The request-scoped routing context. Carries the common-case tenant plus
22/// an extensible typed store so any app/plugin can stash its own routing key.
23#[derive(Clone, Default)]
24pub struct RouteContext {
25 tenant: Option<TenantKey>,
26 /// Postgres session variables (GUCs) to set on the connection this request
27 /// uses — e.g. `("app.user_id", "42")` for an RLS policy that reads
28 /// `current_setting('app.user_id')`. The PG pool's `after_acquire` hook
29 /// runs `set_config(name, value, false)` for each; `after_release` resets
30 /// them, so a value can't leak to the next request on the same connection.
31 session_vars: Vec<(String, String)>,
32 /// The authenticated caller's id, as a string (PK-shape independent: i64,
33 /// String and Uuid user models all round-trip through this).
34 ///
35 /// This is what `#[umbral(auto_user_add)]` / `#[umbral(auto_user)]` stamp
36 /// into a row on write. `None` means "no user in scope" — a background job,
37 /// a CLI command, or an anonymous request — and stamps NULL, which is why
38 /// those columns must be nullable.
39 user: Option<String>,
40 extensions: http::Extensions,
41}
42
43impl RouteContext {
44 pub fn new() -> Self {
45 Self::default()
46 }
47 pub fn with_tenant(mut self, tenant: TenantKey) -> Self {
48 self.tenant = Some(tenant);
49 self
50 }
51 pub fn tenant(&self) -> Option<&TenantKey> {
52 self.tenant.as_ref()
53 }
54 /// Set the authenticated caller for this request. Builder form.
55 pub fn with_user(mut self, user_id: impl Into<String>) -> Self {
56 self.user = Some(user_id.into());
57 self
58 }
59 /// Mutating form of [`Self::with_user`], for middleware that augments an
60 /// already-scoped context rather than building a fresh one.
61 pub fn set_user(&mut self, user_id: impl Into<String>) {
62 self.user = Some(user_id.into());
63 }
64 /// The authenticated caller's id, if one is in scope.
65 pub fn user(&self) -> Option<&str> {
66 self.user.as_deref()
67 }
68 /// Add a Postgres session variable (GUC) to apply on this request's DB
69 /// connection. Builder form; see [`Self::session_vars`].
70 pub fn with_session_var(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
71 self.session_vars.push((name.into(), value.into()));
72 self
73 }
74 /// Mutating form of [`Self::with_session_var`] (used by middleware that
75 /// augments an already-scoped context).
76 pub fn add_session_var(&mut self, name: impl Into<String>, value: impl Into<String>) {
77 self.session_vars.push((name.into(), value.into()));
78 }
79 /// The Postgres session variables to set for this request.
80 pub fn session_vars(&self) -> &[(String, String)] {
81 &self.session_vars
82 }
83 /// Stash a typed routing value for a custom router to read back.
84 pub fn insert<T: Clone + Send + Sync + 'static>(&mut self, value: T) {
85 self.extensions.insert(value);
86 }
87 /// Read a typed routing value previously stashed via [`Self::insert`].
88 pub fn get<T: Clone + Send + Sync + 'static>(&self) -> Option<&T> {
89 self.extensions.get::<T>()
90 }
91}
92
93tokio::task_local! {
94 static ROUTE_CONTEXT: Arc<RouteContext>;
95}
96
97/// The current request's routing context. Returns a **default** context when
98/// none is set — background `umbral-tasks` jobs, boot, CLI, and tests. The
99/// router then falls back to the default DB / `public` schema; it never
100/// silently inherits or guesses a tenant.
101pub fn current() -> Arc<RouteContext> {
102 ROUTE_CONTEXT
103 .try_with(|c| c.clone())
104 .unwrap_or_else(|_| Arc::new(RouteContext::default()))
105}
106
107/// The authenticated caller's id for this request, or `None`.
108///
109/// What `#[umbral(auto_user_add)]` / `#[umbral(auto_user)]` stamp on write. It is
110/// deliberately `Option`: a background job, a CLI command, a migration and an
111/// anonymous request all genuinely have no user, and a write from one of those
112/// stamps NULL rather than inventing an author. Task-locals do not cross
113/// `tokio::spawn`, so a spawned job has no user unless it explicitly enters a
114/// context with [`scope`] — which is the honest default.
115pub fn current_user_id() -> Option<String> {
116 ROUTE_CONTEXT
117 .try_with(|c| c.user().map(str::to_string))
118 .unwrap_or(None)
119}
120
121/// Run `fut` with `ctx` as the ambient routing context. The explicit opt-in a
122/// background job uses to run as a tenant.
123pub async fn scope<F: Future>(ctx: RouteContext, fut: F) -> F::Output {
124 ROUTE_CONTEXT.scope(Arc::new(ctx), fut).await
125}
126
127#[cfg(test)]
128mod tests {
129 use super::*;
130
131 #[tokio::test]
132 async fn current_is_default_when_unset() {
133 // No scope established: spawned-task / background fallback.
134 assert!(current().tenant().is_none());
135 }
136
137 #[tokio::test]
138 async fn scope_sets_and_restores_context() {
139 let ctx = RouteContext::new().with_tenant(TenantKey::new("acme"));
140 scope(ctx, async {
141 assert_eq!(current().tenant().unwrap().as_str(), "acme");
142 })
143 .await;
144 // Outside the scope, back to default.
145 assert!(current().tenant().is_none());
146 }
147
148 #[tokio::test]
149 async fn spawned_task_does_not_inherit_context() {
150 let ctx = RouteContext::new().with_tenant(TenantKey::new("acme"));
151 scope(ctx, async {
152 // A freshly spawned task has NO ambient context (task-locals
153 // don't cross spawn). This is the hard safety rule: no silent
154 // tenant inheritance into background work.
155 let handle = tokio::spawn(async { current().tenant().cloned() });
156 assert!(handle.await.unwrap().is_none());
157 })
158 .await;
159 }
160
161 #[tokio::test]
162 async fn session_vars_round_trip_through_scope() {
163 // audit_2 C2/R2: GUCs set on the context are visible inside the scope
164 // (the PG pool's before_acquire hook reads them) and gone outside it.
165 let ctx = RouteContext::new()
166 .with_session_var("app.user_id", "42")
167 .with_session_var("app.tenant_id", "acme");
168 scope(ctx, async {
169 let vars = current().session_vars().to_vec();
170 assert_eq!(
171 vars,
172 vec![
173 ("app.user_id".to_string(), "42".to_string()),
174 ("app.tenant_id".to_string(), "acme".to_string()),
175 ]
176 );
177 })
178 .await;
179 // Outside the scope: no session vars (nothing leaks to background work).
180 assert!(current().session_vars().is_empty());
181 }
182
183 #[tokio::test]
184 async fn extensions_store_typed_values() {
185 #[derive(Clone, PartialEq, Debug)]
186 struct Region(&'static str);
187 let mut ctx = RouteContext::new();
188 ctx.insert(Region("eu"));
189 scope(ctx, async {
190 assert_eq!(current().get::<Region>(), Some(&Region("eu")));
191 })
192 .await;
193 }
194}