first project commit
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
/**
|
||||
* Singleton PrismaClient. In dev, Next's HMR causes module re-evaluation; the
|
||||
* globalThis cache prevents leaking a new client per reload.
|
||||
*
|
||||
* This is the UNSCOPED root client. Application code MUST NOT use it directly
|
||||
* for tenant-scoped reads/writes — always pass it through `tenantScoped(...)`
|
||||
* with the tenantId from the request context.
|
||||
*/
|
||||
|
||||
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };
|
||||
|
||||
export const prisma =
|
||||
globalForPrisma.prisma ??
|
||||
new PrismaClient({
|
||||
log: process.env.NODE_ENV === 'development' ? ['warn', 'error'] : ['error'],
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
globalForPrisma.prisma = prisma;
|
||||
}
|
||||
|
||||
export type DbClient = typeof prisma;
|
||||
@@ -0,0 +1,4 @@
|
||||
export { prisma, type DbClient } from './client';
|
||||
export { tenantScoped, type TenantScopedClient } from './tenant-extension';
|
||||
export { Prisma, UserRole } from '@prisma/client';
|
||||
export type { User, Tenant, Workstation, DomainEvent } from '@prisma/client';
|
||||
@@ -0,0 +1,180 @@
|
||||
import type { PrismaClient } from '@prisma/client';
|
||||
|
||||
/**
|
||||
* ============================================================================
|
||||
* Multi-tenant Prisma extension
|
||||
* ============================================================================
|
||||
*
|
||||
* PURPOSE
|
||||
* -------
|
||||
* Guarantee that every read and write against a tenant-scoped model is
|
||||
* filtered/stamped with the current tenantId, so application code can never
|
||||
* accidentally cross tenant boundaries. Call this once per request from the
|
||||
* tRPC context, passing the tenantId resolved from the authenticated session.
|
||||
*
|
||||
* const db = tenantScoped(prisma, ctx.tenantId);
|
||||
* await db.workstation.findMany(); // implicitly WHERE tenantId = ctx.tenantId
|
||||
*
|
||||
* HOW TO EXTEND THIS
|
||||
* ------------------
|
||||
* Adding a new tenant-scoped model:
|
||||
* 1. Add `tenantId String` + `@@index([tenantId])` in schema.prisma.
|
||||
* 2. Add a relation to Tenant with `onDelete: Cascade`.
|
||||
* 3. Add the model's PascalCase name to TENANT_SCOPED_MODELS below.
|
||||
* 4. Run `pnpm db:migrate`.
|
||||
*
|
||||
* OPERATIONS INTERCEPTED
|
||||
* ----------------------
|
||||
* Reads : findFirst, findFirstOrThrow, findMany, count, aggregate, groupBy
|
||||
* Writes : create, createMany, createManyAndReturn, update, updateMany,
|
||||
* upsert, delete, deleteMany
|
||||
* Special : findUnique / findUniqueOrThrow are DOWNGRADED to
|
||||
* findFirst / findFirstOrThrow + tenantId filter.
|
||||
*
|
||||
* Reason: Prisma's findUnique only accepts where clauses that match
|
||||
* a declared unique constraint. Adding tenantId to the where would
|
||||
* either (a) require every @unique to be redeclared as
|
||||
* @@unique([tenantId, ...]), or (b) fail Prisma's validation. The
|
||||
* downgrade preserves tenant isolation at the cost of the runtime
|
||||
* guarantee that the result is unique. If you specifically need
|
||||
* "throw on multiple", use `findFirstOrThrow` with a where clause
|
||||
* that you know is unique within the tenant (most commonly the
|
||||
* compound `(tenantId, id)`).
|
||||
*
|
||||
* OPERATIONS NOT INTERCEPTED — THESE BYPASS TENANT SCOPING
|
||||
* --------------------------------------------------------
|
||||
* The following are intentionally left alone. They are the known holes in this
|
||||
* guarantee and MUST be audited at PR-review time:
|
||||
*
|
||||
* 1. $queryRaw, $queryRawUnsafe, $executeRaw, $executeRawUnsafe
|
||||
* Raw SQL bypasses the extension entirely. Always include the tenant
|
||||
* filter explicitly:
|
||||
* await db.$queryRaw`SELECT ... FROM "Workstation" WHERE "tenantId" = ${tenantId}`;
|
||||
* Use raw SQL only for migrations, admin tooling, or aggregations the
|
||||
* Prisma query engine cannot express.
|
||||
*
|
||||
* 2. $transaction (interactive callback form) when the callback receives a
|
||||
* client OTHER than the scoped one returned by this function.
|
||||
* The extension does not re-wrap the inner transactional client. Either:
|
||||
* - issue all operations through the outer scoped client and use the
|
||||
* sequential array form: `await db.$transaction([op1, op2])`, OR
|
||||
* - if you need the interactive form, scope explicitly:
|
||||
* await prisma.$transaction(async (tx) => {
|
||||
* const scopedTx = tenantScoped(tx as PrismaClient, tenantId);
|
||||
* ...
|
||||
* });
|
||||
*
|
||||
* 3. Models without tenantId (currently only `Tenant`). These are passed
|
||||
* through unchanged. Code touching Tenant must take care not to leak
|
||||
* cross-tenant data through joins or includes from the tenant side.
|
||||
*
|
||||
* INVARIANTS THIS EXTENSION ENFORCES
|
||||
* ----------------------------------
|
||||
* - On every intercepted read, `where.tenantId` is set to the bound tenantId,
|
||||
* OVERWRITING any tenantId the caller may have supplied. This is on purpose:
|
||||
* callers must not be able to read other tenants' data even by mistake.
|
||||
* - On `create` and `createMany`, the bound tenantId is injected into `data`,
|
||||
* OVERWRITING any value the caller supplied — same reason.
|
||||
* - On `update` and `upsert.update`, any attempt to set `tenantId` is silently
|
||||
* dropped. Records cannot be re-homed across tenants through this path.
|
||||
*
|
||||
* CHANGELOG
|
||||
* ---------
|
||||
* 2026-05-16 — initial version (scaffold).
|
||||
* ============================================================================
|
||||
*/
|
||||
|
||||
const TENANT_SCOPED_MODELS = ['User', 'Workstation', 'DomainEvent'] as const;
|
||||
type TenantScopedModel = (typeof TENANT_SCOPED_MODELS)[number];
|
||||
|
||||
function isTenantScoped(model: string | undefined): model is TenantScopedModel {
|
||||
return !!model && (TENANT_SCOPED_MODELS as readonly string[]).includes(model);
|
||||
}
|
||||
|
||||
function modelAccessor(model: string): string {
|
||||
return model.charAt(0).toLowerCase() + model.slice(1);
|
||||
}
|
||||
|
||||
export function tenantScoped(prisma: PrismaClient, tenantId: string) {
|
||||
return prisma.$extends({
|
||||
name: 'tenant-scope',
|
||||
query: {
|
||||
$allModels: {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
async $allOperations({ model, operation, args, query }: any) {
|
||||
if (!isTenantScoped(model)) {
|
||||
return query(args);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const a = args as any;
|
||||
|
||||
switch (operation) {
|
||||
case 'findFirst':
|
||||
case 'findFirstOrThrow':
|
||||
case 'findMany':
|
||||
case 'count':
|
||||
case 'aggregate':
|
||||
case 'groupBy':
|
||||
case 'updateMany':
|
||||
case 'deleteMany': {
|
||||
a.where = { ...(a.where ?? {}), tenantId };
|
||||
return query(a);
|
||||
}
|
||||
|
||||
case 'findUnique':
|
||||
case 'findUniqueOrThrow': {
|
||||
// Downgrade to findFirst[OrThrow]. See header.
|
||||
const target =
|
||||
operation === 'findUnique' ? 'findFirst' : 'findFirstOrThrow';
|
||||
const where = { ...(a.where ?? {}), tenantId };
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const delegate = (prisma as any)[modelAccessor(model)];
|
||||
return delegate[target]({ ...a, where });
|
||||
}
|
||||
|
||||
case 'create': {
|
||||
a.data = { ...(a.data ?? {}), tenantId };
|
||||
return query(a);
|
||||
}
|
||||
|
||||
case 'createMany':
|
||||
case 'createManyAndReturn': {
|
||||
const data = a.data;
|
||||
if (Array.isArray(data)) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
a.data = data.map((d: any) => ({ ...d, tenantId }));
|
||||
} else {
|
||||
a.data = { ...(data ?? {}), tenantId };
|
||||
}
|
||||
return query(a);
|
||||
}
|
||||
|
||||
case 'update': {
|
||||
a.where = { ...(a.where ?? {}), tenantId };
|
||||
if (a.data && 'tenantId' in a.data) delete a.data.tenantId;
|
||||
return query(a);
|
||||
}
|
||||
|
||||
case 'upsert': {
|
||||
a.where = { ...(a.where ?? {}), tenantId };
|
||||
a.create = { ...(a.create ?? {}), tenantId };
|
||||
if (a.update && 'tenantId' in a.update) delete a.update.tenantId;
|
||||
return query(a);
|
||||
}
|
||||
|
||||
case 'delete': {
|
||||
a.where = { ...(a.where ?? {}), tenantId };
|
||||
return query(a);
|
||||
}
|
||||
|
||||
default:
|
||||
return query(args);
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export type TenantScopedClient = ReturnType<typeof tenantScoped>;
|
||||
Reference in New Issue
Block a user