diff --git a/x-pack/solutions/security/plugins/security_solution/common/entity_analytics/lead_generation/constants.ts b/x-pack/solutions/security/plugins/security_solution/common/entity_analytics/lead_generation/constants.ts index 60bffe9d45e01..7f3eb8c7356f9 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/entity_analytics/lead_generation/constants.ts +++ b/x-pack/solutions/security/plugins/security_solution/common/entity_analytics/lead_generation/constants.ts @@ -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}`; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/lead_generation/lead_data_client.test.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/lead_generation/lead_data_client.test.ts index f1eca477368ca..b410877679fc7 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/lead_generation/lead_data_client.test.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/lead_generation/lead_data_client.test.ts @@ -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 => ({ id: 'lead-1', title: 'Test Lead', @@ -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', () => { @@ -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', () => { @@ -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', () => { @@ -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(); + }); }); }); diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/lead_generation/lead_data_client.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/lead_generation/lead_data_client.ts index 71d166fbcf869..66c2e661e2ee1 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/lead_generation/lead_data_client.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/lead_generation/lead_data_client.ts @@ -64,6 +64,18 @@ export interface LeadDataClient { deleteAllLeads(): Promise; } +// --------------------------------------------------------------------------- +// 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) // --------------------------------------------------------------------------- @@ -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}`); } }; @@ -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}`); @@ -387,6 +402,9 @@ export const createLeadDataClient = ({ lastRun = (latestHit._source as Record).timestamp as string; } } catch (e) { + if (isEsSecurityException(e)) { + throw e; + } logger.debug(`[LeadGeneration] Status check — indices not available: ${e}`); } @@ -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}`); } }; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/lead_generation/routes/bulk_update_leads.test.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/lead_generation/routes/bulk_update_leads.test.ts index 64e8cab94ac3f..1a00a3ef132ab 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/lead_generation/routes/bulk_update_leads.test.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/lead_generation/routes/bulk_update_leads.test.ts @@ -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 { @@ -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; let context: ReturnType; @@ -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); @@ -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); + }); }); diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/lead_generation/routes/disable_lead_generation.test.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/lead_generation/routes/disable_lead_generation.test.ts index 62558e12aeace..b1c257d6c2b11 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/lead_generation/routes/disable_lead_generation.test.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/lead_generation/routes/disable_lead_generation.test.ts @@ -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 { @@ -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', diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/lead_generation/routes/dismiss_lead.test.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/lead_generation/routes/dismiss_lead.test.ts index b9ec00b4137d5..62a153b13bf1c 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/lead_generation/routes/dismiss_lead.test.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/lead_generation/routes/dismiss_lead.test.ts @@ -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 { @@ -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; let context: ReturnType; @@ -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); @@ -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); + }); }); diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/lead_generation/routes/enable_lead_generation.test.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/lead_generation/routes/enable_lead_generation.test.ts index d972bcb25970d..156b7dffbcf8e 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/lead_generation/routes/enable_lead_generation.test.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/lead_generation/routes/enable_lead_generation.test.ts @@ -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 { @@ -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', diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/lead_generation/routes/generate_leads.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/lead_generation/routes/generate_leads.ts index 2962877b97b29..57ff79f426cdf 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/lead_generation/routes/generate_leads.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/lead_generation/routes/generate_leads.ts @@ -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({ diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/lead_generation/routes/get_lead_generation_status.test.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/lead_generation/routes/get_lead_generation_status.test.ts index 5d885e1ac52bf..9bb7d863a3f6f 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/lead_generation/routes/get_lead_generation_status.test.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/lead_generation/routes/get_lead_generation_status.test.ts @@ -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 { @@ -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`, @@ -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, @@ -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({ @@ -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); + }); }); diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/lead_generation/routes/get_leads.test.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/lead_generation/routes/get_leads.test.ts index 360743b1d0157..f7fea172f13d2 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/lead_generation/routes/get_leads.test.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/lead_generation/routes/get_leads.test.ts @@ -6,6 +6,7 @@ */ import { loggingSystemMock } from '@kbn/core/server/mocks'; +import { APP_ID } from '../../../../../common'; import { getLeadsRoute } from './get_leads'; import { GET_LEADS_URL } from '../../../../../common/entity_analytics/lead_generation/constants'; import { @@ -19,6 +20,12 @@ jest.mock('../lead_data_client', () => ({ createLeadDataClient: () => ({ findLeads: mockFindLeads }), })); +const makeEsSecurityException = () => ({ + statusCode: 403, + body: { error: { type: 'security_exception', reason: 'access denied' } }, + meta: { body: { error: { type: 'security_exception', reason: 'access denied' } } }, +}); + describe('getLeadsRoute', () => { let server: ReturnType; let context: ReturnType; @@ -32,6 +39,14 @@ describe('getLeadsRoute', () => { getLeadsRoute(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.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 paginated leads', async () => { mockFindLeads.mockResolvedValueOnce({ leads: [{ id: 'lead-1', title: 'Test' }], @@ -109,7 +124,7 @@ describe('getLeadsRoute', () => { ); }); - it('returns 500 when data client throws', async () => { + it('returns 500 when data client throws a generic error', async () => { mockFindLeads.mockRejectedValueOnce(new Error('ES failure')); const request = requestMock.create({ @@ -121,4 +136,17 @@ describe('getLeadsRoute', () => { 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 () => { + mockFindLeads.mockRejectedValueOnce(makeEsSecurityException()); + + const request = requestMock.create({ + method: 'get', + path: GET_LEADS_URL, + query: {}, + }); + + const response = await server.inject(request, context); + expect(response.status).toEqual(403); + }); });