Multi-tenancy
A security filter scopes every query to the current tenant, read from a per-request context. Reads, writes, relations and cascades are all scoped, so you never write WHERE tenantId = ... or set the tenant on a new row by hand.
This page covers tenants sharing a table, kept apart by a column. To give each tenant its own schema instead, see Multiple Schemas.
1. Add a security filter
Section titled “1. Add a security filter”The filter’s where is a function of the context:
import { Entity, Id, Field, Filter } from 'uql-orm';
@Filter('tenant', { where: (ctx) => ctx?.tenantId != null ? { companyId: ctx.tenantId } : undefined, security: true,})@Entity()export class Invoice { @Id({ type: Number }) id?: number; @Field({ type: Number }) companyId?: number | null; @Field({ type: Number }) total?: number | null;}With no tenant, return undefined, never { companyId: undefined }, so the query throws instead of running unscoped.
To type ctx.tenantId, declare the context once. Keep the keys optional, since a background job may know only some of them:
declare module 'uql-orm' { interface UqlContext { tenantId?: number; system?: boolean; }}2. Set the context
Section titled “2. Set the context”withContext sets the context for a unit of work. It follows await, Promise.all, transactions and pool calls:
import { withContext } from 'uql-orm';
await withContext({ tenantId: 42 }, async () => { await pool.findMany(Invoice, { $where: { total: { $gt: 100 } } }); // SELECT ... FROM "Invoice" WHERE ("total" > $1) AND ("companyId" = $2) -- $2 = 42});At your HTTP boundary, getContext does this for every request. Read the tenant from a verified source such as a session or a decoded JWT, never from client input:
import { createRequestHandler } from 'uql-orm/http';
createRequestHandler<{ user: { tenantId: number } }>({ pool, include: [Invoice], getContext: (req) => ({ tenantId: req.user.tenantId }),});NestJS takes the same option: UqlModule.forRoot<AuthedRequest>({ pool, getContext }).
What security: true guarantees
Section titled “What security: true guarantees”A security filter is always applied: { filters: false } and bypassing by name are ignored, and the HTTP request parser accepts only query keys, so a client cannot send filters or context. Its condition is ANDed on its own, so a request sending $where: { companyId: 999 } becomes companyId = 999 AND companyId = 42 and matches nothing.
It fails closed. When the condition returns undefined, the query throws UqlSecurityError, and onMissing: 'skip' is refused on a security filter.
It follows relations. $populate: { company: true } enforces company’s security filter on every driver, MongoDB included, and so do relation filters and $size counts.
It guards writes:
- An insert gets the filter’s fields from the context, and naming another value is refused.
- An update cannot change them.
- A save or upsert naming another tenant’s row fails on its key (a
409over HTTP) instead of overwriting it. - A condition returning
{}writes freely. One that is not plain field equalities ($in,$or) refuses writes, since there is no single value to check.
A refused write throws UqlSecurityError, a 403 over HTTP.
Security filters combine with other filters. An Invoice that is also soft-deletable gets both conditions, WHERE ("companyId" = $1) AND ("deletedAt" IS NULL), and withDeleted() lifts only the soft-delete one.
Jobs, scripts and tests
Section titled “Jobs, scripts and tests”Outside an HTTP request, wrap the work in withContext({ tenantId }, () => runNightlyBilling(tenantId)).
For trusted work across all tenants, such as startup recovery, give the filter’s where a system branch that returns {} and run those jobs under a system context. A query with no context at all still fails closed. Annotate the function’s return type: typing the const as FilterWhere<Invoice> instead lets a mistyped column through.
import type { QueryWhere, UqlContext } from 'uql-orm';
// the filter's `where`const tenantScope = (ctx?: UqlContext): QueryWhere<Invoice> | undefined => ctx?.system ? {} : ctx?.tenantId != null ? { companyId: ctx.tenantId } : undefined;
await withContext({ system: true }, () => recoverStaleJobs());Event-driven pipelines (emitters, timers, queues)
Section titled “Event-driven pipelines (emitters, timers, queues)”withContext uses AsyncLocalStorage, which follows await but not event callbacks. An emitter listener, a timer or a queued job runs outside the scope that registered it. captureContext() captures the context and replays it when the callback fires:
import { captureContext } from 'uql-orm';
const scoped = captureContext(); // inside the scoped requestdeepgram.on('transcript', (t) => scoped(() => saveTranscript(t)));When the code already knows its tenant, pass { context } to withQuerier or transaction instead:
await pool.withQuerier( (q) => q.updateMany(Resource, { $where: { id } }, patch), { context: { tenantId } },);Wrap your app’s few chokepoints (event bus, queue runners, socket dispatch), not every function.
Security filters and database RLS
Section titled “Security filters and database RLS”Security filters hold only inside UQL. Raw SQL (querier.all) is yours to scope, and other services on the same database bypass them. For the strongest isolation, add the database’s own row-level security, such as Postgres RLS, as a backstop.