diff --git a/packages/kbn-check-mappings-update-cli/current_fields.json b/packages/kbn-check-mappings-update-cli/current_fields.json index 7f7c317f7f63a..3986d3be2063b 100644 --- a/packages/kbn-check-mappings-update-cli/current_fields.json +++ b/packages/kbn-check-mappings-update-cli/current_fields.json @@ -468,6 +468,7 @@ "ingest_manager_settings": [ "fleet_server_hosts", "has_seen_add_data_notice", + "output_secret_storage_requirements_met", "prerelease_integrations_enabled", "secret_storage_requirements_met" ], diff --git a/packages/kbn-check-mappings-update-cli/current_mappings.json b/packages/kbn-check-mappings-update-cli/current_mappings.json index fe4b3dba0940d..48aba0b659f0a 100644 --- a/packages/kbn-check-mappings-update-cli/current_mappings.json +++ b/packages/kbn-check-mappings-update-cli/current_mappings.json @@ -1564,6 +1564,9 @@ "prerelease_integrations_enabled": { "type": "boolean" }, + "output_secret_storage_requirements_met": { + "type": "boolean" + }, "secret_storage_requirements_met": { "type": "boolean" } diff --git a/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts b/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts index a2235b0f77812..9aa11d90479d0 100644 --- a/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts +++ b/src/core/server/integration_tests/ci_checks/saved_objects/check_registered_types.test.ts @@ -108,7 +108,7 @@ describe('checking migration metadata changes on all registered SO types', () => "ingest-download-sources": "279a68147e62e4d8858c09ad1cf03bd5551ce58d", "ingest-outputs": "e36a25e789f22b4494be728321f4304a040e286b", "ingest-package-policies": "f4c2767e852b700a8b82678925b86bac08958b43", - "ingest_manager_settings": "64955ef1b7a9ffa894d4bb9cf863b5602bfa6885", + "ingest_manager_settings": "91445219e7115ff0c45d1dabd5d614a80b421797", "inventory-view": "b8683c8e352a286b4aca1ab21003115a4800af83", "kql-telemetry": "93c1d16c1a0dfca9c8842062cf5ef8f62ae401ad", "legacy-url-alias": "9b8cca3fbb2da46fd12823d3cd38fdf1c9f24bc8", diff --git a/x-pack/plugins/fleet/common/constants/secrets.ts b/x-pack/plugins/fleet/common/constants/secrets.ts index 7626c36f5c902..13bc987bf72b3 100644 --- a/x-pack/plugins/fleet/common/constants/secrets.ts +++ b/x-pack/plugins/fleet/common/constants/secrets.ts @@ -8,3 +8,4 @@ export const SECRETS_ENDPOINT_PATH = '/_fleet/secret'; export const SECRETS_MINIMUM_FLEET_SERVER_VERSION = '8.10.0'; +export const OUTPUT_SECRETS_MINIMUM_FLEET_SERVER_VERSION = '8.12.0'; diff --git a/x-pack/plugins/fleet/common/types/models/settings.ts b/x-pack/plugins/fleet/common/types/models/settings.ts index e4175ae3bbfaf..bb44724d5c54e 100644 --- a/x-pack/plugins/fleet/common/types/models/settings.ts +++ b/x-pack/plugins/fleet/common/types/models/settings.ts @@ -15,4 +15,5 @@ export interface Settings extends BaseSettings { id: string; preconfigured_fields?: Array<'fleet_server_hosts'>; secret_storage_requirements_met?: boolean; + output_secret_storage_requirements_met?: boolean; } diff --git a/x-pack/plugins/fleet/cypress/e2e/fleet_settings_outputs.cy.ts b/x-pack/plugins/fleet/cypress/e2e/fleet_settings_outputs.cy.ts index 5101c4d93fd39..53e9461346106 100644 --- a/x-pack/plugins/fleet/cypress/e2e/fleet_settings_outputs.cy.ts +++ b/x-pack/plugins/fleet/cypress/e2e/fleet_settings_outputs.cy.ts @@ -174,8 +174,9 @@ describe('Outputs', () => { cy.wait('@saveOutput').then((interception) => { const responseBody = interception.response?.body; cy.visit(`/app/fleet/settings/outputs/${responseBody?.item?.id}`); - expect(responseBody?.item.service_token).to.equal(undefined); - expect(responseBody?.item.secrets.service_token.id).not.to.equal(undefined); + // There is no Fleet server, therefore the service token should have been saved in plain text. + expect(responseBody?.item.service_token).to.equal('service_token'); + expect(responseBody?.item.secrets).to.equal(undefined); }); cy.get('[placeholder="Specify host URL"').should('have.value', 'https://localhost:5000'); diff --git a/x-pack/plugins/fleet/server/constants/index.ts b/x-pack/plugins/fleet/server/constants/index.ts index 11dce1550cf1d..a1e56045a8b6a 100644 --- a/x-pack/plugins/fleet/server/constants/index.ts +++ b/x-pack/plugins/fleet/server/constants/index.ts @@ -81,6 +81,7 @@ export { // secrets SECRETS_ENDPOINT_PATH, SECRETS_MINIMUM_FLEET_SERVER_VERSION, + OUTPUT_SECRETS_MINIMUM_FLEET_SERVER_VERSION, // outputs OUTPUT_HEALTH_DATA_STREAM, type PrivilegeMapObject, diff --git a/x-pack/plugins/fleet/server/saved_objects/index.ts b/x-pack/plugins/fleet/server/saved_objects/index.ts index ff569adfd95b5..82ffa8e762a5c 100644 --- a/x-pack/plugins/fleet/server/saved_objects/index.ts +++ b/x-pack/plugins/fleet/server/saved_objects/index.ts @@ -81,6 +81,7 @@ import { migratePackagePolicyToV81102, migratePackagePolicyEvictionsFromV81102, } from './migrations/security_solution/to_v8_11_0_2'; +import { settingsV1 } from './model_versions/v1'; /* * Saved object types and mappings @@ -104,6 +105,7 @@ const getSavedObjectTypes = (): { [key: string]: SavedObjectsType } => ({ has_seen_add_data_notice: { type: 'boolean', index: false }, prerelease_integrations_enabled: { type: 'boolean' }, secret_storage_requirements_met: { type: 'boolean' }, + output_secret_storage_requirements_met: { type: 'boolean' }, }, }, migrations: { @@ -111,6 +113,9 @@ const getSavedObjectTypes = (): { [key: string]: SavedObjectsType } => ({ '7.13.0': migrateSettingsToV7130, '8.6.0': migrateSettingsToV860, }, + modelVersions: { + 1: settingsV1, + }, }, [AGENT_POLICY_SAVED_OBJECT_TYPE]: { name: AGENT_POLICY_SAVED_OBJECT_TYPE, diff --git a/x-pack/plugins/fleet/server/saved_objects/model_versions/v1.ts b/x-pack/plugins/fleet/server/saved_objects/model_versions/v1.ts new file mode 100644 index 0000000000000..585a01a534b6d --- /dev/null +++ b/x-pack/plugins/fleet/server/saved_objects/model_versions/v1.ts @@ -0,0 +1,19 @@ +/* + * 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 { SavedObjectsModelVersion } from '@kbn/core-saved-objects-server'; + +export const settingsV1: SavedObjectsModelVersion = { + changes: [ + { + type: 'mappings_addition', + addedMappings: { + output_secret_storage_requirements_met: { type: 'boolean' }, + }, + }, + ], +}; diff --git a/x-pack/plugins/fleet/server/services/output.test.ts b/x-pack/plugins/fleet/server/services/output.test.ts index 8c2fee196d8d8..48b8aaa808112 100644 --- a/x-pack/plugins/fleet/server/services/output.test.ts +++ b/x-pack/plugins/fleet/server/services/output.test.ts @@ -289,556 +289,599 @@ describe('Output Service', () => { }); describe('create', () => { - it('works with a predefined id', async () => { - const soClient = getMockedSoClient(); - - await outputService.create( - soClient, - esClientMock, - { - is_default: false, - is_default_monitoring: false, - name: 'Test', - type: 'elasticsearch', - }, - { id: 'output-test' } - ); - - expect(soClient.create).toBeCalled(); - - // ID should always be the same for a predefined id - expect(soClient.create.mock.calls[0][2]?.id).toEqual(outputIdToUuid('output-test')); - expect((soClient.create.mock.calls[0][1] as OutputSOAttributes).output_id).toEqual( - 'output-test' - ); - }); - - it('should create a new default output if none exists before', async () => { - const soClient = getMockedSoClient(); + describe('elasticsearch output', () => { + it('works with a predefined id', async () => { + const soClient = getMockedSoClient(); - await outputService.create( - soClient, - esClientMock, - { - is_default: true, - is_default_monitoring: false, - name: 'Test', - type: 'elasticsearch', - }, - { id: 'output-test' } - ); + await outputService.create( + soClient, + esClientMock, + { + is_default: false, + is_default_monitoring: false, + name: 'Test', + type: 'elasticsearch', + }, + { id: 'output-test' } + ); - expect(soClient.update).not.toBeCalled(); - }); + expect(soClient.create).toBeCalled(); - it('should update existing default output when creating a new default output', async () => { - const soClient = getMockedSoClient({ - defaultOutputId: 'existing-default-output', + // ID should always be the same for a predefined id + expect(soClient.create.mock.calls[0][2]?.id).toEqual(outputIdToUuid('output-test')); + expect((soClient.create.mock.calls[0][1] as OutputSOAttributes).output_id).toEqual( + 'output-test' + ); }); - await outputService.create( - soClient, - esClientMock, - { - is_default: true, - is_default_monitoring: false, - name: 'Test', - type: 'elasticsearch', - }, - { id: 'output-test' } - ); + it('should create a new default output if none exists before', async () => { + const soClient = getMockedSoClient(); - expect(soClient.update).toBeCalledTimes(1); - expect(soClient.update).toBeCalledWith( - expect.anything(), - outputIdToUuid('existing-default-output'), - { is_default: false } - ); - }); + await outputService.create( + soClient, + esClientMock, + { + is_default: true, + is_default_monitoring: false, + name: 'Test', + type: 'elasticsearch', + }, + { id: 'output-test' } + ); - it('should create a new default monitoring output if none exists before', async () => { - const soClient = getMockedSoClient(); + expect(soClient.update).not.toBeCalled(); + }); - await outputService.create( - soClient, - esClientMock, - { - is_default: false, - is_default_monitoring: true, - name: 'Test', - type: 'elasticsearch', - }, - { id: 'output-test' } - ); + it('should update existing default output when creating a new default output', async () => { + const soClient = getMockedSoClient({ + defaultOutputId: 'existing-default-output', + }); - expect(soClient.update).not.toBeCalled(); - }); + await outputService.create( + soClient, + esClientMock, + { + is_default: true, + is_default_monitoring: false, + name: 'Test', + type: 'elasticsearch', + }, + { id: 'output-test' } + ); - it('should update existing default monitoring output when creating a new default output', async () => { - const soClient = getMockedSoClient({ - defaultOutputMonitoringId: 'existing-default-monitoring-output', + expect(soClient.update).toBeCalledTimes(1); + expect(soClient.update).toBeCalledWith( + expect.anything(), + outputIdToUuid('existing-default-output'), + { is_default: false } + ); }); - await outputService.create( - soClient, - esClientMock, - { - is_default: true, - is_default_monitoring: true, - name: 'Test', - type: 'elasticsearch', - }, - { id: 'output-test' } - ); + it('should create a new default monitoring output if none exists before', async () => { + const soClient = getMockedSoClient(); - expect(soClient.update).toBeCalledTimes(1); - expect(soClient.update).toBeCalledWith( - expect.anything(), - outputIdToUuid('existing-default-monitoring-output'), - { is_default_monitoring: false } - ); - }); + await outputService.create( + soClient, + esClientMock, + { + is_default: false, + is_default_monitoring: true, + name: 'Test', + type: 'elasticsearch', + }, + { id: 'output-test' } + ); - // With preconfigured outputs - it('should throw when an existing preconfigured default output and creating a new default output outside of preconfiguration', async () => { - const soClient = getMockedSoClient({ - defaultOutputId: 'existing-preconfigured-default-output', + expect(soClient.update).not.toBeCalled(); }); - await expect( - outputService.create( + it('should update existing default monitoring output when creating a new default output', async () => { + const soClient = getMockedSoClient({ + defaultOutputMonitoringId: 'existing-default-monitoring-output', + }); + + await outputService.create( soClient, esClientMock, { is_default: true, - is_default_monitoring: false, + is_default_monitoring: true, name: 'Test', type: 'elasticsearch', }, { id: 'output-test' } - ) - ).rejects.toThrow( - `Preconfigured output existing-preconfigured-default-output is_default cannot be updated outside of kibana config file.` - ); - }); + ); - it('should update existing default preconfigured monitoring output when creating a new default output from preconfiguration', async () => { - const soClient = getMockedSoClient({ - defaultOutputId: 'existing-preconfigured-default-output', + expect(soClient.update).toBeCalledTimes(1); + expect(soClient.update).toBeCalledWith( + expect.anything(), + outputIdToUuid('existing-default-monitoring-output'), + { is_default_monitoring: false } + ); }); - await outputService.create( - soClient, - esClientMock, - { - is_default: true, - is_default_monitoring: true, - name: 'Test', - type: 'elasticsearch', - }, - { id: 'output-test', fromPreconfiguration: true } - ); - - expect(soClient.update).toBeCalledTimes(1); - expect(soClient.update).toBeCalledWith( - expect.anything(), - outputIdToUuid('existing-preconfigured-default-output'), - { is_default: false } - ); - }); - - // With logstash output - it('should throw if encryptedSavedObject is not configured', async () => { - const soClient = getMockedSoClient({}); + it('should call audit logger', async () => { + const soClient = getMockedSoClient(); - await expect( - outputService.create( + await outputService.create( soClient, esClientMock, { is_default: false, - is_default_monitoring: false, + is_default_monitoring: true, name: 'Test', - type: 'logstash', + type: 'elasticsearch', }, { id: 'output-test' } - ) - ).rejects.toThrow(`logstash output needs encrypted saved object api key to be set`); - }); + ); - it('should throw if encryptedSavedObject is not configured, kafka', async () => { - const soClient = getMockedSoClient({}); + expect(mockedAuditLoggingService.writeCustomSoAuditLog).toHaveBeenCalledWith({ + action: 'create', + id: outputIdToUuid('output-test'), + savedObjectType: OUTPUT_SAVED_OBJECT_TYPE, + }); + }); - await expect( - outputService.create( + it('should set preset: balanced by default when creating a new ES output', async () => { + const soClient = getMockedSoClient({}); + + await outputService.create( soClient, esClientMock, { is_default: false, is_default_monitoring: false, name: 'Test', - type: 'kafka', - topics: [{ topic: 'test' }], + type: 'elasticsearch', }, - { id: 'output-test' } - ) - ).rejects.toThrow(`kafka output needs encrypted saved object api key to be set`); - }); - - it('should work if encryptedSavedObject is configured', async () => { - const soClient = getMockedSoClient({}); - mockedAppContextService.getEncryptedSavedObjectsSetup.mockReturnValue({ - canEncrypt: true, - } as any); - await outputService.create( - soClient, - esClientMock, - { - is_default: false, - is_default_monitoring: false, - name: 'Test', - type: 'logstash', - }, - { id: 'output-test' } - ); - expect(soClient.create).toBeCalled(); - }); + { + id: 'output-1', + } + ); - it('Should update fleet server policies with data_output_id=default_output_id if a new default logstash output is created', async () => { - const soClient = getMockedSoClient({ - defaultOutputId: 'output-test', + expect(soClient.create).toBeCalledWith( + OUTPUT_SAVED_OBJECT_TYPE, + // Preset should be inferred as balanced if not provided + expect.objectContaining({ + preset: 'balanced', + }), + expect.anything() + ); }); - mockedAppContextService.getEncryptedSavedObjectsSetup.mockReturnValue({ - canEncrypt: true, - } as any); - mockedAgentPolicyService.list.mockResolvedValue( - mockedAgentPolicyWithFleetServerResolvedValue - ); - mockedAgentPolicyService.hasFleetServerIntegration.mockReturnValue(true); - await outputService.create( - soClient, - esClientMock, - { - is_default: true, - is_default_monitoring: false, - name: 'Test', - type: 'logstash', - }, - { id: 'output-1' } - ); + it('should set preset: custom when config_yaml contains a reserved key', async () => { + const soClient = getMockedSoClient({}); - expect(mockedAgentPolicyService.update).toBeCalledWith( - expect.anything(), - expect.anything(), - 'fleet_server_policy', - { data_output_id: 'output-test' }, - { force: false } - ); - }); + await outputService.create( + soClient, + esClientMock, + { + is_default: false, + is_default_monitoring: false, + name: 'Test', + type: 'elasticsearch', + config_yaml: ` + bulk_max_size: 1000 + `, + }, + { + id: 'output-1', + } + ); - it('should update synthetics policies with data_output_id=default_output_id if a new default logstash output is created', async () => { - const soClient = getMockedSoClient({ - defaultOutputId: 'output-test', + expect(soClient.create).toBeCalledWith( + OUTPUT_SAVED_OBJECT_TYPE, + expect.objectContaining({ + preset: 'custom', + }), + expect.anything() + ); }); - mockedAppContextService.getEncryptedSavedObjectsSetup.mockReturnValue({ - canEncrypt: true, - } as any); - mockedAgentPolicyService.list.mockResolvedValue(mockedAgentPolicyWithSyntheticsResolvedValue); - mockedAgentPolicyService.hasSyntheticsIntegration.mockReturnValue(true); - await outputService.create( - soClient, - esClientMock, - { - is_default: true, - is_default_monitoring: false, - name: 'Test', - type: 'logstash', - }, - { id: 'output-1' } - ); + it('should honor preset: custom in attributes', async () => { + const soClient = getMockedSoClient({}); - expect(mockedAgentPolicyService.update).toBeCalledWith( - expect.anything(), - expect.anything(), - 'synthetics_policy', - { data_output_id: 'output-test' }, - { force: false } - ); - }); + await outputService.create( + soClient, + esClientMock, + { + is_default: false, + is_default_monitoring: false, + name: 'Test', + type: 'elasticsearch', + config_yaml: ` + some_non_reserved_key: foo + `, + preset: 'custom', + }, + { + id: 'output-1', + } + ); - it('Should allow to create a new logstash output with no errors if is not set as default', async () => { - const soClient = getMockedSoClient({ - defaultOutputId: 'output-test', + expect(soClient.create).toBeCalledWith( + OUTPUT_SAVED_OBJECT_TYPE, + expect.objectContaining({ + preset: 'custom', + }), + expect.anything() + ); }); - mockedAppContextService.getEncryptedSavedObjectsSetup.mockReturnValue({ - canEncrypt: true, - } as any); - mockedAgentPolicyService.list.mockResolvedValue( - mockedAgentPolicyWithFleetServerResolvedValue - ); - mockedAgentPolicyService.hasFleetServerIntegration.mockReturnValue(true); - await outputService.create( - soClient, - esClientMock, - { - is_default: false, - is_default_monitoring: false, - name: 'Test', - type: 'logstash', - }, - { id: 'output-1' } - ); - }); - - it('should call audit logger', async () => { - const soClient = getMockedSoClient(); - - await outputService.create( - soClient, - esClientMock, - { - is_default: false, - is_default_monitoring: true, - name: 'Test', - type: 'elasticsearch', - }, - { id: 'output-test' } - ); + it('should throw an error when preset: balanced is provided but config_yaml contains a reserved key', async () => { + const soClient = getMockedSoClient({}); - expect(mockedAuditLoggingService.writeCustomSoAuditLog).toHaveBeenCalledWith({ - action: 'create', - id: outputIdToUuid('output-test'), - savedObjectType: OUTPUT_SAVED_OBJECT_TYPE, - }); - }); + expect( + outputService.create( + soClient, + esClientMock, + { + is_default: false, + is_default_monitoring: false, + name: 'Test', + type: 'elasticsearch', + config_yaml: ` + bulk_max_size: 1000 + `, + preset: 'balanced', + }, + { + id: 'output-1', + } + ) + ).rejects.toThrow( + `preset cannot be balanced when config_yaml contains one of ${RESERVED_CONFIG_YML_KEYS.join( + ', ' + )}` + ); + + expect(soClient.create).not.toBeCalled(); + }); + + // With preconfigured outputs + it('should throw when an existing preconfigured default output and creating a new default output outside of preconfiguration', async () => { + const soClient = getMockedSoClient({ + defaultOutputId: 'existing-preconfigured-default-output', + }); - // With kafka output - it('Should update fleet server policies with data_output_id=default_output_id if a new default kafka output is created', async () => { - const soClient = getMockedSoClient({ - defaultOutputId: 'output-test', + await expect( + outputService.create( + soClient, + esClientMock, + { + is_default: true, + is_default_monitoring: false, + name: 'Test', + type: 'elasticsearch', + }, + { id: 'output-test' } + ) + ).rejects.toThrow( + `Preconfigured output existing-preconfigured-default-output is_default cannot be updated outside of kibana config file.` + ); }); - mockedAppContextService.getEncryptedSavedObjectsSetup.mockReturnValue({ - canEncrypt: true, - } as any); - mockedAgentPolicyService.list.mockResolvedValue( - mockedAgentPolicyWithFleetServerResolvedValue - ); - mockedAgentPolicyService.hasFleetServerIntegration.mockReturnValue(true); - await outputService.create( - soClient, - esClientMock, - { - is_default: true, - is_default_monitoring: false, - name: 'Test', - type: 'kafka', - }, - { id: 'output-1' } - ); + it('should update existing default preconfigured monitoring output when creating a new default output from preconfiguration', async () => { + const soClient = getMockedSoClient({ + defaultOutputId: 'existing-preconfigured-default-output', + }); - expect(mockedAgentPolicyService.update).toBeCalledWith( - expect.anything(), - expect.anything(), - 'fleet_server_policy', - { data_output_id: 'output-test' }, - { force: false } - ); - }); + await outputService.create( + soClient, + esClientMock, + { + is_default: true, + is_default_monitoring: true, + name: 'Test', + type: 'elasticsearch', + }, + { id: 'output-test', fromPreconfiguration: true } + ); - it('Should update synthetics policies with data_output_id=default_output_id if a new default kafka output is created', async () => { - const soClient = getMockedSoClient({ - defaultOutputId: 'output-test', + expect(soClient.update).toBeCalledTimes(1); + expect(soClient.update).toBeCalledWith( + expect.anything(), + outputIdToUuid('existing-preconfigured-default-output'), + { is_default: false } + ); }); - mockedAppContextService.getEncryptedSavedObjectsSetup.mockReturnValue({ - canEncrypt: true, - } as any); - mockedAgentPolicyService.list.mockResolvedValue(mockedAgentPolicyWithSyntheticsResolvedValue); - mockedAgentPolicyService.hasSyntheticsIntegration.mockReturnValue(true); + }); - await outputService.create( - soClient, - esClientMock, - { - is_default: true, - is_default_monitoring: false, - name: 'Test', - type: 'kafka', - }, - { id: 'output-1' } - ); + describe('logstash output', () => { + it('should throw if encryptedSavedObject is not configured', async () => { + const soClient = getMockedSoClient({}); - expect(mockedAgentPolicyService.update).toBeCalledWith( - expect.anything(), - expect.anything(), - 'synthetics_policy', - { data_output_id: 'output-test' }, - { force: false } - ); - }); + await expect( + outputService.create( + soClient, + esClientMock, + { + is_default: false, + is_default_monitoring: false, + name: 'Test', + type: 'logstash', + }, + { id: 'output-test' } + ) + ).rejects.toThrow(`logstash output needs encrypted saved object api key to be set`); + }); - it('Should allow to create a new kafka output with no errors if is not set as default', async () => { - const soClient = getMockedSoClient({ - defaultOutputId: 'output-test', + it('should work if encryptedSavedObject is configured', async () => { + const soClient = getMockedSoClient({}); + mockedAppContextService.getEncryptedSavedObjectsSetup.mockReturnValue({ + canEncrypt: true, + } as any); + await outputService.create( + soClient, + esClientMock, + { + is_default: false, + is_default_monitoring: false, + name: 'Test', + type: 'logstash', + }, + { id: 'output-test' } + ); + expect(soClient.create).toBeCalled(); }); - mockedAppContextService.getEncryptedSavedObjectsSetup.mockReturnValue({ - canEncrypt: true, - } as any); - mockedAgentPolicyService.list.mockResolvedValue( - mockedAgentPolicyWithFleetServerResolvedValue - ); - mockedAgentPolicyService.hasFleetServerIntegration.mockReturnValue(true); - await outputService.create( - soClient, - esClientMock, - { - is_default: false, - is_default_monitoring: false, - name: 'Test', - type: 'kafka', - }, - { id: 'output-1' } - ); - }); + it('should update fleet server policies with data_output_id=default_output_id if a new default logstash output is created', async () => { + const soClient = getMockedSoClient({ + defaultOutputId: 'output-test', + }); + mockedAppContextService.getEncryptedSavedObjectsSetup.mockReturnValue({ + canEncrypt: true, + } as any); + mockedAgentPolicyService.list.mockResolvedValue( + mockedAgentPolicyWithFleetServerResolvedValue + ); + mockedAgentPolicyService.hasFleetServerIntegration.mockReturnValue(true); + + await outputService.create( + soClient, + esClientMock, + { + is_default: true, + is_default_monitoring: false, + name: 'Test', + type: 'logstash', + }, + { id: 'output-1' } + ); - it('should not throw when a remote es output is attempted to be created as default data output', async () => { - const soClient = getMockedSoClient({ - defaultOutputId: 'output-test', + expect(mockedAgentPolicyService.update).toBeCalledWith( + expect.anything(), + expect.anything(), + 'fleet_server_policy', + { data_output_id: 'output-test' }, + { force: false } + ); }); - expect( - outputService.create( + it('should update synthetics policies with data_output_id=default_output_id if a new default logstash output is created', async () => { + const soClient = getMockedSoClient({ + defaultOutputId: 'output-test', + }); + mockedAppContextService.getEncryptedSavedObjectsSetup.mockReturnValue({ + canEncrypt: true, + } as any); + mockedAgentPolicyService.list.mockResolvedValue( + mockedAgentPolicyWithSyntheticsResolvedValue + ); + mockedAgentPolicyService.hasSyntheticsIntegration.mockReturnValue(true); + + await outputService.create( soClient, esClientMock, { is_default: true, is_default_monitoring: false, name: 'Test', - type: 'remote_elasticsearch', + type: 'logstash', }, { id: 'output-1' } - ) - ).resolves.not.toThrow(); - }); + ); - it('should set preset: balanced by default when creating a new ES output', async () => { - const soClient = getMockedSoClient({}); + expect(mockedAgentPolicyService.update).toBeCalledWith( + expect.anything(), + expect.anything(), + 'synthetics_policy', + { data_output_id: 'output-test' }, + { force: false } + ); + }); - await outputService.create( - soClient, - esClientMock, - { - is_default: false, - is_default_monitoring: false, - name: 'Test', - type: 'elasticsearch', - }, - { - id: 'output-1', - } - ); + it('should allow to create a new logstash output with no errors if is not set as default', async () => { + const soClient = getMockedSoClient({ + defaultOutputId: 'output-test', + }); + mockedAppContextService.getEncryptedSavedObjectsSetup.mockReturnValue({ + canEncrypt: true, + } as any); + mockedAgentPolicyService.list.mockResolvedValue( + mockedAgentPolicyWithFleetServerResolvedValue + ); + mockedAgentPolicyService.hasFleetServerIntegration.mockReturnValue(true); + + await outputService.create( + soClient, + esClientMock, + { + is_default: false, + is_default_monitoring: false, + name: 'Test', + type: 'logstash', + }, + { id: 'output-1' } + ); + }); - expect(soClient.create).toBeCalledWith( - OUTPUT_SAVED_OBJECT_TYPE, - // Preset should be inferred as balanced if not provided - expect.objectContaining({ - preset: 'balanced', - }), - expect.anything() - ); + it('should store output secrets as plain text if disabled', async () => { + const soClient = getMockedSoClient({}); + mockedAppContextService.getEncryptedSavedObjectsSetup.mockReturnValue({ + canEncrypt: true, + } as any); + await outputService.create( + soClient, + esClientMock, + { + is_default: false, + is_default_monitoring: false, + name: 'Test', + type: 'logstash', + ssl: { + certificate: 'xxx', + }, + secrets: { + ssl: { + key: 'secretKey', + }, + }, + }, + { id: 'output-test' } + ); + expect(soClient.create).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + ssl: JSON.stringify({ certificate: 'xxx', key: 'secretKey' }), + }), + expect.anything() + ); + }); }); - it('should set preset: custom when config_yaml contains a reserved key', async () => { - const soClient = getMockedSoClient({}); + describe('kafka output', () => { + it('should throw if encryptedSavedObject is not configured', async () => { + const soClient = getMockedSoClient({}); - await outputService.create( - soClient, - esClientMock, - { - is_default: false, - is_default_monitoring: false, - name: 'Test', - type: 'elasticsearch', - config_yaml: ` - bulk_max_size: 1000 - `, - }, - { - id: 'output-1', - } - ); - - expect(soClient.create).toBeCalledWith( - OUTPUT_SAVED_OBJECT_TYPE, - expect.objectContaining({ - preset: 'custom', - }), - expect.anything() - ); - }); + await expect( + outputService.create( + soClient, + esClientMock, + { + is_default: false, + is_default_monitoring: false, + name: 'Test', + type: 'kafka', + topics: [{ topic: 'test' }], + }, + { id: 'output-test' } + ) + ).rejects.toThrow(`kafka output needs encrypted saved object api key to be set`); + }); - it('should honor preset: custom in attributes', async () => { - const soClient = getMockedSoClient({}); + it('should update fleet server policies with data_output_id=default_output_id if a new default kafka output is created', async () => { + const soClient = getMockedSoClient({ + defaultOutputId: 'output-test', + }); + mockedAppContextService.getEncryptedSavedObjectsSetup.mockReturnValue({ + canEncrypt: true, + } as any); + mockedAgentPolicyService.list.mockResolvedValue( + mockedAgentPolicyWithFleetServerResolvedValue + ); + mockedAgentPolicyService.hasFleetServerIntegration.mockReturnValue(true); + + await outputService.create( + soClient, + esClientMock, + { + is_default: true, + is_default_monitoring: false, + name: 'Test', + type: 'kafka', + }, + { id: 'output-1' } + ); - await outputService.create( - soClient, - esClientMock, - { - is_default: false, - is_default_monitoring: false, - name: 'Test', - type: 'elasticsearch', - config_yaml: ` - some_non_reserved_key: foo - `, - preset: 'custom', - }, - { - id: 'output-1', - } - ); + expect(mockedAgentPolicyService.update).toBeCalledWith( + expect.anything(), + expect.anything(), + 'fleet_server_policy', + { data_output_id: 'output-test' }, + { force: false } + ); + }); - expect(soClient.create).toBeCalledWith( - OUTPUT_SAVED_OBJECT_TYPE, - expect.objectContaining({ - preset: 'custom', - }), - expect.anything() - ); - }); + it('should update synthetics policies with data_output_id=default_output_id if a new default kafka output is created', async () => { + const soClient = getMockedSoClient({ + defaultOutputId: 'output-test', + }); + mockedAppContextService.getEncryptedSavedObjectsSetup.mockReturnValue({ + canEncrypt: true, + } as any); + mockedAgentPolicyService.list.mockResolvedValue( + mockedAgentPolicyWithSyntheticsResolvedValue + ); + mockedAgentPolicyService.hasSyntheticsIntegration.mockReturnValue(true); + + await outputService.create( + soClient, + esClientMock, + { + is_default: true, + is_default_monitoring: false, + name: 'Test', + type: 'kafka', + }, + { id: 'output-1' } + ); - it('should throw an error when preset: balanced is provided but config_yaml contains a reserved key', async () => { - const soClient = getMockedSoClient({}); + expect(mockedAgentPolicyService.update).toBeCalledWith( + expect.anything(), + expect.anything(), + 'synthetics_policy', + { data_output_id: 'output-test' }, + { force: false } + ); + }); - expect( - outputService.create( + it('should allow to create a new kafka output with no errors if is not set as default', async () => { + const soClient = getMockedSoClient({ + defaultOutputId: 'output-test', + }); + mockedAppContextService.getEncryptedSavedObjectsSetup.mockReturnValue({ + canEncrypt: true, + } as any); + mockedAgentPolicyService.list.mockResolvedValue( + mockedAgentPolicyWithFleetServerResolvedValue + ); + mockedAgentPolicyService.hasFleetServerIntegration.mockReturnValue(true); + + await outputService.create( soClient, esClientMock, { is_default: false, is_default_monitoring: false, name: 'Test', - type: 'elasticsearch', - config_yaml: ` - bulk_max_size: 1000 - `, - preset: 'balanced', + type: 'kafka', }, - { - id: 'output-1', - } - ) - ).rejects.toThrow( - `preset cannot be balanced when config_yaml contains one of ${RESERVED_CONFIG_YML_KEYS.join( - ', ' - )}` - ); + { id: 'output-1' } + ); + }); + }); + + describe('remote elasticsearch output', () => { + it('should not throw when a remote es output is attempted to be created as default data output', async () => { + const soClient = getMockedSoClient({ + defaultOutputId: 'output-test', + }); - expect(soClient.create).not.toBeCalled(); + expect( + outputService.create( + soClient, + esClientMock, + { + is_default: true, + is_default_monitoring: false, + name: 'Test', + type: 'remote_elasticsearch', + }, + { id: 'output-1' } + ) + ).resolves.not.toThrow(); + }); }); }); diff --git a/x-pack/plugins/fleet/server/services/output.ts b/x-pack/plugins/fleet/server/services/output.ts index a478122286d32..45ad714eda9d3 100644 --- a/x-pack/plugins/fleet/server/services/output.ts +++ b/x-pack/plugins/fleet/server/services/output.ts @@ -70,6 +70,7 @@ import { deleteSecrets, extractAndUpdateOutputSecrets, extractAndWriteOutputSecrets, + isOutputSecretStorageEnabled, } from './secrets'; type Nullable = { [P in keyof T]: T[P] | null }; @@ -121,12 +122,6 @@ function outputSavedObjectToOutput(so: SavedObject): Output }; } -function isOutputSecretsEnabled() { - const { outputSecretsStorage } = appContextService.getExperimentalFeatures(); - - return !!outputSecretsStorage; -} - async function getAgentPoliciesPerOutput( soClient: SavedObjectsClientContract, outputId?: string, @@ -577,7 +572,8 @@ class OutputService { const id = options?.id ? outputIdToUuid(options.id) : SavedObjectsUtils.generateId(); - if (isOutputSecretsEnabled()) { + // Store secret values if enabled; if not, store plain text values + if (await isOutputSecretStorageEnabled(esClient, soClient)) { const { output: outputWithSecrets } = await extractAndWriteOutputSecrets({ output, esClient, @@ -585,6 +581,26 @@ class OutputService { }); if (outputWithSecrets.secrets) data.secrets = outputWithSecrets.secrets; + } else { + if (output.type === outputType.Logstash && data.type === outputType.Logstash) { + if (!output.ssl?.key && output.secrets?.ssl?.key) { + data.ssl = JSON.stringify({ ...output.ssl, ...output.secrets.ssl }); + } + } else if (output.type === outputType.Kafka && data.type === outputType.Kafka) { + if (!output.password && output.secrets?.password) { + data.password = output.secrets?.password as string; + } + if (!output.ssl?.key && output.secrets?.ssl?.key) { + data.ssl = JSON.stringify({ ...output.ssl, ...output.secrets.ssl }); + } + } else if ( + output.type === outputType.RemoteElasticsearch && + data.type === outputType.RemoteElasticsearch + ) { + if (!output.service_token && output.secrets?.service_token) { + data.service_token = output.secrets?.service_token as string; + } + } } auditLoggingService.writeCustomSoAuditLog({ @@ -777,17 +793,7 @@ class OutputService { ); } } - if (isOutputSecretsEnabled()) { - const secretsRes = await extractAndUpdateOutputSecrets({ - oldOutput: originalOutput, - outputUpdate: data, - esClient, - secretHashes: data.is_preconfigured ? secretHashes : undefined, - }); - updateData.secrets = secretsRes.outputUpdate.secrets; - secretsToDelete = secretsRes.secretsToDelete; - } const mergedType = data.type ?? originalOutput.type; const defaultDataOutputId = await this.getDefaultDataOutputId(soClient); await validateTypeChanges( @@ -970,6 +976,39 @@ class OutputService { } } + // Store secret values if enabled; if not, store plain text values + if (await isOutputSecretStorageEnabled(esClient, soClient)) { + const secretsRes = await extractAndUpdateOutputSecrets({ + oldOutput: originalOutput, + outputUpdate: data, + esClient, + secretHashes: data.is_preconfigured ? secretHashes : undefined, + }); + + updateData.secrets = secretsRes.outputUpdate.secrets; + secretsToDelete = secretsRes.secretsToDelete; + } else { + if (data.type === outputType.Logstash && updateData.type === outputType.Logstash) { + if (!data.ssl?.key && data.secrets?.ssl?.key) { + updateData.ssl = JSON.stringify({ ...data.ssl, ...data.secrets.ssl }); + } + } else if (data.type === outputType.Kafka && updateData.type === outputType.Kafka) { + if (!data.password && data.secrets?.password) { + updateData.password = data.secrets?.password as string; + } + if (!data.ssl?.key && data.secrets?.ssl?.key) { + updateData.ssl = JSON.stringify({ ...data.ssl, ...data.secrets.ssl }); + } + } else if ( + data.type === outputType.RemoteElasticsearch && + updateData.type === outputType.RemoteElasticsearch + ) { + if (!data.service_token && data.secrets?.service_token) { + updateData.service_token = data.secrets?.service_token as string; + } + } + } + auditLoggingService.writeCustomSoAuditLog({ action: 'update', id: outputIdToUuid(id), diff --git a/x-pack/plugins/fleet/server/services/secrets.ts b/x-pack/plugins/fleet/server/services/secrets.ts index 6f50b2cd8b73b..692ad0b665e18 100644 --- a/x-pack/plugins/fleet/server/services/secrets.ts +++ b/x-pack/plugins/fleet/server/services/secrets.ts @@ -47,7 +47,11 @@ import type { } from '../types'; import { FleetError } from '../errors'; -import { SECRETS_ENDPOINT_PATH, SECRETS_MINIMUM_FLEET_SERVER_VERSION } from '../constants'; +import { + OUTPUT_SECRETS_MINIMUM_FLEET_SERVER_VERSION, + SECRETS_ENDPOINT_PATH, + SECRETS_MINIMUM_FLEET_SERVER_VERSION, +} from '../constants'; import { retryTransientEsErrors } from './epm/elasticsearch/retry'; @@ -642,6 +646,65 @@ export async function isSecretStorageEnabled( return false; } +export async function isOutputSecretStorageEnabled( + esClient: ElasticsearchClient, + soClient: SavedObjectsClientContract +): Promise { + const logger = appContextService.getLogger(); + + // first check if the feature flag is enabled, if not output secrets are disabled + const { outputSecretsStorage: outputSecretsStorageEnabled } = + appContextService.getExperimentalFeatures(); + if (!outputSecretsStorageEnabled) { + logger.debug('Output secrets storage is disabled by feature flag'); + return false; + } + + // if serverless then output secrets will always be supported + const isFleetServerStandalone = + appContextService.getConfig()?.internal?.fleetServerStandalone ?? false; + + if (isFleetServerStandalone) { + logger.trace('Output secrets storage is enabled as fleet server is standalone'); + return true; + } + + // now check the flag in settings to see if the fleet server requirement has already been met + // once the requirement has been met, output secrets are always on + const settings = await settingsService.getSettingsOrUndefined(soClient); + + if (settings && settings.output_secret_storage_requirements_met) { + logger.debug('Output secrets storage requirements already met, turned on in settings'); + return true; + } + + // otherwise check if we have the minimum fleet server version and enable secrets if so + if ( + await allFleetServerVersionsAreAtLeast( + esClient, + soClient, + OUTPUT_SECRETS_MINIMUM_FLEET_SERVER_VERSION + ) + ) { + logger.debug('Enabling output secrets storage as minimum fleet server version has been met'); + try { + await settingsService.saveSettings(soClient, { + output_secret_storage_requirements_met: true, + }); + } catch (err) { + // we can suppress this error as it will be retried on the next function call + logger.warn(`Failed to save settings after enabling output secrets storage: ${err.message}`); + } + + return true; + } + + logger.info( + 'Output secrets storage is disabled as minimum fleet server version has not been met' + ); + return false; +} + function _getPackageLevelSecretPaths( packagePolicy: NewPackagePolicy, packageInfo: PackageInfo diff --git a/x-pack/plugins/fleet/server/types/so_attributes.ts b/x-pack/plugins/fleet/server/types/so_attributes.ts index 311e5159b8f15..ea637d401bb12 100644 --- a/x-pack/plugins/fleet/server/types/so_attributes.ts +++ b/x-pack/plugins/fleet/server/types/so_attributes.ts @@ -228,6 +228,7 @@ export interface SettingsSOAttributes { has_seen_add_data_notice?: boolean; fleet_server_hosts?: string[]; secret_storage_requirements_met?: boolean; + output_secret_storage_requirements_met?: boolean; } export interface DownloadSourceSOAttributes { diff --git a/x-pack/test/fleet_api_integration/apis/outputs/crud.ts b/x-pack/test/fleet_api_integration/apis/outputs/crud.ts index 4c580351d8b5c..29b796689850a 100644 --- a/x-pack/test/fleet_api_integration/apis/outputs/crud.ts +++ b/x-pack/test/fleet_api_integration/apis/outputs/crud.ts @@ -60,6 +60,79 @@ export default function (providerContext: FtrProviderContext) { } }; + const enableOutputSecrets = async () => { + try { + await kibanaServer.savedObjects.update({ + type: GLOBAL_SETTINGS_SAVED_OBJECT_TYPE, + id: 'fleet-default-settings', + attributes: { + output_secret_storage_requirements_met: true, + }, + overwrite: false, + }); + } catch (e) { + throw e; + } + }; + + const disableOutputSecrets = async () => { + try { + await kibanaServer.savedObjects.update({ + type: GLOBAL_SETTINGS_SAVED_OBJECT_TYPE, + id: 'fleet-default-settings', + attributes: { + output_secret_storage_requirements_met: false, + }, + overwrite: false, + }); + } catch (e) { + throw e; + } + }; + + const createFleetServerAgent = async ( + agentPolicyId: string, + hostname: string, + agentVersion: string + ) => { + const agentResponse = await es.index({ + index: '.fleet-agents', + refresh: true, + body: { + access_api_key_id: 'api-key-3', + active: true, + policy_id: agentPolicyId, + type: 'PERMANENT', + local_metadata: { + host: { hostname }, + elastic: { agent: { version: agentVersion } }, + }, + user_provided_metadata: {}, + enrolled_at: '2022-06-21T12:14:25Z', + last_checkin: '2022-06-27T12:28:29Z', + tags: ['tag1'], + }, + }); + + return agentResponse._id; + }; + + const clearAgents = async () => { + try { + await es.deleteByQuery({ + index: '.fleet-agents', + refresh: true, + body: { + query: { + match_all: {}, + }, + }, + }); + } catch (err) { + // index doesn't exist + } + }; + describe('fleet_outputs_crud', async function () { skipIfNoDockerRegistry(providerContext); before(async () => { @@ -75,6 +148,7 @@ export default function (providerContext: FtrProviderContext) { before(async function () { await enableSecrets(); + await enableOutputSecrets(); // we must first force install the fleet_server package to override package verification error on policy create // https://github.com/elastic/kibana/issues/137450 const getPkRes = await supertest @@ -1305,6 +1379,90 @@ export default function (providerContext: FtrProviderContext) { // @ts-ignore _source unknown type expect(secret._source.value).to.equal('token'); }); + + it('should store secrets if fleet server meets minimum version', async function () { + await clearAgents(); + await createFleetServerAgent(fleetServerPolicyId, 'server_1', '8.12.0'); + + const res = await supertest + .post(`/api/fleet/outputs`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: 'Logstash Output', + type: 'logstash', + hosts: ['test.fr:443'], + ssl: { + certificate: 'CERTIFICATE', + certificate_authorities: ['CA1', 'CA2'], + }, + config_yaml: 'shipper: {}', + secrets: { ssl: { key: 'KEY' } }, + }) + .expect(200); + + expect(Object.keys(res.body.item)).to.contain('ssl'); + expect(Object.keys(res.body.item.ssl)).not.to.contain('key'); + expect(Object.keys(res.body.item)).to.contain('secrets'); + expect(Object.keys(res.body.item.secrets)).to.contain('ssl'); + expect(Object.keys(res.body.item.secrets.ssl)).to.contain('key'); + const secretId = res.body.item.secrets.ssl.key.id; + const secret = await getSecretById(secretId); + // @ts-ignore _source unknown type + expect(secret._source.value).to.equal('KEY'); + }); + + it('should not store secrets if fleet server does not meet minimum version', async function () { + await disableOutputSecrets(); + await clearAgents(); + await createFleetServerAgent(fleetServerPolicyId, 'server_1', '7.0.0'); + + const res = await supertest + .post(`/api/fleet/outputs`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: 'Logstash Output', + type: 'logstash', + hosts: ['test.fr:443'], + ssl: { + certificate: 'CERTIFICATE', + certificate_authorities: ['CA1', 'CA2'], + }, + config_yaml: 'shipper: {}', + secrets: { ssl: { key: 'KEY' } }, + }) + .expect(200); + + expect(Object.keys(res.body.item)).not.to.contain('secrets'); + expect(Object.keys(res.body.item)).to.contain('ssl'); + expect(Object.keys(res.body.item.ssl)).to.contain('key'); + expect(res.body.item.ssl.key).to.equal('KEY'); + }); + + it('should not store secrets if there is no fleet server', async function () { + await disableOutputSecrets(); + await clearAgents(); + + const res = await supertest + .post(`/api/fleet/outputs`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: 'Logstash Output', + type: 'logstash', + hosts: ['test.fr:443'], + ssl: { + certificate: 'CERTIFICATE', + certificate_authorities: ['CA1', 'CA2'], + }, + config_yaml: 'shipper: {}', + secrets: { ssl: { key: 'KEY' } }, + }) + .expect(200); + + expect(Object.keys(res.body.item)).not.to.contain('secrets'); + expect(Object.keys(res.body.item)).to.contain('ssl'); + expect(Object.keys(res.body.item.ssl)).to.contain('key'); + expect(res.body.item.ssl.key).to.equal('KEY'); + }); }); describe('DELETE /outputs/{outputId}', () => { @@ -1378,50 +1536,6 @@ export default function (providerContext: FtrProviderContext) { expect(deleteResponse.id).to.eql(outputId); }); - - it('should delete secrets when deleting an output', async function () { - const res = await supertest - .post(`/api/fleet/outputs`) - .set('kbn-xsrf', 'xxxx') - .send({ - name: 'Kafka Output With Secret', - type: 'kafka', - hosts: ['test.fr:2000'], - auth_type: 'ssl', - topics: [{ topic: 'topic1' }], - config_yaml: 'shipper: {}', - shipper: { - disk_queue_enabled: true, - disk_queue_path: 'path/to/disk/queue', - disk_queue_encryption_enabled: true, - }, - ssl: { - certificate: 'CERTIFICATE', - certificate_authorities: ['CA1', 'CA2'], - }, - secrets: { - ssl: { - key: 'KEY', - }, - }, - }) - .expect(200); - - const outputWithSecretsId = res.body.item.id; - const secretId = res.body.item.secrets.ssl.key.id; - - await supertest - .delete(`/api/fleet/outputs/${outputWithSecretsId}`) - .set('kbn-xsrf', 'xxxx') - .expect(200); - - try { - await getSecretById(secretId); - expect().fail('Secret should have been deleted'); - } catch (e) { - // not found - } - }); }); describe('Kafka output', () => { @@ -1470,6 +1584,53 @@ export default function (providerContext: FtrProviderContext) { expect(deleteResponse.id).to.eql(outputId); }); + + it('should delete secrets when deleting an output', async function () { + // Output secrets require at least one Fleet server on 8.12.0 or higher (and none under 8.12.0). + await clearAgents(); + await createFleetServerAgent(fleetServerPolicyId, 'server_1', '8.12.0'); + const res = await supertest + .post(`/api/fleet/outputs`) + .set('kbn-xsrf', 'xxxx') + .send({ + name: 'Kafka Output With Secret', + type: 'kafka', + hosts: ['test.fr:2000'], + auth_type: 'ssl', + topics: [{ topic: 'topic1' }], + config_yaml: 'shipper: {}', + shipper: { + disk_queue_enabled: true, + disk_queue_path: 'path/to/disk/queue', + disk_queue_encryption_enabled: true, + }, + ssl: { + certificate: 'CERTIFICATE', + certificate_authorities: ['CA1', 'CA2'], + }, + secrets: { + ssl: { + key: 'KEY', + }, + }, + }) + .expect(200); + + const outputWithSecretsId = res.body.item.id; + const secretId = res.body.item.secrets.ssl.key.id; + + await supertest + .delete(`/api/fleet/outputs/${outputWithSecretsId}`) + .set('kbn-xsrf', 'xxxx') + .expect(200); + + try { + await getSecretById(secretId); + expect().fail('Secret should have been deleted'); + } catch (e) { + // not found + } + }); }); }); }); diff --git a/x-pack/test/fleet_api_integration/apis/policy_secrets.ts b/x-pack/test/fleet_api_integration/apis/policy_secrets.ts index 6197738eda4e8..a492e91c00f04 100644 --- a/x-pack/test/fleet_api_integration/apis/policy_secrets.ts +++ b/x-pack/test/fleet_api_integration/apis/policy_secrets.ts @@ -461,6 +461,8 @@ export default function (providerContext: FtrProviderContext) { }); it('Should return output secrets if policy uses output with secrets', async () => { + // Output secrets require at least one Fleet server on 8.12.0 or higher (and none under 8.12.0). + await createFleetServerAgent(fleetServerAgentPolicyId, 'server_1', '8.12.0'); const outputWithSecret = await createOutputWithSecret(); const { body: agentPolicyResponse } = await supertest