From 3c7c2f16cc3641c42c6edb436ebe500c201d9b59 Mon Sep 17 00:00:00 2001 From: Julia Bardi <90178898+juliaElastic@users.noreply.github.com> Date: Mon, 15 Jan 2024 12:04:03 +0100 Subject: [PATCH] [8.12][Fleet] support toggle between secret and plain text storage (#174354) (#174826) Backport https://github.com/elastic/kibana/pull/174354 to 8.12 --- .../edit_output_flyout/index.test.tsx | 145 ++++++++++++- .../components/edit_output_flyout/index.tsx | 189 ++--------------- .../output_form_elasticsearch.tsx | 63 ++++++ .../edit_output_flyout/output_form_kafka.tsx | 6 +- .../output_form_kafka_authentication.tsx | 66 +++++- .../output_form_logstash.tsx | 194 ++++++++++++++++++ .../output_form_remote_es.tsx | 48 ++++- .../output_form_secret_form_row.test.tsx | 38 +++- .../output_form_secret_form_row.tsx | 53 ++++- .../fleet/server/services/output.test.ts | 26 +++ .../plugins/fleet/server/services/output.ts | 9 + 11 files changed, 620 insertions(+), 217 deletions(-) create mode 100644 x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/output_form_elasticsearch.tsx create mode 100644 x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/output_form_logstash.tsx diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/index.test.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/index.test.tsx index 2126a275818e1..3a3bd50864ce0 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/index.test.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/index.test.tsx @@ -6,12 +6,13 @@ */ import React from 'react'; +import { fireEvent, waitFor } from '@testing-library/react'; import type { Output } from '../../../../types'; import { createFleetTestRendererMock } from '../../../../../../mock'; import { useFleetStatus } from '../../../../../../hooks/use_fleet_status'; import { ExperimentalFeaturesService } from '../../../../../../services'; -import { useStartServices } from '../../../../hooks'; +import { useStartServices, sendPutOutput } from '../../../../hooks'; import { EditOutputFlyout } from '.'; @@ -31,9 +32,15 @@ jest.mock('../../../../hooks', () => { ...jest.requireActual('../../../../hooks'), useBreadcrumbs: jest.fn(), useStartServices: jest.fn(), + sendPutOutput: jest.fn(), }; }); +jest.mock('./confirm_update', () => ({ + confirmUpdate: () => jest.fn().mockResolvedValue(true), +})); + +const mockSendPutOutput = sendPutOutput as jest.MockedFunction; const mockUseStartServices = useStartServices as jest.Mock; const mockedUsedFleetStatus = useFleetStatus as jest.MockedFunction; @@ -80,7 +87,11 @@ const remoteEsOutputLabels = ['Hosts', 'Service Token']; describe('EditOutputFlyout', () => { const mockStartServices = (isServerlessEnabled?: boolean) => { mockUseStartServices.mockReturnValue({ - notifications: { toasts: {} }, + notifications: { + toasts: { + addError: jest.fn(), + }, + }, docLinks: { links: { fleet: {}, logstash: {}, kibana: {} }, }, @@ -92,6 +103,9 @@ describe('EditOutputFlyout', () => { beforeEach(() => { mockStartServices(false); + jest.clearAllMocks(); + // mockSendPutOutput.mockClear(); + // mockedUsedFleetStatus.mockClear(); }); it('should render the flyout if there is not output provided', async () => { @@ -174,6 +188,102 @@ describe('EditOutputFlyout', () => { }); }); + it('should populate secret input with plain text value when editing kafka output', async () => { + jest + .spyOn(ExperimentalFeaturesService, 'get') + .mockReturnValue({ outputSecretsStorage: true, kafkaOutput: true }); + const { utils } = renderFlyout({ + type: 'kafka', + name: 'kafka output', + id: 'outputK', + is_default: false, + is_default_monitoring: false, + hosts: ['kafka:443'], + topics: [{ topic: 'topic' }], + auth_type: 'ssl', + version: '1.0.0', + ssl: { certificate: 'cert', key: 'key', verification_mode: 'full' }, + compression: 'none', + }); + + expect((utils.getByTestId('kafkaSslKeySecretInput') as any).value).toEqual('key'); + + fireEvent.click(utils.getByText('Save and apply settings')); + + await waitFor(() => { + expect(mockSendPutOutput).toHaveBeenCalledWith( + 'outputK', + expect.objectContaining({ + secrets: { ssl: { key: 'key' } }, + ssl: { certificate: 'cert', key: '', verification_mode: 'full' }, + }) + ); + }); + }); + + it('should populate secret password input with plain text value when editing kafka output', async () => { + jest + .spyOn(ExperimentalFeaturesService, 'get') + .mockReturnValue({ outputSecretsStorage: true, kafkaOutput: true }); + const { utils } = renderFlyout({ + type: 'kafka', + name: 'kafka output', + id: 'outputK', + is_default: false, + is_default_monitoring: false, + hosts: ['kafka:443'], + topics: [{ topic: 'topic' }], + auth_type: 'user_pass', + version: '1.0.0', + username: 'user', + password: 'pass', + compression: 'none', + }); + + expect( + (utils.getByTestId('settingsOutputsFlyout.kafkaPasswordSecretInput') as any).value + ).toEqual('pass'); + + fireEvent.click(utils.getByText('Save and apply settings')); + + await waitFor(() => { + expect(mockSendPutOutput).toHaveBeenCalledWith( + 'outputK', + expect.objectContaining({ + secrets: { password: 'pass' }, + }) + ); + expect((mockSendPutOutput.mock.calls[0][1] as any).password).toBeUndefined(); + }); + }); + + it('should populate secret input with plain text value when editing logstash output', async () => { + jest.spyOn(ExperimentalFeaturesService, 'get').mockReturnValue({ outputSecretsStorage: true }); + const { utils } = renderFlyout({ + type: 'logstash', + name: 'logstash output', + id: 'outputL', + is_default: false, + is_default_monitoring: false, + hosts: ['logstash'], + ssl: { certificate: 'cert', key: 'key', certificate_authorities: [] }, + }); + + expect((utils.getByTestId('sslKeySecretInput') as HTMLInputElement).value).toEqual('key'); + + fireEvent.click(utils.getByText('Save and apply settings')); + + await waitFor(() => { + expect(mockSendPutOutput).toHaveBeenCalledWith( + 'outputL', + expect.objectContaining({ + secrets: { ssl: { key: 'key' } }, + ssl: { certificate: 'cert', certificate_authorities: [] }, + }) + ); + }); + }); + it('should show a callout in the flyout if the selected output is logstash and no encrypted key is set', async () => { mockedUsedFleetStatus.mockReturnValue({ missingOptionalFeatures: ['encrypted_saved_object_encryption_key_required'], @@ -214,6 +324,37 @@ describe('EditOutputFlyout', () => { expect(utils.queryByTestId('serviceTokenSecretInput')).not.toBeNull(); }); + it('should populate secret service token input with plain text value when editing remote ES output', async () => { + jest + .spyOn(ExperimentalFeaturesService, 'get') + .mockReturnValue({ remoteESOutput: true, outputSecretsStorage: true }); + const { utils } = renderFlyout({ + type: 'remote_elasticsearch', + name: 'remote es output', + id: 'outputR', + is_default: false, + is_default_monitoring: false, + service_token: '1234', + hosts: ['https://localhost:9200'], + }); + + expect((utils.getByTestId('serviceTokenSecretInput') as HTMLInputElement).value).toEqual( + '1234' + ); + + fireEvent.click(utils.getByText('Save and apply settings')); + + await waitFor(() => { + expect(mockSendPutOutput).toHaveBeenCalledWith( + 'outputR', + expect.objectContaining({ + secrets: { service_token: '1234' }, + service_token: undefined, + }) + ); + }); + }); + it('should not display remote ES output in type lists if serverless', async () => { jest.spyOn(ExperimentalFeaturesService, 'get').mockReturnValue({ remoteESOutput: true }); mockUseStartServices.mockReset(); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/index.tsx index daebac615d6eb..ed4774ea7470c 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/index.tsx @@ -22,7 +22,6 @@ import { EuiForm, EuiFormRow, EuiFieldText, - EuiTextArea, EuiSelect, EuiSwitch, EuiCallOut, @@ -50,22 +49,20 @@ import { ExperimentalFeaturesService } from '../../../../../../services'; import { outputType, RESERVED_CONFIG_YML_KEYS } from '../../../../../../../common/constants'; -import { MultiRowInput } from '../multi_row_input'; import type { Output, FleetProxy } from '../../../../types'; import { FLYOUT_MAX_WIDTH } from '../../constants'; -import { LogstashInstructions } from '../logstash_instructions'; -import { useBreadcrumbs, useStartServices } from '../../../../hooks'; -import { SecretFormRow } from './output_form_secret_form_row'; +import { useBreadcrumbs, useStartServices } from '../../../../hooks'; import { OutputFormKafkaSection } from './output_form_kafka'; import { YamlCodeEditorWithPlaceholder } from './yaml_code_editor_with_placeholder'; import { useOutputForm } from './use_output_form'; -import { EncryptionKeyRequiredCallout } from './encryption_key_required_callout'; import { AdvancedOptionsSection } from './advanced_options_section'; import { OutputFormRemoteEsSection } from './output_form_remote_es'; import { OutputHealth } from './output_health'; +import { OutputFormLogstashSection } from './output_form_logstash'; +import { OutputFormElasticsearchSection } from './output_form_elasticsearch'; export interface EditOutputFlyoutProps { output?: Output; @@ -85,9 +82,8 @@ export const EditOutputFlyout: React.FunctionComponent = const { euiTheme } = useEuiTheme(); const { outputSecretsStorage: isOutputSecretsStorageEnabled } = ExperimentalFeaturesService.get(); const [useSecretsStorage, setUseSecretsStorage] = React.useState(isOutputSecretsStorageEnabled); - - const onUsePlainText = () => { - setUseSecretsStorage(false); + const onToggleSecretStorage = (secretEnabled: boolean) => { + setUseSecretsStorage(secretEnabled); }; const proxiesOptions = useMemo( @@ -118,174 +114,17 @@ export const EditOutputFlyout: React.FunctionComponent = const renderLogstashSection = () => { return ( - <> - {!form.hasEncryptedSavedObjectConfigured && ( - <> - - - - )} - - - - - - - ), - }} - /> - } - label={i18n.translate('xpack.fleet.settings.editOutputFlyout.logstashHostsInputLabel', { - defaultMessage: 'Logstash hosts', - })} - {...inputs.logstashHostsInput.props} - /> - - - } - {...inputs.sslCertificateInput.formRowProps} - > - - - {(output && output?.ssl?.key) || !useSecretsStorage ? ( - - } - {...inputs.sslKeyInput.formRowProps} - > - - - ) : ( - - - - )} - + ); }; const renderElasticsearchSection = () => { - return ( - <> - - - } - {...inputs.caTrustedFingerprintInput.formRowProps} - > - - - - ); + return ; }; const renderRemoteElasticsearchSection = () => { @@ -294,7 +133,7 @@ export const EditOutputFlyout: React.FunctionComponent = ); } @@ -307,7 +146,7 @@ export const EditOutputFlyout: React.FunctionComponent = ); } diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/output_form_elasticsearch.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/output_form_elasticsearch.tsx new file mode 100644 index 0000000000000..877c285e391c6 --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/output_form_elasticsearch.tsx @@ -0,0 +1,63 @@ +/* + * 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 React from 'react'; +import { EuiFieldText, EuiFormRow } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { i18n } from '@kbn/i18n'; + +import { MultiRowInput } from '../multi_row_input'; + +import type { OutputFormInputsType } from './use_output_form'; + +interface Props { + inputs: OutputFormInputsType; +} + +export const OutputFormElasticsearchSection: React.FunctionComponent = (props) => { + const { inputs } = props; + + return ( + <> + + + } + {...inputs.caTrustedFingerprintInput.formRowProps} + > + + + + ); +}; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/output_form_kafka.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/output_form_kafka.tsx index 66493b328697c..a9c5c587c60f7 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/output_form_kafka.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/output_form_kafka.tsx @@ -33,11 +33,11 @@ import type { OutputFormInputsType } from './use_output_form'; interface Props { inputs: OutputFormInputsType; useSecretsStorage: boolean; - onUsePlainText: () => void; + onToggleSecretStorage: (secretEnabled: boolean) => void; } export const OutputFormKafkaSection: React.FunctionComponent = (props) => { - const { inputs, useSecretsStorage, onUsePlainText } = props; + const { inputs, useSecretsStorage, onToggleSecretStorage } = props; const { docLinks } = useStartServices(); @@ -109,7 +109,7 @@ export const OutputFormKafkaSection: React.FunctionComponent = (props) => diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/output_form_kafka_authentication.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/output_form_kafka_authentication.tsx index b70a9828371a4..5d21567cbbb35 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/output_form_kafka_authentication.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/output_form_kafka_authentication.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useMemo } from 'react'; +import React, { useEffect, useMemo } from 'react'; import { i18n } from '@kbn/i18n'; import { @@ -72,9 +72,46 @@ const kafkaAuthenticationsOptions = [ export const OutputFormKafkaAuthentication: React.FunctionComponent<{ inputs: OutputFormInputsType; useSecretsStorage: boolean; - onUsePlainText: () => void; + onToggleSecretStorage: (secretEnabled: boolean) => void; }> = (props) => { - const { inputs, useSecretsStorage, onUsePlainText } = props; + const { inputs, useSecretsStorage, onToggleSecretStorage } = props; + const [isFirstLoad, setIsFirstLoad] = React.useState(true); + + useEffect(() => { + if (!isFirstLoad) return; + setIsFirstLoad(false); + // populate the secret input with the value of the plain input in order to re-save the output with secret storage + if (useSecretsStorage) { + if (inputs.kafkaAuthPasswordInput.value && !inputs.kafkaAuthPasswordSecretInput.value) { + inputs.kafkaAuthPasswordSecretInput.setValue(inputs.kafkaAuthPasswordInput.value); + inputs.kafkaAuthPasswordInput.clear(); + } + + if (inputs.kafkaSslKeyInput.value && !inputs.kafkaSslKeySecretInput.value) { + inputs.kafkaSslKeySecretInput.setValue(inputs.kafkaSslKeyInput.value); + inputs.kafkaSslKeyInput.clear(); + } + } + }, [ + useSecretsStorage, + inputs.kafkaAuthPasswordInput, + inputs.kafkaAuthPasswordSecretInput, + inputs.kafkaSslKeyInput, + inputs.kafkaSslKeySecretInput, + isFirstLoad, + setIsFirstLoad, + ]); + + const onToggleSecretAndClearValue = (secretEnabled: boolean) => { + if (secretEnabled) { + inputs.kafkaAuthPasswordInput.clear(); + inputs.kafkaSslKeyInput.clear(); + } else { + inputs.kafkaAuthPasswordSecretInput.setValue(''); + inputs.kafkaSslKeySecretInput.setValue(''); + } + onToggleSecretStorage(secretEnabled); + }; const kafkaVerificationModeOptions = useMemo( () => @@ -148,8 +185,8 @@ export const OutputFormKafkaAuthentication: React.FunctionComponent<{ )} /> - {inputs.kafkaSslKeyInput.value || !useSecretsStorage ? ( - } {...inputs.kafkaSslKeyInput.formRowProps} + useSecretsStorage={useSecretsStorage} + onToggleSecretStorage={onToggleSecretAndClearValue} > - + ) : ( - {inputs.kafkaAuthPasswordInput.value || !useSecretsStorage ? ( - } {...inputs.kafkaAuthPasswordInput.formRowProps} + useSecretsStorage={useSecretsStorage} + onToggleSecretStorage={onToggleSecretAndClearValue} > - + ) : ( void; + hasEncryptedSavedObjectConfigured: boolean; +} + +export const OutputFormLogstashSection: React.FunctionComponent = (props) => { + const { inputs, useSecretsStorage, onToggleSecretStorage, hasEncryptedSavedObjectConfigured } = + props; + const { docLinks } = useStartServices(); + + const [isFirstLoad, setIsFirstLoad] = React.useState(true); + + useEffect(() => { + if (!isFirstLoad) return; + setIsFirstLoad(false); + // populate the secret input with the value of the plain input in order to re-save the output with secret storage + if (useSecretsStorage) { + if (inputs.sslKeyInput.value && !inputs.sslKeySecretInput.value) { + inputs.sslKeySecretInput.setValue(inputs.sslKeyInput.value); + inputs.sslKeyInput.clear(); + } + } + }, [ + useSecretsStorage, + inputs.sslKeyInput, + inputs.sslKeySecretInput, + isFirstLoad, + setIsFirstLoad, + ]); + + const onToggleSecretAndClearValue = (secretEnabled: boolean) => { + if (secretEnabled) { + inputs.sslKeyInput.clear(); + } else { + inputs.sslKeySecretInput.setValue(''); + } + onToggleSecretStorage(secretEnabled); + }; + + return ( + <> + {!hasEncryptedSavedObjectConfigured && ( + <> + + + + )} + + + + + + + ), + }} + /> + } + label={i18n.translate('xpack.fleet.settings.editOutputFlyout.logstashHostsInputLabel', { + defaultMessage: 'Logstash hosts', + })} + {...inputs.logstashHostsInput.props} + /> + + + } + {...inputs.sslCertificateInput.formRowProps} + > + + + {!useSecretsStorage ? ( + + } + {...inputs.sslKeyInput.formRowProps} + useSecretsStorage={useSecretsStorage} + onToggleSecretStorage={onToggleSecretAndClearValue} + > + + + ) : ( + + + + )} + + ); +}; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/output_form_remote_es.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/output_form_remote_es.tsx index 86a89f05ef756..1b44bdc97ed8f 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/output_form_remote_es.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/output_form_remote_es.tsx @@ -5,8 +5,8 @@ * 2.0. */ -import React from 'react'; -import { EuiCallOut, EuiCodeBlock, EuiFieldText, EuiFormRow, EuiSpacer } from '@elastic/eui'; +import React, { useEffect } from 'react'; +import { EuiCallOut, EuiCodeBlock, EuiFieldText, EuiSpacer } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; import { i18n } from '@kbn/i18n'; @@ -18,11 +18,40 @@ import { SecretFormRow } from './output_form_secret_form_row'; interface Props { inputs: OutputFormInputsType; useSecretsStorage: boolean; - onUsePlainText: () => void; + onToggleSecretStorage: (secretEnabled: boolean) => void; } export const OutputFormRemoteEsSection: React.FunctionComponent = (props) => { - const { inputs, useSecretsStorage, onUsePlainText } = props; + const { inputs, useSecretsStorage, onToggleSecretStorage } = props; + + const [isFirstLoad, setIsFirstLoad] = React.useState(true); + + useEffect(() => { + if (!isFirstLoad) return; + setIsFirstLoad(false); + // populate the secret input with the value of the plain input in order to re-save the output with secret storage + if (useSecretsStorage) { + if (inputs.serviceTokenInput.value && !inputs.serviceTokenSecretInput.value) { + inputs.serviceTokenSecretInput.setValue(inputs.serviceTokenInput.value); + inputs.serviceTokenInput.clear(); + } + } + }, [ + useSecretsStorage, + inputs.serviceTokenInput, + inputs.serviceTokenSecretInput, + isFirstLoad, + setIsFirstLoad, + ]); + + const onToggleSecretAndClearValue = (secretEnabled: boolean) => { + if (secretEnabled) { + inputs.serviceTokenInput.clear(); + } else { + inputs.serviceTokenSecretInput.setValue(''); + } + onToggleSecretStorage(secretEnabled); + }; return ( <> @@ -41,8 +70,8 @@ export const OutputFormRemoteEsSection: React.FunctionComponent = (props) isUrl /> - {inputs.serviceTokenInput.value || !useSecretsStorage ? ( - = (props) /> } {...inputs.serviceTokenInput.formRowProps} + useSecretsStorage={useSecretsStorage} + onToggleSecretStorage={onToggleSecretAndClearValue} > = (props) } )} /> - + ) : ( = (props) })} {...inputs.serviceTokenSecretInput.formRowProps} cancelEdit={inputs.serviceTokenSecretInput.cancelEdit} - onUsePlainText={onUsePlainText} + useSecretsStorage={useSecretsStorage} + onToggleSecretStorage={onToggleSecretAndClearValue} > { const title = 'Test Secret'; const initialValue = 'initial value'; const clear = jest.fn(); - const onUsePlainText = jest.fn(); + const onToggleSecretStorage = jest.fn(); const cancelEdit = jest.fn(); + const useSecretsStorage = true; it('should switch to edit mode when the replace button is clicked', () => { const { getByText, queryByText, container } = render( @@ -23,7 +24,8 @@ describe('SecretFormRow', () => { title={title} initialValue={initialValue} clear={clear} - onUsePlainText={onUsePlainText} + useSecretsStorage={useSecretsStorage} + onToggleSecretStorage={onToggleSecretStorage} cancelEdit={cancelEdit} > @@ -46,7 +48,8 @@ describe('SecretFormRow', () => { title={title} initialValue={initialValue} clear={clear} - onUsePlainText={onUsePlainText} + useSecretsStorage={useSecretsStorage} + onToggleSecretStorage={onToggleSecretStorage} cancelEdit={cancelEdit} > @@ -59,12 +62,13 @@ describe('SecretFormRow', () => { expect(cancelEdit).toHaveBeenCalled(); }); - it('should call the onUsePlainText function when the revert link is clicked', () => { + it('should call the onToggleSecretStorage function when the revert link is clicked', () => { const { getByText } = render( @@ -73,7 +77,7 @@ describe('SecretFormRow', () => { fireEvent.click(getByText('Click to use plain text storage instead')); - expect(onUsePlainText).toHaveBeenCalled(); + expect(onToggleSecretStorage).toHaveBeenCalledWith(false); }); it('should not display the cancel change button when no initial value is provided', () => { @@ -81,7 +85,8 @@ describe('SecretFormRow', () => { @@ -91,4 +96,23 @@ describe('SecretFormRow', () => { expect(queryByTestId('secretCancelChangeBtn')).not.toBeInTheDocument(); }); + + it('should call the onToggleSecretStorage function when the use secret storage button is clicked in plain text mode', () => { + const { getByText, queryByTestId } = render( + Test Field} + useSecretsStorage={false} + onToggleSecretStorage={onToggleSecretStorage} + > + + + ); + + expect(queryByTestId('lockIcon')).not.toBeInTheDocument(); + expect(getByText('Test Field')).toBeInTheDocument(); + + fireEvent.click(getByText('Click to use secret storage instead')); + + expect(onToggleSecretStorage).toHaveBeenCalledWith(true); + }); }); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/output_form_secret_form_row.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/output_form_secret_form_row.tsx index 868c80b895fa3..2028c6107bfd3 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/output_form_secret_form_row.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/output_form_secret_form_row.tsx @@ -23,13 +23,15 @@ import { i18n } from '@kbn/i18n'; export const SecretFormRow: React.FC<{ fullWidth?: boolean; children: ConstructorParameters[0]['children']; + useSecretsStorage: boolean; + onToggleSecretStorage: (secretEnabled: boolean) => void; error?: string[]; isInvalid?: boolean; - title: string; - clear: () => void; + title?: string; + clear?: () => void; initialValue?: any; - onUsePlainText: () => void; - cancelEdit: () => void; + cancelEdit?: () => void; + label?: JSX.Element; }> = ({ fullWidth, error, @@ -38,8 +40,10 @@ export const SecretFormRow: React.FC<{ clear, title, initialValue, - onUsePlainText, + onToggleSecretStorage, cancelEdit, + useSecretsStorage, + label, }) => { const hasInitialValue = !!initialValue; const [editMode, setEditMode] = useState(!initialValue); @@ -77,7 +81,7 @@ export const SecretFormRow: React.FC<{ { setEditMode(false); - cancelEdit(); + if (cancelEdit) cancelEdit(); }} color="primary" iconType="refresh" @@ -105,9 +109,9 @@ export const SecretFormRow: React.FC<{ ); - const label = ( + const secretLabel = ( - +   {title}   @@ -128,7 +132,7 @@ export const SecretFormRow: React.FC<{ defaultMessage="This field uses secret storage and requires Fleet Server v8.12.0 and above. {revertLink}" values={{ revertLink: ( - + onToggleSecretStorage(false)} color="primary" size="xs"> ) : undefined; + const plainTextHelp = ( + onToggleSecretStorage(true)} color="primary" size="xs"> + + + ), + }} + /> + ); + const inputComponent = editMode ? editValue : valueHiddenPanel; - return ( + return useSecretsStorage ? ( {inputComponent} + ) : ( + + {inputComponent} + ); }; diff --git a/x-pack/plugins/fleet/server/services/output.test.ts b/x-pack/plugins/fleet/server/services/output.test.ts index 48b8aaa808112..b22e001739e7c 100644 --- a/x-pack/plugins/fleet/server/services/output.test.ts +++ b/x-pack/plugins/fleet/server/services/output.test.ts @@ -137,6 +137,14 @@ function getMockedSoClient( }); } + case outputIdToUuid('existing-remote-es-output'): { + return mockOutputSO('existing-remote-es-output', { + type: 'remote_elasticsearch', + is_default: false, + service_token: 'plain', + }); + } + default: throw new Error('not found: ' + id); } @@ -1699,6 +1707,24 @@ describe('Output Service', () => { }) ).resolves.not.toThrow(); }); + + it('Should delete service_token if updated remote es output does not have a value', async () => { + const soClient = getMockedSoClient({}); + mockedAgentPolicyService.list.mockResolvedValue({ + items: [{}], + } as unknown as ReturnType); + mockedAgentPolicyService.hasAPMIntegration.mockReturnValue(false); + mockedAgentPolicyService.hasFleetServerIntegration.mockReturnValue(false); + + await outputService.update(soClient, esClientMock, 'existing-remote-es-output', { + type: 'remote_elasticsearch', + }); + + expect(soClient.update).toBeCalledWith(expect.anything(), expect.anything(), { + type: 'remote_elasticsearch', + service_token: null, + }); + }); }); describe('delete', () => { diff --git a/x-pack/plugins/fleet/server/services/output.ts b/x-pack/plugins/fleet/server/services/output.ts index 45ad714eda9d3..5218cf2f46736 100644 --- a/x-pack/plugins/fleet/server/services/output.ts +++ b/x-pack/plugins/fleet/server/services/output.ts @@ -959,6 +959,15 @@ class OutputService { updateData.hosts = updateData.hosts.map(normalizeHostsForAgents); } + if ( + data.type === outputType.RemoteElasticsearch && + updateData.type === outputType.RemoteElasticsearch + ) { + if (!data.service_token) { + updateData.service_token = null; + } + } + if (!data.preset && data.type === outputType.Elasticsearch) { updateData.preset = getDefaultPresetForEsOutput(data.config_yaml ?? '', safeLoad); }