Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,4 @@ export const DISABLE_LEAD_GENERATION_URL = `${LEAD_GENERATION_URL}/disable` as c
export type LeadGenerationMode = 'adhoc' | 'scheduled';

export const getLeadsIndexName = (spaceId: string, mode: LeadGenerationMode = 'adhoc'): string =>
`.entity-analytics.entity-leads-${mode}.entity-${spaceId}`;
`.entity_analytics.entity-leads-${mode}.entity-${spaceId}`;
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ import type { LeadDataClient } from './lead_data_client';
import { getLeadsIndexName } from '../../../../common/entity_analytics/lead_generation/constants';
import type { Lead } from '../../../../common/entity_analytics/lead_generation/types';

const makeEsSecurityException = () => ({
statusCode: 403,
body: { error: { type: 'security_exception', reason: 'access denied' } },
meta: { body: { error: { type: 'security_exception', reason: 'access denied' } } },
});

const makeTestLead = (overrides: Partial<Lead> = {}): Lead => ({
id: 'lead-1',
title: 'Test Lead',
Expand Down Expand Up @@ -163,6 +169,21 @@ describe('LeadDataClient', () => {

expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining('Failed to persist leads'));
});

it('re-throws when ES returns security_exception so callers can surface the 403', async () => {
const securityException = makeEsSecurityException();
esClient.bulk.mockRejectedValueOnce(securityException);

await expect(
client.createLeads({
leads: [makeTestLead()],
executionId: 'exec-5',
sourceType: 'adhoc',
})
).rejects.toBe(securityException);

expect(logger.warn).not.toHaveBeenCalled();
});
});

describe('findLeads', () => {
Expand Down Expand Up @@ -247,6 +268,25 @@ describe('LeadDataClient', () => {
const result = await client.findLeads({});
expect(result).toEqual({ leads: [], total: 0, page: 1, perPage: 20 });
});

it('returns empty results when error type is index_not_found_exception', async () => {
const indexNotFound = {
statusCode: 404,
body: { error: { type: 'index_not_found_exception', reason: 'no such index' } },
meta: { body: { error: { type: 'index_not_found_exception', reason: 'no such index' } } },
};
esClient.search.mockRejectedValueOnce(indexNotFound);

const result = await client.findLeads({});
expect(result).toEqual({ leads: [], total: 0, page: 1, perPage: 20 });
});

it('re-throws when ES returns security_exception so the route can return 403', async () => {
const securityException = makeEsSecurityException();
esClient.search.mockRejectedValueOnce(securityException);

await expect(client.findLeads({})).rejects.toBe(securityException);
});
});

describe('dismissLead', () => {
Expand Down Expand Up @@ -356,6 +396,25 @@ describe('LeadDataClient', () => {
lastRun: null,
});
});

it('returns defaults for any generic ES error', async () => {
esClient.search.mockRejectedValueOnce(new Error('cluster timeout'));

const status = await client.getStatus();
expect(status).toEqual({
isEnabled: false,
indexExists: false,
totalLeads: 0,
lastRun: null,
});
});

it('re-throws when ES returns security_exception so the route can return 403', async () => {
const securityException = makeEsSecurityException();
esClient.search.mockRejectedValueOnce(securityException);

await expect(client.getStatus()).rejects.toBe(securityException);
});
});

describe('deleteAllLeads', () => {
Expand All @@ -377,5 +436,14 @@ describe('LeadDataClient', () => {
})
);
});

