How I built tenant isolation for SmartFarmAI a poultry farm management platform serving farms across Nigeria and Tanzania.
I've been building SmartFarmAI, an AI-powered poultry farm management platform, and one of the hardest architectural decisions I had to make early on was: how do I safely isolate data between farms?
When a farmer in Lagos logs in and checks their egg production numbers, they should never under any circumstance see data from a 60,000-bird enterprise operation in Tanzania. One bug, one missed WHERE clause, and you're leaking customer data. In agriculture, that's not just a privacy issue it's a business-ending trust violation.
This article walks through exactly how I solved this using Rust, Actix-web, and PostgreSQL Row-Level Security (RLS) the patterns, the gotchas I hit in production, and the code that keeps it all safe.
If you're building any kind of multi-tenant SaaS in Rust, this should save you weeks of trial and error.
The pattern in a nutshell: Actix middleware extracts the tenant from each request, starts a DB transaction, calls SET LOCAL app.current_tenant_id, and lets Postgres RLS policies automatically scope every query no manual WHERE org_id = $1 needed.
This article assumes you're comfortable with Rust basics, Actix-web routing, and have at least heard of PostgreSQL Row-Level Security. If you've never touched RLS before, you'll still be able to follow along I introduce the concepts as we go.
Multi-Tenancy: Pick Your Strategy
Before writing any code, you have to decide how to isolate tenant data. There are three common approaches:
Database-per-tenant: Every customer gets their own PostgreSQL database. Maximum isolation, but a nightmare to manage at scale. Migrations become a coordinated rollout across hundreds of databases.
Schema-per-tenant: One database, but each customer gets their own schema. Better than separate databases, but schema migrations are still painful and connection pooling gets complicated fast.
Shared database with Row-Level Security: One database, one schema, one set of tables but PostgreSQL itself enforces which rows each tenant can see. This is what I chose for SmartFarmAI.
Why RLS? Because it pushes security enforcement down to the database layer. My application code doesn't need to remember to add WHERE org_id = $1 to every query. PostgreSQL does it automatically. If I forget a filter in my Rust code? Doesn't matter the database won't return rows that don't belong to the current tenant.
For an early-stage SaaS serving poultry farmers, this gives me the isolation guarantees I need without the operational overhead of managing hundreds of databases.
The Data Model: org_id Everywhere
SmartFarmAI's domain hierarchy looks like this:
Organization (tenant)
└── Farm(s)
└── Batch(es) / Pen House(s)
└── Egg Records, Mortality Logs, Vaccination Schedules...
The key decision: every table carries org_id, even child tables. You might think "batches already belong to a farm, and farms belong to an org why duplicate org_id on the batch table?" Two reasons:
RLS policies need a direct column reference. PostgreSQL can't follow foreign key chains in a policy expression. The policy needs to see
org_idon the row it's evaluating.Composite foreign keys enforce correctness. A batch doesn't just reference a
farm_idit references(org_id, farm_id). This makes it structurally impossible to accidentally associate a batch with a farm from a different organization.
Here's what the schema looks like:
-- Organizations (tenants)
CREATE TABLE organizations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Farms belong to an organization
CREATE TABLE farms (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL REFERENCES organizations(id),
name TEXT NOT NULL,
location TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Composite unique for child FK references
UNIQUE (org_id, id)
);
-- Batches belong to a farm, scoped by org
CREATE TABLE batches (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL,
farm_id UUID NOT NULL,
batch_name TEXT NOT NULL,
bird_count INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Composite FK: can't assign a batch to a farm in a different org
CONSTRAINT batch_farm_fk
FOREIGN KEY (org_id, farm_id)
REFERENCES farms (org_id, id)
ON DELETE CASCADE,
UNIQUE (org_id, farm_id, id)
);
-- Egg production records
CREATE TABLE egg_production_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL,
farm_id UUID NOT NULL,
batch_id UUID NOT NULL,
record_date DATE NOT NULL DEFAULT CURRENT_DATE,
eggs_collected INTEGER NOT NULL CHECK (eggs_collected >= 0),
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT egg_log_batch_fk
FOREIGN KEY (org_id, farm_id, batch_id)
REFERENCES batches (org_id, farm_id, id)
ON DELETE CASCADE,
-- One record per batch per day
CONSTRAINT unique_egg_record UNIQUE (batch_id, record_date)
);
Notice the pattern: every child table has a composite foreign key that threads org_id all the way down. This is intentional. It's one of those things that feels redundant until it saves you from a data leak.
Enabling Row-Level Security
With the schema in place, enabling RLS is straightforward:
-- Enable RLS on all tenant-scoped tables
ALTER TABLE farms ENABLE ROW LEVEL SECURITY;
ALTER TABLE batches ENABLE ROW LEVEL SECURITY;
ALTER TABLE egg_production_logs ENABLE ROW LEVEL SECURITY;
-- Create isolation policies
CREATE POLICY tenant_isolation ON farms
USING (org_id = current_setting('app.current_tenant_id')::uuid);
CREATE POLICY tenant_isolation ON batches
USING (org_id = current_setting('app.current_tenant_id')::uuid);
CREATE POLICY tenant_isolation ON egg_production_logs
USING (org_id = current_setting('app.current_tenant_id')::uuid);
-- WITH CHECK ensures inserts/updates also respect the tenant boundary
CREATE POLICY tenant_write_isolation ON farms
FOR INSERT
WITH CHECK (org_id = current_setting('app.current_tenant_id')::uuid);
CREATE POLICY tenant_write_isolation ON batches
FOR INSERT
WITH CHECK (org_id = current_setting('app.current_tenant_id')::uuid);
CREATE POLICY tenant_write_isolation ON egg_production_logs
FOR INSERT
WITH CHECK (org_id = current_setting('app.current_tenant_id')::uuid);
The magic here is current_setting('app.current_tenant_id'). This is a PostgreSQL session variable not a table, not a function, just a key-value pair that lives for the duration of a transaction or session. Before any query runs, we set this variable to the current user's organization ID. PostgreSQL's RLS engine then uses it to filter every SELECT, INSERT, UPDATE, and DELETE.
Important: RLS policies are enforced for regular users but not for the table owner or superusers. Make sure your application connects as a non-superuser role.
The Middleware: Setting Tenant Context in Actix-web
This is where Rust and Actix-web come in. Every authenticated request needs to:
Extract the tenant ID (from JWT claims)
Set the PostgreSQL session variable before any query runs
Ensure the variable is cleared when the request ends
Here's how I built it:
The TenantId Extractor
First, a newtype for tenant IDs. This prevents accidentally passing a user ID where an org ID is expected Rust's type system catches it at compile time.
use uuid::Uuid;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct TenantId(Uuid);
impl TenantId {
pub fn new(id: Uuid) -> Self {
Self(id)
}
pub fn as_uuid(&self) -> &Uuid {
&self.0
}
}
impl std::fmt::Display for TenantId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
Extracting Tenant from JWT
In Actix-web, you can implement FromRequest to automatically extract the tenant ID from the JWT on every request:
use actix_web::{FromRequest, HttpRequest, dev::Payload, Error, HttpMessage};
use std::future::{Ready, ready};
impl FromRequest for TenantId {
type Error = Error;
type Future = Ready<Result<Self, Self::Error>>;
fn from_request(req: &HttpRequest, _payload: &mut Payload) -> Self::Future {
// The auth middleware has already validated the JWT
// and inserted claims into request extensions
match req.extensions().get::<AuthClaims>() {
Some(claims) => ready(Ok(TenantId::new(claims.org_id))),
None => ready(Err(actix_web::error::ErrorUnauthorized(
"Missing tenant context"
))),
}
}
}
The RLS Middleware
Now the critical piece the middleware that sets app.current_tenant_id before your handler runs:
use actix_web::{dev::ServiceRequest, Error, HttpMessage};
use sqlx::PgPool;
pub async fn set_rls_context(
pool: &PgPool,
org_id: &TenantId,
) -> Result<sqlx::Transaction<'_, sqlx::Postgres>, Error> {
let mut tx = pool.begin().await.map_err(|e| {
actix_web::error::ErrorInternalServerError(format!("DB error: {}", e))
})?;
// SET LOCAL scopes the variable to this transaction only
sqlx::query("SELECT set_config('app.current_tenant_id', $1, true)")
.bind(org_id.to_string())
.execute(&mut *tx)
.await
.map_err(|e| {
actix_web::error::ErrorInternalServerError(format!("RLS context error: {}", e))
})?;
Ok(tx)
}
The critical detail here is the third argument to set_config: true means transaction-local. When the transaction commits or rolls back, the variable is automatically cleared. This is essential for connection pool safety more on that below.
Using It in Handlers
With the extractor and the RLS context function, your route handlers look clean:
use actix_web::{web, HttpResponse, Result};
use sqlx::PgPool;
pub async fn get_farms(
pool: web::Data<PgPool>,
tenant: TenantId,
) -> Result<HttpResponse> {
let mut tx = set_rls_context(&pool, &tenant).await?;
// No WHERE clause needed — RLS handles filtering
let farms = sqlx::query_as!(
Farm,
"SELECT id, org_id, name, location, created_at FROM farms"
)
.fetch_all(&mut *tx)
.await
.map_err(actix_web::error::ErrorInternalServerError)?;
tx.commit().await
.map_err(actix_web::error::ErrorInternalServerError)?;
Ok(HttpResponse::Ok().json(farms))
}
Notice: no WHERE org_id = $1. The query selects all farms, but RLS ensures the farmer in Lagos only sees their own farms. The 60,000-bird operation in Tanzania is invisible to them at the database level.
The Gotcha That Almost Burned Me: Connection Pools and Session Variables
Here's the mistake that will bite you if you're not careful.
PostgreSQL session variables persist for the lifetime of a connection. When you use a connection pool (like sqlx's PgPool), connections are reused across requests. If you set app.current_tenant_id with session scope (false as the third argument to set_config), here's what happens:
1. Request from Org-A arrives
2. Connection #5 is checked out from the pool
3. SET app.current_tenant_id = 'org-a-uuid' (session-scoped)
4. Query runs, returns Org-A's data ✅
5. Connection #5 is returned to the pool
6. Request from Org-B arrives
7. Connection #5 is checked out again
8. But app.current_tenant_id is STILL 'org-a-uuid' !!
9. Query runs... returns Org-A's data to Org-B's user 🚨
This is a data leak. And it's subtle it depends on which connection the pool hands out, so it won't happen consistently in development. It'll show up in production under load.
The fix: always use transaction-local scope.
// ✅ SAFE: Transaction-local — cleared automatically when tx ends
sqlx::query("SELECT set_config('app.current_tenant_id', $1, true)")
.bind(org_id.to_string())
.execute(&mut *tx)
.await?;
// ❌ DANGEROUS: Session-local — persists after connection returns to pool
sqlx::query("SELECT set_config('app.current_tenant_id', $1, false)")
.bind(org_id.to_string())
.execute(&pool)
.await?;
By wrapping every tenant-scoped operation in a transaction and using set_config(..., true), the variable is automatically cleaned up when the transaction ends. No stale tenant context, no data leaks.
When NOT to Use RLS: Background Workers and the Outbox Pattern
SmartFarmAI uses an outbox pattern for event-driven processing. When a farmer logs egg production, the API handler inserts the record and publishes a domain event to an outbox_events table all in the same transaction. A background worker then picks up events and processes them (updating batch totals, triggering AI predictions, etc.).
Here's the thing: the outbox table should NOT have RLS enabled.
Why? The background worker needs to poll for events across all organizations:
SELECT * FROM outbox_events
WHERE processed_at IS NULL
ORDER BY created_at ASC
LIMIT 10
FOR UPDATE SKIP LOCKED;
If RLS were enabled on this table, the worker would need a tenant context to read events but it doesn't belong to any tenant. It's infrastructure.
My rule of thumb:
| Table Type | RLS? | Why |
|---|---|---|
| Business data (farms, batches, eggs) | Yes | Tenant-scoped, must be isolated |
| Work queues (outbox_events, email_jobs) | No | Infrastructure, cross-tenant by design |
| Audit logs | Depends | RLS if tenant-facing, no RLS if admin-only |
But here's the nuance: when the worker processes an event and needs to update business data, it does need to set the RLS context for that specific event's organization:
pub async fn process_event(
&self,
pool: &PgPool,
event: &OutboxEvent,
) -> Result<(), Box<dyn std::error::Error>> {
let mut tx = pool.begin().await?;
// Set RLS context for this event's organization
sqlx::query("SELECT set_config('app.current_tenant_id', $1, true)")
.bind(event.org_id.to_string())
.execute(&mut *tx)
.await?;
// Now we can safely update tenant-scoped tables
match event.topic.as_str() {
"EggProductionRecorded" => {
self.update_batch_totals(&mut tx, event).await?;
}
"MortalityRecorded" => {
self.update_mortality_stats(&mut tx, event).await?;
}
_ => {}
}
// Mark event as processed
sqlx::query!(
"UPDATE outbox_events SET processed_at = NOW() WHERE id = $1",
event.id
)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(())
}
This is the pattern that took me the longest to get right. The worker reads from an unprotected queue, then enters a tenant context per-event to do the actual work. Each event is processed in its own transaction with its own RLS scope.
I actually hit a bug in production where my outbox table initially had RLS enabled. The worker was fetching zero events even though the table had hundreds queued up. No errors just silent filtering. It took me an embarrassing amount of debugging before I realised the worker had no tenant context set, so RLS was filtering out everything.
Testing Multi-Tenant Isolation
Testing RLS requires more thought than testing regular business logic. You need to verify that tenants genuinely can't see each other's data:
#[tokio::test]
async fn test_tenant_isolation() {
let pool = setup_test_db().await;
// Create two organizations
let org_a = create_test_org(&pool, "Farm Corp A").await;
let org_b = create_test_org(&pool, "Farm Corp B").await;
// Create farms under each org (using superuser/direct insert)
create_test_farm(&pool, &org_a, "Org A Farm").await;
create_test_farm(&pool, &org_b, "Org B Farm").await;
// Query as Org A — should only see their farm
let mut tx = pool.begin().await.unwrap();
sqlx::query("SELECT set_config('app.current_tenant_id', $1, true)")
.bind(org_a.to_string())
.execute(&mut *tx)
.await
.unwrap();
let farms: Vec<Farm> = sqlx::query_as!(Farm, "SELECT * FROM farms")
.fetch_all(&mut *tx)
.await
.unwrap();
assert_eq!(farms.len(), 1);
assert_eq!(farms[0].name, "Org A Farm");
tx.commit().await.unwrap();
// Query as Org B — should only see their farm
let mut tx = pool.begin().await.unwrap();
sqlx::query("SELECT set_config('app.current_tenant_id', $1, true)")
.bind(org_b.to_string())
.execute(&mut *tx)
.await
.unwrap();
let farms: Vec<Farm> = sqlx::query_as!(Farm, "SELECT * FROM farms")
.fetch_all(&mut *tx)
.await
.unwrap();
assert_eq!(farms.len(), 1);
assert_eq!(farms[0].name, "Org B Farm");
tx.commit().await.unwrap();
}
#[tokio::test]
async fn test_cross_tenant_insert_blocked() {
let pool = setup_test_db().await;
let org_a = create_test_org(&pool, "Org A").await;
let org_b = create_test_org(&pool, "Org B").await;
// Set context as Org A, but try to insert with Org B's ID
let mut tx = pool.begin().await.unwrap();
sqlx::query("SELECT set_config('app.current_tenant_id', $1, true)")
.bind(org_a.to_string())
.execute(&mut *tx)
.await
.unwrap();
// This should be rejected by the WITH CHECK policy
let result = sqlx::query!(
"INSERT INTO farms (org_id, name) VALUES ($1, $2)",
org_b.as_uuid(), // Wrong org!
"Sneaky Farm"
)
.execute(&mut *tx)
.await;
assert!(result.is_err()); // RLS blocks it
}
These tests are non-negotiable. Run them in CI. They're the safety net that catches regressions before your farmers do.
Production Lessons from SmartFarmAI
After running this architecture in production with farms across Nigeria and Tanzania, here's what I've learned:
1. RLS has near-zero performance overhead. PostgreSQL evaluates the policy as part of query planning. For simple equality checks like org_id = current_setting(...)::uuid, the optimiser handles it efficiently. I haven't needed to do anything special for performance.
2. Always index org_id. Even though RLS handles filtering, the database still needs to efficiently find matching rows. Composite indexes on (org_id, ...) are essential:
CREATE INDEX idx_farms_org ON farms(org_id);
CREATE INDEX idx_batches_org_farm ON batches(org_id, farm_id);
CREATE INDEX idx_egg_logs_batch_date ON egg_production_logs(batch_id, record_date DESC);
3. Migrations need care. When you run migrations, you're typically using a superuser or the table owner which bypasses RLS. This is fine and expected, but be aware of it. Don't write migration scripts that assume RLS is filtering data.
4. Debug with current_setting(). When something seems off, check what the database thinks the current tenant is:
SELECT current_setting('app.current_tenant_id', true);
-- Returns NULL if not set, the tenant UUID if set
The second argument (true) tells PostgreSQL to return NULL instead of throwing an error if the variable isn't set. Extremely useful for debugging.
5. The outbox worker issue is real. As I mentioned earlier if you enable RLS on infrastructure tables, your background workers will silently return zero results. No errors, no warnings. Set up monitoring for your outbox queue depth so you catch this fast.
Wrapping Up
Multi-tenant data isolation isn't the kind of thing you want to get "mostly right." It needs to be airtight. PostgreSQL's Row-Level Security, combined with Rust's type system and Actix-web's middleware patterns, gives you defense in depth:
Database layer: RLS policies enforce isolation regardless of application bugs.
Application layer: Transaction-scoped
SET LOCALprevents connection pool contamination.
SmartFarmAI currently manages farms ranging from 550 birds to 60,000 birds. Every single one of them shares the same database, the same tables, the same application code. And none of them can see each other's data.
If you're building multi-tenant SaaS in Rust, start with RLS. Push security as far down the stack as it will go. And let Rust's type system catch everything else.
I'm Chinedu Okere I write about building production systems in Rust. If you're working on multi-tenant architecture or SaaS in Rust, I'd love to hear about your approach. Find me on Twitter/X or check out SmartFarmAI.



Top comments (0)