diff --git a/lib/services/mimecast-blast-radius.test.ts b/lib/services/mimecast-blast-radius.test.ts index 120bb88..54ebbd6 100644 --- a/lib/services/mimecast-blast-radius.test.ts +++ b/lib/services/mimecast-blast-radius.test.ts @@ -50,6 +50,7 @@ const BASE_INPUT: BlastRadiusInput = { describe('getBlastRadius', () => { beforeEach(() => { isMimecastConfiguredMock.mockReset().mockReturnValue(true); + getMimecastClientMock.mockClear(); getMessageInfoMock.mockReset().mockResolvedValue(null); searchDeliveredMessagesMock.mockReset().mockResolvedValue({ messages: [] }); getHeldMessagesMock.mockReset().mockResolvedValue({ messages: [], totalCount: 0 }); diff --git a/lib/services/mimecast-blast-radius.ts b/lib/services/mimecast-blast-radius.ts index 7be67e5..31ee6ef 100644 --- a/lib/services/mimecast-blast-radius.ts +++ b/lib/services/mimecast-blast-radius.ts @@ -22,21 +22,20 @@ * `clicked: 0` means "no click-type threat event found in the events * this module can see," NOT "confirmed zero clicks." * - * (c) KNOWN LIMITATION — MULTI-TENANT GAP (D-05): this module uses only the - * single global env-var-configured getMimecastClient(), NOT the - * per-company `mimecast_tenants` table / getMimecastClientForTenant(). - * Reports belonging to companies with their own registered Mimecast - * tenant (not covered by the global MIMECAST_CLIENT_ID) will return - * `status: 'unavailable'` even though Mimecast is technically configured - * for that company. Per-tenant resolution is a deliberate, documented v1 - * gap — not a silent oversight — and would be a small, mechanical - * follow-up later (swap getMimecastClient() for a tenant lookup + - * getMimecastClientForTenant(), same fan-out/merge logic below). + * (c) PER-TENANT RESOLUTION (D-05, formerly a known gap): this module now + * accepts an optional `options.client` — a pre-built MimecastClient for a + * specific company's `mimecast_tenants` row (via + * getMimecastClientForTenant()). Resolution of WHICH tenant to use is the + * caller's responsibility (the campaign detail route looks up + * reports.company_id -> mimecast_tenants); this module simply uses + * whatever client it is given, or falls back to the single global + * env-var-configured getMimecastClient() when no client is injected. */ import { isMimecastConfigured, getMimecastClient, + type MimecastClient, type MimecastDeliveredMessage, type MimecastHeldMessage, } from './mimecast-client'; @@ -89,21 +88,27 @@ function isClickEvent(analysis: string[] | undefined): boolean { return (analysis ?? []).some((a) => /click/i.test(a)); } -export async function getBlastRadius(input: BlastRadiusInput): Promise { - if (!isMimecastConfigured()) { +export async function getBlastRadius( + input: BlastRadiusInput, + options?: { client?: MimecastClient; cacheScope?: string } +): Promise { + // An injected tenant client is self-sufficient (it carries its own + // credentials) — only fall back to the global env-configured client (and + // its isMimecastConfigured() gate) when no client was injected. + const client = options?.client ?? (isMimecastConfigured() ? getMimecastClient() : null); + if (!client) { return { status: 'unavailable', reason: 'not_configured' }; } + const scope = options?.cacheScope ?? 'global'; const cacheKey = input.messageId - ? `mimecast:blast-radius:msgid:${input.messageId}` - : `mimecast:blast-radius:composite:${input.sender}:${input.subject}:${input.dateWindow.start.toISOString()}:${input.dateWindow.end.toISOString()}`; + ? `mimecast:blast-radius:${scope}:msgid:${input.messageId}` + : `mimecast:blast-radius:${scope}:composite:${input.sender}:${input.subject}:${input.dateWindow.start.toISOString()}:${input.dateWindow.end.toISOString()}`; const cached = await getCachedData(cacheKey); if (cached) return cached; try { - const client = getMimecastClient(); - // Supplementary only — body/header evidence, never gates the fan-out // (17-RESEARCH.md Pitfall 1: getMessageInfo() has no status/counts). if (input.messageId) { @@ -127,6 +132,16 @@ export async function getBlastRadius(input: BlastRadiusInput): Promise