Django Multi-Tenancy Without Schemas: Tenant Isolation as Queryset Rules, and a Profile Switcher That Can’t Be Spoofed

Smarter isolates its tenants in Django with two queryset methods, with_read_permission_for() and with_ownership_permission_for(), that every lookup of a user’s resource starts from, plus a profile switcher whose choice is checked against the user on the way in and again on every use. Neither method is part of Django’s ORM. Smarter adds both to a custom manager and QuerySet that replace Django’s default objects manager on every model that can be owned, so they sit alongside Django’s own filter() and get(). This post looks at that design as a security control, covering what attack it stops, where it is strong, where it depends on discipline, and what it costs. The data model behind it (who owns a resource, how sharing works, how names and caching work) is covered in Smarter’s Ownership Model, and I won’t repeat it here.

The attack is a guessed id

The most common serious flaw in multi-tenant web applications isn’t exotic. It is an endpoint that takes an object’s id from the request and loads the object without asking whether the caller may have it. OWASP lists it first among api risks, as Broken Object Level Authorization. Someone signs up for a free account, opens their own chatbot in the console, changes the id in the url or the request body, and gets someone else’s chatbot, or Secret, or api connection.

For Smarter the stakes are concrete. An account’s resources include Secrets (provider api keys, database passwords), SQL and api connections that reach into customers’ own systems, system prompts that are often the product itself, and chat histories. A cross-tenant read leaks credentials. A cross-tenant write can repoint a customer’s chatbot at an attacker’s tools. I rate this control 9 out of 10, because the surface is every endpoint that touches a tenant’s resource, and a single miss is a breach.

Three ways to isolate tenants

There are three common designs, and each puts the boundary somewhere different.

  • A database or schema per tenant. The boundary is the connection, so a query physically can’t see another tenant’s rows. Packages like django-tenants do this on PostgreSQL. It is the strongest isolation, and the most expensive to operate. Migrations run once per tenant, cross-tenant features (a shared catalog of built-in resources, platform-wide reporting) become hard, and connection counts grow with tenants.
  • Row-level security in the database. The boundary is a database policy that filters every query by a session variable. PostgreSQL has it. Smarter runs on MariaDB, which doesn’t.
  • Query rules in the application. Every table holds every tenant’s rows, and the application filters them. The boundary is code, and it is only as strong as the code’s consistency.

Smarter uses the third, because it needs what the first makes hard, namely built-in Guardrails and other platform resources that every account reads, accounts that share resources among their members without grant tables, and one person who belongs to several accounts. The cost of that choice is that isolation depends on every lookup going through the rules. Most of this design is about making that the easy path.

Three ways to isolate tenants, side by side. A database or schema per tenant puts the boundary at the connection. It is the strongest, but migrations run per tenant, cross-tenant features get hard and connections grow. Row-level security puts it in a database policy, which PostgreSQL has and MariaDB doesn't. Query rules in the application, Smarter's choice, put it in code, which suits built-in resources, sharing inside an account and people in several accounts.

The rules, as querysets

Both rules are methods on a custom QuerySet that every ownable resource’s manager returns. The read rule, from metadata_with_ownership.py, trimmed.

if not isinstance(user, User):
    return self.none()

def _get_for_user_profile(user_profile: UserProfile) -> models.QuerySet[_MT]:
    if user_profile.user.is_superuser:
        return self.all()
    return self.filter(
        models.Q(user_profile__account=smarter_cached_objects.smarter_account)
        | models.Q(user_profile__account=user_profile.account)
    )

request_user_profiles = UserProfile.get_cached_objects(user=user)
if not request_user_profiles.exists():
    return self.none()

qs = self.none()
for request_user_profile in request_user_profiles:
    qs = qs | _get_for_user_profile(request_user_profile)
return qs.distinct()

And the stricter rule for changing a resource.

