Multi-Tenancy Architecture¶
TruLoad is a multi-tenant platform. Each tenant maps to a single organisation (weighbridge operator or transporter). The backend uses two isolation strategies depending on the tenant.
Isolation Strategies¶
| Tenant Type | Strategy | Example |
|---|---|---|
| Dedicated database | Each tenant gets its own PostgreSQL database | kura → kuraweigh |
| Shared database | All other tenants share the truload database, filtered by organisation_id |
Default |
How Tenant Routing Works¶
Request Resolution¶
The TenantContextMiddleware runs on every request and populates ITenantContext with the resolved organisation code and station ID. Resolution order:
X-Tenant-Slugheader (set by nginx or load balancer for subdomain routing)- JWT claim
org_code(set at login time) - Default tenant (platform admin fallback)
Database Connection Selection¶
TenantConnectionStringProvider holds a map of slug → connectionString loaded from configuration. The DbContext factory (scoped, registered in DI) calls Resolve(tenantSlug) on every request:
kura → TENANTDATABASES__KURA env var → kuraweigh database
truload, (default) → DefaultConnection → truload database
Configuration¶
Development (appsettings.Development.json):
"TenantDatabases": {
"kura": "Host=localhost;Database=kuraweigh;..."
}
Production (K8s environment variable):
TENANTDATABASES__KURA=Host=pg-host;Database=kuraweigh;Username=...;Password=...
Environment Switching (X-Env)¶
Some tenants (such as KURA) operate two frontend domains — one for live operations and one for testing — both pointing at the same backend. The X-Env HTTP header lets the frontend signal which database to use for the request.
How it works¶
- The frontend (
src/lib/api/client.ts) addsX-Env: liveorX-Env: testto every API request based on the current hostname: kuraweigh.kura.go.ke→X-Env: livekuraweightest.masterspace.co.ke→X-Env: test-
localhost/ TruLoad SaaS domains →X-Env: test -
TenantContextMiddlewarereads the header (or auto-detects from theOrigin/Hostheader) and setsITenantContext.IsTestMode. -
TenantConnectionStringProvider.Resolve(tenantSlug, isTestMode)short-circuits to the defaulttruloaddatabase whenisTestMode == true, regardless of the tenant slug. This means a KURA user on the test domain always hits the shared truload DB, not the dedicated kuraweigh DB.
CORS configuration¶
The NGINX ingress cors-allow-headers annotation must include X-Env so browsers do not block the header on cross-origin requests. This is configured in devops-k8s/apps/truload-backend/values.yaml.
Security note¶
X-Env: test only bypasses the dedicated tenant DB selection — it does NOT bypass authentication, authorization, or subscription enforcement. A valid JWT is still required for every request.
Startup: Auto-Migration¶
On startup, the app automatically migrates and seeds every configured database:
// Program.cs
// 1. Migrate + seed the default (truload) database
await ApplyMigrationsAsync(defaultMigCs, "truload");
await SeedDatabaseAsync(defaultMigCs, "truload");
// 2. For each dedicated tenant database
foreach (var (slug, tenantCs) in tenantConnProvider.GetDedicatedTenantDatabases())
{
await ApplyMigrationsAsync(tenantMigCs, slug);
await SeedDatabaseAsync(tenantMigCs, slug);
}
A failed tenant DB migration logs the error and continues — it does not block the whole application startup.
This is not as safe as it sounds
A swallowed migration failure leaves the pod reporting healthy while the schema underneath it is
NOT updated. Worse, because EF applies migrations as one ordered batch, the failure also blocks
every later migration and every seeder queued after it in the same startup run — not just the
one migration that failed. The health check only proves the app booted; it does not prove the
schema is current. This was a real incident, not a hypothetical: see the commercial-weighing
audit initiative's "CRITICAL PATH" section, Blocker 1 — a table ownership mismatch
(commercial_tariff_rules owned by the wrong Postgres role) made one migration fail on the
shared truload database, which silently blocked every migration and seeder after it, while CI
and the live health check both stayed green. Confirming a deploy actually landed requires a
direct query against __EFMigrationsHistory (or the target table/column itself) — a green
health check alone does not prove it.
Row-Level Security (Shared Database)¶
For shared-database tenants, isolation is enforced at the ORM level. All models inheriting from TenantAwareEntity carry an OrganisationId foreign key. EF Core global query filters ensure tenants never see each other's data:
// Applied automatically by DbContext — every LINQ query gets a WHERE clause
modelBuilder.Entity<WeighingTransaction>()
.HasQueryFilter(e => e.OrganizationId == _tenantContext.OrganizationId);
IgnoreQueryFilters() is used only in specific service methods that need cross-tenant access (e.g., portal team membership lookup, background jobs).
Adding a New Dedicated Tenant¶
- Create the PostgreSQL database on the shared instance.
- Add the connection string to the K8s secret:
TENANTDATABASES__<SLUG>=Host=...;Database=...;Username=...;Password=... - Restart the pod — migrations and seeding run automatically.
- Create the organisation record in the
truloadadmin database linking to the new tenant slug.
Subscription Enforcement and Bypass Rules¶
SubscriptionEnforcementMiddleware runs after authentication and checks whether a commercial tenant (TenantType == "CommercialWeighing") has an active subscription before allowing the request.
Enforcement Flow¶
authenticated request
→ check billing_mode JWT claim == "service_charge" → bypass ✅
→ check is_demo JWT claim == "true" → bypass ✅
→ skip exempt paths (/auth, /portal, /health, /hangfire)
→ read Redis: sub:status:{orgId}
cache hit → ACTIVE / TRIAL → pass through ✅
→ EXPIRED / CANCELLED / NONE → 402 ❌
cache miss → load org from DB
org not found / not CommercialWeighing → pass through ✅
org.BillingMode == "service_charge" → bypass ✅
org.IsDemo == true → bypass ✅
org.SsoTenantSlug empty → pass through ✅ (misconfigured)
call subscriptions-api → cache 60 s → enforce
Bypass Modes¶
| Mode | How to configure | Effect |
|---|---|---|
| Service charge | Set Organization.BillingMode = "service_charge" |
Org pays per-transaction via treasury; subscription gating is completely bypassed |
| Demo | Set Organization.IsDemo = true |
Demo/training org; subscription gating is completely bypassed |
| Non-commercial | Organization.TenantType != "CommercialWeighing" |
Enforcement-mode orgs never pay a TruLoad subscription |
Both BillingMode and IsDemo are embedded in the access token by JwtService.GenerateAccessToken, so the middleware fast-path runs from JWT claims and avoids any DB or Redis lookup for bypass tenants.
Redis Cache Key¶
sub:status:{orgId} TTL: 60 seconds
The key is invalidated automatically by SubscriptionCacheInvalidationService (see BACKGROUND_JOBS.md) when subscriptions-api publishes a tenant.subscription.updated NATS event.
Tenant Attribute Storage: Narrow Column vs Metadata JSON¶
Organization has both patterns, deliberately. A real binary switch that's queried at many call
sites - TenantType, WeighingBusinessModel, BillingMode, IsDemo - earns a dedicated typed
column, because it's cheap to index/filter on and every call site benefits from compile-time typing.
A small, one-off tenant attribute that's read in exactly one place doesn't clear that bar, so it goes
into Organization.MetadataJson instead - a single nullable jsonb "bag" column, read/written
through the typed OrganizationMetadataHelper (constants for known keys + get/merge functions), the
same shape already proven by WeighingTransaction.IndustryMetadata /
CommercialWeighingService.MergeIndustryMetadata at the transaction level. The commercial
vertical/sub-use-case classification ("vertical" key, see CommercialVerticals) is the first
consumer: it's read once, when resolving an org's default enabled modules and its report catalog,
so a new nullable column - and a new EF migration - for it would have been overkill. The next small
tenant attribute in this or another service should default to this pattern (a documented key under
an existing metadata column) rather than a new narrow column, unless it turns out to need the same
call-site-heavy, indexable treatment TenantType gets - in which case a dedicated column is still
the right call, same as it was for TenantType itself.
Background Jobs and Tenant Context¶
Background jobs (Hangfire) have no HTTP request context and therefore no ITenantContext. Services called from background jobs must either:
- Use
IgnoreQueryFilters()when querying across tenants - Pass the tenant slug explicitly to notification services (
tenantSlug: org.Code.ToLowerInvariant())
See BACKGROUND_JOBS.md for job-specific details.