Skip to main content

backbone_orm/
company_scope.rs

1//! Request company scope for the Postgres RLS read/write fence (ADR-0008).
2//!
3//! The security boundary is the database: every company-scoped table carries a Row-Level-Security
4//! policy `USING (company_id = NULLIF(current_setting('app.company_id', true), '')::uuid)`. This
5//! module is the *application* half — it carries the caller's company for the duration of a request
6//! and sets `app.company_id` on the connection each statement runs on.
7//!
8//! Why a task-local and not a signature parameter: the ORM executes connection-per-statement
9//! against a shared pool (`fetch_all(&self.pool)`), so there is no request-held connection to bind,
10//! and threading a scope argument through `CrudService::list` would be a breaking change across ~40
11//! modules that *still* would not reach raw `sqlx::query` callers. The task-local rides the async
12//! task instead, and the scoped execute helpers below set `app.company_id` transaction-locally so a
13//! value never leaks onto a pooled connection reused by the next request.
14//!
15//! **The task-local is not the fence.** RLS is. A statement that runs without the task-local set
16//! (a missed call site, a raw query, a spawned job) sees `app.company_id` unset → the policy matches
17//! zero rows. That is fail-closed: such a path *breaks* (returns empty), it never leaks. These
18//! helpers exist so the ORM read path returns the caller's rows instead of empty — correctness, not
19//! safety.
20
21use sqlx::pool::PoolConnection;
22use sqlx::postgres::{PgArguments, PgRow};
23use sqlx::query::{Query, QueryAs, QueryScalar};
24use sqlx::{FromRow, PgPool, Postgres};
25use std::future::Future;
26use std::sync::Arc;
27use tokio::sync::Mutex;
28use uuid::Uuid;
29
30tokio::task_local! {
31    /// The company the current request is scoped to. `Platform` callers and unscoped code leave
32    /// this unset; `None` inside the scope means an explicit platform (no-fence) caller.
33    static COMPANY: Option<Uuid>;
34
35    /// A connection dedicated to the current request, with `app.company_id` already set at the
36    /// SESSION level. When present, every scoped execute helper runs on it — so an ID-only lookup in
37    /// a hand-written service (e.g. `SELECT … WHERE id = $1`, with no `company_id` in the query) is
38    /// still fenced, because the scope rides the connection rather than the query text. This is the
39    /// path that makes custom write services RLS-correct without threading a company argument through
40    /// every method. Set by [`with_request_scope`].
41    static REQUEST_CONN: Arc<Mutex<PoolConnection<Postgres>>>;
42}
43
44/// Run `f` with a request-dedicated connection whose `app.company_id` is set to `company`.
45///
46/// Acquires one connection from `pool`, sets the session variable on it, and binds it as the
47/// request connection for the duration of `f`. Every scoped execute helper called inside `f` — from
48/// the ORM or a hand-written service — runs on this connection, so the whole request shares one
49/// company scope set exactly once (no per-statement transaction, and ID-only lookups are fenced
50/// too). The variable is reset before the connection returns to the pool, so it can never ride into
51/// the next request.
52///
53/// Trade-off: this pins a pooled connection for the request's lifetime (vs. connection-per-statement),
54/// so size the pool for peak concurrent requests. Prefer this at the HTTP composition root; leave
55/// non-request callers (jobs) on [`with_company_scope`] (per-statement scoping).
56pub async fn with_request_scope<F, R>(pool: &PgPool, company: Uuid, f: F) -> Result<R, sqlx::Error>
57where
58    F: Future<Output = R>,
59{
60    let mut conn = pool.acquire().await?;
61    sqlx::query("SELECT set_config('app.company_id', $1, false)")
62        .bind(company.to_string())
63        .execute(&mut *conn)
64        .await?;
65
66    let holder = Arc::new(Mutex::new(conn));
67    // Publish the COMPANY task-local too, matching `with_company_scope`'s visibility contract.
68    // The dedicated connection carries the company in its session var, but code that asks
69    // `current_company()` (application-layer adapters, audit stamps) has no other way to learn
70    // it — the two scope modes must not disagree about task-local visibility, or request-scoped
71    // deployments silently degrade `current_company()` to `None` for every handler.
72    let result = COMPANY
73        .scope(Some(company), REQUEST_CONN.scope(holder.clone(), f))
74        .await;
75
76    // Unconditional reset. We do NOT gate on `Arc::try_unwrap(holder)` (sole-reference check):
77    // `REQUEST_CONN` is a clonable `Arc`, and every scoped helper takes a clone via `request_conn()`.
78    // A clone that outlives the scope (a `tokio::spawn` capturing it, a value held across an `.await`
79    // that outlives the request future, or cancellation with a lingering task) would make `try_unwrap`
80    // return `Err`, skipping this block entirely and leaving `app.company_id` set at SESSION level —
81    // the connection then returns to the pool dirty, and the next acquire reads the PREVIOUS tenant's
82    // rows. That is a non-deterministic cross-tenant leak, not fail-closed. (Regression test:
83    // `lingering_request_conn_clone_does_not_dirty_the_pooled_connection`.)
84    //
85    // Locking the mutex here serializes behind any in-flight clone query, then clears the session var
86    // regardless of how many clones exist or when they drop. The `PoolConnection` only returns to the
87    // pool when the LAST `Arc` reference drops — by which point the var is already cleared here. A
88    // clone that runs further queries after this reset does so unscoped (fail-closed), which is the
89    // correct behaviour for work that outlived its request scope.
90    {
91        let mut guard = holder.lock().await;
92        if let Err(e) = sqlx::query("SELECT set_config('app.company_id', '', false)")
93            .execute(&mut **guard)
94            .await
95        {
96            // A reset failure (transient DB error) could leave the session var set. We cannot `detach`
97            // the connection from a `&mut` borrow, so log loud at ERROR — this must surface in ops as
98            // a fence-hygiene alert, not be swallowed silently. (Previously `let _ =` hid this.)
99            tracing::error!(
100                target: "backbone_orm::company_scope",
101                error = %e,
102                "failed to reset app.company_id on request connection; the pool connection may carry \
103                 the previous tenant's company_id — treat as a fence-hygiene incident",
104            );
105        }
106    }
107    Ok(result)
108}
109
110/// The request-dedicated connection, if [`with_request_scope`] set one for this task.
111fn request_conn() -> Option<Arc<Mutex<PoolConnection<Postgres>>>> {
112    REQUEST_CONN.try_with(|c| c.clone()).ok()
113}
114
115/// The request-dedicated connection for callers outside this module (`org_scope`'s
116/// statement-level helpers route onto the same connection so request-scoped sessions and
117/// per-statement callers share one fence surface).
118pub(crate) fn current_request_conn() -> Option<Arc<Mutex<PoolConnection<Postgres>>>> {
119    request_conn()
120}
121
122/// Count rows with one query on the request's own connection: `sql` selects a single `count(*)`
123/// and takes `binds` as `$1`, `$2`, … (text; cast them in the SQL). `None` when the task runs
124/// outside a request scope (a job, a seed), or when the query fails, which is logged. A compiled
125/// delete-guard rule counts the rows still referencing the row being deleted this way, and a
126/// roles source looks the writer's roles up, on the request's database and fence, never on a
127/// pool captured when the module was built, which can be another tenant's database.
128pub async fn count_on_request_conn(sql: &str, binds: &[&str]) -> Option<i64> {
129    let conn = request_conn()?;
130    let mut g = conn.lock().await;
131    let mut query = sqlx::query_scalar::<_, i64>(sql);
132    for b in binds {
133        query = query.bind(*b);
134    }
135    match query.fetch_one(&mut **g).await {
136        Ok(n) => Some(n),
137        Err(e) => {
138            tracing::warn!(target: "backbone_orm::company_scope", error = %e, sql, "a delete-guard count failed");
139            None
140        }
141    }
142}
143
144/// Run `f` with the request's company scope bound to the current async task.
145///
146/// Middleware calls this once per request with the company derived from the signed token, so every
147/// query issued while handling the request inherits it. `Some(uuid)` fences to that company;
148/// `None` is an explicit platform caller (no `app.company_id` is set → RLS-fenced tables return
149/// zero rows unless the connecting role bypasses RLS).
150pub async fn with_company_scope<F, R>(company: Option<Uuid>, f: F) -> R
151where
152    F: Future<Output = R>,
153{
154    COMPANY.scope(company, f).await
155}
156
157/// Internal: bind ONLY the `COMPANY` task-local around `f`, without acquiring a connection or
158/// setting any session variable.
159///
160/// For [`org_scope`](crate::org_scope), which drives its own request-dedicated connection and
161/// must not nest the full [`with_request_scope`] (that would acquire a second connection and
162/// re-set `app.company_id` from the legacy argument alone).
163pub(crate) async fn with_company_scope_internal<F, R>(company: Option<Uuid>, f: F) -> R
164where
165    F: Future<Output = R>,
166{
167    COMPANY.scope(company, f).await
168}
169
170/// Internal: bind ONLY the `REQUEST_CONN` task-local around `f`, without acquiring a connection
171/// or setting any session variable. The caller owns the connection and its fence variables.
172///
173/// For [`org_scope`](crate::org_scope), same reason as [`with_company_scope_internal`].
174pub(crate) async fn with_request_conn_internal<F, R>(
175    holder: Arc<Mutex<PoolConnection<Postgres>>>,
176    f: F,
177) -> R
178where
179    F: Future<Output = R>,
180{
181    REQUEST_CONN.scope(holder, f).await
182}
183
184/// The company bound to the current task, or `None` when no scope is set (unscoped code path).
185///
186/// `Ok(Some(id))` — fenced to a company. `Ok(None)` / no scope — no `app.company_id` will be set.
187/// The two `None` cases are intentionally indistinguishable here: neither sets the session var, and
188/// RLS fails closed for both.
189pub fn current_company() -> Option<Uuid> {
190    COMPANY.try_with(|c| *c).ok().flatten()
191}
192
193/// Set `app.company_id` transaction-locally on `conn`, with the request's audit attribution.
194///
195/// `set_config(_, _, true)` — the `true` scopes it to the surrounding transaction, so it is
196/// discarded on commit/rollback and cannot ride a pooled connection into the next request.
197/// The actor rides along: a transaction scoped here is one a write service opened itself, and
198/// without the relay its audit triggers would record the change as `'system'`.
199async fn bind_company(conn: &mut sqlx::PgConnection, company: Uuid) -> Result<(), sqlx::Error> {
200    sqlx::query("SELECT set_config('app.company_id', $1, true)")
201        .bind(company.to_string())
202        .execute(&mut *conn)
203        .await?;
204    crate::audit_context::relay_ambient_audit_on(conn).await
205}
206
207/// Bind an EXPLICIT company onto an already-open transaction/connection.
208///
209/// For call sites that know their company directly (it is on the DTO, or was just read off the row)
210/// and open their own transaction — the common shape in hand-written write services. Prefer this over
211/// [`bind_current_company`] when the company is known: it does not depend on an ambient task-local, so
212/// it is correct for non-request callers (event subscribers, jobs) too.
213pub async fn bind_company_on(
214    conn: &mut sqlx::PgConnection,
215    company: Uuid,
216) -> Result<(), sqlx::Error> {
217    bind_company(conn, company).await
218}
219
220/// Bind the current task's company onto an already-open transaction/connection.
221///
222/// For call sites that manage their own transaction (batch operations run all-or-nothing inside one
223/// `pool.begin()`): call this immediately after `begin()` so every statement in the transaction is
224/// company-scoped. A no-op when no company is in scope (fail-closed at the DB for fenced tables).
225pub async fn bind_current_company(conn: &mut sqlx::PgConnection) -> Result<(), sqlx::Error> {
226    match current_company() {
227        Some(company) => bind_company(conn, company).await,
228        // No company in scope (org-unit tenancy, jobs): nothing to fence here, but the change
229        // still belongs to whoever made the request.
230        None => crate::audit_context::relay_ambient_audit_on(conn).await,
231    }
232}
233
234// ─── Scoped execute helpers ────────────────────────────────────────────────────
235//
236// Each wraps a fully-bound query. When a company is in scope, the query runs inside a transaction
237// that first sets `app.company_id`; otherwise it runs directly against the pool (fail-closed at the
238// DB for fenced tables). The extra BEGIN/COMMIT per statement is the cost of connection-per-statement
239// pooling; a request-scoped held connection could remove it later (ADR-0008 follow-up).
240
241/// `fetch_all` for a typed row query, company-scoped.
242pub async fn fetch_all_scoped<'q, T>(
243    pool: &PgPool,
244    query: QueryAs<'q, Postgres, T, PgArguments>,
245) -> Result<Vec<T>, sqlx::Error>
246where
247    T: for<'r> FromRow<'r, PgRow> + Send + Unpin,
248{
249    if let Some(conn) = request_conn() {
250        let mut g = conn.lock().await;
251        return query.fetch_all(&mut **g).await;
252    }
253    match current_company() {
254        None => query.fetch_all(pool).await,
255        Some(company) => {
256            let mut tx = pool.begin().await?;
257            bind_company(&mut tx, company).await?;
258            let rows = query.fetch_all(&mut *tx).await?;
259            tx.commit().await?;
260            Ok(rows)
261        }
262    }
263}
264
265/// `fetch_one` for a typed row query, company-scoped.
266pub async fn fetch_one_scoped<'q, T>(
267    pool: &PgPool,
268    query: QueryAs<'q, Postgres, T, PgArguments>,
269) -> Result<T, sqlx::Error>
270where
271    T: for<'r> FromRow<'r, PgRow> + Send + Unpin,
272{
273    if let Some(conn) = request_conn() {
274        let mut g = conn.lock().await;
275        return query.fetch_one(&mut **g).await;
276    }
277    match current_company() {
278        None => query.fetch_one(pool).await,
279        Some(company) => {
280            let mut tx = pool.begin().await?;
281            bind_company(&mut tx, company).await?;
282            let row = query.fetch_one(&mut *tx).await?;
283            tx.commit().await?;
284            Ok(row)
285        }
286    }
287}
288
289/// `fetch_optional` for a typed row query, company-scoped.
290pub async fn fetch_optional_scoped<'q, T>(
291    pool: &PgPool,
292    query: QueryAs<'q, Postgres, T, PgArguments>,
293) -> Result<Option<T>, sqlx::Error>
294where
295    T: for<'r> FromRow<'r, PgRow> + Send + Unpin,
296{
297    if let Some(conn) = request_conn() {
298        let mut g = conn.lock().await;
299        return query.fetch_optional(&mut **g).await;
300    }
301    match current_company() {
302        None => query.fetch_optional(pool).await,
303        Some(company) => {
304            let mut tx = pool.begin().await?;
305            bind_company(&mut tx, company).await?;
306            let row = query.fetch_optional(&mut *tx).await?;
307            tx.commit().await?;
308            Ok(row)
309        }
310    }
311}
312
313/// `fetch_one` for a scalar query (e.g. `COUNT(*)`), company-scoped.
314pub async fn fetch_one_scalar_scoped<'q, S>(
315    pool: &PgPool,
316    query: QueryScalar<'q, Postgres, S, PgArguments>,
317) -> Result<S, sqlx::Error>
318where
319    S: Send + Unpin,
320    (S,): for<'r> FromRow<'r, PgRow>,
321{
322    if let Some(conn) = request_conn() {
323        let mut g = conn.lock().await;
324        return query.fetch_one(&mut **g).await;
325    }
326    match current_company() {
327        None => query.fetch_one(pool).await,
328        Some(company) => {
329            let mut tx = pool.begin().await?;
330            bind_company(&mut tx, company).await?;
331            let val = query.fetch_one(&mut *tx).await?;
332            tx.commit().await?;
333            Ok(val)
334        }
335    }
336}
337
338/// `fetch_optional` for a scalar query (e.g. `SELECT 1 … LIMIT 1`), company-scoped.
339pub async fn fetch_optional_scalar_scoped<'q, S>(
340    pool: &PgPool,
341    query: QueryScalar<'q, Postgres, S, PgArguments>,
342) -> Result<Option<S>, sqlx::Error>
343where
344    S: Send + Unpin,
345    (S,): for<'r> FromRow<'r, PgRow>,
346{
347    if let Some(conn) = request_conn() {
348        let mut g = conn.lock().await;
349        return query.fetch_optional(&mut **g).await;
350    }
351    match current_company() {
352        None => query.fetch_optional(pool).await,
353        Some(company) => {
354            let mut tx = pool.begin().await?;
355            bind_company(&mut tx, company).await?;
356            let val = query.fetch_optional(&mut *tx).await?;
357            tx.commit().await?;
358            Ok(val)
359        }
360    }
361}
362
363/// `fetch_optional` for an untyped row query (`sqlx::query(..)` → `PgRow`), company-scoped.
364///
365/// Hand-written services commonly read ad-hoc column sets as raw rows rather than a typed struct;
366/// these mirror the typed helpers so such a service can be scoped without restructuring its queries.
367pub async fn fetch_optional_row_scoped<'q>(
368    pool: &PgPool,
369    query: Query<'q, Postgres, PgArguments>,
370) -> Result<Option<PgRow>, sqlx::Error> {
371    if let Some(conn) = request_conn() {
372        let mut g = conn.lock().await;
373        return query.fetch_optional(&mut **g).await;
374    }
375    match current_company() {
376        None => query.fetch_optional(pool).await,
377        Some(company) => {
378            let mut tx = pool.begin().await?;
379            bind_company(&mut tx, company).await?;
380            let row = query.fetch_optional(&mut *tx).await?;
381            tx.commit().await?;
382            Ok(row)
383        }
384    }
385}
386
387/// `fetch_one` for an untyped row query (`sqlx::query(..)` → `PgRow`), company-scoped.
388pub async fn fetch_one_row_scoped<'q>(
389    pool: &PgPool,
390    query: Query<'q, Postgres, PgArguments>,
391) -> Result<PgRow, sqlx::Error> {
392    if let Some(conn) = request_conn() {
393        let mut g = conn.lock().await;
394        return query.fetch_one(&mut **g).await;
395    }
396    match current_company() {
397        None => query.fetch_one(pool).await,
398        Some(company) => {
399            let mut tx = pool.begin().await?;
400            bind_company(&mut tx, company).await?;
401            let row = query.fetch_one(&mut *tx).await?;
402            tx.commit().await?;
403            Ok(row)
404        }
405    }
406}
407
408/// `fetch_all` for an untyped row query (`sqlx::query(..)` → `Vec<PgRow>`), company-scoped.
409pub async fn fetch_all_rows_scoped<'q>(
410    pool: &PgPool,
411    query: Query<'q, Postgres, PgArguments>,
412) -> Result<Vec<PgRow>, sqlx::Error> {
413    if let Some(conn) = request_conn() {
414        let mut g = conn.lock().await;
415        return query.fetch_all(&mut **g).await;
416    }
417    match current_company() {
418        None => query.fetch_all(pool).await,
419        Some(company) => {
420            let mut tx = pool.begin().await?;
421            bind_company(&mut tx, company).await?;
422            let rows = query.fetch_all(&mut *tx).await?;
423            tx.commit().await?;
424            Ok(rows)
425        }
426    }
427}
428
429/// `execute` for a write/DDL query (INSERT/UPDATE/DELETE), company-scoped.
430///
431/// Writes are scoped too so the RLS `WITH CHECK` clause sees `app.company_id` and accepts the row
432/// (and rejects a forged cross-company write). A request whose company is unset cannot write to a
433/// fenced table — fail-closed on writes as well.
434pub async fn execute_scoped<'q>(
435    pool: &PgPool,
436    query: Query<'q, Postgres, PgArguments>,
437) -> Result<sqlx::postgres::PgQueryResult, sqlx::Error> {
438    if let Some(conn) = request_conn() {
439        let mut g = conn.lock().await;
440        return query.execute(&mut **g).await;
441    }
442    match current_company() {
443        None => query.execute(pool).await,
444        Some(company) => {
445            let mut tx = pool.begin().await?;
446            bind_company(&mut tx, company).await?;
447            let res = query.execute(&mut *tx).await?;
448            tx.commit().await?;
449            Ok(res)
450        }
451    }
452}
453
454#[cfg(test)]
455mod tests {
456    //! Regression: the request-scope connection's `app.company_id` MUST be reset even when a clone
457    //! of the task-local `REQUEST_CONN` Arc outlives the scope (a `tokio::spawn`, a value held across
458    //! an `.await` that outlives the request future, cancellation). Before the fix, the reset was
459    //! gated on `Arc::try_unwrap` succeeding; a lingering clone made it return `Err`, the reset was
460    //! skipped SILENTLY, and the pooled connection returned dirty — leaking the previous tenant's
461    //! `company_id` to the next acquire (a non-deterministic cross-tenant leak).
462    //!
463    //! Gated on `BACKBONE_ORM_RLS_DSN` (a superuser DSN). Skips when unset.
464    use super::{request_conn, with_request_scope};
465    use sqlx::postgres::PgPoolOptions;
466    use sqlx::PgPool;
467    use uuid::Uuid;
468
469    fn dsn() -> Option<String> {
470        std::env::var("BACKBONE_ORM_RLS_DSN").ok()
471    }
472
473    async fn admin_pool(dsn: &str) -> PgPool {
474        PgPoolOptions::new().max_connections(4).connect(dsn).await.unwrap()
475    }
476
477    /// A single-connection pool as the non-super test role. max_connections=1 forces a
478    /// re-acquire after the leaked clone drops to land on the SAME connection the scope dirtied.
479    async fn app_pool(dsn: &str, role: &str) -> PgPool {
480        let after_at = dsn.rsplit('@').next().unwrap();
481        let url = format!("postgresql://{role}:rlspw@{after_at}");
482        PgPoolOptions::new().max_connections(1).connect(&url).await.unwrap()
483    }
484
485    /// Mint the per-run role name. A fixed name breaks on shared dev clusters: `DROP ROLE` fails
486    /// when the role still holds grants in another database, so a leftover from earlier work
487    /// poisons every later run. A fresh name per run can never collide with residue.
488    fn role_name() -> String {
489        format!("rls_reset_app_{}", &Uuid::new_v4().simple().to_string()[..8])
490    }
491
492    async fn setup(admin: &PgPool, role: &str) {
493        sqlx::raw_sql(&format!(
494            "DROP SCHEMA IF EXISTS rls_reset_test CASCADE; \
495             CREATE SCHEMA rls_reset_test; \
496             CREATE ROLE {role} LOGIN PASSWORD 'rlspw'; \
497             GRANT USAGE ON SCHEMA rls_reset_test TO {role}; \
498             CREATE TABLE rls_reset_test.t (id uuid PRIMARY KEY, company_id uuid NOT NULL); \
499             GRANT SELECT, INSERT, UPDATE, DELETE ON rls_reset_test.t TO {role};",
500        ))
501        .execute(admin).await.unwrap();
502    }
503
504    /// Hold a clone of REQUEST_CONN past the scope, then verify the pooled connection comes back clean.
505    #[tokio::test]
506    async fn lingering_request_conn_clone_does_not_dirty_the_pooled_connection() {
507        let Some(dsn) = dsn() else { eprintln!("skipping: set BACKBONE_ORM_RLS_DSN"); return; };
508        let role = role_name();
509        let admin = admin_pool(&dsn).await;
510        setup(&admin, &role).await;
511        let pool = app_pool(&dsn, &role).await;
512        let company_a = Uuid::new_v4();
513
514        // Smuggle a clone of REQUEST_CONN OUT of the scope via a channel, so it outlives `f`.
515        let (tx, rx) = tokio::sync::oneshot::channel();
516
517        // Drive a request scope for company A; inside it, capture a clone of the request connection
518        // (the exact thing a `tokio::spawn` or a held-across-await would do) and hand it out.
519        with_request_scope(&pool, company_a, async {
520            if let Some(conn) = request_conn() {
521                let _ = tx.send(conn);
522            }
523        })
524        .await
525        .unwrap();
526
527        // The clone now outlives the scope. With the OLD (try_unwrap-gated) code the reset was
528        // skipped here and the connection would return to the pool dirty. Hold then drop the clone so
529        // the single pooled connection is returned, then re-acquire that SAME connection.
530        let held = rx.await.unwrap();
531        drop(held);
532
533        let mut conn = pool.acquire().await.unwrap();
534        let setting: String =
535            sqlx::query_scalar("SELECT current_setting('app.company_id', true)")
536                .fetch_one(&mut *conn)
537                .await
538                .unwrap();
539        // Before the fix this was `company_a.to_string()` (cross-tenant LEAK). It must be empty now.
540        assert_eq!(
541            setting, "",
542            "app.company_id leaked onto the pooled connection after scope exit — a lingering \
543             REQUEST_CONN clone must not bypass the session-var reset (cross-tenant leak)"
544        );
545    }
546}