# Tenant Context and Isolation

Course: [High-Level Design](https://www.devtutorials.ai/learn/system-design/hld) · Section: B2B SaaS Architectures
Source: https://www.devtutorials.ai/learn/system-design/hld/b2b-saas/tenant-isolation

> Carry the tenant through every hop: request, cache, database, queue, worker and search index. Find where the tenant context gets dropped, and fix it with tenant-scoped keys and row-level security.

## Where Does the Tenant Go Missing?

In Lesson 1, Northwind put 40 small tenants in one pool: shared tables, every row tagged with `tenant_id`. The database query for a ticket list is correct. So how can one customer still see another's report?

Because a request does not only touch the database. It passes a cache, drops a job on a queue, and asks a search index. Each hop needs to know **which tenant** it is working for. Lose the tenant at any hop and you have a data leak, the worst kind of B2B incident.

**The main idea:** the tenant comes from a trusted source (the signed-in user's token), travels with the request through **every** hop, and is enforced in each store. Row-level security in the database catches the queries you forget.

## TL;DR

- **Tenant context** is "which tenant is this request for". It comes from the authenticated token, never from a URL or a form field the user can edit.
- Every hop needs it: **cache keys**, **job payloads**, **search queries**, **file paths**, **log lines** and **metrics**.
- **Row-level security (RLS)** makes the database itself filter by tenant. A query that forgets the tenant gets nothing back instead of everything.
- RLS only protects the database. Caches, queues and search indexes need their own tenant scope.
- Test isolation directly: replay requests from many tenants and check that no response contains another tenant's data.

## A Real-World Example

A SaaS analytics product caches report results for speed. The key is `report:42`. Report ids are numbered per tenant, so every tenant has a report 42.

Tenant A opens report 42, and the result is cached. A minute later tenant B opens their own report 42, gets a cache hit and sees tenant A's revenue figures. The database was never asked. The bug is one missing word in a cache key.

## What You Will Learn

1. Where tenant context comes from and how it should travel.
2. The three classic leaks: cache keys, background jobs and shared search indexes.
3. How row-level security works, and what it cannot protect.
4. How to test isolation.

## Tenant Context

The tenant must come from something the user cannot change: the **signed-in session or token**. A tenant id in the URL is fine for routing, but the server must check it matches the token.

Once the server knows the tenant, it puts it in a **request context** that every function can read, and it passes it explicitly whenever work leaves the request: to a queue, another service or a cache.

## Leak 1: The Cache Key

Ids are often unique **within a tenant**, not across tenants. A cache key without the tenant mixes them up.

**Fix:** build every key with a helper that requires the tenant, so a key without one cannot be written.

## Leak 2: The Background Job

The web request knew the tenant. The job it put on the queue did not. The worker picks the job up later, on a pooled database connection that still has whatever tenant it served last.

**Fix:** every job payload carries `tenant_id`, and the worker sets the database session's tenant from the payload before it does anything else. A job with no tenant is rejected.

## Leak 3: The Shared Search Index

Search engines are separate stores. A shared index holds every tenant's documents; the query must filter by tenant, every time.

**Fix:** a tenant filter that the search client adds automatically, or an index (or index alias) per tenant for large tenants.

## Row-Level Security: The Safety Net

Even careful teams forget a filter one day. **Row-level security** moves the rule into the database. In Postgres it takes three steps:

1. Turn on row-level security for the `tickets` table.
2. Add a policy: a row is visible only when its `tenant_id` equals the session setting `app.tenant_id`.
3. Every request or job sets that value first, for example to `t01`.

Now `SELECT * FROM tickets` returns only tenant t01's rows, even without a `WHERE` clause. And if a job forgets to set the tenant, the query returns nothing, or fails, instead of returning every tenant's rows.

RLS does not protect anything outside the database. The cache and the search index in this lesson leak even with RLS on. Defence in depth means scoping **every** store, with RLS as the last line for the one that matters most.

Database admin users and table owners usually **bypass** RLS. Connect your application as a normal role, and use `FORCE ROW LEVEL SECURITY` on tables where even the owner must be filtered.

## Try It: Replay 200 Requests

The lab replays 200 requests from 12 Northwind tenants: open a report (through the cache), export a report (through a background job), and search. It counts every response that contains another tenant's data.

1. Start with no fixes and read the first leak, step by step.
2. Turn on only row-level security. Which leaks stay? What happens to the exports?
3. Fix all three hops, then keep RLS on as the safety net.

**Pass rule:** 0 cross-tenant leaks in all three places, no failed jobs, and row-level security on.

Keep a replay test like this in your test suite. Create two tenants with **identical ids** for everything (report 1, ticket 1, user 1), run every API as tenant A, and fail the build if any response contains tenant B's data.

## Check Yourself

## Common mistakes

| Mistake | Wrong | Correct | Why it happens |
|---|---|---|---|
| Ids that are only unique per tenant in a shared key | `report:42` | `t01:report:42` | Ids look unique in a one-tenant test |
| Tenant lost at the queue | `{ reportId: 42 }` | `{ tenant_id: "t01", reportId: 42 }` | The request had the tenant, so the job "obviously" does too |
| One shared search index, no filter | `search("invoice")` | `search("invoice", { tenant })` | Search is set up later, by a different team |
| RLS as the only control | "We have RLS, so we are safe" | RLS plus tenant scope in every other store | RLS cannot see caches, queues or indexes |
| Admin role in the app | The app connects as the table owner | A normal role that RLS applies to | Owners and superusers bypass RLS |

## Practice

## What to Remember

- The tenant comes from the token and travels with **every** hop.
- Scope cache keys, job payloads, search queries, file paths and logs by tenant.
- Row-level security makes the database filter by tenant, so a forgotten `WHERE` returns nothing instead of everything.
- RLS cannot protect caches, queues or search indexes.
- Test isolation with two tenants that share the same ids.

## Further Reading

- [PostgreSQL: Row Security Policies](https://www.postgresql.org/docs/current/ddl-rowsecurity.html): how RLS works, and which roles bypass it.
- [AWS: Multi-tenant data isolation with PostgreSQL row-level security](https://aws.amazon.com/blogs/database/multi-tenant-data-isolation-with-postgresql-row-level-security/): RLS with a session variable per request.
- [OWASP: Authorization Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Authorization_Cheat_Sheet.html): deny by default and check access on every request, which is what tenant checks are.

Next: **Noisy Neighbours and Fairness**. Isolation is not only about data. One tenant's huge export can slow down everyone else, and you fix it with quotas, fair queues and a cell.
