src/prisma/prisma.service.ts
Tenant-aware Prisma service.
Multi-tenant isolation is enforced by Postgres Row-Level Security (RLS).
Per-tenant tables have policies that filter rows by app.current_org_id.
On every authenticated HTTP request, the TenantContextInterceptor:
$transactionapp.current_org_id via SET LOCAL on that connectionThis service is a Proxy: when a service does prisma.user.findMany(),
the proxy looks up the tx client from AsyncLocalStorage and routes the
call there. The tx connection has the GUC set, Postgres enforces the
RLS policy, and queries return only the current tenant's rows.
If no tenant context exists (login flow, health checks, public endpoints),
queries fall through to the base client. Tables with RLS enabled and
no context will return zero rows / raise a row-security violation —
which is the desired fail-closed behavior for tenant data, while
users, audit_logs, and organizations are intentionally excluded
from RLS so the bootstrap auth flow works.
IMPORTANT: connect as the fleetapp Postgres role (NOT fleetadmin).
fleetadmin has BYPASSRLS and would defeat the policies.
PrismaClient
Methods |
|
constructor()
|
|
Defined in src/prisma/prisma.service.ts:58
|
|
Initialise the Prisma client with environment-appropriate log levels. In development, |
| Async onModuleDestroy |
onModuleDestroy()
|
|
Defined in src/prisma/prisma.service.ts:91
|
|
Gracefully disconnect from Postgres when the application shuts down.
Returns :
any
|
| Async onModuleInit |
onModuleInit()
|
|
Defined in src/prisma/prisma.service.ts:83
|
|
Connect to Postgres when the NestJS module initialises. Called automatically by the NestJS lifecycle. The connection must use
the
Returns :
any
|
import { Injectable, OnModuleInit, OnModuleDestroy, Logger } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import { getCurrentTenant } from './tenant-context';
/**
* Tenant-aware Prisma service.
*
* Multi-tenant isolation is enforced by Postgres Row-Level Security (RLS).
* Per-tenant tables have policies that filter rows by `app.current_org_id`.
*
* On every authenticated HTTP request, the TenantContextInterceptor:
* 1. Opens a Prisma `$transaction`
* 2. Sets `app.current_org_id` via `SET LOCAL` on that connection
* 3. Stores the tx client in AsyncLocalStorage
* 4. Runs the route handler
* 5. Commits the transaction
*
* This service is a Proxy: when a service does `prisma.user.findMany()`,
* the proxy looks up the tx client from AsyncLocalStorage and routes the
* call there. The tx connection has the GUC set, Postgres enforces the
* RLS policy, and queries return only the current tenant's rows.
*
* If no tenant context exists (login flow, health checks, public endpoints),
* queries fall through to the base client. Tables with RLS enabled and
* no context will return zero rows / raise a row-security violation —
* which is the desired fail-closed behavior for tenant data, while
* `users`, `audit_logs`, and `organizations` are intentionally excluded
* from RLS so the bootstrap auth flow works.
*
* IMPORTANT: connect as the `fleetapp` Postgres role (NOT `fleetadmin`).
* `fleetadmin` has BYPASSRLS and would defeat the policies.
*/
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(PrismaService.name);
/**
* Initialise the Prisma client with environment-appropriate log levels.
*
* In development, `warn` and `error` events are logged so developers can
* see slow queries and constraint violations. In production, only `error`
* events are emitted to keep log volume manageable.
*/
constructor() {
super({
log:
process.env.NODE_ENV !== 'production'
? ['warn', 'error']
: ['error'],
});
}
/**
* Connect to Postgres when the NestJS module initialises.
*
* Called automatically by the NestJS lifecycle. The connection must use
* the `fleetapp` Postgres role (non-BYPASSRLS) so that RLS policies
* are enforced on every query.
*/
async onModuleInit() {
await this.$connect();
this.logger.log('PrismaService connected; tenant RLS proxy active');
}
/**
* Gracefully disconnect from Postgres when the application shuts down.
*/
async onModuleDestroy() {
await this.$disconnect();
}
}
/**
* Wrap a PrismaService instance in a Proxy that, on model accessors,
* looks up the per-request transactional client from AsyncLocalStorage
* and routes the call there. Lifecycle and $-methods stay on the base.
*/
export function createPrismaServiceProxy(): PrismaService {
const instance = new PrismaService();
const baseHasOwn = (prop: PropertyKey): boolean => {
if (typeof prop !== 'string') return false;
// These properties always live on the base instance
return [
'onModuleInit',
'onModuleDestroy',
'$connect',
'$disconnect',
'$transaction',
'$executeRaw',
'$executeRawUnsafe',
'$queryRaw',
'$queryRawUnsafe',
'$on',
'$extends',
'$use',
'logger',
].includes(prop);
};
return new Proxy(instance, {
get(target, prop, _receiver) {
if (typeof prop === 'symbol' || baseHasOwn(prop)) {
// CRITICAL: read with `target` as the receiver and bind functions
// to `target` so Prisma's $transaction (and friends) see the real
// PrismaClient as `this` rather than the Proxy. Without this,
// calling `prisma.$transaction(cb)` from outside any HTTP request
// (e.g. background workers like GpsSimulatorService) ends up with
// `this` pointing at the Proxy, which breaks Prisma's internal
// transaction tracking and produces "Transaction not found"
// errors a few hundred ms after open.
const value = Reflect.get(target, prop, target);
if (typeof value === 'function') {
return value.bind(target);
}
return value;
}
// Model accessor (user, order, trip, etc.) — route to tenant tx
// client if a request context is active.
const ctx = getCurrentTenant();
if (ctx?.tx) {
return (ctx.tx as any)[prop];
}
// No tenant context: fall through to the base client. Postgres RLS
// will enforce fail-closed for any table with policies enabled.
// Background workers must open their own $transaction and SET
// app.current_org_id inside it before reading tenant tables.
return Reflect.get(target, prop, target);
},
});
}