diff --git a/docs/developer/advanced/upgrading-nodejs.asciidoc b/docs/developer/advanced/upgrading-nodejs.asciidoc index c2c559c9c380e..3f27d5a62147d 100644 --- a/docs/developer/advanced/upgrading-nodejs.asciidoc +++ b/docs/developer/advanced/upgrading-nodejs.asciidoc @@ -44,15 +44,21 @@ Use best judgement when backporting. ==== Node.js patch upgrades -Typically, you want to backport Node.js *patch* upgrades to all supported release branches that run the same *major* Node.js version (which currently is all of them, but this might change in the future once Node.js v18 is released and becomes LTS): +Typically, you want to backport Node.js *patch* upgrades to all supported release branches that run the same *major* Node.js version (which currently is all of them, but this might change in the future): - - If upgrading Node.js 16, and the current release is 8.1.x, the main PR should target `main` and be backported to `7.17` and `8.1`. + - If the current release is 8.1.x, the main PR should target `main` and be backported to `7.17` and `8.1` (GitHub tag example: `backport:all-open`). ==== Node.js minor upgrades -Typically, you want to backport Node.js *minor* upgrades to the next minor {kib} release branch that runs the same *major* Node.js version: +Typically, you want to backport Node.js *minor* upgrades to the previous major {kib} release branch (if it runs the same *major* Node.js version): - - If upgrading Node.js 16, and the current release is 8.1.x, the main PR should target `main` and be backported to `7.17`, while leaving the `8.1` branch as-is. + - If the current release is 8.1.x, the main PR should target `main` and be backported to `7.17`, while leaving the `8.1` branch as-is (GitHub tag example: `auto-backport` + `v7.17.13`). + +==== Node.js major upgrades + +Typically, you want to backport Node.js *major* upgrades to the previous major {kib} release branch: + + - If the current release is 8.1.x, the main PR should target `main` and be backported to `7.17`, while leaving the `8.1` branch as-is (GitHub tag example: `auto-backport` + `v7.17.13`). === Upgrading installed Node.js version diff --git a/src/plugins/chart_expressions/expression_gauge/public/components/gauge_component.tsx b/src/plugins/chart_expressions/expression_gauge/public/components/gauge_component.tsx index affbff3ea9b2c..4684a160881b8 100644 --- a/src/plugins/chart_expressions/expression_gauge/public/components/gauge_component.tsx +++ b/src/plugins/chart_expressions/expression_gauge/public/components/gauge_component.tsx @@ -246,7 +246,10 @@ export const GaugeComponent: FC = memo( const onRenderChange = useCallback( (isRendered: boolean = true) => { if (isRendered) { - renderComplete(); + // this requestAnimationFrame call is a temporary fix for https://github.com/elastic/elastic-charts/issues/2124 + window.requestAnimationFrame(() => { + renderComplete(); + }); } }, [renderComplete] diff --git a/src/plugins/chart_expressions/expression_heatmap/public/components/heatmap_component.tsx b/src/plugins/chart_expressions/expression_heatmap/public/components/heatmap_component.tsx index ab70211361e46..95856d1bae485 100644 --- a/src/plugins/chart_expressions/expression_heatmap/public/components/heatmap_component.tsx +++ b/src/plugins/chart_expressions/expression_heatmap/public/components/heatmap_component.tsx @@ -196,7 +196,10 @@ export const HeatmapComponent: FC = memo( const onRenderChange = useCallback( (isRendered: boolean = true) => { if (isRendered) { - renderComplete(); + // this requestAnimationFrame call is a temporary fix for https://github.com/elastic/elastic-charts/issues/2124 + window.requestAnimationFrame(() => { + renderComplete(); + }); } }, [renderComplete] diff --git a/src/plugins/chart_expressions/expression_metric/public/components/metric_vis.test.tsx b/src/plugins/chart_expressions/expression_metric/public/components/metric_vis.test.tsx index 209608c260eca..6e96c2ab06cdf 100644 --- a/src/plugins/chart_expressions/expression_metric/public/components/metric_vis.test.tsx +++ b/src/plugins/chart_expressions/expression_metric/public/components/metric_vis.test.tsx @@ -971,6 +971,11 @@ describe('MetricVisComponent', function () { }); it('should report render complete', () => { + jest.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => { + cb(0); + return 0; + }); + const renderCompleteSpy = jest.fn(); const component = shallow( { diff --git a/src/plugins/chart_expressions/expression_metric/public/components/metric_vis.tsx b/src/plugins/chart_expressions/expression_metric/public/components/metric_vis.tsx index f0aa3c3e1dede..cb352fe883152 100644 --- a/src/plugins/chart_expressions/expression_metric/public/components/metric_vis.tsx +++ b/src/plugins/chart_expressions/expression_metric/public/components/metric_vis.tsx @@ -134,7 +134,10 @@ export const MetricVis = ({ const onRenderChange = useCallback( (isRendered) => { if (isRendered) { - renderComplete(); + // this requestAnimationFrame call is a temporary fix for https://github.com/elastic/elastic-charts/issues/2124 + window.requestAnimationFrame(() => { + renderComplete(); + }); } }, [renderComplete] diff --git a/src/plugins/chart_expressions/expression_partition_vis/public/components/partition_vis_component.tsx b/src/plugins/chart_expressions/expression_partition_vis/public/components/partition_vis_component.tsx index a321aaf181e2d..c151741158ac1 100644 --- a/src/plugins/chart_expressions/expression_partition_vis/public/components/partition_vis_component.tsx +++ b/src/plugins/chart_expressions/expression_partition_vis/public/components/partition_vis_component.tsx @@ -176,8 +176,11 @@ const PartitionVisComponent = (props: PartitionVisComponentProps) => { const onRenderChange = useCallback( (isRendered: boolean = true) => { if (isRendered) { - props.renderComplete(); - setChartIsLoaded(true); + // this requestAnimationFrame call is a temporary fix for https://github.com/elastic/elastic-charts/issues/2124 + window.requestAnimationFrame(() => { + props.renderComplete(); + setChartIsLoaded(true); + }); } }, [props] diff --git a/src/plugins/chart_expressions/expression_tagcloud/public/components/tagcloud_component.tsx b/src/plugins/chart_expressions/expression_tagcloud/public/components/tagcloud_component.tsx index 7fe65370693d5..adfc3df81f97f 100644 --- a/src/plugins/chart_expressions/expression_tagcloud/public/components/tagcloud_component.tsx +++ b/src/plugins/chart_expressions/expression_tagcloud/public/components/tagcloud_component.tsx @@ -145,7 +145,10 @@ export const TagCloudChart = ({ const onRenderChange = useCallback( (isRendered) => { if (isRendered) { - renderComplete(); + // this requestAnimationFrame call is a temporary fix for https://github.com/elastic/elastic-charts/issues/2124 + window.requestAnimationFrame(() => { + renderComplete(); + }); } }, [renderComplete] diff --git a/src/plugins/chart_expressions/expression_xy/public/components/xy_chart.tsx b/src/plugins/chart_expressions/expression_xy/public/components/xy_chart.tsx index 0c19cc72d691c..e1ad4fa19d1c0 100644 --- a/src/plugins/chart_expressions/expression_xy/public/components/xy_chart.tsx +++ b/src/plugins/chart_expressions/expression_xy/public/components/xy_chart.tsx @@ -309,7 +309,10 @@ export function XYChart({ const onRenderChange = useCallback( (isRendered: boolean = true) => { if (isRendered) { - renderComplete(); + // this requestAnimationFrame call is a temporary fix for https://github.com/elastic/elastic-charts/issues/2124 + window.requestAnimationFrame(() => { + renderComplete(); + }); } }, [renderComplete] diff --git a/x-pack/plugins/apm/public/components/app/infra_overview/infra_tabs/failure_prompt.tsx b/x-pack/plugins/apm/public/components/app/infra_overview/infra_tabs/failure_prompt.tsx index 5c5158ee8c491..db6baee9d6d4d 100644 --- a/x-pack/plugins/apm/public/components/app/infra_overview/infra_tabs/failure_prompt.tsx +++ b/x-pack/plugins/apm/public/components/app/infra_overview/infra_tabs/failure_prompt.tsx @@ -5,42 +5,32 @@ * 2.0. */ -import { - EuiEmptyPrompt, - EuiPageTemplate_Deprecated as EuiPageTemplate, -} from '@elastic/eui'; +import { EuiEmptyPrompt } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import React from 'react'; export function FailurePrompt() { return ( - - - {i18n.translate('xpack.apm.infraTabs.failurePromptTitle', { - defaultMessage: 'Unable to load your infrastructure data', - })} - - } - titleSize="m" - body={ -

- {i18n.translate('xpack.apm.infraTabs.failurePromptDescription', { - defaultMessage: - 'There was a problem loading the Infrastructure tab and your data. You can contact your administrator for help.', - })} -

- } - /> -
+ + {i18n.translate('xpack.apm.infraTabs.failurePromptTitle', { + defaultMessage: 'Unable to load your infrastructure data', + })} + + } + titleSize="m" + body={ +

+ {i18n.translate('xpack.apm.infraTabs.failurePromptDescription', { + defaultMessage: + 'There was a problem loading the Infrastructure tab and your data. You can contact your administrator for help.', + })} +

+ } + /> ); } diff --git a/x-pack/plugins/apm/public/components/app/settings/agent_keys/agent_keys_table.stories.tsx b/x-pack/plugins/apm/public/components/app/settings/agent_keys/agent_keys_table.stories.tsx index ad75c7ff60edf..296f66a51e98b 100644 --- a/x-pack/plugins/apm/public/components/app/settings/agent_keys/agent_keys_table.stories.tsx +++ b/x-pack/plugins/apm/public/components/app/settings/agent_keys/agent_keys_table.stories.tsx @@ -29,6 +29,7 @@ const KibanaReactContext = createKibanaReactContext(coreMock); const agentKeys: ApiKey[] = [ { + type: 'rest', id: 'M96XSX4BQcLuJqE2VX29', name: 'apm_api_key1', creation: 1641912161726, @@ -36,10 +37,11 @@ const agentKeys: ApiKey[] = [ username: 'elastic', realm: 'reserved', expiration: 0, + role_descriptors: {}, metadata: { application: 'apm' }, }, - { + type: 'rest', id: 'Nd6XSX4BQcLuJqE2eH2A', name: 'apm_api_key2', creation: 1641912170624, @@ -47,6 +49,7 @@ const agentKeys: ApiKey[] = [ username: 'elastic', realm: 'reserved', expiration: 0, + role_descriptors: {}, metadata: { application: 'apm' }, }, ]; diff --git a/x-pack/plugins/apm/server/feature.ts b/x-pack/plugins/apm/server/feature.ts index 292f48bf18156..09681f01da2d6 100644 --- a/x-pack/plugins/apm/server/feature.ts +++ b/x-pack/plugins/apm/server/feature.ts @@ -35,7 +35,7 @@ export const APM_FEATURE = { privileges: { all: { app: [APM_SERVER_FEATURE_ID, 'ux', 'kibana'], - api: [APM_SERVER_FEATURE_ID, 'apm_write', 'rac', 'ai_assistant'], + api: [APM_SERVER_FEATURE_ID, 'apm_write', 'rac'], catalogue: [APM_SERVER_FEATURE_ID], savedObject: { all: [], @@ -56,7 +56,7 @@ export const APM_FEATURE = { }, read: { app: [APM_SERVER_FEATURE_ID, 'ux', 'kibana'], - api: [APM_SERVER_FEATURE_ID, 'rac', 'ai_assistant'], + api: [APM_SERVER_FEATURE_ID, 'rac'], catalogue: [APM_SERVER_FEATURE_ID], savedObject: { all: [], diff --git a/x-pack/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable.scss b/x-pack/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable.scss index 204de7d4b345d..29888d862db7c 100644 --- a/x-pack/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable.scss +++ b/x-pack/plugins/canvas/canvas_plugin_src/renderers/embeddable/embeddable.scss @@ -1,8 +1,8 @@ .canvasEmbeddable { .embPanel { - border: none; - border-style: none !important; + outline: none !important; background: none; + border-radius: 0 !important; .embPanel__title { margin-bottom: $euiSizeXS; diff --git a/x-pack/plugins/canvas/public/components/toolbar/toolbar.component.tsx b/x-pack/plugins/canvas/public/components/toolbar/toolbar.component.tsx index 24dd7c55e198a..b0eedd3720e4b 100644 --- a/x-pack/plugins/canvas/public/components/toolbar/toolbar.component.tsx +++ b/x-pack/plugins/canvas/public/components/toolbar/toolbar.component.tsx @@ -106,6 +106,7 @@ export const Toolbar: FC = ({ iconType="arrowLeft" isDisabled={selectedPageNumber <= 1} aria-label={strings.getPreviousPageAriaLabel()} + data-test-subj="previousPageButton" /> @@ -124,6 +125,7 @@ export const Toolbar: FC = ({ iconType="arrowRight" isDisabled={selectedPageNumber >= totalPages} aria-label={strings.getNextPageAriaLabel()} + data-test-subj="nextPageButton" /> diff --git a/x-pack/plugins/canvas/public/state/actions/elements.js b/x-pack/plugins/canvas/public/state/actions/elements.js index eb9dc23faac4a..b94a198ee4be9 100644 --- a/x-pack/plugins/canvas/public/state/actions/elements.js +++ b/x-pack/plugins/canvas/public/state/actions/elements.js @@ -7,7 +7,7 @@ import { createAction } from 'redux-actions'; import immutable from 'object-path-immutable'; -import { get, pick, cloneDeep, without, last, debounce } from 'lodash'; +import { get, pick, cloneDeep, without, last } from 'lodash'; import { toExpression, safeElementFromExpression } from '@kbn/interpreter'; import { createThunk } from '../../lib/create_thunk'; import { isGroupId } from '../../lib/workpad'; @@ -112,13 +112,7 @@ const fetchContextFn = ({ dispatch, getState }, index, element, fullRefresh = fa }); }; -// It is necessary to debounce fetching of the context in the situations -// when the components of the arguments update the expression. For example, suppose there are -// multiple datacolumns that change the column to the first one from the list after datasource update. -// In that case, it is necessary to fetch the context only for the last version of the expression. -const fetchContextFnDebounced = debounce(fetchContextFn, 100); - -export const fetchContext = createThunk('fetchContext', fetchContextFnDebounced); +export const fetchContext = createThunk('fetchContext', fetchContextFn); const fetchRenderableWithContextFn = ({ dispatch, getState }, element, ast, context) => { const argumentPath = [element.id, 'expressionRenderable']; @@ -148,15 +142,9 @@ const fetchRenderableWithContextFn = ({ dispatch, getState }, element, ast, cont }); }; -// It is necessary to debounce fetching of the renderable with the context in the situations -// when the components of the arguments update the expression. For example, suppose there are -// multiple datacolumns that change the column to the first one from the list after datasource update. -// In that case, it is necessary to fetch the context only for the last version of the expression. -const fetchRenderableWithContextFnDebounced = debounce(fetchRenderableWithContextFn, 100); - export const fetchRenderableWithContext = createThunk( 'fetchRenderableWithContext', - fetchRenderableWithContextFnDebounced + fetchRenderableWithContextFn ); export const fetchRenderable = createThunk('fetchRenderable', ({ dispatch }, element) => { diff --git a/x-pack/plugins/fleet/server/services/api_keys/transform_api_keys.ts b/x-pack/plugins/fleet/server/services/api_keys/transform_api_keys.ts index f75c8d5677b28..ab17eeaac7921 100644 --- a/x-pack/plugins/fleet/server/services/api_keys/transform_api_keys.ts +++ b/x-pack/plugins/fleet/server/services/api_keys/transform_api_keys.ts @@ -5,7 +5,10 @@ * 2.0. */ -import type { CreateAPIKeyParams } from '@kbn/security-plugin/server'; +import type { + CreateRestAPIKeyParams, + CreateRestAPIKeyWithKibanaPrivilegesParams, +} from '@kbn/security-plugin/server'; import type { FakeRawRequest, Headers } from '@kbn/core-http-server'; import { CoreKibanaRequest } from '@kbn/core-http-router-server-internal'; @@ -60,7 +63,7 @@ export async function generateTransformSecondaryAuthHeaders({ }: { authorizationHeader: HTTPAuthorizationHeader | null | undefined; logger: Logger; - createParams?: CreateAPIKeyParams; + createParams?: CreateRestAPIKeyParams | CreateRestAPIKeyWithKibanaPrivilegesParams; username?: string; pkgName?: string; pkgVersion?: string; diff --git a/x-pack/plugins/infra/server/features.ts b/x-pack/plugins/infra/server/features.ts index 41f60ab5eac9b..e9fec4a5b4f5d 100644 --- a/x-pack/plugins/infra/server/features.ts +++ b/x-pack/plugins/infra/server/features.ts @@ -33,7 +33,7 @@ export const METRICS_FEATURE = { all: { app: ['infra', 'metrics', 'kibana'], catalogue: ['infraops', 'metrics'], - api: ['infra', 'rac', 'ai_assistant'], + api: ['infra', 'rac'], savedObject: { all: ['infrastructure-ui-source'], read: ['index-pattern'], @@ -54,7 +54,7 @@ export const METRICS_FEATURE = { read: { app: ['infra', 'metrics', 'kibana'], catalogue: ['infraops', 'metrics'], - api: ['infra', 'rac', 'ai_assistant'], + api: ['infra', 'rac'], savedObject: { all: [], read: ['infrastructure-ui-source', 'index-pattern'], @@ -92,7 +92,7 @@ export const LOGS_FEATURE = { all: { app: ['infra', 'logs', 'kibana'], catalogue: ['infralogging', 'logs'], - api: ['infra', 'rac', 'ai_assistant'], + api: ['infra', 'rac'], savedObject: { all: [infraSourceConfigurationSavedObjectName, logViewSavedObjectName], read: [], @@ -113,7 +113,7 @@ export const LOGS_FEATURE = { read: { app: ['infra', 'logs', 'kibana'], catalogue: ['infralogging', 'logs'], - api: ['infra', 'rac', 'ai_assistant'], + api: ['infra', 'rac'], alerting: { rule: { read: [LOG_DOCUMENT_COUNT_RULE_TYPE_ID], diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.tsx index 36d8b4104e64c..11f50252b993e 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/suggestion_panel.tsx @@ -64,7 +64,6 @@ import { selectFrameDatasourceAPI, } from '../../state_management'; import { filterAndSortUserMessages } from '../../app_plugin/get_application_user_messages'; - const MAX_SUGGESTIONS_DISPLAYED = 5; const LOCAL_STORAGE_SUGGESTIONS_PANEL = 'LENS_SUGGESTIONS_PANEL_HIDDEN'; @@ -108,11 +107,13 @@ const PreviewRenderer = ({ ExpressionRendererComponent, expression, hasError, + onRender, }: { withLabel: boolean; expression: string | null | undefined; ExpressionRendererComponent: ReactExpressionRendererType; hasError: boolean; + onRender: () => void; }) => { const onErrorMessage = (
@@ -143,6 +144,7 @@ const PreviewRenderer = ({ padding="s" renderMode="preview" expression={expression} + onRender$={onRender} debounce={2000} renderError={() => { return onErrorMessage; @@ -159,6 +161,7 @@ const SuggestionPreview = ({ selected, onSelect, showTitleAsLabel, + onRender, }: { onSelect: () => void; preview: { @@ -170,6 +173,7 @@ const SuggestionPreview = ({ ExpressionRenderer: ReactExpressionRendererType; selected: boolean; showTitleAsLabel?: boolean; + onRender: () => void; }) => { return ( @@ -194,6 +198,7 @@ const SuggestionPreview = ({ expression={preview.expression && toExpression(preview.expression)} withLabel={Boolean(showTitleAsLabel)} hasError={Boolean(preview.error)} + onRender={onRender} /> ) : ( @@ -358,20 +363,36 @@ export function SuggestionPanel({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [existsStagedPreview]); - if (!activeDatasourceId) { - return null; - } - - if (suggestions.length === 0) { - return null; - } + const startTime = useRef(0); + const initialRenderComplete = useRef(false); + const suggestionsRendered = useRef([]); + const totalSuggestions = suggestions.length + 1; + + const onSuggestionRender = useCallback((suggestionIndex: number) => { + suggestionsRendered.current[suggestionIndex] = true; + if (initialRenderComplete.current === false && suggestionsRendered.current.every(Boolean)) { + initialRenderComplete.current = true; + // console.log( + // 'time to fetch data and perform initial render for all suggestions', + // performance.now() - startTime.current + // ); + } + }, []); - function rollbackToCurrentVisualization() { + const rollbackToCurrentVisualization = useCallback(() => { if (lastSelectedSuggestion !== -1) { setLastSelectedSuggestion(-1); dispatchLens(rollbackSuggestion()); dispatchLens(applyChanges()); } + }, [dispatchLens, lastSelectedSuggestion]); + + if (!activeDatasourceId) { + return null; + } + + if (suggestions.length === 0) { + return null; } const renderApplyChangesPrompt = () => ( @@ -400,52 +421,58 @@ export function SuggestionPanel({ ); - const renderSuggestionsUI = () => ( - <> - {currentVisualization.activeId && !hideSuggestions && ( - - )} - {!hideSuggestions && - suggestions.map((suggestion, index) => { - return ( - { - if (lastSelectedSuggestion === index) { - rollbackToCurrentVisualization(); - } else { - setLastSelectedSuggestion(index); - switchToSuggestion(dispatchLens, suggestion, { applyImmediately: true }); - } - }} - selected={index === lastSelectedSuggestion} - /> - ); - })} - - ); + const renderSuggestionsUI = () => { + suggestionsRendered.current = new Array(totalSuggestions).fill(false); + startTime.current = performance.now(); + return ( + <> + {currentVisualization.activeId && !hideSuggestions && ( + onSuggestionRender(0)} + /> + )} + {!hideSuggestions && + suggestions.map((suggestion, index) => { + return ( + { + if (lastSelectedSuggestion === index) { + rollbackToCurrentVisualization(); + } else { + setLastSelectedSuggestion(index); + switchToSuggestion(dispatchLens, suggestion, { applyImmediately: true }); + } + }} + selected={index === lastSelectedSuggestion} + onRender={() => onSuggestionRender(index + 1)} + /> + ); + })} + + ); + }; return (
diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx index f4767124afb81..3f92236c99e5d 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/workspace_panel/workspace_panel.tsx @@ -138,6 +138,10 @@ export const WorkspacePanel = React.memo(function WorkspacePanel(props: Workspac ); }); +const log = (...messages: Array) => { + // console.log(...messages); +}; + // Exported for testing purposes only. export const InnerWorkspacePanel = React.memo(function InnerWorkspacePanel({ framePublicAPI, @@ -170,7 +174,10 @@ export const InnerWorkspacePanel = React.memo(function InnerWorkspacePanel({ errors: [], }); - const initialRenderComplete = useRef(); + const initialVisualizationRenderComplete = useRef(false); + + // NOTE: This does not reflect the actual visualization render + const initialWorkspaceRenderComplete = useRef(); const renderDeps = useRef<{ datasourceMap: DatasourceMap; @@ -192,8 +199,20 @@ export const InnerWorkspacePanel = React.memo(function InnerWorkspacePanel({ dataViews, }; + // NOTE: initialRenderTime is only set once when the component mounts + const visualizationRenderStartTime = useRef(NaN); + const dataReceivedTime = useRef(NaN); + const onRender$ = useCallback(() => { if (renderDeps.current) { + if (!initialVisualizationRenderComplete.current) { + initialVisualizationRenderComplete.current = true; + // NOTE: this metric is only reported for an initial editor load of a pre-existing visualization + log( + 'initial visualization took to render after data received', + performance.now() - dataReceivedTime.current + ); + } const datasourceEvents = Object.values(renderDeps.current.datasourceMap).reduce( (acc, datasource) => { if (!renderDeps.current!.datasourceStates[datasource.id]) return []; @@ -232,6 +251,15 @@ export const InnerWorkspacePanel = React.memo(function InnerWorkspacePanel({ const onData$ = useCallback( (_data: unknown, adapters?: Partial) => { if (renderDeps.current) { + dataReceivedTime.current = performance.now(); + if (!initialVisualizationRenderComplete.current) { + // NOTE: this metric is only reported for an initial editor load of a pre-existing visualization + log( + 'initial data took to arrive', + dataReceivedTime.current - visualizationRenderStartTime.current + ); + } + const [defaultLayerId] = Object.keys(renderDeps.current.datasourceLayers); const datasource = Object.values(renderDeps.current.datasourceMap)[0]; const datasourceState = Object.values(renderDeps.current.datasourceStates)[0].state; @@ -276,7 +304,8 @@ export const InnerWorkspacePanel = React.memo(function InnerWorkspacePanel({ [addUserMessages, dispatchLens, plugins.data.search] ); - const shouldApplyExpression = autoApplyEnabled || !initialRenderComplete.current || triggerApply; + const shouldApplyExpression = + autoApplyEnabled || !initialWorkspaceRenderComplete.current || triggerApply; const activeVisualization = visualization.activeId ? visualizationMap[visualization.activeId] : null; @@ -389,9 +418,9 @@ export const InnerWorkspacePanel = React.memo(function InnerWorkspacePanel({ // null signals an empty workspace which should count as an initial render if ( (expressionExists || localState.expressionToRender === null) && - !initialRenderComplete.current + !initialWorkspaceRenderComplete.current ) { - initialRenderComplete.current = true; + initialWorkspaceRenderComplete.current = true; } }, [expressionExists, localState.expressionToRender]); @@ -559,7 +588,6 @@ export const InnerWorkspacePanel = React.memo(function InnerWorkspacePanel({ return ( { + visualizationRenderStartTime.current = performance.now(); + }} /> ); }; @@ -656,7 +686,6 @@ function useReportingState(errors: UserMessage[]): { export const VisualizationWrapper = ({ expression, - framePublicAPI, lensInspector, onEvent, hasCompatibleActions, @@ -665,12 +694,11 @@ export const VisualizationWrapper = ({ errors, ExpressionRendererComponent, core, - activeDatasourceId, onRender$, onData$, + onComponentRendered, }: { expression: string | null | undefined; - framePublicAPI: FramePublicAPI; lensInspector: LensInspector; onEvent: (event: ExpressionRendererEvent) => void; hasCompatibleActions: (event: ExpressionRendererEvent) => Promise; @@ -679,14 +707,25 @@ export const VisualizationWrapper = ({ errors: UserMessage[]; ExpressionRendererComponent: ReactExpressionRendererType; core: CoreStart; - activeDatasourceId: string | null; onRender$: () => void; onData$: (data: unknown, adapters?: Partial) => void; + onComponentRendered: () => void; }) => { + useEffect(() => { + onComponentRendered(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + const context = useLensSelector(selectExecutionContext); // Used for reporting const { isRenderComplete, hasDynamicError, setIsRenderComplete, setDynamicError, nodeRef } = useReportingState(errors); + + const onRenderHandler = useCallback(() => { + setIsRenderComplete(true); + onRender$(); + }, [setIsRenderComplete, onRender$]); + const searchContext: ExecutionContextSearch = useMemo( () => ({ query: context.query, @@ -782,10 +821,7 @@ export const VisualizationWrapper = ({ onEvent={onEvent} hasCompatibleActions={hasCompatibleActions} onData$={onData$} - onRender$={() => { - setIsRenderComplete(true); - onRender$(); - }} + onRender$={onRenderHandler} inspectorAdapters={lensInspector.adapters} executionContext={executionContext} renderMode="edit" diff --git a/x-pack/plugins/observability_ai_assistant/common/feature.ts b/x-pack/plugins/observability_ai_assistant/common/feature.ts new file mode 100644 index 0000000000000..db06ec43086f2 --- /dev/null +++ b/x-pack/plugins/observability_ai_assistant/common/feature.ts @@ -0,0 +1,8 @@ +/* + * 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 const OBSERVABILITY_AI_ASSISTANT_FEATURE_ID = 'observabilityAIAssistant'; diff --git a/x-pack/plugins/observability_ai_assistant/kibana.jsonc b/x-pack/plugins/observability_ai_assistant/kibana.jsonc index 05e0e2e450553..2faf2c7de57e9 100644 --- a/x-pack/plugins/observability_ai_assistant/kibana.jsonc +++ b/x-pack/plugins/observability_ai_assistant/kibana.jsonc @@ -13,7 +13,8 @@ "requiredPlugins": [ "triggersActionsUi", "actions", - "security" + "security", + "features" ], "requiredBundles": [ "kibanaReact" diff --git a/x-pack/plugins/observability_ai_assistant/public/plugin.ts b/x-pack/plugins/observability_ai_assistant/public/plugin.ts index 607f54a3c6da7..187275b127dd3 100644 --- a/x-pack/plugins/observability_ai_assistant/public/plugin.ts +++ b/x-pack/plugins/observability_ai_assistant/public/plugin.ts @@ -5,7 +5,7 @@ * 2.0. */ -import type { CoreStart, Plugin, PluginInitializerContext } from '@kbn/core/public'; +import type { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from '@kbn/core/public'; import type { Logger } from '@kbn/logging'; import { createService } from './service/create_service'; import type { @@ -29,11 +29,20 @@ export class ObservabilityAIAssistantPlugin constructor(context: PluginInitializerContext) { this.logger = context.logger.get(); } - setup(): ObservabilityAIAssistantPluginSetup { + setup( + core: CoreSetup, + pluginsSetup: ObservabilityAIAssistantPluginSetupDependencies + ): ObservabilityAIAssistantPluginSetup { return {}; } - start(coreStart: CoreStart): ObservabilityAIAssistantPluginStart { - return createService(coreStart); + start( + coreStart: CoreStart, + pluginsStart: ObservabilityAIAssistantPluginStartDependencies + ): ObservabilityAIAssistantPluginStart { + return createService({ + coreStart, + enabled: coreStart.application.capabilities.observabilityAIAssistant.show === true, + }); } } diff --git a/x-pack/plugins/observability_ai_assistant/public/service/create_service.test.ts b/x-pack/plugins/observability_ai_assistant/public/service/create_service.test.ts index 010503e7abf4f..8a7ec44bfd9bc 100644 --- a/x-pack/plugins/observability_ai_assistant/public/service/create_service.test.ts +++ b/x-pack/plugins/observability_ai_assistant/public/service/create_service.test.ts @@ -41,10 +41,13 @@ describe('createService', () => { beforeEach(() => { service = createService({ - http: { - post: httpPostSpy, - }, - } as unknown as CoreStart); + coreStart: { + http: { + post: httpPostSpy, + }, + } as unknown as CoreStart, + enabled: true, + }); }); afterEach(() => { diff --git a/x-pack/plugins/observability_ai_assistant/public/service/create_service.ts b/x-pack/plugins/observability_ai_assistant/public/service/create_service.ts index 4c54bef460eb7..da29de585c9d1 100644 --- a/x-pack/plugins/observability_ai_assistant/public/service/create_service.ts +++ b/x-pack/plugins/observability_ai_assistant/public/service/create_service.ts @@ -12,12 +12,18 @@ import { createCallObservabilityAIAssistantAPI } from '../api'; import { CreateChatCompletionResponseChunk, ObservabilityAIAssistantService } from '../types'; import { readableStreamReaderIntoObservable } from '../utils/readable_stream_reader_into_observable'; -export function createService(coreStart: CoreStart): ObservabilityAIAssistantService { +export function createService({ + coreStart, + enabled, +}: { + coreStart: CoreStart; + enabled: boolean; +}): ObservabilityAIAssistantService { const client = createCallObservabilityAIAssistantAPI(coreStart); return { isEnabled: () => { - return true; + return enabled; }, async chat({ connectorId, diff --git a/x-pack/plugins/observability_ai_assistant/public/types.ts b/x-pack/plugins/observability_ai_assistant/public/types.ts index fc553cb201010..69d826c7ebf17 100644 --- a/x-pack/plugins/observability_ai_assistant/public/types.ts +++ b/x-pack/plugins/observability_ai_assistant/public/types.ts @@ -8,11 +8,13 @@ import type { TriggersAndActionsUIPublicPluginSetup, TriggersAndActionsUIPublicPluginStart, } from '@kbn/triggers-actions-ui-plugin/public'; +import type { SecurityPluginSetup, SecurityPluginStart } from '@kbn/security-plugin/public'; import type { CreateChatCompletionResponse, CreateChatCompletionResponseChoicesInner, } from 'openai'; import type { Observable } from 'rxjs'; +import type { FeaturesPluginSetup, FeaturesPluginStart } from '@kbn/features-plugin/public'; import type { Message } from '../common/types'; import type { ObservabilityAIAssistantAPIClient } from './api'; @@ -40,10 +42,14 @@ export interface ObservabilityAIAssistantPluginStart extends ObservabilityAIAssi export interface ObservabilityAIAssistantPluginSetup {} export interface ObservabilityAIAssistantPluginSetupDependencies { - triggersActions: TriggersAndActionsUIPublicPluginSetup; + triggersActionsUi: TriggersAndActionsUIPublicPluginSetup; + security: SecurityPluginSetup; + features: FeaturesPluginSetup; } export interface ObservabilityAIAssistantPluginStartDependencies { - triggersActions: TriggersAndActionsUIPublicPluginStart; + triggersActionsUi: TriggersAndActionsUIPublicPluginStart; + security: SecurityPluginStart; + features: FeaturesPluginStart; } export interface ConfigSchema {} diff --git a/x-pack/plugins/observability_ai_assistant/server/plugin.ts b/x-pack/plugins/observability_ai_assistant/server/plugin.ts index 23efe49ab24a7..a81b9e18a0a80 100644 --- a/x-pack/plugins/observability_ai_assistant/server/plugin.ts +++ b/x-pack/plugins/observability_ai_assistant/server/plugin.ts @@ -5,14 +5,22 @@ * 2.0. */ -import type { +import { CoreSetup, CoreStart, + DEFAULT_APP_CATEGORIES, Logger, Plugin, PluginInitializerContext, } from '@kbn/core/server'; import { mapValues } from 'lodash'; +import { i18n } from '@kbn/i18n'; +import { + CONNECTOR_TOKEN_SAVED_OBJECT_TYPE, + ACTION_SAVED_OBJECT_TYPE, + ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE, +} from '@kbn/actions-plugin/server/constants/saved_objects'; +import { OBSERVABILITY_AI_ASSISTANT_FEATURE_ID } from '../common/feature'; import type { ObservabilityAIAssistantConfig } from './config'; import { registerServerRoutes } from './routes/register_routes'; import { ObservabilityAIAssistantRouteHandlerResources } from './routes/types'; @@ -50,6 +58,58 @@ export class ObservabilityAIAssistantPlugin >, plugins: ObservabilityAIAssistantPluginSetupDependencies ): ObservabilityAIAssistantPluginSetup { + plugins.features.registerKibanaFeature({ + id: OBSERVABILITY_AI_ASSISTANT_FEATURE_ID, + name: i18n.translate('xpack.observabilityAiAssistant.featureRegistry.featureName', { + defaultMessage: 'Observability AI Assistant', + }), + order: 8600, + category: DEFAULT_APP_CATEGORIES.observability, + app: [OBSERVABILITY_AI_ASSISTANT_FEATURE_ID, 'kibana'], + catalogue: [OBSERVABILITY_AI_ASSISTANT_FEATURE_ID], + management: { + insightsAndAlerting: ['triggersActionsConnectors'], + }, + minimumLicense: 'enterprise', + // see x-pack/plugins/features/common/feature_kibana_privileges.ts + privileges: { + all: { + app: [OBSERVABILITY_AI_ASSISTANT_FEATURE_ID, 'kibana'], + api: [OBSERVABILITY_AI_ASSISTANT_FEATURE_ID, 'ai_assistant'], + catalogue: [OBSERVABILITY_AI_ASSISTANT_FEATURE_ID], + savedObject: { + all: [ + ACTION_SAVED_OBJECT_TYPE, + ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE, + CONNECTOR_TOKEN_SAVED_OBJECT_TYPE, + ], + read: [], + }, + management: { + insightsAndAlerting: ['triggersActionsConnectors'], + }, + ui: ['show'], + }, + read: { + app: [OBSERVABILITY_AI_ASSISTANT_FEATURE_ID, 'kibana'], + api: [OBSERVABILITY_AI_ASSISTANT_FEATURE_ID, 'ai_assistant'], + catalogue: [OBSERVABILITY_AI_ASSISTANT_FEATURE_ID], + savedObject: { + all: [], + read: [ + ACTION_SAVED_OBJECT_TYPE, + ACTION_TASK_PARAMS_SAVED_OBJECT_TYPE, + CONNECTOR_TOKEN_SAVED_OBJECT_TYPE, + ], + }, + management: { + insightsAndAlerting: ['triggersActionsConnectors'], + }, + ui: ['show'], + }, + }, + }); + const routeHandlerPlugins = mapValues(plugins, (value, key) => { return { setup: value, diff --git a/x-pack/plugins/observability_ai_assistant/server/types.ts b/x-pack/plugins/observability_ai_assistant/server/types.ts index faa5c346541c0..58f890c80000f 100644 --- a/x-pack/plugins/observability_ai_assistant/server/types.ts +++ b/x-pack/plugins/observability_ai_assistant/server/types.ts @@ -4,18 +4,26 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - -import type { PluginSetupContract, PluginStartContract } from '@kbn/actions-plugin/server'; +import type { + PluginStartContract as FeaturesPluginStart, + PluginSetupContract as FeaturesPluginSetup, +} from '@kbn/features-plugin/server'; +import type { + PluginSetupContract as ActionsPluginSetup, + PluginStartContract as ActionsPluginStart, +} from '@kbn/actions-plugin/server'; import type { SecurityPluginSetup, SecurityPluginStart } from '@kbn/security-plugin/server'; /* eslint-disable @typescript-eslint/no-empty-interface*/ export interface ObservabilityAIAssistantPluginStart {} export interface ObservabilityAIAssistantPluginSetup {} export interface ObservabilityAIAssistantPluginSetupDependencies { - actions: PluginSetupContract; + actions: ActionsPluginSetup; security: SecurityPluginSetup; + features: FeaturesPluginSetup; } export interface ObservabilityAIAssistantPluginStartDependencies { - actions: PluginStartContract; + actions: ActionsPluginStart; security: SecurityPluginStart; + features: FeaturesPluginStart; } diff --git a/x-pack/plugins/observability_ai_assistant/tsconfig.json b/x-pack/plugins/observability_ai_assistant/tsconfig.json index fbe0b8b245377..39507261898f5 100644 --- a/x-pack/plugins/observability_ai_assistant/tsconfig.json +++ b/x-pack/plugins/observability_ai_assistant/tsconfig.json @@ -24,7 +24,8 @@ "@kbn/spaces-plugin", "@kbn/kibana-react-plugin", "@kbn/shared-ux-utility", - "@kbn/alerting-plugin" + "@kbn/alerting-plugin", + "@kbn/features-plugin" ], "exclude": [ "target/**/*", diff --git a/x-pack/plugins/observability_onboarding/server/routes/elastic_agent/custom_logs/__snapshots__/generate_custom_logs_yml.test.ts.snap b/x-pack/plugins/observability_onboarding/common/elastic_agent_logs/custom_logs/__snapshots__/generate_custom_logs_yml.test.ts.snap similarity index 100% rename from x-pack/plugins/observability_onboarding/server/routes/elastic_agent/custom_logs/__snapshots__/generate_custom_logs_yml.test.ts.snap rename to x-pack/plugins/observability_onboarding/common/elastic_agent_logs/custom_logs/__snapshots__/generate_custom_logs_yml.test.ts.snap diff --git a/x-pack/plugins/observability_onboarding/server/routes/elastic_agent/custom_logs/generate_custom_logs_yml.test.ts b/x-pack/plugins/observability_onboarding/common/elastic_agent_logs/custom_logs/generate_custom_logs_yml.test.ts similarity index 100% rename from x-pack/plugins/observability_onboarding/server/routes/elastic_agent/custom_logs/generate_custom_logs_yml.test.ts rename to x-pack/plugins/observability_onboarding/common/elastic_agent_logs/custom_logs/generate_custom_logs_yml.test.ts diff --git a/x-pack/plugins/observability_onboarding/server/routes/elastic_agent/custom_logs/generate_custom_logs_yml.ts b/x-pack/plugins/observability_onboarding/common/elastic_agent_logs/custom_logs/generate_custom_logs_yml.ts similarity index 100% rename from x-pack/plugins/observability_onboarding/server/routes/elastic_agent/custom_logs/generate_custom_logs_yml.ts rename to x-pack/plugins/observability_onboarding/common/elastic_agent_logs/custom_logs/generate_custom_logs_yml.ts diff --git a/x-pack/plugins/observability_onboarding/common/elastic_agent_logs/index.ts b/x-pack/plugins/observability_onboarding/common/elastic_agent_logs/index.ts new file mode 100644 index 0000000000000..d987fe480e7df --- /dev/null +++ b/x-pack/plugins/observability_onboarding/common/elastic_agent_logs/index.ts @@ -0,0 +1,9 @@ +/* + * 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 './custom_logs/generate_custom_logs_yml'; +export * from './system_logs/generate_system_logs_yml'; diff --git a/x-pack/plugins/observability_onboarding/server/routes/elastic_agent/system_logs/__snapshots__/generate_system_logs_yml.test.ts.snap b/x-pack/plugins/observability_onboarding/common/elastic_agent_logs/system_logs/__snapshots__/generate_system_logs_yml.test.ts.snap similarity index 100% rename from x-pack/plugins/observability_onboarding/server/routes/elastic_agent/system_logs/__snapshots__/generate_system_logs_yml.test.ts.snap rename to x-pack/plugins/observability_onboarding/common/elastic_agent_logs/system_logs/__snapshots__/generate_system_logs_yml.test.ts.snap diff --git a/x-pack/plugins/observability_onboarding/server/routes/elastic_agent/system_logs/generate_system_logs_yml.test.ts b/x-pack/plugins/observability_onboarding/common/elastic_agent_logs/system_logs/generate_system_logs_yml.test.ts similarity index 100% rename from x-pack/plugins/observability_onboarding/server/routes/elastic_agent/system_logs/generate_system_logs_yml.test.ts rename to x-pack/plugins/observability_onboarding/common/elastic_agent_logs/system_logs/generate_system_logs_yml.test.ts diff --git a/x-pack/plugins/observability_onboarding/common/elastic_agent_logs/system_logs/generate_system_logs_yml.ts b/x-pack/plugins/observability_onboarding/common/elastic_agent_logs/system_logs/generate_system_logs_yml.ts new file mode 100644 index 0000000000000..c9008c1276535 --- /dev/null +++ b/x-pack/plugins/observability_onboarding/common/elastic_agent_logs/system_logs/generate_system_logs_yml.ts @@ -0,0 +1,103 @@ +/* + * 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 { dump } from 'js-yaml'; + +interface SystemLogsStream { + id: string; + data_stream: { + dataset: string; + type: string; + }; + paths: string[]; + exclude_files: string[]; + multiline: { + pattern: string; + match: string; + }; + tags?: string[]; + processors: Array<{ + add_locale: string | null; + }>; +} + +export const generateSystemLogsYml = ({ + namespace = 'default', + apiKey, + esHost, + uuid, +}: { + namespace?: string; + apiKey: string; + esHost: string[]; + uuid: string; +}) => { + return dump({ + outputs: { + default: { + type: 'elasticsearch', + hosts: esHost, + api_key: apiKey, + }, + }, + inputs: [ + { + id: `system-logs-${uuid}`, + type: 'logfile', + data_stream: { + namespace, + }, + streams: getSystemLogsDataStreams(uuid), + }, + ], + }); +}; + +/* + * Utils + */ +export const getSystemLogsDataStreams = ( + uuid: string = '' +): SystemLogsStream[] => [ + { + id: `logfile-system.auth-${uuid}`, + data_stream: { + dataset: 'system.auth', + type: 'logs', + }, + paths: ['/var/log/auth.log*', '/var/log/secure*'], + exclude_files: ['.gz$'], + multiline: { + pattern: '^s', + match: 'after', + }, + tags: ['system-auth'], + processors: [ + { + add_locale: null, + }, + ], + }, + { + id: `logfile-system.syslog-${uuid}`, + data_stream: { + dataset: 'system.syslog', + type: 'logs', + }, + paths: ['/var/log/messages*', '/var/log/syslog*', '/var/log/system*'], + exclude_files: ['.gz$'], + multiline: { + pattern: '^s', + match: 'after', + }, + processors: [ + { + add_locale: null, + }, + ], + }, +]; diff --git a/x-pack/plugins/observability_onboarding/kibana.jsonc b/x-pack/plugins/observability_onboarding/kibana.jsonc index 85a387fff085a..97689407aff41 100644 --- a/x-pack/plugins/observability_onboarding/kibana.jsonc +++ b/x-pack/plugins/observability_onboarding/kibana.jsonc @@ -7,7 +7,7 @@ "server": true, "browser": true, "configPath": ["xpack", "observability_onboarding"], - "requiredPlugins": ["data", "observability", "observabilityShared"], + "requiredPlugins": ["data", "observability", "observabilityShared", "discover"], "optionalPlugins": ["cloud", "usageCollection"], "requiredBundles": ["kibanaReact"], "extraPublicDirs": ["common"] diff --git a/x-pack/plugins/observability_onboarding/public/components/app/custom_logs/wizard/install_elastic_agent.tsx b/x-pack/plugins/observability_onboarding/public/components/app/custom_logs/wizard/install_elastic_agent.tsx index dab59e7999068..187724f68bbb8 100644 --- a/x-pack/plugins/observability_onboarding/public/components/app/custom_logs/wizard/install_elastic_agent.tsx +++ b/x-pack/plugins/observability_onboarding/public/components/app/custom_logs/wizard/install_elastic_agent.tsx @@ -14,10 +14,11 @@ import { EuiText, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; import { default as React, useCallback, useEffect, useState } from 'react'; +import { ObservabilityOnboardingPluginSetupDeps } from '../../../../plugin'; import { useWizard } from '.'; import { FETCH_STATUS, useFetcher } from '../../../../hooks/use_fetcher'; -import { useKibanaNavigation } from '../../../../hooks/use_kibana_navigation'; import { ElasticAgentPlatform, getElasticAgentSetupCommand, @@ -34,9 +35,14 @@ import { } from '../../../shared/step_panel'; import { ApiKeyBanner } from './api_key_banner'; import { BackButton } from './back_button'; +import { getDiscoverNavigationParams } from '../../utils'; export function InstallElasticAgent() { - const { navigateToKibanaUrl } = useKibanaNavigation(); + const { + services: { + discover: { locator }, + }, + } = useKibana(); const { goBack, goToStep, getState, setState } = useWizard(); const wizardState = getState(); const [elasticAgentPlatform, setElasticAgentPlatform] = @@ -45,8 +51,10 @@ export function InstallElasticAgent() { function onInspect() { goToStep('inspect'); } - function onContinue() { - navigateToKibanaUrl('/app/logs/stream'); + async function onContinue() { + await locator?.navigate( + getDiscoverNavigationParams([wizardState.datasetName]) + ); } function onAutoDownloadConfig() { diff --git a/x-pack/plugins/observability_onboarding/public/components/app/system_logs/install_elastic_agent.tsx b/x-pack/plugins/observability_onboarding/public/components/app/system_logs/install_elastic_agent.tsx index 5eb80c9e11525..d1744793bbd31 100644 --- a/x-pack/plugins/observability_onboarding/public/components/app/system_logs/install_elastic_agent.tsx +++ b/x-pack/plugins/observability_onboarding/public/components/app/system_logs/install_elastic_agent.tsx @@ -13,7 +13,10 @@ import { EuiText, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; +import { useKibana } from '@kbn/kibana-react-plugin/public'; import { default as React, useCallback, useEffect, useState } from 'react'; +import { getSystemLogsDataStreams } from '../../../../common/elastic_agent_logs'; +import { ObservabilityOnboardingPluginSetupDeps } from '../../../plugin'; import { useWizard } from '.'; import { FETCH_STATUS, useFetcher } from '../../../hooks/use_fetcher'; import { useKibanaNavigation } from '../../../hooks/use_kibana_navigation'; @@ -32,8 +35,15 @@ import { StepPanelFooter, } from '../../shared/step_panel'; import { ApiKeyBanner } from '../custom_logs/wizard/api_key_banner'; +import { getDiscoverNavigationParams } from '../utils'; export function InstallElasticAgent() { + const { + services: { + discover: { locator }, + }, + } = useKibana(); + const { navigateToKibanaUrl } = useKibanaNavigation(); const { getState, setState } = useWizard(); const wizardState = getState(); @@ -45,8 +55,12 @@ export function InstallElasticAgent() { function onBack() { navigateToKibanaUrl('/app/observabilityOnboarding'); } - function onContinue() { - navigateToKibanaUrl('/app/logs/stream'); + async function onContinue() { + const dataStreams = getSystemLogsDataStreams(); + const dataSets = dataStreams.map( + (dataSream) => dataSream.data_stream.dataset + ); + await locator?.navigate(getDiscoverNavigationParams(dataSets)); } function onAutoDownloadConfig() { diff --git a/x-pack/plugins/observability_onboarding/public/components/app/utils.ts b/x-pack/plugins/observability_onboarding/public/components/app/utils.ts new file mode 100644 index 0000000000000..843002cb1fcc6 --- /dev/null +++ b/x-pack/plugins/observability_onboarding/public/components/app/utils.ts @@ -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 type { DataViewSpec } from '@kbn/data-views-plugin/common'; +import { DiscoverAppLocatorParams } from '@kbn/discover-plugin/common'; +import { Filter, FilterStateStore } from '@kbn/es-query'; + +type DiscoverPropertiesToPick = 'dataViewId' | 'dataViewSpec' | 'filters'; + +type DiscoverNavigationParams = Pick< + DiscoverAppLocatorParams, + DiscoverPropertiesToPick +>; + +const defaultFilterKey = 'data_stream.dataset'; +const defaultLogsDataViewId = 'logs-*'; +const defaultLogsDataView: DataViewSpec = { + id: defaultLogsDataViewId, + title: defaultLogsDataViewId, +}; + +const getDefaultDatasetFilter = (datasets: string[]): Filter[] => [ + { + meta: { + index: defaultLogsDataViewId, + key: defaultFilterKey, + params: datasets, + type: 'phrases', + }, + query: { + bool: { + minimum_should_match: 1, + should: datasets.map((dataset) => ({ + match_phrase: { + [defaultFilterKey]: dataset, + }, + })), + }, + }, + $state: { + store: FilterStateStore.APP_STATE, + }, + }, +]; + +export const getDiscoverNavigationParams = ( + datasets: string[] +): DiscoverNavigationParams => ({ + dataViewId: defaultLogsDataViewId, + dataViewSpec: defaultLogsDataView, + filters: getDefaultDatasetFilter(datasets), +}); diff --git a/x-pack/plugins/observability_onboarding/public/plugin.ts b/x-pack/plugins/observability_onboarding/public/plugin.ts index 22b34e0306515..8769991169090 100644 --- a/x-pack/plugins/observability_onboarding/public/plugin.ts +++ b/x-pack/plugins/observability_onboarding/public/plugin.ts @@ -23,6 +23,7 @@ import { DataPublicPluginSetup, DataPublicPluginStart, } from '@kbn/data-plugin/public'; +import type { DiscoverSetup } from '@kbn/discover-plugin/public'; import type { ObservabilityOnboardingConfig } from '../server'; export type ObservabilityOnboardingPluginSetup = void; @@ -31,6 +32,7 @@ export type ObservabilityOnboardingPluginStart = void; export interface ObservabilityOnboardingPluginSetupDeps { data: DataPublicPluginSetup; observability: ObservabilityPublicSetup; + discover: DiscoverSetup; } export interface ObservabilityOnboardingPluginStartDeps { diff --git a/x-pack/plugins/observability_onboarding/server/routes/elastic_agent/route.ts b/x-pack/plugins/observability_onboarding/server/routes/elastic_agent/route.ts index 11741726e344c..37256a1159923 100644 --- a/x-pack/plugins/observability_onboarding/server/routes/elastic_agent/route.ts +++ b/x-pack/plugins/observability_onboarding/server/routes/elastic_agent/route.ts @@ -7,12 +7,14 @@ import * as t from 'io-ts'; import { v4 as uuidv4 } from 'uuid'; +import { + generateSystemLogsYml, + generateCustomLogsYml, +} from '../../../common/elastic_agent_logs'; import { getAuthenticationAPIKey } from '../../lib/get_authentication_api_key'; import { getFallbackESUrl } from '../../lib/get_fallback_urls'; import { getObservabilityOnboardingFlow } from '../../lib/state'; import { createObservabilityOnboardingServerRoute } from '../create_observability_onboarding_server_route'; -import { generateCustomLogsYml } from './custom_logs/generate_custom_logs_yml'; -import { generateSystemLogsYml } from './system_logs/generate_system_logs_yml'; const generateConfig = createObservabilityOnboardingServerRoute({ endpoint: 'GET /internal/observability_onboarding/elastic_agent/config', diff --git a/x-pack/plugins/observability_onboarding/server/routes/elastic_agent/system_logs/generate_system_logs_yml.ts b/x-pack/plugins/observability_onboarding/server/routes/elastic_agent/system_logs/generate_system_logs_yml.ts deleted file mode 100644 index c9335cb97fa28..0000000000000 --- a/x-pack/plugins/observability_onboarding/server/routes/elastic_agent/system_logs/generate_system_logs_yml.ts +++ /dev/null @@ -1,82 +0,0 @@ -/* - * 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 { dump } from 'js-yaml'; - -export const generateSystemLogsYml = ({ - namespace = 'default', - apiKey, - esHost, - uuid, -}: { - namespace?: string; - apiKey: string; - esHost: string[]; - uuid: string; -}) => { - return dump({ - outputs: { - default: { - type: 'elasticsearch', - hosts: esHost, - api_key: apiKey, - }, - }, - inputs: [ - { - id: `system-logs-${uuid}`, - type: 'logfile', - data_stream: { - namespace, - }, - streams: [ - { - id: `logfile-system.auth-${uuid}`, - data_stream: { - dataset: 'system.auth', - type: 'logs', - }, - paths: ['/var/log/auth.log*', '/var/log/secure*'], - exclude_files: ['.gz$'], - multiline: { - pattern: '^s', - match: 'after', - }, - tags: ['system-auth'], - processors: [ - { - add_locale: null, - }, - ], - }, - { - id: `logfile-system.syslog-${uuid}`, - data_stream: { - dataset: 'system.syslog', - type: 'logs', - }, - paths: [ - '/var/log/messages*', - '/var/log/syslog*', - '/var/log/system*', - ], - exclude_files: ['.gz$'], - multiline: { - pattern: '^s', - match: 'after', - }, - processors: [ - { - add_locale: null, - }, - ], - }, - ], - }, - ], - }); -}; diff --git a/x-pack/plugins/observability_onboarding/tsconfig.json b/x-pack/plugins/observability_onboarding/tsconfig.json index 2099683e42a59..6bb24fde8c588 100644 --- a/x-pack/plugins/observability_onboarding/tsconfig.json +++ b/x-pack/plugins/observability_onboarding/tsconfig.json @@ -14,6 +14,7 @@ "kbn_references": [ "@kbn/core", "@kbn/data-plugin", + "@kbn/discover-plugin", "@kbn/kibana-react-plugin", "@kbn/observability-plugin", "@kbn/i18n", @@ -29,6 +30,8 @@ "@kbn/core-http-server", "@kbn/security-plugin", "@kbn/std", + "@kbn/data-views-plugin", + "@kbn/es-query", ], "exclude": [ "target/**/*", diff --git a/x-pack/plugins/profiling/server/feature.ts b/x-pack/plugins/profiling/server/feature.ts index 446a436145c77..13e064364b7b8 100644 --- a/x-pack/plugins/profiling/server/feature.ts +++ b/x-pack/plugins/profiling/server/feature.ts @@ -27,7 +27,7 @@ export const PROFILING_FEATURE = { read: [], }, ui: ['show'], - api: [PROFILING_SERVER_FEATURE_ID, 'ai_assistant'], + api: [PROFILING_SERVER_FEATURE_ID], }, read: { app: [PROFILING_SERVER_FEATURE_ID, 'ux', 'kibana'], @@ -36,7 +36,7 @@ export const PROFILING_FEATURE = { read: [], }, ui: ['show'], - api: [PROFILING_SERVER_FEATURE_ID, 'ai_assistant'], + api: [PROFILING_SERVER_FEATURE_ID], }, }, }; diff --git a/x-pack/plugins/security/common/model/api_key.ts b/x-pack/plugins/security/common/model/api_key.ts index fcdd823b4b85e..3441f4d07a8c1 100644 --- a/x-pack/plugins/security/common/model/api_key.ts +++ b/x-pack/plugins/security/common/model/api_key.ts @@ -5,23 +5,75 @@ * 2.0. */ -import type { Role } from './role'; +import type { estypes } from '@elastic/elasticsearch'; -export interface ApiKey { - id: string; - name: string; - username: string; - realm: string; - creation: number; - expiration: number; - invalidated: boolean; - metadata: Record; - role_descriptors?: Record; +/** + * Interface representing an API key the way it is returned by Elasticsearch GET endpoint. + */ +export type ApiKey = RestApiKey | CrossClusterApiKey; + +/** + * Interface representing a REST API key the way it is returned by Elasticsearch GET endpoint. + * + * TODO: Remove this type when `@elastic/elasticsearch` has been updated. + */ +export interface RestApiKey extends BaseApiKey { + type: 'rest'; } +/** + * Interface representing a Cross-Cluster API key the way it is returned by Elasticsearch GET endpoint. + * + * TODO: Remove this type when `@elastic/elasticsearch` has been updated. + */ +export interface CrossClusterApiKey extends BaseApiKey { + type: 'cross_cluster'; + + /** + * The access to be granted to this API key. The access is composed of permissions for cross-cluster + * search and cross-cluster replication. At least one of them must be specified. + */ + access: CrossClusterApiKeyAccess; +} + +/** + * Fixing up `estypes.SecurityApiKey` type since some fields are marked as optional even though they are guaranteed to be returned. + * + * TODO: Remove this type when `@elastic/elasticsearch` has been updated. + */ +interface BaseApiKey extends estypes.SecurityApiKey { + username: Required['username']; + realm: Required['realm']; + creation: Required['creation']; + metadata: Required['metadata']; + role_descriptors: Required['role_descriptors']; +} + +// TODO: Remove this type when `@elastic/elasticsearch` has been updated. +export interface CrossClusterApiKeyAccess { + /** + * A list of indices permission entries for cross-cluster search. + */ + search?: CrossClusterApiKeySearch[]; + + /** + * A list of indices permission entries for cross-cluster replication. + */ + replication?: CrossClusterApiKeyReplication[]; +} + +// TODO: Remove this type when `@elastic/elasticsearch` has been updated. +type CrossClusterApiKeySearch = Pick< + estypes.SecurityIndicesPrivileges, + 'names' | 'field_security' | 'query' | 'allow_restricted_indices' +>; + +// TODO: Remove this type when `@elastic/elasticsearch` has been updated. +type CrossClusterApiKeyReplication = Pick; + +export type ApiKeyRoleDescriptors = Record; + export interface ApiKeyToInvalidate { id: string; name: string; } - -export type ApiKeyRoleDescriptors = Record; diff --git a/x-pack/plugins/security/common/model/index.ts b/x-pack/plugins/security/common/model/index.ts index a1f3e88fdf56c..822d6036efc06 100644 --- a/x-pack/plugins/security/common/model/index.ts +++ b/x-pack/plugins/security/common/model/index.ts @@ -5,7 +5,14 @@ * 2.0. */ -export type { ApiKey, ApiKeyToInvalidate, ApiKeyRoleDescriptors } from './api_key'; +export type { + ApiKey, + RestApiKey, + CrossClusterApiKey, + ApiKeyToInvalidate, + ApiKeyRoleDescriptors, + CrossClusterApiKeyAccess, +} from './api_key'; export type { User, EditUser, GetUserDisplayNameParams } from './user'; export type { GetUserProfileResponse, diff --git a/x-pack/plugins/security/public/components/token_field.tsx b/x-pack/plugins/security/public/components/token_field.tsx index 7363ce7b28ff8..7e1308bf37d47 100644 --- a/x-pack/plugins/security/public/components/token_field.tsx +++ b/x-pack/plugins/security/public/components/token_field.tsx @@ -57,7 +57,11 @@ export const TokenField: FunctionComponent = (props) => { })} className="euiFieldText euiFieldText--inGroup" value={props.value} - style={{ fontFamily: euiThemeVars.euiCodeFontFamily, fontSize: euiThemeVars.euiFontSizeXS }} + style={{ + fontFamily: euiThemeVars.euiCodeFontFamily, + fontSize: euiThemeVars.euiFontSizeXS, + backgroundColor: 'transparent', + }} onFocus={(event) => event.currentTarget.select()} readOnly /> diff --git a/x-pack/plugins/security/public/management/api_keys/api_keys_api_client.mock.ts b/x-pack/plugins/security/public/management/api_keys/api_keys_api_client.mock.ts index a8f317800d8b0..4270ccbc8539b 100644 --- a/x-pack/plugins/security/public/management/api_keys/api_keys_api_client.mock.ts +++ b/x-pack/plugins/security/public/management/api_keys/api_keys_api_client.mock.ts @@ -5,9 +5,12 @@ * 2.0. */ +import type { PublicMethodsOf } from '@kbn/utility-types'; + +import type { APIKeysAPIClient } from './api_keys_api_client'; + export const apiKeysAPIClientMock = { - create: () => ({ - checkPrivileges: jest.fn(), + create: (): jest.Mocked> => ({ getApiKeys: jest.fn(), invalidateApiKeys: jest.fn(), createApiKey: jest.fn(), diff --git a/x-pack/plugins/security/public/management/api_keys/api_keys_api_client.test.ts b/x-pack/plugins/security/public/management/api_keys/api_keys_api_client.test.ts index 02b2024e609e0..de92fb637ddf2 100644 --- a/x-pack/plugins/security/public/management/api_keys/api_keys_api_client.test.ts +++ b/x-pack/plugins/security/public/management/api_keys/api_keys_api_client.test.ts @@ -10,19 +10,6 @@ import { httpServiceMock } from '@kbn/core/public/mocks'; import { APIKeysAPIClient } from './api_keys_api_client'; describe('APIKeysAPIClient', () => { - it('checkPrivileges() queries correct endpoint', async () => { - const httpMock = httpServiceMock.createStartContract(); - - const mockResponse = Symbol('mockResponse'); - httpMock.get.mockResolvedValue(mockResponse); - - const apiClient = new APIKeysAPIClient(httpMock); - - await expect(apiClient.checkPrivileges()).resolves.toBe(mockResponse); - expect(httpMock.get).toHaveBeenCalledTimes(1); - expect(httpMock.get).toHaveBeenCalledWith('/internal/security/api_key/privileges'); - }); - it('getApiKeys() queries correct endpoint', async () => { const httpMock = httpServiceMock.createStartContract(); @@ -33,23 +20,8 @@ describe('APIKeysAPIClient', () => { await expect(apiClient.getApiKeys()).resolves.toBe(mockResponse); expect(httpMock.get).toHaveBeenCalledTimes(1); - expect(httpMock.get).toHaveBeenCalledWith('/internal/security/api_key', { - query: { isAdmin: false }, - }); + expect(httpMock.get).toHaveBeenCalledWith('/internal/security/api_key'); httpMock.get.mockClear(); - - await expect(apiClient.getApiKeys(false)).resolves.toBe(mockResponse); - expect(httpMock.get).toHaveBeenCalledTimes(1); - expect(httpMock.get).toHaveBeenCalledWith('/internal/security/api_key', { - query: { isAdmin: false }, - }); - httpMock.get.mockClear(); - - await expect(apiClient.getApiKeys(true)).resolves.toBe(mockResponse); - expect(httpMock.get).toHaveBeenCalledTimes(1); - expect(httpMock.get).toHaveBeenCalledWith('/internal/security/api_key', { - query: { isAdmin: true }, - }); }); it('invalidateApiKeys() queries correct endpoint', async () => { @@ -92,7 +64,7 @@ describe('APIKeysAPIClient', () => { httpMock.post.mockResolvedValue(mockResponse); const apiClient = new APIKeysAPIClient(httpMock); - const mockAPIKeys = { name: 'name', expiration: '7d' }; + const mockAPIKeys = { name: 'name', expiration: '7d' } as any; await expect(apiClient.createApiKey(mockAPIKeys)).resolves.toBe(mockResponse); expect(httpMock.post).toHaveBeenCalledTimes(1); @@ -108,7 +80,7 @@ describe('APIKeysAPIClient', () => { httpMock.put.mockResolvedValue(mockResponse); const apiClient = new APIKeysAPIClient(httpMock); - const mockApiKeyUpdate = { id: 'test_id', metadata: {}, roles_descriptor: {} }; + const mockApiKeyUpdate = { id: 'test_id', metadata: {}, roles_descriptor: {} } as any; await expect(apiClient.updateApiKey(mockApiKeyUpdate)).resolves.toBe(mockResponse); expect(httpMock.put).toHaveBeenCalledTimes(1); diff --git a/x-pack/plugins/security/public/management/api_keys/api_keys_api_client.ts b/x-pack/plugins/security/public/management/api_keys/api_keys_api_client.ts index 6095278d73864..be236b02e4c65 100644 --- a/x-pack/plugins/security/public/management/api_keys/api_keys_api_client.ts +++ b/x-pack/plugins/security/public/management/api_keys/api_keys_api_client.ts @@ -7,58 +7,29 @@ import type { HttpStart } from '@kbn/core/public'; -import type { ApiKey, ApiKeyRoleDescriptors, ApiKeyToInvalidate } from '../../../common/model'; +import type { ApiKeyToInvalidate } from '../../../common/model'; +import type { + CreateAPIKeyParams, + CreateAPIKeyResult, + GetAPIKeysResult, + UpdateAPIKeyParams, + UpdateAPIKeyResult, +} from '../../../server/routes/api_keys'; -export interface CheckPrivilegesResponse { - areApiKeysEnabled: boolean; - isAdmin: boolean; - canManage: boolean; -} +export type { CreateAPIKeyParams, CreateAPIKeyResult, UpdateAPIKeyParams, UpdateAPIKeyResult }; export interface InvalidateApiKeysResponse { itemsInvalidated: ApiKeyToInvalidate[]; errors: any[]; } -export interface GetApiKeysResponse { - apiKeys: ApiKey[]; -} - -export interface CreateApiKeyRequest { - name: string; - expiration?: string; - role_descriptors?: ApiKeyRoleDescriptors; - metadata?: Record; -} - -export interface CreateApiKeyResponse { - id: string; - name: string; - expiration: number; - api_key: string; -} - -export interface UpdateApiKeyRequest { - id: string; - role_descriptors?: ApiKeyRoleDescriptors; - metadata?: Record; -} - -export interface UpdateApiKeyResponse { - updated: boolean; -} - const apiKeysUrl = '/internal/security/api_key'; export class APIKeysAPIClient { constructor(private readonly http: HttpStart) {} - public async checkPrivileges() { - return await this.http.get(`${apiKeysUrl}/privileges`); - } - - public async getApiKeys(isAdmin = false) { - return await this.http.get(apiKeysUrl, { query: { isAdmin } }); + public async getApiKeys() { + return await this.http.get(apiKeysUrl); } public async invalidateApiKeys(apiKeys: ApiKeyToInvalidate[], isAdmin = false) { @@ -67,14 +38,14 @@ export class APIKeysAPIClient { }); } - public async createApiKey(apiKey: CreateApiKeyRequest) { - return await this.http.post(apiKeysUrl, { + public async createApiKey(apiKey: CreateAPIKeyParams) { + return await this.http.post(apiKeysUrl, { body: JSON.stringify(apiKey), }); } - public async updateApiKey(apiKey: UpdateApiKeyRequest) { - return await this.http.put(apiKeysUrl, { + public async updateApiKey(apiKey: UpdateAPIKeyParams) { + return await this.http.put(apiKeysUrl, { body: JSON.stringify(apiKey), }); } diff --git a/x-pack/plugins/security/public/management/api_keys/api_keys_grid/api_key_flyout.tsx b/x-pack/plugins/security/public/management/api_keys/api_keys_grid/api_key_flyout.tsx index 959c3690b623c..c4196355cda16 100644 --- a/x-pack/plugins/security/public/management/api_keys/api_keys_grid/api_key_flyout.tsx +++ b/x-pack/plugins/security/public/management/api_keys/api_keys_grid/api_key_flyout.tsx @@ -5,23 +5,23 @@ * 2.0. */ +import type { ExclusiveUnion } from '@elastic/eui'; import { EuiCallOut, + EuiCheckableCard, EuiFieldNumber, - EuiFieldText, EuiFlexGroup, EuiFlexItem, - EuiForm, EuiFormFieldset, EuiFormRow, - EuiHealth, - EuiIcon, + EuiHorizontalRule, EuiSkeletonText, EuiSpacer, EuiSwitch, EuiText, - EuiToolTip, + EuiTitle, } from '@elastic/eui'; +import { Form, FormikProvider, useFormik } from 'formik'; import moment from 'moment-timezone'; import type { FunctionComponent } from 'react'; import React, { useEffect } from 'react'; @@ -31,51 +31,79 @@ import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; import { CodeEditorField, useKibana } from '@kbn/kibana-react-plugin/public'; -import type { ApiKey, ApiKeyRoleDescriptors, AuthenticatedUser } from '../../../../common/model'; +import type { ApiKeyRoleDescriptors } from '../../../../common/model'; import { DocLink } from '../../../components/doc_link'; +import { FormField } from '../../../components/form_field'; import type { FormFlyoutProps } from '../../../components/form_flyout'; import { FormFlyout } from '../../../components/form_flyout'; +import { FormRow } from '../../../components/form_row'; import { useCurrentUser } from '../../../components/use_current_user'; -import { useForm } from '../../../components/use_form'; -import type { ValidationErrors } from '../../../components/use_form'; import { useInitialFocus } from '../../../components/use_initial_focus'; import { RolesAPIClient } from '../../roles/roles_api_client'; import { APIKeysAPIClient } from '../api_keys_api_client'; import type { - CreateApiKeyRequest, - CreateApiKeyResponse, - UpdateApiKeyRequest, - UpdateApiKeyResponse, + CreateAPIKeyParams, + CreateAPIKeyResult, + UpdateAPIKeyParams, + UpdateAPIKeyResult, } from '../api_keys_api_client'; +import type { CategorizedApiKey } from './api_keys_grid_page'; +import { ApiKeyBadge, ApiKeyStatus, TimeToolTip, UsernameWithIcon } from './api_keys_grid_page'; export interface ApiKeyFormValues { name: string; + type: string; expiration: string; customExpiration: boolean; customPrivileges: boolean; includeMetadata: boolean; + access: string; role_descriptors: string; metadata: string; } -export interface ApiKeyFlyoutProps { - defaultValues?: ApiKeyFormValues; - onSuccess?: ( - createApiKeyResponse: CreateApiKeyResponse | undefined, - updateApiKeyResponse: UpdateApiKeyResponse | undefined - ) => void; +interface CommonApiKeyFlyoutProps { + initialValues?: ApiKeyFormValues; onCancel: FormFlyoutProps['onCancel']; - apiKey?: ApiKey; - readonly?: boolean; + canManageCrossClusterApiKeys?: boolean; + readOnly?: boolean; } -const defaultDefaultValues: ApiKeyFormValues = { +interface CreateApiKeyFlyoutProps extends CommonApiKeyFlyoutProps { + onSuccess?: (createApiKeyResponse: CreateAPIKeyResult) => void; +} + +interface UpdateApiKeyFlyoutProps extends CommonApiKeyFlyoutProps { + onSuccess?: (updateApiKeyResponse: UpdateAPIKeyResult) => void; + apiKey: CategorizedApiKey; +} + +export type ApiKeyFlyoutProps = ExclusiveUnion; + +const defaultInitialValues: ApiKeyFormValues = { name: '', + type: 'rest', customExpiration: false, expiration: '', includeMetadata: false, metadata: '{}', customPrivileges: false, + access: JSON.stringify( + { + search: [ + { + names: ['*'], + }, + ], + replication: [ + { + names: ['*'], + }, + ], + }, + null, + 2 + ), role_descriptors: '{}', }; @@ -83,67 +111,39 @@ export const ApiKeyFlyout: FunctionComponent = ({ onSuccess, onCancel, apiKey, - readonly = false, + canManageCrossClusterApiKeys = false, + readOnly = false, }) => { - let formTitle = 'Create API Key'; - let inProgressButtonText = 'Creating API Key…'; - let errorTitle = 'create API key'; - - const { value: currentUser, loading: isLoadingCurrentUser } = useCurrentUser(); - - let canEditApiKey = false; - - let defaultValues = defaultDefaultValues; - - if (apiKey) { - defaultValues = retrieveValuesFromApiKeyToDefaultFlyout(apiKey); - - canEditApiKey = isEditable(currentUser, apiKey); - - if (readonly || !canEditApiKey) { - formTitle = 'API key details'; - inProgressButtonText = ''; // This won't be seen since Submit will be disabled - errorTitle = ''; - } else { - formTitle = 'Update API Key'; - inProgressButtonText = 'Updating API Key…'; - errorTitle = 'update API key'; - } - } - const { services } = useKibana(); - + const { value: currentUser, loading: isLoadingCurrentUser } = useCurrentUser(); const [{ value: roles, loading: isLoadingRoles }, getRoles] = useAsyncFn( () => new RolesAPIClient(services.http!).getRoles(), [services.http] ); - const [form, eventHandlers] = useForm({ + const formik = useFormik({ onSubmit: async (values) => { try { if (apiKey) { const updateApiKeyResponse = await new APIKeysAPIClient(services.http!).updateApiKey( - mapUpdateApiKeyValues(apiKey.id, values) + mapUpdateApiKeyValues(apiKey.type, apiKey.id, values) ); - onSuccess?.(undefined, updateApiKeyResponse); + onSuccess?.(updateApiKeyResponse); } else { const createApiKeyResponse = await new APIKeysAPIClient(services.http!).createApiKey( mapCreateApiKeyValues(values) ); - onSuccess?.(createApiKeyResponse, undefined); + onSuccess?.(createApiKeyResponse); } } catch (error) { throw error; } }, - validate, - defaultValues, + initialValues: apiKey ? mapApiKeyFormValues(apiKey) : defaultInitialValues, }); - const isLoading = isLoadingCurrentUser || isLoadingRoles; - useEffect(() => { getRoles(); }, []); // eslint-disable-line react-hooks/exhaustive-deps @@ -161,431 +161,574 @@ export const ApiKeyFlyout: FunctionComponent = ({ {} ); - if (!form.touched.role_descriptors && !apiKey) { - form.setValue('role_descriptors', JSON.stringify(userPermissions, null, 2)); + if (!formik.touched.role_descriptors && !apiKey) { + formik.setFieldValue('role_descriptors', JSON.stringify(userPermissions, null, 2)); } } }, [currentUser, roles]); // eslint-disable-line react-hooks/exhaustive-deps + const isLoading = isLoadingCurrentUser || isLoadingRoles; + + const isOwner = currentUser && apiKey ? currentUser.username === apiKey.username : false; + const hasExpired = apiKey ? apiKey.expiration && moment(apiKey.expiration).isBefore() : false; + const canEdit = isOwner && !hasExpired; + const firstFieldRef = useInitialFocus([isLoading]); return ( - + - {form.submitError && ( - <> - - {(form.submitError as any).body?.message || form.submitError.message} - - - - )} - - - - 0 && !formik.isValid) || + readOnly || + (apiKey && !canEdit) + } + isSubmitButtonHidden={readOnly || (apiKey && !canEdit)} + size="m" + ownFocus + > + + {apiKey && !readOnly ? ( + !isOwner ? ( + <> + + } + /> + + + ) : hasExpired ? ( + <> + + } + /> + + + ) : null + ) : null} + +
+ } - )} - > - - - - - - - - {apiKey ? apiKey.username : currentUser?.username} - - - - - - - - - + > + + - {!!apiKey && ( - <> - - - {determineReadonlyExpiration(form.values?.expiration)} - - - )} - - - - form.setValue('customPrivileges', e.target.checked)} - disabled={readonly || (apiKey && !canEditApiKey)} - /> - {form.values.customPrivileges && ( + {apiKey ? ( <> - - + + + + } > - - - } - error={form.errors.role_descriptors} - isInvalid={form.touched.role_descriptors && !!form.errors.role_descriptors} - > - form.setValue('role_descriptors', value)} - languageId="xjson" - height={200} - options={{ readOnly: readonly || (apiKey && !canEditApiKey) }} - /> - - + + + + + + } + > + + + + + + } + > + + + + + + } + > + + + + + ) : canManageCrossClusterApiKeys ? ( + + } + fullWidth + > + + + + +

+ +

+
+ + + + + + } + onChange={() => formik.setFieldValue('type', 'rest')} + checked={formik.values.type === 'rest'} + /> +
+ + + +

+ +

+
+ + + + + + } + onChange={() => formik.setFieldValue('type', 'cross_cluster')} + checked={formik.values.type === 'cross_cluster'} + /> +
+
+
+ ) : ( + + } + > + + )} -
- - {!apiKey && ( - <> - + + + {formik.values.type === 'cross_cluster' ? ( + + } + helpText={ + + + + } + fullWidth + > + formik.setFieldValue('access', value)} + validate={(value: string) => { + if (!value) { + return i18n.translate( + 'xpack.security.management.apiKeys.apiKeyFlyout.accessRequired', + { + defaultMessage: 'Enter access permissions or disable this option.', + } + ); + } + try { + JSON.parse(value); + } catch (e) { + return i18n.translate( + 'xpack.security.management.apiKeys.apiKeyFlyout.invalidJsonError', + { + defaultMessage: 'Enter valid JSON.', + } + ); + } + }} + fullWidth + languageId="xjson" + height={200} + /> + + ) : ( form.setValue('customExpiration', e.target.checked)} - disabled={readonly || !!apiKey} - data-test-subj="apiKeyCustomExpirationSwitch" + label={ + + } + checked={formik.values.customPrivileges} + data-test-subj="apiKeysRoleDescriptorsSwitch" + onChange={(e) => formik.setFieldValue('customPrivileges', e.target.checked)} + disabled={readOnly || (apiKey && !canEdit)} /> - {form.values.customExpiration && ( + {formik.values.customPrivileges && ( <> - - + + + } + fullWidth + data-test-subj="apiKeysRoleDescriptorsCodeEditor" > - + formik.setFieldValue('role_descriptors', value) + } + validate={(value: string) => { + if (!value) { + return i18n.translate( + 'xpack.security.management.apiKeys.apiKeyFlyout.roleDescriptorsRequired', + { + defaultMessage: 'Enter role descriptors or disable this option.', + } + ); + } + try { + JSON.parse(value); + } catch (e) { + return i18n.translate( + 'xpack.security.management.apiKeys.apiKeyFlyout.invalidJsonError', + { + defaultMessage: 'Enter valid JSON.', + } + ); } - )} - name="expiration" - min={0} - defaultValue={form.values.expiration} - isInvalid={form.touched.expiration && !!form.errors.expiration && !apiKey} + }} fullWidth - data-test-subj="apiKeyCustomExpirationInput" - disabled={readonly || !!apiKey} + languageId="xjson" + height={200} /> - + )} - - )} - - - form.setValue('includeMetadata', e.target.checked)} - /> - {form.values.includeMetadata && ( + )} + + {!apiKey && ( <> - - + + + - - } - error={form.errors.metadata} - isInvalid={form.touched.metadata && !!form.errors.metadata} - > - form.setValue('metadata', value)} - languageId="xjson" - height={200} - options={{ readOnly: readonly || (apiKey && !canEditApiKey) }} + } + checked={formik.values.customExpiration} + onChange={(e) => formik.setFieldValue('customExpiration', e.target.checked)} + disabled={readOnly || !!apiKey} + data-test-subj="apiKeyCustomExpirationSwitch" /> - - + {formik.values.customExpiration && ( + <> + + + } + fullWidth + > + + + + + )} + )} - - - {/* Hidden submit button is required for enter key to trigger form submission */} - - - - + + + + } + data-test-subj="apiKeysMetadataSwitch" + checked={formik.values.includeMetadata} + disabled={readOnly || (apiKey && !canEdit)} + onChange={(e) => formik.setFieldValue('includeMetadata', e.target.checked)} + /> + {formik.values.includeMetadata && ( + <> + + + + + } + fullWidth + > + formik.setFieldValue('metadata', value)} + validate={(value: string) => { + if (!value) { + return i18n.translate( + 'xpack.security.management.apiKeys.apiKeyFlyout.metadataRequired', + { + defaultMessage: 'Enter metadata or disable this option.', + } + ); + } + try { + JSON.parse(value); + } catch (e) { + return i18n.translate( + 'xpack.security.management.apiKeys.apiKeyFlyout.invalidJsonError', + { + defaultMessage: 'Enter valid JSON.', + } + ); + } + }} + fullWidth + languageId="xjson" + height={200} + /> + + + + )} + + +
+
+ ); }; -export function validate(values: ApiKeyFormValues) { - const errors: ValidationErrors = {}; - - if (!values.name) { - errors.name = i18n.translate('xpack.security.management.apiKeys.apiKeyFlyout.nameRequired', { - defaultMessage: 'Enter a name.', - }); - } - - if (values.customExpiration) { - const parsedExpiration = parseFloat(values.expiration); - if (isNaN(parsedExpiration) || parsedExpiration <= 0) { - errors.expiration = i18n.translate( - 'xpack.security.management.apiKeys.apiKeyFlyout.expirationRequired', - { - defaultMessage: 'Enter a valid duration or disable this option.', - } - ); - } - } - - if (values.customPrivileges) { - if (!values.role_descriptors) { - errors.role_descriptors = i18n.translate( - 'xpack.security.management.apiKeys.apiKeyFlyout.roleDescriptorsRequired', - { - defaultMessage: 'Enter role descriptors or disable this option.', - } - ); - } else { - try { - JSON.parse(values.role_descriptors); - } catch (e) { - errors.role_descriptors = i18n.translate( - 'xpack.security.management.apiKeys.apiKeyFlyout.invalidJsonError', - { - defaultMessage: 'Enter valid JSON.', - } - ); - } - } +export function mapCreateApiKeyValues(values: ApiKeyFormValues): CreateAPIKeyParams { + const { type, name } = values; + const expiration = values.customExpiration ? `${values.expiration}d` : undefined; + const metadata = values.includeMetadata ? JSON.parse(values.metadata) : '{}'; + + if (type === 'cross_cluster') { + return { + type, + name, + expiration, + metadata, + access: JSON.parse(values.access), + }; } - if (values.includeMetadata) { - if (!values.metadata) { - errors.metadata = i18n.translate( - 'xpack.security.management.apiKeys.apiKeyFlyout.metadataRequired', - { - defaultMessage: 'Enter metadata or disable this option.', - } - ); - } else { - try { - JSON.parse(values.metadata); - } catch (e) { - errors.metadata = i18n.translate( - 'xpack.security.management.apiKeys.apiKeyFlyout.invalidJsonError', - { - defaultMessage: 'Enter valid JSON.', - } - ); - } - } - } - - return errors; -} - -export function mapCreateApiKeyValues(values: ApiKeyFormValues): CreateApiKeyRequest { return { - name: values.name, - expiration: values.customExpiration && values.expiration ? `${values.expiration}d` : undefined, - role_descriptors: - values.customPrivileges && values.role_descriptors - ? JSON.parse(values.role_descriptors) - : undefined, - metadata: values.includeMetadata && values.metadata ? JSON.parse(values.metadata) : undefined, + name, + expiration, + metadata, + role_descriptors: values.customPrivileges ? JSON.parse(values.role_descriptors) : '{}', }; } -export function mapUpdateApiKeyValues(id: string, values: ApiKeyFormValues): UpdateApiKeyRequest { +export function mapUpdateApiKeyValues( + type: CategorizedApiKey['type'], + id: string, + values: ApiKeyFormValues +): UpdateAPIKeyParams { + const metadata = values.includeMetadata ? JSON.parse(values.metadata) : '{}'; + + if (type === 'cross_cluster') { + return { + type, + id, + metadata, + access: JSON.parse(values.access), + }; + } + return { id, - role_descriptors: - values.customPrivileges && values.role_descriptors - ? JSON.parse(values.role_descriptors) - : undefined, - metadata: values.includeMetadata && values.metadata ? JSON.parse(values.metadata) : {}, + metadata, + role_descriptors: values.customPrivileges ? JSON.parse(values.role_descriptors) : '{}', }; } -function isEditable(currentUser: AuthenticatedUser | undefined, apiKey: ApiKey): boolean { - let result = false; - const isApiKeyOwner = currentUser && currentUser.username === apiKey.username; - const isNotExpired = !apiKey.expiration || moment(apiKey.expiration).isAfter(); - - if (isApiKeyOwner && isNotExpired) { - result = true; - } - - return result; -} - -function determineReadonlyExpiration(expiration?: string) { - const DATE_FORMAT = 'MMMM Do YYYY HH:mm:ss'; - - if (!expiration) { - return ( - - - - ); - } - - const expirationInt = parseInt(expiration, 10); - - if (Date.now() > expirationInt) { - return ( - - - - ); - } - - return ( - - - - - - ); -} - -function retrieveValuesFromApiKeyToDefaultFlyout(apiKey: ApiKey): ApiKeyFormValues { - // Collect data from the selected API key to pre-populate the form - const doesMetadataExist = Object.keys(apiKey.metadata).length > 0; - const doCustomPrivilegesExist = Object.keys(apiKey.role_descriptors ?? 0).length > 0; +/** + * Maps data from the selected API key to pre-populate the form + */ +function mapApiKeyFormValues(apiKey: CategorizedApiKey): ApiKeyFormValues { + const includeMetadata = Object.keys(apiKey.metadata).length > 0; + const customPrivileges = + apiKey.type !== 'cross_cluster' ? Object.keys(apiKey.role_descriptors).length > 0 : false; return { name: apiKey.name, + type: apiKey.type, customExpiration: !!apiKey.expiration, expiration: !!apiKey.expiration ? apiKey.expiration.toString() : '', - includeMetadata: doesMetadataExist, - metadata: doesMetadataExist ? JSON.stringify(apiKey.metadata, null, 2) : '{}', - customPrivileges: doCustomPrivilegesExist, - role_descriptors: doCustomPrivilegesExist - ? JSON.stringify(apiKey.role_descriptors, null, 2) + includeMetadata, + metadata: includeMetadata ? JSON.stringify(apiKey.metadata, null, 2) : '{}', + customPrivileges, + role_descriptors: customPrivileges + ? JSON.stringify(apiKey.type !== 'cross_cluster' && apiKey.role_descriptors, null, 2) : '{}', + access: apiKey.type === 'cross_cluster' ? JSON.stringify(apiKey.access, null, 2) : '{}', }; } diff --git a/x-pack/plugins/security/public/management/api_keys/api_keys_grid/api_keys_empty_prompt.tsx b/x-pack/plugins/security/public/management/api_keys/api_keys_grid/api_keys_empty_prompt.tsx index b6300832551e8..77d71f1f6db91 100644 --- a/x-pack/plugins/security/public/management/api_keys/api_keys_grid/api_keys_empty_prompt.tsx +++ b/x-pack/plugins/security/public/management/api_keys/api_keys_grid/api_keys_empty_prompt.tsx @@ -64,7 +64,7 @@ export const ApiKeysEmptyPrompt: FunctionComponent = ({

} @@ -160,7 +160,7 @@ export const ApiKeysEmptyPrompt: FunctionComponent = ({

} diff --git a/x-pack/plugins/security/public/management/api_keys/api_keys_grid/api_keys_grid_page.test.tsx b/x-pack/plugins/security/public/management/api_keys/api_keys_grid/api_keys_grid_page.test.tsx index 0f20e4a5cfe96..6a7234ba10488 100644 --- a/x-pack/plugins/security/public/management/api_keys/api_keys_grid/api_keys_grid_page.test.tsx +++ b/x-pack/plugins/security/public/management/api_keys/api_keys_grid/api_keys_grid_page.test.tsx @@ -14,7 +14,6 @@ import { coreMock, themeServiceMock } from '@kbn/core/public/mocks'; import { mockAuthenticatedUser } from '../../../../common/model/authenticated_user.mock'; import { securityMock } from '../../../mocks'; import { Providers } from '../api_keys_management_app'; -import { apiKeysAPIClientMock } from '../index.mock'; import { APIKeysGridPage } from './api_keys_grid_page'; /* @@ -35,26 +34,19 @@ describe('APIKeysGridPage', () => { let coreStart: ReturnType; const theme$ = themeServiceMock.createTheme$(); - const apiClientMock = apiKeysAPIClientMock.create(); const { authc } = securityMock.createSetup(); beforeEach(() => { coreStart = coreMock.createStart(); - apiClientMock.checkPrivileges.mockClear(); - apiClientMock.getApiKeys.mockClear(); + coreStart.http.get.mockClear(); coreStart.http.get.mockClear(); coreStart.http.post.mockClear(); authc.getCurrentUser.mockClear(); - apiClientMock.checkPrivileges.mockResolvedValue({ - areApiKeysEnabled: true, - canManage: true, - isAdmin: true, - }); - - apiClientMock.getApiKeys.mockResolvedValue({ + coreStart.http.get.mockResolvedValue({ apiKeys: [ { + type: 'rest', creation: 1571322182082, expiration: 1571408582082, id: '0QQZ2m0BO2XZwgJFuWTT', @@ -62,8 +54,11 @@ describe('APIKeysGridPage', () => { name: 'first-api-key', realm: 'reserved', username: 'elastic', + metadata: {}, + role_descriptors: {}, }, { + type: 'rest', creation: 1571322182082, expiration: 1571408582082, id: 'BO2XZwgJFuWTT0QQZ2m0', @@ -71,8 +66,13 @@ describe('APIKeysGridPage', () => { name: 'second-api-key', realm: 'reserved', username: 'elastic', + metadata: {}, + role_descriptors: {}, }, ], + canManageCrossClusterApiKeys: true, + canManageApiKeys: true, + canManageOwnApiKeys: true, }); authc.getCurrentUser.mockResolvedValue( @@ -86,6 +86,11 @@ describe('APIKeysGridPage', () => { ); }); + afterAll(() => { + // Let's make sure we restore everything just in case + consoleWarnMock.mockRestore(); + }); + it('loads and displays API keys', async () => { const history = createMemoryHistory({ initialEntries: ['/'] }); @@ -98,16 +103,11 @@ describe('APIKeysGridPage', () => { const { findByText, queryByTestId, getByText } = render( - + ); - expect(await queryByTestId('apiKeysCreateTableButton')).toBeNull(); + expect(await queryByTestId('apiKeysCreateTableButton')).not.toBeInTheDocument(); expect(await findByText(/Loading API keys/)).not.toBeInTheDocument(); @@ -119,17 +119,11 @@ describe('APIKeysGridPage', () => { expect(secondKeyEuiLink!.getAttribute('data-test-subj')).toBe('apiKeyRowName-second-api-key'); }); - afterAll(() => { - // Let's make sure we restore everything just in case - consoleWarnMock.mockRestore(); - }); - it('displays callout when API keys are disabled', async () => { const history = createMemoryHistory({ initialEntries: ['/'] }); - apiClientMock.checkPrivileges.mockResolvedValueOnce({ - areApiKeysEnabled: false, - canManage: true, - isAdmin: true, + + coreStart.http.get.mockRejectedValueOnce({ + body: { message: 'disabled.feature="api_keys"' }, }); coreStart.application.capabilities = { @@ -141,25 +135,19 @@ describe('APIKeysGridPage', () => { const { findByText } = render( - + ); expect(await findByText(/Loading API keys/)).not.toBeInTheDocument(); - await findByText(/API keys not enabled/); + await findByText(/API keys are disabled/); }); it('displays error when user does not have required permissions', async () => { const history = createMemoryHistory({ initialEntries: ['/'] }); - apiClientMock.checkPrivileges.mockResolvedValueOnce({ - areApiKeysEnabled: true, - canManage: false, - isAdmin: false, + + coreStart.http.get.mockRejectedValueOnce({ + body: { statusCode: 403, message: 'forbidden' }, }); coreStart.application.capabilities = { @@ -171,21 +159,16 @@ describe('APIKeysGridPage', () => { const { findByText } = render( - + ); expect(await findByText(/Loading API keys/)).not.toBeInTheDocument(); - await findByText(/You need permission to manage API keys/); + await findByText(/You do not have permission to manage API keys/); }); it('displays error when fetching API keys fails', async () => { - apiClientMock.getApiKeys.mockRejectedValueOnce({ + coreStart.http.get.mockRejectedValueOnce({ body: { error: 'Internal Server Error', message: 'Internal Server Error', @@ -203,12 +186,7 @@ describe('APIKeysGridPage', () => { const { findByText } = render( - + ); @@ -218,20 +196,17 @@ describe('APIKeysGridPage', () => { describe('Read Only View', () => { beforeEach(() => { - apiClientMock.checkPrivileges.mockResolvedValueOnce({ - areApiKeysEnabled: true, - canManage: false, - isAdmin: false, + coreStart.http.get.mockResolvedValue({ + apiKeys: [], + canManageCrossClusterApiKeys: false, + canManageApiKeys: false, + canManageOwnApiKeys: false, }); }); it('should not display prompt `Create Button` when no API keys are shown', async () => { const history = createMemoryHistory({ initialEntries: ['/'] }); - apiClientMock.getApiKeys.mockResolvedValue({ - apiKeys: [], - }); - coreStart.application.capabilities = { ...coreStart.application.capabilities, api_keys: { @@ -241,17 +216,12 @@ describe('APIKeysGridPage', () => { const { findByText, queryByText } = render( - + ); expect(await findByText(/Loading API keys/)).not.toBeInTheDocument(); expect(await findByText('You do not have permission to create API keys')).toBeInTheDocument(); - expect(queryByText('Create API key')).toBeNull(); + expect(queryByText('Create API key')).not.toBeInTheDocument(); }); }); }); diff --git a/x-pack/plugins/security/public/management/api_keys/api_keys_grid/api_keys_grid_page.tsx b/x-pack/plugins/security/public/management/api_keys/api_keys_grid/api_keys_grid_page.tsx index ee3aa35411782..90839fd3ac83c 100644 --- a/x-pack/plugins/security/public/management/api_keys/api_keys_grid/api_keys_grid_page.tsx +++ b/x-pack/plugins/security/public/management/api_keys/api_keys_grid/api_keys_grid_page.tsx @@ -5,758 +5,878 @@ * 2.0. */ -import type { EuiBasicTableColumn, EuiInMemoryTableProps } from '@elastic/eui'; +import type { EuiBasicTableColumn, Query, SearchFilterConfig } from '@elastic/eui'; import { EuiBadge, EuiButton, EuiCallOut, + EuiFilterButton, EuiFlexGroup, EuiFlexItem, EuiHealth, - EuiIcon, EuiInMemoryTable, EuiLink, EuiSpacer, EuiText, EuiToolTip, } from '@elastic/eui'; -import { css } from '@emotion/react'; -import type { History } from 'history'; import moment from 'moment-timezone'; -import React, { Component } from 'react'; +import type { FunctionComponent } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; +import { useHistory } from 'react-router-dom'; +import useAsyncFn from 'react-use/lib/useAsyncFn'; -import type { NotificationsStart } from '@kbn/core/public'; +import type { CoreStart } from '@kbn/core/public'; import { SectionLoading } from '@kbn/es-ui-shared-plugin/public'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n-react'; -import { reactRouterNavigate } from '@kbn/kibana-react-plugin/public'; +import { reactRouterNavigate, useKibana } from '@kbn/kibana-react-plugin/public'; import { KibanaPageTemplate } from '@kbn/shared-ux-page-kibana-template'; import { Route } from '@kbn/shared-ux-router'; -import type { PublicMethodsOf } from '@kbn/utility-types'; +import { UserAvatar, UserProfilesPopover } from '@kbn/user-profile-components'; -import type { ApiKey, ApiKeyToInvalidate } from '../../../../common/model'; +import type { ApiKey, AuthenticatedUser, RestApiKey } from '../../../../common/model'; import { Breadcrumb } from '../../../components/breadcrumb'; import { SelectableTokenField } from '../../../components/token_field'; -import type { - APIKeysAPIClient, - CreateApiKeyResponse, - UpdateApiKeyResponse, -} from '../api_keys_api_client'; +import { useCapabilities } from '../../../components/use_capabilities'; +import { useAuthentication } from '../../../components/use_current_user'; +import type { CreateAPIKeyResult } from '../api_keys_api_client'; +import { APIKeysAPIClient } from '../api_keys_api_client'; import { ApiKeyFlyout } from './api_key_flyout'; import { ApiKeysEmptyPrompt } from './api_keys_empty_prompt'; -import type { InvalidateApiKeys } from './invalidate_provider'; import { InvalidateProvider } from './invalidate_provider'; -import { NotEnabled } from './not_enabled'; -import { PermissionDenied } from './permission_denied'; -interface Props { - history: History; - notifications: NotificationsStart; - apiKeysAPIClient: PublicMethodsOf; - readOnly?: boolean; -} - -interface State { - isLoadingApp: boolean; - isLoadingTable: boolean; - isAdmin: boolean; - canManage: boolean; - areApiKeysEnabled: boolean; - apiKeys?: ApiKey[]; - selectedItems: ApiKey[]; - error: any; - createdApiKey?: CreateApiKeyResponse; - selectedApiKey?: ApiKey; - isUpdateFlyoutVisible: boolean; -} - -const DATE_FORMAT = 'MMMM Do YYYY HH:mm:ss'; - -export class APIKeysGridPage extends Component { - static defaultProps: Partial = { - readOnly: false, - }; - - constructor(props: any) { - super(props); - this.state = { - isLoadingApp: true, - isLoadingTable: false, - isAdmin: false, - canManage: false, - areApiKeysEnabled: false, - apiKeys: undefined, - selectedItems: [], - error: undefined, - selectedApiKey: undefined, - isUpdateFlyoutVisible: false, - }; - } - - public componentDidMount() { - this.checkPrivileges(); - } +export const APIKeysGridPage: FunctionComponent = () => { + const { services } = useKibana(); + const history = useHistory(); + const authc = useAuthentication(); + const [state, getApiKeys] = useAsyncFn( + () => Promise.all([new APIKeysAPIClient(services.http).getApiKeys(), authc.getCurrentUser()]), + [services.http] + ); + + const [createdApiKey, setCreatedApiKey] = useState(); + const [openedApiKey, setOpenedApiKey] = useState(); + const readOnly = !useCapabilities('api_keys').save; + + useEffect(() => { + getApiKeys(); + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + if (!state.value) { + if (state.loading) { + return ( + + + + ); + } - public render() { return ( - // Flyout to create new ApiKey - <> - - - { - this.props.history.push({ pathname: '/' }); - - this.reloadApiKeys(); - - this.setState({ - createdApiKey: createApiKeyResponse, - }); - }} - onCancel={() => { - this.props.history.push({ pathname: '/' }); - this.setState({ selectedApiKey: undefined }); - }} - /> - - - - { - // Flyout to update or view ApiKey - this.state.isUpdateFlyoutVisible && ( - { - this.reloadApiKeys(); - this.displayUpdatedApiKeyToast(updateApiKeyResponse); - this.setState({ - selectedApiKey: undefined, - isUpdateFlyoutVisible: false, - }); - }} - onCancel={() => { - this.setState({ selectedApiKey: undefined, isUpdateFlyoutVisible: false }); - }} - apiKey={this.state.selectedApiKey} - readonly={this.props.readOnly} - /> - ) - } - {this.renderContent()} - + + getApiKeys()}> + + + ); } - public renderContent() { - const { isLoadingApp, isLoadingTable, areApiKeysEnabled, isAdmin, canManage, error, apiKeys } = - this.state; + const [ + { apiKeys, canManageCrossClusterApiKeys, canManageApiKeys, canManageOwnApiKeys }, + currentUser, + ] = state.value; + + return ( + <> + + + { + history.push({ pathname: '/' }); + setCreatedApiKey(createApiKeyResponse); + getApiKeys(); + }} + onCancel={() => history.push({ pathname: '/' })} + canManageCrossClusterApiKeys={canManageCrossClusterApiKeys} + /> + + + + {openedApiKey && ( + { + services.notifications.toasts.addSuccess({ + title: i18n.translate('xpack.security.management.apiKeys.updateSuccessMessage', { + defaultMessage: "Updated API key '{name}'", + values: { name: openedApiKey.name }, + }), + 'data-test-subj': 'updateApiKeySuccessToast', + }); - if (!apiKeys) { - if (isLoadingApp) { - return ( - + setOpenedApiKey(undefined); + getApiKeys(); + }} + onCancel={() => setOpenedApiKey(undefined)} + apiKey={openedApiKey} + readOnly={readOnly} + /> + )} + + {!apiKeys.length ? ( + + - - ); - } - - if (!canManage) { - return ; - } - - if (error) { - return ( - - + + + ) : ( + <> + - - - ); - } - - if (!areApiKeysEnabled) { - return ; - } - } - - if (!isLoadingTable && apiKeys && apiKeys.length === 0) { - if (this.props.readOnly) { - return ; - } else { - return ( - - + } + description={ - - - ); - } - } - - const concatenated = `${this.state.createdApiKey?.id}:${this.state.createdApiKey?.api_key}`; - - const description = this.determineDescription(isAdmin, this.props.readOnly ?? false); + } + rightSideItems={ + !readOnly + ? [ + + + , + ] + : undefined + } + paddingSize="none" + bottomBorder + /> + + + {createdApiKey && !state.loading && ( + <> + + + + )} - return ( - <> - - } - description={description} - rightSideItems={ - this.props.readOnly - ? undefined - : [ - + {canManageOwnApiKeys && !canManageApiKeys ? ( + <> + - , - ] - } - /> - - {this.state.createdApiKey && !this.state.isLoadingTable && ( - <> - - + + + ) : undefined} + + -

- ( + setOpenedApiKey(apiKey)} + onDelete={(apiKeysToDelete) => + invalidateApiKeyPrompt( + apiKeysToDelete.map(({ name, id }) => ({ name, id })), + getApiKeys + ) + } + currentUser={currentUser} + createdApiKey={createdApiKey} + canManageCrossClusterApiKeys={canManageCrossClusterApiKeys} + canManageApiKeys={canManageApiKeys} + canManageOwnApiKeys={canManageOwnApiKeys} + readOnly={readOnly} + loading={state.loading} /> -

- -
- - )} - - - - - {this.renderTable()} - - - ); - } - - private renderTable = () => { - const { apiKeys, selectedItems, isLoadingTable, isAdmin, error } = this.state; + )} + +
+ + )} + + ); +}; + +export interface ApiKeyCreatedCalloutProps { + createdApiKey: CreateAPIKeyResult; +} - const message = isLoadingTable ? ( - = ({ + createdApiKey, +}) => { + const concatenated = `${createdApiKey.id}:${createdApiKey.api_key}`; + return ( + +

+ +

+ - ) : undefined; +
+ ); +}; + +export interface ApiKeysTableProps { + apiKeys: ApiKey[]; + currentUser: AuthenticatedUser; + createdApiKey?: CreateAPIKeyResult; + readOnly?: boolean; + loading?: boolean; + canManageCrossClusterApiKeys: boolean; + canManageApiKeys: boolean; + canManageOwnApiKeys: boolean; + onClick(apiKey: CategorizedApiKey): void; + onDelete(apiKeys: CategorizedApiKey[]): void; +} - const sorting = { - sort: { - field: 'creation', - direction: 'desc', - }, - } as const; - - const pagination = { - initialPageSize: 20, - pageSizeOptions: [10, 20, 50], - }; - - const selection = { - onSelectionChange: (newSelectedItems: ApiKey[]) => { - this.setState({ - selectedItems: newSelectedItems, - }); - }, - }; - - const search: EuiInMemoryTableProps['search'] = { - toolsLeft: selectedItems.length ? ( - - {(invalidateApiKeyPrompt) => { - return ( - - invalidateApiKeyPrompt( - selectedItems.map(({ name, id }) => ({ name, id })), - this.onApiKeysInvalidated - ) - } - color="danger" - data-test-subj="bulkInvalidateActionButton" - > - - - ); - }} - - ) : undefined, - box: { - incremental: true, +export const ApiKeysTable: FunctionComponent = ({ + apiKeys, + createdApiKey, + currentUser, + onClick, + onDelete, + canManageApiKeys = false, + canManageOwnApiKeys = false, + readOnly = false, + loading = false, +}) => { + const columns: Array> = []; + const [selectedItems, setSelectedItems] = useState([]); + + const { categorizedApiKeys, typeFilters, usernameFilters, expiredFilters } = useMemo( + () => categorizeApiKeys(apiKeys), + [apiKeys] + ); + + const deletable = (item: CategorizedApiKey) => + canManageApiKeys || (canManageOwnApiKeys && item.username === currentUser.username); + + columns.push( + { + field: 'name', + name: i18n.translate('xpack.security.management.apiKeys.table.nameColumnName', { + defaultMessage: 'Name', + }), + sortable: true, + render: (name: string, item: CategorizedApiKey) => { + return ( + onClick(item)} data-test-subj={`apiKeyRowName-${item.name}`}> + {name} + + ); }, - filters: isAdmin - ? [ - { - type: 'field_value_selection', - field: 'username', - name: i18n.translate('xpack.security.management.apiKeys.table.userFilterLabel', { - defaultMessage: 'User', - }), - multiSelect: false, - options: Object.keys( - apiKeys?.reduce((apiKeysMap: any, apiKey) => { - apiKeysMap[apiKey.username] = true; - return apiKeysMap; - }, {}) ?? {} - ).map((username) => { - return { - value: username, - view: ( - - - - - - - {username} - - - - ), - }; - }), - }, - { - type: 'field_value_selection', - field: 'realm', - name: i18n.translate('xpack.security.management.apiKeys.table.realmFilterLabel', { - defaultMessage: 'Realm', - }), - multiSelect: false, - options: Object.keys( - apiKeys?.reduce((apiKeysMap: any, apiKey) => { - apiKeysMap[apiKey.realm] = true; - return apiKeysMap; - }, {}) ?? {} - ).map((realm) => { - return { - value: realm, - view: realm, - }; - }), - }, - ] - : undefined, - }; + }, + { + field: 'type', + name: ( + + ), + sortable: true, + render: (type: CategorizedApiKey['type']) => , + } + ); - const callOutTitle = this.determineCallOutTitle(this.props.readOnly ?? false); + if (canManageApiKeys || usernameFilters.length > 1) { + columns.push({ + field: 'username', + name: ( + + ), + sortable: true, + render: (username: CategorizedApiKey['username']) => , + }); + } - return ( - <> - {!isAdmin ? ( - <> - - - - ) : undefined} - - - {(invalidateApiKeyPrompt) => ( - - )} - - - ); - }; + columns.push( + { + field: 'creation', + name: ( + + ), + sortable: true, + mobileOptions: { + show: false, + }, + render: (creation: number, item: CategorizedApiKey) => ( + + {item.id === createdApiKey?.id ? ( + + + + ) : null} + + ), + }, + { + field: 'expiration', + name: ( + + ), + sortable: true, + render: (expiration: number) => , + } + ); - private getColumnConfig = (invalidateApiKeyPrompt: InvalidateApiKeys) => { - const { isAdmin, createdApiKey } = this.state; - - let config: Array> = []; - - config = config.concat([ - { - field: 'name', - name: i18n.translate('xpack.security.management.apiKeys.table.nameColumnName', { - defaultMessage: 'Name', - }), - sortable: true, - render: (name: string, recordAP: ApiKey) => { - return ( - - { - this.setState({ selectedApiKey: recordAP, isUpdateFlyoutVisible: true }); - }} - > - {name} - - - ); + if (!readOnly) { + columns.push({ + width: `${24 + 2 * 8}px`, + actions: [ + { + name: ( + + ), + description: i18n.translate('xpack.security.management.apiKeys.table.deleteDescription', { + defaultMessage: 'Delete this API key', + }), + icon: 'trash', + type: 'icon', + color: 'danger', + onClick: (item) => onDelete([item]), + available: deletable, + 'data-test-subj': 'apiKeysTableDeleteAction', }, - }, - ]); + ], + }); + } + + const filters: SearchFilterConfig[] = []; + + if (typeFilters.length > 1) { + filters.push({ + type: 'custom_component', + component: ({ query, onChange }) => ( + + ), + }); + } + + if (usernameFilters.length > 1) { + filters.push({ + type: 'custom_component', + component: ({ query, onChange }) => ( + + ), + }); + } - if (isAdmin) { - config = config.concat([ + if (expiredFilters.length > 1) { + filters.push({ + type: 'field_value_toggle_group', + field: 'expired', + items: [ { - field: 'username', - name: i18n.translate('xpack.security.management.apiKeys.table.userNameColumnName', { - defaultMessage: 'User', + value: false, + name: i18n.translate('xpack.security.management.apiKeys.table.activeFilter', { + defaultMessage: 'Active', }), - sortable: true, - render: (username: string) => ( - - - - - - {username} - - - ), }, { - field: 'realm', - name: i18n.translate('xpack.security.management.apiKeys.table.realmColumnName', { - defaultMessage: 'Realm', + value: true, + name: i18n.translate('xpack.security.management.apiKeys.table.expiredFilter', { + defaultMessage: 'Expired', }), - sortable: true, }, - ]); - } + ], + }); + } - config = config.concat([ - { - field: 'creation', - name: i18n.translate('xpack.security.management.apiKeys.table.creationDateColumnName', { - defaultMessage: 'Created', - }), - sortable: true, - mobileOptions: { - show: false, + return ( + 0 + ? { + toolsLeft: selectedItems.length ? ( + onDelete(selectedItems)} + color="danger" + iconType="trash" + data-test-subj="bulkInvalidateActionButton" + > + + + ) : undefined, + box: { + incremental: true, + }, + filters, + } + : undefined + } + sorting={{ + sort: { + field: 'creation', + direction: 'desc', }, - render: (creation: string, item: ApiKey) => ( - - {item.id === createdApiKey?.id ? ( - - - - ) : ( - {moment(creation).fromNow()} - )} - - ), - }, - { - name: i18n.translate('xpack.security.management.apiKeys.table.statusColumnName', { - defaultMessage: 'Status', - }), - render: ({ expiration }: any) => { - if (!expiration) { - return ( - - - - ); - } + }} + selection={ + readOnly + ? undefined + : { + selectable: deletable, + onSelectionChange: setSelectedItems, + } + } + pagination={{ + initialPageSize: 10, + pageSizeOptions: [10, 25, 50], + }} + loading={loading} + isSelectable={canManageOwnApiKeys} + /> + ); +}; + +export interface TypesFilterButtonProps { + query: Query; + onChange?: (query: Query) => void; + types: string[]; +} - if (Date.now() > expiration) { - return ( - - - - ); +export const TypesFilterButton: FunctionComponent = ({ + query, + onChange, + types, +}) => { + if (!onChange) { + return null; + } + + return ( + <> + {types.includes('rest') ? ( + + onChange( + query.hasSimpleFieldClause('type', 'rest') + ? query.removeSimpleFieldClauses('type') + : query.removeSimpleFieldClauses('type').addSimpleFieldValue('type', 'rest') + ) + } + withNext={types.includes('cross_cluster') || types.includes('managed')} + > + + + ) : null} + {types.includes('cross_cluster') ? ( + + onChange( + query.hasSimpleFieldClause('type', 'cross_cluster') + ? query.removeSimpleFieldClauses('type') + : query + .removeSimpleFieldClauses('type') + .addSimpleFieldValue('type', 'cross_cluster') + ) } + withNext={types.includes('managed')} + > + + + ) : null} + {types.includes('managed') ? ( + + onChange( + query.hasSimpleFieldClause('type', 'managed') + ? query.removeSimpleFieldClauses('type') + : query.removeSimpleFieldClauses('type').addSimpleFieldValue('type', 'managed') + ) + } + > + + + ) : null} + + ); +}; + +export interface UsersFilterButtonProps { + query: Query; + onChange?: (query: Query) => void; + usernames: string[]; +} - return ( - - - - - - ); - }, - }, - ]); +export const UsersFilterButton: FunctionComponent = ({ + query, + onChange, + usernames, +}) => { + const [isOpen, setIsOpen] = useState(false); + const [searchTerm, setSearchTerm] = useState(''); - if (!this.props.readOnly) { - config.push({ - actions: [ - { - name: i18n.translate('xpack.security.management.apiKeys.table.deleteAction', { - defaultMessage: 'Delete', - }), - description: i18n.translate( - 'xpack.security.management.apiKeys.table.deleteDescription', - { - defaultMessage: 'Delete this API key', - } - ), - icon: 'trash', - type: 'icon', - color: 'danger', - onClick: (item) => - invalidateApiKeyPrompt([{ id: item.id, name: item.name }], this.onApiKeysInvalidated), - 'data-test-subj': 'apiKeysTableDeleteAction', - }, - ], - }); + if (!onChange) { + return null; + } + + let numActiveFilters = 0; + const clause = query.getOrFieldClause('username'); + if (clause) { + if (Array.isArray(clause.value)) { + numActiveFilters = clause.value.length; + } else { + numActiveFilters = 1; } + } - return config; - }; + const usernamesMatchingSearchTerm = searchTerm + ? usernames.filter((username) => username.includes(searchTerm)) + : usernames; + + return ( + setIsOpen((toggle) => !toggle)} + isSelected={isOpen} + numFilters={usernames.length} + hasActiveFilters={numActiveFilters ? true : false} + numActiveFilters={numActiveFilters} + > + + + } + isOpen={isOpen} + panelPaddingSize="none" + anchorPosition="downCenter" + panelClassName="euiFilterGroup__popoverPanel" + closePopover={() => setIsOpen(false)} + selectableProps={{ + options: usernamesMatchingSearchTerm.map((username) => ({ + uid: username, + user: { username }, + enabled: false, + data: {}, + })), + onSearchChange: setSearchTerm, + selectedOptions: usernames + .filter((username) => query.hasOrFieldClause('username', username)) + .map((username) => ({ + uid: username, + user: { username }, + enabled: false, + data: {}, + })), + onChange: (nextSelectedOptions) => { + const nextQuery = nextSelectedOptions.reduce( + (acc, option) => acc.addOrFieldValue('username', option.user.username), + query.removeOrFieldClauses('username') + ); + onChange(nextQuery); + }, + }} + /> + ); +}; + +export type UsernameWithIconProps = Pick; + +export const UsernameWithIcon: FunctionComponent = ({ username }) => ( + + + + + + + {username} + + + +); + +export interface TimeToolTipProps { + timestamp: number; +} - private onApiKeysInvalidated = (apiKeysInvalidated: ApiKeyToInvalidate[]): void => { - if (apiKeysInvalidated.length) { - this.reloadApiKeys(); - } - }; +export const TimeToolTip: FunctionComponent = ({ timestamp, children }) => { + return ( + + {children ?? moment(timestamp).fromNow()} + + ); +}; - private async checkPrivileges() { - try { - const { isAdmin, canManage, areApiKeysEnabled } = - await this.props.apiKeysAPIClient.checkPrivileges(); - this.setState({ isAdmin, canManage, areApiKeysEnabled }); +export type ApiKeyStatusProps = Pick; - if ((!canManage && !this.props.readOnly) || !areApiKeysEnabled) { - this.setState({ isLoadingApp: false }); - } else { - this.loadApiKeys(); - } - } catch (e) { - this.props.notifications.toasts.addDanger( - i18n.translate('xpack.security.management.apiKeys.table.fetchingApiKeysErrorMessage', { - defaultMessage: 'Error checking privileges: {message}', - values: { message: e.body?.message ?? '' }, - }) - ); - } +export const ApiKeyStatus: FunctionComponent = ({ expiration }) => { + if (!expiration) { + return ( + + + + ); } - private reloadApiKeys = () => { - this.setState({ - isLoadingApp: false, - isLoadingTable: true, - createdApiKey: undefined, - error: undefined, - }); - this.loadApiKeys(); - }; + if (Date.now() > expiration) { + return ( + + + + ); + } - private loadApiKeys = async () => { - try { - const { isAdmin } = this.state; - const { apiKeys } = await this.props.apiKeysAPIClient.getApiKeys(isAdmin); - this.setState({ apiKeys }); - } catch (e) { - this.setState({ error: e }); - } + return ( + + + + + + ); +}; - this.setState({ isLoadingApp: false, isLoadingTable: false }); - }; +export interface ApiKeyBadgeProps { + type: CategorizedApiKeyType; +} - private determineDescription(isAdmin: boolean, readOnly: boolean) { - if (isAdmin) { - return ( +export const ApiKeyBadge: FunctionComponent = ({ type }) => { + return type === 'cross_cluster' ? ( + - ); - } else if (readOnly) { - return ( + } + > + - ); - } else { - return ( + + + ) : type === 'managed' ? ( + - ); - } - } - - private determineCallOutTitle(readOnly: boolean) { - if (readOnly) { - return ( + } + > + - ); - } else { - return ( + + + ) : ( + - ); - } - } + } + > + + + + + ); +}; - private displayUpdatedApiKeyToast(updateApiKeyResponse?: UpdateApiKeyResponse) { - if (updateApiKeyResponse) { - this.props.notifications.toasts.addSuccess({ - title: i18n.translate('xpack.security.management.apiKeys.updateSuccessMessage', { - defaultMessage: "Updated API key '{name}'", - values: { name: this.state.selectedApiKey?.name }, - }), - 'data-test-subj': 'updateApiKeySuccessToast', - }); - } - } +/** + * Interface representing a REST API key that is managed by Kibana. + */ +export interface ManagedApiKey extends Omit { + type: 'managed'; +} + +/** + * Interface representing an API key the way it is presented in the Kibana UI (with Kibana system + * API keys given its own dedicated `managed` type). + */ +export type CategorizedApiKey = (ApiKey | ManagedApiKey) & { + expired: boolean; +}; + +/** + * Categorizes API keys by type (with Kibana system API keys given its own dedicated `managed` type) + * and determines applicable filter values. + */ +export function categorizeApiKeys(apiKeys: ApiKey[]) { + const categorizedApiKeys: CategorizedApiKey[] = []; + const typeFilters: Set = new Set(); + const usernameFilters: Set = new Set(); + const expiredFilters: Set = new Set(); + + apiKeys.forEach((apiKey) => { + const type = getApiKeyType(apiKey); + const expired = apiKey.expiration ? Date.now() > apiKey.expiration : false; + + typeFilters.add(type); + usernameFilters.add(apiKey.username); + expiredFilters.add(expired); + + categorizedApiKeys.push({ ...apiKey, type, expired } as CategorizedApiKey); + }); + + return { + categorizedApiKeys, + typeFilters: [...typeFilters], + usernameFilters: [...usernameFilters], + expiredFilters: [...expiredFilters], + }; +} + +export type CategorizedApiKeyType = ReturnType; + +/** + * Determines API key type the way it is presented in the UI with Kibana system API keys given its own dedicated `managed` type. + */ +export function getApiKeyType(apiKey: ApiKey) { + return apiKey.type === 'rest' && + (apiKey.metadata?.managed || apiKey.name.indexOf('Alerting: ') === 0) + ? 'managed' + : apiKey.type; } diff --git a/x-pack/plugins/security/public/management/api_keys/api_keys_management_app.test.tsx b/x-pack/plugins/security/public/management/api_keys/api_keys_management_app.test.tsx index bcde6bbb619b7..b487b977b620b 100644 --- a/x-pack/plugins/security/public/management/api_keys/api_keys_management_app.test.tsx +++ b/x-pack/plugins/security/public/management/api_keys/api_keys_management_app.test.tsx @@ -5,107 +5,63 @@ * 2.0. */ -jest.mock('./api_keys_grid', () => ({ - APIKeysGridPage: (props: any) => JSON.stringify(props, null, 2), -})); - import { act } from '@testing-library/react'; +import { noop } from 'lodash'; import { coreMock, scopedHistoryMock, themeServiceMock } from '@kbn/core/public/mocks'; import type { Unmount } from '@kbn/management-plugin/public/types'; +import { mockAuthenticatedUser } from '../../../common/model/authenticated_user.mock'; import { securityMock } from '../../mocks'; import { apiKeysManagementApp } from './api_keys_management_app'; +const element = document.body.appendChild(document.createElement('div')); + describe('apiKeysManagementApp', () => { - it('create() returns proper management app descriptor', () => { + it('renders application and sets breadcrumbs', async () => { const { getStartServices } = coreMock.createSetup(); + const coreStartMock = coreMock.createStart(); + getStartServices.mockResolvedValue([coreStartMock, {}, {}]); const { authc } = securityMock.createSetup(); - - expect(apiKeysManagementApp.create({ authc, getStartServices: getStartServices as any })) - .toMatchInlineSnapshot(` - Object { - "id": "api_keys", - "mount": [Function], - "order": 30, - "title": "API keys", - } - `); - }); - - it('mount() works for the `grid` page', async () => { - const coreStart = coreMock.createSetup(); - const { authc } = securityMock.createSetup(); - - const startServices = await coreStart.getStartServices(); - - const [{ application }] = startServices; - application.capabilities = { - ...application.capabilities, + const setBreadcrumbs = jest.fn(); + const history = scopedHistoryMock.create({ pathname: '/' }); + coreStartMock.application.capabilities = { + ...coreStartMock.application.capabilities, api_keys: { save: true, }, }; - const docTitle = startServices[0].chrome.docTitle; - - const container = document.createElement('div'); - - const setBreadcrumbs = jest.fn(); - - let unmount: Unmount; + coreStartMock.http.get.mockResolvedValue({ + apiKeys: [], + canManageCrossClusterApiKeys: true, + canManageApiKeys: true, + canManageOwnApiKeys: true, + }); + authc.getCurrentUser.mockResolvedValue( + mockAuthenticatedUser({ + username: 'elastic', + full_name: '', + email: '', + enabled: true, + roles: ['superuser'], + }) + ); + + let unmount: Unmount = noop; await act(async () => { - unmount = await apiKeysManagementApp - .create({ authc, getStartServices: () => Promise.resolve(startServices) as any }) - .mount({ - basePath: '/some-base-path', - element: container, - setBreadcrumbs, - history: scopedHistoryMock.create(), - theme$: themeServiceMock.createTheme$(), - }); + unmount = await apiKeysManagementApp.create({ authc, getStartServices }).mount({ + basePath: '/', + element, + setBreadcrumbs, + history, + theme$: themeServiceMock.createTheme$(), + }); }); - expect(setBreadcrumbs).toHaveBeenCalledTimes(1); - expect(setBreadcrumbs).toHaveBeenCalledWith([{ text: 'API keys' }]); - expect(docTitle.change).toHaveBeenCalledWith(['API keys']); - expect(docTitle.reset).not.toHaveBeenCalled(); - expect(container).toMatchInlineSnapshot(` -
- { - "history": { - "action": "PUSH", - "length": 1, - "location": { - "pathname": "/", - "search": "", - "hash": "" - } - }, - "notifications": { - "toasts": {} - }, - "apiKeysAPIClient": { - "http": { - "basePath": { - "basePath": "", - "serverBasePath": "" - }, - "anonymousPaths": {}, - "externalUrl": {} - } - }, - "readOnly": false - } -
- `); - - act(() => { - unmount!(); - }); + expect(setBreadcrumbs).toHaveBeenLastCalledWith([{ text: 'API keys' }]); - expect(docTitle.reset).toHaveBeenCalledTimes(1); - expect(container).toMatchInlineSnapshot(`
`); + unmount(); }); }); diff --git a/x-pack/plugins/security/public/management/api_keys/api_keys_management_app.tsx b/x-pack/plugins/security/public/management/api_keys/api_keys_management_app.tsx index c7f0f675bde48..ac7b067e3371c 100644 --- a/x-pack/plugins/security/public/management/api_keys/api_keys_management_app.tsx +++ b/x-pack/plugins/security/public/management/api_keys/api_keys_management_app.tsx @@ -44,10 +44,9 @@ export const apiKeysManagementApp = Object.freeze({ defaultMessage: 'API keys', }), async mount({ element, theme$, setBreadcrumbs, history }) { - const [[coreStart], { APIKeysGridPage }, { APIKeysAPIClient }] = await Promise.all([ + const [[coreStart], { APIKeysGridPage }] = await Promise.all([ getStartServices(), import('./api_keys_grid'), - import('./api_keys_api_client'), ]); render( @@ -64,12 +63,7 @@ export const apiKeysManagementApp = Object.freeze({ })} href="/" > - + , element diff --git a/x-pack/plugins/security/server/authentication/api_keys/api_keys.mock.ts b/x-pack/plugins/security/server/authentication/api_keys/api_keys.mock.ts index 697f1bf355a70..cfa857ca833a2 100644 --- a/x-pack/plugins/security/server/authentication/api_keys/api_keys.mock.ts +++ b/x-pack/plugins/security/server/authentication/api_keys/api_keys.mock.ts @@ -12,6 +12,7 @@ import type { APIKeys } from './api_keys'; export const apiKeysMock = { create: (): jest.Mocked> => ({ areAPIKeysEnabled: jest.fn(), + areCrossClusterAPIKeysEnabled: jest.fn(), create: jest.fn(), update: jest.fn(), grantAsInternalUser: jest.fn(), diff --git a/x-pack/plugins/security/server/authentication/api_keys/api_keys.test.ts b/x-pack/plugins/security/server/authentication/api_keys/api_keys.test.ts index b59ff8e506380..24bebe0862591 100644 --- a/x-pack/plugins/security/server/authentication/api_keys/api_keys.test.ts +++ b/x-pack/plugins/security/server/authentication/api_keys/api_keys.test.ts @@ -140,6 +140,56 @@ describe('API Keys', () => { }); }); + describe('areCrossClusterAPIKeysEnabled()', () => { + it('returns false when security feature is disabled', async () => { + mockLicense.isEnabled.mockReturnValue(false); + + const result = await apiKeys.areCrossClusterAPIKeysEnabled(); + expect(result).toEqual(false); + expect(mockClusterClient.asInternalUser.transport.request).not.toHaveBeenCalled(); + }); + + it('returns false when the operation completes without error (which should never happen)', async () => { + mockLicense.isEnabled.mockReturnValue(true); + mockClusterClient.asInternalUser.transport.request.mockResolvedValueOnce({}); + + const result = await apiKeys.areCrossClusterAPIKeysEnabled(); + expect(result).toEqual(false); + expect(mockClusterClient.asInternalUser.transport.request).toHaveBeenCalledTimes(1); + }); + + it('returns false when the exception metadata indicates cross cluster api keys are disabled', async () => { + mockLicense.isEnabled.mockReturnValue(true); + mockClusterClient.asInternalUser.transport.request.mockRejectedValueOnce({ + statusCode: 404, + }); + + const result = await apiKeys.areCrossClusterAPIKeysEnabled(); + expect(result).toEqual(false); + expect(mockClusterClient.asInternalUser.transport.request).toHaveBeenCalledWith({ + method: 'PUT', + path: '/_security/cross_cluster/api_key/kibana-api-key-service-test', + body: {}, + }); + }); + + it('returns true when the exception metadata indicates cross cluster api keys are enabled', async () => { + mockLicense.isEnabled.mockReturnValue(true); + mockClusterClient.asInternalUser.transport.request.mockRejectedValueOnce({ + statusCode: 400, + body: { error: { type: 'action_request_validation_exception' } }, + }); + + const result = await apiKeys.areCrossClusterAPIKeysEnabled(); + expect(result).toEqual(true); + expect(mockClusterClient.asInternalUser.transport.request).toHaveBeenCalledWith({ + method: 'PUT', + path: '/_security/cross_cluster/api_key/kibana-api-key-service-test', + body: {}, + }); + }); + }); + describe('create()', () => { it('returns null when security feature is disabled', async () => { mockLicense.isEnabled.mockReturnValue(false); @@ -177,7 +227,7 @@ describe('API Keys', () => { expect(mockScopedClusterClient.asCurrentUser.security.createApiKey).not.toHaveBeenCalled(); }); - it('calls `createApiKey` with proper parameters', async () => { + it('calls `createApiKey` with proper parameters when type is `rest` or not defined', async () => { mockLicense.isEnabled.mockReturnValue(true); mockScopedClusterClient.asCurrentUser.security.createApiKey.mockResponseOnce({ @@ -199,6 +249,7 @@ describe('API Keys', () => { name: 'key-name', }); expect(mockValidateKibanaPrivileges).not.toHaveBeenCalled(); // this is only called if kibana_role_descriptors is defined + expect(mockScopedClusterClient.asCurrentUser.transport.request).not.toHaveBeenCalled(); expect(mockScopedClusterClient.asCurrentUser.security.createApiKey).toHaveBeenCalledWith({ body: { name: 'key-name', @@ -207,6 +258,42 @@ describe('API Keys', () => { }, }); }); + + it('creates Cross-Cluster API key when type is `cross_cluster`', async () => { + mockLicense.isEnabled.mockReturnValue(true); + + mockScopedClusterClient.asCurrentUser.transport.request.mockResolvedValueOnce({ + id: '123', + name: 'key-name', + expiration: '1d', + api_key: 'abc123', + }); + const result = await apiKeys.create(httpServerMock.createKibanaRequest(), { + type: 'cross_cluster', + name: 'key-name', + expiration: '1d', + access: {}, + metadata: {}, + }); + expect(result).toEqual({ + api_key: 'abc123', + expiration: '1d', + id: '123', + name: 'key-name', + }); + expect(mockValidateKibanaPrivileges).not.toHaveBeenCalled(); // this is only called if kibana_role_descriptors is defined + expect(mockScopedClusterClient.asCurrentUser.security.createApiKey).not.toHaveBeenCalled(); + expect(mockScopedClusterClient.asCurrentUser.transport.request).toHaveBeenCalledWith({ + method: 'POST', + path: '/_security/cross_cluster/api_key', + body: { + name: 'key-name', + expiration: '1d', + access: {}, + metadata: {}, + }, + }); + }); }); describe('update()', () => { @@ -300,6 +387,33 @@ describe('API Keys', () => { metadata: {}, }); }); + + it('updates Cross-Cluster API key when type is `cross_cluster`', async () => { + mockLicense.isEnabled.mockReturnValue(true); + + mockScopedClusterClient.asCurrentUser.transport.request.mockResolvedValueOnce({ + updated: true, + }); + const result = await apiKeys.update(httpServerMock.createKibanaRequest(), { + type: 'cross_cluster', + id: '123', + access: {}, + metadata: {}, + }); + expect(result).toEqual({ + updated: true, + }); + expect(mockValidateKibanaPrivileges).not.toHaveBeenCalled(); // this is only called if kibana_role_descriptors is defined + expect(mockScopedClusterClient.asCurrentUser.security.updateApiKey).not.toHaveBeenCalled(); + expect(mockScopedClusterClient.asCurrentUser.transport.request).toHaveBeenCalledWith({ + method: 'PUT', + path: '/_security/cross_cluster/api_key/123', + body: { + access: {}, + metadata: {}, + }, + }); + }); }); describe('grantAsInternalUser()', () => { diff --git a/x-pack/plugins/security/server/authentication/api_keys/api_keys.ts b/x-pack/plugins/security/server/authentication/api_keys/api_keys.ts index 854524df7d596..462630d4eae28 100644 --- a/x-pack/plugins/security/server/authentication/api_keys/api_keys.ts +++ b/x-pack/plugins/security/server/authentication/api_keys/api_keys.ts @@ -9,17 +9,34 @@ import type { IClusterClient, KibanaRequest, Logger } from '@kbn/core/server'; import type { KibanaFeature } from '@kbn/features-plugin/server'; -import type { OneOf } from '@kbn/utility-types'; import type { SecurityLicense } from '../../../common/licensing'; -import type { ElasticsearchPrivilegesType, KibanaPrivilegesType } from '../../lib'; import { transformPrivilegesToElasticsearchPrivileges, validateKibanaPrivileges } from '../../lib'; +import type { + CreateAPIKeyParams, + CreateAPIKeyResult, + CreateCrossClusterAPIKeyParams, + CreateRestAPIKeyParams, + CreateRestAPIKeyWithKibanaPrivilegesParams, + UpdateAPIKeyParams, + UpdateAPIKeyResult, +} from '../../routes/api_keys'; import { BasicHTTPAuthorizationHeaderCredentials, HTTPAuthorizationHeader, } from '../http_authentication'; import { getFakeKibanaRequest } from './fake_kibana_request'; +export type { + CreateAPIKeyParams, + CreateAPIKeyResult, + CreateRestAPIKeyParams, + CreateRestAPIKeyWithKibanaPrivilegesParams, + CreateCrossClusterAPIKeyParams, + UpdateAPIKeyParams, + UpdateAPIKeyResult, +}; + /** * Represents the options to create an APIKey class instance that will be * shared between functions (create, invalidate, etc). @@ -32,52 +49,15 @@ export interface ConstructorOptions { kibanaFeatures: KibanaFeature[]; } -interface BaseCreateAPIKeyParams { - name: string; - expiration?: string; - metadata?: Record; - role_descriptors: Record; - kibana_role_descriptors: Record< - string, - { elasticsearch: ElasticsearchPrivilegesType; kibana: KibanaPrivilegesType } - >; -} - -interface BaseUpdateAPIKeyParams { - id: string; - metadata?: Record; - role_descriptors: Record; - kibana_role_descriptors: Record< - string, - { elasticsearch: ElasticsearchPrivilegesType; kibana: KibanaPrivilegesType } - >; -} - -/** - * Represents the params for creating an API key - */ -export type CreateAPIKeyParams = OneOf< - BaseCreateAPIKeyParams, - 'role_descriptors' | 'kibana_role_descriptors' ->; - -/** - * Represents the params for updating an API key - */ -export type UpdateAPIKeyParams = OneOf< - BaseUpdateAPIKeyParams, - 'role_descriptors' | 'kibana_role_descriptors' ->; - type GrantAPIKeyParams = | { - api_key: CreateAPIKeyParams; + api_key: CreateRestAPIKeyParams | CreateRestAPIKeyWithKibanaPrivilegesParams; grant_type: 'password'; username: string; password: string; } | { - api_key: CreateAPIKeyParams; + api_key: CreateRestAPIKeyParams | CreateRestAPIKeyWithKibanaPrivilegesParams; grant_type: 'access_token'; access_token: string; }; @@ -89,42 +69,6 @@ export interface InvalidateAPIKeysParams { ids: string[]; } -/** - * The return value when creating an API key in Elasticsearch. The API key returned by this API - * can then be used by sending a request with a Authorization header with a value having the - * prefix ApiKey `{token}` where token is id and api_key joined by a colon `{id}:{api_key}` and - * then encoded to base64. - */ -export interface CreateAPIKeyResult { - /** - * Unique id for this API key - */ - id: string; - /** - * Name for this API key - */ - name: string; - /** - * Optional expiration in milliseconds for this API key - */ - expiration?: number; - /** - * Generated API key - */ - api_key: string; -} - -/** - * The return value when update an API key in Elasticsearch. The value returned by this API - * can is contains a `updated` boolean that corresponds to whether or not the API key was updated - */ -export interface UpdateAPIKeyResult { - /** - * Boolean represented if the API key was updated in ES - */ - updated: boolean; -} - export interface GrantAPIKeyResult { /** * Unique id for this API key @@ -216,7 +160,7 @@ export class APIKeys { return false; } - const id = `kibana-api-key-service-test`; + const id = 'kibana-api-key-service-test'; this.logger.debug( `Testing if API Keys are enabled by attempting to invalidate a non-existant key: ${id}` @@ -237,11 +181,39 @@ export class APIKeys { } } + /** + * Determines if Cross-Cluster API Keys are enabled in Elasticsearch. + */ + async areCrossClusterAPIKeysEnabled(): Promise { + if (!this.license.isEnabled()) { + return false; + } + + const id = 'kibana-api-key-service-test'; + + this.logger.debug( + `Testing if Cross-Cluster API Keys are enabled by attempting to update a non-existant key: ${id}` + ); + + try { + await this.clusterClient.asInternalUser.transport.request({ + method: 'PUT', + path: `/_security/cross_cluster/api_key/${id}`, + body: {}, // We are sending an empty request body and expect a validation error if Update Cross-Cluster API key endpoint is available. + }); + return false; + } catch (error) { + return !this.doesErrorIndicateCrossClusterAPIKeysAreDisabled(error); + } + } + /** * Tries to create an API key for the current user. * * Returns newly created API key or `null` if API keys are disabled. * + * User needs `manage_api_key` privilege to create REST API keys and `manage_security` for Cross-Cluster API keys. + * * @param request Request instance. * @param createParams The params to create an API key */ @@ -253,22 +225,40 @@ export class APIKeys { return null; } - const { expiration, metadata, name } = createParams; - - const roleDescriptors = this.parseRoleDescriptorsWithKibanaPrivileges(createParams, false); + const { type, expiration, name, metadata } = createParams; + const scopedClusterClient = this.clusterClient.asScoped(request); this.logger.debug('Trying to create an API key'); - // User needs `manage_api_key` privilege to use this API let result: CreateAPIKeyResult; try { - result = await this.clusterClient.asScoped(request).asCurrentUser.security.createApiKey({ - body: { role_descriptors: roleDescriptors, name, metadata, expiration }, - }); + if (type === 'cross_cluster') { + result = await scopedClusterClient.asCurrentUser.transport.request({ + method: 'POST', + path: '/_security/cross_cluster/api_key', + body: { name, expiration, metadata, access: createParams.access }, + }); + } else { + result = await scopedClusterClient.asCurrentUser.security.createApiKey({ + body: { + name, + expiration, + metadata, + role_descriptors: + 'role_descriptors' in createParams + ? createParams.role_descriptors + : this.parseRoleDescriptorsWithKibanaPrivileges( + createParams.kibana_role_descriptors, + false + ), + }, + }); + } + this.logger.debug('API key was created successfully'); - } catch (e) { - this.logger.error(`Failed to create API key: ${e.message}`); - throw e; + } catch (error) { + this.logger.error(`Failed to create API key: ${error.message}`); + throw error; } return result; } @@ -278,6 +268,8 @@ export class APIKeys { * * Returns `updated`, `true` if the update was successful, `false` if there was nothing to update * + * User needs `manage_api_key` privilege to update REST API keys and `manage_security` for Cross-Cluster API keys. + * * @param request Request instance. * @param updateParams The params to edit an API key */ @@ -289,32 +281,42 @@ export class APIKeys { return null; } - const { id, metadata } = updateParams; - - const roleDescriptors = this.parseRoleDescriptorsWithKibanaPrivileges(updateParams, true); + const { type, id, metadata } = updateParams; + const scopedClusterClient = this.clusterClient.asScoped(request); this.logger.debug('Trying to edit an API key'); - // User needs `manage_api_key` privilege to use this API let result: UpdateAPIKeyResult; - try { - result = await this.clusterClient.asScoped(request).asCurrentUser.security.updateApiKey({ - id, - role_descriptors: roleDescriptors, - metadata, - }); + if (type === 'cross_cluster') { + result = await scopedClusterClient.asCurrentUser.transport.request({ + method: 'PUT', + path: `/_security/cross_cluster/api_key/${id}`, + body: { metadata, access: updateParams.access }, + }); + } else { + result = await scopedClusterClient.asCurrentUser.security.updateApiKey({ + id, + metadata, + role_descriptors: + 'role_descriptors' in updateParams + ? updateParams.role_descriptors + : this.parseRoleDescriptorsWithKibanaPrivileges( + updateParams.kibana_role_descriptors, + true + ), + }); + } if (result.updated) { this.logger.debug('API key was updated successfully'); } else { this.logger.debug('There were no updates to make for API key'); } - } catch (e) { - this.logger.error(`Failed to update API key: ${e.message}`); - throw e; + } catch (error) { + this.logger.error(`Failed to update API key: ${error.message}`); + throw error; } - return result; } @@ -323,7 +325,10 @@ export class APIKeys { * @param request Request instance. * @param createParams Create operation parameters. */ - async grantAsInternalUser(request: KibanaRequest, createParams: CreateAPIKeyParams) { + async grantAsInternalUser( + request: KibanaRequest, + createParams: CreateRestAPIKeyParams | CreateRestAPIKeyWithKibanaPrivilegesParams + ) { if (!this.license.isEnabled()) { return null; } @@ -337,7 +342,13 @@ export class APIKeys { } const { expiration, metadata, name } = createParams; - const roleDescriptors = this.parseRoleDescriptorsWithKibanaPrivileges(createParams, false); + const roleDescriptors = + 'role_descriptors' in createParams + ? createParams.role_descriptors + : this.parseRoleDescriptorsWithKibanaPrivileges( + createParams.kibana_role_descriptors, + false + ); const params = this.getGrantParams( { expiration, metadata, name, role_descriptors: roleDescriptors }, @@ -451,8 +462,14 @@ export class APIKeys { return disabledFeature === 'api_keys'; } + private doesErrorIndicateCrossClusterAPIKeysAreDisabled(error: Record) { + return ( + error.statusCode !== 400 || error.body?.error?.type !== 'action_request_validation_exception' + ); + } + private getGrantParams( - createParams: CreateAPIKeyParams, + createParams: CreateRestAPIKeyParams | CreateRestAPIKeyWithKibanaPrivilegesParams, authorizationHeader: HTTPAuthorizationHeader ): GrantAPIKeyParams { if (authorizationHeader.scheme.toLowerCase() === 'bearer') { @@ -479,23 +496,11 @@ export class APIKeys { } private parseRoleDescriptorsWithKibanaPrivileges( - params: Partial<{ - kibana_role_descriptors: Record< - string, - { elasticsearch: ElasticsearchPrivilegesType; kibana: KibanaPrivilegesType } - >; - role_descriptors: Record; - }>, + kibanaRoleDescriptors: CreateRestAPIKeyWithKibanaPrivilegesParams['kibana_role_descriptors'], isEdit: boolean ) { - if (params.role_descriptors) { - return params.role_descriptors; - } - const roleDescriptors = Object.create(null); - const { kibana_role_descriptors: kibanaRoleDescriptors } = params; - const allValidationErrors: string[] = []; if (kibanaRoleDescriptors) { Object.entries(kibanaRoleDescriptors).forEach(([roleKey, roleDescriptor]) => { diff --git a/x-pack/plugins/security/server/authentication/api_keys/index.ts b/x-pack/plugins/security/server/authentication/api_keys/index.ts index b93043302f8d2..ae9e9c98c149b 100644 --- a/x-pack/plugins/security/server/authentication/api_keys/index.ts +++ b/x-pack/plugins/security/server/authentication/api_keys/index.ts @@ -6,9 +6,12 @@ */ export type { + CreateAPIKeyParams, CreateAPIKeyResult, + CreateRestAPIKeyParams, + CreateRestAPIKeyWithKibanaPrivilegesParams, + CreateCrossClusterAPIKeyParams, InvalidateAPIKeyResult, - CreateAPIKeyParams, InvalidateAPIKeysParams, ValidateAPIKeyParams, GrantAPIKeyResult, diff --git a/x-pack/plugins/security/server/authentication/authentication_service.ts b/x-pack/plugins/security/server/authentication/authentication_service.ts index cbd71b890761f..170d3d1d24784 100644 --- a/x-pack/plugins/security/server/authentication/authentication_service.ts +++ b/x-pack/plugins/security/server/authentication/authentication_service.ts @@ -63,6 +63,7 @@ export interface InternalAuthenticationServiceStart extends AuthenticationServic apiKeys: Pick< APIKeys, | 'areAPIKeysEnabled' + | 'areCrossClusterAPIKeysEnabled' | 'create' | 'update' | 'invalidate' @@ -83,6 +84,7 @@ export interface AuthenticationServiceStart { apiKeys: Pick< APIKeys, | 'areAPIKeysEnabled' + | 'areCrossClusterAPIKeysEnabled' | 'create' | 'invalidate' | 'validate' @@ -371,6 +373,7 @@ export class AuthenticationService { return { apiKeys: { areAPIKeysEnabled: apiKeys.areAPIKeysEnabled.bind(apiKeys), + areCrossClusterAPIKeysEnabled: apiKeys.areCrossClusterAPIKeysEnabled.bind(apiKeys), create: apiKeys.create.bind(apiKeys), update: apiKeys.update.bind(apiKeys), grantAsInternalUser: apiKeys.grantAsInternalUser.bind(apiKeys), diff --git a/x-pack/plugins/security/server/authentication/index.ts b/x-pack/plugins/security/server/authentication/index.ts index 2e844c6f60542..e207b316922dd 100644 --- a/x-pack/plugins/security/server/authentication/index.ts +++ b/x-pack/plugins/security/server/authentication/index.ts @@ -28,9 +28,12 @@ export { HTTPAuthorizationHeader, } from './http_authentication'; export type { + CreateAPIKeyParams, CreateAPIKeyResult, + CreateRestAPIKeyParams, + CreateRestAPIKeyWithKibanaPrivilegesParams, + CreateCrossClusterAPIKeyParams, InvalidateAPIKeyResult, - CreateAPIKeyParams, InvalidateAPIKeysParams, ValidateAPIKeyParams, GrantAPIKeyResult, diff --git a/x-pack/plugins/security/server/index.ts b/x-pack/plugins/security/server/index.ts index 9fbefabecd249..4cf199ef6d686 100644 --- a/x-pack/plugins/security/server/index.ts +++ b/x-pack/plugins/security/server/index.ts @@ -23,6 +23,9 @@ import { SecurityPlugin } from './plugin'; export type { CreateAPIKeyParams, CreateAPIKeyResult, + CreateRestAPIKeyParams, + CreateRestAPIKeyWithKibanaPrivilegesParams, + CreateCrossClusterAPIKeyParams, InvalidateAPIKeysParams, InvalidateAPIKeyResult, GrantAPIKeyResult, diff --git a/x-pack/plugins/security/server/plugin.test.ts b/x-pack/plugins/security/server/plugin.test.ts index 73092f856ef6a..e838016e94524 100644 --- a/x-pack/plugins/security/server/plugin.test.ts +++ b/x-pack/plugins/security/server/plugin.test.ts @@ -138,6 +138,7 @@ describe('Security Plugin', () => { "authc": Object { "apiKeys": Object { "areAPIKeysEnabled": [Function], + "areCrossClusterAPIKeysEnabled": [Function], "create": [Function], "grantAsInternalUser": [Function], "invalidate": [Function], diff --git a/x-pack/plugins/security/server/routes/api_keys/create.test.ts b/x-pack/plugins/security/server/routes/api_keys/create.test.ts index 7236e46df7ed5..27167f945aaa1 100644 --- a/x-pack/plugins/security/server/routes/api_keys/create.test.ts +++ b/x-pack/plugins/security/server/routes/api_keys/create.test.ts @@ -77,6 +77,7 @@ describe('Create API Key route', () => { api_key: 'abc123', id: 'key_id', name: 'my api key', + encoded: 'encoded123', }); const payload = { @@ -104,6 +105,7 @@ describe('Create API Key route', () => { api_key: 'abc123', id: 'key_id', name: 'my api key', + encoded: 'encoded123', }); }); diff --git a/x-pack/plugins/security/server/routes/api_keys/create.ts b/x-pack/plugins/security/server/routes/api_keys/create.ts index 8bb097ee01259..ee69a80efa103 100644 --- a/x-pack/plugins/security/server/routes/api_keys/create.ts +++ b/x-pack/plugins/security/server/routes/api_keys/create.ts @@ -5,7 +5,10 @@ * 2.0. */ +import type { estypes } from '@elastic/elasticsearch'; + import { schema } from '@kbn/config-schema'; +import type { TypeOf } from '@kbn/config-schema'; import type { RouteDefinitionParams } from '..'; import { CreateApiKeyValidationError } from '../../authentication/api_keys'; @@ -13,7 +16,27 @@ import { wrapIntoCustomErrorResponse } from '../../errors'; import { elasticsearchRoleSchema, getKibanaRoleSchema } from '../../lib'; import { createLicensedRouteHandler } from '../licensed_route_handler'; -const bodySchema = schema.object({ +/** + * Response of Kibana Create API key endpoint. + */ +export type CreateAPIKeyResult = estypes.SecurityCreateApiKeyResponse; + +/** + * Request body of Kibana Create API key endpoint. + */ +export type CreateAPIKeyParams = + | CreateRestAPIKeyParams + | CreateRestAPIKeyWithKibanaPrivilegesParams + | CreateCrossClusterAPIKeyParams; + +export type CreateRestAPIKeyParams = TypeOf; +export type CreateRestAPIKeyWithKibanaPrivilegesParams = TypeOf< + ReturnType +>; +export type CreateCrossClusterAPIKeyParams = TypeOf; + +export const restApiKeySchema = schema.object({ + type: schema.maybe(schema.literal('rest')), name: schema.string(), expiration: schema.maybe(schema.string()), role_descriptors: schema.recordOf(schema.string(), schema.object({}, { unknowns: 'allow' }), { @@ -22,12 +45,11 @@ const bodySchema = schema.object({ metadata: schema.maybe(schema.object({}, { unknowns: 'allow' })), }); -const getBodySchemaWithKibanaPrivileges = ( - getBasePrivilegeNames: () => { global: string[]; space: string[] } +export const getRestApiKeyWithKibanaPrivilegesSchema = ( + getBasePrivilegeNames: Parameters[0] ) => - schema.object({ - name: schema.string(), - expiration: schema.maybe(schema.string()), + restApiKeySchema.extends({ + role_descriptors: null, kibana_role_descriptors: schema.recordOf( schema.string(), schema.object({ @@ -35,15 +57,38 @@ const getBodySchemaWithKibanaPrivileges = ( kibana: getKibanaRoleSchema(getBasePrivilegeNames), }) ), - metadata: schema.maybe(schema.object({}, { unknowns: 'allow' })), }); +export const crossClusterApiKeySchema = restApiKeySchema.extends({ + type: schema.literal('cross_cluster'), + role_descriptors: null, + access: schema.object( + { + search: schema.maybe( + schema.arrayOf( + schema.object({ + names: schema.arrayOf(schema.string()), + }) + ) + ), + replication: schema.maybe( + schema.arrayOf( + schema.object({ + names: schema.arrayOf(schema.string()), + }) + ) + ), + }, + { unknowns: 'allow' } + ), +}); + export function defineCreateApiKeyRoutes({ router, authz, getAuthenticationService, }: RouteDefinitionParams) { - const bodySchemaWithKibanaPrivileges = getBodySchemaWithKibanaPrivileges(() => { + const bodySchemaWithKibanaPrivileges = getRestApiKeyWithKibanaPrivilegesSchema(() => { const privileges = authz.privileges.get(); return { global: Object.keys(privileges.global), @@ -54,18 +99,28 @@ export function defineCreateApiKeyRoutes({ { path: '/internal/security/api_key', validate: { - body: schema.oneOf([bodySchema, bodySchemaWithKibanaPrivileges]), + body: schema.oneOf([ + restApiKeySchema, + bodySchemaWithKibanaPrivileges, + crossClusterApiKeySchema, + ]), + }, + options: { + access: 'internal', }, }, createLicensedRouteHandler(async (context, request, response) => { try { - const apiKey = await getAuthenticationService().apiKeys.create(request, request.body); + const createdApiKey = await getAuthenticationService().apiKeys.create( + request, + request.body + ); - if (!apiKey) { - return response.badRequest({ body: { message: `API Keys are not available` } }); + if (!createdApiKey) { + return response.badRequest({ body: { message: 'API Keys are not available' } }); } - return response.ok({ body: apiKey }); + return response.ok({ body: createdApiKey }); } catch (error) { if (error instanceof CreateApiKeyValidationError) { return response.badRequest({ body: { message: error.message } }); diff --git a/x-pack/plugins/security/server/routes/api_keys/get.test.ts b/x-pack/plugins/security/server/routes/api_keys/get.test.ts index c34a67ff1bfcb..40257dc2171c5 100644 --- a/x-pack/plugins/security/server/routes/api_keys/get.test.ts +++ b/x-pack/plugins/security/server/routes/api_keys/get.test.ts @@ -4,156 +4,258 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - import Boom from '@hapi/boom'; import { kibanaResponseFactory } from '@kbn/core/server'; +import type { RequestHandler } from '@kbn/core/server'; +import type { CustomRequestHandlerMock, ScopedClusterClientMock } from '@kbn/core/server/mocks'; import { coreMock, httpServerMock } from '@kbn/core/server/mocks'; -import type { LicenseCheck } from '@kbn/licensing-plugin/server'; +import { licensingMock } from '@kbn/licensing-plugin/server/mocks'; +import type { DeeplyMockedKeys } from '@kbn/utility-types-jest'; +import type { InternalAuthenticationServiceStart } from '../../authentication'; +import { authenticationServiceMock } from '../../authentication/authentication_service.mock'; import { routeDefinitionParamsMock } from '../index.mock'; import { defineGetApiKeysRoutes } from './get'; -interface TestOptions { - isAdmin?: boolean; - licenseCheckResult?: LicenseCheck; - apiResponse?: () => unknown; - asserts: { statusCode: number; result?: Record }; -} - -describe('Get API keys', () => { - const getApiKeysTest = ( - description: string, - { licenseCheckResult = { state: 'valid' }, apiResponse, asserts, isAdmin = true }: TestOptions - ) => { - test(description, async () => { - const mockRouteDefinitionParams = routeDefinitionParamsMock.create(); - const mockCoreContext = coreMock.createRequestHandlerContext(); - const mockLicensingContext = { - license: { check: jest.fn().mockReturnValue(licenseCheckResult) }, - }; - const mockContext = coreMock.createCustomRequestHandlerContext({ - core: mockCoreContext, - licensing: mockLicensingContext as any, - }); - - if (apiResponse) { - mockCoreContext.elasticsearch.client.asCurrentUser.security.getApiKey.mockResponse( - // @ts-expect-error unknown type - apiResponse() - ); - } - - defineGetApiKeysRoutes(mockRouteDefinitionParams); - const [[, handler]] = mockRouteDefinitionParams.router.get.mock.calls; - - const headers = { authorization: 'foo' }; - const mockRequest = httpServerMock.createKibanaRequest({ - method: 'get', - path: '/internal/security/api_key', - query: { isAdmin: isAdmin.toString() }, - headers, - }); - - const response = await handler(mockContext, mockRequest, kibanaResponseFactory); - expect(response.status).toBe(asserts.statusCode); - expect(response.payload).toEqual(asserts.result); - - if (apiResponse) { - expect( - mockCoreContext.elasticsearch.client.asCurrentUser.security.getApiKey - ).toHaveBeenCalledWith({ owner: !isAdmin }); - } - expect(mockLicensingContext.license.check).toHaveBeenCalledWith('security', 'basic'); - }); - }; - - describe('failure', () => { - getApiKeysTest('returns result of license checker', { - licenseCheckResult: { state: 'invalid', message: 'test forbidden message' }, - asserts: { statusCode: 403, result: { message: 'test forbidden message' } }, - }); - - const error = Boom.notAcceptable('test not acceptable message'); - getApiKeysTest('returns error from cluster client', { - apiResponse: async () => { - throw error; +describe('Get API Keys route', () => { + let routeHandler: RequestHandler; + let authc: DeeplyMockedKeys; + let esClientMock: ScopedClusterClientMock; + let mockContext: CustomRequestHandlerMock; + + beforeEach(async () => { + const mockRouteDefinitionParams = routeDefinitionParamsMock.create(); + authc = authenticationServiceMock.createStart(); + mockRouteDefinitionParams.getAuthenticationService.mockReturnValue(authc); + defineGetApiKeysRoutes(mockRouteDefinitionParams); + [[, routeHandler]] = mockRouteDefinitionParams.router.get.mock.calls; + mockContext = coreMock.createCustomRequestHandlerContext({ + core: coreMock.createRequestHandlerContext(), + licensing: licensingMock.createRequestHandlerContext(), + }); + + esClientMock = (await mockContext.core).elasticsearch.client; + + authc.apiKeys.areAPIKeysEnabled.mockResolvedValue(true); + authc.apiKeys.areCrossClusterAPIKeysEnabled.mockResolvedValue(true); + + esClientMock.asCurrentUser.security.hasPrivileges.mockResponse({ + cluster: { + manage_security: true, + read_security: true, + manage_api_key: true, + manage_own_api_key: true, }, - asserts: { statusCode: 406, result: error }, + } as any); + + esClientMock.asCurrentUser.security.getApiKey.mockResponse({ + api_keys: [ + { id: '123', invalidated: false }, + { id: '456', invalidated: true }, + ], + } as any); + }); + + it('should filter out invalidated API keys', async () => { + const response = await routeHandler( + mockContext, + httpServerMock.createKibanaRequest(), + kibanaResponseFactory + ); + + expect(response.status).toBe(200); + expect(response.payload.apiKeys).toContainEqual({ id: '123', invalidated: false }); + expect(response.payload.apiKeys).not.toContainEqual({ id: '456', invalidated: true }); + }); + + it('should return `404` if API keys are disabled', async () => { + authc.apiKeys.areAPIKeysEnabled.mockResolvedValue(false); + + const response = await routeHandler( + mockContext, + httpServerMock.createKibanaRequest(), + kibanaResponseFactory + ); + + expect(response.status).toBe(404); + expect(response.payload).toEqual({ + message: + "API keys are disabled in Elasticsearch. To use API keys enable 'xpack.security.authc.api_key.enabled' setting.", + }); + }); + + it('should forward error from Elasticsearch GET API keys endpoint', async () => { + const error = Boom.forbidden('test not acceptable message'); + esClientMock.asCurrentUser.security.getApiKey.mockResponseImplementation(() => { + throw error; }); + + const response = await routeHandler( + mockContext, + httpServerMock.createKibanaRequest(), + kibanaResponseFactory + ); + + expect(response.status).toBe(403); + expect(response.payload).toEqual(error); }); - describe('success', () => { - getApiKeysTest('returns API keys', { - apiResponse: () => ({ - api_keys: [ - { - id: 'YCLV7m0BJ3xI4hhWB648', - name: 'test-api-key', - creation: 1571670001452, - expiration: 1571756401452, - invalidated: false, - username: 'elastic', - realm: 'reserved', - }, - ], - }), - asserts: { - statusCode: 200, - result: { - apiKeys: [ - { - id: 'YCLV7m0BJ3xI4hhWB648', - name: 'test-api-key', - creation: 1571670001452, - expiration: 1571756401452, - invalidated: false, - username: 'elastic', - realm: 'reserved', - }, - ], + describe('when user has `manage_security` permission', () => { + beforeEach(() => { + esClientMock.asCurrentUser.security.hasPrivileges.mockResponse({ + cluster: { + manage_security: true, + read_security: true, + manage_api_key: true, + manage_own_api_key: true, }, - }, + } as any); + }); + + it('should calculate user privileges correctly', async () => { + const response = await routeHandler( + mockContext, + httpServerMock.createKibanaRequest(), + kibanaResponseFactory + ); + + expect(response.payload).toEqual( + expect.objectContaining({ + canManageCrossClusterApiKeys: true, + canManageApiKeys: true, + canManageOwnApiKeys: true, + }) + ); + }); + + it('should disable `canManageCrossClusterApiKeys` when not supported by Elasticsearch', async () => { + authc.apiKeys.areCrossClusterAPIKeysEnabled.mockResolvedValue(false); + + const response = await routeHandler( + mockContext, + httpServerMock.createKibanaRequest(), + kibanaResponseFactory + ); + + expect(response.payload).toEqual( + expect.objectContaining({ + canManageCrossClusterApiKeys: false, + canManageApiKeys: true, + canManageOwnApiKeys: true, + }) + ); + }); + + it('should request list of all Elasticsearch API keys', async () => { + await routeHandler(mockContext, httpServerMock.createKibanaRequest(), kibanaResponseFactory); + + expect(esClientMock.asCurrentUser.security.getApiKey).toHaveBeenCalledWith({ owner: false }); }); - getApiKeysTest('returns only valid API keys', { - apiResponse: () => ({ - api_keys: [ - { - id: 'YCLV7m0BJ3xI4hhWB648', - name: 'test-api-key1', - creation: 1571670001452, - expiration: 1571756401452, - invalidated: true, - username: 'elastic', - realm: 'reserved', - }, - { - id: 'YCLV7m0BJ3xI4hhWB648', - name: 'test-api-key2', - creation: 1571670001452, - expiration: 1571756401452, - invalidated: false, - username: 'elastic', - realm: 'reserved', - }, - ], - }), - asserts: { - statusCode: 200, - result: { - apiKeys: [ - { - id: 'YCLV7m0BJ3xI4hhWB648', - name: 'test-api-key2', - creation: 1571670001452, - expiration: 1571756401452, - invalidated: false, - username: 'elastic', - realm: 'reserved', - }, - ], + }); + + describe('when user has `manage_api_key` permission', () => { + beforeEach(() => { + esClientMock.asCurrentUser.security.hasPrivileges.mockResponse({ + cluster: { + manage_security: false, + read_security: false, + manage_api_key: true, + manage_own_api_key: true, }, - }, + } as any); + }); + + it('should calculate user privileges correctly ', async () => { + const response = await routeHandler( + mockContext, + httpServerMock.createKibanaRequest(), + kibanaResponseFactory + ); + + expect(response.payload).toEqual( + expect.objectContaining({ + canManageCrossClusterApiKeys: false, + canManageApiKeys: true, + canManageOwnApiKeys: true, + }) + ); + }); + + it('should request list of all Elasticsearch API keys', async () => { + await routeHandler(mockContext, httpServerMock.createKibanaRequest(), kibanaResponseFactory); + + expect(esClientMock.asCurrentUser.security.getApiKey).toHaveBeenCalledWith({ owner: false }); + }); + }); + + describe('when user has `read_security` permission', () => { + beforeEach(() => { + esClientMock.asCurrentUser.security.hasPrivileges.mockResponse({ + cluster: { + manage_security: false, + read_security: true, + manage_api_key: false, + manage_own_api_key: false, + }, + } as any); + }); + + it('should calculate user privileges correctly ', async () => { + const response = await routeHandler( + mockContext, + httpServerMock.createKibanaRequest(), + kibanaResponseFactory + ); + + expect(response.payload).toEqual( + expect.objectContaining({ + canManageCrossClusterApiKeys: false, + canManageApiKeys: false, + canManageOwnApiKeys: false, + }) + ); + }); + + it('should request list of all Elasticsearch API keys', async () => { + await routeHandler(mockContext, httpServerMock.createKibanaRequest(), kibanaResponseFactory); + + expect(esClientMock.asCurrentUser.security.getApiKey).toHaveBeenCalledWith({ owner: false }); + }); + }); + + describe('when user has `manage_own_api_key` permission', () => { + beforeEach(() => { + esClientMock.asCurrentUser.security.hasPrivileges.mockResponse({ + cluster: { + manage_security: false, + read_security: false, + manage_api_key: false, + manage_own_api_key: true, + }, + } as any); + }); + + it('should calculate user privileges correctly ', async () => { + const response = await routeHandler( + mockContext, + httpServerMock.createKibanaRequest(), + kibanaResponseFactory + ); + + expect(response.payload).toEqual( + expect.objectContaining({ + canManageCrossClusterApiKeys: false, + canManageApiKeys: false, + canManageOwnApiKeys: true, + }) + ); + }); + + it('should only request list of API keys owned by the user', async () => { + await routeHandler(mockContext, httpServerMock.createKibanaRequest(), kibanaResponseFactory); + + expect(esClientMock.asCurrentUser.security.getApiKey).toHaveBeenCalledWith({ owner: true }); }); }); }); diff --git a/x-pack/plugins/security/server/routes/api_keys/get.ts b/x-pack/plugins/security/server/routes/api_keys/get.ts index db0d02a20c687..47eb9274ab6b7 100644 --- a/x-pack/plugins/security/server/routes/api_keys/get.ts +++ b/x-pack/plugins/security/server/routes/api_keys/get.ts @@ -5,38 +5,79 @@ * 2.0. */ -import { schema } from '@kbn/config-schema'; - import type { RouteDefinitionParams } from '..'; +import type { ApiKey } from '../../../common/model'; import { wrapIntoCustomErrorResponse } from '../../errors'; import { createLicensedRouteHandler } from '../licensed_route_handler'; -export function defineGetApiKeysRoutes({ router }: RouteDefinitionParams) { +/** + * Response of Kibana Get API keys endpoint. + */ +export interface GetAPIKeysResult { + apiKeys: ApiKey[]; + canManageCrossClusterApiKeys: boolean; + canManageApiKeys: boolean; + canManageOwnApiKeys: boolean; +} + +export function defineGetApiKeysRoutes({ + router, + getAuthenticationService, +}: RouteDefinitionParams) { router.get( { path: '/internal/security/api_key', - validate: { - query: schema.object({ - // We don't use `schema.boolean` here, because all query string parameters are treated as - // strings and @kbn/config-schema doesn't coerce strings to booleans. - // - // A boolean flag that can be used to query API keys owned by the currently authenticated - // user. `false` means that only API keys of currently authenticated user will be returned. - isAdmin: schema.oneOf([schema.literal('true'), schema.literal('false')]), - }), + validate: false, + options: { + access: 'internal', }, }, createLicensedRouteHandler(async (context, request, response) => { try { - const isAdmin = request.query.isAdmin === 'true'; const esClient = (await context.core).elasticsearch.client; + const authenticationService = getAuthenticationService(); + + const [{ cluster: clusterPrivileges }, areApiKeysEnabled, areCrossClusterApiKeysEnabled] = + await Promise.all([ + esClient.asCurrentUser.security.hasPrivileges({ + body: { + cluster: [ + 'manage_security', + 'read_security', + 'manage_api_key', + 'manage_own_api_key', + ], + }, + }), + authenticationService.apiKeys.areAPIKeysEnabled(), + authenticationService.apiKeys.areCrossClusterAPIKeysEnabled(), + ]); + + if (!areApiKeysEnabled) { + return response.notFound({ + body: { + message: + "API keys are disabled in Elasticsearch. To use API keys enable 'xpack.security.authc.api_key.enabled' setting.", + }, + }); + } + const apiResponse = await esClient.asCurrentUser.security.getApiKey({ - owner: !isAdmin, + owner: !clusterPrivileges.manage_api_key && !clusterPrivileges.read_security, }); const validKeys = apiResponse.api_keys.filter(({ invalidated }) => !invalidated); - return response.ok({ body: { apiKeys: validKeys } }); + return response.ok({ + body: { + // @ts-expect-error Elasticsearch client types do not know about Cross-Cluster API keys yet. + apiKeys: validKeys, + canManageCrossClusterApiKeys: + clusterPrivileges.manage_security && areCrossClusterApiKeysEnabled, + canManageApiKeys: clusterPrivileges.manage_api_key, + canManageOwnApiKeys: clusterPrivileges.manage_own_api_key, + }, + }); } catch (error) { return response.customError(wrapIntoCustomErrorResponse(error)); } diff --git a/x-pack/plugins/security/server/routes/api_keys/index.ts b/x-pack/plugins/security/server/routes/api_keys/index.ts index d815fb749fb4d..15c8e149470d0 100644 --- a/x-pack/plugins/security/server/routes/api_keys/index.ts +++ b/x-pack/plugins/security/server/routes/api_keys/index.ts @@ -10,14 +10,28 @@ import { defineCreateApiKeyRoutes } from './create'; import { defineEnabledApiKeysRoutes } from './enabled'; import { defineGetApiKeysRoutes } from './get'; import { defineInvalidateApiKeysRoutes } from './invalidate'; -import { defineCheckPrivilegesRoutes } from './privileges'; import { defineUpdateApiKeyRoutes } from './update'; +export type { + CreateAPIKeyParams, + CreateAPIKeyResult, + CreateRestAPIKeyParams, + CreateCrossClusterAPIKeyParams, + CreateRestAPIKeyWithKibanaPrivilegesParams, +} from './create'; +export type { + UpdateAPIKeyParams, + UpdateAPIKeyResult, + UpdateRestAPIKeyParams, + UpdateCrossClusterAPIKeyParams, + UpdateRestAPIKeyWithKibanaPrivilegesParams, +} from './update'; +export type { GetAPIKeysResult } from './get'; + export function defineApiKeysRoutes(params: RouteDefinitionParams) { defineEnabledApiKeysRoutes(params); defineGetApiKeysRoutes(params); defineCreateApiKeyRoutes(params); defineUpdateApiKeyRoutes(params); - defineCheckPrivilegesRoutes(params); defineInvalidateApiKeysRoutes(params); } diff --git a/x-pack/plugins/security/server/routes/api_keys/privileges.test.ts b/x-pack/plugins/security/server/routes/api_keys/privileges.test.ts deleted file mode 100644 index 52d1d59a486da..0000000000000 --- a/x-pack/plugins/security/server/routes/api_keys/privileges.test.ts +++ /dev/null @@ -1,179 +0,0 @@ -/* - * 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 Boom from '@hapi/boom'; - -import { kibanaResponseFactory } from '@kbn/core/server'; -import { coreMock, httpServerMock } from '@kbn/core/server/mocks'; -import type { LicenseCheck } from '@kbn/licensing-plugin/server'; - -import { authenticationServiceMock } from '../../authentication/authentication_service.mock'; -import { routeDefinitionParamsMock } from '../index.mock'; -import { defineCheckPrivilegesRoutes } from './privileges'; - -interface TestOptions { - licenseCheckResult?: LicenseCheck; - areAPIKeysEnabled?: boolean; - apiResponse?: () => unknown; - asserts: { statusCode: number; result?: Record; apiArguments?: unknown }; -} - -describe('Check API keys privileges', () => { - const getPrivilegesTest = ( - description: string, - { - licenseCheckResult = { state: 'valid' }, - areAPIKeysEnabled = true, - apiResponse, - asserts, - }: TestOptions - ) => { - test(description, async () => { - const mockRouteDefinitionParams = routeDefinitionParamsMock.create(); - const mockCoreContext = coreMock.createRequestHandlerContext(); - const mockLicensingContext = { - license: { check: jest.fn().mockReturnValue(licenseCheckResult) }, - } as any; - const mockContext = coreMock.createCustomRequestHandlerContext({ - core: mockCoreContext, - licensing: mockLicensingContext, - }); - - const authc = authenticationServiceMock.createStart(); - authc.apiKeys.areAPIKeysEnabled.mockResolvedValue(areAPIKeysEnabled); - mockRouteDefinitionParams.getAuthenticationService.mockReturnValue(authc); - - if (apiResponse) { - mockCoreContext.elasticsearch.client.asCurrentUser.security.hasPrivileges.mockResponseImplementation( - // @ts-expect-error unknown return - () => { - return { - body: apiResponse(), - }; - } - ); - } - - defineCheckPrivilegesRoutes(mockRouteDefinitionParams); - const [[, handler]] = mockRouteDefinitionParams.router.get.mock.calls; - - const headers = { authorization: 'foo' }; - const mockRequest = httpServerMock.createKibanaRequest({ - method: 'get', - path: '/internal/security/api_key/privileges', - headers, - }); - - const response = await handler(mockContext, mockRequest, kibanaResponseFactory); - expect(response.status).toBe(asserts.statusCode); - expect(response.payload).toEqual(asserts.result); - - if (asserts.apiArguments) { - expect( - mockCoreContext.elasticsearch.client.asCurrentUser.security.hasPrivileges - ).toHaveBeenCalledWith(asserts.apiArguments); - } - - expect(mockLicensingContext.license.check).toHaveBeenCalledWith('security', 'basic'); - }); - }; - - describe('failure', () => { - getPrivilegesTest('returns result of license checker', { - licenseCheckResult: { state: 'invalid', message: 'test forbidden message' }, - asserts: { statusCode: 403, result: { message: 'test forbidden message' } }, - }); - - const error = Boom.notAcceptable('test not acceptable message'); - getPrivilegesTest('returns error from cluster client', { - apiResponse: () => { - throw error; - }, - asserts: { - apiArguments: { - body: { cluster: ['manage_security', 'manage_api_key', 'manage_own_api_key'] }, - }, - statusCode: 406, - result: error, - }, - }); - }); - - describe('success', () => { - getPrivilegesTest('returns areApiKeysEnabled and isAdmin', { - apiResponse: () => ({ - username: 'elastic', - has_all_requested: true, - cluster: { manage_api_key: true, manage_security: true, manage_own_api_key: false }, - index: {}, - application: {}, - }), - asserts: { - apiArguments: { - body: { cluster: ['manage_security', 'manage_api_key', 'manage_own_api_key'] }, - }, - statusCode: 200, - result: { areApiKeysEnabled: true, isAdmin: true, canManage: true }, - }, - }); - - getPrivilegesTest( - 'returns areApiKeysEnabled=false when API Keys are disabled in Elasticsearch', - { - apiResponse: () => ({ - username: 'elastic', - has_all_requested: true, - cluster: { manage_api_key: true, manage_security: true, manage_own_api_key: true }, - index: {}, - application: {}, - }), - areAPIKeysEnabled: false, - asserts: { - apiArguments: { - body: { cluster: ['manage_security', 'manage_api_key', 'manage_own_api_key'] }, - }, - statusCode: 200, - result: { areApiKeysEnabled: false, isAdmin: true, canManage: true }, - }, - } - ); - - getPrivilegesTest('returns isAdmin=false when user has insufficient privileges', { - apiResponse: () => ({ - username: 'elastic', - has_all_requested: true, - cluster: { manage_api_key: false, manage_security: false, manage_own_api_key: false }, - index: {}, - application: {}, - }), - asserts: { - apiArguments: { - body: { cluster: ['manage_security', 'manage_api_key', 'manage_own_api_key'] }, - }, - statusCode: 200, - result: { areApiKeysEnabled: true, isAdmin: false, canManage: false }, - }, - }); - - getPrivilegesTest('returns canManage=true when user can manage their own API Keys', { - apiResponse: () => ({ - username: 'elastic', - has_all_requested: true, - cluster: { manage_api_key: false, manage_security: false, manage_own_api_key: true }, - index: {}, - application: {}, - }), - asserts: { - apiArguments: { - body: { cluster: ['manage_security', 'manage_api_key', 'manage_own_api_key'] }, - }, - statusCode: 200, - result: { areApiKeysEnabled: true, isAdmin: false, canManage: true }, - }, - }); - }); -}); diff --git a/x-pack/plugins/security/server/routes/api_keys/privileges.ts b/x-pack/plugins/security/server/routes/api_keys/privileges.ts deleted file mode 100644 index 09210e1d00d37..0000000000000 --- a/x-pack/plugins/security/server/routes/api_keys/privileges.ts +++ /dev/null @@ -1,51 +0,0 @@ -/* - * 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 { RouteDefinitionParams } from '..'; -import { wrapIntoCustomErrorResponse } from '../../errors'; -import { createLicensedRouteHandler } from '../licensed_route_handler'; - -export function defineCheckPrivilegesRoutes({ - router, - getAuthenticationService, -}: RouteDefinitionParams) { - router.get( - { - path: '/internal/security/api_key/privileges', - validate: false, - }, - createLicensedRouteHandler(async (context, request, response) => { - try { - const esClient = (await context.core).elasticsearch.client; - const [ - { - cluster: { - manage_security: manageSecurity, - manage_api_key: manageApiKey, - manage_own_api_key: manageOwnApiKey, - }, - }, - areApiKeysEnabled, - ] = await Promise.all([ - esClient.asCurrentUser.security.hasPrivileges({ - body: { cluster: ['manage_security', 'manage_api_key', 'manage_own_api_key'] }, - }), - getAuthenticationService().apiKeys.areAPIKeysEnabled(), - ]); - - const isAdmin = manageSecurity || manageApiKey; - const canManage = manageSecurity || manageApiKey || manageOwnApiKey; - - return response.ok({ - body: { areApiKeysEnabled, isAdmin, canManage }, - }); - } catch (error) { - return response.customError(wrapIntoCustomErrorResponse(error)); - } - }) - ); -} diff --git a/x-pack/plugins/security/server/routes/api_keys/update.ts b/x-pack/plugins/security/server/routes/api_keys/update.ts index a7ff22fe58cc2..0a05ffe048205 100644 --- a/x-pack/plugins/security/server/routes/api_keys/update.ts +++ b/x-pack/plugins/security/server/routes/api_keys/update.ts @@ -5,16 +5,38 @@ * 2.0. */ +import type { estypes } from '@elastic/elasticsearch'; + import { schema } from '@kbn/config-schema'; +import type { TypeOf } from '@kbn/config-schema'; import type { RouteDefinitionParams } from '..'; -import type { UpdateAPIKeyResult } from '../../authentication/api_keys/api_keys'; import { UpdateApiKeyValidationError } from '../../authentication/api_keys/api_keys'; import { wrapIntoCustomErrorResponse } from '../../errors'; import { elasticsearchRoleSchema, getKibanaRoleSchema } from '../../lib'; import { createLicensedRouteHandler } from '../licensed_route_handler'; -const bodySchema = schema.object({ +/** + * Response of Kibana Update API key endpoint. + */ +export type UpdateAPIKeyResult = estypes.SecurityUpdateApiKeyResponse; + +/** + * Request body of Kibana Update API key endpoint. + */ +export type UpdateAPIKeyParams = + | UpdateRestAPIKeyParams + | UpdateCrossClusterAPIKeyParams + | UpdateRestAPIKeyWithKibanaPrivilegesParams; + +export type UpdateRestAPIKeyParams = TypeOf; +export type UpdateCrossClusterAPIKeyParams = TypeOf; +export type UpdateRestAPIKeyWithKibanaPrivilegesParams = TypeOf< + ReturnType +>; + +const restApiKeySchema = schema.object({ + type: schema.maybe(schema.literal('rest')), id: schema.string(), role_descriptors: schema.recordOf(schema.string(), schema.object({}, { unknowns: 'allow' }), { defaultValue: {}, @@ -22,11 +44,41 @@ const bodySchema = schema.object({ metadata: schema.maybe(schema.object({}, { unknowns: 'allow' })), }); -const getBodySchemaWithKibanaPrivileges = ( - getBasePrivilegeNames: () => { global: string[]; space: string[] } +const crossClusterApiKeySchema = restApiKeySchema.extends({ + type: schema.literal('cross_cluster'), + role_descriptors: null, + access: schema.object( + { + search: schema.maybe( + schema.arrayOf( + schema.object( + { + names: schema.arrayOf(schema.string()), + }, + { unknowns: 'allow' } + ) + ) + ), + replication: schema.maybe( + schema.arrayOf( + schema.object( + { + names: schema.arrayOf(schema.string()), + }, + { unknowns: 'allow' } + ) + ) + ), + }, + { unknowns: 'allow' } + ), +}); + +const getRestApiKeyWithKibanaPrivilegesSchema = ( + getBasePrivilegeNames: Parameters[0] ) => - schema.object({ - id: schema.string(), + restApiKeySchema.extends({ + role_descriptors: null, kibana_role_descriptors: schema.recordOf( schema.string(), schema.object({ @@ -34,7 +86,6 @@ const getBodySchemaWithKibanaPrivileges = ( kibana: getKibanaRoleSchema(getBasePrivilegeNames), }) ), - metadata: schema.maybe(schema.object({}, { unknowns: 'allow' })), }); export function defineUpdateApiKeyRoutes({ @@ -42,7 +93,7 @@ export function defineUpdateApiKeyRoutes({ authz, getAuthenticationService, }: RouteDefinitionParams) { - const bodySchemaWithKibanaPrivileges = getBodySchemaWithKibanaPrivileges(() => { + const bodySchemaWithKibanaPrivileges = getRestApiKeyWithKibanaPrivilegesSchema(() => { const privileges = authz.privileges.get(); return { global: Object.keys(privileges.global), @@ -54,7 +105,14 @@ export function defineUpdateApiKeyRoutes({ { path: '/internal/security/api_key', validate: { - body: schema.oneOf([bodySchema, bodySchemaWithKibanaPrivileges]), + body: schema.oneOf([ + restApiKeySchema, + crossClusterApiKeySchema, + bodySchemaWithKibanaPrivileges, + ]), + }, + options: { + access: 'internal', }, }, createLicensedRouteHandler(async (context, request, response) => { diff --git a/x-pack/plugins/security_solution/cypress/e2e/investigations/timelines/creation.cy.ts b/x-pack/plugins/security_solution/cypress/e2e/investigations/timelines/creation.cy.ts index 6064b602ee0e5..fb0dff794d117 100644 --- a/x-pack/plugins/security_solution/cypress/e2e/investigations/timelines/creation.cy.ts +++ b/x-pack/plugins/security_solution/cypress/e2e/investigations/timelines/creation.cy.ts @@ -42,7 +42,7 @@ import { import { OVERVIEW_URL, TIMELINE_TEMPLATES_URL } from '../../../urls/navigation'; -describe('Create a timeline from a template', () => { +describe.skip('Create a timeline from a template', () => { before(() => { deleteTimelines(); login(); diff --git a/x-pack/plugins/security_solution/cypress/e2e/investigations/timelines/notes_tab.cy.ts b/x-pack/plugins/security_solution/cypress/e2e/investigations/timelines/notes_tab.cy.ts index 1196555a16e7d..cbde4900d2e79 100644 --- a/x-pack/plugins/security_solution/cypress/e2e/investigations/timelines/notes_tab.cy.ts +++ b/x-pack/plugins/security_solution/cypress/e2e/investigations/timelines/notes_tab.cy.ts @@ -35,7 +35,7 @@ import { TIMELINES_URL } from '../../../urls/navigation'; const text = 'system_indices_superuser'; const link = 'https://www.elastic.co/'; -describe('Timeline notes tab', () => { +describe.skip('Timeline notes tab', () => { before(() => { cleanKibana(); login(); diff --git a/x-pack/plugins/security_solution/cypress/e2e/investigations/timelines/query_tab.cy.ts b/x-pack/plugins/security_solution/cypress/e2e/investigations/timelines/query_tab.cy.ts index 0d509063c6ff0..bd8d80ceed851 100644 --- a/x-pack/plugins/security_solution/cypress/e2e/investigations/timelines/query_tab.cy.ts +++ b/x-pack/plugins/security_solution/cypress/e2e/investigations/timelines/query_tab.cy.ts @@ -30,7 +30,7 @@ import { import { TIMELINES_URL } from '../../../urls/navigation'; -describe('Timeline query tab', () => { +describe.skip('Timeline query tab', () => { before(() => { cleanKibana(); login(); diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index c5c3d996bfa49..034ddf93a27ad 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -29087,9 +29087,6 @@ "xpack.searchProfiler.registryProviderTitle": "Search Profiler", "xpack.searchProfiler.scoreTimeDescription": "Temps passé sur l'attribution de score au document par rapport à la recherche.", "xpack.searchProfiler.trialLicenseTitle": "Trial", - "xpack.security.accountManagement.apiKeyFlyout.errorMessage": "Impossible de {errorTitle}", - "xpack.security.accountManagement.apiKeyFlyout.submitButton": "{isSubmitting, select, true {{inProgressButtonText}} other {{formTitle}}}", - "xpack.security.accountManagement.apiKeyFlyout.title": "{formTitle}", "xpack.security.accountManagement.userProfile.saveChangesButton": "{isSubmitting, select, true {Enregistrement des modifications…} other {Enregistrer les modifications}}", "xpack.security.accountManagement.userProfile.unsavedChangesMessage": "{count, plural, one {# modification non enregistrée} many {# modifications non enregistrées} other {# modifications non enregistrées}}", "xpack.security.changePasswordForm.confirmButton": "{isSubmitting, select, true {Modification du mot de passe…} other {Modifier le mot de passe}}", @@ -29109,7 +29106,6 @@ "xpack.security.management.apiKeys.deleteApiKey.errorMultipleNotificationTitle": "Erreur lors de la suppression de {count} clés d'API", "xpack.security.management.apiKeys.deleteApiKey.successMultipleNotificationTitle": "{count} clés d'API supprimées", "xpack.security.management.apiKeys.table.apiKeysDisabledErrorDescription": "Contactez votre administrateur système et reportez-vous à {link} pour activer les clés d'API.", - "xpack.security.management.apiKeys.table.fetchingApiKeysErrorMessage": "Erreur de vérification des privilèges : {message}", "xpack.security.management.apiKeys.table.invalidateApiKeyButton": "Supprimer {count, plural, one {Clé d'API} many {Clés d'API} other {Clés d'API}}", "xpack.security.management.apiKeys.table.statusExpires": "Expiration : {timeFromNow}", "xpack.security.management.editRole.featureTable.actionLegendText": "Privilège de fonctionnalité {featureName}", @@ -29318,8 +29314,6 @@ "xpack.security.management.apiKeys.apiKeyFlyout.metadataRequired": "Entrez des métadonnées ou désactivez cette option.", "xpack.security.management.apiKeys.apiKeyFlyout.nameRequired": "Entrez un nom.", "xpack.security.management.apiKeys.apiKeyFlyout.roleDescriptorsRequired": "Entrez des descripteurs de rôles ou désactivez cette option.", - "xpack.security.management.apiKeys.base64Description": "Format utilisé pour l'authentification avec Elasticsearch.", - "xpack.security.management.apiKeys.base64Label": "Base64", "xpack.security.management.apiKeys.beatsDescription": "Format utilisé pour la configuration de Beats.", "xpack.security.management.apiKeys.beatsLabel": "Beats", "xpack.security.management.apiKeys.createBreadcrumb": "Créer", @@ -29330,8 +29324,6 @@ "xpack.security.management.apiKeys.deleteApiKey.errorSingleNotificationTitle": "Erreur lors de la suppression de la clé d'API \"{name}\"", "xpack.security.management.apiKeys.deleteApiKey.successSingleNotificationTitle": "Suppression de la clé d'API \"{name}\" effectuée", "xpack.security.management.apiKeys.deniedPermissionTitle": "Vous devez disposer d'une autorisation pour gérer les clés d'API", - "xpack.security.management.apiKeys.jsonDescription": "Réponse API complète.", - "xpack.security.management.apiKeys.jsonLabel": "JSON", "xpack.security.management.apiKeys.logstashDescription": "Format utilisé pour la configuration de Logstash.", "xpack.security.management.apiKeys.logstashLabel": "Logstash", "xpack.security.management.apiKeys.noPermissionToManageRolesDescription": "Contactez votre administrateur système.", @@ -29339,26 +29331,17 @@ "xpack.security.management.apiKeys.table.apiKeysAllDescription": "Affichez et supprimez les clés d'API, qui envoient des requêtes au nom d'un utilisateur.", "xpack.security.management.apiKeys.table.apiKeysDisabledErrorLinkText": "documents", "xpack.security.management.apiKeys.table.apiKeysDisabledErrorTitle": "Clés d'API non activées dans Elasticsearch", - "xpack.security.management.apiKeys.table.apiKeysOwnDescription": "Affichez et supprimez vos clés d'API, qui envoient des requêtes en votre nom.", - "xpack.security.management.apiKeys.table.apiKeysReadOnlyDescription": "Affichez vos clés d'API, qui envoient des requêtes en votre nom.", - "xpack.security.management.apiKeys.table.apiKeysTableLoadingMessage": "Chargement des clés d'API…", "xpack.security.management.apiKeys.table.apiKeysTitle": "Clés d'API", "xpack.security.management.apiKeys.table.createButton": "Créer une clé d'API", "xpack.security.management.apiKeys.table.createdBadge": "Juste maintenant", - "xpack.security.management.apiKeys.table.creationDateColumnName": "Créé", "xpack.security.management.apiKeys.table.deleteAction": "Supprimer", "xpack.security.management.apiKeys.table.deleteDescription": "Supprimer cette clé d'API", "xpack.security.management.apiKeys.table.loadingApiKeysDescription": "Chargement des clés d'API…", "xpack.security.management.apiKeys.table.manageOwnKeysWarning": "Vous avez uniquement l'autorisation de gérer vos propres clés d'API.", "xpack.security.management.apiKeys.table.nameColumnName": "Nom", - "xpack.security.management.apiKeys.table.readOnlyOwnKeysWarning": "Vous avez uniquement l'autorisation d'afficher vos propres clés d'API.", - "xpack.security.management.apiKeys.table.realmColumnName": "Domaine", - "xpack.security.management.apiKeys.table.realmFilterLabel": "Domaine", "xpack.security.management.apiKeys.table.statusActive": "Actif", "xpack.security.management.apiKeys.table.statusColumnName": "Statut", "xpack.security.management.apiKeys.table.statusExpired": "Expiré", - "xpack.security.management.apiKeys.table.userFilterLabel": "Utilisateur", - "xpack.security.management.apiKeys.table.userNameColumnName": "Utilisateur", "xpack.security.management.apiKeys.updateSuccessMessage": "Mise à jour de la clé d'API \"{name}\" effectuée", "xpack.security.management.apiKeysEmptyPrompt.disabledErrorMessage": "Les clés d'API sont désactivées.", "xpack.security.management.apiKeysEmptyPrompt.docsLinkText": "Découvrez comment activer les clés d'API.", @@ -29637,7 +29620,6 @@ "xpack.security.management.roles.statusColumnName": "Statut", "xpack.security.management.roles.subtitle": "Appliquez les rôles aux groupes d'utilisateurs et gérez les autorisations dans toute la Suite.", "xpack.security.management.rolesTitle": "Rôles", - "xpack.security.management.users.changePasswordFlyout.userLabel": "Utilisateur", "xpack.security.management.users.changePasswordForm.confirmPasswordInvalidError": "Les mots de passe ne correspondent pas.", "xpack.security.management.users.changePasswordForm.confirmPasswordLabel": "Confirmer le mot de passe", "xpack.security.management.users.changePasswordForm.currentPasswordInvalidError": "Mot de passe non valide.", @@ -40487,4 +40469,4 @@ "xpack.painlessLab.walkthroughButtonLabel": "Présentation", "xpack.serverlessObservability.nav.getStarted": "Démarrer" } -} +} \ No newline at end of file diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index bd977ffdfb135..7b24e76e72564 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -29087,9 +29087,6 @@ "xpack.searchProfiler.registryProviderTitle": "検索プロファイラー", "xpack.searchProfiler.scoreTimeDescription": "クエリに対してドキュメントを実際にスコアリングするためにかかった時間。", "xpack.searchProfiler.trialLicenseTitle": "トライアル", - "xpack.security.accountManagement.apiKeyFlyout.errorMessage": "{errorTitle}できませんでした", - "xpack.security.accountManagement.apiKeyFlyout.submitButton": "{isSubmitting, select, true {{inProgressButtonText}} other {{formTitle}}}", - "xpack.security.accountManagement.apiKeyFlyout.title": "{formTitle}", "xpack.security.accountManagement.userProfile.saveChangesButton": "{isSubmitting, select, true {変更を保存中...} other {変更を保存}}", "xpack.security.accountManagement.userProfile.unsavedChangesMessage": "{count, plural, other {#個の保存されていない変更}}", "xpack.security.changePasswordForm.confirmButton": "{isSubmitting, select, true {パスワードを変更中...} other {パスワードを変更}}", @@ -29109,7 +29106,6 @@ "xpack.security.management.apiKeys.deleteApiKey.errorMultipleNotificationTitle": "{count}個のAPIキーの削除中にエラーが発生", "xpack.security.management.apiKeys.deleteApiKey.successMultipleNotificationTitle": "APIキー{count}を削除しました", "xpack.security.management.apiKeys.table.apiKeysDisabledErrorDescription": "システム管理者に連絡し、{link}を伝えてAPIキーを有効にしてください。", - "xpack.security.management.apiKeys.table.fetchingApiKeysErrorMessage": "権限の確認エラー:{message}", "xpack.security.management.apiKeys.table.invalidateApiKeyButton": "{count, plural, other {APIキー}}削除", "xpack.security.management.apiKeys.table.statusExpires": "有効期限:{timeFromNow}", "xpack.security.management.editRole.featureTable.actionLegendText": "{featureName}機能権限", @@ -29317,8 +29313,6 @@ "xpack.security.management.apiKeys.apiKeyFlyout.metadataRequired": "メタデータを入力するか、このオプションを無効にします。", "xpack.security.management.apiKeys.apiKeyFlyout.nameRequired": "名前を入力します。", "xpack.security.management.apiKeys.apiKeyFlyout.roleDescriptorsRequired": "ロール記述子を入力するか、このオプションを無効にします。", - "xpack.security.management.apiKeys.base64Description": "Elasticsearchで認証するために使用される形式。", - "xpack.security.management.apiKeys.base64Label": "Base64", "xpack.security.management.apiKeys.beatsDescription": "Beatsを構成するために使用される形式。", "xpack.security.management.apiKeys.beatsLabel": "ビート", "xpack.security.management.apiKeys.createBreadcrumb": "作成", @@ -29329,8 +29323,6 @@ "xpack.security.management.apiKeys.deleteApiKey.errorSingleNotificationTitle": "API キー「{name}」の削除中にエラーが発生", "xpack.security.management.apiKeys.deleteApiKey.successSingleNotificationTitle": "APIキー'{name}'を削除しました", "xpack.security.management.apiKeys.deniedPermissionTitle": "API キーを管理するにはパーミッションが必要です", - "xpack.security.management.apiKeys.jsonDescription": "完全なAPI応答。", - "xpack.security.management.apiKeys.jsonLabel": "JSON", "xpack.security.management.apiKeys.logstashDescription": "Logstashを構成するために使用される形式。", "xpack.security.management.apiKeys.logstashLabel": "Logstash", "xpack.security.management.apiKeys.noPermissionToManageRolesDescription": "システム管理者にお問い合わせください。", @@ -29338,26 +29330,17 @@ "xpack.security.management.apiKeys.table.apiKeysAllDescription": "ユーザーの代わりにリクエストを送信するAPIキーを表示、削除します。", "xpack.security.management.apiKeys.table.apiKeysDisabledErrorLinkText": "ドキュメント", "xpack.security.management.apiKeys.table.apiKeysDisabledErrorTitle": "Elasticsearch で API キーが有効ではありません", - "xpack.security.management.apiKeys.table.apiKeysOwnDescription": "自分の代わりにリクエストを送信するAPIキーを表示、削除します。", - "xpack.security.management.apiKeys.table.apiKeysReadOnlyDescription": "自分の代わりにリクエストを送信するAPIキーを表示します。", - "xpack.security.management.apiKeys.table.apiKeysTableLoadingMessage": "API キーを読み込み中…", "xpack.security.management.apiKeys.table.apiKeysTitle": "API キー", "xpack.security.management.apiKeys.table.createButton": "APIキーを作成", "xpack.security.management.apiKeys.table.createdBadge": "たった今", - "xpack.security.management.apiKeys.table.creationDateColumnName": "作成済み", "xpack.security.management.apiKeys.table.deleteAction": "削除", "xpack.security.management.apiKeys.table.deleteDescription": "このAPIキーを削除", "xpack.security.management.apiKeys.table.loadingApiKeysDescription": "API キーを読み込み中…", "xpack.security.management.apiKeys.table.manageOwnKeysWarning": "自分のAPIキーを管理する権限のみが付与されています。", "xpack.security.management.apiKeys.table.nameColumnName": "名前", - "xpack.security.management.apiKeys.table.readOnlyOwnKeysWarning": "自分のAPIキーを表示する権限のみが付与されています。", - "xpack.security.management.apiKeys.table.realmColumnName": "レルム", - "xpack.security.management.apiKeys.table.realmFilterLabel": "レルム", "xpack.security.management.apiKeys.table.statusActive": "アクティブ", "xpack.security.management.apiKeys.table.statusColumnName": "ステータス", "xpack.security.management.apiKeys.table.statusExpired": "期限切れ", - "xpack.security.management.apiKeys.table.userFilterLabel": "ユーザー", - "xpack.security.management.apiKeys.table.userNameColumnName": "ユーザー", "xpack.security.management.apiKeys.updateSuccessMessage": "API キー'{name}'を更新しました", "xpack.security.management.apiKeysEmptyPrompt.disabledErrorMessage": "APIキーが無効です。", "xpack.security.management.apiKeysEmptyPrompt.docsLinkText": "APIキーを有効にする方法をご覧ください。", @@ -29636,7 +29619,6 @@ "xpack.security.management.roles.statusColumnName": "ステータス", "xpack.security.management.roles.subtitle": "ユーザーのグループにロールを適用してスタック全体のパーミッションを管理します。", "xpack.security.management.rolesTitle": "ロール", - "xpack.security.management.users.changePasswordFlyout.userLabel": "ユーザー", "xpack.security.management.users.changePasswordForm.confirmPasswordInvalidError": "パスワードが一致していません。", "xpack.security.management.users.changePasswordForm.confirmPasswordLabel": "パスワードの確認", "xpack.security.management.users.changePasswordForm.currentPasswordInvalidError": "無効なパスワードです。", @@ -40478,4 +40460,4 @@ "xpack.painlessLab.walkthroughButtonLabel": "実地検証", "xpack.serverlessObservability.nav.getStarted": "使ってみる" } -} +} \ No newline at end of file diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 3d75a18a75e28..f3dbabafc450e 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -29083,9 +29083,6 @@ "xpack.searchProfiler.registryProviderTitle": "Search Profiler", "xpack.searchProfiler.scoreTimeDescription": "基于查询实际评分文档所用的时间。", "xpack.searchProfiler.trialLicenseTitle": "试用", - "xpack.security.accountManagement.apiKeyFlyout.errorMessage": "无法 {errorTitle}", - "xpack.security.accountManagement.apiKeyFlyout.submitButton": "{isSubmitting, select, true {{inProgressButtonText}} other {{formTitle}}}", - "xpack.security.accountManagement.apiKeyFlyout.title": "{formTitle}", "xpack.security.accountManagement.userProfile.saveChangesButton": "{isSubmitting, select, true {正在保存更改……} other {保存更改}}", "xpack.security.accountManagement.userProfile.unsavedChangesMessage": "{count, plural, other {# 个未保存更改}}", "xpack.security.changePasswordForm.confirmButton": "{isSubmitting, select, true {正在更改密码……} other {更改密码}}", @@ -29105,7 +29102,6 @@ "xpack.security.management.apiKeys.deleteApiKey.errorMultipleNotificationTitle": "删除 {count} 个 api 密钥时出错", "xpack.security.management.apiKeys.deleteApiKey.successMultipleNotificationTitle": "已删除 {count} 个 API 密钥", "xpack.security.management.apiKeys.table.apiKeysDisabledErrorDescription": "请联系您的系统管理员并参阅{link}以启用 API 密钥。", - "xpack.security.management.apiKeys.table.fetchingApiKeysErrorMessage": "检查权限时出错:{message}", "xpack.security.management.apiKeys.table.invalidateApiKeyButton": "删除 {count, plural, other {API 密钥}}", "xpack.security.management.apiKeys.table.statusExpires": "{timeFromNow} 后过期", "xpack.security.management.editRole.featureTable.actionLegendText": "{featureName} 功能权限", @@ -29313,8 +29309,6 @@ "xpack.security.management.apiKeys.apiKeyFlyout.metadataRequired": "输入元数据或禁用此选项。", "xpack.security.management.apiKeys.apiKeyFlyout.nameRequired": "输入名称。", "xpack.security.management.apiKeys.apiKeyFlyout.roleDescriptorsRequired": "输入角色描述符或禁用此选项。", - "xpack.security.management.apiKeys.base64Description": "用于通过 Elasticsearch 进行身份验证的格式。", - "xpack.security.management.apiKeys.base64Label": "Base64", "xpack.security.management.apiKeys.beatsDescription": "用于配置 Beats 的格式。", "xpack.security.management.apiKeys.beatsLabel": "Beats", "xpack.security.management.apiKeys.createBreadcrumb": "创建", @@ -29325,8 +29319,6 @@ "xpack.security.management.apiKeys.deleteApiKey.errorSingleNotificationTitle": "删除 API 密钥“{name}”时出错", "xpack.security.management.apiKeys.deleteApiKey.successSingleNotificationTitle": "已删除 API 密钥“{name}”", "xpack.security.management.apiKeys.deniedPermissionTitle": "您需要管理 API 密钥的权限", - "xpack.security.management.apiKeys.jsonDescription": "完全的 API 响应。", - "xpack.security.management.apiKeys.jsonLabel": "JSON", "xpack.security.management.apiKeys.logstashDescription": "用于配置 Logstash 的格式。", "xpack.security.management.apiKeys.logstashLabel": "Logstash", "xpack.security.management.apiKeys.noPermissionToManageRolesDescription": "请联系您的系统管理员。", @@ -29334,26 +29326,17 @@ "xpack.security.management.apiKeys.table.apiKeysAllDescription": "查看并删除代表用户发送请求的 API 密钥。", "xpack.security.management.apiKeys.table.apiKeysDisabledErrorLinkText": "文档", "xpack.security.management.apiKeys.table.apiKeysDisabledErrorTitle": "Elasticsearch 中未启用 API 密钥", - "xpack.security.management.apiKeys.table.apiKeysOwnDescription": "查看并删除代表您发送请求的 API 密钥。", - "xpack.security.management.apiKeys.table.apiKeysReadOnlyDescription": "查看代表您发送请求的 API 密钥。", - "xpack.security.management.apiKeys.table.apiKeysTableLoadingMessage": "正在加载 API 密钥……", "xpack.security.management.apiKeys.table.apiKeysTitle": "API 密钥", "xpack.security.management.apiKeys.table.createButton": "创建 API 密钥", "xpack.security.management.apiKeys.table.createdBadge": "刚刚", - "xpack.security.management.apiKeys.table.creationDateColumnName": "创建时间", "xpack.security.management.apiKeys.table.deleteAction": "删除", "xpack.security.management.apiKeys.table.deleteDescription": "删除此 API 密钥", "xpack.security.management.apiKeys.table.loadingApiKeysDescription": "正在加载 API 密钥……", "xpack.security.management.apiKeys.table.manageOwnKeysWarning": "您仅有权管理自己的 API 密钥。", "xpack.security.management.apiKeys.table.nameColumnName": "名称", - "xpack.security.management.apiKeys.table.readOnlyOwnKeysWarning": "您仅有权查看自己的 API 密钥。", - "xpack.security.management.apiKeys.table.realmColumnName": "Realm", - "xpack.security.management.apiKeys.table.realmFilterLabel": "Realm", "xpack.security.management.apiKeys.table.statusActive": "活动", "xpack.security.management.apiKeys.table.statusColumnName": "状态", "xpack.security.management.apiKeys.table.statusExpired": "已过期", - "xpack.security.management.apiKeys.table.userFilterLabel": "用户", - "xpack.security.management.apiKeys.table.userNameColumnName": "用户", "xpack.security.management.apiKeys.updateSuccessMessage": "已更新 API 密钥“{name}”", "xpack.security.management.apiKeysEmptyPrompt.disabledErrorMessage": "API 密钥已禁用。", "xpack.security.management.apiKeysEmptyPrompt.docsLinkText": "了解如何启用 API 密钥。", @@ -29632,7 +29615,6 @@ "xpack.security.management.roles.statusColumnName": "状态", "xpack.security.management.roles.subtitle": "将角色应用到用户组并管理整个堆栈的权限。", "xpack.security.management.rolesTitle": "角色", - "xpack.security.management.users.changePasswordFlyout.userLabel": "用户", "xpack.security.management.users.changePasswordForm.confirmPasswordInvalidError": "密码不匹配。", "xpack.security.management.users.changePasswordForm.confirmPasswordLabel": "确认密码", "xpack.security.management.users.changePasswordForm.currentPasswordInvalidError": "密码无效。", @@ -40472,4 +40454,4 @@ "xpack.painlessLab.walkthroughButtonLabel": "指导", "xpack.serverlessObservability.nav.getStarted": "开始使用" } -} +} \ No newline at end of file diff --git a/x-pack/test/api_integration/apis/features/features/features.ts b/x-pack/test/api_integration/apis/features/features/features.ts index d5038a7c51ded..fd91262d57a61 100644 --- a/x-pack/test/api_integration/apis/features/features/features.ts +++ b/x-pack/test/api_integration/apis/features/features/features.ts @@ -107,6 +107,7 @@ export default function ({ getService }: FtrProviderContext) { 'graph', 'guidedOnboardingFeature', 'monitoring', + 'observabilityAIAssistant', 'observabilityCases', 'savedObjectsManagement', 'savedObjectsTagging', diff --git a/x-pack/test/api_integration/apis/security/privileges.ts b/x-pack/test/api_integration/apis/security/privileges.ts index c03f9fb91db65..ad9eb9b3bd6eb 100644 --- a/x-pack/test/api_integration/apis/security/privileges.ts +++ b/x-pack/test/api_integration/apis/security/privileges.ts @@ -24,6 +24,7 @@ export default function ({ getService }: FtrProviderContext) { maps: ['all', 'read', 'minimal_all', 'minimal_read'], generalCases: ['all', 'read', 'minimal_all', 'minimal_read', 'cases_delete'], observabilityCases: ['all', 'read', 'minimal_all', 'minimal_read', 'cases_delete'], + observabilityAIAssistant: ['all', 'read', 'minimal_all', 'minimal_read'], slo: ['all', 'read', 'minimal_all', 'minimal_read'], fleetv2: ['all', 'read', 'minimal_all', 'minimal_read'], fleet: ['all', 'read', 'minimal_all', 'minimal_read'], diff --git a/x-pack/test/api_integration/apis/security/privileges_basic.ts b/x-pack/test/api_integration/apis/security/privileges_basic.ts index 201590cdcaf5a..680bd9fd13298 100644 --- a/x-pack/test/api_integration/apis/security/privileges_basic.ts +++ b/x-pack/test/api_integration/apis/security/privileges_basic.ts @@ -32,6 +32,7 @@ export default function ({ getService }: FtrProviderContext) { maps: ['all', 'read', 'minimal_all', 'minimal_read'], generalCases: ['all', 'read', 'minimal_all', 'minimal_read'], observabilityCases: ['all', 'read', 'minimal_all', 'minimal_read'], + observabilityAIAssistant: ['all', 'read', 'minimal_all', 'minimal_read'], slo: ['all', 'read', 'minimal_all', 'minimal_read'], canvas: ['all', 'read', 'minimal_all', 'minimal_read'], infrastructure: ['all', 'read', 'minimal_all', 'minimal_read'], @@ -97,6 +98,7 @@ export default function ({ getService }: FtrProviderContext) { maps: ['all', 'read', 'minimal_all', 'minimal_read'], generalCases: ['all', 'read', 'minimal_all', 'minimal_read', 'cases_delete'], observabilityCases: ['all', 'read', 'minimal_all', 'minimal_read', 'cases_delete'], + observabilityAIAssistant: ['all', 'read', 'minimal_all', 'minimal_read'], slo: ['all', 'read', 'minimal_all', 'minimal_read'], fleetv2: ['all', 'read', 'minimal_all', 'minimal_read'], fleet: ['all', 'read', 'minimal_all', 'minimal_read'], diff --git a/x-pack/test/functional/apps/api_keys/home_page.ts b/x-pack/test/functional/apps/api_keys/home_page.ts index abc2db92906d8..316a2e32c47fd 100644 --- a/x-pack/test/functional/apps/api_keys/home_page.ts +++ b/x-pack/test/functional/apps/api_keys/home_page.ts @@ -66,7 +66,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { it('Loads the app', async () => { await security.testUser.setRoles(['test_api_keys']); - log.debug('Checking for create API key call to action'); + log.debug('Checking for Create API key call to action'); await find.existsByLinkText('Create API key'); }); @@ -91,9 +91,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { const apiKeyName = 'Happy API Key'; await pageObjects.apiKeys.clickOnPromptCreateApiKey(); expect(await browser.getCurrentUrl()).to.contain('app/management/security/api_keys/create'); - expect(await pageObjects.apiKeys.getFlyoutTitleText()).to.be('Create API Key'); - - expect(await pageObjects.apiKeys.getFlyoutUsername()).to.be('test_user'); + expect(await pageObjects.apiKeys.getFlyoutTitleText()).to.be('Create API key'); await pageObjects.apiKeys.setApiKeyName(apiKeyName); await pageObjects.apiKeys.clickSubmitButtonOnApiKeyFlyout(); @@ -114,11 +112,6 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { await pageObjects.apiKeys.setApiKeyName(apiKeyName); await pageObjects.apiKeys.toggleCustomExpiration(); - await pageObjects.apiKeys.clickSubmitButtonOnApiKeyFlyout(); - expect(await pageObjects.apiKeys.getErrorCallOutText()).to.be( - 'Enter a valid duration or disable this option.' - ); - await pageObjects.apiKeys.setApiKeyCustomExpiration('12'); await pageObjects.apiKeys.clickSubmitButtonOnApiKeyFlyout(); const newApiKeyCreation = await pageObjects.apiKeys.getNewApiKeyCreation(); @@ -175,7 +168,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { await pageObjects.apiKeys.waitForSubmitButtonOnApiKeyFlyoutEnabled(); - expect(await pageObjects.apiKeys.getFlyoutTitleText()).to.be('Update API Key'); + expect(await pageObjects.apiKeys.getFlyoutTitleText()).to.be('Update API key'); // Verify name input box are disabled const apiKeyNameInput = await pageObjects.apiKeys.getApiKeyName(); @@ -372,8 +365,6 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { expect(await browser.getCurrentUrl()).to.contain('app/management/security/api_keys'); expect(await pageObjects.apiKeys.getFlyoutTitleText()).to.be('API key details'); - expect(await pageObjects.apiKeys.getFlyoutUsername()).to.be('elastic'); - // Verify name input box are disabled const apiKeyNameInput = await pageObjects.apiKeys.getApiKeyName(); expect(await apiKeyNameInput.isEnabled()).to.be(false); diff --git a/x-pack/test/functional/apps/canvas/embeddables/lens.ts b/x-pack/test/functional/apps/canvas/embeddables/lens.ts index 1c6e0bb2c7075..de7a2eb753204 100644 --- a/x-pack/test/functional/apps/canvas/embeddables/lens.ts +++ b/x-pack/test/functional/apps/canvas/embeddables/lens.ts @@ -5,11 +5,9 @@ * 2.0. */ -import expect from '@kbn/expect'; import { FtrProviderContext } from '../../../ftr_provider_context'; export default function canvasLensTest({ getService, getPageObjects }: FtrProviderContext) { - const retry = getService('retry'); const PageObjects = getPageObjects(['canvas', 'common', 'header', 'lens']); const esArchiver = getService('esArchiver'); const dashboardAddPanel = getService('dashboardAddPanel'); @@ -27,12 +25,8 @@ export default function canvasLensTest({ getService, getPageObjects }: FtrProvid await kibanaServer.savedObjects.cleanStandardList(); await kibanaServer.importExport.load(archives.kbn); await kibanaServer.uiSettings.replace({ defaultIndex: 'logstash-lens' }); - // open canvas home await PageObjects.common.navigateToApp('canvas'); - // load test workpad - await PageObjects.common.navigateToApp('canvas', { - hash: '/workpad/workpad-1705f884-6224-47de-ba49-ca224fe6ec31/page/1', - }); + await PageObjects.canvas.createNewWorkpad(); }); after(async () => { @@ -41,16 +35,7 @@ export default function canvasLensTest({ getService, getPageObjects }: FtrProvid }); describe('by-reference', () => { - it('renders lens visualization using savedLens expression', async () => { - await PageObjects.header.waitUntilLoadingHasFinished(); - - await PageObjects.lens.assertLegacyMetric('Maximum of bytes', '16,788'); - }); - it('adds existing lens embeddable from the visualize library', async () => { - await PageObjects.canvas.goToListingPageViaBreadcrumbs(); - await PageObjects.canvas.createNewWorkpad(); - await PageObjects.canvas.setWorkpadName('lens tests'); await PageObjects.canvas.clickAddFromLibrary(); await dashboardAddPanel.addEmbeddable('Artistpreviouslyknownaslens', 'lens'); await testSubjects.existOrFail('embeddablePanelHeading-Artistpreviouslyknownaslens'); @@ -61,12 +46,21 @@ export default function canvasLensTest({ getService, getPageObjects }: FtrProvid await PageObjects.lens.save('Artistpreviouslyknownaslens v2', false, true); await testSubjects.existOrFail('embeddablePanelHeading-Artistpreviouslyknownaslensv2'); }); + + it('renders lens visualization using savedLens expression', async () => { + // load test workpad + await PageObjects.common.navigateToApp('canvas', { + hash: '/workpad/workpad-1705f884-6224-47de-ba49-ca224fe6ec31/page/1', + }); + await PageObjects.header.waitUntilLoadingHasFinished(); + + await PageObjects.lens.assertLegacyMetric('Maximum of bytes', '16,788'); + }); }); describe('by-value', () => { it('creates new lens embeddable', async () => { - await PageObjects.canvas.deleteSelectedElement(); - const originalEmbeddableCount = await PageObjects.canvas.getEmbeddableCount(); + await PageObjects.canvas.addNewPage(); await PageObjects.canvas.createNewVis('lens'); await PageObjects.lens.goToTimeRange(); await PageObjects.lens.configureDimension({ @@ -80,21 +74,26 @@ export default function canvasLensTest({ getService, getPageObjects }: FtrProvid field: 'bytes', }); await PageObjects.lens.saveAndReturn(); - await retry.try(async () => { - const embeddableCount = await PageObjects.canvas.getEmbeddableCount(); - expect(embeddableCount).to.eql(originalEmbeddableCount + 1); - }); + await PageObjects.header.waitUntilLoadingHasFinished(); + await testSubjects.exists('xyVisChart'); }); it('edits lens by-value embeddable', async () => { - const originalEmbeddableCount = await PageObjects.canvas.getEmbeddableCount(); - await dashboardPanelActions.openContextMenu(); + await PageObjects.header.waitUntilLoadingHasFinished(); + const panelHeader = await testSubjects.find('embeddablePanelHeading-'); + await dashboardPanelActions.openContextMenu(panelHeader); await dashboardPanelActions.clickEdit(); - await PageObjects.lens.saveAndReturn(); - await retry.try(async () => { - const embeddableCount = await PageObjects.canvas.getEmbeddableCount(); - expect(embeddableCount).to.eql(originalEmbeddableCount); - }); + await await PageObjects.lens.saveAndReturn(); + await PageObjects.header.waitUntilLoadingHasFinished(); + await testSubjects.exists('xyVisChart'); + }); + }); + + describe('switch page smoke test', async () => { + it('loads embeddables on page change', async () => { + await PageObjects.canvas.goToPreviousPage(); + await PageObjects.header.waitUntilLoadingHasFinished(); + await PageObjects.lens.assertLegacyMetric('Maximum of bytes', '16,788'); }); }); }); diff --git a/x-pack/test/functional/apps/ml/data_frame_analytics/classification_creation.ts b/x-pack/test/functional/apps/ml/data_frame_analytics/classification_creation.ts index 820f8620e8af9..52da33a14c5ac 100644 --- a/x-pack/test/functional/apps/ml/data_frame_analytics/classification_creation.ts +++ b/x-pack/test/functional/apps/ml/data_frame_analytics/classification_creation.ts @@ -123,7 +123,6 @@ export default function ({ getService }: FtrProviderContext) { 'Model memory limit', '25mb', 'Version', - '8.10.0', ], }, { diff --git a/x-pack/test/functional/apps/ml/data_frame_analytics/classification_creation_saved_search.ts b/x-pack/test/functional/apps/ml/data_frame_analytics/classification_creation_saved_search.ts index a28afe4bd06ff..02c14a37277ec 100644 --- a/x-pack/test/functional/apps/ml/data_frame_analytics/classification_creation_saved_search.ts +++ b/x-pack/test/functional/apps/ml/data_frame_analytics/classification_creation_saved_search.ts @@ -123,7 +123,6 @@ export default function ({ getService }: FtrProviderContext) { 'Model memory limit', '20mb', 'Version', - '8.10.0', ], }, { @@ -224,7 +223,6 @@ export default function ({ getService }: FtrProviderContext) { 'Model memory limit', '20mb', 'Version', - '8.10.0', ], }, { @@ -318,14 +316,7 @@ export default function ({ getService }: FtrProviderContext) { { section: 'state', // Don't include the 'Create time' value entry as it's not stable. - expectedEntries: [ - 'STOPPED', - 'Create time', - 'Model memory limit', - '7mb', - 'Version', - '8.10.0', - ], + expectedEntries: ['STOPPED', 'Create time', 'Model memory limit', '7mb', 'Version'], }, { section: 'stats', @@ -415,14 +406,7 @@ export default function ({ getService }: FtrProviderContext) { { section: 'state', // Don't include the 'Create time' value entry as it's not stable. - expectedEntries: [ - 'STOPPED', - 'Create time', - 'Model memory limit', - '6mb', - 'Version', - '8.10.0', - ], + expectedEntries: ['STOPPED', 'Create time', 'Model memory limit', '6mb', 'Version'], }, { section: 'stats', diff --git a/x-pack/test/functional/apps/ml/data_frame_analytics/outlier_detection_creation.ts b/x-pack/test/functional/apps/ml/data_frame_analytics/outlier_detection_creation.ts index 4c73a36bd174a..b92421143fc52 100644 --- a/x-pack/test/functional/apps/ml/data_frame_analytics/outlier_detection_creation.ts +++ b/x-pack/test/functional/apps/ml/data_frame_analytics/outlier_detection_creation.ts @@ -130,14 +130,7 @@ export default function ({ getService }: FtrProviderContext) { { section: 'state', // Don't include the 'Create time' value entry as it's not stable. - expectedEntries: [ - 'STOPPED', - 'Create time', - 'Model memory limit', - '2mb', - 'Version', - '8.10.0', - ], + expectedEntries: ['STOPPED', 'Create time', 'Model memory limit', '2mb', 'Version'], }, { section: 'stats', diff --git a/x-pack/test/functional/apps/ml/data_frame_analytics/outlier_detection_creation_saved_search.ts b/x-pack/test/functional/apps/ml/data_frame_analytics/outlier_detection_creation_saved_search.ts index 4eb223017dbd0..a6f68a8eafd0b 100644 --- a/x-pack/test/functional/apps/ml/data_frame_analytics/outlier_detection_creation_saved_search.ts +++ b/x-pack/test/functional/apps/ml/data_frame_analytics/outlier_detection_creation_saved_search.ts @@ -83,14 +83,7 @@ export default function ({ getService }: FtrProviderContext) { { section: 'state', // Don't include the 'Create time' value entry as it's not stable. - expectedEntries: [ - 'STOPPED', - 'Create time', - 'Model memory limit', - '1mb', - 'Version', - '8.10.0', - ], + expectedEntries: ['STOPPED', 'Create time', 'Model memory limit', '1mb', 'Version'], }, { section: 'stats', @@ -167,14 +160,7 @@ export default function ({ getService }: FtrProviderContext) { { section: 'state', // Don't include the 'Create time' value entry as it's not stable. - expectedEntries: [ - 'STOPPED', - 'Create time', - 'Model memory limit', - '1mb', - 'Version', - '8.10.0', - ], + expectedEntries: ['STOPPED', 'Create time', 'Model memory limit', '1mb', 'Version'], }, { section: 'stats', @@ -251,14 +237,7 @@ export default function ({ getService }: FtrProviderContext) { { section: 'state', // Don't include the 'Create time' value entry as it's not stable. - expectedEntries: [ - 'STOPPED', - 'Create time', - 'Model memory limit', - '1mb', - 'Version', - '8.10.0', - ], + expectedEntries: ['STOPPED', 'Create time', 'Model memory limit', '1mb', 'Version'], }, { section: 'stats', @@ -336,14 +315,7 @@ export default function ({ getService }: FtrProviderContext) { { section: 'state', // Don't include the 'Create time' value entry as it's not stable. - expectedEntries: [ - 'STOPPED', - 'Create time', - 'Model memory limit', - '1mb', - 'Version', - '8.10.0', - ], + expectedEntries: ['STOPPED', 'Create time', 'Model memory limit', '1mb', 'Version'], }, { section: 'stats', diff --git a/x-pack/test/functional/apps/ml/data_frame_analytics/regression_creation.ts b/x-pack/test/functional/apps/ml/data_frame_analytics/regression_creation.ts index 96f0add69704b..df965dae9cce1 100644 --- a/x-pack/test/functional/apps/ml/data_frame_analytics/regression_creation.ts +++ b/x-pack/test/functional/apps/ml/data_frame_analytics/regression_creation.ts @@ -119,7 +119,6 @@ export default function ({ getService }: FtrProviderContext) { 'Model memory limit', '16mb', 'Version', - '8.10.0', ], }, { @@ -190,7 +189,8 @@ export default function ({ getService }: FtrProviderContext) { ]; for (const testData of testDataList) { - describe(`${testData.suiteTitle}`, function () { + // FAILED ES PROMOTION: https://github.com/elastic/kibana/issues/163338 + describe.skip(`${testData.suiteTitle}`, function () { after(async () => { await ml.api.deleteIndices(testData.destinationIndex); await ml.testResources.deleteIndexPatternByTitle(testData.destinationIndex); diff --git a/x-pack/test/functional/apps/ml/data_frame_analytics/regression_creation_saved_search.ts b/x-pack/test/functional/apps/ml/data_frame_analytics/regression_creation_saved_search.ts index 119fe25b35c55..043f546dea69f 100644 --- a/x-pack/test/functional/apps/ml/data_frame_analytics/regression_creation_saved_search.ts +++ b/x-pack/test/functional/apps/ml/data_frame_analytics/regression_creation_saved_search.ts @@ -94,7 +94,6 @@ export default function ({ getService }: FtrProviderContext) { 'Model memory limit', '10mb', 'Version', - '8.10.0', ], }, { @@ -183,7 +182,6 @@ export default function ({ getService }: FtrProviderContext) { 'Model memory limit', '10mb', 'Version', - '8.10.0', ], }, { @@ -266,14 +264,7 @@ export default function ({ getService }: FtrProviderContext) { { section: 'state', // Don't include the 'Create time' value entry as it's not stable. - expectedEntries: [ - 'STOPPED', - 'Create time', - 'Model memory limit', - '5mb', - 'Version', - '8.10.0', - ], + expectedEntries: ['STOPPED', 'Create time', 'Model memory limit', '5mb', 'Version'], }, { section: 'stats', @@ -355,14 +346,7 @@ export default function ({ getService }: FtrProviderContext) { { section: 'state', // Don't include the 'Create time' value entry as it's not stable. - expectedEntries: [ - 'STOPPED', - 'Create time', - 'Model memory limit', - '5mb', - 'Version', - '8.10.0', - ], + expectedEntries: ['STOPPED', 'Create time', 'Model memory limit', '5mb', 'Version'], }, { section: 'stats', diff --git a/x-pack/test/functional/page_objects/api_keys_page.ts b/x-pack/test/functional/page_objects/api_keys_page.ts index 348b7025b733a..23f63c87107fa 100644 --- a/x-pack/test/functional/page_objects/api_keys_page.ts +++ b/x-pack/test/functional/page_objects/api_keys_page.ts @@ -74,11 +74,6 @@ export function ApiKeysPageProvider({ getService }: FtrProviderContext) { return euiCallOutHeader.getVisibleText(); }, - async getErrorCallOutText() { - const alertElem = await find.byCssSelector('[role="dialog"] [role="alert"] .euiText'); - return await alertElem.getVisibleText(); - }, - async getApiKeysFirstPromptTitle() { const titlePromptElem = await find.byCssSelector('.euiEmptyPrompt .euiTitle'); return await titlePromptElem.getVisibleText(); @@ -135,11 +130,6 @@ export function ApiKeysPageProvider({ getService }: FtrProviderContext) { return header.getVisibleText(); }, - async getFlyoutUsername() { - const usernameField = await testSubjects.find('apiKeyFlyoutUsername'); - return usernameField.getVisibleText(); - }, - async getFlyoutApiKeyStatus() { const apiKeyStatusField = await testSubjects.find('apiKeyStatus'); return apiKeyStatusField.getVisibleText(); diff --git a/x-pack/test/functional/page_objects/canvas_page.ts b/x-pack/test/functional/page_objects/canvas_page.ts index 5cc801be9fbeb..e2a2c6438d1f3 100644 --- a/x-pack/test/functional/page_objects/canvas_page.ts +++ b/x-pack/test/functional/page_objects/canvas_page.ts @@ -191,5 +191,29 @@ export function CanvasPageProvider({ getService, getPageObjects }: FtrProviderCo log.debug('CanvasPage.saveDatasourceChanges'); await testSubjects.click('canvasSaveDatasourceButton'); }, + + async goToPreviousPage() { + log.debug('CanvasPage.goToPreviousPage'); + await testSubjects.click('previousPageButton'); + }, + + async goToNextPage() { + log.debug('CanvasPage.goToNextPage'); + await testSubjects.click('nextPageButton'); + }, + + async togglePageManager() { + log.debug('CanvasPage.openPageManager'); + await testSubjects.click('canvasPageManagerButton'); + }, + + async addNewPage() { + log.debug('CanvasPage.addNewPage'); + if (!(await testSubjects.exists('canvasAddPageButton'))) { + await this.togglePageManager(); + } + await testSubjects.click('canvasAddPageButton'); + await this.togglePageManager(); + }, }; } diff --git a/x-pack/test/saved_object_tagging/functional/tests/listing.ts b/x-pack/test/saved_object_tagging/functional/tests/listing.ts index 7b2d19095359e..8cb8ded66e1f7 100644 --- a/x-pack/test/saved_object_tagging/functional/tests/listing.ts +++ b/x-pack/test/saved_object_tagging/functional/tests/listing.ts @@ -14,7 +14,8 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { const PageObjects = getPageObjects(['common', 'security', 'savedObjects', 'tagManagement']); const tagManagementPage = PageObjects.tagManagement; - describe('table listing', () => { + // FLAKY: https://github.com/elastic/kibana/issues/90578 + describe.skip('table listing', () => { before(async () => { await kibanaServer.importExport.load( 'x-pack/test/saved_object_tagging/common/fixtures/es_archiver/functional_base/data.json'