Tenancy Architecture
ThreatWeaver supports two deployment modes controlled by the DEPLOYMENT_MODE environment variable. Both modes use the same codebase and API surface β the difference is entirely in how data is isolated between tenants.
The backend enforces tenancy through a two-middleware chain (resolveTenant β setTenantSchema) that runs on every authenticated request. This chain reads the deployment mode once at startup and either fast-paths to a static context (dedicated) or resolves per-request context from a JWT claim (SaaS).
Deployment Modesβ
| Mode | DEPLOYMENT_MODE value | Who uses it | Schema strategy |
|---|---|---|---|
| Dedicated (Single-Tenant) | dedicated | Self-hosted, enterprise on-prem | public schema β all data in one schema |
| SaaS (Multi-Tenant) | saas | Cloud-hosted, BluCypher managed | Per-tenant schema β each tenant gets tenant_{id} |
Single-Tenant Architecture (Dedicated Mode)β
How it worksβ
In dedicated mode, the platform runs for a single customer. All data lives in the PostgreSQL public schema. No schema switching happens per request β the resolveTenant middleware builds a static TenantContext once at server startup and reuses it for every request.
DEPLOYMENT_MODE=dedicated
β
βΌ
resolveTenant middleware (startup)
β builds static TenantContext once from env vars:
tenantId: "dedicated-{DEDICATED_TENANT_SLUG}"
tenantSlug: "{DEDICATED_TENANT_SLUG}"
tenantPlan: "{DEDICATED_TENANT_PLAN}"
schemaName: "public"
planLimits: { requestsPerMinute: 2000, requestsPerDay: 200000,
maxUsers: 500, maxAgents: 100 }
β req.tenantContext = dedicatedContext (same object every request)
β
βΌ
setTenantSchema middleware
β detects req.tenantContext.schemaName === 'public'
β SKIPS QueryRunner creation entirely (no search_path needed)
β calls next() immediately
β
βΌ
Route Handler
β TypeORM uses AppDataSource connection directly
β All queries run against the public schema
β No per-request overhead from schema switching
Database schema layout (dedicated)β
PostgreSQL instance
βββ public (single schema, all tables here)
βββ users
βββ assets
βββ vulnerabilities
βββ findings
βββ assessments
βββ dashboard_widgets
βββ roles / permissions
βββ settings
βββ agent_configs
βββ scan_sessions
βββ audit_logs
βββ ... (134 total entity files)
Configurationβ
DEPLOYMENT_MODE=dedicated
DEDICATED_TENANT_SLUG=your-company
DEDICATED_TENANT_PLAN=enterprise
Isolation modelβ
- No inter-tenant isolation β single company owns all data
adminandsuperadminroles have full access to all recordstenant_user_routing,tenant_entitlements, andtenant_membershipstables are not consulted at runtime- Rate limits are enforced per the plan defaults in
TenantContext.planLimits
Multi-Tenant Architecture (SaaS Mode)β
How it worksβ
In SaaS mode, each customer (tenant) gets a dedicated PostgreSQL schema. The schema name is resolved per-request from the tenantId claim in the JWT token. The resolution chain is: JWT β Redis cache β TLM API β local routing table fallback.
DEPLOYMENT_MODE=saas
β
JWT contains tenantId (e.g., "cust_abc123") β set by authenticate() middleware
β
βΌ
resolveTenant middleware
β check Redis cache (tw:tenant:customer:{tenantId})
β
ββ CACHE HIT β deserialize TenantContext
β β if status === 'suspended' β 403 Tenant account suspended
β β req.tenantContext = cachedContext
β
ββ CACHE MISS β call TLM API:
GET /api/customers/{tenantId}/tenant-context
headers: Bearer service-token (or X-Vendor-Key fallback)
timeout: 5 000 ms
β
ββ 404 β 404 Tenant not found
ββ non-2xx β fallback to local tenant_user_routing table
ββ 200 β build TenantContext from response:
{ tenantId, tenantSlug, tenantPlan,
schemaName: data.schemaName,
licenseJti, planLimits, status,
allowedModules, maxUsers }
β cache for tenantCacheTtl seconds (default 300s)
β if status === 'suspended' β 403
β req.tenantContext = resolvedContext
β
βΌ
setTenantSchema middleware
β call schemaManager.createTenantQueryRunner(schemaName)
ββ validates schemaName against /^[a-zA-Z_][a-zA-Z0-9_-]{0,62}$/
ββ dataSource.createQueryRunner()
ββ queryRunner.connect()
ββ execute: SET search_path TO "tenant_abc123", public
β execute: SELECT set_config('app.current_tenant_id', tenantId, false)
β register cleanup on res.on('finish') + res.on('close'):
cleanup: set_config('app.current_tenant_id', '', false)
SET search_path TO public
queryRunner.release()
β tenantStorage.run({ entityManager: queryRunner.manager, tenantContext }, () => next())
β
βΌ
Route Handler / Service Layer
β getTenantAwareRepository() calls tenantStorage.getStore()
β returns queryRunner.manager (scoped to tenant_abc123)
β All TypeORM queries automatically resolve to tenant_abc123 schema
β Completely isolated from other tenants
β
βΌ
Response finish / connection close:
β QueryRunner cleanup fires (idempotent, double-release protected)
β app.current_tenant_id GUC cleared
β search_path reset to public
β QueryRunner released back to connection pool
Schema isolation diagramβ
Tables in public schema (cross-tenant)β
These tables exist in the public schema and are readable regardless of the active tenant:
| Table | Purpose |
|---|---|
tenant_user_routing | Maps customer_id β schema_name for local fallback resolution |
tenant_entitlements | Per-tenant plan, allowed modules, max users, plan limits |
tenant_memberships | Tenant lifecycle status: active, suspended, deprovisioned |
Tables in tenant schema (per-tenant isolated)β
Every other table lives inside the tenant's own schema. No cross-tenant query is possible without explicitly switching schemas. Tables include (but are not limited to):
users, roles, permissions, assets, asset_groups, vulnerabilities, findings, assessments, scan_sessions, agent_configs, dashboard_widgets, report_templates, compliance_frameworks, audit_logs, settings, sla_policy_config, vfp_policy_config, proxy_config, ai_providers, ai_prompt_templates, sensitive_data_patterns, webhook_receivers, and more.
Total entity files: 134
Request-Level Isolationβ
Every authenticated API request goes through this isolation chain:
Defense in depthβ
ThreatWeaver applies three independent isolation mechanisms in SaaS mode:
-
PostgreSQL search_path β Primary isolation.
SET search_path TO "tenant_{id}", publicensures TypeORM cannot accidentally reference another tenant's tables. All unqualified table names resolve to the tenant schema. -
Row-Level Security GUC β
SELECT set_config('app.current_tenant_id', tenantId, false)sets a session-level GUC. PostgreSQL RLS policies can referencecurrent_setting('app.current_tenant_id')for an additional, independent enforcement layer. Theis_local=falseflag means it persists for the lifetime of the connection β it is explicitly cleared inreleaseTenantQueryRunnerbefore the connection returns to the pool. -
AsyncLocalStorage context β The tenant's
EntityManageris bound to the request's async context via Node.jsAsyncLocalStorage. Service code callsgetTenantAwareRepository(Entity)which retrieves the tenant-scoped manager without needingreqpassed down the call stack. This prevents accidental use of the defaultAppDataSourceconnection.
Tenant Provisioning Flowβ
When a new tenant is created in SaaS mode, the following sequence runs:
The seed process is idempotent β it is safe to run seedTenantDefaults again on a schema that already has partial data. Each seed step checks for existence before inserting.
Multi-tenant module files: index.ts, rls-setup.ts, schema-manager.ts, tenant-context.ts, tenant-local-storage.ts, tenant-repository.ts, tenant-seed.ts
Switching Between Modesβ
To switch from dedicated to SaaS mode:
- Set
DEPLOYMENT_MODE=saasin environment variables - Run
npm run migrate:devβ this createstenant_user_routing,tenant_entitlements, andtenant_membershipstables in thepublicschema - Provision the first tenant via TLM
- Existing data in the
publicschema does not auto-migrate to a tenant schema β a manual schema move is required
To switch from SaaS back to dedicated:
- Set
DEPLOYMENT_MODE=dedicatedalong withDEDICATED_TENANT_SLUGandDEDICATED_TENANT_PLAN - Ensure data exists in
publicschema (run migration scripts) - Redis cache entries for tenant contexts will be ignored β no cleanup needed
Security Considerationsβ
| Concern | Mitigation |
|---|---|
| Schema name injection | SchemaManager.createTenantQueryRunner validates against /^[a-zA-Z_][a-zA-Z0-9_-]{0,62}$/ before any query |
| TenantContext source | schemaName comes from TLM (server-side, validated at provisioning) β never from user input |
| Suspended tenant access | Blocked with 403 Tenant account suspended immediately after JWT verification, before schema is set |
| Deprovisioned tenant | 410 Gone β blocked in local DB fallback path |
| GUC context bleed | app.current_tenant_id explicitly cleared in releaseTenantQueryRunner using set_config('app.current_tenant_id', '', false) before connection returns to pool |
| Connection pool pollution | SET search_path TO public executed in cleanup before pool return β stale search_path cannot carry into a future request |
| Double release | released flag in closure prevents double-release on both res.finish and res.close events |
| Non-tenant routes | Routes without a JWT (login, health check, internal) have req.tenantContext = undefined β setTenantSchema skips entirely |
Related Documentationβ
- Deployment Models β single vs multi-tenant deployment options
- Auth Deep Dive β JWT structure and tenant claims
- Request Flows β full middleware chain walkthrough
- Environment Variables β
DEPLOYMENT_MODE,DEDICATED_TENANT_SLUG,TLM_BASE_URL - Multi-Tenant Architecture Diagram β visual diagram