Add support for GeoIP processor databases in Ingest Pipelines (#190830)

Fixes https://github.com/elastic/kibana/issues/190818

## Summary

Elasticsearch has added support for GeoIP, enabling the use of paid
GeoIP databases from MaxMind/IPInfo for more accurate and granular
geolocation data. As such we should add support to ingest pipelines UI
for making this available to the user.


* If the user doesn't have enough privileges, the "Manage Pipelines"
link and UI won't show.
* Users can add two types of databases through the UI: MaxMind and
IPinfo. Database names are predefined by ES, and the user cannot enter
their own.
* Certain types of databases (local and web) can be configured through
ES, and these will appear in the UI, but they cannot be deleted as they
are read-only.
* When configuring a `IP location` processor, the database field will
display a list of available and configured databases that the user can
select. It also allows for free-text input if the user wants to
configure a database that does not yet exist.
* The new IP location processor is essentially a clone of the GeoIP
processor, which we are moving away from due to copyright issues.
However, it was decided that GeoIP will remain as is for backward
compatibility, and all new work will only be added to IP location going
forward.
* I left a few mocks in the `server/routes/api/geoip_database/list.ts `
to try `local/web` types

## Release note
The Ingest Pipelines app now supports adding and managing databases for
the GeoIP processor. Additionally, the pipeline creation flow now
includes support for the IP Location processor.

<details>
<summary>Screenshots</summary>

![Screenshot 2024-10-07 at 09 36
31](https://github.com/user-attachments/assets/60d438cc-6658-4475-bd27-036c7d13d496)
![Screenshot 2024-10-07 at 09 38
58](https://github.com/user-attachments/assets/7c08e94f-b35c-4e78-a204-1fb456d88181)
![Screenshot 2024-10-07 at 09 47
08](https://github.com/user-attachments/assets/2baca0bd-811d-4dd5-9eb6-9b3f41579249)
![Screenshot 2024-10-07 at 09 47
20](https://github.com/user-attachments/assets/74d8664c-8c73-41f3-8cd5-e0670f3ada77)
![Screenshot 2024-10-07 at 09 48
19](https://github.com/user-attachments/assets/9fb4c186-6224-404c-a8d6-5c44c14da951)
![Screenshot 2024-10-07 at 09 48
25](https://github.com/user-attachments/assets/07e4909d-2613-45aa-918b-11a189e14f6f)


</details>

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
Co-authored-by: Ignacio Rivas <rivasign@gmail.com>
Co-authored-by: Elena Stoeva <elenastoeva99@gmail.com>
Co-authored-by: Elena Stoeva <59341489+ElenaStoeva@users.noreply.github.com>
Co-authored-by: Matthew Kime <matt@mattki.me>
This commit is contained in:
Yulia Čech 2024-10-16 00:58:43 +07:00 committed by GitHub
parent 7b9ff3d90c
commit 302ac0d336
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
54 changed files with 2218 additions and 88 deletions

View file

@ -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

View file

@ -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.

View file

@ -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,
};
};

View file

@ -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 },
};

View file

@ -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<ManageProcessorsTestSubjects> & {
actions: ReturnType<typeof createActions>;
};
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<ManageProcessorsTestBed> => {
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';

View file

@ -70,6 +70,9 @@ const appServices = {
},
overlays: overlayServiceMock.createStartContract(),
http: httpServiceMock.createStartContract({ basePath: '/mock' }),
config: {
enableManageProcessors: true,
},
};
export const setupEnvironment = () => {

View file

@ -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('<ManageProcessors />', () => {
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);
});
});
});

View file

@ -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;
}

View file

