Multi-tenancy
One-line: one database, one app, many schools — isolation is enforced by a
tenant_idon 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(addstenant_id,created_at,updated_at). - Unique constraints are scoped per tenant — e.g.
UNIQUE(email, tenant_id), so two schools can both haveadmin@school.com. - Soft deletes use
deleted_at; queries filterdeleted_at IS NULLby 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 setsCache-Control: no-store, private, max-age=0andVary: 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)
fetchusescache: "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: falseuntiltenantIdis 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 becausetenantIdis the last key segment.
Two questions that settle “is this tenant-scoped?”
- Would two different schools ever expect different data from this URL? If yes → scope it.
- Pre-auth public endpoints (
/api/auth/login,/api/auth/tenant-branding) and platform endpoints (/api/platform/*) are not tenant-scoped.