# Scoping Every Data Access Path — Multi-Tenant SaaS Architecture

Source: https://www.skillbyai.com/en/multi-tenant-saas/mt-scoped-data-access

> Funnel all data access through tenant-scoped repositories, and extend scoping to caches, search indexes, object storage and queues.

## One door to the data

Even with RLS, application code should make the safe path the easy path. A **tenant-scoped repository** (or ORM default scope) receives the tenant once, from request context, and adds `tenant_id` to every query and every insert. Raw SQL scattered across controllers is where filters get forgotten, so many teams lint for or forbid direct table access outside the repository layer. Remember that the database is not your only store. **Caches** need tenant-prefixed keys, otherwise `report:monthly` computed for one tenant is served to another. **Search indexes** need a tenant filter on every query, or a separate index per tenant. **Object storage** paths should start with the tenant, such as `tenants/acme/uploads/...`, ideally enforced by access policy. **Queues and events** must carry `tenant_id`. **Exports and admin tools** are classic leak points because they are often written in a hurry.

## A repository that cannot forget

Every method uses the repository's tenant. Asking zen's repository for project 2, which belongs to acme, returns nothing.

```python
import sqlite3

db = sqlite3.connect(":memory:")
db.execute("CREATE TABLE projects (tenant_id TEXT, id INTEGER, name TEXT, PRIMARY KEY (tenant_id, id))")
db.executemany("INSERT INTO projects VALUES (?, ?, ?)",
               [("acme", 1, "Website"), ("acme", 2, "App"), ("zen", 1, "Menu")])

class ProjectRepo:
    """Every query goes through here, and every query is scoped."""
    def __init__(self, conn, tenant_id):
        self.conn, self.tenant_id = conn, tenant_id

    def list(self):
        sql = "SELECT id, name FROM projects WHERE tenant_id = ? ORDER BY id"
        return self.conn.execute(sql, (self.tenant_id,)).fetchall()

    def get(self, project_id):
        sql = "SELECT id, name FROM projects WHERE tenant_id = ? AND id = ?"
        return self.conn.execute(sql, (self.tenant_id, project_id)).fetchone()

zen = ProjectRepo(db, "zen")
print(zen.list())
print(zen.get(2))   # id 2 exists, but belongs to acme
```

Output:

```
[(1, 'Menu')]
None
```

## A bank teller's counter

A bank teller never walks into the vault and picks notes from any shelf. They request them through a system that checks the account first. The repository is that counter: code asks it for data, and the tenant check happens there every single time.

## Guessable ids are not a security boundary

Using random UUIDs instead of 1, 2, 3 makes ids harder to guess, but it does not replace tenant checks. Ids leak through URLs, logs and support tickets. Always check ownership, whatever the id format.

**Quiz:** A dashboard caches results under the key `stats:last30days`. What is the multi-tenant bug?

- [x] The key lacks the tenant, so one tenant's cached stats can be served to another
- [ ] Cache keys may not contain colons
- [ ] Thirty days is too long to cache
- [ ] Caches cannot hold dashboard data

*Answer:* The key lacks the tenant, so one tenant's cached stats can be served to another. Cache keys must include the tenant, for example `t:acme:stats:last30days`. Otherwise the first tenant to warm the cache decides what everyone sees.
