ArchitectureMulti-tenancy

Multi-tenancy

One-line: one database, one app, many schools — isolation is enforced by a tenant_id on every business row and automatic query scoping, backed by defense-in-depth against cache leaks.

The model

NexSchool is single-database, shared-schema multi-tenancy. Every tenant-scoped table carries a tenant_id. There is no per-tenant database or schema — isolation is enforced in the application layer, so it must be enforced consistently and by default, not per-query.

  • Tenant-scoped models inherit TenantBaseModel (adds tenant_id, created_at, updated_at).
  • Unique constraints are scoped per tenant — e.g. UNIQUE(email, tenant_id), so two schools can both have admin@school.com.
  • Soft deletes use deleted_at; queries filter deleted_at IS NULL by default.

Tenant resolution

A @before_request hook (core/tenant.py) resolves the tenant for every /api/* route (except health, OPTIONS, platform routes) and sets g.tenant_id. Priority:

1. body tenant_id        4. X-Tenant-Subdomain header (admin-web sends this automatically)
2. body subdomain        5. Host header subdomain  (mts.nexchool.in → "mts")
3. X-Tenant-ID header    6. DEFAULT_TENANT_SUBDOMAIN env (dev fallback)

Suspended tenant → 403; unknown → 404. /api/platform/* routes bypass all of this and require a platform-admin JWT.

Automatic query scoping — and the pagination trap

Scoping is applied automatically in core/database.py so services don’t have to remember to filter by tenant_id on every query.

🚫

Historical bug worth knowing (fixed): an earlier before_compile scoping hook could drop its WHERE tenant_id = … clause under LIMIT/OFFSET, so every paginated list endpoint leaked cross-tenant rows. The fix moved scoping to SQLAlchemy’s with_loader_criteria, which survives pagination. This is exactly the kind of failure a single-DB tenancy model must guard against — see ADR-001.

Defense-in-depth (belts and suspenders)

Automatic scoping is the primary control. Around it:

On the API side

  • Every /api/* response sets Cache-Control: no-store, private, max-age=0 and Vary: X-Tenant-ID, X-Tenant-Subdomain, Authorization — so no shared cache can serve one tenant’s response to another.
  • Branch-scoped sub-admins get a second layer (core/branch_scope.py) that fails closed. See Auth & RBAC.

On the client side (see the query conventions rule)

  • fetch uses cache: "no-store" — the browser HTTP cache never crosses tenants.
  • Every tenant-scoped TanStack Query key ends with tenantId — two tenants never share a cache entry.
  • Queries are enabled: false until tenantId is known — no fetch fires before tenant context is ready.
  • Login/logout call queryClient.clear() — a session change never reuses the prior cache.
  • Mutations invalidate by prefix (keys.all), which matches across tenant scopes because tenantId is the last key segment.

Two questions that settle “is this tenant-scoped?”

  1. Would two different schools ever expect different data from this URL? If yes → scope it.
  2. Pre-auth public endpoints (/api/auth/login, /api/auth/tenant-branding) and platform endpoints (/api/platform/*) are not tenant-scoped.