Files
pn-new-crm/src/app/api/v1/custom-fields/[entityId]/route.ts
Matt Ciaccio 4eea19a85b sec: lock down 5 cross-tenant FK gaps from fifth-pass review
1. HIGH — reminders.create/updateReminder accepted clientId/interestId/
   berthId from the body and persisted them with no port check; getReminder
   then hydrated the row via Drizzle relations (no port filter on the
   join), so a port-A user with reminders:create could exfiltrate any
   port-B client/interest/berth row by guessing its UUID. New
   assertReminderFksInPort gates create + update.

2. HIGH — listRecommendations(interestId, _portId) discarded portId
   entirely; the route GET /api/v1/interests/[id]/recommendations
   forwarded the URL id straight through. A port-A user with
   interests:view could read any other tenant's recommended berths
   (mooring numbers, dimensions, status). Service now verifies the
   interest belongs to portId and joins berths filtered by port.

3. HIGH — Berth waiting list. The PATCH route did not pre-check that
   the berth belonged to ctx.portId — a port-A user with
   manage_waiting_list could reorder a port-B berth's queue. Separately,
   updateWaitingList accepted arbitrary entries[].clientId and inserted
   them without verifying tenancy, polluting the table with foreign-port
   FKs. Both gaps closed.

4. MEDIUM — setEntityTags (clients/companies/yachts/interests/berths)
   accepted any tagId and inserted into the join table. The tags table
   is per-port but the join only carries a single-column FK. The
   downstream getById join `tags ON join.tag_id = tags.id` has no port
   filter, so a foreign tag's name + color render in the requesting port.
   Helper now batch-validates tagIds belong to portId before insert.

5. MEDIUM — /api/v1/custom-fields/[entityId] PUT had no withPermission
   gate (any role, including viewer, could write) and didn't validate
   that the URL entityId pointed at a port-scoped entity of the field
   definition's entityType. Route now uses
   withPermission('clients','view'/'edit',…); service validates the
   entityId per resolved entityType (client/interest/berth/yacht/company)
   against portId.

Test mocks updated to cover the new entity-port-scope check.
818 vitest tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 03:28:31 +02:00

56 lines
1.8 KiB
TypeScript

import { NextRequest, NextResponse } from 'next/server';
import { withAuth, withPermission } from '@/lib/api/helpers';
import { errorResponse, NotFoundError } from '@/lib/errors';
import { setValuesSchema } from '@/lib/validators/custom-fields';
import { getValues, setValues } from '@/lib/services/custom-fields.service';
// Custom-field values live on top of a port-scoped entity (client, yacht,
// interest, berth, company). Reading the values is in scope for any role
// that can view clients (the most common surface); writing requires the
// equivalent edit permission. The service-layer also re-validates the
// entityId against the field definition's entityType + portId so a
// caller cannot poke values onto an arbitrary or foreign-port entity.
export const GET = withAuth(
withPermission('clients', 'view', async (_req: NextRequest, ctx, params) => {
try {
const { entityId } = params;
if (!entityId) throw new NotFoundError('Entity');
const data = await getValues(entityId, ctx.portId);
return NextResponse.json({ data });
} catch (error) {
return errorResponse(error);
}
}),
);
export const PUT = withAuth(
withPermission('clients', 'edit', async (req: NextRequest, ctx, params) => {
try {
const { entityId } = params;
if (!entityId) throw new NotFoundError('Entity');
const body = await req.json();
const { values } = setValuesSchema.parse(body);
const result = await setValues(
entityId,
ctx.portId,
ctx.userId,
values as Array<{ fieldId: string; value: unknown }>,
{
userId: ctx.userId,
portId: ctx.portId,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
},
);
return NextResponse.json({ data: result });
} catch (error) {
return errorResponse(error);
}
}),
);