@ -27,20 +27,27 @@ import {
PipelinesEdit,
PipelinesClone,
PipelinesCreateFromCsv,
ManageProcessors,
} from './sections';
import { ROUTES } from './services/navigation';
export const AppWithoutRouter = () => (
<Routes>
<Route exact path={ROUTES.list} component={PipelinesList} />
<Route exact path={ROUTES.clone} component={PipelinesClone} />
<Route exact path={ROUTES.create} component={PipelinesCreate} />
<Route exact path={ROUTES.edit} component={PipelinesEdit} />
<Route exact path={ROUTES.createFromCsv} component={PipelinesCreateFromCsv} />
{/* Catch all */}
<Route component={PipelinesList} />
</Routes>
);
export const AppWithoutRouter = () => {
const { services } = useKibana();
return (
<Routes>
<Route exact path={ROUTES.list} component={PipelinesList} />
<Route exact path={ROUTES.clone} component={PipelinesClone} />
<Route exact path={ROUTES.create} component={PipelinesCreate} />
<Route exact path={ROUTES.edit} component={PipelinesEdit} />
<Route exact path={ROUTES.createFromCsv} component={PipelinesCreateFromCsv} />
{services.config.enableManageProcessors && (
<Route exact path={ROUTES.manageProcessors} component={ManageProcessors} />
)}
{/* Catch all */}
<Route component={PipelinesList} />
</Routes>
);
};
export const App: FunctionComponent = () => {
const { apiError } = useAuthorizationContext();

View file

@ -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';

View file

@ -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: (
<FormattedMessage
id="xpack.ingestPipelines.pipelineEditor.ipLocationForm.databaseFileHelpText"
defaultMessage="GeoIP2 database file in the {ingestGeoIP} configuration directory. Defaults to {databaseFile}."
values={{
databaseFile: <EuiCode>{'GeoLite2-City.mmdb'}</EuiCode>,
ingestGeoIP: <EuiCode>{'ingest-geoip'}</EuiCode>,
}}
/>
),
},
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 (
<>
<FieldNameField
helpText={i18n.translate(
'xpack.ingestPipelines.pipelineEditor.ipLocationForm.fieldNameHelpText',
{ defaultMessage: 'Field containing an IP address for the geographical lookup.' }
)}
/>
<TargetField
helpText={i18n.translate(
'xpack.ingestPipelines.pipelineEditor.ipLocationForm.targetFieldHelpText',
{
defaultMessage: 'Field used to contain geo data properties.',
}
)}
/>
<UseField
component={ComboBoxField}
config={fieldsConfig.database_file}
path="fields.database_file"
euiFieldProps={{
isLoading,
noSuggestions: false,
singleSelection: { asPlainText: true },
options: groupedOptions,
}}
/>
<PropertiesField
helpText={i18n.translate(
'xpack.ingestPipelines.pipelineEditor.ipLocationForm.propertiesFieldHelpText',
{
defaultMessage:
'Properties added to the target field. Valid properties depend on the database file used.',
}
)}
/>
<UseField component={ToggleField} config={fieldsConfig.first_only} path="fields.first_only" />
<IgnoreMissingField />
</>
);
};

View file

@ -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,

View file

@ -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';

View file

@ -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<CoreStart, 'analytics' | 'i18n' | 'theme'>;
@ -66,7 +67,7 @@ export const renderApp = (
render(
<KibanaRenderContextProvider {...coreServices}>
<AuthorizationProvider
privilegesEndpoint={`${API_BASE_PATH}/privileges`}
privilegesEndpoint={`${API_BASE_PATH}/privileges/ingest_pipelines`}
httpClient={coreServices.http}
>
<KibanaContextProvider services={services}>

View file

@ -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<StartDependencies>,
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 });

View file

@ -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';

View file

