DEV Community

Ugur Aslim
Ugur Aslim

Posted on • Originally published at uguraslim.com

SQLAlchemy Event Listeners for Automatic Tenant Data Isolation: Enforcing Multi-Tenancy at the ORM Layer Without Manual Filtering

SQLAlchemy Event Listeners for Automatic Tenant Data Isolation: Enforcing Multi-Tenancy at the ORM Layer Without Manual Filtering

I've shipped multi-tenant SaaS. I've also watched a junior developer accidentally expose Customer A's data to Customer B because they forgot a single filter(Tenant.id == current_tenant_id) clause in one endpoint. That's the moment I realized: manual tenant filtering is security theater.

Here's the uncomfortable truth: telling developers "just remember to filter by tenant_id in every query" is like telling drivers "just remember not to hit things." Human error is inevitable. The solution isn't better reminders—it's making the mistake physically impossible at the ORM layer.

SQLAlchemy event listeners let you enforce tenant isolation automatically. Every query gets the tenant filter injected before it even touches the database. A junior developer can't leak data even if they try.

The Problem with Manual Filtering

Let's say your endpoint looks like this:

@app.get("/users")
async def list_users(current_user: User = Depends(get_current_user)):
    users = db.query(User).filter(User.active == True).all()
    return users
Enter fullscreen mode Exit fullscreen mode

Looks innocent. But there's a bug here—no tenant filter. That query returns every active user across all tenants. Customer A sees Customer B's employees. GDPR violation. Blog post about your security failure. CVE number. Your weekend is ruined.

You could add the filter:

users = db.query(User).filter(
    User.active == True,
    User.tenant_id == current_user.tenant_id
).all()
Enter fullscreen mode Exit fullscreen mode

Better. But what about this endpoint? And this one? And the one added by the new hire who doesn't know about the tenant isolation pattern yet?

The problem multiplies across your codebase. Every developer has to remember. Every review has to catch it. Every new endpoint is a security risk until proven otherwise.

This is backwards. Security should be the default, not an afterthought.

The ORM-Layer Solution: Event Listeners

SQLAlchemy has an event system that hooks into the query lifecycle. I use before_bulk_update and before_delete to inject tenant filters automatically—before the database ever sees the query.

Here's the architecture:

from sqlalchemy import event
from sqlalchemy.orm import Query, Session
from contextlib import contextmanager

# Thread-local storage for current tenant context
_tenant_context = {}

@contextmanager
def tenant_context(tenant_id: int):
    """Context manager to set the current tenant."""
    _tenant_context['tenant_id'] = tenant_id
    try:
        yield
    finally:
        _tenant_context.pop('tenant_id', None)

def get_current_tenant_id() -> int | None:
    """Retrieve the current tenant from context."""
    return _tenant_context.get('tenant_id')

# Define a marker for multi-tenant tables
TENANT_AWARE = '__tenant_aware__'

def make_tenant_aware(cls):
    """Decorator to mark a model as tenant-aware."""
    setattr(cls, TENANT_AWARE, True)
    return cls

@make_tenant_aware
class User(Base):
    __tablename__ = "users"
    id: Mapped[int] = mapped_column(primary_key=True)
    tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id"))
    email: Mapped[str]
    active: Mapped[bool] = mapped_column(default=True)

@make_tenant_aware
class Document(Base):
    __tablename__ = "documents"
    id: Mapped[int] = mapped_column(primary_key=True)
    tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id"))
    title: Mapped[str]
Enter fullscreen mode Exit fullscreen mode

Now the event listeners:

@event.listens_for(Session, "before_bulk_update")
def receive_before_bulk_update(update_context):
    """Inject tenant filter before bulk update."""
    mapper = update_context.mapper

    # Only filter tenant-aware models
    if not hasattr(mapper.class_, TENANT_AWARE):
        return

    tenant_id = get_current_tenant_id()
    if tenant_id is None:
        raise RuntimeError(
            f"Attempted to update {mapper.class_.__name__} "
            "without tenant context. Use tenant_context()."
        )

    # Inject the tenant filter
    update_context.whereclause = update_context.whereclause & (
        mapper.class_.tenant_id == tenant_id
    )

@event.listens_for(Session, "before_bulk_delete")
def receive_before_bulk_delete(delete_context):
    """Inject tenant filter before bulk delete."""
    mapper = delete_context.mapper

    if not hasattr(mapper.class_, TENANT_AWARE):
        return

    tenant_id = get_current_tenant_id()
    if tenant_id is None:
        raise RuntimeError(
            f"Attempted to delete {mapper.class_.__name__} "
            "without tenant context. Use tenant_context()."
        )

    # Inject the tenant filter
    delete_context.whereclause = delete_context.whereclause & (
        mapper.class_.tenant_id == tenant_id
    )

@event.listens_for(Session, "before_flush")
def receive_before_flush(session, flush_context, instances):
    """Validate that all tenant-aware objects have tenant_id set."""
    for obj in session.new | session.dirty:
        if hasattr(obj.__class__, TENANT_AWARE):
            if not hasattr(obj, 'tenant_id') or obj.tenant_id is None:
                raise RuntimeError(
                    f"{obj.__class__.__name__} missing tenant_id. "
                    "Tenant context not properly set."
                )
Enter fullscreen mode Exit fullscreen mode

Usage: The Developer Experience

Your endpoint now becomes:

@app.get("/users")
async def list_users(current_user: User = Depends(get_current_user)):
    # This context manager sets the tenant for all queries within it
    with tenant_context(current_user.tenant_id):
        # No explicit tenant filter needed—it's automatic
        users = db.query(User).filter(User.active == True).all()
    return users
Enter fullscreen mode Exit fullscreen mode

No filter forgotten. No data leak possible. If a developer tries to query without setting tenant context:

users = db.query(User).all()  # RuntimeError: Attempted to update User without tenant context
Enter fullscreen mode Exit fullscreen mode

The code fails loudly. Not silently. Not after three months in production.

For FastAPI, wrap it in dependency injection:

async def get_db_session(current_user: User = Depends(get_current_user)):
    """Provide a session with tenant context already set."""
    with tenant_context(current_user.tenant_id):
        yield db
Enter fullscreen mode Exit fullscreen mode

Now your endpoint is just:

@app.get("/users")
async def list_users(db: Session = Depends(get_db_session)):
    users = db.query(User).filter(User.active == True).all()
    return users
Enter fullscreen mode Exit fullscreen mode

The tenant filter is invisible—which is exactly the point.

Gotcha: Unscoped Joins

This approach works for single-table queries. But joins can be tricky:

# This gets filtered automatically
users = db.query(User).all()

# But this might not, if Document isn't also tenant-aware
docs = db.query(Document).join(User).all()
Enter fullscreen mode Exit fullscreen mode

The join doesn't automatically filter User. You need to either:

  1. Mark both tables tenant-aware (preferred)
  2. Explicitly filter the joined table if it's cross-tenant by design

I prefer option 1—if two tables are related, they usually share a tenant anyway.

Why This Matters

Security shouldn't depend on developer discipline. It should be baked into the framework. SQLAlchemy event listeners move tenant isolation from the "nice to have" checklist to the "physically impossible to bypass" category.

I've used this pattern in CitizenApp for two years. Zero accidental data leaks. Junior developers ship features confidently. Code reviews focus on logic, not "did you remember the tenant filter?"

That's the difference between security theater and actual security.

Top comments (0)