boatramp_core/sql.rs
1//! A small, engine-agnostic SQL backend contract for the handler `sql` binding.
2//!
3//! The handler engine exposes a `sql` capability to guests, but *which* database
4//! serves it is a deployment detail — the same seam as the blob ([`Storage`])
5//! and KV ([`kv::KvStore`]) backends. [`SqlBackend`] is that seam, so the guest
6//! interface and the server UX stay identical across single-node and cluster
7//! deployments. The one implementation is **libsql** (SQLite-compatible): an
8//! embedded file per site (single-node) or a sqld namespace per site (cluster,
9//! read-replicable) — one engine, the split being config, not a backend choice.
10//!
11//! Each backend instance is **scoped to one site**; the engine/transport and the
12//! per-site database mapping live behind the trait, so a handler can never
13//! address another site's data ([`crate::deploy`]-style isolation).
14//!
15//! The contract is deliberately tiny — `begin` a transaction, `query`/`execute`
16//! within it, then `commit`/`rollback` — and the trait keeps the engine
17//! decoupled from libsql's specifics (and lets tests substitute a fake). The
18//! handler engine wraps each invocation in one transaction (commit on success,
19//! roll back on trap/error).
20//!
21//! [`Storage`]: crate::Storage
22//! [`kv::KvStore`]: crate::kv::KvStore
23
24use std::sync::Arc;
25
26use async_trait::async_trait;
27
28/// A single SQL value. `Boolean` is carried as a distinct class (so a guest can
29/// express one and a strictly-typed engine could bind a native `BOOL`); libsql,
30/// being SQLite-family, maps it to `0`/`1`.
31#[derive(Debug, Clone, PartialEq)]
32pub enum SqlValue {
33 /// SQL `NULL`.
34 Null,
35 /// A boolean (a native `BOOL` where the engine has one, else `0`/`1`).
36 Boolean(bool),
37 /// A 64-bit signed integer.
38 Integer(i64),
39 /// A 64-bit float.
40 Real(f64),
41 /// UTF-8 text.
42 Text(String),
43 /// A byte string.
44 Blob(Vec<u8>),
45 /// A JSON document (its JSON text) — the portable "JSON document" value, bound to
46 /// each engine's canonical document type: `jsonb` on Postgres (validated,
47 /// canonical, operator- and index-capable), the binary `JSON` type on MySQL, text
48 /// (json1) on SQLite. So a guest writes a `jsonb`/`JSON` column with no `::` cast,
49 /// AND the value **type-unifies** with such a column in `COALESCE`/comparison/`||`,
50 /// not only on INSERT. Note Postgres `jsonb` validates on write (malformed JSON is
51 /// rejected). Postgres's raw-text `json` type is out of the portable model — use
52 /// raw SQL with an explicit `::json` cast for it. Read back as
53 /// [`Text`](Self::Text) (the engines stringify JSON on the way out).
54 Json(String),
55}
56
57/// The rows a [`SqlTransaction::query`] returned: column names plus row-major
58/// cells (each row's length equals `columns.len()`).
59#[derive(Debug, Clone, Default, PartialEq)]
60pub struct SqlRows {
61 /// Column names, in result order.
62 pub columns: Vec<String>,
63 /// Rows, each a vector of cells aligned to `columns`.
64 pub rows: Vec<Vec<SqlValue>>,
65}
66
67/// Why a SQL operation failed.
68#[derive(Debug, Clone, thiserror::Error)]
69pub enum SqlError {
70 /// The statement could not be parsed or planned.
71 #[error("sql syntax error: {0}")]
72 Syntax(String),
73 /// A constraint (unique, type, foreign key, ...) was violated.
74 #[error("sql constraint error: {0}")]
75 Constraint(String),
76 /// Any other backend/transport error (I/O, connection, ...).
77 #[error("sql error: {0}")]
78 Other(String),
79 /// The backend is **not ready yet** — a host-MANAGED database that is still starting,
80 /// recovering, or has no healthy replica (distinct from a permanent config/transport error).
81 /// This is **transient**: the caller should retry, or return a retryable `503` — never treat it
82 /// as a permanent "not configured"/"not granted" failure. Emitted only by the managed-compute
83 /// resolver; an external/local backend's outages stay [`Other`](SqlError::Other) (per-DB
84 /// resilience, no readiness gate).
85 #[error("sql backend not ready: {0}")]
86 Unavailable(String),
87}
88
89impl SqlError {
90 /// Wrap any displayable error as [`SqlError::Other`].
91 pub fn other<E: std::fmt::Display>(err: E) -> Self {
92 Self::Other(err.to_string())
93 }
94
95 /// Wrap a displayable error as [`SqlError::Unavailable`] — a transient "managed backend not
96 /// ready yet" condition (still starting / recovering / no healthy replica).
97 pub fn unavailable<E: std::fmt::Display>(err: E) -> Self {
98 Self::Unavailable(err.to_string())
99 }
100
101 /// Whether this is the transient [`Unavailable`](SqlError::Unavailable) not-ready condition
102 /// (the caller may retry or gate with a retryable `503`).
103 pub fn is_unavailable(&self) -> bool {
104 matches!(self, Self::Unavailable(_))
105 }
106}
107
108/// Operator-configured SQL session-context GUC names carrying the host-resolved tenant (and the
109/// anonymous session) to an app's **Postgres RLS**, so its policies (`current_setting(name, true)`)
110/// mirror boatramp's injected tenancy predicate as a defense-in-depth backstop. Set on a managed /
111/// external SQL binding (the `rls_session` flag + these names, e.g. `app.tenant_id`). **Postgres
112/// only** — the backstop is the `current_setting` RLS pattern; a libsql/MySQL backend leaves these
113/// unset. The guest can NEVER set them itself (the reserved-write guard blocks the configured names,
114/// [`reject_reserved_session_writes`]); the host derives the value from the SAME resolution the
115/// injected predicate uses (own / target / session) or, for a posture-vetted `all` write, from the
116/// row/statement being written — the DB's `WITH CHECK` / `USING` is the final arbiter of a mismatch.
117#[derive(Debug, Clone)]
118pub struct RlsGuc {
119 /// The GUC carrying the resolved TENANT (e.g. `app.tenant_id`).
120 pub tenant: String,
121 /// The GUC carrying the anonymous SESSION id (e.g. `app.session_id`), if the operator uses one.
122 pub session: Option<String>,
123 /// A reserved, host-controlled sentinel written to [`Self::tenant`] on an **`all`-scoped READ**
124 /// (v0.4.21) — a value the operator guarantees can never be a real tenant id, so a table that
125 /// opts in with `USING (tenant_id = current_setting(name, true) OR current_setting(name, true) =
126 /// '<marker>')` opens cross-tenant for the audited `all` twins while every other table (and every
127 /// write) stays strict. `None` ⇒ `all` reads leave the GUC untouched (v0.4.20 behavior:
128 /// fail-closed). The guest can never set it — the whole [`Self::tenant`] namespace is reserved.
129 pub all_marker: Option<String>,
130}
131
132impl RlsGuc {
133 /// The configured GUC names, lowercased — the EXTRA reserved keys a guest may not set (on top of
134 /// the always-reserved `boatramp.*` / `@boatramp_*`), so a guest can't forge the RLS backstop.
135 pub fn reserved_names(&self) -> Vec<String> {
136 let mut v = vec![self.tenant.to_ascii_lowercase()];
137 if let Some(s) = &self.session {
138 v.push(s.to_ascii_lowercase());
139 }
140 v
141 }
142}
143
144/// Render a **transaction-local Postgres** GUC set (`SELECT set_config($1, $2, true)`). Both the
145/// setting NAME and the VALUE are BOUND parameters (never interpolated), so a dotted operator name
146/// like `app.tenant_id` and any value are injection-safe; the `true` scopes the setting to the
147/// current transaction (auto-cleared at COMMIT/ROLLBACK, like the `boatramp.project`/`site` context).
148/// The value is bound as TEXT (`set_config`'s argument type); the operator's RLS policy casts if its
149/// key column isn't text. Postgres only — callers gate on [`Dialect::Postgres`].
150pub fn render_set_local_guc(name: &str, value: &SqlValue) -> (String, Vec<SqlValue>) {
151 let text = match value {
152 SqlValue::Text(s) => s.clone(),
153 SqlValue::Integer(i) => i.to_string(),
154 SqlValue::Boolean(b) => b.to_string(),
155 SqlValue::Real(r) => r.to_string(),
156 // A tenant/session key is realistically text or an integer; anything else (blob/json/null)
157 // has no meaningful GUC text — bind empty so RLS `= current_setting(...)` denies (fail-safe).
158 SqlValue::Blob(_) | SqlValue::Null | SqlValue::Json(_) => String::new(),
159 };
160 (
161 "SELECT set_config(?1, ?2, true)".to_string(),
162 vec![SqlValue::Text(name.to_string()), SqlValue::Text(text)],
163 )
164}
165
166/// The SQL dialect a backend speaks. The `orm` compiler is `?N`-portable for almost
167/// everything (the backend rewrites the placeholders), and only consults this for the
168/// handful of constructs whose *syntax* genuinely differs across engines — currently JSON
169/// extraction (`json_extract(...)` on SQLite/MySQL vs `#>>` on Postgres).
170#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
171pub enum Dialect {
172 /// SQLite family (libsql). The default.
173 #[default]
174 Sqlite,
175 Postgres,
176 Mysql,
177}
178
179/// A per-site SQL backend (libsql — a local file or a remote sqld namespace).
180///
181/// One instance serves one site. The handler engine calls [`begin`] once per
182/// invocation that uses SQL and drives the resulting [`SqlTransaction`] to a
183/// commit (on a successful response) or rollback (on trap/error).
184///
185/// [`begin`]: SqlBackend::begin
186#[async_trait]
187pub trait SqlBackend: Send + Sync {
188 /// The SQL dialect this backend speaks — used by the `orm` compiler for the few
189 /// dialect-divergent constructs (e.g. JSON extraction). Defaults to SQLite-family
190 /// (libsql); the Postgres/MySQL backends override it.
191 fn dialect(&self) -> Dialect {
192 Dialect::Sqlite
193 }
194
195 /// The operator-configured RLS session-GUC names ([`RlsGuc`]) this backend carries, or `None`
196 /// (the common case). When `Some` **and** [`dialect`](Self::dialect) is
197 /// [`Postgres`](Dialect::Postgres), the handler `sql`/`orm` binding sets the host-resolved tenant
198 /// (own/target/session) per transaction, and the row's tenant per `all` write, via
199 /// [`render_set_local_guc`], so an app's RLS mirrors the injected predicate. The guest can never
200 /// set these itself ([`reject_reserved_session_writes`] blocks the configured names).
201 fn rls_guc(&self) -> Option<&RlsGuc> {
202 None
203 }
204
205 /// Open a new read-write transaction. Backends are free to draw the
206 /// underlying connection from a pool, a fresh embedded connection, or a
207 /// remote session. Writes always land on the primary.
208 async fn begin(&self) -> Result<Box<dyn SqlTransaction>, SqlError>;
209
210 /// Open a transaction for a **read-only** invocation, which a backend
211 /// configured with a read replica may route to that replica (separate read
212 /// vs write endpoint: reads → replicas, writes →
213 /// primary). A replica may lag the primary, so such reads are
214 /// **eventually consistent**; issuing a write on this transaction is a
215 /// caller error (it hits the read endpoint, which a replica rejects).
216 ///
217 /// The default has no replica and simply opens a normal transaction, so
218 /// single-node and replica-less deployments behave identically.
219 async fn begin_read_only(&self) -> Result<Box<dyn SqlTransaction>, SqlError> {
220 self.begin().await
221 }
222
223 /// Run a multi-statement SQL **script** as one unit (the simple-query protocol),
224 /// for operator migrations: `CREATE EXTENSION` and long chains of DDL/DML that the
225 /// parameterized per-statement path can't express. Only the external
226 /// Postgres/MySQL backends implement it (the per-site libsql backend rejects it);
227 /// it is an operator tool, not a guest capability.
228 async fn run_script(&self, _sql: &str) -> Result<(), SqlError> {
229 Err(SqlError::Other(
230 "this database does not support running a raw SQL script".into(),
231 ))
232 }
233
234 /// Run one row-returning statement directly, in its own short-lived read-only
235 /// transaction — backs the operator `sql query`. The default composes the existing
236 /// transaction methods, so every backend supports it.
237 async fn run_query(&self, sql: &str) -> Result<SqlRows, SqlError> {
238 let mut tx = self.begin_read_only().await?;
239 let result = tx.query(sql, &[]).await;
240 // Read-only: always roll back so nothing lingers and no write can slip through.
241 let _ = tx.rollback().await;
242 result
243 }
244
245 /// Whether this backend injects a **reserved** boatramp session context
246 /// (`rls_session` — the `boatramp.project` / `boatramp.site` GUC on Postgres, or
247 /// the `@boatramp_project` / `@boatramp_site` MySQL session var) that an app's
248 /// row-level-security policy keys on. Default `false`.
249 ///
250 /// When `true`, the guest `sql` binding must **refuse** any guest statement that
251 /// would set/reset those reserved keys (see [`reject_reserved_session_writes`]):
252 /// otherwise a hostile guest could spoof its injected tenant and defeat the app's
253 /// RLS. This is a security signal, not a routing one — see the `rls_session` doc for
254 /// the trust model (the real isolation boundary is the per-tenant database + role).
255 fn injects_session_context(&self) -> bool {
256 false
257 }
258}
259
260/// Reject a guest SQL statement that would set or reset a **boatramp-reserved**
261/// session key — the `boatramp.*` GUC (Postgres) or an `@boatramp_*` user variable
262/// (MySQL). Used by the guest `sql` binding when the backend
263/// [`injects_session_context`](SqlBackend::injects_session_context): with `rls_session`
264/// on, boatramp injects the request's tenant into those keys for the app's RLS, so a
265/// guest that could overwrite them would spoof its tenant and defeat that RLS.
266///
267/// The statement is **tokenized with `sqlparser`** (the [`GenericDialect`], which lexes
268/// Postgres `"idents"`, MySQL backticks, `@vars`, and comments), not string-matched, so
269/// the earlier naive filter's bypasses are closed: comments and whitespace are normalized
270/// away (`SET/*x*/ boatramp.project`, `/*c*/SET …`), casing is folded, and a
271/// concatenated / non-literal `set_config` argument can no longer smuggle the reserved
272/// name past the check. The match stays **narrow** — ordinary app SQL
273/// (`SET statement_timeout = …`, `SET search_path TO …`, `set_config('search_path', …)`,
274/// a `SELECT` merely mentioning "set" or "boatramp.project") is untouched.
275///
276/// Recognised hostile forms (all rejected):
277///
278/// - a **deferred-execution or persistent-default** construct, whose body the tokenizer
279/// cannot see into and where a reserved-key write could hide: any **dollar-quoted**
280/// token (`$$…$$` / `$tag$…$tag$` — a `DO` block, a routine body, or a string literal),
281/// a leading `DO` / `CALL`, a `CREATE`/`ALTER … FUNCTION|PROCEDURE`, or an
282/// `ALTER ROLE|DATABASE|USER|SYSTEM … boatramp.*`. A guest on the RLS path has no
283/// legitimate need for procedural code, so these whole classes are refused (the
284/// operator keeps them via trusted operator SQL); `$1`/`$2` bind params are unaffected
285/// (they lex as placeholders, not dollar-quoted strings);
286/// - a statement whose leading keyword is `SET` / `SET SESSION` / `SET LOCAL` /
287/// `RESET` / `DISCARD` whose target is a `boatramp.*` GUC or an `@boatramp_*` var
288/// (`RESET ALL` / `DISCARD ALL` reset custom GUCs too, so they are refused);
289/// - **any** `@boatramp_*` MySQL user-var token appearing *anywhere* in the statement
290/// — MySQL writes it not only as the leading `SET` target but after a comma
291/// (`SET @x=1, @boatramp_project=…`, incl. `:=`) or via `SELECT … INTO @boatramp_*`
292/// (no `SET` at all); the reserved namespace is refused position-independently
293/// (Postgres has no such token and a MySQL app never names the reserved var);
294/// - any `set_config(<arg1>, …)` call — anywhere, incl. inside a `SELECT` — whose first
295/// argument is a single-quoted string literal naming `boatramp` / `boatramp.*`, **or**
296/// whose first argument is not a single simple string literal at all (a concatenation
297/// or other expression could construct `boatramp.*` at runtime; a legitimate caller
298/// always passes a plain literal such as `'search_path'`).
299///
300/// **Fail-closed:** if the tokenizer cannot lex the statement at all, it is rejected — a
301/// guest statement the guard cannot understand must not slip through while a session
302/// context is injected.
303///
304/// `extra_namespaces` are additional reserved GUC namespaces (lowercased leading segments, e.g.
305/// `app` for a configured `app.tenant_id`/`app.session_id` RLS GUC) — a guest must not set the
306/// operator's RLS session keys either, or it could forge the defense-in-depth backstop. Reserving
307/// the whole namespace (like `boatramp`) is the safe, simple superset.
308///
309/// Returns [`SqlError::Other`] with a clear message on a match, else `Ok(())`.
310pub fn reject_reserved_session_writes(
311 sql: &str,
312 extra_namespaces: &[String],
313) -> Result<(), SqlError> {
314 use sqlparser::dialect::GenericDialect;
315 use sqlparser::tokenizer::{Token, Tokenizer, Word};
316
317 /// The reserved GUC namespace (Postgres) — the first dotted segment, lowercased.
318 const GUC_NAMESPACE: &str = "boatramp";
319 /// The reserved MySQL user-var prefix, lowercased (an `@`-prefixed identifier).
320 const MYSQL_VAR_PREFIX: &str = "@boatramp_";
321
322 // A reserved GUC namespace (leading dotted segment): boatramp's own, or an operator RLS one.
323 let is_reserved_ns = |w: &str| w == GUC_NAMESPACE || extra_namespaces.iter().any(|n| n == w);
324
325 let refused = || {
326 Err(SqlError::Other(
327 "setting a reserved session key (boatramp.* / @boatramp_*, or the operator's \
328 rls_session tenant GUC) is not permitted from a handler: it is managed by \
329 rls_session and reserved for per-request tenant isolation"
330 .to_string(),
331 ))
332 };
333
334 // Tokenize with the generic dialect: it lexes Postgres `"idents"`, MySQL backticks,
335 // `@vars`, and both comment styles, folding comments/whitespace into `Whitespace`
336 // tokens we then drop. A statement the tokenizer rejects fails closed (below).
337 let dialect = GenericDialect {};
338 let Ok(raw) = Tokenizer::new(&dialect, sql).tokenize() else {
339 // Fail closed: an unlexable guest statement (e.g. an unbalanced backtick like
340 // `SET @`boatramp_project`=1`) must not pass while a context is injected.
341 return refused();
342 };
343
344 // Drop whitespace/comment tokens so a comment cannot split a keyword or hide inside
345 // a `set_config(` call. What remains are the statement's significant tokens.
346 let toks: Vec<&Token> = raw
347 .iter()
348 .filter(|t| !matches!(t, Token::Whitespace(_)))
349 .collect();
350
351 // The unquoted, case-folded text of a `Word` token, or `None` for any other token.
352 // Quoted identifiers keep their inner text (so a backtick-/double-quoted reserved
353 // name is still recognized), just without the quotes.
354 fn word_lc(tok: &Token) -> Option<String> {
355 match tok {
356 Token::Word(Word { value, .. }) => Some(value.to_ascii_lowercase()),
357 _ => None,
358 }
359 }
360
361 // Whether a case-folded identifier names a reserved key: the MySQL `@boatramp_*`
362 // user var, or (as the leading segment of a GUC) the `boatramp` namespace.
363 let is_reserved_var = |w: &str| w.starts_with(MYSQL_VAR_PREFIX);
364
365 // ---- (0) Deferred-execution / persistent-default constructs the token scan below
366 // cannot see into. `sqlparser` lexes a **dollar-quoted body** (`$$…$$`, `$tag$…$tag$`)
367 // — a `DO` block or a `CREATE FUNCTION` body — as ONE opaque `DollarQuotedString`
368 // token, and a **single-quoted** `DO`/function body as a `SingleQuotedString`, so a
369 // reserved-key write hidden inside either (`DO $$ … set_config('boatramp.project', …,
370 // false) … $$`) is invisible to (a)/(b). A guest on the RLS path has no legitimate
371 // need for procedural code, so under an injected context these whole classes are
372 // refused outright — the operator keeps them via operator SQL, which is trusted and
373 // unguarded. Refused:
374 // - any dollar-quoted token (a `$$…$$` / `$tag$…$tag$` body or string literal);
375 // - a leading `DO` (anonymous block) or `CALL` (invoke a procedure that could set it);
376 // - `CREATE`/`ALTER … FUNCTION|PROCEDURE` (defines a body the tokenizer can't inspect);
377 // - `ALTER ROLE|DATABASE|USER|SYSTEM … boatramp.*` (sets a *persistent* default GUC).
378 // `$1`/`$2` bind params lex as `Placeholder`, not `DollarQuotedString`, so ordinary
379 // parameterized guest queries are unaffected.
380 if toks
381 .iter()
382 .any(|t| matches!(t, Token::DollarQuotedString(_)))
383 {
384 return refused();
385 }
386
387 // ---- (0b) A reserved MySQL user var (`@boatramp_*`) appearing ANYWHERE. MySQL
388 // writes it not only via a leading `SET` but also mid-`SET` after a comma
389 // (`SET @x=1, @boatramp_project='v'` — a `:=` variant too) and via
390 // `SELECT … INTO @boatramp_project` (no `SET` keyword at all), none of which the
391 // leading-token check (a) sees. Postgres has no legitimate `@boatramp_*` token and
392 // a MySQL app never needs to name the reserved var, so a **position-independent**
393 // refusal (like the `set_config` scan) closes the whole family — comma-assign,
394 // `SELECT … INTO`, `:=`, and case/quote variants. ----
395 if toks
396 .iter()
397 .any(|t| word_lc(t).is_some_and(|w| is_reserved_var(&w)))
398 {
399 return refused();
400 }
401 {
402 let leading = toks.first().and_then(|t| word_lc(t));
403 let has_word = |w: &str| toks.iter().any(|t| word_lc(t).as_deref() == Some(w));
404 let names_reserved = || {
405 toks.iter()
406 .any(|t| word_lc(t).is_some_and(|w| is_reserved_ns(&w) || is_reserved_var(&w)))
407 };
408 match leading.as_deref() {
409 // Anonymous code block / procedure call / prepared-statement indirection:
410 // deferred execution the token scan can't see through. `DO`/`CALL` run a
411 // body; `PREPARE s FROM '<text>'` + `EXECUTE s` (MySQL, same pooled
412 // connection within one invocation) hides the reserved write inside a
413 // *string literal* — which we must NOT scan (a literal naming the key is
414 // legitimate data), so refuse the deferral construct instead. The guest
415 // `sql` binding parameterizes via bind params (`$1`/`?`), never SQL-level
416 // PREPARE/EXECUTE, so refusing these on the RLS path costs nothing.
417 Some("do") | Some("call") | Some("prepare") | Some("execute") => return refused(),
418 // Defining a routine (single- or dollar-quoted body) on the guest path.
419 Some("create") | Some("alter") if has_word("function") || has_word("procedure") => {
420 return refused()
421 }
422 // A persistent GUC default: `ALTER ROLE/DATABASE/USER/SYSTEM … SET boatramp.*`
423 // (scoped to those targets so an `ALTER TABLE`/`INDEX` isn't caught).
424 Some("alter")
425 if matches!(
426 toks.get(1).and_then(|t| word_lc(t)).as_deref(),
427 Some("role") | Some("database") | Some("user") | Some("system")
428 ) && names_reserved() =>
429 {
430 return refused()
431 }
432 _ => {}
433 }
434 }
435
436 // ---- (a) A leading SET / RESET / DISCARD targeting a reserved key. ----
437 if let Some(first) = toks.first().and_then(|t| word_lc(t)) {
438 match first.as_str() {
439 // DISCARD [ALL|…]: DISCARD ALL resets every session GUC (incl. ours); any
440 // DISCARD is a broad session reset, so refuse it outright under a context.
441 "discard" => return refused(),
442 "reset" => {
443 // `RESET boatramp.project` (target segment == namespace) or `RESET ALL`
444 // (clears custom GUCs too).
445 if let Some(target) = toks.get(1).and_then(|t| word_lc(t)) {
446 if target == "all" || is_reserved_ns(&target) || is_reserved_var(&target) {
447 return refused();
448 }
449 }
450 }
451 "set" => {
452 // Skip an optional SESSION / LOCAL qualifier, then inspect the target.
453 let mut idx = 1;
454 if matches!(
455 toks.get(idx).and_then(|t| word_lc(t)).as_deref(),
456 Some("session") | Some("local")
457 ) {
458 idx += 1;
459 }
460 if let Some(target) = toks.get(idx).and_then(|t| word_lc(t)) {
461 // A GUC is `boatramp` `.` `project` (dotted); the MySQL var is the
462 // single `@boatramp_*` word. Either way the first identifier decides.
463 if is_reserved_ns(&target) || is_reserved_var(&target) {
464 return refused();
465 }
466 }
467 }
468 _ => {}
469 }
470 }
471
472 // ---- (b) A `set_config(<arg1>, …)` call anywhere (it can hide inside a SELECT, and
473 // more than one can appear). For each `set_config` word immediately followed by `(`,
474 // inspect the first argument: reject unless it is a single simple string literal that
475 // does NOT start with `boatramp.`. A concatenation/expression first arg is refused
476 // (it could build `boatramp.*` at runtime). ----
477 for (i, tok) in toks.iter().enumerate() {
478 if word_lc(tok).as_deref() != Some("set_config") {
479 continue;
480 }
481 // Must be a call: the next significant token is `(`.
482 if !matches!(toks.get(i + 1), Some(Token::LParen)) {
483 continue;
484 }
485 // The first argument token and the token following it.
486 let arg0 = toks.get(i + 2);
487 let after = toks.get(i + 3);
488 match (arg0, after) {
489 // A single simple **string literal** delimited by `,` or `)` — the only form
490 // a legitimate caller uses for the setting name (`set_config('search_path', …)`).
491 // Allow it iff it does not name the reserved GUC namespace. Note the generic
492 // dialect lexes a double-quoted `"…"` as a *delimited identifier* (a quoted
493 // `Word`), not a string literal, so it falls through to the catch-all below —
494 // a non-idiomatic double-quoted first arg is refused, which is fine.
495 (Some(Token::SingleQuotedString(s)), Some(Token::Comma | Token::RParen)) => {
496 let name = s.to_ascii_lowercase();
497 // A reserved namespace itself, or `<ns>.<anything>` (`.` as the boundary) — for
498 // boatramp's own namespace AND any operator RLS namespace (e.g. `app.tenant_id`).
499 let ns_of = name.split('.').next().unwrap_or(&name);
500 if is_reserved_ns(ns_of) {
501 return refused();
502 }
503 }
504 // Anything else as the first argument (a concatenation, a function call, a
505 // quoted identifier, a bind param, an empty `()`, …) cannot be proven safe →
506 // refuse: a non-literal could construct `boatramp.*` at runtime.
507 _ => return refused(),
508 }
509 }
510
511 Ok(())
512}
513
514/// How a **preview** deployment's SQL database relates to the site's live one
515/// (operator policy; see the per-site/server config). The default is the safe,
516/// isolated choice.
517#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
518pub enum PreviewSqlMode {
519 /// A fresh, empty database isolated from live (optionally seeded by an init
520 /// script). Can never read or clobber live data.
521 #[default]
522 Empty,
523 /// A consistent **copy** of the live database at branch time — realistic
524 /// data, but writes stay in the preview's copy.
525 Branch,
526 /// The site's **live** database, shared with production traffic. The preview
527 /// reads and writes real data — use only when that's intended.
528 Shared,
529}
530
531/// Resolves a site's named SQL databases to [`SqlBackend`]s — the seam the
532/// server's handler runtime uses to obtain a per-site database on demand
533/// (opening/caching it lazily). The concrete mapping (a libsql file per site,
534/// or a sqld namespace per site) lives behind this, so the server stays
535/// storage-agnostic.
536#[async_trait]
537pub trait SqlBackends: Send + Sync {
538 /// Open (or reuse) the database called `name` for `site` within tenant
539 /// `project` (the empty name is the site's default database). Per-tenant +
540 /// per-site isolation is the implementation's responsibility — a handler can
541 /// only ever reach its own project's site's data.
542 ///
543 /// `project` and `site` are **separately** validated by the implementation
544 /// and composed internally via
545 /// [`ProjectRef::qualified`](crate::project::ProjectRef::qualified) (the
546 /// reserved `default` project keeps the byte-identical, pre-project identity
547 /// for back-compat; any other project prefixes `"<project>/"`). Passing a
548 /// single already-composed `"<project>/<site>"` string as `site` would be
549 /// rejected — the two names are kept apart so each is validated on its own.
550 async fn database(
551 &self,
552 project: &str,
553 site: &str,
554 name: &str,
555 ) -> Result<Arc<dyn SqlBackend>, SqlError>;
556
557 /// Open (or reuse) the database for a **preview** deployment `preview` of
558 /// `site` within tenant `project`. The implementation applies its configured
559 /// [`PreviewSqlMode`]. The default is [`PreviewSqlMode::Empty`] — an isolated
560 /// database keyed by project+site+preview, so a preview can never touch live
561 /// state. The default composition qualifies `site` by `project` first, then
562 /// appends the trusted `_preview/{preview}` suffix (both from validated
563 /// parts), and delegates to [`database`](Self::database) under the reserved
564 /// `default` project so the already-qualified identity is not re-qualified.
565 async fn preview_database(
566 &self,
567 project: &str,
568 site: &str,
569 name: &str,
570 preview: &str,
571 ) -> Result<Arc<dyn SqlBackend>, SqlError> {
572 let qualified = crate::project::ProjectRef::new(project).qualified(site);
573 self.database(
574 crate::project::DEFAULT_PROJECT,
575 &format!("{qualified}/_preview/{preview}"),
576 name,
577 )
578 .await
579 }
580}
581
582/// The operator-facing SQL capability for a **managed** database: run a migration
583/// script or a single query against a compute-backed database boatramp runs, using
584/// its sealed managed credential (resolved server-side — the credential never leaves
585/// the node). Backs `POST /api/sql/{db}/{exec,query}` and the `boatramp sql` CLI.
586/// Distinct from [`SqlBackends`] (the per-site guest binding): this is a
587/// project-scoped **operator** tool, admin-gated at the API.
588#[async_trait]
589pub trait OperatorSql: Send + Sync {
590 /// Run a multi-statement migration `script` against managed database `db` in
591 /// `project` (the simple-query protocol — `CREATE EXTENSION` + chained DDL).
592 async fn exec_script(&self, project: &str, db: &str, script: &str) -> Result<(), SqlError>;
593
594 /// Run one row-returning `sql` statement against managed database `db`.
595 async fn query(&self, project: &str, db: &str, sql: &str) -> Result<SqlRows, SqlError>;
596
597 /// Probe every replica of managed database `db`'s compute workload — an **active**
598 /// TCP reachability check, independent of the stored health flag. Lets an operator
599 /// tell "the DB is actually down" (`tcp_reachable: false`) from "the DB is up but
600 /// the endpoint resolver won't serve it" (`tcp_reachable: true, healthy: false` —
601 /// the reachable-but-not-served signature). Never runs a query or presents a
602 /// credential; it only opens (and immediately drops) a TCP connection.
603 async fn ping(&self, project: &str, db: &str) -> Result<Vec<SqlPingReplica>, SqlError>;
604}
605
606/// One replica's reachability, returned by [`OperatorSql::ping`].
607#[derive(Debug, Clone, PartialEq, Eq)]
608pub struct SqlPingReplica {
609 /// The replica's endpoint (`host:port`).
610 pub endpoint: String,
611 /// The stored health flag (what the endpoint resolver gates serving on).
612 pub healthy: bool,
613 /// The replica's lifecycle phase (`running` / `zero`).
614 pub phase: String,
615 /// Whether a TCP connection to the endpoint succeeded just now.
616 pub tcp_reachable: bool,
617}
618
619/// Tear down a deleted tenant's **managed** databases — the delete-time counterpart
620/// to the create-time provisioning of a per-tenant managed `sql` binding. When a
621/// project (or site) is deleted through the control plane, boatramp drops *that
622/// tenant's* databases + login roles + sealed credentials — exactly that tenant's,
623/// nothing else — so a deleted tenant leaves no orphaned data plane behind.
624///
625/// **Best-effort by contract.** Both methods return `()`: a deprovision failure is
626/// the implementation's to log, and must never block or fail the delete it hangs off
627/// (an orphaned database is a lesser evil than a delete that can't complete). The
628/// reserved `default` project is never touched — its "tenant" is the whole
629/// single-tenant install. Wired by the node when a compute-backed managed database
630/// exists; the delete handlers call it after the store delete succeeds.
631#[async_trait]
632pub trait TenantDeprovisioner: Send + Sync {
633 /// Deprovision every `Project`-scoped managed binding for the deleted `project`.
634 async fn deprovision_project(&self, project: &str);
635
636 /// Deprovision every `Site`-scoped managed binding for the deleted `site` of
637 /// `project`.
638 async fn deprovision_site(&self, project: &str, site: &str);
639}
640
641/// One transaction's worth of work. Dropping it without [`commit`] must leave
642/// the database unchanged (the engine rolls back).
643///
644/// [`commit`]: SqlTransaction::commit
645#[async_trait]
646pub trait SqlTransaction: Send {
647 /// Run a row-returning statement (e.g. `SELECT`), binding `params` to the
648 /// statement's positional placeholders.
649 async fn query(&mut self, sql: &str, params: &[SqlValue]) -> Result<SqlRows, SqlError>;
650
651 /// Run a non-row statement (`INSERT`/`UPDATE`/`DELETE`/DDL), binding
652 /// `params`. Returns the number of affected rows (0 for DDL).
653 async fn execute(&mut self, sql: &str, params: &[SqlValue]) -> Result<u64, SqlError>;
654
655 /// Commit the transaction.
656 async fn commit(self: Box<Self>) -> Result<(), SqlError>;
657
658 /// Roll the transaction back.
659 async fn rollback(self: Box<Self>) -> Result<(), SqlError>;
660}
661
662#[cfg(test)]
663mod reserved_session_writes_tests {
664 use super::reject_reserved_session_writes as check;
665
666 fn rejected(sql: &str) -> bool {
667 check(sql, &[]).is_err()
668 }
669
670 /// Rejected when the operator's RLS GUC namespace (`app`) is reserved.
671 fn rejected_with_app(sql: &str) -> bool {
672 check(sql, &["app".to_string()]).is_err()
673 }
674
675 #[test]
676 fn operator_rls_guc_is_rejected_only_when_its_namespace_is_reserved() {
677 // With `app` reserved (a configured `app.tenant_id` RLS GUC), a guest cannot forge it
678 // via any form — direct SET, set_config, or a RESET of the namespace.
679 assert!(rejected_with_app("SET app.tenant_id = 'victim'"));
680 assert!(rejected_with_app("set local app.tenant_id = 'victim'"));
681 assert!(rejected_with_app(
682 "SELECT set_config('app.tenant_id','victim',false)"
683 ));
684 assert!(rejected_with_app("RESET app.tenant_id"));
685 // Without the reservation, an ordinary `app.*` set is NOT the guard's business (only
686 // boatramp's own namespace is always reserved).
687 assert!(!rejected("SET app.tenant_id = 'x'"));
688 assert!(!rejected("SELECT set_config('app.tenant_id','x',true)"));
689 // The always-reserved boatramp namespace is still blocked regardless of extras.
690 assert!(rejected_with_app("SET boatramp.project = 'x'"));
691 }
692
693 // ---- hostile statements that spoof the injected tenant MUST be rejected ----
694
695 #[test]
696 fn set_config_on_reserved_guc_is_rejected() {
697 assert!(rejected(
698 "SELECT set_config('boatramp.project','victim',false)"
699 ));
700 assert!(rejected("select set_config('boatramp.site', 'x', true)"));
701 // Tolerant of whitespace around the call and the quote.
702 assert!(rejected(
703 "SELECT set_config ( 'boatramp.project' , 'v', false )"
704 ));
705 // Double-quoted first arg (unusual but a literal in some dialects).
706 assert!(rejected(
707 "SELECT set_config(\"boatramp.project\", 'v', false)"
708 ));
709 // A reserved set_config hiding AFTER a benign one in the same statement is
710 // still caught (every occurrence is checked, not just the first).
711 assert!(rejected(
712 "SELECT set_config('search_path','app',false), \
713 set_config('boatramp.project','v',false)"
714 ));
715 }
716
717 #[test]
718 fn set_reserved_guc_is_rejected() {
719 assert!(rejected("SET boatramp.project = 'victim'"));
720 assert!(rejected("set boatramp.project='victim'")); // no spaces
721 assert!(rejected("SET SESSION boatramp.site = 'x'"));
722 assert!(rejected("SET LOCAL boatramp.project TO 'x'"));
723 }
724
725 #[test]
726 fn set_reserved_mysql_var_is_rejected() {
727 assert!(rejected("SET @boatramp_project = 'victim'"));
728 assert!(rejected("set @boatramp_site='x'"));
729 assert!(rejected("SET @boatramp_project := 'x'")); // MySQL := assignment
730 assert!(rejected("SET SESSION @boatramp_project = 'x'"));
731 }
732
733 #[test]
734 fn reset_and_discard_of_reserved_state_is_rejected() {
735 assert!(rejected("RESET boatramp.project"));
736 assert!(rejected("RESET ALL")); // clears our GUC too
737 assert!(rejected("DISCARD ALL"));
738 assert!(rejected("discard all"));
739 }
740
741 // ---- legitimate app SQL MUST be allowed (narrow match) ----
742
743 #[test]
744 fn unrelated_set_statements_are_allowed() {
745 assert!(!rejected("SET statement_timeout = 5000"));
746 assert!(!rejected("SET search_path TO app, public"));
747 assert!(!rejected("SET SESSION time_zone = '+00:00'"));
748 assert!(!rejected("SET @my_var = 1")); // a non-reserved MySQL user var
749 assert!(!rejected("RESET statement_timeout"));
750 }
751
752 #[test]
753 fn a_select_mentioning_set_in_an_identifier_is_allowed() {
754 // "set" appears only as an identifier / column word, not a SET statement.
755 assert!(!rejected("SELECT settings FROM boatramp_projects"));
756 assert!(!rejected(
757 "SELECT * FROM offset_table WHERE reset_at > now()"
758 ));
759 // A normal SELECT that happens to filter on a column literally named similarly.
760 assert!(!rejected("SELECT * FROM t WHERE name = 'boatramp.project'"));
761 }
762
763 #[test]
764 fn set_config_on_a_non_reserved_guc_is_allowed() {
765 assert!(!rejected("SELECT set_config('search_path','app',false)"));
766 assert!(!rejected(
767 "SELECT set_config('statement_timeout', '5000', true)"
768 ));
769 }
770
771 // ---- bypasses of the earlier naive string filter, now closed by the tokenizer ----
772
773 /// A comment spliced into the keyword or between the function name and `(` used to
774 /// defeat the substring match; the tokenizer folds comments into whitespace we drop.
775 #[test]
776 fn inline_comment_splitting_the_keyword_is_rejected() {
777 assert!(rejected("SET/*x*/ boatramp.project='x'"));
778 assert!(rejected("set_config/*c*/('boatramp.project','x')"));
779 }
780
781 /// A leading comment used to push the real keyword out of the string's head.
782 #[test]
783 fn leading_comment_before_set_is_rejected() {
784 assert!(rejected("/*c*/SET boatramp.project='x'"));
785 assert!(rejected("/* hi */ set_config('boatramp.site','x')"));
786 }
787
788 /// String-concatenating the setting name hid `boatramp.` from a literal-prefix check;
789 /// a non-simple-literal first argument is now refused wholesale.
790 #[test]
791 fn set_config_with_concatenated_name_is_rejected() {
792 assert!(rejected(
793 "SELECT set_config('boat'||'ramp.project','x',false)"
794 ));
795 assert!(rejected(
796 "SELECT set_config('boatramp.'||'project','x',false)"
797 ));
798 }
799
800 /// MySQL quoting variants around the reserved user var.
801 #[test]
802 fn mysql_quoted_reserved_var_is_rejected() {
803 // Backtick-quoted whole var: `@boatramp_project` (one delimited identifier).
804 assert!(rejected("SET `@boatramp_project`=1"));
805 // `@` then a backtick-quoted name — an unbalanced/oddly-lexing form fails closed.
806 assert!(rejected("SET @`boatramp_project`=1"));
807 }
808
809 /// Casing of the keyword and of the `set_config` function name is folded.
810 #[test]
811 fn case_variants_are_rejected() {
812 assert!(rejected("sEt boatramp.project=1"));
813 assert!(rejected("SeT_config('boatramp.project','x')"));
814 }
815
816 /// The `set_config` guard tolerates whitespace/comments around the call and catches a
817 /// reserved call hiding after a benign one in the same statement.
818 #[test]
819 fn set_config_edge_forms_are_rejected() {
820 assert!(rejected(
821 "SELECT set_config ( 'boatramp.project' , 'v', false )"
822 ));
823 assert!(rejected(
824 "SELECT set_config('search_path','app',false), \
825 set_config('boatramp.project','v',false)"
826 ));
827 }
828
829 // ---- deferred-execution bypasses (the tokenizer can't see into a body) ----
830
831 /// The proven Round-1 High: a reserved write hidden in a **dollar-quoted** `DO`
832 /// block. `$$…$$` / `$tag$…$tag$` lex as one opaque token, so the inner
833 /// `set_config`/`SET` was invisible to the token scan — now the whole
834 /// dollar-quoted class is refused under an injected context.
835 #[test]
836 fn dollar_quoted_do_block_reserved_write_is_rejected() {
837 assert!(rejected(
838 "DO $$ BEGIN PERFORM set_config('boatramp.project','victim',false); END $$;"
839 ));
840 assert!(rejected(
841 "DO $$ BEGIN SET boatramp.project = 'victim'; END $$;"
842 ));
843 assert!(rejected(
844 "DO $tag$ PERFORM set_config('boatramp.project','v',false); $tag$;"
845 ));
846 // A dollar-quoted string literal anywhere is refused too (a guest has no need
847 // for one on the RLS path; it could carry a hidden body).
848 assert!(rejected(
849 "SELECT set_config($$boatramp.project$$, 'v', false)"
850 ));
851 }
852
853 /// The rest of the deferred-execution / persistent-default class: a single-quoted
854 /// `DO` body, `CALL`, defining a routine (single- or dollar-quoted body), and a
855 /// persistent GUC default via `ALTER ROLE/DATABASE`.
856 #[test]
857 fn procedural_and_persistent_constructs_are_rejected() {
858 assert!(rejected(
859 "DO 'BEGIN PERFORM set_config(''boatramp.project'',''v'',false); END'"
860 ));
861 assert!(rejected("CALL do_evil()"));
862 assert!(rejected(
863 "CREATE FUNCTION e() RETURNS void AS $$ SELECT set_config('boatramp.project','v',false) $$ LANGUAGE sql"
864 ));
865 assert!(rejected(
866 "CREATE FUNCTION e() RETURNS void AS 'BEGIN PERFORM set_config(''boatramp.project'',''v'',false); END' LANGUAGE plpgsql"
867 ));
868 assert!(rejected(
869 "CREATE OR REPLACE PROCEDURE p() LANGUAGE sql AS $$ SELECT 1 $$"
870 ));
871 assert!(rejected(
872 "ALTER ROLE tenant_role SET boatramp.project = 'victim'"
873 ));
874 assert!(rejected("ALTER DATABASE app SET boatramp.site = 'victim'"));
875 }
876
877 /// Round-2 High: MySQL writes the reserved `@boatramp_*` user var without it being
878 /// the first `SET` target — via a comma-list, a `:=` variant, or
879 /// `SELECT … INTO @var` (no `SET` at all) — evading the leading-target check. A
880 /// position-independent reserved-var refusal closes the whole family.
881 #[test]
882 fn mysql_reserved_var_anywhere_is_rejected() {
883 assert!(rejected("SET @x=1, @boatramp_project='victim'"));
884 assert!(rejected("SET @a=1, @b=2, @boatramp_project='victim'"));
885 assert!(rejected("SET @x:=1, @boatramp_project:='victim'"));
886 assert!(rejected("SELECT 'victim' INTO @boatramp_project"));
887 assert!(rejected("SELECT 'victim' AS v INTO @boatramp_project"));
888 assert!(rejected("SELECT 1,'victim' INTO @junk, @boatramp_project"));
889 assert!(rejected("select 'victim' into @boatramp_project"));
890 assert!(rejected("SELECT 'v' INTO @boatramp_site"));
891 }
892
893 /// Prepared-statement indirection hides the reserved write inside a string literal
894 /// (which is legitimate data elsewhere, so must not be scanned): refuse the
895 /// deferral construct itself. The guest binding never issues SQL-level
896 /// PREPARE/EXECUTE (it parameterizes via bind params), so this costs nothing.
897 #[test]
898 fn prepared_statement_indirection_is_rejected() {
899 assert!(rejected(
900 "PREPARE s FROM 'SET @boatramp_project=''victim'''"
901 ));
902 assert!(rejected("EXECUTE s"));
903 assert!(rejected(
904 "prepare s from 'SELECT ''v'' INTO @boatramp_site'"
905 ));
906 }
907
908 // ---- legit forms must still parse-and-pass (no regression) ----
909
910 #[test]
911 fn legit_set_and_set_config_forms_still_pass() {
912 assert!(!rejected("SET statement_timeout = '5s'"));
913 assert!(!rejected("SET search_path TO myschema"));
914 assert!(!rejected("SET SESSION time_zone = '+00:00'"));
915 assert!(!rejected("SET @my_var = 1"));
916 assert!(!rejected("RESET statement_timeout"));
917 assert!(!rejected("set_config('search_path','x',false)"));
918 assert!(!rejected("set_config('statement_timeout','5s',true)"));
919 // "set" / "boatramp.project" appearing only in identifiers or string literals.
920 assert!(!rejected(
921 "SELECT settings FROM t WHERE k = 'boatramp.project'"
922 ));
923 // Ordinary app SQL on the RLS path is untouched: parameterized queries ($1 is a
924 // Placeholder, not a dollar-quoted body), plain DML, and non-routine DDL.
925 assert!(!rejected("SELECT * FROM orders WHERE id = $1"));
926 assert!(!rejected("INSERT INTO orders (id, total) VALUES ($1, $2)"));
927 assert!(!rejected("UPDATE orders SET total = $1 WHERE id = $2"));
928 assert!(!rejected(
929 "CREATE TABLE orders (id bigint primary key, total numeric)"
930 ));
931 assert!(!rejected("ALTER TABLE orders ADD COLUMN note text"));
932 // Non-reserved MySQL user vars (comma-list and `SELECT … INTO`) are untouched —
933 // only the `@boatramp_*` namespace is refused.
934 assert!(!rejected("SET @x = 1, @y = 2"));
935 assert!(!rejected("SELECT 42 INTO @myvar"));
936 assert!(!rejected("SELECT total INTO @t FROM orders WHERE id = $1"));
937 }
938}