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 { getPort, updatePort } from '@/lib/services/ports.service';
|
||||
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(
|
||||
withPermission('admin', 'manage_settings', async (_req, _ctx, params) => {
|
||||
withPermission('admin', 'manage_settings', async (_req, ctx, params) => {
|
||||
try {
|
||||
assertPortInScope(params.id!, ctx);
|
||||
const data = await getPort(params.id!);
|
||||
return NextResponse.json({ data });
|
||||
} catch (error) {
|
||||
@@ -20,6 +34,7 @@ export const GET = withAuth(
|
||||
export const PATCH = withAuth(
|
||||
withPermission('admin', 'manage_settings', async (req, ctx, params) => {
|
||||
try {
|
||||
assertPortInScope(params.id!, ctx);
|
||||
const body = await parseBody(req, updatePortSchema);
|
||||
const data = await updatePort(params.id!, body, {
|
||||
userId: ctx.userId,
|
||||
|
||||
@@ -4,11 +4,18 @@ import { withAuth, withPermission } from '@/lib/api/helpers';
|
||||
import { parseBody } from '@/lib/api/route-helpers';
|
||||
import { listPorts, createPort } from '@/lib/services/ports.service';
|
||||
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(
|
||||
withPermission('admin', 'manage_settings', async () => {
|
||||
withPermission('admin', 'manage_settings', async (_req, ctx) => {
|
||||
try {
|
||||
if (!ctx.isSuperAdmin) {
|
||||
throw new ForbiddenError('Listing all ports requires super-admin');
|
||||
}
|
||||
const data = await listPorts();
|
||||
return NextResponse.json({ data });
|
||||
} catch (error) {
|
||||
@@ -20,6 +27,9 @@ export const GET = withAuth(
|
||||
export const POST = withAuth(
|
||||
withPermission('admin', 'manage_settings', async (req, ctx) => {
|
||||
try {
|
||||
if (!ctx.isSuperAdmin) {
|
||||
throw new ForbiddenError('Creating ports requires super-admin');
|
||||
}
|
||||
const body = await parseBody(req, createPortSchema);
|
||||
const data = await createPort(body, {
|
||||
userId: ctx.userId,
|
||||
|
||||
@@ -4,14 +4,17 @@ import { withAuth } from '@/lib/api/helpers';
|
||||
import { getEmailDraftResult } from '@/lib/services/email-draft.service';
|
||||
import { errorResponse } from '@/lib/errors';
|
||||
|
||||
export const GET = withAuth(async (_req, _ctx, params) => {
|
||||
export const GET = withAuth(async (_req, ctx, params) => {
|
||||
try {
|
||||
const { jobId } = params;
|
||||
if (!jobId) {
|
||||
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) {
|
||||
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 { 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([
|
||||
db.query.interests.findFirst({
|
||||
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) {
|
||||
|
||||
@@ -10,6 +10,9 @@ import {
|
||||
} from '@/lib/db/schema/documents';
|
||||
import { interests } from '@/lib/db/schema/interests';
|
||||
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 { buildListQuery } from '@/lib/db/query-builder';
|
||||
import { createAuditLog, type AuditMeta } from '@/lib/audit';
|
||||
@@ -258,9 +261,90 @@ export async function getDocumentById(id: string, portId: string) {
|
||||
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 ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function createDocument(portId: string, data: CreateDocumentInput, meta: AuditMeta) {
|
||||
await assertSubjectFksInPort(portId, {
|
||||
clientId: data.clientId,
|
||||
interestId: data.interestId,
|
||||
});
|
||||
|
||||
const [doc] = await db
|
||||
.insert(documents)
|
||||
.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.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
|
||||
? 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;
|
||||
|
||||
const client = doc.clientId
|
||||
? 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 },
|
||||
})
|
||||
: null;
|
||||
@@ -1198,6 +1288,14 @@ export async function createFromWizard(
|
||||
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
|
||||
.insert(documents)
|
||||
.values({
|
||||
@@ -1275,6 +1373,14 @@ export async function createFromUpload(
|
||||
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
|
||||
.insert(documents)
|
||||
.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';
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
@@ -20,16 +27,38 @@ export interface DraftResult {
|
||||
|
||||
/**
|
||||
* 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(
|
||||
userId: string,
|
||||
request: DraftRequest,
|
||||
): 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)),
|
||||
});
|
||||
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');
|
||||
}
|
||||
|
||||
const job = await aiQueue.add('generate-email-draft', {
|
||||
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,
|
||||
@@ -37,9 +66,11 @@ export async function requestEmailDraft(
|
||||
context: request.context,
|
||||
additionalInstructions: request.additionalInstructions,
|
||||
requestedBy: userId,
|
||||
});
|
||||
},
|
||||
{ jobId },
|
||||
);
|
||||
|
||||
return { jobId: job.id! };
|
||||
return { jobId };
|
||||
}
|
||||
|
||||
// ─── Poll for result ──────────────────────────────────────────────────────────
|
||||
@@ -47,13 +78,26 @@ export async function requestEmailDraft(
|
||||
/**
|
||||
* Get the result of an email draft generation job.
|
||||
* 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 job = await aiQueue.getJob(jobId);
|
||||
|
||||
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();
|
||||
|
||||
if (state !== 'completed') return null;
|
||||
|
||||
@@ -23,7 +23,11 @@ export interface InterestScore {
|
||||
|
||||
// ─── 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
|
||||
|
||||
// ─── Scoring helpers ──────────────────────────────────────────────────────────
|
||||
@@ -56,10 +60,7 @@ function scoreStageSpeed(createdAt: Date, pipelineStage: string): number {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const daysSinceCreation = Math.max(
|
||||
1,
|
||||
(Date.now() - createdAt.getTime()) / (1000 * 60 * 60 * 24),
|
||||
);
|
||||
const daysSinceCreation = Math.max(1, (Date.now() - createdAt.getTime()) / (1000 * 60 * 60 * 24));
|
||||
|
||||
// Average days per stage transition
|
||||
const avgDaysPerStage = daysSinceCreation / stageIndex;
|
||||
@@ -108,18 +109,10 @@ export async function calculateInterestScore(
|
||||
interestId: string,
|
||||
portId: string,
|
||||
): Promise<InterestScore> {
|
||||
// Try cache first
|
||||
try {
|
||||
const cached = await redis.get(SCORE_KEY(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');
|
||||
}
|
||||
|
||||
// Fetch interest
|
||||
// Verify the interest belongs to the caller's port BEFORE returning a
|
||||
// cached value. The cache key now includes portId, but defense-in-depth:
|
||||
// a port-B caller passing a port-A interestId still gets NotFound
|
||||
// instead of a leaked score.
|
||||
const interest = await db.query.interests.findFirst({
|
||||
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}`);
|
||||
}
|
||||
|
||||
// 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
|
||||
const pipelineAge = scorePipelineAge(interest.createdAt);
|
||||
|
||||
@@ -145,10 +149,7 @@ export async function calculateInterestScore(
|
||||
.select({ value: count() })
|
||||
.from(interestNotes)
|
||||
.where(
|
||||
and(
|
||||
eq(interestNotes.interestId, interestId),
|
||||
gte(interestNotes.createdAt, thirtyDaysAgo),
|
||||
),
|
||||
and(eq(interestNotes.interestId, interestId), gte(interestNotes.createdAt, thirtyDaysAgo)),
|
||||
),
|
||||
db
|
||||
.select({ value: count() })
|
||||
@@ -203,8 +204,10 @@ export async function calculateInterestScore(
|
||||
|
||||
// Write to cache (fire-and-forget)
|
||||
redis
|
||||
.setex(SCORE_KEY(interestId), SCORE_TTL, JSON.stringify(result))
|
||||
.catch((err) => logger.warn({ err, interestId }, 'Redis cache write failed for interest score'));
|
||||
.setex(SCORE_KEY(portId, interestId), SCORE_TTL, JSON.stringify(result))
|
||||
.catch((err) =>
|
||||
logger.warn({ err, interestId }, 'Redis cache write failed for interest score'),
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -227,7 +230,8 @@ export async function calculateBulkScores(
|
||||
);
|
||||
|
||||
return results
|
||||
.filter((r): r is PromiseFulfilledResult<{ interestId: string; score: InterestScore }> =>
|
||||
.filter(
|
||||
(r): r is PromiseFulfilledResult<{ interestId: string; score: InterestScore }> =>
|
||||
r.status === 'fulfilled',
|
||||
)
|
||||
.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 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
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))
|
||||
.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
|
||||
.select({ expense: expenses })
|
||||
.from(invoiceExpenses)
|
||||
.innerJoin(expenses, eq(expenses.id, invoiceExpenses.expenseId))
|
||||
.where(eq(invoiceExpenses.invoiceId, id));
|
||||
.where(and(eq(invoiceExpenses.invoiceId, id), eq(expenses.portId, portId)));
|
||||
|
||||
return {
|
||||
...invoice,
|
||||
@@ -250,8 +274,11 @@ export async function createInvoice(portId: string, data: CreateInvoiceInput, me
|
||||
const feePct = 0;
|
||||
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 ?? [];
|
||||
await assertExpensesInPort(tx, portId, expenseIds);
|
||||
if (expenseIds.length > 0) {
|
||||
const alreadyLinked = await tx
|
||||
.select({ expenseId: invoiceExpenses.expenseId })
|
||||
@@ -418,6 +445,9 @@ export async function updateInvoice(
|
||||
|
||||
// Replace expense links if provided
|
||||
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
|
||||
if (data.expenseIds.length > 0) {
|
||||
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,7 +155,8 @@ describe('calculateInterestScore', () => {
|
||||
// High engagement: 5 notes, 3 emails, 2 reminders
|
||||
const selectChain = {
|
||||
from: vi.fn().mockReturnThis(),
|
||||
where: vi.fn()
|
||||
where: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce([{ value: 5 }]) // notes
|
||||
.mockResolvedValueOnce([{ value: 2 }]) // reminders
|
||||
.mockResolvedValueOnce([{ value: 3 }]), // emails
|
||||
@@ -254,12 +255,20 @@ describe('calculateInterestScore', () => {
|
||||
const selectChain = makeSelectChain(0);
|
||||
(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');
|
||||
expect(withBerth.breakdown.berthLinked).toBe(25);
|
||||
|
||||
(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');
|
||||
expect(withoutBerth.breakdown.berthLinked).toBe(0);
|
||||
});
|
||||
@@ -269,7 +278,11 @@ describe('calculateInterestScore', () => {
|
||||
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 = {
|
||||
totalScore: 42,
|
||||
breakdown: {
|
||||
@@ -281,11 +294,26 @@ describe('calculateInterestScore', () => {
|
||||
},
|
||||
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));
|
||||
|
||||
const result = await calculateInterestScore('cached-id', 'p1');
|
||||
expect(result.totalScore).toBe(42);
|
||||
// Should NOT hit the database
|
||||
expect(db.query.interests.findFirst).not.toHaveBeenCalled();
|
||||
// Port-scope check: the DB IS hit, but no other queries (notes/threads)
|
||||
// are needed since the cache served the score body.
|
||||
expect(db.query.interests.findFirst).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user