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:
@@ -1,4 +1,4 @@
|
||||
import { and, desc, eq, isNull, ne } from 'drizzle-orm';
|
||||
import { and, desc, eq, inArray, isNull, ne } from 'drizzle-orm';
|
||||
import { db } from '@/lib/db';
|
||||
import { companies, companyMemberships } from '@/lib/db/schema/companies';
|
||||
import type { CompanyMembership } from '@/lib/db/schema/companies';
|
||||
@@ -135,7 +135,20 @@ export async function updateMembership(
|
||||
const rows = await db
|
||||
.update(companyMemberships)
|
||||
.set({ ...data, updatedAt: new Date() })
|
||||
.where(eq(companyMemberships.id, membershipId))
|
||||
// Defense-in-depth: companyMemberships has no port_id column, so the
|
||||
// tenant boundary lives on the parent company. Pin the WHERE to a
|
||||
// sub-select of port companies — even if loadMembershipScoped were
|
||||
// ever bypassed, a stray membershipId from another tenant cannot
|
||||
// mutate.
|
||||
.where(
|
||||
and(
|
||||
eq(companyMemberships.id, membershipId),
|
||||
inArray(
|
||||
companyMemberships.companyId,
|
||||
db.select({ id: companies.id }).from(companies).where(eq(companies.portId, portId)),
|
||||
),
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
|
||||
const updated = rows[0]!;
|
||||
@@ -173,7 +186,20 @@ export async function endMembership(
|
||||
const rows = await db
|
||||
.update(companyMemberships)
|
||||
.set({ endDate: data.endDate, updatedAt: new Date() })
|
||||
.where(eq(companyMemberships.id, membershipId))
|
||||
// Defense-in-depth: companyMemberships has no port_id column, so the
|
||||
// tenant boundary lives on the parent company. Pin the WHERE to a
|
||||
// sub-select of port companies — even if loadMembershipScoped were
|
||||
// ever bypassed, a stray membershipId from another tenant cannot
|
||||
// mutate.
|
||||
.where(
|
||||
and(
|
||||
eq(companyMemberships.id, membershipId),
|
||||
inArray(
|
||||
companyMemberships.companyId,
|
||||
db.select({ id: companies.id }).from(companies).where(eq(companies.portId, portId)),
|
||||
),
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
|
||||
const updated = rows[0]!;
|
||||
@@ -227,7 +253,16 @@ export async function setPrimary(
|
||||
const rows = await tx
|
||||
.update(companyMemberships)
|
||||
.set({ isPrimary: true, updatedAt: new Date() })
|
||||
.where(eq(companyMemberships.id, membershipId))
|
||||
// Same defense-in-depth via port-companies sub-select — see updateMembership.
|
||||
.where(
|
||||
and(
|
||||
eq(companyMemberships.id, membershipId),
|
||||
inArray(
|
||||
companyMemberships.companyId,
|
||||
tx.select({ id: companies.id }).from(companies).where(eq(companies.portId, portId)),
|
||||
),
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
|
||||
const updated = rows[0]!;
|
||||
|
||||
Reference in New Issue
Block a user