Skip to main content

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​

ModeDEPLOYMENT_MODE valueWho uses itSchema strategy
Dedicated (Single-Tenant)dedicatedSelf-hosted, enterprise on-prempublic schema β€” all data in one schema
SaaS (Multi-Tenant)saasCloud-hosted, BluCypher managedPer-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
  • admin and superadmin roles have full access to all records
  • tenant_user_routing, tenant_entitlements, and tenant_memberships tables 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:

TablePurpose
tenant_user_routingMaps customer_id β†’ schema_name for local fallback resolution
tenant_entitlementsPer-tenant plan, allowed modules, max users, plan limits
tenant_membershipsTenant 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:

  1. PostgreSQL search_path β€” Primary isolation. SET search_path TO "tenant_{id}", public ensures TypeORM cannot accidentally reference another tenant's tables. All unqualified table names resolve to the tenant schema.

  2. Row-Level Security GUC β€” SELECT set_config('app.current_tenant_id', tenantId, false) sets a session-level GUC. PostgreSQL RLS policies can reference current_setting('app.current_tenant_id') for an additional, independent enforcement layer. The is_local=false flag means it persists for the lifetime of the connection β€” it is explicitly cleared in releaseTenantQueryRunner before the connection returns to the pool.

  3. AsyncLocalStorage context β€” The tenant's EntityManager is bound to the request's async context via Node.js AsyncLocalStorage. Service code calls getTenantAwareRepository(Entity) which retrieves the tenant-scoped manager without needing req passed down the call stack. This prevents accidental use of the default AppDataSource connection.


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:

  1. Set DEPLOYMENT_MODE=saas in environment variables
  2. Run npm run migrate:dev β€” this creates tenant_user_routing, tenant_entitlements, and tenant_memberships tables in the public schema
  3. Provision the first tenant via TLM
  4. Existing data in the public schema does not auto-migrate to a tenant schema β€” a manual schema move is required

To switch from SaaS back to dedicated:

  1. Set DEPLOYMENT_MODE=dedicated along with DEDICATED_TENANT_SLUG and DEDICATED_TENANT_PLAN
  2. Ensure data exists in public schema (run migration scripts)
  3. Redis cache entries for tenant contexts will be ignored β€” no cleanup needed

Security Considerations​

ConcernMitigation
Schema name injectionSchemaManager.createTenantQueryRunner validates against /^[a-zA-Z_][a-zA-Z0-9_-]{0,62}$/ before any query
TenantContext sourceschemaName comes from TLM (server-side, validated at provisioning) β€” never from user input
Suspended tenant accessBlocked with 403 Tenant account suspended immediately after JWT verification, before schema is set
Deprovisioned tenant410 Gone β€” blocked in local DB fallback path
GUC context bleedapp.current_tenant_id explicitly cleared in releaseTenantQueryRunner using set_config('app.current_tenant_id', '', false) before connection returns to pool
Connection pool pollutionSET search_path TO public executed in cleanup before pool return β€” stale search_path cannot carry into a future request
Double releasereleased flag in closure prevents double-release on both res.finish and res.close events
Non-tenant routesRoutes without a JWT (login, health check, internal) have req.tenantContext = undefined β€” setTenantSchema skips entirely