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:
@@ -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)));
|
||||
|
||||
Reference in New Issue
Block a user