This document outlines the multi-tenant architecture and data isolation model implemented in the LiquiFact backend.
In LiquiFact, a tenant represents an independent organizational entity (e.g., a corporate client, financial institution, or discrete market operator) containing its own users, API keys, invoices, escrow logs, files, and settings.
LiquiFact serves multiple tenants on a single shared instance (database and compute resources). Since tenants may be direct competitors or handle highly sensitive financial data, ensuring that one tenant can never access, modify, or even detect the existence of another tenant's data is a core security requirement.
- Zero Data Leakage: No request from Tenant A should ever retrieve, update, delete, or check the existence of resources belonging to Tenant B.
- Access Control: Users and API keys belong to a specific tenant and are restricted to operations on that tenant's resources.
- Storage & Cache Isolation: All uploaded files and cached responses must be separated using tenant-specific namespaces or keys.
- Failsafe Enforcement: Request context should automatically resolve and enforce the tenant scope. If the context cannot be resolved, the request must fail loudly and immediately.
The tenant context is resolved at the entrypoint of the request lifecycle using custom middleware.
Incoming Request
│
▼
┌────────────────────────────────────────────────────────┐
│ check "x-tenant-id" Header │ (Service-to-Service / API Key)
└──────┬─────────────────────────────────────────────────┘
│
├──► [Found & Valid] ──► Set req.tenantId ──► next()
│
▼
┌────────────────────────────────────────────────────────┐
│ check JWT Claim "req.user.tenantId" │ (User / SME Session)
└──────┬─────────────────────────────────────────────────┘
│
├──► [Found & Valid] ──► Set req.tenantId ──► next()
│
▼
[Not Found / Invalid] ──► Reject with 400 Bad Request
The resolution is implemented in src/middleware/tenant.js. The extractTenant middleware checks two sources in order of priority:
x-tenant-idHeader (Priority 1): Used for service-to-service communication or API-key authenticated flows.- JWT claim
tenantId(Priority 2): Stored in the user's authenticated token (e.g.,req.user.tenantId), which is set by authenticateToken middleware running upstream.
To protect against injection attacks or invalid inputs, the raw tenant ID is sanitised using sanitiseTenantId:
- Must be a string.
- Trimmed of leading/trailing whitespace.
- Must not be empty.
- Length-capped using
MAX_TENANT_ID_LENGTH(defaults to 128 characters, configured in environment).
If no valid tenant ID can be resolved, the request is rejected immediately with a 400 Bad Request containing:
{
"error": "Missing tenant context.",
"message": "A valid tenant identifier must be supplied via the x-tenant-id header or an authenticated JWT claim."
}Once the tenant is successfully resolved, the information flows through the system as follows:
[Middleware Stack]
└─► auth.js / stacks.js (Validates JWT / API key)
└─► tenant.js (Extracts and binds req.tenantId)
│
▼
[Controller/Route Handlers]
└─► Read req.tenantId
└─► Pass tenantId as argument to Service layer
│
▼
[Service Layer]
└─► Pass tenantId to Database queries / Storage Service / Cache Store
Composed middleware chains are centralized in src/middleware/stacks.js. There are two primary stacks:
- authenticatedTenantStack: Composed of
authenticateTokenfollowed byextractTenant. - adminStack: Composed of
adminAuthfollowed byextractTenant.
When asynchronous jobs are scheduled, the tenant context is propagated in the job payload. In src/workers/worker.js, the buildJobContext helper extracts the tenant context safely:
- It checks the job's
payloadobject. - Only a safe whitelist of context keys (including
tenantId) is copied to the running job context to prevent leaking secrets.
Multi-tenant isolation is enforced across all operational layers:
Routes requiring tenant isolation mount extractTenant. Statically-ordered middleware ensures validation and context binding are completed before controller logic executes.
The application relies on explicit query scoping in the service layer using Knex. Database queries that read, write, or delete resources must explicitly filter by tenant_id.
Examples from the codebase:
- Invoices: Enforced in src/services/invoiceService.js. For example:
- Reading a list:
db('invoices').where({ tenant_id: tenantId })(invoiceService.js:L120) - Finding a single invoice:
db('invoices').where({ invoice_id: id, tenant_id: tenantId }).first()(invoiceService.js:L293) - Creation: Inserts
tenant_id: tenantId(invoiceService.js:L232)
- Reading a list:
- Investor Commitments: Enforced in src/services/investService.js.
- Queries filter by
tenant_id(e.g. investService.js:L73).
- Queries filter by
- Marketplace: Enforced in src/services/marketplaceService.js (e.g. marketplaceService.js:L134).
- Webhooks: Enforced in src/services/webhooks.js. Webhook settings and event delivery logs (including dead letters) are looked up and stored with the tenant context (e.g. webhooks.js:L109, webhooks.js:L339).
- Audit Logs: Enforced in src/services/auditLogStore.js. Searches extract and filter log metadata by tenant ID (auditLogStore.js:L190):
query = query.whereRaw("metadata->>'tenantId' = ?", [filters.tenantId]);
The database schema defines Row-Level Security (RLS) policies as a defense-in-depth measure.
The database checks the session variable app.current_tenant_id to restrict row access automatically if RLS is invoked:
tenants: Managed in migrations/20240425000001_create_users_and_tenants.sql.users: RLS enforcesUSING (tenant_id = current_setting('app.current_tenant_id')::uuid).api_keys: RLS enforcesUSING (tenant_id = current_setting('app.current_tenant_id')::uuid).invoices: Managed in migrations/20240425000002_add_tenant_to_invoices.sql. Policyinvoice_tenant_policyenforcesUSING (tenant_id = current_setting('app.current_tenant_id')::uuid).escrow_operations,escrow_summaries,audit_logs: Managed in migrations/20240425000003_create_escrow_operations.sql. Policies enforce matching tenant IDs using the SQL context variable.retention_policies,legal_holds,retention_audit_log,retention_job_executions: Managed in migrations/20250425000000_create_retention_system.sql.
Note
PostgreSQL RLS is currently configured in migrations for database-level security compliance. The active application codebase relies primarily on application-level query scoping using Knex (.where({ tenant_id })).
File uploads in src/services/storage.js enforce strict tenant scoping via object key namespacing:
- The helper _generateKey constructs the S3 object key with the tenant ID embedded:
tenants/${tenantId}/invoices/${invoiceId}/${uuid}-${safeName} - The tenant ID is validated against a safe alphanumeric regex (
/^[a-zA-Z0-9_-]+$/) to prevent path traversal in the storage namespace (storage.js:L87-L89).
Metadata for uploaded files is stored in the database invoice_files table containing tenant_id columns, scoped during query retrieval:
return await db('invoice_files').where({ tenant_id: tenantId, invoice_id: invoiceId }).first();Response caching in src/middleware/cache.js prevents cross-tenant cache poisoning by generating tenant-isolated keys:
- Marketplace Cache Key:
marketplace:${tenantId}:${req.originalUrl}(cache.js:L106-L109) - Investor Locks List:
investor:locks:${tenantId}:${req.originalUrl}(cache.js:L117-L120) - Single Lock Key:
investor:lock:${tenantId}:${req.params.invoiceId}:${req.query.funderAddress}(cache.js:L129-L132)
When adding a new endpoint or service that handles tenant data, developers must verify the following items:
- Resolve Tenant: Ensure the route is protected by
extractTenant(or mounts a composed stack fromstacks.jslikeauthenticatedTenantStackoradminStack). - Never Trust Client-supplied Tenant IDs: Do not read
tenantIdfrom client query parameters, route parameters, or JSON payloads (unless it is an administrative endpoint explicitly authorized to manage multiple tenants). Always resolve it usingreq.tenantIdset by the middleware. - Scope Database Queries: Every Knex query that interacts with tenant-scoped tables must include a
.where('tenant_id', tenantId)or.where({ tenant_id: tenantId })constraint. - Scope Storage Keys: When storing or fetching S3 files, ensure the S3 object key is constructed using the
StorageServicegenerator containing the tenant ID prefix. - Scope Cache Keys: When caching HTTP responses, ensure cache keys incorporate the
req.tenantIdcontext variable. - SME Wallet and Ownership Check: If the endpoint operates on a specific SME resource (e.g. invoices), compose
verifyInvoiceOwneror verify that the user's bound wallet (req.walletAddress) corresponds to the invoice's wallet address orownerId. - Test Cross-Tenant Isolation: Write integration tests asserting that attempts to access resources belonging to a different tenant ID return an appropriate error (e.g.,
404 Not Foundto avoid confirming resource existence, or400 Bad Request/403 Forbidden).
Contributors must avoid these common errors when writing code in this repository:
Performing a lookup on a resource using only its primary key (e.g., id or invoice_id) without filtering by tenant_id.
Incorrect:
const invoice = await db('invoices').where({ invoice_id: id }).first();Correct:
const invoice = await db('invoices').where({ invoice_id: id, tenant_id: tenantId }).first();Allowing the client to specify the target tenant inside the request body or path variables for standard operations. Incorrect:
router.post('/invoices', async (req, res) => {
const { tenantId, amount } = req.body; // VULNERABILITY: Client controls the tenant mapping
...
});Correct:
router.post('/invoices', extractTenant, async (req, res) => {
const tenantId = req.tenantId; // Secured: Context resolved from JWT or signed header
const { amount } = req.body;
...
});Generating S3 keys dynamically from user input without sanitising the input or omitting the tenant prefix. This could allow path traversal or cross-tenant document hijacking. Incorrect:
const key = `invoices/${req.body.fileName}`;Correct:
const key = storageService.generateKey({ tenantId: req.tenantId, invoiceId, fileName });Caching responses on a key that only includes the path or URL path query, without including the tenant context. Incorrect:
const cacheKey = `marketplace:${req.originalUrl}`; // VULNERABILITY: Tenant A's cached search results can be served to Tenant BCorrect:
const cacheKey = `marketplace:${req.tenantId}:${req.originalUrl}`;