it('re-throws when ES returns security_exception so the disable route can return 403', async () => {
const securityException = makeEsSecurityException();
esClient.deleteByQuery.mockRejectedValueOnce(securityException);

await expect(client.deleteAllLeads()).rejects.toBe(securityException);

expect(logger.warn).not.toHaveBeenCalled();
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,18 @@ export interface LeadDataClient {
deleteAllLeads(): Promise<void>;
}

// ---------------------------------------------------------------------------
// ES error classification helpers
// ---------------------------------------------------------------------------

const getEsErrorType = (e: unknown): string | undefined =>
(e as { meta?: { body?: { error?: { type?: string } } } })?.meta?.body?.error?.type;

const isEsSecurityException = (e: unknown): boolean => getEsErrorType(e) === 'security_exception';

const isEsIndexNotFoundException = (e: unknown): boolean =>
getEsErrorType(e) === 'index_not_found_exception';

// ---------------------------------------------------------------------------
// Staleness computation (timestamp-based, computed at read time)
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -231,6 +243,9 @@ export const createLeadDataClient = ({
ignore_unavailable: true,
});
} catch (e) {
if (isEsSecurityException(e)) {
throw e;
}
logger.warn(`[LeadGeneration] Failed to persist leads to "${indexName}": ${e}`);
}
};
Expand Down Expand Up @@ -277,11 +292,11 @@ export const createLeadDataClient = ({

return { leads, total, page, perPage };
} catch (e) {
const isIndexNotFound =
(e as { meta?: { body?: { error?: { type?: string } } } })?.meta?.body?.error?.type ===
'index_not_found_exception';
if (isEsSecurityException(e)) {
throw e;
}
const errorMessage = e instanceof Error ? e.message : String(e);
if (isIndexNotFound) {
if (isEsIndexNotFoundException(e)) {
logger.debug(`[LeadGeneration] Leads indices not available yet: ${errorMessage}`);
} else {
logger.error(`[LeadGeneration] Unable to find leads due to error: ${errorMessage}`);
Expand Down Expand Up @@ -387,6 +402,9 @@ export const createLeadDataClient = ({
lastRun = (latestHit._source as Record<string, unknown>).timestamp as string;
}
} catch (e) {
if (isEsSecurityException(e)) {
throw e;
}
logger.debug(`[LeadGeneration] Status check — indices not available: ${e}`);
}

Expand All @@ -408,6 +426,9 @@ export const createLeadDataClient = ({
});
logger.info(`[LeadGeneration] Deleted all leads from space "${spaceId}"`);
} catch (e) {
if (isEsSecurityException(e)) {
throw e;
}
logger.warn(`[LeadGeneration] Failed to delete all leads: ${e}`);
}
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
*/

import { loggingSystemMock } from '@kbn/core/server/mocks';
import { APP_ID } from '../../../../../common';
import { bulkUpdateLeadsRoute } from './bulk_update_leads';
import { BULK_UPDATE_LEADS_URL } from '../../../../../common/entity_analytics/lead_generation/constants';
import {
Expand All @@ -19,6 +20,12 @@ jest.mock('../lead_data_client', () => ({
createLeadDataClient: () => ({ bulkUpdateLeads: mockBulkUpdateLeads }),
}));

const makeEsSecurityException = () => ({
statusCode: 403,
body: { error: { type: 'security_exception', reason: 'access denied' } },
meta: { body: { error: { type: 'security_exception', reason: 'access denied' } } },
});

describe('bulkUpdateLeadsRoute', () => {
let server: ReturnType<typeof serverMock.create>;
let context: ReturnType<typeof requestContextMock.convertContext>;
Expand All @@ -32,6 +39,14 @@ describe('bulkUpdateLeadsRoute', () => {
bulkUpdateLeadsRoute(server.router, logger);
});

describe('route security config', () => {
it('declares the required Kibana privileges so users without Security Solution access are rejected', () => {
const [routeConfig] = server.router.versioned.post.mock.calls[0];
const authz = routeConfig.security?.authz as { requiredPrivileges?: unknown } | undefined;
expect(authz?.requiredPrivileges).toEqual(['securitySolution', `${APP_ID}-entity-analytics`]);
});
});

it('returns 200 with updated count', async () => {
mockBulkUpdateLeads.mockResolvedValueOnce(3);

Expand Down Expand Up @@ -71,4 +86,17 @@ describe('bulkUpdateLeadsRoute', () => {
const response = await server.inject(request, context);
expect(response.status).toEqual(500);
});

it('returns 403 when ES denies write access to the leads index', async () => {
mockBulkUpdateLeads.mockRejectedValueOnce(makeEsSecurityException());

const request = requestMock.create({
method: 'post',
path: BULK_UPDATE_LEADS_URL,
body: { ids: ['a'], status: 'dismissed' },
});

const response = await server.inject(request, context);
expect(response.status).toEqual(403);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

import { loggingSystemMock } from '@kbn/core/server/mocks';
import { taskManagerMock } from '@kbn/task-manager-plugin/server/mocks';
import { APP_ID } from '../../../../../common';
import { disableLeadGenerationRoute } from './disable_lead_generation';
import { DISABLE_LEAD_GENERATION_URL } from '../../../../../common/entity_analytics/lead_generation/constants';
import {
Expand Down Expand Up @@ -37,6 +38,14 @@ describe('disableLeadGenerationRoute', () => {
disableLeadGenerationRoute(server.router, logger, getStartServicesMock);
});

describe('route security config', () => {
it('declares the required Kibana privileges so users without Security Solution access are rejected', () => {
const [routeConfig] = server.router.versioned.post.mock.calls[0];
const authz = routeConfig.security?.authz as { requiredPrivileges?: unknown } | undefined;
expect(authz?.requiredPrivileges).toEqual(['securitySolution', `${APP_ID}-entity-analytics`]);
});
});

it('returns 200 and removes the task', async () => {
const request = requestMock.create({
method: 'post',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
*/

import { loggingSystemMock } from '@kbn/core/server/mocks';
import { APP_ID } from '../../../../../common';
import { dismissLeadRoute } from './dismiss_lead';
import { DISMISS_LEAD_URL } from '../../../../../common/entity_analytics/lead_generation/constants';
import {
Expand All @@ -19,6 +20,12 @@ jest.mock('../lead_data_client', () => ({
createLeadDataClient: () => ({ dismissLead: mockDismissLead }),
}));

const makeEsSecurityException = () => ({
statusCode: 403,
body: { error: { type: 'security_exception', reason: 'access denied' } },
meta: { body: { error: { type: 'security_exception', reason: 'access denied' } } },
});

describe('dismissLeadRoute', () => {
let server: ReturnType<typeof serverMock.create>;
let context: ReturnType<typeof requestContextMock.convertContext>;
Expand All @@ -32,6 +39,14 @@ describe('dismissLeadRoute', () => {
dismissLeadRoute(server.router, logger);
});

describe('route security config', () => {
it('declares the required Kibana privileges so users without Security Solution access are rejected', () => {
const [routeConfig] = server.router.versioned.post.mock.calls[0];
const authz = routeConfig.security?.authz as { requiredPrivileges?: unknown } | undefined;
expect(authz?.requiredPrivileges).toEqual(['securitySolution', `${APP_ID}-entity-analytics`]);
});
});

it('returns 200 with success when lead is dismissed', async () => {
mockDismissLead.mockResolvedValueOnce(true);

Expand Down Expand Up @@ -71,4 +86,17 @@ describe('dismissLeadRoute', () => {
const response = await server.inject(request, context);
expect(response.status).toEqual(500);
});

it('returns 403 when ES denies write access to the leads index', async () => {
mockDismissLead.mockRejectedValueOnce(makeEsSecurityException());

const request = requestMock.create({
method: 'post',
path: DISMISS_LEAD_URL,
params: { id: 'lead-1' },
});

const response = await server.inject(request, context);
expect(response.status).toEqual(403);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

import { loggingSystemMock } from '@kbn/core/server/mocks';
import { taskManagerMock } from '@kbn/task-manager-plugin/server/mocks';
import { APP_ID } from '../../../../../common';
import { enableLeadGenerationRoute } from './enable_lead_generation';
import { ENABLE_LEAD_GENERATION_URL } from '../../../../../common/entity_analytics/lead_generation/constants';
import {
Expand Down Expand Up @@ -42,6 +43,14 @@ describe('enableLeadGenerationRoute', () => {
enableLeadGenerationRoute(server.router, logger, getStartServicesMock);
});

describe('route security config', () => {
it('declares the required Kibana privileges so users without Security Solution access are rejected', () => {
const [routeConfig] = server.router.versioned.post.mock.calls[0];
const authz = routeConfig.security?.authz as { requiredPrivileges?: unknown } | undefined;
expect(authz?.requiredPrivileges).toEqual(['securitySolution', `${APP_ID}-entity-analytics`]);
});
});

it('returns 200, creates indices, and starts the task', async () => {
const request = requestMock.create({
method: 'post',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,10 @@ export const generateLeadsRoute = (
`[LeadGeneration] Connector resolved successfully (connectorId=${connectorId}, executionUuid=${executionUuid})`
);

// The pipeline runs in the background after the 202 is returned.
// ES index-level permission errors (security_exception) thrown by
// createLeads are caught here, not propagated to the HTTP response.
// They surface via the status endpoint's `lastError` field instead.
void (async () => {
try {
await runLeadGenerationPipeline({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

import { loggingSystemMock } from '@kbn/core/server/mocks';
import { taskManagerMock } from '@kbn/task-manager-plugin/server/mocks';
import { APP_ID } from '../../../../../common';
import { getLeadGenerationStatusRoute } from './get_lead_generation_status';
import { LEAD_GENERATION_STATUS_URL } from '../../../../../common/entity_analytics/lead_generation/constants';
import {
Expand All @@ -20,6 +21,12 @@ jest.mock('../lead_data_client', () => ({
createLeadDataClient: () => ({ getStatus: mockGetStatus }),
}));

const makeEsSecurityException = () => ({
statusCode: 403,
body: { error: { type: 'security_exception', reason: 'access denied' } },
meta: { body: { error: { type: 'security_exception', reason: 'access denied' } } },
});

jest.mock('../tasks', () => ({
getLeadGenerationTaskId: (spaceId: string) =>
`entity_analytics:lead_generation:engine:${spaceId}:1.0.0`,
Expand All @@ -42,6 +49,14 @@ describe('getLeadGenerationStatusRoute', () => {
getLeadGenerationStatusRoute(server.router, logger, getStartServicesMock);
});

describe('route security config', () => {
it('declares the required Kibana privileges so users without Security Solution access are rejected', () => {
const [routeConfig] = server.router.versioned.get.mock.calls[0];
const authz = routeConfig.security?.authz as { requiredPrivileges?: unknown } | undefined;
expect(authz?.requiredPrivileges).toEqual(['securitySolution', `${APP_ID}-entity-analytics`]);
});
});

it('returns 200 with engine status when task exists (isEnabled: true)', async () => {
mockGetStatus.mockResolvedValueOnce({
isEnabled: true,
Expand Down Expand Up @@ -81,7 +96,7 @@ describe('getLeadGenerationStatusRoute', () => {
expect(mockGetStatus).toHaveBeenCalledWith({ isEnabled: false });
});

it('returns 500 on error', async () => {
it('returns 500 on generic error', async () => {
mockGetStatus.mockRejectedValueOnce(new Error('status check failed'));

const request = requestMock.create({
Expand All @@ -92,4 +107,16 @@ describe('getLeadGenerationStatusRoute', () => {
const response = await server.inject(request, context);
expect(response.status).toEqual(500);
});

it('returns 403 when ES denies read access to the leads index', async () => {
mockGetStatus.mockRejectedValueOnce(makeEsSecurityException());

const request = requestMock.create({
method: 'get',
path: LEAD_GENERATION_STATUS_URL,
});

const response = await server.inject(request, context);
expect(response.status).toEqual(403);
});
});
Loading
Loading