diff --git a/config/serverless.yml b/config/serverless.yml index 8f7857988d77e..4249d8ff786ec 100644 --- a/config/serverless.yml +++ b/config/serverless.yml @@ -113,6 +113,9 @@ xpack.index_management.enableTogglingDataRetention: false # Disable project level rentention checks in DSL form from Index Management UI xpack.index_management.enableProjectLevelRetentionChecks: false +# Disable Manage Processors UI in Ingest Pipelines +xpack.ingest_pipelines.enableManageProcessors: false + # Keep deeplinks visible so that they are shown in the sidenav dev_tools.deeplinks.navLinkStatus: visible management.deeplinks.navLinkStatus: visible diff --git a/test/plugin_functional/test_suites/core_plugins/rendering.ts b/test/plugin_functional/test_suites/core_plugins/rendering.ts index 02355c97823cf..6a863a78cff15 100644 --- a/test/plugin_functional/test_suites/core_plugins/rendering.ts +++ b/test/plugin_functional/test_suites/core_plugins/rendering.ts @@ -314,6 +314,7 @@ export default function ({ getService }: PluginFunctionalProviderContext) { 'xpack.ml.nlp.modelDeployment.vCPURange.medium.static (number?)', 'xpack.osquery.actionEnabled (boolean?)', 'xpack.remote_clusters.ui.enabled (boolean?)', + 'xpack.ingest_pipelines.enableManageProcessors (boolean?|never)', /** * NOTE: The Reporting plugin is currently disabled in functional tests (see test/functional/config.base.js). * It will be re-enabled once #102552 is completed. diff --git a/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/http_requests.ts b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/http_requests.ts index d7c833ef85403..e9793791a394e 100644 --- a/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/http_requests.ts +++ b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/http_requests.ts @@ -73,12 +73,27 @@ const registerHttpRequestMockHelpers = ( const setParseCsvResponse = (response?: object, error?: ResponseError) => mockResponse('POST', `${API_BASE_PATH}/parse_csv`, response, error); + const setLoadDatabasesResponse = (response?: object[], error?: ResponseError) => + mockResponse('GET', `${API_BASE_PATH}/databases`, response, error); + + const setDeleteDatabasesResponse = ( + databaseName: string, + response?: object, + error?: ResponseError + ) => mockResponse('DELETE', `${API_BASE_PATH}/databases/${databaseName}`, response, error); + + const setCreateDatabasesResponse = (response?: object, error?: ResponseError) => + mockResponse('POST', `${API_BASE_PATH}/databases`, response, error); + return { setLoadPipelinesResponse, setLoadPipelineResponse, setDeletePipelineResponse, setCreatePipelineResponse, setParseCsvResponse, + setLoadDatabasesResponse, + setDeleteDatabasesResponse, + setCreateDatabasesResponse, }; }; diff --git a/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/index.ts b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/index.ts index 5f4dc01fa924a..31cf685e35533 100644 --- a/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/index.ts +++ b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/index.ts @@ -10,8 +10,9 @@ import { setup as pipelinesCreateSetup } from './pipelines_create.helpers'; import { setup as pipelinesCloneSetup } from './pipelines_clone.helpers'; import { setup as pipelinesEditSetup } from './pipelines_edit.helpers'; import { setup as pipelinesCreateFromCsvSetup } from './pipelines_create_from_csv.helpers'; +import { setup as manageProcessorsSetup } from './manage_processors.helpers'; -export { nextTick, getRandomString, findTestSubject } from '@kbn/test-jest-helpers'; +export { getRandomString, findTestSubject } from '@kbn/test-jest-helpers'; export { setupEnvironment } from './setup_environment'; @@ -21,4 +22,5 @@ export const pageHelpers = { pipelinesClone: { setup: pipelinesCloneSetup }, pipelinesEdit: { setup: pipelinesEditSetup }, pipelinesCreateFromCsv: { setup: pipelinesCreateFromCsvSetup }, + manageProcessors: { setup: manageProcessorsSetup }, }; diff --git a/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/manage_processors.helpers.ts b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/manage_processors.helpers.ts new file mode 100644 index 0000000000000..d0127943d7fa3 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/manage_processors.helpers.ts @@ -0,0 +1,144 @@ +/* + * 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 { act } from 'react-dom/test-utils'; +import { HttpSetup } from '@kbn/core/public'; + +import { registerTestBed, TestBed, AsyncTestBedConfig } from '@kbn/test-jest-helpers'; +import { ManageProcessors } from '../../../public/application/sections'; +import { WithAppDependencies } from './setup_environment'; +import { getManageProcessorsPath, ROUTES } from '../../../public/application/services/navigation'; + +const testBedConfig: AsyncTestBedConfig = { + memoryRouter: { + initialEntries: [getManageProcessorsPath()], + componentRoutePath: ROUTES.manageProcessors, + }, + doMountAsync: true, +}; + +export type ManageProcessorsTestBed = TestBed & { + actions: ReturnType; +}; + +const createActions = (testBed: TestBed) => { + const { component, find, form } = testBed; + + const clickDeleteDatabaseButton = async (index: number) => { + const allDeleteButtons = find('deleteGeoipDatabaseButton'); + const deleteButton = allDeleteButtons.at(index); + await act(async () => { + deleteButton.simulate('click'); + }); + + component.update(); + }; + + const confirmDeletingDatabase = async () => { + await act(async () => { + form.setInputValue('geoipDatabaseConfirmation', 'delete'); + }); + + component.update(); + + const confirmButton: HTMLButtonElement | null = document.body.querySelector( + '[data-test-subj="deleteGeoipDatabaseSubmit"]' + ); + + expect(confirmButton).not.toBe(null); + expect(confirmButton!.disabled).toBe(false); + expect(confirmButton!.textContent).toContain('Delete'); + + await act(async () => { + confirmButton!.click(); + }); + + component.update(); + }; + + const clickAddDatabaseButton = async () => { + const button = find('addGeoipDatabaseButton'); + expect(button).not.toBe(undefined); + await act(async () => { + button.simulate('click'); + }); + + component.update(); + }; + + const fillOutDatabaseValues = async ( + databaseType: string, + databaseName: string, + maxmind?: string + ) => { + await act(async () => { + form.setSelectValue('databaseTypeSelect', databaseType); + }); + component.update(); + + if (maxmind) { + await act(async () => { + form.setInputValue('maxmindField', maxmind); + }); + } + await act(async () => { + form.setSelectValue('databaseNameSelect', databaseName); + }); + + component.update(); + }; + + const confirmAddingDatabase = async () => { + const confirmButton: HTMLButtonElement | null = document.body.querySelector( + '[data-test-subj="addGeoipDatabaseSubmit"]' + ); + + expect(confirmButton).not.toBe(null); + expect(confirmButton!.disabled).toBe(false); + + await act(async () => { + confirmButton!.click(); + }); + + component.update(); + }; + + return { + clickDeleteDatabaseButton, + confirmDeletingDatabase, + clickAddDatabaseButton, + fillOutDatabaseValues, + confirmAddingDatabase, + }; +}; + +export const setup = async (httpSetup: HttpSetup): Promise => { + const initTestBed = registerTestBed( + WithAppDependencies(ManageProcessors, httpSetup), + testBedConfig + ); + const testBed = await initTestBed(); + + return { + ...testBed, + actions: createActions(testBed), + }; +}; + +export type ManageProcessorsTestSubjects = + | 'manageProcessorsTitle' + | 'addGeoipDatabaseForm' + | 'addGeoipDatabaseButton' + | 'geoipDatabaseList' + | 'databaseTypeSelect' + | 'maxmindField' + | 'databaseNameSelect' + | 'addGeoipDatabaseSubmit' + | 'deleteGeoipDatabaseButton' + | 'geoipDatabaseConfirmation' + | 'geoipEmptyListPrompt' + | 'geoipListLoadingError'; diff --git a/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/setup_environment.tsx b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/setup_environment.tsx index 58701ffb1dd64..6725a7381decf 100644 --- a/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/setup_environment.tsx +++ b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/helpers/setup_environment.tsx @@ -70,6 +70,9 @@ const appServices = { }, overlays: overlayServiceMock.createStartContract(), http: httpServiceMock.createStartContract({ basePath: '/mock' }), + config: { + enableManageProcessors: true, + }, }; export const setupEnvironment = () => { diff --git a/x-pack/plugins/ingest_pipelines/__jest__/client_integration/manage_processors.test.tsx b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/manage_processors.test.tsx new file mode 100644 index 0000000000000..81375d1e3ae83 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/__jest__/client_integration/manage_processors.test.tsx @@ -0,0 +1,187 @@ +/* + * 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 { act } from 'react-dom/test-utils'; + +import { ManageProcessorsTestBed } from './helpers/manage_processors.helpers'; + +import { setupEnvironment, pageHelpers } from './helpers'; +import type { GeoipDatabase } from '../../common/types'; +import { API_BASE_PATH } from '../../common/constants'; + +const { setup } = pageHelpers.manageProcessors; + +describe('', () => { + const { httpSetup, httpRequestsMockHelpers } = setupEnvironment(); + let testBed: ManageProcessorsTestBed; + + describe('With databases', () => { + beforeEach(async () => { + await act(async () => { + testBed = await setup(httpSetup); + }); + + testBed.component.update(); + }); + + const database1: GeoipDatabase = { + name: 'GeoIP2-Anonymous-IP', + id: 'geoip2-anonymous-ip', + type: 'maxmind', + }; + + const database2: GeoipDatabase = { + name: 'GeoIP2-City', + id: 'geoip2-city', + type: 'maxmind', + }; + + const database3: GeoipDatabase = { + name: 'GeoIP2-Country', + id: 'geoip2-country', + type: 'maxmind', + }; + + const database4: GeoipDatabase = { + name: 'Free-IP-to-ASN', + id: 'free-ip-to-asn', + type: 'ipinfo', + }; + + const databases = [database1, database2, database3, database4]; + + httpRequestsMockHelpers.setLoadDatabasesResponse(databases); + + test('renders the list of databases', async () => { + const { exists, find, table } = testBed; + + // Page title + expect(exists('manageProcessorsTitle')).toBe(true); + expect(find('manageProcessorsTitle').text()).toEqual('Manage Processors'); + + // Add database button + expect(exists('addGeoipDatabaseButton')).toBe(true); + + // Table has columns for database name and type + const { tableCellsValues } = table.getMetaData('geoipDatabaseList'); + tableCellsValues.forEach((row, i) => { + const database = databases[i]; + + expect(row).toEqual([ + database.name, + database.type === 'maxmind' ? 'MaxMind' : 'IPInfo', + '', + ]); + }); + }); + + test('deletes a database', async () => { + const { actions } = testBed; + const databaseIndexToDelete = 0; + const databaseName = databases[databaseIndexToDelete].name; + httpRequestsMockHelpers.setDeleteDatabasesResponse(databaseName, {}); + + await actions.clickDeleteDatabaseButton(databaseIndexToDelete); + + await actions.confirmDeletingDatabase(); + + expect(httpSetup.delete).toHaveBeenLastCalledWith( + `${API_BASE_PATH}/databases/${databaseName.toLowerCase()}`, + expect.anything() + ); + }); + }); + + describe('Creates a database', () => { + it('creates a MaxMind database when none with the same name exists', async () => { + const { actions, exists } = testBed; + const databaseName = 'GeoIP2-ISP'; + const maxmind = '123456'; + httpRequestsMockHelpers.setCreateDatabasesResponse({ + name: databaseName, + id: databaseName.toLowerCase(), + }); + + await actions.clickAddDatabaseButton(); + + expect(exists('addGeoipDatabaseForm')).toBe(true); + + await actions.fillOutDatabaseValues('maxmind', databaseName, maxmind); + + await actions.confirmAddingDatabase(); + + expect(httpSetup.post).toHaveBeenLastCalledWith(`${API_BASE_PATH}/databases`, { + asSystemRequest: undefined, + body: '{"databaseType":"maxmind","databaseName":"GeoIP2-ISP","maxmind":"123456"}', + query: undefined, + version: undefined, + }); + }); + + it('creates an IPInfo database when none with the same name exists', async () => { + const { actions, exists } = testBed; + const databaseName = 'ASN'; + httpRequestsMockHelpers.setCreateDatabasesResponse({ + name: databaseName, + id: databaseName.toLowerCase(), + }); + + await actions.clickAddDatabaseButton(); + + expect(exists('addGeoipDatabaseForm')).toBe(true); + + await actions.fillOutDatabaseValues('ipinfo', databaseName); + + await actions.confirmAddingDatabase(); + + expect(httpSetup.post).toHaveBeenLastCalledWith(`${API_BASE_PATH}/databases`, { + asSystemRequest: undefined, + body: '{"databaseType":"ipinfo","databaseName":"ASN","maxmind":""}', + query: undefined, + version: undefined, + }); + }); + }); + + describe('No databases', () => { + test('displays an empty prompt', async () => { + httpRequestsMockHelpers.setLoadDatabasesResponse([]); + + await act(async () => { + testBed = await setup(httpSetup); + }); + const { exists, component } = testBed; + component.update(); + + expect(exists('geoipEmptyListPrompt')).toBe(true); + }); + }); + + describe('Error handling', () => { + beforeEach(async () => { + const error = { + statusCode: 500, + error: 'Internal server error', + message: 'Internal server error', + }; + + httpRequestsMockHelpers.setLoadDatabasesResponse(undefined, error); + + await act(async () => { + testBed = await setup(httpSetup); + }); + + testBed.component.update(); + }); + + test('displays an error callout', async () => { + const { exists } = testBed; + + expect(exists('geoipListLoadingError')).toBe(true); + }); + }); +}); diff --git a/x-pack/plugins/ingest_pipelines/common/types.ts b/x-pack/plugins/ingest_pipelines/common/types.ts index c526facdedab8..4c68b443fb8fb 100644 --- a/x-pack/plugins/ingest_pipelines/common/types.ts +++ b/x-pack/plugins/ingest_pipelines/common/types.ts @@ -28,16 +28,15 @@ export interface Pipeline { deprecated?: boolean; } -export interface PipelinesByName { - [key: string]: { - description: string; - version?: number; - processors: Processor[]; - on_failure?: Processor[]; - }; -} - export enum FieldCopyAction { Copy = 'copy', Rename = 'rename', } + +export type DatabaseType = 'maxmind' | 'ipinfo' | 'web' | 'local' | 'unknown'; + +export interface GeoipDatabase { + name: string; + id: string; + type: DatabaseType; +} diff --git a/x-pack/plugins/ingest_pipelines/public/application/app.tsx b/x-pack/plugins/ingest_pipelines/public/application/app.tsx index 6b47ed277673e..045db4511e181 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/app.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/app.tsx @@ -27,20 +27,27 @@ import { PipelinesEdit, PipelinesClone, PipelinesCreateFromCsv, + ManageProcessors, } from './sections'; import { ROUTES } from './services/navigation'; -export const AppWithoutRouter = () => ( - - - - - - - {/* Catch all */} - - -); +export const AppWithoutRouter = () => { + const { services } = useKibana(); + return ( + + + + + + + {services.config.enableManageProcessors && ( + + )} + {/* Catch all */} + + + ); +}; export const App: FunctionComponent = () => { const { apiError } = useAuthorizationContext(); diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/index.ts b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/index.ts index 2e4dc65f32314..b55337f088887 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/index.ts +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/index.ts @@ -25,6 +25,7 @@ export { Fingerprint } from './fingerprint'; export { Foreach } from './foreach'; export { GeoGrid } from './geogrid'; export { GeoIP } from './geoip'; +export { IpLocation } from './ip_location'; export { Grok } from './grok'; export { Gsub } from './gsub'; export { HtmlStrip } from './html_strip'; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/ip_location.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/ip_location.tsx new file mode 100644 index 0000000000000..d1b8fbd7ea513 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/processor_form/processors/ip_location.tsx @@ -0,0 +1,131 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { FunctionComponent } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { EuiCode } from '@elastic/eui'; +import { groupBy, map } from 'lodash'; + +import { + FIELD_TYPES, + UseField, + ToggleField, + ComboBoxField, +} from '../../../../../../shared_imports'; + +import { useKibana } from '../../../../../../shared_imports'; +import { FieldNameField } from './common_fields/field_name_field'; +import { IgnoreMissingField } from './common_fields/ignore_missing_field'; +import { FieldsConfig, from, to } from './shared'; +import { TargetField } from './common_fields/target_field'; +import { PropertiesField } from './common_fields/properties_field'; +import type { GeoipDatabase } from '../../../../../../../common/types'; +import { getTypeLabel } from '../../../../../sections/manage_processors/constants'; + +const fieldsConfig: FieldsConfig = { + /* Optional field config */ + database_file: { + type: FIELD_TYPES.COMBO_BOX, + deserializer: to.arrayOfStrings, + serializer: (v: string[]) => (v.length ? v[0] : undefined), + label: i18n.translate('xpack.ingestPipelines.pipelineEditor.ipLocationForm.databaseFileLabel', { + defaultMessage: 'Database file (optional)', + }), + helpText: ( + {'GeoLite2-City.mmdb'}, + ingestGeoIP: {'ingest-geoip'}, + }} + /> + ), + }, + + first_only: { + type: FIELD_TYPES.TOGGLE, + defaultValue: true, + deserializer: to.booleanOrUndef, + serializer: from.undefinedIfValue(true), + label: i18n.translate( + 'xpack.ingestPipelines.pipelineEditor.ipLocationForm.firstOnlyFieldLabel', + { + defaultMessage: 'First only', + } + ), + helpText: i18n.translate( + 'xpack.ingestPipelines.pipelineEditor.ipLocationForm.firstOnlyFieldHelpText', + { + defaultMessage: 'Use the first matching geo data, even if the field contains an array.', + } + ), + }, +}; + +export const IpLocation: FunctionComponent = () => { + const { services } = useKibana(); + const { data, isLoading } = services.api.useLoadDatabases(); + + const dataAsOptions = (data || []).map((item) => ({ + id: item.id, + type: item.type, + label: item.name, + })); + const optionsByGroup = groupBy(dataAsOptions, 'type'); + const groupedOptions = map(optionsByGroup, (items, groupName) => ({ + label: getTypeLabel(groupName as GeoipDatabase['type']), + options: map(items, (item) => item), + })); + + return ( + <> + + + + + + + + + + + + + ); +}; diff --git a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/shared/map_processor_type_to_form.tsx b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/shared/map_processor_type_to_form.tsx index 5d672deb739d3..6618e1bd9b352 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/shared/map_processor_type_to_form.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/components/pipeline_editor/components/shared/map_processor_type_to_form.tsx @@ -32,6 +32,7 @@ import { Foreach, GeoGrid, GeoIP, + IpLocation, Grok, Gsub, HtmlStrip, @@ -477,6 +478,24 @@ export const mapProcessorTypeToDescriptor: MapProcessorTypeToDescriptor = { }, }), }, + ip_location: { + category: processorCategories.DATA_ENRICHMENT, + FieldsComponent: IpLocation, + docLinkPath: '/geoip-processor.html', + label: i18n.translate('xpack.ingestPipelines.processors.label.ipLocation', { + defaultMessage: 'IP Location', + }), + typeDescription: i18n.translate('xpack.ingestPipelines.processors.description.ipLocation', { + defaultMessage: 'Adds geo data based on an IP address.', + }), + getDefaultDescription: ({ field }) => + i18n.translate('xpack.ingestPipelines.processors.defaultDescription.ipLocation', { + defaultMessage: 'Adds geo data to documents based on the value of "{field}"', + values: { + field, + }, + }), + }, grok: { category: processorCategories.DATA_TRANSFORMATION, FieldsComponent: Grok, diff --git a/x-pack/plugins/ingest_pipelines/public/application/constants/index.ts b/x-pack/plugins/ingest_pipelines/public/application/constants/index.ts index 3c415bf9e0682..03aa734800ff6 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/constants/index.ts +++ b/x-pack/plugins/ingest_pipelines/public/application/constants/index.ts @@ -13,3 +13,4 @@ export const UIM_PIPELINE_UPDATE = 'pipeline_update'; export const UIM_PIPELINE_DELETE = 'pipeline_delete'; export const UIM_PIPELINE_DELETE_MANY = 'pipeline_delete_many'; export const UIM_PIPELINE_SIMULATE = 'pipeline_simulate'; +export const UIM_MANAGE_PROCESSORS = 'manage_processes'; diff --git a/x-pack/plugins/ingest_pipelines/public/application/index.tsx b/x-pack/plugins/ingest_pipelines/public/application/index.tsx index 6ec215db8b043..9bc3ba7fe27ad 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/index.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/index.tsx @@ -18,7 +18,7 @@ import type { FileUploadPluginStart } from '@kbn/file-upload-plugin/public'; import type { SettingsStart } from '@kbn/core-ui-settings-browser'; import { KibanaContextProvider, KibanaRenderContextProvider } from '../shared_imports'; -import { ILicense } from '../types'; +import type { Config, ILicense } from '../types'; import { API_BASE_PATH } from '../../common/constants'; @@ -50,6 +50,7 @@ export interface AppServices { consolePlugin?: ConsolePluginStart; overlays: OverlayStart; http: HttpStart; + config: Config; } type StartServices = Pick; @@ -66,7 +67,7 @@ export const renderApp = ( render( diff --git a/x-pack/plugins/ingest_pipelines/public/application/mount_management_section.ts b/x-pack/plugins/ingest_pipelines/public/application/mount_management_section.ts index 4b6ca4f35cd3f..c4382e73720d7 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/mount_management_section.ts +++ b/x-pack/plugins/ingest_pipelines/public/application/mount_management_section.ts @@ -8,7 +8,7 @@ import { CoreSetup } from '@kbn/core/public'; import { ManagementAppMountParams } from '@kbn/management-plugin/public'; -import { StartDependencies, ILicense } from '../types'; +import type { StartDependencies, ILicense, Config } from '../types'; import { documentationService, uiMetricService, @@ -20,13 +20,14 @@ import { renderApp } from '.'; export interface AppParams extends ManagementAppMountParams { license: ILicense | null; + config: Config; } export async function mountManagementSection( { http, getStartServices, notifications }: CoreSetup, params: AppParams ) { - const { element, setBreadcrumbs, history, license } = params; + const { element, setBreadcrumbs, history, license, config } = params; const [coreStart, depsStart] = await getStartServices(); const { docLinks, application, executionContext, overlays } = coreStart; @@ -51,6 +52,7 @@ export async function mountManagementSection( consolePlugin: depsStart.console, overlays, http, + config, }; return renderApp(element, services, { ...coreStart, http }); diff --git a/x-pack/plugins/ingest_pipelines/public/application/sections/index.ts b/x-pack/plugins/ingest_pipelines/public/application/sections/index.ts index bd3ab41936b29..f299c9ec0db74 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/sections/index.ts +++ b/x-pack/plugins/ingest_pipelines/public/application/sections/index.ts @@ -14,3 +14,5 @@ export { PipelinesEdit } from './pipelines_edit'; export { PipelinesClone } from './pipelines_clone'; export { PipelinesCreateFromCsv } from './pipelines_create_from_csv'; + +export { ManageProcessors } from './manage_processors'; diff --git a/x-pack/plugins/ingest_pipelines/public/application/sections/manage_processors/add_database_modal.tsx b/x-pack/plugins/ingest_pipelines/public/application/sections/manage_processors/add_database_modal.tsx new file mode 100644 index 0000000000000..6289fe3953f3e --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/sections/manage_processors/add_database_modal.tsx @@ -0,0 +1,280 @@ +/* + * 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 { + EuiButton, + EuiButtonEmpty, + EuiCallOut, + EuiFieldText, + EuiForm, + EuiFormRow, + EuiModal, + EuiModalBody, + EuiModalFooter, + EuiModalHeader, + EuiModalHeaderTitle, + EuiSelect, + EuiSpacer, +} from '@elastic/eui'; +import React, { useMemo, useState } from 'react'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { css } from '@emotion/react'; +import type { GeoipDatabase } from '../../../../common/types'; +import { useKibana } from '../../../shared_imports'; +import { + ADD_DATABASE_MODAL_TITLE_ID, + ADD_DATABASE_MODAL_FORM_ID, + DATABASE_TYPE_OPTIONS, + GEOIP_NAME_OPTIONS, + IPINFO_NAME_OPTIONS, + getAddDatabaseSuccessMessage, + addDatabaseErrorTitle, +} from './constants'; + +export const AddDatabaseModal = ({ + closeModal, + reloadDatabases, + databases, +}: { + closeModal: () => void; + reloadDatabases: () => void; + databases: GeoipDatabase[]; +}) => { + const [databaseType, setDatabaseType] = useState(undefined); + const [maxmind, setMaxmind] = useState(''); + const [databaseName, setDatabaseName] = useState(''); + const [nameExistsError, setNameExistsError] = useState(false); + const [isLoading, setIsLoading] = useState(false); + + const existingDatabaseNames = useMemo( + () => databases.map((database) => database.name), + [databases] + ); + const { services } = useKibana(); + const onDatabaseNameChange = (value: string) => { + setDatabaseName(value); + setNameExistsError(existingDatabaseNames.includes(value)); + }; + const isFormValid = (): boolean => { + if (!databaseType || nameExistsError) { + return false; + } + if (databaseType === 'maxmind') { + return Boolean(maxmind) && Boolean(databaseName); + } + return Boolean(databaseName); + }; + const onDatabaseTypeChange = (value: string) => { + setDatabaseType(value); + }; + const onAddDatabase = async (event: React.FormEvent) => { + event.preventDefault(); + if (!isFormValid()) { + return; + } + setIsLoading(true); + try { + const { error } = await services.api.createDatabase({ + databaseType: databaseType!, + databaseName, + maxmind, + }); + setIsLoading(false); + if (error) { + services.notifications.toasts.addError(error, { + title: addDatabaseErrorTitle, + }); + } else { + services.notifications.toasts.addSuccess(getAddDatabaseSuccessMessage(databaseName)); + await reloadDatabases(); + closeModal(); + } + } catch (e) { + setIsLoading(false); + services.notifications.toasts.addError(e, { + title: addDatabaseErrorTitle, + }); + } + }; + + return ( + + + + + + + + + onAddDatabase(event)} + data-test-subj="addGeoipDatabaseForm" + > + + } + helpText={ + + } + > + onDatabaseTypeChange(e.target.value)} + data-test-subj="databaseTypeSelect" + /> + + {databaseType === 'maxmind' && ( + <> + + + } + iconType="iInCircle" + > +

