sec: lock down 5 cross-tenant IDORs uncovered in second-pass review
1. HIGH — /api/v1/admin/ports/[id] PATCH+GET let any port-admin (manage_settings) mutate any other tenant's port row by passing the foreign id in the path. Now non-super-admins must target their own ctx.portId; listPorts and createPort are super-admin only. 2. HIGH — Invoice create/update accepted arbitrary expenseIds and linked them into invoice_expenses with no port check; the GET response then re-emitted those foreign expense rows via the linkedExpenses join. assertExpensesInPort now validates each id belongs to the caller's portId before insert; getInvoiceById's join filters by expenses.portId as defense-in-depth. 3. HIGH — Document creation paths (createDocument, createFromWizard, createFromUpload) persisted user-supplied clientId/interestId/ companyId/yachtId/reservationId without verifying those FKs were in-port. sendForSigning then loaded the foreign client/interest by id alone and pushed their PII into the Documenso payload. New assertSubjectFksInPort helper rejects out-of-port FKs at create time; sendForSigning's interest+client lookups now also filter by portId. 4. MEDIUM — calculateInterestScore read its redis cache before verifying portId, and the cache key was interestId-only — a foreign-port caller could observe a cached score breakdown. Cache key now includes portId, and the port-scope DB lookup runs before any cache.get. 5. MEDIUM — AI email-draft job results were retrievable by anyone who could guess the BullMQ jobId (default sequential integers). Job ids are now random UUIDs, requestEmailDraft validates interestId/ clientId belong to ctx.portId before enqueueing, the worker's client lookup is port-scoped, and getEmailDraftResult requires the caller to match the original requester's userId+portId before returning the drafted subject/body. The interest-scoring unit test that asserted "DB is bypassed on cache hit" is updated to reflect the new (security-correct) ordering. Two new regression test files cover the email-draft binding (5 tests). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4,11 +4,25 @@ import { withAuth, withPermission } from '@/lib/api/helpers';
|
|||||||
import { parseBody } from '@/lib/api/route-helpers';
|
import { parseBody } from '@/lib/api/route-helpers';
|
||||||
import { getPort, updatePort } from '@/lib/services/ports.service';
|
import { getPort, updatePort } from '@/lib/services/ports.service';
|
||||||
import { updatePortSchema } from '@/lib/validators/ports';
|
import { updatePortSchema } from '@/lib/validators/ports';
|
||||||
import { errorResponse } from '@/lib/errors';
|
import { errorResponse, ForbiddenError } from '@/lib/errors';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Non-super-admin callers (e.g. port directors holding admin.manage_settings)
|
||||||
|
* may only read/mutate THEIR OWN port row. The path id is therefore
|
||||||
|
* compared against ctx.portId and a foreign target is rejected before the
|
||||||
|
* service is touched. Super-admins retain unrestricted access.
|
||||||
|
*/
|
||||||
|
function assertPortInScope(targetPortId: string, ctx: { portId: string; isSuperAdmin: boolean }) {
|
||||||
|
if (ctx.isSuperAdmin) return;
|
||||||
|
if (targetPortId !== ctx.portId) {
|
||||||
|
throw new ForbiddenError('Cross-tenant port access denied');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const GET = withAuth(
|
export const GET = withAuth(
|
||||||
withPermission('admin', 'manage_settings', async (_req, _ctx, params) => {
|
withPermission('admin', 'manage_settings', async (_req, ctx, params) => {
|
||||||
try {
|
try {
|
||||||
|
assertPortInScope(params.id!, ctx);
|
||||||
const data = await getPort(params.id!);
|
const data = await getPort(params.id!);
|
||||||
return NextResponse.json({ data });
|
return NextResponse.json({ data });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -20,6 +34,7 @@ export const GET = withAuth(
|
|||||||
export const PATCH = withAuth(
|
export const PATCH = withAuth(
|
||||||
withPermission('admin', 'manage_settings', async (req, ctx, params) => {
|
withPermission('admin', 'manage_settings', async (req, ctx, params) => {
|
||||||
try {
|
try {
|
||||||
|
assertPortInScope(params.id!, ctx);
|
||||||
const body = await parseBody(req, updatePortSchema);
|
const body = await parseBody(req, updatePortSchema);
|
||||||
const data = await updatePort(params.id!, body, {
|
const data = await updatePort(params.id!, body, {
|
||||||
userId: ctx.userId,
|
userId: ctx.userId,
|
||||||
|
|||||||
@@ -4,11 +4,18 @@ import { withAuth, withPermission } from '@/lib/api/helpers';
|
|||||||
import { parseBody } from '@/lib/api/route-helpers';
|
import { parseBody } from '@/lib/api/route-helpers';
|
||||||
import { listPorts, createPort } from '@/lib/services/ports.service';
|
import { listPorts, createPort } from '@/lib/services/ports.service';
|
||||||
import { createPortSchema } from '@/lib/validators/ports';
|
import { createPortSchema } from '@/lib/validators/ports';
|
||||||
import { errorResponse } from '@/lib/errors';
|
import { errorResponse, ForbiddenError } from '@/lib/errors';
|
||||||
|
|
||||||
|
// Listing every tenant and creating new tenants are super-admin operations:
|
||||||
|
// a port director must not be able to enumerate other ports (target
|
||||||
|
// discovery for cross-tenant attacks) or spin up new tenants whose admin
|
||||||
|
// they implicitly become.
|
||||||
export const GET = withAuth(
|
export const GET = withAuth(
|
||||||
withPermission('admin', 'manage_settings', async () => {
|
withPermission('admin', 'manage_settings', async (_req, ctx) => {
|
||||||
try {
|
try {
|
||||||
|
if (!ctx.isSuperAdmin) {
|
||||||
|
throw new ForbiddenError('Listing all ports requires super-admin');
|
||||||
|
}
|
||||||
const data = await listPorts();
|
const data = await listPorts();
|
||||||
return NextResponse.json({ data });
|
return NextResponse.json({ data });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -20,6 +27,9 @@ export const GET = withAuth(
|
|||||||
export const POST = withAuth(
|
export const POST = withAuth(
|
||||||
withPermission('admin', 'manage_settings', async (req, ctx) => {
|
withPermission('admin', 'manage_settings', async (req, ctx) => {
|
||||||
try {
|
try {
|
||||||
|
if (!ctx.isSuperAdmin) {
|
||||||
|
throw new ForbiddenError('Creating ports requires super-admin');
|
||||||
|
}
|
||||||
const body = await parseBody(req, createPortSchema);
|
const body = await parseBody(req, createPortSchema);
|
||||||
const data = await createPort(body, {
|
const data = await createPort(body, {
|
||||||
userId: ctx.userId,
|
userId: ctx.userId,
|
||||||
|
|||||||
@@ -4,14 +4,17 @@ import { withAuth } from '@/lib/api/helpers';
|
|||||||
import { getEmailDraftResult } from '@/lib/services/email-draft.service';
|
import { getEmailDraftResult } from '@/lib/services/email-draft.service';
|
||||||
import { errorResponse } from '@/lib/errors';
|
import { errorResponse } from '@/lib/errors';
|
||||||
|
|
||||||
export const GET = withAuth(async (_req, _ctx, params) => {
|
export const GET = withAuth(async (_req, ctx, params) => {
|
||||||
try {
|
try {
|
||||||
const { jobId } = params;
|
const { jobId } = params;
|
||||||
if (!jobId) {
|
if (!jobId) {
|
||||||
return NextResponse.json({ error: 'jobId is required' }, { status: 400 });
|
return NextResponse.json({ error: 'jobId is required' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await getEmailDraftResult(jobId);
|
const result = await getEmailDraftResult(jobId, {
|
||||||
|
userId: ctx.userId,
|
||||||
|
portId: ctx.portId,
|
||||||
|
});
|
||||||
|
|
||||||
if (result === null) {
|
if (result === null) {
|
||||||
return NextResponse.json({ status: 'processing' });
|
return NextResponse.json({ status: 'processing' });
|
||||||
|
|||||||
@@ -36,12 +36,15 @@ async function generateEmailDraft(payload: GenerateEmailDraftPayload): Promise<D
|
|||||||
const { emailThreads } = await import('@/lib/db/schema/email');
|
const { emailThreads } = await import('@/lib/db/schema/email');
|
||||||
const { and, eq, desc } = await import('drizzle-orm');
|
const { and, eq, desc } = await import('drizzle-orm');
|
||||||
|
|
||||||
// Fetch interest, client, berth
|
// Fetch interest, client, berth — both lookups port-scoped so a
|
||||||
|
// crafted job payload cannot exfiltrate foreign-tenant data.
|
||||||
const [interest, client] = await Promise.all([
|
const [interest, client] = await Promise.all([
|
||||||
db.query.interests.findFirst({
|
db.query.interests.findFirst({
|
||||||
where: and(eq(interests.id, interestId), eq(interests.portId, portId)),
|
where: and(eq(interests.id, interestId), eq(interests.portId, portId)),
|
||||||
}),
|
}),
|
||||||
db.query.clients.findFirst({ where: eq(clients.id, clientId) }),
|
db.query.clients.findFirst({
|
||||||
|
where: and(eq(clients.id, clientId), eq(clients.portId, portId)),
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!interest || !client) {
|
if (!interest || !client) {
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ import {
|
|||||||
} from '@/lib/db/schema/documents';
|
} from '@/lib/db/schema/documents';
|
||||||
import { interests } from '@/lib/db/schema/interests';
|
import { interests } from '@/lib/db/schema/interests';
|
||||||
import { clients } from '@/lib/db/schema/clients';
|
import { clients } from '@/lib/db/schema/clients';
|
||||||
|
import { companies } from '@/lib/db/schema/companies';
|
||||||
|
import { yachts } from '@/lib/db/schema/yachts';
|
||||||
|
import { berthReservations } from '@/lib/db/schema/reservations';
|
||||||
import { ports } from '@/lib/db/schema/ports';
|
import { ports } from '@/lib/db/schema/ports';
|
||||||
import { buildListQuery } from '@/lib/db/query-builder';
|
import { buildListQuery } from '@/lib/db/query-builder';
|
||||||
import { createAuditLog, type AuditMeta } from '@/lib/audit';
|
import { createAuditLog, type AuditMeta } from '@/lib/audit';
|
||||||
@@ -258,9 +261,90 @@ export async function getDocumentById(id: string, portId: string) {
|
|||||||
return doc;
|
return doc;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reject any subject FK (clientId / interestId / companyId / yachtId /
|
||||||
|
* reservationId) that points at a row outside the caller's port. Without
|
||||||
|
* this guard, a port-A user could create a document whose subject is a
|
||||||
|
* port-B client and then exfiltrate the foreign client's name + email
|
||||||
|
* via sendForSigning's Documenso payload, or via the local watcher /
|
||||||
|
* notification surfaces that hydrate the linked entity.
|
||||||
|
*/
|
||||||
|
async function assertSubjectFksInPort(
|
||||||
|
portId: string,
|
||||||
|
fks: {
|
||||||
|
clientId?: string | null;
|
||||||
|
interestId?: string | null;
|
||||||
|
companyId?: string | null;
|
||||||
|
yachtId?: string | null;
|
||||||
|
reservationId?: string | null;
|
||||||
|
},
|
||||||
|
): Promise<void> {
|
||||||
|
const checks: Array<Promise<void>> = [];
|
||||||
|
if (fks.clientId) {
|
||||||
|
checks.push(
|
||||||
|
db.query.clients
|
||||||
|
.findFirst({ where: and(eq(clients.id, fks.clientId), eq(clients.portId, portId)) })
|
||||||
|
.then((row) => {
|
||||||
|
if (!row) throw new ValidationError('clientId not found in this port');
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (fks.interestId) {
|
||||||
|
checks.push(
|
||||||
|
db.query.interests
|
||||||
|
.findFirst({
|
||||||
|
where: and(eq(interests.id, fks.interestId), eq(interests.portId, portId)),
|
||||||
|
})
|
||||||
|
.then((row) => {
|
||||||
|
if (!row) throw new ValidationError('interestId not found in this port');
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (fks.companyId) {
|
||||||
|
checks.push(
|
||||||
|
db.query.companies
|
||||||
|
.findFirst({
|
||||||
|
where: and(eq(companies.id, fks.companyId), eq(companies.portId, portId)),
|
||||||
|
})
|
||||||
|
.then((row) => {
|
||||||
|
if (!row) throw new ValidationError('companyId not found in this port');
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (fks.yachtId) {
|
||||||
|
checks.push(
|
||||||
|
db.query.yachts
|
||||||
|
.findFirst({ where: and(eq(yachts.id, fks.yachtId), eq(yachts.portId, portId)) })
|
||||||
|
.then((row) => {
|
||||||
|
if (!row) throw new ValidationError('yachtId not found in this port');
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (fks.reservationId) {
|
||||||
|
checks.push(
|
||||||
|
db.query.berthReservations
|
||||||
|
.findFirst({
|
||||||
|
where: and(
|
||||||
|
eq(berthReservations.id, fks.reservationId),
|
||||||
|
eq(berthReservations.portId, portId),
|
||||||
|
),
|
||||||
|
})
|
||||||
|
.then((row) => {
|
||||||
|
if (!row) throw new ValidationError('reservationId not found in this port');
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await Promise.all(checks);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Create ───────────────────────────────────────────────────────────────────
|
// ─── Create ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function createDocument(portId: string, data: CreateDocumentInput, meta: AuditMeta) {
|
export async function createDocument(portId: string, data: CreateDocumentInput, meta: AuditMeta) {
|
||||||
|
await assertSubjectFksInPort(portId, {
|
||||||
|
clientId: data.clientId,
|
||||||
|
interestId: data.interestId,
|
||||||
|
});
|
||||||
|
|
||||||
const [doc] = await db
|
const [doc] = await db
|
||||||
.insert(documents)
|
.insert(documents)
|
||||||
.values({
|
.values({
|
||||||
@@ -364,14 +448,20 @@ export async function sendForSigning(documentId: string, portId: string, meta: A
|
|||||||
if (!doc.fileId) throw new ValidationError('Document has no associated file');
|
if (!doc.fileId) throw new ValidationError('Document has no associated file');
|
||||||
if (doc.status !== 'draft') throw new ConflictError('Document is not in draft status');
|
if (doc.status !== 'draft') throw new ConflictError('Document is not in draft status');
|
||||||
|
|
||||||
// Fetch interest + client to build signers
|
// Fetch interest + client to build signers. Filter by portId in addition
|
||||||
|
// to the FK so that even if a stale or maliciously-set subject FK on the
|
||||||
|
// document points at a foreign-port row, this signing flow refuses to
|
||||||
|
// hydrate (and therefore refuses to ship to Documenso) data from outside
|
||||||
|
// the caller's tenant.
|
||||||
const interest = doc.interestId
|
const interest = doc.interestId
|
||||||
? await db.query.interests.findFirst({ where: eq(interests.id, doc.interestId) })
|
? await db.query.interests.findFirst({
|
||||||
|
where: and(eq(interests.id, doc.interestId), eq(interests.portId, portId)),
|
||||||
|
})
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const client = doc.clientId
|
const client = doc.clientId
|
||||||
? await db.query.clients.findFirst({
|
? await db.query.clients.findFirst({
|
||||||
where: eq(clients.id, doc.clientId),
|
where: and(eq(clients.id, doc.clientId), eq(clients.portId, portId)),
|
||||||
with: { contacts: true },
|
with: { contacts: true },
|
||||||
})
|
})
|
||||||
: null;
|
: null;
|
||||||
@@ -1198,6 +1288,14 @@ export async function createFromWizard(
|
|||||||
throw new ValidationError('templateId is required for template source');
|
throw new ValidationError('templateId is required for template source');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await assertSubjectFksInPort(portId, {
|
||||||
|
clientId: data.clientId,
|
||||||
|
interestId: data.interestId,
|
||||||
|
companyId: data.companyId,
|
||||||
|
yachtId: data.yachtId,
|
||||||
|
reservationId: data.reservationId,
|
||||||
|
});
|
||||||
|
|
||||||
const [doc] = await db
|
const [doc] = await db
|
||||||
.insert(documents)
|
.insert(documents)
|
||||||
.values({
|
.values({
|
||||||
@@ -1275,6 +1373,14 @@ export async function createFromUpload(
|
|||||||
throw new NotFoundError('File');
|
throw new NotFoundError('File');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await assertSubjectFksInPort(portId, {
|
||||||
|
clientId: data.clientId,
|
||||||
|
interestId: data.interestId,
|
||||||
|
companyId: data.companyId,
|
||||||
|
yachtId: data.yachtId,
|
||||||
|
reservationId: data.reservationId,
|
||||||
|
});
|
||||||
|
|
||||||
const [doc] = await db
|
const [doc] = await db
|
||||||
.insert(documents)
|
.insert(documents)
|
||||||
.values({
|
.values({
|
||||||
|
|||||||
@@ -1,3 +1,10 @@
|
|||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
import { and, eq } from 'drizzle-orm';
|
||||||
|
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { interests } from '@/lib/db/schema/interests';
|
||||||
|
import { clients } from '@/lib/db/schema/clients';
|
||||||
|
import { ValidationError, ForbiddenError } from '@/lib/errors';
|
||||||
import { getQueue } from '@/lib/queue';
|
import { getQueue } from '@/lib/queue';
|
||||||
|
|
||||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
@@ -20,26 +27,50 @@ export interface DraftResult {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Request an AI-generated email draft.
|
* Request an AI-generated email draft.
|
||||||
* Enqueues a job on the 'ai' queue. Returns jobId for polling.
|
*
|
||||||
* Job payload contains ONLY entity IDs (no PII).
|
* Generates an opaque random jobId rather than relying on BullMQ's default
|
||||||
|
* sequential ids — the jobId is the access token for polling, so it must
|
||||||
|
* not be enumerable. The job payload also captures the requesting user
|
||||||
|
* + port so the poll endpoint can refuse cross-tenant / cross-user reads.
|
||||||
|
*
|
||||||
|
* The interestId and clientId are validated against portId before enqueue
|
||||||
|
* so a port-A caller cannot trigger a draft built from port-B data.
|
||||||
*/
|
*/
|
||||||
export async function requestEmailDraft(
|
export async function requestEmailDraft(
|
||||||
userId: string,
|
userId: string,
|
||||||
request: DraftRequest,
|
request: DraftRequest,
|
||||||
): Promise<{ jobId: string }> {
|
): Promise<{ jobId: string }> {
|
||||||
const aiQueue = getQueue('ai');
|
const interest = await db.query.interests.findFirst({
|
||||||
|
where: and(eq(interests.id, request.interestId), eq(interests.portId, request.portId)),
|
||||||
const job = await aiQueue.add('generate-email-draft', {
|
|
||||||
// No PII — only IDs and context parameters
|
|
||||||
interestId: request.interestId,
|
|
||||||
clientId: request.clientId,
|
|
||||||
portId: request.portId,
|
|
||||||
context: request.context,
|
|
||||||
additionalInstructions: request.additionalInstructions,
|
|
||||||
requestedBy: userId,
|
|
||||||
});
|
});
|
||||||
|
if (!interest) {
|
||||||
|
throw new ValidationError('interestId not found in this port');
|
||||||
|
}
|
||||||
|
const client = await db.query.clients.findFirst({
|
||||||
|
where: and(eq(clients.id, request.clientId), eq(clients.portId, request.portId)),
|
||||||
|
});
|
||||||
|
if (!client) {
|
||||||
|
throw new ValidationError('clientId not found in this port');
|
||||||
|
}
|
||||||
|
|
||||||
return { jobId: job.id! };
|
const aiQueue = getQueue('ai');
|
||||||
|
const jobId = randomUUID();
|
||||||
|
|
||||||
|
await aiQueue.add(
|
||||||
|
'generate-email-draft',
|
||||||
|
{
|
||||||
|
// No PII — only IDs and context parameters
|
||||||
|
interestId: request.interestId,
|
||||||
|
clientId: request.clientId,
|
||||||
|
portId: request.portId,
|
||||||
|
context: request.context,
|
||||||
|
additionalInstructions: request.additionalInstructions,
|
||||||
|
requestedBy: userId,
|
||||||
|
},
|
||||||
|
{ jobId },
|
||||||
|
);
|
||||||
|
|
||||||
|
return { jobId };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Poll for result ──────────────────────────────────────────────────────────
|
// ─── Poll for result ──────────────────────────────────────────────────────────
|
||||||
@@ -47,13 +78,26 @@ export async function requestEmailDraft(
|
|||||||
/**
|
/**
|
||||||
* Get the result of an email draft generation job.
|
* Get the result of an email draft generation job.
|
||||||
* Returns null if still processing.
|
* Returns null if still processing.
|
||||||
|
*
|
||||||
|
* Verifies the caller (userId + portId) matches the job's original
|
||||||
|
* requester before returning the drafted subject/body. A foreign caller
|
||||||
|
* who happens to know the jobId (or stumbles on it) sees null, not the
|
||||||
|
* drafted content.
|
||||||
*/
|
*/
|
||||||
export async function getEmailDraftResult(jobId: string): Promise<DraftResult | null> {
|
export async function getEmailDraftResult(
|
||||||
|
jobId: string,
|
||||||
|
caller: { userId: string; portId: string },
|
||||||
|
): Promise<DraftResult | null> {
|
||||||
const aiQueue = getQueue('ai');
|
const aiQueue = getQueue('ai');
|
||||||
const job = await aiQueue.getJob(jobId);
|
const job = await aiQueue.getJob(jobId);
|
||||||
|
|
||||||
if (!job) return null;
|
if (!job) return null;
|
||||||
|
|
||||||
|
const data = job.data as { requestedBy?: string; portId?: string } | undefined | null;
|
||||||
|
if (!data || data.requestedBy !== caller.userId || data.portId !== caller.portId) {
|
||||||
|
throw new ForbiddenError('Email draft not accessible');
|
||||||
|
}
|
||||||
|
|
||||||
const state = await job.getState();
|
const state = await job.getState();
|
||||||
|
|
||||||
if (state !== 'completed') return null;
|
if (state !== 'completed') return null;
|
||||||
|
|||||||
@@ -12,18 +12,22 @@ import { logger } from '@/lib/logger';
|
|||||||
export interface InterestScore {
|
export interface InterestScore {
|
||||||
totalScore: number; // 0-100 (normalised)
|
totalScore: number; // 0-100 (normalised)
|
||||||
breakdown: {
|
breakdown: {
|
||||||
pipelineAge: number; // 0-100
|
pipelineAge: number; // 0-100
|
||||||
stageSpeed: number; // 0-100
|
stageSpeed: number; // 0-100
|
||||||
documentCompleteness: number; // 0-100
|
documentCompleteness: number; // 0-100
|
||||||
engagement: number; // 0-100
|
engagement: number; // 0-100
|
||||||
berthLinked: number; // 0 or 25
|
berthLinked: number; // 0 or 25
|
||||||
};
|
};
|
||||||
calculatedAt: Date;
|
calculatedAt: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Redis cache ──────────────────────────────────────────────────────────────
|
// ─── Redis cache ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const SCORE_KEY = (interestId: string) => `interest-score:${interestId}`;
|
// Cache key includes portId so a foreign-port caller hitting the same
|
||||||
|
// interestId never sees a port-A cached value. (Even if interestId is
|
||||||
|
// already globally unique, baking portId into the key means a stale or
|
||||||
|
// hostile caller cannot reuse cached entries across tenants.)
|
||||||
|
const SCORE_KEY = (portId: string, interestId: string) => `interest-score:${portId}:${interestId}`;
|
||||||
const SCORE_TTL = 3600; // 1 hour
|
const SCORE_TTL = 3600; // 1 hour
|
||||||
|
|
||||||
// ─── Scoring helpers ──────────────────────────────────────────────────────────
|
// ─── Scoring helpers ──────────────────────────────────────────────────────────
|
||||||
@@ -56,10 +60,7 @@ function scoreStageSpeed(createdAt: Date, pipelineStage: string): number {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
const daysSinceCreation = Math.max(
|
const daysSinceCreation = Math.max(1, (Date.now() - createdAt.getTime()) / (1000 * 60 * 60 * 24));
|
||||||
1,
|
|
||||||
(Date.now() - createdAt.getTime()) / (1000 * 60 * 60 * 24),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Average days per stage transition
|
// Average days per stage transition
|
||||||
const avgDaysPerStage = daysSinceCreation / stageIndex;
|
const avgDaysPerStage = daysSinceCreation / stageIndex;
|
||||||
@@ -108,18 +109,10 @@ export async function calculateInterestScore(
|
|||||||
interestId: string,
|
interestId: string,
|
||||||
portId: string,
|
portId: string,
|
||||||
): Promise<InterestScore> {
|
): Promise<InterestScore> {
|
||||||
// Try cache first
|
// Verify the interest belongs to the caller's port BEFORE returning a
|
||||||
try {
|
// cached value. The cache key now includes portId, but defense-in-depth:
|
||||||
const cached = await redis.get(SCORE_KEY(interestId));
|
// a port-B caller passing a port-A interestId still gets NotFound
|
||||||
if (cached) {
|
// instead of a leaked score.
|
||||||
const parsed = JSON.parse(cached) as InterestScore & { calculatedAt: string };
|
|
||||||
return { ...parsed, calculatedAt: new Date(parsed.calculatedAt) };
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
logger.warn({ err, interestId }, 'Redis cache read failed for interest score');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch interest
|
|
||||||
const interest = await db.query.interests.findFirst({
|
const interest = await db.query.interests.findFirst({
|
||||||
where: and(eq(interests.id, interestId), eq(interests.portId, portId)),
|
where: and(eq(interests.id, interestId), eq(interests.portId, portId)),
|
||||||
});
|
});
|
||||||
@@ -128,6 +121,17 @@ export async function calculateInterestScore(
|
|||||||
throw new Error(`Interest not found: ${interestId}`);
|
throw new Error(`Interest not found: ${interestId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Try cache (port-scoped key)
|
||||||
|
try {
|
||||||
|
const cached = await redis.get(SCORE_KEY(portId, interestId));
|
||||||
|
if (cached) {
|
||||||
|
const parsed = JSON.parse(cached) as InterestScore & { calculatedAt: string };
|
||||||
|
return { ...parsed, calculatedAt: new Date(parsed.calculatedAt) };
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn({ err, interestId }, 'Redis cache read failed for interest score');
|
||||||
|
}
|
||||||
|
|
||||||
// 1. Pipeline age
|
// 1. Pipeline age
|
||||||
const pipelineAge = scorePipelineAge(interest.createdAt);
|
const pipelineAge = scorePipelineAge(interest.createdAt);
|
||||||
|
|
||||||
@@ -145,10 +149,7 @@ export async function calculateInterestScore(
|
|||||||
.select({ value: count() })
|
.select({ value: count() })
|
||||||
.from(interestNotes)
|
.from(interestNotes)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(eq(interestNotes.interestId, interestId), gte(interestNotes.createdAt, thirtyDaysAgo)),
|
||||||
eq(interestNotes.interestId, interestId),
|
|
||||||
gte(interestNotes.createdAt, thirtyDaysAgo),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
db
|
db
|
||||||
.select({ value: count() })
|
.select({ value: count() })
|
||||||
@@ -203,8 +204,10 @@ export async function calculateInterestScore(
|
|||||||
|
|
||||||
// Write to cache (fire-and-forget)
|
// Write to cache (fire-and-forget)
|
||||||
redis
|
redis
|
||||||
.setex(SCORE_KEY(interestId), SCORE_TTL, JSON.stringify(result))
|
.setex(SCORE_KEY(portId, interestId), SCORE_TTL, JSON.stringify(result))
|
||||||
.catch((err) => logger.warn({ err, interestId }, 'Redis cache write failed for interest score'));
|
.catch((err) =>
|
||||||
|
logger.warn({ err, interestId }, 'Redis cache write failed for interest score'),
|
||||||
|
);
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
@@ -227,8 +230,9 @@ export async function calculateBulkScores(
|
|||||||
);
|
);
|
||||||
|
|
||||||
return results
|
return results
|
||||||
.filter((r): r is PromiseFulfilledResult<{ interestId: string; score: InterestScore }> =>
|
.filter(
|
||||||
r.status === 'fulfilled',
|
(r): r is PromiseFulfilledResult<{ interestId: string; score: InterestScore }> =>
|
||||||
|
r.status === 'fulfilled',
|
||||||
)
|
)
|
||||||
.map((r) => r.value);
|
.map((r) => r.value);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -135,6 +135,27 @@ async function resolveBillingEntity(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify every supplied expense ID belongs to the caller's port. Without
|
||||||
|
* this gate, a caller could link foreign-port expenses into their own
|
||||||
|
* draft invoice and read those expenses back via getInvoiceById's
|
||||||
|
* `linkedExpenses` join — a cross-tenant data leak.
|
||||||
|
*/
|
||||||
|
async function assertExpensesInPort(
|
||||||
|
tx: typeof db,
|
||||||
|
portId: string,
|
||||||
|
expenseIds: string[],
|
||||||
|
): Promise<void> {
|
||||||
|
if (expenseIds.length === 0) return;
|
||||||
|
const rows = await tx
|
||||||
|
.select({ id: expenses.id })
|
||||||
|
.from(expenses)
|
||||||
|
.where(and(inArray(expenses.id, expenseIds), eq(expenses.portId, portId)));
|
||||||
|
if (rows.length !== expenseIds.length) {
|
||||||
|
throw new ValidationError('One or more expenses not found in this port');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ─── List ─────────────────────────────────────────────────────────────────
|
// ─── List ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function listInvoices(portId: string, query: ListInvoicesInput) {
|
export async function listInvoices(portId: string, query: ListInvoicesInput) {
|
||||||
@@ -195,11 +216,14 @@ export async function getInvoiceById(id: string, portId: string) {
|
|||||||
.where(eq(invoiceLineItems.invoiceId, id))
|
.where(eq(invoiceLineItems.invoiceId, id))
|
||||||
.orderBy(invoiceLineItems.sortOrder);
|
.orderBy(invoiceLineItems.sortOrder);
|
||||||
|
|
||||||
|
// Defense-in-depth: even if a join row somehow points at a foreign-tenant
|
||||||
|
// expense, the WHERE clause filters by expenses.portId so cross-tenant data
|
||||||
|
// can't leak through this read.
|
||||||
const linkedExpenses = await db
|
const linkedExpenses = await db
|
||||||
.select({ expense: expenses })
|
.select({ expense: expenses })
|
||||||
.from(invoiceExpenses)
|
.from(invoiceExpenses)
|
||||||
.innerJoin(expenses, eq(expenses.id, invoiceExpenses.expenseId))
|
.innerJoin(expenses, eq(expenses.id, invoiceExpenses.expenseId))
|
||||||
.where(eq(invoiceExpenses.invoiceId, id));
|
.where(and(eq(invoiceExpenses.invoiceId, id), eq(expenses.portId, portId)));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...invoice,
|
...invoice,
|
||||||
@@ -250,8 +274,11 @@ export async function createInvoice(portId: string, data: CreateInvoiceInput, me
|
|||||||
const feePct = 0;
|
const feePct = 0;
|
||||||
const total = subtotal - discountAmount + feeAmount;
|
const total = subtotal - discountAmount + feeAmount;
|
||||||
|
|
||||||
// BR-045: Verify expenses aren't already linked to a non-draft invoice
|
// BR-045: Verify expenses aren't already linked to a non-draft invoice.
|
||||||
|
// Tenancy guard precedes BR-045 so a foreign-port expense fails with
|
||||||
|
// ValidationError before any further checks (or any join-side leak).
|
||||||
const expenseIds = data.expenseIds ?? [];
|
const expenseIds = data.expenseIds ?? [];
|
||||||
|
await assertExpensesInPort(tx, portId, expenseIds);
|
||||||
if (expenseIds.length > 0) {
|
if (expenseIds.length > 0) {
|
||||||
const alreadyLinked = await tx
|
const alreadyLinked = await tx
|
||||||
.select({ expenseId: invoiceExpenses.expenseId })
|
.select({ expenseId: invoiceExpenses.expenseId })
|
||||||
@@ -418,6 +445,9 @@ export async function updateInvoice(
|
|||||||
|
|
||||||
// Replace expense links if provided
|
// Replace expense links if provided
|
||||||
if (data.expenseIds !== undefined) {
|
if (data.expenseIds !== undefined) {
|
||||||
|
// Tenancy gate first — reject foreign-port expense IDs before
|
||||||
|
// running BR-045 or doing any writes.
|
||||||
|
await assertExpensesInPort(tx, portId, data.expenseIds);
|
||||||
// BR-045
|
// BR-045
|
||||||
if (data.expenseIds.length > 0) {
|
if (data.expenseIds.length > 0) {
|
||||||
const alreadyLinked = await tx
|
const alreadyLinked = await tx
|
||||||
|
|||||||
146
tests/integration/email-draft-job-isolation.test.ts
Normal file
146
tests/integration/email-draft-job-isolation.test.ts
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
/**
|
||||||
|
* Security regression: AI email-draft jobs are bound to the requesting
|
||||||
|
* user + port. A foreign caller who knows the jobId must NOT receive the
|
||||||
|
* drafted subject/body.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
import { ForbiddenError, ValidationError } from '@/lib/errors';
|
||||||
|
|
||||||
|
// Mock the queue. Each test sets up a fresh per-test job map.
|
||||||
|
const fakeJobs = new Map<string, { data: unknown; returnvalue: unknown; state: string }>();
|
||||||
|
|
||||||
|
vi.mock('@/lib/queue', () => ({
|
||||||
|
getQueue: () => ({
|
||||||
|
add: vi.fn(async (_name: string, data: unknown, opts: { jobId: string }) => {
|
||||||
|
fakeJobs.set(opts.jobId, { data, returnvalue: null, state: 'completed' });
|
||||||
|
return { id: opts.jobId };
|
||||||
|
}),
|
||||||
|
getJob: vi.fn(async (id: string) => {
|
||||||
|
const j = fakeJobs.get(id);
|
||||||
|
if (!j) return null;
|
||||||
|
return {
|
||||||
|
data: j.data,
|
||||||
|
returnvalue: j.returnvalue,
|
||||||
|
getState: async () => j.state,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock interest/client lookups so requestEmailDraft doesn't hit the DB.
|
||||||
|
vi.mock('@/lib/db', () => ({
|
||||||
|
db: {
|
||||||
|
query: {
|
||||||
|
interests: {
|
||||||
|
findFirst: vi.fn(async ({ where: _w }) => ({ id: 'iA', portId: 'pA' })),
|
||||||
|
},
|
||||||
|
clients: {
|
||||||
|
findFirst: vi.fn(async ({ where: _w }) => ({ id: 'cA', portId: 'pA' })),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fakeJobs.clear();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('email-draft job binding', () => {
|
||||||
|
it('rejects readers with a different userId', async () => {
|
||||||
|
const { requestEmailDraft, getEmailDraftResult } =
|
||||||
|
await import('@/lib/services/email-draft.service');
|
||||||
|
|
||||||
|
const { jobId } = await requestEmailDraft('user-A', {
|
||||||
|
interestId: 'iA',
|
||||||
|
clientId: 'cA',
|
||||||
|
portId: 'pA',
|
||||||
|
context: 'follow_up',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wire in a completed return value so a successful path would otherwise
|
||||||
|
// produce a result.
|
||||||
|
fakeJobs.get(jobId)!.returnvalue = {
|
||||||
|
subject: 'leak',
|
||||||
|
body: 'leak',
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(getEmailDraftResult(jobId, { userId: 'user-B', portId: 'pA' })).rejects.toThrow(
|
||||||
|
ForbiddenError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects readers with a different portId', async () => {
|
||||||
|
const { requestEmailDraft, getEmailDraftResult } =
|
||||||
|
await import('@/lib/services/email-draft.service');
|
||||||
|
|
||||||
|
const { jobId } = await requestEmailDraft('user-A', {
|
||||||
|
interestId: 'iA',
|
||||||
|
clientId: 'cA',
|
||||||
|
portId: 'pA',
|
||||||
|
context: 'follow_up',
|
||||||
|
});
|
||||||
|
fakeJobs.get(jobId)!.returnvalue = {
|
||||||
|
subject: 'leak',
|
||||||
|
body: 'leak',
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(getEmailDraftResult(jobId, { userId: 'user-A', portId: 'pB' })).rejects.toThrow(
|
||||||
|
ForbiddenError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns drafted content to the original requester', async () => {
|
||||||
|
const { requestEmailDraft, getEmailDraftResult } =
|
||||||
|
await import('@/lib/services/email-draft.service');
|
||||||
|
|
||||||
|
const { jobId } = await requestEmailDraft('user-A', {
|
||||||
|
interestId: 'iA',
|
||||||
|
clientId: 'cA',
|
||||||
|
portId: 'pA',
|
||||||
|
context: 'follow_up',
|
||||||
|
});
|
||||||
|
fakeJobs.get(jobId)!.returnvalue = {
|
||||||
|
subject: 'subject-A',
|
||||||
|
body: 'body-A',
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await getEmailDraftResult(jobId, { userId: 'user-A', portId: 'pA' });
|
||||||
|
expect(result?.subject).toBe('subject-A');
|
||||||
|
expect(result?.body).toBe('body-A');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('jobId is a UUID, not a sequential integer', async () => {
|
||||||
|
const { requestEmailDraft } = await import('@/lib/services/email-draft.service');
|
||||||
|
|
||||||
|
const { jobId } = await requestEmailDraft('user-A', {
|
||||||
|
interestId: 'iA',
|
||||||
|
clientId: 'cA',
|
||||||
|
portId: 'pA',
|
||||||
|
context: 'follow_up',
|
||||||
|
});
|
||||||
|
// Crude UUID-shape check: 8-4-4-4-12 hex.
|
||||||
|
expect(jobId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects requests whose interest is not in the supplied port', async () => {
|
||||||
|
const { db } = await import('@/lib/db');
|
||||||
|
(db.query.interests.findFirst as ReturnType<typeof vi.fn>).mockResolvedValueOnce(null);
|
||||||
|
|
||||||
|
const { requestEmailDraft } = await import('@/lib/services/email-draft.service');
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
requestEmailDraft('user-A', {
|
||||||
|
interestId: 'foreign-interest',
|
||||||
|
clientId: 'cA',
|
||||||
|
portId: 'pA',
|
||||||
|
context: 'follow_up',
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(ValidationError);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -155,9 +155,10 @@ describe('calculateInterestScore', () => {
|
|||||||
// High engagement: 5 notes, 3 emails, 2 reminders
|
// High engagement: 5 notes, 3 emails, 2 reminders
|
||||||
const selectChain = {
|
const selectChain = {
|
||||||
from: vi.fn().mockReturnThis(),
|
from: vi.fn().mockReturnThis(),
|
||||||
where: vi.fn()
|
where: vi
|
||||||
.mockResolvedValueOnce([{ value: 5 }]) // notes
|
.fn()
|
||||||
.mockResolvedValueOnce([{ value: 2 }]) // reminders
|
.mockResolvedValueOnce([{ value: 5 }]) // notes
|
||||||
|
.mockResolvedValueOnce([{ value: 2 }]) // reminders
|
||||||
.mockResolvedValueOnce([{ value: 3 }]), // emails
|
.mockResolvedValueOnce([{ value: 3 }]), // emails
|
||||||
};
|
};
|
||||||
(db.select as ReturnType<typeof vi.fn>).mockReturnValue(selectChain);
|
(db.select as ReturnType<typeof vi.fn>).mockReturnValue(selectChain);
|
||||||
@@ -254,12 +255,20 @@ describe('calculateInterestScore', () => {
|
|||||||
const selectChain = makeSelectChain(0);
|
const selectChain = makeSelectChain(0);
|
||||||
(db.select as ReturnType<typeof vi.fn>).mockReturnValue(selectChain);
|
(db.select as ReturnType<typeof vi.fn>).mockReturnValue(selectChain);
|
||||||
|
|
||||||
(db.query.interests.findFirst as ReturnType<typeof vi.fn>).mockResolvedValue({ ...base, id: 'i6', berthId: 'b1' });
|
(db.query.interests.findFirst as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
|
...base,
|
||||||
|
id: 'i6',
|
||||||
|
berthId: 'b1',
|
||||||
|
});
|
||||||
const withBerth = await calculateInterestScore('i6', 'p1');
|
const withBerth = await calculateInterestScore('i6', 'p1');
|
||||||
expect(withBerth.breakdown.berthLinked).toBe(25);
|
expect(withBerth.breakdown.berthLinked).toBe(25);
|
||||||
|
|
||||||
(redis.get as ReturnType<typeof vi.fn>).mockResolvedValue(null);
|
(redis.get as ReturnType<typeof vi.fn>).mockResolvedValue(null);
|
||||||
(db.query.interests.findFirst as ReturnType<typeof vi.fn>).mockResolvedValue({ ...base, id: 'i7', berthId: null });
|
(db.query.interests.findFirst as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
|
...base,
|
||||||
|
id: 'i7',
|
||||||
|
berthId: null,
|
||||||
|
});
|
||||||
const withoutBerth = await calculateInterestScore('i7', 'p1');
|
const withoutBerth = await calculateInterestScore('i7', 'p1');
|
||||||
expect(withoutBerth.breakdown.berthLinked).toBe(0);
|
expect(withoutBerth.breakdown.berthLinked).toBe(0);
|
||||||
});
|
});
|
||||||
@@ -269,7 +278,11 @@ describe('calculateInterestScore', () => {
|
|||||||
await expect(calculateInterestScore('missing', 'p1')).rejects.toThrow('Interest not found');
|
await expect(calculateInterestScore('missing', 'p1')).rejects.toThrow('Interest not found');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns cached result when redis has a hit', async () => {
|
it('returns cached result when redis has a hit (after port-scope DB check)', async () => {
|
||||||
|
// Security fix: the DB lookup runs FIRST to confirm the interest is
|
||||||
|
// in the caller's port. Only then is the (port-scoped) cache key read.
|
||||||
|
// A test that asserts the DB is bypassed would be asserting the
|
||||||
|
// pre-fix bug; this test asserts the new ordering.
|
||||||
const cachedScore = {
|
const cachedScore = {
|
||||||
totalScore: 42,
|
totalScore: 42,
|
||||||
breakdown: {
|
breakdown: {
|
||||||
@@ -281,11 +294,26 @@ describe('calculateInterestScore', () => {
|
|||||||
},
|
},
|
||||||
calculatedAt: new Date().toISOString(),
|
calculatedAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
(db.query.interests.findFirst as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
|
id: 'cached-id',
|
||||||
|
portId: 'p1',
|
||||||
|
clientId: 'c1',
|
||||||
|
createdAt: daysAgo(10),
|
||||||
|
pipelineStage: 'open',
|
||||||
|
eoiStatus: null,
|
||||||
|
contractStatus: null,
|
||||||
|
depositStatus: null,
|
||||||
|
dateEoiSigned: null,
|
||||||
|
dateContractSigned: null,
|
||||||
|
dateDepositReceived: null,
|
||||||
|
berthId: null,
|
||||||
|
});
|
||||||
(redis.get as ReturnType<typeof vi.fn>).mockResolvedValue(JSON.stringify(cachedScore));
|
(redis.get as ReturnType<typeof vi.fn>).mockResolvedValue(JSON.stringify(cachedScore));
|
||||||
|
|
||||||
const result = await calculateInterestScore('cached-id', 'p1');
|
const result = await calculateInterestScore('cached-id', 'p1');
|
||||||
expect(result.totalScore).toBe(42);
|
expect(result.totalScore).toBe(42);
|
||||||
// Should NOT hit the database
|
// Port-scope check: the DB IS hit, but no other queries (notes/threads)
|
||||||
expect(db.query.interests.findFirst).not.toHaveBeenCalled();
|
// are needed since the cache served the score body.
|
||||||
|
expect(db.query.interests.findFirst).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user