From de3f8fca009f6c22bcaf3ab20d2beea8ffd7e549 Mon Sep 17 00:00:00 2001 From: Janki Salvi <117571355+js-jankisalvi@users.noreply.github.com> Date: Wed, 21 Jun 2023 15:00:25 +0200 Subject: [PATCH 1/7] [Cases] Limit perPage param in findComments API (#160042) ## Summary This PR limits `perPage` param to 100 in `findComments` API. ### Checklist - [x] [Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html) was added for features that require explanation or tutorials - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios --------- Co-authored-by: lcawl Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> --- .../cases/case-apis-passthru.asciidoc | 2 +- .../plugins/cases/common/constants/index.ts | 1 + .../plugins/cases/docs/openapi/bundled.json | 10 +++++++++- .../plugins/cases/docs/openapi/bundled.yaml | 9 ++++++++- ...id}@api@cases@{caseid}@comments@_find.yaml | 9 ++++++++- .../server/client/attachments/get.test.ts | 10 +++++++++- .../client/attachments/validators.test.ts | 19 ++++++++++++++++--- .../server/client/attachments/validators.ts | 10 ++++++++-- .../tests/common/cases/delete_cases.ts | 10 +++++----- .../tests/common/comments/find_comments.ts | 2 +- 10 files changed, 66 insertions(+), 16 deletions(-) diff --git a/docs/api-generated/cases/case-apis-passthru.asciidoc b/docs/api-generated/cases/case-apis-passthru.asciidoc index d0e7a67c50660..f007882af237c 100644 --- a/docs/api-generated/cases/case-apis-passthru.asciidoc +++ b/docs/api-generated/cases/case-apis-passthru.asciidoc @@ -556,7 +556,7 @@ Any modifications made to this file will be overwritten.
Query Parameter — The page number to return. default: 1
perPage (optional)
-
Query Parameter — The number of items to return. default: 20
sortOrder (optional)
+
Query Parameter — The number of items to return. Limited to 100 items. default: 20
sortOrder (optional)
Query Parameter — Determines the sort order. default: desc
diff --git a/x-pack/plugins/cases/common/constants/index.ts b/x-pack/plugins/cases/common/constants/index.ts index 33473a9c80bfd..1a49de004e73f 100644 --- a/x-pack/plugins/cases/common/constants/index.ts +++ b/x-pack/plugins/cases/common/constants/index.ts @@ -104,6 +104,7 @@ export const MAX_DOCS_PER_PAGE = 10000 as const; export const MAX_BULK_GET_ATTACHMENTS = MAX_DOCS_PER_PAGE; export const MAX_CONCURRENT_SEARCHES = 10 as const; export const MAX_BULK_GET_CASES = 1000 as const; +export const MAX_COMMENTS_PER_PAGE = 100 as const; export const MAX_CATEGORY_FILTER_LENGTH = 100 as const; /** diff --git a/x-pack/plugins/cases/docs/openapi/bundled.json b/x-pack/plugins/cases/docs/openapi/bundled.json index 92dcfe5779306..78ba5444f40aa 100644 --- a/x-pack/plugins/cases/docs/openapi/bundled.json +++ b/x-pack/plugins/cases/docs/openapi/bundled.json @@ -1852,7 +1852,15 @@ "$ref": "#/components/parameters/page_index" }, { - "$ref": "#/components/parameters/page_size" + "name": "perPage", + "in": "query", + "description": "The number of items to return. Limited to 100 items.", + "required": false, + "schema": { + "type": "integer", + "default": 20, + "maximum": 100 + } }, { "$ref": "#/components/parameters/sort_order" diff --git a/x-pack/plugins/cases/docs/openapi/bundled.yaml b/x-pack/plugins/cases/docs/openapi/bundled.yaml index 3d53916c89df8..405cf4fb689f0 100644 --- a/x-pack/plugins/cases/docs/openapi/bundled.yaml +++ b/x-pack/plugins/cases/docs/openapi/bundled.yaml @@ -1156,7 +1156,14 @@ paths: parameters: - $ref: '#/components/parameters/case_id' - $ref: '#/components/parameters/page_index' - - $ref: '#/components/parameters/page_size' + - name: perPage + in: query + description: The number of items to return. Limited to 100 items. + required: false + schema: + type: integer + default: 20 + maximum: 100 - $ref: '#/components/parameters/sort_order' - $ref: '#/components/parameters/space_id' responses: diff --git a/x-pack/plugins/cases/docs/openapi/paths/s@{spaceid}@api@cases@{caseid}@comments@_find.yaml b/x-pack/plugins/cases/docs/openapi/paths/s@{spaceid}@api@cases@{caseid}@comments@_find.yaml index b1dd32a659515..bb43f4dcc0b26 100644 --- a/x-pack/plugins/cases/docs/openapi/paths/s@{spaceid}@api@cases@{caseid}@comments@_find.yaml +++ b/x-pack/plugins/cases/docs/openapi/paths/s@{spaceid}@api@cases@{caseid}@comments@_find.yaml @@ -10,7 +10,14 @@ get: parameters: - $ref: '../components/parameters/case_id.yaml' - $ref: '../components/parameters/page_index.yaml' - - $ref: '../components/parameters/page_size.yaml' + - name: perPage + in: query + description: The number of items to return. Limited to 100 items. + required: false + schema: + type: integer + default: 20 + maximum: 100 - $ref: '../components/parameters/sort_order.yaml' - $ref: '../components/parameters/space_id.yaml' responses: diff --git a/x-pack/plugins/cases/server/client/attachments/get.test.ts b/x-pack/plugins/cases/server/client/attachments/get.test.ts index 250cfa9f0b252..c2c3423b2388b 100644 --- a/x-pack/plugins/cases/server/client/attachments/get.test.ts +++ b/x-pack/plugins/cases/server/client/attachments/get.test.ts @@ -18,12 +18,20 @@ describe('get', () => { it('Invalid total items results in error', async () => { await expect(() => - findComment({ caseID: 'mock-id', findQueryParams: { page: 2, perPage: 9001 } }, clientArgs) + findComment({ caseID: 'mock-id', findQueryParams: { page: 209, perPage: 100 } }, clientArgs) ).rejects.toThrowErrorMatchingInlineSnapshot( `"Failed to find comments case id: mock-id: Error: The number of documents is too high. Paginating through more than 10,000 documents is not possible."` ); }); + it('Invalid perPage items results in error', async () => { + await expect(() => + findComment({ caseID: 'mock-id', findQueryParams: { page: 2, perPage: 9001 } }, clientArgs) + ).rejects.toThrowErrorMatchingInlineSnapshot( + `"Failed to find comments case id: mock-id: Error: The provided perPage value was too high. The maximum allowed perPage value is 100."` + ); + }); + it('throws with excess fields', async () => { await expect( findComment( diff --git a/x-pack/plugins/cases/server/client/attachments/validators.test.ts b/x-pack/plugins/cases/server/client/attachments/validators.test.ts index 4626fd13fba35..ce6c43a665a10 100644 --- a/x-pack/plugins/cases/server/client/attachments/validators.test.ts +++ b/x-pack/plugins/cases/server/client/attachments/validators.test.ts @@ -6,10 +6,13 @@ */ import { validateFindCommentsPagination } from './validators'; +import { MAX_COMMENTS_PER_PAGE } from '../../../common/constants'; const ERROR_MSG = 'The number of documents is too high. Paginating through more than 10,000 documents is not possible.'; +const ERROR_MSG_PER_PAGE = `The provided perPage value was too high. The maximum allowed perPage value is ${MAX_COMMENTS_PER_PAGE}.`; + describe('validators', () => { describe('validateFindCommentsPagination', () => { it('does not throw if only page is undefined', () => { @@ -20,20 +23,30 @@ describe('validators', () => { expect(() => validateFindCommentsPagination({ page: 100 })).not.toThrowError(); }); + it('does not throw if page and perPage are defined and valid', () => { + expect(() => validateFindCommentsPagination({ page: 2, perPage: 100 })).not.toThrowError(); + }); + it('returns if page and perPage are undefined', () => { expect(() => validateFindCommentsPagination({})).not.toThrowError(); }); + it('returns if perPage < 0', () => { + expect(() => validateFindCommentsPagination({ perPage: -1 })).not.toThrowError(); + }); + it('throws if page > 10k', () => { expect(() => validateFindCommentsPagination({ page: 10001 })).toThrow(ERROR_MSG); }); - it('throws if perPage > 10k', () => { - expect(() => validateFindCommentsPagination({ perPage: 10001 })).toThrowError(ERROR_MSG); + it('throws if perPage > 100', () => { + expect(() => + validateFindCommentsPagination({ perPage: MAX_COMMENTS_PER_PAGE + 1 }) + ).toThrowError(ERROR_MSG_PER_PAGE); }); it('throws if page * perPage > 10k', () => { - expect(() => validateFindCommentsPagination({ page: 10, perPage: 1001 })).toThrow(ERROR_MSG); + expect(() => validateFindCommentsPagination({ page: 101, perPage: 100 })).toThrow(ERROR_MSG); }); }); }); diff --git a/x-pack/plugins/cases/server/client/attachments/validators.ts b/x-pack/plugins/cases/server/client/attachments/validators.ts index 770d9d8e88c0f..ea38fa71e702a 100644 --- a/x-pack/plugins/cases/server/client/attachments/validators.ts +++ b/x-pack/plugins/cases/server/client/attachments/validators.ts @@ -6,7 +6,7 @@ */ import Boom from '@hapi/boom'; -import { MAX_DOCS_PER_PAGE } from '../../../common/constants'; +import { MAX_DOCS_PER_PAGE, MAX_COMMENTS_PER_PAGE } from '../../../common/constants'; import { isCommentRequestTypeExternalReference, isCommentRequestTypePersistableState, @@ -51,7 +51,13 @@ export const validateFindCommentsPagination = (params?: FindCommentsQueryParams) const pageAsNumber = params.page ?? 0; const perPageAsNumber = params.perPage ?? 0; - if (Math.max(pageAsNumber, perPageAsNumber, pageAsNumber * perPageAsNumber) > MAX_DOCS_PER_PAGE) { + if (perPageAsNumber > MAX_COMMENTS_PER_PAGE) { + throw Boom.badRequest( + `The provided perPage value was too high. The maximum allowed perPage value is ${MAX_COMMENTS_PER_PAGE}.` + ); + } + + if (Math.max(pageAsNumber, pageAsNumber * perPageAsNumber) > MAX_DOCS_PER_PAGE) { throw Boom.badRequest( 'The number of documents is too high. Paginating through more than 10,000 documents is not possible.' ); diff --git a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/cases/delete_cases.ts b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/cases/delete_cases.ts index 267f5472e6ebb..6da8cfccf1352 100644 --- a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/cases/delete_cases.ts +++ b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/cases/delete_cases.ts @@ -7,7 +7,7 @@ import expect from '@kbn/expect'; import type SuperTest from 'supertest'; -import { MAX_DOCS_PER_PAGE } from '@kbn/cases-plugin/common/constants'; +import { MAX_COMMENTS_PER_PAGE } from '@kbn/cases-plugin/common/constants'; import { Alerts, createCaseAttachAlertAndDeleteCase, @@ -170,7 +170,7 @@ export default ({ getService }: FtrProviderContext): void => { supertest: supertestWithoutAuth, caseId: postedCase.id, query: { - perPage: MAX_DOCS_PER_PAGE, + perPage: MAX_COMMENTS_PER_PAGE, }, }), ]); @@ -210,14 +210,14 @@ export default ({ getService }: FtrProviderContext): void => { supertest: supertestWithoutAuth, caseId: postedCase1.id, query: { - perPage: MAX_DOCS_PER_PAGE, + perPage: MAX_COMMENTS_PER_PAGE, }, }), findAttachments({ supertest: supertestWithoutAuth, caseId: postedCase2.id, query: { - perPage: MAX_DOCS_PER_PAGE, + perPage: MAX_COMMENTS_PER_PAGE, }, }), ]); @@ -457,7 +457,7 @@ export default ({ getService }: FtrProviderContext): void => { supertest: supertestWithoutAuth, caseId: postedCase.id, query: { - perPage: MAX_DOCS_PER_PAGE, + perPage: MAX_COMMENTS_PER_PAGE, }, auth: { user: secAllUser, space: 'space1' }, }), diff --git a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/comments/find_comments.ts b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/comments/find_comments.ts index a218350ec95ca..761959db29f66 100644 --- a/x-pack/test/cases_api_integration/security_and_spaces/tests/common/comments/find_comments.ts +++ b/x-pack/test/cases_api_integration/security_and_spaces/tests/common/comments/find_comments.ts @@ -114,7 +114,7 @@ export default ({ getService }: FtrProviderContext): void => { { name: 'field is wrong type', queryParams: { perPage: true } }, { name: 'field is unknown', queryParams: { foo: 'bar' } }, { name: 'page > 10k', queryParams: { page: 10001 } }, - { name: 'perPage > 10k', queryParams: { perPage: 10001 } }, + { name: 'perPage > 100', queryParams: { perPage: 101 } }, { name: 'page * perPage > 10k', queryParams: { page: 2, perPage: 9001 } }, ]) { it(`400s when ${errorScenario.name}`, async () => { From dba2e9ba2c720e3b96dea4d301e2e2229361c94b Mon Sep 17 00:00:00 2001 From: Khristinin Nikita Date: Wed, 21 Jun 2023 15:14:03 +0200 Subject: [PATCH 2/7] RiskScoreEndgineData client + install ds and other resources for risk scoring (#158422) ## Risc score resources bootstrap Screenshot 2023-06-12 at 14 46 56 ES PR: https://github.com/elastic/elasticsearch/pull/96348 This PR introduces RiskEngineDataClient, which purpose to install resources per namespace, including ilm policy, component template, index template and datastream for risk score. Some view demo/overview of the steps we do to initialise RiskEngineDataClient and resources https://github.com/elastic/kibana/assets/7609147/bf72bbb4-56f6-46be-9b89-c96e9f33b354 For default space, it installs indexes when the security_soluition plugin is set up. For other spaces, it initialises the resource when you call `getWriter`. This data client was passed to `RequestContextFactory` So in any request, it can be called like ``` const riskEngineDataClient = (await context.securitySolution).getRiskEngineDataClient(); const spaceId = (await context.securitySolution).getSpaceId(); const riskEngineDataClientWriter = riskEngineDataClient.getWriter({ namespace: spaceId }); ``` ### What is generated 1. ILM policy `GET _ilm/policy/.risk-score-ilm-policy` ``` { ".risk-score-ilm-policy": { "version": 1, "modified_date": "2023-05-25T10:52:36.592Z", "policy": { "phases": { "hot": { "min_age": "0ms", "actions": { "rollover": { "max_age": "30d", "max_primary_shard_size": "50gb" } } } }, "_meta": { "managed": true } }, "in_use_by": { "indices": [ ".ds-risk-score.risk-score-default-2023.05.25-000001" ], "data_streams": [ "risk-score.risk-score-default" ], "composable_templates": [ ".risk-score.risk-score-default-index-template" ] } } } ``` 2. Component template for risk score mappings `GET _component_template/risk-score-mappings` ``` { "component_templates": [ { "name": "risk-score-mappings", "component_template": { "template": { "settings": {}, "mappings": { "dynamic": "strict", "properties": { "identifierField": { "type": "keyword" }, "otherScore": { "type": "float" }, "alertsScore": { "type": "float" }, "@timestamp": { "type": "date" }, "level": { "type": "keyword" }, "riskiestInputs": { "type": "nested", "properties": { "index": { "type": "keyword" }, "id": { "type": "keyword" }, "riskScore": { "type": "float" } } }, "identifierValue": { "type": "keyword" }, "totalScore": { "type": "float" }, "totalScoreNormalized": { "type": "float" } } } }, "_meta": { "managed": true } } } ] } ``` 3. Index template `GET _index_template/.risk-score.risk-score-default-index-template` ``` { "index_templates": [ { "name": ".risk-score.risk-score-default-index-template", "index_template": { "index_patterns": [ "risk-score.risk-score-default" ], "template": { "settings": { "index": { "lifecycle": { "name": ".risk-score-ilm-policy" }, "mapping": { "total_fields": { "limit": "1000" } }, "hidden": "true", "auto_expand_replicas": "0-1" } }, "mappings": { "_meta": { "managed": true, "namespace": "default", "kibana": { "version": "8.9.0" } }, "dynamic": false } }, "composed_of": [ "risk-score-mappings" ], "_meta": { "managed": true, "namespace": "default", "kibana": { "version": "8.9.0" } }, "data_stream": { "hidden": true, "allow_custom_routing": false } } } ] } ``` 4. Data stream `GET risk-score.risk-score-default` - where is `default` is space name return ``` { ".ds-risk-score.risk-score-default-2023.05.25-000001": { "aliases": {}, "mappings": { "dynamic": "false", "_meta": { "namespace": "default", "kibana": { "version": "8.9.0" }, "managed": true }, "_data_stream_timestamp": { "enabled": true }, "properties": { "@timestamp": { "type": "date" }, "alertsScore": { "type": "float" }, "identifierField": { "type": "keyword" }, "identifierValue": { "type": "keyword" }, "level": { "type": "keyword" }, "otherScore": { "type": "float" }, "riskiestInputs": { "type": "nested", "properties": { "id": { "type": "keyword" }, "index": { "type": "keyword" }, "riskScore": { "type": "float" } } }, "totalScore": { "type": "float" }, "totalScoreNormalized": { "type": "float" } } }, "settings": { "index": { "lifecycle": { "name": ".risk-engine-ilm-policy" }, "routing": { "allocation": { "include": { "_tier_preference": "data_hot" } } }, "mapping": { "total_fields": { "limit": "1000" } }, "hidden": "true", "number_of_shards": "1", "auto_expand_replicas": "0-1", "provided_name": ".ds-risk-score.risk-score-default-2023.05.25-000001", "creation_date": "1685009904171", "number_of_replicas": "0", "uuid": "_5yc7wG4Sxy88zIVqxC7yg", "version": { "created": "8090099" } } }, "data_stream": "risk-score.risk-score-default" } } ``` --------- Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .../common/experimental_features.ts | 5 + .../routes/__mocks__/request_context.ts | 3 + .../__mocks__/risk_engine_data_client_mock.ts | 16 ++ .../server/lib/risk_engine/configurations.ts | 97 ++++++++ .../risk_engine_data_client.test.ts | 218 ++++++++++++++++++ .../risk_engine/risk_engine_data_client.ts | 142 ++++++++++++ .../risk_engine/utils/create_datastream.ts | 206 +++++++++++++++++ .../utils/retry_transient_es_errors.test.ts | 95 ++++++++ .../utils/retry_transient_es_errors.ts | 58 +++++ .../security_solution/server/plugin.ts | 16 ++ .../server/request_context_factory.ts | 14 +- .../plugins/security_solution/server/types.ts | 2 + .../common/config.ts | 1 + .../security_and_spaces/group10/index.ts | 1 + .../group10/risk_engine_install_resources.ts | 150 ++++++++++++ 15 files changed, 1023 insertions(+), 1 deletion(-) create mode 100644 x-pack/plugins/security_solution/server/lib/risk_engine/__mocks__/risk_engine_data_client_mock.ts create mode 100644 x-pack/plugins/security_solution/server/lib/risk_engine/configurations.ts create mode 100644 x-pack/plugins/security_solution/server/lib/risk_engine/risk_engine_data_client.test.ts create mode 100644 x-pack/plugins/security_solution/server/lib/risk_engine/risk_engine_data_client.ts create mode 100644 x-pack/plugins/security_solution/server/lib/risk_engine/utils/create_datastream.ts create mode 100644 x-pack/plugins/security_solution/server/lib/risk_engine/utils/retry_transient_es_errors.test.ts create mode 100644 x-pack/plugins/security_solution/server/lib/risk_engine/utils/retry_transient_es_errors.ts create mode 100644 x-pack/test/detection_engine_api_integration/security_and_spaces/group10/risk_engine_install_resources.ts diff --git a/x-pack/plugins/security_solution/common/experimental_features.ts b/x-pack/plugins/security_solution/common/experimental_features.ts index 079ad8434f445..dd00c9b3ab190 100644 --- a/x-pack/plugins/security_solution/common/experimental_features.ts +++ b/x-pack/plugins/security_solution/common/experimental_features.ts @@ -117,6 +117,11 @@ export const allowedExperimentalValues = Object.freeze({ */ detectionsCoverageOverview: false, + /** + * Enable risk engine client and initialisation of datastream, component templates and mappings + */ + riskScoringPersistence: false, + /** * Enables experimental Entity Analytics HTTP endpoints */ diff --git a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_context.ts b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_context.ts index 94e688a4db079..f45a57208e915 100644 --- a/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_context.ts +++ b/x-pack/plugins/security_solution/server/lib/detection_engine/routes/__mocks__/request_context.ts @@ -34,6 +34,7 @@ import type { import { getEndpointAuthzInitialStateMock } from '../../../../../common/endpoint/service/authz/mocks'; import type { EndpointAuthz } from '../../../../../common/endpoint/types/authz'; +import { riskEngineDataClientMock } from '../../../risk_engine/__mocks__/risk_engine_data_client_mock'; export const createMockClients = () => { const core = coreMock.createRequestHandlerContext(); @@ -61,6 +62,7 @@ export const createMockClients = () => { detectionEngineHealthClient: detectionEngineHealthClientMock.create(), ruleExecutionLog: ruleExecutionLogMock.forRoutes.create(), + riskEngineDataClient: riskEngineDataClientMock.create(), }; }; @@ -139,6 +141,7 @@ const createSecuritySolutionRequestContextMock = ( // TODO: Mock EndpointInternalFleetServicesInterface and return the mocked object. throw new Error('Not implemented'); }), + getRiskEngineDataClient: jest.fn(() => clients.riskEngineDataClient), }; }; diff --git a/x-pack/plugins/security_solution/server/lib/risk_engine/__mocks__/risk_engine_data_client_mock.ts b/x-pack/plugins/security_solution/server/lib/risk_engine/__mocks__/risk_engine_data_client_mock.ts new file mode 100644 index 0000000000000..0e9f1fade7bb6 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/risk_engine/__mocks__/risk_engine_data_client_mock.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { RiskEngineDataClient } from '../risk_engine_data_client'; + +const createRiskEngineDataClientMock = () => + ({ + getWriter: jest.fn(), + initializeResources: jest.fn(), + } as unknown as jest.Mocked); + +export const riskEngineDataClientMock = { create: createRiskEngineDataClientMock }; diff --git a/x-pack/plugins/security_solution/server/lib/risk_engine/configurations.ts b/x-pack/plugins/security_solution/server/lib/risk_engine/configurations.ts new file mode 100644 index 0000000000000..64b31b2c705a5 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/risk_engine/configurations.ts @@ -0,0 +1,97 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import type { FieldMap } from '@kbn/alerts-as-data-utils'; +import type { IIndexPatternString } from './utils/create_datastream'; + +export const ilmPolicy = { + _meta: { + managed: true, + }, + phases: { + hot: { + actions: { + rollover: { + max_age: '30d', + max_primary_shard_size: '50gb', + }, + }, + }, + }, +}; + +export const riskFieldMap: FieldMap = { + '@timestamp': { + type: 'date', + array: false, + required: false, + }, + identifierField: { + type: 'keyword', + array: false, + required: false, + }, + identifierValue: { + type: 'keyword', + array: false, + required: false, + }, + level: { + type: 'keyword', + array: false, + required: false, + }, + totalScore: { + type: 'float', + array: false, + required: false, + }, + totalScoreNormalized: { + type: 'float', + array: false, + required: false, + }, + alertsScore: { + type: 'float', + array: false, + required: false, + }, + otherScore: { + type: 'float', + array: false, + required: false, + }, + riskiestInputs: { + type: 'nested', + required: false, + }, + 'riskiestInputs.id': { + type: 'keyword', + array: false, + required: false, + }, + 'riskiestInputs.index': { + type: 'keyword', + array: false, + required: false, + }, + 'riskiestInputs.riskScore': { + type: 'float', + array: false, + required: false, + }, +} as const; + +export const ilmPolicyName = '.risk-score-ilm-policy'; +export const mappingComponentName = '.risk-score-mappings'; +export const totalFieldsLimit = 1000; + +const riskScoreBaseIndexName = 'risk-score'; + +export const getIndexPattern = (namespace: string): IIndexPatternString => ({ + template: `.${riskScoreBaseIndexName}.${riskScoreBaseIndexName}-${namespace}-index-template`, + alias: `${riskScoreBaseIndexName}.${riskScoreBaseIndexName}-${namespace}`, +}); diff --git a/x-pack/plugins/security_solution/server/lib/risk_engine/risk_engine_data_client.test.ts b/x-pack/plugins/security_solution/server/lib/risk_engine/risk_engine_data_client.test.ts new file mode 100644 index 0000000000000..391d89b2ebf8b --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/risk_engine/risk_engine_data_client.test.ts @@ -0,0 +1,218 @@ +/* + * 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 { + createOrUpdateComponentTemplate, + createOrUpdateIlmPolicy, + createOrUpdateIndexTemplate, +} from '@kbn/alerting-plugin/server'; +import { loggingSystemMock, elasticsearchServiceMock } from '@kbn/core/server/mocks'; +import { RiskEngineDataClient } from './risk_engine_data_client'; +import { createDataStream } from './utils/create_datastream'; + +jest.mock('@kbn/alerting-plugin/server', () => ({ + createOrUpdateComponentTemplate: jest.fn(), + createOrUpdateIlmPolicy: jest.fn(), + createOrUpdateIndexTemplate: jest.fn(), +})); + +jest.mock('./utils/create_datastream', () => ({ + createDataStream: jest.fn(), +})); + +describe('RiskEngineDataClient', () => { + let riskEngineDataClient: RiskEngineDataClient; + let logger: ReturnType; + const esClient = elasticsearchServiceMock.createClusterClient().asInternalUser; + const totalFieldsLimit = 1000; + + beforeEach(() => { + logger = loggingSystemMock.createLogger(); + const options = { + logger, + kibanaVersion: '8.9.0', + elasticsearchClientPromise: Promise.resolve(esClient), + }; + riskEngineDataClient = new RiskEngineDataClient(options); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('getWriter', () => { + it('should return a writer object', async () => { + const writer = await riskEngineDataClient.getWriter({ namespace: 'default' }); + expect(writer).toBeDefined(); + expect(typeof writer?.bulk).toBe('function'); + }); + + it('should cache and return the same writer for the same namespace', async () => { + const writer1 = await riskEngineDataClient.getWriter({ namespace: 'default' }); + const writer2 = await riskEngineDataClient.getWriter({ namespace: 'default' }); + const writer3 = await riskEngineDataClient.getWriter({ namespace: 'space-1' }); + + expect(writer1).toEqual(writer2); + expect(writer2).not.toEqual(writer3); + }); + + it('should cache writer and not call initializeResources for a second tme', async () => { + const initializeResourcesSpy = jest.spyOn(riskEngineDataClient, 'initializeResources'); + await riskEngineDataClient.getWriter({ namespace: 'default' }); + await riskEngineDataClient.getWriter({ namespace: 'default' }); + expect(initializeResourcesSpy).toHaveBeenCalledTimes(1); + }); + }); + + describe('initializeResources succes', () => { + it('should initialize risk engine resources', async () => { + await riskEngineDataClient.initializeResources({ namespace: 'default' }); + + expect(createOrUpdateIlmPolicy).toHaveBeenCalledWith({ + logger, + esClient, + name: '.risk-score-ilm-policy', + policy: { + _meta: { + managed: true, + }, + phases: { + hot: { + actions: { + rollover: { + max_age: '30d', + max_primary_shard_size: '50gb', + }, + }, + }, + }, + }, + }); + + expect(createOrUpdateComponentTemplate).toHaveBeenCalledWith({ + logger, + esClient, + template: { + name: '.risk-score-mappings', + _meta: { + managed: true, + }, + template: { + settings: {}, + mappings: { + dynamic: 'strict', + properties: { + '@timestamp': { + type: 'date', + }, + alertsScore: { + type: 'float', + }, + identifierField: { + type: 'keyword', + }, + identifierValue: { + type: 'keyword', + }, + level: { + type: 'keyword', + }, + otherScore: { + type: 'float', + }, + riskiestInputs: { + properties: { + id: { + type: 'keyword', + }, + index: { + type: 'keyword', + }, + riskScore: { + type: 'float', + }, + }, + type: 'nested', + }, + totalScore: { + type: 'float', + }, + totalScoreNormalized: { + type: 'float', + }, + }, + }, + }, + }, + totalFieldsLimit, + }); + + expect(createOrUpdateIndexTemplate).toHaveBeenCalledWith({ + logger, + esClient, + template: { + name: '.risk-score.risk-score-default-index-template', + body: { + data_stream: { hidden: true }, + index_patterns: ['risk-score.risk-score-default'], + composed_of: ['.risk-score-mappings'], + template: { + settings: { + auto_expand_replicas: '0-1', + hidden: true, + 'index.lifecycle': { + name: '.risk-score-ilm-policy', + }, + 'index.mapping.total_fields.limit': totalFieldsLimit, + }, + mappings: { + dynamic: false, + _meta: { + kibana: { + version: '8.9.0', + }, + managed: true, + namespace: 'default', + }, + }, + }, + _meta: { + kibana: { + version: '8.9.0', + }, + managed: true, + namespace: 'default', + }, + }, + }, + }); + + expect(createDataStream).toHaveBeenCalledWith({ + logger, + esClient, + totalFieldsLimit, + indexPatterns: { + template: `.risk-score.risk-score-default-index-template`, + alias: `risk-score.risk-score-default`, + }, + }); + }); + }); + + describe('initializeResources error', () => { + it('should handle errors during initialization', async () => { + const error = new Error('There error'); + (createOrUpdateIlmPolicy as jest.Mock).mockRejectedValue(error); + + await riskEngineDataClient.initializeResources({ namespace: 'default' }); + + expect(logger.error).toHaveBeenCalledWith( + `Error initializing risk engine resources: ${error.message}` + ); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/server/lib/risk_engine/risk_engine_data_client.ts b/x-pack/plugins/security_solution/server/lib/risk_engine/risk_engine_data_client.ts new file mode 100644 index 0000000000000..9b77741bb164a --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/risk_engine/risk_engine_data_client.ts @@ -0,0 +1,142 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import type { Metadata } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import type { ClusterPutComponentTemplateRequest } from '@elastic/elasticsearch/lib/api/types'; +import { + createOrUpdateComponentTemplate, + createOrUpdateIlmPolicy, + createOrUpdateIndexTemplate, +} from '@kbn/alerting-plugin/server'; +import { mappingFromFieldMap } from '@kbn/alerting-plugin/common'; +import { DEFAULT_NAMESPACE_STRING } from '@kbn/core-saved-objects-utils-server'; +import type { Logger, ElasticsearchClient } from '@kbn/core/server'; +import { + riskFieldMap, + getIndexPattern, + totalFieldsLimit, + mappingComponentName, + ilmPolicyName, + ilmPolicy, +} from './configurations'; +import { createDataStream } from './utils/create_datastream'; + +interface InitializeRiskEngineResourcesOpts { + namespace?: string; +} + +interface RiskEngineDataClientOpts { + logger: Logger; + kibanaVersion: string; + elasticsearchClientPromise: Promise; +} + +interface Writer { + bulk: () => Promise; +} + +export class RiskEngineDataClient { + private writerCache: Map = new Map(); + constructor(private readonly options: RiskEngineDataClientOpts) {} + + public async getWriter({ namespace }: { namespace: string }): Promise { + if (this.writerCache.get(namespace)) { + return this.writerCache.get(namespace) as Writer; + } + + await this.initializeResources({ namespace }); + return this.writerCache.get(namespace) as Writer; + } + + private async initializeWriter(namespace: string): Promise { + const writer: Writer = { + bulk: async () => {}, + }; + this.writerCache.set(namespace, writer); + return writer; + } + + public async initializeResources({ + namespace = DEFAULT_NAMESPACE_STRING, + }: InitializeRiskEngineResourcesOpts) { + try { + const esClient = await this.options.elasticsearchClientPromise; + + const indexPatterns = getIndexPattern(namespace); + + const indexMetadata: Metadata = { + kibana: { + version: this.options.kibanaVersion, + }, + managed: true, + namespace, + }; + + await Promise.all([ + createOrUpdateIlmPolicy({ + logger: this.options.logger, + esClient, + name: ilmPolicyName, + policy: ilmPolicy, + }), + createOrUpdateComponentTemplate({ + logger: this.options.logger, + esClient, + template: { + name: mappingComponentName, + _meta: { + managed: true, + }, + template: { + settings: {}, + mappings: mappingFromFieldMap(riskFieldMap, 'strict'), + }, + } as ClusterPutComponentTemplateRequest, + totalFieldsLimit, + }), + ]); + + await createOrUpdateIndexTemplate({ + logger: this.options.logger, + esClient, + template: { + name: indexPatterns.template, + body: { + data_stream: { hidden: true }, + index_patterns: [indexPatterns.alias], + composed_of: [mappingComponentName], + template: { + settings: { + auto_expand_replicas: '0-1', + hidden: true, + 'index.lifecycle': { + name: ilmPolicyName, + }, + 'index.mapping.total_fields.limit': totalFieldsLimit, + }, + mappings: { + dynamic: false, + _meta: indexMetadata, + }, + }, + _meta: indexMetadata, + }, + }, + }); + + await createDataStream({ + logger: this.options.logger, + esClient, + totalFieldsLimit, + indexPatterns, + }); + + this.initializeWriter(namespace); + } catch (error) { + this.options.logger.error(`Error initializing risk engine resources: ${error.message}`); + } + } +} diff --git a/x-pack/plugins/security_solution/server/lib/risk_engine/utils/create_datastream.ts b/x-pack/plugins/security_solution/server/lib/risk_engine/utils/create_datastream.ts new file mode 100644 index 0000000000000..910ba5e887046 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/risk_engine/utils/create_datastream.ts @@ -0,0 +1,206 @@ +/* + * 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. + */ + +// This file is a copy of x-pack/plugins/alerting/server/alerts_service/lib/create_concrete_write_index.ts +// original function create index instead of datastream, and their have plan to use datastream in the future +// so we probably should remove this file and use the original when datastream will be supported + +import type { IndicesSimulateIndexTemplateResponse } from '@elastic/elasticsearch/lib/api/typesWithBodyKey'; +import type { Logger, ElasticsearchClient } from '@kbn/core/server'; +import { get } from 'lodash'; +import { retryTransientEsErrors } from './retry_transient_es_errors'; + +export interface IIndexPatternString { + template: string; + alias: string; +} + +interface ConcreteIndexInfo { + index: string; + alias: string; + isWriteIndex: boolean; +} + +interface UpdateIndexMappingsOpts { + logger: Logger; + esClient: ElasticsearchClient; + totalFieldsLimit: number; + concreteIndices: ConcreteIndexInfo[]; +} + +interface UpdateIndexOpts { + logger: Logger; + esClient: ElasticsearchClient; + totalFieldsLimit: number; + concreteIndexInfo: ConcreteIndexInfo; +} + +const updateTotalFieldLimitSetting = async ({ + logger, + esClient, + totalFieldsLimit, + concreteIndexInfo, +}: UpdateIndexOpts) => { + const { index, alias } = concreteIndexInfo; + try { + await retryTransientEsErrors( + () => + esClient.indices.putSettings({ + index, + body: { 'index.mapping.total_fields.limit': totalFieldsLimit }, + }), + { logger } + ); + return; + } catch (err) { + logger.error( + `Failed to PUT index.mapping.total_fields.limit settings for alias ${alias}: ${err.message}` + ); + throw err; + } +}; + +// This will update the mappings of backing indices but *not* the settings. This +// is due to the fact settings can be classed as dynamic and static, and static +// updates will fail on an index that isn't closed. New settings *will* be applied as part +// of the ILM policy rollovers. More info: https://github.com/elastic/kibana/pull/113389#issuecomment-940152654 +const updateUnderlyingMapping = async ({ + logger, + esClient, + concreteIndexInfo, +}: UpdateIndexOpts) => { + const { index, alias } = concreteIndexInfo; + let simulatedIndexMapping: IndicesSimulateIndexTemplateResponse; + try { + simulatedIndexMapping = await retryTransientEsErrors( + () => esClient.indices.simulateIndexTemplate({ name: index }), + { logger } + ); + } catch (err) { + logger.error( + `Ignored PUT mappings for alias ${alias}; error generating simulated mappings: ${err.message}` + ); + return; + } + + const simulatedMapping = get(simulatedIndexMapping, ['template', 'mappings']); + + if (simulatedMapping == null) { + logger.error(`Ignored PUT mappings for alias ${alias}; simulated mappings were empty`); + return; + } + + try { + await retryTransientEsErrors( + () => esClient.indices.putMapping({ index, body: simulatedMapping }), + { logger } + ); + + return; + } catch (err) { + logger.error(`Failed to PUT mapping for alias ${alias}: ${err.message}`); + throw err; + } +}; +/** + * Updates the underlying mapping for any existing concrete indices + */ +const updateIndexMappings = async ({ + logger, + esClient, + totalFieldsLimit, + concreteIndices, +}: UpdateIndexMappingsOpts) => { + logger.debug(`Updating underlying mappings for ${concreteIndices.length} indices.`); + + // Update total field limit setting of found indices + // Other index setting changes are not updated at this time + await Promise.all( + concreteIndices.map((index) => + updateTotalFieldLimitSetting({ logger, esClient, totalFieldsLimit, concreteIndexInfo: index }) + ) + ); + + // Update mappings of the found indices. + await Promise.all( + concreteIndices.map((index) => + updateUnderlyingMapping({ logger, esClient, totalFieldsLimit, concreteIndexInfo: index }) + ) + ); +}; + +interface CreateConcreteWriteIndexOpts { + logger: Logger; + esClient: ElasticsearchClient; + totalFieldsLimit: number; + indexPatterns: IIndexPatternString; +} +/** + * Create a data stream + */ +export const createDataStream = async ({ + logger, + esClient, + indexPatterns, + totalFieldsLimit, +}: CreateConcreteWriteIndexOpts) => { + logger.info(`Creating data stream - ${indexPatterns.alias}`); + + // check if a datastream already exists + let dataStreams: ConcreteIndexInfo[] = []; + try { + // Specify both the index pattern for the backing indices and their aliases + // The alias prevents the request from finding other namespaces that could match the -* pattern + const response = await retryTransientEsErrors( + () => esClient.indices.getDataStream({ name: indexPatterns.alias, expand_wildcards: 'all' }), + { logger } + ); + + dataStreams = response.data_streams.map((dataStream) => ({ + index: dataStream.name, + alias: dataStream.name, + isWriteIndex: true, + })); + + logger.debug( + `Found ${dataStreams.length} concrete indices for ${indexPatterns.alias} - ${JSON.stringify( + dataStreams + )}` + ); + } catch (error) { + // 404 is expected if no datastream have been created + if (error.statusCode !== 404) { + logger.error( + `Error fetching concrete indices for ${indexPatterns.alias} pattern - ${error.message}` + ); + throw error; + } + } + + const dataStreamsExist = dataStreams.length > 0; + + // if a concrete write datastream already exists, update the underlying mapping + if (dataStreams.length > 0) { + await updateIndexMappings({ logger, esClient, totalFieldsLimit, concreteIndices: dataStreams }); + } + + // check if a concrete write datastream already exists + if (!dataStreamsExist) { + try { + await retryTransientEsErrors( + () => + esClient.indices.createDataStream({ + name: indexPatterns.alias, + }), + { logger } + ); + } catch (error) { + logger.error(`Error creating datastream - ${error.message}`); + throw error; + } + } +}; diff --git a/x-pack/plugins/security_solution/server/lib/risk_engine/utils/retry_transient_es_errors.test.ts b/x-pack/plugins/security_solution/server/lib/risk_engine/utils/retry_transient_es_errors.test.ts new file mode 100644 index 0000000000000..2501c57776d80 --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/risk_engine/utils/retry_transient_es_errors.test.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 { loggerMock } from '@kbn/logging-mocks'; +import { errors as EsErrors } from '@elastic/elasticsearch'; + +import { retryTransientEsErrors } from './retry_transient_es_errors'; + +const logger = loggerMock.create(); +const randomDelayMultiplier = 0.01; + +describe('retryTransientErrors', () => { + beforeEach(() => { + jest.resetAllMocks(); + jest.spyOn(global.Math, 'random').mockReturnValue(randomDelayMultiplier); + }); + + it("doesn't retry if operation is successful", async () => { + const esCallMock = jest.fn().mockResolvedValue('success'); + expect(await retryTransientEsErrors(esCallMock, { logger })).toEqual('success'); + expect(esCallMock).toHaveBeenCalledTimes(1); + }); + + it('logs a warning message on retry', async () => { + const esCallMock = jest + .fn() + .mockRejectedValueOnce(new EsErrors.ConnectionError('foo')) + .mockResolvedValue('success'); + + await retryTransientEsErrors(esCallMock, { logger }); + expect(logger.warn).toHaveBeenCalledTimes(1); + expect(logger.warn.mock.calls[0][0]).toMatch( + `Retrying Elasticsearch operation after [2s] due to error: ConnectionError: foo ConnectionError: foo` + ); + }); + + it('retries with an exponential backoff', async () => { + let attempt = 0; + const esCallMock = jest.fn(async () => { + attempt++; + if (attempt < 4) { + throw new EsErrors.ConnectionError('foo'); + } else { + return 'success'; + } + }); + + expect(await retryTransientEsErrors(esCallMock, { logger })).toEqual('success'); + expect(esCallMock).toHaveBeenCalledTimes(4); + expect(logger.warn).toHaveBeenCalledTimes(3); + expect(logger.warn.mock.calls[0][0]).toMatch( + `Retrying Elasticsearch operation after [2s] due to error: ConnectionError: foo ConnectionError: foo` + ); + expect(logger.warn.mock.calls[1][0]).toMatch( + `Retrying Elasticsearch operation after [4s] due to error: ConnectionError: foo ConnectionError: foo` + ); + expect(logger.warn.mock.calls[2][0]).toMatch( + `Retrying Elasticsearch operation after [8s] due to error: ConnectionError: foo ConnectionError: foo` + ); + }); + + it('retries each supported error type', async () => { + const errors = [ + new EsErrors.NoLivingConnectionsError('no living connection', { + warnings: [], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + meta: {} as any, + }), + new EsErrors.ConnectionError('no connection'), + new EsErrors.TimeoutError('timeout'), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + new EsErrors.ResponseError({ statusCode: 503, meta: {} as any, warnings: [] }), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + new EsErrors.ResponseError({ statusCode: 408, meta: {} as any, warnings: [] }), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + new EsErrors.ResponseError({ statusCode: 410, meta: {} as any, warnings: [] }), + ]; + + for (const error of errors) { + const esCallMock = jest.fn().mockRejectedValueOnce(error).mockResolvedValue('success'); + expect(await retryTransientEsErrors(esCallMock, { logger })).toEqual('success'); + expect(esCallMock).toHaveBeenCalledTimes(2); + } + }); + + it('does not retry unsupported errors', async () => { + const error = new Error('foo!'); + const esCallMock = jest.fn().mockRejectedValueOnce(error).mockResolvedValue('success'); + await expect(retryTransientEsErrors(esCallMock, { logger })).rejects.toThrow(error); + expect(esCallMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/x-pack/plugins/security_solution/server/lib/risk_engine/utils/retry_transient_es_errors.ts b/x-pack/plugins/security_solution/server/lib/risk_engine/utils/retry_transient_es_errors.ts new file mode 100644 index 0000000000000..7a3839ad3c5bc --- /dev/null +++ b/x-pack/plugins/security_solution/server/lib/risk_engine/utils/retry_transient_es_errors.ts @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { Logger } from '@kbn/core/server'; +import { errors as EsErrors } from '@elastic/elasticsearch'; + +const MAX_ATTEMPTS = 3; + +const retryResponseStatuses = [ + 503, // ServiceUnavailable + 408, // RequestTimeout + 410, // Gone +]; + +const isRetryableError = (e: Error) => + e instanceof EsErrors.NoLivingConnectionsError || + e instanceof EsErrors.ConnectionError || + e instanceof EsErrors.TimeoutError || + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + (e instanceof EsErrors.ResponseError && retryResponseStatuses.includes(e?.statusCode!)); + +const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +export const retryTransientEsErrors = async ( + esCall: () => Promise, + { + logger, + attempt = 0, + }: { + logger: Logger; + attempt?: number; + } +): Promise => { + try { + return await esCall(); + } catch (e) { + if (attempt < MAX_ATTEMPTS && isRetryableError(e)) { + const retryCount = attempt + 1; + const retryDelaySec: number = Math.min(Math.pow(2, retryCount), 30); // 2s, 4s, 8s, 16s, 30s, 30s, 30s... + + logger.warn( + `Retrying Elasticsearch operation after [${retryDelaySec}s] due to error: ${e.toString()} ${ + e.stack + }` + ); + + // delay with some randomness + await delay(retryDelaySec * 1000 * Math.random()); + return retryTransientEsErrors(esCall, { logger, attempt: retryCount }); + } + + throw e; + } +}; diff --git a/x-pack/plugins/security_solution/server/plugin.ts b/x-pack/plugins/security_solution/server/plugin.ts index a838c83a31e2b..b9dce72f64671 100644 --- a/x-pack/plugins/security_solution/server/plugin.ts +++ b/x-pack/plugins/security_solution/server/plugin.ts @@ -94,6 +94,8 @@ import { ENDPOINT_FIELDS_SEARCH_STRATEGY, ENDPOINT_SEARCH_STRATEGY, } from '../common/endpoint/constants'; +import { RiskEngineDataClient } from './lib/risk_engine/risk_engine_data_client'; + import { AppFeatures } from './lib/app_features'; export type { SetupPlugins, StartPlugins, PluginSetup, PluginStart } from './plugin_contract'; @@ -118,6 +120,7 @@ export class Plugin implements ISecuritySolutionPlugin { private checkMetadataTransformsTask: CheckMetadataTransformsTask | undefined; private telemetryUsageCounter?: UsageCounter; private endpointContext: EndpointAppContext; + private riskEngineDataClient: RiskEngineDataClient | undefined; constructor(context: PluginInitializerContext) { const serverConfig = createConfig(context); @@ -159,6 +162,18 @@ export class Plugin implements ISecuritySolutionPlugin { this.ruleMonitoringService.setup(core, plugins); + this.riskEngineDataClient = new RiskEngineDataClient({ + logger: this.logger, + kibanaVersion: this.pluginContext.env.packageInfo.version, + elasticsearchClientPromise: core + .getStartServices() + .then(([{ elasticsearch }]) => elasticsearch.client.asInternalUser), + }); + + if (experimentalFeatures.riskScoringPersistence) { + this.riskEngineDataClient.initializeResources({}); + } + const requestContextFactory = new RequestContextFactory({ config, logger, @@ -168,6 +183,7 @@ export class Plugin implements ISecuritySolutionPlugin { ruleMonitoringService: this.ruleMonitoringService, kibanaVersion: pluginContext.env.packageInfo.version, kibanaBranch: pluginContext.env.packageInfo.branch, + riskEngineDataClient: this.riskEngineDataClient, }); const router = core.http.createRouter(); diff --git a/x-pack/plugins/security_solution/server/request_context_factory.ts b/x-pack/plugins/security_solution/server/request_context_factory.ts index e56df95a10650..c5cf4b24750a4 100644 --- a/x-pack/plugins/security_solution/server/request_context_factory.ts +++ b/x-pack/plugins/security_solution/server/request_context_factory.ts @@ -25,6 +25,7 @@ import type { import type { Immutable } from '../common/endpoint/types'; import type { EndpointAuthz } from '../common/endpoint/types/authz'; import type { EndpointAppContextService } from './endpoint/endpoint_app_context_services'; +import type { RiskEngineDataClient } from './lib/risk_engine/risk_engine_data_client'; export interface IRequestContextFactory { create( @@ -42,6 +43,7 @@ interface ConstructorOptions { ruleMonitoringService: IRuleMonitoringService; kibanaVersion: string; kibanaBranch: string; + riskEngineDataClient: RiskEngineDataClient; } export class RequestContextFactory implements IRequestContextFactory { @@ -56,7 +58,15 @@ export class RequestContextFactory implements IRequestContextFactory { request: KibanaRequest ): Promise { const { options, appClientFactory } = this; - const { config, core, plugins, endpointAppContextService, ruleMonitoringService } = options; + const { + config, + core, + plugins, + endpointAppContextService, + ruleMonitoringService, + riskEngineDataClient, + } = options; + const { lists, ruleRegistry, security } = plugins; const [, startPlugins] = await core.getStartServices(); @@ -128,6 +138,8 @@ export class RequestContextFactory implements IRequestContextFactory { }, getInternalFleetServices: memoize(() => endpointAppContextService.getInternalFleetServices()), + + getRiskEngineDataClient: () => riskEngineDataClient, }; } } diff --git a/x-pack/plugins/security_solution/server/types.ts b/x-pack/plugins/security_solution/server/types.ts index 64211998defc5..8326d13ad03a7 100644 --- a/x-pack/plugins/security_solution/server/types.ts +++ b/x-pack/plugins/security_solution/server/types.ts @@ -29,6 +29,7 @@ import type { import type { FrameworkRequest } from './lib/framework'; import type { EndpointAuthz } from '../common/endpoint/types/authz'; import type { EndpointInternalFleetServicesInterface } from './endpoint/services/fleet'; +import type { RiskEngineDataClient } from './lib/risk_engine/risk_engine_data_client'; export { AppClient }; @@ -46,6 +47,7 @@ export interface SecuritySolutionApiRequestHandlerContext { getRacClient: (req: KibanaRequest) => Promise; getExceptionListClient: () => ExceptionListClient | null; getInternalFleetServices: () => EndpointInternalFleetServicesInterface; + getRiskEngineDataClient: () => RiskEngineDataClient; } export type SecuritySolutionRequestHandlerContext = CustomRequestHandlerContext<{ diff --git a/x-pack/test/detection_engine_api_integration/common/config.ts b/x-pack/test/detection_engine_api_integration/common/config.ts index 8454915db9a7d..c4c3c44f1c418 100644 --- a/x-pack/test/detection_engine_api_integration/common/config.ts +++ b/x-pack/test/detection_engine_api_integration/common/config.ts @@ -76,6 +76,7 @@ export function createTestConfig(options: CreateTestConfigOptions, testFiles?: s '--xpack.ruleRegistry.unsafe.legacyMultiTenancy.enabled=true', `--xpack.securitySolution.enableExperimental=${JSON.stringify([ 'previewTelemetryUrlEnabled', + 'riskScoringPersistence', 'riskScoringRoutesEnabled', ])}`, '--xpack.task_manager.poll_interval=1000', diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/group10/index.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/group10/index.ts index c8501050fc25c..6841648145fd3 100644 --- a/x-pack/test/detection_engine_api_integration/security_and_spaces/group10/index.ts +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/group10/index.ts @@ -37,6 +37,7 @@ export default ({ loadTestFile }: FtrProviderContext): void => { loadTestFile(require.resolve('./throttle')); loadTestFile(require.resolve('./ignore_fields')); loadTestFile(require.resolve('./migrations')); + loadTestFile(require.resolve('./risk_engine_install_resources')); loadTestFile(require.resolve('./risk_engine')); loadTestFile(require.resolve('./set_alert_tags')); }); diff --git a/x-pack/test/detection_engine_api_integration/security_and_spaces/group10/risk_engine_install_resources.ts b/x-pack/test/detection_engine_api_integration/security_and_spaces/group10/risk_engine_install_resources.ts new file mode 100644 index 0000000000000..a7cae20fa8b34 --- /dev/null +++ b/x-pack/test/detection_engine_api_integration/security_and_spaces/group10/risk_engine_install_resources.ts @@ -0,0 +1,150 @@ +/* + * 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 '../../common/ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext) => { + const es = getService('es'); + + describe('install risk engine resources', () => { + it('should install resources on startup', async () => { + const ilmPolicyName = '.risk-score-ilm-policy'; + const componentTemplateName = '.risk-score-mappings'; + const indexTemplateName = '.risk-score.risk-score-default-index-template'; + const indexName = 'risk-score.risk-score-default'; + + const ilmPolicy = await es.ilm.getLifecycle({ + name: ilmPolicyName, + }); + + expect(ilmPolicy[ilmPolicyName].policy).to.eql({ + _meta: { + managed: true, + }, + phases: { + hot: { + min_age: '0ms', + actions: { + rollover: { + max_age: '30d', + max_primary_shard_size: '50gb', + }, + }, + }, + }, + }); + + const { component_templates: componentTemplates1 } = await es.cluster.getComponentTemplate({ + name: componentTemplateName, + }); + + expect(componentTemplates1.length).to.eql(1); + const componentTemplate = componentTemplates1[0]; + + expect(componentTemplate.name).to.eql(componentTemplateName); + expect(componentTemplate.component_template.template.mappings).to.eql({ + dynamic: 'strict', + properties: { + '@timestamp': { + type: 'date', + }, + alertsScore: { + type: 'float', + }, + identifierField: { + type: 'keyword', + }, + identifierValue: { + type: 'keyword', + }, + level: { + type: 'keyword', + }, + otherScore: { + type: 'float', + }, + riskiestInputs: { + properties: { + id: { + type: 'keyword', + }, + index: { + type: 'keyword', + }, + riskScore: { + type: 'float', + }, + }, + type: 'nested', + }, + totalScore: { + type: 'float', + }, + totalScoreNormalized: { + type: 'float', + }, + }, + }); + + const { index_templates: indexTemplates } = await es.indices.getIndexTemplate({ + name: indexTemplateName, + }); + expect(indexTemplates.length).to.eql(1); + const indexTemplate = indexTemplates[0]; + expect(indexTemplate.name).to.eql(indexTemplateName); + expect(indexTemplate.index_template.index_patterns).to.eql(['risk-score.risk-score-default']); + expect(indexTemplate.index_template.composed_of).to.eql(['.risk-score-mappings']); + expect(indexTemplate.index_template.template!.mappings?.dynamic).to.eql(false); + expect(indexTemplate.index_template.template!.mappings?._meta?.managed).to.eql(true); + expect(indexTemplate.index_template.template!.mappings?._meta?.namespace).to.eql('default'); + expect(indexTemplate.index_template.template!.mappings?._meta?.kibana?.version).to.be.a( + 'string' + ); + expect(indexTemplate.index_template.template!.settings).to.eql({ + index: { + lifecycle: { + name: '.risk-score-ilm-policy', + }, + mapping: { + total_fields: { + limit: '1000', + }, + }, + hidden: 'true', + auto_expand_replicas: '0-1', + }, + }); + + const dsResponse = await es.indices.get({ + index: indexName, + }); + + const dataStream = Object.values(dsResponse).find((ds) => ds.data_stream === indexName); + + expect(dataStream?.mappings?._meta?.managed).to.eql(true); + expect(dataStream?.mappings?._meta?.namespace).to.eql('default'); + expect(dataStream?.mappings?._meta?.kibana?.version).to.be.a('string'); + expect(dataStream?.mappings?.dynamic).to.eql('false'); + + expect(dataStream?.settings?.index?.lifecycle).to.eql({ + name: '.risk-score-ilm-policy', + }); + + expect(dataStream?.settings?.index?.mapping).to.eql({ + total_fields: { + limit: '1000', + }, + }); + + expect(dataStream?.settings?.index?.hidden).to.eql('true'); + expect(dataStream?.settings?.index?.number_of_shards).to.eql(1); + expect(dataStream?.settings?.index?.auto_expand_replicas).to.eql('0-1'); + }); + }); +}; From bdf910ed50e1535ef99578a8a08b639835da3add Mon Sep 17 00:00:00 2001 From: James Gowdy Date: Wed, 21 Jun 2023 14:15:59 +0100 Subject: [PATCH 3/7] [ML] Hiding pattern analysis button for non-time series data (#160051) The `Run pattern analysis` button should only be available for time series data. This PR adds an additional check using the dataview's `isTimeBased` method. --- .../categorize_trigger_utils.test.ts | 2 +- .../field_categorize_button/categorize_trigger_utils.ts | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/plugins/unified_field_list/public/components/field_categorize_button/categorize_trigger_utils.test.ts b/src/plugins/unified_field_list/public/components/field_categorize_button/categorize_trigger_utils.test.ts index 79dacc2ed1fd0..a0efbaf8de869 100644 --- a/src/plugins/unified_field_list/public/components/field_categorize_button/categorize_trigger_utils.test.ts +++ b/src/plugins/unified_field_list/public/components/field_categorize_button/categorize_trigger_utils.test.ts @@ -51,7 +51,7 @@ const action: Action = { execute: () => Promise.resolve(), }; -const dataViewMock = { id: '1', toSpec: () => ({}) } as DataView; +const dataViewMock = { id: '1', toSpec: () => ({}), isTimeBased: () => true } as DataView; describe('categorize_trigger_utils', () => { afterEach(() => { diff --git a/src/plugins/unified_field_list/public/components/field_categorize_button/categorize_trigger_utils.ts b/src/plugins/unified_field_list/public/components/field_categorize_button/categorize_trigger_utils.ts index 560671c10d1c1..371425d7e9d14 100644 --- a/src/plugins/unified_field_list/public/components/field_categorize_button/categorize_trigger_utils.ts +++ b/src/plugins/unified_field_list/public/components/field_categorize_button/categorize_trigger_utils.ts @@ -46,7 +46,12 @@ export async function canCategorize( field: DataViewField, dataView: DataView | undefined ): Promise { - if (field.name === '_id' || !dataView?.id || !field.esTypes?.includes('text')) { + if ( + field.name === '_id' || + !dataView?.id || + !dataView.isTimeBased() || + !field.esTypes?.includes('text') + ) { return false; } From 9f002b9a40194a43f4c45191827931cae78be102 Mon Sep 17 00:00:00 2001 From: Saarika Bhasi <55930906+saarikabhasi@users.noreply.github.com> Date: Wed, 21 Jun 2023 09:20:19 -0400 Subject: [PATCH 4/7] [Enterprise Search] Search Application add tour in preview page (#159845) ## Summary * Adds tour near to configuration button when page loads * Uses localStorage to save tour closed/open state ### Screen Recording https://github.com/elastic/kibana/assets/55930906/4acb6142-9822-41c4-b88a-1da2d3bf0b28 --- .../engine_search_preview.tsx | 80 ++++++++++++++++--- 1 file changed, 67 insertions(+), 13 deletions(-) diff --git a/x-pack/plugins/enterprise_search/public/applications/applications/components/engine/engine_search_preview/engine_search_preview.tsx b/x-pack/plugins/enterprise_search/public/applications/applications/components/engine/engine_search_preview/engine_search_preview.tsx index 428283df3cd86..cde84312d81f7 100644 --- a/x-pack/plugins/enterprise_search/public/applications/applications/components/engine/engine_search_preview/engine_search_preview.tsx +++ b/x-pack/plugins/enterprise_search/public/applications/applications/components/engine/engine_search_preview/engine_search_preview.tsx @@ -9,6 +9,8 @@ import React, { useState, useMemo } from 'react'; import { useActions, useValues } from 'kea'; +import useLocalStorage from 'react-use/lib/useLocalStorage'; + import { EuiButtonEmpty, EuiContextMenuItem, @@ -24,6 +26,7 @@ import { EuiText, EuiTextColor, EuiTitle, + EuiTourStep, } from '@elastic/eui'; import { PagingInfo, @@ -128,6 +131,11 @@ const ConfigurationPopover: React.FC = ({ const { engineData } = useValues(EngineViewLogic); const { openDeleteEngineModal } = useActions(EngineViewLogic); const { sendEnterpriseSearchTelemetry } = useActions(TelemetryLogic); + const [isTourClosed, setTourClosed] = useLocalStorage( + 'search-application-tour-closed', + false + ); + return ( <> = ({ closePopover={setCloseConfiguration} button={ - {hasSchemaConflicts && } - - {i18n.translate( - 'xpack.enterpriseSearch.content.engine.searchPreview.configuration.buttonTitle', - { - defaultMessage: 'Configuration', + {hasSchemaConflicts && ( + <> + + + + {!isTourClosed && } + + )} + + +

+ {i18n.translate( + 'xpack.enterpriseSearch.content.searchApplication.searchPreview.configuration.tourContent', + { + defaultMessage: + 'Create your API key, learn about using language clients and find more resources in Connect.', + } + )} +

+ } - )} -
+ isStepOpen={!isTourClosed} + maxWidth={360} + hasArrow + step={1} + onFinish={() => { + setTourClosed(true); + }} + stepsTotal={1} + anchorPosition="downCenter" + title={i18n.translate( + 'xpack.enterpriseSearch.content.searchApplication.searchPreview.configuration.tourTitle', + { + defaultMessage: 'Review our API page to start using your search application', + } + )} + > + <> + + + + + + {i18n.translate( + 'xpack.enterpriseSearch.content.engine.searchPreview.configuration.buttonTitle', + { + defaultMessage: 'Configuration', + } + )} + +
} > From c9134f131aef84ac0a4d84c9472f32f7a7c80293 Mon Sep 17 00:00:00 2001 From: jennypavlova Date: Wed, 21 Jun 2023 15:50:17 +0200 Subject: [PATCH 5/7] Refactor host filtering (#160080) ## Summary This PR refactors the filtering function (replaces the `createHostsFilter` with the new `buildCombinedHostsFilter` created for the table filtering feature) ## Testing As it's not adding any new functionality testing that will require checking if the existing filters (table selection, metrics selection) and all tabs (Metrics, Logs, Alerts) are working as expected - Check if you see the content of the logs and alerts tabs Add filter using: - the hosts table (select several hosts and add filter): image - the metrics charts (right click inside the chart and add filter): image --------- Co-authored-by: Carlos Crespo --- .../metrics/hosts/components/kpis/tile.tsx | 11 ++++---- .../components/tabs/logs/logs_tab_content.tsx | 8 ++++-- .../components/tabs/metrics/metric_chart.tsx | 11 ++++---- .../metrics/hosts/hooks/use_alerts_query.ts | 7 +++-- .../infra/public/pages/metrics/hosts/utils.ts | 28 +++++-------------- 5 files changed, 30 insertions(+), 35 deletions(-) diff --git a/x-pack/plugins/infra/public/pages/metrics/hosts/components/kpis/tile.tsx b/x-pack/plugins/infra/public/pages/metrics/hosts/components/kpis/tile.tsx index 8b7e604c6f9b9..7760fbc8bc16e 100644 --- a/x-pack/plugins/infra/public/pages/metrics/hosts/components/kpis/tile.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/hosts/components/kpis/tile.tsx @@ -24,7 +24,7 @@ import { useUnifiedSearchContext } from '../../hooks/use_unified_search'; import { HostsLensMetricChartFormulas } from '../../../../../common/visualizations'; import { useHostsViewContext } from '../../hooks/use_hosts_view'; import { LensWrapper } from '../chart/lens_wrapper'; -import { createHostsFilter } from '../../utils'; +import { buildCombinedHostsFilter } from '../../utils'; import { useHostCountContext } from '../../hooks/use_host_count'; import { useAfterLoadedState } from '../../hooks/use_after_loaded_state'; import { TooltipContent } from '../metric_explanation/tooltip_content'; @@ -83,10 +83,11 @@ export const Tile = ({ const filters = useMemo(() => { return [ - createHostsFilter( - hostNodes.map((p) => p.name), - dataView - ), + buildCombinedHostsFilter({ + field: 'host.name', + values: hostNodes.map((p) => p.name), + dataView, + }), ]; }, [hostNodes, dataView]); diff --git a/x-pack/plugins/infra/public/pages/metrics/hosts/components/tabs/logs/logs_tab_content.tsx b/x-pack/plugins/infra/public/pages/metrics/hosts/components/tabs/logs/logs_tab_content.tsx index 8b9f99c132446..6fe796d33e909 100644 --- a/x-pack/plugins/infra/public/pages/metrics/hosts/components/tabs/logs/logs_tab_content.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/hosts/components/tabs/logs/logs_tab_content.tsx @@ -15,7 +15,7 @@ import { useUnifiedSearchContext } from '../../../hooks/use_unified_search'; import { useLogsSearchUrlState } from '../../../hooks/use_logs_search_url_state'; import { LogsLinkToStream } from './logs_link_to_stream'; import { LogsSearchBar } from './logs_search_bar'; -import { createHostsFilter } from '../../../utils'; +import { buildCombinedHostsFilter } from '../../../utils'; import { useLogViewReference } from '../../../hooks/use_log_view_reference'; export const LogsTabContent = () => { @@ -25,7 +25,11 @@ export const LogsTabContent = () => { const { hostNodes, loading } = useHostsViewContext(); const hostsFilterQuery = useMemo( - () => createHostsFilter(hostNodes.map((p) => p.name)), + () => + buildCombinedHostsFilter({ + field: 'host.name', + values: hostNodes.map((p) => p.name), + }), [hostNodes] ); diff --git a/x-pack/plugins/infra/public/pages/metrics/hosts/components/tabs/metrics/metric_chart.tsx b/x-pack/plugins/infra/public/pages/metrics/hosts/components/tabs/metrics/metric_chart.tsx index 89f26a7ba906b..721feccd0f4f1 100644 --- a/x-pack/plugins/infra/public/pages/metrics/hosts/components/tabs/metrics/metric_chart.tsx +++ b/x-pack/plugins/infra/public/pages/metrics/hosts/components/tabs/metrics/metric_chart.tsx @@ -22,7 +22,7 @@ import { useMetricsDataViewContext } from '../../../hooks/use_data_view'; import { useUnifiedSearchContext } from '../../../hooks/use_unified_search'; import { HostsLensLineChartFormulas } from '../../../../../../common/visualizations'; import { useHostsViewContext } from '../../../hooks/use_hosts_view'; -import { createHostsFilter } from '../../../utils'; +import { buildCombinedHostsFilter } from '../../../utils'; import { useHostsTableContext } from '../../../hooks/use_hosts_table'; import { LensWrapper } from '../../chart/lens_wrapper'; import { useAfterLoadedState } from '../../../hooks/use_after_loaded_state'; @@ -62,10 +62,11 @@ export const MetricChart = ({ title, type, breakdownSize }: MetricChartProps) => const filters = useMemo(() => { return [ - createHostsFilter( - currentPage.map((p) => p.name), - dataView - ), + buildCombinedHostsFilter({ + field: 'host.name', + values: currentPage.map((p) => p.name), + dataView, + }), ]; }, [currentPage, dataView]); diff --git a/x-pack/plugins/infra/public/pages/metrics/hosts/hooks/use_alerts_query.ts b/x-pack/plugins/infra/public/pages/metrics/hosts/hooks/use_alerts_query.ts index 200bff521d86a..d79ee39498386 100644 --- a/x-pack/plugins/infra/public/pages/metrics/hosts/hooks/use_alerts_query.ts +++ b/x-pack/plugins/infra/public/pages/metrics/hosts/hooks/use_alerts_query.ts @@ -15,7 +15,7 @@ import { HostsState } from './use_unified_search_url_state'; import { useHostsViewContext } from './use_hosts_view'; import { AlertStatus } from '../types'; import { ALERT_STATUS_QUERY } from '../constants'; -import { createHostsFilter } from '../utils'; +import { buildCombinedHostsFilter } from '../utils'; export interface AlertsEsQuery { bool: BoolQuery; @@ -69,7 +69,10 @@ const createAlertsEsQuery = ({ const alertStatusFilter = createAlertStatusFilter(status); const dateFilter = createDateFilter(dateRange); - const hostsFilter = createHostsFilter(hostNodes.map((p) => p.name)); + const hostsFilter = buildCombinedHostsFilter({ + field: 'host.name', + values: hostNodes.map((p) => p.name), + }); const filters = [alertStatusFilter, dateFilter, hostsFilter].filter(Boolean) as Filter[]; diff --git a/x-pack/plugins/infra/public/pages/metrics/hosts/utils.ts b/x-pack/plugins/infra/public/pages/metrics/hosts/utils.ts index 63f0f7b172fb8..eba7b4d8ba032 100644 --- a/x-pack/plugins/infra/public/pages/metrics/hosts/utils.ts +++ b/x-pack/plugins/infra/public/pages/metrics/hosts/utils.ts @@ -5,9 +5,13 @@ * 2.0. */ -import { DataViewBase, Filter, isCombinedFilter } from '@kbn/es-query'; - -import { BooleanRelation, buildCombinedFilter, buildPhraseFilter } from '@kbn/es-query'; +import { + BooleanRelation, + buildCombinedFilter, + buildPhraseFilter, + Filter, + isCombinedFilter, +} from '@kbn/es-query'; import type { DataView } from '@kbn/data-views-plugin/common'; export const buildCombinedHostsFilter = ({ @@ -35,24 +39,6 @@ export const buildCombinedHostsFilter = ({ return buildCombinedFilter(BooleanRelation.OR, filtersFromValues, dataView); }; -export const createHostsFilter = (hostNames: string[], dataView?: DataViewBase): Filter => { - return { - query: { - terms: { - 'host.name': hostNames, - }, - }, - meta: dataView - ? { - value: hostNames.join(), - type: 'phrases', - params: hostNames, - index: dataView.id, - key: 'host.name', - } - : {}, - }; -}; export const retrieveFieldsFromFilter = (filters: Filter[], fields: string[] = []) => { for (const filter of filters) { if (isCombinedFilter(filter)) { From b122d398413f0a196811944ae94930f19b01b42a Mon Sep 17 00:00:00 2001 From: Bhavya RM Date: Wed, 21 Jun 2023 09:52:46 -0400 Subject: [PATCH 6/7] =?UTF-8?q?Unskipping=20fleet=5Ffunctional/apps/home/w?= =?UTF-8?q?elcome=C2=B7ts=20=20test=20(#160078)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- x-pack/test/fleet_functional/apps/home/welcome.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/x-pack/test/fleet_functional/apps/home/welcome.ts b/x-pack/test/fleet_functional/apps/home/welcome.ts index 87926f1803102..4498dda69197f 100644 --- a/x-pack/test/fleet_functional/apps/home/welcome.ts +++ b/x-pack/test/fleet_functional/apps/home/welcome.ts @@ -14,8 +14,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const PageObjects = getPageObjects(['common', 'home']); const kibanaServer = getService('kibanaServer'); - // FLAKY: https://github.com/elastic/kibana/issues/156809 - describe.skip('Welcome interstitial', () => { + describe('Welcome interstitial', () => { before(async () => { // Need to navigate to page first to clear storage before test can be run await PageObjects.common.navigateToUrl('home', undefined); From 1149fe437fa718393ab87de1300ac733e5be0c10 Mon Sep 17 00:00:00 2001 From: Xavier Mouligneau Date: Wed, 21 Jun 2023 09:57:04 -0400 Subject: [PATCH 7/7] [RAM] Add the feature for slack api to have allowed list on channels (#159534) ## Summary This will enable our user to create a slack api connector with the ability to only allow some channels as an allowed list. Our user will only be able to edit this input if the secrets is enter like the `test` button work. image ### Checklist Delete any items that are not applicable to this PR. - [x] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md) - [ ] [Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html) was added for features that require explanation or tutorials - [ ] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios --------- Co-authored-by: Lisa Cawley --- x-pack/plugins/actions/server/mocks.ts | 1 + x-pack/plugins/actions/server/plugin.ts | 4 +- .../stack_connectors/common/slack_api/lib.ts | 6 +- .../common/slack_api/schema.ts | 4 + .../common/slack_api/types.ts | 48 +++++--- .../connector_types/slack_api/slack_api.tsx | 3 +- .../slack_api/slack_connectors.test.tsx | 106 +++++++++++++++- .../slack_api/slack_connectors.tsx | 82 ++++++++++++- .../slack_api/slack_params.test.tsx | 49 ++++---- .../slack_api/slack_params.tsx | 114 +++--------------- .../connector_types/slack_api/translations.ts | 19 +++ .../slack_api/use_fetch_channels.tsx | 75 ++++++++++++ .../connector_types/slack_api/index.test.ts | 34 +++++- .../server/connector_types/slack_api/index.ts | 11 +- .../connector_types/slack_api/service.test.ts | 47 +++++++- .../connector_types/slack_api/service.ts | 108 ++++++++++++++--- .../plugins/stack_connectors/server/plugin.ts | 11 +- .../server/routes/get_slack_api_channels.ts | 109 +++++++++++++++++ .../stack_connectors/server/routes/index.ts | 1 + .../translations/translations/fr-FR.json | 6 +- .../translations/translations/ja-JP.json | 6 +- .../translations/translations/zh-CN.json | 6 +- .../components/simple_connector_form.tsx | 81 ++++++++++--- 23 files changed, 726 insertions(+), 205 deletions(-) create mode 100644 x-pack/plugins/stack_connectors/public/connector_types/slack_api/use_fetch_channels.tsx create mode 100644 x-pack/plugins/stack_connectors/server/routes/get_slack_api_channels.ts diff --git a/x-pack/plugins/actions/server/mocks.ts b/x-pack/plugins/actions/server/mocks.ts index 93b77dc7c4378..d14c082f96905 100644 --- a/x-pack/plugins/actions/server/mocks.ts +++ b/x-pack/plugins/actions/server/mocks.ts @@ -30,6 +30,7 @@ const createSetupMock = () => { getSubActionConnectorClass: jest.fn(), getCaseConnectorClass: jest.fn(), getActionsHealth: jest.fn(), + getActionsConfigurationUtilities: jest.fn(), }; return mock; }; diff --git a/x-pack/plugins/actions/server/plugin.ts b/x-pack/plugins/actions/server/plugin.ts index b044babaa22e1..8e44714129557 100644 --- a/x-pack/plugins/actions/server/plugin.ts +++ b/x-pack/plugins/actions/server/plugin.ts @@ -67,7 +67,7 @@ import { ActionsRequestHandlerContext, } from './types'; -import { getActionsConfigurationUtilities } from './actions_config'; +import { ActionsConfigurationUtilities, getActionsConfigurationUtilities } from './actions_config'; import { defineRoutes } from './routes'; import { initializeActionsTelemetry, scheduleActionsTelemetry } from './usage/task'; @@ -129,6 +129,7 @@ export interface PluginSetupContract { getSubActionConnectorClass: () => IServiceAbstract; getCaseConnectorClass: () => IServiceAbstract; getActionsHealth: () => { hasPermanentEncryptionKey: boolean }; + getActionsConfigurationUtilities: () => ActionsConfigurationUtilities; } export interface PluginStartContract { @@ -370,6 +371,7 @@ export class ActionsPlugin implements Plugin actionsConfigUtils, }; } diff --git a/x-pack/plugins/stack_connectors/common/slack_api/lib.ts b/x-pack/plugins/stack_connectors/common/slack_api/lib.ts index 449b1aef56b14..38f9cd0609897 100644 --- a/x-pack/plugins/stack_connectors/common/slack_api/lib.ts +++ b/x-pack/plugins/stack_connectors/common/slack_api/lib.ts @@ -8,10 +8,10 @@ import type { ActionTypeExecutorResult as ConnectorTypeExecutorResult } from '@kbn/actions-plugin/server/types'; import { i18n } from '@kbn/i18n'; -export function successResult( +export function successResult( actionId: string, - data: unknown -): ConnectorTypeExecutorResult { + data: T +): ConnectorTypeExecutorResult { return { status: 'ok', data, actionId }; } diff --git a/x-pack/plugins/stack_connectors/common/slack_api/schema.ts b/x-pack/plugins/stack_connectors/common/slack_api/schema.ts index e257ee20a3d7c..3a96528ba2801 100644 --- a/x-pack/plugins/stack_connectors/common/slack_api/schema.ts +++ b/x-pack/plugins/stack_connectors/common/slack_api/schema.ts @@ -11,6 +11,10 @@ export const SlackApiSecretsSchema = schema.object({ token: schema.string({ minLength: 1 }), }); +export const SlackApiConfigSchema = schema.object({ + allowedChannels: schema.maybe(schema.arrayOf(schema.string())), +}); + export const GetChannelsParamsSchema = schema.object({ subAction: schema.literal('getChannels'), }); diff --git a/x-pack/plugins/stack_connectors/common/slack_api/types.ts b/x-pack/plugins/stack_connectors/common/slack_api/types.ts index 1098d40eded19..6d87b078b8c15 100644 --- a/x-pack/plugins/stack_connectors/common/slack_api/types.ts +++ b/x-pack/plugins/stack_connectors/common/slack_api/types.ts @@ -14,52 +14,64 @@ import { PostMessageSubActionParamsSchema, SlackApiSecretsSchema, SlackApiParamsSchema, + SlackApiConfigSchema, } from './schema'; export type SlackApiSecrets = TypeOf; +export type SlackApiConfig = TypeOf; export type PostMessageParams = TypeOf; export type PostMessageSubActionParams = TypeOf; export type SlackApiParams = TypeOf; -export type SlackApiConnectorType = ConnectorType<{}, SlackApiSecrets, SlackApiParams, unknown>; +export type SlackApiConnectorType = ConnectorType< + SlackApiConfig, + SlackApiSecrets, + SlackApiParams, + unknown +>; export type SlackApiExecutorOptions = ConnectorTypeExecutorOptions< - {}, + SlackApiConfig, SlackApiSecrets, SlackApiParams >; export type SlackExecutorOptions = ConnectorTypeExecutorOptions< - {}, + SlackApiConfig, SlackApiSecrets, SlackApiParams >; export type SlackApiActionParams = TypeOf; -export interface GetChannelsResponse { - ok: true; - error?: string; - channels?: Array<{ - id: string; - name: string; - is_channel: boolean; - is_archived: boolean; - is_private: boolean; - }>; -} - -export interface PostMessageResponse { +export interface SlackAPiResponse { ok: boolean; - channel?: string; error?: string; message?: { text: string; }; + response_metadata?: { + next_cursor: string; + }; +} + +export interface ChannelsResponse { + id: string; + name: string; + is_channel: boolean; + is_archived: boolean; + is_private: boolean; +} +export interface GetChannelsResponse extends SlackAPiResponse { + channels?: ChannelsResponse[]; +} + +export interface PostMessageResponse extends SlackAPiResponse { + channel?: string; } export interface SlackApiService { - getChannels: () => Promise>; + getChannels: () => Promise>; postMessage: ({ channels, text, diff --git a/x-pack/plugins/stack_connectors/public/connector_types/slack_api/slack_api.tsx b/x-pack/plugins/stack_connectors/public/connector_types/slack_api/slack_api.tsx index 6b985dbb90e34..102595ebb89b8 100644 --- a/x-pack/plugins/stack_connectors/public/connector_types/slack_api/slack_api.tsx +++ b/x-pack/plugins/stack_connectors/public/connector_types/slack_api/slack_api.tsx @@ -20,13 +20,14 @@ import type { SlackApiActionParams, SlackApiSecrets, PostMessageParams, + SlackApiConfig, } from '../../../common/slack_api/types'; import { SLACK_API_CONNECTOR_ID } from '../../../common/slack_api/constants'; import { SlackActionParams } from '../types'; import { subtype } from '../slack/slack'; export const getConnectorType = (): ConnectorTypeModel< - unknown, + SlackApiConfig, SlackApiSecrets, PostMessageParams > => ({ diff --git a/x-pack/plugins/stack_connectors/public/connector_types/slack_api/slack_connectors.test.tsx b/x-pack/plugins/stack_connectors/public/connector_types/slack_api/slack_connectors.test.tsx index ef9877c5a8772..8346e4b07c697 100644 --- a/x-pack/plugins/stack_connectors/public/connector_types/slack_api/slack_connectors.test.tsx +++ b/x-pack/plugins/stack_connectors/public/connector_types/slack_api/slack_connectors.test.tsx @@ -7,29 +7,57 @@ import React from 'react'; import { act, render, fireEvent, screen } from '@testing-library/react'; -import SlackActionFields from './slack_connectors'; +import { useKibana } from '@kbn/triggers-actions-ui-plugin/public'; + import { ConnectorFormTestProvider, waitForComponentToUpdate } from '../lib/test_utils'; +import SlackActionFields from './slack_connectors'; +import { useFetchChannels } from './use_fetch_channels'; jest.mock('@kbn/triggers-actions-ui-plugin/public/common/lib/kibana'); +jest.mock('./use_fetch_channels'); + +(useKibana as jest.Mock).mockImplementation(() => ({ + services: { + docLinks: { + links: { + alerting: { slackApiAction: 'url' }, + }, + }, + notifications: { + toasts: { + addSuccess: jest.fn(), + addDanger: jest.fn(), + }, + }, + }, +})); + +(useFetchChannels as jest.Mock).mockImplementation(() => ({ + channels: [], + isLoading: false, +})); describe('SlackActionFields renders', () => { const onSubmit = jest.fn(); beforeEach(() => { jest.clearAllMocks(); }); + it('all connector fields is rendered for web_api type', async () => { const actionConnector = { secrets: { token: 'some token', }, + config: { + allowedChannels: ['foo', 'bar'], + }, id: 'test', actionTypeId: '.slack', name: 'slack', - config: {}, isDeprecated: false, }; - render( + const { container } = render( {}} /> @@ -37,6 +65,17 @@ describe('SlackActionFields renders', () => { expect(screen.getByTestId('secrets.token-input')).toBeInTheDocument(); expect(screen.getByTestId('secrets.token-input')).toHaveValue('some token'); + expect(screen.getByTestId('config.allowedChannels-input')).toBeInTheDocument(); + const allowedChannels: string[] = []; + container + .querySelectorAll('[data-test-subj="config.allowedChannels-input"] .euiBadge') + .forEach((node) => { + const channel = node.getAttribute('title'); + if (channel) { + allowedChannels.push(channel); + } + }); + expect(allowedChannels).toEqual(['foo', 'bar']); }); it('connector validation succeeds when connector config is valid for Web API type', async () => { @@ -66,6 +105,9 @@ describe('SlackActionFields renders', () => { secrets: { token: 'some token', }, + config: { + allowedChannels: [], + }, id: 'test', actionTypeId: '.slack', name: 'slack', @@ -74,4 +116,62 @@ describe('SlackActionFields renders', () => { isValid: true, }); }); + + it('Allowed Channels combobox should be disable when there is NO token', async () => { + const actionConnector = { + secrets: { + token: '', + }, + config: { + allowedChannels: ['foo', 'bar'], + }, + id: 'test', + actionTypeId: '.slack', + name: 'slack', + isDeprecated: false, + }; + + const { container } = render( + + {}} /> + + ); + expect( + container.querySelector( + '[data-test-subj="config.allowedChannels-input"].euiComboBox-isDisabled' + ) + ).toBeInTheDocument(); + }); + + it('Allowed Channels combobox should NOT be disable when there is token', async () => { + const actionConnector = { + secrets: { + token: 'qwertyuiopasdfghjklzxcvbnm', + }, + config: { + allowedChannels: ['foo', 'bar'], + }, + id: 'test', + actionTypeId: '.slack', + name: 'slack', + isDeprecated: false, + }; + + (useFetchChannels as jest.Mock).mockImplementation(() => ({ + channels: [{ label: 'foo' }, { label: 'bar' }, { label: 'hello' }, { label: 'world' }], + isLoading: false, + })); + + const { container } = render( + + {}} /> + + ); + + expect( + container.querySelector( + '[data-test-subj="config.allowedChannels-input"].euiComboBox-isDisabled' + ) + ).not.toBeInTheDocument(); + }); }); diff --git a/x-pack/plugins/stack_connectors/public/connector_types/slack_api/slack_connectors.tsx b/x-pack/plugins/stack_connectors/public/connector_types/slack_api/slack_connectors.tsx index 70d1e06501748..2caf8bff0b611 100644 --- a/x-pack/plugins/stack_connectors/public/connector_types/slack_api/slack_connectors.tsx +++ b/x-pack/plugins/stack_connectors/public/connector_types/slack_api/slack_connectors.tsx @@ -5,17 +5,26 @@ * 2.0. */ -import React from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { ActionConnectorFieldsProps, + ConfigFieldSchema, SecretsFieldSchema, SimpleConnectorForm, useKibana, } from '@kbn/triggers-actions-ui-plugin/public'; -import { EuiLink } from '@elastic/eui'; +import { EuiComboBoxOptionOption, EuiLink } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n-react'; import { DocLinksStart } from '@kbn/core/public'; + +import { useFormContext, useFormData } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { debounce, isEmpty } from 'lodash'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import * as i18n from './translations'; +import { useFetchChannels } from './use_fetch_channels'; + +/** wait this many ms after the user completes typing before applying the filter input */ +const INPUT_TIMEOUT = 250; const getSecretsFormSchema = (docLinks: DocLinksStart): SecretsFieldSchema[] => [ { @@ -33,18 +42,83 @@ const getSecretsFormSchema = (docLinks: DocLinksStart): SecretsFieldSchema[] => }, ]; -const SlackActionFields: React.FC = ({ readOnly, isEdit }) => { +const getConfigFormSchemaAfterSecrets = ( + options: EuiComboBoxOptionOption[], + isLoading: boolean, + isDisabled: boolean +): ConfigFieldSchema[] => [ + { + id: 'allowedChannels', + isRequired: false, + label: i18n.ALLOWED_CHANNELS, + helpText: ( + + ), + type: 'COMBO_BOX', + euiFieldProps: { + isDisabled, + isLoading, + noSuggestions: false, + options, + }, + }, +]; + +const NO_SCHEMA: ConfigFieldSchema[] = []; + +export const SlackActionFieldsComponents: React.FC = ({ + readOnly, + isEdit, +}) => { const { docLinks } = useKibana().services; + const form = useFormContext(); + const { setFieldValue } = form; + const [formData] = useFormData({ form }); + const [authToken, setAuthToken] = useState(''); + + const { channels, isLoading } = useFetchChannels({ authToken }); + const configFormSchemaAfterSecrets = useMemo( + () => getConfigFormSchemaAfterSecrets(channels, isLoading, channels.length === 0), + [channels, isLoading] + ); + + const debounceSetToken = debounce(setAuthToken, INPUT_TIMEOUT); + useEffect(() => { + if (formData.secrets && formData.secrets.token !== authToken) { + debounceSetToken(formData.secrets.token); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [formData.secrets]); + + useEffect(() => { + if (isEmpty(authToken) && channels.length > 0) { + setFieldValue('config.allowedChannels', []); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [authToken]); + return ( ); }; +export const simpleConnectorQueryClient = new QueryClient(); + +const SlackActionFields: React.FC = (props) => ( + + + +); + // eslint-disable-next-line import/no-default-export export { SlackActionFields as default }; diff --git a/x-pack/plugins/stack_connectors/public/connector_types/slack_api/slack_params.test.tsx b/x-pack/plugins/stack_connectors/public/connector_types/slack_api/slack_params.test.tsx index e8353a3bbabf3..85f909783f178 100644 --- a/x-pack/plugins/stack_connectors/public/connector_types/slack_api/slack_params.test.tsx +++ b/x-pack/plugins/stack_connectors/public/connector_types/slack_api/slack_params.test.tsx @@ -6,7 +6,7 @@ */ import React from 'react'; -import { render, screen, fireEvent } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; import SlackParamsFields from './slack_params'; import type { UseSubActionParams } from '@kbn/triggers-actions-ui-plugin/public/application/hooks/use_sub_action'; import { __IntlProvider as IntlProvider } from '@kbn/i18n-react'; @@ -156,28 +156,33 @@ describe('SlackParamsFields renders', () => { }); test('all params fields is rendered for getChannels call', async () => { - render( - - {}} - index={0} - defaultMessage="default message" - messageVariables={[]} - /> - - ); + const WrappedComponent = () => { + return ( + + {}} + index={0} + defaultMessage="default message" + messageVariables={[]} + /> + + ); + }; + const { getByTestId } = render(); + + getByTestId('slackChannelsComboBox').click(); + getByTestId('comboBoxSearchInput').focus(); - expect(screen.getByTestId('slackChannelsButton')).toHaveTextContent('Channels'); - fireEvent.click(screen.getByTestId('slackChannelsButton')); - expect(screen.getByTestId('slackChannelsSelectableList')).toBeInTheDocument(); - expect(screen.getByTestId('slackChannelsSelectableList')).toHaveTextContent('general'); - fireEvent.click(screen.getByText('general')); - expect(screen.getByTitle('general').getAttribute('aria-checked')).toEqual('true'); + const options = getByTestId( + 'comboBoxOptionsList slackChannelsComboBox-optionsList' + ).querySelectorAll('.euiComboBoxOption__content'); + expect(options).toHaveLength(1); + expect(options[0].textContent).toBe('general'); }); test('show error message when no channel is selected', async () => { diff --git a/x-pack/plugins/stack_connectors/public/connector_types/slack_api/slack_params.tsx b/x-pack/plugins/stack_connectors/public/connector_types/slack_api/slack_params.tsx index 6d5f284e764b5..68172f50d51de 100644 --- a/x-pack/plugins/stack_connectors/public/connector_types/slack_api/slack_params.tsx +++ b/x-pack/plugins/stack_connectors/public/connector_types/slack_api/slack_params.tsx @@ -9,24 +9,10 @@ import React, { useState, useEffect, useMemo, useCallback } from 'react'; import type { ActionParamsProps } from '@kbn/triggers-actions-ui-plugin/public'; import { i18n } from '@kbn/i18n'; import { TextAreaWithMessageVariables } from '@kbn/triggers-actions-ui-plugin/public'; -import { - EuiSpacer, - EuiFilterGroup, - EuiPopover, - EuiFilterButton, - EuiSelectable, - EuiSelectableOption, - EuiFormRow, -} from '@elastic/eui'; +import { EuiSpacer, EuiFormRow, EuiComboBox, EuiComboBoxOptionOption } from '@elastic/eui'; import { useSubAction, useKibana } from '@kbn/triggers-actions-ui-plugin/public'; -import { FormattedMessage } from '@kbn/i18n-react'; import type { GetChannelsResponse, PostMessageParams } from '../../../common/slack_api/types'; -interface ChannelsStatus { - label: string; - checked?: 'on'; -} - const SlackParamsFields: React.FunctionComponent> = ({ actionConnector, actionParams, @@ -85,6 +71,10 @@ const SlackParamsFields: React.FunctionComponent( + (channels ?? []).map((c) => ({ label: c })) + ); + const slackChannels = useMemo( () => channelsInfo @@ -93,44 +83,11 @@ const SlackParamsFields: React.FunctionComponent(channels ?? []); - - const button = ( - setIsPopoverOpen(!isPopoverOpen)} - numFilters={selectedChannels.length} - hasActiveFilters={selectedChannels.length > 0} - numActiveFilters={selectedChannels.length} - data-test-subj="slackChannelsButton" - > - - - ); - - const options: ChannelsStatus[] = useMemo( - () => - slackChannels.map((slackChannel) => ({ - label: slackChannel.label, - ...(selectedChannels.includes(slackChannel.label) ? { checked: 'on' } : {}), - })), - [slackChannels, selectedChannels] - ); - const onChange = useCallback( - (newOptions: EuiSelectableOption[]) => { - const newSelectedChannels = newOptions.reduce((result, option) => { - if (option.checked === 'on') { - result = [...result, option.label]; - } - return result; - }, []); + (newOptions: EuiComboBoxOptionOption[]) => { + const newSelectedChannels = newOptions.map((option) => option.label); - setSelectedChannels(newSelectedChannels); + setSelectedChannels(newOptions); editAction('subActionParams', { channels: newSelectedChannels, text }, index); }, [editAction, index, text] @@ -139,53 +96,22 @@ const SlackParamsFields: React.FunctionComponent 0 && channels.length === 0} > - - setIsPopoverOpen(false)} - > - - {(list, search) => ( - <> - {search} - - {list} - - )} - - - + => { + return http.post( + `${INTERNAL_BASE_STACK_CONNECTORS_API_PATH}/_slack_api/channels`, + { + body: JSON.stringify({ + authToken: newAuthToken, + }), + } + ); +}; + +export function useFetchChannels(props: UseFetchChannelsProps) { + const { authToken } = props; + + const { + http, + notifications: { toasts }, + } = useKibana().services; + + const queryFn = () => { + return fetchChannels(http, authToken); + }; + + const onErrorFn = () => { + toasts.addDanger(i18n.ERROR_FETCH_CHANNELS); + }; + + const { data, isLoading, isFetching } = useQuery({ + queryKey: ['fetchChannels', authToken], + queryFn, + onError: onErrorFn, + enabled: authToken.length > 0, + refetchOnWindowFocus: false, + }); + + const channels = useMemo(() => { + return (data?.channels ?? []).map((channel: ChannelsResponse) => ({ + label: channel.name, + })); + }, [data]); + + return { + channels, + isLoading: isLoading || isFetching, + }; +} diff --git a/x-pack/plugins/stack_connectors/server/connector_types/slack_api/index.test.ts b/x-pack/plugins/stack_connectors/server/connector_types/slack_api/index.test.ts index 66bc3fba1219c..2b4022285dea8 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/slack_api/index.test.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/slack_api/index.test.ts @@ -8,7 +8,7 @@ import axios from 'axios'; import { Logger } from '@kbn/core/server'; import { Services } from '@kbn/actions-plugin/server/types'; -import { validateParams, validateSecrets } from '@kbn/actions-plugin/server/lib'; +import { validateConfig, validateParams, validateSecrets } from '@kbn/actions-plugin/server/lib'; import { getConnectorType } from '.'; import { actionsConfigMock } from '@kbn/actions-plugin/server/actions_config.mock'; import { actionsMock } from '@kbn/actions-plugin/server/mocks'; @@ -48,6 +48,36 @@ describe('connector registration', () => { }); }); +describe('validate config', () => { + test('should throw error when config are invalid', () => { + expect(() => { + validateConfig(connectorType, { message: 1 }, { configurationUtilities }); + }).toThrowErrorMatchingInlineSnapshot( + `"error validating action type config: [message]: definition for this key is missing"` + ); + + expect(() => { + validateConfig(connectorType, { allowedChannels: 'foo' }, { configurationUtilities }); + }).toThrowErrorMatchingInlineSnapshot( + `"error validating action type config: [allowedChannels]: could not parse array value from json input"` + ); + }); + + test('should validate when config are valid', () => { + expect(() => { + validateConfig( + connectorType, + { allowedChannels: ['foo', 'bar'] }, + { configurationUtilities } + ); + }).not.toThrow(); + + expect(() => { + validateConfig(connectorType, {}, { configurationUtilities }); + }).not.toThrow(); + }); +}); + describe('validate params', () => { test('should validate and throw error when params are invalid', () => { expect(() => { @@ -280,7 +310,7 @@ describe('execute', () => { configurationUtilities, logger: mockedLogger, method: 'get', - url: 'conversations.list?types=public_channel,private_channel', + url: 'conversations.list?exclude_archived=true&types=public_channel,private_channel&limit=1000', }); expect(response).toEqual({ diff --git a/x-pack/plugins/stack_connectors/server/connector_types/slack_api/index.ts b/x-pack/plugins/stack_connectors/server/connector_types/slack_api/index.ts index ee467dad3d8a2..bc3128dc666b8 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/slack_api/index.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/slack_api/index.ts @@ -13,14 +13,17 @@ import { import { renderMustacheString } from '@kbn/actions-plugin/server/lib/mustache_renderer'; import type { ValidatorServices } from '@kbn/actions-plugin/server/types'; import { i18n } from '@kbn/i18n'; -import { schema } from '@kbn/config-schema'; import type { SlackApiExecutorOptions, SlackApiConnectorType, SlackApiParams, SlackApiSecrets, } from '../../../common/slack_api/types'; -import { SlackApiSecretsSchema, SlackApiParamsSchema } from '../../../common/slack_api/schema'; +import { + SlackApiSecretsSchema, + SlackApiParamsSchema, + SlackApiConfigSchema, +} from '../../../common/slack_api/schema'; import { SLACK_API_CONNECTOR_ID, SLACK_URL } from '../../../common/slack_api/constants'; import { SLACK_CONNECTOR_NAME } from './translations'; import { api } from './api'; @@ -35,7 +38,7 @@ export const getConnectorType = (): SlackApiConnectorType => { name: SLACK_CONNECTOR_NAME, supportedFeatureIds: [AlertingConnectorFeatureId, SecurityConnectorFeatureId], validate: { - config: { schema: schema.object({}, { defaultValue: {} }) }, + config: { schema: SlackApiConfigSchema }, secrets: { schema: SlackApiSecretsSchema, customValidator: validateSlackUrl, @@ -80,6 +83,7 @@ const renderParameterTemplates = (params: SlackApiParams, variables: Record { logger, configurationUtilities, method: 'get', - url: 'conversations.list?types=public_channel,private_channel', + url: 'conversations.list?exclude_archived=true&types=public_channel,private_channel&limit=1000', }); }); @@ -177,5 +177,50 @@ describe('Slack API service', () => { status: 'error', }); }); + + test('should NOT by pass allowed channels when present', async () => { + service = createExternalService( + { + secrets: { token: 'token' }, + config: { allowedChannels: ['foo', 'bar'] }, + }, + logger, + configurationUtilities + ); + + expect( + await service.postMessage({ channels: ['general', 'privat'], text: 'a message' }) + ).toEqual({ + actionId: SLACK_API_CONNECTOR_ID, + serviceMessage: + 'The channel "general,privat" is not included in the allowed channels list "foo,bar"', + message: 'error posting slack message', + status: 'error', + }); + }); + + test('should allowed channels to be persisted', async () => { + service = createExternalService( + { + secrets: { token: 'token' }, + config: { allowedChannels: ['foo', 'bar', 'general', 'privat'] }, + }, + logger, + configurationUtilities + ); + requestMock.mockImplementation(() => postMessageResponse); + + await service.postMessage({ channels: ['general', 'privat'], text: 'a message' }); + + expect(requestMock).toHaveBeenCalledTimes(1); + expect(requestMock).toHaveBeenNthCalledWith(1, { + axios, + logger, + configurationUtilities, + method: 'post', + url: 'chat.postMessage', + data: { channel: 'general', text: 'a message' }, + }); + }); }); }); diff --git a/x-pack/plugins/stack_connectors/server/connector_types/slack_api/service.ts b/x-pack/plugins/stack_connectors/server/connector_types/slack_api/service.ts index 723d629a74418..a2b6ff8989880 100644 --- a/x-pack/plugins/stack_connectors/server/connector_types/slack_api/service.ts +++ b/x-pack/plugins/stack_connectors/server/connector_types/slack_api/service.ts @@ -18,6 +18,9 @@ import type { PostMessageSubActionParams, SlackApiService, PostMessageResponse, + GetChannelsResponse, + SlackAPiResponse, + ChannelsResponse, } from '../../../common/slack_api/types'; import { retryResultSeconds, @@ -29,13 +32,16 @@ import { import { SLACK_API_CONNECTOR_ID, SLACK_URL } from '../../../common/slack_api/constants'; import { getRetryAfterIntervalFromHeaders } from '../lib/http_response_retry_header'; +const RE_TRY = 5; +const LIMIT = 1000; + const buildSlackExecutorErrorResponse = ({ slackApiError, logger, }: { slackApiError: { message: string; - response: { + response?: { status: number; statusText: string; headers: Record; @@ -78,11 +84,11 @@ const buildSlackExecutorErrorResponse = ({ return errorResult(SLACK_API_CONNECTOR_ID, errorMessage); }; -const buildSlackExecutorSuccessResponse = ({ +const buildSlackExecutorSuccessResponse = ({ slackApiResponseData, }: { - slackApiResponseData: PostMessageResponse; -}) => { + slackApiResponseData: T; +}): ConnectorTypeExecutorResult => { if (!slackApiResponseData) { const errMessage = i18n.translate( 'xpack.stackConnectors.slack.unexpectedNullResponseErrorMessage', @@ -96,17 +102,16 @@ const buildSlackExecutorSuccessResponse = ({ if (!slackApiResponseData.ok) { return serviceErrorResult(SLACK_API_CONNECTOR_ID, slackApiResponseData.error); } - - return successResult(SLACK_API_CONNECTOR_ID, slackApiResponseData); + return successResult(SLACK_API_CONNECTOR_ID, slackApiResponseData); }; export const createExternalService = ( - { secrets }: { secrets: { token: string } }, + { config, secrets }: { config?: { allowedChannels?: string[] }; secrets: { token: string } }, logger: Logger, configurationUtilities: ActionsConfigurationUtilities ): SlackApiService => { const { token } = secrets; - + const { allowedChannels } = config || { allowedChannels: [] }; if (!token) { throw Error(`[Action][${SLACK_CONNECTOR_NAME}]: Wrong configuration.`); } @@ -119,17 +124,73 @@ export const createExternalService = ( }, }); - const getChannels = async (): Promise> => { + const getChannels = async (): Promise< + ConnectorTypeExecutorResult + > => { try { - const result = await request({ - axios: axiosInstance, - configurationUtilities, - logger, - method: 'get', - url: 'conversations.list?types=public_channel,private_channel', - }); + const fetchChannels = (cursor: string = ''): Promise> => { + return request({ + axios: axiosInstance, + configurationUtilities, + logger, + method: 'get', + url: `conversations.list?exclude_archived=true&types=public_channel,private_channel&limit=${LIMIT}${ + cursor.length > 0 ? `&cursor=${cursor}` : '' + }`, + }); + }; + + let numberOfFetch = 0; + let cursor = ''; + const channels: ChannelsResponse[] = []; + let result: AxiosResponse = { + data: { ok: false, channels }, + status: 0, + statusText: '', + headers: {}, + config: {}, + }; + + while (numberOfFetch < RE_TRY) { + result = await fetchChannels(cursor); + if (result.data.ok && (result.data?.channels ?? []).length > 0) { + channels.push(...(result.data?.channels ?? [])); + } + if ( + result.data.ok && + result.data.response_metadata && + result.data.response_metadata.next_cursor && + result.data.response_metadata.next_cursor.length > 0 + ) { + numberOfFetch += 1; + cursor = result.data.response_metadata.next_cursor; + } else { + break; + } + } + result.data.channels = channels; + const responseData = result.data; + if ((allowedChannels ?? []).length > 0) { + const allowedChannelsList = channels.filter((channel: ChannelsResponse) => + allowedChannels?.includes(channel.name) + ); + allowedChannels?.forEach((ac) => { + if (!allowedChannelsList.find((c: ChannelsResponse) => c.name === ac)) { + allowedChannelsList.push({ + id: '-1', + name: ac, + is_channel: true, + is_archived: false, + is_private: false, + }); + } + }); + responseData.channels = allowedChannelsList; + } - return buildSlackExecutorSuccessResponse({ slackApiResponseData: result.data }); + return buildSlackExecutorSuccessResponse({ + slackApiResponseData: responseData, + }); } catch (error) { return buildSlackExecutorErrorResponse({ slackApiError: error, logger }); } @@ -140,6 +201,19 @@ export const createExternalService = ( text, }: PostMessageSubActionParams): Promise> => { try { + if ( + allowedChannels && + allowedChannels.length > 0 && + !channels.every((c) => allowedChannels?.includes(c)) + ) { + return buildSlackExecutorErrorResponse({ + slackApiError: { + message: `The channel "${channels.join()}" is not included in the allowed channels list "${allowedChannels.join()}"`, + }, + logger, + }); + } + const result: AxiosResponse = await request({ axios: axiosInstance, method: 'post', diff --git a/x-pack/plugins/stack_connectors/server/plugin.ts b/x-pack/plugins/stack_connectors/server/plugin.ts index ce1795b4eb7fb..3e76b9adbb083 100644 --- a/x-pack/plugins/stack_connectors/server/plugin.ts +++ b/x-pack/plugins/stack_connectors/server/plugin.ts @@ -5,10 +5,10 @@ * 2.0. */ -import { PluginInitializerContext, Plugin, CoreSetup } from '@kbn/core/server'; +import { PluginInitializerContext, Plugin, CoreSetup, Logger } from '@kbn/core/server'; import { PluginSetupContract as ActionsPluginSetupContract } from '@kbn/actions-plugin/server'; import { registerConnectorTypes } from './connector_types'; -import { getWellKnownEmailServiceRoute } from './routes'; +import { getSlackApiChannelsRoute, getWellKnownEmailServiceRoute } from './routes'; export interface ConnectorsPluginsSetup { actions: ActionsPluginSetupContract; } @@ -18,13 +18,18 @@ export interface ConnectorsPluginsStart { } export class StackConnectorsPlugin implements Plugin { - constructor(context: PluginInitializerContext) {} + private readonly logger: Logger; + + constructor(context: PluginInitializerContext) { + this.logger = context.logger.get(); + } public setup(core: CoreSetup, plugins: ConnectorsPluginsSetup) { const router = core.http.createRouter(); const { actions } = plugins; getWellKnownEmailServiceRoute(router); + getSlackApiChannelsRoute(router, actions.getActionsConfigurationUtilities(), this.logger); registerConnectorTypes({ actions, diff --git a/x-pack/plugins/stack_connectors/server/routes/get_slack_api_channels.ts b/x-pack/plugins/stack_connectors/server/routes/get_slack_api_channels.ts new file mode 100644 index 0000000000000..dac35c0503cbe --- /dev/null +++ b/x-pack/plugins/stack_connectors/server/routes/get_slack_api_channels.ts @@ -0,0 +1,109 @@ +/* + * 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 { + IRouter, + RequestHandlerContext, + KibanaRequest, + IKibanaResponse, + KibanaResponseFactory, + Logger, +} from '@kbn/core/server'; +import axios, { AxiosResponse } from 'axios'; +import { request } from '@kbn/actions-plugin/server/lib/axios_utils'; +import { ActionsConfigurationUtilities } from '@kbn/actions-plugin/server/actions_config'; +import { INTERNAL_BASE_STACK_CONNECTORS_API_PATH } from '../../common'; +import { SLACK_URL } from '../../common/slack_api/constants'; +import { ChannelsResponse, GetChannelsResponse } from '../../common/slack_api/types'; + +const bodySchema = schema.object({ + authToken: schema.string(), +}); + +const RE_TRY = 5; +const LIMIT = 1000; + +export const getSlackApiChannelsRoute = ( + router: IRouter, + configurationUtilities: ActionsConfigurationUtilities, + logger: Logger +) => { + router.post( + { + path: `${INTERNAL_BASE_STACK_CONNECTORS_API_PATH}/_slack_api/channels`, + validate: { + body: bodySchema, + }, + }, + handler + ); + + async function handler( + ctx: RequestHandlerContext, + req: KibanaRequest, + res: KibanaResponseFactory + ): Promise { + const { authToken } = req.body; + + const axiosInstance = axios.create({ + baseURL: SLACK_URL, + headers: { + Authorization: `Bearer ${authToken}`, + 'Content-type': 'application/json; charset=UTF-8', + }, + }); + + const fetchChannels = (cursor: string = ''): Promise> => { + return request({ + axios: axiosInstance, + configurationUtilities, + logger, + method: 'get', + url: `conversations.list?exclude_archived=true&types=public_channel,private_channel&limit=${LIMIT}${ + cursor.length > 0 ? `&cursor=${cursor}` : '' + }`, + }); + }; + + let numberOfFetch = 0; + let cursor = ''; + const channels: ChannelsResponse[] = []; + let result: AxiosResponse = { + data: { ok: false, channels }, + status: 0, + statusText: '', + headers: {}, + config: {}, + }; + + while (numberOfFetch < RE_TRY) { + result = await fetchChannels(cursor); + if (result.data.ok && (result.data?.channels ?? []).length > 0) { + channels.push(...(result.data?.channels ?? [])); + } + if ( + result.data.ok && + result.data.response_metadata && + result.data.response_metadata.next_cursor && + result.data.response_metadata.next_cursor.length > 0 + ) { + numberOfFetch += 1; + cursor = result.data.response_metadata.next_cursor; + } else { + break; + } + } + + return res.ok({ + body: { + ...result.data, + channels, + }, + }); + } +}; diff --git a/x-pack/plugins/stack_connectors/server/routes/index.ts b/x-pack/plugins/stack_connectors/server/routes/index.ts index 2766b99679845..df48f18480252 100644 --- a/x-pack/plugins/stack_connectors/server/routes/index.ts +++ b/x-pack/plugins/stack_connectors/server/routes/index.ts @@ -6,3 +6,4 @@ */ export { getWellKnownEmailServiceRoute } from './get_well_known_email_service'; +export { getSlackApiChannelsRoute } from './get_slack_api_channels'; diff --git a/x-pack/plugins/translations/translations/fr-FR.json b/x-pack/plugins/translations/translations/fr-FR.json index 4be90d89d6878..04666ee28108d 100644 --- a/x-pack/plugins/translations/translations/fr-FR.json +++ b/x-pack/plugins/translations/translations/fr-FR.json @@ -35295,10 +35295,7 @@ "xpack.stackConnectors.components.slack..error.requiredSlackMessageText": "Le message est requis.", "xpack.stackConnectors.components.slack.connectorTypeTitle": "Envoyer vers Slack", "xpack.stackConnectors.components.slack.error.invalidWebhookUrlText": "L'URL de webhook n'est pas valide.", - "xpack.stackConnectors.components.slack.loadingMessage": "Chargement des canaux", "xpack.stackConnectors.components.slack.messageTextAreaFieldLabel": "Message", - "xpack.stackConnectors.components.slack.noChannelsAvailable": "Aucun canal disponible", - "xpack.stackConnectors.components.slack.noChannelsFound": "Aucun canal trouvé", "xpack.stackConnectors.components.slack.selectMessageText": "Envoyez un message à un canal ou à un utilisateur Slack.", "xpack.stackConnectors.components.slack.webApi": "API web", "xpack.stackConnectors.components.slack.webhook": "Webhook", @@ -35451,7 +35448,6 @@ "xpack.stackConnectors.slack.configurationErrorNoHostname": "erreur lors de la configuration de l'action slack : impossible d'analyser le nom de l'hôte depuis webhookUrl", "xpack.stackConnectors.slack.errorPostingErrorMessage": "erreur lors de la publication du message slack", "xpack.stackConnectors.slack.errorPostingRetryLaterErrorMessage": "erreur lors de la publication d'un message slack, réessayer ultérieurement", - "xpack.stackConnectors.slack.params..showChannelsListButton": "Canaux", "xpack.stackConnectors.slack.params.componentError.getChannelsRequestFailed": "Impossible de récupérer la liste des canaux Slack", "xpack.stackConnectors.slack.title": "Slack", "xpack.stackConnectors.slack.unexpectedNullResponseErrorMessage": "réponse nulle inattendue de Slack", @@ -39562,4 +39558,4 @@ "xpack.painlessLab.title": "Painless Lab", "xpack.painlessLab.walkthroughButtonLabel": "Présentation" } -} \ No newline at end of file +} diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 663b18d425472..b6697ff42efc1 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -35276,10 +35276,7 @@ "xpack.stackConnectors.components.slack..error.requiredSlackMessageText": "メッセージが必要です。", "xpack.stackConnectors.components.slack.connectorTypeTitle": "Slack に送信", "xpack.stackConnectors.components.slack.error.invalidWebhookUrlText": "Web フック URL が無効です。", - "xpack.stackConnectors.components.slack.loadingMessage": "チャンネルを読み込み中", "xpack.stackConnectors.components.slack.messageTextAreaFieldLabel": "メッセージ", - "xpack.stackConnectors.components.slack.noChannelsAvailable": "チャンネルがありません", - "xpack.stackConnectors.components.slack.noChannelsFound": "チャンネルが見つかりません", "xpack.stackConnectors.components.slack.selectMessageText": "Slack チャネルにメッセージを送信します。", "xpack.stackConnectors.components.slack.webApi": "Web API", "xpack.stackConnectors.components.slack.webhook": "Web フック", @@ -35432,7 +35429,6 @@ "xpack.stackConnectors.slack.configurationErrorNoHostname": "slack アクションの構成エラー:Web フック URL からホスト名をパースできません", "xpack.stackConnectors.slack.errorPostingErrorMessage": "slack メッセージの投稿エラー", "xpack.stackConnectors.slack.errorPostingRetryLaterErrorMessage": "slack メッセージの投稿エラー、後ほど再試行", - "xpack.stackConnectors.slack.params..showChannelsListButton": "チャンネル", "xpack.stackConnectors.slack.params.componentError.getChannelsRequestFailed": "Slackチャンネルリストを取得できませんでした", "xpack.stackConnectors.slack.title": "Slack", "xpack.stackConnectors.slack.unexpectedNullResponseErrorMessage": "Slack から予期せぬ null 応答", @@ -39532,4 +39528,4 @@ "xpack.painlessLab.title": "Painless Lab", "xpack.painlessLab.walkthroughButtonLabel": "実地検証" } -} \ No newline at end of file +} diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index b61d162af69df..bcc76652181c4 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -35270,10 +35270,7 @@ "xpack.stackConnectors.components.slack..error.requiredSlackMessageText": "“消息”必填。", "xpack.stackConnectors.components.slack.connectorTypeTitle": "发送到 Slack", "xpack.stackConnectors.components.slack.error.invalidWebhookUrlText": "Webhook URL 无效。", - "xpack.stackConnectors.components.slack.loadingMessage": "正在加载频道", "xpack.stackConnectors.components.slack.messageTextAreaFieldLabel": "消息", - "xpack.stackConnectors.components.slack.noChannelsAvailable": "无频道可用", - "xpack.stackConnectors.components.slack.noChannelsFound": "找不到频道", "xpack.stackConnectors.components.slack.selectMessageText": "向 Slack 频道或用户发送消息。", "xpack.stackConnectors.components.slack.webApi": "Web API", "xpack.stackConnectors.components.slack.webhook": "Webhook", @@ -35426,7 +35423,6 @@ "xpack.stackConnectors.slack.configurationErrorNoHostname": "配置 slack 操作时出错:无法解析 webhookUrl 中的主机名", "xpack.stackConnectors.slack.errorPostingErrorMessage": "发布 slack 消息时出错", "xpack.stackConnectors.slack.errorPostingRetryLaterErrorMessage": "发布 slack 消息时出错,稍后重试", - "xpack.stackConnectors.slack.params..showChannelsListButton": "频道", "xpack.stackConnectors.slack.params.componentError.getChannelsRequestFailed": "无法检索 Slack 频道列表", "xpack.stackConnectors.slack.title": "Slack", "xpack.stackConnectors.slack.unexpectedNullResponseErrorMessage": "来自 slack 的异常空响应", @@ -39526,4 +39522,4 @@ "xpack.painlessLab.title": "Painless 实验室", "xpack.painlessLab.walkthroughButtonLabel": "指导" } -} \ No newline at end of file +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/simple_connector_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/components/simple_connector_form.tsx index d552babfcab8d..d76b76e641821 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/simple_connector_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/simple_connector_form.tsx @@ -7,8 +7,12 @@ import React, { memo, ReactNode } from 'react'; import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiTitle } from '@elastic/eui'; -import { Field, PasswordField } from '@kbn/es-ui-shared-plugin/static/forms/components'; -import { getUseField } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; +import { + ComboBoxField, + Field, + PasswordField, +} from '@kbn/es-ui-shared-plugin/static/forms/components'; +import { FIELD_TYPES, getUseField } from '@kbn/es-ui-shared-plugin/static/forms/hook_form_lib'; import { fieldValidators } from '@kbn/es-ui-shared-plugin/static/forms/helpers'; import { i18n } from '@kbn/i18n'; @@ -16,11 +20,14 @@ export interface CommonFieldSchema { id: string; label: string; helpText?: string | ReactNode; + isRequired?: boolean; + type?: keyof typeof FIELD_TYPES; + euiFieldProps?: Record; } export interface ConfigFieldSchema extends CommonFieldSchema { isUrlField?: boolean; - defaultValue?: string; + defaultValue?: string | string[]; } export interface SecretsFieldSchema extends CommonFieldSchema { @@ -32,35 +39,45 @@ interface SimpleConnectorFormProps { readOnly: boolean; configFormSchema: ConfigFieldSchema[]; secretsFormSchema: SecretsFieldSchema[]; + configFormSchemaAfterSecrets?: ConfigFieldSchema[]; } type FormRowProps = ConfigFieldSchema & SecretsFieldSchema & { readOnly: boolean }; -const UseField = getUseField({ component: Field }); +const UseTextField = getUseField({ component: Field }); +const UseComboBoxField = getUseField({ component: ComboBoxField }); const { emptyField, urlField } = fieldValidators; const getFieldConfig = ({ label, + isRequired = true, isUrlField = false, defaultValue, + type, }: { label: string; + isRequired?: boolean; isUrlField?: boolean; - defaultValue?: string; + defaultValue?: string | string[]; + type?: keyof typeof FIELD_TYPES; }) => ({ label, validations: [ - { - validator: emptyField( - i18n.translate( - 'xpack.triggersActionsUI.sections.actionConnectorForm.error.requireFieldText', + ...(isRequired + ? [ { - values: { label }, - defaultMessage: `{label} is required.`, - } - ) - ), - }, + validator: emptyField( + i18n.translate( + 'xpack.triggersActionsUI.sections.actionConnectorForm.error.requireFieldText', + { + values: { label }, + defaultMessage: `{label} is required.`, + } + ) + ), + }, + ] + : []), ...(isUrlField ? [ { @@ -77,18 +94,33 @@ const getFieldConfig = ({ : []), ], defaultValue, + ...(type && FIELD_TYPES[type] + ? { type: FIELD_TYPES[type], defaultValue: Array.isArray(defaultValue) ? defaultValue : [] } + : {}), }); +const getComponentByType = (type?: keyof typeof FIELD_TYPES) => { + let UseField = UseTextField; + if (type && FIELD_TYPES[type] === FIELD_TYPES.COMBO_BOX) { + UseField = UseComboBoxField; + } + return UseField; +}; + const FormRow: React.FC = ({ id, label, readOnly, isPasswordField, + isRequired = true, isUrlField, helpText, defaultValue, + euiFieldProps = {}, + type, }) => { const dataTestSub = `${id}-input`; + const UseField = getComponentByType(type); return ( <> @@ -96,20 +128,26 @@ const FormRow: React.FC = ({ {!isPasswordField ? ( ) : ( = ({ readOnly, configFormSchema, secretsFormSchema, + configFormSchemaAfterSecrets = [], }) => { return ( <> @@ -162,6 +201,12 @@ const SimpleConnectorFormComponent: React.FC = ({ {index !== secretsFormSchema.length ? : null} ))} + {configFormSchemaAfterSecrets.map(({ id, ...restConfigSchemaAfterSecrets }, index) => ( + + + {index !== configFormSchemaAfterSecrets.length ? : null} + + ))} ); };