@ -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<string | undefined>(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<HTMLFormElement>) => {
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 (
<EuiModal
css={css`
width: 500px;
`}
aria-labelledby={ADD_DATABASE_MODAL_TITLE_ID}
onClose={closeModal}
initialFocus={'[data-test-subj="databaseTypeSelect"]'}
>
<EuiModalHeader>
<EuiModalHeaderTitle id={ADD_DATABASE_MODAL_TITLE_ID}>
<FormattedMessage
id="xpack.ingestPipelines.manageProcessors.geoip.addDatabaseModalTitle"
defaultMessage="Add database"
/>
</EuiModalHeaderTitle>
</EuiModalHeader>
<EuiModalBody>
<EuiForm
fullWidth={true}
id={ADD_DATABASE_MODAL_FORM_ID}
component="form"
onSubmit={(event) => onAddDatabase(event)}
data-test-subj="addGeoipDatabaseForm"
>
<EuiFormRow
label={
<FormattedMessage
id="xpack.ingestPipelines.manageProcessors.geoip.addDatabaseForm.databaseTypeSelectLabel"
defaultMessage="Type"
/>
}
helpText={
<FormattedMessage
id="xpack.ingestPipelines.manageProcessors.geoip.addDatabaseForm.databaseTypeSelectHelpText"
defaultMessage="Select which provider you want to use"
/>
}
>
<EuiSelect
options={DATABASE_TYPE_OPTIONS}
hasNoInitialSelection={true}
value={databaseType}
onChange={(e) => onDatabaseTypeChange(e.target.value)}
data-test-subj="databaseTypeSelect"
/>
</EuiFormRow>
{databaseType === 'maxmind' && (
<>
<EuiSpacer />
<EuiCallOut
title={
<FormattedMessage
id="xpack.ingestPipelines.manageProcessors.geoip.licenseCalloutTitle"
defaultMessage="Add your MaxMind license key to keystore"
/>
}
iconType="iInCircle"
>
<p>
<FormattedMessage
id="xpack.ingestPipelines.manageProcessors.geoip.licenseCalloutText"
defaultMessage="In order to grant access to your MaxMind account, you must add the license key to the keystore."
/>
</p>
</EuiCallOut>
<EuiSpacer />
</>
)}
{databaseType === 'ipinfo' && (
<>
<EuiSpacer />
<EuiCallOut
title={
<FormattedMessage
id="xpack.ingestPipelines.manageProcessors.geoip.licenseCalloutTitle"
defaultMessage="Add your IP Info license token to keystore"
/>
}
iconType="iInCircle"
>
<p>
<FormattedMessage
id="xpack.ingestPipelines.manageProcessors.geoip.licenseCalloutText"
defaultMessage="In order to grant access to your IP Info account, you must add the license token to the keystore."
/>
</p>
</EuiCallOut>
<EuiSpacer />
</>
)}
{databaseType === 'maxmind' && (
<EuiFormRow
label={
<FormattedMessage
id="xpack.ingestPipelines.manageProcessors.geoip.addDatabaseForm.maxMindInputLabel"
defaultMessage="MaxMind Account ID"
/>
}
>
<EuiFieldText
value={maxmind}
onChange={(e) => setMaxmind(e.target.value)}
data-test-subj="maxmindField"
/>
</EuiFormRow>
)}
{databaseType && (
<EuiFormRow
label={
<FormattedMessage
id="xpack.ingestPipelines.manageProcessors.geoip.addDatabaseForm.databaseNameSelectLabel"
defaultMessage="Database name"
/>
}
>
<EuiSelect
options={databaseType === 'maxmind' ? GEOIP_NAME_OPTIONS : IPINFO_NAME_OPTIONS}
hasNoInitialSelection={true}
value={databaseName}
onChange={(e) => onDatabaseNameChange(e.target.value)}
data-test-subj="databaseNameSelect"
/>
</EuiFormRow>
)}
</EuiForm>
{nameExistsError && (
<>
<EuiSpacer />
<EuiCallOut
color="danger"
title={
<FormattedMessage
id="xpack.ingestPipelines.manageProcessors.geoip.nameExistsErrorTitle"
defaultMessage="Database already exists"
/>
}
iconType="warning"
>
<p>
<FormattedMessage
id="xpack.ingestPipelines.manageProcessors.geoip.nameExistsErrorText"
defaultMessage="Database cannot be added multiple times."
/>
</p>
</EuiCallOut>
</>
)}
</EuiModalBody>
<EuiModalFooter>
<EuiButtonEmpty onClick={closeModal}>
<FormattedMessage
id="xpack.ingestPipelines.manageProcessors.geoip.addModalCancelButtonLabel"
defaultMessage="Cancel"
/>
</EuiButtonEmpty>
<EuiButton
fill
type="submit"
form={ADD_DATABASE_MODAL_FORM_ID}
disabled={isLoading || !isFormValid()}
data-test-subj="addGeoipDatabaseSubmit"
>
<FormattedMessage
id="xpack.ingestPipelines.manageProcessors.geoip.addModalConfirmButtonLabel"
defaultMessage="Add database"
/>
</EuiButton>
</EuiModalFooter>
</EuiModal>
);
};

