From 4889cba1482927be3e3b25cd1f73b81a6a77e9b6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 2 Sep 2026 21:53:28 +0000 Subject: [PATCH 01/10] feat(db): add org-scoping Prisma extension for query-layer isolation Introduces db/org-scope: an AsyncLocalStorage-backed org context plus a Prisma client extension that automatically injects/forces organizationId filters (or relation-based equivalents) on every model operation. Access without an active context fails closed (MissingOrgContextError); a deliberate runWithoutOrgScope escape hatch exists for pre-auth system paths. Relation-scoped creates are verified via a DB round trip through the same scoped client, so cross-org foreign keys are rejected (OrgScopeViolationError). Covered by 61 unit tests exercising the pure scoping logic and a fake Prisma-extension client (no live DB needed), plus manual verification against a real local Postgres. Co-authored-by: Andrea Mazzucchelli --- packages/db/jest.config.cjs | 29 ++ packages/db/package.json | 5 + packages/db/src/index.ts | 4 + packages/db/src/org-scope/config.ts | 66 +++++ packages/db/src/org-scope/context.spec.ts | 117 ++++++++ packages/db/src/org-scope/context.ts | 78 +++++ packages/db/src/org-scope/errors.ts | 50 ++++ packages/db/src/org-scope/extension.spec.ts | 228 +++++++++++++++ packages/db/src/org-scope/extension.ts | 103 +++++++ packages/db/src/org-scope/index.ts | 25 ++ packages/db/src/org-scope/scope-args.spec.ts | 274 ++++++++++++++++++ packages/db/src/org-scope/scope-args.ts | 203 +++++++++++++ .../db/src/org-scope/verify-relation.spec.ts | 100 +++++++ packages/db/src/org-scope/verify-relation.ts | 59 ++++ packages/db/src/org-scope/where.spec.ts | 139 +++++++++ packages/db/src/org-scope/where.ts | 78 +++++ 16 files changed, 1558 insertions(+) create mode 100644 packages/db/jest.config.cjs create mode 100644 packages/db/src/org-scope/config.ts create mode 100644 packages/db/src/org-scope/context.spec.ts create mode 100644 packages/db/src/org-scope/context.ts create mode 100644 packages/db/src/org-scope/errors.ts create mode 100644 packages/db/src/org-scope/extension.spec.ts create mode 100644 packages/db/src/org-scope/extension.ts create mode 100644 packages/db/src/org-scope/index.ts create mode 100644 packages/db/src/org-scope/scope-args.spec.ts create mode 100644 packages/db/src/org-scope/scope-args.ts create mode 100644 packages/db/src/org-scope/verify-relation.spec.ts create mode 100644 packages/db/src/org-scope/verify-relation.ts create mode 100644 packages/db/src/org-scope/where.spec.ts create mode 100644 packages/db/src/org-scope/where.ts 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..bb70253 --- /dev/null +++ b/packages/db/src/org-scope/config.ts @@ -0,0 +1,66 @@ +/** + * 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: 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" }, + }, + 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..d46348e --- /dev/null +++ b/packages/db/src/org-scope/context.spec.ts @@ -0,0 +1,117 @@ +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", () => { + runWithoutOrgScope(() => { + const context = getOrgContext(); + expect(context).toBeDefined(); + expect(isUnscopedContext(context!)).toBe(true); + }); + + 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 is + * what makes `runWithOrgContext`'s callback shape matter: a callback that + * just *returns* a lazy thenable (instead of `async`ly consuming it) + * hands back an inert value with no `.then()` reaction registered yet, + * so nothing ties the eventual reaction to the active context. + */ + function createLazyThenable(resolve: () => T) { + return { + then(onFulfilled: (value: T) => void) { + queueMicrotask(() => onFulfilled(resolve())); + }, + }; + } + + it("loses the bound context when the callback merely returns a lazy thenable instead of awaiting it", async () => { + const observed: unknown[] = []; + const rawThenable = runWithOrgContext("org-1", () => + createLazyThenable(() => observed.push(getOrgContext())), + ); + + await new Promise((resolve) => { + // Mirrors an outer, uninstrumented caller awaiting the callback's + // return value — by now `runWithOrgContext` has already exited and + // restored the previous (absent) context. + (rawThenable as { then(cb: () => void): void }).then(resolve); + }); + + expect(observed).toEqual([undefined]); + }); + + 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", () => { + runWithoutOrgScope(() => { + expect(isUnscopedContext(getOrgContext()!)).toBe(true); + runWithOrgContext("org-nested", () => { + expect(getOrgContext()).toEqual({ organizationId: "org-nested" }); + }); + expect(isUnscopedContext(getOrgContext()!)).toBe(true); + }); + }); +}); diff --git a/packages/db/src/org-scope/context.ts b/packages/db/src/org-scope/context.ts new file mode 100644 index 0000000..b5c23e2 --- /dev/null +++ b/packages/db/src/org-scope/context.ts @@ -0,0 +1,78 @@ +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. + * + * **Correct usage — `fn` must be `async` (or otherwise consume any returned + * Prisma call synchronously within its own body):** + * + * ```ts + * // Correct: the callback is async, so `db.user.findMany(...)` is attached + * // to (via the implicit return-value resolution) while still inside the + * // active context. + * const users = await runWithOrgContext(orgId, async () => db.user.findMany()); + * ``` + * + * **Incorrect — do not do this:** + * + * ```ts + * // Wrong: Prisma's client methods return a *lazy* promise that doesn't + * // register a `.then()` reaction until something awaits it. A plain + * // (non-async) callback just hands that lazy promise back to + * // `runWithOrgContext`, which itself returns synchronously and restores + * // the *previous* context before the caller ever gets a chance to await + * // it — so the eventual query runs with no org context bound at all. + * const users = await runWithOrgContext(orgId, () => db.user.findMany()); + * ``` + */ +export function runWithOrgContext(organizationId: string, fn: () => T): T { + return storage.run({ organizationId }, 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. See `runWithOrgContext`'s doc comment for why `fn` + * must be `async`. + */ +export function runWithoutOrgScope(fn: () => T): T { + return storage.run({ unscoped: true }, 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..7cc68e0 --- /dev/null +++ b/packages/db/src/org-scope/extension.spec.ts @@ -0,0 +1,228 @@ +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 FakeExtension { + query: { + $allModels: { + $allOperations(params: AllOperationsParams): Promise; + }; + }; +} + +/** Structural shape of the fake client: `$extends` plus arbitrary model delegates. */ +type FakeClient = { + $extends(extension: FakeExtension): Record>; +} & Record>; + +/** 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]; + const impl = delegate?.[operation]; + if (!impl) { + throw new Error(`Fake client has no ${model}.${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`. + */ +function createFakeBaseClient(modelImpls: ModelImpls) { + return { + $extends(extension: FakeExtension) { + const scoped: Record> = {}; + for (const [modelDelegate, ops] of Object.entries(modelImpls)) { + const model = + modelDelegate.charAt(0).toUpperCase() + modelDelegate.slice(1); + scoped[modelDelegate] = {}; + for (const [operation, impl] of Object.entries(ops)) { + scoped[modelDelegate][operation] = (args: unknown) => + extension.query.$allModels.$allOperations({ + model, + 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 findUnique = jest + .fn() + .mockResolvedValue({ id: "user-1", organizationId: "org-1" }); + const createUserDepartment = jest.fn().mockResolvedValue({ id: "ud-1" }); + const client = createOrgScopedClient( + createFakeBaseClient({ + user: { findUnique }, + 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(findUnique).toHaveBeenCalledWith({ + where: { id: "user-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 }, + 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("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" } }); + }); +}); diff --git a/packages/db/src/org-scope/extension.ts b/packages/db/src/org-scope/extension.ts new file mode 100644 index 0000000..a3037af --- /dev/null +++ b/packages/db/src/org-scope/extension.ts @@ -0,0 +1,103 @@ +import { getOrgContext, isUnscopedContext } from "./context.js"; +import { MissingOrgContextError } from "./errors.js"; +import { + CREATE_OPERATIONS, + computeScopedArgs, + getScopeConfig, +} 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; +} + +/** + * 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; + }; + }; + }): unknown; +} + +/** + * 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`, ...). + */ +export function createOrgScopedClient( + baseClient: T, +): T { + let scopedClient: T; + + scopedClient = baseClient.$extends({ + name: "org-scope", + query: { + $allModels: { + async $allOperations(params: AllOperationsParams): Promise { + const { model, operation, args, query } = params; + if (!model) { + return query(args); + } + + 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" && CREATE_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..2efa2db --- /dev/null +++ b/packages/db/src/org-scope/scope-args.spec.ts @@ -0,0 +1,274 @@ +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("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("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..0a8439c --- /dev/null +++ b/packages/db/src/org-scope/scope-args.ts @@ -0,0 +1,203 @@ +import type { OrgScopeConfig } from "./config.js"; +import { ORG_SCOPE_CONFIG } from "./config.js"; +import { 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", +]); + +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"] : {}; + args["data"] = { ...data, [field]: organizationId }; + return args; + } + if (operation === "createMany" || operation === "createManyAndReturn") { + const items = Array.isArray(args["data"]) ? args["data"] : []; + args["data"] = items.map((item: unknown) => ({ + ...(isPlainObject(item) ? item : {}), + [field]: organizationId, + })); + return args; + } + if (operation === "upsert") { + args["where"] = mergeScopeIntoWhere(operation, args["where"], { + [field]: organizationId, + }); + const create = isPlainObject(args["create"]) ? args["create"] : {}; + args["create"] = { ...create, [field]: organizationId }; + if (isPlainObject(args["update"])) { + 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"])) { + args["data"] = stripField(args["data"], field); + } + return args; + } + throw unsupportedOperation(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..c474f0c --- /dev/null +++ b/packages/db/src/org-scope/verify-relation.spec.ts @@ -0,0 +1,100 @@ +import { verifyRelationOwnership } from "./verify-relation.js"; +import { OrgScopeViolationError } from "./errors.js"; +import type { RelationVerification } from "./config.js"; + +const verifyVia: RelationVerification = { + foreignKeyField: "userId", + parentModel: "User", +}; + +function makeClient(existingIds: readonly string[]) { + return { + user: { + findUnique: jest.fn( + async ({ where: { id } }: { where: { id: string } }) => + existingIds.includes(id) ? { id } : null, + ), + }, + }; +} + +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-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" }, + }); + }); + + it("throws OrgScopeViolationError when the foreign key resolves to nothing under org scope", async () => { + const client = makeClient([]); + await expect( + verifyRelationOwnership( + "UserDepartment", + "create", + { data: { userId: "cross-org-user", departmentId: "dept-1" } }, + verifyVia, + client, + ), + ).rejects.toThrow(OrgScopeViolationError); + }); + + it("verifies every distinct foreign key across a createMany batch", async () => { + const client = makeClient(["user-1", "user-2"]); + 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-1"]); + await expect( + verifyRelationOwnership( + "UserDepartment", + "createMany", + { + data: [ + { userId: "user-1", departmentId: "d1" }, + { userId: "cross-org-user", departmentId: "d2" }, + ], + }, + verifyVia, + 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..ec9412d --- /dev/null +++ b/packages/db/src/org-scope/verify-relation.ts @@ -0,0 +1,59 @@ +import type { RelationVerification } from "./config.js"; +import { OrgScopeViolationError } from "./errors.js"; +import { isPlainObject } from "./where.js"; +import { CREATE_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: { id: string } }): Promise; + }; +} + +function toModelDelegateName(model: string): string { + return model.charAt(0).toLowerCase() + model.slice(1); +} + +/** + * Verifies that the foreign key referenced by a relation-scoped `create`/ + * `createMany` payload belongs to the caller's organization, by looking up + * the parent record through the *same* org-scoped client. Because that + * lookup is itself subject to org scoping, a foreign key from another + * organization simply won't be found — turning a cross-tenant write attempt + * into a clear, auditable rejection instead of silently succeeding. + */ +export async function verifyRelationOwnership( + model: string, + operation: string, + args: Record, + verifyVia: RelationVerification, + client: OwnershipCheckClient, +): Promise { + if (!CREATE_OPERATIONS.has(operation)) { + return; + } + + const items = + operation === "create" + ? [args["data"]] + : Array.isArray(args["data"]) + ? args["data"] + : []; + + const foreignKeys = items + .filter(isPlainObject) + .map((item) => item[verifyVia.foreignKeyField]) + .filter((value): value is string => typeof value === "string"); + + const uniqueForeignKeys = Array.from(new Set(foreignKeys)); + const parentDelegate = client[toModelDelegateName(verifyVia.parentModel)]; + + await Promise.all( + uniqueForeignKeys.map(async (id) => { + const parent = await parentDelegate?.findUnique({ where: { id } }); + if (!parent) { + throw new OrgScopeViolationError(model, operation); + } + }), + ); +} 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; +} From a6ab61d8c5c15cc02ad5a64ab06b50280c1bc59c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 2 Sep 2026 21:53:40 +0000 Subject: [PATCH 02/10] feat(api): enforce org context at the query layer for every request - PrismaService no longer extends PrismaClient directly; every model delegate it exposes is routed through db's org-scoped Prisma client, and the raw client is a private field with no external accessor. - Wires @prisma/adapter-pg (Prisma 7 requires a driver adapter), fixing a previously-documented boot gap as a side effect of this change. - OrgContextInterceptor (registered globally via APP_INTERCEPTOR) binds the authenticated caller's organizationId as the active org context for the duration of each request, so controllers/services don't need to remember to filter by org themselves. - OrgScopeExceptionFilter maps a query-layer OrgScopeViolationError to 403 Forbidden. - Extends the db-client jest mock with a real (duplicated, dependency-free) copy of the org-context primitives so tests exercise real ALS behavior. Covered by new interceptor/filter test suites, including a regression test that a runWithOrgContext callback must synchronously consume any returned Prisma-like lazy promise (a subtlety documented on runWithOrgContext itself) and a realistic multi-hop async chain test. Co-authored-by: Andrea Mazzucchelli --- apps/api/package.json | 4 + apps/api/src/__mocks__/db-client.mock.ts | 70 ++++++- apps/api/src/app.module.ts | 7 + .../common/org-context.interceptor.spec.ts | 191 ++++++++++++++++++ .../api/src/common/org-context.interceptor.ts | 53 +++++ .../common/org-scope-exception.filter.spec.ts | 56 +++++ .../src/common/org-scope-exception.filter.ts | 28 +++ apps/api/src/prisma/prisma.service.ts | 80 +++++++- pnpm-lock.yaml | 165 +++++++++++++++ turbo.json | 1 + 10 files changed, 648 insertions(+), 7 deletions(-) create mode 100644 apps/api/src/common/org-context.interceptor.spec.ts create mode 100644 apps/api/src/common/org-context.interceptor.ts create mode 100644 apps/api/src/common/org-scope-exception.filter.spec.ts create mode 100644 apps/api/src/common/org-scope-exception.filter.ts 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..e6a4a9c 100644 --- a/apps/api/src/__mocks__/db-client.mock.ts +++ b/apps/api/src/__mocks__/db-client.mock.ts @@ -1,9 +1,77 @@ /** - * 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 function runWithOrgContext(organizationId: string, fn: () => T): T { + return orgContextStorage.run({ organizationId }, fn); +} + +export function runWithoutOrgScope(fn: () => T): T { + return orgContextStorage.run({ unscoped: true }, 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/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/common/org-context.interceptor.spec.ts b/apps/api/src/common/org-context.interceptor.spec.ts new file mode 100644 index 0000000..3abd5fb --- /dev/null +++ b/apps/api/src/common/org-context.interceptor.spec.ts @@ -0,0 +1,191 @@ +import { + CanActivate, + Controller, + ExecutionContext, + Get, + INestApplication, + Injectable, + Module, + UseGuards, +} from "@nestjs/common"; +import { APP_INTERCEPTOR } from "@nestjs/core"; +import { Test } from "@nestjs/testing"; +import request from "supertest"; +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 }; + } +} + +@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("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" }); + }); +}); 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..10219b6 --- /dev/null +++ b/apps/api/src/common/org-context.interceptor.ts @@ -0,0 +1,53 @@ +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(organizationId, () => { + next.handle().subscribe(subscriber); + }); + }); + } +} 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/prisma/prisma.service.ts b/apps/api/src/prisma/prisma.service.ts index 456eb7d..b59c11f 100644 --- a/apps/api/src/prisma/prisma.service.ts +++ b/apps/api/src/prisma/prisma.service.ts @@ -1,16 +1,84 @@ 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, so 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/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", From bd8f4a85953bbf9cea943a0ca8be1fa19c0b8349 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 2 Sep 2026 21:53:46 +0000 Subject: [PATCH 03/10] fix(auth): bypass org scoping explicitly for pre-auth identity lookups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit findByGoogleSub and findOrCreate run before the caller's organization is known (findOrCreate is literally what determines it, via the email-domain lookup), so they wrap their Prisma calls in runWithoutOrgScope. The runWithoutOrgScope callback must be async (or otherwise synchronously consume the Prisma call) — a bare non-async callback that merely returns the lazy promise loses the bound context once storage.run() exits. Co-authored-by: Andrea Mazzucchelli --- apps/api/src/auth/user.service.ts | 116 +++++++++++++++++------------- 1 file changed, 67 insertions(+), 49 deletions(-) diff --git a/apps/api/src/auth/user.service.ts b/apps/api/src/auth/user.service.ts index 1150011..93ebb71 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,20 @@ 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`. + * + * The callback passed to `runWithoutOrgScope` must be `async` (not a + * plain function that merely returns the Prisma call's result) — see the + * "Correct usage" note on `runWithoutOrgScope` for why a bare + * non-`async` callback silently loses the bound context. + */ async findByGoogleSub(googleSub: string): Promise { - return this.prisma.user.findUnique({ where: { googleSub } }); + return runWithoutOrgScope(async () => + this.prisma.user.findUnique({ where: { googleSub } }), + ); } async findById(id: string): Promise { @@ -67,64 +80,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; - } + }); } } From fd847cb71488d55531b328449e4337ea360ae019 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 2 Sep 2026 21:53:53 +0000 Subject: [PATCH 04/10] fix(admin): scope the organizations endpoints to the caller's own org MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OrganizationsController previously had no organization scoping at all: any authenticated admin, regardless of their own org, could list every organization in the system, fetch any organization by id, and rename any organization — the clearest cross-org leak in the API surface this slice is meant to close. GET /:id and PATCH /:id now use assertAdminOrganizationAccess (the same convention already used by DepartmentsController and AdminUsersController) to return 403 for a mismatched id before ever touching the database. GET / (list) now returns only the caller's own organization instead of every tenant. POST (create) is unchanged — provisioning a brand-new organization doesn't read or modify existing tenant data. Removes OrganizationService.findAll(), which had become dead, unscoped code once the controller stopped calling it. Co-authored-by: Andrea Mazzucchelli --- .../organization.service.spec.ts | 28 +------- .../organizations/organization.service.ts | 6 -- .../organizations/organizations.controller.ts | 37 ++++++++-- .../organizations.integration.spec.ts | 72 ++++++++++++++----- 4 files changed, 90 insertions(+), 53 deletions(-) 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); From 177004018da298c51efb41764823e294ec203729 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 2 Sep 2026 21:53:58 +0000 Subject: [PATCH 05/10] test(mcp): add organization-isolation regression tests Verifies the userDepartment lookup is scoped by the caller's own organization, that concurrent requests from different organizations never cross-contaminate scope resolution, and that a department only resolves when its relation filter matches the caller's org. Co-authored-by: Andrea Mazzucchelli --- apps/api/src/mcp/mcp.integration.spec.ts | 125 +++++++++++++++++++++++ 1 file changed, 125 insertions(+) diff --git a/apps/api/src/mcp/mcp.integration.spec.ts b/apps/api/src/mcp/mcp.integration.spec.ts index 3b63023..76fa313 100644 --- a/apps/api/src/mcp/mcp.integration.spec.ts +++ b/apps/api/src/mcp/mcp.integration.spec.ts @@ -143,6 +143,131 @@ describe("MCP Integration", () => { }); }); + describe("organization isolation", () => { + 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()) From 03835c5b935cf42423628828a1155c1764ee69f1 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Sat, 5 Sep 2026 12:20:39 +0000 Subject: [PATCH 06/10] Harden org-scoped relation writes and preserve context for lazy thenables --- packages/db/src/org-scope/config.ts | 16 +- packages/db/src/org-scope/context.spec.ts | 39 ++--- packages/db/src/org-scope/context.ts | 26 +-- packages/db/src/org-scope/extension.spec.ts | 98 +++++++++++- packages/db/src/org-scope/extension.ts | 7 +- packages/db/src/org-scope/scope-args.spec.ts | 49 ++++++ packages/db/src/org-scope/scope-args.ts | 38 ++++- .../db/src/org-scope/verify-relation.spec.ts | 149 ++++++++++++++++-- packages/db/src/org-scope/verify-relation.ts | 107 ++++++++++--- 9 files changed, 435 insertions(+), 94 deletions(-) diff --git a/packages/db/src/org-scope/config.ts b/packages/db/src/org-scope/config.ts index bb70253..e354cd1 100644 --- a/packages/db/src/org-scope/config.ts +++ b/packages/db/src/org-scope/config.ts @@ -20,7 +20,10 @@ export type OrgScopeConfig = | { readonly kind: "relation"; readonly chain: readonly [string, ...string[]]; - readonly verifyVia: RelationVerification; + readonly verifyVia: readonly [ + RelationVerification, + ...RelationVerification[], + ]; }; /** @@ -46,21 +49,24 @@ export const ORG_SCOPE_CONFIG: Readonly> = { UserDepartment: { kind: "relation", chain: ["user", "organizationId"], - verifyVia: { foreignKeyField: "userId", parentModel: "User" }, + verifyVia: [ + { foreignKeyField: "userId", parentModel: "User" }, + { foreignKeyField: "departmentId", parentModel: "Department" }, + ], }, Document: { kind: "relation", chain: ["source", "organizationId"], - verifyVia: { foreignKeyField: "sourceId", parentModel: "Source" }, + verifyVia: [{ foreignKeyField: "sourceId", parentModel: "Source" }], }, Chunk: { kind: "relation", chain: ["document", "source", "organizationId"], - verifyVia: { foreignKeyField: "documentId", parentModel: "Document" }, + verifyVia: [{ foreignKeyField: "documentId", parentModel: "Document" }], }, QueryLog: { kind: "relation", chain: ["user", "organizationId"], - verifyVia: { foreignKeyField: "userId", parentModel: "User" }, + 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 index d46348e..7038db3 100644 --- a/packages/db/src/org-scope/context.spec.ts +++ b/packages/db/src/org-scope/context.spec.ts @@ -65,34 +65,35 @@ describe("org context", () => { /** * 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 is - * what makes `runWithOrgContext`'s callback shape matter: a callback that - * just *returns* a lazy thenable (instead of `async`ly consuming it) - * hands back an inert value with no `.then()` reaction registered yet, - * so nothing ties the eventual reaction to the active context. + * `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) { + function createLazyThenable(resolve: () => T): PromiseLike { return { - then(onFulfilled: (value: T) => void) { - queueMicrotask(() => onFulfilled(resolve())); + then( + onFulfilled?: ((value: T) => TResult1 | PromiseLike) | null, + onRejected?: + | ((reason: unknown) => TResult2 | PromiseLike) + | null, + ): PromiseLike { + return Promise.resolve().then(resolve).then(onFulfilled, onRejected); }, }; } - it("loses the bound context when the callback merely returns a lazy thenable instead of awaiting it", async () => { + it("preserves the bound context while consuming a returned lazy thenable", async () => { const observed: unknown[] = []; - const rawThenable = runWithOrgContext("org-1", () => - createLazyThenable(() => observed.push(getOrgContext())), + const result = await runWithOrgContext("org-1", () => + createLazyThenable(() => { + observed.push(getOrgContext()); + return "resolved"; + }), ); - await new Promise((resolve) => { - // Mirrors an outer, uninstrumented caller awaiting the callback's - // return value — by now `runWithOrgContext` has already exited and - // restored the previous (absent) context. - (rawThenable as { then(cb: () => void): void }).then(resolve); - }); - - expect(observed).toEqual([undefined]); + 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 () => { diff --git a/packages/db/src/org-scope/context.ts b/packages/db/src/org-scope/context.ts index b5c23e2..da837f0 100644 --- a/packages/db/src/org-scope/context.ts +++ b/packages/db/src/org-scope/context.ts @@ -26,30 +26,18 @@ const storage = new AsyncLocalStorage(); * org-scoped queries succeed — there is no ambient "current organization" * outside of this call. * - * **Correct usage — `fn` must be `async` (or otherwise consume any returned - * Prisma call synchronously within its own body):** + * The callback may return a Promise or a lazy Prisma thenable directly; + * `runWithOrgContext` consumes it before leaving the active context: * * ```ts - * // Correct: the callback is async, so `db.user.findMany(...)` is attached - * // to (via the implicit return-value resolution) while still inside the - * // active context. - * const users = await runWithOrgContext(orgId, async () => db.user.findMany()); - * ``` - * - * **Incorrect — do not do this:** - * - * ```ts - * // Wrong: Prisma's client methods return a *lazy* promise that doesn't - * // register a `.then()` reaction until something awaits it. A plain - * // (non-async) callback just hands that lazy promise back to - * // `runWithOrgContext`, which itself returns synchronously and restores - * // the *previous* context before the caller ever gets a chance to await - * // it — so the eventual query runs with no org context bound at all. * const users = await runWithOrgContext(orgId, () => db.user.findMany()); * ``` */ -export function runWithOrgContext(organizationId: string, fn: () => T): T { - return storage.run({ organizationId }, fn); +export async function runWithOrgContext( + organizationId: string, + fn: () => T | PromiseLike, +): Promise { + return storage.run({ organizationId }, async () => await fn()); } /** diff --git a/packages/db/src/org-scope/extension.spec.ts b/packages/db/src/org-scope/extension.spec.ts index 7cc68e0..a570405 100644 --- a/packages/db/src/org-scope/extension.spec.ts +++ b/packages/db/src/org-scope/extension.spec.ts @@ -154,13 +154,17 @@ describe("createOrgScopedClient", () => { }); it("allows a relation-scoped create when the referenced parent belongs to the caller's org", async () => { - const findUnique = jest + 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 }, + user: { findUnique: findUser }, + department: { findUnique: findDepartment }, userDepartment: { create: createUserDepartment }, }) as unknown as FakeClient, ); @@ -174,9 +178,12 @@ describe("createOrgScopedClient", () => { // 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(findUnique).toHaveBeenCalledWith({ + 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" }, }); @@ -190,6 +197,9 @@ describe("createOrgScopedClient", () => { const client = createOrgScopedClient( createFakeBaseClient({ user: { findUnique }, + department: { + findUnique: jest.fn().mockResolvedValue({ id: "dept-1" }), + }, userDepartment: { create: createUserDepartment }, }) as unknown as FakeClient, ); @@ -205,6 +215,88 @@ describe("createOrgScopedClient", () => { 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("keeps concurrent requests for different organizations isolated", async () => { const underlying = jest.fn(async (args: unknown) => args); const client = createOrgScopedClient( diff --git a/packages/db/src/org-scope/extension.ts b/packages/db/src/org-scope/extension.ts index a3037af..c9bcbfe 100644 --- a/packages/db/src/org-scope/extension.ts +++ b/packages/db/src/org-scope/extension.ts @@ -1,9 +1,9 @@ import { getOrgContext, isUnscopedContext } from "./context.js"; import { MissingOrgContextError } from "./errors.js"; import { - CREATE_OPERATIONS, computeScopedArgs, getScopeConfig, + RELATION_WRITE_OPERATIONS, } from "./scope-args.js"; import type { OwnershipCheckClient } from "./verify-relation.js"; import { verifyRelationOwnership } from "./verify-relation.js"; @@ -81,7 +81,10 @@ export function createOrgScopedClient( ); const config = getScopeConfig(model); - if (config.kind === "relation" && CREATE_OPERATIONS.has(operation)) { + if ( + config.kind === "relation" && + RELATION_WRITE_OPERATIONS.has(operation) + ) { await verifyRelationOwnership( model, operation, diff --git a/packages/db/src/org-scope/scope-args.spec.ts b/packages/db/src/org-scope/scope-args.spec.ts index 2efa2db..548bad4 100644 --- a/packages/db/src/org-scope/scope-args.spec.ts +++ b/packages/db/src/org-scope/scope-args.spec.ts @@ -106,6 +106,18 @@ describe("computeScopedArgs — direct scope (User, Department, ...)", () => { ]); }); + 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( { @@ -194,6 +206,43 @@ describe("computeScopedArgs — direct scope (User, Department, ...)", () => { 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( diff --git a/packages/db/src/org-scope/scope-args.ts b/packages/db/src/org-scope/scope-args.ts index 0a8439c..dd31999 100644 --- a/packages/db/src/org-scope/scope-args.ts +++ b/packages/db/src/org-scope/scope-args.ts @@ -1,6 +1,6 @@ import type { OrgScopeConfig } from "./config.js"; import { ORG_SCOPE_CONFIG } from "./config.js"; -import { UnknownOrgScopeModelError } from "./errors.js"; +import { OrgScopeViolationError, UnknownOrgScopeModelError } from "./errors.js"; import { buildRelationFilter, isPlainObject, @@ -36,6 +36,13 @@ export const CREATE_OPERATIONS = new Set([ "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, @@ -137,15 +144,17 @@ function applyDirectScope( ): 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"] = items.map((item: unknown) => ({ - ...(isPlainObject(item) ? item : {}), - [field]: organizationId, - })); + 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") { @@ -153,8 +162,10 @@ function applyDirectScope( [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; @@ -165,6 +176,7 @@ function applyDirectScope( }); // 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; @@ -172,6 +184,20 @@ function applyDirectScope( 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, diff --git a/packages/db/src/org-scope/verify-relation.spec.ts b/packages/db/src/org-scope/verify-relation.spec.ts index c474f0c..2fa675d 100644 --- a/packages/db/src/org-scope/verify-relation.spec.ts +++ b/packages/db/src/org-scope/verify-relation.spec.ts @@ -2,25 +2,36 @@ import { verifyRelationOwnership } from "./verify-relation.js"; import { OrgScopeViolationError } from "./errors.js"; import type { RelationVerification } from "./config.js"; -const verifyVia: RelationVerification = { - foreignKeyField: "userId", - parentModel: "User", -}; +const verifyVia: readonly RelationVerification[] = [ + { foreignKeyField: "userId", parentModel: "User" }, + { foreignKeyField: "departmentId", parentModel: "Department" }, +]; -function makeClient(existingIds: readonly string[]) { +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: { - findUnique: jest.fn( - async ({ where: { id } }: { where: { id: string } }) => - existingIds.includes(id) ? { id } : null, - ), - }, + user: delegate("user"), + department: delegate("department"), + source: delegate("source"), }; } describe("verifyRelationOwnership", () => { it("no-ops for non-create operations", async () => { - const client = makeClient([]); + const client = makeClient({}); await expect( verifyRelationOwnership( "UserDepartment", @@ -34,7 +45,7 @@ describe("verifyRelationOwnership", () => { }); it("resolves when the referenced foreign key belongs to the caller's org", async () => { - const client = makeClient(["user-1"]); + const client = makeClient({ user: ["user-1"], department: ["dept-1"] }); await expect( verifyRelationOwnership( "UserDepartment", @@ -47,10 +58,13 @@ describe("verifyRelationOwnership", () => { 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([]); + const client = makeClient({ department: ["dept-1"] }); await expect( verifyRelationOwnership( "UserDepartment", @@ -62,8 +76,24 @@ describe("verifyRelationOwnership", () => { ).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-1", "user-2"]); + const client = makeClient({ + user: ["user-1", "user-2"], + department: ["d1", "d2", "d3"], + }); await verifyRelationOwnership( "UserDepartment", "createMany", @@ -81,7 +111,10 @@ describe("verifyRelationOwnership", () => { }); it("rejects a createMany batch if any single item references a foreign org", async () => { - const client = makeClient(["user-1"]); + const client = makeClient({ + user: ["user-1"], + department: ["d1", "d2"], + }); await expect( verifyRelationOwnership( "UserDepartment", @@ -97,4 +130,88 @@ describe("verifyRelationOwnership", () => { ), ).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("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 index ec9412d..0b08b08 100644 --- a/packages/db/src/org-scope/verify-relation.ts +++ b/packages/db/src/org-scope/verify-relation.ts @@ -1,12 +1,12 @@ import type { RelationVerification } from "./config.js"; import { OrgScopeViolationError } from "./errors.js"; import { isPlainObject } from "./where.js"; -import { CREATE_OPERATIONS } from "./scope-args.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: { id: string } }): Promise; + findUnique(args: { where: Record }): Promise; }; } @@ -15,45 +15,104 @@ function toModelDelegateName(model: string): string { } /** - * Verifies that the foreign key referenced by a relation-scoped `create`/ - * `createMany` payload belongs to the caller's organization, by looking up - * the parent record through the *same* org-scoped client. Because that - * lookup is itself subject to org scoping, a foreign key from another - * organization simply won't be found — turning a cross-tenant write attempt - * into a clear, auditable rejection instead of silently succeeding. + * 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, - verifyVia: RelationVerification, + verifications: readonly RelationVerification[], client: OwnershipCheckClient, ): Promise { - if (!CREATE_OPERATIONS.has(operation)) { + if (!RELATION_WRITE_OPERATIONS.has(operation)) { return; } - const items = - operation === "create" - ? [args["data"]] - : Array.isArray(args["data"]) - ? args["data"] - : []; + const items = getWriteItems(operation, args).filter(isPlainObject); + const checks: Array<{ + parentModel: string; + where: Record; + }> = []; - const foreignKeys = items - .filter(isPlainObject) - .map((item) => item[verifyVia.foreignKeyField]) - .filter((value): value is string => typeof value === "string"); + 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 }, + }); + } - const uniqueForeignKeys = Array.from(new Set(foreignKeys)); - const parentDelegate = client[toModelDelegateName(verifyVia.parentModel)]; + 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( - uniqueForeignKeys.map(async (id) => { - const parent = await parentDelegate?.findUnique({ where: { id } }); + 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"]]; +} From 0b6b02a165fb1561a42a1c79ac012f5b9d2694e5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 5 Sep 2026 13:07:47 +0000 Subject: [PATCH 07/10] fix(api): keep the org-context interceptor and jest mock in sync with the now-async runWithOrgContext runWithOrgContext became async (it now awaits its callback's return value internally, including lazy Prisma-like thenables, so context is preserved even for non-async callers). Two call sites needed to follow: - OrgContextInterceptor called it without consuming the returned promise, so a synchronous throw from next.handle() would become an unhandled rejection instead of an error response. Forwards it to the subscriber's error channel instead. - The duplicated org-context primitives in the db-client jest mock were still the old synchronous signature, so every authenticated request in the interceptor's own test suite failed with 'Cannot read properties of undefined (reading catch)' once the real implementation changed. Adds a direct unit test for the interceptor (bypassing the full Nest HTTP pipeline, which already catches a real CallHandler's synchronous throws before they reach this code) that fails/hangs without the fix and passes with it. Co-authored-by: Andrea Mazzucchelli --- apps/api/src/__mocks__/db-client.mock.ts | 7 +- .../common/org-context.interceptor.spec.ts | 81 ++++++++++++++++++- .../api/src/common/org-context.interceptor.ts | 7 +- 3 files changed, 91 insertions(+), 4 deletions(-) diff --git a/apps/api/src/__mocks__/db-client.mock.ts b/apps/api/src/__mocks__/db-client.mock.ts index e6a4a9c..fcbea57 100644 --- a/apps/api/src/__mocks__/db-client.mock.ts +++ b/apps/api/src/__mocks__/db-client.mock.ts @@ -27,8 +27,11 @@ type OrgContextStore = OrgContext | UnscopedContext; const orgContextStorage = new AsyncLocalStorage(); -export function runWithOrgContext(organizationId: string, fn: () => T): T { - return orgContextStorage.run({ organizationId }, fn); +export async function runWithOrgContext( + organizationId: string, + fn: () => T | PromiseLike, +): Promise { + return orgContextStorage.run({ organizationId }, async () => await fn()); } export function runWithoutOrgScope(fn: () => T): T { diff --git a/apps/api/src/common/org-context.interceptor.spec.ts b/apps/api/src/common/org-context.interceptor.spec.ts index 3abd5fb..8785d1e 100644 --- a/apps/api/src/common/org-context.interceptor.spec.ts +++ b/apps/api/src/common/org-context.interceptor.spec.ts @@ -1,7 +1,7 @@ +import type { CallHandler, ExecutionContext } from "@nestjs/common"; import { CanActivate, Controller, - ExecutionContext, Get, INestApplication, Injectable, @@ -11,6 +11,7 @@ import { 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"; @@ -90,6 +91,16 @@ class ProbeController { 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({ @@ -175,6 +186,13 @@ describe("OrgContextInterceptor", () => { 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()) @@ -189,3 +207,64 @@ describe("OrgContextInterceptor", () => { 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 index 10219b6..8a5fd63 100644 --- a/apps/api/src/common/org-context.interceptor.ts +++ b/apps/api/src/common/org-context.interceptor.ts @@ -45,9 +45,14 @@ export class OrgContextInterceptor implements NestInterceptor { } 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)); }); } } From 6cc48850ca6b580fa49f9bca3c3e5a9614cd950b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 5 Sep 2026 13:09:04 +0000 Subject: [PATCH 08/10] test(db): cover in-scope nested parent connects Lock in the allow path for relation-scoped creates that use source.connect with an in-org parent, and correct the leftover runWithoutOrgScope docs now that runWithOrgContext consumes lazy thenables itself. Co-authored-by: Andrea Mazzucchelli --- packages/db/src/org-scope/context.ts | 8 +++++-- packages/db/src/org-scope/extension.spec.ts | 24 +++++++++++++++++++ .../db/src/org-scope/verify-relation.spec.ts | 16 +++++++++++++ 3 files changed, 46 insertions(+), 2 deletions(-) diff --git a/packages/db/src/org-scope/context.ts b/packages/db/src/org-scope/context.ts index da837f0..a1ad517 100644 --- a/packages/db/src/org-scope/context.ts +++ b/packages/db/src/org-scope/context.ts @@ -47,8 +47,12 @@ export async function runWithOrgContext( * * Every call site is a deliberate, auditable exception to org isolation — * grep for `runWithoutOrgScope` in review and justify each usage in a - * neighboring comment. See `runWithOrgContext`'s doc comment for why `fn` - * must be `async`. + * neighboring comment. + * + * Unlike {@link runWithOrgContext}, this helper is synchronous and does not + * consume a returned lazy Prisma thenable. The callback must be `async` (or + * otherwise await any Prisma call) so the query runs while the unscoped + * context is still bound. */ export function runWithoutOrgScope(fn: () => T): T { return storage.run({ unscoped: true }, fn); diff --git a/packages/db/src/org-scope/extension.spec.ts b/packages/db/src/org-scope/extension.spec.ts index a570405..4afc346 100644 --- a/packages/db/src/org-scope/extension.spec.ts +++ b/packages/db/src/org-scope/extension.spec.ts @@ -297,6 +297,30 @@ describe("createOrgScopedClient", () => { }, ); + 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( diff --git a/packages/db/src/org-scope/verify-relation.spec.ts b/packages/db/src/org-scope/verify-relation.spec.ts index 2fa675d..94d6672 100644 --- a/packages/db/src/org-scope/verify-relation.spec.ts +++ b/packages/db/src/org-scope/verify-relation.spec.ts @@ -202,6 +202,22 @@ describe("verifyRelationOwnership", () => { ).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( From 2478af0bcfe3e83968700cb1e0e31eded5331c2c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 5 Sep 2026 13:45:45 +0000 Subject: [PATCH 09/10] fix(db): fail closed on raw SQL and consume thenables in the unscoped hatch Raw Prisma client operations cannot carry an organization filter, so $queryRaw/$executeRaw (and the unsafe variants) now throw unless the caller is inside runWithoutOrgScope. runWithoutOrgScope itself now awaits a returned Promise or lazy thenable, matching runWithOrgContext. MCP integration tests register OrgContextInterceptor and assert the JWT organizationId is bound at the query layer for the request. Co-authored-by: Andrea Mazzucchelli --- apps/api/src/__mocks__/db-client.mock.ts | 6 +- apps/api/src/auth/user.service.ts | 7 +- apps/api/src/mcp/mcp.integration.spec.ts | 29 +++++ apps/api/src/prisma/prisma.service.ts | 8 +- packages/db/src/org-scope/context.spec.ts | 26 +++-- packages/db/src/org-scope/context.ts | 17 +-- packages/db/src/org-scope/extension.spec.ts | 112 ++++++++++++++++++-- packages/db/src/org-scope/extension.ts | 44 +++++++- 8 files changed, 214 insertions(+), 35 deletions(-) diff --git a/apps/api/src/__mocks__/db-client.mock.ts b/apps/api/src/__mocks__/db-client.mock.ts index fcbea57..595db4b 100644 --- a/apps/api/src/__mocks__/db-client.mock.ts +++ b/apps/api/src/__mocks__/db-client.mock.ts @@ -34,8 +34,10 @@ export async function runWithOrgContext( return orgContextStorage.run({ organizationId }, async () => await fn()); } -export function runWithoutOrgScope(fn: () => T): T { - return orgContextStorage.run({ unscoped: true }, fn); +export async function runWithoutOrgScope( + fn: () => T | PromiseLike, +): Promise { + return orgContextStorage.run({ unscoped: true }, async () => await fn()); } export function getOrgContext(): OrgContextStore | undefined { diff --git a/apps/api/src/auth/user.service.ts b/apps/api/src/auth/user.service.ts index 93ebb71..2c0a4a4 100644 --- a/apps/api/src/auth/user.service.ts +++ b/apps/api/src/auth/user.service.ts @@ -27,14 +27,9 @@ export class UserService { * 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`. - * - * The callback passed to `runWithoutOrgScope` must be `async` (not a - * plain function that merely returns the Prisma call's result) — see the - * "Correct usage" note on `runWithoutOrgScope` for why a bare - * non-`async` callback silently loses the bound context. */ async findByGoogleSub(googleSub: string): Promise { - return runWithoutOrgScope(async () => + return runWithoutOrgScope(() => this.prisma.user.findUnique({ where: { googleSub } }), ); } diff --git a/apps/api/src/mcp/mcp.integration.spec.ts b/apps/api/src/mcp/mcp.integration.spec.ts index 76fa313..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) @@ -144,6 +150,29 @@ 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", diff --git a/apps/api/src/prisma/prisma.service.ts b/apps/api/src/prisma/prisma.service.ts index b59c11f..fd53c2e 100644 --- a/apps/api/src/prisma/prisma.service.ts +++ b/apps/api/src/prisma/prisma.service.ts @@ -19,9 +19,11 @@ function createRawPrismaClient(): PrismaClient { * 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, so there is no way for a consumer to - * bypass org scoping — it is enforced for every query, not opted into per - * call site. + * 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 diff --git a/packages/db/src/org-scope/context.spec.ts b/packages/db/src/org-scope/context.spec.ts index 7038db3..b4e5af6 100644 --- a/packages/db/src/org-scope/context.spec.ts +++ b/packages/db/src/org-scope/context.spec.ts @@ -49,14 +49,14 @@ describe("org context", () => { expect(seenInB).toEqual([{ organizationId: "org-b" }]); }); - it("marks the unscoped escape hatch distinctly from a normal org context", () => { - runWithoutOrgScope(() => { + 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); }); - runWithOrgContext("org-1", () => { + await runWithOrgContext("org-1", () => { const context = getOrgContext(); expect(isUnscopedContext(context!)).toBe(false); }); @@ -106,13 +106,27 @@ describe("org context", () => { expect(observed).toEqual([{ organizationId: "org-1" }]); }); - it("nesting runWithOrgContext inside runWithoutOrgScope re-applies scoping for the inner block", () => { - runWithoutOrgScope(() => { + it("nesting runWithOrgContext inside runWithoutOrgScope re-applies scoping for the inner block", async () => { + await runWithoutOrgScope(async () => { expect(isUnscopedContext(getOrgContext()!)).toBe(true); - runWithOrgContext("org-nested", () => { + 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 index a1ad517..04217ef 100644 --- a/packages/db/src/org-scope/context.ts +++ b/packages/db/src/org-scope/context.ts @@ -49,13 +49,18 @@ export async function runWithOrgContext( * grep for `runWithoutOrgScope` in review and justify each usage in a * neighboring comment. * - * Unlike {@link runWithOrgContext}, this helper is synchronous and does not - * consume a returned lazy Prisma thenable. The callback must be `async` (or - * otherwise await any Prisma call) so the query runs while the unscoped - * context is still bound. + * 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 function runWithoutOrgScope(fn: () => T): T { - return storage.run({ unscoped: true }, fn); +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. */ diff --git a/packages/db/src/org-scope/extension.spec.ts b/packages/db/src/org-scope/extension.spec.ts index 4afc346..7175531 100644 --- a/packages/db/src/org-scope/extension.spec.ts +++ b/packages/db/src/org-scope/extension.spec.ts @@ -11,18 +11,27 @@ interface AllOperationsParams { 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 model delegates. */ +/** Structural shape of the fake client: `$extends` plus arbitrary delegates. */ type FakeClient = { - $extends(extension: FakeExtension): Record>; -} & Record>; + $extends(extension: FakeExtension): FakeClient; + [key: string]: unknown; +}; /** Invokes a model operation on the fake client, asserting it was registered. */ function callModel( @@ -31,7 +40,7 @@ function callModel( operation: string, args: unknown, ): Promise { - const delegate = client[model]; + const delegate = client[model] as Record | undefined; const impl = delegate?.[operation]; if (!impl) { throw new Error(`Fake client has no ${model}.${operation}() registered`); @@ -39,23 +48,40 @@ function callModel( return impl(args); } +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`. + * 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) { +function createFakeBaseClient( + modelImpls: ModelImpls, + clientImpls: Record = {}, +) { return { $extends(extension: FakeExtension) { - const scoped: Record> = {}; + const scoped: Record = {}; for (const [modelDelegate, ops] of Object.entries(modelImpls)) { const model = modelDelegate.charAt(0).toUpperCase() + modelDelegate.slice(1); - scoped[modelDelegate] = {}; + const nextOps: Record = {}; for (const [operation, impl] of Object.entries(ops)) { - scoped[modelDelegate][operation] = (args: unknown) => + nextOps[operation] = (args: unknown) => extension.query.$allModels.$allOperations({ model, operation, @@ -63,6 +89,25 @@ function createFakeBaseClient(modelImpls: ModelImpls) { query: impl, }); } + scoped[modelDelegate] = nextOps; + } + for (const [operation, impl] of Object.entries(clientImpls)) { + const namedHandler = ( + extension.query as Record< + string, + ((params: RawQueryParams) => Promise) | undefined + > + )[operation]; + scoped[operation] = (args: unknown) => { + if (typeof namedHandler === "function") { + return namedHandler({ args, query: impl }); + } + return extension.query.$allModels.$allOperations({ + operation, + args, + query: impl, + }); + }; } return scoped; }, @@ -341,4 +386,53 @@ describe("createOrgScopedClient", () => { 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 index c9bcbfe..a50d3b3 100644 --- a/packages/db/src/org-scope/extension.ts +++ b/packages/db/src/org-scope/extension.ts @@ -1,5 +1,5 @@ import { getOrgContext, isUnscopedContext } from "./context.js"; -import { MissingOrgContextError } from "./errors.js"; +import { MissingOrgContextError, OrgScopeViolationError } from "./errors.js"; import { computeScopedArgs, getScopeConfig, @@ -15,6 +15,11 @@ interface AllOperationsParams { 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 @@ -31,10 +36,33 @@ interface ExtensibleClient { $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 @@ -45,7 +73,9 @@ interface ExtensibleClient { * * The generic `T` is preserved (via a cast) so the returned value keeps the * full delegate surface of the original client (`.user`, `.department`, - * `$transaction`, ...). + * `$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, @@ -55,11 +85,19 @@ export function createOrgScopedClient( 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 query(args); + return enforceClientOperation(operation, args, query); } const context = getOrgContext(); From 05cc6b4035d8a3dc1ef72c026759b29fb33d26dc Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 5 Sep 2026 13:47:13 +0000 Subject: [PATCH 10/10] test(db): type the fake client's raw-SQL handler lookup without a cast Co-authored-by: Andrea Mazzucchelli --- packages/db/src/org-scope/extension.spec.ts | 24 +++++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/packages/db/src/org-scope/extension.spec.ts b/packages/db/src/org-scope/extension.spec.ts index 7175531..2a48b86 100644 --- a/packages/db/src/org-scope/extension.spec.ts +++ b/packages/db/src/org-scope/extension.spec.ts @@ -48,6 +48,21 @@ function callModel( 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, @@ -92,14 +107,9 @@ function createFakeBaseClient( scoped[modelDelegate] = nextOps; } for (const [operation, impl] of Object.entries(clientImpls)) { - const namedHandler = ( - extension.query as Record< - string, - ((params: RawQueryParams) => Promise) | undefined - > - )[operation]; + const namedHandler = getNamedRawHandler(extension.query, operation); scoped[operation] = (args: unknown) => { - if (typeof namedHandler === "function") { + if (namedHandler) { return namedHandler({ args, query: impl }); } return extension.query.$allModels.$allOperations({