Lesson 14 / 32
Scoping Every Data Access Path
Funnel all data access through tenant-scoped repositories, and extend scoping to caches, search indexes, object storage and queues.
By Rahul Agarwal · 15 years in web development
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.
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.
Quick check: A dashboard caches results under the key `stats:last30days`. What is the multi-tenant bug?
- 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.