View file

@ -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',
});
}
}
};

View file

@ -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<HTMLFormElement>) => {
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 (
<EuiModal
aria-labelledby={DELETE_DATABASE_MODAL_TITLE_ID}
onClose={closeModal}
initialFocus="[name=confirmation]"
>
<EuiModalHeader>
<EuiModalHeaderTitle id={DELETE_DATABASE_MODAL_TITLE_ID}>
<FormattedMessage
id="xpack.ingestPipelines.manageProcessors.geoip.deleteDatabaseModalTitle"
defaultMessage="Delete {database}"
values={{
database: database.name,
}}
/>
</EuiModalHeaderTitle>
</EuiModalHeader>
<EuiModalBody>
<EuiForm
id={DELETE_DATABASE_MODAL_FORM_ID}
component="form"
onSubmit={(event) => onDeleteDatabase(event)}
>
<EuiFormRow
label={
<FormattedMessage
id="xpack.ingestPipelines.manageProcessors.geoip.deleteDatabaseForm.confirmationLabel"
defaultMessage={'Please type "delete" to confirm.'}
/>
}
>
<EuiFieldText
name="confirmation"
value={confirmation}
onChange={(e) => setConfirmation(e.target.value)}
data-test-subj="geoipDatabaseConfirmation"
/>
</EuiFormRow>
</EuiForm>
</EuiModalBody>
<EuiModalFooter>
<EuiButtonEmpty onClick={closeModal}>
<FormattedMessage
id="xpack.ingestPipelines.manageProcessors.geoip.deleteModalCancelButtonLabel"
defaultMessage="Cancel"
/>
</EuiButtonEmpty>
<EuiButton
fill
type="submit"
form={DELETE_DATABASE_MODAL_FORM_ID}
disabled={isLoading || !isValid}
color="danger"
data-test-subj="deleteGeoipDatabaseSubmit"
>
<FormattedMessage
id="xpack.ingestPipelines.manageProcessors.geoip.deleteModalConfirmButtonLabel"
defaultMessage="Delete"
/>
</EuiButton>
</EuiModalFooter>
</EuiModal>
);
};

View file

@ -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 (
<EuiPageTemplate.EmptyPrompt
iconType="database"
iconColor="default"
title={
<h2 data-test-subj="geoipEmptyListPrompt">
<FormattedMessage
id="xpack.ingestPipelines.manageProcessors.geoip.emptyPromptTitle"
defaultMessage="Add your first database for IP Location processor"
/>
</h2>
}
body={
<p>
<FormattedMessage
id="xpack.ingestPipelines.manageProcessors.geoip.emptyPromptDescription"
defaultMessage="Use a custom database when setting up IP Location processor."
/>
</p>
}
actions={addDatabaseButton}
/>
);
};

View file