+ +

+
+ + + )} + {databaseType === 'ipinfo' && ( + <> + + + } + iconType="iInCircle" + > +

+ +

+
+ + + )} + + {databaseType === 'maxmind' && ( + + } + > + setMaxmind(e.target.value)} + data-test-subj="maxmindField" + /> + + )} + {databaseType && ( + + } + > + onDatabaseNameChange(e.target.value)} + data-test-subj="databaseNameSelect" + /> + + )} +
+ + {nameExistsError && ( + <> + + + } + iconType="warning" + > +

+ +

+
+ + )} +
+ + + + + + + + + + +
+ ); +}; diff --git a/x-pack/plugins/ingest_pipelines/public/application/sections/manage_processors/constants.ts b/x-pack/plugins/ingest_pipelines/public/application/sections/manage_processors/constants.ts new file mode 100644 index 0000000000000..799c3a8c29b40 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/sections/manage_processors/constants.ts @@ -0,0 +1,176 @@ +/* + * 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 { i18n } from '@kbn/i18n'; +import type { GeoipDatabase } from '../../../../common/types'; + +export const ADD_DATABASE_MODAL_TITLE_ID = 'manageProcessorsAddGeoipDatabase'; +export const ADD_DATABASE_MODAL_FORM_ID = 'manageProcessorsAddGeoipDatabaseForm'; +export const DATABASE_TYPE_OPTIONS = [ + { + value: 'maxmind', + text: i18n.translate('xpack.ingestPipelines.manageProcessors.geoip.maxmindDatabaseType', { + defaultMessage: 'MaxMind', + }), + }, + { + value: 'ipinfo', + text: i18n.translate('xpack.ingestPipelines.manageProcessors.geoip.ipinfoDatabaseType', { + defaultMessage: 'IPInfo', + }), + }, +]; +export const GEOIP_NAME_OPTIONS = [ + { + value: 'GeoIP2-Anonymous-IP', + text: i18n.translate('xpack.ingestPipelines.manageProcessors.geoip.anonymousIPDatabaseName', { + defaultMessage: 'GeoIP2 Anonymous IP', + }), + }, + { + value: 'GeoIP2-City', + text: i18n.translate('xpack.ingestPipelines.manageProcessors.geoip.cityDatabaseName', { + defaultMessage: 'GeoIP2 City', + }), + }, + { + value: 'GeoIP2-Connection-Type', + text: i18n.translate( + 'xpack.ingestPipelines.manageProcessors.geoip.connectionTypeDatabaseName', + { + defaultMessage: 'GeoIP2 Connection Type', + } + ), + }, + { + value: 'GeoIP2-Country', + text: i18n.translate('xpack.ingestPipelines.manageProcessors.geoip.countryDatabaseName', { + defaultMessage: 'GeoIP2 Country', + }), + }, + { + value: 'GeoIP2-Domain', + text: i18n.translate('xpack.ingestPipelines.manageProcessors.geoip.domainDatabaseName', { + defaultMessage: 'GeoIP2 Domain', + }), + }, + { + value: 'GeoIP2-Enterprise', + text: i18n.translate('xpack.ingestPipelines.manageProcessors.geoip.enterpriseDatabaseName', { + defaultMessage: 'GeoIP2 Enterprise', + }), + }, + { + value: 'GeoIP2-ISP', + text: i18n.translate('xpack.ingestPipelines.manageProcessors.geoip.ispDatabaseName', { + defaultMessage: 'GeoIP2 ISP', + }), + }, +]; +export const IPINFO_NAME_OPTIONS = [ + { + value: 'asn', + text: i18n.translate('xpack.ingestPipelines.manageProcessors.ipinfo.freeAsnDatabaseName', { + defaultMessage: 'Free IP to ASN', + }), + }, + { + value: 'country', + text: i18n.translate('xpack.ingestPipelines.manageProcessors.ipinfo.freeCountryDatabaseName', { + defaultMessage: 'Free IP to Country', + }), + }, + { + value: 'standard_asn', + text: i18n.translate('xpack.ingestPipelines.manageProcessors.ipinfo.asnDatabaseName', { + defaultMessage: 'ASN', + }), + }, + { + value: 'standard_location', + text: i18n.translate( + 'xpack.ingestPipelines.manageProcessors.ipinfo.ipGeolocationDatabaseName', + { + defaultMessage: 'IP Geolocation', + } + ), + }, + { + value: 'standard_privacy', + text: i18n.translate( + 'xpack.ingestPipelines.manageProcessors.ipinfo.privacyDetectionDatabaseName', + { + defaultMessage: 'Privacy Detection', + } + ), + }, +]; + +export const getAddDatabaseSuccessMessage = (databaseName: string): string => { + return i18n.translate('xpack.ingestPipelines.manageProcessors.geoip.addDatabaseSuccessMessage', { + defaultMessage: 'Added database {databaseName}', + values: { databaseName }, + }); +}; + +export const addDatabaseErrorTitle = i18n.translate( + 'xpack.ingestPipelines.manageProcessors.geoip.addDatabaseErrorTitle', + { + defaultMessage: 'Error adding database', + } +); + +export const DELETE_DATABASE_MODAL_TITLE_ID = 'manageProcessorsDeleteGeoipDatabase'; +export const DELETE_DATABASE_MODAL_FORM_ID = 'manageProcessorsDeleteGeoipDatabaseForm'; + +export const getDeleteDatabaseSuccessMessage = (databaseName: string): string => { + return i18n.translate( + 'xpack.ingestPipelines.manageProcessors.geoip.deleteDatabaseSuccessMessage', + { + defaultMessage: 'Deleted database {databaseName}', + values: { databaseName }, + } + ); +}; + +export const deleteDatabaseErrorTitle = i18n.translate( + 'xpack.ingestPipelines.manageProcessors.geoip.deleteDatabaseErrorTitle', + { + defaultMessage: 'Error deleting database', + } +); + +export const getTypeLabel = (type: GeoipDatabase['type']): string => { + switch (type) { + case 'maxmind': { + return i18n.translate('xpack.ingestPipelines.manageProcessors.geoip.list.typeMaxmindLabel', { + defaultMessage: 'MaxMind', + }); + } + case 'ipinfo': { + return i18n.translate('xpack.ingestPipelines.manageProcessors.geoip.list.typeIpinfoLabel', { + defaultMessage: 'IPInfo', + }); + } + case 'web': { + return i18n.translate('xpack.ingestPipelines.manageProcessors.geoip.list.webLabel', { + defaultMessage: 'Web', + }); + } + case 'local': { + return i18n.translate('xpack.ingestPipelines.manageProcessors.geoip.list.localLabel', { + defaultMessage: 'Local', + }); + } + case 'unknown': + default: { + return i18n.translate('xpack.ingestPipelines.manageProcessors.geoip.list.typeUnknownLabel', { + defaultMessage: 'Unknown', + }); + } + } +}; diff --git a/x-pack/plugins/ingest_pipelines/public/application/sections/manage_processors/delete_database_modal.tsx b/x-pack/plugins/ingest_pipelines/public/application/sections/manage_processors/delete_database_modal.tsx new file mode 100644 index 0000000000000..711fab34984a5 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/sections/manage_processors/delete_database_modal.tsx @@ -0,0 +1,135 @@ +/* + * 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 { + EuiButton, + EuiButtonEmpty, + EuiFieldText, + EuiForm, + EuiFormRow, + EuiModal, + EuiModalBody, + EuiModalFooter, + EuiModalHeader, + EuiModalHeaderTitle, +} from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import React, { useState } from 'react'; +import type { GeoipDatabase } from '../../../../common/types'; +import { useKibana } from '../../../shared_imports'; +import { + DELETE_DATABASE_MODAL_FORM_ID, + DELETE_DATABASE_MODAL_TITLE_ID, + deleteDatabaseErrorTitle, + getDeleteDatabaseSuccessMessage, +} from './constants'; + +export const DeleteDatabaseModal = ({ + closeModal, + database, + reloadDatabases, +}: { + closeModal: () => void; + database: GeoipDatabase; + reloadDatabases: () => void; +}) => { + const [confirmation, setConfirmation] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const isValid = confirmation === 'delete'; + const { services } = useKibana(); + const onDeleteDatabase = async (event: React.FormEvent) => { + event.preventDefault(); + if (!isValid) { + return; + } + setIsLoading(true); + try { + const { error } = await services.api.deleteDatabase(database.id); + setIsLoading(false); + if (error) { + services.notifications.toasts.addError(error, { + title: deleteDatabaseErrorTitle, + }); + } else { + services.notifications.toasts.addSuccess(getDeleteDatabaseSuccessMessage(database.name)); + await reloadDatabases(); + closeModal(); + } + } catch (e) { + setIsLoading(false); + services.notifications.toasts.addError(e, { + title: deleteDatabaseErrorTitle, + }); + } + }; + return ( + + + + + + + + + onDeleteDatabase(event)} + > + + } + > + setConfirmation(e.target.value)} + data-test-subj="geoipDatabaseConfirmation" + /> + + + + + + + + + + + + + + + ); +}; diff --git a/x-pack/plugins/ingest_pipelines/public/application/sections/manage_processors/empty_list.tsx b/x-pack/plugins/ingest_pipelines/public/application/sections/manage_processors/empty_list.tsx new file mode 100644 index 0000000000000..d5e908b155feb --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/sections/manage_processors/empty_list.tsx @@ -0,0 +1,36 @@ +/* + * 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 { EuiPageTemplate } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; +import React from 'react'; + +export const EmptyList = ({ addDatabaseButton }: { addDatabaseButton: JSX.Element }) => { + return ( + + + + } + body={ +

+ +

+ } + actions={addDatabaseButton} + /> + ); +}; diff --git a/x-pack/plugins/ingest_pipelines/public/application/sections/manage_processors/geoip_list.tsx b/x-pack/plugins/ingest_pipelines/public/application/sections/manage_processors/geoip_list.tsx new file mode 100644 index 0000000000000..e09ac4e6e2c4d --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/sections/manage_processors/geoip_list.tsx @@ -0,0 +1,202 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useState } from 'react'; + +import { + EuiButton, + EuiFlexGroup, + EuiFlexItem, + EuiInMemoryTable, + EuiInMemoryTableProps, + EuiPageTemplate, + EuiSpacer, + EuiTitle, + EuiButtonIcon, +} from '@elastic/eui'; + +import { FormattedMessage } from '@kbn/i18n-react'; +import { i18n } from '@kbn/i18n'; +import { css } from '@emotion/react'; + +import { IPINFO_NAME_OPTIONS } from './constants'; +import type { GeoipDatabase } from '../../../../common/types'; +import { SectionLoading, useKibana } from '../../../shared_imports'; +import { getTypeLabel } from './constants'; +import { EmptyList } from './empty_list'; +import { AddDatabaseModal } from './add_database_modal'; +import { DeleteDatabaseModal } from './delete_database_modal'; +import { getErrorMessage } from './get_error_message'; + +export const GeoipList: React.FunctionComponent = () => { + const { services } = useKibana(); + const { data, isLoading, error, resendRequest } = services.api.useLoadDatabases(); + const [showModal, setShowModal] = useState<'add' | 'delete' | null>(null); + const [databaseToDelete, setDatabaseToDelete] = useState(null); + const onDatabaseDelete = (item: GeoipDatabase) => { + setDatabaseToDelete(item); + setShowModal('delete'); + }; + let content: JSX.Element; + const addDatabaseButton = ( + { + setShowModal('add'); + }} + data-test-subj="addGeoipDatabaseButton" + > + + + ); + const tableProps: EuiInMemoryTableProps = { + 'data-test-subj': 'geoipDatabaseList', + rowProps: () => ({ + 'data-test-subj': 'geoipDatabaseListRow', + }), + columns: [ + { + field: 'name', + name: i18n.translate('xpack.ingestPipelines.manageProcessors.geoip.list.nameColumnTitle', { + defaultMessage: 'Database name', + }), + sortable: true, + render: (name: string, row) => { + if (row.type === 'ipinfo') { + // find the name in the options to get the translated value + const option = IPINFO_NAME_OPTIONS.find((opt) => opt.value === name); + return option?.text ?? name; + } + + return name; + }, + }, + { + field: 'type', + name: i18n.translate('xpack.ingestPipelines.manageProcessors.geoip.list.typeColumnTitle', { + defaultMessage: 'Type', + }), + sortable: true, + render: (type: GeoipDatabase['type']) => { + return getTypeLabel(type); + }, + }, + { + name: 'Actions', + align: 'right', + render: (item: GeoipDatabase) => { + // Local and web databases are read only and cannot be deleted through UI + if (['web', 'local'].includes(item.type)) { + return; + } + + return ( + onDatabaseDelete(item)} + data-test-subj="deleteGeoipDatabaseButton" + /> + ); + }, + }, + ], + items: data ?? [], + }; + if (error) { + content = ( + + + + } + body={

{getErrorMessage(error)}

} + actions={ + + + + } + /> + ); + } else if (isLoading && !data) { + content = ( + + + + ); + } else if (data && data.length === 0) { + content = ; + } else { + content = ( + <> + + + +

+ +

+
+
+ {addDatabaseButton} +
+ + + + + ); + } + return ( + <> + {content} + {showModal === 'add' && ( + setShowModal(null)} + reloadDatabases={resendRequest} + databases={data!} + /> + )} + {showModal === 'delete' && databaseToDelete && ( + setShowModal(null)} + /> + )} + + ); +}; diff --git a/x-pack/plugins/ingest_pipelines/public/application/sections/manage_processors/get_error_message.tsx b/x-pack/plugins/ingest_pipelines/public/application/sections/manage_processors/get_error_message.tsx new file mode 100644 index 0000000000000..09767f328da50 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/sections/manage_processors/get_error_message.tsx @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { EuiCode } from '@elastic/eui'; +import { ResponseErrorBody } from '@kbn/core-http-browser'; +import { FormattedMessage } from '@kbn/i18n-react'; + +export const getErrorMessage = (error: ResponseErrorBody) => { + if (error.statusCode === 403) { + return ( + manage, + }} + /> + ); + } + + return error.message; +}; diff --git a/x-pack/plugins/ingest_pipelines/public/application/sections/manage_processors/index.ts b/x-pack/plugins/ingest_pipelines/public/application/sections/manage_processors/index.ts new file mode 100644 index 0000000000000..517fe284874f8 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/sections/manage_processors/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { ManageProcessors } from './manage_processors'; +export { useCheckManageProcessorsPrivileges } from './use_check_manage_processors_privileges'; diff --git a/x-pack/plugins/ingest_pipelines/public/application/sections/manage_processors/manage_processors.tsx b/x-pack/plugins/ingest_pipelines/public/application/sections/manage_processors/manage_processors.tsx new file mode 100644 index 0000000000000..d721441856b15 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/sections/manage_processors/manage_processors.tsx @@ -0,0 +1,44 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useEffect } from 'react'; + +import { EuiPageHeader, EuiSpacer } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n-react'; + +import { useKibana } from '../../../shared_imports'; +import { UIM_MANAGE_PROCESSORS } from '../../constants'; +import { GeoipList } from './geoip_list'; + +export const ManageProcessors: React.FunctionComponent = () => { + const { services } = useKibana(); + // Track component loaded + useEffect(() => { + services.metric.trackUiMetric(UIM_MANAGE_PROCESSORS); + services.breadcrumbs.setBreadcrumbs('manage_processors'); + }, [services.metric, services.breadcrumbs]); + + return ( + <> + + + + } + /> + + + + + + ); +}; diff --git a/x-pack/plugins/ingest_pipelines/public/application/sections/manage_processors/use_check_manage_processors_privileges.ts b/x-pack/plugins/ingest_pipelines/public/application/sections/manage_processors/use_check_manage_processors_privileges.ts new file mode 100644 index 0000000000000..c1afa6dc94209 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/public/application/sections/manage_processors/use_check_manage_processors_privileges.ts @@ -0,0 +1,15 @@ +/* + * 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 { useKibana } from '../../../shared_imports'; + +export const useCheckManageProcessorsPrivileges = () => { + const { services } = useKibana(); + const { isLoading, data: privilegesData } = services.api.useLoadManageProcessorsPrivileges(); + const hasPrivileges = privilegesData?.hasAllPrivileges; + return isLoading ? false : !!hasPrivileges; +}; diff --git a/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_list/main.tsx b/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_list/main.tsx index 886bfcf8b9029..55456ee54e8c9 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_list/main.tsx +++ b/x-pack/plugins/ingest_pipelines/public/application/sections/pipelines_list/main.tsx @@ -26,7 +26,14 @@ import { import { Pipeline } from '../../../../common/types'; import { useKibana, SectionLoading } from '../../../shared_imports'; import { UIM_PIPELINES_LIST_LOAD } from '../../constants'; -import { getEditPath, getClonePath } from '../../services/navigation'; +import { + getEditPath, + getClonePath, + getCreateFromCsvPath, + getCreatePath, + getManageProcessorsPath, +} from '../../services/navigation'; +import { useCheckManageProcessorsPrivileges } from '../manage_processors'; import { EmptyList } from './empty_list'; import { PipelineTable } from './table'; @@ -54,6 +61,7 @@ export const PipelinesList: React.FunctionComponent = ({ const { data, isLoading, error, resendRequest } = services.api.useLoadPipelines(); + const hasManageProcessorsPrivileges = useCheckManageProcessorsPrivileges(); // Track component loaded useEffect(() => { services.metric.trackUiMetric(UIM_PIPELINES_LIST_LOAD); @@ -142,7 +150,7 @@ export const PipelinesList: React.FunctionComponent = ({ name: i18n.translate('xpack.ingestPipelines.list.table.createPipelineButtonLabel', { defaultMessage: 'New pipeline', }), - ...reactRouterNavigate(history, '/create'), + ...reactRouterNavigate(history, getCreatePath()), 'data-test-subj': `createNewPipeline`, }, /** @@ -152,10 +160,71 @@ export const PipelinesList: React.FunctionComponent = ({ name: i18n.translate('xpack.ingestPipelines.list.table.createPipelineFromCsvButtonLabel', { defaultMessage: 'New pipeline from CSV', }), - ...reactRouterNavigate(history, '/csv_create'), + ...reactRouterNavigate(history, getCreateFromCsvPath()), 'data-test-subj': `createPipelineFromCsv`, }, ]; + const titleActionButtons = [ + setShowPopover(false)} + button={ + setShowPopover((previousBool) => !previousBool)} + > + {i18n.translate('xpack.ingestPipelines.list.table.createPipelineDropdownLabel', { + defaultMessage: 'Create pipeline', + })} + + } + panelPaddingSize="none" + repositionOnScroll + > + + , + ]; + if (services.config.enableManageProcessors && hasManageProcessorsPrivileges) { + titleActionButtons.push( + + + + ); + } + titleActionButtons.push( + + + + ); const renderFlyout = (): React.ReactNode => { if (!showFlyout) { @@ -199,51 +268,7 @@ export const PipelinesList: React.FunctionComponent = ({ defaultMessage="Use ingest pipelines to remove or transform fields, extract values from text, and enrich your data before indexing into Elasticsearch." /> } - rightSideItems={[ - setShowPopover(false)} - button={ - setShowPopover((previousBool) => !previousBool)} - > - {i18n.translate('xpack.ingestPipelines.list.table.createPipelineDropdownLabel', { - defaultMessage: 'Create pipeline', - })} - - } - panelPaddingSize="none" - repositionOnScroll - > - - , - - - , - ]} + rightSideItems={titleActionButtons} /> diff --git a/x-pack/plugins/ingest_pipelines/public/application/services/api.ts b/x-pack/plugins/ingest_pipelines/public/application/services/api.ts index f687c80351075..e32245e325b15 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/services/api.ts +++ b/x-pack/plugins/ingest_pipelines/public/application/services/api.ts @@ -5,9 +5,9 @@ * 2.0. */ -import { HttpSetup } from '@kbn/core/public'; +import { HttpSetup, ResponseErrorBody } from '@kbn/core/public'; -import { FieldCopyAction, Pipeline } from '../../../common/types'; +import type { FieldCopyAction, GeoipDatabase, Pipeline } from '../../../common/types'; import { API_BASE_PATH } from '../../../common/constants'; import { UseRequestConfig, @@ -140,6 +140,39 @@ export class ApiService { }); return result; } + + public useLoadDatabases() { + return this.useRequest({ + path: `${API_BASE_PATH}/databases`, + method: 'get', + }); + } + + public async createDatabase(database: { + databaseType: string; + maxmind?: string; + databaseName: string; + }) { + return this.sendRequest({ + path: `${API_BASE_PATH}/databases`, + method: 'post', + body: JSON.stringify(database), + }); + } + + public async deleteDatabase(id: string) { + return this.sendRequest({ + path: `${API_BASE_PATH}/databases/${id}`, + method: 'delete', + }); + } + + public useLoadManageProcessorsPrivileges() { + return this.useRequest<{ hasAllPrivileges: boolean }>({ + path: `${API_BASE_PATH}/privileges/manage_processors`, + method: 'get', + }); + } } export const apiService = new ApiService(); diff --git a/x-pack/plugins/ingest_pipelines/public/application/services/breadcrumbs.ts b/x-pack/plugins/ingest_pipelines/public/application/services/breadcrumbs.ts index f09b1325f7982..e8b010917cfae 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/services/breadcrumbs.ts +++ b/x-pack/plugins/ingest_pipelines/public/application/services/breadcrumbs.ts @@ -48,6 +48,17 @@ export class BreadcrumbService { }), }, ], + manage_processors: [ + { + text: homeBreadcrumbText, + href: `/`, + }, + { + text: i18n.translate('xpack.ingestPipelines.breadcrumb.manageProcessorsLabel', { + defaultMessage: 'Manage processors', + }), + }, + ], }; private setBreadcrumbsHandler?: SetBreadcrumbs; @@ -56,7 +67,7 @@ export class BreadcrumbService { this.setBreadcrumbsHandler = setBreadcrumbsHandler; } - public setBreadcrumbs(type: 'create' | 'home' | 'edit'): void { + public setBreadcrumbs(type: 'create' | 'home' | 'edit' | 'manage_processors'): void { if (!this.setBreadcrumbsHandler) { throw new Error('Breadcrumb service has not been initialized'); } diff --git a/x-pack/plugins/ingest_pipelines/public/application/services/navigation.ts b/x-pack/plugins/ingest_pipelines/public/application/services/navigation.ts index 7d3e11fea3d89..aa4f95be09b17 100644 --- a/x-pack/plugins/ingest_pipelines/public/application/services/navigation.ts +++ b/x-pack/plugins/ingest_pipelines/public/application/services/navigation.ts @@ -13,6 +13,8 @@ const CREATE_PATH = 'create'; const CREATE_FROM_CSV_PATH = 'csv_create'; +const MANAGE_PROCESSORS_PATH = 'manage_processors'; + const _getEditPath = (name: string, encode = true): string => { return `${BASE_PATH}${EDIT_PATH}/${encode ? encodeURIComponent(name) : name}`; }; @@ -33,12 +35,17 @@ const _getCreateFromCsvPath = (): string => { return `${BASE_PATH}${CREATE_FROM_CSV_PATH}`; }; +const _getManageProcessorsPath = (): string => { + return `${BASE_PATH}${MANAGE_PROCESSORS_PATH}`; +}; + export const ROUTES = { list: _getListPath(), edit: _getEditPath(':name', false), create: _getCreatePath(), clone: _getClonePath(':sourceName', false), createFromCsv: _getCreateFromCsvPath(), + manageProcessors: _getManageProcessorsPath(), }; export const getListPath = ({ @@ -52,3 +59,4 @@ export const getCreatePath = (): string => _getCreatePath(); export const getClonePath = ({ clonedPipelineName }: { clonedPipelineName: string }): string => _getClonePath(clonedPipelineName, true); export const getCreateFromCsvPath = (): string => _getCreateFromCsvPath(); +export const getManageProcessorsPath = (): string => _getManageProcessorsPath(); diff --git a/x-pack/plugins/ingest_pipelines/public/index.ts b/x-pack/plugins/ingest_pipelines/public/index.ts index b269245faf520..d7fb12c5477d3 100644 --- a/x-pack/plugins/ingest_pipelines/public/index.ts +++ b/x-pack/plugins/ingest_pipelines/public/index.ts @@ -5,10 +5,11 @@ * 2.0. */ +import { PluginInitializerContext } from '@kbn/core/public'; import { IngestPipelinesPlugin } from './plugin'; -export function plugin() { - return new IngestPipelinesPlugin(); +export function plugin(context: PluginInitializerContext) { + return new IngestPipelinesPlugin(context); } export { INGEST_PIPELINES_APP_LOCATOR, INGEST_PIPELINES_PAGES } from './locator'; diff --git a/x-pack/plugins/ingest_pipelines/public/plugin.ts b/x-pack/plugins/ingest_pipelines/public/plugin.ts index ae180b8378af3..75a6139e95933 100644 --- a/x-pack/plugins/ingest_pipelines/public/plugin.ts +++ b/x-pack/plugins/ingest_pipelines/public/plugin.ts @@ -7,11 +7,11 @@ import { i18n } from '@kbn/i18n'; import { Subscription } from 'rxjs'; -import { CoreStart, CoreSetup, Plugin } from '@kbn/core/public'; +import type { CoreStart, CoreSetup, Plugin, PluginInitializerContext } from '@kbn/core/public'; import { PLUGIN_ID } from '../common/constants'; import { uiMetricService, apiService } from './application/services'; -import { SetupDependencies, StartDependencies, ILicense } from './types'; +import type { SetupDependencies, StartDependencies, ILicense, Config } from './types'; import { IngestPipelinesLocatorDefinition } from './locator'; export class IngestPipelinesPlugin @@ -19,6 +19,11 @@ export class IngestPipelinesPlugin { private license: ILicense | null = null; private licensingSubscription?: Subscription; + private readonly config: Config; + + constructor(initializerContext: PluginInitializerContext) { + this.config = initializerContext.config.get(); + } public setup(coreSetup: CoreSetup, plugins: SetupDependencies): void { const { management, usageCollection, share } = plugins; @@ -49,6 +54,9 @@ export class IngestPipelinesPlugin const unmountAppCallback = await mountManagementSection(coreSetup, { ...params, license: this.license, + config: { + enableManageProcessors: this.config.enableManageProcessors !== false, + }, }); return () => { diff --git a/x-pack/plugins/ingest_pipelines/public/types.ts b/x-pack/plugins/ingest_pipelines/public/types.ts index bfa1ac4300b3a..5b1dee11d37e0 100644 --- a/x-pack/plugins/ingest_pipelines/public/types.ts +++ b/x-pack/plugins/ingest_pipelines/public/types.ts @@ -25,3 +25,7 @@ export interface StartDependencies { licensing?: LicensingPluginStart; console?: ConsolePluginStart; } + +export interface Config { + enableManageProcessors: boolean; +} diff --git a/x-pack/plugins/ingest_pipelines/server/config.ts b/x-pack/plugins/ingest_pipelines/server/config.ts new file mode 100644 index 0000000000000..dc3dcf86a6256 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/server/config.ts @@ -0,0 +1,29 @@ +/* + * 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 { offeringBasedSchema, schema, TypeOf } from '@kbn/config-schema'; +import { PluginConfigDescriptor } from '@kbn/core-plugins-server'; + +const configSchema = schema.object( + { + enableManageProcessors: offeringBasedSchema({ + // Manage processors UI is disabled in serverless; refer to the serverless.yml file as the source of truth + // We take this approach in order to have a central place (serverless.yml) for serverless config across Kibana + serverless: schema.boolean({ defaultValue: true }), + }), + }, + { defaultValue: undefined } +); + +export type IngestPipelinesConfigType = TypeOf; + +export const config: PluginConfigDescriptor = { + schema: configSchema, + exposeToBrowser: { + enableManageProcessors: true, + }, +}; diff --git a/x-pack/plugins/ingest_pipelines/server/index.ts b/x-pack/plugins/ingest_pipelines/server/index.ts index aac84c37591db..b48d8214c1264 100644 --- a/x-pack/plugins/ingest_pipelines/server/index.ts +++ b/x-pack/plugins/ingest_pipelines/server/index.ts @@ -5,7 +5,11 @@ * 2.0. */ -export async function plugin() { +import { PluginInitializerContext } from '@kbn/core/server'; + +export { config } from './config'; + +export async function plugin(context: PluginInitializerContext) { const { IngestPipelinesPlugin } = await import('./plugin'); - return new IngestPipelinesPlugin(); + return new IngestPipelinesPlugin(context); } diff --git a/x-pack/plugins/ingest_pipelines/server/plugin.ts b/x-pack/plugins/ingest_pipelines/server/plugin.ts index ea1d9fc01c42a..85ca1691bf392 100644 --- a/x-pack/plugins/ingest_pipelines/server/plugin.ts +++ b/x-pack/plugins/ingest_pipelines/server/plugin.ts @@ -5,17 +5,20 @@ * 2.0. */ -import { CoreSetup, Plugin } from '@kbn/core/server'; +import { CoreSetup, Plugin, PluginInitializerContext } from '@kbn/core/server'; +import { IngestPipelinesConfigType } from './config'; import { ApiRoutes } from './routes'; import { handleEsError } from './shared_imports'; import { Dependencies } from './types'; export class IngestPipelinesPlugin implements Plugin { private readonly apiRoutes: ApiRoutes; + private readonly config: IngestPipelinesConfigType; - constructor() { + constructor(initContext: PluginInitializerContext) { this.apiRoutes = new ApiRoutes(); + this.config = initContext.config.get(); } public setup({ http }: CoreSetup, { security, features }: Dependencies) { @@ -38,6 +41,7 @@ export class IngestPipelinesPlugin implements Plugin { router, config: { isSecurityEnabled: () => security !== undefined && security.license.isEnabled(), + enableManageProcessors: this.config.enableManageProcessors !== false, }, lib: { handleEsError, diff --git a/x-pack/plugins/ingest_pipelines/server/routes/api/database/create.ts b/x-pack/plugins/ingest_pipelines/server/routes/api/database/create.ts new file mode 100644 index 0000000000000..56fef0e159d66 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/server/routes/api/database/create.ts @@ -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 { schema } from '@kbn/config-schema'; +import { RouteDependencies } from '../../../types'; +import { API_BASE_PATH } from '../../../../common/constants'; +import { serializeGeoipDatabase } from './serialization'; +import { normalizeDatabaseName } from './normalize_database_name'; + +const bodySchema = schema.object({ + databaseType: schema.oneOf([schema.literal('ipinfo'), schema.literal('maxmind')]), + // maxmind is only needed for "geoip" type + maxmind: schema.maybe(schema.string({ maxLength: 1000 })), + // only allow database names in sync with ES + databaseName: schema.oneOf([ + // geoip names https://github.com/elastic/elasticsearch/blob/f150e2c11df0fe3bef298c55bd867437e50f5f73/modules/ingest-geoip/src/main/java/org/elasticsearch/ingest/geoip/direct/DatabaseConfiguration.java#L58 + schema.literal('GeoIP2-Anonymous-IP'), + schema.literal('GeoIP2-City'), + schema.literal('GeoIP2-Connection-Type'), + schema.literal('GeoIP2-Country'), + schema.literal('GeoIP2-Domain'), + schema.literal('GeoIP2-Enterprise'), + schema.literal('GeoIP2-ISP'), + // ipinfo names + schema.literal('asn'), + schema.literal('country'), + schema.literal('standard_asn'), + schema.literal('standard_location'), + schema.literal('standard_privacy'), + ]), +}); + +export const registerCreateDatabaseRoute = ({ + router, + lib: { handleEsError }, +}: RouteDependencies): void => { + router.post( + { + path: `${API_BASE_PATH}/databases`, + validate: { + body: bodySchema, + }, + }, + async (ctx, req, res) => { + const { client: clusterClient } = (await ctx.core).elasticsearch; + const { databaseType, databaseName, maxmind } = req.body; + const serializedDatabase = serializeGeoipDatabase({ databaseType, databaseName, maxmind }); + const normalizedDatabaseName = normalizeDatabaseName(databaseName); + + try { + // TODO: Replace this request with the one below when the JS client fixed + await clusterClient.asCurrentUser.transport.request({ + method: 'PUT', + path: `/_ingest/ip_location/database/${normalizedDatabaseName}`, + body: serializedDatabase, + }); + + // This request fails because there is a bug in the JS client + // await clusterClient.asCurrentUser.ingest.putGeoipDatabase({ + // id: normalizedDatabaseName, + // body: serializedDatabase, + // }); + + return res.ok({ body: { name: databaseName, id: normalizedDatabaseName } }); + } catch (error) { + return handleEsError({ error, response: res }); + } + } + ); +}; diff --git a/x-pack/plugins/ingest_pipelines/server/routes/api/database/delete.ts b/x-pack/plugins/ingest_pipelines/server/routes/api/database/delete.ts new file mode 100644 index 0000000000000..69dcde1436fd6 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/server/routes/api/database/delete.ts @@ -0,0 +1,40 @@ +/* + * 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 { schema } from '@kbn/config-schema'; +import { RouteDependencies } from '../../../types'; +import { API_BASE_PATH } from '../../../../common/constants'; + +const paramsSchema = schema.object({ + database_id: schema.string(), +}); + +export const registerDeleteDatabaseRoute = ({ + router, + lib: { handleEsError }, +}: RouteDependencies): void => { + router.delete( + { + path: `${API_BASE_PATH}/databases/{database_id}`, + validate: { + params: paramsSchema, + }, + }, + async (ctx, req, res) => { + const { client: clusterClient } = (await ctx.core).elasticsearch; + const { database_id: databaseID } = req.params; + + try { + await clusterClient.asCurrentUser.ingest.deleteGeoipDatabase({ id: databaseID }); + + return res.ok(); + } catch (error) { + return handleEsError({ error, response: res }); + } + } + ); +}; diff --git a/x-pack/plugins/ingest_pipelines/server/routes/api/database/index.ts b/x-pack/plugins/ingest_pipelines/server/routes/api/database/index.ts new file mode 100644 index 0000000000000..612b52dbd0643 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/server/routes/api/database/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { registerListDatabaseRoute } from './list'; +export { registerCreateDatabaseRoute } from './create'; +export { registerDeleteDatabaseRoute } from './delete'; diff --git a/x-pack/plugins/ingest_pipelines/server/routes/api/database/list.ts b/x-pack/plugins/ingest_pipelines/server/routes/api/database/list.ts new file mode 100644 index 0000000000000..b3509a5486435 --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/server/routes/api/database/list.ts @@ -0,0 +1,37 @@ +/* + * 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 { deserializeGeoipDatabase, type GeoipDatabaseFromES } from './serialization'; +import { API_BASE_PATH } from '../../../../common/constants'; +import { RouteDependencies } from '../../../types'; + +export const registerListDatabaseRoute = ({ + router, + lib: { handleEsError }, +}: RouteDependencies): void => { + router.get({ path: `${API_BASE_PATH}/databases`, validate: false }, async (ctx, req, res) => { + const { client: clusterClient } = (await ctx.core).elasticsearch; + + try { + const data = (await clusterClient.asCurrentUser.ingest.getGeoipDatabase()) as { + databases: GeoipDatabaseFromES[]; + }; + + const geoipDatabases = data.databases; + + return res.ok({ body: geoipDatabases.map(deserializeGeoipDatabase) }); + } catch (error) { + const esErrorResponse = handleEsError({ error, response: res }); + if (esErrorResponse.status === 404) { + // ES returns 404 when there are no pipelines + // Instead, we return an empty array and 200 status back to the client + return res.ok({ body: [] }); + } + return esErrorResponse; + } + }); +}; diff --git a/x-pack/plugins/ingest_pipelines/server/routes/api/database/normalize_database_name.ts b/x-pack/plugins/ingest_pipelines/server/routes/api/database/normalize_database_name.ts new file mode 100644 index 0000000000000..36f142d91a28d --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/server/routes/api/database/normalize_database_name.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const normalizeDatabaseName = (databaseName: string): string => { + return databaseName.replace(/\s+/g, '_').toLowerCase(); +}; diff --git a/x-pack/plugins/ingest_pipelines/server/routes/api/database/serialization.ts b/x-pack/plugins/ingest_pipelines/server/routes/api/database/serialization.ts new file mode 100644 index 0000000000000..2f2c93ba5334d --- /dev/null +++ b/x-pack/plugins/ingest_pipelines/server/routes/api/database/serialization.ts @@ -0,0 +1,94 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export interface GeoipDatabaseFromES { + id: string; + version: number; + modified_date_millis: number; + database: { + name: string; + // maxmind type + maxmind?: { + account_id: string; + }; + // ipinfo type + ipinfo?: {}; + // local type + local?: {}; + // web type + web?: {}; + }; +} + +interface SerializedGeoipDatabase { + name: string; + ipinfo?: {}; + local?: {}; + web?: {}; + maxmind?: { + account_id: string; + }; +} + +const getGeoipType = ({ database }: GeoipDatabaseFromES) => { + if (database.maxmind && database.maxmind.account_id) { + return 'maxmind'; + } + + if (database.ipinfo) { + return 'ipinfo'; + } + + if (database.local) { + return 'local'; + } + + if (database.web) { + return 'web'; + } + + return 'unknown'; +}; + +export const deserializeGeoipDatabase = (geoipDatabase: GeoipDatabaseFromES) => { + const { database, id } = geoipDatabase; + return { + name: database.name, + id, + type: getGeoipType(geoipDatabase), + }; +}; + +export const serializeGeoipDatabase = ({ + databaseType, + databaseName, + maxmind, +}: { + databaseType: 'maxmind' | 'ipinfo' | 'local' | 'web'; + databaseName: string; + maxmind?: string; +}): SerializedGeoipDatabase => { + const database = { name: databaseName } as SerializedGeoipDatabase; + + if (databaseType === 'maxmind') { + database.maxmind = { account_id: maxmind ?? '' }; + } + + if (databaseType === 'ipinfo') { + database.ipinfo = {}; + } + + if (databaseType === 'local') { + database.local = {}; + } + + if (databaseType === 'web') { + database.web = {}; + } + + return database; +}; diff --git a/x-pack/plugins/ingest_pipelines/server/routes/api/index.ts b/x-pack/plugins/ingest_pipelines/server/routes/api/index.ts index aec90d2c3a2eb..7be84d9baad87 100644 --- a/x-pack/plugins/ingest_pipelines/server/routes/api/index.ts +++ b/x-pack/plugins/ingest_pipelines/server/routes/api/index.ts @@ -20,3 +20,9 @@ export { registerSimulateRoute } from './simulate'; export { registerDocumentsRoute } from './documents'; export { registerParseCsvRoute } from './parse_csv'; + +export { + registerListDatabaseRoute, + registerCreateDatabaseRoute, + registerDeleteDatabaseRoute, +} from './database'; diff --git a/x-pack/plugins/ingest_pipelines/server/routes/api/privileges.ts b/x-pack/plugins/ingest_pipelines/server/routes/api/privileges.ts index 29b282b5fbf20..87f0e3e79f07f 100644 --- a/x-pack/plugins/ingest_pipelines/server/routes/api/privileges.ts +++ b/x-pack/plugins/ingest_pipelines/server/routes/api/privileges.ts @@ -6,9 +6,14 @@ */ import { Privileges } from '@kbn/es-ui-shared-plugin/common'; +import { schema } from '@kbn/config-schema'; import { RouteDependencies } from '../../types'; import { API_BASE_PATH, APP_CLUSTER_REQUIRED_PRIVILEGES } from '../../../common/constants'; +const requiredPrivilegesMap = { + ingest_pipelines: APP_CLUSTER_REQUIRED_PRIVILEGES, + manage_processors: ['manage'], +}; const extractMissingPrivileges = (privilegesObject: { [key: string]: boolean } = {}): string[] => Object.keys(privilegesObject).reduce((privileges: string[], privilegeName: string): string[] => { if (!privilegesObject[privilegeName]) { @@ -20,10 +25,18 @@ const extractMissingPrivileges = (privilegesObject: { [key: string]: boolean } = export const registerPrivilegesRoute = ({ router, config }: RouteDependencies) => { router.get( { - path: `${API_BASE_PATH}/privileges`, - validate: false, + path: `${API_BASE_PATH}/privileges/{permissions_type}`, + validate: { + params: schema.object({ + permissions_type: schema.oneOf([ + schema.literal('ingest_pipelines'), + schema.literal('manage_processors'), + ]), + }), + }, }, async (ctx, req, res) => { + const permissionsType = req.params.permissions_type; const privilegesResult: Privileges = { hasAllPrivileges: true, missingPrivileges: { @@ -38,9 +51,10 @@ export const registerPrivilegesRoute = ({ router, config }: RouteDependencies) = const { client: clusterClient } = (await ctx.core).elasticsearch; + const requiredPrivileges = requiredPrivilegesMap[permissionsType]; const { has_all_requested: hasAllPrivileges, cluster } = await clusterClient.asCurrentUser.security.hasPrivileges({ - body: { cluster: APP_CLUSTER_REQUIRED_PRIVILEGES }, + body: { cluster: requiredPrivileges }, }); if (!hasAllPrivileges) { diff --git a/x-pack/plugins/ingest_pipelines/server/routes/index.ts b/x-pack/plugins/ingest_pipelines/server/routes/index.ts index d3d74b31c1013..9a74a285fb5e4 100644 --- a/x-pack/plugins/ingest_pipelines/server/routes/index.ts +++ b/x-pack/plugins/ingest_pipelines/server/routes/index.ts @@ -16,6 +16,9 @@ import { registerSimulateRoute, registerDocumentsRoute, registerParseCsvRoute, + registerListDatabaseRoute, + registerCreateDatabaseRoute, + registerDeleteDatabaseRoute, } from './api'; export class ApiRoutes { @@ -28,5 +31,10 @@ export class ApiRoutes { registerSimulateRoute(dependencies); registerDocumentsRoute(dependencies); registerParseCsvRoute(dependencies); + if (dependencies.config.enableManageProcessors) { + registerListDatabaseRoute(dependencies); + registerCreateDatabaseRoute(dependencies); + registerDeleteDatabaseRoute(dependencies); + } } } diff --git a/x-pack/plugins/ingest_pipelines/server/types.ts b/x-pack/plugins/ingest_pipelines/server/types.ts index 34c821b90e79c..8204e7f21e93d 100644 --- a/x-pack/plugins/ingest_pipelines/server/types.ts +++ b/x-pack/plugins/ingest_pipelines/server/types.ts @@ -19,6 +19,7 @@ export interface RouteDependencies { router: IRouter; config: { isSecurityEnabled: () => boolean; + enableManageProcessors: boolean; }; lib: { handleEsError: typeof handleEsError; diff --git a/x-pack/plugins/ingest_pipelines/tsconfig.json b/x-pack/plugins/ingest_pipelines/tsconfig.json index 7570a8f659167..5792ac1b9fda1 100644 --- a/x-pack/plugins/ingest_pipelines/tsconfig.json +++ b/x-pack/plugins/ingest_pipelines/tsconfig.json @@ -36,7 +36,9 @@ "@kbn/react-kibana-context-theme", "@kbn/unsaved-changes-prompt", "@kbn/core-http-browser-mocks", - "@kbn/shared-ux-table-persist" + "@kbn/shared-ux-table-persist", + "@kbn/core-http-browser", + "@kbn/core-plugins-server" ], "exclude": [ "target/**/*", diff --git a/x-pack/test/api_integration/apis/management/ingest_pipelines/databases.ts b/x-pack/test/api_integration/apis/management/ingest_pipelines/databases.ts new file mode 100644 index 0000000000000..93a7ccc7d4088 --- /dev/null +++ b/x-pack/test/api_integration/apis/management/ingest_pipelines/databases.ts @@ -0,0 +1,67 @@ +/* + * 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 expect from '@kbn/expect'; + +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const ingestPipelines = getService('ingestPipelines'); + const url = `/api/ingest_pipelines/databases`; + const databaseName = 'GeoIP2-Anonymous-IP'; + const normalizedDatabaseName = 'geoip2-anonymous-ip'; + + describe('Manage databases', function () { + after(async () => { + await ingestPipelines.api.deleteGeoipDatabases(); + }); + + describe('Create', () => { + it('creates a geoip database when using a correct database name', async () => { + const database = { maxmind: '123456', databaseName }; + const { body } = await supertest + .post(url) + .set('kbn-xsrf', 'xxx') + .send(database) + .expect(200); + + expect(body).to.eql({ + name: databaseName, + id: normalizedDatabaseName, + }); + }); + + it('creates a geoip database when using an incorrect database name', async () => { + const database = { maxmind: '123456', databaseName: 'Test' }; + await supertest.post(url).set('kbn-xsrf', 'xxx').send(database).expect(400); + }); + }); + + describe('List', () => { + it('returns existing databases', async () => { + const { body } = await supertest.get(url).set('kbn-xsrf', 'xxx').expect(200); + expect(body).to.eql([ + { + id: normalizedDatabaseName, + name: databaseName, + type: 'maxmind', + }, + ]); + }); + }); + + describe('Delete', () => { + it('deletes a geoip database', async () => { + await supertest + .delete(`${url}/${normalizedDatabaseName}`) + .set('kbn-xsrf', 'xxx') + .expect(200); + }); + }); + }); +} diff --git a/x-pack/test/api_integration/apis/management/ingest_pipelines/index.ts b/x-pack/test/api_integration/apis/management/ingest_pipelines/index.ts new file mode 100644 index 0000000000000..0afcb720dc3cd --- /dev/null +++ b/x-pack/test/api_integration/apis/management/ingest_pipelines/index.ts @@ -0,0 +1,14 @@ +/* + * 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 { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ loadTestFile }: FtrProviderContext) { + describe('Ingest pipelines', () => { + loadTestFile(require.resolve('./databases')); + }); +} diff --git a/x-pack/test/api_integration/services/ingest_pipelines/geoip_databases.ts b/x-pack/test/api_integration/services/ingest_pipelines/geoip_databases.ts new file mode 100644 index 0000000000000..1fec1c76430eb --- /dev/null +++ b/x-pack/test/api_integration/services/ingest_pipelines/geoip_databases.ts @@ -0,0 +1,6 @@ +/* + * 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. + */ diff --git a/x-pack/test/api_integration/services/ingest_pipelines/lib/api.ts b/x-pack/test/api_integration/services/ingest_pipelines/lib/api.ts index e1f4b8a430314..493540afa4710 100644 --- a/x-pack/test/api_integration/services/ingest_pipelines/lib/api.ts +++ b/x-pack/test/api_integration/services/ingest_pipelines/lib/api.ts @@ -70,5 +70,20 @@ export function IngestPipelinesAPIProvider({ getService }: FtrProviderContext) { return await es.indices.delete({ index: indexName }); }, + + async deleteGeoipDatabases() { + const { databases } = await es.ingest.getGeoipDatabase(); + // Remove all geoip databases + const databaseIds = databases.map((database: { id: string }) => database.id); + + const deleteDatabase = (id: string) => + es.ingest.deleteGeoipDatabase({ + id, + }); + + return Promise.all(databaseIds.map(deleteDatabase)).catch((err) => { + log.debug(`[Cleanup error] Error deleting ES resources: ${err.message}`); + }); + }, }; } diff --git a/x-pack/test/functional/apps/ingest_pipelines/index.ts b/x-pack/test/functional/apps/ingest_pipelines/index.ts index 3c585319cfe13..1f77f5078de9f 100644 --- a/x-pack/test/functional/apps/ingest_pipelines/index.ts +++ b/x-pack/test/functional/apps/ingest_pipelines/index.ts @@ -11,5 +11,6 @@ export default ({ loadTestFile }: FtrProviderContext) => { describe('Ingest pipelines app', function () { loadTestFile(require.resolve('./feature_controls')); loadTestFile(require.resolve('./ingest_pipelines')); + loadTestFile(require.resolve('./manage_processors')); }); }; diff --git a/x-pack/test/functional/apps/ingest_pipelines/manage_processors.ts b/x-pack/test/functional/apps/ingest_pipelines/manage_processors.ts new file mode 100644 index 0000000000000..a4951a2829fd0 --- /dev/null +++ b/x-pack/test/functional/apps/ingest_pipelines/manage_processors.ts @@ -0,0 +1,95 @@ +/* + * 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 expect from '@kbn/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default ({ getPageObjects, getService }: FtrProviderContext) => { + const pageObjects = getPageObjects(['common', 'ingestPipelines', 'savedObjects']); + const security = getService('security'); + const maxMindDatabaseName = 'GeoIP2-Anonymous-IP'; + const ipInfoDatabaseName = 'ASN'; + + // TODO: Fix flaky tests + describe.skip('Ingest Pipelines: Manage Processors', function () { + this.tags('smoke'); + before(async () => { + await security.testUser.setRoles(['manage_processors_user']); + }); + beforeEach(async () => { + await pageObjects.common.navigateToApp('ingestPipelines'); + await pageObjects.ingestPipelines.navigateToManageProcessorsPage(); + }); + after(async () => { + await security.testUser.restoreDefaults(); + }); + + it('Empty list prompt', async () => { + const promptExists = await pageObjects.ingestPipelines.geoipEmptyListPromptExists(); + expect(promptExists).to.be(true); + }); + + it('Create a MaxMind database', async () => { + await pageObjects.ingestPipelines.openCreateDatabaseModal(); + await pageObjects.ingestPipelines.fillAddDatabaseForm( + 'MaxMind', + 'GeoIP2 Anonymous IP', + '123456' + ); + await pageObjects.ingestPipelines.clickAddDatabaseButton(); + + // Wait for new row to gets displayed + await pageObjects.common.sleep(1000); + + const databasesList = await pageObjects.ingestPipelines.getGeoipDatabases(); + const databaseExists = Boolean( + databasesList.find((databaseRow) => databaseRow.includes(maxMindDatabaseName)) + ); + + expect(databaseExists).to.be(true); + }); + + it('Create an IPInfo database', async () => { + await pageObjects.ingestPipelines.openCreateDatabaseModal(); + await pageObjects.ingestPipelines.fillAddDatabaseForm('IPInfo', ipInfoDatabaseName); + await pageObjects.ingestPipelines.clickAddDatabaseButton(); + + // Wait for new row to gets displayed + await pageObjects.common.sleep(1000); + + const databasesList = await pageObjects.ingestPipelines.getGeoipDatabases(); + const databaseExists = Boolean( + databasesList.find((databaseRow) => databaseRow.includes(ipInfoDatabaseName)) + ); + + expect(databaseExists).to.be(true); + }); + + it('Table contains database name and maxmind type', async () => { + const databasesList = await pageObjects.ingestPipelines.getGeoipDatabases(); + const maxMindDatabaseRow = databasesList.find((database) => + database.includes(maxMindDatabaseName) + ); + expect(maxMindDatabaseRow).to.contain(maxMindDatabaseName); + expect(maxMindDatabaseRow).to.contain('MaxMind'); + + const ipInfoDatabaseRow = databasesList.find((database) => + database.includes(ipInfoDatabaseName) + ); + expect(ipInfoDatabaseRow).to.contain(ipInfoDatabaseName); + expect(ipInfoDatabaseRow).to.contain('IPInfo'); + }); + + it('Modal to delete a database', async () => { + // Delete both databases + await pageObjects.ingestPipelines.deleteDatabase(0); + await pageObjects.ingestPipelines.deleteDatabase(0); + const promptExists = await pageObjects.ingestPipelines.geoipEmptyListPromptExists(); + expect(promptExists).to.be(true); + }); + }); +}; diff --git a/x-pack/test/functional/config.base.js b/x-pack/test/functional/config.base.js index 8d1e875dabccc..b35d1f6b6673c 100644 --- a/x-pack/test/functional/config.base.js +++ b/x-pack/test/functional/config.base.js @@ -640,6 +640,20 @@ export default async function ({ readConfigFile }) { ], }, + manage_processors_user: { + elasticsearch: { + cluster: ['manage'], + }, + kibana: [ + { + feature: { + advancedSettings: ['read'], + }, + spaces: ['*'], + }, + ], + }, + license_management_user: { elasticsearch: { cluster: ['manage'], diff --git a/x-pack/test/functional/page_objects/ingest_pipelines_page.ts b/x-pack/test/functional/page_objects/ingest_pipelines_page.ts index 218a34e5c1ae2..b62d34b114f4b 100644 --- a/x-pack/test/functional/page_objects/ingest_pipelines_page.ts +++ b/x-pack/test/functional/page_objects/ingest_pipelines_page.ts @@ -7,12 +7,14 @@ import path from 'path'; import { WebElementWrapper } from '@kbn/ftr-common-functional-ui-services'; +import expect from '@kbn/expect'; import { FtrProviderContext } from '../ftr_provider_context'; export function IngestPipelinesPageProvider({ getService, getPageObjects }: FtrProviderContext) { const testSubjects = getService('testSubjects'); const pageObjects = getPageObjects(['header', 'common']); const aceEditor = getService('aceEditor'); + const retry = getService('retry'); return { async sectionHeadingText() { @@ -113,5 +115,56 @@ export function IngestPipelinesPageProvider({ getService, getPageObjects }: FtrP await testSubjects.click('tablePaginationPopoverButton'); await testSubjects.click(`tablePagination-50-rows`); }, + + async navigateToManageProcessorsPage() { + await testSubjects.click('manageProcessorsLink'); + await retry.waitFor('Manage Processors page title to be displayed', async () => { + return await testSubjects.isDisplayed('manageProcessorsTitle'); + }); + }, + + async geoipEmptyListPromptExists() { + return await testSubjects.exists('geoipEmptyListPrompt'); + }, + + async openCreateDatabaseModal() { + await testSubjects.click('addGeoipDatabaseButton'); + }, + + async fillAddDatabaseForm(databaseType: string, databaseName: string, maxmind?: string) { + await testSubjects.setValue('databaseTypeSelect', databaseType); + + // Wait for the rest of the fields to get displayed + await pageObjects.common.sleep(1000); + expect(await testSubjects.exists('databaseNameSelect')).to.be(true); + + if (maxmind) { + await testSubjects.setValue('maxmindField', maxmind); + } + await testSubjects.setValue('databaseNameSelect', databaseName); + }, + + async clickAddDatabaseButton() { + // Wait for button to get enabled + await pageObjects.common.sleep(1000); + await testSubjects.click('addGeoipDatabaseSubmit'); + }, + + async getGeoipDatabases() { + const databases = await testSubjects.findAll('geoipDatabaseListRow'); + + const getDatabaseRow = async (database: WebElementWrapper) => { + return await database.getVisibleText(); + }; + + return await Promise.all(databases.map((database) => getDatabaseRow(database))); + }, + + async deleteDatabase(index: number) { + const deleteButtons = await testSubjects.findAll('deleteGeoipDatabaseButton'); + await deleteButtons.at(index)?.click(); + await testSubjects.setValue('geoipDatabaseConfirmation', 'delete'); + await testSubjects.click('deleteGeoipDatabaseSubmit'); + }, }; }