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 7f3eb8c7356f9..1f0c5091d5804 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 @@ -13,8 +13,13 @@ export const DISMISS_LEAD_URL = `${LEAD_GENERATION_URL}/{id}/_dismiss` as const; export const BULK_UPDATE_LEADS_URL = `${LEAD_GENERATION_URL}/bulk_update` as const; export const ENABLE_LEAD_GENERATION_URL = `${LEAD_GENERATION_URL}/enable` as const; export const DISABLE_LEAD_GENERATION_URL = `${LEAD_GENERATION_URL}/disable` as const; +export const LEAD_GENERATION_PRIVILEGES_URL = `${LEAD_GENERATION_URL}/privileges` as const; + +const LEADS_INDEX_PREFIX = '.entity_analytics.entity-leads' as const; + +export const LEADS_INDEX_PATTERN = `${LEADS_INDEX_PREFIX}-*` as const; export type LeadGenerationMode = 'adhoc' | 'scheduled'; export const getLeadsIndexName = (spaceId: string, mode: LeadGenerationMode = 'adhoc'): string => - `.entity_analytics.entity-leads-${mode}.entity-${spaceId}`; + `${LEADS_INDEX_PREFIX}-${mode}.entity-${spaceId}`; diff --git a/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/api/api.ts b/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/api/api.ts index c9711a46b6034..9f31ec3f75e92 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/api/api.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/api/api.ts @@ -107,6 +107,7 @@ import { BULK_UPDATE_LEADS_URL, ENABLE_LEAD_GENERATION_URL, DISABLE_LEAD_GENERATION_URL, + LEAD_GENERATION_PRIVILEGES_URL, } from '../../../common/entity_analytics/lead_generation/constants'; import type { FindLeadsResponse, @@ -908,6 +909,12 @@ export const useEntityAnalyticsRoutes = () => { method: 'POST', }); + const fetchLeadGenerationPrivileges = () => + http.fetch(LEAD_GENERATION_PRIVILEGES_URL, { + version: API_VERSIONS.internal.v1, + method: 'GET', + }); + return { fetchRiskScorePreview, fetchRiskEngineStatus, @@ -959,6 +966,7 @@ export const useEntityAnalyticsRoutes = () => { bulkUpdateLeads, enableLeadGeneration, disableLeadGeneration, + fetchLeadGenerationPrivileges, }; }, [ http, diff --git a/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/threat_hunting/top_threat_hunting_leads/index.tsx b/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/threat_hunting/top_threat_hunting_leads/index.tsx index fb9b063e55a38..2fc681fdc0b55 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/threat_hunting/top_threat_hunting_leads/index.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/threat_hunting/top_threat_hunting_leads/index.tsx @@ -58,6 +58,7 @@ interface TopThreatHuntingLeadsProps { hasValidConnector: boolean; onConnectorIdSelected: (id: string) => void; isAgentChatExperienceEnabled: boolean; + hasWritePermissionError?: boolean; } export const TopThreatHuntingLeads: React.FC = ({ @@ -77,6 +78,7 @@ export const TopThreatHuntingLeads: React.FC = ({ hasValidConnector, onConnectorIdSelected, isAgentChatExperienceEnabled, + hasWritePermissionError, }) => { const [isOpen, setIsOpen] = useState(true); const [isOptionsOpen, setIsOptionsOpen] = useState(false); @@ -101,6 +103,12 @@ export const TopThreatHuntingLeads: React.FC = ({ const genAiSettingsUrl = getUrlForApp('management', { path: '/ai/genAiSettings' }); const showHeaderGenerate = !isOpen && leads.length === 0 && !hasGenerated; + const generateTooltipContent = hasWritePermissionError + ? i18n.GENERATE_DISABLED_NO_WRITE_PERMISSION_TOOLTIP + : !hasValidConnector + ? i18n.GENERATE_DISABLED_NO_CONNECTOR_TOOLTIP + : undefined; + const isGenerateDisabled = !hasValidConnector || !!hasWritePermissionError; const renderCount = Math.min(leads.length, visibleCardCount); const hasFewLeads = leads.length < visibleCardCount; @@ -148,15 +156,24 @@ export const TopThreatHuntingLeads: React.FC = ({ )} {leads.length > 0 && ( - - {i18n.REGENERATE} - + + {i18n.REGENERATE} + + )} {leads.length > 0 && ( @@ -198,16 +215,12 @@ export const TopThreatHuntingLeads: React.FC = ({ {i18n.OPEN_GENAI_SETTINGS} ) : ( - + @@ -322,18 +335,12 @@ export const TopThreatHuntingLeads: React.FC = ({ {i18n.OPEN_GENAI_SETTINGS} ) : ( - + @@ -370,18 +377,12 @@ export const TopThreatHuntingLeads: React.FC = ({ {i18n.OPEN_GENAI_SETTINGS} ) : ( - + diff --git a/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/threat_hunting/top_threat_hunting_leads/translations.ts b/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/threat_hunting/top_threat_hunting_leads/translations.ts index 9e2adcf30d7e0..93df9ca094fd7 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/threat_hunting/top_threat_hunting_leads/translations.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/threat_hunting/top_threat_hunting_leads/translations.ts @@ -212,6 +212,11 @@ export const SCHEDULE_UPDATE_ERROR = i18n.translate( { defaultMessage: 'Failed to update schedule' } ); +export const GENERATE_DISABLED_NO_WRITE_PERMISSION_TOOLTIP = i18n.translate( + 'xpack.securitySolution.entityAnalytics.threatHunting.leads.generateDisabledNoWritePermissionTooltip', + { defaultMessage: "You don't have write access to the leads index" } +); + export const getStalenessLabel = (staleness: string): string => { switch (staleness) { case 'fresh': diff --git a/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/threat_hunting/top_threat_hunting_leads/use_hunting_leads.test.ts b/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/threat_hunting/top_threat_hunting_leads/use_hunting_leads.test.ts index 86413ce0be9c2..7ee441456a392 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/threat_hunting/top_threat_hunting_leads/use_hunting_leads.test.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/threat_hunting/top_threat_hunting_leads/use_hunting_leads.test.ts @@ -38,6 +38,9 @@ describe('useHuntingLeads', () => { fetchLeadGenerationStatus: jest.fn().mockResolvedValue({ isEnabled: false }), enableLeadGeneration: jest.fn().mockResolvedValue({ success: true }), disableLeadGeneration: jest.fn().mockResolvedValue({ success: true }), + fetchLeadGenerationPrivileges: jest + .fn() + .mockResolvedValue({ has_read_permissions: true, has_write_permissions: true }), }); mockUseAppToasts.mockReturnValue({ addSuccess: mockAddSuccess, @@ -63,7 +66,8 @@ describe('useHuntingLeads', () => { mockUseQuery.mockImplementation( (config: { queryFn?: (ctx: { signal?: AbortSignal }) => Promise }) => { queryCallCount++; - if (queryCallCount === 1) { + // useQuery call order: 1=privileges, 2=fetchLeads, 3=fetchLeadGenerationStatus + if (queryCallCount === 2) { capturedQueryFn = config.queryFn; } return { @@ -192,4 +196,39 @@ describe('useHuntingLeads', () => { expect(result.current.isGenerating).toBe(true); }); + + it('returns readPermissionError true when privileges indicate no read access', () => { + mockUseQuery.mockImplementation((config: { queryKey?: string[]; queryFn?: () => unknown }) => { + if (config.queryKey?.[0] === 'lead-generation-privileges') { + return { + data: { has_read_permissions: false, has_write_permissions: false }, + isLoading: false, + refetch: jest.fn(), + }; + } + return { data: undefined, isLoading: false, refetch: jest.fn() }; + }); + + const { result } = renderHook(() => useHuntingLeads('test-connector-id')); + + expect(result.current.readPermissionError).toBe(true); + }); + + it('returns writePermissionError true when privileges indicate no write access', () => { + mockUseQuery.mockImplementation((config: { queryKey?: string[]; queryFn?: () => unknown }) => { + if (config.queryKey?.[0] === 'lead-generation-privileges') { + return { + data: { has_read_permissions: true, has_write_permissions: false }, + isLoading: false, + refetch: jest.fn(), + }; + } + return { data: undefined, isLoading: false, refetch: jest.fn() }; + }); + + const { result } = renderHook(() => useHuntingLeads('test-connector-id')); + + expect(result.current.writePermissionError).toBe(true); + expect(result.current.readPermissionError).toBe(false); + }); }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/threat_hunting/top_threat_hunting_leads/use_hunting_leads.ts b/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/threat_hunting/top_threat_hunting_leads/use_hunting_leads.ts index f65110a29c7d6..9810e12f6d7ae 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/threat_hunting/top_threat_hunting_leads/use_hunting_leads.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/components/threat_hunting/top_threat_hunting_leads/use_hunting_leads.ts @@ -16,12 +16,16 @@ import * as i18n from './translations'; const HUNTING_LEADS_QUERY_KEY = 'hunting-leads'; const LEAD_SCHEDULE_QUERY_KEY = 'lead-generation-status'; +const LEAD_GENERATION_PRIVILEGES_QUERY_KEY = 'lead-generation-privileges'; const POLL_INTERVAL_MS = 2_000; const MAX_POLLS = 30; const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); +const isPermissionDenied = (error: unknown): boolean => + (error as { body?: { statusCode?: number } })?.body?.statusCode === 403; + const FETCH_LEADS_PARAMS = { params: { page: 1 as const, @@ -39,12 +43,26 @@ export const useHuntingLeads = (connectorId: string, isEnabled: boolean = true) fetchLeadGenerationStatus, enableLeadGeneration, disableLeadGeneration, + fetchLeadGenerationPrivileges, } = useEntityAnalyticsRoutes(); const queryClient = useQueryClient(); const { addSuccess, addError, addWarning } = useAppToasts(); const { telemetry } = useKibana().services; const abortCtrl = useRef(new AbortController()); const [hasGenerated, setHasGenerated] = useState(false); + const [readPermissionError, setReadPermissionError] = useState(false); + const [writePermissionError, setWritePermissionError] = useState(false); + + const { data: privileges } = useQuery({ + queryKey: [LEAD_GENERATION_PRIVILEGES_QUERY_KEY], + queryFn: fetchLeadGenerationPrivileges, + enabled: isEnabled, + }); + + const proactiveReadPermissionError = + isEnabled && privileges != null && !privileges.has_read_permissions; + const proactiveWritePermissionError = + isEnabled && privileges != null && !privileges.has_write_permissions; useEffect(() => { return () => { @@ -60,7 +78,13 @@ export const useHuntingLeads = (connectorId: string, isEnabled: boolean = true) queryKey: [HUNTING_LEADS_QUERY_KEY], queryFn: ({ signal }) => fetchLeads({ signal, ...FETCH_LEADS_PARAMS }), enabled: isEnabled, - onError: (error: Error) => addError(error, { title: i18n.FETCH_LEADS_ERROR }), + onError: (error: Error) => { + if (isPermissionDenied(error)) { + setReadPermissionError(true); + } else { + addError(error, { title: i18n.FETCH_LEADS_ERROR }); + } + }, }); const pollForCompletion = useCallback( @@ -117,7 +141,11 @@ export const useHuntingLeads = (connectorId: string, isEnabled: boolean = true) } }, onError: (error: Error) => { - addError(error, { title: i18n.GENERATE_ERROR }); + if (isPermissionDenied(error)) { + setWritePermissionError(true); + } else { + addError(error, { title: i18n.GENERATE_ERROR }); + } }, }); @@ -125,7 +153,13 @@ export const useHuntingLeads = (connectorId: string, isEnabled: boolean = true) queryKey: [LEAD_SCHEDULE_QUERY_KEY], queryFn: ({ signal }) => fetchLeadGenerationStatus({ signal }), enabled: isEnabled, - onError: (error: Error) => addError(error, { title: i18n.FETCH_STATUS_ERROR }), + onError: (error: Error) => { + if (isPermissionDenied(error)) { + setReadPermissionError(true); + } else { + addError(error, { title: i18n.FETCH_STATUS_ERROR }); + } + }, }); const { mutate: toggleSchedule } = useMutation({ @@ -148,5 +182,7 @@ export const useHuntingLeads = (connectorId: string, isEnabled: boolean = true) refetch, isScheduled: statusData?.isEnabled ?? false, toggleSchedule, + readPermissionError: proactiveReadPermissionError || readPermissionError, + writePermissionError: proactiveWritePermissionError || writePermissionError, }; }; diff --git a/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/pages/entity_analytics_home_page.tsx b/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/pages/entity_analytics_home_page.tsx index 668b7032a5958..1f0ca5b5a07a5 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/pages/entity_analytics_home_page.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/entity_analytics/pages/entity_analytics_home_page.tsx @@ -116,6 +116,8 @@ export const EntityAnalyticsHomePage = () => { generate, isScheduled, toggleSchedule, + readPermissionError: leadsReadPermissionError, + writePermissionError: leadsWritePermissionError, } = useHuntingLeads(connectorId, leadGenerationEnabled); const openAgentBuilderWithLead = useLeadAttachment(); @@ -252,7 +254,7 @@ export const EntityAnalyticsHomePage = () => { ) : ( - {leadGenerationEnabled && ( + {leadGenerationEnabled && !leadsReadPermissionError && ( { hasValidConnector={hasValidConnector} onConnectorIdSelected={safeSetConnectorId} isAgentChatExperienceEnabled={isAgentChatExperienceEnabled} + hasWritePermissionError={leadsWritePermissionError} /> )} diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/lead_generation/routes/get_lead_generation_privileges.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/lead_generation/routes/get_lead_generation_privileges.ts new file mode 100644 index 0000000000000..6c18bb8026cbe --- /dev/null +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/lead_generation/routes/get_lead_generation_privileges.ts @@ -0,0 +1,71 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { IKibanaResponse, Logger } from '@kbn/core/server'; +import { buildSiemResponse } from '@kbn/lists-plugin/server/routes/utils'; +import { transformError } from '@kbn/securitysolution-es-utils'; + +import { + LEAD_GENERATION_PRIVILEGES_URL, + LEADS_INDEX_PATTERN, +} from '../../../../../common/entity_analytics/lead_generation/constants'; +import { API_VERSIONS } from '../../../../../common/entity_analytics/constants'; +import { APP_ID } from '../../../../../common'; +import type { EntityAnalyticsRoutesDeps } from '../../types'; +import { + _formatPrivileges, + hasReadWritePermissions, +} from '../../utils/check_and_format_privileges'; + +export const getLeadGenerationPrivilegesRoute = ( + router: EntityAnalyticsRoutesDeps['router'], + logger: Logger, + getStartServices: EntityAnalyticsRoutesDeps['getStartServices'] +) => { + router.versioned + .get({ + access: 'internal', + path: LEAD_GENERATION_PRIVILEGES_URL, + security: { + authz: { + requiredPrivileges: ['securitySolution', `${APP_ID}-entity-analytics`], + }, + }, + }) + .addVersion( + { + version: API_VERSIONS.internal.v1, + validate: false, + }, + async (context, request, response): Promise => { + const siemResponse = buildSiemResponse(response); + try { + const [_, { security }] = await getStartServices(); + + const checkPrivileges = security.authz.checkPrivilegesDynamicallyWithRequest(request); + const { privileges, hasAllRequested } = await checkPrivileges({ + elasticsearch: { + cluster: [], + index: { [LEADS_INDEX_PATTERN]: ['read', 'write'] }, + }, + }); + + const body = { + privileges: _formatPrivileges(privileges), + has_all_required: hasAllRequested, + ...hasReadWritePermissions(privileges.elasticsearch, LEADS_INDEX_PATTERN), + }; + + return response.ok({ body }); + } catch (e) { + logger.error(`[LeadGeneration] Error checking privileges: ${e}`); + const error = transformError(e); + return siemResponse.error({ statusCode: error.statusCode, body: error.message }); + } + } + ); +}; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/lead_generation/routes/register_lead_generation_routes.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/lead_generation/routes/register_lead_generation_routes.ts index 2451c7d95223f..319463c6687f6 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/lead_generation/routes/register_lead_generation_routes.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/entity_analytics/lead_generation/routes/register_lead_generation_routes.ts @@ -13,6 +13,7 @@ import { dismissLeadRoute } from './dismiss_lead'; import { bulkUpdateLeadsRoute } from './bulk_update_leads'; import { enableLeadGenerationRoute } from './enable_lead_generation'; import { disableLeadGenerationRoute } from './disable_lead_generation'; +import { getLeadGenerationPrivilegesRoute } from './get_lead_generation_privileges'; export const registerLeadGenerationRoutes = ({ router, @@ -26,4 +27,5 @@ export const registerLeadGenerationRoutes = ({ bulkUpdateLeadsRoute(router, logger); enableLeadGenerationRoute(router, logger, getStartServices); disableLeadGenerationRoute(router, logger, getStartServices); + getLeadGenerationPrivilegesRoute(router, logger, getStartServices); };