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:
@@ -55,6 +55,16 @@ export interface MergeOptions {
|
||||
loserId: string;
|
||||
/** ID of the user performing the merge (for audit + clientMergeLog.mergedBy). */
|
||||
mergedBy: string;
|
||||
/**
|
||||
* Caller's port — defends against any future caller forgetting to
|
||||
* pre-validate. Today the sole route caller pre-checks via ctx.portId,
|
||||
* so this is defense-in-depth: a future bulk-import or CLI caller
|
||||
* that omits the check still cannot trigger a cross-tenant merge.
|
||||
* Optional for backwards compat; mergeClients enforces same-port
|
||||
* regardless, but when set the assertion is "winner.portId === callerPortId"
|
||||
* (and by transitive same-port rule, loser too).
|
||||
*/
|
||||
callerPortId?: string;
|
||||
/** Per-field choice overrides. Multi-value fields (contacts, addresses,
|
||||
* notes, tags) are always preserved from both sides; this only
|
||||
* affects single-value scalar fields on the `clients` row. */
|
||||
@@ -105,6 +115,14 @@ export async function mergeClients(opts: MergeOptions): Promise<MergeResult> {
|
||||
if (winnerRow.portId !== loserRow.portId) {
|
||||
throw new ValidationError('Cannot merge clients across different ports');
|
||||
}
|
||||
if (opts.callerPortId && winnerRow.portId !== opts.callerPortId) {
|
||||
// Defense-in-depth: even though the route already pre-checks, a
|
||||
// future caller that wires this service from a CLI / bulk-import
|
||||
// path would otherwise be able to merge any two clients sharing a
|
||||
// port (or, if the same-port check above were ever weakened, two
|
||||
// arbitrary clients). Refuse upfront.
|
||||
throw new ValidationError('Cannot merge clients in another port');
|
||||
}
|
||||
if (loserRow.mergedIntoClientId) {
|
||||
throw new ConflictError('That client has already been merged into another record.');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user