diff --git a/x-pack/plugins/fleet/common/constants/output.ts b/x-pack/plugins/fleet/common/constants/output.ts index e41e3c526951e..318712d228859 100644 --- a/x-pack/plugins/fleet/common/constants/output.ts +++ b/x-pack/plugins/fleet/common/constants/output.ts @@ -23,3 +23,5 @@ export const DEFAULT_OUTPUT: NewOutput = { type: outputType.Elasticsearch, hosts: [''], }; + +export const LICENCE_FOR_PER_POLICY_OUTPUT = 'platinum'; diff --git a/x-pack/plugins/fleet/common/openapi/bundled.json b/x-pack/plugins/fleet/common/openapi/bundled.json index 46cd3e998ea7f..bb9c5f76ee9cd 100644 --- a/x-pack/plugins/fleet/common/openapi/bundled.json +++ b/x-pack/plugins/fleet/common/openapi/bundled.json @@ -3825,6 +3825,14 @@ "logs" ] } + }, + "data_output_id": { + "type": "string", + "nullable": true + }, + "monitoring_output_id": { + "type": "string", + "nullable": true } }, "required": [ @@ -3981,6 +3989,12 @@ "updated_by": { "type": "string" }, + "data_output_id": { + "type": "string" + }, + "monitoring_output_id": { + "type": "string" + }, "revision": { "type": "number" }, diff --git a/x-pack/plugins/fleet/common/openapi/bundled.yaml b/x-pack/plugins/fleet/common/openapi/bundled.yaml index ae8fdb3b87d4d..8d301cafbde09 100644 --- a/x-pack/plugins/fleet/common/openapi/bundled.yaml +++ b/x-pack/plugins/fleet/common/openapi/bundled.yaml @@ -2402,6 +2402,12 @@ components: enum: - metrics - logs + data_output_id: + type: string + nullable: true + monitoring_output_id: + type: string + nullable: true required: - name - namespace @@ -2501,6 +2507,10 @@ components: format: date-time updated_by: type: string + data_output_id: + type: string + monitoring_output_id: + type: string revision: type: number agents: diff --git a/x-pack/plugins/fleet/common/openapi/components/schemas/agent_policy.yaml b/x-pack/plugins/fleet/common/openapi/components/schemas/agent_policy.yaml index 7eed85eb2e3bc..c2cebb183ed86 100644 --- a/x-pack/plugins/fleet/common/openapi/components/schemas/agent_policy.yaml +++ b/x-pack/plugins/fleet/common/openapi/components/schemas/agent_policy.yaml @@ -22,6 +22,10 @@ allOf: format: date-time updated_by: type: string + data_output_id: + type: string + monitoring_output_id: + type: string revision: type: number agents: diff --git a/x-pack/plugins/fleet/common/openapi/components/schemas/new_agent_policy.yaml b/x-pack/plugins/fleet/common/openapi/components/schemas/new_agent_policy.yaml index 7b9e7f43c8ab0..7ad8988f1b0e4 100644 --- a/x-pack/plugins/fleet/common/openapi/components/schemas/new_agent_policy.yaml +++ b/x-pack/plugins/fleet/common/openapi/components/schemas/new_agent_policy.yaml @@ -16,6 +16,12 @@ properties: enum: - metrics - logs + data_output_id: + type: string + nullable: true + monitoring_output_id: + type: string + nullable: true required: - name - namespace \ No newline at end of file diff --git a/x-pack/plugins/fleet/common/services/license.ts b/x-pack/plugins/fleet/common/services/license.ts index d7e64f484474a..a5fdfb1e74149 100644 --- a/x-pack/plugins/fleet/common/services/license.ts +++ b/x-pack/plugins/fleet/common/services/license.ts @@ -40,18 +40,10 @@ export class LicenseService { } public isGoldPlus() { - return ( - this.licenseInformation?.isAvailable && - this.licenseInformation?.isActive && - this.licenseInformation?.hasAtLeast('gold') - ); + return this.hasAtLeast('gold'); } public isEnterprise() { - return ( - this.licenseInformation?.isAvailable && - this.licenseInformation?.isActive && - this.licenseInformation?.hasAtLeast('enterprise') - ); + return this.hasAtLeast('enterprise'); } public hasAtLeast(licenseType: LicenseType) { return ( diff --git a/x-pack/plugins/fleet/common/types/models/agent_policy.ts b/x-pack/plugins/fleet/common/types/models/agent_policy.ts index 6fbb423507c3b..4d87d10385617 100644 --- a/x-pack/plugins/fleet/common/types/models/agent_policy.ts +++ b/x-pack/plugins/fleet/common/types/models/agent_policy.ts @@ -25,8 +25,9 @@ export interface NewAgentPolicy { monitoring_enabled?: MonitoringType; unenroll_timeout?: number; is_preconfigured?: boolean; - data_output_id?: string; - monitoring_output_id?: string; + // Nullable to allow user to reset to default outputs + data_output_id?: string | null; + monitoring_output_id?: string | null; } export interface AgentPolicy extends Omit { diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_advanced_fields/hooks.test.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_advanced_fields/hooks.test.tsx new file mode 100644 index 0000000000000..88072b327d9f2 --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_advanced_fields/hooks.test.tsx @@ -0,0 +1,181 @@ +/* + * 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 { createFleetTestRendererMock } from '../../../../../../mock'; +import type { MockedFleetStartServices } from '../../../../../../mock'; +import { useLicense } from '../../../../../../hooks/use_license'; +import type { LicenseService } from '../../../../services'; + +import { useOutputOptions } from './hooks'; + +jest.mock('../../../../../../hooks/use_license'); + +const mockedUseLicence = useLicense as jest.MockedFunction; + +function defaultHttpClientGetImplementation(path: any) { + if (typeof path !== 'string') { + throw new Error('Invalid request'); + } + const err = new Error(`API [GET ${path}] is not MOCKED!`); + // eslint-disable-next-line no-console + console.log(err); + throw err; +} + +const mockApiCallsWithOutputs = (http: MockedFleetStartServices['http']) => { + http.get.mockImplementation(async (path) => { + if (typeof path !== 'string') { + throw new Error('Invalid request'); + } + if (path === '/api/fleet/outputs') { + return { + data: { + items: [ + { + id: 'output1', + name: 'Output 1', + is_default: true, + is_default_monitoring: true, + }, + { + id: 'output2', + name: 'Output 2', + is_default: true, + is_default_monitoring: true, + }, + { + id: 'output3', + name: 'Output 3', + is_default: true, + is_default_monitoring: true, + }, + ], + }, + }; + } + + return defaultHttpClientGetImplementation(path); + }); +}; + +describe('useOutputOptions', () => { + it('should generate enabled options if the licence is platinium', async () => { + const testRenderer = createFleetTestRendererMock(); + mockedUseLicence.mockReturnValue({ + hasAtLeast: () => true, + } as unknown as LicenseService); + mockApiCallsWithOutputs(testRenderer.startServices.http); + const { result, waitForNextUpdate } = testRenderer.renderHook(() => useOutputOptions()); + expect(result.current.isLoading).toBeTruthy(); + + await waitForNextUpdate(); + expect(result.current.dataOutputOptions).toMatchInlineSnapshot(` + Array [ + Object { + "inputDisplay": "Default (currently Output 1)", + "value": "@@##DEFAULT_OUTPUT_VALUE##@@", + }, + Object { + "disabled": false, + "inputDisplay": "Output 1", + "value": "output1", + }, + Object { + "disabled": false, + "inputDisplay": "Output 2", + "value": "output2", + }, + Object { + "disabled": false, + "inputDisplay": "Output 3", + "value": "output3", + }, + ] + `); + expect(result.current.monitoringOutputOptions).toMatchInlineSnapshot(` + Array [ + Object { + "inputDisplay": "Default (currently Output 1)", + "value": "@@##DEFAULT_OUTPUT_VALUE##@@", + }, + Object { + "disabled": false, + "inputDisplay": "Output 1", + "value": "output1", + }, + Object { + "disabled": false, + "inputDisplay": "Output 2", + "value": "output2", + }, + Object { + "disabled": false, + "inputDisplay": "Output 3", + "value": "output3", + }, + ] + `); + }); + + it('should only enable the default options if the licence is not platinium', async () => { + const testRenderer = createFleetTestRendererMock(); + mockedUseLicence.mockReturnValue({ + hasAtLeast: () => false, + } as unknown as LicenseService); + mockApiCallsWithOutputs(testRenderer.startServices.http); + const { result, waitForNextUpdate } = testRenderer.renderHook(() => useOutputOptions()); + expect(result.current.isLoading).toBeTruthy(); + + await waitForNextUpdate(); + expect(result.current.dataOutputOptions).toMatchInlineSnapshot(` + Array [ + Object { + "inputDisplay": "Default (currently Output 1)", + "value": "@@##DEFAULT_OUTPUT_VALUE##@@", + }, + Object { + "disabled": true, + "inputDisplay": "Output 1", + "value": "output1", + }, + Object { + "disabled": true, + "inputDisplay": "Output 2", + "value": "output2", + }, + Object { + "disabled": true, + "inputDisplay": "Output 3", + "value": "output3", + }, + ] + `); + expect(result.current.monitoringOutputOptions).toMatchInlineSnapshot(` + Array [ + Object { + "inputDisplay": "Default (currently Output 1)", + "value": "@@##DEFAULT_OUTPUT_VALUE##@@", + }, + Object { + "disabled": true, + "inputDisplay": "Output 1", + "value": "output1", + }, + Object { + "disabled": true, + "inputDisplay": "Output 2", + "value": "output2", + }, + Object { + "disabled": true, + "inputDisplay": "Output 3", + "value": "output3", + }, + ] + `); + }); +}); diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_advanced_fields/hooks.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_advanced_fields/hooks.tsx new file mode 100644 index 0000000000000..b092223879994 --- /dev/null +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_advanced_fields/hooks.tsx @@ -0,0 +1,74 @@ +/* + * 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 { useMemo } from 'react'; +import { i18n } from '@kbn/i18n'; +import type { EuiSuperSelectOption } from '@elastic/eui'; + +import { useGetOutputs, useLicense } from '../../../../hooks'; +import { LICENCE_FOR_PER_POLICY_OUTPUT } from '../../../../../../../common'; + +// The super select component do not support null or '' as a value +export const DEFAULT_OUTPUT_VALUE = '@@##DEFAULT_OUTPUT_VALUE##@@'; + +function getDefaultOutput(defaultOutputName?: string) { + return { + inputDisplay: i18n.translate('xpack.fleet.agentPolicy.outputOptions.defaultOutputText', { + defaultMessage: 'Default (currently {defaultOutputName})', + values: { defaultOutputName }, + }), + value: DEFAULT_OUTPUT_VALUE, + }; +} + +export function useOutputOptions() { + const outputsRequest = useGetOutputs(); + const licenseService = useLicense(); + + const isLicenceAllowingPolicyPerOutput = licenseService.hasAtLeast(LICENCE_FOR_PER_POLICY_OUTPUT); + + const outputOptions: Array> = useMemo(() => { + if (outputsRequest.isLoading || !outputsRequest.data) { + return []; + } + + return outputsRequest.data.items.map((item) => ({ + value: item.id, + inputDisplay: item.name, + disabled: !isLicenceAllowingPolicyPerOutput, + })); + }, [outputsRequest, isLicenceAllowingPolicyPerOutput]); + + const dataOutputOptions = useMemo(() => { + if (outputsRequest.isLoading || !outputsRequest.data) { + return []; + } + + const defaultOutputName = outputsRequest.data.items.find((item) => item.is_default)?.name; + return [getDefaultOutput(defaultOutputName), ...outputOptions]; + }, [outputsRequest, outputOptions]); + + const monitoringOutputOptions = useMemo(() => { + if (outputsRequest.isLoading || !outputsRequest.data) { + return []; + } + + const defaultOutputName = outputsRequest.data.items.find( + (item) => item.is_default_monitoring + )?.name; + return [getDefaultOutput(defaultOutputName), ...outputOptions]; + }, [outputsRequest, outputOptions]); + + return useMemo( + () => ({ + dataOutputOptions, + monitoringOutputOptions, + isLoading: outputsRequest.isLoading, + }), + [dataOutputOptions, monitoringOutputOptions, outputsRequest.isLoading] + ); +} diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_advanced_fields.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_advanced_fields/index.tsx similarity index 77% rename from x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_advanced_fields.tsx rename to x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_advanced_fields/index.tsx index d26dc83084a20..305008513d019 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_advanced_fields.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/agent_policy/components/agent_policy_advanced_fields/index.tsx @@ -17,20 +17,23 @@ import { EuiLink, EuiFieldNumber, EuiFieldText, + EuiSuperSelect, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; import { i18n } from '@kbn/i18n'; -import { dataTypes } from '../../../../../../common'; -import type { NewAgentPolicy, AgentPolicy } from '../../../types'; -import { useStartServices } from '../../../hooks'; +import { dataTypes } from '../../../../../../../common'; +import type { NewAgentPolicy, AgentPolicy } from '../../../../types'; +import { useStartServices } from '../../../../hooks'; -import { AgentPolicyPackageBadge } from '../../../components'; +import { AgentPolicyPackageBadge } from '../../../../components'; -import { policyHasFleetServer } from '../../agents/services/has_fleet_server'; +import { policyHasFleetServer } from '../../../agents/services/has_fleet_server'; -import { AgentPolicyDeleteProvider } from './agent_policy_delete_provider'; -import type { ValidationResults } from './agent_policy_validation'; +import { AgentPolicyDeleteProvider } from '../agent_policy_delete_provider'; +import type { ValidationResults } from '../agent_policy_validation'; + +import { useOutputOptions, DEFAULT_OUTPUT_VALUE } from './hooks'; interface Props { agentPolicy: Partial; @@ -49,6 +52,11 @@ export const AgentPolicyAdvancedOptionsContent: React.FunctionComponent = }) => { const { docLinks } = useStartServices(); const [touchedFields, setTouchedFields] = useState<{ [key: string]: boolean }>({}); + const { + dataOutputOptions, + monitoringOutputOptions, + isLoading: isLoadingOptions, + } = useOutputOptions(); // agent monitoring checkbox group can appear multiple times in the DOM, ids have to be unique to work correctly const monitoringCheckboxIdSuffix = Date.now(); @@ -275,6 +283,82 @@ export const AgentPolicyAdvancedOptionsContent: React.FunctionComponent = /> + + + + } + description={ + + } + > + + { + updateAgentPolicy({ + data_output_id: e !== DEFAULT_OUTPUT_VALUE ? e : null, + }); + }} + options={dataOutputOptions} + /> + + + + + + } + description={ + + } + > + + { + updateAgentPolicy({ + monitoring_output_id: e !== DEFAULT_OUTPUT_VALUE ? e : null, + }); + }} + options={monitoringOutputOptions} + /> + + {isEditing && 'id' in agentPolicy && !agentPolicy.is_managed ? ( ( const submitUpdateAgentPolicy = async () => { setIsLoading(true); try { - // eslint-disable-next-line @typescript-eslint/naming-convention - const { name, description, namespace, monitoring_enabled, unenroll_timeout } = agentPolicy; + const { + name, + description, + namespace, + // eslint-disable-next-line @typescript-eslint/naming-convention + monitoring_enabled, + // eslint-disable-next-line @typescript-eslint/naming-convention + unenroll_timeout, + // eslint-disable-next-line @typescript-eslint/naming-convention + data_output_id, + // eslint-disable-next-line @typescript-eslint/naming-convention + monitoring_output_id, + } = agentPolicy; const { data, error } = await sendUpdateAgentPolicy(agentPolicy.id, { name, description, namespace, monitoring_enabled, unenroll_timeout, + data_output_id, + monitoring_output_id, }); if (data) { notifications.toasts.addSuccess( diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/settings_page/output_section.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/settings_page/output_section.tsx index 835a3576da77b..1da2bacf9068d 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/settings_page/output_section.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/components/settings_page/output_section.tsx @@ -12,7 +12,6 @@ import { FormattedMessage } from '@kbn/i18n-react'; import { useLink } from '../../../../hooks'; import type { Output } from '../../../../types'; import { OutputsTable } from '../outputs_table'; -import { FEATURE_ADD_OUTPUT_ENABLED } from '../../constants'; export interface OutputSectionProps { outputs: Output[]; @@ -42,14 +41,12 @@ export const OutputSection: React.FunctionComponent = ({ - {FEATURE_ADD_OUTPUT_ENABLED && ( - - - - )} + + + ); }; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/settings/constants/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/constants/index.tsx index b609c4c25308f..8d29433e7232b 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/settings/constants/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/constants/index.tsx @@ -6,5 +6,3 @@ */ export const FLYOUT_MAX_WIDTH = 670; - -export const FEATURE_ADD_OUTPUT_ENABLED = false; diff --git a/x-pack/plugins/fleet/public/applications/fleet/sections/settings/index.tsx b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/index.tsx index 5a393ee74ea7b..c586e88261940 100644 --- a/x-pack/plugins/fleet/public/applications/fleet/sections/settings/index.tsx +++ b/x-pack/plugins/fleet/public/applications/fleet/sections/settings/index.tsx @@ -19,7 +19,6 @@ import { withConfirmModalProvider } from './hooks/use_confirm_modal'; import { FleetServerHostsFlyout } from './components/fleet_server_hosts_flyout'; import { EditOutputFlyout } from './components/edit_output_flyout'; import { useDeleteOutput } from './hooks/use_delete_output'; -import { FEATURE_ADD_OUTPUT_ENABLED } from './constants'; export const SettingsApp = withConfirmModalProvider(() => { useBreadcrumbs('settings'); @@ -64,13 +63,11 @@ export const SettingsApp = withConfirmModalProvider(() => { /> - {FEATURE_ADD_OUTPUT_ENABLED && ( - - - - - - )} + + + + + {(route: { match: { params: { outputId: string } } }) => { const output = outputs.data?.items.find((o) => route.match.params.outputId === o.id); diff --git a/x-pack/plugins/fleet/server/services/agent_policies/index.ts b/x-pack/plugins/fleet/server/services/agent_policies/index.ts index b793ed26a08b5..2e1fffdec1147 100644 --- a/x-pack/plugins/fleet/server/services/agent_policies/index.ts +++ b/x-pack/plugins/fleet/server/services/agent_policies/index.ts @@ -6,3 +6,4 @@ */ export { getFullAgentPolicy } from './full_agent_policy'; +export { validateOutputForPolicy } from './validate_outputs_for_policy'; diff --git a/x-pack/plugins/fleet/server/services/agent_policies/validate_outputs_for_policy.test.ts b/x-pack/plugins/fleet/server/services/agent_policies/validate_outputs_for_policy.test.ts new file mode 100644 index 0000000000000..ba5bc4a3aeeb2 --- /dev/null +++ b/x-pack/plugins/fleet/server/services/agent_policies/validate_outputs_for_policy.test.ts @@ -0,0 +1,182 @@ +/* + * 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 { appContextService } from '..'; + +import { validateOutputForPolicy } from '.'; + +jest.mock('../app_context'); + +const mockedAppContextService = appContextService as jest.Mocked; + +function mockHasLicence(res: boolean) { + mockedAppContextService.getSecurityLicense.mockReturnValue({ + hasAtLeast: () => res, + } as any); +} + +describe('validateOutputForPolicy', () => { + describe('Without oldData (create)', () => { + it('should allow default outputs without platinum licence', async () => { + mockHasLicence(false); + await validateOutputForPolicy({ + data_output_id: null, + monitoring_output_id: null, + }); + }); + + it('should allow default outputs with platinum licence', async () => { + mockHasLicence(false); + await validateOutputForPolicy({ + data_output_id: null, + monitoring_output_id: null, + }); + }); + + it('should not allow custom data outputs without platinum licence', async () => { + mockHasLicence(false); + const res = validateOutputForPolicy({ + data_output_id: 'test1', + monitoring_output_id: null, + }); + await expect(res).rejects.toThrow( + 'Invalid licence to set per policy output, you need platinum licence' + ); + }); + + it('should not allow custom monitoring outputs without platinum licence', async () => { + mockHasLicence(false); + const res = validateOutputForPolicy({ + data_output_id: null, + monitoring_output_id: 'test1', + }); + await expect(res).rejects.toThrow( + 'Invalid licence to set per policy output, you need platinum licence' + ); + }); + + it('should allow custom data output with platinum licence', async () => { + mockHasLicence(true); + await validateOutputForPolicy({ + data_output_id: 'test1', + monitoring_output_id: null, + }); + }); + + it('should allow custom monitoring output with platinum licence', async () => { + mockHasLicence(true); + await validateOutputForPolicy({ + data_output_id: null, + monitoring_output_id: 'test1', + }); + }); + + it('should allow custom outputs for managed preconfigured policy without licence', async () => { + mockHasLicence(false); + await validateOutputForPolicy({ + is_managed: true, + is_preconfigured: true, + data_output_id: 'test1', + monitoring_output_id: 'test1', + }); + }); + }); + + describe('With oldData (update)', () => { + it('should allow default outputs without platinum licence', async () => { + mockHasLicence(false); + await validateOutputForPolicy( + { + data_output_id: null, + monitoring_output_id: null, + }, + { + data_output_id: 'test1', + monitoring_output_id: 'test1', + } + ); + }); + + it('should not allow custom data outputs without platinum licence', async () => { + mockHasLicence(false); + const res = validateOutputForPolicy( + { + data_output_id: 'test1', + monitoring_output_id: null, + }, + { + data_output_id: null, + monitoring_output_id: null, + } + ); + await expect(res).rejects.toThrow( + 'Invalid licence to set per policy output, you need platinum licence' + ); + }); + + it('should not allow custom monitoring outputs without platinum licence', async () => { + mockHasLicence(false); + const res = validateOutputForPolicy( + { + data_output_id: null, + monitoring_output_id: 'test1', + }, + { + data_output_id: null, + monitoring_output_id: null, + } + ); + await expect(res).rejects.toThrow( + 'Invalid licence to set per policy output, you need platinum licence' + ); + }); + + it('should allow custom data output with platinum licence', async () => { + mockHasLicence(true); + await validateOutputForPolicy( + { + data_output_id: 'test1', + monitoring_output_id: null, + }, + { + data_output_id: 'test1', + monitoring_output_id: null, + } + ); + }); + + it('should allow custom monitoring output with platinum licence', async () => { + mockHasLicence(true); + await validateOutputForPolicy({ + data_output_id: null, + monitoring_output_id: 'test1', + }); + }); + + it('should allow custom outputs for managed preconfigured policy without licence', async () => { + mockHasLicence(false); + await validateOutputForPolicy( + { + data_output_id: 'test1', + monitoring_output_id: 'test1', + }, + { is_managed: true, is_preconfigured: true } + ); + }); + + it('should allow custom outputs if they did not change without licence', async () => { + mockHasLicence(false); + await validateOutputForPolicy( + { + data_output_id: 'test1', + monitoring_output_id: 'test1', + }, + { data_output_id: 'test1', monitoring_output_id: 'test1' } + ); + }); + }); +}); diff --git a/x-pack/plugins/fleet/server/services/agent_policies/validate_outputs_for_policy.ts b/x-pack/plugins/fleet/server/services/agent_policies/validate_outputs_for_policy.ts new file mode 100644 index 0000000000000..272e1cd6c5b52 --- /dev/null +++ b/x-pack/plugins/fleet/server/services/agent_policies/validate_outputs_for_policy.ts @@ -0,0 +1,48 @@ +/* + * 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 { AgentPolicySOAttributes } from '../../types'; +import { LICENCE_FOR_PER_POLICY_OUTPUT } from '../../../common'; +import { appContextService } from '..'; + +/** + * Validate outputs are valid for a policy using the current kibana licence or throw. + * @param data + * @returns + */ +export async function validateOutputForPolicy( + newData: Partial, + oldData: Partial = {} +) { + if ( + newData.data_output_id === oldData.data_output_id && + newData.monitoring_output_id === oldData.monitoring_output_id + ) { + return; + } + + const data = { ...oldData, ...newData }; + + if (!data.data_output_id && !data.monitoring_output_id) { + return; + } + + // Do not validate licence output for managed and preconfigured policy + if (data.is_managed && data.is_preconfigured) { + return; + } + + const hasLicence = appContextService + .getSecurityLicense() + .hasAtLeast(LICENCE_FOR_PER_POLICY_OUTPUT); + + if (!hasLicence) { + throw new Error( + `Invalid licence to set per policy output, you need ${LICENCE_FOR_PER_POLICY_OUTPUT} licence` + ); + } +} diff --git a/x-pack/plugins/fleet/server/services/agent_policy.ts b/x-pack/plugins/fleet/server/services/agent_policy.ts index 50586badbe0c8..1784ff190385d 100644 --- a/x-pack/plugins/fleet/server/services/agent_policy.ts +++ b/x-pack/plugins/fleet/server/services/agent_policy.ts @@ -63,6 +63,7 @@ import { agentPolicyUpdateEventHandler } from './agent_policy_update'; import { normalizeKuery, escapeSearchQueryPhrase } from './saved_object'; import { appContextService } from './app_context'; import { getFullAgentPolicy } from './agent_policies'; +import { validateOutputForPolicy } from './agent_policies'; const SAVED_OBJECT_TYPE = AGENT_POLICY_SAVED_OBJECT_TYPE; @@ -99,6 +100,8 @@ class AgentPolicyService { ); } + await validateOutputForPolicy(agentPolicy); + await soClient.update(SAVED_OBJECT_TYPE, id, { ...agentPolicy, ...(options.bumpRevision ? { revision: oldAgentPolicy.revision + 1 } : {}), @@ -169,6 +172,8 @@ class AgentPolicyService { ): Promise { await this.requireUniqueName(soClient, agentPolicy); + await validateOutputForPolicy(agentPolicy); + const newSo = await soClient.create( SAVED_OBJECT_TYPE, { diff --git a/x-pack/plugins/fleet/server/types/models/agent_policy.ts b/x-pack/plugins/fleet/server/types/models/agent_policy.ts index d15d73fca7332..38d4b88722743 100644 --- a/x-pack/plugins/fleet/server/types/models/agent_policy.ts +++ b/x-pack/plugins/fleet/server/types/models/agent_policy.ts @@ -32,6 +32,8 @@ export const AgentPolicyBaseSchema = { schema.oneOf([schema.literal(dataTypes.Logs), schema.literal(dataTypes.Metrics)]) ) ), + data_output_id: schema.maybe(schema.nullable(schema.string())), + monitoring_output_id: schema.maybe(schema.nullable(schema.string())), }; export const NewAgentPolicySchema = schema.object({