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

@@ -21,7 +21,7 @@ import { and, desc, eq, inArray } from 'drizzle-orm';
import { db } from '@/lib/db';
import { interestBerths, interests, type InterestBerth } from '@/lib/db/schema/interests';
import { berths } from '@/lib/db/schema/berths';
import { CodedError } from '@/lib/errors';
import { CodedError, NotFoundError } from '@/lib/errors';
type DbOrTx = typeof db | Parameters<Parameters<typeof db.transaction>[0]>[0];
@@ -274,8 +274,34 @@ export async function setPrimaryBerth(interestId: string, berthId: string): Prom
await upsertInterestBerth(interestId, berthId, { isPrimary: true });
}
/** Remove a berth from an interest. */
export async function removeInterestBerth(interestId: string, berthId: string): Promise<void> {
/** Remove a berth from an interest.
*
* `portId` is required for cross-port defense — `upsertInterestBerth`
* and `setPrimaryBerth` both verify the interest + berth share the
* caller's port before mutation, but the original `removeInterestBerth`
* issued a delete keyed only by (interestId, berthId), so a future
* caller that omitted its own port check could delete a junction row
* across tenants. This now mirrors the cross-check used by upsert.
*/
export async function removeInterestBerth(
interestId: string,
berthId: string,
portId: string,
): Promise<void> {
// Verify both the interest and the berth belong to the caller's
// port before issuing the delete. A tenant boundary breach would
// otherwise be a single misrouted call away.
const [interestRow, berthRow] = await Promise.all([
db.query.interests.findFirst({
where: and(eq(interests.id, interestId), eq(interests.portId, portId)),
}),
db.query.berths.findFirst({
where: and(eq(berths.id, berthId), eq(berths.portId, portId)),
}),
]);
if (!interestRow || !berthRow) {
throw new NotFoundError('interest or berth');
}
await db
.delete(interestBerths)
.where(and(eq(interestBerths.interestId, interestId), eq(interestBerths.berthId, berthId)));