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.
Files changed (41) hide show
  1. django_boundary-0.1.0/LICENSE +21 -0
  2. django_boundary-0.1.0/PKG-INFO +683 -0
  3. django_boundary-0.1.0/README.md +651 -0
  4. django_boundary-0.1.0/pyproject.toml +46 -0
  5. django_boundary-0.1.0/setup.cfg +4 -0
  6. django_boundary-0.1.0/src/boundary/__init__.py +4 -0
  7. django_boundary-0.1.0/src/boundary/apps.py +36 -0
  8. django_boundary-0.1.0/src/boundary/celery.py +123 -0
  9. django_boundary-0.1.0/src/boundary/checks.py +155 -0
  10. django_boundary-0.1.0/src/boundary/conf.py +100 -0
  11. django_boundary-0.1.0/src/boundary/context.py +131 -0
  12. django_boundary-0.1.0/src/boundary/exceptions.py +29 -0
  13. django_boundary-0.1.0/src/boundary/management/__init__.py +0 -0
  14. django_boundary-0.1.0/src/boundary/management/commands/__init__.py +0 -0
  15. django_boundary-0.1.0/src/boundary/management/commands/boundary_deprovision.py +113 -0
  16. django_boundary-0.1.0/src/boundary/management/commands/boundary_provision.py +48 -0
  17. django_boundary-0.1.0/src/boundary/management/commands/boundary_run.py +45 -0
  18. django_boundary-0.1.0/src/boundary/management/commands/boundary_run_all.py +127 -0
  19. django_boundary-0.1.0/src/boundary/middleware.py +108 -0
  20. django_boundary-0.1.0/src/boundary/migrations/__init__.py +0 -0
  21. django_boundary-0.1.0/src/boundary/migrations_ops.py +181 -0
  22. django_boundary-0.1.0/src/boundary/models.py +153 -0
  23. django_boundary-0.1.0/src/boundary/resolvers.py +226 -0
  24. django_boundary-0.1.0/src/boundary/routing.py +114 -0
  25. django_boundary-0.1.0/src/boundary/signals.py +19 -0
  26. django_boundary-0.1.0/src/boundary/testing.py +71 -0
  27. django_boundary-0.1.0/src/django_boundary.egg-info/PKG-INFO +683 -0
  28. django_boundary-0.1.0/src/django_boundary.egg-info/SOURCES.txt +39 -0
  29. django_boundary-0.1.0/src/django_boundary.egg-info/dependency_links.txt +1 -0
  30. django_boundary-0.1.0/src/django_boundary.egg-info/requires.txt +11 -0
  31. django_boundary-0.1.0/src/django_boundary.egg-info/top_level.txt +1 -0
  32. django_boundary-0.1.0/tests/test_celery.py +87 -0
  33. django_boundary-0.1.0/tests/test_checks.py +50 -0
  34. django_boundary-0.1.0/tests/test_commands.py +189 -0
  35. django_boundary-0.1.0/tests/test_context.py +133 -0
  36. django_boundary-0.1.0/tests/test_middleware.py +184 -0
  37. django_boundary-0.1.0/tests/test_models.py +202 -0
  38. django_boundary-0.1.0/tests/test_resolvers.py +196 -0
  39. django_boundary-0.1.0/tests/test_rls.py +330 -0
  40. django_boundary-0.1.0/tests/test_routing.py +125 -0
  41. 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
+ [![CI](https://github.com/nigelcopley/icv-oss/actions/workflows/ci.yml/badge.svg)](https://github.com/nigelcopley/icv-oss/actions/workflows/ci.yml)
36
+ [![PyPI version](https://img.shields.io/pypi/v/django-boundary.svg)](https://pypi.org/project/django-boundary/)
37
+ [![Python versions](https://img.shields.io/pypi/pyversions/django-boundary.svg)](https://pypi.org/project/django-boundary/)
38
+ [![Django versions](https://img.shields.io/pypi/djversions/django-boundary.svg)](https://pypi.org/project/django-boundary/)
39
+ [![Licence: MIT](https://img.shields.io/badge/Licence-MIT-blue.svg)](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