Skip to main content

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}