def _get_for_user_profile(user_profile: UserProfile) -> models.QuerySet[_MT]:
    # superusers have ownership permission for all resources
    if user_profile.user.is_superuser:
        return self.all()

    # staff users have ownership permission for resources owned within their account, or owned by themselves
    if user_profile.user.is_staff:
        return self.filter(user_profile__account=user_profile.account)

    # regular authenticated users have ownership permission only for resources they own
    return self.filter(user_profile=user_profile)

Here they are put together.

WhoMay readMay change
A platform superusereverythingeverything
An account’s staff usertheir accounts’ resources, and the platform’s built-insevery resource owned in their accounts
A regular membertheir accounts’ resources, and the platform’s built-insonly what they own
Anyone without a profile, or not a Usernothingnothing

The security properties are in the details.

The default is empty. An anonymous user, an object that isn’t a User, or a user without a profile gets self.none(). Nothing depends on the caller remembering to check is_authenticated first.

Reads and writes are separate rules. Sharing for reading inside an account is what makes a team productive, and it doesn’t mean a teammate can rewrite your chatbot. Only the owner, the account’s staff, or a superuser can change a resource.

The result is a QuerySet, not a list of ids. It composes with whatever comes next, so the safe lookup and the convenient lookup are the same expression.

custom_domain = LLMClientCustomDomain.objects.with_ownership_permission_for(self.user_profile.user).get(id=custom_domain_id)

That line, from a web console delete view (listview/api.py), is the pattern. A resource outside the rule doesn’t exist as far as the query is concerned, so .get() raises DoesNotExist and the view answers 404, the same answer as an id that was never used. An attacker probing ids can’t tell “not yours” from “not there”, which leaves nothing to enumerate.

A caller in account A changes an id in the request. The lookup starts from with_ownership_permission_for(user).get(id=...). The caller's own resource is returned; account B's resource doesn't exist for the query, .get() raises DoesNotExist and the view answers 404, the same answer as an id that was never used, so there is nothing to enumerate.

Making the safe path the default path

Query rules fail when someone writes Model.objects.get(pk=pk) in a view and forgets the filter. Smarter can’t make that impossible, but it puts the rules where new code picks them up without trying.

The rules live on the abstract base model’s manager, so every ownable kind has them from the moment it inherits the base class. The manager also re-declares every queryset-returning method with the permission-aware return type, so the rule is available after any .filter() or .select_related(), and type checkers know it.

The shared pieces that most endpoints are built from call the rules themselves. Every list page in the web console gets its rows from one method in listview.py, which has no unfiltered branch.

if ownership_filter == SmarterResourceOwnershipFilterEnum.OWNED:
    queryset = manager.owned_by(user)
elif ownership_filter == SmarterResourceOwnershipFilterEnum.SHARED:
    queryset = manager.shared_with(user)
else:
    queryset = manager.with_read_permission_for(user)

The manifest brokers, which serve smarter get, describe, apply and delete for every kind, follow the same split as the table above. apply and delete act only on the caller’s own resource of that name, and describe falls back to with_read_permission_for() when the caller doesn’t own one. The Django admin’s querysets for chatbots and Secrets go through with_ownership_permission_for() too, so even staff users in the admin see only their accounts.

None of this proves that no endpoint loads a resource some other way. It means that a new endpoint built from the existing pieces is safe by default, and a reviewer can grep for lookups that don’t start from one of these methods.

The profile switcher

A Smarter user can belong to several accounts. Each membership is a UserProfile, and the web console’s user menu has a Change Profile item that chooses which one the console acts as. That choice decides who owns a resource you create and which account a charge goes to, so it is exactly the kind of value that an attacker would like to set to someone else’s profile.

It is checked twice. When the choice is made, the view refuses any profile that isn’t the caller’s, with the same 404 as above. From user_profiles.py.

if not UserProfile.objects.filter(pk=body.user_profile_id, user=user).exists():
    logger.warning(
        "%s.post() user %s chose UserProfile %s, which isn't theirs",
        self.formatted_class_name,
        user.pk,
        body.user_profile_id,
    )
    return http.JsonResponse({"error": "Not one of your profiles."}, status=HTTPStatus.NOT_FOUND)
request.session[SELECTED_USER_PROFILE_SESSION_KEY] = body.user_profile_id

