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:
@@ -216,10 +216,20 @@ export async function markRead(notificationId: string, userId: string): Promise<
|
||||
throw new NotFoundError('Notification');
|
||||
}
|
||||
|
||||
// Pin the WHERE to (id, userId, portId) — the SELECT just resolved
|
||||
// notif.portId for this notification, so the update is fully tenant-
|
||||
// scoped and a future caller mistake (or a cache layer that ever
|
||||
// surfaced a foreign-port id) can't flip a row in another port.
|
||||
await db
|
||||
.update(notifications)
|
||||
.set({ isRead: true })
|
||||
.where(and(eq(notifications.id, notificationId), eq(notifications.userId, userId)));
|
||||
.where(
|
||||
and(
|
||||
eq(notifications.id, notificationId),
|
||||
eq(notifications.userId, userId),
|
||||
eq(notifications.portId, notif.portId),
|
||||
),
|
||||
);
|
||||
|
||||
const unreadCount = await getUnreadCountValue(userId, notif.portId);
|
||||
emitToRoom(`user:${userId}`, 'notification:unreadCount', { count: unreadCount });
|
||||
|
||||
Reference in New Issue
Block a user