django-boundary 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- django_boundary-0.1.0/LICENSE +21 -0
- django_boundary-0.1.0/PKG-INFO +683 -0
- django_boundary-0.1.0/README.md +651 -0
- django_boundary-0.1.0/pyproject.toml +46 -0
- django_boundary-0.1.0/setup.cfg +4 -0
- django_boundary-0.1.0/src/boundary/__init__.py +4 -0
- django_boundary-0.1.0/src/boundary/apps.py +36 -0
- django_boundary-0.1.0/src/boundary/celery.py +123 -0
- django_boundary-0.1.0/src/boundary/checks.py +155 -0
- django_boundary-0.1.0/src/boundary/conf.py +100 -0
- django_boundary-0.1.0/src/boundary/context.py +131 -0
- django_boundary-0.1.0/src/boundary/exceptions.py +29 -0
- django_boundary-0.1.0/src/boundary/management/__init__.py +0 -0
- django_boundary-0.1.0/src/boundary/management/commands/__init__.py +0 -0
- django_boundary-0.1.0/src/boundary/management/commands/boundary_deprovision.py +113 -0
- django_boundary-0.1.0/src/boundary/management/commands/boundary_provision.py +48 -0
- django_boundary-0.1.0/src/boundary/management/commands/boundary_run.py +45 -0
- django_boundary-0.1.0/src/boundary/management/commands/boundary_run_all.py +127 -0
- django_boundary-0.1.0/src/boundary/middleware.py +108 -0
- django_boundary-0.1.0/src/boundary/migrations/__init__.py +0 -0
- django_boundary-0.1.0/src/boundary/migrations_ops.py +181 -0
- django_boundary-0.1.0/src/boundary/models.py +153 -0
- django_boundary-0.1.0/src/boundary/resolvers.py +226 -0
- django_boundary-0.1.0/src/boundary/routing.py +114 -0
- django_boundary-0.1.0/src/boundary/signals.py +19 -0
- django_boundary-0.1.0/src/boundary/testing.py +71 -0
- django_boundary-0.1.0/src/django_boundary.egg-info/PKG-INFO +683 -0
- django_boundary-0.1.0/src/django_boundary.egg-info/SOURCES.txt +39 -0
- django_boundary-0.1.0/src/django_boundary.egg-info/dependency_links.txt +1 -0
- django_boundary-0.1.0/src/django_boundary.egg-info/requires.txt +11 -0
- django_boundary-0.1.0/src/django_boundary.egg-info/top_level.txt +1 -0
- django_boundary-0.1.0/tests/test_celery.py +87 -0
- django_boundary-0.1.0/tests/test_checks.py +50 -0
- django_boundary-0.1.0/tests/test_commands.py +189 -0
- django_boundary-0.1.0/tests/test_context.py +133 -0
- django_boundary-0.1.0/tests/test_middleware.py +184 -0
- django_boundary-0.1.0/tests/test_models.py +202 -0
- django_boundary-0.1.0/tests/test_resolvers.py +196 -0
- django_boundary-0.1.0/tests/test_rls.py +330 -0
- django_boundary-0.1.0/tests/test_routing.py +125 -0
- django_boundary-0.1.0/tests/test_testing.py +58 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ICV
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,683 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: django-boundary
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Scalable row-level multi-tenancy for Django with PostgreSQL RLS
|
|
5
|
+
Author-email: ICV <dev@icv.dev>
|
|
6
|
+
License: MIT
|
|
7
|
+
Classifier: Development Status :: 3 - Alpha
|
|
8
|
+
Classifier: Framework :: Django
|
|
9
|
+
Classifier: Framework :: Django :: 4.2
|
|
10
|
+
Classifier: Framework :: Django :: 5.0
|
|
11
|
+
Classifier: Framework :: Django :: 5.1
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: LICENSE
|
|
21
|
+
Requires-Dist: Django>=4.2
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
24
|
+
Requires-Dist: pytest-django>=4.8; extra == "dev"
|
|
25
|
+
Requires-Dist: pytest-cov>=5.0; extra == "dev"
|
|
26
|
+
Requires-Dist: factory-boy>=3.3; extra == "dev"
|
|
27
|
+
Requires-Dist: psycopg[binary]>=3.1; extra == "dev"
|
|
28
|
+
Requires-Dist: ruff>=0.5.0; extra == "dev"
|
|
29
|
+
Requires-Dist: mypy>=1.10; extra == "dev"
|
|
30
|
+
Requires-Dist: django-stubs>=5.0; extra == "dev"
|
|
31
|
+
Dynamic: license-file
|
|
32
|
+
|
|
33
|
+
# django-boundary
|
|
34
|
+
|
|
35
|
+
[](https://github.com/nigelcopley/icv-oss/actions/workflows/ci.yml)
|
|
36
|
+
[](https://pypi.org/project/django-boundary/)
|
|
37
|
+
[](https://pypi.org/project/django-boundary/)
|
|
38
|
+
[](https://pypi.org/project/django-boundary/)
|
|
39
|
+
[](https://opensource.org/licenses/MIT)
|
|
40
|
+
|
|
41
|
+
Scalable row-level multi-tenancy for Django with PostgreSQL Row Level Security.
|
|
42
|
+
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
## Who Is This For?
|
|
46
|
+
|
|
47
|
+
django-boundary is for Django projects that serve multiple tenants from a
|
|
48
|
+
single database. If your users belong to organisations, workspaces, teams,
|
|
49
|
+
schools, clinics, clubs, or any other entity that should only see its own
|
|
50
|
+
data — boundary handles the isolation.
|
|
51
|
+
|
|
52
|
+
### Common use cases
|
|
53
|
+
|
|
54
|
+
**SaaS platforms** — Each customer (organisation, workspace, account) is a
|
|
55
|
+
tenant. Their data is isolated at the ORM and database level. New tenants are
|
|
56
|
+
provisioned via management command; no schema migrations required.
|
|
57
|
+
|
|
58
|
+
**Marketplace platforms** — Sellers, venues, or merchants each have their own
|
|
59
|
+
tenant. Products, orders, and analytics are scoped per-tenant. Platform-wide
|
|
60
|
+
reporting uses the `unscoped` manager.
|
|
61
|
+
|
|
62
|
+
**Education / healthcare / government** — Schools, clinics, or departments are
|
|
63
|
+
tenants. Data residency requirements are met via regional routing (e.g. UK data
|
|
64
|
+
stays in UK database, EU data in EU database).
|
|
65
|
+
|
|
66
|
+
**Agency or white-label products** — Each client gets their own tenant, resolved
|
|
67
|
+
by subdomain (`client-a.app.com`) or JWT claim from the auth provider.
|
|
68
|
+
|
|
69
|
+
**Internal tools** — Departments or business units are tenants, resolved via
|
|
70
|
+
session or header. `STRICT_MODE` catches accidental cross-department data
|
|
71
|
+
exposure during development.
|
|
72
|
+
|
|
73
|
+
### When NOT to use boundary
|
|
74
|
+
|
|
75
|
+
- **Single-tenant apps** — no need for isolation machinery.
|
|
76
|
+
- **Schema-per-tenant** — use [django-tenants](https://github.com/django-tenants/django-tenants) instead (different trade-offs at scale).
|
|
77
|
+
- **Non-PostgreSQL databases** — the ORM layer works on any database, but RLS enforcement requires PostgreSQL 14+.
|
|
78
|
+
|
|
79
|
+
---
|
|
80
|
+
|
|
81
|
+
## Features
|
|
82
|
+
|
|
83
|
+
- **Automatic ORM filtering** — queries are scoped to the active tenant by default
|
|
84
|
+
- **PostgreSQL RLS** — database-level enforcement as a second layer of defence
|
|
85
|
+
- **Async-native** — context propagation via `contextvars`, works with sync and async Django
|
|
86
|
+
- **Pluggable resolvers** — subdomain, header, JWT claim, session, or custom
|
|
87
|
+
- **Strict mode** — raises on unscoped queries (default: on), catches data leaks at development time
|
|
88
|
+
- **Regional routing** — route queries to geographically distinct databases for data residency compliance
|
|
89
|
+
- **Celery integration** — tenant context propagated via task headers, restored on workers
|
|
90
|
+
- **Management commands** — provision, deprovision (with NDJSON export), scoped run, run-all with parallelism
|
|
91
|
+
- **Test utilities** — `set_tenant()`, `TenantTestMixin`, `tenant_factory()`
|
|
92
|
+
- **System checks** — validates configuration at startup
|
|
93
|
+
- **LEAKPROOF RLS functions** — prevents query planner information leakage
|
|
94
|
+
- **Zero assumptions** — no opinion on auth, URL structure, or domain model
|
|
95
|
+
|
|
96
|
+
---
|
|
97
|
+
|
|
98
|
+
## Installation
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
pip install django-boundary
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Add to `INSTALLED_APPS`:
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
INSTALLED_APPS = [
|
|
108
|
+
...
|
|
109
|
+
"boundary",
|
|
110
|
+
...
|
|
111
|
+
]
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
---
|
|
115
|
+
|
|
116
|
+
## Quick Start
|
|
117
|
+
|
|
118
|
+
### 1. Define your tenant model
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
# tenants/models.py
|
|
122
|
+
from boundary.models import AbstractTenant
|
|
123
|
+
|
|
124
|
+
class Organisation(AbstractTenant):
|
|
125
|
+
# Inherits: name, slug, region, is_active, created_at, updated_at
|
|
126
|
+
plan = models.CharField(max_length=50, default="free")
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
### 2. Configure settings
|
|
130
|
+
|
|
131
|
+
```python
|
|
132
|
+
# settings.py
|
|
133
|
+
BOUNDARY_TENANT_MODEL = "tenants.Organisation"
|
|
134
|
+
BOUNDARY_STRICT_MODE = True # default — raises on unscoped queries
|
|
135
|
+
|
|
136
|
+
# Resolver chain: first match wins.
|
|
137
|
+
# For public-facing apps, SubdomainResolver should be first.
|
|
138
|
+
BOUNDARY_RESOLVERS = [
|
|
139
|
+
"boundary.resolvers.SubdomainResolver",
|
|
140
|
+
]
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
### 3. Add middleware
|
|
144
|
+
|
|
145
|
+
```python
|
|
146
|
+
MIDDLEWARE = [
|
|
147
|
+
"django.middleware.security.SecurityMiddleware",
|
|
148
|
+
"boundary.middleware.TenantMiddleware", # before session/auth
|
|
149
|
+
"django.contrib.sessions.middleware.SessionMiddleware",
|
|
150
|
+
...
|
|
151
|
+
]
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
### 4. Make models tenant-scoped
|
|
155
|
+
|
|
156
|
+
```python
|
|
157
|
+
# bookings/models.py
|
|
158
|
+
from boundary.models import TenantModel
|
|
159
|
+
|
|
160
|
+
class Booking(TenantModel):
|
|
161
|
+
court = models.IntegerField()
|
|
162
|
+
start_time = models.DateTimeField()
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
That's it. `Booking.objects.all()` now automatically filters by the active
|
|
166
|
+
tenant. Creating a booking auto-populates the `tenant` field from context.
|
|
167
|
+
|
|
168
|
+
---
|
|
169
|
+
|
|
170
|
+
## Example Configurations
|
|
171
|
+
|
|
172
|
+
### SaaS with subdomain routing
|
|
173
|
+
|
|
174
|
+
Each customer gets a subdomain: `acme.app.com`, `globex.app.com`.
|
|
175
|
+
|
|
176
|
+
```python
|
|
177
|
+
# models.py
|
|
178
|
+
class Workspace(AbstractTenant):
|
|
179
|
+
plan = models.CharField(max_length=20, default="starter")
|
|
180
|
+
max_users = models.IntegerField(default=5)
|
|
181
|
+
|
|
182
|
+
class Project(TenantModel):
|
|
183
|
+
name = models.CharField(max_length=200)
|
|
184
|
+
|
|
185
|
+
class Task(TenantModel):
|
|
186
|
+
project = models.ForeignKey(Project, on_delete=models.CASCADE)
|
|
187
|
+
title = models.CharField(max_length=200)
|
|
188
|
+
completed = models.BooleanField(default=False)
|
|
189
|
+
|
|
190
|
+
# settings.py
|
|
191
|
+
BOUNDARY_TENANT_MODEL = "core.Workspace"
|
|
192
|
+
BOUNDARY_RESOLVERS = ["boundary.resolvers.SubdomainResolver"]
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
```python
|
|
196
|
+
# In a view — no tenant filtering needed, it's automatic
|
|
197
|
+
def dashboard(request):
|
|
198
|
+
projects = Project.objects.all() # only this workspace's projects
|
|
199
|
+
tasks = Task.objects.filter(completed=False) # only this workspace's tasks
|
|
200
|
+
return render(request, "dashboard.html", {"projects": projects, "tasks": tasks})
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
### API with JWT-based tenancy
|
|
204
|
+
|
|
205
|
+
A React/mobile frontend sends a JWT containing the tenant ID. Useful for
|
|
206
|
+
single-page apps where subdomains aren't practical.
|
|
207
|
+
|
|
208
|
+
```python
|
|
209
|
+
# settings.py
|
|
210
|
+
BOUNDARY_TENANT_MODEL = "accounts.Account"
|
|
211
|
+
BOUNDARY_RESOLVERS = [
|
|
212
|
+
"boundary.resolvers.JWTClaimResolver", # reads tenant_id from JWT
|
|
213
|
+
]
|
|
214
|
+
BOUNDARY_JWT_CLAIM = "org_id" # custom claim name
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
The JWT is validated by your auth middleware (DRF, django-allauth, etc.).
|
|
218
|
+
Boundary only reads the claim — it never validates signatures.
|
|
219
|
+
|
|
220
|
+
### Marketplace with seller isolation
|
|
221
|
+
|
|
222
|
+
Sellers manage their own products, orders, and inventory. Platform admins
|
|
223
|
+
see everything via the `unscoped` manager.
|
|
224
|
+
|
|
225
|
+
```python
|
|
226
|
+
class Seller(AbstractTenant):
|
|
227
|
+
contact_email = models.EmailField()
|
|
228
|
+
stripe_account_id = models.CharField(max_length=100, blank=True)
|
|
229
|
+
|
|
230
|
+
class Product(TenantModel):
|
|
231
|
+
name = models.CharField(max_length=200)
|
|
232
|
+
price = models.DecimalField(max_digits=10, decimal_places=2)
|
|
233
|
+
|
|
234
|
+
class Order(TenantModel):
|
|
235
|
+
product = models.ForeignKey(Product, on_delete=models.PROTECT)
|
|
236
|
+
quantity = models.IntegerField()
|
|
237
|
+
|
|
238
|
+
# settings.py
|
|
239
|
+
BOUNDARY_TENANT_MODEL = "sellers.Seller"
|
|
240
|
+
BOUNDARY_RESOLVERS = [
|
|
241
|
+
"boundary.resolvers.HeaderResolver", # internal API, trusted clients
|
|
242
|
+
]
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
```python
|
|
246
|
+
# Seller's view — only sees their own products
|
|
247
|
+
def my_products(request):
|
|
248
|
+
return Product.objects.all()
|
|
249
|
+
|
|
250
|
+
# Admin analytics — sees all sellers
|
|
251
|
+
def platform_revenue():
|
|
252
|
+
return Order.unscoped.aggregate(total=Sum("product__price"))
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
### Multi-region with data residency
|
|
256
|
+
|
|
257
|
+
UK customer data must stay in the UK database; EU data in the EU database.
|
|
258
|
+
|
|
259
|
+
```python
|
|
260
|
+
# settings.py
|
|
261
|
+
BOUNDARY_TENANT_MODEL = "orgs.Organisation"
|
|
262
|
+
BOUNDARY_REGIONS = {
|
|
263
|
+
"uk": {"ENGINE": "django.db.backends.postgresql", "HOST": "uk.db.example.com", ...},
|
|
264
|
+
"eu-west": {"ENGINE": "django.db.backends.postgresql", "HOST": "eu.db.example.com", ...},
|
|
265
|
+
"us-east": {"ENGINE": "django.db.backends.postgresql", "HOST": "us.db.example.com", ...},
|
|
266
|
+
}
|
|
267
|
+
DATABASE_ROUTERS = ["boundary.routing.RegionalRouter"]
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
```python
|
|
271
|
+
# Tenant has region="uk" — all queries automatically hit the UK database
|
|
272
|
+
with TenantContext.using(uk_tenant):
|
|
273
|
+
Patient.objects.create(name="Smith", nhs_number="123") # stored in UK DB
|
|
274
|
+
|
|
275
|
+
# Platform-wide reporting across all regions
|
|
276
|
+
from boundary.routing import all_regions
|
|
277
|
+
with all_regions() as aliases:
|
|
278
|
+
for alias in aliases:
|
|
279
|
+
count = Patient.objects.using(alias).count()
|
|
280
|
+
print(f"{alias}: {count} patients")
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
### Internal tool with session-based switching
|
|
284
|
+
|
|
285
|
+
Staff users switch between departments via a dropdown. The selected
|
|
286
|
+
department is stored in the session.
|
|
287
|
+
|
|
288
|
+
```python
|
|
289
|
+
# settings.py
|
|
290
|
+
BOUNDARY_TENANT_MODEL = "departments.Department"
|
|
291
|
+
BOUNDARY_REQUIRED = False # allow unauthenticated pages
|
|
292
|
+
BOUNDARY_RESOLVERS = [
|
|
293
|
+
"boundary.resolvers.SessionResolver",
|
|
294
|
+
]
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
```python
|
|
298
|
+
# Switch department view
|
|
299
|
+
def switch_department(request, dept_id):
|
|
300
|
+
dept = Department.objects.get(pk=dept_id)
|
|
301
|
+
request.session["boundary_tenant_id"] = str(dept.pk)
|
|
302
|
+
return redirect("dashboard")
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
---
|
|
306
|
+
|
|
307
|
+
## How It Works
|
|
308
|
+
|
|
309
|
+
### Architecture
|
|
310
|
+
|
|
311
|
+
```
|
|
312
|
+
HTTP Request / Celery Task / Management Command
|
|
313
|
+
|
|
|
314
|
+
v
|
|
315
|
+
RESOLUTION LAYER — TenantMiddleware + pluggable Resolvers
|
|
316
|
+
|
|
|
317
|
+
v
|
|
318
|
+
CONTEXT LAYER — TenantContext (ContextVar + DB session variable)
|
|
319
|
+
|
|
|
320
|
+
v
|
|
321
|
+
ORM LAYER — TenantManager auto-filters every queryset
|
|
322
|
+
|
|
|
323
|
+
v
|
|
324
|
+
ROUTING LAYER (optional) — RegionalRouter per-tenant DB alias
|
|
325
|
+
|
|
|
326
|
+
v
|
|
327
|
+
DATABASE LAYER — PostgreSQL RLS policies (defence in depth)
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
### Defence in Depth
|
|
331
|
+
|
|
332
|
+
Two independent layers enforce tenant isolation:
|
|
333
|
+
|
|
334
|
+
1. **ORM layer** — `TenantManager` filters every queryset by the active tenant.
|
|
335
|
+
This catches standard Django ORM usage.
|
|
336
|
+
2. **PostgreSQL RLS** — Row Level Security policies enforce isolation at the
|
|
337
|
+
database level, catching raw SQL, third-party packages, and ORM bugs.
|
|
338
|
+
|
|
339
|
+
A bug in one layer is caught by the other.
|
|
340
|
+
|
|
341
|
+
---
|
|
342
|
+
|
|
343
|
+
## Models
|
|
344
|
+
|
|
345
|
+
### AbstractTenant
|
|
346
|
+
|
|
347
|
+
Convenience base for your tenant model. Provides common fields:
|
|
348
|
+
|
|
349
|
+
| Field | Type | Description |
|
|
350
|
+
|-------|------|-------------|
|
|
351
|
+
| `name` | CharField(200) | Tenant name |
|
|
352
|
+
| `slug` | SlugField(unique) | URL-safe identifier |
|
|
353
|
+
| `region` | CharField(50) | Regional routing key (blank if single-region) |
|
|
354
|
+
| `is_active` | BooleanField | Inactive tenants are rejected by middleware (403) |
|
|
355
|
+
| `created_at` | DateTimeField | Auto-set on creation |
|
|
356
|
+
| `updated_at` | DateTimeField | Auto-set on save |
|
|
357
|
+
|
|
358
|
+
### TenantModel / TenantMixin
|
|
359
|
+
|
|
360
|
+
Base class for tenant-scoped data models. Adds:
|
|
361
|
+
|
|
362
|
+
- `tenant` ForeignKey to your tenant model (CASCADE, non-nullable)
|
|
363
|
+
- `objects` — `TenantManager` that auto-filters by active tenant
|
|
364
|
+
- `unscoped` — plain `Manager` for cross-tenant operations (admin, analytics)
|
|
365
|
+
|
|
366
|
+
```python
|
|
367
|
+
class Booking(TenantModel):
|
|
368
|
+
court = models.IntegerField()
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
**Auto-populate on save:** When no `tenant` is set explicitly,
|
|
372
|
+
`TenantModel.save()` reads from `TenantContext` automatically.
|
|
373
|
+
|
|
374
|
+
**Bulk operations:**
|
|
375
|
+
- `bulk_create()` — auto-populates tenant on objects where `tenant_id` is None
|
|
376
|
+
- `bulk_update()` — validates all objects belong to the active tenant
|
|
377
|
+
|
|
378
|
+
---
|
|
379
|
+
|
|
380
|
+
## Context
|
|
381
|
+
|
|
382
|
+
### TenantContext
|
|
383
|
+
|
|
384
|
+
The core API for tenant context management:
|
|
385
|
+
|
|
386
|
+
```python
|
|
387
|
+
from boundary.context import TenantContext
|
|
388
|
+
|
|
389
|
+
# Set and get
|
|
390
|
+
token = TenantContext.set(tenant)
|
|
391
|
+
tenant = TenantContext.get() # returns tenant or None
|
|
392
|
+
tenant = TenantContext.require() # returns tenant or raises TenantNotSetError
|
|
393
|
+
TenantContext.clear(token)
|
|
394
|
+
|
|
395
|
+
# Context manager (recommended)
|
|
396
|
+
with TenantContext.using(tenant):
|
|
397
|
+
Booking.objects.all() # filtered to this tenant
|
|
398
|
+
# Context automatically restored on exit
|
|
399
|
+
```
|
|
400
|
+
|
|
401
|
+
The context manager is savepoint-safe: it explicitly restores the DB session
|
|
402
|
+
variable on exit rather than relying on PostgreSQL savepoint rollback.
|
|
403
|
+
|
|
404
|
+
---
|
|
405
|
+
|
|
406
|
+
## Resolvers
|
|
407
|
+
|
|
408
|
+
Resolvers determine which tenant applies to an incoming request. Configure
|
|
409
|
+
via `BOUNDARY_RESOLVERS` — first match wins.
|
|
410
|
+
|
|
411
|
+
| Resolver | Source | Setting |
|
|
412
|
+
|----------|--------|---------|
|
|
413
|
+
| `SubdomainResolver` | `club.example.com` -> slug lookup | `BOUNDARY_SUBDOMAIN_FIELD` |
|
|
414
|
+
| `HeaderResolver` | `X-Tenant-ID` header (UUID first, slug fallback) | `BOUNDARY_HEADER_NAME` |
|
|
415
|
+
| `JWTClaimResolver` | JWT payload claim (no signature validation) | `BOUNDARY_JWT_CLAIM` |
|
|
416
|
+
| `SessionResolver` | Django session key | `BOUNDARY_SESSION_KEY` |
|
|
417
|
+
| `ExplicitResolver` | `request.boundary_tenant` set by upstream code | None |
|
|
418
|
+
|
|
419
|
+
**Security note:** Resolver ordering determines precedence. Placing
|
|
420
|
+
`HeaderResolver` first allows any HTTP client to set the tenant via header.
|
|
421
|
+
For public-facing apps, place `SubdomainResolver` first.
|
|
422
|
+
|
|
423
|
+
### Custom resolvers
|
|
424
|
+
|
|
425
|
+
```python
|
|
426
|
+
from boundary.resolvers import BaseResolver
|
|
427
|
+
|
|
428
|
+
class PathResolver(BaseResolver):
|
|
429
|
+
def resolve(self, request):
|
|
430
|
+
parts = request.path.split("/")
|
|
431
|
+
if len(parts) >= 3 and parts[1] == "t":
|
|
432
|
+
TenantModel = self.get_tenant_model()
|
|
433
|
+
try:
|
|
434
|
+
return TenantModel.objects.get(slug=parts[2], is_active=True)
|
|
435
|
+
except TenantModel.DoesNotExist:
|
|
436
|
+
return None
|
|
437
|
+
return None
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
### Resolver cache
|
|
441
|
+
|
|
442
|
+
Resolvers that perform DB lookups cache results in a process-local LRU cache.
|
|
443
|
+
Cache is invalidated automatically on tenant save/delete via Django signals,
|
|
444
|
+
and by TTL (default: 60 seconds).
|
|
445
|
+
|
|
446
|
+
---
|
|
447
|
+
|
|
448
|
+
## Row Level Security
|
|
449
|
+
|
|
450
|
+
RLS provides database-level enforcement independent of application code.
|
|
451
|
+
|
|
452
|
+
### Migration operations
|
|
453
|
+
|
|
454
|
+
```python
|
|
455
|
+
# In your migration file
|
|
456
|
+
from boundary.migrations_ops import EnableRLS, CreateTenantPolicy
|
|
457
|
+
|
|
458
|
+
class Migration(migrations.Migration):
|
|
459
|
+
operations = [
|
|
460
|
+
migrations.CreateModel(name="Booking", ...),
|
|
461
|
+
EnableRLS("Booking"),
|
|
462
|
+
CreateTenantPolicy("Booking"),
|
|
463
|
+
]
|
|
464
|
+
```
|
|
465
|
+
|
|
466
|
+
`CreateTenantPolicy` generates:
|
|
467
|
+
- A `LEAKPROOF` helper function (`boundary_current_tenant_id()`) that safely
|
|
468
|
+
casts the session variable to the correct type
|
|
469
|
+
- An isolation policy with `USING` + `WITH CHECK` (enforces on SELECT, INSERT,
|
|
470
|
+
UPDATE, DELETE)
|
|
471
|
+
- An admin bypass policy for management commands
|
|
472
|
+
|
|
473
|
+
### Type-aware
|
|
474
|
+
|
|
475
|
+
The RLS function detects whether your tenant model uses UUID or integer primary
|
|
476
|
+
keys and generates the appropriate type cast.
|
|
477
|
+
|
|
478
|
+
### Reversible
|
|
479
|
+
|
|
480
|
+
All operations are fully reversible via `migrate --reverse`.
|
|
481
|
+
|
|
482
|
+
---
|
|
483
|
+
|
|
484
|
+
## Regional Routing
|
|
485
|
+
|
|
486
|
+
Route queries to geographically distinct databases for data residency compliance.
|
|
487
|
+
|
|
488
|
+
```python
|
|
489
|
+
# settings.py
|
|
490
|
+
BOUNDARY_REGIONS = {
|
|
491
|
+
"eu-west": {"ENGINE": "django.db.backends.postgresql", "HOST": "eu.db.example.com", ...},
|
|
492
|
+
"us": {"ENGINE": "django.db.backends.postgresql", "HOST": "us.db.example.com", ...},
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
DATABASE_ROUTERS = ["boundary.routing.RegionalRouter"]
|
|
496
|
+
```
|
|
497
|
+
|
|
498
|
+
Tenant-scoped queries are routed to the tenant's region. Non-tenant models
|
|
499
|
+
(auth, sessions, etc.) always route to `default`.
|
|
500
|
+
|
|
501
|
+
```python
|
|
502
|
+
from boundary.routing import all_regions, specific_region
|
|
503
|
+
|
|
504
|
+
# Iterate all regions
|
|
505
|
+
with all_regions() as aliases:
|
|
506
|
+
for alias in aliases:
|
|
507
|
+
count = Booking.objects.using(alias).count()
|
|
508
|
+
|
|
509
|
+
# Pin to a specific region
|
|
510
|
+
with specific_region("eu-west"):
|
|
511
|
+
bookings = Booking.objects.all()
|
|
512
|
+
```
|
|
513
|
+
|
|
514
|
+
---
|
|
515
|
+
|
|
516
|
+
## Celery Integration
|
|
517
|
+
|
|
518
|
+
Tenant context is propagated to Celery tasks via headers.
|
|
519
|
+
|
|
520
|
+
```python
|
|
521
|
+
from boundary.celery import tenant_task
|
|
522
|
+
|
|
523
|
+
@app.task
|
|
524
|
+
@tenant_task
|
|
525
|
+
def send_confirmation(booking_id):
|
|
526
|
+
# TenantContext.get() returns the correct tenant
|
|
527
|
+
booking = Booking.objects.get(id=booking_id)
|
|
528
|
+
```
|
|
529
|
+
|
|
530
|
+
For class-based tasks:
|
|
531
|
+
|
|
532
|
+
```python
|
|
533
|
+
from boundary.celery import TenantTask
|
|
534
|
+
|
|
535
|
+
class GenerateReport(TenantTask, app.Task):
|
|
536
|
+
def run(self, report_id):
|
|
537
|
+
...
|
|
538
|
+
```
|
|
539
|
+
|
|
540
|
+
---
|
|
541
|
+
|
|
542
|
+
## Management Commands
|
|
543
|
+
|
|
544
|
+
### boundary_provision
|
|
545
|
+
|
|
546
|
+
```bash
|
|
547
|
+
python manage.py boundary_provision --name "Club A" --slug "club-a" --region eu-west
|
|
548
|
+
# Outputs: the new tenant's PK
|
|
549
|
+
```
|
|
550
|
+
|
|
551
|
+
### boundary_deprovision
|
|
552
|
+
|
|
553
|
+
```bash
|
|
554
|
+
python manage.py boundary_deprovision --tenant club-a --export data.ndjson --yes
|
|
555
|
+
# Streams tenant data to NDJSON, then deletes
|
|
556
|
+
```
|
|
557
|
+
|
|
558
|
+
Supports `--dry-run`, `--batch-size`, `--yes` (skip confirmation).
|
|
559
|
+
|
|
560
|
+
### boundary_run
|
|
561
|
+
|
|
562
|
+
```bash
|
|
563
|
+
python manage.py boundary_run --tenant club-a send_reminders
|
|
564
|
+
# Runs send_reminders with tenant context active
|
|
565
|
+
```
|
|
566
|
+
|
|
567
|
+
### boundary_run_all
|
|
568
|
+
|
|
569
|
+
```bash
|
|
570
|
+
python manage.py boundary_run_all send_reminders --parallel 4 --region eu-west --json
|
|
571
|
+
# Runs against all active tenants, 4 workers, EU only, NDJSON output
|
|
572
|
+
```
|
|
573
|
+
|
|
574
|
+
---
|
|
575
|
+
|
|
576
|
+
## Settings Reference
|
|
577
|
+
|
|
578
|
+
| Setting | Default | Description |
|
|
579
|
+
|---------|---------|-------------|
|
|
580
|
+
| `BOUNDARY_TENANT_MODEL` | **Required** | Dotted path to tenant model, e.g. `"tenants.Organisation"` |
|
|
581
|
+
| `BOUNDARY_STRICT_MODE` | `True` | Raise `TenantNotSetError` on unscoped queries |
|
|
582
|
+
| `BOUNDARY_REQUIRED` | `True` | Return 404 if no resolver matches |
|
|
583
|
+
| `BOUNDARY_RESOLVERS` | `["...SubdomainResolver"]` | Ordered resolver class paths |
|
|
584
|
+
| `BOUNDARY_SUBDOMAIN_FIELD` | `"slug"` | Tenant field for subdomain lookup |
|
|
585
|
+
| `BOUNDARY_HEADER_NAME` | `"X-Tenant-ID"` | HTTP header for HeaderResolver |
|
|
586
|
+
| `BOUNDARY_JWT_CLAIM` | `"tenant_id"` | JWT payload claim |
|
|
587
|
+
| `BOUNDARY_SESSION_KEY` | `"boundary_tenant_id"` | Session key for SessionResolver |
|
|
588
|
+
| `BOUNDARY_REGIONS` | `None` | Regional DB configs (activates routing) |
|
|
589
|
+
| `BOUNDARY_REGION_FIELD` | `"region"` | Tenant field storing region key |
|
|
590
|
+
| `BOUNDARY_DB_SESSION_VAR` | `"app.current_tenant_id"` | PostgreSQL session variable |
|
|
591
|
+
| `BOUNDARY_WRAP_ATOMIC` | `True` | Wrap requests in `transaction.atomic()` |
|
|
592
|
+
| `BOUNDARY_RESOLVER_CACHE_SIZE` | `1000` | LRU cache max entries |
|
|
593
|
+
| `BOUNDARY_RESOLVER_CACHE_TTL` | `60` | Cache TTL in seconds |
|
|
594
|
+
| `BOUNDARY_POST_PROVISION_HOOK` | `None` | Callable after tenant provisioning |
|
|
595
|
+
| `BOUNDARY_PRE_DEPROVISION_HOOK` | `None` | Callable before tenant deletion |
|
|
596
|
+
|
|
597
|
+
---
|
|
598
|
+
|
|
599
|
+
## System Checks
|
|
600
|
+
|
|
601
|
+
| ID | Severity | Condition |
|
|
602
|
+
|----|----------|-----------|
|
|
603
|
+
| `boundary.E001` | Error | `BOUNDARY_TENANT_MODEL` missing or invalid |
|
|
604
|
+
| `boundary.E003` | Error | Resolver class cannot be imported |
|
|
605
|
+
| `boundary.E004` | Error | TenantMiddleware not in MIDDLEWARE |
|
|
606
|
+
| `boundary.E005` | Error | BOUNDARY_REGIONS set but RegionalRouter not in DATABASE_ROUTERS |
|
|
607
|
+
| `boundary.E006` | Error | TenantModel table missing RLS (queries pg_class at startup) |
|
|
608
|
+
| `boundary.W001` | Warning | STRICT_MODE is False |
|
|
609
|
+
|
|
610
|
+
---
|
|
611
|
+
|
|
612
|
+
## Testing
|
|
613
|
+
|
|
614
|
+
### In your tests
|
|
615
|
+
|
|
616
|
+
```python
|
|
617
|
+
from boundary.testing import set_tenant, tenant_factory, TenantTestMixin
|
|
618
|
+
|
|
619
|
+
# Context manager
|
|
620
|
+
def test_isolation():
|
|
621
|
+
tenant_a = tenant_factory(name="A", slug="a")
|
|
622
|
+
tenant_b = tenant_factory(name="B", slug="b")
|
|
623
|
+
|
|
624
|
+
with set_tenant(tenant_a):
|
|
625
|
+
Booking.objects.create(court=1)
|
|
626
|
+
|
|
627
|
+
with set_tenant(tenant_b):
|
|
628
|
+
assert Booking.objects.count() == 0 # tenant_b sees nothing
|
|
629
|
+
|
|
630
|
+
# Mixin for TestCase
|
|
631
|
+
class BookingTests(TenantTestMixin, TestCase):
|
|
632
|
+
def test_auto_populate(self):
|
|
633
|
+
booking = Booking.objects.create(court=1)
|
|
634
|
+
assert booking.tenant == self.tenant
|
|
635
|
+
```
|
|
636
|
+
|
|
637
|
+
### Unscoped operations
|
|
638
|
+
|
|
639
|
+
```python
|
|
640
|
+
# Cross-tenant admin/analytics queries
|
|
641
|
+
all_bookings = Booking.unscoped.all()
|
|
642
|
+
|
|
643
|
+
# Explicitly set tenant on unscoped create
|
|
644
|
+
Booking.unscoped.create(court=1, tenant=specific_tenant)
|
|
645
|
+
```
|
|
646
|
+
|
|
647
|
+
---
|
|
648
|
+
|
|
649
|
+
## Signals
|
|
650
|
+
|
|
651
|
+
| Signal | Arguments | Fired when |
|
|
652
|
+
|--------|-----------|------------|
|
|
653
|
+
| `tenant_resolved` | `tenant, resolver, request` | After successful resolution |
|
|
654
|
+
| `tenant_resolution_failed` | `request` | No resolver matched (REQUIRED=True) |
|
|
655
|
+
| `strict_mode_violation` | `model, queryset` | Before TenantNotSetError is raised |
|
|
656
|
+
|
|
657
|
+
---
|
|
658
|
+
|
|
659
|
+
## Requirements
|
|
660
|
+
|
|
661
|
+
- Python 3.10+
|
|
662
|
+
- Django 5.1+
|
|
663
|
+
- PostgreSQL 14+ (for RLS; ORM layer works with any database)
|
|
664
|
+
|
|
665
|
+
---
|
|
666
|
+
|
|
667
|
+
## Comparison with django-tenants
|
|
668
|
+
|
|
669
|
+
| | django-tenants | django-boundary |
|
|
670
|
+
|-|---------------|-----------------|
|
|
671
|
+
| Isolation | PostgreSQL schemas | Row-level + RLS |
|
|
672
|
+
| Scale ceiling | ~500 tenants | No architectural ceiling |
|
|
673
|
+
| Migration cost | O(n tenants) | O(1) |
|
|
674
|
+
| Async support | Thread-local (breaks async) | contextvars (native async) |
|
|
675
|
+
| Celery | Manual | Automatic via headers |
|
|
676
|
+
| Regional routing | Not supported | First-class |
|
|
677
|
+
| Dev enforcement | None | STRICT_MODE |
|
|
678
|
+
|
|
679
|
+
---
|
|
680
|
+
|
|
681
|
+
## Licence
|
|
682
|
+
|
|
683
|
+
MIT
|