(null);
-
- const clearTimer = () => {
- if (leaveTimer.current) {
- clearTimeout(leaveTimer.current);
- }
- };
-
- const onMouseEnter = () => {
- clearTimer();
- setIsPopoverOpen(true);
- };
-
- const onMouseLeave = () => {
- leaveTimer.current = setTimeout(() => setIsPopoverOpen(false), 100);
- };
-
- useEffect(() => {
- return () => {
- clearTimer();
- };
- }, []);
-
- return (
-
-
- {title}
- {children}
-
-
- );
-};
diff --git a/x-pack/plugins/observability_solution/logs_explorer/public/components/virtual_columns/column_tooltips/actions_column_tooltip.tsx b/x-pack/plugins/observability_solution/logs_explorer/public/components/virtual_columns/column_tooltips/actions_column_tooltip.tsx
index 6ed03bf91b8baa..c33c514bf37892 100644
--- a/x-pack/plugins/observability_solution/logs_explorer/public/components/virtual_columns/column_tooltips/actions_column_tooltip.tsx
+++ b/x-pack/plugins/observability_solution/logs_explorer/public/components/virtual_columns/column_tooltips/actions_column_tooltip.tsx
@@ -17,8 +17,7 @@ import {
actionsLabel,
actionsLabelLowerCase,
} from '../../common/translations';
-import { HoverPopover } from '../../common/hover_popover';
-import { TooltipButtonComponent } from './tooltip_button';
+import { TooltipButton } from './tooltip_button';
import * as constants from '../../../../common/constants';
import { FieldWithToken } from './field_with_token';
@@ -28,10 +27,7 @@ const spacingCSS = css`
export const ActionsColumnTooltip = () => {
return (
- }
- title={actionsLabel}
- >
+
{actionsHeaderTooltipParagraph}
@@ -94,6 +90,6 @@ export const ActionsColumnTooltip = () => {
))}
-
+
);
};
diff --git a/x-pack/plugins/observability_solution/logs_explorer/public/components/virtual_columns/column_tooltips/content_column_tooltip.tsx b/x-pack/plugins/observability_solution/logs_explorer/public/components/virtual_columns/column_tooltips/content_column_tooltip.tsx
index a845b999400a6d..df33f1f1beff32 100644
--- a/x-pack/plugins/observability_solution/logs_explorer/public/components/virtual_columns/column_tooltips/content_column_tooltip.tsx
+++ b/x-pack/plugins/observability_solution/logs_explorer/public/components/virtual_columns/column_tooltips/content_column_tooltip.tsx
@@ -14,8 +14,7 @@ import {
contentHeaderTooltipParagraph2,
contentLabel,
} from '../../common/translations';
-import { HoverPopover } from '../../common/hover_popover';
-import { TooltipButtonComponent } from './tooltip_button';
+import { TooltipButton } from './tooltip_button';
import { FieldWithToken } from './field_with_token';
import * as constants from '../../../../common/constants';
@@ -26,14 +25,10 @@ export const ContentColumnTooltip = ({ column, headerRowHeight }: CustomGridColu
`;
return (
-
- }
- title={contentLabel}
+
@@ -45,6 +40,6 @@ export const ContentColumnTooltip = ({ column, headerRowHeight }: CustomGridColu
-
+
);
};
diff --git a/x-pack/plugins/observability_solution/logs_explorer/public/components/virtual_columns/column_tooltips/resource_column_tooltip.tsx b/x-pack/plugins/observability_solution/logs_explorer/public/components/virtual_columns/column_tooltips/resource_column_tooltip.tsx
index 57e51097e391af..64a64156cbaeac 100644
--- a/x-pack/plugins/observability_solution/logs_explorer/public/components/virtual_columns/column_tooltips/resource_column_tooltip.tsx
+++ b/x-pack/plugins/observability_solution/logs_explorer/public/components/virtual_columns/column_tooltips/resource_column_tooltip.tsx
@@ -11,8 +11,7 @@ import { EuiText } from '@elastic/eui';
import type { CustomGridColumnProps } from '@kbn/unified-data-table';
import { euiThemeVars } from '@kbn/ui-theme';
import { resourceHeaderTooltipParagraph, resourceLabel } from '../../common/translations';
-import { HoverPopover } from '../../common/hover_popover';
-import { TooltipButtonComponent } from './tooltip_button';
+import { TooltipButton } from './tooltip_button';
import * as constants from '../../../../common/constants';
import { FieldWithToken } from './field_with_token';
@@ -22,14 +21,10 @@ const spacingCSS = css`
export const ResourceColumnTooltip = ({ column, headerRowHeight }: CustomGridColumnProps) => {
return (
-
- }
- title={resourceLabel}
+
@@ -45,6 +40,6 @@ export const ResourceColumnTooltip = ({ column, headerRowHeight }: CustomGridCol
))}
-
+
);
};
diff --git a/x-pack/plugins/observability_solution/logs_explorer/public/components/virtual_columns/column_tooltips/tooltip_button.tsx b/x-pack/plugins/observability_solution/logs_explorer/public/components/virtual_columns/column_tooltips/tooltip_button.tsx
index 3f0a6883937026..811e71d44dd000 100644
--- a/x-pack/plugins/observability_solution/logs_explorer/public/components/virtual_columns/column_tooltips/tooltip_button.tsx
+++ b/x-pack/plugins/observability_solution/logs_explorer/public/components/virtual_columns/column_tooltips/tooltip_button.tsx
@@ -5,18 +5,74 @@
* 2.0.
*/
+import React, { useEffect, useRef, useState, useCallback, useMemo } from 'react';
import { EuiIcon } from '@elastic/eui';
-import React from 'react';
import ColumnHeaderTruncateContainer from '@kbn/unified-data-table/src/components/column_header_truncate_container';
-export const TooltipButtonComponent = ({
+import { EuiPopover, EuiPopoverTitle } from '@elastic/eui';
+
+export const TooltipButton = ({
+ children,
+ popoverTitle,
displayText,
headerRowHeight,
+ iconType = 'questionInCircle',
}: {
+ children: React.ReactChild;
+ popoverTitle: string;
displayText?: string;
headerRowHeight?: number;
-}) => (
-
- {displayText}
-
-);
+ iconType?: string;
+}) => {
+ const [isPopoverOpen, setIsPopoverOpen] = useState(false);
+ const leaveTimer = useRef(null);
+
+ const clearTimer = useMemo(
+ () => () => {
+ if (leaveTimer.current) {
+ clearTimeout(leaveTimer.current);
+ }
+ },
+ []
+ );
+
+ const onMouseEnter = useCallback(() => {
+ clearTimer();
+ setIsPopoverOpen(true);
+ }, [clearTimer]);
+
+ const onMouseLeave = useCallback(() => {
+ leaveTimer.current = setTimeout(() => setIsPopoverOpen(false), 100);
+ }, []);
+
+ useEffect(() => {
+ return () => {
+ clearTimer();
+ };
+ }, [clearTimer]);
+
+ return (
+
+ {displayText}{' '}
+
+ }
+ isOpen={isPopoverOpen}
+ anchorPosition="upCenter"
+ panelPaddingSize="s"
+ ownFocus={false}
+ >
+ {popoverTitle}
+ {children}
+
+
+ );
+};
diff --git a/x-pack/plugins/observability_solution/logs_shared/public/test_utils/use_global_storybook_theme.tsx b/x-pack/plugins/observability_solution/logs_shared/public/test_utils/use_global_storybook_theme.tsx
index a24fd9b549a050..dd0f97038740ab 100644
--- a/x-pack/plugins/observability_solution/logs_shared/public/test_utils/use_global_storybook_theme.tsx
+++ b/x-pack/plugins/observability_solution/logs_shared/public/test_utils/use_global_storybook_theme.tsx
@@ -10,7 +10,7 @@ import React, { useEffect, useMemo, useState, FC, PropsWithChildren } from 'reac
import { BehaviorSubject } from 'rxjs';
import type { CoreTheme } from '@kbn/core/public';
import { EuiThemeProvider } from '@kbn/kibana-react-plugin/common';
-import { KibanaThemeProvider } from '@kbn/kibana-react-plugin/public';
+import { KibanaThemeProvider } from '@kbn/react-kibana-context-theme';
type StoryContext = Parameters[1];
@@ -35,7 +35,7 @@ export const GlobalStorybookThemeProviders: FC<
> = ({ children, storyContext }) => {
const { theme, theme$ } = useGlobalStorybookTheme(storyContext);
return (
-
+
{children}
);
diff --git a/x-pack/plugins/observability_solution/logs_shared/tsconfig.json b/x-pack/plugins/observability_solution/logs_shared/tsconfig.json
index cfe5687f0404fe..927c2f0374018e 100644
--- a/x-pack/plugins/observability_solution/logs_shared/tsconfig.json
+++ b/x-pack/plugins/observability_solution/logs_shared/tsconfig.json
@@ -37,6 +37,7 @@
"@kbn/share-plugin",
"@kbn/shared-ux-utility",
"@kbn/search-types",
- "@kbn/discover-shared-plugin"
+ "@kbn/discover-shared-plugin",
+ "@kbn/react-kibana-context-theme"
]
}
diff --git a/x-pack/plugins/observability_solution/metrics_data_access/public/apps/common_providers.tsx b/x-pack/plugins/observability_solution/metrics_data_access/public/apps/common_providers.tsx
index cd2ac05f14778f..1734b49c694f50 100644
--- a/x-pack/plugins/observability_solution/metrics_data_access/public/apps/common_providers.tsx
+++ b/x-pack/plugins/observability_solution/metrics_data_access/public/apps/common_providers.tsx
@@ -5,25 +5,24 @@
* 2.0.
*/
-import { AppMountParameters, CoreStart } from '@kbn/core/public';
+import { CoreStart } from '@kbn/core/public';
import React from 'react';
-import { KibanaThemeProvider } from '@kbn/kibana-react-plugin/public';
+import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render';
import { useKibanaContextForPluginProvider } from '../hooks/use_kibana';
export interface CoreProvidersProps {
children?: React.ReactNode;
core: CoreStart;
- theme$: AppMountParameters['theme$'];
}
-export const CoreProviders: React.FC = ({ children, core, theme$ }) => {
+export const CoreProviders: React.FC = ({ children, core }) => {
const KibanaContextProviderForPlugin = useKibanaContextForPluginProvider(core);
return (
-
-
- {children}
-
-
+
+
+ {children}
+
+
);
};
diff --git a/x-pack/plugins/observability_solution/metrics_data_access/public/components/infrastructure_node_metrics_tables/container/create_lazy_container_metrics_table.tsx b/x-pack/plugins/observability_solution/metrics_data_access/public/components/infrastructure_node_metrics_tables/container/create_lazy_container_metrics_table.tsx
index d187c88bcbba50..4faa4320aa6a0a 100644
--- a/x-pack/plugins/observability_solution/metrics_data_access/public/components/infrastructure_node_metrics_tables/container/create_lazy_container_metrics_table.tsx
+++ b/x-pack/plugins/observability_solution/metrics_data_access/public/components/infrastructure_node_metrics_tables/container/create_lazy_container_metrics_table.tsx
@@ -21,7 +21,6 @@ export function createLazyContainerMetricsTable(core: CoreStart, metricsClient:
[coreProvidersPropsMock.core];
diff --git a/x-pack/plugins/observability_solution/metrics_data_access/public/test_utils/use_global_storybook_theme.tsx b/x-pack/plugins/observability_solution/metrics_data_access/public/test_utils/use_global_storybook_theme.tsx
index b813cfa563a0f9..7ed147138cce3a 100644
--- a/x-pack/plugins/observability_solution/metrics_data_access/public/test_utils/use_global_storybook_theme.tsx
+++ b/x-pack/plugins/observability_solution/metrics_data_access/public/test_utils/use_global_storybook_theme.tsx
@@ -10,7 +10,7 @@ import React, { useEffect, useMemo, useState } from 'react';
import { BehaviorSubject } from 'rxjs';
import type { CoreTheme } from '@kbn/core/public';
import { EuiThemeProvider } from '@kbn/kibana-react-plugin/common';
-import { KibanaThemeProvider } from '@kbn/kibana-react-plugin/public';
+import { KibanaThemeProvider } from '@kbn/react-kibana-context-theme';
type StoryContext = Parameters[1];
@@ -34,7 +34,7 @@ export const GlobalStorybookThemeProviders: React.FC<{
}> = ({ children, storyContext }) => {
const { theme, theme$ } = useGlobalStorybookTheme(storyContext);
return (
-
+
{children}
);
diff --git a/x-pack/plugins/observability_solution/metrics_data_access/tsconfig.json b/x-pack/plugins/observability_solution/metrics_data_access/tsconfig.json
index 7c6c2e1bd3d958..b2ba77bff9f379 100644
--- a/x-pack/plugins/observability_solution/metrics_data_access/tsconfig.json
+++ b/x-pack/plugins/observability_solution/metrics_data_access/tsconfig.json
@@ -34,6 +34,8 @@
"@kbn/i18n-react",
"@kbn/logging",
"@kbn/core-http-request-handler-context-server",
- "@kbn/lens-embeddable-utils"
+ "@kbn/lens-embeddable-utils",
+ "@kbn/react-kibana-context-render",
+ "@kbn/react-kibana-context-theme"
]
}
diff --git a/x-pack/plugins/observability_solution/observability/public/application/index.tsx b/x-pack/plugins/observability_solution/observability/public/application/index.tsx
index 1166755ca14572..9c56ec1bb54ecd 100644
--- a/x-pack/plugins/observability_solution/observability/public/application/index.tsx
+++ b/x-pack/plugins/observability_solution/observability/public/application/index.tsx
@@ -5,7 +5,6 @@
* 2.0.
*/
-import { EuiErrorBoundary } from '@elastic/eui';
import React from 'react';
import ReactDOM from 'react-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
@@ -13,6 +12,7 @@ import { i18n } from '@kbn/i18n';
import { Router, Routes, Route } from '@kbn/shared-ux-router';
import { AppMountParameters, APP_WRAPPER_CLASS, CoreStart } from '@kbn/core/public';
import { EuiThemeProvider } from '@kbn/kibana-react-plugin/common';
+import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render';
import type { LazyObservabilityPageTemplateProps } from '@kbn/observability-shared-plugin/public';
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
import { KibanaThemeProvider } from '@kbn/react-kibana-context-theme';
@@ -66,7 +66,6 @@ export const renderApp = ({
isServerless?: boolean;
}) => {
const { element, history, theme$ } = appMountParameters;
- const i18nCore = core.i18n;
const isDarkMode = core.theme.getTheme().darkMode;
core.chrome.setHelpExtension({
@@ -87,8 +86,8 @@ export const renderApp = ({
const PresentationContextProvider = plugins.presentationUtil?.ContextProvider ?? React.Fragment;
ReactDOM.render(
-
-
+
+
@@ -113,17 +112,15 @@ export const renderApp = ({
>
-
-
-
-
-
-
-
-
+
+
+
+
+
+
@@ -131,8 +128,8 @@ export const renderApp = ({
-
- ,
+
+ ,
element
);
return () => {
diff --git a/x-pack/plugins/observability_solution/observability/public/pages/overview/components/header_menu/header_menu.tsx b/x-pack/plugins/observability_solution/observability/public/pages/overview/components/header_menu/header_menu.tsx
index a7ca5fa38ffc3c..52ca08e40c031d 100644
--- a/x-pack/plugins/observability_solution/observability/public/pages/overview/components/header_menu/header_menu.tsx
+++ b/x-pack/plugins/observability_solution/observability/public/pages/overview/components/header_menu/header_menu.tsx
@@ -10,6 +10,7 @@ import { i18n } from '@kbn/i18n';
import React from 'react';
import { usePluginContext } from '../../../../hooks/use_plugin_context';
import { useKibana } from '../../../../utils/kibana_react';
+// FIXME: import { HeaderMenuPortal } from '@kbn/observability-shared-plugin/public'
import HeaderMenuPortal from './header_menu_portal';
export function HeaderMenu(): React.ReactElement | null {
diff --git a/x-pack/plugins/observability_solution/observability/public/test_utils/use_global_storybook_theme.tsx b/x-pack/plugins/observability_solution/observability/public/test_utils/use_global_storybook_theme.tsx
index 2930d9de22346e..ea94e2edb6f8be 100644
--- a/x-pack/plugins/observability_solution/observability/public/test_utils/use_global_storybook_theme.tsx
+++ b/x-pack/plugins/observability_solution/observability/public/test_utils/use_global_storybook_theme.tsx
@@ -10,7 +10,7 @@ import React, { useEffect, useMemo, useState } from 'react';
import { BehaviorSubject } from 'rxjs';
import type { CoreTheme } from '@kbn/core/public';
import { EuiThemeProvider } from '@kbn/kibana-react-plugin/common';
-import { KibanaThemeProvider } from '@kbn/kibana-react-plugin/public';
+import { KibanaThemeProvider } from '@kbn/react-kibana-context-theme';
type StoryContext = Parameters[1];
@@ -37,7 +37,7 @@ export function GlobalStorybookThemeProviders({
}) {
const { theme, theme$ } = useGlobalStorybookTheme(storyContext);
return (
-
+
{children}
);
diff --git a/x-pack/plugins/observability_solution/observability/tsconfig.json b/x-pack/plugins/observability_solution/observability/tsconfig.json
index fb045f31492dee..b4f617079dc58d 100644
--- a/x-pack/plugins/observability_solution/observability/tsconfig.json
+++ b/x-pack/plugins/observability_solution/observability/tsconfig.json
@@ -86,7 +86,6 @@
"@kbn/content-management-plugin",
"@kbn/deeplinks-observability",
"@kbn/core-application-common",
- "@kbn/react-kibana-mount",
"@kbn/react-kibana-context-theme",
"@kbn/shared-ux-link-redirect-app",
"@kbn/lens-embeddable-utils",
@@ -99,6 +98,8 @@
"@kbn/data-view-field-editor-plugin",
"@kbn/cases-components",
"@kbn/aiops-log-rate-analysis",
+ "@kbn/react-kibana-context-render",
+ "@kbn/react-kibana-mount",
],
"exclude": [
"target/**/*"
diff --git a/x-pack/plugins/observability_solution/observability_onboarding/public/application/app.tsx b/x-pack/plugins/observability_solution/observability_onboarding/public/application/app.tsx
index 5c0bce9030aa7e..2b928b37f471b9 100644
--- a/x-pack/plugins/observability_solution/observability_onboarding/public/application/app.tsx
+++ b/x-pack/plugins/observability_solution/observability_onboarding/public/application/app.tsx
@@ -8,7 +8,9 @@
import { EuiErrorBoundary } from '@elastic/eui';
import { AppMountParameters, APP_WRAPPER_CLASS, CoreStart } from '@kbn/core/public';
import { i18n } from '@kbn/i18n';
-import { KibanaContextProvider, KibanaThemeProvider } from '@kbn/kibana-react-plugin/public';
+import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
+import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render';
+import { KibanaThemeProvider } from '@kbn/react-kibana-context-theme';
import { RedirectAppLinks } from '@kbn/shared-ux-link-redirect-app';
import { HeaderMenuPortal } from '@kbn/observability-shared-plugin/public';
import { Router } from '@kbn/shared-ux-router';
@@ -45,7 +47,6 @@ export function ObservabilityOnboardingAppRoot({
appMountParameters: AppMountParameters;
} & RenderAppProps) {
const { history, setHeaderActionMenu, theme$ } = appMountParameters;
- const i18nCore = core.i18n;
const plugins = { ...deps };
const renderFeedbackLinkAsPortal = !config.serverless.enabled;
@@ -55,31 +56,31 @@ export function ObservabilityOnboardingAppRoot({
});
return (
-
-
-
+
+
-
-
+
{renderFeedbackLinkAsPortal && (
@@ -90,11 +91,11 @@ export function ObservabilityOnboardingAppRoot({
-
-
-
-
-
+
+
+
+
+
);
}
diff --git a/x-pack/plugins/observability_solution/observability_onboarding/tsconfig.json b/x-pack/plugins/observability_solution/observability_onboarding/tsconfig.json
index b03e6d6ffe6d59..eb31601928b877 100644
--- a/x-pack/plugins/observability_solution/observability_onboarding/tsconfig.json
+++ b/x-pack/plugins/observability_solution/observability_onboarding/tsconfig.json
@@ -36,7 +36,9 @@
"@kbn/shared-ux-link-redirect-app",
"@kbn/cloud-experiments-plugin",
"@kbn/home-sample-data-tab",
- "@kbn/analytics-client"
+ "@kbn/analytics-client",
+ "@kbn/react-kibana-context-render",
+ "@kbn/react-kibana-context-theme"
],
"exclude": ["target/**/*"]
}
diff --git a/x-pack/plugins/observability_solution/observability_shared/public/components/header_menu/header_menu_portal.tsx b/x-pack/plugins/observability_solution/observability_shared/public/components/header_menu/header_menu_portal.tsx
index 4f7c4bb817e3e7..fcd71e1e75d420 100644
--- a/x-pack/plugins/observability_solution/observability_shared/public/components/header_menu/header_menu_portal.tsx
+++ b/x-pack/plugins/observability_solution/observability_shared/public/components/header_menu/header_menu_portal.tsx
@@ -7,6 +7,7 @@
import React, { useEffect, useMemo } from 'react';
import { createHtmlPortalNode, InPortal, OutPortal } from 'react-reverse-portal';
+// FIXME use import { toMountPoint } from '@kbn/react-kibana-mount';
import { toMountPoint } from '@kbn/kibana-react-plugin/public';
import type { HeaderMenuPortalProps } from '../../types';
diff --git a/x-pack/plugins/observability_solution/slo/common/constants.ts b/x-pack/plugins/observability_solution/slo/common/constants.ts
index 1cf872bd0a9184..db0c91ab0ef4bd 100644
--- a/x-pack/plugins/observability_solution/slo/common/constants.ts
+++ b/x-pack/plugins/observability_solution/slo/common/constants.ts
@@ -88,3 +88,6 @@ export const getSLOSummaryPipelineId = (sloId: string, sloRevision: number) =>
export const SYNTHETICS_INDEX_PATTERN = 'synthetics-*';
export const SYNTHETICS_DEFAULT_GROUPINGS = ['monitor.name', 'observer.geo.name', 'monitor.id'];
+
+// in hours
+export const DEFAULT_STALE_SLO_THRESHOLD_HOURS = 48;
diff --git a/x-pack/plugins/observability_solution/slo/common/summary_indices.test.ts b/x-pack/plugins/observability_solution/slo/common/summary_indices.test.ts
index d55107dfcd8028..64f9ad0d445400 100644
--- a/x-pack/plugins/observability_solution/slo/common/summary_indices.test.ts
+++ b/x-pack/plugins/observability_solution/slo/common/summary_indices.test.ts
@@ -6,13 +6,17 @@
*/
import { getListOfSloSummaryIndices } from './summary_indices';
-import { SLO_SUMMARY_DESTINATION_INDEX_PATTERN } from './constants';
+import {
+ DEFAULT_STALE_SLO_THRESHOLD_HOURS,
+ SLO_SUMMARY_DESTINATION_INDEX_PATTERN,
+} from './constants';
describe('getListOfSloSummaryIndices', () => {
it('should return default index if disabled', function () {
const settings = {
useAllRemoteClusters: false,
selectedRemoteClusters: [],
+ staleThresholdInHours: DEFAULT_STALE_SLO_THRESHOLD_HOURS,
};
const result = getListOfSloSummaryIndices(settings, []);
expect(result).toBe(SLO_SUMMARY_DESTINATION_INDEX_PATTERN);
@@ -22,6 +26,7 @@ describe('getListOfSloSummaryIndices', () => {
const settings = {
useAllRemoteClusters: true,
selectedRemoteClusters: [],
+ staleThresholdInHours: DEFAULT_STALE_SLO_THRESHOLD_HOURS,
};
const clustersByName = [
{ name: 'cluster1', isConnected: true },
@@ -37,6 +42,7 @@ describe('getListOfSloSummaryIndices', () => {
const settings = {
useAllRemoteClusters: false,
selectedRemoteClusters: ['cluster1'],
+ staleThresholdInHours: DEFAULT_STALE_SLO_THRESHOLD_HOURS,
};
const clustersByName = [
{ name: 'cluster1', isConnected: true },
diff --git a/x-pack/plugins/observability_solution/slo/docs/openapi/slo/paths/s@{spaceid}@api@slos.yaml b/x-pack/plugins/observability_solution/slo/docs/openapi/slo/paths/s@{spaceid}@api@slos.yaml
index 782e8fb477f94a..0b6cd584ed3f30 100644
--- a/x-pack/plugins/observability_solution/slo/docs/openapi/slo/paths/s@{spaceid}@api@slos.yaml
+++ b/x-pack/plugins/observability_solution/slo/docs/openapi/slo/paths/s@{spaceid}@api@slos.yaml
@@ -97,6 +97,11 @@ get:
enum: [asc, desc]
default: asc
example: asc
+ - name: hideStale
+ in: query
+ description: Hide stale SLOs from the list as defined by stale SLO threshold in SLO settings
+ schema:
+ type: boolean
responses:
'200':
description: Successful request
diff --git a/x-pack/plugins/observability_solution/slo/public/application.tsx b/x-pack/plugins/observability_solution/slo/public/application.tsx
index 9cf739f5e543a2..aedfba1b1eadae 100644
--- a/x-pack/plugins/observability_solution/slo/public/application.tsx
+++ b/x-pack/plugins/observability_solution/slo/public/application.tsx
@@ -7,13 +7,13 @@
import React from 'react';
import ReactDOM from 'react-dom';
-import { EuiErrorBoundary } from '@elastic/eui';
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
import { KibanaThemeProvider } from '@kbn/react-kibana-context-theme';
import { AppMountParameters, APP_WRAPPER_CLASS, CoreStart } from '@kbn/core/public';
import type { LazyObservabilityPageTemplateProps } from '@kbn/observability-shared-plugin/public';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { Router, Routes, Route } from '@kbn/shared-ux-router';
+import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render';
import { EuiThemeProvider } from '@kbn/kibana-react-plugin/common';
import { RedirectAppLinks } from '@kbn/shared-ux-link-redirect-app';
import { UsageCollectionSetup } from '@kbn/usage-collection-plugin/public';
@@ -72,7 +72,6 @@ export const renderApp = ({
experimentalFeatures: ExperimentalFeatures;
}) => {
const { element, history, theme$ } = appMountParameters;
- const i18nCore = core.i18n;
const isDarkMode = core.theme.getTheme().darkMode;
// ensure all divs are .kbnAppWrappers
@@ -110,8 +109,8 @@ export const renderApp = ({
});
ReactDOM.render(
-
-
+
+
@@ -137,16 +136,14 @@ export const renderApp = ({
>
-
-
-
-
-
-
-
+
+
+
+
+
@@ -154,8 +151,8 @@ export const renderApp = ({
-
- ,
+
+ ,
element
);
diff --git a/x-pack/plugins/observability_solution/slo/public/context/plugin_context.tsx b/x-pack/plugins/observability_solution/slo/public/context/plugin_context.tsx
index 1f8d6f25e09619..9bdfcb2a9d0e4a 100644
--- a/x-pack/plugins/observability_solution/slo/public/context/plugin_context.tsx
+++ b/x-pack/plugins/observability_solution/slo/public/context/plugin_context.tsx
@@ -14,7 +14,7 @@ import { ExperimentalFeatures } from '../../common/config';
export interface PluginContextValue {
isDev?: boolean;
isServerless?: boolean;
- appMountParameters?: AppMountParameters;
+ appMountParameters: AppMountParameters;
observabilityRuleTypeRegistry: ObservabilityRuleTypeRegistry;
ObservabilityPageTemplate: React.ComponentType;
experimentalFeatures?: ExperimentalFeatures;
@@ -24,6 +24,6 @@ export interface OverviewEmbeddableContextValue {
observabilityRuleTypeRegistry: ObservabilityRuleTypeRegistry;
}
-export const PluginContext = createContext(
- {} as PluginContextValue | OverviewEmbeddableContextValue
-);
+export const OverviewEmbeddableContext = createContext(null);
+
+export const PluginContext = createContext(null);
diff --git a/x-pack/plugins/observability_solution/slo/public/embeddable/slo/alerts/handle_explicit_input.tsx b/x-pack/plugins/observability_solution/slo/public/embeddable/slo/alerts/handle_explicit_input.tsx
index 0ead7cd58229ee..54af69acbe13d3 100644
--- a/x-pack/plugins/observability_solution/slo/public/embeddable/slo/alerts/handle_explicit_input.tsx
+++ b/x-pack/plugins/observability_solution/slo/public/embeddable/slo/alerts/handle_explicit_input.tsx
@@ -47,7 +47,7 @@ export async function resolveEmbeddableSloUserInput(
/>
,
- { i18n: coreStart.i18n, theme: coreStart.theme }
+ coreStart
)
);
} catch (error) {
diff --git a/x-pack/plugins/observability_solution/slo/public/embeddable/slo/error_budget/error_budget_open_configuration.tsx b/x-pack/plugins/observability_solution/slo/public/embeddable/slo/error_budget/error_budget_open_configuration.tsx
index 10b65cf5c342da..fbda743a951b2c 100644
--- a/x-pack/plugins/observability_solution/slo/public/embeddable/slo/error_budget/error_budget_open_configuration.tsx
+++ b/x-pack/plugins/observability_solution/slo/public/embeddable/slo/error_budget/error_budget_open_configuration.tsx
@@ -42,7 +42,7 @@ export async function openSloConfiguration(
/>
,
- { i18n: coreStart.i18n, theme: coreStart.theme }
+ coreStart
)
);
} catch (error) {
diff --git a/x-pack/plugins/observability_solution/slo/public/embeddable/slo/overview/slo_embeddable_factory.tsx b/x-pack/plugins/observability_solution/slo/public/embeddable/slo/overview/slo_embeddable_factory.tsx
index a4f26679dd1dde..e0cf5d0b138371 100644
--- a/x-pack/plugins/observability_solution/slo/public/embeddable/slo/overview/slo_embeddable_factory.tsx
+++ b/x-pack/plugins/observability_solution/slo/public/embeddable/slo/overview/slo_embeddable_factory.tsx
@@ -34,7 +34,7 @@ import {
GroupSloCustomInput,
} from './types';
import { EDIT_SLO_OVERVIEW_ACTION } from '../../../ui_actions/edit_slo_overview_panel';
-import { PluginContext } from '../../../context/plugin_context';
+import { OverviewEmbeddableContext } from '../../../context/plugin_context';
const queryClient = new QueryClient();
export const getOverviewPanelTitle = () =>
@@ -191,7 +191,7 @@ export const getOverviewEmbeddableFactory = (deps: SloEmbeddableDeps) => {
-
+
{showAllGroupByInstances ? (
@@ -199,7 +199,7 @@ export const getOverviewEmbeddableFactory = (deps: SloEmbeddableDeps) => {
renderOverview()
)}
-
+
diff --git a/x-pack/plugins/observability_solution/slo/public/embeddable/slo/overview/slo_overview_open_configuration.tsx b/x-pack/plugins/observability_solution/slo/public/embeddable/slo/overview/slo_overview_open_configuration.tsx
index fa0f9992d718eb..df2edd134423de 100644
--- a/x-pack/plugins/observability_solution/slo/public/embeddable/slo/overview/slo_overview_open_configuration.tsx
+++ b/x-pack/plugins/observability_solution/slo/public/embeddable/slo/overview/slo_overview_open_configuration.tsx
@@ -44,7 +44,7 @@ export async function openSloConfiguration(
/>
,
- { i18n: coreStart.i18n, theme: coreStart.theme }
+ coreStart
)
);
} catch (error) {
diff --git a/x-pack/plugins/observability_solution/slo/public/hooks/query_key_factory.ts b/x-pack/plugins/observability_solution/slo/public/hooks/query_key_factory.ts
index dd8f3c49a4b43d..a493a9cc27066b 100644
--- a/x-pack/plugins/observability_solution/slo/public/hooks/query_key_factory.ts
+++ b/x-pack/plugins/observability_solution/slo/public/hooks/query_key_factory.ts
@@ -27,12 +27,19 @@ interface SloGroupListFilter {
groupsFilter?: string[];
}
+interface SLOOverviewFilter {
+ kqlQuery: string;
+ filters: string;
+ lastRefresh?: number;
+}
+
export const sloKeys = {
all: ['slo'] as const,
lists: () => [...sloKeys.all, 'list'] as const,
list: (filters: SloListFilter) => [...sloKeys.lists(), filters] as const,
group: (filters: SloGroupListFilter) => [...sloKeys.groups(), filters] as const,
groups: () => [...sloKeys.all, 'group'] as const,
+ overview: (filters: SLOOverviewFilter) => ['overview', filters] as const,
details: () => [...sloKeys.all, 'details'] as const,
detail: (sloId?: string) => [...sloKeys.details(), sloId] as const,
rules: () => [...sloKeys.all, 'rules'] as const,
diff --git a/x-pack/plugins/observability_solution/slo/public/hooks/use_alerts_url.ts b/x-pack/plugins/observability_solution/slo/public/hooks/use_alerts_url.ts
new file mode 100644
index 00000000000000..fa97a222043726
--- /dev/null
+++ b/x-pack/plugins/observability_solution/slo/public/hooks/use_alerts_url.ts
@@ -0,0 +1,24 @@
+/*
+ * 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 { observabilityPaths } from '@kbn/observability-plugin/common';
+import rison from '@kbn/rison';
+import { useKibana } from '../utils/kibana_react';
+
+export const useAlertsUrl = () => {
+ const { basePath } = useKibana().services.http;
+
+ const kuery = 'kibana.alert.rule.rule_type_id:("slo.rules.burnRate")';
+
+ return (status?: 'active' | 'recovered') =>
+ `${basePath.prepend(observabilityPaths.alerts)}?_a=${rison.encode({
+ kuery,
+ rangeFrom: 'now-24h',
+ rangeTo: 'now',
+ status,
+ })}`;
+};
diff --git a/x-pack/plugins/observability_solution/slo/public/hooks/use_fetch_slo_list.ts b/x-pack/plugins/observability_solution/slo/public/hooks/use_fetch_slo_list.ts
index 27bd77a869c86c..289c8e5cbd4187 100644
--- a/x-pack/plugins/observability_solution/slo/public/hooks/use_fetch_slo_list.ts
+++ b/x-pack/plugins/observability_solution/slo/public/hooks/use_fetch_slo_list.ts
@@ -103,6 +103,7 @@ export function useFetchSloList({
...(page !== undefined && { page }),
...(perPage !== undefined && { perPage }),
...(filters && { filters }),
+ hideStale: true,
},
signal,
});
diff --git a/x-pack/plugins/observability_solution/slo/public/hooks/use_plugin_context.tsx b/x-pack/plugins/observability_solution/slo/public/hooks/use_plugin_context.tsx
index f9a302ff64aa70..d0640deb575b21 100644
--- a/x-pack/plugins/observability_solution/slo/public/hooks/use_plugin_context.tsx
+++ b/x-pack/plugins/observability_solution/slo/public/hooks/use_plugin_context.tsx
@@ -9,6 +9,11 @@ import { useContext } from 'react';
import { PluginContext } from '../context/plugin_context';
import type { PluginContextValue } from '../context/plugin_context';
-export function usePluginContext() {
- return useContext(PluginContext) as PluginContextValue;
+export function usePluginContext(): PluginContextValue {
+ const context = useContext(PluginContext);
+ if (!context) {
+ throw new Error('Plugin context value is missing!');
+ }
+
+ return context;
}
diff --git a/x-pack/plugins/observability_solution/slo/public/pages/slo_edit/shared_flyout/get_create_slo_flyout.tsx b/x-pack/plugins/observability_solution/slo/public/pages/slo_edit/shared_flyout/get_create_slo_flyout.tsx
index d73144d089ffd7..265b063928fda7 100644
--- a/x-pack/plugins/observability_solution/slo/public/pages/slo_edit/shared_flyout/get_create_slo_flyout.tsx
+++ b/x-pack/plugins/observability_solution/slo/public/pages/slo_edit/shared_flyout/get_create_slo_flyout.tsx
@@ -9,7 +9,7 @@ import React from 'react';
import { QueryClientProvider } from '@tanstack/react-query';
import { QueryClient } from '@tanstack/react-query';
import { Storage } from '@kbn/kibana-utils-plugin/public';
-import { CoreStart } from '@kbn/core-lifecycle-browser';
+import { AppMountParameters, CoreStart } from '@kbn/core/public';
import { LazyObservabilityPageTemplateProps } from '@kbn/observability-shared-plugin/public';
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
import { RecursivePartial } from '@kbn/utility-types';
@@ -23,6 +23,7 @@ import { SloAddFormFlyout } from './slo_form';
export const getCreateSLOFlyoutLazy = ({
core,
plugins,
+ getAppMountParameters,
observabilityRuleTypeRegistry,
ObservabilityPageTemplate,
isDev,
@@ -32,6 +33,7 @@ export const getCreateSLOFlyoutLazy = ({
}: {
core: CoreStart;
plugins: SloPublicPluginsStart;
+ getAppMountParameters: () => Promise;
observabilityRuleTypeRegistry: ObservabilityRuleTypeRegistry;
ObservabilityPageTemplate: React.ComponentType;
isDev?: boolean;
@@ -39,7 +41,7 @@ export const getCreateSLOFlyoutLazy = ({
isServerless?: boolean;
experimentalFeatures: ExperimentalFeatures;
}) => {
- return ({
+ return async ({
onClose,
initialValues,
}: {
@@ -47,6 +49,7 @@ export const getCreateSLOFlyoutLazy = ({
initialValues?: RecursivePartial;
}) => {
const queryClient = new QueryClient();
+ const appMountParameters = await getAppMountParameters();
return (
diff --git a/x-pack/plugins/observability_solution/slo/public/pages/slo_settings/settings_form.tsx b/x-pack/plugins/observability_solution/slo/public/pages/slo_settings/settings_form.tsx
index 86a2597c2188ae..895ca2c0f8e2b1 100644
--- a/x-pack/plugins/observability_solution/slo/public/pages/slo_settings/settings_form.tsx
+++ b/x-pack/plugins/observability_solution/slo/public/pages/slo_settings/settings_form.tsx
@@ -17,22 +17,27 @@ import {
EuiButtonEmpty,
EuiButton,
EuiSpacer,
+ EuiFieldNumber,
} from '@elastic/eui';
import React, { useEffect, useState } from 'react';
import { useFetcher } from '@kbn/observability-shared-plugin/public';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import { isEqual } from 'lodash';
+import { DEFAULT_STALE_SLO_THRESHOLD_HOURS } from '../../../common/constants';
import { useGetSettings } from './use_get_settings';
import { usePutSloSettings } from './use_put_slo_settings';
export function SettingsForm() {
const [useAllRemoteClusters, setUseAllRemoteClusters] = useState(false);
const [selectedRemoteClusters, setSelectedRemoteClusters] = useState([]);
+ const [staleThresholdInHours, setStaleThresholdInHours] = useState(
+ DEFAULT_STALE_SLO_THRESHOLD_HOURS
+ );
const { http } = useKibana().services;
const { data: currentSettings } = useGetSettings();
- const { mutateAsync: updateSettings } = usePutSloSettings();
+ const { mutateAsync: updateSettings, isLoading: isUpdating } = usePutSloSettings();
const { data, loading } = useFetcher(() => {
return http?.get>('/api/remote_clusters');
@@ -42,6 +47,7 @@ export function SettingsForm() {
if (currentSettings) {
setUseAllRemoteClusters(currentSettings.useAllRemoteClusters);
setSelectedRemoteClusters(currentSettings.selectedRemoteClusters);
+ setStaleThresholdInHours(currentSettings.staleThresholdInHours);
}
}, [currentSettings]);
@@ -50,6 +56,7 @@ export function SettingsForm() {
settings: {
useAllRemoteClusters,
selectedRemoteClusters,
+ staleThresholdInHours,
},
});
};
@@ -119,18 +126,57 @@ export function SettingsForm() {
/>
+
+
+ {i18n.translate('xpack.slo.settingsForm.h3.staleThresholdLabel', {
+ defaultMessage: 'Stale SLOs threshold',
+ })}
+
+ }
+ description={
+
+ {i18n.translate('xpack.slo.settingsForm.select.staleThresholdLabel', {
+ defaultMessage:
+ 'SLOs not updated within the defined stale threshold will be hidden by default from the overview list.',
+ })}
+
+ }
+ >
+
+ {
+ setStaleThresholdInHours(Number(evt.target.value));
+ }}
+ append={i18n.translate('xpack.slo.settingsForm.euiFormRow.select.hours', {
+ defaultMessage: 'Hours',
+ })}
+ />
+
+
{
setUseAllRemoteClusters(currentSettings?.useAllRemoteClusters || false);
setSelectedRemoteClusters(currentSettings?.selectedRemoteClusters || []);
+ setStaleThresholdInHours(
+ currentSettings?.staleThresholdInHours ?? DEFAULT_STALE_SLO_THRESHOLD_HOURS
+ );
}}
isDisabled={isEqual(currentSettings, {
useAllRemoteClusters,
selectedRemoteClusters,
+ staleThresholdInHours,
})}
>
{i18n.translate('xpack.slo.settingsForm.euiButtonEmpty.cancelLabel', {
@@ -140,7 +186,7 @@ export function SettingsForm() {
{i18n.translate('xpack.slo.settingsForm.applyButtonEmptyLabel', {
diff --git a/x-pack/plugins/observability_solution/slo/public/pages/slo_settings/use_get_settings.ts b/x-pack/plugins/observability_solution/slo/public/pages/slo_settings/use_get_settings.ts
index aa30659d85d3b3..88d38bc7f936dd 100644
--- a/x-pack/plugins/observability_solution/slo/public/pages/slo_settings/use_get_settings.ts
+++ b/x-pack/plugins/observability_solution/slo/public/pages/slo_settings/use_get_settings.ts
@@ -7,6 +7,7 @@
import { GetSLOSettingsResponse } from '@kbn/slo-schema';
import { useQuery } from '@tanstack/react-query';
+import { DEFAULT_STALE_SLO_THRESHOLD_HOURS } from '../../../common/constants';
import { useKibana } from '../../utils/kibana_react';
export const useGetSettings = () => {
@@ -30,4 +31,5 @@ export const useGetSettings = () => {
const defaultSettings: GetSLOSettingsResponse = {
useAllRemoteClusters: false,
selectedRemoteClusters: [],
+ staleThresholdInHours: DEFAULT_STALE_SLO_THRESHOLD_HOURS,
};
diff --git a/x-pack/plugins/observability_solution/slo/public/pages/slos/components/card_view/slo_card_item.tsx b/x-pack/plugins/observability_solution/slo/public/pages/slos/components/card_view/slo_card_item.tsx
index c6f6e1834ff83e..deb2a1b880ad71 100644
--- a/x-pack/plugins/observability_solution/slo/public/pages/slos/components/card_view/slo_card_item.tsx
+++ b/x-pack/plugins/observability_solution/slo/public/pages/slos/components/card_view/slo_card_item.tsx
@@ -23,6 +23,7 @@ import {
} from '@kbn/presentation-util-plugin/public';
import { ALL_VALUE, HistoricalSummaryResponse, SLOWithSummaryResponse } from '@kbn/slo-schema';
import { Rule } from '@kbn/triggers-actions-ui-plugin/public';
+import moment from 'moment';
import React, { useState } from 'react';
import { SloDeleteModal } from '../../../../components/slo/delete_confirmation_modal/slo_delete_confirmation_modal';
import { SloResetConfirmationModal } from '../../../../components/slo/reset_confirmation_modal/slo_reset_confirmation_modal';
@@ -124,7 +125,17 @@ export function SloCardItem({ slo, rules, activeAlerts, historicalSummary, refet
overflow: hidden;
position: relative;
`}
- title={slo.summary.status}
+ title={
+ slo.summary.summaryUpdatedAt
+ ? i18n.translate('xpack.slo.sloCardItem.euiPanel.lastUpdatedLabel', {
+ defaultMessage: '{status}, Last updated: {value}',
+ values: {
+ status: slo.summary.status,
+ value: moment(slo.summary.summaryUpdatedAt).fromNow(),
+ },
+ })
+ : slo.summary.status
+ }
>
{
+ onStateChange({
+ kqlQuery: '',
+ filters: [],
+ tagsFilter: undefined,
+ statusFilter: undefined,
+ });
+ }}
+ color="primary"
+ >
+ {i18n.translate('xpack.slo.sloListEmpty.clearFiltersButtonLabel', {
+ defaultMessage: 'Clear filters',
+ })}
+
);
}
diff --git a/x-pack/plugins/observability_solution/slo/public/pages/slos/components/slos_overview/overview_item.tsx b/x-pack/plugins/observability_solution/slo/public/pages/slos/components/slos_overview/overview_item.tsx
new file mode 100644
index 00000000000000..d26eea29f996ca
--- /dev/null
+++ b/x-pack/plugins/observability_solution/slo/public/pages/slos/components/slos_overview/overview_item.tsx
@@ -0,0 +1,56 @@
+/*
+ * 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 { EuiFlexItem, EuiStat, EuiToolTip } from '@elastic/eui';
+import React from 'react';
+import { useUrlSearchState } from '../../hooks/use_url_search_state';
+
+export function OverViewItem({
+ title,
+ description,
+ titleColor,
+ isLoading,
+ query,
+ tooltip,
+ onClick,
+}: {
+ title?: string | number;
+ description: string;
+ titleColor: string;
+ isLoading: boolean;
+ query?: string;
+ tooltip?: string;
+ onClick?: () => void;
+}) {
+ const { onStateChange } = useUrlSearchState();
+
+ return (
+
+
+ {
+ if (onClick) {
+ onClick();
+ return;
+ }
+ onStateChange({
+ kqlQuery: query,
+ });
+ }}
+ css={{
+ cursor: 'pointer',
+ }}
+ />
+
+
+ );
+}
diff --git a/x-pack/plugins/observability_solution/slo/public/pages/slos/components/slos_overview/slo_overview_alerts.tsx b/x-pack/plugins/observability_solution/slo/public/pages/slos/components/slos_overview/slo_overview_alerts.tsx
new file mode 100644
index 00000000000000..808c9096a37934
--- /dev/null
+++ b/x-pack/plugins/observability_solution/slo/public/pages/slos/components/slos_overview/slo_overview_alerts.tsx
@@ -0,0 +1,96 @@
+/*
+ * 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 { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiText, EuiTitle, EuiPanel } from '@elastic/eui';
+import { i18n } from '@kbn/i18n';
+import React from 'react';
+import { GetOverviewResponse } from '@kbn/slo-schema/src/rest_specs/routes/get_overview';
+import { rulesLocatorID, RulesParams } from '@kbn/observability-plugin/public';
+import { useAlertsUrl } from '../../../../hooks/use_alerts_url';
+import { useKibana } from '../../../../utils/kibana_react';
+import { OverViewItem } from './overview_item';
+
+export function SLOOverviewAlerts({
+ data,
+ isLoading,
+}: {
+ data?: GetOverviewResponse;
+ isLoading: boolean;
+}) {
+ const {
+ application,
+ share: {
+ url: { locators },
+ },
+ } = useKibana().services;
+
+ const locator = locators.get(rulesLocatorID);
+
+ const getAlertsUrl = useAlertsUrl();
+
+ return (
+
+
+
+
+
+ {i18n.translate('xpack.slo.sLOsOverview.h3.burnRateLabel', {
+ defaultMessage: 'Burn rate',
+ })}
+
+
+
+
+
+ {i18n.translate('xpack.slo.sLOsOverview.lastTextLabel', {
+ defaultMessage: 'Last 24h',
+ })}
+
+
+
+
+
+
+ {
+ application.navigateToUrl(getAlertsUrl('active'));
+ }}
+ />
+ {
+ application.navigateToUrl(getAlertsUrl('recovered'));
+ }}
+ />
+ {
+ locator?.navigate({
+ type: ['slo.rules.burnRate'],
+ });
+ }}
+ />
+
+
+ );
+}
diff --git a/x-pack/plugins/observability_solution/slo/public/pages/slos/components/slos_overview/slos_overview.tsx b/x-pack/plugins/observability_solution/slo/public/pages/slos/components/slos_overview/slos_overview.tsx
new file mode 100644
index 00000000000000..c3c234e0f98a5d
--- /dev/null
+++ b/x-pack/plugins/observability_solution/slo/public/pages/slos/components/slos_overview/slos_overview.tsx
@@ -0,0 +1,127 @@
+/*
+ * 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 {
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiPanel,
+ EuiSpacer,
+ EuiTitle,
+ useEuiTheme,
+} from '@elastic/eui';
+import React from 'react';
+import { i18n } from '@kbn/i18n';
+import { DEFAULT_STALE_SLO_THRESHOLD_HOURS } from '../../../../../common/constants';
+import { SLOOverviewAlerts } from './slo_overview_alerts';
+import { useGetSettings } from '../../../slo_settings/use_get_settings';
+import { useFetchSLOsOverview } from '../../hooks/use_fetch_slos_overview';
+import { useUrlSearchState } from '../../hooks/use_url_search_state';
+import { OverViewItem } from './overview_item';
+
+export function SLOsOverview() {
+ const { state } = useUrlSearchState();
+ const { kqlQuery, filters, tagsFilter, statusFilter } = state;
+
+ const { data, isLoading } = useFetchSLOsOverview({
+ kqlQuery,
+ filters,
+ tagsFilter,
+ statusFilter,
+ });
+
+ const theme = useEuiTheme().euiTheme;
+ const { data: currentSettings } = useGetSettings();
+
+ return (
+
+
+
+
+
+ {i18n.translate('xpack.slo.sLOsOverview.h3.overviewLabel', {
+ defaultMessage: 'Overview',
+ })}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/x-pack/plugins/observability_solution/slo/public/pages/slos/hooks/use_fetch_slos_overview.ts b/x-pack/plugins/observability_solution/slo/public/pages/slos/hooks/use_fetch_slos_overview.ts
new file mode 100644
index 00000000000000..783c23d49a42ba
--- /dev/null
+++ b/x-pack/plugins/observability_solution/slo/public/pages/slos/hooks/use_fetch_slos_overview.ts
@@ -0,0 +1,108 @@
+/*
+ * 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 { useQuery } from '@tanstack/react-query';
+import { i18n } from '@kbn/i18n';
+import { buildQueryFromFilters, Filter } from '@kbn/es-query';
+import { useMemo } from 'react';
+import { GetOverviewResponse } from '@kbn/slo-schema/src/rest_specs/routes/get_overview';
+import { sloKeys } from '../../../hooks/query_key_factory';
+import { SLO_SUMMARY_DESTINATION_INDEX_PATTERN } from '../../../../common/constants';
+import { useCreateDataView } from '../../../hooks/use_create_data_view';
+import { useKibana } from '../../../utils/kibana_react';
+import { SearchState } from './use_url_search_state';
+
+interface SLOsOverviewParams {
+ kqlQuery?: string;
+ tagsFilter?: SearchState['tagsFilter'];
+ statusFilter?: SearchState['statusFilter'];
+ filters?: Filter[];
+ lastRefresh?: number;
+}
+
+interface UseFetchSloGroupsResponse {
+ isLoading: boolean;
+ isRefetching: boolean;
+ isSuccess: boolean;
+ isError: boolean;
+ data: GetOverviewResponse | undefined;
+}
+
+export function useFetchSLOsOverview({
+ kqlQuery = '',
+ tagsFilter,
+ statusFilter,
+ filters: filterDSL = [],
+ lastRefresh,
+}: SLOsOverviewParams = {}): UseFetchSloGroupsResponse {
+ const {
+ http,
+ notifications: { toasts },
+ } = useKibana().services;
+
+ const { dataView } = useCreateDataView({
+ indexPatternString: SLO_SUMMARY_DESTINATION_INDEX_PATTERN,
+ });
+
+ const filters = useMemo(() => {
+ try {
+ return JSON.stringify(
+ buildQueryFromFilters(
+ [
+ ...filterDSL,
+ ...(tagsFilter ? [tagsFilter] : []),
+ ...(statusFilter ? [statusFilter] : []),
+ ],
+ dataView,
+ {
+ ignoreFilterIfFieldNotInIndex: true,
+ }
+ )
+ );
+ } catch (e) {
+ return '';
+ }
+ }, [filterDSL, tagsFilter, statusFilter, dataView]);
+ const { data, isLoading, isSuccess, isError, isRefetching } = useQuery({
+ queryKey: sloKeys.overview({
+ kqlQuery,
+ filters,
+ lastRefresh,
+ }),
+ queryFn: async ({ signal }) => {
+ return await http.get('/internal/observability/slos/overview', {
+ query: {
+ ...(kqlQuery && { kqlQuery }),
+ ...(filters && { filters }),
+ },
+ signal,
+ });
+ },
+ cacheTime: 0,
+ refetchOnWindowFocus: false,
+ retry: (failureCount, error) => {
+ if (String(error) === 'Error: Forbidden') {
+ return false;
+ }
+ return failureCount < 4;
+ },
+ onError: (error: Error) => {
+ toasts.addError(error, {
+ title: i18n.translate('xpack.slo.overview.list.errorNotification', {
+ defaultMessage: 'Something went wrong while fetching SLOs overview',
+ }),
+ });
+ },
+ });
+
+ return {
+ data,
+ isLoading,
+ isSuccess,
+ isError,
+ isRefetching,
+ };
+}
diff --git a/x-pack/plugins/observability_solution/slo/public/pages/slos/slos.tsx b/x-pack/plugins/observability_solution/slo/public/pages/slos/slos.tsx
index 5ec9b84dddb29a..fdb40e12ba4e27 100644
--- a/x-pack/plugins/observability_solution/slo/public/pages/slos/slos.tsx
+++ b/x-pack/plugins/observability_solution/slo/public/pages/slos/slos.tsx
@@ -6,8 +6,10 @@
*/
import { i18n } from '@kbn/i18n';
-import { useBreadcrumbs } from '@kbn/observability-shared-plugin/public';
import React, { useEffect } from 'react';
+import { EuiSpacer } from '@elastic/eui';
+import { useBreadcrumbs } from '@kbn/observability-shared-plugin/public';
+import { SLOsOverview } from './components/slos_overview/slos_overview';
import { paths } from '../../../common/locators/paths';
import { HeaderMenu } from '../../components/header_menu/header_menu';
import { SloOutdatedCallout } from '../../components/slo/slo_outdated_callout';
@@ -66,6 +68,8 @@ export function SlosPage() {
>
+
+
);
diff --git a/x-pack/plugins/observability_solution/slo/public/plugin.ts b/x-pack/plugins/observability_solution/slo/public/plugin.ts
index 5b25f97742c86c..99acdbbc733551 100644
--- a/x-pack/plugins/observability_solution/slo/public/plugin.ts
+++ b/x-pack/plugins/observability_solution/slo/public/plugin.ts
@@ -14,7 +14,7 @@ import {
Plugin,
PluginInitializerContext,
} from '@kbn/core/public';
-import { BehaviorSubject, firstValueFrom } from 'rxjs';
+import { BehaviorSubject, Subject, firstValueFrom } from 'rxjs';
import { SloPublicPluginsSetup, SloPublicPluginsStart } from './types';
import { PLUGIN_NAME, sloAppId } from '../common';
import type { SloPublicSetup, SloPublicStart } from './types';
@@ -32,6 +32,7 @@ export class SloPlugin
implements Plugin
{
private readonly appUpdater$ = new BehaviorSubject(() => ({}));
+ private readonly appMountParameters$ = new Subject>();
private experimentalFeatures: ExperimentalFeatures = { ruleFormV2: { enabled: false } };
constructor(private readonly initContext: PluginInitializerContext) {
@@ -56,6 +57,7 @@ export class SloPlugin
const [coreStart, pluginsStart] = await coreSetup.getStartServices();
const { ruleTypeRegistry, actionTypeRegistry } = pluginsStart.triggersActionsUi;
const { observabilityRuleTypeRegistry } = pluginsStart.observability;
+ this.appMountParameters$.next(params);
return renderApp({
appMountParameters: params,
@@ -88,7 +90,7 @@ export class SloPlugin
registerBurnRateRuleType(pluginsSetup.observability.observabilityRuleTypeRegistry);
const assertPlatinumLicense = async () => {
- const licensing = await pluginsSetup.licensing;
+ const licensing = pluginsSetup.licensing;
const license = await firstValueFrom(licensing.license$);
const hasPlatinumLicense = license.hasAtLeast('platinum');
@@ -164,6 +166,7 @@ export class SloPlugin
observabilityRuleTypeRegistry: pluginsStart.observability.observabilityRuleTypeRegistry,
ObservabilityPageTemplate: pluginsStart.observabilityShared.navigation.PageTemplate,
plugins: { ...pluginsStart, ruleTypeRegistry, actionTypeRegistry },
+ getAppMountParameters: () => firstValueFrom(this.appMountParameters$),
isServerless: !!pluginsStart.serverless,
experimentalFeatures: this.experimentalFeatures,
}),
diff --git a/x-pack/plugins/observability_solution/slo/server/plugin.ts b/x-pack/plugins/observability_solution/slo/server/plugin.ts
index bcbd201b6bc91a..7c3dd521a5d527 100644
--- a/x-pack/plugins/observability_solution/slo/server/plugin.ts
+++ b/x-pack/plugins/observability_solution/slo/server/plugin.ts
@@ -17,7 +17,10 @@ import {
} from '@kbn/core/server';
import { PluginSetupContract, PluginStartContract } from '@kbn/alerting-plugin/server';
import { PluginSetupContract as FeaturesSetup } from '@kbn/features-plugin/server';
-import { RuleRegistryPluginSetupContract } from '@kbn/rule-registry-plugin/server';
+import {
+ RuleRegistryPluginSetupContract,
+ RuleRegistryPluginStartContract,
+} from '@kbn/rule-registry-plugin/server';
import {
TaskManagerSetupContract,
TaskManagerStartContract,
@@ -56,6 +59,7 @@ export interface PluginStart {
alerting: PluginStartContract;
taskManager: TaskManagerStartContract;
spaces?: SpacesPluginStart;
+ ruleRegistry: RuleRegistryPluginStartContract;
}
const sloRuleTypes = [SLO_BURN_RATE_RULE_TYPE_ID];
@@ -153,6 +157,10 @@ export class SloPlugin implements Plugin {
const [, pluginStart] = await core.getStartServices();
return pluginStart.alerting.getRulesClientWithRequest(request);
},
+ getRacClientWithRequest: async (request) => {
+ const [, pluginStart] = await core.getStartServices();
+ return pluginStart.ruleRegistry.getRacClientWithRequest(request);
+ },
},
logger: this.logger,
repository: getSloServerRouteRepository({
diff --git a/x-pack/plugins/observability_solution/slo/server/routes/register_routes.ts b/x-pack/plugins/observability_solution/slo/server/routes/register_routes.ts
index 83f5ce796e70d6..e74b5abb811b98 100644
--- a/x-pack/plugins/observability_solution/slo/server/routes/register_routes.ts
+++ b/x-pack/plugins/observability_solution/slo/server/routes/register_routes.ts
@@ -8,7 +8,11 @@ import { errors } from '@elastic/elasticsearch';
import Boom from '@hapi/boom';
import { RulesClientApi } from '@kbn/alerting-plugin/server/types';
import { CoreSetup, KibanaRequest, Logger, RouteRegistrar } from '@kbn/core/server';
-import { RuleDataPluginService } from '@kbn/rule-registry-plugin/server';
+import {
+ AlertsClient,
+ RuleDataPluginService,
+ RuleRegistryPluginSetupContract,
+} from '@kbn/rule-registry-plugin/server';
import {
decodeRequestParams,
parseEndpoint,
@@ -33,10 +37,12 @@ interface RegisterRoutes {
export interface RegisterRoutesDependencies {
pluginsSetup: {
core: CoreSetup;
+ ruleRegistry: RuleRegistryPluginSetupContract;
};
getSpacesStart: () => Promise;
ruleDataService: RuleDataPluginService;
getRulesClientWithRequest: (request: KibanaRequest) => Promise;
+ getRacClientWithRequest: (request: KibanaRequest) => Promise;
}
export function registerRoutes({ config, repository, core, logger, dependencies }: RegisterRoutes) {
diff --git a/x-pack/plugins/observability_solution/slo/server/routes/slo/route.ts b/x-pack/plugins/observability_solution/slo/server/routes/slo/route.ts
index d71a65e95013a2..d7e6d02583376c 100644
--- a/x-pack/plugins/observability_solution/slo/server/routes/slo/route.ts
+++ b/x-pack/plugins/observability_solution/slo/server/routes/slo/route.ts
@@ -27,6 +27,8 @@ import {
resetSLOParamsSchema,
updateSLOParamsSchema,
} from '@kbn/slo-schema';
+import { getOverviewParamsSchema } from '@kbn/slo-schema/src/rest_specs/routes/get_overview';
+import { GetSLOsOverview } from '../../services/get_slos_overview';
import type { IndicatorTypes } from '../../domain/models';
import {
CreateSLO,
@@ -663,6 +665,37 @@ const putSloSettings = (isServerless?: boolean) =>
},
});
+const getSLOsOverview = createSloServerRoute({
+ endpoint: 'GET /internal/observability/slos/overview',
+ options: {
+ tags: ['access:slo_read'],
+ access: 'internal',
+ },
+ params: getOverviewParamsSchema,
+ handler: async ({ context, params, request, logger, dependencies }) => {
+ await assertPlatinumLicense(context);
+
+ const soClient = (await context.core).savedObjects.client;
+ const esClient = (await context.core).elasticsearch.client.asCurrentUser;
+
+ const racClient = await dependencies.getRacClientWithRequest(request);
+
+ const spaces = await dependencies.getSpacesStart();
+ const spaceId = (await spaces?.spacesService?.getActiveSpace(request))?.id ?? 'default';
+ const rulesClient = await dependencies.getRulesClientWithRequest(request);
+
+ const slosOverview = new GetSLOsOverview(
+ soClient,
+ esClient,
+ spaceId,
+ logger,
+ rulesClient,
+ racClient
+ );
+ return await slosOverview.execute(params?.query ?? {});
+ },
+});
+
export const getSloRouteRepository = (isServerless?: boolean) => {
return {
...fetchSloHealthRoute,
@@ -686,5 +719,6 @@ export const getSloRouteRepository = (isServerless?: boolean) => {
...resetSLORoute,
...findSLOGroupsRoute,
...getSLOSuggestionsRoute,
+ ...getSLOsOverview,
};
};
diff --git a/x-pack/plugins/observability_solution/slo/server/services/__snapshots__/summary_search_client.test.ts.snap b/x-pack/plugins/observability_solution/slo/server/services/__snapshots__/summary_search_client.test.ts.snap
index fc76eaeb65c386..76a022867cfcfa 100644
--- a/x-pack/plugins/observability_solution/slo/server/services/__snapshots__/summary_search_client.test.ts.snap
+++ b/x-pack/plugins/observability_solution/slo/server/services/__snapshots__/summary_search_client.test.ts.snap
@@ -1,5 +1,6 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
+
exports[`Summary Search Client returns the summary documents without duplicate temporary summary documents 1`] = `
Array [
Object {
@@ -48,6 +49,7 @@ Object {
},
"sliValue": -1,
"status": "NO_DATA",
+ "summaryUpdatedAt": null,
},
},
Object {
@@ -63,6 +65,7 @@ Object {
},
"sliValue": -1,
"status": "NO_DATA",
+ "summaryUpdatedAt": null,
},
},
Object {
@@ -78,6 +81,7 @@ Object {
},
"sliValue": -1,
"status": "NO_DATA",
+ "summaryUpdatedAt": null,
},
},
Object {
@@ -93,6 +97,7 @@ Object {
},
"sliValue": -1,
"status": "NO_DATA",
+ "summaryUpdatedAt": null,
},
},
Object {
@@ -108,6 +113,7 @@ Object {
},
"sliValue": -1,
"status": "NO_DATA",
+ "summaryUpdatedAt": null,
},
},
],
diff --git a/x-pack/plugins/observability_solution/slo/server/services/find_slo.test.ts b/x-pack/plugins/observability_solution/slo/server/services/find_slo.test.ts
index cfff12d2f503b1..239b3aaaec5188 100644
--- a/x-pack/plugins/observability_solution/slo/server/services/find_slo.test.ts
+++ b/x-pack/plugins/observability_solution/slo/server/services/find_slo.test.ts
@@ -45,6 +45,7 @@ describe('FindSLO', () => {
"page": 1,
"perPage": 25,
},
+ undefined,
]
`);
@@ -139,6 +140,7 @@ describe('FindSLO', () => {
"page": 2,
"perPage": 10,
},
+ undefined,
]
`);
});
diff --git a/x-pack/plugins/observability_solution/slo/server/services/find_slo.ts b/x-pack/plugins/observability_solution/slo/server/services/find_slo.ts
index 2ea0a3c44a8f95..dcd7fe44d0783d 100644
--- a/x-pack/plugins/observability_solution/slo/server/services/find_slo.ts
+++ b/x-pack/plugins/observability_solution/slo/server/services/find_slo.ts
@@ -27,7 +27,8 @@ export class FindSLO {
params.kqlQuery ?? '',
params.filters ?? '',
toSort(params),
- toPagination(params)
+ toPagination(params),
+ params.hideStale
);
const localSloDefinitions = await this.repository.findAllByIds(
diff --git a/x-pack/plugins/observability_solution/slo/server/services/find_slo_groups.ts b/x-pack/plugins/observability_solution/slo/server/services/find_slo_groups.ts
index 9931a8c8c6c6f0..d3e4670f025bb2 100644
--- a/x-pack/plugins/observability_solution/slo/server/services/find_slo_groups.ts
+++ b/x-pack/plugins/observability_solution/slo/server/services/find_slo_groups.ts
@@ -12,10 +12,10 @@ import {
Pagination,
sloGroupWithSummaryResponseSchema,
} from '@kbn/slo-schema';
+import { getListOfSummaryIndices, getSloSettings } from './slo_settings';
import { DEFAULT_SLO_GROUPS_PAGE_SIZE } from '../../common/constants';
import { IllegalArgumentError } from '../errors';
import { typedSearch } from '../utils/queries';
-import { getListOfSummaryIndices } from './slo_settings';
import { EsSummaryDocument } from './summary_transform_generator/helpers/create_temp_summary';
import { getElasticsearchQueryOrThrow } from './transform_generators';
@@ -56,8 +56,8 @@ export class FindSLOGroups {
} catch (e) {
this.logger.error(`Failed to parse filters: ${e.message}`);
}
-
- const indices = await getListOfSummaryIndices(this.soClient, this.esClient);
+ const settings = await getSloSettings(this.soClient);
+ const { indices } = await getListOfSummaryIndices(this.esClient, settings);
const hasSelectedTags = groupBy === 'slo.tags' && groupsFilter.length > 0;
diff --git a/x-pack/plugins/observability_solution/slo/server/services/get_slos_overview.ts b/x-pack/plugins/observability_solution/slo/server/services/get_slos_overview.ts
new file mode 100644
index 00000000000000..f3c25f46bf71a8
--- /dev/null
+++ b/x-pack/plugins/observability_solution/slo/server/services/get_slos_overview.ts
@@ -0,0 +1,153 @@
+/*
+ * 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 { SavedObjectsClientContract } from '@kbn/core-saved-objects-api-server';
+import { ElasticsearchClient } from '@kbn/core-elasticsearch-server';
+import { Logger } from '@kbn/logging';
+import {
+ GetOverviewParams,
+ GetOverviewResponse,
+} from '@kbn/slo-schema/src/rest_specs/routes/get_overview';
+import { RulesClientApi } from '@kbn/alerting-plugin/server/types';
+import { AlertsClient } from '@kbn/rule-registry-plugin/server';
+import moment from 'moment';
+import { observabilityAlertFeatureIds } from '@kbn/observability-plugin/common';
+import { typedSearch } from '../utils/queries';
+import { getElasticsearchQueryOrThrow } from './transform_generators';
+import { getListOfSummaryIndices, getSloSettings } from './slo_settings';
+
+export class GetSLOsOverview {
+ constructor(
+ private soClient: SavedObjectsClientContract,
+ private esClient: ElasticsearchClient,
+ private spaceId: string,
+ private logger: Logger,
+ private rulesClient: RulesClientApi,
+ private racClient: AlertsClient
+ ) {}
+
+ public async execute(params: GetOverviewParams = {}): Promise {
+ const settings = await getSloSettings(this.soClient);
+ const { indices } = await getListOfSummaryIndices(this.esClient, settings);
+
+ const kqlQuery = params.kqlQuery ?? '';
+ const filters = params.filters ?? '';
+ let parsedFilters: any = {};
+ try {
+ parsedFilters = JSON.parse(filters);
+ } catch (e) {
+ this.logger.error(`Failed to parse filters: ${e.message}`);
+ }
+
+ const response = await typedSearch(this.esClient, {
+ index: indices,
+ size: 0,
+ query: {
+ bool: {
+ filter: [
+ { term: { spaceId: this.spaceId } },
+ getElasticsearchQueryOrThrow(kqlQuery),
+ ...(parsedFilters.filter ?? []),
+ ],
+ must_not: [...(parsedFilters.must_not ?? [])],
+ },
+ },
+ body: {
+ aggs: {
+ worst: {
+ top_hits: {
+ sort: {
+ errorBudgetRemaining: {
+ order: 'asc',
+ },
+ },
+ _source: {
+ includes: ['sliValue', 'status', 'slo.id', 'slo.instanceId', 'slo.name'],
+ },
+ size: 1,
+ },
+ },
+ stale: {
+ filter: {
+ range: {
+ summaryUpdatedAt: {
+ lt: `now-${settings.staleThresholdInHours}h`,
+ },
+ },
+ },
+ },
+ violated: {
+ filter: {
+ term: {
+ status: 'VIOLATED',
+ },
+ },
+ },
+ healthy: {
+ filter: {
+ term: {
+ status: 'HEALTHY',
+ },
+ },
+ },
+ degrading: {
+ filter: {
+ term: {
+ status: 'DEGRADING',
+ },
+ },
+ },
+ noData: {
+ filter: {
+ term: {
+ status: 'NO_DATA',
+ },
+ },
+ },
+ },
+ },
+ });
+
+ const [rules, alerts] = await Promise.all([
+ this.rulesClient.find({
+ options: {
+ search: 'alert.attributes.alertTypeId:("slo.rules.burnRate")',
+ },
+ }),
+
+ this.racClient.getAlertSummary({
+ featureIds: observabilityAlertFeatureIds,
+ gte: moment().subtract(24, 'hours').toISOString(),
+ lte: moment().toISOString(),
+ filter: [
+ {
+ term: {
+ 'kibana.alert.rule.rule_type_id': 'slo.rules.burnRate',
+ },
+ },
+ ],
+ }),
+ ]);
+
+ const aggs = response.aggregations;
+
+ return {
+ violated: aggs?.violated.doc_count ?? 0,
+ degrading: aggs?.degrading.doc_count ?? 0,
+ healthy: aggs?.healthy.doc_count ?? 0,
+ noData: aggs?.noData.doc_count ?? 0,
+ stale: aggs?.stale.doc_count ?? 0,
+ worst: {
+ value: 0,
+ id: 'id',
+ },
+ burnRateRules: rules.total,
+ burnRateActiveAlerts: alerts.activeAlertCount,
+ burnRateRecoveredAlerts: alerts.recoveredAlertCount,
+ };
+ }
+}
diff --git a/x-pack/plugins/observability_solution/slo/server/services/slo_settings.ts b/x-pack/plugins/observability_solution/slo/server/services/slo_settings.ts
index 407fd692cc646a..e3bce05843374e 100644
--- a/x-pack/plugins/observability_solution/slo/server/services/slo_settings.ts
+++ b/x-pack/plugins/observability_solution/slo/server/services/slo_settings.ts
@@ -9,7 +9,10 @@ import { ElasticsearchClient } from '@kbn/core-elasticsearch-server';
import { SavedObjectsClientContract } from '@kbn/core-saved-objects-api-server';
import { SavedObjectsErrorHelpers } from '@kbn/core-saved-objects-server';
import { PutSLOSettingsParams, sloSettingsSchema } from '@kbn/slo-schema';
-import { SLO_SUMMARY_DESTINATION_INDEX_PATTERN } from '../../common/constants';
+import {
+ DEFAULT_STALE_SLO_THRESHOLD_HOURS,
+ SLO_SUMMARY_DESTINATION_INDEX_PATTERN,
+} from '../../common/constants';
import { getListOfSloSummaryIndices } from '../../common/summary_indices';
import { StoredSLOSettings } from '../domain/models';
import { sloSettingsObjectId, SO_SLO_SETTINGS_TYPE } from '../saved_objects/slo_settings';
@@ -20,12 +23,15 @@ export const getSloSettings = async (soClient: SavedObjectsClientContract) => {
SO_SLO_SETTINGS_TYPE,
sloSettingsObjectId(soClient.getCurrentNamespace())
);
+ // set if it's not there
+ soObject.attributes.staleThresholdInHours = soObject.attributes.staleThresholdInHours ?? 2;
return sloSettingsSchema.encode(soObject.attributes);
} catch (e) {
if (SavedObjectsErrorHelpers.isNotFoundError(e)) {
return {
useAllRemoteClusters: false,
selectedRemoteClusters: [],
+ staleThresholdInHours: DEFAULT_STALE_SLO_THRESHOLD_HOURS,
};
}
throw e;
@@ -49,15 +55,12 @@ export const storeSloSettings = async (
};
export const getListOfSummaryIndices = async (
- soClient: SavedObjectsClientContract,
- esClient: ElasticsearchClient
+ esClient: ElasticsearchClient,
+ settings: StoredSLOSettings
) => {
- const indices: string[] = [SLO_SUMMARY_DESTINATION_INDEX_PATTERN];
-
- const settings = await getSloSettings(soClient);
const { useAllRemoteClusters, selectedRemoteClusters } = settings;
if (!useAllRemoteClusters && selectedRemoteClusters.length === 0) {
- return indices;
+ return { indices: [SLO_SUMMARY_DESTINATION_INDEX_PATTERN], settings };
}
const clustersByName = await esClient.cluster.remoteInfo();
@@ -67,5 +70,5 @@ export const getListOfSummaryIndices = async (
isConnected: clustersByName[clusterName].connected,
}));
- return getListOfSloSummaryIndices(settings, clusterInfo);
+ return { indices: getListOfSloSummaryIndices(settings, clusterInfo) };
};
diff --git a/x-pack/plugins/observability_solution/slo/server/services/summary_search_client.test.ts b/x-pack/plugins/observability_solution/slo/server/services/summary_search_client.test.ts
index 8ad2f657d926d3..a522bd287d0458 100644
--- a/x-pack/plugins/observability_solution/slo/server/services/summary_search_client.test.ts
+++ b/x-pack/plugins/observability_solution/slo/server/services/summary_search_client.test.ts
@@ -121,4 +121,117 @@ describe('Summary Search Client', () => {
expect(results).toMatchSnapshot();
expect(results.total).toBe(5);
});
+
+ it('handles hideStale filter', async () => {
+ await service.search('', '', defaultSort, defaultPagination, true);
+ expect(esClientMock.search.mock.calls[0]).toEqual([
+ {
+ from: 0,
+ index: ['.slo-observability.summary-v3*'],
+ query: {
+ bool: {
+ filter: [
+ {
+ term: {
+ spaceId: 'default',
+ },
+ },
+ {
+ bool: {
+ should: [
+ {
+ term: {
+ isTempDoc: true,
+ },
+ },
+ {
+ range: {
+ summaryUpdatedAt: {
+ gte: 'now-2h',
+ },
+ },
+ },
+ ],
+ },
+ },
+ {
+ match_all: {},
+ },
+ ],
+ must_not: [],
+ },
+ },
+ size: 40,
+ sort: {
+ isTempDoc: {
+ order: 'asc',
+ },
+ sliValue: {
+ order: 'asc',
+ },
+ },
+ track_total_hits: true,
+ },
+ ]);
+
+ await service.search('', '', defaultSort, defaultPagination);
+ expect(esClientMock.search.mock.calls[1]).toEqual([
+ {
+ from: 0,
+ index: ['.slo-observability.summary-v3*'],
+ query: {
+ bool: {
+ filter: [
+ {
+ term: {
+ spaceId: 'default',
+ },
+ },
+ {
+ match_all: {},
+ },
+ ],
+ must_not: [],
+ },
+ },
+ size: 40,
+ sort: {
+ isTempDoc: {
+ order: 'asc',
+ },
+ sliValue: {
+ order: 'asc',
+ },
+ },
+ track_total_hits: true,
+ },
+ ]);
+ });
+
+ it('handles summaryUpdate kql filter override', async () => {
+ await service.search('summaryUpdatedAt > now-2h', '', defaultSort, defaultPagination, true);
+ expect(esClientMock.search.mock.calls[0]).toEqual([
+ {
+ from: 0,
+ index: ['.slo-observability.summary-v3*'],
+ query: {
+ bool: {
+ filter: [
+ { term: { spaceId: 'default' } },
+ {
+ bool: {
+ minimum_should_match: 1,
+ should: [{ range: { summaryUpdatedAt: { gt: 'now-2h' } } }],
+ },
+ },
+ ],
+ must_not: [],
+ },
+ },
+ size: 40,
+ sort: { isTempDoc: { order: 'asc' }, sliValue: { order: 'asc' } },
+ track_total_hits: true,
+ },
+ ]);
+ });
});
diff --git a/x-pack/plugins/observability_solution/slo/server/services/summary_search_client.ts b/x-pack/plugins/observability_solution/slo/server/services/summary_search_client.ts
index e5f9992cc0f214..19bccfe72c537b 100644
--- a/x-pack/plugins/observability_solution/slo/server/services/summary_search_client.ts
+++ b/x-pack/plugins/observability_solution/slo/server/services/summary_search_client.ts
@@ -10,11 +10,12 @@ import { ElasticsearchClient, Logger, SavedObjectsClientContract } from '@kbn/co
import { ALL_VALUE, Paginated, Pagination } from '@kbn/slo-schema';
import { assertNever } from '@kbn/std';
import { partition } from 'lodash';
+import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import { SLO_SUMMARY_DESTINATION_INDEX_PATTERN } from '../../common/constants';
-import { Groupings, SLODefinition, SLOId, Summary } from '../domain/models';
+import { Groupings, SLODefinition, SLOId, StoredSLOSettings, Summary } from '../domain/models';
import { toHighPrecision } from '../utils/number';
import { createEsParams, typedSearch } from '../utils/queries';
-import { getListOfSummaryIndices } from './slo_settings';
+import { getListOfSummaryIndices, getSloSettings } from './slo_settings';
import { EsSummaryDocument } from './summary_transform_generator/helpers/create_temp_summary';
import { getElasticsearchQueryOrThrow } from './transform_generators';
import { fromRemoteSummaryDocumentToSloDefinition } from './unsafe_federated/remote_summary_doc_to_slo';
@@ -44,7 +45,8 @@ export interface SummarySearchClient {
kqlQuery: string,
filters: string,
sort: Sort,
- pagination: Pagination
+ pagination: Pagination,
+ hideStale?: boolean
): Promise>;
}
@@ -60,7 +62,8 @@ export class DefaultSummarySearchClient implements SummarySearchClient {
kqlQuery: string,
filters: string,
sort: Sort,
- pagination: Pagination
+ pagination: Pagination,
+ hideStale?: boolean
): Promise> {
let parsedFilters: any = {};
@@ -69,8 +72,8 @@ export class DefaultSummarySearchClient implements SummarySearchClient {
} catch (e) {
this.logger.error(`Failed to parse filters: ${e.message}`);
}
-
- const indices = await getListOfSummaryIndices(this.soClient, this.esClient);
+ const settings = await getSloSettings(this.soClient);
+ const { indices } = await getListOfSummaryIndices(this.esClient, settings);
const esParams = createEsParams({
index: indices,
track_total_hits: true,
@@ -78,6 +81,7 @@ export class DefaultSummarySearchClient implements SummarySearchClient {
bool: {
filter: [
{ term: { spaceId: this.spaceId } },
+ ...excludeStaleSummaryFilter(settings, kqlQuery, hideStale),
getElasticsearchQueryOrThrow(kqlQuery),
...(parsedFilters.filter ?? []),
],
@@ -159,6 +163,7 @@ export class DefaultSummarySearchClient implements SummarySearchClient {
},
sliValue: toHighPrecision(doc._source.sliValue),
status: summaryDoc.status,
+ summaryUpdatedAt: summaryDoc.summaryUpdatedAt,
},
groupings: getFlattenedGroupings({
groupings: summaryDoc.slo.groupings,
@@ -189,6 +194,32 @@ export class DefaultSummarySearchClient implements SummarySearchClient {
}
}
+function excludeStaleSummaryFilter(
+ settings: StoredSLOSettings,
+ kqlFilter: string,
+ hideStale?: boolean
+): estypes.QueryDslQueryContainer[] {
+ if (kqlFilter.includes('summaryUpdatedAt') || !settings.staleThresholdInHours || !hideStale) {
+ return [];
+ }
+ return [
+ {
+ bool: {
+ should: [
+ { term: { isTempDoc: true } },
+ {
+ range: {
+ summaryUpdatedAt: {
+ gte: `now-${settings.staleThresholdInHours}h`,
+ },
+ },
+ },
+ ],
+ },
+ },
+ ];
+}
+
function getRemoteClusterName(index: string) {
if (index.includes(':')) {
return index.split(':')[0];
diff --git a/x-pack/plugins/observability_solution/slo/tsconfig.json b/x-pack/plugins/observability_solution/slo/tsconfig.json
index 34ce93e23f5746..d16328c73a35f3 100644
--- a/x-pack/plugins/observability_solution/slo/tsconfig.json
+++ b/x-pack/plugins/observability_solution/slo/tsconfig.json
@@ -95,5 +95,6 @@
"@kbn/dashboard-plugin",
"@kbn/monaco",
"@kbn/code-editor",
+ "@kbn/react-kibana-context-render"
]
}
diff --git a/x-pack/plugins/security_solution/common/endpoint/data_generators/sentinelone_data_generator.ts b/x-pack/plugins/security_solution/common/endpoint/data_generators/sentinelone_data_generator.ts
index 19aedd173abbfc..3870710456ad07 100644
--- a/x-pack/plugins/security_solution/common/endpoint/data_generators/sentinelone_data_generator.ts
+++ b/x-pack/plugins/security_solution/common/endpoint/data_generators/sentinelone_data_generator.ts
@@ -22,6 +22,7 @@ import type {
SentinelOneActivityEsDoc,
EndpointActionDataParameterTypes,
EndpointActionResponseDataOutput,
+ SentinelOneActivityDataForType80,
} from '../types';
export class SentinelOneDataGenerator extends EndpointActionGenerator {
@@ -41,12 +42,13 @@ export class SentinelOneDataGenerator extends EndpointActionGenerator {
}
/** Generate a SentinelOne activity index ES doc */
- generateActivityEsDoc(
+ generateActivityEsDoc(
overrides: DeepPartial = {}
- ): SentinelOneActivityEsDoc {
+ ): SentinelOneActivityEsDoc {
const doc: SentinelOneActivityEsDoc = {
sentinel_one: {
activity: {
+ data: {},
agent: {
id: this.seededUUIDv4(),
},
@@ -60,13 +62,13 @@ export class SentinelOneDataGenerator extends EndpointActionGenerator {
},
};
- return merge(doc, overrides);
+ return merge(doc, overrides) as SentinelOneActivityEsDoc;
}
- generateActivityEsSearchHit(
- overrides: DeepPartial = {}
- ): SearchHit {
- const hit = this.toEsSearchHit(
+ generateActivityEsSearchHit(
+ overrides: DeepPartial> = {}
+ ): SearchHit> {
+ const hit = this.toEsSearchHit>(
this.generateActivityEsDoc(overrides),
SENTINEL_ONE_ACTIVITY_INDEX_PATTERN
);
@@ -81,10 +83,39 @@ export class SentinelOneDataGenerator extends EndpointActionGenerator {
return hit;
}
- generateActivityEsSearchResponse(
- docs: Array> = [this.generateActivityEsSearchHit()]
- ): SearchResponse {
- return this.toEsSearchResponse(docs);
+ generateActivityEsSearchResponse(
+ docs: Array>> = [this.generateActivityEsSearchHit()]
+ ): SearchResponse> {
+ return this.toEsSearchResponse>(docs);
+ }
+
+ generateActivityFetchFileResponseData(
+ overrides: DeepPartial = {}
+ ): SentinelOneActivityDataForType80 {
+ const data: SentinelOneActivityDataForType80 = {
+ flattened: {
+ commandId: Number([...this.randomNGenerator(1000, 2)].join('')),
+ commandBatchUuid: this.seededUUIDv4(),
+ filename: 'file.zip',
+ sourceType: 'API',
+ uploadedFilename: 'file_fetch.zip',
+ },
+ site: { name: 'Default site' },
+ group_name: 'Default Group',
+ scope: { level: 'Group', name: 'Default Group' },
+ fullscope: {
+ details: 'Group Default Group in Site Default site of Account Foo',
+ details_path: 'Global / Foo / Default site / Default Group',
+ },
+ downloaded: {
+ url: `/agents/${[...this.randomNGenerator(100, 4)].join('')}/uploads/${[
+ ...this.randomNGenerator(100, 4),
+ ].join('')}`,
+ },
+ account: { name: 'Foo' },
+ };
+
+ return merge(data, overrides);
}
generateSentinelOneApiActivityResponse(
diff --git a/x-pack/plugins/security_solution/common/endpoint/service/response_actions/constants.ts b/x-pack/plugins/security_solution/common/endpoint/service/response_actions/constants.ts
index 57df36e32a893e..6e6b6de839344e 100644
--- a/x-pack/plugins/security_solution/common/endpoint/service/response_actions/constants.ts
+++ b/x-pack/plugins/security_solution/common/endpoint/service/response_actions/constants.ts
@@ -155,3 +155,13 @@ export const RESPONSE_CONSOLE_ACTION_COMMANDS_TO_REQUIRED_AUTHZ = Object.freeze<
// 4 hrs in seconds
// 4 * 60 * 60
export const DEFAULT_EXECUTE_ACTION_TIMEOUT = 14400;
+
+/**
+ * The passcodes used for accessing the content of a zip file (ex. from a `get-file` response action)
+ */
+export const RESPONSE_ACTIONS_ZIP_PASSCODE: Readonly> =
+ Object.freeze({
+ endpoint: 'elastic',
+ sentinel_one: 'Elastic@123',
+ crowdstrike: 'tbd..',
+ });
diff --git a/x-pack/plugins/security_solution/common/endpoint/service/response_actions/get_file_download_id.test.ts b/x-pack/plugins/security_solution/common/endpoint/service/response_actions/get_file_download_id.test.ts
new file mode 100644
index 00000000000000..035c7c4e6d2092
--- /dev/null
+++ b/x-pack/plugins/security_solution/common/endpoint/service/response_actions/get_file_download_id.test.ts
@@ -0,0 +1,50 @@
+/*
+ * 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 { EndpointActionGenerator } from '../../data_generators/endpoint_action_generator';
+import type { ActionDetails } from '../../types';
+import { getFileDownloadId } from './get_file_download_id';
+
+describe('getFileDownloadId()', () => {
+ let action: ActionDetails;
+ let agentId: string;
+
+ beforeEach(() => {
+ action = new EndpointActionGenerator().generateActionDetails();
+ agentId = action.agents[0];
+ });
+
+ it('should throw if agentId is not listed in the action', () => {
+ action.agents = ['foo'];
+
+ expect(() => getFileDownloadId(action, agentId)).toThrow(
+ `Action [${action.id}] was not sent to agent id [${agentId}]`
+ );
+ });
+
+ it('Should return expected id for Endpoint agent type when agentId is passed as an argument', () => {
+ expect(getFileDownloadId(action, agentId)).toEqual(`${action.id}.${agentId}`);
+ });
+
+ it('Should return expected id for Endpoint agent type when agentId is NOT passed as an argument', () => {
+ action.agents = ['foo', 'foo2'];
+
+ expect(getFileDownloadId(action)).toEqual(`${action.id}.foo`);
+ });
+
+ it('should return expected ID for non-endpoint agent types when agentId is passed as an argument', () => {
+ action.agentType = 'sentinel_one';
+ expect(getFileDownloadId(action, agentId)).toEqual(agentId);
+ });
+
+ it('should return expected ID for non-endpoint agent types when agentId is NOT passed as an argument', () => {
+ action.agentType = 'sentinel_one';
+ action.agents = ['foo', 'foo2'];
+
+ expect(getFileDownloadId(action)).toEqual(`foo`);
+ });
+});
diff --git a/x-pack/plugins/security_solution/common/endpoint/service/response_actions/get_file_download_id.ts b/x-pack/plugins/security_solution/common/endpoint/service/response_actions/get_file_download_id.ts
index 12d74207c57b9c..3b249490cfea47 100644
--- a/x-pack/plugins/security_solution/common/endpoint/service/response_actions/get_file_download_id.ts
+++ b/x-pack/plugins/security_solution/common/endpoint/service/response_actions/get_file_download_id.ts
@@ -13,11 +13,17 @@ import type { ActionDetails } from '../../types';
* @param agentId
*/
export const getFileDownloadId = (action: ActionDetails, agentId?: string): string => {
- const { id: actionId, agents } = action;
+ const { id: actionId, agents, agentType } = action;
if (agentId && !agents.includes(agentId)) {
throw new Error(`Action [${actionId}] was not sent to agent id [${agentId}]`);
}
+ // If not an Endpoint agent type, then return the agent id. Agent ID will be used as the
+ // file identifier for non-endpoint agents
+ if (agentType !== 'endpoint') {
+ return agentId ?? agents[0];
+ }
+
return `${actionId}.${agentId ?? agents[0]}`;
};
diff --git a/x-pack/plugins/security_solution/common/endpoint/service/response_actions/sentinel_one.ts b/x-pack/plugins/security_solution/common/endpoint/service/response_actions/sentinel_one.ts
index 5fe865488347c4..786e43dae61ace 100644
--- a/x-pack/plugins/security_solution/common/endpoint/service/response_actions/sentinel_one.ts
+++ b/x-pack/plugins/security_solution/common/endpoint/service/response_actions/sentinel_one.ts
@@ -9,9 +9,3 @@
* Index pattern where the SentinelOne activity log is written to by the SentinelOne integration
*/
export const SENTINEL_ONE_ACTIVITY_INDEX_PATTERN = 'logs-sentinel_one.activity-*';
-
-/**
- * The passcode to be used when initiating actions in SentinelOne that require a passcode to be
- * set for the resulting zip file
- */
-export const SENTINEL_ONE_ZIP_PASSCODE = 'Elastic@123';
diff --git a/x-pack/plugins/security_solution/common/endpoint/types/actions.ts b/x-pack/plugins/security_solution/common/endpoint/types/actions.ts
index 34c0a8bafe10b4..c2279ec7b8be64 100644
--- a/x-pack/plugins/security_solution/common/endpoint/types/actions.ts
+++ b/x-pack/plugins/security_solution/common/endpoint/types/actions.ts
@@ -522,6 +522,7 @@ export type UploadedFileInfo = Pick<
> & {
actionId: string;
agentId: string;
+ agentType: ResponseActionAgentType;
};
export interface ActionFileInfoApiResponse {
diff --git a/x-pack/plugins/security_solution/common/endpoint/types/sentinel_one.ts b/x-pack/plugins/security_solution/common/endpoint/types/sentinel_one.ts
index c7e0b1d9a45815..91a06ffc5ffca4 100644
--- a/x-pack/plugins/security_solution/common/endpoint/types/sentinel_one.ts
+++ b/x-pack/plugins/security_solution/common/endpoint/types/sentinel_one.ts
@@ -11,7 +11,7 @@
* NOTE: not all properties are currently mapped below. Check the index definition if wanting to
* see what else is available and add it bellow if needed
*/
-export interface SentinelOneActivityEsDoc {
+export interface SentinelOneActivityEsDoc {
sentinel_one: {
activity: {
agent: {
@@ -26,10 +26,50 @@ export interface SentinelOneActivityEsDoc {
id: string;
/** The activity type. Valid values can be retrieved from S1 via API: `/web/api/v2.1/activities/types` */
type: number;
+ /** Activity specific data */
+ data: TData;
};
};
}
+/**
+ * Activity data for file uploaded to S1 by an Agent:
+ * ```
+ * {
+ * "action": "Agent Uploaded Fetched Files",
+ * "descriptionTemplate": "Agent {{ computer_name }} ({{ external_ip }}) successfully uploaded {{ filename }}.",
+ * "id": 80
+ * },
+ * ```
+ */
+export interface SentinelOneActivityDataForType80 {
+ flattened: {
+ commandId: number;
+ commandBatchUuid: string;
+ filename: string;
+ sourceType: string;
+ uploadedFilename: string;
+ };
+ site: {
+ name: string;
+ };
+ group_name: string;
+ scope: {
+ level: string;
+ name: string;
+ };
+ fullscope: {
+ details: string;
+ details_path: string;
+ };
+ downloaded: {
+ url: string;
+ };
+ account: {
+ name: string;
+ };
+}
+
export interface SentinelOneActionRequestCommonMeta {
/** The S1 agent id */
agentId: string;
@@ -63,3 +103,15 @@ export interface SentinelOneGetFileRequestMeta extends SentinelOneActionRequestC
*/
commandBatchUuid: string;
}
+
+export interface SentinelOneGetFileResponseMeta {
+ /** The document ID in the Elasticsearch S1 activity index that was used to complete the response action */
+ elasticDocId: string;
+ /** The SentinelOne activity log entry ID */
+ activityLogEntryId: string;
+ /** The S1 download url (relative URI) for the file that was retrieved */
+ downloadUrl: string;
+ /** When the file was created/uploaded to SentinelOne */
+ createdAt: string;
+ filename: string;
+}
diff --git a/x-pack/plugins/security_solution/public/management/components/response_action_file_download_link/response_action_file_download_link.test.tsx b/x-pack/plugins/security_solution/public/management/components/response_action_file_download_link/response_action_file_download_link.test.tsx
index c0f30b7b640fc5..873e816e12dce8 100644
--- a/x-pack/plugins/security_solution/public/management/components/response_action_file_download_link/response_action_file_download_link.test.tsx
+++ b/x-pack/plugins/security_solution/public/management/components/response_action_file_download_link/response_action_file_download_link.test.tsx
@@ -26,6 +26,7 @@ import { responseActionsHttpMocks } from '../../mocks/response_actions_http_mock
import { getDeferred } from '../../mocks/utils';
import { waitFor } from '@testing-library/react';
import type { IHttpFetchError } from '@kbn/core-http-browser';
+import { RESPONSE_ACTIONS_ZIP_PASSCODE } from '../../../../common/endpoint/service/response_actions/constants';
describe('When using the `ResponseActionFileDownloadLink` component', () => {
let render: () => ReturnType;
@@ -66,7 +67,7 @@ describe('When using the `ResponseActionFileDownloadLink` component', () => {
'/api/endpoint/action/123/file/123.agent-a/download?apiVersion=2023-10-31'
);
expect(renderResult.getByTestId('test-passcodeMessage')).toHaveTextContent(
- FILE_PASSCODE_INFO_MESSAGE
+ FILE_PASSCODE_INFO_MESSAGE(RESPONSE_ACTIONS_ZIP_PASSCODE.endpoint)
);
expect(renderResult.getByTestId('test-fileDeleteMessage')).toHaveTextContent(
FILE_DELETED_MESSAGE
diff --git a/x-pack/plugins/security_solution/public/management/components/response_action_file_download_link/response_action_file_download_link.tsx b/x-pack/plugins/security_solution/public/management/components/response_action_file_download_link/response_action_file_download_link.tsx
index 2d679d4b3479e6..f4db61a270ff82 100644
--- a/x-pack/plugins/security_solution/public/management/components/response_action_file_download_link/response_action_file_download_link.tsx
+++ b/x-pack/plugins/security_solution/public/management/components/response_action_file_download_link/response_action_file_download_link.tsx
@@ -17,6 +17,7 @@ import {
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import styled from 'styled-components';
+import { RESPONSE_ACTIONS_ZIP_PASSCODE } from '../../../../common/endpoint/service/response_actions/constants';
import { getFileDownloadId } from '../../../../common/endpoint/service/response_actions/get_file_download_id';
import { resolvePathVariables } from '../../../common/utils/resolve_path_variables';
import { FormattedError } from '../formatted_error';
@@ -48,15 +49,11 @@ export const FILE_DELETED_MESSAGE = i18n.translate(
}
);
-export const FILE_PASSCODE_INFO_MESSAGE = i18n.translate(
- 'xpack.securitySolution.responseActionFileDownloadLink.passcodeInfo',
- {
+export const FILE_PASSCODE_INFO_MESSAGE = (passcode: string) =>
+ i18n.translate('xpack.securitySolution.responseActionFileDownloadLink.passcodeInfo', {
defaultMessage: '(ZIP file passcode: {passcode}).',
- values: {
- passcode: 'elastic',
- },
- }
-);
+ values: { passcode },
+ });
export const FILE_TRUNCATED_MESSAGE = i18n.translate(
'xpack.securitySolution.responseActionFileDownloadLink.fileTruncated',
@@ -189,7 +186,7 @@ export const ResponseActionFileDownloadLink = memo
- {FILE_PASSCODE_INFO_MESSAGE}
+ {FILE_PASSCODE_INFO_MESSAGE(RESPONSE_ACTIONS_ZIP_PASSCODE[action.agentType])}
{FILE_DELETED_MESSAGE}
diff --git a/x-pack/plugins/security_solution/public/management/mocks/response_actions_http_mocks.ts b/x-pack/plugins/security_solution/public/management/mocks/response_actions_http_mocks.ts
index 2d775cfb3ff8a3..d05c2920f508ad 100644
--- a/x-pack/plugins/security_solution/public/management/mocks/response_actions_http_mocks.ts
+++ b/x-pack/plugins/security_solution/public/management/mocks/response_actions_http_mocks.ts
@@ -208,6 +208,7 @@ export const responseActionsHttpMocks = httpHandlerMockFactory {
+ const actual = jest.requireActual('../../services');
+ return {
+ ...actual,
+ validateActionIdMock: jest.fn(async () => {}),
+ getActionAgentType: jest.fn(async () => ({ agentType: 'endpoint' })),
+ };
+});
describe('Response Actions file download API', () => {
- const validateActionIdMock = _validateActionId as jest.Mock;
-
let apiTestSetup: HttpApiTestSetupMock;
let httpRequestMock: ReturnType<
HttpApiTestSetupMock['createRequestMock']
@@ -34,6 +42,17 @@ describe('Response Actions file download API', () => {
beforeEach(() => {
apiTestSetup = createHttpApiTestSetupMock();
+ const esClientMock = apiTestSetup.getEsClientMock();
+ const actionRequestEsSearchResponse = createActionRequestsEsSearchResultsMock();
+
+ actionRequestEsSearchResponse.hits.hits[0]._source!.EndpointActions.action_id = '321-654';
+
+ applyEsClientSearchMock({
+ esClientMock,
+ index: ENDPOINT_ACTIONS_INDEX,
+ response: actionRequestEsSearchResponse,
+ });
+
({ httpHandlerContextMock, httpResponseMock } = apiTestSetup);
httpRequestMock = apiTestSetup.createRequestMock({
params: { action_id: '321-654', file_id: '123-456-789' },
@@ -75,42 +94,12 @@ describe('Response Actions file download API', () => {
describe('Route handler', () => {
let fileDownloadHandler: ReturnType;
- let fleetFilesClientMock: jest.Mocked;
beforeEach(async () => {
fileDownloadHandler = getActionFileDownloadRouteHandler(apiTestSetup.endpointAppContextMock);
-
- validateActionIdMock.mockImplementation(async () => {});
-
- fleetFilesClientMock =
- (await apiTestSetup.endpointAppContextMock.service.getFleetFromHostFilesClient()) as jest.Mocked;
- });
-
- it('should error if action ID is invalid', async () => {
- validateActionIdMock.mockRejectedValueOnce(new NotFoundError('not found'));
- await fileDownloadHandler(httpHandlerContextMock, httpRequestMock, httpResponseMock);
-
- expect(httpResponseMock.notFound).toHaveBeenCalled();
- });
-
- it('should error if file ID is invalid', async () => {
- // @ts-expect-error assignment to readonly value
- httpRequestMock.params.file_id = 'invalid';
- await fileDownloadHandler(httpHandlerContextMock, httpRequestMock, httpResponseMock);
-
- expect(httpResponseMock.customError).toHaveBeenCalledWith({
- statusCode: 400,
- body: expect.any(CustomHttpRequestError),
- });
- });
-
- it('should retrieve the download Stream using correct file ID', async () => {
- await fileDownloadHandler(httpHandlerContextMock, httpRequestMock, httpResponseMock);
-
- expect(fleetFilesClientMock.download).toHaveBeenCalledWith('123-456-789');
});
- it('should respond with expected HTTP headers', async () => {
+ it('should respond with expected Body and HTTP headers', async () => {
await fileDownloadHandler(httpHandlerContextMock, httpRequestMock, httpResponseMock);
expect(httpResponseMock.ok).toHaveBeenCalledWith(
@@ -121,6 +110,7 @@ describe('Response Actions file download API', () => {
'content-type': 'application/octet-stream',
'x-content-type-options': 'nosniff',
},
+ body: expect.any(Readable),
})
);
});
diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/actions/file_download_handler.ts b/x-pack/plugins/security_solution/server/endpoint/routes/actions/file_download_handler.ts
index a9ece70bb214bb..cb4afe44964722 100644
--- a/x-pack/plugins/security_solution/server/endpoint/routes/actions/file_download_handler.ts
+++ b/x-pack/plugins/security_solution/server/endpoint/routes/actions/file_download_handler.ts
@@ -8,8 +8,12 @@
import type { RequestHandler } from '@kbn/core/server';
import type { EndpointActionFileDownloadParams } from '../../../../common/api/endpoint';
import { EndpointActionFileDownloadSchema } from '../../../../common/api/endpoint';
-import { CustomHttpRequestError } from '../../../utils/custom_http_request_error';
-import { validateActionId } from '../../services';
+import type { ResponseActionsClient } from '../../services';
+import {
+ getResponseActionsClient,
+ NormalizedExternalConnectorClient,
+ getActionAgentType,
+} from '../../services';
import { errorHandler } from '../error_handler';
import { ACTION_AGENT_FILE_DOWNLOAD_ROUTE } from '../../../../common/endpoint/constants';
import { withEndpointAuthz } from '../with_endpoint_authz';
@@ -61,22 +65,23 @@ export const getActionFileDownloadRouteHandler = (
const logger = endpointContext.logFactory.get('actionFileDownload');
return async (context, req, res) => {
- const fleetFiles = await endpointContext.service.getFleetFromHostFilesClient();
- const esClient = (await context.core).elasticsearch.client.asInternalUser;
const { action_id: actionId, file_id: fileId } = req.params;
try {
- await validateActionId(esClient, actionId);
- const file = await fleetFiles.get(fileId);
-
- if (file.id !== fileId) {
- throw new CustomHttpRequestError(
- `Invalid file id [${fileId}] for action [${actionId}]`,
- 400
- );
- }
+ const esClient = (await context.core).elasticsearch.client.asInternalUser;
+ const { agentType } = await getActionAgentType(esClient, actionId);
+ const user = endpointContext.service.security?.authc.getCurrentUser(req);
+ const casesClient = await endpointContext.service.getCasesClient(req);
+ const connectorActions = (await context.actions).getActionsClient();
+ const responseActionsClient: ResponseActionsClient = getResponseActionsClient(agentType, {
+ esClient,
+ casesClient,
+ endpointService: endpointContext.service,
+ username: user?.username || 'unknown',
+ connectorActions: new NormalizedExternalConnectorClient(connectorActions, logger),
+ });
- const { stream, fileName } = await fleetFiles.download(fileId);
+ const { stream, fileName } = await responseActionsClient.getFileDownload(actionId, fileId);
return res.ok({
body: stream,
diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/actions/file_info_handler.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/actions/file_info_handler.test.ts
index 83462931b53177..e6554ee14ad6d8 100644
--- a/x-pack/plugins/security_solution/server/endpoint/routes/actions/file_info_handler.test.ts
+++ b/x-pack/plugins/security_solution/server/endpoint/routes/actions/file_info_handler.test.ts
@@ -5,22 +5,29 @@
* 2.0.
*/
-import { validateActionId as _validateActionId } from '../../services';
import type { HttpApiTestSetupMock } from '../../mocks';
import { createHttpApiTestSetupMock } from '../../mocks';
import type { EndpointActionFileDownloadParams } from '../../../../common/api/endpoint';
import { getActionFileInfoRouteHandler, registerActionFileInfoRoute } from './file_info_handler';
-import { ACTION_AGENT_FILE_INFO_ROUTE } from '../../../../common/endpoint/constants';
-import { EndpointAuthorizationError, NotFoundError } from '../../errors';
-import { CustomHttpRequestError } from '../../../utils/custom_http_request_error';
+import {
+ ACTION_AGENT_FILE_INFO_ROUTE,
+ ENDPOINT_ACTIONS_INDEX,
+} from '../../../../common/endpoint/constants';
+import { EndpointAuthorizationError } from '../../errors';
import { getEndpointAuthzInitialStateMock } from '../../../../common/endpoint/service/authz/mocks';
-import type { FleetFromHostFileClientInterface } from '@kbn/fleet-plugin/server';
-
-jest.mock('../../services');
+import { createActionRequestsEsSearchResultsMock } from '../../services/actions/mocks';
+import { applyEsClientSearchMock } from '../../mocks/utils.mock';
+
+jest.mock('../../services', () => {
+ const actual = jest.requireActual('../../services');
+ return {
+ ...actual,
+ validateActionIdMock: jest.fn(async () => {}),
+ getActionAgentType: jest.fn(async () => ({ agentType: 'endpoint' })),
+ };
+});
describe('Response Action file info API', () => {
- const validateActionIdMock = _validateActionId as jest.Mock;
-
let apiTestSetup: HttpApiTestSetupMock;
let httpRequestMock: ReturnType<
HttpApiTestSetupMock['createRequestMock']
@@ -31,6 +38,17 @@ describe('Response Action file info API', () => {
beforeEach(() => {
apiTestSetup = createHttpApiTestSetupMock();
+ const esClientMock = apiTestSetup.getEsClientMock();
+ const actionRequestEsSearchResponse = createActionRequestsEsSearchResultsMock();
+
+ actionRequestEsSearchResponse.hits.hits[0]._source!.EndpointActions.action_id = '321-654';
+
+ applyEsClientSearchMock({
+ esClientMock,
+ index: ENDPOINT_ACTIONS_INDEX,
+ response: actionRequestEsSearchResponse,
+ });
+
({ httpHandlerContextMock, httpResponseMock } = apiTestSetup);
httpRequestMock = apiTestSetup.createRequestMock({
params: { action_id: '321-654', file_id: '123-456-789' },
@@ -65,41 +83,9 @@ describe('Response Action file info API', () => {
describe('Route handler', () => {
let fileInfoHandler: ReturnType;
- let fleetFilesClientMock: jest.Mocked;
beforeEach(async () => {
fileInfoHandler = getActionFileInfoRouteHandler(apiTestSetup.endpointAppContextMock);
-
- validateActionIdMock.mockImplementation(async () => {});
-
- fleetFilesClientMock =
- (await apiTestSetup.endpointAppContextMock.service.getFleetFromHostFilesClient()) as jest.Mocked;
- });
-
- it('should error if action ID is invalid', async () => {
- validateActionIdMock.mockImplementationOnce(async () => {
- throw new NotFoundError('not found');
- });
- await fileInfoHandler(httpHandlerContextMock, httpRequestMock, httpResponseMock);
-
- expect(httpResponseMock.notFound).toHaveBeenCalled();
- });
-
- it('should error if file ID is invalid', async () => {
- // @ts-expect-error assignment to readonly value
- httpRequestMock.params.file_id = 'invalid';
- await fileInfoHandler(httpHandlerContextMock, httpRequestMock, httpResponseMock);
-
- expect(httpResponseMock.customError).toHaveBeenCalledWith({
- statusCode: 400,
- body: expect.any(CustomHttpRequestError),
- });
- });
-
- it('should retrieve the file info with correct file id', async () => {
- await fileInfoHandler(httpHandlerContextMock, httpRequestMock, httpResponseMock);
-
- expect(fleetFilesClientMock.get).toHaveBeenCalledWith('123-456-789');
});
it('should respond with expected output', async () => {
@@ -110,6 +96,7 @@ describe('Response Action file info API', () => {
data: {
actionId: '321-654',
agentId: '111-222',
+ agentType: 'endpoint',
created: '2023-05-12T19:47:33.702Z',
id: '123-456-789',
mimeType: 'text/plain',
diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/actions/file_info_handler.ts b/x-pack/plugins/security_solution/server/endpoint/routes/actions/file_info_handler.ts
index e6e38f27f9cda6..0576cec69b01ca 100644
--- a/x-pack/plugins/security_solution/server/endpoint/routes/actions/file_info_handler.ts
+++ b/x-pack/plugins/security_solution/server/endpoint/routes/actions/file_info_handler.ts
@@ -8,8 +8,12 @@
import type { RequestHandler } from '@kbn/core/server';
import type { EndpointActionFileInfoParams } from '../../../../common/api/endpoint';
import { EndpointActionFileInfoSchema } from '../../../../common/api/endpoint';
-import { CustomHttpRequestError } from '../../../utils/custom_http_request_error';
-import { validateActionId } from '../../services';
+import type { ResponseActionsClient } from '../../services';
+import {
+ getResponseActionsClient,
+ NormalizedExternalConnectorClient,
+ getActionAgentType,
+} from '../../services';
import { ACTION_AGENT_FILE_INFO_ROUTE } from '../../../../common/endpoint/constants';
import type { EndpointAppContext } from '../../types';
import type {
@@ -31,34 +35,23 @@ export const getActionFileInfoRouteHandler = (
const logger = endpointContext.logFactory.get('actionFileInfo');
return async (context, req, res) => {
- const fleetFiles = await endpointContext.service.getFleetFromHostFilesClient();
const { action_id: requestActionId, file_id: fileId } = req.params;
- const esClient = (await context.core).elasticsearch.client.asInternalUser;
try {
- await validateActionId(esClient, requestActionId);
- const { actionId, mimeType, status, size, name, id, agents, created } = await fleetFiles.get(
- fileId
- );
-
- if (id !== fileId) {
- throw new CustomHttpRequestError(
- `Invalid file id [${fileId}] for action [${requestActionId}]`,
- 400
- );
- }
-
+ const esClient = (await context.core).elasticsearch.client.asInternalUser;
+ const { agentType } = await getActionAgentType(esClient, requestActionId);
+ const user = endpointContext.service.security?.authc.getCurrentUser(req);
+ const casesClient = await endpointContext.service.getCasesClient(req);
+ const connectorActions = (await context.actions).getActionsClient();
+ const responseActionsClient: ResponseActionsClient = getResponseActionsClient(agentType, {
+ esClient,
+ casesClient,
+ endpointService: endpointContext.service,
+ username: user?.username || 'unknown',
+ connectorActions: new NormalizedExternalConnectorClient(connectorActions, logger),
+ });
const response: ActionFileInfoApiResponse = {
- data: {
- name,
- id,
- mimeType,
- size,
- status,
- created,
- actionId,
- agentId: agents.at(0) ?? '',
- },
+ data: await responseActionsClient.getFileInfo(requestActionId, fileId),
};
return res.ok({ body: response });
diff --git a/x-pack/plugins/security_solution/server/endpoint/services/actions/action_details_by_id.ts b/x-pack/plugins/security_solution/server/endpoint/services/actions/action_details_by_id.ts
index 35b807de65daec..7a4246e7b1a2fe 100644
--- a/x-pack/plugins/security_solution/server/endpoint/services/actions/action_details_by_id.ts
+++ b/x-pack/plugins/security_solution/server/endpoint/services/actions/action_details_by_id.ts
@@ -7,7 +7,7 @@
import type { ElasticsearchClient } from '@kbn/core/server';
-import { fetchActionResponses } from './fetch_action_responses';
+import { fetchActionResponses } from './utils/fetch_action_responses';
import { ENDPOINT_ACTIONS_INDEX } from '../../../../common/endpoint/constants';
import {
formatEndpointActionResults,
diff --git a/x-pack/plugins/security_solution/server/endpoint/services/actions/action_list.ts b/x-pack/plugins/security_solution/server/endpoint/services/actions/action_list.ts
index efcbd6fb49a929..1ebe20eb393b65 100644
--- a/x-pack/plugins/security_solution/server/endpoint/services/actions/action_list.ts
+++ b/x-pack/plugins/security_solution/server/endpoint/services/actions/action_list.ts
@@ -7,7 +7,7 @@
import type { ElasticsearchClient, Logger } from '@kbn/core/server';
import type { SearchTotalHits } from '@elastic/elasticsearch/lib/api/types';
-import { fetchActionResponses } from './fetch_action_responses';
+import { fetchActionResponses } from './utils/fetch_action_responses';
import { ENDPOINT_DEFAULT_PAGE_SIZE } from '../../../../common/endpoint/constants';
import { CustomHttpRequestError } from '../../../utils/custom_http_request_error';
import type { ActionListApiResponse } from '../../../../common/endpoint/types';
diff --git a/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/endpoint/endpoint_actions_client.test.ts b/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/endpoint/endpoint_actions_client.test.ts
index 73948a4daf398b..9fce2a5e609f3e 100644
--- a/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/endpoint/endpoint_actions_client.test.ts
+++ b/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/endpoint/endpoint_actions_client.test.ts
@@ -13,6 +13,10 @@ import { responseActionsClientMock } from '../mocks';
import { ENDPOINT_ACTIONS_INDEX } from '../../../../../../common/endpoint/constants';
import type { ResponseActionRequestBody } from '../../../../../../common/endpoint/types';
import { DEFAULT_EXECUTE_ACTION_TIMEOUT } from '../../../../../../common/endpoint/service/response_actions/constants';
+import { applyEsClientSearchMock } from '../../../../mocks/utils.mock';
+import type { ElasticsearchClientMock } from '@kbn/core-elasticsearch-client-server-mocks';
+import { BaseDataGenerator } from '../../../../../../common/endpoint/data_generators/base_data_generator';
+import { Readable } from 'stream';
describe('EndpointActionsClient', () => {
let classConstructorOptions: ResponseActionsClientOptions;
@@ -229,7 +233,10 @@ describe('EndpointActionsClient', () => {
]);
});
- type ResponseActionsMethodsOnly = keyof Omit;
+ type ResponseActionsMethodsOnly = keyof Omit<
+ ResponseActionsClient,
+ 'processPendingActions' | 'getFileDownload' | 'getFileInfo'
+ >;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const responseActionMethods: Record = {
@@ -257,7 +264,7 @@ describe('EndpointActionsClient', () => {
};
it.each(Object.keys(responseActionMethods) as ResponseActionsMethodsOnly[])(
- 'should handle call to %s() method',
+ 'should dispatch a fleet action request calling %s() method',
async (methodName) => {
await endpointActionsClient[methodName](responseActionMethods[methodName]);
@@ -295,4 +302,68 @@ describe('EndpointActionsClient', () => {
);
}
);
+
+ describe('#getFileDownload()', () => {
+ it('should throw error if agent type for the action id is not endpoint', async () => {
+ applyEsClientSearchMock({
+ esClientMock: classConstructorOptions.esClient as ElasticsearchClientMock,
+ index: ENDPOINT_ACTIONS_INDEX,
+ response: BaseDataGenerator.toEsSearchResponse([]),
+ });
+
+ await expect(endpointActionsClient.getFileDownload('abc', '123')).rejects.toThrow(
+ 'Action id [abc] not found with an agent type of [endpoint]'
+ );
+ });
+
+ it('should throw error if file id not associated with action id', async () => {
+ await expect(endpointActionsClient.getFileDownload('abc', '123')).rejects.toThrow(
+ 'Invalid file id [123] for action [abc]'
+ );
+ });
+
+ it('should return expected response', async () => {
+ await expect(
+ endpointActionsClient.getFileDownload('321-654', '123-456-789')
+ ).resolves.toEqual({
+ stream: expect.any(Readable),
+ fileName: expect.any(String),
+ mimeType: expect.any(String),
+ });
+ });
+ });
+
+ describe('#getFileInfo()', () => {
+ it('should throw error if agent type for the action id is not endpoint', async () => {
+ applyEsClientSearchMock({
+ esClientMock: classConstructorOptions.esClient as ElasticsearchClientMock,
+ index: ENDPOINT_ACTIONS_INDEX,
+ response: BaseDataGenerator.toEsSearchResponse([]),
+ });
+
+ await expect(endpointActionsClient.getFileInfo('abc', '123')).rejects.toThrow(
+ 'Action id [abc] not found with an agent type of [endpoint]'
+ );
+ });
+
+ it('should throw error if file id not associated with action id', async () => {
+ await expect(endpointActionsClient.getFileInfo('abc', '123')).rejects.toThrow(
+ 'Invalid file ID. File [123] not associated with action ID [abc]'
+ );
+ });
+
+ it('should return expected response', async () => {
+ await expect(endpointActionsClient.getFileInfo('321-654', '123-456-789')).resolves.toEqual({
+ actionId: '321-654',
+ agentId: '111-222',
+ agentType: 'endpoint',
+ created: '2023-05-12T19:47:33.702Z',
+ id: '123-456-789',
+ mimeType: 'text/plain',
+ name: 'foo.txt',
+ size: 45632,
+ status: 'READY',
+ });
+ });
+ });
});
diff --git a/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/endpoint/endpoint_actions_client.ts b/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/endpoint/endpoint_actions_client.ts
index 08c1869256b2f6..96e77be833e3c5 100644
--- a/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/endpoint/endpoint_actions_client.ts
+++ b/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/endpoint/endpoint_actions_client.ts
@@ -7,6 +7,7 @@
import type { FleetActionRequest } from '@kbn/fleet-plugin/server/services/actions';
import { v4 as uuidv4 } from 'uuid';
+import { CustomHttpRequestError } from '../../../../../utils/custom_http_request_error';
import { getActionRequestExpiration } from '../../utils';
import { ResponseActionsClientError } from '../errors';
import { stringify } from '../../../../utils/stringify';
@@ -40,8 +41,12 @@ import type {
SuspendProcessActionOutputContent,
LogsEndpointAction,
EndpointActionDataParameterTypes,
+ UploadedFileInfo,
} from '../../../../../../common/endpoint/types';
-import type { CommonResponseActionMethodOptions } from '../lib/types';
+import type {
+ CommonResponseActionMethodOptions,
+ GetFileDownloadMethodResponse,
+} from '../lib/types';
import { DEFAULT_EXECUTE_ACTION_TIMEOUT } from '../../../../../../common/endpoint/service/response_actions/constants';
export class EndpointActionsClient extends ResponseActionsClientImpl {
@@ -342,4 +347,51 @@ export class EndpointActionsClient extends ResponseActionsClientImpl {
throw err;
}
}
+
+ async getFileDownload(actionId: string, fileId: string): Promise {
+ await this.ensureValidActionId(actionId);
+
+ const fleetFiles = await this.options.endpointService.getFleetFromHostFilesClient();
+ const file = await fleetFiles.get(fileId);
+
+ if (file.actionId !== actionId) {
+ throw new CustomHttpRequestError(`Invalid file id [${fileId}] for action [${actionId}]`, 400);
+ }
+
+ return fleetFiles.download(fileId);
+ }
+
+ async getFileInfo(actionId: string, fileId: string): Promise {
+ await this.ensureValidActionId(actionId);
+
+ const fleetFiles = await this.options.endpointService.getFleetFromHostFilesClient();
+ const {
+ name,
+ id,
+ mimeType,
+ size,
+ status,
+ created,
+ agents,
+ actionId: fileActionId,
+ } = await fleetFiles.get(fileId);
+
+ if (fileActionId !== actionId) {
+ throw new ResponseActionsClientError(
+ `Invalid file ID. File [${fileId}] not associated with action ID [${actionId}]`
+ );
+ }
+
+ return {
+ name,
+ id,
+ mimeType,
+ size,
+ status,
+ created,
+ actionId,
+ agentId: agents[0],
+ agentType: this.agentType,
+ };
+ }
}
diff --git a/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/errors.ts b/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/errors.ts
index 3c0190a459e35f..c22045fae97efe 100644
--- a/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/errors.ts
+++ b/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/errors.ts
@@ -54,3 +54,5 @@ export class ResponseActionsConnectorNotConfiguredError extends ResponseActionsC
super(`No stack connector instance configured for [${connectorTypeId}]`, statusCode, meta);
}
}
+
+export class ResponseActionAgentResponseEsDocNotFound extends ResponseActionsClientError {}
diff --git a/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/lib/base_response_actions_client.test.ts b/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/lib/base_response_actions_client.test.ts
index a41e651ee34494..a027f8e662c851 100644
--- a/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/lib/base_response_actions_client.test.ts
+++ b/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/lib/base_response_actions_client.test.ts
@@ -116,6 +116,17 @@ describe('ResponseActionsClientImpl base class', () => {
await expect(responsePromise).rejects.toBeInstanceOf(ResponseActionsNotSupportedError);
await expect(responsePromise).rejects.toHaveProperty('statusCode', 405);
});
+
+ it.each(['getFileDownload', 'getFileInfo'])(
+ 'should throw not implemented error for %s()',
+ async (method) => {
+ // @ts-expect-error ignoring input type to method since they all should throw
+ const responsePromise = baseClassMock[method]({});
+
+ await expect(responsePromise).rejects.toThrow(`Method ${method}() not implemented`);
+ await expect(responsePromise).rejects.toHaveProperty('statusCode', 501);
+ }
+ );
});
describe('#updateCases()', () => {
diff --git a/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/lib/base_response_actions_client.ts b/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/lib/base_response_actions_client.ts
index c784c5e5eef5bb..148f04a587990d 100644
--- a/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/lib/base_response_actions_client.ts
+++ b/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/lib/base_response_actions_client.ts
@@ -13,7 +13,11 @@ import { AttachmentType, ExternalReferenceStorageType } from '@kbn/cases-plugin/
import type { CaseAttachments } from '@kbn/cases-plugin/public/types';
import { i18n } from '@kbn/i18n';
import type { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/types';
-import { fetchActionResponses } from '../../fetch_action_responses';
+import { validateActionId } from '../../utils/validate_action_id';
+import {
+ fetchActionResponses,
+ fetchEndpointActionResponses,
+} from '../../utils/fetch_action_responses';
import { createEsSearchIterable } from '../../../../utils/create_es_search_iterable';
import { categorizeResponseResults, getActionRequestExpiration } from '../../utils';
import { isActionSupportedByAgentType } from '../../../../../../common/endpoint/service/response_actions/is_response_action_supported';
@@ -33,6 +37,7 @@ import type {
CommonResponseActionMethodOptions,
ProcessPendingActionsMethodOptions,
ResponseActionsClient,
+ GetFileDownloadMethodResponse,
} from './types';
import type {
ActionDetails,
@@ -52,6 +57,7 @@ import type {
ResponseActionUploadParameters,
SuspendProcessActionOutputContent,
WithAllKeys,
+ UploadedFileInfo,
} from '../../../../../../common/endpoint/types';
import type {
ExecuteActionRequestBody,
@@ -141,6 +147,13 @@ export type ResponseActionsClientValidateRequestResponse =
error: ResponseActionsClientError;
};
+export interface FetchActionResponseEsDocsResponse<
+ TOutputContent extends EndpointActionResponseDataOutput = EndpointActionResponseDataOutput,
+ TMeta extends {} = {}
+> {
+ [agentId: string]: LogsEndpointActionResponse;
+}
+
/**
* Base class for a Response Actions client
*/
@@ -284,6 +297,38 @@ export abstract class ResponseActionsClientImpl implements ResponseActionsClient
);
}
+ /**
+ * Fetches the Response Action ES response documents for a given action id
+ * @param actionId
+ * @param agentIds
+ * @protected
+ */
+ protected async fetchActionResponseEsDocs<
+ TOutputContent extends EndpointActionResponseDataOutput = EndpointActionResponseDataOutput,
+ TMeta extends {} = {}
+ >(
+ actionId: string,
+ /** Specific Agent IDs to retrieve. default is to retrieve all */
+ agentIds?: string[]
+ ): Promise> {
+ const responseDocs = await fetchEndpointActionResponses({
+ esClient: this.options.esClient,
+ actionIds: [actionId],
+ agentIds,
+ });
+
+ return responseDocs.reduce>(
+ (acc, response) => {
+ const agentId = Array.isArray(response.agent.id) ? response.agent.id[0] : response.agent.id;
+
+ acc[agentId] = response;
+
+ return acc;
+ },
+ {}
+ );
+ }
+
/**
* Provides validations against a response action request and returns the result.
* Checks made should be generic to all response actions and not specific to any one action.
@@ -499,6 +544,10 @@ export abstract class ResponseActionsClientImpl implements ResponseActionsClient
usageService.notifyUsage(featureKey);
}
+ protected async ensureValidActionId(actionId: string): Promise {
+ return validateActionId(this.options.esClient, actionId, this.agentType);
+ }
+
protected fetchAllPendingActions(): AsyncIterable {
const esClient = this.options.esClient;
const query: QueryDslQueryContainer = {
@@ -653,4 +702,15 @@ export abstract class ResponseActionsClientImpl implements ResponseActionsClient
public async processPendingActions(_: ProcessPendingActionsMethodOptions): Promise {
this.log.debug(`#processPendingActions() method is not implemented for ${this.agentType}!`);
}
+
+ public async getFileDownload(
+ actionId: string,
+ fileId: string
+ ): Promise {
+ throw new ResponseActionsClientError(`Method getFileDownload() not implemented`, 501);
+ }
+
+ public async getFileInfo(actionId: string, fileId: string): Promise {
+ throw new ResponseActionsClientError(`Method getFileInfo() not implemented`, 501);
+ }
}
diff --git a/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/lib/types.ts b/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/lib/types.ts
index 8285450298deaa..9cc7f088c38403 100644
--- a/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/lib/types.ts
+++ b/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/lib/types.ts
@@ -5,6 +5,7 @@
* 2.0.
*/
+import type { Readable } from 'stream';
import type {
ActionDetails,
KillOrSuspendProcessRequestBody,
@@ -20,6 +21,7 @@ import type {
ResponseActionUploadParameters,
EndpointActionData,
LogsEndpointActionResponse,
+ UploadedFileInfo,
} from '../../../../../../common/endpoint/types';
import type {
IsolationRouteRequestBody,
@@ -62,6 +64,12 @@ export interface ProcessPendingActionsMethodOptions {
abortSignal: AbortSignal;
}
+export interface GetFileDownloadMethodResponse {
+ stream: Readable;
+ fileName: string;
+ mimeType?: string;
+}
+
/**
* The interface required for a Response Actions provider
*/
@@ -118,4 +126,18 @@ export interface ResponseActionsClient {
* the time of this writing, is being controlled by the background task.
*/
processPendingActions: (options: ProcessPendingActionsMethodOptions) => Promise;
+
+ /**
+ * Retrieve a file for download
+ * @param actionId
+ * @param fileId
+ */
+ getFileDownload(actionId: string, fileId: string): Promise;
+
+ /**
+ * Retrieve info about a file
+ * @param actionId
+ * @param fileId
+ */
+ getFileInfo(actionId: string, fileId: string): Promise;
}
diff --git a/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/mocks.ts b/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/mocks.ts
index 71090d5af98feb..c64b107b86761e 100644
--- a/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/mocks.ts
+++ b/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/mocks.ts
@@ -66,6 +66,8 @@ const createResponseActionClientMock = (): jest.Mocked =>
release: jest.fn().mockReturnValue(Promise.resolve()),
runningProcesses: jest.fn().mockReturnValue(Promise.resolve()),
processPendingActions: jest.fn().mockReturnValue(Promise.resolve()),
+ getFileInfo: jest.fn().mockReturnValue(Promise.resolve()),
+ getFileDownload: jest.fn().mockReturnValue(Promise.resolve()),
};
};
diff --git a/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/sentinelone/sentinel_one_actions_client.test.ts b/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/sentinelone/sentinel_one_actions_client.test.ts
index 9c0e60ec837fef..6fbcb6ff3350b2 100644
--- a/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/sentinelone/sentinel_one_actions_client.test.ts
+++ b/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/sentinelone/sentinel_one_actions_client.test.ts
@@ -17,7 +17,10 @@ import {
ENDPOINT_ACTION_RESPONSES_INDEX_PATTERN,
ENDPOINT_ACTIONS_INDEX,
} from '../../../../../../common/endpoint/constants';
-import type { NormalizedExternalConnectorClient } from '../../..';
+import type {
+ NormalizedExternalConnectorClient,
+ NormalizedExternalConnectorClientExecuteOptions,
+} from '../../..';
import { applyEsClientSearchMock } from '../../../../mocks/utils.mock';
import { SENTINEL_ONE_ACTIVITY_INDEX_PATTERN } from '../../../../../../common';
import { SentinelOneDataGenerator } from '../../../../../../common/endpoint/data_generators/sentinelone_data_generator';
@@ -27,11 +30,18 @@ import type {
LogsEndpointActionResponse,
SentinelOneActivityEsDoc,
SentinelOneIsolationRequestMeta,
+ SentinelOneActivityDataForType80,
+ ResponseActionGetFileOutputContent,
+ ResponseActionGetFileParameters,
+ SentinelOneGetFileRequestMeta,
} from '../../../../../../common/endpoint/types';
-import type { SearchHit } from '@elastic/elasticsearch/lib/api/types';
+import type { SearchHit, SearchResponse } from '@elastic/elasticsearch/lib/api/types';
import type { ResponseActionGetFileRequestBody } from '../../../../../../common/api/endpoint';
-import { SENTINEL_ONE_ZIP_PASSCODE } from '../../../../../../common/endpoint/service/response_actions/sentinel_one';
import { SUB_ACTION } from '@kbn/stack-connectors-plugin/common/sentinelone/constants';
+import { ACTIONS_SEARCH_PAGE_SIZE } from '../../constants';
+import type { ElasticsearchClientMock } from '@kbn/core-elasticsearch-client-server-mocks';
+import { Readable } from 'stream';
+import { RESPONSE_ACTIONS_ZIP_PASSCODE } from '../../../../../../common/endpoint/service/response_actions/constants';
jest.mock('../../action_details_by_id', () => {
const originalMod = jest.requireActual('../../action_details_by_id');
@@ -509,7 +519,7 @@ describe('SentinelOneActionsClient class', () => {
],
},
},
- size: 1000,
+ size: ACTIONS_SEARCH_PAGE_SIZE,
sort: [{ 'sentinel_one.activity.updated_at': { order: 'asc' } }],
});
});
@@ -549,11 +559,172 @@ describe('SentinelOneActionsClient class', () => {
],
},
},
- size: 1000,
+ size: ACTIONS_SEARCH_PAGE_SIZE,
sort: [{ 'sentinel_one.activity.updated_at': { order: 'asc' } }],
});
});
});
+
+ describe('for get-file response action', () => {
+ let actionRequestsSearchResponse: SearchResponse<
+ LogsEndpointAction
+ >;
+
+ beforeEach(() => {
+ const s1DataGenerator = new SentinelOneDataGenerator('seed');
+ actionRequestsSearchResponse = s1DataGenerator.toEsSearchResponse([
+ s1DataGenerator.generateActionEsHit<
+ ResponseActionGetFileParameters,
+ ResponseActionGetFileOutputContent,
+ SentinelOneGetFileRequestMeta
+ >({
+ agent: { id: 'agent-uuid-1' },
+ EndpointActions: { data: { command: 'get-file' } },
+ meta: {
+ agentId: 's1-agent-a',
+ agentUUID: 'agent-uuid-1',
+ hostName: 's1-host-name',
+ commandBatchUuid: 'batch-111',
+ activityId: 'activity-222',
+ },
+ }),
+ ]);
+ const actionResponsesSearchResponse = s1DataGenerator.toEsSearchResponse<
+ LogsEndpointActionResponse | EndpointActionResponse
+ >([]);
+ const s1ActivitySearchResponse = s1DataGenerator.generateActivityEsSearchResponse([
+ s1DataGenerator.generateActivityEsSearchHit({
+ sentinel_one: {
+ activity: {
+ id: 'activity-222',
+ data: s1DataGenerator.generateActivityFetchFileResponseData({
+ flattened: {
+ commandBatchUuid: 'batch-111',
+ },
+ }),
+ agent: {
+ id: 's1-agent-a',
+ },
+ type: 80,
+ },
+ },
+ }),
+ ]);
+
+ applyEsClientSearchMock({
+ esClientMock: classConstructorOptions.esClient,
+ index: ENDPOINT_ACTIONS_INDEX,
+ response: actionRequestsSearchResponse,
+ pitUsage: true,
+ });
+
+ applyEsClientSearchMock({
+ esClientMock: classConstructorOptions.esClient,
+ index: ENDPOINT_ACTION_RESPONSES_INDEX_PATTERN,
+ response: actionResponsesSearchResponse,
+ });
+
+ applyEsClientSearchMock({
+ esClientMock: classConstructorOptions.esClient,
+ index: SENTINEL_ONE_ACTIVITY_INDEX_PATTERN,
+ response: s1ActivitySearchResponse,
+ });
+ });
+
+ it('should search for S1 activity with correct query', async () => {
+ await s1ActionsClient.processPendingActions(processPendingActionsOptions);
+
+ expect(classConstructorOptions.esClient.search).toHaveBeenNthCalledWith(4, {
+ index: SENTINEL_ONE_ACTIVITY_INDEX_PATTERN,
+ size: ACTIONS_SEARCH_PAGE_SIZE,
+ query: {
+ bool: {
+ minimum_should_match: 1,
+ must: [
+ {
+ term: {
+ 'sentinel_one.activity.type': 80,
+ },
+ },
+ ],
+ should: [
+ {
+ bool: {
+ filter: [
+ {
+ term: {
+ 'sentinel_one.activity.agent.id': 's1-agent-a',
+ },
+ },
+ {
+ term: {
+ 'sentinel_one.activity.data.flattened.commandBatchUuid': 'batch-111',
+ },
+ },
+ ],
+ },
+ },
+ ],
+ },
+ },
+ });
+ });
+
+ it('should complete action as a failure if no S1 agentId/commandBatchUuid present in action request doc', async () => {
+ actionRequestsSearchResponse.hits.hits[0]!._source!.meta = {
+ agentId: 's1-agent-a',
+ agentUUID: 'agent-uuid-1',
+ hostName: 's1-host-name',
+ };
+ await s1ActionsClient.processPendingActions(processPendingActionsOptions);
+
+ expect(processPendingActionsOptions.addToQueue).toHaveBeenCalledWith(
+ expect.objectContaining({
+ error: {
+ message:
+ 'Unable to very if action completed. SentinelOne agent id or commandBatchUuid missing on action request document!',
+ },
+ })
+ );
+ });
+
+ it('should generate an action success response doc', async () => {
+ await s1ActionsClient.processPendingActions(processPendingActionsOptions);
+
+ expect(processPendingActionsOptions.addToQueue).toHaveBeenCalledWith({
+ '@timestamp': expect.any(String),
+ EndpointActions: {
+ action_id: '1d6e6796-b0af-496f-92b0-25fcb06db499',
+ completed_at: expect.any(String),
+ data: {
+ command: 'get-file',
+ comment: 'Some description here',
+ output: {
+ content: {
+ code: '',
+ contents: [],
+ zip_size: 0,
+ },
+ type: 'json',
+ },
+ },
+ input_type: 'sentinel_one',
+ started_at: expect.any(String),
+ },
+ agent: {
+ id: 'agent-uuid-1',
+ },
+ error: undefined,
+ meta: {
+ activityLogEntryId: 'activity-222',
+ downloadUrl: '/agents/5173897/uploads/40558796',
+ elasticDocId: '16ae44fc-4be7-446c-8e8f-a5c082dda918',
+ createdAt: expect.any(String),
+ filename: 'file.zip',
+ },
+ });
+ });
+ });
});
describe('#getFile()', () => {
@@ -587,7 +758,7 @@ describe('SentinelOneActionsClient class', () => {
subActionParams: {
agentUUID: '1-2-3',
files: [getFileReqOptions.parameters.path],
- zipPassCode: SENTINEL_ONE_ZIP_PASSCODE,
+ zipPassCode: RESPONSE_ACTIONS_ZIP_PASSCODE.sentinel_one,
},
},
});
@@ -769,4 +940,183 @@ describe('SentinelOneActionsClient class', () => {
expect(classConstructorOptions.casesClient?.attachments.bulkCreate).toHaveBeenCalled();
});
});
+
+ describe('#getFileInfo()', () => {
+ beforeEach(() => {
+ // @ts-expect-error updating readonly attribute
+ classConstructorOptions.endpointService.experimentalFeatures.responseActionsSentinelOneGetFileEnabled =
+ true;
+ });
+
+ it('should throw error if feature flag is disabled', async () => {
+ // @ts-expect-error updating readonly attribute
+ classConstructorOptions.endpointService.experimentalFeatures.responseActionsSentinelOneGetFileEnabled =
+ false;
+
+ await expect(s1ActionsClient.getFileInfo('acb', '123')).rejects.toThrow(
+ 'File downloads are not supported for sentinel_one agent type. Feature disabled'
+ );
+ });
+
+ it('should throw error if action id is not for an agent type of sentinelOne', async () => {
+ applyEsClientSearchMock({
+ esClientMock: classConstructorOptions.esClient as ElasticsearchClientMock,
+ index: ENDPOINT_ACTIONS_INDEX,
+ response: SentinelOneDataGenerator.toEsSearchResponse([]),
+ });
+
+ await expect(s1ActionsClient.getFileInfo('abc', '123')).rejects.toThrow(
+ 'Action id [abc] not found with an agent type of [sentinel_one]'
+ );
+ });
+
+ it('should return file info with with status of AWAITING_UPLOAD if action is still pending', async () => {
+ applyEsClientSearchMock({
+ esClientMock: classConstructorOptions.esClient as ElasticsearchClientMock,
+ index: ENDPOINT_ACTION_RESPONSES_INDEX_PATTERN,
+ response: SentinelOneDataGenerator.toEsSearchResponse([]),
+ });
+
+ await expect(s1ActionsClient.getFileInfo('abc', '123')).resolves.toEqual({
+ actionId: 'abc',
+ agentId: '123',
+ agentType: 'sentinel_one',
+ created: '',
+ id: '123',
+ mimeType: '',
+ name: '',
+ size: 0,
+ status: 'AWAITING_UPLOAD',
+ });
+ });
+
+ it('should return expected file information', async () => {
+ applyEsClientSearchMock({
+ esClientMock: classConstructorOptions.esClient as ElasticsearchClientMock,
+ index: ENDPOINT_ACTION_RESPONSES_INDEX_PATTERN,
+ response: SentinelOneDataGenerator.toEsSearchResponse([]),
+ });
+ });
+ });
+
+ describe('#getFileDownload()', () => {
+ let s1DataGenerator: SentinelOneDataGenerator;
+
+ beforeEach(() => {
+ s1DataGenerator = new SentinelOneDataGenerator('seed');
+
+ // @ts-expect-error updating readonly attribute
+ classConstructorOptions.endpointService.experimentalFeatures.responseActionsSentinelOneGetFileEnabled =
+ true;
+
+ const esHit = s1DataGenerator.generateResponseEsHit({
+ agent: { id: '123' },
+ EndpointActions: { data: { command: 'get-file' } },
+ meta: {
+ activityLogEntryId: 'activity-1',
+ elasticDocId: 'esdoc-1',
+ downloadUrl: '/some/url',
+ createdAt: '2024-05-09',
+ filename: 'foo.zip',
+ },
+ });
+
+ applyEsClientSearchMock({
+ esClientMock: classConstructorOptions.esClient,
+ index: ENDPOINT_ACTION_RESPONSES_INDEX_PATTERN,
+ response: s1DataGenerator.toEsSearchResponse([esHit]),
+ });
+
+ (connectorActionsMock.execute as jest.Mock).mockImplementation(
+ (options: NormalizedExternalConnectorClientExecuteOptions) => {
+ if (options.params.subAction === SUB_ACTION.DOWNLOAD_AGENT_FILE) {
+ return {
+ data: Readable.from(['test']),
+ };
+ }
+ }
+ );
+ });
+
+ it('should throw error if feature flag is disabled', async () => {
+ // @ts-expect-error updating readonly attribute
+ classConstructorOptions.endpointService.experimentalFeatures.responseActionsSentinelOneGetFileEnabled =
+ false;
+
+ await expect(s1ActionsClient.getFileDownload('acb', '123')).rejects.toThrow(
+ 'File downloads are not supported for sentinel_one agent type. Feature disabled'
+ );
+ });
+
+ it('should throw error if action id is not for an agent type of sentinelOne', async () => {
+ applyEsClientSearchMock({
+ esClientMock: classConstructorOptions.esClient as ElasticsearchClientMock,
+ index: ENDPOINT_ACTIONS_INDEX,
+ response: SentinelOneDataGenerator.toEsSearchResponse([]),
+ });
+
+ await expect(s1ActionsClient.getFileDownload('abc', '123')).rejects.toThrow(
+ 'Action id [abc] not found with an agent type of [sentinel_one]'
+ );
+ });
+
+ it('should throw error if action is still pending for the given agent id', async () => {
+ applyEsClientSearchMock({
+ esClientMock: classConstructorOptions.esClient,
+ index: ENDPOINT_ACTION_RESPONSES_INDEX_PATTERN,
+ response: s1DataGenerator.toEsSearchResponse([]),
+ });
+ await expect(s1ActionsClient.getFileDownload('abc', '123')).rejects.toThrow(
+ 'Action ID [abc] for agent ID [abc] is still pending'
+ );
+ });
+
+ it('should throw error if the action response ES Doc is missing required data', async () => {
+ applyEsClientSearchMock({
+ esClientMock: classConstructorOptions.esClient,
+ index: ENDPOINT_ACTION_RESPONSES_INDEX_PATTERN,
+ response: s1DataGenerator.toEsSearchResponse([
+ s1DataGenerator.generateResponseEsHit({
+ agent: { id: '123' },
+ EndpointActions: { data: { command: 'get-file' } },
+ meta: { activityLogEntryId: undefined },
+ }),
+ ]),
+ });
+
+ await expect(s1ActionsClient.getFileDownload('abc', '123')).rejects.toThrow(
+ 'Unable to retrieve file from SentinelOne. Response ES document is missing [meta.activityLogEntryId]'
+ );
+ });
+
+ it('should call SentinelOne connector to get file download Readable stream', async () => {
+ await s1ActionsClient.getFileDownload('abc', '123');
+
+ expect(connectorActionsMock.execute).toHaveBeenCalledWith({
+ params: {
+ subAction: 'downloadAgentFile',
+ subActionParams: {
+ activityId: 'activity-1',
+ agentUUID: '123',
+ },
+ },
+ });
+ });
+
+ it('should throw an error if call to SentinelOne did not return a Readable stream', async () => {
+ (connectorActionsMock.execute as jest.Mock).mockReturnValue({ data: undefined });
+
+ await expect(s1ActionsClient.getFileDownload('abc', '123')).rejects.toThrow(
+ 'Unable to establish a readable stream for file with SentinelOne'
+ );
+ });
+
+ it('should return expected data', async () => {
+ await expect(s1ActionsClient.getFileDownload('abc', '123')).resolves.toEqual({
+ stream: expect.any(Readable),
+ fileName: 'foo.zip',
+ mimeType: undefined,
+ });
+ });
+ });
});
diff --git a/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/sentinelone/sentinel_one_actions_client.ts b/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/sentinelone/sentinel_one_actions_client.ts
index 89d35353ed1d0a..a757eb16b63bd9 100644
--- a/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/sentinelone/sentinel_one_actions_client.ts
+++ b/x-pack/plugins/security_solution/server/endpoint/services/actions/clients/sentinelone/sentinel_one_actions_client.ts
@@ -12,17 +12,19 @@ import {
import { groupBy } from 'lodash';
import type { ActionTypeExecutorResult } from '@kbn/actions-plugin/common';
import type {
- SentinelOneGetAgentsParams,
- SentinelOneGetAgentsResponse,
SentinelOneGetActivitiesParams,
SentinelOneGetActivitiesResponse,
+ SentinelOneGetAgentsParams,
+ SentinelOneGetAgentsResponse,
+ SentinelOneDownloadAgentFileParams,
} from '@kbn/stack-connectors-plugin/common/sentinelone/types';
import type {
QueryDslQueryContainer,
SearchHit,
SearchRequest,
} from '@elastic/elasticsearch/lib/api/types';
-import { SENTINEL_ONE_ZIP_PASSCODE } from '../../../../../../common/endpoint/service/response_actions/sentinel_one';
+import type { Readable } from 'stream';
+import { ACTIONS_SEARCH_PAGE_SIZE } from '../../constants';
import type {
NormalizedExternalConnectorClient,
NormalizedExternalConnectorClientExecuteOptions,
@@ -31,14 +33,15 @@ import { SENTINEL_ONE_ACTIVITY_INDEX_PATTERN } from '../../../../../../common';
import { catchAndWrapError } from '../../../../utils';
import type {
CommonResponseActionMethodOptions,
+ GetFileDownloadMethodResponse,
ProcessPendingActionsMethodOptions,
-} from '../../..';
+} from '../lib/types';
import type {
ResponseActionAgentType,
ResponseActionsApiCommandNames,
} from '../../../../../../common/endpoint/service/response_actions/constants';
import { stringify } from '../../../../utils/stringify';
-import { ResponseActionsClientError } from '../errors';
+import { ResponseActionAgentResponseEsDocNotFound, ResponseActionsClientError } from '../errors';
import type {
ActionDetails,
EndpointActionDataParameterTypes,
@@ -48,10 +51,13 @@ import type {
ResponseActionGetFileOutputContent,
ResponseActionGetFileParameters,
SentinelOneActionRequestCommonMeta,
+ SentinelOneActivityDataForType80,
SentinelOneActivityEsDoc,
SentinelOneGetFileRequestMeta,
+ SentinelOneGetFileResponseMeta,
SentinelOneIsolationRequestMeta,
SentinelOneIsolationResponseMeta,
+ UploadedFileInfo,
} from '../../../../../../common/endpoint/types';
import type {
IsolationRouteRequestBody,
@@ -63,6 +69,7 @@ import type {
ResponseActionsClientWriteActionRequestToEndpointIndexOptions,
} from '../lib/base_response_actions_client';
import { ResponseActionsClientImpl } from '../lib/base_response_actions_client';
+import { RESPONSE_ACTIONS_ZIP_PASSCODE } from '../../../../../../common/endpoint/service/response_actions/constants';
export type SentinelOneActionsClientOptions = ResponseActionsClientOptions & {
connectorActions: NormalizedExternalConnectorClient;
@@ -393,7 +400,7 @@ export class SentinelOneActionsClient extends ResponseActionsClientImpl {
await this.sendAction(SUB_ACTION.FETCH_AGENT_FILES, {
agentUUID: actionRequest.endpoint_ids[0],
files: [actionRequest.parameters.path],
- zipPassCode: SENTINEL_ONE_ZIP_PASSCODE,
+ zipPassCode: RESPONSE_ACTIONS_ZIP_PASSCODE.sentinel_one,
});
} catch (err) {
error = err;
@@ -460,6 +467,95 @@ export class SentinelOneActionsClient extends ResponseActionsClientImpl {
).actionDetails;
}
+ async getFileInfo(actionId: string, agentId: string): Promise {
+ if (
+ !this.options.endpointService.experimentalFeatures.responseActionsSentinelOneGetFileEnabled
+ ) {
+ throw new ResponseActionsClientError(
+ `File downloads are not supported for ${this.agentType} agent type. Feature disabled`,
+ 400
+ );
+ }
+ await this.ensureValidActionId(actionId);
+
+ const fileInfo: UploadedFileInfo = {
+ actionId,
+ agentId,
+ id: agentId,
+ agentType: this.agentType,
+ status: 'AWAITING_UPLOAD',
+ created: '',
+ name: '',
+ size: 0,
+ mimeType: '',
+ };
+
+ try {
+ const agentResponse = await this.fetchGetFileResponseEsDocForAgentId(actionId, agentId);
+
+ // Unfortunately, there is no way to determine if a file is still available in SentinelOne without actually
+ // calling the download API, which would return the following error:
+ // { "errors":[ {
+ // "code":4100010,
+ // "detail":"The requested files do not exist. Fetched files are deleted after 3 days, or earlier if more than 30 files are fetched.",
+ // "title":"Resource not found"
+ // } ] }
+ fileInfo.status = 'READY';
+ fileInfo.created = agentResponse.meta?.createdAt ?? '';
+ fileInfo.name = agentResponse.meta?.filename ?? '';
+ fileInfo.mimeType = 'application/octet-stream';
+ } catch (e) {
+ // Ignore "no response doc" error for the agent and just return the file info with the status of 'AWAITING_UPLOAD'
+ if (!(e instanceof ResponseActionAgentResponseEsDocNotFound)) {
+ throw e;
+ }
+ }
+
+ return fileInfo;
+ }
+
+ async getFileDownload(actionId: string, agentId: string): Promise {
+ if (
+ !this.options.endpointService.experimentalFeatures.responseActionsSentinelOneGetFileEnabled
+ ) {
+ throw new ResponseActionsClientError(
+ `File downloads are not supported for ${this.agentType} agent type. Feature disabled`,
+ 400
+ );
+ }
+
+ await this.ensureValidActionId(actionId);
+
+ const agentResponse = await this.fetchGetFileResponseEsDocForAgentId(actionId, agentId);
+
+ if (!agentResponse.meta?.activityLogEntryId) {
+ throw new ResponseActionsClientError(
+ `Unable to retrieve file from SentinelOne. Response ES document is missing [meta.activityLogEntryId]`
+ );
+ }
+
+ const downloadAgentFileMethodOptions: SentinelOneDownloadAgentFileParams = {
+ agentUUID: agentId,
+ activityId: agentResponse.meta?.activityLogEntryId,
+ };
+ const { data } = await this.sendAction(
+ SUB_ACTION.DOWNLOAD_AGENT_FILE,
+ downloadAgentFileMethodOptions
+ );
+
+ if (!data) {
+ throw new ResponseActionsClientError(
+ `Unable to establish a readable stream for file with SentinelOne`
+ );
+ }
+
+ return {
+ stream: data,
+ fileName: agentResponse.meta.filename,
+ mimeType: undefined,
+ };
+ }
+
async processPendingActions({
abortSignal,
addToQueue,
@@ -495,11 +591,58 @@ export class SentinelOneActionsClient extends ResponseActionsClientImpl {
}
}
break;
+
+ case 'get-file':
+ {
+ const responseDocsForGetFile = await this.checkPendingGetFileActions(
+ typePendingActions as Array<
+ LogsEndpointAction<
+ ResponseActionGetFileParameters,
+ ResponseActionGetFileOutputContent,
+ SentinelOneGetFileRequestMeta
+ >
+ >
+ );
+ if (responseDocsForGetFile.length) {
+ addToQueue(...responseDocsForGetFile);
+ }
+ }
+ break;
}
}
}
}
+ private async fetchGetFileResponseEsDocForAgentId(
+ actionId: string,
+ agentId: string
+ ): Promise<
+ LogsEndpointActionResponse
+ > {
+ const agentResponse = (
+ await this.fetchActionResponseEsDocs<
+ ResponseActionGetFileOutputContent,
+ SentinelOneGetFileResponseMeta
+ >(actionId, [agentId])
+ )[agentId];
+
+ if (!agentResponse) {
+ throw new ResponseActionAgentResponseEsDocNotFound(
+ `Action ID [${actionId}] for agent ID [${actionId}] is still pending`,
+ 404
+ );
+ }
+
+ if (agentResponse.EndpointActions.data.command !== 'get-file') {
+ throw new ResponseActionsClientError(
+ `Invalid action ID [${actionId}] - Not a get-file action: [${agentResponse.EndpointActions.data.command}]`,
+ 400
+ );
+ }
+
+ return agentResponse;
+ }
+
/**
* Checks if the provided Isolate or Unisolate actions are complete and if so, then it builds the Response
* document for them and returns it. (NOTE: the response is NOT written to ES - only returned)
@@ -617,7 +760,7 @@ export class SentinelOneActionsClient extends ResponseActionsClientImpl {
// due to use of `collapse
_source: false,
sort: [{ 'sentinel_one.activity.updated_at': { order: 'asc' } }],
- size: 1000,
+ size: ACTIONS_SEARCH_PAGE_SIZE,
};
this.log.debug(
@@ -691,4 +834,180 @@ export class SentinelOneActionsClient extends ResponseActionsClientImpl {
return completedResponses;
}
+
+ private async checkPendingGetFileActions(
+ actionRequests: Array<
+ LogsEndpointAction<
+ ResponseActionGetFileParameters,
+ ResponseActionGetFileOutputContent,
+ SentinelOneGetFileRequestMeta
+ >
+ >
+ ): Promise {
+ const warnings: string[] = [];
+ const completedResponses: LogsEndpointActionResponse[] = [];
+ const actionsByAgentAndBatchId: {
+ [agentIdAndCommandBatchUuid: string]: LogsEndpointAction<
+ ResponseActionGetFileParameters,
+ ResponseActionGetFileOutputContent,
+ SentinelOneGetFileRequestMeta
+ >;
+ } = {};
+ // Utility to create the key to lookup items in the `actionByAgentAndBatchId` grouping above
+ const getLookupKey = (agentId: string, commandBatchUuid: string): string =>
+ `${agentId}:${commandBatchUuid}`;
+ const searchRequestOptions: SearchRequest = {
+ index: SENTINEL_ONE_ACTIVITY_INDEX_PATTERN,
+ size: ACTIONS_SEARCH_PAGE_SIZE,
+ query: {
+ bool: {
+ must: [
+ {
+ term: {
+ // Activity Types can be retrieved from S1 via API: `/web/api/v2.1/activities/types`
+ // {
+ // "action": "Agent Uploaded Fetched Files",
+ // "descriptionTemplate": "Agent {{ computer_name }} ({{ external_ip }}) successfully uploaded {{ filename }}.",
+ // "id": 80
+ // },
+ 'sentinel_one.activity.type': 80,
+ },
+ },
+ ],
+ should: actionRequests.reduce((acc, action) => {
+ const s1AgentId = action.meta?.agentId;
+ const s1CommandBatchUUID = action.meta?.commandBatchUuid;
+
+ if (s1AgentId && s1CommandBatchUUID) {
+ actionsByAgentAndBatchId[getLookupKey(s1AgentId, s1CommandBatchUUID)] = action;
+
+ acc.push({
+ bool: {
+ filter: [
+ { term: { 'sentinel_one.activity.agent.id': s1AgentId } },
+ {
+ term: {
+ 'sentinel_one.activity.data.flattened.commandBatchUuid': s1CommandBatchUUID,
+ },
+ },
+ ],
+ },
+ });
+ } else {
+ // This is an edge case and should never happen. But just in case :-)
+ warnings.push(
+ `get-file response action ID [${action.EndpointActions.action_id}] missing SentinelOne agent ID or commandBatchUuid value(s). Unable to check on it's status - forcing it to complete as a failure.`
+ );
+
+ completedResponses.push(
+ this.buildActionResponseEsDoc<{}, {}>({
+ actionId: action.EndpointActions.action_id,
+ agentId: Array.isArray(action.agent.id) ? action.agent.id[0] : action.agent.id,
+ data: { command: 'get-file' },
+ error: {
+ message: `Unable to very if action completed. SentinelOne agent id or commandBatchUuid missing on action request document!`,
+ },
+ })
+ );
+ }
+
+ return acc;
+ }, [] as QueryDslQueryContainer[]),
+ minimum_should_match: 1,
+ },
+ },
+ };
+
+ if (Object.keys(actionsByAgentAndBatchId).length) {
+ this.log.debug(
+ `searching for get-file responses from [${SENTINEL_ONE_ACTIVITY_INDEX_PATTERN}] index with:\n${stringify(
+ searchRequestOptions,
+ 15
+ )}`
+ );
+
+ const searchResults = await this.options.esClient
+ .search>(searchRequestOptions)
+ .catch(catchAndWrapError);
+
+ this.log.debug(
+ `Search results for SentinelOne get-file activity documents:\n${stringify(searchResults)}`
+ );
+
+ for (const s1Hit of searchResults.hits.hits) {
+ const s1ActivityDoc = s1Hit._source;
+ const s1AgentId = s1ActivityDoc?.sentinel_one.activity.agent.id;
+ const s1CommandBatchUuid =
+ s1ActivityDoc?.sentinel_one.activity.data.flattened.commandBatchUuid ?? '';
+ const activityLogEntryId = s1ActivityDoc?.sentinel_one.activity.id ?? '';
+
+ if (s1AgentId && s1CommandBatchUuid) {
+ const actionRequest =
+ actionsByAgentAndBatchId[getLookupKey(s1AgentId, s1CommandBatchUuid)];
+
+ if (actionRequest) {
+ const downloadUrl = s1ActivityDoc?.sentinel_one.activity.data.downloaded.url ?? '';
+ const error = !downloadUrl
+ ? {
+ message: `File retrieval failed (No download URL defined in SentinelOne activity log id [${activityLogEntryId}])`,
+ }
+ : undefined;
+
+ completedResponses.push(
+ this.buildActionResponseEsDoc<
+ ResponseActionGetFileOutputContent,
+ SentinelOneGetFileResponseMeta
+ >({
+ actionId: actionRequest.EndpointActions.action_id,
+ agentId: Array.isArray(actionRequest.agent.id)
+ ? actionRequest.agent.id[0]
+ : actionRequest.agent.id,
+ data: {
+ command: 'get-file',
+ comment: s1ActivityDoc?.sentinel_one.activity.description.primary ?? '',
+ output: {
+ type: 'json',
+ content: {
+ // code applies only to Endpoint agents
+ code: '',
+ // We don't know the file size for S1 retrieved files
+ zip_size: 0,
+ // We don't have the contents of the zip file for S1
+ contents: [],
+ },
+ },
+ },
+ error,
+ meta: {
+ activityLogEntryId,
+ elasticDocId: s1Hit._id,
+ downloadUrl,
+ createdAt: s1ActivityDoc?.sentinel_one.activity.updated_at ?? '',
+ filename: s1ActivityDoc?.sentinel_one.activity.data.flattened.filename ?? '',
+ },
+ })
+ );
+ } else {
+ warnings.push(
+ `Activity log entry ${s1Hit._id} was a matched, but no action request for it (should not happen)`
+ );
+ }
+ }
+ }
+ } else {
+ this.log.debug(`Nothing to search for. No pending get-file actions`);
+ }
+
+ this.log.debug(
+ `${completedResponses.length} get-file action responses generated:\n${stringify(
+ completedResponses
+ )}`
+ );
+
+ if (warnings.length > 0) {
+ this.log.warn(warnings.join('\n'));
+ }
+
+ return completedResponses;
+ }
}
diff --git a/x-pack/plugins/security_solution/server/endpoint/services/actions/index.ts b/x-pack/plugins/security_solution/server/endpoint/services/actions/index.ts
index e9f0ed89eb2ac7..1490da7b018a09 100644
--- a/x-pack/plugins/security_solution/server/endpoint/services/actions/index.ts
+++ b/x-pack/plugins/security_solution/server/endpoint/services/actions/index.ts
@@ -9,5 +9,5 @@ export * from './actions';
export { getActionDetailsById } from './action_details_by_id';
export { getActionList, getActionListByStatus } from './action_list';
export { getPendingActionsSummary } from './pending_actions_summary';
-export { validateActionId } from './validate_action_id';
export * from './clients';
+export * from './utils';
diff --git a/x-pack/plugins/security_solution/server/endpoint/services/actions/fetch_action_responses.test.ts b/x-pack/plugins/security_solution/server/endpoint/services/actions/utils/fetch_action_responses.test.ts
similarity index 96%
rename from x-pack/plugins/security_solution/server/endpoint/services/actions/fetch_action_responses.test.ts
rename to x-pack/plugins/security_solution/server/endpoint/services/actions/utils/fetch_action_responses.test.ts
index f6cde845fc8299..6c366142adfb9b 100644
--- a/x-pack/plugins/security_solution/server/endpoint/services/actions/fetch_action_responses.test.ts
+++ b/x-pack/plugins/security_solution/server/endpoint/services/actions/utils/fetch_action_responses.test.ts
@@ -5,14 +5,14 @@
* 2.0.
*/
-import { applyActionListEsSearchMock } from './mocks';
+import { applyActionListEsSearchMock } from '../mocks';
import { elasticsearchServiceMock } from '@kbn/core-elasticsearch-server-mocks';
import type { ElasticsearchClientMock } from '@kbn/core-elasticsearch-client-server-mocks';
import { fetchActionResponses } from './fetch_action_responses';
-import { BaseDataGenerator } from '../../../../common/endpoint/data_generators/base_data_generator';
+import { BaseDataGenerator } from '../../../../../common/endpoint/data_generators/base_data_generator';
import { AGENT_ACTIONS_RESULTS_INDEX } from '@kbn/fleet-plugin/common';
-import { ENDPOINT_ACTION_RESPONSES_INDEX_PATTERN } from '../../../../common/endpoint/constants';
-import { ACTIONS_SEARCH_PAGE_SIZE } from './constants';
+import { ENDPOINT_ACTION_RESPONSES_INDEX_PATTERN } from '../../../../../common/endpoint/constants';
+import { ACTIONS_SEARCH_PAGE_SIZE } from '../constants';
describe('fetchActionResponses()', () => {
let esClientMock: ElasticsearchClientMock;
diff --git a/x-pack/plugins/security_solution/server/endpoint/services/actions/fetch_action_responses.ts b/x-pack/plugins/security_solution/server/endpoint/services/actions/utils/fetch_action_responses.ts
similarity index 50%
rename from x-pack/plugins/security_solution/server/endpoint/services/actions/fetch_action_responses.ts
rename to x-pack/plugins/security_solution/server/endpoint/services/actions/utils/fetch_action_responses.ts
index 7ed0bad9a42be9..eb49c6c67216e8 100644
--- a/x-pack/plugins/security_solution/server/endpoint/services/actions/fetch_action_responses.ts
+++ b/x-pack/plugins/security_solution/server/endpoint/services/actions/utils/fetch_action_responses.ts
@@ -11,10 +11,11 @@ import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import type {
EndpointActionResponse,
LogsEndpointActionResponse,
-} from '../../../../common/endpoint/types';
-import { ACTIONS_SEARCH_PAGE_SIZE } from './constants';
-import { catchAndWrapError } from '../../utils';
-import { ENDPOINT_ACTION_RESPONSES_INDEX_PATTERN } from '../../../../common/endpoint/constants';
+ EndpointActionResponseDataOutput,
+} from '../../../../../common/endpoint/types';
+import { ACTIONS_SEARCH_PAGE_SIZE } from '../constants';
+import { catchAndWrapError } from '../../../utils';
+import { ENDPOINT_ACTION_RESPONSES_INDEX_PATTERN } from '../../../../../common/endpoint/constants';
interface FetchActionResponsesOptions {
esClient: ElasticsearchClient;
@@ -28,28 +29,35 @@ interface FetchActionResponsesResult {
data: Array>;
}
+/** @private */
+const buildSearchQuery = (
+ actionIds: string[] = [],
+ agentIds: string[] = []
+): estypes.QueryDslQueryContainer => {
+ const filter: estypes.QueryDslQueryContainer[] = [];
+ const query: estypes.QueryDslQueryContainer = { bool: { filter } };
+
+ if (agentIds?.length) {
+ filter.push({ terms: { agent_id: agentIds } });
+ }
+ if (actionIds?.length) {
+ filter.push({ terms: { action_id: actionIds } });
+ }
+
+ return query;
+};
+
/**
- * Fetch Response Action responses
+ * Fetch Response Action responses from both the Endpoint and the Fleet indexes
*/
export const fetchActionResponses = async ({
esClient,
actionIds = [],
agentIds = [],
}: FetchActionResponsesOptions): Promise => {
- const filter = [];
-
- if (agentIds?.length) {
- filter.push({ terms: { agent_id: agentIds } });
- }
- if (actionIds.length) {
- filter.push({ terms: { action_id: actionIds } });
- }
+ const query = buildSearchQuery(actionIds, agentIds);
- const query: estypes.QueryDslQueryContainer = {
- bool: {
- filter,
- },
- };
+ // TODO:PT refactor this method to use new `fetchFleetActionResponses()` and `fetchEndpointActionResponses()`
// Get the Action Response(s) from both the Fleet action response index and the Endpoint
// action response index.
@@ -87,3 +95,64 @@ export const fetchActionResponses = async ({
data: [...(fleetResponses?.hits?.hits ?? []), ...(endpointResponses?.hits?.hits ?? [])],
};
};
+
+/**
+ * Fetch Response Action response documents from the Endpoint index
+ * @param esClient
+ * @param actionIds
+ * @param agentIds
+ */
+export const fetchEndpointActionResponses = async <
+ TOutputContent extends EndpointActionResponseDataOutput = EndpointActionResponseDataOutput,
+ TResponseMeta extends {} = {}
+>({
+ esClient,
+ actionIds,
+ agentIds,
+}: FetchActionResponsesOptions): Promise<
+ Array>
+> => {
+ const searchResponse = await esClient
+ .search>(
+ {
+ index: ENDPOINT_ACTION_RESPONSES_INDEX_PATTERN,
+ size: ACTIONS_SEARCH_PAGE_SIZE,
+ query: buildSearchQuery(actionIds, agentIds),
+ },
+ { ignore: [404] }
+ )
+ .catch(catchAndWrapError);
+
+ return searchResponse.hits.hits.map((esHit) => {
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ return esHit._source!;
+ });
+};
+
+/**
+ * Fetch Response Action response documents from the Fleet index
+ * @param esClient
+ * @param actionIds
+ * @param agentIds
+ */
+export const fetchFleetActionResponses = async ({
+ esClient,
+ actionIds,
+ agentIds,
+}: FetchActionResponsesOptions): Promise => {
+ const searchResponse = await esClient
+ .search(
+ {
+ index: AGENT_ACTIONS_RESULTS_INDEX,
+ size: ACTIONS_SEARCH_PAGE_SIZE,
+ query: buildSearchQuery(actionIds, agentIds),
+ },
+ { ignore: [404] }
+ )
+ .catch(catchAndWrapError);
+
+ return searchResponse.hits.hits.map((esHit) => {
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ return esHit._source!;
+ });
+};
diff --git a/x-pack/plugins/security_solution/server/endpoint/services/actions/utils/get_action_agent_type.test.ts b/x-pack/plugins/security_solution/server/endpoint/services/actions/utils/get_action_agent_type.test.ts
new file mode 100644
index 00000000000000..2d933b2c1d8f78
--- /dev/null
+++ b/x-pack/plugins/security_solution/server/endpoint/services/actions/utils/get_action_agent_type.test.ts
@@ -0,0 +1,41 @@
+/*
+ * 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 { elasticsearchServiceMock } from '@kbn/core-elasticsearch-server-mocks';
+import type { ElasticsearchClientMock } from '@kbn/core-elasticsearch-client-server-mocks';
+import { getActionAgentType } from './get_action_agent_type';
+import { applyEsClientSearchMock } from '../../../mocks/utils.mock';
+import { ENDPOINT_ACTIONS_INDEX } from '../../../../../common/endpoint/constants';
+import { EndpointActionGenerator } from '../../../../../common/endpoint/data_generators/endpoint_action_generator';
+
+describe('getActionAgentType()', () => {
+ let esClientMock: ElasticsearchClientMock;
+
+ beforeEach(() => {
+ esClientMock = elasticsearchServiceMock.createScopedClusterClient().asInternalUser;
+ });
+
+ it('should throw error if action is not found', async () => {
+ await expect(getActionAgentType(esClientMock, '123')).rejects.toThrow(
+ 'Action id [123] not found'
+ );
+ });
+
+ it('should return agent type', async () => {
+ const generator = new EndpointActionGenerator('seed');
+
+ applyEsClientSearchMock({
+ esClientMock,
+ index: ENDPOINT_ACTIONS_INDEX,
+ response: EndpointActionGenerator.toEsSearchResponse([generator.generateActionEsHit()]),
+ });
+
+ await expect(getActionAgentType(esClientMock, '123')).resolves.toEqual({
+ agentType: 'endpoint',
+ });
+ });
+});
diff --git a/x-pack/plugins/security_solution/server/endpoint/services/actions/utils/get_action_agent_type.ts b/x-pack/plugins/security_solution/server/endpoint/services/actions/utils/get_action_agent_type.ts
new file mode 100644
index 00000000000000..f87deeb729660e
--- /dev/null
+++ b/x-pack/plugins/security_solution/server/endpoint/services/actions/utils/get_action_agent_type.ts
@@ -0,0 +1,42 @@
+/*
+ * 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 { ElasticsearchClient } from '@kbn/core-elasticsearch-server';
+import type { LogsEndpointAction } from '../../../../../common/endpoint/types';
+import { ENDPOINT_ACTIONS_INDEX } from '../../../../../common/endpoint/constants';
+import { catchAndWrapError } from '../../../utils';
+import { NotFoundError } from '../../../errors';
+import type { ResponseActionAgentType } from '../../../../../common/endpoint/service/response_actions/constants';
+
+/**
+ * Returns the `agentType` for a given response action
+ */
+export const getActionAgentType = async (
+ esClient: ElasticsearchClient,
+ actionId: string
+): Promise<{ agentType: ResponseActionAgentType }> => {
+ const response = await esClient
+ .search({
+ index: ENDPOINT_ACTIONS_INDEX,
+ body: {
+ query: {
+ bool: {
+ filter: [{ term: { action_id: actionId } }],
+ },
+ },
+ },
+ _source: ['EndpointActions.input_type'],
+ size: 1,
+ })
+ .catch(catchAndWrapError);
+
+ if (!response?.hits?.hits[0]._source?.EndpointActions.input_type) {
+ throw new NotFoundError(`Action id [${actionId}] not found`, response);
+ }
+
+ return { agentType: response.hits.hits[0]._source.EndpointActions.input_type };
+};
diff --git a/x-pack/plugins/security_solution/server/endpoint/services/actions/utils/index.ts b/x-pack/plugins/security_solution/server/endpoint/services/actions/utils/index.ts
new file mode 100644
index 00000000000000..354086031b3dd0
--- /dev/null
+++ b/x-pack/plugins/security_solution/server/endpoint/services/actions/utils/index.ts
@@ -0,0 +1,11 @@
+/*
+ * 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.
+ */
+
+export * from './utils';
+export * from './fetch_action_responses';
+export * from './validate_action_id';
+export * from './get_action_agent_type';
diff --git a/x-pack/plugins/security_solution/server/endpoint/services/actions/utils.test.ts b/x-pack/plugins/security_solution/server/endpoint/services/actions/utils/utils.test.ts
similarity index 98%
rename from x-pack/plugins/security_solution/server/endpoint/services/actions/utils.test.ts
rename to x-pack/plugins/security_solution/server/endpoint/services/actions/utils/utils.test.ts
index e63caeb222be8c..a2e69696b557cb 100644
--- a/x-pack/plugins/security_solution/server/endpoint/services/actions/utils.test.ts
+++ b/x-pack/plugins/security_solution/server/endpoint/services/actions/utils/utils.test.ts
@@ -6,8 +6,8 @@
*/
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
-import { EndpointActionGenerator } from '../../../../common/endpoint/data_generators/endpoint_action_generator';
-import { FleetActionGenerator } from '../../../../common/endpoint/data_generators/fleet_action_generator';
+import { EndpointActionGenerator } from '../../../../../common/endpoint/data_generators/endpoint_action_generator';
+import { FleetActionGenerator } from '../../../../../common/endpoint/data_generators/fleet_action_generator';
import type { NormalizedActionRequest } from './utils';
import {
categorizeActionResults,
@@ -31,11 +31,11 @@ import type {
LogsEndpointAction,
LogsEndpointActionResponse,
EndpointActionResponseDataOutput,
-} from '../../../../common/endpoint/types';
+} from '../../../../../common/endpoint/types';
import { v4 as uuidv4 } from 'uuid';
-import type { Results } from '../../routes/actions/mocks';
-import { mockAuditLogSearchResult } from '../../routes/actions/mocks';
-import { ActivityLogItemTypes } from '../../../../common/endpoint/types';
+import type { Results } from '../../../routes/actions/mocks';
+import { mockAuditLogSearchResult } from '../../../routes/actions/mocks';
+import { ActivityLogItemTypes } from '../../../../../common/endpoint/types';
describe('When using Actions service utilities', () => {
let fleetActionGenerator: FleetActionGenerator;
diff --git a/x-pack/plugins/security_solution/server/endpoint/services/actions/utils.ts b/x-pack/plugins/security_solution/server/endpoint/services/actions/utils/utils.ts
similarity index 98%
rename from x-pack/plugins/security_solution/server/endpoint/services/actions/utils.ts
rename to x-pack/plugins/security_solution/server/endpoint/services/actions/utils/utils.ts
index 856c1ede8b218e..1c64d1f59a0621 100644
--- a/x-pack/plugins/security_solution/server/endpoint/services/actions/utils.ts
+++ b/x-pack/plugins/security_solution/server/endpoint/services/actions/utils/utils.ts
@@ -13,12 +13,12 @@ import { i18n } from '@kbn/i18n';
import type {
ResponseActionAgentType,
ResponseActionsApiCommandNames,
-} from '../../../../common/endpoint/service/response_actions/constants';
+} from '../../../../../common/endpoint/service/response_actions/constants';
import {
ENDPOINT_ACTION_RESPONSES_DS,
ENDPOINT_ACTIONS_DS,
failedFleetActionErrorCode,
-} from '../../../../common/endpoint/constants';
+} from '../../../../../common/endpoint/constants';
import type {
ActionDetails,
ActivityLogAction,
@@ -33,9 +33,9 @@ import type {
LogsEndpointAction,
LogsEndpointActionResponse,
WithAllKeys,
-} from '../../../../common/endpoint/types';
-import { ActivityLogItemTypes } from '../../../../common/endpoint/types';
-import type { EndpointMetadataService } from '../metadata';
+} from '../../../../../common/endpoint/types';
+import { ActivityLogItemTypes } from '../../../../../common/endpoint/types';
+import type { EndpointMetadataService } from '../../metadata';
/**
* Type guard to check if a given Action is in the shape of the Endpoint Action.
diff --git a/x-pack/plugins/security_solution/server/endpoint/services/actions/validate_action_id.ts b/x-pack/plugins/security_solution/server/endpoint/services/actions/utils/validate_action_id.ts
similarity index 59%
rename from x-pack/plugins/security_solution/server/endpoint/services/actions/validate_action_id.ts
rename to x-pack/plugins/security_solution/server/endpoint/services/actions/utils/validate_action_id.ts
index 8ba75deb297977..91027522365205 100644
--- a/x-pack/plugins/security_solution/server/endpoint/services/actions/validate_action_id.ts
+++ b/x-pack/plugins/security_solution/server/endpoint/services/actions/utils/validate_action_id.ts
@@ -7,10 +7,11 @@
import type { ElasticsearchClient } from '@kbn/core-elasticsearch-server';
import type { SearchTotalHits } from '@elastic/elasticsearch/lib/api/types';
-import { NotFoundError } from '../../errors';
-import { catchAndWrapError } from '../../utils';
-import type { LogsEndpointAction } from '../../../../common/endpoint/types';
-import { ENDPOINT_ACTIONS_INDEX } from '../../../../common/endpoint/constants';
+import type { ResponseActionAgentType } from '../../../../../common/endpoint/service/response_actions/constants';
+import { NotFoundError } from '../../../errors';
+import { catchAndWrapError } from '../../../utils';
+import type { LogsEndpointAction } from '../../../../../common/endpoint/types';
+import { ENDPOINT_ACTIONS_INDEX } from '../../../../../common/endpoint/constants';
/**
* Validates that a given action ID is a valid Endpoint action
@@ -19,7 +20,8 @@ import { ENDPOINT_ACTIONS_INDEX } from '../../../../common/endpoint/constants';
*/
export const validateActionId = async (
esClient: ElasticsearchClient,
- actionId: string
+ actionId: string,
+ agentType?: ResponseActionAgentType
): Promise => {
const response = await esClient
.search({
@@ -29,17 +31,23 @@ export const validateActionId = async (
bool: {
filter: [
{ term: { action_id: actionId } },
- { term: { input_type: 'endpoint' } },
{ term: { type: 'INPUT_ACTION' } },
+ ...(agentType ? [{ term: { 'EndpointActions.input_type': agentType } }] : []),
],
},
},
},
_source: false,
+ size: 1,
})
.catch(catchAndWrapError);
if (!(response.hits?.total as SearchTotalHits)?.value) {
- throw new NotFoundError(`Action id [${actionId}] not found`, response);
+ throw new NotFoundError(
+ `Action id [${actionId}] not found${
+ agentType ? ` with an agent type of [${agentType}]` : ''
+ }`,
+ response
+ );
}
};
diff --git a/x-pack/test/fleet_api_integration/apis/agent_policy/__snapshots__/agent_policy.snap b/x-pack/test/fleet_api_integration/apis/agent_policy/__snapshots__/agent_policy.snap
index a31d1d0f176e65..b8e043638fdee3 100644
--- a/x-pack/test/fleet_api_integration/apis/agent_policy/__snapshots__/agent_policy.snap
+++ b/x-pack/test/fleet_api_integration/apis/agent_policy/__snapshots__/agent_policy.snap
@@ -33,7 +33,6 @@ Object {
"package": Object {
"name": "system",
"title": "System",
- "version": "1.56.0",
},
"revision": 1,
"updated_by": "elastic",
diff --git a/x-pack/test/fleet_api_integration/apis/agent_policy/agent_policy.ts b/x-pack/test/fleet_api_integration/apis/agent_policy/agent_policy.ts
index 5c64bec5623c7b..3d13f43531f3fc 100644
--- a/x-pack/test/fleet_api_integration/apis/agent_policy/agent_policy.ts
+++ b/x-pack/test/fleet_api_integration/apis/agent_policy/agent_policy.ts
@@ -1356,8 +1356,12 @@ export default function (providerContext: FtrProviderContext) {
created_at: ppcreatedAt,
updated_at: ppupdatedAt,
version,
+ package: { version: pkgVersion, ...pkgRest },
...ppRest
- }: any) => ppRest
+ }: any) => ({
+ ...ppRest,
+ package: pkgRest,
+ })
),
}).toMatch();
});