fix(audit-tier-4): tenant-isolation defense-in-depth

Closes the audit's HIGH §10 + MED §§17–22 isolation footguns. None of
these are user-impactful TODAY — every site is preceded by a port-
scoped read or pre-validated by ctx.portId — but each is a future-
refactor accident waiting to happen, so the SQL itself now pins the
tenant boundary:

* mergeClients gains a callerPortId option; the route caller passes
  ctx.portId.  removeInterestBerth now requires portId and verifies
  both the interest and the berth share it before deleting the
  junction row.  All three callers updated.
* Six service mutations now scope the WHERE to (id, portId):
  form-templates update + delete, invoices.detectOverdue per-row
  update, notifications.markRead, clients.deleteRelationship.
  company-memberships uses an inArray sub-select against port
  companies (no port_id column on the table itself), covering
  updateMembership / endMembership / setPrimary.
* Port-scoped file lookups in portal.getDocumentDownloadUrl,
  reports.getDownloadUrl (file presign), berth-reservations.activate
  (contractFileId attach guard), and residential.getResidentialInterestById
  (residentialClient join).

Test status: 1168/1168 vitest, tsc clean.

Refs: docs/audit-comprehensive-2026-05-05.md HIGH §10 + MED §§17–22
(auditor-B3 Issues 1–5,7).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Ciaccio
2026-05-05 20:48:13 +02:00
parent 7854cbabe4
commit 4eea4ceff9
14 changed files with 142 additions and 18 deletions

View File

@@ -3,6 +3,7 @@ import { db } from '@/lib/db';
import { berthReservations, type BerthReservation } from '@/lib/db/schema/reservations';
import { berths } from '@/lib/db/schema/berths';
import { clients } from '@/lib/db/schema/clients';
import { files } from '@/lib/db/schema/documents';
import { yachts } from '@/lib/db/schema/yachts';
import { companyMemberships } from '@/lib/db/schema/companies';
import { buildListQuery } from '@/lib/db/query-builder';
@@ -169,6 +170,19 @@ export async function activate(
updatedAt: new Date(),
};
if (data.contractFileId !== undefined) {
// Verify the contract file lives in the caller's port. Without this,
// a port-A user activating a port-A reservation could attach a
// port-B file id and downstream presigned-download paths (admin
// export, portal contract download) would otherwise leak that
// foreign-port content.
if (data.contractFileId !== null) {
const contractFile = await db.query.files.findFirst({
where: and(eq(files.id, data.contractFileId), eq(files.portId, portId)),
});
if (!contractFile) {
throw new ValidationError('contract file not found in this port');
}
}
patch.contractFileId = data.contractFileId;
}
if (data.effectiveDate !== undefined) {