From 2d84e8b83c78d2286051f7981e75b1fa4935e347 Mon Sep 17 00:00:00 2001 From: Cristina Amico Date: Thu, 29 May 2025 10:16:47 +0200 Subject: [PATCH] [Fleet] Add license gate around remote synced integrations (#221636) Closes https://github.com/elastic/kibana/issues/219488 ## Summary Add license gate around remote synced integrations - Only accounts having Enterprise license will be able to use the new remote synced integrations feature. This requirement depends on the ccr feature, which works only on Enterprise licenses, this PR makes sure that this also checked in Fleet. ### Testing 1. With a license different than enterprise - Try to create an output that enables synced integrations - it should fail with a 400: ``` POST kbn:/api/fleet/outputs { "name": "new 1", "type": "remote_elasticsearch", "hosts": [ "https://lskfgjojg" ], "is_default": false, "is_default_monitoring": false, "sync_integrations": true } ``` - Verify that the UI elements listed in https://github.com/elastic/kibana/issues/219488 are not visible 2. With Enterprise license - Check that the UI elements are visible and the remote sync integrations features work as expected ### Checklist - [ ] [Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html) was added for features that require explanation or tutorials - [ ] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios - [ ] This was checked for breaking HTTP API changes, and any breaking changes have been approved by the breaking-change committee. The `release_note:breaking` label should be applied in these situations. - [ ] [Flaky Test Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was used on any tests changed - [ ] The PR description includes the appropriate Release Notes section, and the correct `release_note:*` label is applied per the [guidelines](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process) --------- Co-authored-by: Elastic Machine Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- oas_docs/bundle.json | 336 -------- oas_docs/bundle.serverless.json | 336 -------- oas_docs/output/kibana.serverless.yaml | 228 ----- oas_docs/output/kibana.yaml | 228 ----- .../edit_output_flyout/index.test.tsx | 44 +- .../output_form_remote_es.tsx | 6 +- .../components/outputs_table/index.tsx | 7 +- .../remote_synced_integrations/index.ts | 120 +-- .../shared/fleet/server/services/output.ts | 16 + .../setup/fleet_synced_integrations.test.ts | 155 ++-- .../setup/fleet_synced_integrations.ts | 12 +- .../compare_synced_integrations.test.ts | 14 + .../compare_synced_integrations.ts | 5 +- .../get_remote_status.test.ts | 14 + .../sync_integrations/get_remote_status.ts | 4 +- .../sync_integrations_task.test.ts | 794 +++++++++++------- .../sync_integrations_task.ts | 11 +- .../apis/outputs/crud.ts | 13 +- 18 files changed, 759 insertions(+), 1584 deletions(-) diff --git a/oas_docs/bundle.json b/oas_docs/bundle.json index 04f9e908bc70..fe29203663ca 100644 --- a/oas_docs/bundle.json +++ b/oas_docs/bundle.json @@ -44288,339 +44288,6 @@ ] } }, - "/api/fleet/remote_synced_integrations/status": { - "get": { - "description": "[Required authorization] Route required privileges: fleet-settings-read AND integrations-read.", - "operationId": "get-fleet-remote-synced-integrations-status", - "parameters": [], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "additionalProperties": false, - "properties": { - "custom_assets": { - "additionalProperties": { - "additionalProperties": false, - "properties": { - "error": { - "type": "string" - }, - "is_deleted": { - "type": "boolean" - }, - "name": { - "type": "string" - }, - "package_name": { - "type": "string" - }, - "package_version": { - "type": "string" - }, - "sync_status": { - "enum": [ - "completed", - "synchronizing", - "failed", - "warning" - ], - "type": "string" - }, - "type": { - "type": "string" - } - }, - "required": [ - "type", - "name", - "package_name", - "package_version", - "sync_status" - ], - "type": "object" - }, - "type": "object" - }, - "error": { - "type": "string" - }, - "integrations": { - "items": { - "additionalProperties": false, - "properties": { - "error": { - "type": "string" - }, - "id": { - "type": "string" - }, - "install_status": { - "additionalProperties": false, - "properties": { - "main": { - "type": "string" - }, - "remote": { - "type": "string" - } - }, - "required": [ - "main" - ], - "type": "object" - }, - "package_name": { - "type": "string" - }, - "package_version": { - "type": "string" - }, - "sync_status": { - "enum": [ - "completed", - "synchronizing", - "failed", - "warning" - ], - "type": "string" - }, - "updated_at": { - "type": "string" - }, - "warning": { - "type": "string" - } - }, - "required": [ - "sync_status", - "install_status" - ], - "type": "object" - }, - "type": "array" - }, - "warning": { - "type": "string" - } - }, - "required": [ - "integrations" - ], - "type": "object" - } - } - } - }, - "400": { - "content": { - "application/json": { - "schema": { - "additionalProperties": false, - "description": "Generic Error", - "properties": { - "attributes": {}, - "error": { - "type": "string" - }, - "errorType": { - "type": "string" - }, - "message": { - "type": "string" - }, - "statusCode": { - "type": "number" - } - }, - "required": [ - "message", - "attributes" - ], - "type": "object" - } - } - } - } - }, - "summary": "Get CCR Remote synced integrations status", - "tags": [ - "CCR Remote synced integrations" - ] - } - }, - "/api/fleet/remote_synced_integrations/{outputId}/remote_status": { - "get": { - "description": "[Required authorization] Route required privileges: fleet-settings-read AND integrations-read.", - "operationId": "get-fleet-remote-synced-integrations-outputid-remote-status", - "parameters": [ - { - "in": "path", - "name": "outputId", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "additionalProperties": false, - "properties": { - "custom_assets": { - "additionalProperties": { - "additionalProperties": false, - "properties": { - "error": { - "type": "string" - }, - "is_deleted": { - "type": "boolean" - }, - "name": { - "type": "string" - }, - "package_name": { - "type": "string" - }, - "package_version": { - "type": "string" - }, - "sync_status": { - "enum": [ - "completed", - "synchronizing", - "failed", - "warning" - ], - "type": "string" - }, - "type": { - "type": "string" - } - }, - "required": [ - "type", - "name", - "package_name", - "package_version", - "sync_status" - ], - "type": "object" - }, - "type": "object" - }, - "error": { - "type": "string" - }, - "integrations": { - "items": { - "additionalProperties": false, - "properties": { - "error": { - "type": "string" - }, - "id": { - "type": "string" - }, - "install_status": { - "additionalProperties": false, - "properties": { - "main": { - "type": "string" - }, - "remote": { - "type": "string" - } - }, - "required": [ - "main" - ], - "type": "object" - }, - "package_name": { - "type": "string" - }, - "package_version": { - "type": "string" - }, - "sync_status": { - "enum": [ - "completed", - "synchronizing", - "failed", - "warning" - ], - "type": "string" - }, - "updated_at": { - "type": "string" - }, - "warning": { - "type": "string" - } - }, - "required": [ - "sync_status", - "install_status" - ], - "type": "object" - }, - "type": "array" - }, - "warning": { - "type": "string" - } - }, - "required": [ - "integrations" - ], - "type": "object" - } - } - } - }, - "400": { - "content": { - "application/json": { - "schema": { - "additionalProperties": false, - "description": "Generic Error", - "properties": { - "attributes": {}, - "error": { - "type": "string" - }, - "errorType": { - "type": "string" - }, - "message": { - "type": "string" - }, - "statusCode": { - "type": "number" - } - }, - "required": [ - "message", - "attributes" - ], - "type": "object" - } - } - } - } - }, - "summary": "Get CCR Remote synced integrations status by outputId", - "tags": [ - "CCR Remote synced integrations" - ] - } - }, "/api/fleet/service_tokens": { "post": { "description": "[Required authorization] Route required privileges: fleet-agents-all.", @@ -58703,9 +58370,6 @@ { "name": "alerting" }, - { - "name": "CCR Remote synced integrations" - }, { "name": "connectors" }, diff --git a/oas_docs/bundle.serverless.json b/oas_docs/bundle.serverless.json index 3ad4d2da3600..de956f9e37af 100644 --- a/oas_docs/bundle.serverless.json +++ b/oas_docs/bundle.serverless.json @@ -44288,339 +44288,6 @@ ] } }, - "/api/fleet/remote_synced_integrations/status": { - "get": { - "description": "[Required authorization] Route required privileges: fleet-settings-read AND integrations-read.", - "operationId": "get-fleet-remote-synced-integrations-status", - "parameters": [], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "additionalProperties": false, - "properties": { - "custom_assets": { - "additionalProperties": { - "additionalProperties": false, - "properties": { - "error": { - "type": "string" - }, - "is_deleted": { - "type": "boolean" - }, - "name": { - "type": "string" - }, - "package_name": { - "type": "string" - }, - "package_version": { - "type": "string" - }, - "sync_status": { - "enum": [ - "completed", - "synchronizing", - "failed", - "warning" - ], - "type": "string" - }, - "type": { - "type": "string" - } - }, - "required": [ - "type", - "name", - "package_name", - "package_version", - "sync_status" - ], - "type": "object" - }, - "type": "object" - }, - "error": { - "type": "string" - }, - "integrations": { - "items": { - "additionalProperties": false, - "properties": { - "error": { - "type": "string" - }, - "id": { - "type": "string" - }, - "install_status": { - "additionalProperties": false, - "properties": { - "main": { - "type": "string" - }, - "remote": { - "type": "string" - } - }, - "required": [ - "main" - ], - "type": "object" - }, - "package_name": { - "type": "string" - }, - "package_version": { - "type": "string" - }, - "sync_status": { - "enum": [ - "completed", - "synchronizing", - "failed", - "warning" - ], - "type": "string" - }, - "updated_at": { - "type": "string" - }, - "warning": { - "type": "string" - } - }, - "required": [ - "sync_status", - "install_status" - ], - "type": "object" - }, - "type": "array" - }, - "warning": { - "type": "string" - } - }, - "required": [ - "integrations" - ], - "type": "object" - } - } - } - }, - "400": { - "content": { - "application/json": { - "schema": { - "additionalProperties": false, - "description": "Generic Error", - "properties": { - "attributes": {}, - "error": { - "type": "string" - }, - "errorType": { - "type": "string" - }, - "message": { - "type": "string" - }, - "statusCode": { - "type": "number" - } - }, - "required": [ - "message", - "attributes" - ], - "type": "object" - } - } - } - } - }, - "summary": "Get CCR Remote synced integrations status", - "tags": [ - "CCR Remote synced integrations" - ] - } - }, - "/api/fleet/remote_synced_integrations/{outputId}/remote_status": { - "get": { - "description": "[Required authorization] Route required privileges: fleet-settings-read AND integrations-read.", - "operationId": "get-fleet-remote-synced-integrations-outputid-remote-status", - "parameters": [ - { - "in": "path", - "name": "outputId", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "additionalProperties": false, - "properties": { - "custom_assets": { - "additionalProperties": { - "additionalProperties": false, - "properties": { - "error": { - "type": "string" - }, - "is_deleted": { - "type": "boolean" - }, - "name": { - "type": "string" - }, - "package_name": { - "type": "string" - }, - "package_version": { - "type": "string" - }, - "sync_status": { - "enum": [ - "completed", - "synchronizing", - "failed", - "warning" - ], - "type": "string" - }, - "type": { - "type": "string" - } - }, - "required": [ - "type", - "name", - "package_name", - "package_version", - "sync_status" - ], - "type": "object" - }, - "type": "object" - }, - "error": { - "type": "string" - }, - "integrations": { - "items": { - "additionalProperties": false, - "properties": { - "error": { - "type": "string" - }, - "id": { - "type": "string" - }, - "install_status": { - "additionalProperties": false, - "properties": { - "main": { - "type": "string" - }, - "remote": { - "type": "string" - } - }, - "required": [ - "main" - ], - "type": "object" - }, - "package_name": { - "type": "string" - }, - "package_version": { - "type": "string" - }, - "sync_status": { - "enum": [ - "completed", - "synchronizing", - "failed", - "warning" - ], - "type": "string" - }, - "updated_at": { - "type": "string" - }, - "warning": { - "type": "string" - } - }, - "required": [ - "sync_status", - "install_status" - ], - "type": "object" - }, - "type": "array" - }, - "warning": { - "type": "string" - } - }, - "required": [ - "integrations" - ], - "type": "object" - } - } - } - }, - "400": { - "content": { - "application/json": { - "schema": { - "additionalProperties": false, - "description": "Generic Error", - "properties": { - "attributes": {}, - "error": { - "type": "string" - }, - "errorType": { - "type": "string" - }, - "message": { - "type": "string" - }, - "statusCode": { - "type": "number" - } - }, - "required": [ - "message", - "attributes" - ], - "type": "object" - } - } - } - } - }, - "summary": "Get CCR Remote synced integrations status by outputId", - "tags": [ - "CCR Remote synced integrations" - ] - } - }, "/api/fleet/service_tokens": { "post": { "description": "[Required authorization] Route required privileges: fleet-agents-all.", @@ -58294,9 +57961,6 @@ { "name": "alerting" }, - { - "name": "CCR Remote synced integrations" - }, { "name": "connectors" }, diff --git a/oas_docs/output/kibana.serverless.yaml b/oas_docs/output/kibana.serverless.yaml index 6b789a7da422..38940dd7baa3 100644 --- a/oas_docs/output/kibana.serverless.yaml +++ b/oas_docs/output/kibana.serverless.yaml @@ -66,7 +66,6 @@ tags: Configure APM source maps. A source map allows minified files to be mapped back to original source code--allowing you to maintain the speed advantage of minified code, without losing the ability to quickly and easily debug your application. For best results, uploading source maps should become a part of your deployment procedure, and not something you only do when you see unhelpful errors. That's because uploading source maps after errors happen won't make old errors magically readable--errors must occur again for source mapping to occur. name: APM sourcemaps - - name: CCR Remote synced integrations - name: connectors description: | Connectors provide a central place to store connection information for services and integrations with Elastic or third party systems. Alerting rules can use connectors to run actions when rule conditions are met. @@ -39060,233 +39059,6 @@ paths: summary: Update a proxy tags: - Fleet proxies - /api/fleet/remote_synced_integrations/{outputId}/remote_status: - get: - description: '[Required authorization] Route required privileges: fleet-settings-read AND integrations-read.' - operationId: get-fleet-remote-synced-integrations-outputid-remote-status - parameters: - - in: path - name: outputId - required: true - schema: - type: string - responses: - '200': - content: - application/json: - schema: - additionalProperties: false - type: object - properties: - custom_assets: - additionalProperties: - additionalProperties: false - type: object - properties: - error: - type: string - is_deleted: - type: boolean - name: - type: string - package_name: - type: string - package_version: - type: string - sync_status: - enum: - - completed - - synchronizing - - failed - - warning - type: string - type: - type: string - required: - - type - - name - - package_name - - package_version - - sync_status - type: object - error: - type: string - integrations: - items: - additionalProperties: false - type: object - properties: - error: - type: string - id: - type: string - install_status: - additionalProperties: false - type: object - properties: - main: - type: string - remote: - type: string - required: - - main - package_name: - type: string - package_version: - type: string - sync_status: - enum: - - completed - - synchronizing - - failed - - warning - type: string - updated_at: - type: string - warning: - type: string - required: - - sync_status - - install_status - type: array - warning: - type: string - required: - - integrations - '400': - content: - application/json: - schema: - additionalProperties: false - description: Generic Error - type: object - properties: - attributes: {} - error: - type: string - errorType: - type: string - message: - type: string - statusCode: - type: number - required: - - message - - attributes - summary: Get CCR Remote synced integrations status by outputId - tags: - - CCR Remote synced integrations - /api/fleet/remote_synced_integrations/status: - get: - description: '[Required authorization] Route required privileges: fleet-settings-read AND integrations-read.' - operationId: get-fleet-remote-synced-integrations-status - parameters: [] - responses: - '200': - content: - application/json: - schema: - additionalProperties: false - type: object - properties: - custom_assets: - additionalProperties: - additionalProperties: false - type: object - properties: - error: - type: string - is_deleted: - type: boolean - name: - type: string - package_name: - type: string - package_version: - type: string - sync_status: - enum: - - completed - - synchronizing - - failed - - warning - type: string - type: - type: string - required: - - type - - name - - package_name - - package_version - - sync_status - type: object - error: - type: string - integrations: - items: - additionalProperties: false - type: object - properties: - error: - type: string - id: - type: string - install_status: - additionalProperties: false - type: object - properties: - main: - type: string - remote: - type: string - required: - - main - package_name: - type: string - package_version: - type: string - sync_status: - enum: - - completed - - synchronizing - - failed - - warning - type: string - updated_at: - type: string - warning: - type: string - required: - - sync_status - - install_status - type: array - warning: - type: string - required: - - integrations - '400': - content: - application/json: - schema: - additionalProperties: false - description: Generic Error - type: object - properties: - attributes: {} - error: - type: string - errorType: - type: string - message: - type: string - statusCode: - type: number - required: - - message - - attributes - summary: Get CCR Remote synced integrations status - tags: - - CCR Remote synced integrations /api/fleet/service_tokens: post: description: '[Required authorization] Route required privileges: fleet-agents-all.' diff --git a/oas_docs/output/kibana.yaml b/oas_docs/output/kibana.yaml index ee33a5b88d33..7adf845f370e 100644 --- a/oas_docs/output/kibana.yaml +++ b/oas_docs/output/kibana.yaml @@ -80,7 +80,6 @@ tags: description: Cases documentation url: https://www.elastic.co/docs/explore-analyze/alerts-cases/cases x-displayName: Cases - - name: CCR Remote synced integrations - name: connectors description: | Connectors provide a central place to store connection information for services and integrations with Elastic or third party systems. Alerting rules can use connectors to run actions when rule conditions are met. @@ -41302,233 +41301,6 @@ paths: summary: Update a proxy tags: - Fleet proxies - /api/fleet/remote_synced_integrations/{outputId}/remote_status: - get: - description: '[Required authorization] Route required privileges: fleet-settings-read AND integrations-read.' - operationId: get-fleet-remote-synced-integrations-outputid-remote-status - parameters: - - in: path - name: outputId - required: true - schema: - type: string - responses: - '200': - content: - application/json: - schema: - additionalProperties: false - type: object - properties: - custom_assets: - additionalProperties: - additionalProperties: false - type: object - properties: - error: - type: string - is_deleted: - type: boolean - name: - type: string - package_name: - type: string - package_version: - type: string - sync_status: - enum: - - completed - - synchronizing - - failed - - warning - type: string - type: - type: string - required: - - type - - name - - package_name - - package_version - - sync_status - type: object - error: - type: string - integrations: - items: - additionalProperties: false - type: object - properties: - error: - type: string - id: - type: string - install_status: - additionalProperties: false - type: object - properties: - main: - type: string - remote: - type: string - required: - - main - package_name: - type: string - package_version: - type: string - sync_status: - enum: - - completed - - synchronizing - - failed - - warning - type: string - updated_at: - type: string - warning: - type: string - required: - - sync_status - - install_status - type: array - warning: - type: string - required: - - integrations - '400': - content: - application/json: - schema: - additionalProperties: false - description: Generic Error - type: object - properties: - attributes: {} - error: - type: string - errorType: - type: string - message: - type: string - statusCode: - type: number - required: - - message - - attributes - summary: Get CCR Remote synced integrations status by outputId - tags: - - CCR Remote synced integrations - /api/fleet/remote_synced_integrations/status: - get: - description: '[Required authorization] Route required privileges: fleet-settings-read AND integrations-read.' - operationId: get-fleet-remote-synced-integrations-status - parameters: [] - responses: - '200': - content: - application/json: - schema: - additionalProperties: false - type: object - properties: - custom_assets: - additionalProperties: - additionalProperties: false - type: object - properties: - error: - type: string - is_deleted: - type: boolean - name: - type: string - package_name: - type: string - package_version: - type: string - sync_status: - enum: - - completed - - synchronizing - - failed - - warning - type: string - type: - type: string - required: - - type - - name - - package_name - - package_version - - sync_status - type: object - error: - type: string - integrations: - items: - additionalProperties: false - type: object - properties: - error: - type: string - id: - type: string - install_status: - additionalProperties: false - type: object - properties: - main: - type: string - remote: - type: string - required: - - main - package_name: - type: string - package_version: - type: string - sync_status: - enum: - - completed - - synchronizing - - failed - - warning - type: string - updated_at: - type: string - warning: - type: string - required: - - sync_status - - install_status - type: array - warning: - type: string - required: - - integrations - '400': - content: - application/json: - schema: - additionalProperties: false - description: Generic Error - type: object - properties: - attributes: {} - error: - type: string - errorType: - type: string - message: - type: string - statusCode: - type: number - required: - - message - - attributes - summary: Get CCR Remote synced integrations status - tags: - - CCR Remote synced integrations /api/fleet/service_tokens: post: description: '[Required authorization] Route required privileges: fleet-agents-all.' diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/index.test.tsx b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/index.test.tsx index 580eb5df52cd..77fd2e07c761 100644 --- a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/index.test.tsx +++ b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/index.test.tsx @@ -12,7 +12,7 @@ import type { Output } from '../../../../types'; import { createFleetTestRendererMock } from '../../../../../../mock'; import { useFleetStatus } from '../../../../../../hooks/use_fleet_status'; import { ExperimentalFeaturesService } from '../../../../../../services'; -import { useStartServices, sendPutOutput } from '../../../../hooks'; +import { useStartServices, sendPutOutput, licenseService } from '../../../../hooks'; import { EditOutputFlyout } from '.'; @@ -99,6 +99,7 @@ describe('EditOutputFlyout', () => { beforeEach(() => { mockStartServices(false); jest.clearAllMocks(); + jest.spyOn(licenseService, 'isEnterprise').mockClear(); mockedUseFleetStatus.mockReturnValue({} as any); }); @@ -322,10 +323,11 @@ describe('EditOutputFlyout', () => { expect(utils.getByText('Additional setup required')).not.toBeNull(); }); - it('should render the flyout if the output provided is a remote ES output', async () => { + it('should render the flyout if the output provided is a remote ES output and license is at least enterprise', async () => { jest .spyOn(ExperimentalFeaturesService, 'get') .mockReturnValue({ enableSyncIntegrationsOnRemote: true } as any); + jest.spyOn(licenseService, 'isEnterprise').mockReturnValue(true); mockedUseFleetStatus.mockReturnValue({ isLoading: false, @@ -380,10 +382,48 @@ describe('EditOutputFlyout', () => { }); }); + it('should not render the flyout if the output is a remote ES output and the license is not at least enterprise', async () => { + jest + .spyOn(ExperimentalFeaturesService, 'get') + .mockReturnValue({ enableSyncIntegrationsOnRemote: true } as any); + jest.spyOn(licenseService, 'isEnterprise').mockReturnValue(false); + + mockedUseFleetStatus.mockReturnValue({ + isLoading: false, + isReady: true, + isSecretsStorageEnabled: true, + } as any); + + const { utils } = renderFlyout({ + type: 'remote_elasticsearch', + name: 'remote es output', + id: 'outputR', + is_default: false, + is_default_monitoring: false, + kibana_url: 'http://localhost', + sync_integrations: true, + }); + + remoteEsOutputLabels.forEach((label) => { + expect(utils.queryByLabelText(label)).not.toBeNull(); + }); + expect(utils.queryByTestId('serviceTokenCallout')).not.toBeNull(); + + expect(utils.queryByTestId('settingsOutputsFlyout.typeInput')?.textContent).toContain( + 'Remote Elasticsearch' + ); + + expect(utils.queryByTestId('serviceTokenSecretInput')).not.toBeNull(); + + expect(utils.queryByTestId('remoteClusterConfigurationCallout')).not.toBeInTheDocument(); + expect(utils.queryByTestId('kibanaAPIKeyCallout')).not.toBeInTheDocument(); + }); + it('should populate secret service token input with plain text value when editing remote ES output', async () => { jest .spyOn(ExperimentalFeaturesService, 'get') .mockReturnValue({ enableSyncIntegrationsOnRemote: true } as any); + jest.spyOn(licenseService, 'isEnterprise').mockReturnValue(true); mockedUseFleetStatus.mockReturnValue({ isLoading: false, diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/output_form_remote_es.tsx b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/output_form_remote_es.tsx index 1377a7d97c49..2c7c1b94b46a 100644 --- a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/output_form_remote_es.tsx +++ b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/sections/settings/components/edit_output_flyout/output_form_remote_es.tsx @@ -27,7 +27,7 @@ import { MultiRowInput } from '../multi_row_input'; import { ExperimentalFeaturesService } from '../../../../services'; -import { useStartServices } from '../../../../hooks'; +import { licenseService, useStartServices } from '../../../../hooks'; import type { OutputFormInputsType } from './use_output_form'; import { SecretFormRow } from './output_form_secret_form_row'; @@ -54,6 +54,8 @@ export const OutputFormRemoteEsSection: React.FunctionComponent = (props) sslKey: false, }); const { enableSyncIntegrationsOnRemote, enableSSLSecrets } = ExperimentalFeaturesService.get(); + const enableSyncIntegrations = enableSyncIntegrationsOnRemote && licenseService.isEnterprise(); + const [isRemoteClusterInstructionsOpen, setIsRemoteClusterInstructionsOpen] = React.useState(false); @@ -208,7 +210,7 @@ export const OutputFormRemoteEsSection: React.FunctionComponent = (props) onToggleSecretAndClearValue={onToggleSecretAndClearValue} /> - {enableSyncIntegrationsOnRemote ? ( + {enableSyncIntegrations ? ( <> = ({ const authz = useAuthz(); const { getHref } = useLink(); const { enableSyncIntegrationsOnRemote } = ExperimentalFeaturesService.get(); + const enableSyncIntegrations = enableSyncIntegrationsOnRemote && licenseService.isEnterprise(); const columns = useMemo((): Array> => { return [ @@ -121,7 +122,7 @@ export const OutputsTable: React.FunctionComponent = ({ defaultMessage: 'Status', }), }, - ...(enableSyncIntegrationsOnRemote + ...(enableSyncIntegrations ? [ { render: (output: Output) => , @@ -180,7 +181,7 @@ export const OutputsTable: React.FunctionComponent = ({ }), }, ]; - }, [deleteOutput, getHref, authz.fleet.allSettings, enableSyncIntegrationsOnRemote]); + }, [deleteOutput, getHref, authz.fleet.allSettings, enableSyncIntegrations]); return ; }; diff --git a/x-pack/platform/plugins/shared/fleet/server/routes/remote_synced_integrations/index.ts b/x-pack/platform/plugins/shared/fleet/server/routes/remote_synced_integrations/index.ts index b5b32e196ef3..1abf9c702225 100644 --- a/x-pack/platform/plugins/shared/fleet/server/routes/remote_synced_integrations/index.ts +++ b/x-pack/platform/plugins/shared/fleet/server/routes/remote_synced_integrations/index.ts @@ -15,77 +15,81 @@ import { GetRemoteSyncedIntegrationsStatusResponseSchema } from '../../types/mod import { GetRemoteSyncedIntegrationsInfoRequestSchema } from '../../types'; +import { canEnableSyncIntegrations } from '../../services/setup/fleet_synced_integrations'; + import { getRemoteSyncedIntegrationsStatusHandler, getRemoteSyncedIntegrationsInfoHandler, } from './handler'; export const registerRoutes = (router: FleetAuthzRouter) => { - router.versioned - .get({ - path: REMOTE_SYNCED_INTEGRATIONS_API_ROUTES.STATUS_PATTERN, - security: { - authz: { - requiredPrivileges: [ - FLEET_API_PRIVILEGES.SETTINGS.READ, - FLEET_API_PRIVILEGES.INTEGRATIONS.READ, - ], + if (canEnableSyncIntegrations()) { + router.versioned + .get({ + path: REMOTE_SYNCED_INTEGRATIONS_API_ROUTES.STATUS_PATTERN, + security: { + authz: { + requiredPrivileges: [ + FLEET_API_PRIVILEGES.SETTINGS.READ, + FLEET_API_PRIVILEGES.INTEGRATIONS.READ, + ], + }, }, - }, - summary: `Get CCR Remote synced integrations status`, - options: { - tags: ['oas-tag:CCR Remote synced integrations'], - }, - }) - .addVersion( - { - version: API_VERSIONS.public.v1, - validate: { - request: {}, - response: { - 200: { - body: () => GetRemoteSyncedIntegrationsStatusResponseSchema, - }, - 400: { - body: genericErrorResponse, + summary: `Get CCR Remote synced integrations status`, + options: { + tags: ['oas-tag:CCR Remote synced integrations'], + }, + }) + .addVersion( + { + version: API_VERSIONS.public.v1, + validate: { + request: {}, + response: { + 200: { + body: () => GetRemoteSyncedIntegrationsStatusResponseSchema, + }, + 400: { + body: genericErrorResponse, + }, }, }, }, - }, - getRemoteSyncedIntegrationsStatusHandler - ); + getRemoteSyncedIntegrationsStatusHandler + ); - router.versioned - .get({ - path: REMOTE_SYNCED_INTEGRATIONS_API_ROUTES.INFO_PATTERN, - security: { - authz: { - requiredPrivileges: [ - FLEET_API_PRIVILEGES.SETTINGS.READ, - FLEET_API_PRIVILEGES.INTEGRATIONS.READ, - ], + router.versioned + .get({ + path: REMOTE_SYNCED_INTEGRATIONS_API_ROUTES.INFO_PATTERN, + security: { + authz: { + requiredPrivileges: [ + FLEET_API_PRIVILEGES.SETTINGS.READ, + FLEET_API_PRIVILEGES.INTEGRATIONS.READ, + ], + }, }, - }, - summary: `Get CCR Remote synced integrations status by outputId`, - options: { - tags: ['oas-tag:CCR Remote synced integrations'], - }, - }) - .addVersion( - { - version: API_VERSIONS.public.v1, - validate: { - request: GetRemoteSyncedIntegrationsInfoRequestSchema, - response: { - 200: { - body: () => GetRemoteSyncedIntegrationsStatusResponseSchema, - }, - 400: { - body: genericErrorResponse, + summary: `Get CCR Remote synced integrations status by outputId`, + options: { + tags: ['oas-tag:CCR Remote synced integrations'], + }, + }) + .addVersion( + { + version: API_VERSIONS.public.v1, + validate: { + request: GetRemoteSyncedIntegrationsInfoRequestSchema, + response: { + 200: { + body: () => GetRemoteSyncedIntegrationsStatusResponseSchema, + }, + 400: { + body: genericErrorResponse, + }, }, }, }, - }, - getRemoteSyncedIntegrationsInfoHandler - ); + getRemoteSyncedIntegrationsInfoHandler + ); + } }; diff --git a/x-pack/platform/plugins/shared/fleet/server/services/output.ts b/x-pack/platform/plugins/shared/fleet/server/services/output.ts index b06ce8d1e414..f0cabc10ef7e 100644 --- a/x-pack/platform/plugins/shared/fleet/server/services/output.ts +++ b/x-pack/platform/plugins/shared/fleet/server/services/output.ts @@ -83,6 +83,7 @@ import { } from './secrets'; import { findAgentlessPolicies } from './outputs/helpers'; import { patchUpdateDataWithRequireEncryptedAADFields } from './outputs/so_helpers'; +import { canEnableSyncIntegrations } from './setup/fleet_synced_integrations'; type Nullable = { [P in keyof T]: T[P] | null }; @@ -367,6 +368,18 @@ async function updateAgentPoliciesDataOutputId( } } +function validateRemoteSyncIntegrationsCanBeEnabled(output: Partial) { + if ( + output.type === outputType.RemoteElasticsearch && + (output.sync_integrations === true || output.sync_uninstalled_integrations === true) && + !canEnableSyncIntegrations() + ) { + throw new OutputUnauthorizedError( + 'Remote sync integrations require at least an Enterprise license.' + ); + } +} + class OutputService { private get encryptedSoClient() { return appContextService.getInternalUserSOClient(fakeRequest); @@ -679,6 +692,8 @@ class OutputService { } } + validateRemoteSyncIntegrationsCanBeEnabled(output); + const id = options?.id ? outputIdToUuid(options.id) : SavedObjectsUtils.generateId(); // Store secret values if enabled; if not, store plain text values @@ -1103,6 +1118,7 @@ class OutputService { updateData.shipper = null; } } + validateRemoteSyncIntegrationsCanBeEnabled(data); // Store secret values if enabled; if not, store plain text values if (await isOutputSecretStorageEnabled(esClient, soClient)) { diff --git a/x-pack/platform/plugins/shared/fleet/server/services/setup/fleet_synced_integrations.test.ts b/x-pack/platform/plugins/shared/fleet/server/services/setup/fleet_synced_integrations.test.ts index 7e297c0ff864..5d58b16e16fb 100644 --- a/x-pack/platform/plugins/shared/fleet/server/services/setup/fleet_synced_integrations.test.ts +++ b/x-pack/platform/plugins/shared/fleet/server/services/setup/fleet_synced_integrations.test.ts @@ -5,6 +5,8 @@ * 2.0. */ +import { licenseService } from '../license'; + import { createCCSIndexPatterns, createOrUpdateFleetSyncedIntegrationsIndex, @@ -66,72 +68,117 @@ describe('fleet_synced_integrations', () => { }; }); - it('should create index if not exists', async () => { - mockExists.mockResolvedValue(false); - - await createOrUpdateFleetSyncedIntegrationsIndex(esClientMock); - - expect(esClientMock.indices.create).toHaveBeenCalled(); - }); - - it('should update index if older version exists', async () => { - mockExists.mockResolvedValue(true); - mockGetMapping.mockResolvedValue({ - 'fleet-synced-integrations': { - mappings: { - _meta: { - version: '0.0', - }, - }, - }, + describe('with Enterprise license', () => { + beforeAll(() => { + jest.spyOn(licenseService, 'isEnterprise').mockReturnValue(true); }); - await createOrUpdateFleetSyncedIntegrationsIndex(esClientMock); + it('should create index if does not exist', async () => { + mockExists.mockResolvedValue(false); - expect(esClientMock.indices.putMapping).toHaveBeenCalled(); - }); + await createOrUpdateFleetSyncedIntegrationsIndex(esClientMock); - it('should not update index if same version exists', async () => { - mockExists.mockResolvedValue(true); - mockGetMapping.mockResolvedValue({ - 'fleet-synced-integrations': { - mappings: { - _meta: { - version: '1.0', + expect(esClientMock.indices.create).toHaveBeenCalled(); + }); + it('should update index if older version exists', async () => { + mockExists.mockResolvedValue(true); + mockGetMapping.mockResolvedValue({ + 'fleet-synced-integrations': { + mappings: { + _meta: { + version: '0.0', + }, }, }, - }, + }); + + await createOrUpdateFleetSyncedIntegrationsIndex(esClientMock); + + expect(esClientMock.indices.putMapping).toHaveBeenCalled(); }); - await createOrUpdateFleetSyncedIntegrationsIndex(esClientMock); + it('should not update index if same version exists', async () => { + mockExists.mockResolvedValue(true); + mockGetMapping.mockResolvedValue({ + 'fleet-synced-integrations': { + mappings: { + _meta: { + version: '1.0', + }, + }, + }, + }); - expect(esClientMock.indices.putMapping).not.toHaveBeenCalled(); + await createOrUpdateFleetSyncedIntegrationsIndex(esClientMock); + + expect(esClientMock.indices.putMapping).not.toHaveBeenCalled(); + }); + + it('should create index patterns for remote clusters', async () => { + await createCCSIndexPatterns(esClientMock, soClientMock, soImporterMock); + + expect(soImporterMock.import).toHaveBeenCalledWith( + expect.objectContaining({ + readStream: ['remote1:metrics-*', 'remote2:metrics-*'], + }) + ); + + expect(soClientMock.updateObjectsSpaces).toHaveBeenCalledTimes(3); + expect(soClientMock.updateObjectsSpaces).toHaveBeenCalledWith( + [{ id: 'remote1:metrics-*', type: 'index-pattern' }], + ['*'], + [] + ); + expect(soClientMock.updateObjectsSpaces).toHaveBeenCalledWith( + [{ id: 'remote2:logs-*', type: 'index-pattern' }], + ['*'], + [] + ); + expect(soClientMock.updateObjectsSpaces).toHaveBeenCalledWith( + [{ id: 'remote2:metrics-*', type: 'index-pattern' }], + ['*'], + [] + ); + }); }); - it('should create index patterns for remote clusters', async () => { - await createCCSIndexPatterns(esClientMock, soClientMock, soImporterMock); + describe('with less than Enterprise license', () => { + beforeAll(() => { + jest.spyOn(licenseService, 'isEnterprise').mockReturnValue(false); + }); - expect(soImporterMock.import).toHaveBeenCalledWith( - expect.objectContaining({ - readStream: ['remote1:metrics-*', 'remote2:metrics-*'], - }) - ); + it('should not create index', async () => { + mockExists.mockResolvedValue(false); + jest.spyOn(licenseService, 'isEnterprise').mockReturnValue(false); - expect(soClientMock.updateObjectsSpaces).toHaveBeenCalledTimes(3); - expect(soClientMock.updateObjectsSpaces).toHaveBeenCalledWith( - [{ id: 'remote1:metrics-*', type: 'index-pattern' }], - ['*'], - [] - ); - expect(soClientMock.updateObjectsSpaces).toHaveBeenCalledWith( - [{ id: 'remote2:logs-*', type: 'index-pattern' }], - ['*'], - [] - ); - expect(soClientMock.updateObjectsSpaces).toHaveBeenCalledWith( - [{ id: 'remote2:metrics-*', type: 'index-pattern' }], - ['*'], - [] - ); + await createOrUpdateFleetSyncedIntegrationsIndex(esClientMock); + + expect(esClientMock.indices.create).not.toHaveBeenCalled(); + }); + + it('should not update index', async () => { + mockExists.mockResolvedValue(true); + mockGetMapping.mockResolvedValue({ + 'fleet-synced-integrations': { + mappings: { + _meta: { + version: '0.0', + }, + }, + }, + }); + + await createOrUpdateFleetSyncedIntegrationsIndex(esClientMock); + + expect(esClientMock.indices.putMapping).not.toHaveBeenCalled(); + }); + + it('should not create index patterns for remote clusters', async () => { + await createCCSIndexPatterns(esClientMock, soClientMock, soImporterMock); + + expect(soImporterMock.import).not.toHaveBeenCalled(); + + expect(soClientMock.updateObjectsSpaces).not.toHaveBeenCalled(); + }); }); }); diff --git a/x-pack/platform/plugins/shared/fleet/server/services/setup/fleet_synced_integrations.ts b/x-pack/platform/plugins/shared/fleet/server/services/setup/fleet_synced_integrations.ts index 5de7707ef126..67b9a3347949 100644 --- a/x-pack/platform/plugins/shared/fleet/server/services/setup/fleet_synced_integrations.ts +++ b/x-pack/platform/plugins/shared/fleet/server/services/setup/fleet_synced_integrations.ts @@ -20,6 +20,7 @@ import { indexPatternTypes, } from '../epm/kibana/index_pattern/install'; import { SO_SEARCH_LIMIT } from '../../constants'; +import { licenseService } from '../license'; export const FLEET_SYNCED_INTEGRATIONS_INDEX_NAME = 'fleet-synced-integrations'; export const FLEET_SYNCED_INTEGRATIONS_CCR_INDEX_PREFIX = 'fleet-synced-integrations-ccr-*'; @@ -64,10 +65,13 @@ export const FLEET_SYNCED_INTEGRATIONS_INDEX_CONFIG = { }, }; -export async function createOrUpdateFleetSyncedIntegrationsIndex(esClient: ElasticsearchClient) { +export const canEnableSyncIntegrations = () => { const { enableSyncIntegrationsOnRemote } = appContextService.getExperimentalFeatures(); + return enableSyncIntegrationsOnRemote && licenseService.isEnterprise(); +}; - if (!enableSyncIntegrationsOnRemote) { +export async function createOrUpdateFleetSyncedIntegrationsIndex(esClient: ElasticsearchClient) { + if (!canEnableSyncIntegrations()) { return; } @@ -133,9 +137,7 @@ export async function createCCSIndexPatterns( savedObjectsClient: SavedObjectsClientContract, savedObjectsImporter: ISavedObjectsImporter ) { - const { enableSyncIntegrationsOnRemote } = appContextService.getExperimentalFeatures(); - - if (!enableSyncIntegrationsOnRemote) { + if (!canEnableSyncIntegrations()) { return; } diff --git a/x-pack/platform/plugins/shared/fleet/server/tasks/sync_integrations/compare_synced_integrations.test.ts b/x-pack/platform/plugins/shared/fleet/server/tasks/sync_integrations/compare_synced_integrations.test.ts index e64bb9fd731e..439e5d4ddab4 100644 --- a/x-pack/platform/plugins/shared/fleet/server/tasks/sync_integrations/compare_synced_integrations.test.ts +++ b/x-pack/platform/plugins/shared/fleet/server/tasks/sync_integrations/compare_synced_integrations.test.ts @@ -14,6 +14,8 @@ import { appContextService } from '../../services/app_context'; import { getPackageSavedObjects } from '../../services/epm/packages/get'; +import { licenseService } from '../../services/license'; + import { installCustomAsset, getPipeline, getComponentTemplate } from './custom_assets'; import { getFollowerIndexInfo, @@ -1640,6 +1642,7 @@ describe('getRemoteSyncedIntegrationsStatus', () => { soClientMock = savedObjectsClientMock.create(); mockedLogger = loggerMock.create(); mockedAppContextService.getLogger.mockReturnValue(mockedLogger); + jest.spyOn(licenseService, 'isEnterprise').mockReturnValue(true); (installCustomAsset as jest.Mock).mockClear(); }); @@ -1657,6 +1660,17 @@ describe('getRemoteSyncedIntegrationsStatus', () => { }); }); + it('should return empty integrations array if license is less than Enterprise', async () => { + jest + .spyOn(mockedAppContextService, 'getExperimentalFeatures') + .mockReturnValue({ enableSyncIntegrationsOnRemote: true } as any); + jest.spyOn(licenseService, 'isEnterprise').mockReturnValue(false); + + expect(await getRemoteSyncedIntegrationsStatus(esClientMock, soClientMock)).toEqual({ + integrations: [], + }); + }); + it('should return error if there is an error in getFollowerIndexInfo', async () => { jest .spyOn(mockedAppContextService, 'getExperimentalFeatures') diff --git a/x-pack/platform/plugins/shared/fleet/server/tasks/sync_integrations/compare_synced_integrations.ts b/x-pack/platform/plugins/shared/fleet/server/tasks/sync_integrations/compare_synced_integrations.ts index b87f3db884ce..63a8d3025d67 100644 --- a/x-pack/platform/plugins/shared/fleet/server/tasks/sync_integrations/compare_synced_integrations.ts +++ b/x-pack/platform/plugins/shared/fleet/server/tasks/sync_integrations/compare_synced_integrations.ts @@ -34,6 +34,8 @@ import type { } from '../../../common/types'; import { SyncStatus } from '../../../common/types'; +import { canEnableSyncIntegrations } from '../../services/setup/fleet_synced_integrations'; + import type { IntegrationsData, SyncIntegrationsData, CustomAssetsData } from './model'; import { getPipeline, getComponentTemplate, CUSTOM_ASSETS_PREFIX } from './custom_assets'; import { getFollowerIndex } from './sync_integrations_on_remote'; @@ -500,10 +502,9 @@ export const getRemoteSyncedIntegrationsStatus = async ( esClient: ElasticsearchClient, soClient: SavedObjectsClientContract ): Promise => { - const { enableSyncIntegrationsOnRemote } = appContextService.getExperimentalFeatures(); const logger = appContextService.getLogger(); - if (!enableSyncIntegrationsOnRemote) { + if (!canEnableSyncIntegrations()) { return { integrations: [] }; } diff --git a/x-pack/platform/plugins/shared/fleet/server/tasks/sync_integrations/get_remote_status.test.ts b/x-pack/platform/plugins/shared/fleet/server/tasks/sync_integrations/get_remote_status.test.ts index 460a6531bde5..bb34307da358 100644 --- a/x-pack/platform/plugins/shared/fleet/server/tasks/sync_integrations/get_remote_status.test.ts +++ b/x-pack/platform/plugins/shared/fleet/server/tasks/sync_integrations/get_remote_status.test.ts @@ -18,6 +18,8 @@ import type { Output } from '../../types'; import { FleetNotFoundError } from '../../errors'; +import { licenseService } from '../../services/license'; + import { getRemoteSyncedIntegrationsInfoByOutputId } from './get_remote_status'; jest.mock('../../services/app_context'); @@ -66,6 +68,7 @@ describe('getRemoteSyncedIntegrationsInfoByOutputId', () => { soClientMock = savedObjectsClientMock.create(); mockedLogger = loggerMock.create(); mockedAppContextService.getLogger.mockReturnValue(mockedLogger); + jest.spyOn(licenseService, 'isEnterprise').mockReturnValue(true); }); afterEach(() => { @@ -82,6 +85,17 @@ describe('getRemoteSyncedIntegrationsInfoByOutputId', () => { }); }); + it('should return empty integrations array if license is not at least Enterprise', async () => { + jest + .spyOn(mockedAppContextService, 'getExperimentalFeatures') + .mockReturnValue({ enableSyncIntegrationsOnRemote: true } as any); + jest.spyOn(licenseService, 'isEnterprise').mockReturnValue(false); + + expect(await getRemoteSyncedIntegrationsInfoByOutputId(soClientMock, 'remote1')).toEqual({ + integrations: [], + }); + }); + it('should return response with error if the passed outputId is not found', async () => { jest .spyOn(mockedAppContextService, 'getExperimentalFeatures') diff --git a/x-pack/platform/plugins/shared/fleet/server/tasks/sync_integrations/get_remote_status.ts b/x-pack/platform/plugins/shared/fleet/server/tasks/sync_integrations/get_remote_status.ts index 5cbb0ebd9bf4..8c710de09162 100644 --- a/x-pack/platform/plugins/shared/fleet/server/tasks/sync_integrations/get_remote_status.ts +++ b/x-pack/platform/plugins/shared/fleet/server/tasks/sync_integrations/get_remote_status.ts @@ -17,15 +17,15 @@ import { outputService } from '../../services'; import { FleetError, FleetNotFoundError } from '../../errors'; import type { GetRemoteSyncedIntegrationsStatusResponse } from '../../../common/types'; +import { canEnableSyncIntegrations } from '../../services/setup/fleet_synced_integrations'; export const getRemoteSyncedIntegrationsInfoByOutputId = async ( soClient: SavedObjectsClientContract, outputId: string ): Promise => { - const { enableSyncIntegrationsOnRemote } = appContextService.getExperimentalFeatures(); const logger = appContextService.getLogger(); - if (!enableSyncIntegrationsOnRemote) { + if (!canEnableSyncIntegrations()) { return { integrations: [] }; } try { diff --git a/x-pack/platform/plugins/shared/fleet/server/tasks/sync_integrations/sync_integrations_task.test.ts b/x-pack/platform/plugins/shared/fleet/server/tasks/sync_integrations/sync_integrations_task.test.ts index 4823dd5078ba..f7cbef33576a 100644 --- a/x-pack/platform/plugins/shared/fleet/server/tasks/sync_integrations/sync_integrations_task.test.ts +++ b/x-pack/platform/plugins/shared/fleet/server/tasks/sync_integrations/sync_integrations_task.test.ts @@ -16,23 +16,33 @@ import { loggingSystemMock } from '@kbn/core/server/mocks'; import { createAppContextStartContractMock, createMockPackageService } from '../../mocks'; -import { appContextService, outputService, packagePolicyService } from '../../services'; +import { outputService } from '../../services/output'; +import { packagePolicyService } from '../../services/package_policy'; +import { appContextService } from '../../services/app_context'; + +import { licenseService } from '../../services/license'; import { SyncIntegrationsTask, TYPE, VERSION } from './sync_integrations_task'; -jest.mock('../../services', () => ({ - appContextService: { - getExperimentalFeatures: jest.fn().mockReturnValue({ enableSyncIntegrationsOnRemote: true }), - start: jest.fn(), - }, +jest.mock('../../services/output', () => ({ outputService: { list: jest.fn(), }, +})); + +jest.mock('../../services/package_policy', () => ({ packagePolicyService: { list: jest.fn(), }, })); +jest.mock('../../services/app_context', () => ({ + appContextService: { + getExperimentalFeatures: jest.fn().mockReturnValue({ enableSyncIntegrationsOnRemote: true }), + start: jest.fn(), + }, +})); + const mockOutputService = outputService as jest.Mocked; const mockPackagePolicyService = packagePolicyService as jest.Mocked; @@ -156,156 +166,212 @@ describe('SyncIntegrationsTask', () => { jest.clearAllMocks(); }); - it('Should not run if task is outdated', async () => { - const result = await runTask({ ...MOCK_TASK_INSTANCE, id: 'old-id' }); + describe('With at least Enterprise license', () => { + beforeAll(() => { + jest.spyOn(licenseService, 'isEnterprise').mockReturnValue(true); + }); + afterAll(() => { + jest.spyOn(licenseService, 'isEnterprise').mockClear(); + }); - expect(result).toEqual(getDeleteTaskRunResult()); - }); + it('Should not run if task is outdated', async () => { + const result = await runTask({ ...MOCK_TASK_INSTANCE, id: 'old-id' }); - it('Should create fleet-synced-integrations doc', async () => { - mockOutputService.list.mockResolvedValue({ - items: [ - { - type: 'remote_elasticsearch', - name: 'remote1', - hosts: ['https://remote1:9200'], - sync_integrations: true, - }, - { - type: 'remote_elasticsearch', - name: 'remote2', - hosts: ['https://remote2:9200'], - sync_integrations: false, - }, - ], - } as any); - mockPackagePolicyService.list.mockResolvedValue({ - items: [ - { - package: { - name: 'filestream', - version: '1.1.0', + expect(result).toEqual(getDeleteTaskRunResult()); + }); + + it('Should create fleet-synced-integrations doc', async () => { + mockOutputService.list.mockResolvedValue({ + items: [ + { + type: 'remote_elasticsearch', + name: 'remote1', + hosts: ['https://remote1:9200'], + sync_integrations: true, }, - inputs: [ - { - streams: [ - { - vars: { - pipeline: { - value: 'filestream-pipeline1', + { + type: 'remote_elasticsearch', + name: 'remote2', + hosts: ['https://remote2:9200'], + sync_integrations: false, + }, + ], + } as any); + mockPackagePolicyService.list.mockResolvedValue({ + items: [ + { + package: { + name: 'filestream', + version: '1.1.0', + }, + inputs: [ + { + streams: [ + { + vars: { + pipeline: { + value: 'filestream-pipeline1', + }, }, }, + ], + }, + ], + }, + ], + } as any); + await runTask(); + + expect(esClient.index).toHaveBeenCalledWith( + { + id: 'fleet-synced-integrations', + index: 'fleet-synced-integrations', + body: { + integrations: [ + { + package_name: 'system', + package_version: '0.1.0', + updated_at: expect.any(String), + install_status: 'installed', + }, + { + package_name: 'package-2', + package_version: '0.2.0', + updated_at: expect.any(String), + install_status: 'installed', + }, + ], + remote_es_hosts: [ + { + hosts: ['https://remote1:9200'], + name: 'remote1', + sync_integrations: true, + sync_uninstalled_integrations: false, + }, + { + hosts: ['https://remote2:9200'], + name: 'remote2', + sync_integrations: false, + sync_uninstalled_integrations: false, + }, + ], + custom_assets: { + 'component_template:logs-system.auth@custom': { + is_deleted: false, + name: 'logs-system.auth@custom', + package_name: 'system', + package_version: '0.1.0', + template: {}, + type: 'component_template', + }, + 'ingest_pipeline:logs-system.auth@custom': { + is_deleted: false, + name: 'logs-system.auth@custom', + package_name: 'system', + package_version: '0.1.0', + pipeline: { + processors: [], }, - ], - }, - ], - }, - ], - } as any); - await runTask(); - - expect(esClient.index).toHaveBeenCalledWith( - { - id: 'fleet-synced-integrations', - index: 'fleet-synced-integrations', - body: { - integrations: [ - { - package_name: 'system', - package_version: '0.1.0', - updated_at: expect.any(String), - install_status: 'installed', - }, - { - package_name: 'package-2', - package_version: '0.2.0', - updated_at: expect.any(String), - install_status: 'installed', - }, - ], - remote_es_hosts: [ - { - hosts: ['https://remote1:9200'], - name: 'remote1', - sync_integrations: true, - sync_uninstalled_integrations: false, - }, - { - hosts: ['https://remote2:9200'], - name: 'remote2', - sync_integrations: false, - sync_uninstalled_integrations: false, - }, - ], - custom_assets: { - 'component_template:logs-system.auth@custom': { - is_deleted: false, - name: 'logs-system.auth@custom', - package_name: 'system', - package_version: '0.1.0', - template: {}, - type: 'component_template', - }, - 'ingest_pipeline:logs-system.auth@custom': { - is_deleted: false, - name: 'logs-system.auth@custom', - package_name: 'system', - package_version: '0.1.0', - pipeline: { - processors: [], + type: 'ingest_pipeline', }, - type: 'ingest_pipeline', - }, - 'ingest_pipeline:filestream-pipeline1': { - is_deleted: false, - name: 'filestream-pipeline1', - package_name: 'filestream', - package_version: '1.1.0', - pipeline: { - processors: [], + 'ingest_pipeline:filestream-pipeline1': { + is_deleted: false, + name: 'filestream-pipeline1', + package_name: 'filestream', + package_version: '1.1.0', + pipeline: { + processors: [], + }, + type: 'ingest_pipeline', }, - type: 'ingest_pipeline', }, }, }, - }, - expect.anything() - ); - }); + expect.anything() + ); + }); - it('Should save custom assets error', async () => { - mockOutputService.list.mockResolvedValue({ - items: [ + it('Should save custom assets error', async () => { + mockOutputService.list.mockResolvedValue({ + items: [ + { + type: 'remote_elasticsearch', + name: 'remote1', + hosts: ['https://remote1:9200'], + sync_integrations: true, + }, + ], + } as any); + esClient.ingest.getPipeline.mockRejectedValue(new Error('es error')); + await runTask(); + + expect(esClient.index).toHaveBeenCalledWith( { - type: 'remote_elasticsearch', - name: 'remote1', - hosts: ['https://remote1:9200'], - sync_integrations: true, + id: 'fleet-synced-integrations', + index: 'fleet-synced-integrations', + body: { + integrations: [ + { + package_name: 'system', + package_version: '0.1.0', + updated_at: expect.any(String), + install_status: 'installed', + }, + { + package_name: 'package-2', + package_version: '0.2.0', + updated_at: expect.any(String), + install_status: 'installed', + }, + ], + remote_es_hosts: [ + { + hosts: ['https://remote1:9200'], + name: 'remote1', + sync_integrations: true, + sync_uninstalled_integrations: false, + }, + ], + custom_assets: {}, + custom_assets_error: { + timestamp: expect.any(String), + error: 'es error', + }, + }, }, - ], - } as any); - esClient.ingest.getPipeline.mockRejectedValue(new Error('es error')); - await runTask(); + expect.anything() + ); + }); - expect(esClient.index).toHaveBeenCalledWith( - { - id: 'fleet-synced-integrations', - index: 'fleet-synced-integrations', - body: { - integrations: [ - { - package_name: 'system', - package_version: '0.1.0', - updated_at: expect.any(String), - install_status: 'installed', - }, - { - package_name: 'package-2', - package_version: '0.2.0', - updated_at: expect.any(String), - install_status: 'installed', - }, - ], + it('Should not index fleet-synced-integrations doc if no outputs with sync enabled', async () => { + mockOutputService.list.mockResolvedValue({ + items: [ + { + type: 'remote_elasticsearch', + name: 'remote2', + hosts: ['https://remote2:9200'], + sync_integrations: false, + }, + ], + } as any); + await runTask(); + + expect(esClient.index).not.toHaveBeenCalled(); + }); + + it('Should index fleet-synced-integrations doc if sync flag changed from true to false', async () => { + mockOutputService.list.mockResolvedValue({ + items: [ + { + type: 'remote_elasticsearch', + name: 'remote2', + hosts: ['https://remote2:9200'], + sync_integrations: false, + }, + ], + } as any); + esClient.get.mockResolvedValue({ + _source: { remote_es_hosts: [ { hosts: ['https://remote1:9200'], @@ -314,209 +380,307 @@ describe('SyncIntegrationsTask', () => { sync_uninstalled_integrations: false, }, ], + }, + } as any); + await runTask(); + + expect(esClient.index).toHaveBeenCalled(); + }); + + it('Should not index fleet-synced-integrations doc if sync flag already false', async () => { + mockOutputService.list.mockResolvedValue({ + items: [ + { + type: 'remote_elasticsearch', + name: 'remote2', + hosts: ['https://remote2:9200'], + sync_integrations: false, + }, + ], + } as any); + esClient.get.mockResolvedValue({ + _source: { + remote_es_hosts: [ + { hosts: ['https://remote1:9200'], name: 'remote1', sync_integrations: false }, + ], + integrations: [], custom_assets: {}, - custom_assets_error: { - timestamp: expect.any(String), - error: 'es error', - }, + custom_assets_error: {}, }, - }, - expect.anything() - ); - }); + } as any); + await runTask(); - it('Should not index fleet-synced-integrations doc if no outputs with sync enabled', async () => { - mockOutputService.list.mockResolvedValue({ - items: [ - { - type: 'remote_elasticsearch', - name: 'remote2', - hosts: ['https://remote2:9200'], - sync_integrations: false, - }, - ], - } as any); - await runTask(); + expect(esClient.index).not.toHaveBeenCalled(); + }); - expect(esClient.index).not.toHaveBeenCalled(); - }); - - it('Should index fleet-synced-integrations doc if sync flag changed from true to false', async () => { - mockOutputService.list.mockResolvedValue({ - items: [ - { - type: 'remote_elasticsearch', - name: 'remote2', - hosts: ['https://remote2:9200'], - sync_integrations: false, - }, - ], - } as any); - esClient.get.mockResolvedValue({ - _source: { - remote_es_hosts: [ - { hosts: ['https://remote1:9200'], name: 'remote1', sync_integrations: true }, - ], - }, - } as any); - await runTask(); - - expect(esClient.index).toHaveBeenCalled(); - }); - - it('Should not index fleet-synced-integrations doc if sync flag already false', async () => { - mockOutputService.list.mockResolvedValue({ - items: [ - { - type: 'remote_elasticsearch', - name: 'remote2', - hosts: ['https://remote2:9200'], - sync_integrations: false, - }, - ], - } as any); - esClient.get.mockResolvedValue({ - _source: { - remote_es_hosts: [ - { hosts: ['https://remote1:9200'], name: 'remote1', sync_integrations: false }, - ], - integrations: [], - custom_assets: {}, - custom_assets_error: {}, - }, - } as any); - await runTask(); - - expect(esClient.index).not.toHaveBeenCalled(); - }); - - it('Should not index fleet-synced-integrations doc if sync doc does not exist', async () => { - mockOutputService.list.mockResolvedValue({ - items: [ - { - type: 'remote_elasticsearch', - name: 'remote2', - hosts: ['https://remote2:9200'], - sync_integrations: false, - }, - ], - } as any); - esClient.get.mockRejectedValue({ statusCode: 404 }); - await runTask(); - - expect(esClient.index).not.toHaveBeenCalled(); - }); - - it('Should mark removed integrations as uninstalled if uninstall syncing is enabled', async () => { - mockOutputService.list.mockResolvedValue({ - items: [ - { - type: 'remote_elasticsearch', - name: 'remote1', - hosts: ['https://remote1:9200'], - sync_integrations: true, - sync_uninstalled_integrations: true, - }, - ], - } as any); - esClient.get.mockResolvedValue({ - _source: { - remote_es_hosts: [ - { hosts: ['https://remote1:9200'], name: 'remote1', sync_integrations: false }, - ], - integrations: [ + it('Should not index fleet-synced-integrations doc if sync doc does not exist', async () => { + mockOutputService.list.mockResolvedValue({ + items: [ { - package_name: 'system', - package_version: '0.1.0', - updated_at: new Date().toISOString(), - install_status: 'installed', - }, - { - package_name: 'package-2', - package_version: '0.2.0', - updated_at: new Date().toISOString(), - install_status: 'installed', - }, - { - package_name: 'package-3', - package_version: '0.3.0', - updated_at: new Date().toISOString(), - install_status: 'installed', + type: 'remote_elasticsearch', + name: 'remote2', + hosts: ['https://remote2:9200'], + sync_integrations: false, }, ], - custom_assets: {}, - custom_assets_error: {}, - }, - } as any); - await runTask(); + } as any); + esClient.get.mockRejectedValue({ statusCode: 404 }); + await runTask(); - expect(esClient.index).toHaveBeenCalledWith( - { - id: 'fleet-synced-integrations', - index: 'fleet-synced-integrations', - body: { + expect(esClient.index).not.toHaveBeenCalled(); + }); + + it('Should mark removed integrations as uninstalled if uninstall syncing is enabled', async () => { + mockOutputService.list.mockResolvedValue({ + items: [ + { + type: 'remote_elasticsearch', + name: 'remote1', + hosts: ['https://remote1:9200'], + sync_integrations: true, + sync_uninstalled_integrations: true, + }, + ], + } as any); + esClient.get.mockResolvedValue({ + _source: { + remote_es_hosts: [ + { hosts: ['https://remote1:9200'], name: 'remote1', sync_integrations: false }, + ], integrations: [ { package_name: 'system', package_version: '0.1.0', - updated_at: expect.any(String), + updated_at: new Date().toISOString(), install_status: 'installed', }, { package_name: 'package-2', package_version: '0.2.0', - updated_at: expect.any(String), + updated_at: new Date().toISOString(), install_status: 'installed', }, { package_name: 'package-3', package_version: '0.3.0', - updated_at: expect.any(String), - install_status: 'not_installed', + updated_at: new Date().toISOString(), + install_status: 'installed', }, ], - remote_es_hosts: [ - { - hosts: ['https://remote1:9200'], - name: 'remote1', - sync_integrations: true, - sync_uninstalled_integrations: true, - }, - ], - custom_assets: { - 'component_template:logs-system.auth@custom': { - is_deleted: false, - name: 'logs-system.auth@custom', - package_name: 'system', - package_version: '0.1.0', - template: {}, - type: 'component_template', - }, - 'ingest_pipeline:filestream-pipeline1': { - is_deleted: false, - name: 'filestream-pipeline1', - package_name: 'filestream', - package_version: '1.1.0', - pipeline: { - processors: [], + custom_assets: {}, + custom_assets_error: {}, + }, + } as any); + await runTask(); + + expect(esClient.index).toHaveBeenCalledWith( + { + id: 'fleet-synced-integrations', + index: 'fleet-synced-integrations', + body: { + integrations: [ + { + package_name: 'system', + package_version: '0.1.0', + updated_at: expect.any(String), + install_status: 'installed', }, - type: 'ingest_pipeline', - }, - 'ingest_pipeline:logs-system.auth@custom': { - is_deleted: false, - name: 'logs-system.auth@custom', - package_name: 'system', - package_version: '0.1.0', - pipeline: { - processors: [], + { + package_name: 'package-2', + package_version: '0.2.0', + updated_at: expect.any(String), + install_status: 'installed', + }, + { + package_name: 'package-3', + package_version: '0.3.0', + updated_at: expect.any(String), + install_status: 'not_installed', + }, + ], + remote_es_hosts: [ + { + hosts: ['https://remote1:9200'], + name: 'remote1', + sync_integrations: true, + sync_uninstalled_integrations: true, + }, + ], + custom_assets: { + 'component_template:logs-system.auth@custom': { + is_deleted: false, + name: 'logs-system.auth@custom', + package_name: 'system', + package_version: '0.1.0', + template: {}, + type: 'component_template', + }, + 'ingest_pipeline:filestream-pipeline1': { + is_deleted: false, + name: 'filestream-pipeline1', + package_name: 'filestream', + package_version: '1.1.0', + pipeline: { + processors: [], + }, + type: 'ingest_pipeline', + }, + 'ingest_pipeline:logs-system.auth@custom': { + is_deleted: false, + name: 'logs-system.auth@custom', + package_name: 'system', + package_version: '0.1.0', + pipeline: { + processors: [], + }, + type: 'ingest_pipeline', }, - type: 'ingest_pipeline', }, }, }, - }, - expect.anything() - ); + expect.anything() + ); + }); + }); + + describe('With less than Enterprise license', () => { + beforeAll(() => { + jest.spyOn(licenseService, 'isEnterprise').mockReturnValue(false); + }); + afterAll(() => { + jest.spyOn(licenseService, 'isEnterprise').mockClear(); + }); + + it('Should not create fleet-synced-integrations doc', async () => { + mockOutputService.list.mockResolvedValue({ + items: [ + { + type: 'remote_elasticsearch', + name: 'remote1', + hosts: ['https://remote1:9200'], + sync_integrations: true, + }, + { + type: 'remote_elasticsearch', + name: 'remote2', + hosts: ['https://remote2:9200'], + sync_integrations: false, + }, + ], + } as any); + mockPackagePolicyService.list.mockResolvedValue({ + items: [ + { + package: { + name: 'filestream', + version: '1.1.0', + }, + inputs: [ + { + streams: [ + { + vars: { + pipeline: { + value: 'filestream-pipeline1', + }, + }, + }, + ], + }, + ], + }, + ], + } as any); + await runTask(); + + expect(esClient.index).not.toHaveBeenCalled(); + }); + + it('Should not save custom assets error', async () => { + mockOutputService.list.mockResolvedValue({ + items: [ + { + type: 'remote_elasticsearch', + name: 'remote1', + hosts: ['https://remote1:9200'], + sync_integrations: true, + }, + ], + } as any); + esClient.ingest.getPipeline.mockRejectedValue(new Error('es error')); + await runTask(); + + expect(esClient.index).not.toHaveBeenCalled(); + }); + + it('Should not index fleet-synced-integrations doc even if sync flag changed', async () => { + mockOutputService.list.mockResolvedValue({ + items: [ + { + type: 'remote_elasticsearch', + name: 'remote2', + hosts: ['https://remote2:9200'], + sync_integrations: false, + }, + ], + } as any); + esClient.get.mockResolvedValue({ + _source: { + remote_es_hosts: [ + { hosts: ['https://remote1:9200'], name: 'remote1', sync_integrations: true }, + ], + }, + } as any); + await runTask(); + + expect(esClient.index).not.toHaveBeenCalled(); + }); + + it('Should mark removed integrations as uninstalled if uninstall syncing is enabled', async () => { + mockOutputService.list.mockResolvedValue({ + items: [ + { + type: 'remote_elasticsearch', + name: 'remote1', + hosts: ['https://remote1:9200'], + sync_integrations: true, + sync_uninstalled_integrations: true, + }, + ], + } as any); + esClient.get.mockResolvedValue({ + _source: { + remote_es_hosts: [ + { hosts: ['https://remote1:9200'], name: 'remote1', sync_integrations: false }, + ], + integrations: [ + { + package_name: 'system', + package_version: '0.1.0', + updated_at: new Date().toISOString(), + install_status: 'installed', + }, + { + package_name: 'package-2', + package_version: '0.2.0', + updated_at: new Date().toISOString(), + install_status: 'installed', + }, + { + package_name: 'package-3', + package_version: '0.3.0', + updated_at: new Date().toISOString(), + install_status: 'installed', + }, + ], + custom_assets: {}, + custom_assets_error: {}, + }, + } as any); + await runTask(); + + expect(esClient.index).not.toHaveBeenCalled(); + }); }); }); }); diff --git a/x-pack/platform/plugins/shared/fleet/server/tasks/sync_integrations/sync_integrations_task.ts b/x-pack/platform/plugins/shared/fleet/server/tasks/sync_integrations/sync_integrations_task.ts index 18691ab4bb24..bdc1da8d151a 100644 --- a/x-pack/platform/plugins/shared/fleet/server/tasks/sync_integrations/sync_integrations_task.ts +++ b/x-pack/platform/plugins/shared/fleet/server/tasks/sync_integrations/sync_integrations_task.ts @@ -19,9 +19,12 @@ import { errors } from '@elastic/elasticsearch'; import { SO_SEARCH_LIMIT, outputType } from '../../../common/constants'; import type { NewRemoteElasticsearchOutput } from '../../../common/types'; -import { appContextService, outputService } from '../../services'; +import { outputService } from '../../services'; import { getInstalledPackageSavedObjects } from '../../services/epm/packages/get'; -import { FLEET_SYNCED_INTEGRATIONS_INDEX_NAME } from '../../services/setup/fleet_synced_integrations'; +import { + FLEET_SYNCED_INTEGRATIONS_INDEX_NAME, + canEnableSyncIntegrations, +} from '../../services/setup/fleet_synced_integrations'; import { syncIntegrationsOnRemote } from './sync_integrations_on_remote'; import { getCustomAssets } from './custom_assets'; @@ -123,9 +126,7 @@ export class SyncIntegrationsTask { const esClient = coreStart.elasticsearch.client.asInternalUser; const soClient = new SavedObjectsClient(coreStart.savedObjects.createInternalRepository()); - const { enableSyncIntegrationsOnRemote } = appContextService.getExperimentalFeatures(); - - if (!enableSyncIntegrationsOnRemote) { + if (!canEnableSyncIntegrations()) { return; } 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 bde6984eb1bf..72e4f1eeedfb 100644 --- a/x-pack/test/fleet_api_integration/apis/outputs/crud.ts +++ b/x-pack/test/fleet_api_integration/apis/outputs/crud.ts @@ -792,7 +792,7 @@ export default function (providerContext: FtrProviderContext) { } }); - it('should allow to update kibana_api_key on an existing remote_elasticsearch output', async function () { + it('should not allow to update kibana_api_key on an existing remote_elasticsearch output if the license is not at least enterprise', async function () { const res = await supertest .post(`/api/fleet/outputs`) .set('kbn-xsrf', 'xxxx') @@ -800,13 +800,11 @@ export default function (providerContext: FtrProviderContext) { name: 'Remote Output With kibana_api_key', type: 'remote_elasticsearch', hosts: ['https://test.fr:443'], - sync_integrations: true, kibana_url: 'https://testhost', - kibana_api_key: 'aaaa', }) .expect(200); const outputId = res.body.item.id; - const updatedRes = await supertest + await supertest .put(`/api/fleet/outputs/${outputId}`) .set('kbn-xsrf', 'xxxx') .send({ @@ -817,8 +815,7 @@ export default function (providerContext: FtrProviderContext) { kibana_url: 'https://testhost', kibana_api_key: 'bbbb', }) - .expect(200); - expect(updatedRes.body.item.kibana_api_key).to.equal('bbbb'); + .expect(400); }); it('should bump all policies in all spaces if updating the default output', async () => { @@ -1824,7 +1821,7 @@ export default function (providerContext: FtrProviderContext) { .expect(200); }); - it('should allow to create a new remote_elasticsearch output with kibana_api_key field', async function () { + it('should not allow to create a new remote_elasticsearch output with kibana_api_key if the license is not at least enterprise', async function () { await supertest .post(`/api/fleet/outputs`) .set('kbn-xsrf', 'xxxx') @@ -1836,7 +1833,7 @@ export default function (providerContext: FtrProviderContext) { kibana_url: 'https://testhost', kibana_api_key: 'aaaa', }) - .expect(200); + .expect(400); }); });