ICVOSS DJANGO PACKAGE REGISTRY

The package index django-boundary Scope a model to a tenant through a relation

Scope a model to a tenant through a relation

Documentation

Goal

Make a model tenant-scoped when it does not carry its own tenant foreign key but reaches the tenant through a relation, for example an ExportLog that belongs to a Destination which belongs to a merchant. The model should auto-filter by the active tenant just like a direct-FK model, without dropping to a hand-written manager.

Prerequisites

When to use this

Use make_tenant_path_mixin when the tenant is reached by a lookup path rather than a local column:

Shape Mixin
Model has its own tenant/merchant FK TenantMixin / make_tenant_mixin("merchant")
Model reaches the tenant through a relation make_tenant_path_mixin("destination__merchant")

If you find yourself writing a bespoke Manager with a for_tenant/for_merchant method that does filter(rel__rel__merchant=...), that is exactly the case this replaces.

Steps

  1. Build a mixin from the lookup path to the tenant and apply it:

```python from django.db import models from boundary.models import make_tenant_path_mixin

ExportScopedMixin = make_tenant_path_mixin("destination__merchant")

class ExportLog(ExportScopedMixin, TimeStampedModel): destination = models.ForeignKey(Destination, on_delete=models.CASCADE) # ExportLog.objects auto-filters on destination__merchant ```

The path is just a Django ORM lookup string, so multi-hop paths work the same way:

python make_tenant_path_mixin("export_log__destination__merchant")

  1. Use the model exactly like any tenant-scoped model:

python with TenantContext.using(merchant): ExportLog.objects.all() # only this merchant's export logs ExportLog.objects.create(destination=dest) # no tenant kwarg needed

The mixin adds no foreign key: the model already has the relation the path traverses. There is no tenant/merchant column on the table, and nothing to populate on save.

Verify it worked

with TenantContext.using(merchant_a):
    ExportLog.objects.create(destination=dest_a)
with TenantContext.using(merchant_b):
    ExportLog.objects.create(destination=dest_b)

with TenantContext.using(merchant_a):
    assert ExportLog.objects.count() == 1      # auto-filtered

assert ExportLog.unscoped.count() == 2         # bypass still sees all

How it differs from a direct-FK model

Path-scoped models have no local tenant column, which changes a few behaviours. boundary handles all of this for you:

Warning: direct SQL against this table (raw queries, an unscoped report, a third-party tool connecting straight to Postgres) returns every tenant's rows, not just the active one's. PostgreSQL RLS is table-specific: the parent's policy (e.g. Destination's policy on its merchant_id) constrains scans of the parent table, so an ORM query that joins through the path is constrained too, but it does not constrain anything that queries this child table directly. If a row needs database-level isolation, it must own a tenant FK of its own and use TenantMixin (or make_tenant_mixin) instead of this mixin.

Introspection

from boundary.models import (
    is_tenant_model, has_tenant_column, get_tenant_lookup, get_tenant_fk_field,
)

is_tenant_model(ExportLog)      # True
has_tenant_column(ExportLog)    # False: no local column
get_tenant_lookup(ExportLog)    # "destination__merchant"
get_tenant_fk_field(ExportLog)  # None: there is no column