HomePlaygroundExpressionsDocsDriversBlogStatusChangelog GitHub

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 — You manage RLS yourself 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_id on 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.

QAIL — RLS is structural 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:

Four tenant scopes for real-world SaaS // 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:

Runtime checks for authorization logic 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:

TypedQail + RLS — the full stack of safety 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_id audit = 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:

PostgreSQL — Enable RLS on a table -- 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:

schema.qail — Define RLS declaratively 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

Cargo.toml [dependencies] qail-core = "0.15" qail-pg = "0.15"
main.rs — Your first RLS-scoped query 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?;

→ View on GitHub   ·   → Read the Docs   ·   → Changelog


QAIL is open-source and MIT-licensed. Built in Rust. Deployed on Cloudflare. Powering production multi-tenant SaaS since 2025.