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 @@ -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}`;
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -908,6 +909,12 @@ export const useEntityAnalyticsRoutes = () => {
method: 'POST',
});

const fetchLeadGenerationPrivileges = () =>
http.fetch<EntityAnalyticsPrivileges>(LEAD_GENERATION_PRIVILEGES_URL, {
version: API_VERSIONS.internal.v1,
method: 'GET',
});

return {
fetchRiskScorePreview,
fetchRiskEngineStatus,
Expand Down Expand Up @@ -959,6 +966,7 @@ export const useEntityAnalyticsRoutes = () => {
bulkUpdateLeads,
enableLeadGeneration,
disableLeadGeneration,
fetchLeadGenerationPrivileges,
};
}, [
http,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ interface TopThreatHuntingLeadsProps {
hasValidConnector: boolean;
onConnectorIdSelected: (id: string) => void;
isAgentChatExperienceEnabled: boolean;
hasWritePermissionError?: boolean;
}

export const TopThreatHuntingLeads: React.FC<TopThreatHuntingLeadsProps> = ({
Expand All @@ -77,6 +78,7 @@ export const TopThreatHuntingLeads: React.FC<TopThreatHuntingLeadsProps> = ({
hasValidConnector,
onConnectorIdSelected,
isAgentChatExperienceEnabled,
hasWritePermissionError,
}) => {
const [isOpen, setIsOpen] = useState(true);
const [isOptionsOpen, setIsOptionsOpen] = useState(false);
Expand All @@ -101,6 +103,12 @@ export const TopThreatHuntingLeads: React.FC<TopThreatHuntingLeadsProps> = ({
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;

Expand Down Expand Up @@ -148,15 +156,24 @@ export const TopThreatHuntingLeads: React.FC<TopThreatHuntingLeadsProps> = ({
)}
{leads.length > 0 && (
<EuiFlexItem grow={false}>
<EuiButtonEmpty
size="s"
iconType="refresh"
isLoading={isGenerating}
onClick={onGenerate}
data-test-subj="refreshLeadsButton"
<EuiToolTip
content={
hasWritePermissionError
? i18n.GENERATE_DISABLED_NO_WRITE_PERMISSION_TOOLTIP
: undefined
}
>
{i18n.REGENERATE}
</EuiButtonEmpty>
<EuiButtonEmpty
size="s"
iconType="refresh"
isLoading={isGenerating}
isDisabled={!!hasWritePermissionError}
onClick={onGenerate}
data-test-subj="refreshLeadsButton"
>
{i18n.REGENERATE}
</EuiButtonEmpty>
</EuiToolTip>
</EuiFlexItem>
)}
{leads.length > 0 && (
Expand Down Expand Up @@ -198,16 +215,12 @@ export const TopThreatHuntingLeads: React.FC<TopThreatHuntingLeadsProps> = ({
{i18n.OPEN_GENAI_SETTINGS}
</EuiButton>
) : (
<EuiToolTip
content={
!hasValidConnector ? i18n.GENERATE_DISABLED_NO_CONNECTOR_TOOLTIP : undefined
}
>
<EuiToolTip content={generateTooltipContent}>
<AiButton
size="s"
iconType="sparkles"
isLoading={isGenerating}
isDisabled={!hasValidConnector}
isDisabled={isGenerateDisabled}
onClick={onGenerate}
data-test-subj="headerGenerateLeadsButton"
>
Expand Down Expand Up @@ -322,18 +335,12 @@ export const TopThreatHuntingLeads: React.FC<TopThreatHuntingLeadsProps> = ({
{i18n.OPEN_GENAI_SETTINGS}
</EuiButton>
) : (
<EuiToolTip
content={
!hasValidConnector
? i18n.GENERATE_DISABLED_NO_CONNECTOR_TOOLTIP
: undefined
}
>
<EuiToolTip content={generateTooltipContent}>
<AiButton
size="s"
iconType="sparkles"
isLoading={isGenerating}
isDisabled={!hasValidConnector}
isDisabled={isGenerateDisabled}
onClick={onGenerate}
data-test-subj="generateLeadsButton"
>
Expand Down Expand Up @@ -370,18 +377,12 @@ export const TopThreatHuntingLeads: React.FC<TopThreatHuntingLeadsProps> = ({
{i18n.OPEN_GENAI_SETTINGS}
</EuiButton>
) : (
<EuiToolTip
content={
!hasValidConnector
? i18n.GENERATE_DISABLED_NO_CONNECTOR_TOOLTIP
: undefined
}
>
<EuiToolTip content={generateTooltipContent}>
<AiButton
size="s"
iconType="sparkles"
isLoading={isGenerating}
isDisabled={!hasValidConnector}
isDisabled={isGenerateDisabled}
onClick={onGenerate}
data-test-subj="generateLeadsButton"
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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':
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
abhishekbhatia1710 marked this conversation as resolved.
.fn()
.mockResolvedValue({ has_read_permissions: true, has_write_permissions: true }),
});
mockUseAppToasts.mockReturnValue({
addSuccess: mockAddSuccess,
Expand All @@ -63,7 +66,8 @@ describe('useHuntingLeads', () => {
mockUseQuery.mockImplementation(
(config: { queryFn?: (ctx: { signal?: AbortSignal }) => Promise<unknown> }) => {
queryCallCount++;
if (queryCallCount === 1) {
// useQuery call order: 1=privileges, 2=fetchLeads, 3=fetchLeadGenerationStatus
if (queryCallCount === 2) {
capturedQueryFn = config.queryFn;
}
return {
Expand Down Expand Up @@ -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);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 () => {
Expand All @@ -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(
Expand Down Expand Up @@ -117,15 +141,25 @@ 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 });
}
},
});

const { data: statusData, isLoading: isStatusLoading } = useQuery({
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({
Expand All @@ -148,5 +182,7 @@ export const useHuntingLeads = (connectorId: string, isEnabled: boolean = true)
refetch,
isScheduled: statusData?.isEnabled ?? false,
toggleSchedule,
readPermissionError: proactiveReadPermissionError || readPermissionError,
writePermissionError: proactiveWritePermissionError || writePermissionError,
};
};
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,8 @@ export const EntityAnalyticsHomePage = () => {
generate,
isScheduled,
toggleSchedule,
readPermissionError: leadsReadPermissionError,
writePermissionError: leadsWritePermissionError,
} = useHuntingLeads(connectorId, leadGenerationEnabled);
const openAgentBuilderWithLead = useLeadAttachment();

Expand Down Expand Up @@ -252,7 +254,7 @@ export const EntityAnalyticsHomePage = () => {
<EuiLoadingSpinner size="l" data-test-subj="entityAnalyticsHomePageLoader" />
) : (
<EuiFlexGroup direction="column" gutterSize="l">
{leadGenerationEnabled && (
{leadGenerationEnabled && !leadsReadPermissionError && (
<EuiFlexItem>
<TopThreatHuntingLeads
leads={leads}
Expand All @@ -271,6 +273,7 @@ export const EntityAnalyticsHomePage = () => {
hasValidConnector={hasValidConnector}
onConnectorIdSelected={safeSetConnectorId}
isAgentChatExperienceEnabled={isAgentChatExperienceEnabled}
hasWritePermissionError={leadsWritePermissionError}
/>
</EuiFlexItem>
)}
Expand Down
Loading
Loading