diff --git a/apps/api/package.json b/apps/api/package.json index 08b1f8b..c0da234 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -23,12 +23,15 @@ "@nestjs/jwt": "^11.0.2", "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^11.1.21", + "@prisma/adapter-pg": "7.8.0", "db": "workspace:*", "express": "^5.2.1", "jsonwebtoken": "^9.0.3", "passport": "^0.7.0", "passport-google-oauth20": "^2.0.0", "passport-jwt": "^4.0.1", + "pg": "^8", + "rxjs": "^7.8.2", "zod": "^4.4.3" }, "devDependencies": { @@ -44,6 +47,7 @@ "@types/passport": "^1.0.17", "@types/passport-google-oauth20": "^2.0.17", "@types/passport-jwt": "^4.0.1", + "@types/pg": "^8.23.1", "@types/supertest": "^7.2.0", "eslint": "^9.39.1", "jest": "^30.4.2", diff --git a/apps/api/src/__mocks__/db-client.mock.ts b/apps/api/src/__mocks__/db-client.mock.ts index 97af2c8..595db4b 100644 --- a/apps/api/src/__mocks__/db-client.mock.ts +++ b/apps/api/src/__mocks__/db-client.mock.ts @@ -1,9 +1,82 @@ /** - * Jest manual mock for the Prisma generated client (`db/client`). + * Jest manual mock for the Prisma generated client (`db/client`) and for the + * `db` package entrypoint (see `jest.config.js` — both specifiers map here). * * Used in all unit and integration tests so that module resolution doesn't * attempt to load the ESM-only generated Prisma files. + * + * `runWithOrgContext`/`runWithoutOrgScope`/`getOrgContext` below are a + * deliberate, self-contained duplicate of `db`'s `org-scope/context.ts` (not + * a re-export): because this file's specifier intercepts every `db` import + * within this test run (including from application code like + * `OrgContextInterceptor`), it cannot itself `import ... from "db"` without + * resolving back to itself. The real implementation has no Prisma + * dependency and is exercised directly by `packages/db`'s own unit tests + * (`org-scope/context.spec.ts`); keep this copy in sync if that file's + * behavior changes. */ +import { AsyncLocalStorage } from "node:async_hooks"; + +interface OrgContext { + readonly organizationId: string; +} +interface UnscopedContext { + readonly unscoped: true; +} +type OrgContextStore = OrgContext | UnscopedContext; + +const orgContextStorage = new AsyncLocalStorage(); + +export async function runWithOrgContext( + organizationId: string, + fn: () => T | PromiseLike, +): Promise { + return orgContextStorage.run({ organizationId }, async () => await fn()); +} + +export async function runWithoutOrgScope( + fn: () => T | PromiseLike, +): Promise { + return orgContextStorage.run({ unscoped: true }, async () => await fn()); +} + +export function getOrgContext(): OrgContextStore | undefined { + return orgContextStorage.getStore(); +} + +export function isUnscopedContext( + context: OrgContextStore, +): context is UnscopedContext { + return "unscoped" in context && context.unscoped === true; +} + +export class MissingOrgContextError extends Error { + constructor(model: string, operation: string) { + super( + `Org-scoped query blocked: no organization context for ${model}.${operation}()`, + ); + this.name = "MissingOrgContextError"; + } +} + +export class OrgScopeViolationError extends Error { + constructor(model: string, operation: string) { + super( + `Org-scoped query blocked: ${model}.${operation}() crossed a tenant boundary`, + ); + this.name = "OrgScopeViolationError"; + } +} + +/** + * Tests override `PrismaService` entirely (see every `*.integration.spec.ts` + * and `*.spec.ts`), so the real `createOrgScopedClient` extension never runs + * in this suite. This stub exists only so application code can import it + * without a resolution error; it is intentionally never exercised. + */ +export function createOrgScopedClient(baseClient: T): T { + return baseClient; +} export const mockPrismaClient = { $connect: jest.fn().mockResolvedValue(undefined), diff --git a/apps/api/src/admin/organizations/organization.service.spec.ts b/apps/api/src/admin/organizations/organization.service.spec.ts index 86db3e7..e3f718c 100644 --- a/apps/api/src/admin/organizations/organization.service.spec.ts +++ b/apps/api/src/admin/organizations/organization.service.spec.ts @@ -14,7 +14,6 @@ const mockOrg = { const mockPrisma = { organization: { - findMany: jest.fn(), findUnique: jest.fn(), create: jest.fn(), update: jest.fn(), @@ -37,27 +36,6 @@ describe("OrganizationService", () => { service = module.get(OrganizationService); }); - describe("findAll", () => { - it("returns all organizations ordered by createdAt desc", async () => { - mockPrisma.organization.findMany.mockResolvedValue([mockOrg]); - - const result = await service.findAll(); - - expect(result).toEqual([mockOrg]); - expect(mockPrisma.organization.findMany).toHaveBeenCalledWith({ - orderBy: { createdAt: "desc" }, - }); - }); - - it("returns an empty array when no organizations exist", async () => { - mockPrisma.organization.findMany.mockResolvedValue([]); - - const result = await service.findAll(); - - expect(result).toEqual([]); - }); - }); - describe("findOne", () => { it("returns the organization when found", async () => { mockPrisma.organization.findUnique.mockResolvedValue(mockOrg); @@ -175,9 +153,9 @@ describe("OrganizationService", () => { mockPrisma.organization.findUnique.mockResolvedValue(mockOrg); mockPrisma.organization.update.mockRejectedValue(p2025); - await expect(service.update("org-1", { name: "new.com" })).rejects.toThrow( - NotFoundException, - ); + await expect( + service.update("org-1", { name: "new.com" }), + ).rejects.toThrow(NotFoundException); }); it("applies no data update when dto is empty", async () => { diff --git a/apps/api/src/admin/organizations/organization.service.ts b/apps/api/src/admin/organizations/organization.service.ts index 57d30a8..cb8def7 100644 --- a/apps/api/src/admin/organizations/organization.service.ts +++ b/apps/api/src/admin/organizations/organization.service.ts @@ -25,12 +25,6 @@ import { isPrismaUniqueConstraintError } from "../../common/prisma-errors"; export class OrganizationService { constructor(private readonly prisma: PrismaService) {} - async findAll(): Promise { - return this.prisma.organization.findMany({ - orderBy: { createdAt: "desc" }, - }); - } - async findOne(id: string): Promise { const org = await this.prisma.organization.findUnique({ where: { id } }); if (!org) { diff --git a/apps/api/src/admin/organizations/organizations.controller.ts b/apps/api/src/admin/organizations/organizations.controller.ts index b1d7000..789c816 100644 --- a/apps/api/src/admin/organizations/organizations.controller.ts +++ b/apps/api/src/admin/organizations/organizations.controller.ts @@ -5,12 +5,14 @@ import { Patch, Param, Body, + Req, UseGuards, HttpCode, HttpStatus, BadRequestException, } from "@nestjs/common"; import { AdminRoleGuard } from "../guards/admin-role.guard"; +import { assertAdminOrganizationAccess } from "../guards/assert-admin-organization"; import { OrganizationService } from "./organization.service"; import type { CreateOrganizationDto, @@ -18,6 +20,11 @@ import type { UpdateOrganizationDto, } from "./organization.dto"; import type { Organization } from "db/client"; +import type { AuthenticatedUser } from "../../auth/auth.types"; + +interface RequestWithUser { + user: AuthenticatedUser; +} function toResponseDto(org: Organization): OrganizationResponseDto { return { @@ -28,21 +35,41 @@ function toResponseDto(org: Organization): OrganizationResponseDto { }; } +/** + * Organization membership is the tenant boundary itself, so every route + * here is scoped to the authenticated admin's own organization — + * `assertAdminOrganizationAccess` blocks any attempt to read or modify a + * different organization with 403 Forbidden, mirroring the same convention + * used by `DepartmentsController` and `AdminUsersController`. Creating a new + * organization is the one exception: it doesn't read or modify existing + * tenant data, so it isn't scoped to an existing org. + */ @Controller("api/admin/organizations") @UseGuards(AdminRoleGuard) export class OrganizationsController { constructor(private readonly organizationService: OrganizationService) {} + /** + * Returns the caller's own organization as a single-item list. There is + * no cross-tenant "list all organizations" view — an admin can only ever + * see the organization they belong to. + */ @Get() @HttpCode(HttpStatus.OK) - async findAll(): Promise { - const orgs = await this.organizationService.findAll(); - return orgs.map(toResponseDto); + async findAll( + @Req() req: RequestWithUser, + ): Promise { + const org = await this.organizationService.findOne(req.user.organizationId); + return [toResponseDto(org)]; } @Get(":id") @HttpCode(HttpStatus.OK) - async findOne(@Param("id") id: string): Promise { + async findOne( + @Req() req: RequestWithUser, + @Param("id") id: string, + ): Promise { + assertAdminOrganizationAccess(req.user.organizationId, id); const org = await this.organizationService.findOne(id); return toResponseDto(org); } @@ -64,9 +91,11 @@ export class OrganizationsController { @Patch(":id") @HttpCode(HttpStatus.OK) async update( + @Req() req: RequestWithUser, @Param("id") id: string, @Body() body: UpdateOrganizationDto, ): Promise { + assertAdminOrganizationAccess(req.user.organizationId, id); if ( body.name !== undefined && (typeof body.name !== "string" || !body.name.trim()) diff --git a/apps/api/src/admin/organizations/organizations.integration.spec.ts b/apps/api/src/admin/organizations/organizations.integration.spec.ts index cbbbfd0..b3ac9cc 100644 --- a/apps/api/src/admin/organizations/organizations.integration.spec.ts +++ b/apps/api/src/admin/organizations/organizations.integration.spec.ts @@ -16,7 +16,7 @@ class MockGoogleStrategy { const now = new Date("2024-06-01T12:00:00Z"); const mockOrg = { - id: "org-uuid-1", + id: "org-1", name: "acme.com", createdAt: now, updatedAt: now, @@ -28,15 +28,18 @@ const mockPrisma = { user: { findUnique: jest.fn() }, organization: { findUnique: jest.fn(), - findMany: jest.fn(), create: jest.fn(), update: jest.fn(), }, }; -function issueToken(role: string, sub = "user-1"): string { +function issueToken( + role: string, + sub = "user-1", + organizationId = "org-1", +): string { return jwt.sign( - { sub, email: `${sub}@example.com`, organizationId: "org-1", role }, + { sub, email: `${sub}@example.com`, organizationId, role }, TEST_JWT_SECRET, { expiresIn: "1h" }, ); @@ -77,7 +80,7 @@ describe("Admin Organizations Integration", () => { }); // --------------------------------------------------------------------------- - // Auth / role enforcement + // Auth / role / org-isolation enforcement // --------------------------------------------------------------------------- describe("authorization", () => { @@ -112,16 +115,39 @@ describe("Admin Organizations Integration", () => { .send({ name: "test.com" }) .expect(403); }); + + it("returns 403 when an admin requests another organization's details by id", async () => { + const token = issueToken("admin", "user-1", "org-1"); + + await request(app.getHttpServer()) + .get("/api/admin/organizations/org-2") + .set("Authorization", `Bearer ${token}`) + .expect(403); + + expect(mockPrisma.organization.findUnique).not.toHaveBeenCalled(); + }); + + it("returns 403 when an admin attempts to update another organization", async () => { + const token = issueToken("admin", "user-1", "org-1"); + + await request(app.getHttpServer()) + .patch("/api/admin/organizations/org-2") + .set("Authorization", `Bearer ${token}`) + .send({ name: "renamed.com" }) + .expect(403); + + expect(mockPrisma.organization.update).not.toHaveBeenCalled(); + }); }); // --------------------------------------------------------------------------- - // List (read) + // List (read) — always scoped to the caller's own organization // --------------------------------------------------------------------------- describe("GET /api/admin/organizations", () => { - it("returns 200 with org list for admin", async () => { + it("returns 200 with only the caller's own organization", async () => { const token = issueToken("admin"); - mockPrisma.organization.findMany.mockResolvedValue([mockOrg]); + mockPrisma.organization.findUnique.mockResolvedValue(mockOrg); const res = await request(app.getHttpServer()) .get("/api/admin/organizations") @@ -136,18 +162,28 @@ describe("Admin Organizations Integration", () => { createdAt: now.toISOString(), updatedAt: now.toISOString(), }); + expect(mockPrisma.organization.findUnique).toHaveBeenCalledWith({ + where: { id: "org-1" }, + }); }); - it("returns an empty array when no organizations exist", async () => { - const token = issueToken("admin"); - mockPrisma.organization.findMany.mockResolvedValue([]); + it("never returns another organization's data regardless of what exists in the database", async () => { + // Even if the caller's own org lookup resolves to a *different* + // record than the one they belong to (e.g. a data bug), the response + // only ever reflects whatever `findOne(callerOrgId)` returns — there + // is no code path that can return more than one organization. + const token = issueToken("admin", "user-1", "org-1"); + mockPrisma.organization.findUnique.mockResolvedValue(mockOrg); - const res = await request(app.getHttpServer()) + await request(app.getHttpServer()) .get("/api/admin/organizations") .set("Authorization", `Bearer ${token}`) .expect(200); - expect(res.body).toEqual([]); + expect(mockPrisma.organization.findUnique).toHaveBeenCalledTimes(1); + expect(mockPrisma.organization.findUnique).toHaveBeenCalledWith({ + where: { id: "org-1" }, + }); }); }); @@ -156,7 +192,7 @@ describe("Admin Organizations Integration", () => { // --------------------------------------------------------------------------- describe("GET /api/admin/organizations/:id", () => { - it("returns 200 with org details for valid id", async () => { + it("returns 200 with org details when the id matches the caller's own organization", async () => { const token = issueToken("admin"); mockPrisma.organization.findUnique.mockResolvedValue(mockOrg); @@ -176,14 +212,14 @@ describe("Admin Organizations Integration", () => { mockPrisma.organization.findUnique.mockResolvedValue(null); await request(app.getHttpServer()) - .get("/api/admin/organizations/missing-id") + .get(`/api/admin/organizations/${mockOrg.id}`) .set("Authorization", `Bearer ${token}`) .expect(404); }); }); // --------------------------------------------------------------------------- - // Create (write) + // Create (write) — not scoped to an existing org; new-tenant onboarding // --------------------------------------------------------------------------- describe("POST /api/admin/organizations", () => { @@ -260,7 +296,7 @@ describe("Admin Organizations Integration", () => { // --------------------------------------------------------------------------- describe("PATCH /api/admin/organizations/:id", () => { - it("returns 200 with updated org on valid payload", async () => { + it("returns 200 with updated org when the id matches the caller's own organization", async () => { const token = issueToken("admin"); const updated = { ...mockOrg, name: "updated.com" }; mockPrisma.organization.findUnique.mockResolvedValue(mockOrg); @@ -280,7 +316,7 @@ describe("Admin Organizations Integration", () => { mockPrisma.organization.findUnique.mockResolvedValue(null); await request(app.getHttpServer()) - .patch("/api/admin/organizations/missing-id") + .patch(`/api/admin/organizations/${mockOrg.id}`) .set("Authorization", `Bearer ${token}`) .send({ name: "new.com" }) .expect(404); diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index d3f6370..355adf7 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -1,10 +1,17 @@ import { Module } from "@nestjs/common"; +import { APP_FILTER, APP_INTERCEPTOR } from "@nestjs/core"; import { MCPModule } from "./mcp/mcp.module"; import { AuthModule } from "./auth/auth.module"; import { PrismaModule } from "./prisma/prisma.module"; import { AdminModule } from "./admin/admin.module"; +import { OrgContextInterceptor } from "./common/org-context.interceptor"; +import { OrgScopeExceptionFilter } from "./common/org-scope-exception.filter"; @Module({ imports: [PrismaModule, AuthModule, MCPModule, AdminModule], + providers: [ + { provide: APP_INTERCEPTOR, useClass: OrgContextInterceptor }, + { provide: APP_FILTER, useClass: OrgScopeExceptionFilter }, + ], }) export class AppModule {} diff --git a/apps/api/src/auth/user.service.ts b/apps/api/src/auth/user.service.ts index 1150011..2c0a4a4 100644 --- a/apps/api/src/auth/user.service.ts +++ b/apps/api/src/auth/user.service.ts @@ -5,6 +5,7 @@ import { } from "@nestjs/common"; import { PrismaService } from "../prisma/prisma.service"; import type { User } from "db/client"; +import { runWithoutOrgScope } from "db"; interface UpsertUserInput { googleSub: string; @@ -22,8 +23,15 @@ import { isPrismaUniqueConstraintError } from "../common/prisma-errors"; export class UserService { constructor(private readonly prisma: PrismaService) {} + /** + * Looks up a user by their Google identity, independent of organization — + * this runs during login, before the caller's org is known, so it must + * bypass org scoping deliberately via `runWithoutOrgScope`. + */ async findByGoogleSub(googleSub: string): Promise { - return this.prisma.user.findUnique({ where: { googleSub } }); + return runWithoutOrgScope(() => + this.prisma.user.findUnique({ where: { googleSub } }), + ); } async findById(id: string): Promise { @@ -67,64 +75,69 @@ export class UserService { * no-ops on the existing row and returns it. */ async findOrCreate(input: UpsertUserInput): Promise { - // Fast path: returning users skip the org lookup entirely. - const existing = await this.prisma.user.findUnique({ - where: { googleSub: input.googleSub }, - }); - if (existing) { - return existing; - } + // The whole flow runs before the caller's organization is known (it's + // what determines it, via the email domain lookup below), so it + // deliberately bypasses org scoping via `runWithoutOrgScope`. + return runWithoutOrgScope(async () => { + // Fast path: returning users skip the org lookup entirely. + const existing = await this.prisma.user.findUnique({ + where: { googleSub: input.googleSub }, + }); + if (existing) { + return existing; + } - // Validate email format – no PII in the error message. - const atIndex = input.email.indexOf("@"); - if (atIndex <= 0) { - throw new BadRequestException("Invalid email format"); - } - const domain = input.email.slice(atIndex + 1); - if (!domain) { - throw new BadRequestException("Invalid email format"); - } + // Validate email format – no PII in the error message. + const atIndex = input.email.indexOf("@"); + if (atIndex <= 0) { + throw new BadRequestException("Invalid email format"); + } + const domain = input.email.slice(atIndex + 1); + if (!domain) { + throw new BadRequestException("Invalid email format"); + } - // Resolve organization by domain. Organization.name is @unique so - // findUnique is safe and semantically correct here. - const org = await this.prisma.organization.findUnique({ - where: { name: domain }, - }); + // Resolve organization by domain. Organization.name is @unique so + // findUnique is safe and semantically correct here. + const org = await this.prisma.organization.findUnique({ + where: { name: domain }, + }); - if (!org) { - throw new UnauthorizedException( - `No organization provisioned for email domain "${domain}". ` + - "Contact your administrator to register your domain.", - ); - } + if (!org) { + throw new UnauthorizedException( + `No organization provisioned for email domain "${domain}". ` + + "Contact your administrator to register your domain.", + ); + } - // Atomic create with race-condition recovery. - // If a concurrent request already created this user, the unique - // constraint on googleSub raises P2002; we upsert to return the - // existing row without a second round-trip failure. - try { - return await this.prisma.user.create({ - data: { - googleSub: input.googleSub, - email: input.email, - role: "member", - organizationId: org.id, - }, - }); - } catch (err) { - if (isPrismaUniqueConstraintError(err)) { - return await this.prisma.user.upsert({ - where: { googleSub: input.googleSub }, - update: {}, - create: { + // Atomic create with race-condition recovery. + // If a concurrent request already created this user, the unique + // constraint on googleSub raises P2002; we upsert to return the + // existing row without a second round-trip failure. + try { + return await this.prisma.user.create({ + data: { googleSub: input.googleSub, email: input.email, role: "member", organizationId: org.id, }, }); + } catch (err) { + if (isPrismaUniqueConstraintError(err)) { + return await this.prisma.user.upsert({ + where: { googleSub: input.googleSub }, + update: {}, + create: { + googleSub: input.googleSub, + email: input.email, + role: "member", + organizationId: org.id, + }, + }); + } + throw err; } - throw err; - } + }); } } diff --git a/apps/api/src/common/org-context.interceptor.spec.ts b/apps/api/src/common/org-context.interceptor.spec.ts new file mode 100644 index 0000000..8785d1e --- /dev/null +++ b/apps/api/src/common/org-context.interceptor.spec.ts @@ -0,0 +1,270 @@ +import type { CallHandler, ExecutionContext } from "@nestjs/common"; +import { + CanActivate, + Controller, + Get, + INestApplication, + Injectable, + Module, + UseGuards, +} from "@nestjs/common"; +import { APP_INTERCEPTOR } from "@nestjs/core"; +import { Test } from "@nestjs/testing"; +import request from "supertest"; +import { of } from "rxjs"; +import { getOrgContext } from "db"; +import { OrgContextInterceptor } from "./org-context.interceptor"; + +interface RequestWithUser { + user?: { organizationId: string }; + headers: Record; +} + +/** + * Stands in for `JwtAuthGuard`: reads a test-only header instead of + * validating a real JWT, so this suite can exercise `OrgContextInterceptor` + * in isolation without pulling in the full auth stack. + */ +@Injectable() +class FakeAuthGuard implements CanActivate { + canActivate(context: ExecutionContext): boolean { + const request = context.switchToHttp().getRequest(); + const organizationId = request.headers["x-test-org"]; + if (organizationId) { + request.user = { organizationId }; + } + return true; + } +} + +function readOrgId(): string | null { + const context = getOrgContext(); + if (context && "organizationId" in context) { + return context.organizationId; + } + return null; +} + +/** + * A "lazy thenable" that, like Prisma's client methods, registers its + * `.then()` reaction only when awaited — unlike a plain `Promise`, which + * begins settling immediately on construction. Used below to prove the + * interceptor's context survives a realistic multi-hop async chain, not + * just a synchronous handler. + */ +function fakeDbCall(resolve: () => T): PromiseLike { + return { + then( + onFulfilled: (value: T) => TResult1 | PromiseLike, + ): PromiseLike { + return new Promise((res) => { + queueMicrotask(() => res(onFulfilled(resolve()))); + }); + }, + }; +} + +async function fakeServiceLayer(): Promise { + // Mirrors a real controller -> service -> PrismaService chain: several + // `await` hops, the last of which is a Prisma-like lazy thenable. + await Promise.resolve(); + const nested = await (async () => fakeDbCall(() => readOrgId()))(); + return nested; +} + +@Controller() +class ProbeController { + @Get("authenticated") + @UseGuards(FakeAuthGuard) + authenticated(): { organizationId: string | null } { + return { organizationId: readOrgId() }; + } + + @Get("public") + public(): { organizationId: string | null } { + return { organizationId: readOrgId() }; + } + + @Get("authenticated-async-chain") + @UseGuards(FakeAuthGuard) + async authenticatedAsyncChain(): Promise<{ organizationId: string | null }> { + const organizationId = await fakeServiceLayer(); + return { organizationId }; + } + + @Get("authenticated-throws") + @UseGuards(FakeAuthGuard) + authenticatedThrows(): never { + // Simulates a synchronous throw inside the org-context window, which + // `runWithOrgContext` (now async) turns into a rejected promise rather + // than a synchronous exception — the interceptor must forward that + // rejection to the response instead of leaving it unhandled. + throw new Error("synchronous failure inside org context"); + } +} + +@Module({ + controllers: [ProbeController], + providers: [{ provide: APP_INTERCEPTOR, useClass: OrgContextInterceptor }], +}) +class ProbeModule {} + +describe("OrgContextInterceptor", () => { + let app: INestApplication; + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + imports: [ProbeModule], + }).compile(); + + app = moduleRef.createNestApplication(); + await app.init(); + }); + + afterAll(async () => { + await app.close(); + }); + + it("binds the authenticated user's organizationId as the active org context", async () => { + const res = await request(app.getHttpServer()) + .get("/authenticated") + .set("x-test-org", "org-1") + .expect(200); + + expect(res.body).toEqual({ organizationId: "org-1" }); + }); + + it("leaves no org context active for unauthenticated requests", async () => { + const res = await request(app.getHttpServer()) + .get("/authenticated") + .expect(200); + + expect(res.body).toEqual({ organizationId: null }); + }); + + it("leaves no org context active for public routes with no guard at all", async () => { + const res = await request(app.getHttpServer()).get("/public").expect(200); + + expect(res.body).toEqual({ organizationId: null }); + }); + + it("clears the org context once the request completes (no leakage between requests)", async () => { + await request(app.getHttpServer()) + .get("/authenticated") + .set("x-test-org", "org-1") + .expect(200); + + const res = await request(app.getHttpServer()).get("/public").expect(200); + + expect(res.body).toEqual({ organizationId: null }); + }); + + it("survives a realistic multi-hop async chain (controller -> service -> lazy Prisma-like call)", async () => { + const res = await request(app.getHttpServer()) + .get("/authenticated-async-chain") + .set("x-test-org", "org-1") + .expect(200); + + expect(res.body).toEqual({ organizationId: "org-1" }); + }); + + it("keeps concurrent requests for different organizations fully isolated", async () => { + const [resA, resB, resC] = await Promise.all([ + request(app.getHttpServer()) + .get("/authenticated") + .set("x-test-org", "org-a"), + request(app.getHttpServer()) + .get("/authenticated") + .set("x-test-org", "org-b"), + request(app.getHttpServer()) + .get("/authenticated") + .set("x-test-org", "org-c"), + ]); + + expect(resA.body).toEqual({ organizationId: "org-a" }); + expect(resB.body).toEqual({ organizationId: "org-b" }); + expect(resC.body).toEqual({ organizationId: "org-c" }); + }); + + it("forwards a synchronous throw inside the org context as a normal error response, not an unhandled rejection", async () => { + await request(app.getHttpServer()) + .get("/authenticated-throws") + .set("x-test-org", "org-1") + .expect(500); + }); + + it("keeps concurrent multi-hop async chains isolated across organizations", async () => { + const [resX, resY] = await Promise.all([ + request(app.getHttpServer()) + .get("/authenticated-async-chain") + .set("x-test-org", "org-x"), + request(app.getHttpServer()) + .get("/authenticated-async-chain") + .set("x-test-org", "org-y"), + ]); + + expect(resX.body).toEqual({ organizationId: "org-x" }); + expect(resY.body).toEqual({ organizationId: "org-y" }); + }); +}); + +describe("OrgContextInterceptor — direct unit test", () => { + function fakeExecutionContext(user?: { + organizationId: string; + }): ExecutionContext { + return { + getType: () => "http", + switchToHttp: () => ({ + getRequest: () => ({ user }), + }), + } as unknown as ExecutionContext; + } + + /** + * `runWithOrgContext` is async — it awaits the callback internally, so a + * throw from `next.handle()` becomes a *rejected promise* rather than a + * synchronous exception. Without the `.catch()` forwarding it to the + * subscriber, this would be an unhandled rejection and the returned + * Observable would simply never emit (the HTTP response would hang). + * This can't be reproduced through the full HTTP pipeline above, because + * RxJS's own `defer`/`subscribe` machinery already catches a throw from a + * *real* Nest `CallHandler.handle()` before it ever reaches this + * interceptor's callback — so this test calls `intercept()` directly with + * a `CallHandler` stand-in that throws before returning an Observable. + */ + it("forwards a synchronous throw from next.handle() to the subscriber instead of an unhandled rejection", async () => { + const interceptor = new OrgContextInterceptor(); + const thrown = new Error("next.handle() failed synchronously"); + const next: CallHandler = { + handle: () => { + throw thrown; + }, + }; + + const result = interceptor.intercept( + fakeExecutionContext({ organizationId: "org-1" }), + next, + ); + + await expect( + new Promise((resolve, reject) => { + result.subscribe({ next: resolve, error: reject }); + }), + ).rejects.toBe(thrown); + }); + + it("still emits normally when next.handle() succeeds", async () => { + const interceptor = new OrgContextInterceptor(); + const next: CallHandler = { handle: () => of("ok") }; + + const result = interceptor.intercept( + fakeExecutionContext({ organizationId: "org-1" }), + next, + ); + + const value = await new Promise((resolve, reject) => { + result.subscribe({ next: resolve, error: reject }); + }); + expect(value).toBe("ok"); + }); +}); diff --git a/apps/api/src/common/org-context.interceptor.ts b/apps/api/src/common/org-context.interceptor.ts new file mode 100644 index 0000000..8a5fd63 --- /dev/null +++ b/apps/api/src/common/org-context.interceptor.ts @@ -0,0 +1,58 @@ +import { + CallHandler, + ExecutionContext, + Injectable, + NestInterceptor, +} from "@nestjs/common"; +import type { Subscriber } from "rxjs"; +import { Observable } from "rxjs"; +import { runWithOrgContext } from "db"; +import type { AuthenticatedUser } from "../auth/auth.types"; + +interface RequestWithOptionalUser { + user?: AuthenticatedUser; +} + +/** + * Binds the authenticated caller's `organizationId` as the active org + * context (see `db`'s `runWithOrgContext`) for the remainder of the request + * pipeline. Every `PrismaService` query made while handling this request is + * therefore automatically scoped to that organization at the query layer — + * controllers and services don't need to remember to filter by org + * themselves. + * + * Requests with no authenticated user (public routes, e.g. the OAuth + * callback) run with no org context. Any org-scoped Prisma call reachable + * from such a route must use `runWithoutOrgScope(...)` explicitly. + * + * Registered globally in `AppModule` via `APP_INTERCEPTOR`, which runs after + * guards — so `request.user`, when present, has already been populated by + * `JwtAuthGuard` by the time this interceptor executes. + */ +@Injectable() +export class OrgContextInterceptor implements NestInterceptor { + intercept(context: ExecutionContext, next: CallHandler): Observable { + if (context.getType() !== "http") { + return next.handle(); + } + + const request = context + .switchToHttp() + .getRequest(); + const organizationId = request.user?.organizationId; + if (!organizationId) { + return next.handle(); + } + + return new Observable((subscriber: Subscriber) => { + // `runWithOrgContext` is async: it awaits the callback's return value + // (including lazy thenables) internally, so it must itself be + // consumed here rather than left as a floating promise — any + // synchronous throw from `next.handle()`/`subscribe()` becomes a + // rejection that needs forwarding to the subscriber's error channel. + runWithOrgContext(organizationId, () => { + next.handle().subscribe(subscriber); + }).catch((err: unknown) => subscriber.error(err)); + }); + } +} diff --git a/apps/api/src/common/org-scope-exception.filter.spec.ts b/apps/api/src/common/org-scope-exception.filter.spec.ts new file mode 100644 index 0000000..d4a8c78 --- /dev/null +++ b/apps/api/src/common/org-scope-exception.filter.spec.ts @@ -0,0 +1,56 @@ +import { + Controller, + Get, + INestApplication, + Module, + UseFilters, +} from "@nestjs/common"; +import { Test } from "@nestjs/testing"; +import request from "supertest"; +import { OrgScopeViolationError } from "db"; +import { OrgScopeExceptionFilter } from "./org-scope-exception.filter"; + +@Controller() +class ThrowingController { + @Get("cross-org-write") + @UseFilters(OrgScopeExceptionFilter) + crossOrgWrite(): never { + throw new OrgScopeViolationError("UserDepartment", "create"); + } +} + +@Module({ controllers: [ThrowingController] }) +class ThrowingModule {} + +describe("OrgScopeExceptionFilter", () => { + let app: INestApplication; + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + imports: [ThrowingModule], + }).compile(); + + app = moduleRef.createNestApplication(); + await app.init(); + }); + + afterAll(async () => { + await app.close(); + }); + + it("maps OrgScopeViolationError to a 403 Forbidden response", async () => { + const res = await request(app.getHttpServer()) + .get("/cross-org-write") + .expect(403); + + expect(res.body).toEqual({ statusCode: 403, message: "Forbidden" }); + }); + + it("does not leak the underlying error message to the client", async () => { + const res = await request(app.getHttpServer()) + .get("/cross-org-write") + .expect(403); + + expect(JSON.stringify(res.body)).not.toContain("UserDepartment"); + }); +}); diff --git a/apps/api/src/common/org-scope-exception.filter.ts b/apps/api/src/common/org-scope-exception.filter.ts new file mode 100644 index 0000000..7443969 --- /dev/null +++ b/apps/api/src/common/org-scope-exception.filter.ts @@ -0,0 +1,28 @@ +import { + ArgumentsHost, + Catch, + ExceptionFilter, + HttpStatus, +} from "@nestjs/common"; +import type { Response } from "express"; +import { OrgScopeViolationError } from "db"; + +/** + * Maps a query-layer tenant-boundary violation (see `db`'s + * `OrgScopeViolationError`, thrown by the org-scoping Prisma extension when + * a write references a record outside the caller's organization) to 403 + * Forbidden. This is the last line of defense — application code should + * already prevent cross-org references at the controller/service level — + * but it ensures the query layer's enforcement is never silently swallowed + * as an unhandled 500. + */ +@Catch(OrgScopeViolationError) +export class OrgScopeExceptionFilter implements ExceptionFilter { + catch(_exception: OrgScopeViolationError, host: ArgumentsHost): void { + const response = host.switchToHttp().getResponse(); + response.status(HttpStatus.FORBIDDEN).json({ + statusCode: HttpStatus.FORBIDDEN, + message: "Forbidden", + }); + } +} diff --git a/apps/api/src/mcp/mcp.integration.spec.ts b/apps/api/src/mcp/mcp.integration.spec.ts index 3b63023..cd0d848 100644 --- a/apps/api/src/mcp/mcp.integration.spec.ts +++ b/apps/api/src/mcp/mcp.integration.spec.ts @@ -1,11 +1,14 @@ import { Test } from "@nestjs/testing"; import type { INestApplication } from "@nestjs/common"; +import { APP_INTERCEPTOR } from "@nestjs/core"; import request from "supertest"; import * as jwt from "jsonwebtoken"; +import { getOrgContext } from "db"; import { MCPModule } from "./mcp.module"; import { AuthModule } from "../auth/auth.module"; import { PrismaService } from "../prisma/prisma.service"; import { GoogleStrategy } from "../auth/strategies/google.strategy"; +import { OrgContextInterceptor } from "../common/org-context.interceptor"; const TEST_JWT_SECRET = "test-secret-for-mcp-integration"; @@ -47,6 +50,9 @@ describe("MCP Integration", () => { const moduleRef = await Test.createTestingModule({ imports: [MCPModule, AuthModule], + providers: [ + { provide: APP_INTERCEPTOR, useClass: OrgContextInterceptor }, + ], }) .overrideProvider(GoogleStrategy) .useClass(MockGoogleStrategy) @@ -143,6 +149,154 @@ describe("MCP Integration", () => { }); }); + describe("organization isolation", () => { + it("binds the JWT organizationId as the query-layer org context for the request", async () => { + const token = issueToken({ + sub: "user_123", + email: "alice@example.com", + organizationId: "org_456", + role: "member", + }); + + let seen: ReturnType; + mockPrisma.userDepartment.findFirst.mockImplementation(async () => { + seen = getOrgContext(); + return null; + }); + + await request(app.getHttpServer()) + .post("/mcp") + .set("Authorization", `Bearer ${token}`) + .send({ query: "test" }) + .expect(201); + + expect(seen).toEqual({ organizationId: "org_456" }); + }); + + it("scopes the userDepartment lookup by the caller's own organization", async () => { + const token = issueToken({ + sub: "user_123", + email: "alice@example.com", + organizationId: "org_456", + role: "member", + }); + + mockPrisma.userDepartment.findFirst.mockResolvedValueOnce(null); + + await request(app.getHttpServer()) + .post("/mcp") + .set("Authorization", `Bearer ${token}`) + .send({ query: "test" }) + .expect(201); + + expect(mockPrisma.userDepartment.findFirst).toHaveBeenCalledWith({ + where: { + userId: "user_123", + isPrimary: true, + department: { organizationId: "org_456" }, + }, + }); + }); + + it("keeps concurrent requests from different organizations fully isolated", async () => { + const tokenA = issueToken({ + sub: "user_a", + email: "a@a-corp.example.com", + organizationId: "org_a", + role: "member", + }); + const tokenB = issueToken({ + sub: "user_b", + email: "b@b-corp.example.com", + organizationId: "org_b", + role: "member", + }); + + mockPrisma.userDepartment.findFirst.mockImplementation( + async ({ where }: { where: { userId: string } }) => { + if (where.userId === "user_a") { + return { + id: "ud_a", + userId: "user_a", + departmentId: "dept_a", + isPrimary: true, + }; + } + if (where.userId === "user_b") { + return { + id: "ud_b", + userId: "user_b", + departmentId: "dept_b", + isPrimary: true, + }; + } + return null; + }, + ); + + const [resA, resB] = await Promise.all([ + request(app.getHttpServer()) + .post("/mcp") + .set("Authorization", `Bearer ${tokenA}`) + .send({ query: "query from org A" }), + request(app.getHttpServer()) + .post("/mcp") + .set("Authorization", `Bearer ${tokenB}`) + .send({ query: "query from org B" }), + ]); + + expect(resA.body.scope).toEqual({ + organizationId: "org_a", + departmentId: "dept_a", + }); + expect(resB.body.scope).toEqual({ + organizationId: "org_b", + departmentId: "dept_b", + }); + expect(resA.body.answer).toContain("query from org A"); + expect(resB.body.answer).toContain("query from org B"); + }); + + it("never returns another organization's departmentId even if that org's row is isPrimary in the same result set", async () => { + // Regression guard: the relation filter on `department.organizationId` + // is what prevents this, not incidental ordering of mock data. + const token = issueToken({ + sub: "user_123", + email: "alice@example.com", + organizationId: "org_456", + role: "member", + }); + + mockPrisma.userDepartment.findFirst.mockImplementationOnce( + async ({ + where, + }: { + where: { department: { organizationId: string } }; + }) => { + // Simulate a correctly org-scoped query engine: a row belonging to + // a foreign org must never satisfy this filter. + if (where.department.organizationId !== "org_456") { + return null; + } + return { + id: "ud_1", + userId: "user_123", + departmentId: "dept_456", + isPrimary: true, + }; + }, + ); + + const response = await request(app.getHttpServer()) + .post("/mcp") + .set("Authorization", `Bearer ${token}`) + .send({ query: "test" }) + .expect(201); + + expect(response.body.scope.departmentId).toBe("dept_456"); + }); + }); + describe("auth rejection", () => { it("returns 401 when Authorization header is missing", async () => { await request(app.getHttpServer()) diff --git a/apps/api/src/prisma/prisma.service.ts b/apps/api/src/prisma/prisma.service.ts index 456eb7d..fd53c2e 100644 --- a/apps/api/src/prisma/prisma.service.ts +++ b/apps/api/src/prisma/prisma.service.ts @@ -1,16 +1,86 @@ import { Injectable, OnModuleInit, OnModuleDestroy } from "@nestjs/common"; +import { PrismaPg } from "@prisma/adapter-pg"; import { PrismaClient } from "db/client"; +import { createOrgScopedClient } from "db"; +function createRawPrismaClient(): PrismaClient { + const connectionString = process.env["DATABASE_URL"]; + if (!connectionString) { + throw new Error("DATABASE_URL environment variable is required"); + } + const adapter = new PrismaPg({ connectionString }); + return new PrismaClient({ adapter }); +} + +/** + * Sole application entrypoint to the database. + * + * `PrismaService` deliberately does NOT extend `PrismaClient`. Every model + * delegate exposed below is routed through `createOrgScopedClient`, which + * enforces organization isolation at the query layer for every operation + * (see `db`'s `org-scope` module). The raw, unscoped client is a private + * field with no external accessor — `$queryRaw` / `$executeRaw` are + * intentionally not re-exported. The scoped client's raw-SQL methods still + * fail closed unless the caller is inside `runWithoutOrgScope`. There is + * no way for a consumer to bypass org scoping; it is enforced for every + * query, not opted into per call site. + * + * Code that legitimately needs to query across organizations (e.g. + * resolving a user's Organization by email domain during login, before the + * caller's org is known) must wrap the call in `runWithoutOrgScope(...)` + * from `db`, using the scoped delegates below as normal — the extension + * recognizes that context and passes the query through unfiltered. + */ @Injectable() -export class PrismaService - extends PrismaClient - implements OnModuleInit, OnModuleDestroy -{ +export class PrismaService implements OnModuleInit, OnModuleDestroy { + private readonly rawClient = createRawPrismaClient(); + private readonly scoped: PrismaClient = createOrgScopedClient(this.rawClient); + async onModuleInit(): Promise { - await this.$connect(); + await this.rawClient.$connect(); } async onModuleDestroy(): Promise { - await this.$disconnect(); + await this.rawClient.$disconnect(); + } + + get organization(): PrismaClient["organization"] { + return this.scoped.organization; + } + + get user(): PrismaClient["user"] { + return this.scoped.user; + } + + get department(): PrismaClient["department"] { + return this.scoped.department; + } + + get userDepartment(): PrismaClient["userDepartment"] { + return this.scoped.userDepartment; + } + + get source(): PrismaClient["source"] { + return this.scoped.source; + } + + get document(): PrismaClient["document"] { + return this.scoped.document; + } + + get chunk(): PrismaClient["chunk"] { + return this.scoped.chunk; + } + + get policy(): PrismaClient["policy"] { + return this.scoped.policy; + } + + get queryLog(): PrismaClient["queryLog"] { + return this.scoped.queryLog; + } + + get $transaction(): PrismaClient["$transaction"] { + return this.scoped.$transaction.bind(this.scoped); } } diff --git a/packages/db/jest.config.cjs b/packages/db/jest.config.cjs new file mode 100644 index 0000000..4f00352 --- /dev/null +++ b/packages/db/jest.config.cjs @@ -0,0 +1,29 @@ +/** @type {import('jest').Config} */ +module.exports = { + moduleFileExtensions: ["js", "json", "ts"], + rootDir: "src", + testRegex: ".*\\.spec\\.ts$", + transform: { + "^.+\\.(t|j)s$": [ + "ts-jest", + { + tsconfig: { + module: "CommonJS", + moduleResolution: "Node", + strict: true, + esModuleInterop: true, + skipLibCheck: true, + }, + }, + ], + }, + // Source files use NodeNext-style explicit `.js` extensions on relative + // imports (required because this package is `"type": "module"`), but the + // test transform above compiles to CommonJS. Strip the extension so + // Jest's resolver falls back to `moduleFileExtensions` (finding the `.ts` + // source) instead of looking for a literal `.js` file that doesn't exist. + moduleNameMapper: { + "^(\\.{1,2}/.*)\\.js$": "$1", + }, + testEnvironment: "node", +}; diff --git a/packages/db/package.json b/packages/db/package.json index 095f812..c966477 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -18,6 +18,7 @@ "scripts": { "build": "prisma generate && tsc", "check-types": "tsc --noEmit", + "test": "jest", "db:generate": "prisma generate", "db:push": "prisma db push", "db:migrate": "prisma migrate dev", @@ -32,7 +33,11 @@ }, "devDependencies": { "@cortex/typescript-config": "workspace:*", + "@types/jest": "^30.0.0", + "@types/node": "^22.0.0", + "jest": "^30.4.2", "prisma": "^7.8.0", + "ts-jest": "^29.4.11", "typescript": "5.9.2" } } diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 6ce41af..137d968 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -11,3 +11,7 @@ export type { Policy, QueryLog, } from "../generated/prisma/client.js"; + +// Organization isolation: query-layer enforcement for all Prisma access. +// See `org-scope/` for the scoping engine and `docs/agents/db.md` for usage. +export * from "./org-scope/index.js"; diff --git a/packages/db/src/org-scope/config.ts b/packages/db/src/org-scope/config.ts new file mode 100644 index 0000000..e354cd1 --- /dev/null +++ b/packages/db/src/org-scope/config.ts @@ -0,0 +1,72 @@ +/** + * Declarative org-scoping strategy per Prisma model. + * + * Every model in `schema.prisma` MUST have an entry here — the extension + * (`extension.ts`) fails closed via `UnknownOrgScopeModelError` for any + * model that isn't registered, so a forgotten entry blocks queries instead + * of silently allowing cross-tenant access. + */ +export type OrgScopeConfig = + /** The model itself IS the tenant boundary (only `Organization`). */ + | { readonly kind: "self" } + /** The model has an `organizationId` column directly. */ + | { readonly kind: "direct"; readonly field: string } + /** + * The model is scoped transitively through a relation. `chain` is the + * sequence of relation field names ending in the scalar `organizationId` + * field on the final related model, e.g. `["document", "source", + * "organizationId"]` for `Chunk`. + */ + | { + readonly kind: "relation"; + readonly chain: readonly [string, ...string[]]; + readonly verifyVia: readonly [ + RelationVerification, + ...RelationVerification[], + ]; + }; + +/** + * Describes how to verify, at create time, that a relation-scoped model's + * foreign key actually belongs to the caller's organization. Reads/updates/ + * deletes are verified by merging a relation filter into `where`; creates + * have no `where` to filter, so the referenced parent record is looked up + * (through the same org-scoped client) instead. + */ +export interface RelationVerification { + /** Field on `data` holding the foreign key to verify. */ + readonly foreignKeyField: string; + /** Model name (as it appears in `ORG_SCOPE_CONFIG`) that owns that key. */ + readonly parentModel: string; +} + +export const ORG_SCOPE_CONFIG: Readonly> = { + Organization: { kind: "self" }, + User: { kind: "direct", field: "organizationId" }, + Department: { kind: "direct", field: "organizationId" }, + Source: { kind: "direct", field: "organizationId" }, + Policy: { kind: "direct", field: "organizationId" }, + UserDepartment: { + kind: "relation", + chain: ["user", "organizationId"], + verifyVia: [ + { foreignKeyField: "userId", parentModel: "User" }, + { foreignKeyField: "departmentId", parentModel: "Department" }, + ], + }, + Document: { + kind: "relation", + chain: ["source", "organizationId"], + verifyVia: [{ foreignKeyField: "sourceId", parentModel: "Source" }], + }, + Chunk: { + kind: "relation", + chain: ["document", "source", "organizationId"], + verifyVia: [{ foreignKeyField: "documentId", parentModel: "Document" }], + }, + QueryLog: { + kind: "relation", + chain: ["user", "organizationId"], + verifyVia: [{ foreignKeyField: "userId", parentModel: "User" }], + }, +}; diff --git a/packages/db/src/org-scope/context.spec.ts b/packages/db/src/org-scope/context.spec.ts new file mode 100644 index 0000000..b4e5af6 --- /dev/null +++ b/packages/db/src/org-scope/context.spec.ts @@ -0,0 +1,132 @@ +import { + getOrgContext, + isUnscopedContext, + runWithOrgContext, + runWithoutOrgScope, +} from "./context.js"; + +describe("org context", () => { + it("returns undefined when no context is active", () => { + expect(getOrgContext()).toBeUndefined(); + }); + + it("exposes the bound organizationId within runWithOrgContext", () => { + runWithOrgContext("org-1", () => { + expect(getOrgContext()).toEqual({ organizationId: "org-1" }); + }); + }); + + it("clears the context once runWithOrgContext returns", () => { + runWithOrgContext("org-1", () => undefined); + expect(getOrgContext()).toBeUndefined(); + }); + + it("propagates context across async continuations (await boundaries)", async () => { + await runWithOrgContext("org-async", async () => { + await Promise.resolve(); + expect(getOrgContext()).toEqual({ organizationId: "org-async" }); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(getOrgContext()).toEqual({ organizationId: "org-async" }); + }); + }); + + it("isolates concurrent contexts from each other", async () => { + const seenInA: unknown[] = []; + const seenInB: unknown[] = []; + + await Promise.all([ + runWithOrgContext("org-a", async () => { + await new Promise((resolve) => setTimeout(resolve, 5)); + seenInA.push(getOrgContext()); + }), + runWithOrgContext("org-b", async () => { + await new Promise((resolve) => setTimeout(resolve, 1)); + seenInB.push(getOrgContext()); + }), + ]); + + expect(seenInA).toEqual([{ organizationId: "org-a" }]); + expect(seenInB).toEqual([{ organizationId: "org-b" }]); + }); + + it("marks the unscoped escape hatch distinctly from a normal org context", async () => { + await runWithoutOrgScope(() => { + const context = getOrgContext(); + expect(context).toBeDefined(); + expect(isUnscopedContext(context!)).toBe(true); + }); + + await runWithOrgContext("org-1", () => { + const context = getOrgContext(); + expect(isUnscopedContext(context!)).toBe(false); + }); + }); + + /** + * A "lazy thenable" that, like Prisma's client methods, does nothing + * until something actually calls `.then()` on it — unlike a plain + * `Promise`, which begins settling as soon as it's constructed. This + * mirrors the behavior `runWithOrgContext` must consume before restoring + * the previous context. + */ + function createLazyThenable(resolve: () => T): PromiseLike { + return { + then( + onFulfilled?: ((value: T) => TResult1 | PromiseLike) | null, + onRejected?: + | ((reason: unknown) => TResult2 | PromiseLike) + | null, + ): PromiseLike { + return Promise.resolve().then(resolve).then(onFulfilled, onRejected); + }, + }; + } + + it("preserves the bound context while consuming a returned lazy thenable", async () => { + const observed: unknown[] = []; + const result = await runWithOrgContext("org-1", () => + createLazyThenable(() => { + observed.push(getOrgContext()); + return "resolved"; + }), + ); + + expect(result).toBe("resolved"); + expect(observed).toEqual([{ organizationId: "org-1" }]); + expect(getOrgContext()).toBeUndefined(); + }); + + it("preserves the bound context when the callback is async and awaits the lazy thenable itself", async () => { + const observed: unknown[] = []; + await runWithOrgContext("org-1", async () => { + const thenable = createLazyThenable(() => observed.push(getOrgContext())); + await thenable; + }); + + expect(observed).toEqual([{ organizationId: "org-1" }]); + }); + + it("nesting runWithOrgContext inside runWithoutOrgScope re-applies scoping for the inner block", async () => { + await runWithoutOrgScope(async () => { + expect(isUnscopedContext(getOrgContext()!)).toBe(true); + await runWithOrgContext("org-nested", () => { + expect(getOrgContext()).toEqual({ organizationId: "org-nested" }); + }); + expect(isUnscopedContext(getOrgContext()!)).toBe(true); + }); + }); + + it("preserves the unscoped context while consuming a returned lazy thenable", async () => { + const observed: unknown[] = []; + const result = await runWithoutOrgScope(() => + createLazyThenable(() => { + observed.push(getOrgContext()); + return "resolved"; + }), + ); + + expect(result).toBe("resolved"); + expect(observed).toEqual([{ unscoped: true }]); + expect(getOrgContext()).toBeUndefined(); + }); +}); diff --git a/packages/db/src/org-scope/context.ts b/packages/db/src/org-scope/context.ts new file mode 100644 index 0000000..04217ef --- /dev/null +++ b/packages/db/src/org-scope/context.ts @@ -0,0 +1,75 @@ +import { AsyncLocalStorage } from "node:async_hooks"; + +/** + * Active tenant scope for the current request/operation. + */ +export interface OrgContext { + readonly organizationId: string; +} + +/** + * Explicit marker for code paths that legitimately need to query across + * organizations (see {@link runWithoutOrgScope}). + */ +export interface UnscopedContext { + readonly unscoped: true; +} + +export type OrgContextStore = OrgContext | UnscopedContext; + +const storage = new AsyncLocalStorage(); + +/** + * Runs `fn` with `organizationId` bound as the active tenant scope for every + * org-scoped Prisma query made during its execution (including inside + * awaited async continuations). This is the only supported way to make + * org-scoped queries succeed — there is no ambient "current organization" + * outside of this call. + * + * The callback may return a Promise or a lazy Prisma thenable directly; + * `runWithOrgContext` consumes it before leaving the active context: + * + * ```ts + * const users = await runWithOrgContext(orgId, () => db.user.findMany()); + * ``` + */ +export async function runWithOrgContext( + organizationId: string, + fn: () => T | PromiseLike, +): Promise { + return storage.run({ organizationId }, async () => await fn()); +} + +/** + * Escape hatch for pre-authentication / system code paths that legitimately + * need to query across organizations (e.g. resolving which Organization owns + * an email domain during login, before the caller's org is known). + * + * Every call site is a deliberate, auditable exception to org isolation — + * grep for `runWithoutOrgScope` in review and justify each usage in a + * neighboring comment. + * + * Like {@link runWithOrgContext}, this helper is async and consumes a + * returned Promise or lazy Prisma thenable before restoring the previous + * context, so a bare `() => db.user.findUnique(...)` is safe: + * + * ```ts + * const user = await runWithoutOrgScope(() => db.user.findUnique({ where })); + * ``` + */ +export async function runWithoutOrgScope( + fn: () => T | PromiseLike, +): Promise { + return storage.run({ unscoped: true }, async () => await fn()); +} + +/** Returns the active org context, or `undefined` if none has been set. */ +export function getOrgContext(): OrgContextStore | undefined { + return storage.getStore(); +} + +export function isUnscopedContext( + context: OrgContextStore, +): context is UnscopedContext { + return "unscoped" in context && context.unscoped === true; +} diff --git a/packages/db/src/org-scope/errors.ts b/packages/db/src/org-scope/errors.ts new file mode 100644 index 0000000..04d8475 --- /dev/null +++ b/packages/db/src/org-scope/errors.ts @@ -0,0 +1,50 @@ +/** + * Thrown when an org-scoped Prisma query is attempted with no active org + * context (see `runWithOrgContext` / `runWithoutOrgScope`). This indicates a + * programming error — a code path that reaches the database without going + * through the request-scoping middleware — and is intentionally distinct + * from {@link OrgScopeViolationError}, which represents a legitimate, + * authenticated attempt to cross a tenant boundary. + */ +export class MissingOrgContextError extends Error { + constructor(model: string, operation: string) { + super( + `Org-scoped query blocked: no organization context is active for ` + + `${model}.${operation}(). Wrap this call in runWithOrgContext(...) ` + + "or, for justified system paths, runWithoutOrgScope(...).", + ); + this.name = "MissingOrgContextError"; + } +} + +/** + * Thrown when an authenticated caller's org context does not match the + * organization that owns the record(s) being accessed or referenced. API + * layers should map this to 403 Forbidden (or 404 Not Found, where existence + * itself should not be disclosed). + */ +export class OrgScopeViolationError extends Error { + constructor(model: string, operation: string) { + super( + `Org-scoped query blocked: ${model}.${operation}() referenced a ` + + "record outside the caller's organization.", + ); + this.name = "OrgScopeViolationError"; + } +} + +/** + * Thrown when a model has no registered org-scoping strategy. New models + * must be added to `ORG_SCOPE_CONFIG` before they can be queried through the + * org-scoped client — this keeps scoping mandatory rather than opt-in as the + * schema grows. + */ +export class UnknownOrgScopeModelError extends Error { + constructor(model: string) { + super( + `Org-scoped query blocked: model "${model}" has no registered org ` + + "scoping strategy in ORG_SCOPE_CONFIG.", + ); + this.name = "UnknownOrgScopeModelError"; + } +} diff --git a/packages/db/src/org-scope/extension.spec.ts b/packages/db/src/org-scope/extension.spec.ts new file mode 100644 index 0000000..2a48b86 --- /dev/null +++ b/packages/db/src/org-scope/extension.spec.ts @@ -0,0 +1,448 @@ +import { createOrgScopedClient } from "./extension.js"; +import { runWithOrgContext, runWithoutOrgScope } from "./context.js"; +import { MissingOrgContextError, OrgScopeViolationError } from "./errors.js"; + +type Impl = (args: unknown) => Promise; +type ModelImpls = Record>; + +interface AllOperationsParams { + model?: string; + operation: string; + args: unknown; + query: Impl; +} +interface RawQueryParams { + args: unknown; + query: Impl; +} +interface FakeExtension { + query: { + $allModels: { + $allOperations(params: AllOperationsParams): Promise; + }; + $queryRaw?: (params: RawQueryParams) => Promise; + $executeRaw?: (params: RawQueryParams) => Promise; + $queryRawUnsafe?: (params: RawQueryParams) => Promise; + $executeRawUnsafe?: (params: RawQueryParams) => Promise; + }; +} + +/** Structural shape of the fake client: `$extends` plus arbitrary delegates. */ +type FakeClient = { + $extends(extension: FakeExtension): FakeClient; + [key: string]: unknown; +}; + +/** Invokes a model operation on the fake client, asserting it was registered. */ +function callModel( + client: FakeClient, + model: string, + operation: string, + args: unknown, +): Promise { + const delegate = client[model] as Record | undefined; + const impl = delegate?.[operation]; + if (!impl) { + throw new Error(`Fake client has no ${model}.${operation}() registered`); + } + return impl(args); +} + +function getNamedRawHandler( + query: FakeExtension["query"], + operation: string, +): ((params: RawQueryParams) => Promise) | undefined { + if ( + operation === "$queryRaw" || + operation === "$executeRaw" || + operation === "$queryRawUnsafe" || + operation === "$executeRawUnsafe" + ) { + return query[operation]; + } + return undefined; +} + +function callClientOperation( + client: FakeClient, + operation: string, + args: unknown, +): Promise { + const impl = client[operation] as Impl | undefined; + if (!impl) { + throw new Error(`Fake client has no ${operation}() registered`); + } + return impl(args); +} + +/** + * A minimal fake Prisma client implementing just enough of the real + * `$extends` contract to exercise `createOrgScopedClient` without a live + * database: calling a model delegate method routes through the registered + * `$allOperations` middleware exactly as the real Prisma runtime does, + * passing the underlying implementation through as `query`. Client-level + * raw-SQL methods (`$queryRaw`, …) route through the matching named + * handler when present, otherwise through `$allOperations` with no model. + */ +function createFakeBaseClient( + modelImpls: ModelImpls, + clientImpls: Record = {}, +) { + return { + $extends(extension: FakeExtension) { + const scoped: Record = {}; + for (const [modelDelegate, ops] of Object.entries(modelImpls)) { + const model = + modelDelegate.charAt(0).toUpperCase() + modelDelegate.slice(1); + const nextOps: Record = {}; + for (const [operation, impl] of Object.entries(ops)) { + nextOps[operation] = (args: unknown) => + extension.query.$allModels.$allOperations({ + model, + operation, + args, + query: impl, + }); + } + scoped[modelDelegate] = nextOps; + } + for (const [operation, impl] of Object.entries(clientImpls)) { + const namedHandler = getNamedRawHandler(extension.query, operation); + scoped[operation] = (args: unknown) => { + if (namedHandler) { + return namedHandler({ args, query: impl }); + } + return extension.query.$allModels.$allOperations({ + operation, + args, + query: impl, + }); + }; + } + return scoped; + }, + }; +} + +describe("createOrgScopedClient", () => { + it("throws MissingOrgContextError and never calls the underlying query when no context is active", async () => { + const underlying = jest.fn(); + const client = createOrgScopedClient( + createFakeBaseClient({ + department: { findMany: underlying }, + }) as unknown as FakeClient, + ); + + await expect( + callModel(client, "department", "findMany", {}), + ).rejects.toThrow(MissingOrgContextError); + expect(underlying).not.toHaveBeenCalled(); + }); + + it("injects the org filter into a direct-scoped model's read args", async () => { + const underlying = jest.fn().mockResolvedValue([{ id: "dept-1" }]); + const client = createOrgScopedClient( + createFakeBaseClient({ + department: { findMany: underlying }, + }) as unknown as FakeClient, + ); + + const result = await runWithOrgContext("org-1", () => + callModel(client, "department", "findMany", { where: { name: "Eng" } }), + ); + + expect(result).toEqual([{ id: "dept-1" }]); + expect(underlying).toHaveBeenCalledWith({ + where: { AND: [{ name: "Eng" }, { organizationId: "org-1" }] }, + }); + }); + + it("forces organizationId on create, ignoring a caller-supplied value", async () => { + const underlying = jest.fn().mockResolvedValue({ id: "dept-1" }); + const client = createOrgScopedClient( + createFakeBaseClient({ + department: { create: underlying }, + }) as unknown as FakeClient, + ); + + await runWithOrgContext("org-1", () => + callModel(client, "department", "create", { + data: { name: "Eng", organizationId: "attacker-org" }, + }), + ); + + expect(underlying).toHaveBeenCalledWith({ + data: { name: "Eng", organizationId: "org-1" }, + }); + }); + + it("scopes the self-referential Organization model to the caller's own id", async () => { + const underlying = jest.fn().mockResolvedValue([{ id: "org-1" }]); + const client = createOrgScopedClient( + createFakeBaseClient({ + organization: { findMany: underlying }, + }) as unknown as FakeClient, + ); + + await runWithOrgContext("org-1", () => + callModel(client, "organization", "findMany", {}), + ); + + expect(underlying).toHaveBeenCalledWith({ where: { id: "org-1" } }); + }); + + it("bypasses scoping entirely inside runWithoutOrgScope", async () => { + const underlying = jest.fn().mockResolvedValue({ id: "org-2" }); + const client = createOrgScopedClient( + createFakeBaseClient({ + organization: { findUnique: underlying }, + }) as unknown as FakeClient, + ); + + await runWithoutOrgScope(() => + callModel(client, "organization", "findUnique", { + where: { name: "example.com" }, + }), + ); + + expect(underlying).toHaveBeenCalledWith({ where: { name: "example.com" } }); + }); + + it("allows a relation-scoped create when the referenced parent belongs to the caller's org", async () => { + const findUser = jest + .fn() + .mockResolvedValue({ id: "user-1", organizationId: "org-1" }); + const findDepartment = jest + .fn() + .mockResolvedValue({ id: "dept-1", organizationId: "org-1" }); + const createUserDepartment = jest.fn().mockResolvedValue({ id: "ud-1" }); + const client = createOrgScopedClient( + createFakeBaseClient({ + user: { findUnique: findUser }, + department: { findUnique: findDepartment }, + userDepartment: { create: createUserDepartment }, + }) as unknown as FakeClient, + ); + + await runWithOrgContext("org-1", () => + callModel(client, "userDepartment", "create", { + data: { userId: "user-1", departmentId: "dept-1" }, + }), + ); + + // The verification lookup runs through the same org-scoped client, so + // it is itself scoped to the caller's organization (flat merge, since + // findUnique requires the unique `id` field to stay top-level). + expect(findUser).toHaveBeenCalledWith({ + where: { id: "user-1", organizationId: "org-1" }, + }); + expect(findDepartment).toHaveBeenCalledWith({ + where: { id: "dept-1", organizationId: "org-1" }, + }); + expect(createUserDepartment).toHaveBeenCalledWith({ + data: { userId: "user-1", departmentId: "dept-1" }, + }); + }); + + it("rejects a relation-scoped create when the referenced parent belongs to another org", async () => { + // The recursive `user.findUnique` lookup is itself org-scoped, so a + // foreign-org user id resolves to nothing under the caller's context. + const findUnique = jest.fn().mockResolvedValue(null); + const createUserDepartment = jest.fn(); + const client = createOrgScopedClient( + createFakeBaseClient({ + user: { findUnique }, + department: { + findUnique: jest.fn().mockResolvedValue({ id: "dept-1" }), + }, + userDepartment: { create: createUserDepartment }, + }) as unknown as FakeClient, + ); + + await expect( + runWithOrgContext("org-1", () => + callModel(client, "userDepartment", "create", { + data: { userId: "cross-org-user", departmentId: "dept-1" }, + }), + ), + ).rejects.toThrow(OrgScopeViolationError); + + expect(createUserDepartment).not.toHaveBeenCalled(); + }); + + it("rejects an in-scope user paired with another organization's department", async () => { + const findUser = jest.fn().mockResolvedValue({ id: "user-1" }); + const findDepartment = jest.fn().mockResolvedValue(null); + const createUserDepartment = jest.fn(); + const client = createOrgScopedClient( + createFakeBaseClient({ + user: { findUnique: findUser }, + department: { findUnique: findDepartment }, + userDepartment: { create: createUserDepartment }, + }) as unknown as FakeClient, + ); + + await expect( + runWithOrgContext("org-1", () => + callModel(client, "userDepartment", "create", { + data: { userId: "user-1", departmentId: "cross-org-dept" }, + }), + ), + ).rejects.toThrow(OrgScopeViolationError); + + expect(findUser).toHaveBeenCalledWith({ + where: { id: "user-1", organizationId: "org-1" }, + }); + expect(findDepartment).toHaveBeenCalledWith({ + where: { id: "cross-org-dept", organizationId: "org-1" }, + }); + expect(createUserDepartment).not.toHaveBeenCalled(); + }); + + it.each([ + ["create", { data: { source: { connect: { id: "cross-org-source" } } } }], + [ + "update", + { + where: { id: "document-1" }, + data: { source: { connect: { id: "cross-org-source" } } }, + }, + ], + [ + "upsert create branch", + { + where: { id: "document-1" }, + create: { source: { connect: { id: "cross-org-source" } } }, + update: { sourceId: "source-1" }, + }, + ], + [ + "upsert update branch", + { + where: { id: "document-1" }, + create: { sourceId: "source-1" }, + update: { source: { connect: { id: "cross-org-source" } } }, + }, + ], + ])( + "rejects cross-organization nested connect during %s", + async (label, args) => { + const operation = label.startsWith("upsert") ? "upsert" : label; + const findSource = jest.fn(async (input: unknown) => { + const { where } = input as { where: { id?: string } }; + return where.id === "source-1" ? { id: "source-1" } : null; + }); + const writeDocument = jest.fn(); + const client = createOrgScopedClient( + createFakeBaseClient({ + source: { findUnique: findSource }, + document: { [operation]: writeDocument }, + }) as unknown as FakeClient, + ); + + await expect( + runWithOrgContext("org-1", () => + callModel(client, "document", operation, args), + ), + ).rejects.toThrow(OrgScopeViolationError); + expect(findSource).toHaveBeenCalledWith({ + where: { id: "cross-org-source", organizationId: "org-1" }, + }); + expect(writeDocument).not.toHaveBeenCalled(); + }, + ); + + it("allows an in-scope nested connect during create", async () => { + const findSource = jest.fn().mockResolvedValue({ id: "source-1" }); + const createDocument = jest.fn().mockResolvedValue({ id: "document-1" }); + const client = createOrgScopedClient( + createFakeBaseClient({ + source: { findUnique: findSource }, + document: { create: createDocument }, + }) as unknown as FakeClient, + ); + + await runWithOrgContext("org-1", () => + callModel(client, "document", "create", { + data: { source: { connect: { id: "source-1" } } }, + }), + ); + + expect(findSource).toHaveBeenCalledWith({ + where: { id: "source-1", organizationId: "org-1" }, + }); + expect(createDocument).toHaveBeenCalledWith({ + data: { source: { connect: { id: "source-1" } } }, + }); + }); + + it("keeps concurrent requests for different organizations isolated", async () => { + const underlying = jest.fn(async (args: unknown) => args); + const client = createOrgScopedClient( + createFakeBaseClient({ + department: { findMany: underlying }, + }) as unknown as FakeClient, + ); + + const [resultA, resultB] = await Promise.all([ + runWithOrgContext("org-a", () => + callModel(client, "department", "findMany", {}), + ), + runWithOrgContext("org-b", () => + callModel(client, "department", "findMany", {}), + ), + ]); + + expect(resultA).toEqual({ where: { organizationId: "org-a" } }); + expect(resultB).toEqual({ where: { organizationId: "org-b" } }); + }); + + it("rejects $queryRaw when a tenant context is active", async () => { + const underlying = jest.fn(); + const client = createOrgScopedClient( + createFakeBaseClient( + {}, + { $queryRaw: underlying }, + ) as unknown as FakeClient, + ); + + await expect( + runWithOrgContext("org-1", () => + callClientOperation(client, "$queryRaw", "SELECT 1"), + ), + ).rejects.toThrow(OrgScopeViolationError); + expect(underlying).not.toHaveBeenCalled(); + }); + + it("rejects $queryRaw when no org context is active", async () => { + const underlying = jest.fn(); + const client = createOrgScopedClient( + createFakeBaseClient( + {}, + { $queryRaw: underlying }, + ) as unknown as FakeClient, + ); + + await expect( + callClientOperation(client, "$queryRaw", "SELECT 1"), + ).rejects.toThrow(MissingOrgContextError); + expect(underlying).not.toHaveBeenCalled(); + }); + + it("allows $queryRaw inside runWithoutOrgScope", async () => { + const underlying = jest.fn().mockResolvedValue([{ ok: 1 }]); + const client = createOrgScopedClient( + createFakeBaseClient( + {}, + { $queryRaw: underlying }, + ) as unknown as FakeClient, + ); + + const result = await runWithoutOrgScope(() => + callClientOperation(client, "$queryRaw", "SELECT 1"), + ); + + expect(result).toEqual([{ ok: 1 }]); + expect(underlying).toHaveBeenCalledWith("SELECT 1"); + }); +}); diff --git a/packages/db/src/org-scope/extension.ts b/packages/db/src/org-scope/extension.ts new file mode 100644 index 0000000..a50d3b3 --- /dev/null +++ b/packages/db/src/org-scope/extension.ts @@ -0,0 +1,144 @@ +import { getOrgContext, isUnscopedContext } from "./context.js"; +import { MissingOrgContextError, OrgScopeViolationError } from "./errors.js"; +import { + computeScopedArgs, + getScopeConfig, + RELATION_WRITE_OPERATIONS, +} from "./scope-args.js"; +import type { OwnershipCheckClient } from "./verify-relation.js"; +import { verifyRelationOwnership } from "./verify-relation.js"; + +interface AllOperationsParams { + readonly model?: string; + readonly operation: string; + readonly args: unknown; + readonly query: (args: unknown) => Promise; +} + +interface RawQueryParams { + readonly args: unknown; + readonly query: (args: unknown) => Promise; +} + +/** + * Narrow structural type for the subset of the Prisma Client extension API + * this module relies on. The real `$extends` signature is a deeply generic + * type tied to a specific client's generated model map; matching it exactly + * here would couple this package's scoping logic to Prisma's internal + * typings for no behavioral benefit; the wrapper is verified instead by the + * pure unit tests in `scope-args.spec.ts` / `where.spec.ts` plus manual + * end-to-end verification against a live database. + */ +interface ExtensibleClient { + $extends(extension: { + name: string; + query: { + $allModels: { + $allOperations(params: AllOperationsParams): Promise; + }; + $queryRaw: (params: RawQueryParams) => Promise; + $executeRaw: (params: RawQueryParams) => Promise; + $queryRawUnsafe: (params: RawQueryParams) => Promise; + $executeRawUnsafe: (params: RawQueryParams) => Promise; + }; + }): unknown; +} + +/** + * Raw SQL cannot carry an org filter, so it is forbidden in a tenant + * context. The unscoped login/system hatch is the only allowed path. + */ +async function enforceClientOperation( + operation: string, + args: unknown, + query: (args: unknown) => Promise, +): Promise { + const context = getOrgContext(); + if (!context) { + throw new MissingOrgContextError("PrismaClient", operation); + } + if (isUnscopedContext(context)) { + return query(args); + } + throw new OrgScopeViolationError("PrismaClient", operation); +} + +/** + * Wraps a Prisma client so every model query is automatically filtered (and, + * for writes, force-assigned) to the organization bound by + * `runWithOrgContext`. There is no way to obtain an unscoped model delegate + * from the returned client, and any query made with no active org context + * throws `MissingOrgContextError` — org scoping is enforced at the query + * layer for every call, not opted into per call site. + * + * The generic `T` is preserved (via a cast) so the returned value keeps the + * full delegate surface of the original client (`.user`, `.department`, + * `$transaction`, ...). Raw SQL (`$queryRaw` / `$executeRaw` / unsafe + * variants) is rejected unless the caller is inside `runWithoutOrgScope`, + * because those operations cannot be rewritten with an org filter. + */ +export function createOrgScopedClient( + baseClient: T, +): T { + let scopedClient: T; + + scopedClient = baseClient.$extends({ + name: "org-scope", + query: { + $queryRaw: ({ args, query }: RawQueryParams) => + enforceClientOperation("$queryRaw", args, query), + $executeRaw: ({ args, query }: RawQueryParams) => + enforceClientOperation("$executeRaw", args, query), + $queryRawUnsafe: ({ args, query }: RawQueryParams) => + enforceClientOperation("$queryRawUnsafe", args, query), + $executeRawUnsafe: ({ args, query }: RawQueryParams) => + enforceClientOperation("$executeRawUnsafe", args, query), + $allModels: { + async $allOperations(params: AllOperationsParams): Promise { + const { model, operation, args, query } = params; + if (!model) { + return enforceClientOperation(operation, args, query); + } + + const context = getOrgContext(); + if (!context) { + throw new MissingOrgContextError(model, operation); + } + if (isUnscopedContext(context)) { + return query(args); + } + + const { organizationId } = context; + const scopedArgs = computeScopedArgs( + { + model, + operation, + args: args as Record | undefined, + }, + organizationId, + ); + + const config = getScopeConfig(model); + if ( + config.kind === "relation" && + RELATION_WRITE_OPERATIONS.has(operation) + ) { + await verifyRelationOwnership( + model, + operation, + scopedArgs, + config.verifyVia, + // The extended client itself is org-scoped, so this lookup is + // recursively subject to the same enforcement. + scopedClient as unknown as OwnershipCheckClient, + ); + } + + return query(scopedArgs); + }, + }, + }, + }) as T; + + return scopedClient; +} diff --git a/packages/db/src/org-scope/index.ts b/packages/db/src/org-scope/index.ts new file mode 100644 index 0000000..4d384b9 --- /dev/null +++ b/packages/db/src/org-scope/index.ts @@ -0,0 +1,25 @@ +export { + runWithOrgContext, + runWithoutOrgScope, + getOrgContext, + isUnscopedContext, +} from "./context.js"; +export type { + OrgContext, + UnscopedContext, + OrgContextStore, +} from "./context.js"; + +export { + MissingOrgContextError, + OrgScopeViolationError, + UnknownOrgScopeModelError, +} from "./errors.js"; + +export { ORG_SCOPE_CONFIG } from "./config.js"; +export type { OrgScopeConfig, RelationVerification } from "./config.js"; + +export { createOrgScopedClient } from "./extension.js"; + +export { computeScopedArgs, getScopeConfig } from "./scope-args.js"; +export type { ScopeArgsInput } from "./scope-args.js"; diff --git a/packages/db/src/org-scope/scope-args.spec.ts b/packages/db/src/org-scope/scope-args.spec.ts new file mode 100644 index 0000000..548bad4 --- /dev/null +++ b/packages/db/src/org-scope/scope-args.spec.ts @@ -0,0 +1,323 @@ +import { computeScopedArgs, getScopeConfig } from "./scope-args.js"; +import { UnknownOrgScopeModelError } from "./errors.js"; + +const ORG_ID = "org-1"; + +describe("getScopeConfig", () => { + it("returns the registered config for a known model", () => { + expect(getScopeConfig("User")).toEqual({ + kind: "direct", + field: "organizationId", + }); + }); + + it("throws UnknownOrgScopeModelError for an unregistered model", () => { + expect(() => getScopeConfig("NotAModel")).toThrow( + UnknownOrgScopeModelError, + ); + }); +}); + +describe("computeScopedArgs — self scope (Organization)", () => { + it("scopes findMany to the caller's own organization id", () => { + const result = computeScopedArgs( + { model: "Organization", operation: "findMany", args: {} }, + ORG_ID, + ); + expect(result["where"]).toEqual({ id: ORG_ID }); + }); + + it("forces findUnique's id to the caller's own organization, regardless of what was requested", () => { + // Prisma's WhereUniqueInput requires `id` to stay a direct top-level + // field (AND-wrapping fails validation), so the scope filter overrides + // the requested id in place rather than nesting a second constraint. + const result = computeScopedArgs( + { + model: "Organization", + operation: "findUnique", + args: { where: { id: "some-other-org" } }, + }, + ORG_ID, + ); + expect(result["where"]).toEqual({ id: ORG_ID }); + }); + + it("forces update's id to the caller's own organization, regardless of what was requested", () => { + const result = computeScopedArgs( + { + model: "Organization", + operation: "update", + args: { where: { id: "any-id" }, data: { name: "New Name" } }, + }, + ORG_ID, + ); + expect(result["where"]).toEqual({ id: ORG_ID }); + }); + + it("leaves create untouched (no existing org to scope against)", () => { + const args = { data: { name: "New Org" } }; + const result = computeScopedArgs( + { model: "Organization", operation: "create", args }, + ORG_ID, + ); + expect(result).toEqual(args); + }); + + it("throws on an unrecognized operation", () => { + expect(() => + computeScopedArgs( + { model: "Organization", operation: "executeRaw", args: {} }, + ORG_ID, + ), + ).toThrow(/not a recognized read\/write operation/); + }); +}); + +describe("computeScopedArgs — direct scope (User, Department, ...)", () => { + it("forces organizationId on create, overriding any caller-supplied value", () => { + const result = computeScopedArgs( + { + model: "Department", + operation: "create", + args: { data: { name: "Eng", organizationId: "attacker-org" } }, + }, + ORG_ID, + ); + expect(result["data"]).toEqual({ name: "Eng", organizationId: ORG_ID }); + }); + + it("forces organizationId on every item of createMany", () => { + const result = computeScopedArgs( + { + model: "Department", + operation: "createMany", + args: { + data: [ + { name: "Eng", organizationId: "attacker-org" }, + { name: "Sales" }, + ], + }, + }, + ORG_ID, + ); + expect(result["data"]).toEqual([ + { name: "Eng", organizationId: ORG_ID }, + { name: "Sales", organizationId: ORG_ID }, + ]); + }); + + it("normalizes scalar createMany data and forces organizationId", () => { + const result = computeScopedArgs( + { + model: "Department", + operation: "createMany", + args: { data: { name: "Eng", organizationId: "attacker-org" } }, + }, + ORG_ID, + ); + expect(result["data"]).toEqual([{ name: "Eng", organizationId: ORG_ID }]); + }); + + it("merges organizationId into where for findMany", () => { + const result = computeScopedArgs( + { + model: "Department", + operation: "findMany", + args: { where: { name: "Eng" } }, + }, + ORG_ID, + ); + expect(result["where"]).toEqual({ + AND: [{ name: "Eng" }, { organizationId: ORG_ID }], + }); + }); + + it("merges organizationId alongside id for findUnique (extended-where lookup, not AND-wrapped)", () => { + const result = computeScopedArgs( + { model: "User", operation: "findUnique", args: { where: { id: "u1" } } }, + ORG_ID, + ); + expect(result["where"]).toEqual({ id: "u1", organizationId: ORG_ID }); + }); + + it("scopes update's where (flat merge, not AND-wrapped) and strips organizationId from update data", () => { + const result = computeScopedArgs( + { + model: "Department", + operation: "update", + args: { + where: { id: "dept-1" }, + data: { name: "Renamed", organizationId: "attacker-org" }, + }, + }, + ORG_ID, + ); + expect(result["where"]).toEqual({ id: "dept-1", organizationId: ORG_ID }); + expect(result["data"]).toEqual({ name: "Renamed" }); + }); + + it("scopes updateMany and deleteMany where clauses", () => { + const updateResult = computeScopedArgs( + { + model: "Department", + operation: "updateMany", + args: { where: { name: "Eng" }, data: { name: "Engineering" } }, + }, + ORG_ID, + ); + expect(updateResult["where"]).toEqual({ + AND: [{ name: "Eng" }, { organizationId: ORG_ID }], + }); + + const deleteResult = computeScopedArgs( + { model: "Department", operation: "deleteMany", args: { where: {} } }, + ORG_ID, + ); + expect(deleteResult["where"]).toEqual({ organizationId: ORG_ID }); + }); + + it("scopes delete's where clause (flat merge, not AND-wrapped)", () => { + const result = computeScopedArgs( + { + model: "Department", + operation: "delete", + args: { where: { id: "dept-1" } }, + }, + ORG_ID, + ); + expect(result["where"]).toEqual({ id: "dept-1", organizationId: ORG_ID }); + }); + + it("scopes upsert: forces organizationId on create, scopes where (flat merge), strips it from update", () => { + const result = computeScopedArgs( + { + model: "Department", + operation: "upsert", + args: { + where: { id: "dept-1" }, + create: { name: "Eng", organizationId: "attacker-org" }, + update: { name: "Eng2", organizationId: "attacker-org" }, + }, + }, + ORG_ID, + ); + expect(result["where"]).toEqual({ id: "dept-1", organizationId: ORG_ID }); + expect(result["create"]).toEqual({ name: "Eng", organizationId: ORG_ID }); + expect(result["update"]).toEqual({ name: "Eng2" }); + }); + + it.each([ + [ + "create", + { data: { name: "Eng", organization: { connect: { id: ORG_ID } } } }, + ], + [ + "update", + { + where: { id: "dept-1" }, + data: { organization: { connect: { id: "other-org" } } }, + }, + ], + [ + "upsert", + { + where: { id: "dept-1" }, + create: { + name: "Eng", + organization: { connect: { id: "other-org" } }, + }, + update: { name: "Engineering" }, + }, + ], + [ + "upsert", + { + where: { id: "dept-1" }, + create: { name: "Eng" }, + update: { organization: { connect: { id: "other-org" } } }, + }, + ], + ])("rejects nested organization writes during %s", (operation, args) => { + expect(() => + computeScopedArgs({ model: "Department", operation, args }, ORG_ID), + ).toThrow(/outside the caller's organization/); + }); + + it("throws on an unrecognized operation", () => { + expect(() => + computeScopedArgs( + { model: "User", operation: "mystery", args: {} }, + ORG_ID, + ), + ).toThrow(/not a recognized read\/write operation/); + }); +}); + +describe("computeScopedArgs — relation scope (UserDepartment, Document, Chunk, QueryLog)", () => { + it("merges a single-hop relation filter for UserDepartment reads", () => { + const result = computeScopedArgs( + { + model: "UserDepartment", + operation: "findMany", + args: { where: { userId: "u1" } }, + }, + ORG_ID, + ); + expect(result["where"]).toEqual({ + AND: [{ userId: "u1" }, { user: { organizationId: ORG_ID } }], + }); + }); + + it("merges a multi-hop relation filter for Chunk reads", () => { + const result = computeScopedArgs( + { model: "Chunk", operation: "findMany", args: {} }, + ORG_ID, + ); + expect(result["where"]).toEqual({ + document: { source: { organizationId: ORG_ID } }, + }); + }); + + it("scopes updateMany/deleteMany via the relation filter", () => { + const result = computeScopedArgs( + { model: "QueryLog", operation: "deleteMany", args: {} }, + ORG_ID, + ); + expect(result["where"]).toEqual({ user: { organizationId: ORG_ID } }); + }); + + it("merges a relation filter alongside a unique identifier (flat merge, not AND-wrapped)", () => { + const result = computeScopedArgs( + { + model: "UserDepartment", + operation: "findUnique", + args: { + where: { userId_departmentId: { userId: "u1", departmentId: "d1" } }, + }, + }, + ORG_ID, + ); + expect(result["where"]).toEqual({ + userId_departmentId: { userId: "u1", departmentId: "d1" }, + user: { organizationId: ORG_ID }, + }); + }); + + it("leaves create/createMany args untouched (verified separately via a DB round trip)", () => { + const args = { data: { userId: "u1", departmentId: "d1" } }; + const result = computeScopedArgs( + { model: "UserDepartment", operation: "create", args }, + ORG_ID, + ); + expect(result).toEqual(args); + }); + + it("throws on an unrecognized operation", () => { + expect(() => + computeScopedArgs( + { model: "Document", operation: "mystery", args: {} }, + ORG_ID, + ), + ).toThrow(/not a recognized read\/write operation/); + }); +}); diff --git a/packages/db/src/org-scope/scope-args.ts b/packages/db/src/org-scope/scope-args.ts new file mode 100644 index 0000000..dd31999 --- /dev/null +++ b/packages/db/src/org-scope/scope-args.ts @@ -0,0 +1,229 @@ +import type { OrgScopeConfig } from "./config.js"; +import { ORG_SCOPE_CONFIG } from "./config.js"; +import { OrgScopeViolationError, UnknownOrgScopeModelError } from "./errors.js"; +import { + buildRelationFilter, + isPlainObject, + mergeUniqueWhere, + mergeWhere, + stripField, +} from "./where.js"; + +/** Accept Prisma's general `WhereInput` type — arbitrary AND/OR/NOT nesting is valid. */ +const GENERAL_WHERE_OPERATIONS = new Set([ + "findFirst", + "findFirstOrThrow", + "findMany", + "count", + "aggregate", + "groupBy", + "updateMany", + "deleteMany", +]); +/** + * Accept Prisma's `WhereUniqueInput` type — the unique identifier must stay + * a direct top-level field; wrapping it in `AND` fails Prisma's validation. + */ +const UNIQUE_WHERE_OPERATIONS = new Set([ + "findUnique", + "findUniqueOrThrow", + "update", + "delete", +]); +export const CREATE_OPERATIONS = new Set([ + "create", + "createMany", + "createManyAndReturn", +]); + +export const RELATION_WRITE_OPERATIONS = new Set([ + ...CREATE_OPERATIONS, + "update", + "updateMany", + "upsert", +]); + +const ALL_WHERE_FILTERED_OPERATIONS = new Set([ + ...GENERAL_WHERE_OPERATIONS, + ...UNIQUE_WHERE_OPERATIONS, + "upsert", +]); + +export interface ScopeArgsInput { + readonly model: string; + readonly operation: string; + readonly args: Record | undefined; +} + +/** + * Looks up a model's org-scoping strategy, failing closed (rather than + * silently passing the query through unscoped) when the model is + * unregistered. + */ +export function getScopeConfig(model: string): OrgScopeConfig { + const config = ORG_SCOPE_CONFIG[model]; + if (!config) { + throw new UnknownOrgScopeModelError(model); + } + return config; +} + +/** Merges a scope filter into `where` using the strategy the operation requires. */ +function mergeScopeIntoWhere( + operation: string, + where: unknown, + scopeFilter: Record, +): Record { + return UNIQUE_WHERE_OPERATIONS.has(operation) || operation === "upsert" + ? mergeUniqueWhere(where, scopeFilter) + : mergeWhere(where, scopeFilter); +} + +/** + * Pure transformation of Prisma query args to enforce org scoping. Contains + * no I/O and no Prisma runtime dependency, so it can be unit tested directly + * against plain objects. + * + * Relation-scoped `create`/`createMany` are intentionally left untouched + * here — verifying a foreign key belongs to the caller's org requires a + * database round trip, which `extension.ts` performs separately (see + * `verifyRelationOwnership`). + */ +export function computeScopedArgs( + { model, operation, args }: ScopeArgsInput, + organizationId: string, +): Record { + const config = getScopeConfig(model); + const nextArgs: Record = { ...(args ?? {}) }; + + switch (config.kind) { + case "self": + return applySelfScope(nextArgs, operation, organizationId, model); + case "direct": + return applyDirectScope( + nextArgs, + operation, + organizationId, + config.field, + model, + ); + case "relation": + return applyRelationScope(nextArgs, operation, organizationId, model); + default: { + const exhaustiveCheck: never = config; + return exhaustiveCheck; + } + } +} + +function applySelfScope( + args: Record, + operation: string, + organizationId: string, + model: string, +): Record { + if (CREATE_OPERATIONS.has(operation)) { + // Creating a brand-new Organization is not scoped to an existing one. + return args; + } + if (ALL_WHERE_FILTERED_OPERATIONS.has(operation)) { + args["where"] = mergeScopeIntoWhere(operation, args["where"], { + id: organizationId, + }); + return args; + } + throw unsupportedOperation(model, operation); +} + +function applyDirectScope( + args: Record, + operation: string, + organizationId: string, + field: string, + model: string, +): Record { + if (operation === "create") { + const data = isPlainObject(args["data"]) ? args["data"] : {}; + rejectNestedForeignKeyWrite(data, field, model, operation); + args["data"] = { ...data, [field]: organizationId }; + return args; + } + if (operation === "createMany" || operation === "createManyAndReturn") { + const items = Array.isArray(args["data"]) ? args["data"] : [args["data"]]; + args["data"] = items.map((item: unknown) => { + const data = isPlainObject(item) ? item : {}; + rejectNestedForeignKeyWrite(data, field, model, operation); + return { ...data, [field]: organizationId }; + }); + return args; + } + if (operation === "upsert") { + args["where"] = mergeScopeIntoWhere(operation, args["where"], { + [field]: organizationId, + }); + const create = isPlainObject(args["create"]) ? args["create"] : {}; + rejectNestedForeignKeyWrite(create, field, model, operation); + args["create"] = { ...create, [field]: organizationId }; + if (isPlainObject(args["update"])) { + rejectNestedForeignKeyWrite(args["update"], field, model, operation); + args["update"] = stripField(args["update"], field); + } + return args; + } + if (ALL_WHERE_FILTERED_OPERATIONS.has(operation)) { + args["where"] = mergeScopeIntoWhere(operation, args["where"], { + [field]: organizationId, + }); + // Never allow a scoped update to reassign a record to another org. + if (isPlainObject(args["data"])) { + rejectNestedForeignKeyWrite(args["data"], field, model, operation); + args["data"] = stripField(args["data"], field); + } + return args; + } + throw unsupportedOperation(model, operation); +} + +function rejectNestedForeignKeyWrite( + data: Record, + foreignKeyField: string, + model: string, + operation: string, +): void { + const relationField = foreignKeyField.endsWith("Id") + ? foreignKeyField.slice(0, -2) + : undefined; + if (relationField && relationField in data) { + throw new OrgScopeViolationError(model, operation); + } +} + +function applyRelationScope( + args: Record, + operation: string, + organizationId: string, + model: string, +): Record { + const config = getScopeConfig(model); + if (config.kind !== "relation") { + throw unsupportedOperation(model, operation); + } + if (CREATE_OPERATIONS.has(operation)) { + // Verified separately in extension.ts via a database round trip. + return args; + } + if (ALL_WHERE_FILTERED_OPERATIONS.has(operation)) { + const filter = buildRelationFilter(config.chain, organizationId); + args["where"] = mergeScopeIntoWhere(operation, args["where"], filter); + return args; + } + throw unsupportedOperation(model, operation); +} + +function unsupportedOperation(model: string, operation: string): Error { + return new Error( + `Org-scoped query blocked: "${model}.${operation}()" is not a ` + + "recognized read/write operation. Add explicit handling in " + + "scope-args.ts before using it on an org-scoped model.", + ); +} diff --git a/packages/db/src/org-scope/verify-relation.spec.ts b/packages/db/src/org-scope/verify-relation.spec.ts new file mode 100644 index 0000000..94d6672 --- /dev/null +++ b/packages/db/src/org-scope/verify-relation.spec.ts @@ -0,0 +1,233 @@ +import { verifyRelationOwnership } from "./verify-relation.js"; +import { OrgScopeViolationError } from "./errors.js"; +import type { RelationVerification } from "./config.js"; + +const verifyVia: readonly RelationVerification[] = [ + { foreignKeyField: "userId", parentModel: "User" }, + { foreignKeyField: "departmentId", parentModel: "Department" }, +]; + +const sourceVerifyVia: readonly RelationVerification[] = [ + { foreignKeyField: "sourceId", parentModel: "Source" }, +]; + +function makeClient( + existing: Partial< + Record<"user" | "department" | "source", readonly string[]> + >, +) { + const delegate = (model: "user" | "department" | "source") => ({ + findUnique: jest.fn( + async ({ where }: { where: Record }) => + existing[model]?.includes(String(where["id"])) ? { ...where } : null, + ), + }); + return { + user: delegate("user"), + department: delegate("department"), + source: delegate("source"), + }; +} + +describe("verifyRelationOwnership", () => { + it("no-ops for non-create operations", async () => { + const client = makeClient({}); + await expect( + verifyRelationOwnership( + "UserDepartment", + "findMany", + { where: {} }, + verifyVia, + client, + ), + ).resolves.toBeUndefined(); + expect(client.user.findUnique).not.toHaveBeenCalled(); + }); + + it("resolves when the referenced foreign key belongs to the caller's org", async () => { + const client = makeClient({ user: ["user-1"], department: ["dept-1"] }); + await expect( + verifyRelationOwnership( + "UserDepartment", + "create", + { data: { userId: "user-1", departmentId: "dept-1" } }, + verifyVia, + client, + ), + ).resolves.toBeUndefined(); + expect(client.user.findUnique).toHaveBeenCalledWith({ + where: { id: "user-1" }, + }); + expect(client.department.findUnique).toHaveBeenCalledWith({ + where: { id: "dept-1" }, + }); + }); + + it("throws OrgScopeViolationError when the foreign key resolves to nothing under org scope", async () => { + const client = makeClient({ department: ["dept-1"] }); + await expect( + verifyRelationOwnership( + "UserDepartment", + "create", + { data: { userId: "cross-org-user", departmentId: "dept-1" } }, + verifyVia, + client, + ), + ).rejects.toThrow(OrgScopeViolationError); + }); + + it("rejects an in-scope user paired with another organization's department", async () => { + const client = makeClient({ user: ["user-1"] }); + await expect( + verifyRelationOwnership( + "UserDepartment", + "create", + { data: { userId: "user-1", departmentId: "cross-org-dept" } }, + verifyVia, + client, + ), + ).rejects.toThrow(OrgScopeViolationError); + }); + + it("verifies every distinct foreign key across a createMany batch", async () => { + const client = makeClient({ + user: ["user-1", "user-2"], + department: ["d1", "d2", "d3"], + }); + await verifyRelationOwnership( + "UserDepartment", + "createMany", + { + data: [ + { userId: "user-1", departmentId: "d1" }, + { userId: "user-2", departmentId: "d2" }, + { userId: "user-1", departmentId: "d3" }, + ], + }, + verifyVia, + client, + ); + expect(client.user.findUnique).toHaveBeenCalledTimes(2); + }); + + it("rejects a createMany batch if any single item references a foreign org", async () => { + const client = makeClient({ + user: ["user-1"], + department: ["d1", "d2"], + }); + await expect( + verifyRelationOwnership( + "UserDepartment", + "createMany", + { + data: [ + { userId: "user-1", departmentId: "d1" }, + { userId: "cross-org-user", departmentId: "d2" }, + ], + }, + verifyVia, + client, + ), + ).rejects.toThrow(OrgScopeViolationError); + }); + + it("rejects scalar createMany data that references another organization", async () => { + const client = makeClient({}); + await expect( + verifyRelationOwnership( + "Document", + "createMany", + { data: { sourceId: "cross-org-source", content: "secret" } }, + sourceVerifyVia, + client, + ), + ).rejects.toThrow(OrgScopeViolationError); + expect(client.source.findUnique).toHaveBeenCalledWith({ + where: { id: "cross-org-source" }, + }); + }); + + it.each([ + ["create", { data: { source: { connect: { id: "cross-org-source" } } } }], + ["update", { data: { source: { connect: { id: "cross-org-source" } } } }], + [ + "upsert", + { + create: { source: { connect: { id: "source-1" } } }, + update: { source: { connect: { id: "cross-org-source" } } }, + }, + ], + ])( + "rejects cross-organization nested connect during %s", + async (operation, args) => { + const client = makeClient({ source: ["source-1"] }); + await expect( + verifyRelationOwnership( + "Document", + operation, + args, + sourceVerifyVia, + client, + ), + ).rejects.toThrow(OrgScopeViolationError); + }, + ); + + it("verifies scalar foreign-key set operations during update", async () => { + const client = makeClient({}); + await expect( + verifyRelationOwnership( + "Document", + "update", + { data: { sourceId: { set: "cross-org-source" } } }, + sourceVerifyVia, + client, + ), + ).rejects.toThrow(OrgScopeViolationError); + expect(client.source.findUnique).toHaveBeenCalledWith({ + where: { id: "cross-org-source" }, + }); + }); + + it("allows an in-scope scalar foreign-key update", async () => { + const client = makeClient({ source: ["source-1"] }); + await expect( + verifyRelationOwnership( + "Document", + "update", + { data: { sourceId: "source-1" } }, + sourceVerifyVia, + client, + ), + ).resolves.toBeUndefined(); + }); + + it("allows an in-scope nested connect during create", async () => { + const client = makeClient({ source: ["source-1"] }); + await expect( + verifyRelationOwnership( + "Document", + "create", + { data: { source: { connect: { id: "source-1" } } } }, + sourceVerifyVia, + client, + ), + ).resolves.toBeUndefined(); + expect(client.source.findUnique).toHaveBeenCalledWith({ + where: { id: "source-1" }, + }); + }); + + it("rejects nested parent mutations other than connect", async () => { + const client = makeClient({}); + await expect( + verifyRelationOwnership( + "Document", + "create", + { data: { source: { create: { name: "Bypass" } } } }, + sourceVerifyVia, + client, + ), + ).rejects.toThrow(OrgScopeViolationError); + }); +}); diff --git a/packages/db/src/org-scope/verify-relation.ts b/packages/db/src/org-scope/verify-relation.ts new file mode 100644 index 0000000..0b08b08 --- /dev/null +++ b/packages/db/src/org-scope/verify-relation.ts @@ -0,0 +1,118 @@ +import type { RelationVerification } from "./config.js"; +import { OrgScopeViolationError } from "./errors.js"; +import { isPlainObject } from "./where.js"; +import { RELATION_WRITE_OPERATIONS } from "./scope-args.js"; + +/** Minimal shape of an org-scoped Prisma client needed to verify a foreign key. */ +export interface OwnershipCheckClient { + [modelDelegate: string]: { + findUnique(args: { where: Record }): Promise; + }; +} + +function toModelDelegateName(model: string): string { + return model.charAt(0).toLowerCase() + model.slice(1); +} + +/** + * Verifies that every parent referenced by a relation-scoped write belongs + * to the caller's organization. Scalar foreign keys and nested `connect` + * payloads are looked up through the same org-scoped client. Other nested + * parent mutations are rejected because Prisma does not run query extensions + * separately for nested writes. + */ +export async function verifyRelationOwnership( + model: string, + operation: string, + args: Record, + verifications: readonly RelationVerification[], + client: OwnershipCheckClient, +): Promise { + if (!RELATION_WRITE_OPERATIONS.has(operation)) { + return; + } + + const items = getWriteItems(operation, args).filter(isPlainObject); + const checks: Array<{ + parentModel: string; + where: Record; + }> = []; + + for (const verification of verifications) { + const relationField = toModelDelegateName(verification.parentModel); + for (const item of items) { + const foreignKey = getForeignKey(item[verification.foreignKeyField]); + if (foreignKey !== undefined) { + checks.push({ + parentModel: verification.parentModel, + where: { id: foreignKey }, + }); + } + + if (!(relationField in item)) { + continue; + } + const relationWrite = item[relationField]; + if ( + !isPlainObject(relationWrite) || + Object.keys(relationWrite).some((key) => key !== "connect") || + !("connect" in relationWrite) + ) { + throw new OrgScopeViolationError(model, operation); + } + + const connects = Array.isArray(relationWrite["connect"]) + ? relationWrite["connect"] + : [relationWrite["connect"]]; + for (const where of connects) { + if (!isPlainObject(where)) { + throw new OrgScopeViolationError(model, operation); + } + checks.push({ parentModel: verification.parentModel, where }); + } + } + } + + const seen = new Set(); + const uniqueChecks = checks.filter(({ parentModel, where }) => { + const key = `${parentModel}:${JSON.stringify(where)}`; + if (seen.has(key)) { + return false; + } + seen.add(key); + return true; + }); + + await Promise.all( + uniqueChecks.map(async ({ parentModel, where }) => { + const parentDelegate = client[toModelDelegateName(parentModel)]; + const parent = await parentDelegate?.findUnique({ where }); + if (!parent) { + throw new OrgScopeViolationError(model, operation); + } + }), + ); +} + +function getForeignKey(value: unknown): string | undefined { + if (typeof value === "string") { + return value; + } + if (isPlainObject(value) && typeof value["set"] === "string") { + return value["set"]; + } + return undefined; +} + +function getWriteItems( + operation: string, + args: Record, +): unknown[] { + if (operation === "upsert") { + return [args["create"], args["update"]]; + } + if (operation === "createMany" || operation === "createManyAndReturn") { + return Array.isArray(args["data"]) ? args["data"] : [args["data"]]; + } + return [args["data"]]; +} diff --git a/packages/db/src/org-scope/where.spec.ts b/packages/db/src/org-scope/where.spec.ts new file mode 100644 index 0000000..62b446c --- /dev/null +++ b/packages/db/src/org-scope/where.spec.ts @@ -0,0 +1,139 @@ +import { + buildRelationFilter, + isPlainObject, + mergeUniqueWhere, + mergeWhere, + stripField, +} from "./where.js"; + +describe("isPlainObject", () => { + it("returns true for plain objects", () => { + expect(isPlainObject({})).toBe(true); + expect(isPlainObject({ a: 1 })).toBe(true); + }); + + it("returns false for arrays, null, and primitives", () => { + expect(isPlainObject([])).toBe(false); + expect(isPlainObject(null)).toBe(false); + expect(isPlainObject(undefined)).toBe(false); + expect(isPlainObject("x")).toBe(false); + expect(isPlainObject(1)).toBe(false); + }); +}); + +describe("buildRelationFilter", () => { + it("builds a single-level relation filter", () => { + expect(buildRelationFilter(["user", "organizationId"], "org-1")).toEqual({ + user: { organizationId: "org-1" }, + }); + }); + + it("builds a multi-level nested relation filter", () => { + expect( + buildRelationFilter(["document", "source", "organizationId"], "org-1"), + ).toEqual({ + document: { source: { organizationId: "org-1" } }, + }); + }); +}); + +describe("mergeWhere", () => { + const scopeFilter = { organizationId: "org-1" }; + + it("returns the scope filter directly when where is undefined", () => { + expect(mergeWhere(undefined, scopeFilter)).toEqual(scopeFilter); + }); + + it("returns the scope filter directly when where is an empty object", () => { + expect(mergeWhere({}, scopeFilter)).toEqual(scopeFilter); + }); + + it("returns the scope filter directly when where is not an object", () => { + expect(mergeWhere("not-an-object", scopeFilter)).toEqual(scopeFilter); + expect(mergeWhere(null, scopeFilter)).toEqual(scopeFilter); + }); + + it("wraps an existing where in AND alongside the scope filter", () => { + const callerWhere = { name: "Engineering" }; + expect(mergeWhere(callerWhere, scopeFilter)).toEqual({ + AND: [callerWhere, scopeFilter], + }); + }); + + it("never lets the scope filter be shadowed by a colliding caller key", () => { + const callerWhere = { organizationId: "attacker-org" }; + const result = mergeWhere(callerWhere, scopeFilter); + expect(result).toEqual({ AND: [callerWhere, scopeFilter] }); + // The scope filter is the last AND clause, so it always narrows the + // result regardless of what the caller supplied. + expect((result["AND"] as unknown[])[1]).toEqual(scopeFilter); + }); +}); + +describe("mergeUniqueWhere", () => { + const scopeFilter = { organizationId: "org-1" }; + + it("returns just the scope filter when where is undefined", () => { + expect(mergeUniqueWhere(undefined, scopeFilter)).toEqual(scopeFilter); + }); + + it("keeps the caller's unique identifier flat alongside the scope filter (no AND-wrapping)", () => { + // Prisma's WhereUniqueInput requires the unique field (e.g. `id`) to + // remain a direct top-level property — AND-wrapping fails validation. + const callerWhere = { id: "user-1" }; + expect(mergeUniqueWhere(callerWhere, scopeFilter)).toEqual({ + id: "user-1", + organizationId: "org-1", + }); + }); + + it("lets the scope filter win when a key collides with the caller's where", () => { + const callerWhere = { id: "org-1", organizationId: "attacker-org" }; + expect(mergeUniqueWhere(callerWhere, scopeFilter)).toEqual({ + id: "org-1", + organizationId: "org-1", + }); + }); + + it("overrides the requested id with the caller's own org id for self-scoped models", () => { + // For the `Organization` model, the scope filter's key IS the unique + // identifier (`id`), so this is how a mismatched request gets forced + // back onto the caller's own org rather than AND-wrapping (unsupported). + const callerWhere = { id: "some-other-org" }; + expect(mergeUniqueWhere(callerWhere, { id: "org-1" })).toEqual({ + id: "org-1", + }); + }); + + it("preserves relation filters alongside a unique identifier", () => { + const callerWhere = { + userId_departmentId: { userId: "u1", departmentId: "d1" }, + }; + const relationFilter = { user: { organizationId: "org-1" } }; + expect(mergeUniqueWhere(callerWhere, relationFilter)).toEqual({ + userId_departmentId: { userId: "u1", departmentId: "d1" }, + user: { organizationId: "org-1" }, + }); + }); +}); + +describe("stripField", () => { + it("removes the given field when present", () => { + expect( + stripField({ organizationId: "org-1", name: "x" }, "organizationId"), + ).toEqual({ + name: "x", + }); + }); + + it("returns the object unchanged (by value) when the field is absent", () => { + const data = { name: "x" }; + expect(stripField(data, "organizationId")).toEqual({ name: "x" }); + }); + + it("does not mutate the input object", () => { + const data = { organizationId: "org-1", name: "x" }; + stripField(data, "organizationId"); + expect(data).toEqual({ organizationId: "org-1", name: "x" }); + }); +}); diff --git a/packages/db/src/org-scope/where.ts b/packages/db/src/org-scope/where.ts new file mode 100644 index 0000000..5b60fc3 --- /dev/null +++ b/packages/db/src/org-scope/where.ts @@ -0,0 +1,78 @@ +/** Narrows an unknown value to a plain filter/data object Prisma would accept. */ +export function isPlainObject( + value: unknown, +): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** + * Builds a nested relation `where` filter from a chain of relation field + * names ending in a scalar field, e.g. `["document", "source", + * "organizationId"]` -> `{ document: { source: { organizationId: id } } }`. + */ +export function buildRelationFilter( + chain: readonly [string, ...string[]], + organizationId: string, +): Record { + const [head, ...rest] = chain; + if (rest.length === 0) { + return { [head]: organizationId }; + } + return { + [head]: buildRelationFilter(rest as [string, ...string[]], organizationId), + }; +} + +/** + * Merges an org-scoping filter into an existing (possibly absent) `where` + * clause for operations that accept Prisma's general `WhereInput` type + * (`findMany`, `findFirst`, `count`, `aggregate`, `groupBy`, `updateMany`, + * `deleteMany`). Uses `AND` rather than a shallow spread so the caller's + * filter can't accidentally be overwritten by a colliding key, and so the + * merge is safe regardless of what the caller's `where` contains. + */ +export function mergeWhere( + where: unknown, + scopeFilter: Record, +): Record { + if (!isPlainObject(where) || Object.keys(where).length === 0) { + return scopeFilter; + } + return { AND: [where, scopeFilter] }; +} + +/** + * Merges an org-scoping filter into a `where` clause for operations that + * accept Prisma's `WhereUniqueInput` type (`findUnique`, + * `findUniqueOrThrow`, `update`, `delete`, `upsert`). Prisma requires the + * unique identifier to remain a direct top-level field on `where` — wrapping + * it in `AND` fails validation ("needs at least one of `id` ... arguments"). + * Extra non-unique fields (including relation filters) are supported + * alongside it at the top level instead, so this does a shallow merge with + * the scope filter spread last, guaranteeing it always wins on a colliding + * key (e.g. a caller-supplied `organizationId`, or — for the + * self-referential `Organization` model, where the scope filter's key IS + * the unique identifier — the requested `id` itself). + */ +export function mergeUniqueWhere( + where: unknown, + scopeFilter: Record, +): Record { + if (!isPlainObject(where)) { + return { ...scopeFilter }; + } + return { ...where, ...scopeFilter }; +} + +/** Returns a shallow copy of `data` with `field` removed, if present. */ +export function stripField( + data: Record, + field: string, +): Record { + if (!(field in data)) { + return data; + } + const next = { ...data }; + delete next[field]; + return next; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2ab2600..fa593d9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -48,6 +48,9 @@ importers: '@nestjs/platform-express': specifier: ^11.1.21 version: 11.1.21(@nestjs/common@11.1.21(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.21) + '@prisma/adapter-pg': + specifier: 7.8.0 + version: 7.8.0 db: specifier: workspace:* version: link:../../packages/db @@ -66,6 +69,12 @@ importers: passport-jwt: specifier: ^4.0.1 version: 4.0.1 + pg: + specifier: ^8 + version: 8.23.0 + rxjs: + specifier: ^7.8.2 + version: 7.8.2 zod: specifier: ^4.4.3 version: 4.4.3 @@ -106,6 +115,9 @@ importers: '@types/passport-jwt': specifier: ^4.0.1 version: 4.0.1 + '@types/pg': + specifier: ^8.23.1 + version: 8.23.1 '@types/supertest': specifier: ^7.2.0 version: 7.2.0 @@ -208,9 +220,21 @@ importers: '@cortex/typescript-config': specifier: workspace:* version: link:../typescript-config + '@types/jest': + specifier: ^30.0.0 + version: 30.0.0 + '@types/node': + specifier: ^22.0.0 + version: 22.15.3 + jest: + specifier: ^30.4.2 + version: 30.4.2(@types/node@22.15.3) prisma: specifier: ^7.8.0 version: 7.8.0(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.2) + ts-jest: + specifier: ^29.4.11 + version: 29.4.11(@babel/core@7.29.7)(@jest/transform@30.4.1)(@jest/types@30.4.1)(babel-jest@30.4.1(@babel/core@7.29.7))(jest-util@30.4.1)(jest@30.4.2(@types/node@22.15.3))(typescript@5.9.2) typescript: specifier: 5.9.2 version: 5.9.2 @@ -1179,6 +1203,9 @@ packages: resolution: {integrity: sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==} engines: {node: ^14.18.0 || >=16.0.0} + '@prisma/adapter-pg@7.8.0': + resolution: {integrity: sha512-ygb3UkerK3v8MDpXVgCISdRNDozpxh6+JVJgiIGbSr5KBgz10LLf5ejUskPGoXlsIjxsOu6nuy1JVQr2EKGSlg==} + '@prisma/client-runtime-utils@7.8.0': resolution: {integrity: sha512-5NQZztQ0oY/ADFkmd9gPuweH5A1/CCY8YQPorLLO0Mu6a87mY5gsnDkzmFmIHs9NFaLnZojzgddFVN4RpKYrdw==} @@ -1206,6 +1233,9 @@ packages: '@prisma/dev@0.24.3': resolution: {integrity: sha512-ffHlQuKXZiaDt9Go0OnCTdJZrHxK0k7omJKNV86/VjpsXu5EIHZLK0T7JSWgvNlJwh56kW9JFu9v0qJciFzepg==} + '@prisma/driver-adapter-utils@7.8.0': + resolution: {integrity: sha512-/Q13o0ZT0rjc1Xk0Q9KhZYwuq2EW/vSbWUBKfgEKkaCuB/Sg6bqnjmTZqC5cD4d6y1vfFAEwBRzfzoSMIVJ55A==} + '@prisma/engines-version@7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a': resolution: {integrity: sha512-fJPQxCkLgA5EayWaW8eArgCvjJ+N+Kz3VyeNKMEeYiQC4alNkxRKFVAGxv/ZUzuJISKqdw+zGeDbS6mn6RCPOA==} @@ -1452,6 +1482,9 @@ packages: '@types/passport@1.0.17': resolution: {integrity: sha512-aciLyx+wDwT2t2/kJGJR2AEeBz0nJU4WuRX04Wu9Dqc5lSUtwu0WERPHYsLhF9PtseiAMPBGNUOtFjxZ56prsg==} + '@types/pg@8.23.1': + resolution: {integrity: sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==} + '@types/qs@6.15.1': resolution: {integrity: sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==} @@ -3543,6 +3576,40 @@ packages: perfect-debounce@2.1.0: resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==} + pg-cloudflare@1.4.0: + resolution: {integrity: sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==} + + pg-connection-string@2.14.0: + resolution: {integrity: sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==} + + pg-int8@1.0.1: + resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} + engines: {node: '>=4.0.0'} + + pg-pool@3.14.0: + resolution: {integrity: sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==} + peerDependencies: + pg: '>=8.0' + + pg-protocol@1.16.0: + resolution: {integrity: sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==} + + pg-types@2.2.0: + resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} + engines: {node: '>=4'} + + pg@8.23.0: + resolution: {integrity: sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==} + engines: {node: '>= 16.0.0'} + peerDependencies: + pg-native: '>=3.0.1' + peerDependenciesMeta: + pg-native: + optional: true + + pgpass@1.0.5: + resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -3585,6 +3652,26 @@ packages: resolution: {integrity: sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==} engines: {node: ^10 || ^12 || >=14} + postgres-array@2.0.0: + resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} + engines: {node: '>=4'} + + postgres-array@3.0.4: + resolution: {integrity: sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ==} + engines: {node: '>=12'} + + postgres-bytea@1.0.1: + resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==} + engines: {node: '>=0.10.0'} + + postgres-date@1.0.7: + resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} + engines: {node: '>=0.10.0'} + + postgres-interval@1.2.0: + resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} + engines: {node: '>=0.10.0'} + postgres@3.4.7: resolution: {integrity: sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==} engines: {node: '>=12'} @@ -3869,6 +3956,10 @@ packages: resolution: {integrity: sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==} engines: {node: '>= 8'} + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} @@ -4320,6 +4411,10 @@ packages: resolution: {integrity: sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -5337,6 +5432,15 @@ snapshots: '@pkgr/core@0.3.6': {} + '@prisma/adapter-pg@7.8.0': + dependencies: + '@prisma/driver-adapter-utils': 7.8.0 + '@types/pg': 8.23.1 + pg: 8.23.0 + postgres-array: 3.0.4 + transitivePeerDependencies: + - pg-native + '@prisma/client-runtime-utils@7.8.0': {} '@prisma/client@7.8.0(prisma@7.8.0(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.2))(typescript@5.9.2)': @@ -5381,6 +5485,10 @@ snapshots: transitivePeerDependencies: - typescript + '@prisma/driver-adapter-utils@7.8.0': + dependencies: + '@prisma/debug': 7.8.0 + '@prisma/engines-version@7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a': {} '@prisma/engines@7.8.0': @@ -5648,6 +5756,12 @@ snapshots: dependencies: '@types/express': 5.0.6 + '@types/pg@8.23.1': + dependencies: + '@types/node': 22.15.3 + pg-protocol: 1.16.0 + pg-types: 2.2.0 + '@types/qs@6.15.1': {} '@types/range-parser@1.2.7': {} @@ -8076,6 +8190,41 @@ snapshots: perfect-debounce@2.1.0: {} + pg-cloudflare@1.4.0: + optional: true + + pg-connection-string@2.14.0: {} + + pg-int8@1.0.1: {} + + pg-pool@3.14.0(pg@8.23.0): + dependencies: + pg: 8.23.0 + + pg-protocol@1.16.0: {} + + pg-types@2.2.0: + dependencies: + pg-int8: 1.0.1 + postgres-array: 2.0.0 + postgres-bytea: 1.0.1 + postgres-date: 1.0.7 + postgres-interval: 1.2.0 + + pg@8.23.0: + dependencies: + pg-connection-string: 2.14.0 + pg-pool: 3.14.0(pg@8.23.0) + pg-protocol: 1.16.0 + pg-types: 2.2.0 + pgpass: 1.0.5 + optionalDependencies: + pg-cloudflare: 1.4.0 + + pgpass@1.0.5: + dependencies: + split2: 4.2.0 + picocolors@1.1.1: {} picomatch@2.3.2: {} @@ -8108,6 +8257,18 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + postgres-array@2.0.0: {} + + postgres-array@3.0.4: {} + + postgres-bytea@1.0.1: {} + + postgres-date@1.0.7: {} + + postgres-interval@1.2.0: + dependencies: + xtend: 4.0.2 + postgres@3.4.7: {} prelude-ls@1.2.1: {} @@ -8460,6 +8621,8 @@ snapshots: source-map@0.7.4: {} + split2@4.2.0: {} + sprintf-js@1.0.3: {} sqlstring@2.3.3: {} @@ -8977,6 +9140,8 @@ snapshots: imurmurhash: 0.1.4 signal-exit: 4.1.0 + xtend@4.0.2: {} + y18n@5.0.8: {} yallist@3.1.1: {} diff --git a/turbo.json b/turbo.json index 1a1085c..d62af2d 100644 --- a/turbo.json +++ b/turbo.json @@ -3,6 +3,7 @@ "ui": "tui", "globalEnv": [ "JWT_SECRET", + "DATABASE_URL", "GOOGLE_CLIENT_ID", "GOOGLE_CLIENT_SECRET", "GOOGLE_CALLBACK_URL",