Skip to main content

backbone_orm/
audit_context.rs

1//! Request audit context: the attribution channel of data-change audit capture (ADR-0025).
2//!
3//! The auditlog module's capture function attributes a row change to WHO made it and WHERE it
4//! came from by reading session variables off the connection the write rides:
5//!
6//! - `app.actor` — the authenticated principal (the token's `sub`). Unset reads as NULL and the
7//!   capture function falls back to `'system'`, so background jobs and unattributed writes stay
8//!   distinguishable from named users without failing.
9//! - `app.correlation_id` — the request correlation id (honored from `X-Correlation-ID`, else
10//!   minted per request). This is the join key between an audit row and the request that caused
11//!   it — logs, responses and audit rows carry the same value.
12//! - `app.client_ip`, `app.user_agent`, `app.http_method`, `app.resource_path` — the request
13//!   facts, for after-the-fact triage of a suspicious change.
14//!
15//! All six are empty-string-when-unset on the wire: every reader wraps them in
16//! `nullif(current_setting(..., true), '')`, so an unset variable reads NULL there. Setting an
17//! empty string is therefore indistinguishable from never having set the variable — there is no
18//! partial-trust state to reason about.
19//!
20//! [`bind_on`](RequestAuditContext::bind_on) is the application half of the channel; the
21//! composing service's guard builds the context (actor off a signed token, the rest off the
22//! request) and the request scope carries it, exactly like the fence variables. The database
23//! half — the trigger reading them — is owned by the composed auditlog module.
24
25use sqlx::PgConnection;
26
27/// Every session variable of the audit channel, in bind and reset order.
28///
29/// Public so request-scope wrappers can reset the whole channel with one inventory (the same
30/// reason `with_org_request_scope` resets every fence variable it may have set, unconditionally
31/// — pool hygiene is cheaper than proving which variables a given request set).
32pub const AUDIT_CONTEXT_VARS: [&str; 6] = [
33    "app.actor",
34    "app.correlation_id",
35    "app.client_ip",
36    "app.user_agent",
37    "app.http_method",
38    "app.resource_path",
39];
40
41/// Who and where one request's writes are attributed to (ADR-0025).
42///
43/// Built by the composing service's guard — the actor off a signed token, never the request
44/// body — and carried by the request scope alongside the fence variables, so every write of the
45/// request (a trigger fires on it) reads the same attribution. All fields are
46/// empty-string-when-unknown; see the [module](self) docs for how readers treat that.
47#[derive(Debug, Clone, Default, PartialEq, Eq)]
48pub struct RequestAuditContext {
49    /// The authenticated principal the capture function records as the actor. Empty falls back
50    /// to `'system'` at capture time.
51    pub actor: String,
52    /// The request correlation id — the join key between audit rows and the request that
53    /// caused them.
54    pub correlation_id: String,
55    /// The client's IP as the edge proxy reported it (`X-Forwarded-For`'s first entry).
56    pub client_ip: String,
57    /// The request's `User-Agent`.
58    pub user_agent: String,
59    /// The HTTP method, upper-case as the router saw it.
60    pub http_method: String,
61    /// The request path (no query string) as the router saw it.
62    pub resource_path: String,
63}
64
65impl RequestAuditContext {
66    /// A context with only an actor — the minimum attribution a guarded request carries; every
67    /// other field stays unset (reads NULL at capture time).
68    pub fn new(actor: impl Into<String>) -> Self {
69        Self {
70            actor: actor.into(),
71            ..Self::default()
72        }
73    }
74
75    /// The (variable, value) pairs of the whole channel, in [`AUDIT_CONTEXT_VARS`] order —
76    /// the wire form bind and tests walk.
77    pub fn pairs(&self) -> [(&'static str, &str); 6] {
78        [
79            ("app.actor", self.actor.as_str()),
80            ("app.correlation_id", self.correlation_id.as_str()),
81            ("app.client_ip", self.client_ip.as_str()),
82            ("app.user_agent", self.user_agent.as_str()),
83            ("app.http_method", self.http_method.as_str()),
84            ("app.resource_path", self.resource_path.as_str()),
85        ]
86    }
87
88    /// Bind every audit variable on a connection.
89    ///
90    /// `local = true` binds transaction-locally (the twin of
91    /// [`bind_org_scope_on`](crate::org_scope::bind_org_scope_on), for hand-written write
92    /// services that manage their own transaction); `local = false` binds at session level on
93    /// a request-dedicated connection — the form the request scope uses, because the variables
94    /// must outlive any single statement for the whole request.
95    ///
96    /// # Errors
97    /// Returns the sqlx error if any `set_config` fails (connection lost mid-bind).
98    pub async fn bind_on(&self, conn: &mut PgConnection, local: bool) -> Result<(), sqlx::Error> {
99        for (var, value) in self.pairs() {
100            sqlx::query("SELECT set_config($1, $2, $3)")
101                .bind(var)
102                .bind(value)
103                .bind(local)
104                .execute(&mut *conn)
105                .await?;
106        }
107        Ok(())
108    }
109}
110
111// ─── Ambient request attribution ─────────────────────────────────────────────
112
113tokio::task_local! {
114    /// The request's audit attribution, published by the audited twin of the org
115    /// request scope ([`crate::org_scope::with_org_request_scope_and_audit`]).
116    /// Write services that open their OWN transactions — whose connections the
117    /// request-dedicated binding never touches — read it through
118    /// [`current_request_audit`] and relay it with [`relay_ambient_audit_on`],
119    /// the same discipline the org fence relay follows.
120    static REQUEST_AUDIT: RequestAuditContext;
121}
122
123/// Publish `audit` for the duration of `f` — the ambient-attribution half of
124/// the audited scope wrapper (crate-internal: callers reach it through the
125/// wrapper, never directly).
126pub(crate) async fn with_request_audit<F>(audit: RequestAuditContext, f: F) -> F::Output
127where
128    F: std::future::Future,
129{
130    REQUEST_AUDIT.scope(audit, f).await
131}
132
133/// The ambient request's audit attribution, when this task runs inside the
134/// audited org request scope; `None` outside one (standalone deployments,
135/// jobs, relays — the honest no-attribution posture).
136pub fn current_request_audit() -> Option<RequestAuditContext> {
137    REQUEST_AUDIT.try_with(|audit| audit.clone()).ok()
138}
139
140/// Re-bind the ambient request's audit attribution onto a connection the
141/// caller opened itself — the audit twin of the fence relay
142/// ([`bind_org_scope_on`](crate::org_scope::bind_org_scope_on)): a hand-written
143/// write service's fresh pool transaction carries none of the request
144/// connection's variables, so the audit triggers on it would read an empty
145/// actor and stamp `'system'`. Transaction-local (`local = true`): the
146/// attribution dies with the transaction, never leaking onto the next checkout
147/// of the pooled connection. With no ambient context this is a no-op.
148///
149/// # Errors
150/// Returns the sqlx error if any `set_config` fails.
151pub async fn relay_ambient_audit_on(conn: &mut PgConnection) -> Result<(), sqlx::Error> {
152    if let Some(audit) = current_request_audit() {
153        audit.bind_on(conn, true).await?;
154    }
155    Ok(())
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161
162    #[tokio::test]
163    async fn the_audit_context_travels_the_task_local() {
164        // Outside the audited wrapper there is no ambient attribution.
165        assert!(current_request_audit().is_none());
166        let audit = RequestAuditContext::new("user-1");
167        let seen = with_request_audit(audit.clone(), async { current_request_audit() }).await;
168        assert_eq!(seen, Some(audit));
169        // And the scope closes behind it.
170        assert!(current_request_audit().is_none());
171    }
172
173    #[tokio::test]
174    async fn relay_binds_the_actor_onto_a_transaction_local_channel() {
175        // A live-database check of the relay's actual effect: with a pool
176        // reachable it proves the transaction-local bind; without one it
177        // proves the no-ambient no-op path (the helper leaves the connection
178        // untouched, so the variables read as empty).
179        let url = std::env::var("DATABASE_URL").unwrap_or_default();
180        if url.is_empty() {
181            eprintln!("SKIP| audit relay: DATABASE_URL unset");
182            return;
183        }
184        let pool = match sqlx::PgPool::connect(&url).await {
185            Ok(p) => p,
186            Err(e) => {
187                eprintln!("SKIP| audit relay: {e}");
188                return;
189            }
190        };
191        let audit = RequestAuditContext {
192            actor: "relay-probe".to_string(),
193            correlation_id: "corr-1".to_string(),
194            ..RequestAuditContext::default()
195        };
196        with_request_audit(audit, async {
197            let mut tx = pool.begin().await.expect("begin");
198            relay_ambient_audit_on(&mut tx).await.expect("relay");
199            let actor: String = sqlx::query_scalar("SELECT current_setting('app.actor', true)")
200                .fetch_one(&mut *tx)
201                .await
202                .expect("read actor");
203            let correlation: String =
204                sqlx::query_scalar("SELECT current_setting('app.correlation_id', true)")
205                    .fetch_one(&mut *tx)
206                    .await
207                    .expect("read correlation");
208            assert_eq!(actor, "relay-probe");
209            assert_eq!(correlation, "corr-1");
210            tx.rollback().await.expect("rollback");
211        })
212        .await;
213    }
214}