Przeglądaj źródła

Tighten record linking checks (#444)

Bernt Christian Egeland 3 tygodni temu
rodzic
commit
3e0e71e6a6

+ 4 - 0
src/__tests__/features/quotes/create-quote.test.ts

@@ -28,6 +28,7 @@ vi.mock('@/lib/db', () => ({
     appSetting: { findMany: vi.fn() },
     appSetting: { findMany: vi.fn() },
     quote: { findFirst: vi.fn() },
     quote: { findFirst: vi.fn() },
     customer: { findFirst: vi.fn() },
     customer: { findFirst: vi.fn() },
+    vehicle: { findFirst: vi.fn() },
     inspection: { findFirst: vi.fn() },
     inspection: { findFirst: vi.fn() },
     $transaction: vi.fn(),
     $transaction: vi.fn(),
   },
   },
@@ -269,6 +270,9 @@ describe('createQuote — from inspection', () => {
   it('links quote to inspectionId, vehicleId, and customerId', async () => {
   it('links quote to inspectionId, vehicleId, and customerId', async () => {
     setupAuth()
     setupAuth()
     setupQuoteCreation()
     setupQuoteCreation()
+    // Both ids are checked against the workshop before they are written.
+    vi.mocked(db.customer.findFirst).mockResolvedValue({ taxExempt: false } as any)
+    vi.mocked(db.vehicle.findFirst).mockResolvedValue({ id: 'veh-1' } as any)
 
 
     const mockCreate = vi.fn().mockResolvedValue({ id: 'q-insp' })
     const mockCreate = vi.fn().mockResolvedValue({ id: 'q-insp' })
     vi.mocked(db.$transaction).mockImplementation(async (fn: any) =>
     vi.mocked(db.$transaction).mockImplementation(async (fn: any) =>

+ 286 - 0
src/__tests__/multitenancy/foreign-id-ownership.test.ts

@@ -0,0 +1,286 @@
+/**
+ * A record may only point at customers and vehicles of its own workshop.
+ *
+ * Every write here loads its own row org-scoped. What these tests pin down
+ * is the second half: the customerId and vehicleId it is handed are checked
+ * against the workshop too, so a quote, a scheduled message, a vehicle or a
+ * tire set can never be made to show another workshop's customer. A foreign
+ * id fails the same way a foreign row id does, and an own id still passes.
+ */
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+vi.mock('@/lib/cached-session', () => ({
+  getCachedSession: vi.fn(),
+  getCachedMembership: vi.fn(),
+}))
+vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
+vi.mock('@/lib/audit', () => ({ logAudit: vi.fn(), auditDetails: vi.fn(() => ({})) }))
+vi.mock('@/lib/notification-bus', () => ({ notificationBus: { publish: vi.fn(), emit: vi.fn() } }))
+vi.mock('@/lib/resolve-upload-path', () => ({ resolveUploadPath: vi.fn((u: string) => u) }))
+vi.mock('@/lib/files/collect', () => ({
+  quoteFileUrls: vi.fn(async () => []),
+  vehicleFileUrls: vi.fn(async () => []),
+  tireSetFileUrls: vi.fn(async () => []),
+}))
+vi.mock('@/lib/files/manager', () => ({
+  releaseFiles: vi.fn(async () => ({ removed: [], kept: [], skipped: [] })),
+  parseStoredFileUrl: vi.fn(() => null),
+}))
+vi.mock('@/lib/workshop-timezone', () => ({ workshopTimeZone: async () => 'UTC' }))
+vi.mock('@/features/tire-hotel/Lib/tireHotelSettings', () => ({
+  // The tire hotel is on; the gate has tests of its own.
+  requireTireHotel: vi.fn(async () => undefined),
+}))
+
+vi.mock('@/lib/db', () => ({
+  db: {
+    user: { findUnique: vi.fn() },
+    appSetting: { findMany: vi.fn() },
+    quote: { findFirst: vi.fn() },
+    customer: { findFirst: vi.fn() },
+    vehicle: { findFirst: vi.fn(), create: vi.fn(), updateMany: vi.fn() },
+    scheduledMessage: { findFirst: vi.fn(), update: vi.fn() },
+    $transaction: vi.fn(),
+  },
+}))
+
+import { getCachedMembership, getCachedSession } from '@/lib/cached-session'
+import { db } from '@/lib/db'
+import { createQuote, updateQuote } from '@/features/quotes/Actions/quoteActions'
+import { updateScheduledMessage } from '@/features/scheduled-messages/Actions/scheduledMessageActions'
+import { checkInTireSet, updateTireSet } from '@/features/tire-hotel/Actions/tireSetActions'
+import { createVehicle, updateVehicle } from '@/features/vehicles/Actions/vehicleActions'
+
+const ORG = 'org-a'
+const FOREIGN = 'cust-of-org-b'
+
+function setupAuth() {
+  vi.mocked(getCachedSession).mockResolvedValue({
+    user: { id: 'user-1', email: 'owner@example.com' },
+  } as never)
+  vi.mocked(getCachedMembership).mockResolvedValue({
+    organizationId: ORG,
+    role: 'owner',
+    roleId: null,
+    customRole: null,
+  } as never)
+  vi.mocked(db.user.findUnique).mockResolvedValue({ isSuperAdmin: false } as never)
+  vi.mocked(db.appSetting.findMany).mockResolvedValue([])
+}
+
+/** What Prisma answers for an id that is not in this workshop. */
+function foreignCustomer() {
+  vi.mocked(db.customer.findFirst).mockResolvedValue(null)
+}
+function ownCustomer() {
+  vi.mocked(db.customer.findFirst).mockResolvedValue({ id: 'cust-a', taxExempt: false } as never)
+}
+function foreignVehicle() {
+  vi.mocked(db.vehicle.findFirst).mockResolvedValue(null)
+}
+
+/** The lookup must have been scoped to the caller's workshop, not just the id. */
+function expectScopedLookup(mock: { mock: { calls: unknown[][] } }, id: string) {
+  const call = mock.mock.calls.find((c) => (c[0] as { where: { id: string } }).where.id === id)
+  expect(call?.[0]).toMatchObject({ where: { id, organizationId: ORG } })
+}
+
+const QUOTE_ROW = {
+  id: 'quote-a',
+  status: 'draft',
+  editUnlockedAt: null,
+  taxComponents: null,
+  taxRate: 25,
+  taxInclusive: false,
+  discountAmount: 0,
+  warrantyStatus: null,
+  warrantyMonths: null,
+  warrantyMileage: null,
+  warrantyNotes: null,
+}
+
+const NEW_QUOTE = {
+  title: 'Brakes',
+  status: 'draft',
+  subtotal: 0,
+  taxRate: 0,
+  taxAmount: 0,
+  discountValue: 0,
+  discountAmount: 0,
+  totalAmount: 0,
+}
+
+beforeEach(() => {
+  vi.resetAllMocks()
+  setupAuth()
+})
+
+describe('quotes', () => {
+  it('updateQuote refuses a customer from another workshop', async () => {
+    vi.mocked(db.quote.findFirst).mockResolvedValue(QUOTE_ROW as never)
+    foreignCustomer()
+
+    const result = await updateQuote({ id: 'quote-a', customerId: FOREIGN })
+
+    expect(result.success).toBe(false)
+    expect(result.error).toBe('Customer not found')
+    expect(db.$transaction).not.toHaveBeenCalled()
+    expectScopedLookup(vi.mocked(db.customer.findFirst), FOREIGN)
+  })
+
+  it('updateQuote refuses a vehicle from another workshop', async () => {
+    vi.mocked(db.quote.findFirst).mockResolvedValue(QUOTE_ROW as never)
+    foreignVehicle()
+
+    const result = await updateQuote({ id: 'quote-a', vehicleId: 'veh-of-org-b' })
+
+    expect(result.success).toBe(false)
+    expect(result.error).toBe('Vehicle not found')
+    expect(db.$transaction).not.toHaveBeenCalled()
+    expectScopedLookup(vi.mocked(db.vehicle.findFirst), 'veh-of-org-b')
+  })
+
+  it('updateQuote still writes an own customer, and still clears one', async () => {
+    vi.mocked(db.quote.findFirst).mockResolvedValue(QUOTE_ROW as never)
+    ownCustomer()
+    vi.mocked(db.$transaction).mockResolvedValue({ id: 'quote-a' } as never)
+
+    await updateQuote({ id: 'quote-a', customerId: 'cust-a' })
+    expect(db.$transaction).toHaveBeenCalledTimes(1)
+
+    vi.mocked(db.customer.findFirst).mockClear()
+    await updateQuote({ id: 'quote-a', customerId: '' })
+    // Clearing is not a lookup.
+    expect(db.customer.findFirst).not.toHaveBeenCalled()
+    expect(db.$transaction).toHaveBeenCalledTimes(2)
+  })
+
+  it('createQuote refuses a customer from another workshop, even one it only read for tax', async () => {
+    vi.mocked(db.quote.findFirst).mockResolvedValue(null)
+    foreignCustomer()
+
+    const result = await createQuote({ ...NEW_QUOTE, customerId: FOREIGN })
+
+    expect(result.success).toBe(false)
+    expect(result.error).toBe('Customer not found')
+    expect(db.$transaction).not.toHaveBeenCalled()
+  })
+
+  it('createQuote refuses a vehicle from another workshop', async () => {
+    vi.mocked(db.quote.findFirst).mockResolvedValue(null)
+    foreignVehicle()
+
+    const result = await createQuote({ ...NEW_QUOTE, vehicleId: 'veh-of-org-b' })
+
+    expect(result.success).toBe(false)
+    expect(result.error).toBe('Vehicle not found')
+    expect(db.$transaction).not.toHaveBeenCalled()
+  })
+})
+
+describe('scheduled messages', () => {
+  it('updateScheduledMessage refuses foreign ids and writes own ones', async () => {
+    vi.mocked(db.scheduledMessage.findFirst).mockResolvedValue({ id: 'msg-1' } as never)
+    foreignCustomer()
+
+    const refused = await updateScheduledMessage({ id: 'msg-1', customerId: FOREIGN })
+    expect(refused.success).toBe(false)
+    expect(refused.error).toBe('Customer not found')
+    expect(db.scheduledMessage.update).not.toHaveBeenCalled()
+
+    ownCustomer()
+    vi.mocked(db.scheduledMessage.update).mockResolvedValue({ id: 'msg-1' } as never)
+    const ok = await updateScheduledMessage({ id: 'msg-1', customerId: 'cust-a' })
+    expect(ok.success).toBe(true)
+    expect(db.scheduledMessage.update).toHaveBeenCalledWith(
+      expect.objectContaining({ data: expect.objectContaining({ customerId: 'cust-a' }) })
+    )
+  })
+})
+
+describe('vehicles', () => {
+  it('createVehicle refuses an owner from another workshop and accepts an own one', async () => {
+    foreignCustomer()
+
+    const refused = await createVehicle({
+      make: 'VW',
+      model: 'Golf',
+      year: 2020,
+      customerId: FOREIGN,
+    })
+    expect(refused.success).toBe(false)
+    expect(refused.error).toBe('Customer not found')
+    expect(db.vehicle.create).not.toHaveBeenCalled()
+
+    ownCustomer()
+    vi.mocked(db.vehicle.create).mockResolvedValue({ id: 'veh-a' } as never)
+    const ok = await createVehicle({ make: 'VW', model: 'Golf', year: 2020, customerId: 'cust-a' })
+    expect(ok.success).toBe(true)
+    expect(db.vehicle.create).toHaveBeenCalledWith(
+      expect.objectContaining({ data: expect.objectContaining({ customerId: 'cust-a' }) })
+    )
+  })
+
+  it('updateVehicle refuses moving a vehicle to a foreign owner', async () => {
+    vi.mocked(db.vehicle.findFirst).mockResolvedValue({ imageUrl: null } as never)
+    foreignCustomer()
+
+    const result = await updateVehicle({ id: 'veh-a', customerId: FOREIGN })
+
+    expect(result.success).toBe(false)
+    expect(result.error).toBe('Customer not found')
+    expect(db.vehicle.updateMany).not.toHaveBeenCalled()
+  })
+})
+
+describe('tire sets', () => {
+  /** A transaction client with a shelf that has room and a set of our own. */
+  function tx(overrides: Record<string, unknown> = {}) {
+    return {
+      tireLocation: {
+        findFirst: vi.fn().mockResolvedValue({ code: 'A1', capacity: 100, tireSets: [] }),
+      },
+      tireSet: {
+        findFirst: vi.fn().mockResolvedValue({ id: 'set-a', quantity: 4, locationId: 'loc-1' }),
+        create: vi.fn(),
+        update: vi.fn(),
+      },
+      customer: { findFirst: vi.fn().mockResolvedValue(null) },
+      vehicle: { findFirst: vi.fn().mockResolvedValue(null) },
+      ...overrides,
+    }
+  }
+
+  it('checkInTireSet refuses a customer named from another workshop', async () => {
+    const client = tx()
+    vi.mocked(db.$transaction).mockImplementation(async (fn: unknown) =>
+      (fn as (c: unknown) => Promise<unknown>)(client)
+    )
+
+    const result = await checkInTireSet({ locationId: 'loc-1', quantity: 4, customerId: FOREIGN })
+
+    expect(result.success).toBe(false)
+    expect(result.error).toBe('Customer not found')
+    expect(client.tireSet.create).not.toHaveBeenCalled()
+    expect(client.customer.findFirst).toHaveBeenCalledWith(
+      expect.objectContaining({ where: { id: FOREIGN, organizationId: ORG } })
+    )
+  })
+
+  it('updateTireSet refuses moving a set to a foreign vehicle or customer', async () => {
+    const client = tx()
+    vi.mocked(db.$transaction).mockImplementation(async (fn: unknown) =>
+      (fn as (c: unknown) => Promise<unknown>)(client)
+    )
+
+    const byVehicle = await updateTireSet({ id: 'set-a', vehicleId: 'veh-of-org-b' })
+    expect(byVehicle.success).toBe(false)
+    expect(byVehicle.error).toBe('Vehicle not found')
+
+    const byCustomer = await updateTireSet({ id: 'set-a', customerId: FOREIGN })
+    expect(byCustomer.success).toBe(false)
+    expect(byCustomer.error).toBe('Customer not found')
+
+    expect(client.tireSet.update).not.toHaveBeenCalled()
+  })
+})

+ 4 - 0
src/features/quotes/Actions/quoteActions.ts

@@ -4,6 +4,7 @@ import { ATTENTION_STATUSES } from '@/features/quotes/Lib/quoteStatus'
 import { assertQuoteEditable, getDocumentLockSettings } from '@/lib/document-lock.server'
 import { assertQuoteEditable, getDocumentLockSettings } from '@/lib/document-lock.server'
 import { DocumentLockedError, quoteLockState } from '@/lib/document-lock'
 import { DocumentLockedError, quoteLockState } from '@/lib/document-lock'
 import { db } from '@/lib/db'
 import { db } from '@/lib/db'
+import { assertOwnedCustomer, assertOwnedVehicle } from '@/lib/owned-records'
 import { parseTaxComponentDefinitions } from '@/lib/tax-components'
 import { parseTaxComponentDefinitions } from '@/lib/tax-components'
 import { documentTotals, taxComponentsForCopy } from '@/features/settings/Lib/workshopTax'
 import { documentTotals, taxComponentsForCopy } from '@/features/settings/Lib/workshopTax'
 import {
 import {
@@ -240,6 +241,9 @@ export async function updateQuote(input: unknown) {
         where: { id: data.id, organizationId },
         where: { id: data.id, organizationId },
       })
       })
       if (!existing) throw new Error('Quote not found')
       if (!existing) throw new Error('Quote not found')
+      // The customer and vehicle the quote is being pointed at must be ours.
+      await assertOwnedCustomer(data.customerId, organizationId)
+      await assertOwnedVehicle(data.vehicleId, organizationId)
 
 
       const {
       const {
         id,
         id,

+ 5 - 1
src/features/quotes/Lib/createQuoteRecord.ts

@@ -2,6 +2,7 @@ import { discountAmountFor } from '@/lib/tax'
 import 'server-only'
 import 'server-only'
 
 
 import { db } from '@/lib/db'
 import { db } from '@/lib/db'
+import { assertOwnedVehicle } from '@/lib/owned-records'
 import {
 import {
   isShopFeeLine,
   isShopFeeLine,
   newShopFeeLine,
   newShopFeeLine,
@@ -103,6 +104,8 @@ export async function createQuoteRecord(
   let defaultTaxRate = workshopTax.rate
   let defaultTaxRate = workshopTax.rate
   const taxInclusive = workshopTax.inclusive
   const taxInclusive = workshopTax.inclusive
 
 
+  // The customer and vehicle the quote points at must be the workshop's own.
+  await assertOwnedVehicle(data.vehicleId, organizationId)
   // Tax-exempt customer: force the rate to 0 regardless of org default.
   // Tax-exempt customer: force the rate to 0 regardless of org default.
   let customerExempt = false
   let customerExempt = false
   if (data.customerId) {
   if (data.customerId) {
@@ -110,7 +113,8 @@ export async function createQuoteRecord(
       where: { id: data.customerId, organizationId },
       where: { id: data.customerId, organizationId },
       select: { taxExempt: true },
       select: { taxExempt: true },
     })
     })
-    if (customer?.taxExempt) {
+    if (!customer) throw new Error('Customer not found')
+    if (customer.taxExempt) {
       customerExempt = true
       customerExempt = true
       defaultTaxRate = 0
       defaultTaxRate = 0
       data.taxRate = 0
       data.taxRate = 0

+ 4 - 0
src/features/scheduled-messages/Actions/scheduledMessageActions.ts

@@ -2,6 +2,7 @@
 
 
 import { revalidatePath } from 'next/cache'
 import { revalidatePath } from 'next/cache'
 import { db } from '@/lib/db'
 import { db } from '@/lib/db'
+import { assertOwnedCustomer, assertOwnedVehicle } from '@/lib/owned-records'
 import { withAuth } from '@/lib/with-auth'
 import { withAuth } from '@/lib/with-auth'
 import { PermissionAction, PermissionSubject } from '@/lib/permissions'
 import { PermissionAction, PermissionSubject } from '@/lib/permissions'
 import { demoGuard } from '@/lib/demo'
 import { demoGuard } from '@/lib/demo'
@@ -192,6 +193,9 @@ export async function updateScheduledMessage(input: unknown) {
         select: { id: true },
         select: { id: true },
       })
       })
       if (!existing) throw new Error('Scheduled message not found')
       if (!existing) throw new Error('Scheduled message not found')
+      // Same check as on create: the ids written must be ours.
+      await assertOwnedCustomer(data.customerId, organizationId)
+      await assertOwnedVehicle(data.vehicleId, organizationId)
       const timeZone = await workshopTimeZone(organizationId)
       const timeZone = await workshopTimeZone(organizationId)
       if (data.sendAt) assertNotPast(parseWorkshopDateTime(data.sendAt, timeZone))
       if (data.sendAt) assertNotPast(parseWorkshopDateTime(data.sendAt, timeZone))
 
 

+ 6 - 0
src/features/tire-hotel/Actions/tireSetActions.ts

@@ -2,6 +2,7 @@
 
 
 import { revalidatePath } from 'next/cache'
 import { revalidatePath } from 'next/cache'
 import { db, type TxClient } from '@/lib/db'
 import { db, type TxClient } from '@/lib/db'
+import { assertOwnedCustomer, assertOwnedVehicle } from '@/lib/owned-records'
 import { Prisma } from '@/generated/prisma/client'
 import { Prisma } from '@/generated/prisma/client'
 import { withAuth } from '@/lib/with-auth'
 import { withAuth } from '@/lib/with-auth'
 import { PermissionAction, PermissionSubject } from '@/lib/permissions'
 import { PermissionAction, PermissionSubject } from '@/lib/permissions'
@@ -374,6 +375,8 @@ export async function checkInTireSet(input: unknown) {
           // from the customer side, even when staff skipped that field.
           // from the customer side, even when staff skipped that field.
           if (!data.customerId && vehicle.customerId) data.customerId = vehicle.customerId
           if (!data.customerId && vehicle.customerId) data.customerId = vehicle.customerId
         }
         }
+        // A customer named directly must be ours too.
+        await assertOwnedCustomer(data.customerId, organizationId, tx)
 
 
         const reference = await nextReference(tx, organizationId)
         const reference = await nextReference(tx, organizationId)
         const now = new Date()
         const now = new Date()
@@ -907,6 +910,9 @@ export async function updateTireSet(input: unknown) {
       const updated = await db.$transaction(async (tx) => {
       const updated = await db.$transaction(async (tx) => {
         const set = await tx.tireSet.findFirst({ where: { id, organizationId } })
         const set = await tx.tireSet.findFirst({ where: { id, organizationId } })
         if (!set) throw new Error('Tire set not found')
         if (!set) throw new Error('Tire set not found')
+        // A set moved to another vehicle or customer stays within the workshop.
+        await assertOwnedVehicle(data.vehicleId, organizationId, tx)
+        await assertOwnedCustomer(data.customerId, organizationId, tx)
 
 
         // Growing the set has to fit where it already sits.
         // Growing the set has to fit where it already sits.
         // New readings are recorded as a fresh round, under a movement of
         // New readings are recorded as a fresh round, under a movement of

+ 4 - 0
src/features/vehicles/Actions/vehicleActions.ts

@@ -4,6 +4,7 @@ import { workshopTimeZone } from '@/lib/workshop-timezone'
 import { assertOwnUploads } from '@/lib/upload-url'
 import { assertOwnUploads } from '@/lib/upload-url'
 import { toSafeWorkshopDate } from '@/lib/workshop-datetime'
 import { toSafeWorkshopDate } from '@/lib/workshop-datetime'
 import { db } from '@/lib/db'
 import { db } from '@/lib/db'
+import { assertOwnedCustomer } from '@/lib/owned-records'
 import { withAuth } from '@/lib/with-auth'
 import { withAuth } from '@/lib/with-auth'
 import { PermissionAction, PermissionSubject } from '@/lib/permissions'
 import { PermissionAction, PermissionSubject } from '@/lib/permissions'
 import { createVehicleSchema, updateVehicleSchema } from '../Schema/vehicleSchema'
 import { createVehicleSchema, updateVehicleSchema } from '../Schema/vehicleSchema'
@@ -242,6 +243,8 @@ export async function createVehicle(input: unknown) {
     async ({ userId, organizationId }) => {
     async ({ userId, organizationId }) => {
       const { inspectionDueAt, ...data } = createVehicleSchema.parse(input)
       const { inspectionDueAt, ...data } = createVehicleSchema.parse(input)
       assertOwnUploads(data, organizationId)
       assertOwnUploads(data, organizationId)
+      // The owner the vehicle is filed under must be one of our customers.
+      await assertOwnedCustomer(data.customerId, organizationId)
       const timeZone = await workshopTimeZone(organizationId)
       const timeZone = await workshopTimeZone(organizationId)
       const vehicle = await db.vehicle.create({
       const vehicle = await db.vehicle.create({
         data: {
         data: {
@@ -289,6 +292,7 @@ export async function updateVehicle(input: unknown) {
         select: { year: true, make: true, model: true, licensePlate: true, imageUrl: true },
         select: { year: true, make: true, model: true, licensePlate: true, imageUrl: true },
       })
       })
       if (!before) throw new Error('Vehicle not found')
       if (!before) throw new Error('Vehicle not found')
+      await assertOwnedCustomer(data.customerId, organizationId)
 
 
       const updateResult = await db.vehicle.updateMany({
       const updateResult = await db.vehicle.updateMany({
         where: { id, organizationId },
         where: { id, organizationId },

+ 40 - 0
src/lib/owned-records.ts

@@ -0,0 +1,40 @@
+import 'server-only'
+
+import { db, type TxClient } from '@/lib/db'
+
+/**
+ * A record may only point at customers and vehicles of its own workshop.
+ *
+ * Every write that stores a customerId or vehicleId it was handed goes
+ * through here first. The row being written is always loaded org-scoped;
+ * these make sure the ids written into it are too, so a quote or a tire set
+ * can never be made to show another workshop's customer. An empty value is
+ * a clearing and passes; a wrong id fails the same way a wrong row id does.
+ */
+type Reader = Pick<TxClient, 'customer' | 'vehicle'>
+
+export async function assertOwnedCustomer(
+  customerId: string | null | undefined,
+  organizationId: string,
+  client: Reader = db
+): Promise<void> {
+  if (!customerId) return
+  const customer = await client.customer.findFirst({
+    where: { id: customerId, organizationId },
+    select: { id: true },
+  })
+  if (!customer) throw new Error('Customer not found')
+}
+
+export async function assertOwnedVehicle(
+  vehicleId: string | null | undefined,
+  organizationId: string,
+  client: Reader = db
+): Promise<void> {
+  if (!vehicleId) return
+  const vehicle = await client.vehicle.findFirst({
+    where: { id: vehicleId, organizationId },
+    select: { id: true },
+  })
+  if (!vehicle) throw new Error('Vehicle not found')
+}