@ -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<GeoipDatabase | null>(null);
const onDatabaseDelete = (item: GeoipDatabase) => {
setDatabaseToDelete(item);
setShowModal('delete');
};
let content: JSX.Element;
const addDatabaseButton = (
<EuiButton
fill
iconType="plusInCircle"
onClick={() => {
setShowModal('add');
}}
data-test-subj="addGeoipDatabaseButton"
>
<FormattedMessage
id="xpack.ingestPipelines.manageProcessors.geoip.addDatabaseButtonLabel"
defaultMessage="Add database"
/>
</EuiButton>
);
const tableProps: EuiInMemoryTableProps<GeoipDatabase> = {
'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 (
<EuiButtonIcon
name="Delete"
aria-label={i18n.translate(
'xpack.ingestPipelines.manageProcessors.geoip.list.actionIconLabel',
{
defaultMessage: 'Delete this database',
}
)}
iconType="trash"
color="danger"
onClick={() => onDatabaseDelete(item)}
data-test-subj="deleteGeoipDatabaseButton"
/>
);
},
},
],
items: data ?? [],
};
if (error) {
content = (
<EuiPageTemplate.EmptyPrompt
color="danger"
iconType="warning"
title={
<h2 data-test-subj="geoipListLoadingError">
<FormattedMessage
id="xpack.ingestPipelines.manageProcessors.geoip.list.loadErrorTitle"
defaultMessage="Unable to load geoIP databases"
/>
</h2>
}
body={<p>{getErrorMessage(error)}</p>}
actions={
<EuiButton onClick={resendRequest} iconType="refresh" color="danger">
<FormattedMessage
id="xpack.ingestPipelines.manageProcessors.geoip.list.geoipListReloadButton"
defaultMessage="Try again"
/>
</EuiButton>
}
/>
);
} else if (isLoading && !data) {
content = (
<SectionLoading data-test-subj="sectionLoading">
<FormattedMessage
id="xpack.ingestPipelines.manageProcessors.geoip.list.loadingMessage"
defaultMessage="Loading geoIP databases..."
/>
</SectionLoading>
);
} else if (data && data.length === 0) {
content = <EmptyList addDatabaseButton={addDatabaseButton} />;
} else {
content = (
<>
<EuiFlexGroup>
<EuiFlexItem>
<EuiTitle>
<h2>
<FormattedMessage
id="xpack.ingestPipelines.manageProcessors.geoip.tableTitle"
defaultMessage="GeoIP"
/>
</h2>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem grow={false}>{addDatabaseButton}</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="l" />
<EuiInMemoryTable
css={css`
height: 100%;
`}
{...tableProps}
/>
</>
);
}
return (
<>
{content}
{showModal === 'add' && (
<AddDatabaseModal
closeModal={() => setShowModal(null)}
reloadDatabases={resendRequest}
databases={data!}
/>
)}
{showModal === 'delete' && databaseToDelete && (
<DeleteDatabaseModal
database={databaseToDelete}
reloadDatabases={resendRequest}
closeModal={() => setShowModal(null)}
/>
)}
</>
);
};

View file

@ -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 (
<FormattedMessage
id="xpack.ingestPipelines.manageProcessors.deniedPrivilegeDescription"
defaultMessage="To manage geoIP databases, you must have the {manage} cluster privilege."
values={{
manage: <EuiCode>manage</EuiCode>,
}}
/>
);
}
return error.message;
};

View file

@ -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';

View file

@ -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 (
<>
<EuiPageHeader
bottomBorder
pageTitle={
<span data-test-subj="manageProcessorsTitle">
<FormattedMessage
id="xpack.ingestPipelines.manageProcessors.pageTitle"
defaultMessage="Manage Processors"
/>
</span>
}
/>
<EuiSpacer size="l" />
<GeoipList />
</>
);
};

View file

@ -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;
};

View file

