The $125 Million WHERE Clause
In 2022, a fintech startup leaked 3.2 million customer records across merchant boundaries.
The root cause? A single missing WHERE operator_id = $1 clause
in a report generation query. One query. One missed filter. Every merchant could see every other
merchant's transaction history.
This isn't rare. If you run a multi-tenant SaaS application — where multiple customers (operators, organizations, tenants) share the same PostgreSQL database — every single query must be scoped to the correct tenant. Miss one, and you have a data breach.
PostgreSQL solved this problem years ago with Row-Level Security (RLS) — declarative policies that automatically filter rows based on session context. It works beautifully. There's just one problem:
No Rust PostgreSQL driver has built-in support for RLS.
The Current Landscape: Everyone Rolls Their Own
Let's look at every major Rust PostgreSQL driver and see what they offer for multi-tenant isolation:
| Driver | Stars | RLS Support | Session Variables | Tenant Injection |
|---|---|---|---|---|
| sqlx | 14k+ | ✕ None | ✕ Manual | ✕ Manual |
| Diesel | 12k+ | ✕ None | ✕ Manual | ✕ Manual |
| SeaORM | 7k+ | ✕ None | ✕ Manual | ✕ Manual |
| tokio-postgres | — | ✕ None | ✕ Manual | ✕ Manual |
| QAIL | — | ✓ Built-in | ✓ Automatic | ✓ AST-level |
Every driver says "just use raw SQL to set session variables." That's like saying "just remember to lock the door every time you leave." It works until the one time you forget — and that one time is the breach.
Why They Don't Support It
It's not that these drivers can't — it's that their architecture makes it awkward. All existing Rust PostgreSQL drivers are string-based:
sqlx::query("SET app.tenant_id = $1").bind(tenant_id).execute(&pool).await?;
sqlx::query("SELECT * FROM bookings").fetch_all(&pool).await?;
// ⚠️ Two separate operations. Connection might change between them.
// ⚠️ Pool returns connections randomly — the SET might go to connection A,
// the SELECT to connection B. Your tenant context is GONE. The fundamental problem:
- Connection pools don't preserve state. When you
SET app.tenant_idon a connection, the pool might give you a different connection for the next query. - No atomic "set context + query" operation. You need to acquire a connection, set the variable, execute the query, and release — all manually, on the same connection.
- String queries can't carry metadata. A raw SQL string has no place to attach "this query requires tenant context." The driver can't enforce it.
These aren't driver bugs — they're architectural limitations of the SQL string model. When your query is a string, there's no structural way to attach tenant context.
How QAIL Solves It: AST-Level Tenant Injection
QAIL doesn't work with SQL strings. It works with Abstract Syntax Trees. Your query is a typed data structure — and typed data structures can carry metadata.
use qail_core::rls::RlsContext;
// Create context once (from your auth middleware)
let ctx = RlsContext::operator(operator_id);
// Every query is automatically scoped
let query = Qail::get("bookings")
.columns(["id", "customer", "status"])
.with_rls(&ctx); // ← Context is part of the AST
// The driver handles EVERYTHING:
// 1. Acquire connection from pool
// 2. SET app.operator_id = '<uuid>' on THAT connection
// 3. Execute query on SAME connection
// 4. Return results
// 5. Release connection
let rows = driver.query(&query).await?;
The key insight: the RLS context is attached to the AST node, not to the connection.
The driver sees .with_rls(ctx) on the query and handles the entire lifecycle atomically.
You can't forget. You can't split it across connections. It's structural.
The RlsContext API
QAIL provides purpose-built constructors for common multi-tenant patterns:
// Scope: single operator (company/organization)
let ctx = RlsContext::operator(operator_id);
// Scope: single agent (sales rep, team member)
let ctx = RlsContext::agent(agent_id);
// Scope: agent within operator (most common)
let ctx = RlsContext::operator_and_agent(op_id, agent_id);
// Scope: platform admin (bypasses all RLS)
let ctx = RlsContext::super_admin(); Query introspection is also built in:
ctx.has_operator(); // true — this query is operator-scoped
ctx.has_agent(); // true — this query is agent-scoped
ctx.bypasses_rls(); // false — RLS is enforced
let admin = RlsContext::super_admin();
admin.bypasses_rls(); // true — admin sees everything Compile-Time Safety + Tenant Isolation
Where QAIL gets truly unique: RLS composes with the typed system. You get compile-time join safety AND runtime tenant isolation in the same query:
use schema::{bookings, users};
let query = Qail::typed(bookings::table)
.join_related(users::table) // ← Compile-time: valid join
.typed_column(bookings::id()) // ← Compile-time: column exists
.typed_column(bookings::status())
.typed_eq(bookings::active(), true) // ← Compile-time: type matches
.with_rls(&ctx) // ← Runtime: tenant-scoped
.build();
// Trying an invalid join:
// Qail::typed(bookings::table)
// .join_related(products::table) // ❌ COMPILE ERROR
// → "bookings::Bookings: RelatedTo<products::Products> not satisfied" This is the stack that no other Rust driver provides:
| Safety Layer | What It Prevents | When |
|---|---|---|
| Rust | Memory corruption, data races | Compile time |
| TypedQail | Invalid joins, wrong column types | Compile time |
| Protected columns | Unauthorized access to sensitive data | Compile time |
| RlsContext | Cross-tenant data leaks | Runtime (structural) |
Rust gives you memory safety. QAIL gives you correctness safety.
Real-World: Powering a Maritime SaaS Platform
QAIL isn't theoretical. It powers Sailtix, a multi-operator maritime booking platform where multiple ferry operators share the same database.
Before QAIL, we used sqlx and had to manually audit every query for operator_id filters. With 200+ queries across the codebase, this was a constant source of anxiety:
- Before: 200+ queries × manual
WHERE operator_idaudit = ticking time bomb - After:
.with_rls(&ctx)on every query = structural guarantee
"We went from 'did we remember to add the operator filter?' to 'the driver handles it.' Sleep quality improved measurably."
A 30-Second PostgreSQL RLS Primer
If you haven't used PostgreSQL RLS before, here's the minimal setup:
-- 1. Enable RLS
ALTER TABLE bookings ENABLE ROW LEVEL SECURITY;
-- 2. Create a policy
CREATE POLICY tenant_isolation ON bookings
USING (operator_id = current_setting('app.operator_id')::uuid);
-- 3. Force RLS for the app role
ALTER TABLE bookings FORCE ROW LEVEL SECURITY;
-- Now, queries only return rows matching the session's operator_id.
-- QAIL handles step 3 automatically via with_rls(). QAIL can also generate these policies from your schema:
table bookings {
id UUID primary_key
operator_id UUID not_null
customer TEXT
status TEXT
}
# qail migrate generates the RLS policies automatically Try It Today
[dependencies]
qail-core = "0.15"
qail-pg = "0.15" use qail_core::{Qail, rls::RlsContext};
use qail_pg::PgDriver;
let mut driver = PgDriver::connect("localhost", 5432, "app_user", "mydb").await?;
let ctx = RlsContext::operator(operator_id);
let bookings = Qail::get("bookings")
.columns(["id", "customer", "status"])
.with_rls(&ctx);
let rows = driver.query(&bookings).await?; QAIL is open-source and MIT-licensed. Built in Rust. Deployed on Cloudflare. Powering production multi-tenant SaaS since 2025.