Блог Engineering

Keeping Multi-Tenant Data Safe in a Flask App Without Separate Databases

A `business_id` column is not a tenancy strategy. Here's how we made the active business explicit in session state, queries, caches, and tests so supplier data does not bleed across accounts.

Keeping Multi-Tenant Data Safe in a Flask App Without Separate Databases

Multi-tenant bugs are usually quiet right up until they are catastrophic.

In a logistics product, the blast radius is obvious: wrong delivery data, wrong return history, wrong supermarket account, wrong numbers on the dashboard. Once you let one business see another business's operational record, you do not have a UX bug. You have a trust failure.

We run multiple businesses in one application and one database. That means the code has to make tenancy explicit everywhere it matters: request context, query shape, cache keys, and tests.

This is the pattern we settled on.

1. Treat the active business as request state

The first mistake in multi-tenant apps is assuming current_user is enough context. It usually is not.

A user can have more than one business context over time, and even if they only use one today, the code gets safer when the tenant boundary is explicit. So instead of inferring everything from user_id, we resolve an active business for the current session and validate that it belongs to the authenticated user.

From app/utils/business_context.py:

def get_current_business():
    if not current_user.is_authenticated:
        return None

    selected_business_id = session.get("current_business_id")

    if selected_business_id:
        business = Business.query.filter_by(
            id=selected_business_id, owner_id=current_user.id
        ).first()
        if business:
            return business

    business = Business.query.filter_by(owner_id=current_user.id).first()

    if business:
        session["current_business_id"] = business.id

    return business

There are two important details here.

First, the session value is not blindly trusted. It is revalidated against the current user on every request path that resolves business context.

Second, the app fails closed. If there is no authenticated user or no valid business, downstream services can return empty or error responses instead of guessing.

That alone eliminates a surprising number of "it worked on my account" leakage paths.

2. Make the tenant boundary obvious in every query

The next rule is simple: business-owned records are queried by business_id, not by hope.

For example, the deliveries API does not fetch "the current user's deliveries" and then filter later in Python. It scopes the SQL query at the start:

@api.route("/deliveries", methods=["GET"])
@require_api_or_jwt
def list_deliveries(current_user):
    business = get_current_business()
    if not business:
        return api_error_response("BUSINESS_REQUIRED", "Business context required", 400)

    query = Delivery.query.filter_by(business_id=business.id)
    ...

The same pattern shows up across returns, products, supermarkets, and service-layer lookups:

return (
    Return.query.filter_by(id=return_id, business_id=business.id)
    .options(
        joinedload(Return.supermarket),
        joinedload(Return.subchain),
        joinedload(Return.delivery),
        joinedload(Return.items).joinedload(ReturnItem.product),
    )
    .first()
)

That kind of code is not clever. It is intentionally repetitive. In tenancy-sensitive systems, repetition can be a safety feature because it makes missing scope easier to notice in review.

3. Cache keys need the same boundary as the database

Teams often get the database scoping right and then undo it in caching.

That is an easy trap. A cache key like products_user_42 may look fine until the same user can operate in different business contexts. Now the cache is wider than the query it is supposed to accelerate.

We fixed that by scoping reference caches to the active business as well:

def get_cached_products(user_id):
    current_business = get_current_business()
    if not current_business:
        return []

    cache_key = f"products_business_{current_business.id}"
    products = current_app.cache.get(cache_key)

    if products is None:
        products = (
            Product.query.filter_by(
                user_id=user_id, business_id=current_business.id
            )
            .order_by(Product.name)
            .all()
        )
        current_app.cache.set(cache_key, products, timeout=300)

    return products

That same idea shows up in supermarket caches and in tests that assert cache isolation behavior. The safe rule is this: if the database query is tenant-scoped, the cache key should be tenant-scoped too.

4. Keep legacy identifiers, but do not let them define ownership

Like a lot of real applications, we still have some legacy user_id fields hanging around for backward compatibility. That is fine as long as ownership is defined by the newer boundary.

In create flows, we keep user_id when it helps with legacy compatibility, but we attach business_id at write time and treat that as the durable ownership line:

return_obj = Return(
    return_date=validated_data["return_date"],
    delivery_id=validated_data["delivery"].id,
    supermarket_id=validated_data["supermarket_id"],
    subchain_id=validated_data["subchain_id"],
    user_id=user.id,
    business_id=business.id,
    slug=generate_slug("return"),
)

That is a useful migration posture in older codebases: you do not need to delete every legacy field on day one, but you do need to stop pretending the old field is enough for access control.

5. Test the leak, not just the happy path

The only multi-tenant test suite worth trusting is one that actively tries to cross the boundary.

Our business-isolation tests create separate users, separate businesses, separate records, then assert that the wrong business cannot see them:

business1_products = Product.query.filter_by(business_id=business1.id).all()

assert len(business1_products) == 1
assert business1_products[0].name == "Product 1"
assert "Product 2" not in [p.name for p in business1_products]

The same suite checks deliveries, returns, supermarkets, analytics behavior, and even cache-key structure. That matters because leakage is often introduced far from the original model layer.

If you only test that user A can see user A's data, you have not tested tenancy. You have only tested the happy path.

6. Fail closed when context is missing

Another pattern we found useful is returning nothing when business context is missing instead of trying to recover magically.

For example, some service methods return empty paginations or None if there is no active business:

business = get_current_business()
if not business:
    return Return.query.filter_by(id=-1).paginate(
        page=page, per_page=per_page, error_out=False
    )

This is less "friendly" than trying to improvise from current_user, but it is much safer. Security-sensitive defaults should be boring.

What we would tell other teams building the same thing

If you are building a multi-tenant Flask app in one database, the main thing to avoid is implicitness.

Do not let tenant context live only in the UI.

Do not let cache keys be broader than queries.

Do not let legacy identifiers quietly define ownership after the product has outgrown them.

And do not ship without tests that deliberately try to cross the boundary.

The implementation here is not exotic. That is the point. In tenancy work, the best architecture is often the one a tired engineer can still follow correctly at 11:30 p.m. during a hotfix.

Все статьи
Поделиться

Программное обеспечение для выполнения операций, описанных в этой статье.

Планирование доставки, обработка возвратов, отслеживание запасов икоординация B2B - специально для предприятий, поставляющих продуктыпитания в супермаркеты.

Отслеживание доставки Возвраты и кредиты Портал B2B Экспорт в формате PDF 6 языков