@ -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<RouteComponentProps> = ({
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<RouteComponentProps> = ({
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<RouteComponentProps> = ({
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 = [
<EuiPopover
key="createPipelinePopover"
isOpen={showPopover}
closePopover={() => setShowPopover(false)}
button={
<EuiButton
fill
iconSide="right"
iconType="arrowDown"
data-test-subj="createPipelineDropdown"
key="createPipelineDropdown"
onClick={() => setShowPopover((previousBool) => !previousBool)}
>
{i18n.translate('xpack.ingestPipelines.list.table.createPipelineDropdownLabel', {
defaultMessage: 'Create pipeline',
})}
</EuiButton>
}
panelPaddingSize="none"
repositionOnScroll
>
<EuiContextMenu
initialPanelId={0}
data-test-subj="autoFollowPatternActionContextMenu"
panels={[
{
id: 0,
items: createMenuItems,
},
]}
/>
</EuiPopover>,
];
if (services.config.enableManageProcessors && hasManageProcessorsPrivileges) {
titleActionButtons.push(
<EuiButtonEmpty
iconType="wrench"
data-test-subj="manageProcessorsLink"
{...reactRouterNavigate(history, getManageProcessorsPath())}
>
<FormattedMessage
id="xpack.ingestPipelines.list.manageProcessorsLinkText"
defaultMessage="Manage processors"
/>
</EuiButtonEmpty>
);
}
titleActionButtons.push(
<EuiButtonEmpty
href={services.documentation.getIngestNodeUrl()}
target="_blank"
iconType="help"
data-test-subj="documentationLink"
>
<FormattedMessage
id="xpack.ingestPipelines.list.pipelinesDocsLinkText"
defaultMessage="Documentation"
/>
</EuiButtonEmpty>
);
const renderFlyout = (): React.ReactNode => {
if (!showFlyout) {
@ -199,51 +268,7 @@ export const PipelinesList: React.FunctionComponent<RouteComponentProps> = ({
defaultMessage="Use ingest pipelines to remove or transform fields, extract values from text, and enrich your data before indexing into Elasticsearch."
/>
}
rightSideItems={[
<EuiPopover
key="createPipelinePopover"
isOpen={showPopover}
closePopover={() => setShowPopover(false)}
button={
<EuiButton
fill
iconSide="right"
iconType="arrowDown"
data-test-subj="createPipelineDropdown"
key="createPipelineDropdown"
onClick={() => setShowPopover((previousBool) => !previousBool)}
>
{i18n.translate('xpack.ingestPipelines.list.table.createPipelineDropdownLabel', {
defaultMessage: 'Create pipeline',
})}
</EuiButton>
}
panelPaddingSize="none"
repositionOnScroll
>
<EuiContextMenu
initialPanelId={0}
data-test-subj="autoFollowPatternActionContextMenu"
panels={[
{
id: 0,
items: createMenuItems,
},
]}
/>
</EuiPopover>,
<EuiButtonEmpty
href={services.documentation.getIngestNodeUrl()}
target="_blank"
iconType="help"
data-test-subj="documentationLink"
>
<FormattedMessage
id="xpack.ingestPipelines.list.pipelinesDocsLinkText"
defaultMessage="Documentation"
/>
</EuiButtonEmpty>,
]}
rightSideItems={titleActionButtons}
/>
<EuiSpacer size="l" />

View file

@ -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<GeoipDatabase[], ResponseErrorBody>({
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();

View file

@ -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');
}

View file

@ -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();

View file

@ -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';

View file

@ -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<Config>) {
this.config = initializerContext.config.get();
}
public setup(coreSetup: CoreSetup<StartDependencies>, 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 () => {

View file

@ -25,3 +25,7 @@ export interface StartDependencies {
licensing?: LicensingPluginStart;
console?: ConsolePluginStart;
}
export interface Config {
enableManageProcessors: boolean;
}

View file

@ -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<typeof configSchema>;
export const config: PluginConfigDescriptor<IngestPipelinesConfigType> = {
schema: configSchema,
exposeToBrowser: {
enableManageProcessors: true,
},
};

View file

@ -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);
}

View file

@ -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<void, void, any, any> {
private readonly apiRoutes: ApiRoutes;
private readonly config: IngestPipelinesConfigType;
constructor() {
constructor(initContext: PluginInitializerContext<IngestPipelinesConfigType>) {
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<void, void, any, any> {
router,
config: {
isSecurityEnabled: () => security !== undefined && security.license.isEnabled(),
enableManageProcessors: this.config.enableManageProcessors !== false,
},
lib: {
handleEsError,

View file

@ -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 });
}
}
);
};

View file

@ -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 });
}
}
);
};

View file

@ -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';

View file

@ -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;
}
});
};

View file

@ -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();
};

View file

@ -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;
};

View file

@ -20,3 +20,9 @@ export { registerSimulateRoute } from './simulate';
export { registerDocumentsRoute } from './documents';
export { registerParseCsvRoute } from './parse_csv';
export {
registerListDatabaseRoute,
registerCreateDatabaseRoute,
registerDeleteDatabaseRoute,
} from './database';

View file

@ -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) {

View file

@ -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);
}
}
}

View file

@ -19,6 +19,7 @@ export interface RouteDependencies {
router: IRouter;
config: {
isSecurityEnabled: () => boolean;
enableManageProcessors: boolean;
};
lib: {
handleEsError: typeof handleEsError;

View file

@ -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/**/*",

View file

@ -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);
});
});
});
}

View file

@ -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'));
});
}

View file

@ -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.
*/

View file

@ -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}`);
});
},
};
}

View file

@ -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'));
});
};

View file

@ -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);
});
});
};

View file

@ -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'],

View file

@ -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');
},
};
}