diff --git a/src/lib/services/report-render.service.ts b/src/lib/services/report-render.service.ts index 1b2f8c81..026aae79 100644 --- a/src/lib/services/report-render.service.ts +++ b/src/lib/services/report-render.service.ts @@ -30,7 +30,7 @@ import { getStorageBackend } from '@/lib/storage'; import { resolvePortLogo } from '@/lib/pdf/brand-kit/logo'; import { renderPdf } from '@/lib/pdf/render'; import { sendEmail } from '@/lib/email'; -import { updateReportRunStatus } from '@/lib/services/report-runs.service'; +import { updateReportRunStatus, userCanAccessPort } from '@/lib/services/report-runs.service'; import { CodedError, NotFoundError } from '@/lib/errors'; import { fetchActivityData, @@ -221,15 +221,39 @@ export async function renderReportRun(reportRunId: string): Promise { } // P7: optional cover-brand swap. When config.coverBrandPortId points - // at a port the rep has access to, the cover-page logo + port name - // come from THAT port's brand kit instead of the report's source - // port. Useful for cross-port leadership decks; falls back to the - // source port when the override port is missing / inaccessible. + // at a port the triggering user has access to, the cover-page logo + + // port name come from THAT port's brand kit instead of the report's + // source port. Useful for cross-port leadership decks; falls back to + // the source port when the override port is missing / inaccessible. + // + // H11 defense-in-depth: enqueue-time validation (createReportRun) + // already rejects forbidden overrides, but a stale/forged config row + // could still reach the worker. Only honour the override when it equals + // the run's own port OR the triggering user is a member of that port + // (super-admins included via userCanAccessPort). Otherwise silently + // fall back to the source port's branding — a forged config can never + // leak a foreign tenant's logo/name onto the rendered PDF. const params = (run.config as Record) ?? {}; - const overrideBrandPortId = + const requestedBrandPortId = typeof params.coverBrandPortId === 'string' && params.coverBrandPortId.length > 0 ? params.coverBrandPortId : null; + const overrideAllowed = + requestedBrandPortId !== null && + (requestedBrandPortId === run.portId || + (await userCanAccessPort(run.triggeredByUserId, requestedBrandPortId))); + if (requestedBrandPortId !== null && !overrideAllowed) { + logger.warn( + { + reportRunId: run.id, + runPortId: run.portId, + requestedBrandPortId, + triggeredByUserId: run.triggeredByUserId, + }, + 'Ignoring cover-brand override: triggering user is not a member of the requested port (H11)', + ); + } + const overrideBrandPortId = overrideAllowed ? requestedBrandPortId : null; const brandPortId = overrideBrandPortId ?? run.portId; const brandPort = overrideBrandPortId === null diff --git a/src/lib/services/report-runs.service.ts b/src/lib/services/report-runs.service.ts index 831c8fb3..867190b8 100644 --- a/src/lib/services/report-runs.service.ts +++ b/src/lib/services/report-runs.service.ts @@ -22,8 +22,9 @@ import { type ReportRun, type NewReportRun, } from '@/lib/db/schema/reports'; +import { userPortRoles, userProfiles } from '@/lib/db/schema/users'; import { createAuditLog, type AuditMeta } from '@/lib/audit'; -import { NotFoundError, ValidationError } from '@/lib/errors'; +import { ForbiddenError, NotFoundError, ValidationError } from '@/lib/errors'; import type { CreateReportRunInput, ListReportRunsInput, @@ -36,6 +37,31 @@ export interface ListReportRunsResult { hasMore: boolean; } +/** + * True when the user may act on behalf of `portId`: either a platform + * super-admin, or the holder of a `userPortRoles` membership row for that + * port. Mirrors the Socket.IO `userCanAccessPort` guard — the canonical + * "is this user a member of this tenant" check. Used to gate the + * cover-brand override so a rep can't stamp a foreign port's logo + + * name onto a report (audit finding H11). Empty/falsy userId → false. + */ +export async function userCanAccessPort( + userId: string | null | undefined, + portId: string, +): Promise { + if (!userId) return false; + const profile = await db.query.userProfiles.findFirst({ + where: eq(userProfiles.userId, userId), + columns: { isSuperAdmin: true }, + }); + if (profile?.isSuperAdmin) return true; + const role = await db.query.userPortRoles.findFirst({ + where: and(eq(userPortRoles.userId, userId), eq(userPortRoles.portId, portId)), + columns: { id: true }, + }); + return Boolean(role); +} + export async function listReportRuns( portId: string, query: ListReportRunsInput, @@ -98,6 +124,31 @@ export async function createReportRun( throw new ValidationError(`config.kind must equal "${input.kind}"`); } + // H11: the cover-brand override (`config.coverBrandPortId`) swaps the + // cover-page logo + port name to another port's brand kit. All report + // DATA still comes from `options.portId`, so the override's only effect + // is rendering a foreign port's branding — a cross-tenant leak vector. + // + // For USER-triggered runs there is an accountable user, so gate the + // override to ports that user belongs to (super-admins exempt) and reject + // before the row is even queued. For SCHEDULE-triggered runs there is no + // human actor to attribute access to, so we don't reject here (that would + // silently kill a whole scheduled report); the renderer's defense-in-depth + // check strips an unauthorized override and falls back to the source + // port's branding instead. + const coverBrandPortId = (input.config as { coverBrandPortId?: unknown }).coverBrandPortId; + if ( + options.triggeredBy === 'user' && + typeof coverBrandPortId === 'string' && + coverBrandPortId.length > 0 && + coverBrandPortId !== options.portId + ) { + const allowed = await userCanAccessPort(options.triggeredByUserId, coverBrandPortId); + if (!allowed) { + throw new ForbiddenError('You do not have access to the port selected for cover branding'); + } + } + // Verify template ownership when provided. Belt-and-braces: the route // already gates by port via withAuth, but a stale template id from a // cached UI would otherwise produce an opaque FK constraint error. diff --git a/src/lib/validators/reports.ts b/src/lib/validators/reports.ts index 2c1dbb3d..ac2a5329 100644 --- a/src/lib/validators/reports.ts +++ b/src/lib/validators/reports.ts @@ -72,7 +72,12 @@ export const createReportRunSchema = z.object({ kind: z.enum(REPORT_KINDS), templateId: z.string().optional(), // Same opaque shape report_templates accepts — the render queue - // re-validates per-kind at use time. + // re-validates per-kind at use time. NOTE: the optional + // `config.coverBrandPortId` (cover-page brand swap) is intentionally NOT + // shape-validated here — it requires a runtime per-user membership check + // that Zod can't express. `createReportRun` rejects an override the + // triggering user can't access (H11), and the renderer strips a + // stale/forged one as defense-in-depth. config: z.record(z.string(), z.unknown()), outputFormat: z.enum(REPORT_OUTPUT_FORMATS).default('pdf'), emailTo: z.array(recipientSchema).max(50).optional(),