The id is stored in the Django session, which Smarter keeps server-side in its cache. The browser holds only the session’s id, not the choice. Then, every time code resolves a user’s profile, the stored choice is treated as a preference and checked again. From user_profile.py.

profile_id = selected_user_profile_id.get()
if not profile_id or not user_id:
    return None
try:
    user_profile = cls.get_cached_object(pk=profile_id)
except cls.DoesNotExist:
    return None
if user_profile.user_id != user_id:
    return None
return user_profile

The second check matters in a way that isn’t obvious. During one request, code sometimes resolves a profile for a user other than the one signed in, for example an account’s admin. If the choice were applied to whatever user was being resolved, one user’s selection could leak onto another. Because the choice only counts when its profile belongs to the user being resolved, it can’t.

Three more details close the remaining gaps.

  • The choice is carried through the request in a ContextVar, set by middleware and reset in a finally block, so concurrent requests on the same ASGI worker can’t see each other’s choice.
  • A request that authenticates with an api key (Authorization: Token ...) ignores the session’s choice entirely. A key acts as its own user, whatever a browser session chose.
  • A malformed value in the session is logged and ignored, not trusted and not raised.

Above all, the switcher doesn’t widen what anyone can see. Both permission rules take the union over all of a user’s profiles, whatever profile is selected. The choice changes who you act as, among identities you already have. It never changes what you may read or change.

The profile switcher's two checks. The browser sends a profile id from Change Profile; check 1 refuses a profile that isn't the caller's with a 404; the choice is stored server-side in the Django session; on every request middleware sets a ContextVar, and check 2 ignores the choice unless its profile belongs to the user being resolved. Api keys ignore the choice, malformed values are ignored, and the choice never widens permissions, because both rules take the union over all of a user's profiles.

Burden, cost, UX and stability

Admin burden. Low in operations, since there is nothing to provision per tenant, no per-tenant migration, and no grant table to keep in sync. The burden moves to code review. Every new endpoint that loads a resource by id must start from one of the two rules, and that is the thing to check in review and to cover with a test that a second account gets a 404.

Cost. One join to the owner’s profile per query, and an OR per profile for users who belong to several accounts. Compared with a database per tenant, the infrastructure cost is zero. The thing to watch is the shape of the query for a user with many memberships, since each one adds a clause.

UX. One login works across every account a person belongs to, and switching takes a click and a page reload. Built-in resources show up in every account without being copied in.

Stability. Isolation failures are code bugs, not infrastructure incidents, so they are fixed with a deploy, and tests catch them. The flip side is that nothing below the application backs them up. A database-level control would.

Should you adopt it?

If you run Django on a database without row-level security, and your product needs shared resources and people in more than one tenant, query rules are the practical choice, and they can be made solid. Put the rules on the base model’s manager, return QuerySets so they compose, make “nothing” the default, keep read and write separate, answer 404 for “not yours”, and build the shared views and serializers on top of them so new code inherits them. Treat anything the client sends to choose an identity (an account id, a profile id, a tenant header) as a preference, verify it when it is set, and verify it again when it is used.

If your tenants must never share anything, or a regulator wants isolation that doesn’t depend on application code, use a schema or a database per tenant instead.

Takeaways

  • Start every lookup of a tenant’s resource from a permission-aware queryset, then .get(id=...).
  • Make the empty queryset the default for anyone who isn’t a known, profiled user.
  • Keep the read rule and the change rule separate, and make the change rule stricter.
  • Answer 404 for a resource that exists but isn’t the caller’s.
  • Validate an identity switch when it is chosen and again on every use, and never let it widen permissions.
  • Carry per-request identity in a ContextVar and reset it in finally.
  • Review new endpoints for unfiltered lookups, and test them with a second account.

Closing thoughts

Tenant isolation in Smarter isn’t a separate subsystem. It is two queryset methods and a habit of starting from them, which works because the base class puts them in every model and the shared views use them for you. The profile switcher follows the same principle at a smaller scale. What the client sends is a request, and the server decides, every time, whether it is allowed.