←

PostgreSQL RLS via contextvars: multitenancy transparente sin WHERE manual

Contexto

En un SaaS multitenant, el error mas peligroso es mostrar datos de un tenant a otro. La solucion clasica es agregar WHERE tenant_id = :id a cada query, pero eso depende de que ningun dev se le olvide. En ulfblk-multitenant necesitaba un mecanismo que hiciera imposible la fuga de datos entre tenants, sin depender de la disciplina del desarrollador.

Lo que aprendi

PostgreSQL Row-Level Security (RLS) filtra filas a nivel de base de datos. Si combinas eso con Python contextvars, el tenant_id se propaga automaticamente desde el request hasta la query sin que el dev escriba un solo WHERE.

La politica RLS en PostgreSQL

La politica se define una sola vez por tabla. Usa una variable de sesion (app.current_tenant) que se setea en cada conexion.

-- Habilitar RLS en la tabla
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;

-- Politica: cada tenant solo ve sus propias filas
CREATE POLICY tenant_isolation ON orders
    USING (tenant_id = current_setting('app.current_tenant')::uuid);

-- Forzar RLS incluso para el owner de la tabla
ALTER TABLE orders FORCE ROW LEVEL SECURITY;

El contextvar que viaja con el request

Un ContextVar almacena el tenant_id durante toda la vida del request. Es thread-safe y compatible con asyncio.

from contextvars import ContextVar
from uuid import UUID

_current_tenant: ContextVar[UUID | None] = ContextVar(
    "current_tenant", default=None
)


def get_current_tenant() -> UUID:
    tenant = _current_tenant.get()
    if tenant is None:
        raise RuntimeError("tenant_id no esta seteado en el contexto")
    return tenant


def set_current_tenant(tenant_id: UUID) -> None:
    _current_tenant.set(tenant_id)

Middleware que setea el tenant en cada request

El middleware extrae el tenant_id del JWT (o header), lo guarda en el contextvar, y lo setea como variable de sesion en PostgreSQL.

from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import Response
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import text


class TenantMiddleware(BaseHTTPMiddleware):
    async def dispatch(self, request: Request, call_next) -> Response:
        tenant_id = request.state.user.tenant_id  # del JWT ya validado

        # Setear en el contextvar de Python
        set_current_tenant(tenant_id)

        # Setear en la sesion de PostgreSQL para RLS
        db: AsyncSession = request.state.db
        await db.execute(
            text("SET LOCAL app.current_tenant = :tid"),
            {"tid": str(tenant_id)},
        )

        response = await call_next(request)
        return response

Query sin WHERE explicito

Lo mejor es lo que NO tienes que escribir. Una query normal retorna solo las filas del tenant actual:

from sqlalchemy import select
from app.models import Order


async def list_orders(db: AsyncSession) -> list[Order]:
    # Sin WHERE tenant_id = ... porque RLS lo hace automatico
    result = await db.execute(select(Order).order_by(Order.created_at.desc()))
    return list(result.scalars().all())

Por que esto es superior al filtro manual

El filtro manual falla cuando un dev olvida el WHERE en una sola query. Con RLS, la base de datos garantiza el aislamiento. Incluso si alguien ejecuta SELECT * FROM orders directamente, solo ve las filas de su tenant. Zero leakage sin depender de la disciplina humana.

Referencia