When building a SaaS that hosts the accounting of multiple companies on the same PostgreSQL schema, data isolation is not a detail. One cross-tenant leak and that is the end of the product. Yet many teams still rely on application-layer filtering alone.

Here is why this is insufficient and how SynkriaOps does it differently.

Application-layer filtering and its trap

Classic pattern: each entity carries a tenant_id, and the repository interleaves this filter in every query.

// Naive pattern — INSUFFICIENT
const invoices = await repo.find({
  where: { tenantId: user.tenantId, status: "validated" },
});

The trap: a single bug — a missing where, a wrongly-typed join, a queryBuilder that forgets the scope, a raw SQL written too quickly — and a query returns all tenants merged together.

And this bug can remain latent in production for months without any alert firing. From the user’s side, nothing signals that they just glimpsed a competitor’s invoices.

Engine-layer filtering (Row Level Security)

PostgreSQL has offered since version 9.5 a native mechanism: Row Level Security (RLS). Filtering is enforced at the engine level, not the application level. Every query — whether from an ORM, raw SQL, or psql console — is filtered automatically before rows leave the database.

-- Enable RLS on the table
ALTER TABLE factures ENABLE ROW LEVEL SECURITY;

-- Read policy
CREATE POLICY factures_tenant_isolation ON factures
  FOR ALL
  USING (tenant_id = current_setting('app.current_tenant_id')::uuid);

From that point, any query that does not explicitly set app.current_tenant_id receives zero rows. This is a fail-safe, not a best-effort.

The SynkriaOps architecture

SynkriaOps uses three-layer defense:

  1. TenantMiddleware (NestJS) — extracts the tenant_id from the JWT, verifies membership, sets set_config('app.current_tenant_id', <uuid>, false) on the DB connection for the duration of the HTTP request.
  2. TenantAccessGuard — verifies at the controller level that the user is authorized to access the current tenant.
  3. PostgreSQL RLS — ultimate safety net: if the two layers above fail, the DB still returns 0 rows.

The code:

// apps/api/src/common/middleware/tenant.middleware.ts
async use(req: Request, res: Response, next: NextFunction) {
  const tenantId = this.extractTenantId(req);
  if (!tenantId) {
    throw new UnauthorizedException('Tenant context missing');
  }

  await this.dataSource.query(
    `SELECT set_config('app.current_tenant_id', $1, false)`,
    [tenantId],
  );
  next();
}

The connection pool trap

Subtle catch: PostgreSQL set_config(..., false) fixes the variable for the session, not for the transaction. With a connection pool (pgBouncer, TypeORM connection pool), the same connection serves several successive requests. If one query badly resets the context, the next inherits the previous tenant_id.

Best practices:

  • Always reset set_config at the start of every request (middleware pattern).
  • For critical mutations (UPDATE / DELETE / INSERT), wrap in an explicit transaction with set_config(..., true) which scopes the variable to the transaction.
  • SynkriaOps reference pattern: TenantContextService.setForTransaction().

Why it is non-negotiable for accounting

An SME’s accounting includes:

  • Customer invoices (amounts, identities, margins)
  • Supplier contracts (terms, rebates)
  • Salaries (HR filings, payslips)
  • Bank flows (statements, balances)

A cross-tenant leak on this data = end of the founder’s career. Regulators, press, customers, everyone piles on. This is an existential risk for an accounting SaaS.

PostgreSQL RLS converts this existential risk into a PostgreSQL risk — that is, a risk managed by a team of hundreds of engineers in open-source for 30 years.


Regression test

SynkriaOps includes a dedicated E2E test: test/rls-fail-safe.e2e-spec.ts. It seeds two distinct tenants, makes a query with a wrong tenant_id set, and asserts that 0 rows are returned.

If someone accidentally disables an RLS policy on a table, the test fails before merge, not in production.


The full pattern is documented in apps/api/src/common/middleware/tenant.middleware.ts and apps/api/src/common/services/tenant-context.service.ts. See also the LOT-MOMO-C retrospective in docs/audit-correction/ for a real-world RLS silent fail case detected in review.