diff --git a/docs/apm/api.asciidoc b/docs/apm/api.asciidoc index 93733f5990a46..b26c7446b91d1 100644 --- a/docs/apm/api.asciidoc +++ b/docs/apm/api.asciidoc @@ -60,7 +60,7 @@ The following Agent configuration APIs are available: ====== `settings`:: -(required) Key/value object with settings and their corresponding value. +(required) Key/value object with option name and option value. `agent_name`:: (optional) The agent name is used by the UI to determine which settings to display. @@ -73,14 +73,14 @@ The following Agent configuration APIs are available: -------------------------------------------------- PUT /api/apm/settings/agent-configuration { - "service" : { - "name" : "frontend", - "environment" : "production" + "service": { + "name": "frontend", + "environment": "production" }, - "settings" : { - "transaction_sample_rate" : 0.4, - "capture_body" : "off", - "transaction_max_spans" : 500 + "settings": { + "transaction_sample_rate": "0.4", + "capture_body": "off", + "transaction_max_spans": "500" }, "agent_name": "nodejs" } @@ -124,7 +124,7 @@ PUT /api/apm/settings/agent-configuration DELETE /api/apm/settings/agent-configuration { "service" : { - "name" : "frontend", + "name": "frontend", "environment": "production" } } @@ -157,9 +157,9 @@ DELETE /api/apm/settings/agent-configuration "environment": "production" }, "settings": { - "transaction_sample_rate": 1, + "transaction_sample_rate": "1", "capture_body": "off", - "transaction_max_spans": 200 + "transaction_max_spans": "200" }, "@timestamp": 1581934104843, "applied_by_agent": false, @@ -171,9 +171,9 @@ DELETE /api/apm/settings/agent-configuration "name": "opbeans-go" }, "settings": { - "transaction_sample_rate": 1, + "transaction_sample_rate": "1", "capture_body": "off", - "transaction_max_spans": 300 + "transaction_max_spans": "300" }, "@timestamp": 1581934111727, "applied_by_agent": false, @@ -185,7 +185,7 @@ DELETE /api/apm/settings/agent-configuration "name": "frontend" }, "settings": { - "transaction_sample_rate": 1, + "transaction_sample_rate": "1", }, "@timestamp": 1582031336265, "applied_by_agent": false, @@ -250,7 +250,7 @@ GET /api/apm/settings/agent-configuration "name": "frontend" }, "settings": { - "transaction_sample_rate": 1, + "transaction_sample_rate": "1", }, "@timestamp": 1582031336265, "applied_by_agent": false, @@ -266,9 +266,9 @@ GET /api/apm/settings/agent-configuration -------------------------------------------------- POST /api/apm/settings/agent-configuration/search { - "etag" : "1e58c178efeebae15c25c539da740d21dee422fc", + "etag": "1e58c178efeebae15c25c539da740d21dee422fc", "service" : { - "name" : "frontend", + "name": "frontend", "environment": "production" } } diff --git a/docs/canvas/canvas-elements.asciidoc b/docs/canvas/canvas-elements.asciidoc index a25460a20eb50..4149039a3f87b 100644 --- a/docs/canvas/canvas-elements.asciidoc +++ b/docs/canvas/canvas-elements.asciidoc @@ -31,7 +31,7 @@ By default, most of the elements you create use demo data until you change the d [[canvas-add-object]] ==== Add a saved object -Add a <>, such as a map or Lens visualization, then customize it to fit your display needs. +Add a <>, then customize it to fit your display needs. . Click *Embed object*. diff --git a/docs/user/alerting/action-types.asciidoc b/docs/user/alerting/action-types.asciidoc index 09878b3059ac8..e8dcf689df8e4 100644 --- a/docs/user/alerting/action-types.asciidoc +++ b/docs/user/alerting/action-types.asciidoc @@ -41,9 +41,9 @@ see https://www.elastic.co/subscriptions[the subscription page]. [float] [[create-connectors]] -=== Preconfigured connectors and action types +=== Preconfigured actions and connectors -For out-of-the-box and standardized connectors, you can <> +For out-of-the-box and standardized connectors, you can <> before {kib} starts. If you preconfigure a connector, you can also <>. @@ -54,4 +54,4 @@ include::action-types/pagerduty.asciidoc[] include::action-types/server-log.asciidoc[] include::action-types/slack.asciidoc[] include::action-types/webhook.asciidoc[] -include::pre-configured-connectors.asciidoc[] +include::action-types/pre-configured-connectors.asciidoc[] diff --git a/docs/user/alerting/action-types/email.asciidoc b/docs/user/alerting/action-types/email.asciidoc index 81b4e210961f6..4fb8a816d1ec9 100644 --- a/docs/user/alerting/action-types/email.asciidoc +++ b/docs/user/alerting/action-types/email.asciidoc @@ -28,27 +28,46 @@ Password:: password for 'login' type authentication. name: preconfigured-email-action-type actionTypeId: .email config: - from: testsender@test.com <1.1> - host: validhostname <1.2> - port: 8080 <1.3> - secure: false <1.4> + from: testsender@test.com + host: validhostname + port: 8080 + secure: false secrets: - user: testuser <2.1> - password: passwordkeystorevalue <2.2> + user: testuser + password: passwordkeystorevalue -- `config` defines the action type specific to the configuration and contains the following properties: -<1.1> `from:` is an email address and correspond to *Sender*. -<1.2> `host:` is a string and correspond to *Host*. -<1.3> `port:` is a number and correspond to *Port*. -<1.4> `secure:` is a boolean and correspond to *Secure*. +[cols="2*<"] +|=== -`secrets` defines action type sensitive configuration: +| `from` +| An email address that corresponds to *Sender*. -<2.1> `user:` is a string and correspond to *User*. -<2.2> `password:` is a string and correspond to *Password*. Should be stored in the <>. +| `host` +| A string that corresponds to *Host*. +| `port` +| A number that corresponds to *Port*. + +| `secure` +| A boolean that corresponds to *Secure*. + +|=== + +`secrets` defines sensitive information for the action type: + +[cols="2*<"] +|=== + +| `user` +| A string that corresponds to *User*. + +| `password` +| A string that corresponds to *Password*. Should be stored in the <>. + +|=== [[email-action-configuration]] ==== Action configuration diff --git a/docs/user/alerting/action-types/index.asciidoc b/docs/user/alerting/action-types/index.asciidoc index c71412210c535..115423086bae3 100644 --- a/docs/user/alerting/action-types/index.asciidoc +++ b/docs/user/alerting/action-types/index.asciidoc @@ -25,16 +25,26 @@ Execution time field:: This field will be automatically set to the time the ale name: action-type-index actionTypeId: .index config: - index: .kibana <1> - refresh: true <2> - executionTimeField: somedate <3> + index: .kibana + refresh: true + executionTimeField: somedate -- `config` defines the action type specific to the configuration and contains the following properties: -<1> `index:` is a string and correspond to *Index*. -<2> `refresh:` is a boolean and correspond to *Refresh*. -<3> `executionTimeField:` is a string and correspond to *Execution time field*. +[cols="2*<"] +|=== + +|`index` +| A string that corresponds to *Index*. + +|`refresh` +| A boolean that corresponds to *Refresh*. + +|`executionTimeField` +| A string that corresponds to *Execution time field*. + +|=== [float] diff --git a/docs/user/alerting/action-types/pagerduty.asciidoc b/docs/user/alerting/action-types/pagerduty.asciidoc index cd51ec2e3301e..0468ab042e57e 100644 --- a/docs/user/alerting/action-types/pagerduty.asciidoc +++ b/docs/user/alerting/action-types/pagerduty.asciidoc @@ -145,18 +145,19 @@ Integration Key:: A 32 character PagerDuty Integration Key for an integration name: preconfigured-pagerduty-action-type actionTypeId: .pagerduty config: - apiUrl: https://test.host <1.1> + apiUrl: https://test.host secrets: - routingKey: testroutingkey <2.1> + routingKey: testroutingkey -- -`config` defines the action type specific to the configuration and contains the following properties: +`config` defines the action type specific to the configuration. +`config` contains +`apiURL`, a string that corresponds to *API URL*. -<1.1> `apiUrl:` is URL string and correspond to *API URL*. +`secrets` defines sensitive information for the action type. +`secrets` contains +`routingKey`, a string that corresponds to *Integration Key*. -`secrets` defines action type sensitive configuration: - -<2.1> `routingKey:` is a string and correspond to *Integration Key*. [float] [[pagerduty-action-configuration]] diff --git a/docs/user/alerting/pre-configured-connectors.asciidoc b/docs/user/alerting/action-types/pre-configured-connectors.asciidoc similarity index 55% rename from docs/user/alerting/pre-configured-connectors.asciidoc rename to docs/user/alerting/action-types/pre-configured-connectors.asciidoc index d5c20d1853d42..b3e401256f27b 100644 --- a/docs/user/alerting/pre-configured-connectors.asciidoc +++ b/docs/user/alerting/action-types/pre-configured-connectors.asciidoc @@ -1,9 +1,9 @@ [role="xpack"] [[pre-configured-action-types-and-connectors]] -== Preconfigured connectors and action types +=== Preconfigured connectors and action types -You can preconfigure an action type or a connector to have all the information it needs prior to startup +You can preconfigure a connector or action type to have all the information it needs prior to startup by adding it to the `kibana.yml` file. Preconfigured connectors offer the following capabilities: @@ -13,15 +13,15 @@ action are predefined, including the connector name and ID. - Appear in all spaces because they are not saved objects. - Cannot be edited or deleted. -Sensitive configuration information, such as credentials, can use the <>. - -A preconfigured action types has only preconfigured connectors. Preconfigured connectors can belong to either the preconfigured action type or to the regular action type. +A preconfigured action type has only preconfigured connectors. Preconfigured +connectors can belong to either the preconfigured action type or to the regular action type. [float] [[preconfigured-connector-example]] -=== Creating a preconfigured connector +==== Preconfigured connectors -The following example shows a valid configuration of two out-of-the box connectors: <> and <>. +This example shows a valid configuration for +two out-of-the box connectors: <> and <>. ```js xpack.actions.preconfigured: @@ -44,7 +44,7 @@ The following example shows a valid configuration of two out-of-the box connecto password: changeme ``` -<1> the key is the action connector identifier, eg `my-slack1` in this example. +<1> The key is the action connector identifier, `my-slack1` in this example. <2> `actionTypeId` is the action type identifier. <3> `name` is the name of the preconfigured connector. <4> `config` is the action type specific to the configuration. @@ -55,74 +55,67 @@ The following example shows a valid configuration of two out-of-the box connecto Sensitive properties, such as passwords, can also be stored in the <>. ============================================== -[float] -[[preconfigured-action-type-example]] -=== Creating a preconfigured action type - -In the `kibana.yml` file: - -. Exclude the action type from `xpack.actions.enabledActionTypes`. -. Add all its preconfigured connectors. - -The following example shows a valid configuration of preconfigured action type with one out-of-the box connector. - -```js - xpack.actions.enabledActionTypes: ['.slack', '.email', '.index'] <1> - xpack.actions.preconfigured: <2> - my-server-log: - actionTypeId: .server-log - name: 'Server log #xyz' -``` - -<1> `enabledActionTypes` should exclude preconfigured action type to prevent creating and deleting connectors. -<2> `preconfigured` is the setting for defining the list of available connectors for the preconfigured action type. - +//// [float] [[managing-pre-configured-connectors]] -=== Managing preconfigured connectors +==== View preconfigured connectors +//// -Preconfigured connectors appear in the connector list, regardless of which space the user is in. -They are tagged as “preconfigured” and cannot be deleted. +In *Management > Alerts and Actions*, preconfigured connectors +appear in the <>, +regardless of which space you are in. +They are tagged as “preconfigured”, and you cannot delete them. [role="screenshot"] image::images/pre-configured-connectors-managing.png[Connectors managing tab with pre-cofigured] -Clicking on a preconfigured connector shows the description, but not any of the configuration. +Clicking a preconfigured connector shows the description, but not the configuration. A message indicates that this is a preconfigured connector. [role="screenshot"] image::images/pre-configured-connectors-view-screen.png[Pre-configured connector view details] -The connector details preview is disabled for preconfigured connectors. +The connector details preview is disabled for preconfigured connectors +of a preconfigured action type. [role="screenshot"] image::images/pre-configured-action-type-managing.png[Connectors managing tab with pre-cofigured] - [float] -[[managing-pre-configured-action-types]] -=== Managing preconfigured action types +[[preconfigured-action-type-example]] +==== Preconfigured action type -Clicking *Create connector* shows the list of available action types. -Disabled action types are not included. +This example shows a preconfigured action type with one out-of-the box connector. -[role="screenshot"] -image::images/pre-configured-action-type-select-type.png[Pre-configured connector create menu] +```js + xpack.actions.enabledActionTypes: ['.slack', '.email', '.index'] <1> + xpack.actions.preconfigured: <2> + my-server-log: + actionTypeId: .server-log + name: 'Server log #xyz' +``` -[float] -[[pre-configured-connector-alert-form]] -=== Alert with a preconfigured connector +<1> `enabledActionTypes` excludes the preconfigured action type to prevent creating and deleting connectors. +<2> `preconfigured` is the setting for defining the list of available connectors for the preconfigured action type. -When attaching an action to an alert, -select from a list of available action types, and -then select the Slack or Webhook type. Those action types were configured previously. -The preconfigured connector is installed and is automatically selected. +[[managing-pre-configured-action-types]] +To attach a preconfigured action to an alert: -[role="screenshot"] -image::images/alert-pre-configured-slack-connector.png[Create alert with selected Slack action type] +. In *Management > Alerts and Actions*, open the *Connectors* tab. -The dropdown is populated with additional preconfigured Slack connectors. -The `preconfigured` label distinguishes them from space-aware connectors that use saved objects. +. Click *Create connector.* +. In the list of available action types, select the preconfigured action type you want. ++ +[role="screenshot"] +image::images/pre-configured-action-type-select-type.png[Pre-configured connector create menu] + +. In *Create alert*, open the connector dropdown, and then select the preconfigured +connector. ++ +The `preconfigured` label distinguishes it from a space-aware connector. ++ [role="screenshot"] image::images/alert-pre-configured-connectors-dropdown.png[Dropdown list with pre-cofigured connectors] + +. Click *Add action*. diff --git a/docs/user/alerting/action-types/slack.asciidoc b/docs/user/alerting/action-types/slack.asciidoc index afa616ba77b3a..5bad8a53f898c 100644 --- a/docs/user/alerting/action-types/slack.asciidoc +++ b/docs/user/alerting/action-types/slack.asciidoc @@ -23,12 +23,12 @@ Webhook URL:: The URL of the incoming webhook. See https://api.slack.com/messa name: preconfigured-slack-action-type actionTypeId: .slack config: - webhookUrl: 'https://hooks.slack.com/services/abcd/efgh/ijklmnopqrstuvwxyz' <1> + webhookUrl: 'https://hooks.slack.com/services/abcd/efgh/ijklmnopqrstuvwxyz' -- -`config` defines the action type specific to the configuration and contains the following properties: - -<1> `webhookUrl:` is URL string and correspond to *Webhook URL*. +`config` defines the action type specific to the configuration. +`config` contains +`webhookUrl`, a string that corresponds to *Webhook URL*. [float] diff --git a/docs/user/alerting/action-types/webhook.asciidoc b/docs/user/alerting/action-types/webhook.asciidoc index 27609652288b5..c91c24430e982 100644 --- a/docs/user/alerting/action-types/webhook.asciidoc +++ b/docs/user/alerting/action-types/webhook.asciidoc @@ -19,7 +19,7 @@ Password:: An optional password. If set, HTTP basic authentication is used. Cur [float] [[Preconfigured-webhook-configuration]] -==== Preconfigured action type +==== Preconfigured action type [source,text] -- @@ -27,25 +27,44 @@ Password:: An optional password. If set, HTTP basic authentication is used. Cur name: preconfigured-webhook-action-type actionTypeId: .webhook config: - url: https://test.host <1.1> - method: POST <1.2> - headers: <1.3> + url: https://test.host + method: POST + headers: testheader: testvalue secrets: - user: testuser <2.1> - password: passwordkeystorevalue <2.2> + user: testuser + password: passwordkeystorevalue -- `config` defines the action type specific to the configuration and contains the following properties: -<1.1> `url:` is URL string and correspond to *URL*. -<1.2> `method:` is a string and correspond to *Method*. -<1.3> `headers:` is Record and correspond to *Headers*. +[cols="2*<"] +|=== -`secrets` defines action type sensitive configuration: +|`url` +| A URL string that corresponds to *URL*. + +|`method` +| A string that corresponds to *Method*. + +|`headers` +|A record that corresponds to *Headers*. + +|=== + +`secrets` defines sensitive information for the action type: + +[cols="2*<"] +|=== + +|`user` +|A string that corresponds to *User*. + +|`password` +|A string that corresponds to *Password*. Should be stored in the <>. + +|=== -<2.1> `user:` is a string and correspond to *User*. -<2.2> `password:` is a string and correspond to *Password*. Should be stored in the <>. [float] [[webhook-action-configuration]] diff --git a/docs/user/alerting/images/alert-pre-configured-connectors-dropdown.png b/docs/user/alerting/images/alert-pre-configured-connectors-dropdown.png index 4e6c713298626..081688758eb48 100644 Binary files a/docs/user/alerting/images/alert-pre-configured-connectors-dropdown.png and b/docs/user/alerting/images/alert-pre-configured-connectors-dropdown.png differ diff --git a/docs/user/alerting/images/alert-pre-configured-slack-connector.png b/docs/user/alerting/images/alert-pre-configured-slack-connector.png index de05e2074ddde..e9d81877fbf4f 100644 Binary files a/docs/user/alerting/images/alert-pre-configured-slack-connector.png and b/docs/user/alerting/images/alert-pre-configured-slack-connector.png differ diff --git a/docs/user/alerting/images/pre-configured-action-type-select-type.png b/docs/user/alerting/images/pre-configured-action-type-select-type.png index 29e5a29edc7c0..91ca831840ce9 100644 Binary files a/docs/user/alerting/images/pre-configured-action-type-select-type.png and b/docs/user/alerting/images/pre-configured-action-type-select-type.png differ diff --git a/docs/user/alerting/images/pre-configured-connectors-view-screen.png b/docs/user/alerting/images/pre-configured-connectors-view-screen.png index 43ac44e7536d8..9c75f86498beb 100644 Binary files a/docs/user/alerting/images/pre-configured-connectors-view-screen.png and b/docs/user/alerting/images/pre-configured-connectors-view-screen.png differ diff --git a/docs/user/plugins.asciidoc b/docs/user/plugins.asciidoc index 83c1ab1a842bb..a96fe811dc84f 100644 --- a/docs/user/plugins.asciidoc +++ b/docs/user/plugins.asciidoc @@ -33,12 +33,17 @@ $ bin/kibana-plugin install x-pack === Install plugins from an arbitrary URL You can download official Elastic plugins simply by specifying their name. You -can alternatively specify a URL to a specific plugin, as in the following -example: +can alternatively specify a URL or file path to a specific plugin, as in the following +examples: ["source","shell",subs="attributes"] $ bin/kibana-plugin install https://artifacts.elastic.co/downloads/packs/x-pack/x-pack-{version}.zip +or + +["source","shell",subs="attributes"] +$ bin/kibana-plugin install file:///local/path/to/custom_plugin.zip + You can specify URLs that use the HTTP, HTTPS, or `file` protocols. [float] diff --git a/test/functional/services/find.ts b/test/functional/services/find.ts index bdcc5ba95e9fb..09fc32115f683 100644 --- a/test/functional/services/find.ts +++ b/test/functional/services/find.ts @@ -198,7 +198,7 @@ export async function FindProvider({ getService }: FtrProviderContext) { if (isDisplayed) { return descendant; } else { - throw new Error('Element is not displayed'); + throw new Error(`Element "${selector}" is not displayed`); } } diff --git a/x-pack/plugins/apm/public/components/app/ServiceMap/cytoscapeOptions.ts b/x-pack/plugins/apm/public/components/app/ServiceMap/cytoscapeOptions.ts index 0cdc7c4eb124d..d3c4654de8164 100644 --- a/x-pack/plugins/apm/public/components/app/ServiceMap/cytoscapeOptions.ts +++ b/x-pack/plugins/apm/public/components/app/ServiceMap/cytoscapeOptions.ts @@ -17,7 +17,8 @@ export const getSeverityColor = (nodeSeverity: string) => { switch (nodeSeverity) { case severity.warning: return theme.euiColorVis0; - case severity.minor || severity.major: + case severity.minor: + case severity.major: return theme.euiColorVis5; case severity.critical: return theme.euiColorVis9; diff --git a/x-pack/plugins/index_management/__jest__/client_integration/template_clone.test.tsx b/x-pack/plugins/index_management/__jest__/client_integration/template_clone.test.tsx index 17e19bf881dee..fa9d13d1ddd07 100644 --- a/x-pack/plugins/index_management/__jest__/client_integration/template_clone.test.tsx +++ b/x-pack/plugins/index_management/__jest__/client_integration/template_clone.test.tsx @@ -42,7 +42,8 @@ jest.mock('@elastic/eui', () => ({ ), })); -describe('', () => { +// FLAKY: https://github.com/elastic/kibana/issues/59849 +describe.skip('', () => { let testBed: TemplateFormTestBed; const { server, httpRequestsMockHelpers } = setupEnvironment(); diff --git a/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/mapped_fields.test.tsx b/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/mapped_fields.test.tsx index 8989e85d9f188..57040eaeefbdf 100644 --- a/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/mapped_fields.test.tsx +++ b/x-pack/plugins/index_management/public/application/components/mappings_editor/__jest__/client_integration/mapped_fields.test.tsx @@ -10,7 +10,8 @@ import { componentHelpers, MappingsEditorTestBed, DomFields, nextTick } from './ const { setup } = componentHelpers.mappingsEditor; const onChangeHandler = jest.fn(); -describe('Mappings editor: mapped fields', () => { +// FLAKY: https://github.com/elastic/kibana/issues/65741 +describe.skip('Mappings editor: mapped fields', () => { afterEach(() => { onChangeHandler.mockReset(); }); diff --git a/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression.tsx b/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression.tsx index 406f9c7602d35..8fdba86f233d4 100644 --- a/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression.tsx +++ b/x-pack/plugins/infra/public/alerting/metric_threshold/components/expression.tsx @@ -74,6 +74,7 @@ export const Expressions: React.FC = props => { fetch: alertsContext.http.fetch, toastWarning: alertsContext.toastNotifications.addWarning, }); + const [timeSize, setTimeSize] = useState(1); const [timeUnit, setTimeUnit] = useState('m'); const derivedIndexPattern = useMemo(() => createDerivedIndexPattern('metrics'), [ @@ -173,52 +174,57 @@ export const Expressions: React.FC = props => { [alertParams.criteria, setAlertParams] ); - useEffect(() => { + const preFillAlertCriteria = useCallback(() => { const md = alertsContext.metadata; - if (md) { - if (md.currentOptions?.metrics) { - setAlertParams( - 'criteria', - md.currentOptions.metrics.map(metric => ({ - metric: metric.field, - comparator: Comparator.GT, - threshold: [], - timeSize, - timeUnit, - aggType: metric.aggregation, - })) - ); - } else { - setAlertParams('criteria', [defaultExpression]); - } + if (md && md.currentOptions?.metrics) { + setAlertParams( + 'criteria', + md.currentOptions.metrics.map(metric => ({ + metric: metric.field, + comparator: Comparator.GT, + threshold: [], + timeSize, + timeUnit, + aggType: metric.aggregation, + })) + ); + } else { + setAlertParams('criteria', [defaultExpression]); + } + }, [alertsContext.metadata, setAlertParams, timeSize, timeUnit]); - if (md.currentOptions) { - if (md.currentOptions.filterQuery) { - setAlertParams('filterQueryText', md.currentOptions.filterQuery); - setAlertParams( - 'filterQuery', - convertKueryToElasticSearchQuery(md.currentOptions.filterQuery, derivedIndexPattern) || - '' - ); - } else if (md.currentOptions.groupBy && md.series) { - const filter = `${md.currentOptions.groupBy}: "${md.series.id}"`; - setAlertParams('filterQueryText', filter); - setAlertParams( - 'filterQuery', - convertKueryToElasticSearchQuery(filter, derivedIndexPattern) || '' - ); - } + const preFillAlertFilter = useCallback(() => { + const md = alertsContext.metadata; + if (md && md.currentOptions?.filterQuery) { + setAlertParams('filterQueryText', md.currentOptions.filterQuery); + setAlertParams( + 'filterQuery', + convertKueryToElasticSearchQuery(md.currentOptions.filterQuery, derivedIndexPattern) || '' + ); + } else if (md && md.currentOptions?.groupBy && md.series) { + const filter = `${md.currentOptions?.groupBy}: "${md.series.id}"`; + setAlertParams('filterQueryText', filter); + setAlertParams( + 'filterQuery', + convertKueryToElasticSearchQuery(filter, derivedIndexPattern) || '' + ); + } + }, [alertsContext.metadata, derivedIndexPattern, setAlertParams]); - setAlertParams('groupBy', md.currentOptions.groupBy); - } - setAlertParams('sourceId', source?.id); + useEffect(() => { + if (alertParams.criteria && alertParams.criteria.length) { + setTimeSize(alertParams.criteria[0].timeSize); + setTimeUnit(alertParams.criteria[0].timeUnit); } else { - if (!alertParams.criteria) { - setAlertParams('criteria', [defaultExpression]); - } - if (!alertParams.sourceId) { - setAlertParams('sourceId', source?.id || 'default'); - } + preFillAlertCriteria(); + } + + if (!alertParams.filterQuery) { + preFillAlertFilter(); + } + + if (!alertParams.sourceId) { + setAlertParams('sourceId', source?.id || 'default'); } }, [alertsContext.metadata, defaultExpression, source]); // eslint-disable-line react-hooks/exhaustive-deps diff --git a/x-pack/plugins/infra/server/lib/alerting/common/utils.ts b/x-pack/plugins/infra/server/lib/alerting/common/utils.ts index a2c5b27c38fd6..5ca65b667ae11 100644 --- a/x-pack/plugins/infra/server/lib/alerting/common/utils.ts +++ b/x-pack/plugins/infra/server/lib/alerting/common/utils.ts @@ -13,6 +13,11 @@ export const oneOfLiterals = (arrayOfLiterals: Readonly) => }); export const validateIsStringElasticsearchJSONFilter = (value: string) => { + if (value === '') { + // Allow clearing the filter. + return; + } + const errorMessage = 'filterQuery must be a valid Elasticsearch filter expressed in JSON'; try { const parsedValue = JSON.parse(value); diff --git a/x-pack/plugins/siem/public/components/ml/empty_ml_capabilities.ts b/x-pack/plugins/siem/common/machine_learning/empty_ml_capabilities.ts similarity index 76% rename from x-pack/plugins/siem/public/components/ml/empty_ml_capabilities.ts rename to x-pack/plugins/siem/common/machine_learning/empty_ml_capabilities.ts index 9c8610ccd628c..0d6a13c108b04 100644 --- a/x-pack/plugins/siem/public/components/ml/empty_ml_capabilities.ts +++ b/x-pack/plugins/siem/common/machine_learning/empty_ml_capabilities.ts @@ -4,10 +4,14 @@ * you may not use this file except in compliance with the Elastic License. */ -import { MlCapabilities } from './types'; +import { MlCapabilitiesResponse } from '../../../ml/common/types/capabilities'; -export const emptyMlCapabilities: MlCapabilities = { +export const emptyMlCapabilities: MlCapabilitiesResponse = { capabilities: { + canAccessML: false, + canGetAnnotations: false, + canCreateAnnotation: false, + canDeleteAnnotation: false, canGetJobs: false, canCreateJob: false, canDeleteJob: false, @@ -26,11 +30,8 @@ export const emptyMlCapabilities: MlCapabilities = { canCreateFilter: false, canDeleteFilter: false, canFindFileStructure: false, - canGetDataFrame: false, - canDeleteDataFrame: false, - canPreviewDataFrame: false, - canCreateDataFrame: false, - canStartStopDataFrame: false, + canCreateDatafeed: false, + canDeleteDatafeed: false, canGetDataFrameAnalytics: false, canDeleteDataFrameAnalytics: false, canCreateDataFrameAnalytics: false, diff --git a/x-pack/plugins/siem/public/components/ml/permissions/has_ml_admin_permissions.test.ts b/x-pack/plugins/siem/common/machine_learning/has_ml_admin_permissions.test.ts similarity index 96% rename from x-pack/plugins/siem/public/components/ml/permissions/has_ml_admin_permissions.test.ts rename to x-pack/plugins/siem/common/machine_learning/has_ml_admin_permissions.test.ts index ee237b42bede9..9824ce1232cbe 100644 --- a/x-pack/plugins/siem/public/components/ml/permissions/has_ml_admin_permissions.test.ts +++ b/x-pack/plugins/siem/common/machine_learning/has_ml_admin_permissions.test.ts @@ -6,7 +6,7 @@ import { hasMlAdminPermissions } from './has_ml_admin_permissions'; import { cloneDeep } from 'lodash/fp'; -import { emptyMlCapabilities } from '../empty_ml_capabilities'; +import { emptyMlCapabilities } from './empty_ml_capabilities'; describe('has_ml_admin_permissions', () => { let mlCapabilities = cloneDeep(emptyMlCapabilities); diff --git a/x-pack/plugins/siem/public/components/ml/permissions/has_ml_admin_permissions.ts b/x-pack/plugins/siem/common/machine_learning/has_ml_admin_permissions.ts similarity index 79% rename from x-pack/plugins/siem/public/components/ml/permissions/has_ml_admin_permissions.ts rename to x-pack/plugins/siem/common/machine_learning/has_ml_admin_permissions.ts index 6fe142cf8e583..106e9aabbc711 100644 --- a/x-pack/plugins/siem/public/components/ml/permissions/has_ml_admin_permissions.ts +++ b/x-pack/plugins/siem/common/machine_learning/has_ml_admin_permissions.ts @@ -4,21 +4,21 @@ * you may not use this file except in compliance with the Elastic License. */ -import { MlCapabilities } from '../types'; +import { MlCapabilitiesResponse } from '../../../ml/common/types/capabilities'; -export const hasMlAdminPermissions = (capabilities: MlCapabilities): boolean => +export const hasMlAdminPermissions = (capabilities: MlCapabilitiesResponse): boolean => getDataFeedPermissions(capabilities) && getJobPermissions(capabilities) && getFilterPermissions(capabilities) && getCalendarPermissions(capabilities); -const getDataFeedPermissions = ({ capabilities }: MlCapabilities): boolean => +const getDataFeedPermissions = ({ capabilities }: MlCapabilitiesResponse): boolean => capabilities.canGetDatafeeds && capabilities.canStartStopDatafeed && capabilities.canUpdateDatafeed && capabilities.canPreviewDatafeed; -const getJobPermissions = ({ capabilities }: MlCapabilities): boolean => +const getJobPermissions = ({ capabilities }: MlCapabilitiesResponse): boolean => capabilities.canCreateJob && capabilities.canGetJobs && capabilities.canUpdateJob && @@ -27,8 +27,8 @@ const getJobPermissions = ({ capabilities }: MlCapabilities): boolean => capabilities.canCloseJob && capabilities.canForecastJob; -const getFilterPermissions = ({ capabilities }: MlCapabilities) => +const getFilterPermissions = ({ capabilities }: MlCapabilitiesResponse) => capabilities.canGetFilters && capabilities.canCreateFilter && capabilities.canDeleteFilter; -const getCalendarPermissions = ({ capabilities }: MlCapabilities) => +const getCalendarPermissions = ({ capabilities }: MlCapabilitiesResponse) => capabilities.canCreateCalendar && capabilities.canGetCalendars && capabilities.canDeleteCalendar; diff --git a/x-pack/plugins/siem/public/components/ml/permissions/has_ml_user_permissions.test.ts b/x-pack/plugins/siem/common/machine_learning/has_ml_user_permissions.test.ts similarity index 94% rename from x-pack/plugins/siem/public/components/ml/permissions/has_ml_user_permissions.test.ts rename to x-pack/plugins/siem/common/machine_learning/has_ml_user_permissions.test.ts index e3804055f2abb..4d58cda81d71c 100644 --- a/x-pack/plugins/siem/public/components/ml/permissions/has_ml_user_permissions.test.ts +++ b/x-pack/plugins/siem/common/machine_learning/has_ml_user_permissions.test.ts @@ -6,7 +6,7 @@ import { cloneDeep } from 'lodash/fp'; import { hasMlUserPermissions } from './has_ml_user_permissions'; -import { emptyMlCapabilities } from '../empty_ml_capabilities'; +import { emptyMlCapabilities } from './empty_ml_capabilities'; describe('has_ml_user_permissions', () => { let mlCapabilities = cloneDeep(emptyMlCapabilities); diff --git a/x-pack/plugins/siem/public/components/ml/permissions/has_ml_user_permissions.ts b/x-pack/plugins/siem/common/machine_learning/has_ml_user_permissions.ts similarity index 81% rename from x-pack/plugins/siem/public/components/ml/permissions/has_ml_user_permissions.ts rename to x-pack/plugins/siem/common/machine_learning/has_ml_user_permissions.ts index 2d55b7d74f93c..dd746e4737bbc 100644 --- a/x-pack/plugins/siem/public/components/ml/permissions/has_ml_user_permissions.ts +++ b/x-pack/plugins/siem/common/machine_learning/has_ml_user_permissions.ts @@ -4,9 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { MlCapabilities } from '../types'; +import { MlCapabilitiesResponse } from '../../../ml/common/types/capabilities'; -export const hasMlUserPermissions = (capabilities: MlCapabilities): boolean => +export const hasMlUserPermissions = (capabilities: MlCapabilitiesResponse): boolean => capabilities.capabilities.canGetJobs && capabilities.capabilities.canGetDatafeeds && capabilities.capabilities.canGetCalendars; diff --git a/x-pack/plugins/siem/common/detection_engine/ml_helpers.test.ts b/x-pack/plugins/siem/common/machine_learning/helpers.test.ts similarity index 96% rename from x-pack/plugins/siem/common/detection_engine/ml_helpers.test.ts rename to x-pack/plugins/siem/common/machine_learning/helpers.test.ts index ba93b2e4b8a0d..ce343f75933dc 100644 --- a/x-pack/plugins/siem/common/detection_engine/ml_helpers.test.ts +++ b/x-pack/plugins/siem/common/machine_learning/helpers.test.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { isJobStarted, isJobLoading, isJobFailed } from './ml_helpers'; +import { isJobStarted, isJobLoading, isJobFailed } from './helpers'; describe('isJobStarted', () => { test('returns false if only jobState is enabled', () => { diff --git a/x-pack/plugins/siem/common/detection_engine/ml_helpers.ts b/x-pack/plugins/siem/common/machine_learning/helpers.ts similarity index 95% rename from x-pack/plugins/siem/common/detection_engine/ml_helpers.ts rename to x-pack/plugins/siem/common/machine_learning/helpers.ts index e4158d08d448d..fe3eb79a6f610 100644 --- a/x-pack/plugins/siem/common/detection_engine/ml_helpers.ts +++ b/x-pack/plugins/siem/common/machine_learning/helpers.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { RuleType } from './types'; +import { RuleType } from '../detection_engine/types'; // Based on ML Job/Datafeed States from x-pack/legacy/plugins/ml/common/constants/states.js const enabledStates = ['started', 'opened']; diff --git a/x-pack/plugins/siem/public/components/ml/anomaly/use_anomalies_table_data.ts b/x-pack/plugins/siem/public/components/ml/anomaly/use_anomalies_table_data.ts index d64bd3a64e941..67efda67a20a3 100644 --- a/x-pack/plugins/siem/public/components/ml/anomaly/use_anomalies_table_data.ts +++ b/x-pack/plugins/siem/public/components/ml/anomaly/use_anomalies_table_data.ts @@ -9,7 +9,7 @@ import { useState, useEffect } from 'react'; import { DEFAULT_ANOMALY_SCORE } from '../../../../common/constants'; import { anomaliesTableData } from '../api/anomalies_table_data'; import { InfluencerInput, Anomalies, CriteriaFields } from '../types'; -import { hasMlUserPermissions } from '../permissions/has_ml_user_permissions'; +import { hasMlUserPermissions } from '../../../../common/machine_learning/has_ml_user_permissions'; import { useSiemJobs } from '../../ml_popover/hooks/use_siem_jobs'; import { useMlCapabilities } from '../../ml_popover/hooks/use_ml_capabilities'; import { useStateToaster, errorToToaster } from '../../toasters'; diff --git a/x-pack/plugins/siem/public/components/ml/api/get_ml_capabilities.ts b/x-pack/plugins/siem/public/components/ml/api/get_ml_capabilities.ts index e69abc1a86e0e..e6a792e779b0c 100644 --- a/x-pack/plugins/siem/public/components/ml/api/get_ml_capabilities.ts +++ b/x-pack/plugins/siem/public/components/ml/api/get_ml_capabilities.ts @@ -4,8 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { InfluencerInput, MlCapabilities } from '../types'; +import { MlCapabilitiesResponse } from '../../../../../ml/public'; import { KibanaServices } from '../../../lib/kibana'; +import { InfluencerInput } from '../types'; export interface Body { jobIds: string[]; @@ -20,8 +21,8 @@ export interface Body { maxExamples: number; } -export const getMlCapabilities = async (signal: AbortSignal): Promise => { - return KibanaServices.get().http.fetch('/api/ml/ml_capabilities', { +export const getMlCapabilities = async (signal: AbortSignal): Promise => { + return KibanaServices.get().http.fetch('/api/ml/ml_capabilities', { method: 'GET', asSystemRequest: true, signal, diff --git a/x-pack/plugins/siem/public/components/ml/permissions/ml_capabilities_provider.tsx b/x-pack/plugins/siem/public/components/ml/permissions/ml_capabilities_provider.tsx index eee44abb44204..9326c53b6064d 100644 --- a/x-pack/plugins/siem/public/components/ml/permissions/ml_capabilities_provider.tsx +++ b/x-pack/plugins/siem/public/components/ml/permissions/ml_capabilities_provider.tsx @@ -6,14 +6,14 @@ import React, { useState, useEffect } from 'react'; -import { MlCapabilities } from '../types'; +import { MlCapabilitiesResponse } from '../../../../../ml/public'; +import { emptyMlCapabilities } from '../../../../common/machine_learning/empty_ml_capabilities'; import { getMlCapabilities } from '../api/get_ml_capabilities'; -import { emptyMlCapabilities } from '../empty_ml_capabilities'; import { errorToToaster, useStateToaster } from '../../toasters'; import * as i18n from './translations'; -interface MlCapabilitiesProvider extends MlCapabilities { +interface MlCapabilitiesProvider extends MlCapabilitiesResponse { capabilitiesFetched: boolean; } diff --git a/x-pack/plugins/siem/public/components/ml/tables/anomalies_host_table.tsx b/x-pack/plugins/siem/public/components/ml/tables/anomalies_host_table.tsx index 16bde076ef763..3272042732dff 100644 --- a/x-pack/plugins/siem/public/components/ml/tables/anomalies_host_table.tsx +++ b/x-pack/plugins/siem/public/components/ml/tables/anomalies_host_table.tsx @@ -9,13 +9,13 @@ import React from 'react'; import { useAnomaliesTableData } from '../anomaly/use_anomalies_table_data'; import { HeaderSection } from '../../header_section'; +import { hasMlUserPermissions } from '../../../../common/machine_learning/has_ml_user_permissions'; import * as i18n from './translations'; import { getAnomaliesHostTableColumnsCurated } from './get_anomalies_host_table_columns'; import { convertAnomaliesToHosts } from './convert_anomalies_to_hosts'; import { Loader } from '../../loader'; import { getIntervalFromAnomalies } from '../anomaly/get_interval_from_anomalies'; import { AnomaliesHostTableProps } from '../types'; -import { hasMlUserPermissions } from '../permissions/has_ml_user_permissions'; import { useMlCapabilities } from '../../ml_popover/hooks/use_ml_capabilities'; import { BasicTable } from './basic_table'; import { hostEquality } from './host_equality'; diff --git a/x-pack/plugins/siem/public/components/ml/tables/anomalies_network_table.tsx b/x-pack/plugins/siem/public/components/ml/tables/anomalies_network_table.tsx index bba6355f0b8b9..cc3b1196f8432 100644 --- a/x-pack/plugins/siem/public/components/ml/tables/anomalies_network_table.tsx +++ b/x-pack/plugins/siem/public/components/ml/tables/anomalies_network_table.tsx @@ -8,13 +8,13 @@ import React from 'react'; import { useAnomaliesTableData } from '../anomaly/use_anomalies_table_data'; import { HeaderSection } from '../../header_section'; +import { hasMlUserPermissions } from '../../../../common/machine_learning/has_ml_user_permissions'; import * as i18n from './translations'; import { convertAnomaliesToNetwork } from './convert_anomalies_to_network'; import { Loader } from '../../loader'; import { AnomaliesNetworkTableProps } from '../types'; import { getAnomaliesNetworkTableColumnsCurated } from './get_anomalies_network_table_columns'; import { useMlCapabilities } from '../../ml_popover/hooks/use_ml_capabilities'; -import { hasMlUserPermissions } from '../permissions/has_ml_user_permissions'; import { BasicTable } from './basic_table'; import { networkEquality } from './network_equality'; import { getCriteriaFromNetworkType } from '../criteria/get_criteria_from_network_type'; diff --git a/x-pack/plugins/siem/public/components/ml/types.ts b/x-pack/plugins/siem/public/components/ml/types.ts index 953fb9f761ea8..f70c7d3eb034c 100644 --- a/x-pack/plugins/siem/public/components/ml/types.ts +++ b/x-pack/plugins/siem/public/components/ml/types.ts @@ -4,15 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ +import { Influencer } from '../../../../ml/public'; + import { HostsType } from '../../store/hosts/model'; import { NetworkType } from '../../store/network/model'; import { FlowTarget } from '../../graphql/types'; -export interface Influencer { - influencer_field_name: string; - influencer_field_values: string[]; -} - export interface Source { job_id: string; result_type: string; @@ -35,11 +32,6 @@ export interface Source { influencers: Influencer[]; } -export interface Influencer { - influencer_field_name: string; - influencer_field_values: string[]; -} - export interface CriteriaFields { fieldName: string; fieldValue: string; @@ -100,41 +92,6 @@ export type AnomaliesNetworkTableProps = HostOrNetworkProps & { flowTarget?: FlowTarget; }; -export interface MlCapabilities { - capabilities: { - canGetJobs: boolean; - canCreateJob: boolean; - canDeleteJob: boolean; - canOpenJob: boolean; - canCloseJob: boolean; - canForecastJob: boolean; - canGetDatafeeds: boolean; - canStartStopDatafeed: boolean; - canUpdateJob: boolean; - canUpdateDatafeed: boolean; - canPreviewDatafeed: boolean; - canGetCalendars: boolean; - canCreateCalendar: boolean; - canDeleteCalendar: boolean; - canGetFilters: boolean; - canCreateFilter: boolean; - canDeleteFilter: boolean; - canFindFileStructure: boolean; - canGetDataFrame: boolean; - canDeleteDataFrame: boolean; - canPreviewDataFrame: boolean; - canCreateDataFrame: boolean; - canStartStopDataFrame: boolean; - canGetDataFrameAnalytics: boolean; - canDeleteDataFrameAnalytics: boolean; - canCreateDataFrameAnalytics: boolean; - canStartStopDataFrameAnalytics: boolean; - }; - isPlatinumOrTrialLicense: boolean; - mlFeatureEnabledInSpace: boolean; - upgradeInProgress: boolean; -} - const sourceOrDestination = ['source.ip', 'destination.ip']; export const isDestinationOrSource = (value: string | null): value is DestinationOrSource => diff --git a/x-pack/plugins/siem/public/components/ml_popover/helpers.test.tsx b/x-pack/plugins/siem/public/components/ml_popover/helpers.test.tsx index 26ebfeb91629b..0b8da6be57e1b 100644 --- a/x-pack/plugins/siem/public/components/ml_popover/helpers.test.tsx +++ b/x-pack/plugins/siem/public/components/ml_popover/helpers.test.tsx @@ -7,10 +7,6 @@ import { mockSiemJobs } from './__mocks__/api'; import { filterJobs, getStablePatternTitles, searchFilter } from './helpers'; -jest.mock('../ml/permissions/has_ml_admin_permissions', () => ({ - hasMlAdminPermissions: () => true, -})); - describe('helpers', () => { describe('filterJobs', () => { test('returns all jobs when no filter is suplied', () => { diff --git a/x-pack/plugins/siem/public/components/ml_popover/hooks/use_siem_jobs.tsx b/x-pack/plugins/siem/public/components/ml_popover/hooks/use_siem_jobs.tsx index 7bcbf4afa10cc..98e74208b3dcc 100644 --- a/x-pack/plugins/siem/public/components/ml_popover/hooks/use_siem_jobs.tsx +++ b/x-pack/plugins/siem/public/components/ml_popover/hooks/use_siem_jobs.tsx @@ -9,7 +9,7 @@ import { useEffect, useState } from 'react'; import { DEFAULT_INDEX_KEY } from '../../../../common/constants'; import { checkRecognizer, getJobsSummary, getModules } from '../api'; import { SiemJob } from '../types'; -import { hasMlUserPermissions } from '../../ml/permissions/has_ml_user_permissions'; +import { hasMlUserPermissions } from '../../../../common/machine_learning/has_ml_user_permissions'; import { errorToToaster, useStateToaster } from '../../toasters'; import { useUiSetting$ } from '../../../lib/kibana'; diff --git a/x-pack/plugins/siem/public/components/ml_popover/jobs_table/job_switch.tsx b/x-pack/plugins/siem/public/components/ml_popover/jobs_table/job_switch.tsx index e7b14f2e80bf2..7de2f0fbfbc54 100644 --- a/x-pack/plugins/siem/public/components/ml_popover/jobs_table/job_switch.tsx +++ b/x-pack/plugins/siem/public/components/ml_popover/jobs_table/job_switch.tsx @@ -11,7 +11,7 @@ import { isJobLoading, isJobFailed, isJobStarted, -} from '../../../../common/detection_engine/ml_helpers'; +} from '../../../../common/machine_learning/helpers'; import { SiemJob } from '../types'; const StaticSwitch = styled(EuiSwitch)` diff --git a/x-pack/plugins/siem/public/components/ml_popover/ml_popover.test.tsx b/x-pack/plugins/siem/public/components/ml_popover/ml_popover.test.tsx index 3c93e1c195cd7..cf4ac87bdb5e7 100644 --- a/x-pack/plugins/siem/public/components/ml_popover/ml_popover.test.tsx +++ b/x-pack/plugins/siem/public/components/ml_popover/ml_popover.test.tsx @@ -11,10 +11,6 @@ import { MlPopover } from './ml_popover'; jest.mock('../../lib/kibana'); -jest.mock('../ml/permissions/has_ml_admin_permissions', () => ({ - hasMlAdminPermissions: () => true, -})); - describe('MlPopover', () => { test('shows upgrade popover on mouse click', () => { const wrapper = mountWithIntl(); diff --git a/x-pack/plugins/siem/public/components/ml_popover/ml_popover.tsx b/x-pack/plugins/siem/public/components/ml_popover/ml_popover.tsx index 6ea5cba4b37e4..e7f7770ee87f8 100644 --- a/x-pack/plugins/siem/public/components/ml_popover/ml_popover.tsx +++ b/x-pack/plugins/siem/public/components/ml_popover/ml_popover.tsx @@ -12,7 +12,7 @@ import styled from 'styled-components'; import { useKibana } from '../../lib/kibana'; import { METRIC_TYPE, TELEMETRY_EVENT, track } from '../../lib/telemetry'; -import { hasMlAdminPermissions } from '../ml/permissions/has_ml_admin_permissions'; +import { hasMlAdminPermissions } from '../../../common/machine_learning/has_ml_admin_permissions'; import { errorToToaster, useStateToaster, ActionToaster } from '../toasters'; import { setupMlJob, startDatafeeds, stopDatafeeds } from './api'; import { filterJobs } from './helpers'; diff --git a/x-pack/plugins/siem/public/components/page/hosts/host_overview/index.tsx b/x-pack/plugins/siem/public/components/page/hosts/host_overview/index.tsx index 4d0e6a737d303..223a16fec77a0 100644 --- a/x-pack/plugins/siem/public/components/page/hosts/host_overview/index.tsx +++ b/x-pack/plugins/siem/public/components/page/hosts/host_overview/index.tsx @@ -19,7 +19,7 @@ import { InspectButton, InspectButtonContainer } from '../../../inspect'; import { HostItem } from '../../../../graphql/types'; import { Loader } from '../../../loader'; import { IPDetailsLink } from '../../../links'; -import { hasMlUserPermissions } from '../../../ml/permissions/has_ml_user_permissions'; +import { hasMlUserPermissions } from '../../../../../common/machine_learning/has_ml_user_permissions'; import { useMlCapabilities } from '../../../ml_popover/hooks/use_ml_capabilities'; import { AnomalyScores } from '../../../ml/score/anomaly_scores'; import { Anomalies, NarrowDateRange } from '../../../ml/types'; diff --git a/x-pack/plugins/siem/public/components/page/network/ip_overview/index.tsx b/x-pack/plugins/siem/public/components/page/network/ip_overview/index.tsx index 56b59ca97156f..456deaac0fb15 100644 --- a/x-pack/plugins/siem/public/components/page/network/ip_overview/index.tsx +++ b/x-pack/plugins/siem/public/components/page/network/ip_overview/index.tsx @@ -31,7 +31,7 @@ import { Loader } from '../../../loader'; import { Anomalies, NarrowDateRange } from '../../../ml/types'; import { AnomalyScores } from '../../../ml/score/anomaly_scores'; import { useMlCapabilities } from '../../../ml_popover/hooks/use_ml_capabilities'; -import { hasMlUserPermissions } from '../../../ml/permissions/has_ml_user_permissions'; +import { hasMlUserPermissions } from '../../../../../common/machine_learning/has_ml_user_permissions'; import { InspectButton, InspectButtonContainer } from '../../../inspect'; interface OwnProps { diff --git a/x-pack/plugins/siem/public/pages/detection_engine/rules/all/columns.tsx b/x-pack/plugins/siem/public/pages/detection_engine/rules/all/columns.tsx index 8e79f037d82b0..542a004cb3727 100644 --- a/x-pack/plugins/siem/public/pages/detection_engine/rules/all/columns.tsx +++ b/x-pack/plugins/siem/public/pages/detection_engine/rules/all/columns.tsx @@ -19,7 +19,7 @@ import { FormattedRelative } from '@kbn/i18n/react'; import * as H from 'history'; import React, { Dispatch } from 'react'; -import { isMlRule } from '../../../../../common/detection_engine/ml_helpers'; +import { isMlRule } from '../../../../../common/machine_learning/helpers'; import { Rule, RuleStatus } from '../../../../containers/detection_engine/rules'; import { getEmptyTagValue } from '../../../../components/empty_value'; import { FormattedDate } from '../../../../components/formatted_date'; diff --git a/x-pack/plugins/siem/public/pages/detection_engine/rules/all/index.tsx b/x-pack/plugins/siem/public/pages/detection_engine/rules/all/index.tsx index 18ca4d42bd018..d9a2fafd144bc 100644 --- a/x-pack/plugins/siem/public/pages/detection_engine/rules/all/index.tsx +++ b/x-pack/plugins/siem/public/pages/detection_engine/rules/all/index.tsx @@ -48,7 +48,7 @@ import { showRulesTable } from './helpers'; import { allRulesReducer, State } from './reducer'; import { RulesTableFilters } from './rules_table_filters/rules_table_filters'; import { useMlCapabilities } from '../../../../components/ml_popover/hooks/use_ml_capabilities'; -import { hasMlAdminPermissions } from '../../../../components/ml/permissions/has_ml_admin_permissions'; +import { hasMlAdminPermissions } from '../../../../../common/machine_learning/has_ml_admin_permissions'; const SORT_FIELD = 'enabled'; const initialState: State = { diff --git a/x-pack/plugins/siem/public/pages/detection_engine/rules/components/description_step/ml_job_description.tsx b/x-pack/plugins/siem/public/pages/detection_engine/rules/components/description_step/ml_job_description.tsx index 79993c37e549c..33d3dbcba8631 100644 --- a/x-pack/plugins/siem/public/pages/detection_engine/rules/components/description_step/ml_job_description.tsx +++ b/x-pack/plugins/siem/public/pages/detection_engine/rules/components/description_step/ml_job_description.tsx @@ -8,7 +8,7 @@ import React from 'react'; import styled from 'styled-components'; import { EuiBadge, EuiIcon, EuiLink, EuiToolTip } from '@elastic/eui'; -import { isJobStarted } from '../../../../../../common/detection_engine/ml_helpers'; +import { isJobStarted } from '../../../../../../common/machine_learning/helpers'; import { useKibana } from '../../../../../lib/kibana'; import { SiemJob } from '../../../../../components/ml_popover/types'; import { ListItems } from './types'; diff --git a/x-pack/plugins/siem/public/pages/detection_engine/rules/components/ml_job_select/index.tsx b/x-pack/plugins/siem/public/pages/detection_engine/rules/components/ml_job_select/index.tsx index 4fb9faaea711c..c011c06e86542 100644 --- a/x-pack/plugins/siem/public/pages/detection_engine/rules/components/ml_job_select/index.tsx +++ b/x-pack/plugins/siem/public/pages/detection_engine/rules/components/ml_job_select/index.tsx @@ -17,7 +17,7 @@ import { } from '@elastic/eui'; import styled from 'styled-components'; -import { isJobStarted } from '../../../../../../common/detection_engine/ml_helpers'; +import { isJobStarted } from '../../../../../../common/machine_learning/helpers'; import { FieldHook, getFieldValidityAndErrorMessage } from '../../../../../shared_imports'; import { useSiemJobs } from '../../../../../components/ml_popover/hooks/use_siem_jobs'; import { useKibana } from '../../../../../lib/kibana'; diff --git a/x-pack/plugins/siem/public/pages/detection_engine/rules/components/select_rule_type/index.tsx b/x-pack/plugins/siem/public/pages/detection_engine/rules/components/select_rule_type/index.tsx index 6f3d299da8d45..dc9a832f820ba 100644 --- a/x-pack/plugins/siem/public/pages/detection_engine/rules/components/select_rule_type/index.tsx +++ b/x-pack/plugins/siem/public/pages/detection_engine/rules/components/select_rule_type/index.tsx @@ -16,7 +16,7 @@ import { EuiText, } from '@elastic/eui'; -import { isMlRule } from '../../../../../../common/detection_engine/ml_helpers'; +import { isMlRule } from '../../../../../../common/machine_learning/helpers'; import { RuleType } from '../../../../../../common/detection_engine/types'; import { FieldHook } from '../../../../../shared_imports'; import { useKibana } from '../../../../../lib/kibana'; diff --git a/x-pack/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/index.tsx b/x-pack/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/index.tsx index b6887badc56be..3517c6fb21e69 100644 --- a/x-pack/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/index.tsx +++ b/x-pack/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/index.tsx @@ -10,7 +10,7 @@ import styled from 'styled-components'; import deepEqual from 'fast-deep-equal'; import { DEFAULT_INDEX_KEY } from '../../../../../../common/constants'; -import { isMlRule } from '../../../../../../common/detection_engine/ml_helpers'; +import { isMlRule } from '../../../../../../common/machine_learning/helpers'; import { IIndexPattern } from '../../../../../../../../../src/plugins/data/public'; import { useFetchIndexPatterns } from '../../../../../containers/detection_engine/rules'; import { DEFAULT_TIMELINE_TITLE } from '../../../../../components/timeline/translations'; @@ -38,7 +38,7 @@ import { import { schema } from './schema'; import * as i18n from './translations'; import { filterRuleFieldsForType, RuleFields } from '../../create/helpers'; -import { hasMlAdminPermissions } from '../../../../../components/ml/permissions/has_ml_admin_permissions'; +import { hasMlAdminPermissions } from '../../../../../../common/machine_learning/has_ml_admin_permissions'; const CommonUseField = getUseField({ component: Field }); diff --git a/x-pack/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/schema.tsx b/x-pack/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/schema.tsx index 8915c5f0a224f..08832c5dfe4f5 100644 --- a/x-pack/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/schema.tsx +++ b/x-pack/plugins/siem/public/pages/detection_engine/rules/components/step_define_rule/schema.tsx @@ -9,7 +9,7 @@ import { EuiText } from '@elastic/eui'; import { isEmpty } from 'lodash/fp'; import React from 'react'; -import { isMlRule } from '../../../../../../common/detection_engine/ml_helpers'; +import { isMlRule } from '../../../../../../common/machine_learning/helpers'; import { esKuery } from '../../../../../../../../../src/plugins/data/public'; import { FieldValueQueryBar } from '../query_bar'; import { diff --git a/x-pack/plugins/siem/public/pages/detection_engine/rules/create/helpers.ts b/x-pack/plugins/siem/public/pages/detection_engine/rules/create/helpers.ts index 7ad116c313361..b912c182a7c65 100644 --- a/x-pack/plugins/siem/public/pages/detection_engine/rules/create/helpers.ts +++ b/x-pack/plugins/siem/public/pages/detection_engine/rules/create/helpers.ts @@ -11,7 +11,7 @@ import deepmerge from 'deepmerge'; import { NOTIFICATION_THROTTLE_NO_ACTIONS } from '../../../../../common/constants'; import { transformAlertToRuleAction } from '../../../../../common/detection_engine/transform_actions'; import { RuleType } from '../../../../../common/detection_engine/types'; -import { isMlRule } from '../../../../../common/detection_engine/ml_helpers'; +import { isMlRule } from '../../../../../common/machine_learning/helpers'; import { NewRule } from '../../../../containers/detection_engine/rules'; import { diff --git a/x-pack/plugins/siem/public/pages/detection_engine/rules/details/index.tsx b/x-pack/plugins/siem/public/pages/detection_engine/rules/details/index.tsx index 3e45c892e23dd..6a43c217e5ff5 100644 --- a/x-pack/plugins/siem/public/pages/detection_engine/rules/details/index.tsx +++ b/x-pack/plugins/siem/public/pages/detection_engine/rules/details/index.tsx @@ -69,7 +69,7 @@ import { RuleStatusFailedCallOut } from './status_failed_callout'; import { FailureHistory } from './failure_history'; import { RuleStatus } from '../components/rule_status'; import { useMlCapabilities } from '../../../../components/ml_popover/hooks/use_ml_capabilities'; -import { hasMlAdminPermissions } from '../../../../components/ml/permissions/has_ml_admin_permissions'; +import { hasMlAdminPermissions } from '../../../../../common/machine_learning/has_ml_admin_permissions'; enum RuleDetailTabs { signals = 'signals', diff --git a/x-pack/plugins/siem/public/pages/detection_engine/rules/helpers.tsx b/x-pack/plugins/siem/public/pages/detection_engine/rules/helpers.tsx index 2ccbffd864070..3dbcf3b2425cc 100644 --- a/x-pack/plugins/siem/public/pages/detection_engine/rules/helpers.tsx +++ b/x-pack/plugins/siem/public/pages/detection_engine/rules/helpers.tsx @@ -11,7 +11,7 @@ import memoizeOne from 'memoize-one'; import { useLocation } from 'react-router-dom'; import { RuleAlertAction, RuleType } from '../../../../common/detection_engine/types'; -import { isMlRule } from '../../../../common/detection_engine/ml_helpers'; +import { isMlRule } from '../../../../common/machine_learning/helpers'; import { transformRuleToAlertAction } from '../../../../common/detection_engine/transform_actions'; import { Filter } from '../../../../../../../src/plugins/data/public'; import { Rule } from '../../../containers/detection_engine/rules'; diff --git a/x-pack/plugins/siem/public/pages/hosts/details/index.tsx b/x-pack/plugins/siem/public/pages/hosts/details/index.tsx index 730c93b43709c..afed0fab0ade7 100644 --- a/x-pack/plugins/siem/public/pages/hosts/details/index.tsx +++ b/x-pack/plugins/siem/public/pages/hosts/details/index.tsx @@ -15,7 +15,7 @@ import { HeaderPage } from '../../../components/header_page'; import { LastEventTime } from '../../../components/last_event_time'; import { AnomalyTableProvider } from '../../../components/ml/anomaly/anomaly_table_provider'; import { hostToCriteria } from '../../../components/ml/criteria/host_to_criteria'; -import { hasMlUserPermissions } from '../../../components/ml/permissions/has_ml_user_permissions'; +import { hasMlUserPermissions } from '../../../../common/machine_learning/has_ml_user_permissions'; import { useMlCapabilities } from '../../../components/ml_popover/hooks/use_ml_capabilities'; import { scoreIntervalToDateTime } from '../../../components/ml/score/score_interval_to_datetime'; import { SiemNavigation } from '../../../components/navigation'; diff --git a/x-pack/plugins/siem/public/pages/hosts/hosts.tsx b/x-pack/plugins/siem/public/pages/hosts/hosts.tsx index 2fbbc0d96a1e3..0e29d634d07a6 100644 --- a/x-pack/plugins/siem/public/pages/hosts/hosts.tsx +++ b/x-pack/plugins/siem/public/pages/hosts/hosts.tsx @@ -14,7 +14,7 @@ import { UpdateDateRange } from '../../components/charts/common'; import { FiltersGlobal } from '../../components/filters_global'; import { HeaderPage } from '../../components/header_page'; import { LastEventTime } from '../../components/last_event_time'; -import { hasMlUserPermissions } from '../../components/ml/permissions/has_ml_user_permissions'; +import { hasMlUserPermissions } from '../../../common/machine_learning/has_ml_user_permissions'; import { SiemNavigation } from '../../components/navigation'; import { KpiHostsComponent } from '../../components/page/hosts'; import { manageQuery } from '../../components/page/manage_query'; diff --git a/x-pack/plugins/siem/public/pages/network/index.tsx b/x-pack/plugins/siem/public/pages/network/index.tsx index babc153823b5a..412e51e74059e 100644 --- a/x-pack/plugins/siem/public/pages/network/index.tsx +++ b/x-pack/plugins/siem/public/pages/network/index.tsx @@ -8,7 +8,7 @@ import React, { useMemo } from 'react'; import { Redirect, Route, Switch, RouteComponentProps } from 'react-router-dom'; import { useMlCapabilities } from '../../components/ml_popover/hooks/use_ml_capabilities'; -import { hasMlUserPermissions } from '../../components/ml/permissions/has_ml_user_permissions'; +import { hasMlUserPermissions } from '../../../common/machine_learning/has_ml_user_permissions'; import { FlowTarget } from '../../graphql/types'; import { IPDetails } from './ip_details'; diff --git a/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_bulk_route.test.ts b/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_bulk_route.test.ts index e6facf6f3b7a8..473d183c8a8f2 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_bulk_route.test.ts +++ b/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_bulk_route.test.ts @@ -5,6 +5,8 @@ */ import { DETECTION_ENGINE_RULES_URL } from '../../../../../common/constants'; +import { mlServicesMock, mlAuthzMock as mockMlAuthzFactory } from '../../../machine_learning/mocks'; +import { buildMlAuthz } from '../../../machine_learning/authz'; import { typicalPayload, getReadBulkRequest, @@ -19,9 +21,12 @@ import { requestContextMock, serverMock, requestMock } from '../__mocks__'; import { createRulesBulkRoute } from './create_rules_bulk_route'; import { setFeatureFlagsForTestsOnly, unSetFeatureFlagsForTestsOnly } from '../../feature_flags'; +jest.mock('../../../machine_learning/authz', () => mockMlAuthzFactory.create()); + describe('create_rules_bulk', () => { let server: ReturnType; let { clients, context } = requestContextMock.createTools(); + let ml: ReturnType; beforeAll(() => { setFeatureFlagsForTestsOnly(); @@ -34,12 +39,13 @@ describe('create_rules_bulk', () => { beforeEach(() => { server = serverMock.create(); ({ clients, context } = requestContextMock.createTools()); + ml = mlServicesMock.create(); clients.clusterClient.callAsCurrentUser.mockResolvedValue(getNonEmptyIndex()); // index exists clients.alertsClient.find.mockResolvedValue(getEmptyFindResult()); // no existing rules clients.alertsClient.create.mockResolvedValue(getResult()); // successful creation - createRulesBulkRoute(server.router); + createRulesBulkRoute(server.router, ml); }); describe('status codes', () => { @@ -64,16 +70,20 @@ describe('create_rules_bulk', () => { }); describe('unhappy paths', () => { - it('returns an error object if creating an ML rule with an insufficient license', async () => { - (context.licensing.license.hasAtLeast as jest.Mock).mockReturnValue(false); + it('returns a 403 error object if ML Authz fails', async () => { + (buildMlAuthz as jest.Mock).mockReturnValueOnce({ + validateRuleType: jest + .fn() + .mockResolvedValue({ valid: false, message: 'mocked validation message' }), + }); const response = await server.inject(createBulkMlRuleRequest(), context); expect(response.status).toEqual(200); expect(response.body).toEqual([ { error: { - message: 'Your license does not support machine learning. Please upgrade your license.', - status_code: 400, + message: 'mocked validation message', + status_code: 403, }, rule_id: 'rule-1', }, diff --git a/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_bulk_route.ts b/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_bulk_route.ts index cf841a9c88b32..371faccfbe47c 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_bulk_route.ts +++ b/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_bulk_route.ts @@ -8,6 +8,9 @@ import uuid from 'uuid'; import { IRouter } from '../../../../../../../../src/core/server'; import { DETECTION_ENGINE_RULES_URL } from '../../../../../common/constants'; +import { SetupPlugins } from '../../../../plugin'; +import { buildMlAuthz } from '../../../machine_learning/authz'; +import { throwHttpError } from '../../../machine_learning/validation'; import { createRules } from '../../rules/create_rules'; import { RuleAlertParamsRest } from '../../types'; import { readRules } from '../../rules/read_rules'; @@ -19,13 +22,12 @@ import { createBulkErrorObject, buildRouteValidation, buildSiemResponse, - validateLicenseForRuleType, } from '../utils'; import { createRulesBulkSchema } from '../schemas/create_rules_bulk_schema'; import { rulesBulkSchema } from '../schemas/response/rules_bulk_schema'; import { updateRulesNotifications } from '../../rules/update_rules_notifications'; -export const createRulesBulkRoute = (router: IRouter) => { +export const createRulesBulkRoute = (router: IRouter, ml: SetupPlugins['ml']) => { router.post( { path: `${DETECTION_ENGINE_RULES_URL}/_bulk_create`, @@ -47,6 +49,8 @@ export const createRulesBulkRoute = (router: IRouter) => { return siemResponse.error({ statusCode: 404 }); } + const mlAuthz = buildMlAuthz({ license: context.licensing.license, ml, request }); + const ruleDefinitions = request.body; const dupes = getDuplicates(ruleDefinitions, 'rule_id'); @@ -89,7 +93,7 @@ export const createRulesBulkRoute = (router: IRouter) => { } = payloadRule; const ruleIdOrUuid = ruleId ?? uuid.v4(); try { - validateLicenseForRuleType({ license: context.licensing.license, ruleType: type }); + throwHttpError(await mlAuthz.validateRuleType(type)); const finalIndex = outputIndex ?? siemClient.getSignalsIndex(); const indexExists = await getIndexExists(clusterClient.callAsCurrentUser, finalIndex); diff --git a/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_route.test.ts b/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_route.test.ts index f15f47432f838..afdcda7da251d 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_route.test.ts +++ b/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_route.test.ts @@ -16,15 +16,19 @@ import { getFindResultWithSingleHit, createMlRuleRequest, } from '../__mocks__/request_responses'; +import { mlServicesMock, mlAuthzMock as mockMlAuthzFactory } from '../../../machine_learning/mocks'; +import { buildMlAuthz } from '../../../machine_learning/authz'; import { requestContextMock, serverMock, requestMock } from '../__mocks__'; import { createRulesRoute } from './create_rules_route'; import { setFeatureFlagsForTestsOnly, unSetFeatureFlagsForTestsOnly } from '../../feature_flags'; import { updateRulesNotifications } from '../../rules/update_rules_notifications'; jest.mock('../../rules/update_rules_notifications'); +jest.mock('../../../machine_learning/authz', () => mockMlAuthzFactory.create()); describe('create_rules', () => { let server: ReturnType; let { clients, context } = requestContextMock.createTools(); + let ml: ReturnType; beforeAll(() => { setFeatureFlagsForTestsOnly(); @@ -37,13 +41,14 @@ describe('create_rules', () => { beforeEach(() => { server = serverMock.create(); ({ clients, context } = requestContextMock.createTools()); + ml = mlServicesMock.create(); clients.clusterClient.callAsCurrentUser.mockResolvedValue(getNonEmptyIndex()); // index exists clients.alertsClient.find.mockResolvedValue(getEmptyFindResult()); // no current rules clients.alertsClient.create.mockResolvedValue(getResult()); // creation succeeds clients.savedObjectsClient.find.mockResolvedValue(getFindResultStatus()); // needed to transform - createRulesRoute(server.router); + createRulesRoute(server.router, ml); }); describe('status codes with actionClient and alertClient', () => { @@ -86,14 +91,18 @@ describe('create_rules', () => { expect(response.status).toEqual(200); }); - it('rejects the request if licensing is not platinum', async () => { - (context.licensing.license.hasAtLeast as jest.Mock).mockReturnValue(false); + it('returns a 403 if ML Authz fails', async () => { + (buildMlAuthz as jest.Mock).mockReturnValueOnce({ + validateRuleType: jest + .fn() + .mockResolvedValue({ valid: false, message: 'mocked validation message' }), + }); const response = await server.inject(createMlRuleRequest(), context); - expect(response.status).toEqual(400); + expect(response.status).toEqual(403); expect(response.body).toEqual({ - message: 'Your license does not support machine learning. Please upgrade your license.', - status_code: 400, + message: 'mocked validation message', + status_code: 403, }); }); }); diff --git a/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_route.ts b/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_route.ts index 6605b5abfcb09..7cbb22221679a 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_route.ts +++ b/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/create_rules_route.ts @@ -8,22 +8,20 @@ import uuid from 'uuid'; import { IRouter } from '../../../../../../../../src/core/server'; import { DETECTION_ENGINE_RULES_URL } from '../../../../../common/constants'; +import { SetupPlugins } from '../../../../plugin'; +import { buildMlAuthz } from '../../../machine_learning/authz'; +import { throwHttpError } from '../../../machine_learning/validation'; import { createRules } from '../../rules/create_rules'; import { readRules } from '../../rules/read_rules'; import { RuleAlertParamsRest } from '../../types'; import { transformValidate } from './validate'; import { getIndexExists } from '../../index/get_index_exists'; import { createRulesSchema } from '../schemas/create_rules_schema'; -import { - buildRouteValidation, - transformError, - buildSiemResponse, - validateLicenseForRuleType, -} from '../utils'; +import { buildRouteValidation, transformError, buildSiemResponse } from '../utils'; import { updateRulesNotifications } from '../../rules/update_rules_notifications'; import { ruleStatusSavedObjectsClientFactory } from '../../signals/rule_status_saved_objects_client'; -export const createRulesRoute = (router: IRouter): void => { +export const createRulesRoute = (router: IRouter, ml: SetupPlugins['ml']): void => { router.post( { path: DETECTION_ENGINE_RULES_URL, @@ -70,7 +68,6 @@ export const createRulesRoute = (router: IRouter): void => { const siemResponse = buildSiemResponse(response); try { - validateLicenseForRuleType({ license: context.licensing.license, ruleType: type }); const alertsClient = context.alerting?.getAlertsClient(); const clusterClient = context.core.elasticsearch.dataClient; const savedObjectsClient = context.core.savedObjects.client; @@ -80,6 +77,9 @@ export const createRulesRoute = (router: IRouter): void => { return siemResponse.error({ statusCode: 404 }); } + const mlAuthz = buildMlAuthz({ license: context.licensing.license, ml, request }); + throwHttpError(await mlAuthz.validateRuleType(type)); + const finalIndex = outputIndex ?? siemClient.getSignalsIndex(); const indexExists = await getIndexExists(clusterClient.callAsCurrentUser, finalIndex); if (!indexExists) { diff --git a/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.test.ts b/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.test.ts index 91685a68a60ae..c33c917c2e987 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.test.ts +++ b/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.test.ts @@ -9,8 +9,6 @@ import { ruleIdsToNdJsonString, rulesToNdJsonString, getSimpleRuleWithId, - getSimpleRule, - getSimpleMlRule, } from '../__mocks__/utils'; import { getImportRulesRequest, @@ -22,10 +20,14 @@ import { getNonEmptyIndex, } from '../__mocks__/request_responses'; import { createMockConfig, requestContextMock, serverMock, requestMock } from '../__mocks__'; +import { mlServicesMock, mlAuthzMock as mockMlAuthzFactory } from '../../../machine_learning/mocks'; +import { buildMlAuthz } from '../../../machine_learning/authz'; import { importRulesRoute } from './import_rules_route'; import * as createRulesStreamFromNdJson from '../../rules/create_rules_stream_from_ndjson'; import { setFeatureFlagsForTestsOnly, unSetFeatureFlagsForTestsOnly } from '../../feature_flags'; +jest.mock('../../../machine_learning/authz', () => mockMlAuthzFactory.create()); + describe('import_rules_route', () => { beforeAll(() => { setFeatureFlagsForTestsOnly(); @@ -39,25 +41,20 @@ describe('import_rules_route', () => { let server: ReturnType; let request: ReturnType; let { clients, context } = requestContextMock.createTools(); + let ml: ReturnType; beforeEach(() => { - // jest carries state between mocked implementations when using - // spyOn. So now we're doing all three of these. - // https://github.com/facebook/jest/issues/7136#issuecomment-565976599 - jest.resetAllMocks(); - jest.restoreAllMocks(); - jest.clearAllMocks(); - server = serverMock.create(); ({ clients, context } = requestContextMock.createTools()); config = createMockConfig(); const hapiStream = buildHapiStream(ruleIdsToNdJsonString(['rule-1'])); request = getImportRulesRequest(hapiStream); + ml = mlServicesMock.create(); clients.clusterClient.callAsCurrentUser.mockResolvedValue(getNonEmptyIndex()); // index exists clients.alertsClient.find.mockResolvedValue(getEmptyFindResult()); // no extant rules - importRulesRoute(server.router, config); + importRulesRoute(server.router, config, ml); }); describe('status codes', () => { @@ -83,11 +80,12 @@ describe('import_rules_route', () => { }); describe('unhappy paths', () => { - it('returns an error object if creating an ML rule with an insufficient license', async () => { - (context.licensing.license.hasAtLeast as jest.Mock).mockReturnValue(false); - const rules = [getSimpleRule(), getSimpleMlRule('rule-2')]; - const hapiStreamWithMlRule = buildHapiStream(rulesToNdJsonString(rules)); - request = getImportRulesRequest(hapiStreamWithMlRule); + it('returns a 403 error object if ML Authz fails', async () => { + (buildMlAuthz as jest.Mock).mockReturnValueOnce({ + validateRuleType: jest + .fn() + .mockResolvedValue({ valid: false, message: 'mocked validation message' }), + }); const response = await server.inject(request, context); expect(response.status).toEqual(200); @@ -95,20 +93,19 @@ describe('import_rules_route', () => { errors: [ { error: { - message: - 'Your license does not support machine learning. Please upgrade your license.', - status_code: 400, + message: 'mocked validation message', + status_code: 403, }, - rule_id: 'rule-2', + rule_id: 'rule-1', }, ], success: false, - success_count: 1, + success_count: 0, }); }); test('returns error if createPromiseFromStreams throws error', async () => { - jest + const transformMock = jest .spyOn(createRulesStreamFromNdJson, 'createRulesStreamFromNdJson') .mockImplementation(() => { throw new Error('Test error'); @@ -116,6 +113,8 @@ describe('import_rules_route', () => { const response = await server.inject(request, context); expect(response.status).toEqual(500); expect(response.body).toEqual({ message: 'Test error', status_code: 500 }); + + transformMock.mockRestore(); }); test('returns an error if the index does not exist', async () => { diff --git a/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.ts b/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.ts index 9ba083ae48086..00010027f106b 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.ts +++ b/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/import_rules_route.ts @@ -11,6 +11,9 @@ import { IRouter } from '../../../../../../../../src/core/server'; import { createPromiseFromStreams } from '../../../../../../../../src/legacy/utils/streams'; import { DETECTION_ENGINE_RULES_URL } from '../../../../../common/constants'; import { ConfigType } from '../../../../config'; +import { SetupPlugins } from '../../../../plugin'; +import { buildMlAuthz } from '../../../machine_learning/authz'; +import { throwHttpError } from '../../../machine_learning/validation'; import { createRules } from '../../rules/create_rules'; import { ImportRulesRequestParams } from '../../rules/types'; import { readRules } from '../../rules/read_rules'; @@ -24,7 +27,6 @@ import { isImportRegular, transformError, buildSiemResponse, - validateLicenseForRuleType, } from '../utils'; import { ImportRuleAlertRest } from '../../types'; import { patchRules } from '../../rules/patch_rules'; @@ -38,7 +40,7 @@ type PromiseFromStreams = ImportRuleAlertRest | Error; const CHUNK_PARSED_OBJECT_SIZE = 10; -export const importRulesRoute = (router: IRouter, config: ConfigType) => { +export const importRulesRoute = (router: IRouter, config: ConfigType, ml: SetupPlugins['ml']) => { router.post( { path: `${DETECTION_ENGINE_RULES_URL}/_import`, @@ -67,6 +69,8 @@ export const importRulesRoute = (router: IRouter, config: ConfigType) => { return siemResponse.error({ statusCode: 404 }); } + const mlAuthz = buildMlAuthz({ license: context.licensing.license, ml, request }); + const { filename } = request.body.file.hapi; const fileExtension = extname(filename).toLowerCase(); if (fileExtension !== '.ndjson') { @@ -148,10 +152,7 @@ export const importRulesRoute = (router: IRouter, config: ConfigType) => { } = parsedRule; try { - validateLicenseForRuleType({ - license: context.licensing.license, - ruleType: type, - }); + throwHttpError(await mlAuthz.validateRuleType(type)); const rule = await readRules({ alertsClient, ruleId }); if (rule == null) { @@ -207,8 +208,7 @@ export const importRulesRoute = (router: IRouter, config: ConfigType) => { timelineTitle, meta, filters, - id: undefined, - ruleId, + rule, index, interval, maxSignals, @@ -240,7 +240,7 @@ export const importRulesRoute = (router: IRouter, config: ConfigType) => { resolve( createBulkErrorObject({ ruleId, - statusCode: 400, + statusCode: err.statusCode ?? 400, message: err.message, }) ); diff --git a/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.test.ts b/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.test.ts index a1f39936dd674..24b2d5631b3a7 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.test.ts +++ b/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.test.ts @@ -5,6 +5,8 @@ */ import { DETECTION_ENGINE_RULES_URL } from '../../../../../common/constants'; +import { mlServicesMock, mlAuthzMock as mockMlAuthzFactory } from '../../../machine_learning/mocks'; +import { buildMlAuthz } from '../../../machine_learning/authz'; import { getEmptyFindResult, typicalPayload, @@ -17,9 +19,12 @@ import { serverMock, requestContextMock, requestMock } from '../__mocks__'; import { patchRulesBulkRoute } from './patch_rules_bulk_route'; import { setFeatureFlagsForTestsOnly, unSetFeatureFlagsForTestsOnly } from '../../feature_flags'; +jest.mock('../../../machine_learning/authz', () => mockMlAuthzFactory.create()); + describe('patch_rules_bulk', () => { let server: ReturnType; let { clients, context } = requestContextMock.createTools(); + let ml: ReturnType; beforeAll(() => { setFeatureFlagsForTestsOnly(); @@ -32,11 +37,12 @@ describe('patch_rules_bulk', () => { beforeEach(() => { server = serverMock.create(); ({ clients, context } = requestContextMock.createTools()); + ml = mlServicesMock.create(); clients.alertsClient.find.mockResolvedValue(getFindResultWithSingleHit()); // rule exists clients.alertsClient.update.mockResolvedValue(getResult()); // update succeeds - patchRulesBulkRoute(server.router); + patchRulesBulkRoute(server.router, ml); }); describe('status codes with actionClient and alertClient', () => { @@ -90,21 +96,51 @@ describe('patch_rules_bulk', () => { expect(response.body).toEqual({ message: 'Not Found', status_code: 404 }); }); - it('rejects patching of an ML rule with an insufficient license', async () => { - (context.licensing.license.hasAtLeast as jest.Mock).mockReturnValue(false); + it('rejects patching a rule to ML if mlAuthz fails', async () => { + (buildMlAuthz as jest.Mock).mockReturnValueOnce({ + validateRuleType: jest + .fn() + .mockResolvedValue({ valid: false, message: 'mocked validation message' }), + }); const request = requestMock.create({ method: 'patch', path: `${DETECTION_ENGINE_RULES_URL}/_bulk_update`, body: [typicalMlRulePayload()], }); + const response = await server.inject(request, context); + expect(response.status).toEqual(200); + expect(response.body).toEqual([ + { + error: { + message: 'mocked validation message', + status_code: 403, + }, + rule_id: 'rule-1', + }, + ]); + }); + + it('rejects patching an existing ML rule if mlAuthz fails', async () => { + (buildMlAuthz as jest.Mock).mockReturnValueOnce({ + validateRuleType: jest + .fn() + .mockResolvedValue({ valid: false, message: 'mocked validation message' }), + }); + const { type, ...payloadWithoutType } = typicalMlRulePayload(); + const request = requestMock.create({ + method: 'patch', + path: `${DETECTION_ENGINE_RULES_URL}/_bulk_update`, + body: [payloadWithoutType], + }); const response = await server.inject(request, context); + expect(response.status).toEqual(200); expect(response.body).toEqual([ { error: { - message: 'Your license does not support machine learning. Please upgrade your license.', - status_code: 400, + message: 'mocked validation message', + status_code: 403, }, rule_id: 'rule-1', }, diff --git a/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.ts b/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.ts index 201e1f823b4cb..69789fe946622 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.ts +++ b/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_bulk_route.ts @@ -6,13 +6,11 @@ import { IRouter } from '../../../../../../../../src/core/server'; import { DETECTION_ENGINE_RULES_URL } from '../../../../../common/constants'; +import { SetupPlugins } from '../../../../plugin'; +import { buildMlAuthz } from '../../../machine_learning/authz'; +import { throwHttpError } from '../../../machine_learning/validation'; import { PatchRuleAlertParamsRest } from '../../rules/types'; -import { - transformBulkError, - buildRouteValidation, - buildSiemResponse, - validateLicenseForRuleType, -} from '../utils'; +import { transformBulkError, buildRouteValidation, buildSiemResponse } from '../utils'; import { getIdBulkError } from './utils'; import { transformValidateBulkError, validate } from './validate'; import { patchRulesBulkSchema } from '../schemas/patch_rules_bulk_schema'; @@ -20,8 +18,9 @@ import { rulesBulkSchema } from '../schemas/response/rules_bulk_schema'; import { patchRules } from '../../rules/patch_rules'; import { updateRulesNotifications } from '../../rules/update_rules_notifications'; import { ruleStatusSavedObjectsClientFactory } from '../../signals/rule_status_saved_objects_client'; +import { readRules } from '../../rules/read_rules'; -export const patchRulesBulkRoute = (router: IRouter) => { +export const patchRulesBulkRoute = (router: IRouter, ml: SetupPlugins['ml']) => { router.patch( { path: `${DETECTION_ENGINE_RULES_URL}/_bulk_update`, @@ -42,6 +41,7 @@ export const patchRulesBulkRoute = (router: IRouter) => { return siemResponse.error({ statusCode: 404 }); } + const mlAuthz = buildMlAuthz({ license: context.licensing.license, ml, request }); const ruleStatusClient = ruleStatusSavedObjectsClientFactory(savedObjectsClient); const rules = await Promise.all( request.body.map(async payloadRule => { @@ -81,10 +81,18 @@ export const patchRulesBulkRoute = (router: IRouter) => { const idOrRuleIdOrUnknown = id ?? ruleId ?? '(unknown id)'; try { if (type) { - validateLicenseForRuleType({ license: context.licensing.license, ruleType: type }); + // reject an unauthorized "promotion" to ML + throwHttpError(await mlAuthz.validateRuleType(type)); + } + + const existingRule = await readRules({ alertsClient, ruleId, id }); + if (existingRule?.params.type) { + // reject an unauthorized modification of an ML rule + throwHttpError(await mlAuthz.validateRuleType(existingRule?.params.type)); } const rule = await patchRules({ + rule: existingRule, alertsClient, description, enabled, @@ -99,8 +107,6 @@ export const patchRulesBulkRoute = (router: IRouter) => { timelineTitle, meta, filters, - id, - ruleId, index, interval, maxSignals, diff --git a/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_route.test.ts b/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_route.test.ts index dbb0a3bb3e1da..9ae7e83ef7989 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_route.test.ts +++ b/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_route.test.ts @@ -5,6 +5,8 @@ */ import { DETECTION_ENGINE_RULES_URL } from '../../../../../common/constants'; +import { mlServicesMock, mlAuthzMock as mockMlAuthzFactory } from '../../../machine_learning/mocks'; +import { buildMlAuthz } from '../../../machine_learning/authz'; import { getEmptyFindResult, getFindResultStatus, @@ -19,9 +21,12 @@ import { requestContextMock, serverMock, requestMock } from '../__mocks__'; import { patchRulesRoute } from './patch_rules_route'; import { setFeatureFlagsForTestsOnly, unSetFeatureFlagsForTestsOnly } from '../../feature_flags'; +jest.mock('../../../machine_learning/authz', () => mockMlAuthzFactory.create()); + describe('patch_rules', () => { let server: ReturnType; let { clients, context } = requestContextMock.createTools(); + let ml: ReturnType; beforeAll(() => { setFeatureFlagsForTestsOnly(); @@ -34,13 +39,14 @@ describe('patch_rules', () => { beforeEach(() => { server = serverMock.create(); ({ clients, context } = requestContextMock.createTools()); + ml = mlServicesMock.create(); clients.alertsClient.get.mockResolvedValue(getResult()); // existing rule clients.alertsClient.find.mockResolvedValue(getFindResultWithSingleHit()); // existing rule clients.alertsClient.update.mockResolvedValue(getResult()); // successful update clients.savedObjectsClient.find.mockResolvedValue(getFindResultStatus()); // successful transform - patchRulesRoute(server.router); + patchRulesRoute(server.router, ml); }); describe('status codes with actionClient and alertClient', () => { @@ -112,8 +118,12 @@ describe('patch_rules', () => { ); }); - it('rejects patching a rule to ML if licensing is not platinum', async () => { - (context.licensing.license.hasAtLeast as jest.Mock).mockReturnValue(false); + it('rejects patching a rule to ML if mlAuthz fails', async () => { + (buildMlAuthz as jest.Mock).mockReturnValueOnce({ + validateRuleType: jest + .fn() + .mockResolvedValue({ valid: false, message: 'mocked validation message' }), + }); const request = requestMock.create({ method: 'patch', path: DETECTION_ENGINE_RULES_URL, @@ -121,10 +131,31 @@ describe('patch_rules', () => { }); const response = await server.inject(request, context); - expect(response.status).toEqual(400); + expect(response.status).toEqual(403); + expect(response.body).toEqual({ + message: 'mocked validation message', + status_code: 403, + }); + }); + + it('rejects patching an ML rule if mlAuthz fails', async () => { + (buildMlAuthz as jest.Mock).mockReturnValueOnce({ + validateRuleType: jest + .fn() + .mockResolvedValue({ valid: false, message: 'mocked validation message' }), + }); + const { type, ...payloadWithoutType } = typicalMlRulePayload(); + const request = requestMock.create({ + method: 'patch', + path: DETECTION_ENGINE_RULES_URL, + body: payloadWithoutType, + }); + const response = await server.inject(request, context); + + expect(response.status).toEqual(403); expect(response.body).toEqual({ - message: 'Your license does not support machine learning. Please upgrade your license.', - status_code: 400, + message: 'mocked validation message', + status_code: 403, }); }); }); diff --git a/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_route.ts b/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_route.ts index 00ccd3059b38d..ae23e0efc857d 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_route.ts +++ b/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/patch_rules_route.ts @@ -6,21 +6,20 @@ import { IRouter } from '../../../../../../../../src/core/server'; import { DETECTION_ENGINE_RULES_URL } from '../../../../../common/constants'; +import { SetupPlugins } from '../../../../plugin'; +import { buildMlAuthz } from '../../../machine_learning/authz'; +import { throwHttpError } from '../../../machine_learning/validation'; import { patchRules } from '../../rules/patch_rules'; import { PatchRuleAlertParamsRest } from '../../rules/types'; import { patchRulesSchema } from '../schemas/patch_rules_schema'; -import { - buildRouteValidation, - transformError, - buildSiemResponse, - validateLicenseForRuleType, -} from '../utils'; +import { buildRouteValidation, transformError, buildSiemResponse } from '../utils'; import { getIdError } from './utils'; import { transformValidate } from './validate'; import { updateRulesNotifications } from '../../rules/update_rules_notifications'; import { ruleStatusSavedObjectsClientFactory } from '../../signals/rule_status_saved_objects_client'; +import { readRules } from '../../rules/read_rules'; -export const patchRulesRoute = (router: IRouter) => { +export const patchRulesRoute = (router: IRouter, ml: SetupPlugins['ml']) => { router.patch( { path: DETECTION_ENGINE_RULES_URL, @@ -68,10 +67,6 @@ export const patchRulesRoute = (router: IRouter) => { const siemResponse = buildSiemResponse(response); try { - if (type) { - validateLicenseForRuleType({ license: context.licensing.license, ruleType: type }); - } - const alertsClient = context.alerting?.getAlertsClient(); const savedObjectsClient = context.core.savedObjects.client; @@ -79,6 +74,18 @@ export const patchRulesRoute = (router: IRouter) => { return siemResponse.error({ statusCode: 404 }); } + const mlAuthz = buildMlAuthz({ license: context.licensing.license, ml, request }); + if (type) { + // reject an unauthorized "promotion" to ML + throwHttpError(await mlAuthz.validateRuleType(type)); + } + + const existingRule = await readRules({ alertsClient, ruleId, id }); + if (existingRule?.params.type) { + // reject an unauthorized modification of an ML rule + throwHttpError(await mlAuthz.validateRuleType(existingRule?.params.type)); + } + const ruleStatusClient = ruleStatusSavedObjectsClientFactory(savedObjectsClient); const rule = await patchRules({ alertsClient, @@ -95,8 +102,7 @@ export const patchRulesRoute = (router: IRouter) => { timelineTitle, meta, filters, - id, - ruleId, + rule: existingRule, index, interval, maxSignals, diff --git a/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_bulk_route.test.ts b/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_bulk_route.test.ts index 332a47d0c0fc2..e48c72ce9579e 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_bulk_route.test.ts +++ b/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_bulk_route.test.ts @@ -4,6 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ +import { DETECTION_ENGINE_RULES_URL } from '../../../../../common/constants'; +import { mlServicesMock, mlAuthzMock as mockMlAuthzFactory } from '../../../machine_learning/mocks'; +import { buildMlAuthz } from '../../../machine_learning/authz'; import { getEmptyFindResult, getResult, @@ -16,12 +19,14 @@ import { import { serverMock, requestContextMock, requestMock } from '../__mocks__'; import { updateRulesBulkRoute } from './update_rules_bulk_route'; import { BulkError } from '../utils'; -import { DETECTION_ENGINE_RULES_URL } from '../../../../../common/constants'; import { setFeatureFlagsForTestsOnly, unSetFeatureFlagsForTestsOnly } from '../../feature_flags'; +jest.mock('../../../machine_learning/authz', () => mockMlAuthzFactory.create()); + describe('update_rules_bulk', () => { let server: ReturnType; let { clients, context } = requestContextMock.createTools(); + let ml: ReturnType; beforeAll(() => { setFeatureFlagsForTestsOnly(); @@ -34,12 +39,13 @@ describe('update_rules_bulk', () => { beforeEach(() => { server = serverMock.create(); ({ clients, context } = requestContextMock.createTools()); + ml = mlServicesMock.create(); clients.alertsClient.find.mockResolvedValue(getFindResultWithSingleHit()); clients.alertsClient.update.mockResolvedValue(getResult()); clients.savedObjectsClient.find.mockResolvedValue(getFindResultStatus()); - updateRulesBulkRoute(server.router); + updateRulesBulkRoute(server.router, ml); }); describe('status codes with actionClient and alertClient', () => { @@ -92,8 +98,12 @@ describe('update_rules_bulk', () => { expect(response.body).toEqual(expected); }); - it('returns an error object if creating an ML rule with an insufficient license', async () => { - (context.licensing.license.hasAtLeast as jest.Mock).mockReturnValue(false); + it('returns a 403 error object if mlAuthz fails', async () => { + (buildMlAuthz as jest.Mock).mockReturnValueOnce({ + validateRuleType: jest + .fn() + .mockResolvedValue({ valid: false, message: 'mocked validation message' }), + }); const request = requestMock.create({ method: 'put', path: `${DETECTION_ENGINE_RULES_URL}/_bulk_update`, @@ -105,8 +115,8 @@ describe('update_rules_bulk', () => { expect(response.body).toEqual([ { error: { - message: 'Your license does not support machine learning. Please upgrade your license.', - status_code: 400, + message: 'mocked validation message', + status_code: 403, }, rule_id: 'rule-1', }, diff --git a/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_bulk_route.ts b/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_bulk_route.ts index 6d8f2243787e8..11892898d214b 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_bulk_route.ts +++ b/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_bulk_route.ts @@ -6,22 +6,20 @@ import { IRouter } from '../../../../../../../../src/core/server'; import { DETECTION_ENGINE_RULES_URL } from '../../../../../common/constants'; +import { SetupPlugins } from '../../../../plugin'; +import { buildMlAuthz } from '../../../machine_learning/authz'; +import { throwHttpError } from '../../../machine_learning/validation'; import { UpdateRuleAlertParamsRest } from '../../rules/types'; import { getIdBulkError } from './utils'; import { transformValidateBulkError, validate } from './validate'; -import { - buildRouteValidation, - transformBulkError, - buildSiemResponse, - validateLicenseForRuleType, -} from '../utils'; +import { buildRouteValidation, transformBulkError, buildSiemResponse } from '../utils'; import { updateRulesBulkSchema } from '../schemas/update_rules_bulk_schema'; import { updateRules } from '../../rules/update_rules'; import { rulesBulkSchema } from '../schemas/response/rules_bulk_schema'; import { updateRulesNotifications } from '../../rules/update_rules_notifications'; import { ruleStatusSavedObjectsClientFactory } from '../../signals/rule_status_saved_objects_client'; -export const updateRulesBulkRoute = (router: IRouter) => { +export const updateRulesBulkRoute = (router: IRouter, ml: SetupPlugins['ml']) => { router.put( { path: `${DETECTION_ENGINE_RULES_URL}/_bulk_update`, @@ -43,6 +41,7 @@ export const updateRulesBulkRoute = (router: IRouter) => { return siemResponse.error({ statusCode: 404 }); } + const mlAuthz = buildMlAuthz({ license: context.licensing.license, ml, request }); const ruleStatusClient = ruleStatusSavedObjectsClientFactory(savedObjectsClient); const rules = await Promise.all( request.body.map(async payloadRule => { @@ -83,7 +82,7 @@ export const updateRulesBulkRoute = (router: IRouter) => { const finalIndex = outputIndex ?? siemClient.getSignalsIndex(); const idOrRuleIdOrUnknown = id ?? ruleId ?? '(unknown id)'; try { - validateLicenseForRuleType({ license: context.licensing.license, ruleType: type }); + throwHttpError(await mlAuthz.validateRuleType(type)); const rule = await updateRules({ alertsClient, diff --git a/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_route.test.ts b/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_route.test.ts index 53c52153e84e6..ce25a0204a606 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_route.test.ts +++ b/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_route.test.ts @@ -4,7 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import { updateRulesRoute } from './update_rules_route'; +import { mlServicesMock, mlAuthzMock as mockMlAuthzFactory } from '../../../machine_learning/mocks'; +import { buildMlAuthz } from '../../../machine_learning/authz'; import { getEmptyFindResult, getResult, @@ -19,11 +20,15 @@ import { requestContextMock, serverMock, requestMock } from '../__mocks__'; import { DETECTION_ENGINE_RULES_URL } from '../../../../../common/constants'; import { setFeatureFlagsForTestsOnly, unSetFeatureFlagsForTestsOnly } from '../../feature_flags'; import { updateRulesNotifications } from '../../rules/update_rules_notifications'; +import { updateRulesRoute } from './update_rules_route'; + +jest.mock('../../../machine_learning/authz', () => mockMlAuthzFactory.create()); jest.mock('../../rules/update_rules_notifications'); describe('update_rules', () => { let server: ReturnType; let { clients, context } = requestContextMock.createTools(); + let ml: ReturnType; beforeAll(() => { setFeatureFlagsForTestsOnly(); @@ -36,13 +41,14 @@ describe('update_rules', () => { beforeEach(() => { server = serverMock.create(); ({ clients, context } = requestContextMock.createTools()); + ml = mlServicesMock.create(); clients.alertsClient.get.mockResolvedValue(getResult()); // existing rule clients.alertsClient.find.mockResolvedValue(getFindResultWithSingleHit()); // rule exists clients.alertsClient.update.mockResolvedValue(getResult()); // successful update clients.savedObjectsClient.find.mockResolvedValue(getFindResultStatusEmpty()); // successful transform - updateRulesRoute(server.router); + updateRulesRoute(server.router, ml); }); describe('status codes with actionClient and alertClient', () => { @@ -106,8 +112,12 @@ describe('update_rules', () => { }); }); - it('rejects the request if licensing is not adequate', async () => { - (context.licensing.license.hasAtLeast as jest.Mock).mockReturnValue(false); + it('returns a 403 if mlAuthz fails', async () => { + (buildMlAuthz as jest.Mock).mockReturnValueOnce({ + validateRuleType: jest + .fn() + .mockResolvedValue({ valid: false, message: 'mocked validation message' }), + }); const request = requestMock.create({ method: 'put', path: DETECTION_ENGINE_RULES_URL, @@ -115,10 +125,10 @@ describe('update_rules', () => { }); const response = await server.inject(request, context); - expect(response.status).toEqual(400); + expect(response.status).toEqual(403); expect(response.body).toEqual({ - message: 'Your license does not support machine learning. Please upgrade your license.', - status_code: 400, + message: 'mocked validation message', + status_code: 403, }); }); }); diff --git a/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_route.ts b/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_route.ts index bfbeef8be2fea..f15154a09657d 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_route.ts +++ b/x-pack/plugins/siem/server/lib/detection_engine/routes/rules/update_rules_route.ts @@ -6,21 +6,19 @@ import { IRouter } from '../../../../../../../../src/core/server'; import { DETECTION_ENGINE_RULES_URL } from '../../../../../common/constants'; +import { SetupPlugins } from '../../../../plugin'; +import { buildMlAuthz } from '../../../machine_learning/authz'; +import { throwHttpError } from '../../../machine_learning/validation'; import { UpdateRuleAlertParamsRest } from '../../rules/types'; import { updateRulesSchema } from '../schemas/update_rules_schema'; -import { - buildRouteValidation, - transformError, - buildSiemResponse, - validateLicenseForRuleType, -} from '../utils'; +import { buildRouteValidation, transformError, buildSiemResponse } from '../utils'; import { getIdError } from './utils'; import { transformValidate } from './validate'; import { updateRules } from '../../rules/update_rules'; import { updateRulesNotifications } from '../../rules/update_rules_notifications'; import { ruleStatusSavedObjectsClientFactory } from '../../signals/rule_status_saved_objects_client'; -export const updateRulesRoute = (router: IRouter) => { +export const updateRulesRoute = (router: IRouter, ml: SetupPlugins['ml']) => { router.put( { path: DETECTION_ENGINE_RULES_URL, @@ -69,8 +67,6 @@ export const updateRulesRoute = (router: IRouter) => { const siemResponse = buildSiemResponse(response); try { - validateLicenseForRuleType({ license: context.licensing.license, ruleType: type }); - const alertsClient = context.alerting?.getAlertsClient(); const savedObjectsClient = context.core.savedObjects.client; const siemClient = context.siem?.getSiemClient(); @@ -80,6 +76,9 @@ export const updateRulesRoute = (router: IRouter) => { return siemResponse.error({ statusCode: 404 }); } + const mlAuthz = buildMlAuthz({ license: context.licensing.license, ml, request }); + throwHttpError(await mlAuthz.validateRuleType(type)); + const finalIndex = outputIndex ?? siemClient.getSignalsIndex(); const rule = await updateRules({ alertsClient, diff --git a/x-pack/plugins/siem/server/lib/detection_engine/routes/schemas/response/check_type_dependents.ts b/x-pack/plugins/siem/server/lib/detection_engine/routes/schemas/response/check_type_dependents.ts index 25e76f367037a..1c1bee58f0c97 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/routes/schemas/response/check_type_dependents.ts +++ b/x-pack/plugins/siem/server/lib/detection_engine/routes/schemas/response/check_type_dependents.ts @@ -8,7 +8,7 @@ import * as t from 'io-ts'; import { Either, left, fold } from 'fp-ts/lib/Either'; import { pipe } from 'fp-ts/lib/pipeable'; -import { isMlRule } from '../../../../../../common/detection_engine/ml_helpers'; +import { isMlRule } from '../../../../../../common/machine_learning/helpers'; import { dependentRulesSchema, RequiredRulesSchema, diff --git a/x-pack/plugins/siem/server/lib/detection_engine/routes/utils.test.ts b/x-pack/plugins/siem/server/lib/detection_engine/routes/utils.test.ts index 8af5df6056913..fdb1cd148c7fa 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/routes/utils.test.ts +++ b/x-pack/plugins/siem/server/lib/detection_engine/routes/utils.test.ts @@ -19,11 +19,9 @@ import { transformImportError, convertToSnakeCase, SiemResponseFactory, - validateLicenseForRuleType, } from './utils'; import { responseMock } from './__mocks__'; import { setFeatureFlagsForTestsOnly, unSetFeatureFlagsForTestsOnly } from '../feature_flags'; -import { licensingMock } from '../../../../../licensing/server/mocks'; describe('utils', () => { beforeAll(() => { @@ -361,36 +359,4 @@ describe('utils', () => { ); }); }); - - describe('validateLicenseForRuleType', () => { - let licenseMock: ReturnType; - - beforeEach(() => { - licenseMock = licensingMock.createLicenseMock(); - }); - - it('throws a BadRequestError if operating on an ML Rule with an insufficient license', () => { - licenseMock.hasAtLeast.mockReturnValue(false); - - expect(() => - validateLicenseForRuleType({ license: licenseMock, ruleType: 'machine_learning' }) - ).toThrowError(BadRequestError); - }); - - it('does not throw if operating on an ML Rule with a sufficient license', () => { - licenseMock.hasAtLeast.mockReturnValue(true); - - expect(() => - validateLicenseForRuleType({ license: licenseMock, ruleType: 'machine_learning' }) - ).not.toThrowError(BadRequestError); - }); - - it('does not throw if operating on a query rule', () => { - licenseMock.hasAtLeast.mockReturnValue(false); - - expect(() => - validateLicenseForRuleType({ license: licenseMock, ruleType: 'query' }) - ).not.toThrowError(BadRequestError); - }); - }); }); diff --git a/x-pack/plugins/siem/server/lib/detection_engine/routes/utils.ts b/x-pack/plugins/siem/server/lib/detection_engine/routes/utils.ts index 52493a9be9b8f..9903840b99c6f 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/routes/utils.ts +++ b/x-pack/plugins/siem/server/lib/detection_engine/routes/utils.ts @@ -7,17 +7,12 @@ import Boom from 'boom'; import Joi from 'joi'; import { has, snakeCase } from 'lodash/fp'; -import { i18n } from '@kbn/i18n'; import { RouteValidationFunction, KibanaResponseFactory, CustomHttpResponseOptions, } from '../../../../../../../src/core/server'; -import { ILicense } from '../../../../../licensing/server'; -import { MINIMUM_ML_LICENSE } from '../../../../common/constants'; -import { RuleType } from '../../../../common/detection_engine/types'; -import { isMlRule } from '../../../../common/detection_engine/ml_helpers'; import { BadRequestError } from '../errors/bad_request_error'; export interface OutputError { @@ -294,28 +289,3 @@ export const convertToSnakeCase = >( return { ...acc, [newKey]: obj[item] }; }, {}); }; - -/** - * Checks the current Kibana License against the rule under operation. - * - * @param license ILicense representing the user license - * @param ruleType the type of the current rule - * - * @throws BadRequestError if rule and license are incompatible - */ -export const validateLicenseForRuleType = ({ - license, - ruleType, -}: { - license: ILicense; - ruleType: RuleType; -}): void => { - if (isMlRule(ruleType) && !license.hasAtLeast(MINIMUM_ML_LICENSE)) { - const message = i18n.translate('xpack.siem.licensing.unsupportedMachineLearningMessage', { - defaultMessage: - 'Your license does not support machine learning. Please upgrade your license.', - }); - - throw new BadRequestError(message); - } -}; diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/patch_rules.test.ts b/x-pack/plugins/siem/server/lib/detection_engine/rules/patch_rules.test.ts index c551eb164ee07..a42500223012e 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/patch_rules.test.ts +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/patch_rules.test.ts @@ -19,14 +19,14 @@ describe('patchRules', () => { }); it('should call alertsClient.disable is the rule was enabled and enabled is false', async () => { - const rule = getResult(); - alertsClient.get.mockResolvedValue(getResult()); + const existingRule = getResult(); + const params = getResult().params; await patchRules({ alertsClient, savedObjectsClient, - id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', - ...rule.params, + rule: existingRule, + ...params, enabled: false, interval: '', name: '', @@ -35,23 +35,23 @@ describe('patchRules', () => { expect(alertsClient.disable).toHaveBeenCalledWith( expect.objectContaining({ - id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + id: existingRule.id, }) ); }); it('should call alertsClient.enable is the rule was disabled and enabled is true', async () => { - const rule = getResult(); - alertsClient.get.mockResolvedValue({ + const existingRule = { ...getResult(), enabled: false, - }); + }; + const params = getResult().params; await patchRules({ alertsClient, savedObjectsClient, - id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', - ...rule.params, + rule: existingRule, + ...params, enabled: true, interval: '', name: '', @@ -60,13 +60,13 @@ describe('patchRules', () => { expect(alertsClient.enable).toHaveBeenCalledWith( expect.objectContaining({ - id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + id: existingRule.id, }) ); }); it('calls the alertsClient with ML params', async () => { - alertsClient.get.mockResolvedValue(getMlResult()); + const existingRule = getMlResult(); const params = { ...getMlResult().params, anomalyThreshold: 55, @@ -76,7 +76,7 @@ describe('patchRules', () => { await patchRules({ alertsClient, savedObjectsClient, - id: '04128c15-0d1b-4716-a4c5-46997ac7f3bd', + rule: existingRule, ...params, }); diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/patch_rules.ts b/x-pack/plugins/siem/server/lib/detection_engine/rules/patch_rules.ts index da5e90ec14b0b..6dfb72532afbb 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/patch_rules.ts +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/patch_rules.ts @@ -6,7 +6,6 @@ import { defaults } from 'lodash/fp'; import { PartialAlert } from '../../../../../alerting/server'; -import { readRules } from './read_rules'; import { PatchRuleParams } from './types'; import { addTags } from './add_tags'; import { calculateVersion, calculateName, calculateInterval } from './utils'; @@ -28,12 +27,11 @@ export const patchRules = async ({ filters, from, immutable, - id, - ruleId, index, interval, maxSignals, riskScore, + rule, name, severity, tags, @@ -47,7 +45,6 @@ export const patchRules = async ({ anomalyThreshold, machineLearningJobId, }: PatchRuleParams): Promise => { - const rule = await readRules({ alertsClient, ruleId, id }); if (rule == null) { return null; } diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/types.ts b/x-pack/plugins/siem/server/lib/detection_engine/rules/types.ts index b5dbfc92cf528..217a966478e78 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/types.ts +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/types.ts @@ -14,7 +14,7 @@ import { SavedObjectsClientContract, } from 'kibana/server'; import { AlertsClient, PartialAlert } from '../../../../../alerting/server'; -import { Alert } from '../../../../../alerting/common'; +import { Alert, SanitizedAlert } from '../../../../../alerting/common'; import { SIGNALS_ID } from '../../../../common/constants'; import { RuleAlertParams, RuleTypeParams, RuleAlertParamsRest } from '../types'; @@ -140,8 +140,8 @@ export interface Clients { alertsClient: AlertsClient; } -export type PatchRuleParams = Partial> & { - id: string | undefined | null; +export type PatchRuleParams = Partial> & { + rule: SanitizedAlert | null; savedObjectsClient: SavedObjectsClientContract; } & Clients; diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/update_prepacked_rules.test.ts b/x-pack/plugins/siem/server/lib/detection_engine/rules/update_prepacked_rules.test.ts index e8fb4fa96ab51..2d77e9a707f74 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/update_prepacked_rules.test.ts +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/update_prepacked_rules.test.ts @@ -6,7 +6,10 @@ import { savedObjectsClientMock } from '../../../../../../../src/core/server/mocks'; import { alertsClientMock } from '../../../../../alerting/server/mocks'; -import { mockPrepackagedRule } from '../routes/__mocks__/request_responses'; +import { + mockPrepackagedRule, + getFindResultWithSingleHit, +} from '../routes/__mocks__/request_responses'; import { updatePrepackagedRules } from './update_prepacked_rules'; import { patchRules } from './patch_rules'; jest.mock('./patch_rules'); @@ -31,6 +34,7 @@ describe('updatePrepackagedRules', () => { ]; const outputIndex = 'outputIndex'; const prepackagedRule = mockPrepackagedRule(); + alertsClient.find.mockResolvedValue(getFindResultWithSingleHit()); await updatePrepackagedRules( alertsClient, @@ -40,17 +44,8 @@ describe('updatePrepackagedRules', () => { ); expect(patchRules).toHaveBeenCalledWith( - expect.objectContaining({ - ruleId: 'rule-1', - }) - ); - expect(patchRules).not.toHaveBeenCalledWith( - expect.objectContaining({ + expect.not.objectContaining({ enabled: true, - }) - ); - expect(patchRules).not.toHaveBeenCalledWith( - expect.objectContaining({ actions, }) ); diff --git a/x-pack/plugins/siem/server/lib/detection_engine/rules/update_prepacked_rules.ts b/x-pack/plugins/siem/server/lib/detection_engine/rules/update_prepacked_rules.ts index 4c183c51d16ea..618dee26b4812 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/rules/update_prepacked_rules.ts +++ b/x-pack/plugins/siem/server/lib/detection_engine/rules/update_prepacked_rules.ts @@ -8,6 +8,7 @@ import { SavedObjectsClientContract } from 'kibana/server'; import { AlertsClient } from '../../../../../alerting/server'; import { patchRules } from './patch_rules'; import { PrepackagedRules } from '../types'; +import { readRules } from './read_rules'; export const updatePrepackagedRules = async ( alertsClient: AlertsClient, @@ -15,63 +16,66 @@ export const updatePrepackagedRules = async ( rules: PrepackagedRules[], outputIndex: string ): Promise => { - await rules.forEach(async rule => { - const { - description, - false_positives: falsePositives, - from, - immutable, - query, - language, - saved_id: savedId, - meta, - filters, - rule_id: ruleId, - index, - interval, - max_signals: maxSignals, - risk_score: riskScore, - name, - severity, - tags, - to, - type, - threat, - references, - version, - note, - } = rule; + await Promise.all( + rules.map(async rule => { + const { + description, + false_positives: falsePositives, + from, + immutable, + query, + language, + saved_id: savedId, + meta, + filters, + rule_id: ruleId, + index, + interval, + max_signals: maxSignals, + risk_score: riskScore, + name, + severity, + tags, + to, + type, + threat, + references, + version, + note, + } = rule; - // Note: we do not pass down enabled as we do not want to suddenly disable - // or enable rules on the user when they were not expecting it if a rule updates - return patchRules({ - alertsClient, - description, - falsePositives, - from, - immutable, - query, - language, - outputIndex, - id: undefined, // We never have an id when updating from pre-packaged rules - savedId, - savedObjectsClient, - meta, - filters, - ruleId, - index, - interval, - maxSignals, - riskScore, - name, - severity, - tags, - to, - type, - threat, - references, - version, - note, - }); - }); + const existingRule = await readRules({ alertsClient, ruleId, id: undefined }); + + // Note: we do not pass down enabled as we do not want to suddenly disable + // or enable rules on the user when they were not expecting it if a rule updates + return patchRules({ + alertsClient, + description, + falsePositives, + from, + immutable, + query, + language, + outputIndex, + rule: existingRule, + savedId, + savedObjectsClient, + meta, + filters, + index, + interval, + maxSignals, + riskScore, + name, + severity, + tags, + to, + type, + threat, + references, + version, + note, + }); + }) + ); }; diff --git a/x-pack/plugins/siem/server/lib/detection_engine/signals/signal_rule_alert_type.ts b/x-pack/plugins/siem/server/lib/detection_engine/signals/signal_rule_alert_type.ts index ca259b3581720..6160f34faef3f 100644 --- a/x-pack/plugins/siem/server/lib/detection_engine/signals/signal_rule_alert_type.ts +++ b/x-pack/plugins/siem/server/lib/detection_engine/signals/signal_rule_alert_type.ts @@ -8,7 +8,7 @@ import { performance } from 'perf_hooks'; import { Logger, KibanaRequest } from 'src/core/server'; import { SIGNALS_ID, DEFAULT_SEARCH_AFTER_PAGE_SIZE } from '../../../../common/constants'; -import { isJobStarted, isMlRule } from '../../../../common/detection_engine/ml_helpers'; +import { isJobStarted, isMlRule } from '../../../../common/machine_learning/helpers'; import { SetupPlugins } from '../../../plugin'; import { buildEventsSearchQuery } from './build_events_query'; diff --git a/x-pack/plugins/siem/server/lib/machine_learning/authz.test.ts b/x-pack/plugins/siem/server/lib/machine_learning/authz.test.ts new file mode 100644 index 0000000000000..93c3a74c71378 --- /dev/null +++ b/x-pack/plugins/siem/server/lib/machine_learning/authz.test.ts @@ -0,0 +1,265 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { KibanaRequest } from '../../../../../../src/core/server'; +import { httpServerMock } from '../../../../../../src/core/server/mocks'; +import { hasMlAdminPermissions } from '../../../common/machine_learning/has_ml_admin_permissions'; +import { mlServicesMock } from './mocks'; +import { hasMlLicense, isMlAdmin, buildMlAuthz } from './authz'; +import { licensingMock } from '../../../../licensing/server/mocks'; + +jest.mock('../../../common/machine_learning/has_ml_admin_permissions'); + +describe('isMlAdmin', () => { + it('returns true if hasMlAdminPermissions is true', async () => { + const mockMl = mlServicesMock.create(); + const request = httpServerMock.createKibanaRequest(); + (hasMlAdminPermissions as jest.Mock).mockReturnValue(true); + + expect(await isMlAdmin({ ml: mockMl, request })).toEqual(true); + }); + + it('returns false if hasMlAdminPermissions is false', async () => { + const mockMl = mlServicesMock.create(); + const request = httpServerMock.createKibanaRequest(); + (hasMlAdminPermissions as jest.Mock).mockReturnValue(false); + + expect(await isMlAdmin({ ml: mockMl, request })).toEqual(false); + }); +}); + +describe('hasMlLicense', () => { + let licenseMock: ReturnType; + + beforeEach(() => { + licenseMock = licensingMock.createLicenseMock(); + }); + + it('returns false for an insufficient license', () => { + licenseMock.hasAtLeast.mockReturnValue(false); + + expect(hasMlLicense(licenseMock)).toEqual(false); + }); + + it('returns true for a sufficient license', () => { + licenseMock.hasAtLeast.mockReturnValue(true); + + expect(hasMlLicense(licenseMock)).toEqual(true); + }); +}); + +describe('mlAuthz', () => { + let licenseMock: ReturnType; + let mlMock: ReturnType; + let request: KibanaRequest; + + beforeEach(() => { + licenseMock = licensingMock.createLicenseMock(); + mlMock = mlServicesMock.create(); + request = httpServerMock.createKibanaRequest(); + }); + + describe('#validateRuleType', () => { + it('is valid for a non-ML rule when ML plugin is unavailable', async () => { + const mlAuthz = buildMlAuthz({ + license: licenseMock, + ml: undefined, + request, + }); + + const validation = await mlAuthz.validateRuleType('query'); + + expect(validation.valid).toEqual(true); + }); + + it('is invalid for an ML rule when ML plugin is unavailable', async () => { + const mlAuthz = buildMlAuthz({ + license: licenseMock, + ml: undefined, + request, + }); + + const validation = await mlAuthz.validateRuleType('machine_learning'); + + expect(validation.valid).toEqual(false); + expect(validation.message).toEqual( + 'The machine learning plugin is not available. Try enabling the plugin.' + ); + }); + + it('is valid for a non-ML rule when license is insufficient', async () => { + licenseMock.hasAtLeast.mockReturnValue(false); + + const mlAuthz = buildMlAuthz({ + license: licenseMock, + ml: mlMock, + request, + }); + + const validation = await mlAuthz.validateRuleType('query'); + + expect(validation.valid).toEqual(true); + }); + + it('is invalid for an ML rule when license is insufficient', async () => { + licenseMock.hasAtLeast.mockReturnValue(false); + + const mlAuthz = buildMlAuthz({ + license: licenseMock, + ml: mlMock, + request, + }); + + const validation = await mlAuthz.validateRuleType('machine_learning'); + + expect(validation.valid).toEqual(false); + expect(validation.message).toEqual( + 'Your license does not support machine learning. Please upgrade your license.' + ); + }); + + it('is valid for a non-ML rule when not an ML Admin', async () => { + (hasMlAdminPermissions as jest.Mock).mockReturnValue(false); + + const mlAuthz = buildMlAuthz({ + license: licenseMock, + ml: mlMock, + request, + }); + + const validation = await mlAuthz.validateRuleType('query'); + + expect(validation.valid).toEqual(true); + }); + + it('is invalid for an ML rule when not an ML Admin', async () => { + licenseMock.hasAtLeast.mockReturnValue(true); // prevents short-circuit on license check + (hasMlAdminPermissions as jest.Mock).mockReturnValue(false); + + const mlAuthz = buildMlAuthz({ + license: licenseMock, + ml: mlMock, + request, + }); + + const validation = await mlAuthz.validateRuleType('machine_learning'); + + expect(validation.valid).toEqual(false); + expect(validation.message).toEqual( + 'The current user is not a machine learning administrator.' + ); + }); + + it('is valid for an ML rule if ML available, license is sufficient, and an ML Admin', async () => { + licenseMock.hasAtLeast.mockReturnValue(true); + (hasMlAdminPermissions as jest.Mock).mockReturnValue(true); + + const mlAuthz = buildMlAuthz({ + license: licenseMock, + ml: mlMock, + request, + }); + + const validation = await mlAuthz.validateRuleType('machine_learning'); + + expect(validation.valid).toEqual(true); + expect(validation.message).toBeUndefined(); + }); + + it('only calls ml services once for multiple invocations', async () => { + const mockMlCapabilities = jest.fn(); + mlMock.mlSystemProvider.mockImplementation(() => ({ + mlInfo: jest.fn(), + mlSearch: jest.fn(), + mlCapabilities: mockMlCapabilities, + })); + + const mlAuthz = buildMlAuthz({ + license: licenseMock, + ml: mlMock, + request, + }); + + await mlAuthz.validateRuleType('machine_learning'); + await mlAuthz.validateRuleType('machine_learning'); + await mlAuthz.validateRuleType('machine_learning'); + + expect(mockMlCapabilities).toHaveBeenCalledTimes(1); + }); + + it('does not call ml services for non-ML rules', async () => { + const mockMlCapabilities = jest.fn(); + mlMock.mlSystemProvider.mockImplementation(() => ({ + mlInfo: jest.fn(), + mlSearch: jest.fn(), + mlCapabilities: mockMlCapabilities, + })); + + const mlAuthz = buildMlAuthz({ + license: licenseMock, + ml: mlMock, + request, + }); + + await mlAuthz.validateRuleType('query'); + await mlAuthz.validateRuleType('query'); + await mlAuthz.validateRuleType('query'); + + expect(mockMlCapabilities).not.toHaveBeenCalled(); + }); + + it('validates the same cache result per request if permissions change mid-stream', async () => { + licenseMock.hasAtLeast.mockReturnValueOnce(false); + licenseMock.hasAtLeast.mockReturnValueOnce(true); + + const mlAuthz = buildMlAuthz({ + license: licenseMock, + ml: mlMock, + request, + }); + + const validationFirst = await mlAuthz.validateRuleType('machine_learning'); + const validationSecond = await mlAuthz.validateRuleType('machine_learning'); + + expect(validationFirst.valid).toEqual(false); + expect(validationFirst.message).toEqual( + 'Your license does not support machine learning. Please upgrade your license.' + ); + expect(validationSecond.valid).toEqual(false); + expect(validationSecond.message).toEqual( + 'Your license does not support machine learning. Please upgrade your license.' + ); + }); + + it('will invalidate the cache result if the builder is called a second time after a license change', async () => { + licenseMock.hasAtLeast.mockReturnValueOnce(false); + licenseMock.hasAtLeast.mockReturnValueOnce(true); + (hasMlAdminPermissions as jest.Mock).mockReturnValueOnce(true); + + const mlAuthzFirst = buildMlAuthz({ + license: licenseMock, + ml: mlMock, + request, + }); + + const mlAuthzSecond = buildMlAuthz({ + license: licenseMock, + ml: mlMock, + request, + }); + + const validationFirst = await mlAuthzFirst.validateRuleType('machine_learning'); + const validationSecond = await mlAuthzSecond.validateRuleType('machine_learning'); + + expect(validationFirst.valid).toEqual(false); + expect(validationFirst.message).toEqual( + 'Your license does not support machine learning. Please upgrade your license.' + ); + expect(validationSecond.valid).toEqual(true); + expect(validationSecond.message).toBeUndefined(); + }); + }); +}); diff --git a/x-pack/plugins/siem/server/lib/machine_learning/authz.ts b/x-pack/plugins/siem/server/lib/machine_learning/authz.ts new file mode 100644 index 0000000000000..fb74f46244361 --- /dev/null +++ b/x-pack/plugins/siem/server/lib/machine_learning/authz.ts @@ -0,0 +1,120 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +import { KibanaRequest } from '../../../../../../src/core/server/'; +import { ILicense } from '../../../../licensing/server'; +import { MlPluginSetup } from '../../../../ml/server'; +import { SetupPlugins } from '../../plugin'; +import { MINIMUM_ML_LICENSE } from '../../../common/constants'; +import { hasMlAdminPermissions } from '../../../common/machine_learning/has_ml_admin_permissions'; +import { isMlRule } from '../../../common/machine_learning/helpers'; +import { RuleType } from '../../../common/detection_engine/types'; +import { Validation } from './validation'; +import { cache } from './cache'; + +export interface MlAuthz { + validateRuleType: (type: RuleType) => Promise; +} + +/** + * Builds ML authz services + * + * @param license A {@link ILicense} representing the user license + * @param ml {@link MlPluginSetup} ML services to fetch ML capabilities + * @param request A {@link KibanaRequest} representing the authenticated user + * + * @returns A {@link MLAuthz} service object + */ +export const buildMlAuthz = ({ + license, + ml, + request, +}: { + license: ILicense; + ml: SetupPlugins['ml']; + request: KibanaRequest; +}): MlAuthz => { + const cachedValidate = cache(() => validateMlAuthz({ license, ml, request })); + const validateRuleType = async (type: RuleType): Promise => { + if (!isMlRule(type)) { + return { valid: true, message: undefined }; + } else { + return cachedValidate(); + } + }; + + return { validateRuleType }; +}; + +/** + * Validates ML authorization for the current request + * + * @param license A {@link ILicense} representing the user license + * @param ml {@link MlPluginSetup} ML services to fetch ML capabilities + * @param request A {@link KibanaRequest} representing the authenticated user + * + * @returns A {@link Validation} validation + */ +export const validateMlAuthz = async ({ + license, + ml, + request, +}: { + license: ILicense; + ml: SetupPlugins['ml']; + request: KibanaRequest; +}): Promise => { + let message: string | undefined; + + if (ml == null) { + message = i18n.translate('xpack.siem.authz.mlUnavailable', { + defaultMessage: 'The machine learning plugin is not available. Try enabling the plugin.', + }); + } else if (!hasMlLicense(license)) { + message = i18n.translate('xpack.siem.licensing.unsupportedMachineLearningMessage', { + defaultMessage: + 'Your license does not support machine learning. Please upgrade your license.', + }); + } else if (!(await isMlAdmin({ ml, request }))) { + message = i18n.translate('xpack.siem.authz.userIsNotMlAdminMessage', { + defaultMessage: 'The current user is not a machine learning administrator.', + }); + } + + return { + valid: message === undefined, + message, + }; +}; + +/** + * Whether the license allows ML usage + * + * @param license A {@link ILicense} representing the user license + * + */ +export const hasMlLicense = (license: ILicense): boolean => license.hasAtLeast(MINIMUM_ML_LICENSE); + +/** + * Whether the requesting user is an ML Admin + * + * @param request A {@link KibanaRequest} representing the authenticated user + * @param ml {@link MlPluginSetup} ML services to fetch ML capabilities + * + */ +export const isMlAdmin = async ({ + request, + ml, +}: { + request: KibanaRequest; + ml: MlPluginSetup; +}): Promise => { + const scopedMlClient = ml.mlClient.asScoped(request).callAsCurrentUser; + const mlCapabilities = await ml.mlSystemProvider(scopedMlClient, request).mlCapabilities(); + return hasMlAdminPermissions(mlCapabilities); +}; diff --git a/x-pack/plugins/siem/server/lib/machine_learning/cache.test.ts b/x-pack/plugins/siem/server/lib/machine_learning/cache.test.ts new file mode 100644 index 0000000000000..14e4cfe8ebdda --- /dev/null +++ b/x-pack/plugins/siem/server/lib/machine_learning/cache.test.ts @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { cache } from './cache'; + +describe('cache', () => { + it('does not call the function if not invoked', () => { + const fn = jest.fn(); + cache(fn); + + expect(fn).not.toHaveBeenCalled(); + }); + + it('returns the function result', () => { + const fn = jest.fn().mockReturnValue('result'); + const cachedFn = cache(fn); + + expect(cachedFn()).toEqual('result'); + }); + + it('only calls the function once for multiple invocations', () => { + const fn = jest.fn(); + const cachedFn = cache(fn); + + cachedFn(); + cachedFn(); + cachedFn(); + + expect(fn).toHaveBeenCalledTimes(1); + }); + + it('returns the function result on subsequent invocations', () => { + const fn = jest.fn().mockReturnValue('result'); + const cachedFn = cache(fn); + + expect([cachedFn(), cachedFn(), cachedFn()]).toEqual(['result', 'result', 'result']); + }); +}); diff --git a/x-pack/plugins/siem/server/lib/machine_learning/cache.ts b/x-pack/plugins/siem/server/lib/machine_learning/cache.ts new file mode 100644 index 0000000000000..1a7b95f2c5af2 --- /dev/null +++ b/x-pack/plugins/siem/server/lib/machine_learning/cache.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +/** + * Caches the result of a function call + * + * @param fn the function to be invoked + * + * @returns A function that will invoke the given function on its first invocation, + * and then simply return the result on subsequent calls + */ +export const cache = (fn: () => T): (() => T) => { + let result: T | null = null; + + return () => { + if (result === null) { + result = fn(); + } + return result; + }; +}; diff --git a/x-pack/plugins/siem/server/lib/machine_learning/mocks.ts b/x-pack/plugins/siem/server/lib/machine_learning/mocks.ts new file mode 100644 index 0000000000000..f044022d6db69 --- /dev/null +++ b/x-pack/plugins/siem/server/lib/machine_learning/mocks.ts @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { MlPluginSetup } from '../../../../ml/server'; +import { elasticsearchServiceMock } from '../../../../../../src/core/server/mocks'; + +const createMockClient = () => elasticsearchServiceMock.createClusterClient(); +const createMockMlSystemProvider = () => + jest.fn(() => ({ + mlCapabilities: jest.fn(), + })); + +export const mlServicesMock = { + create: () => + (({ + mlSystemProvider: createMockMlSystemProvider(), + mlClient: createMockClient(), + } as unknown) as jest.Mocked), +}; + +const mockValidateRuleType = jest.fn().mockResolvedValue({ valid: true, message: undefined }); +const createBuildMlAuthzMock = () => + jest.fn().mockReturnValue({ validateRuleType: mockValidateRuleType }); + +export const mlAuthzMock = { + create: () => ({ + buildMlAuthz: createBuildMlAuthzMock(), + }), +}; diff --git a/x-pack/plugins/siem/server/lib/machine_learning/validation.test.ts b/x-pack/plugins/siem/server/lib/machine_learning/validation.test.ts new file mode 100644 index 0000000000000..effe59c073c59 --- /dev/null +++ b/x-pack/plugins/siem/server/lib/machine_learning/validation.test.ts @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { toHttpError, throwHttpError } from './validation'; + +describe('toHttpError', () => { + it('returns nothing if validation is valid', () => { + expect(toHttpError({ valid: true, message: undefined })).toBeUndefined(); + }); + + it('returns an HTTP error if validation is invalid', () => { + const error = toHttpError({ valid: false, message: 'validation message' }); + expect(error?.statusCode).toEqual(403); + expect(error?.message).toEqual('validation message'); + }); +}); + +describe('throwHttpError', () => { + it('does nothing if validation is valid', () => { + expect(() => throwHttpError({ valid: true, message: undefined })).not.toThrowError(); + }); + + it('throws an error if validation is invalid', () => { + let error; + try { + throwHttpError({ valid: false, message: 'validation failed' }); + } catch (e) { + error = e; + } + expect(error?.statusCode).toEqual(403); + expect(error?.message).toEqual('validation failed'); + }); +}); diff --git a/x-pack/plugins/siem/server/lib/machine_learning/validation.ts b/x-pack/plugins/siem/server/lib/machine_learning/validation.ts new file mode 100644 index 0000000000000..eab85bbb510be --- /dev/null +++ b/x-pack/plugins/siem/server/lib/machine_learning/validation.ts @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export interface Validation { + valid: boolean; + message: string | undefined; +} + +export class HttpAuthzError extends Error { + public readonly statusCode: number; + + constructor(message: string | undefined) { + super(message); + this.name = 'HttpAuthzError'; + this.statusCode = 403; + } +} + +export const toHttpError = (validation: Validation): HttpAuthzError | undefined => { + if (!validation.valid) { + return new HttpAuthzError(validation.message); + } +}; + +export const throwHttpError = (validation: Validation): void => { + const error = toHttpError(validation); + if (error) { + throw error; + } +}; diff --git a/x-pack/plugins/siem/server/plugin.ts b/x-pack/plugins/siem/server/plugin.ts index 3ef4b39bd0979..d296ee94e8958 100644 --- a/x-pack/plugins/siem/server/plugin.ts +++ b/x-pack/plugins/siem/server/plugin.ts @@ -98,7 +98,8 @@ export class Plugin implements IPlugin { // Detection Engine Rule routes that have the REST endpoints of /api/detection_engine/rules // All REST rule creation, deletion, updating, etc...... - createRulesRoute(router); + createRulesRoute(router, ml); readRulesRoute(router); - updateRulesRoute(router); - patchRulesRoute(router); + updateRulesRoute(router, ml); + patchRulesRoute(router, ml); deleteRulesRoute(router); findRulesRoute(router); addPrepackedRulesRoute(router); getPrepackagedRulesStatusRoute(router); - createRulesBulkRoute(router); - updateRulesBulkRoute(router); - patchRulesBulkRoute(router); + createRulesBulkRoute(router, ml); + updateRulesBulkRoute(router, ml); + patchRulesBulkRoute(router, ml); deleteRulesBulkRoute(router); createTimelinesRoute(router, config, security); updateTimelinesRoute(router, config, security); - importRulesRoute(router, config); + importRulesRoute(router, config, ml); exportRulesRoute(router, config); importTimelinesRoute(router, config, security); diff --git a/x-pack/plugins/uptime/common/constants/settings_defaults.ts b/x-pack/plugins/uptime/common/constants/settings_defaults.ts index 82575e875577b..b9e99a54b3b11 100644 --- a/x-pack/plugins/uptime/common/constants/settings_defaults.ts +++ b/x-pack/plugins/uptime/common/constants/settings_defaults.ts @@ -8,6 +8,6 @@ import { DynamicSettings } from '../runtime_types'; export const DYNAMIC_SETTINGS_DEFAULTS: DynamicSettings = { heartbeatIndices: 'heartbeat-8*', - certAgeThreshold: 365, + certAgeThreshold: 730, certExpirationThreshold: 30, }; diff --git a/x-pack/plugins/uptime/server/lib/alerts/__tests__/status_check.test.ts b/x-pack/plugins/uptime/server/lib/alerts/__tests__/status_check.test.ts index a89e5ff62319d..73d104c1d21ae 100644 --- a/x-pack/plugins/uptime/server/lib/alerts/__tests__/status_check.test.ts +++ b/x-pack/plugins/uptime/server/lib/alerts/__tests__/status_check.test.ts @@ -87,7 +87,7 @@ describe('status check alert', () => { Object { "callES": [MockFunction], "dynamicSettings": Object { - "certAgeThreshold": 365, + "certAgeThreshold": 730, "certExpirationThreshold": 30, "heartbeatIndices": "heartbeat-8*", }, @@ -132,7 +132,7 @@ describe('status check alert', () => { Object { "callES": [MockFunction], "dynamicSettings": Object { - "certAgeThreshold": 365, + "certAgeThreshold": 730, "certExpirationThreshold": 30, "heartbeatIndices": "heartbeat-8*", }, diff --git a/x-pack/test/case_api_integration/basic/tests/cases/comments/delete_comment.ts b/x-pack/test/case_api_integration/basic/tests/cases/comments/delete_comment.ts new file mode 100644 index 0000000000000..afae04ae9cf5b --- /dev/null +++ b/x-pack/test/case_api_integration/basic/tests/cases/comments/delete_comment.ts @@ -0,0 +1,76 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../../common/ftr_provider_context'; + +import { CASES_URL } from '../../../../../../plugins/case/common/constants'; +import { postCaseReq, postCommentReq } from '../../../../common/lib/mock'; +import { deleteCases, deleteCasesUserActions, deleteComments } from '../../../../common/lib/utils'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const supertest = getService('supertest'); + const es = getService('es'); + + describe('delete_comment', () => { + afterEach(async () => { + await deleteCases(es); + await deleteComments(es); + await deleteCasesUserActions(es); + }); + + it('should delete a comment', async () => { + const { body: postedCase } = await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send(postCaseReq) + .expect(200); + + const { body: patchedCase } = await supertest + .post(`${CASES_URL}/${postedCase.id}/comments`) + .set('kbn-xsrf', 'true') + .send(postCommentReq); + + const { body: comment } = await supertest + .delete(`${CASES_URL}/${postedCase.id}/comments/${patchedCase.comments[0].id}`) + .set('kbn-xsrf', 'true') + .send(); + + expect(comment).to.eql({}); + }); + + it('unhappy path - 404s when comment belongs to different case', async () => { + const { body: postedCase } = await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send(postCaseReq) + .expect(200); + + const { body: patchedCase } = await supertest + .post(`${CASES_URL}/${postedCase.id}/comments`) + .set('kbn-xsrf', 'true') + .send(postCommentReq); + + const { body } = await supertest + .delete(`${CASES_URL}/fake-id/comments/${patchedCase.comments[0].id}`) + .set('kbn-xsrf', 'true') + .send() + .expect(404); + expect(body.message).to.eql( + `This comment ${patchedCase.comments[0].id} does not exist in fake-id).` + ); + }); + + it('unhappy path - 404s when comment is not there', async () => { + await supertest + .delete(`${CASES_URL}/fake-id/comments/fake-id`) + .set('kbn-xsrf', 'true') + .send() + .expect(404); + }); + }); +}; diff --git a/x-pack/test/case_api_integration/basic/tests/cases/comments/find_comments.ts b/x-pack/test/case_api_integration/basic/tests/cases/comments/find_comments.ts new file mode 100644 index 0000000000000..e5c44de90b5a1 --- /dev/null +++ b/x-pack/test/case_api_integration/basic/tests/cases/comments/find_comments.ts @@ -0,0 +1,93 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../../common/ftr_provider_context'; + +import { CASES_URL } from '../../../../../../plugins/case/common/constants'; +import { postCaseReq, postCommentReq } from '../../../../common/lib/mock'; +import { deleteCases, deleteCasesUserActions, deleteComments } from '../../../../common/lib/utils'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const supertest = getService('supertest'); + const es = getService('es'); + + describe('find_comments', () => { + afterEach(async () => { + await deleteCases(es); + await deleteComments(es); + await deleteCasesUserActions(es); + }); + + it('should find all case comment', async () => { + const { body: postedCase } = await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send(postCaseReq) + .expect(200); + // post 2 comments + await supertest + .post(`${CASES_URL}/${postedCase.id}/comments`) + .set('kbn-xsrf', 'true') + .send(postCommentReq); + + const { body: patchedCase } = await supertest + .post(`${CASES_URL}/${postedCase.id}/comments`) + .set('kbn-xsrf', 'true') + .send(postCommentReq); + + const { body: caseComments } = await supertest + .get(`${CASES_URL}/${postedCase.id}/comments/_find`) + .set('kbn-xsrf', 'true') + .send(); + + expect(caseComments.comments).to.eql(patchedCase.comments); + }); + + it('should filter case comments', async () => { + const { body: postedCase } = await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send(postCaseReq) + .expect(200); + // post 2 comments + await supertest + .post(`${CASES_URL}/${postedCase.id}/comments`) + .set('kbn-xsrf', 'true') + .send(postCommentReq); + + const { body: patchedCase } = await supertest + .post(`${CASES_URL}/${postedCase.id}/comments`) + .set('kbn-xsrf', 'true') + .send({ comment: 'unique' }); + + const { body: caseComments } = await supertest + .get(`${CASES_URL}/${postedCase.id}/comments/_find?search=unique`) + .set('kbn-xsrf', 'true') + .send(); + + expect(caseComments.comments).to.eql([patchedCase.comments[1]]); + }); + + it('unhappy path - 400s when query is bad', async () => { + const { body: postedCase } = await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send(postCaseReq) + .expect(200); + await supertest + .post(`${CASES_URL}/${postedCase.id}/comments`) + .set('kbn-xsrf', 'true') + .send(postCommentReq); + await supertest + .get(`${CASES_URL}/${postedCase.id}/comments/_find?perPage=true`) + .set('kbn-xsrf', 'true') + .send() + .expect(400); + }); + }); +}; diff --git a/x-pack/test/case_api_integration/basic/tests/cases/comments/get_comment.ts b/x-pack/test/case_api_integration/basic/tests/cases/comments/get_comment.ts new file mode 100644 index 0000000000000..53da0ef1d2b16 --- /dev/null +++ b/x-pack/test/case_api_integration/basic/tests/cases/comments/get_comment.ts @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../../common/ftr_provider_context'; + +import { CASES_URL } from '../../../../../../plugins/case/common/constants'; +import { postCaseReq, postCommentReq } from '../../../../common/lib/mock'; +import { deleteCases, deleteCasesUserActions, deleteComments } from '../../../../common/lib/utils'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const supertest = getService('supertest'); + const es = getService('es'); + + describe('get_comment', () => { + afterEach(async () => { + await deleteCases(es); + await deleteComments(es); + await deleteCasesUserActions(es); + }); + + it('should get a comment', async () => { + const { body: postedCase } = await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send(postCaseReq); + + const { body: patchedCase } = await supertest + .post(`${CASES_URL}/${postedCase.id}/comments`) + .set('kbn-xsrf', 'true') + .send(postCommentReq); + + const { body: comment } = await supertest + .get(`${CASES_URL}/${postedCase.id}/comments/${patchedCase.comments[0].id}`) + .set('kbn-xsrf', 'true') + .send() + .expect(200); + + expect(comment).to.eql(patchedCase.comments[0]); + }); + it('unhappy path - 404s when comment is not there', async () => { + await supertest + .get(`${CASES_URL}/fake-id/comments/fake-comment`) + .set('kbn-xsrf', 'true') + .send() + .expect(404); + }); + }); +}; diff --git a/x-pack/test/case_api_integration/basic/tests/cases/comments/patch_comment.ts b/x-pack/test/case_api_integration/basic/tests/cases/comments/patch_comment.ts new file mode 100644 index 0000000000000..73aeeb0fb989a --- /dev/null +++ b/x-pack/test/case_api_integration/basic/tests/cases/comments/patch_comment.ts @@ -0,0 +1,123 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../../common/ftr_provider_context'; + +import { CASES_URL } from '../../../../../../plugins/case/common/constants'; +import { defaultUser, postCaseReq, postCommentReq } from '../../../../common/lib/mock'; +import { deleteCases, deleteCasesUserActions, deleteComments } from '../../../../common/lib/utils'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const supertest = getService('supertest'); + const es = getService('es'); + + describe('patch_comment', () => { + afterEach(async () => { + await deleteCases(es); + await deleteComments(es); + await deleteCasesUserActions(es); + }); + + it('should patch a comment', async () => { + const { body: postedCase } = await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send(postCaseReq) + .expect(200); + + const { body: patchedCase } = await supertest + .post(`${CASES_URL}/${postedCase.id}/comments`) + .set('kbn-xsrf', 'true') + .send(postCommentReq); + const newComment = 'Well I decided to update my comment. So what? Deal with it.'; + const { body } = await supertest + .patch(`${CASES_URL}/${postedCase.id}/comments`) + .set('kbn-xsrf', 'true') + .send({ + id: patchedCase.comments[0].id, + version: patchedCase.comments[0].version, + comment: newComment, + }); + expect(body.comments[0].comment).to.eql(newComment); + expect(body.updated_by).to.eql(defaultUser); + }); + + it('unhappy path - 404s when comment is not there', async () => { + const { body: postedCase } = await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send(postCaseReq); + await supertest + .patch(`${CASES_URL}/${postedCase.id}/comments`) + .set('kbn-xsrf', 'true') + .send({ + id: 'id', + version: 'version', + comment: 'comment', + }) + .expect(404); + }); + + it('unhappy path - 404s when case is not there', async () => { + await supertest + .patch(`${CASES_URL}/fake-id/comments`) + .set('kbn-xsrf', 'true') + .send({ + id: 'id', + version: 'version', + comment: 'comment', + }) + .expect(404); + }); + + it('unhappy path - 400s when patch body is bad', async () => { + const { body: postedCase } = await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send(postCaseReq) + .expect(200); + + const { body: patchedCase } = await supertest + .post(`${CASES_URL}/${postedCase.id}/comments`) + .set('kbn-xsrf', 'true') + .send(postCommentReq); + await supertest + .patch(`${CASES_URL}/${postedCase.id}/comments`) + .set('kbn-xsrf', 'true') + .send({ + id: patchedCase.comments[0].id, + version: patchedCase.comments[0].version, + comment: true, + }) + .expect(400); + }); + + it('unhappy path - 409s when conflict', async () => { + const { body: postedCase } = await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send(postCaseReq) + .expect(200); + + const { body: patchedCase } = await supertest + .post(`${CASES_URL}/${postedCase.id}/comments`) + .set('kbn-xsrf', 'true') + .send(postCommentReq); + const newComment = 'Well I decided to update my comment. So what? Deal with it.'; + await supertest + .patch(`${CASES_URL}/${postedCase.id}/comments`) + .set('kbn-xsrf', 'true') + .send({ + id: patchedCase.comments[0].id, + version: 'version-mismatch', + comment: newComment, + }) + .expect(409); + }); + }); +}; diff --git a/x-pack/test/case_api_integration/basic/tests/cases/comments/post_comment.ts b/x-pack/test/case_api_integration/basic/tests/cases/comments/post_comment.ts new file mode 100644 index 0000000000000..6e8353f8ea86a --- /dev/null +++ b/x-pack/test/case_api_integration/basic/tests/cases/comments/post_comment.ts @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../../common/ftr_provider_context'; + +import { CASES_URL } from '../../../../../../plugins/case/common/constants'; +import { defaultUser, postCaseReq, postCommentReq } from '../../../../common/lib/mock'; +import { deleteCases, deleteCasesUserActions, deleteComments } from '../../../../common/lib/utils'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const supertest = getService('supertest'); + const es = getService('es'); + + describe('post_comment', () => { + afterEach(async () => { + await deleteCases(es); + await deleteComments(es); + await deleteCasesUserActions(es); + }); + + it('should post a comment', async () => { + const { body: postedCase } = await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send(postCaseReq) + .expect(200); + + const { body: patchedCase } = await supertest + .post(`${CASES_URL}/${postedCase.id}/comments`) + .set('kbn-xsrf', 'true') + .send(postCommentReq); + expect(patchedCase.comments[0].comment).to.eql(postCommentReq.comment); + expect(patchedCase.updated_by).to.eql(defaultUser); + }); + + it('unhappy path - 400s when post body is bad', async () => { + const { body: postedCase } = await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send(postCaseReq) + .expect(200); + + await supertest + .post(`${CASES_URL}/${postedCase.id}/comments`) + .set('kbn-xsrf', 'true') + .send({ + bad: 'comment', + }) + .expect(400); + }); + }); +}; diff --git a/x-pack/test/case_api_integration/basic/tests/cases/delete_cases.ts b/x-pack/test/case_api_integration/basic/tests/cases/delete_cases.ts new file mode 100644 index 0000000000000..aa2465e44c5c1 --- /dev/null +++ b/x-pack/test/case_api_integration/basic/tests/cases/delete_cases.ts @@ -0,0 +1,76 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../common/ftr_provider_context'; + +import { CASES_URL } from '../../../../../plugins/case/common/constants'; +import { postCaseReq, postCommentReq } from '../../../common/lib/mock'; +import { deleteCases, deleteCasesUserActions, deleteComments } from '../../../common/lib/utils'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const supertest = getService('supertest'); + const es = getService('es'); + + describe('delete_cases', () => { + afterEach(async () => { + await deleteCases(es); + await deleteComments(es); + await deleteCasesUserActions(es); + }); + + it('should delete a case', async () => { + const { body: postedCase } = await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send(postCaseReq); + + const { body } = await supertest + .delete(`${CASES_URL}?ids=["${postedCase.id}"]`) + .set('kbn-xsrf', 'true') + .send() + .expect(204); + + expect(body).to.eql({}); + }); + + it(`should delete a case's comments when that case gets deleted`, async () => { + const { body: postedCase } = await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send(postCaseReq); + + const { body: patchedCase } = await supertest + .post(`${CASES_URL}/${postedCase.id}/comments`) + .set('kbn-xsrf', 'true') + .send(postCommentReq); + + await supertest + .get(`${CASES_URL}/${postedCase.id}/comments/${patchedCase.comments[0].id}`) + .set('kbn-xsrf', 'true') + .send() + .expect(200); + await supertest + .delete(`${CASES_URL}?ids=["${postedCase.id}"]`) + .set('kbn-xsrf', 'true') + .send() + .expect(204); + await supertest + .get(`${CASES_URL}/${postedCase.id}/comments/${patchedCase.comments[0].id}`) + .set('kbn-xsrf', 'true') + .send() + .expect(404); + }); + it('unhappy path - 404s when case is not there', async () => { + await supertest + .delete(`${CASES_URL}?ids=["fake-id"]`) + .set('kbn-xsrf', 'true') + .send() + .expect(404); + }); + }); +}; diff --git a/x-pack/test/case_api_integration/basic/tests/cases/find_cases.ts b/x-pack/test/case_api_integration/basic/tests/cases/find_cases.ts new file mode 100644 index 0000000000000..04d195ea73509 --- /dev/null +++ b/x-pack/test/case_api_integration/basic/tests/cases/find_cases.ts @@ -0,0 +1,158 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../common/ftr_provider_context'; + +import { CASES_URL } from '../../../../../plugins/case/common/constants'; +import { postCaseReq, postCommentReq, findCasesResp } from '../../../common/lib/mock'; +import { deleteCases, deleteComments, deleteCasesUserActions } from '../../../common/lib/utils'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const supertest = getService('supertest'); + const es = getService('es'); + describe('find_cases', () => { + afterEach(async () => { + await deleteCases(es); + await deleteComments(es); + await deleteCasesUserActions(es); + }); + it('should return empty response', async () => { + const { body } = await supertest + .get(`${CASES_URL}/_find`) + .set('kbn-xsrf', 'true') + .send() + .expect(200); + + expect(body).to.eql(findCasesResp); + }); + + it('should return cases', async () => { + const { body: a } = await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send(postCaseReq); + const { body: b } = await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send(postCaseReq); + const { body: c } = await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send(postCaseReq); + const { body } = await supertest + .get(`${CASES_URL}/_find?sortOrder=asc`) + .set('kbn-xsrf', 'true') + .send() + .expect(200); + + expect(body).to.eql({ + ...findCasesResp, + total: 3, + cases: [a, b, c], + count_open_cases: 3, + }); + }); + + it('filters by tags', async () => { + await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send(postCaseReq); + const { body: postedCase } = await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send({ ...postCaseReq, tags: ['unique'] }); + const { body } = await supertest + .get(`${CASES_URL}/_find?sortOrder=asc&tags=unique`) + .set('kbn-xsrf', 'true') + .send() + .expect(200); + + expect(body).to.eql({ + ...findCasesResp, + total: 1, + cases: [postedCase], + count_open_cases: 1, + }); + }); + + it('correctly counts comments', async () => { + const { body: postedCase } = await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send(postCaseReq); + + // post 2 comments + await supertest + .post(`${CASES_URL}/${postedCase.id}/comments`) + .set('kbn-xsrf', 'true') + .send(postCommentReq); + const { body: patchedCase } = await supertest + .post(`${CASES_URL}/${postedCase.id}/comments`) + .set('kbn-xsrf', 'true') + .send(postCommentReq); + const { body } = await supertest + .get(`${CASES_URL}/_find?sortOrder=asc`) + .set('kbn-xsrf', 'true') + .send() + .expect(200); + + expect(body).to.eql({ + ...findCasesResp, + total: 1, + cases: [ + { + ...patchedCase, + comments: [], + totalComment: 2, + }, + ], + count_open_cases: 1, + }); + }); + + it('correctly counts open/closed', async () => { + await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send(postCaseReq); + const { body: postedCase } = await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send(postCaseReq); + await supertest + .patch(CASES_URL) + .set('kbn-xsrf', 'true') + .send({ + cases: [ + { + id: postedCase.id, + version: postedCase.version, + status: 'closed', + }, + ], + }) + .expect(200); + const { body } = await supertest + .get(`${CASES_URL}/_find?sortOrder=asc`) + .set('kbn-xsrf', 'true') + .send() + .expect(200); + + expect(body.count_open_cases).to.eql(1); + expect(body.count_closed_cases).to.eql(1); + }); + it('unhappy path - 400s when bad query supplied', async () => { + await supertest + .get(`${CASES_URL}/_find?perPage=true`) + .set('kbn-xsrf', 'true') + .send() + .expect(400); + }); + }); +}; diff --git a/x-pack/test/case_api_integration/basic/tests/cases/get_case.ts b/x-pack/test/case_api_integration/basic/tests/cases/get_case.ts new file mode 100644 index 0000000000000..9aad86126ceaf --- /dev/null +++ b/x-pack/test/case_api_integration/basic/tests/cases/get_case.ts @@ -0,0 +1,52 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../common/ftr_provider_context'; + +import { CASES_URL } from '../../../../../plugins/case/common/constants'; +import { + postCaseReq, + postCaseResp, + removeServerGeneratedPropertiesFromCase, +} from '../../../common/lib/mock'; +import { deleteCases } from '../../../common/lib/utils'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const supertest = getService('supertest'); + const es = getService('es'); + + describe('get_case', () => { + afterEach(async () => { + await deleteCases(es); + }); + + it('should return a case', async () => { + const { body: postedCase } = await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send(postCaseReq) + .expect(200); + + const { body } = await supertest + .get(`${CASES_URL}/${postedCase.id}`) + .set('kbn-xsrf', 'true') + .send() + .expect(200); + + const data = removeServerGeneratedPropertiesFromCase(body); + expect(data).to.eql(postCaseResp(postedCase.id)); + }); + it('unhappy path - 404s when case is not there', async () => { + await supertest + .get(`${CASES_URL}/fake-id`) + .set('kbn-xsrf', 'true') + .send() + .expect(404); + }); + }); +}; diff --git a/x-pack/test/case_api_integration/basic/tests/cases/patch_cases.ts b/x-pack/test/case_api_integration/basic/tests/cases/patch_cases.ts new file mode 100644 index 0000000000000..caeaf46cbc953 --- /dev/null +++ b/x-pack/test/case_api_integration/basic/tests/cases/patch_cases.ts @@ -0,0 +1,139 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../common/ftr_provider_context'; + +import { CASES_URL } from '../../../../../plugins/case/common/constants'; +import { + defaultUser, + postCaseReq, + postCaseResp, + removeServerGeneratedPropertiesFromCase, +} from '../../../common/lib/mock'; +import { deleteCases, deleteCasesUserActions } from '../../../common/lib/utils'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const supertest = getService('supertest'); + const es = getService('es'); + + describe('patch_cases', () => { + afterEach(async () => { + await deleteCases(es); + await deleteCasesUserActions(es); + }); + + it('should patch a case', async () => { + const { body: postedCase } = await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send(postCaseReq) + .expect(200); + const { body: patchedCases } = await supertest + .patch(CASES_URL) + .set('kbn-xsrf', 'true') + .send({ + cases: [ + { + id: postedCase.id, + version: postedCase.version, + status: 'closed', + }, + ], + }) + .expect(200); + + const data = removeServerGeneratedPropertiesFromCase(patchedCases[0]); + expect(data).to.eql({ + ...postCaseResp(postedCase.id), + closed_by: defaultUser, + status: 'closed', + updated_by: defaultUser, + }); + }); + + it('unhappy path - 404s when case is not there', async () => { + await supertest + .patch(CASES_URL) + .set('kbn-xsrf', 'true') + .send({ + cases: [ + { + id: 'not-real', + version: 'version', + status: 'closed', + }, + ], + }) + .expect(404); + }); + + it('unhappy path - 406s when excess data sent', async () => { + const { body: postedCase } = await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send(postCaseReq) + .expect(200); + await supertest + .patch(CASES_URL) + .set('kbn-xsrf', 'true') + .send({ + cases: [ + { + id: postedCase.id, + version: postedCase.version, + badKey: 'closed', + }, + ], + }) + .expect(406); + }); + + it('unhappy path - 400s when bad data sent', async () => { + const { body: postedCase } = await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send(postCaseReq) + .expect(200); + await supertest + .patch(CASES_URL) + .set('kbn-xsrf', 'true') + .send({ + cases: [ + { + id: postedCase.id, + version: postedCase.version, + status: true, + }, + ], + }) + .expect(400); + }); + + it('unhappy path - 409s when conflict', async () => { + const { body: postedCase } = await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send(postCaseReq) + .expect(200); + + await supertest + .patch(`${CASES_URL}`) + .set('kbn-xsrf', 'true') + .send({ + cases: [ + { + id: postedCase.id, + version: 'version', + status: 'closed', + }, + ], + }) + .expect(409); + }); + }); +}; diff --git a/x-pack/test/case_api_integration/basic/tests/cases/post_case.ts b/x-pack/test/case_api_integration/basic/tests/cases/post_case.ts new file mode 100644 index 0000000000000..ab668c2c32725 --- /dev/null +++ b/x-pack/test/case_api_integration/basic/tests/cases/post_case.ts @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../common/ftr_provider_context'; + +import { CASES_URL } from '../../../../../plugins/case/common/constants'; +import { + postCaseReq, + postCaseResp, + removeServerGeneratedPropertiesFromCase, +} from '../../../common/lib/mock'; +import { deleteCases } from '../../../common/lib/utils'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const supertest = getService('supertest'); + const es = getService('es'); + + describe('post_case', () => { + afterEach(async () => { + await deleteCases(es); + }); + + it('should post a case', async () => { + const { body: postedCase } = await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send(postCaseReq) + .expect(200); + + const data = removeServerGeneratedPropertiesFromCase(postedCase); + expect(data).to.eql(postCaseResp(postedCase.id)); + }); + it('unhappy path - 400s when bad query supplied', async () => { + await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send({ ...postCaseReq, badKey: true }) + .expect(400); + }); + }); +}; diff --git a/x-pack/test/case_api_integration/basic/tests/cases/push_case.ts b/x-pack/test/case_api_integration/basic/tests/cases/push_case.ts new file mode 100644 index 0000000000000..848b980dee769 --- /dev/null +++ b/x-pack/test/case_api_integration/basic/tests/cases/push_case.ts @@ -0,0 +1,161 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../common/ftr_provider_context'; + +import { CASE_CONFIGURE_URL, CASES_URL } from '../../../../../plugins/case/common/constants'; +import { postCaseReq, defaultUser, postCommentReq } from '../../../common/lib/mock'; +import { + deleteCases, + deleteCasesUserActions, + deleteComments, + deleteConfiguration, + getConfiguration, +} from '../../../common/lib/utils'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const supertest = getService('supertest'); + const es = getService('es'); + + describe('push_case', () => { + afterEach(async () => { + await deleteCases(es); + await deleteComments(es); + await deleteConfiguration(es); + await deleteCasesUserActions(es); + }); + + it('should push a case', async () => { + const { body: configure } = await supertest + .post(CASE_CONFIGURE_URL) + .set('kbn-xsrf', 'true') + .send(getConfiguration()) + .expect(200); + const { body: postedCase } = await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send(postCaseReq) + .expect(200); + + const { body } = await supertest + .post(`${CASES_URL}/${postedCase.id}/_push`) + .set('kbn-xsrf', 'true') + .send({ + connector_id: configure.connector_id, + connector_name: configure.connector_name, + external_id: 'external_id', + external_title: 'external_title', + external_url: 'external_url', + }) + .expect(200); + expect(body.connector_id).to.eql(configure.connector_id); + expect(body.external_service.pushed_by).to.eql(defaultUser); + }); + + it('pushes a comment appropriately', async () => { + const { body: configure } = await supertest + .post(CASE_CONFIGURE_URL) + .set('kbn-xsrf', 'true') + .send(getConfiguration()) + .expect(200); + const { body: postedCase } = await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send(postCaseReq) + .expect(200); + + await supertest + .post(`${CASES_URL}/${postedCase.id}/_push`) + .set('kbn-xsrf', 'true') + .send({ + connector_id: configure.connector_id, + connector_name: configure.connector_name, + external_id: 'external_id', + external_title: 'external_title', + external_url: 'external_url', + }) + .expect(200); + + await supertest + .post(`${CASES_URL}/${postedCase.id}/comments`) + .set('kbn-xsrf', 'true') + .send(postCommentReq); + + const { body } = await supertest + .post(`${CASES_URL}/${postedCase.id}/_push`) + .set('kbn-xsrf', 'true') + .send({ + connector_id: configure.connector_id, + connector_name: configure.connector_name, + external_id: 'external_id', + external_title: 'external_title', + external_url: 'external_url', + }) + .expect(200); + expect(body.comments[0].pushed_by).to.eql(defaultUser); + }); + it('unhappy path - 404s when case does not exist', async () => { + await supertest + .post(`${CASES_URL}/fake-id/_push`) + .set('kbn-xsrf', 'true') + .send({ + connector_id: 'connector_id', + connector_name: 'connector_name', + external_id: 'external_id', + external_title: 'external_title', + external_url: 'external_url', + }) + .expect(404); + }); + it('unhappy path - 400s when bad data supplied', async () => { + await supertest + .post(`${CASES_URL}/fake-id/_push`) + .set('kbn-xsrf', 'true') + .send({ + badKey: 'connector_id', + }) + .expect(400); + }); + it('unhappy path = 409s when case is closed', async () => { + const { body: configure } = await supertest + .post(CASE_CONFIGURE_URL) + .set('kbn-xsrf', 'true') + .send(getConfiguration()) + .expect(200); + const { body: postedCase } = await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send(postCaseReq) + .expect(200); + await supertest + .patch(CASES_URL) + .set('kbn-xsrf', 'true') + .send({ + cases: [ + { + id: postedCase.id, + version: postedCase.version, + status: 'closed', + }, + ], + }) + .expect(200); + await supertest + .post(`${CASES_URL}/${postedCase.id}/_push`) + .set('kbn-xsrf', 'true') + .send({ + connector_id: configure.connector_id, + connector_name: configure.connector_name, + external_id: 'external_id', + external_title: 'external_title', + external_url: 'external_url', + }) + .expect(409); + }); + }); +}; diff --git a/x-pack/test/case_api_integration/basic/tests/cases/reporters/get_reporters.ts b/x-pack/test/case_api_integration/basic/tests/cases/reporters/get_reporters.ts new file mode 100644 index 0000000000000..a781b928b2b68 --- /dev/null +++ b/x-pack/test/case_api_integration/basic/tests/cases/reporters/get_reporters.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../../common/ftr_provider_context'; + +import { CASES_URL, CASE_REPORTERS_URL } from '../../../../../../plugins/case/common/constants'; +import { defaultUser, postCaseReq } from '../../../../common/lib/mock'; +import { deleteCases } from '../../../../common/lib/utils'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const supertest = getService('supertest'); + const es = getService('es'); + + describe('get_reporters', () => { + afterEach(async () => { + await deleteCases(es); + }); + + it('should return reporters', async () => { + await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send(postCaseReq) + .expect(200); + + const { body } = await supertest + .get(CASE_REPORTERS_URL) + .set('kbn-xsrf', 'true') + .send() + .expect(200); + + expect(body).to.eql([defaultUser]); + }); + }); +}; diff --git a/x-pack/test/case_api_integration/basic/tests/cases/status/get_status.ts b/x-pack/test/case_api_integration/basic/tests/cases/status/get_status.ts new file mode 100644 index 0000000000000..6552f588bdc19 --- /dev/null +++ b/x-pack/test/case_api_integration/basic/tests/cases/status/get_status.ts @@ -0,0 +1,59 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../../common/ftr_provider_context'; + +import { CASES_URL, CASE_STATUS_URL } from '../../../../../../plugins/case/common/constants'; +import { postCaseReq } from '../../../../common/lib/mock'; +import { deleteCases } from '../../../../common/lib/utils'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const supertest = getService('supertest'); + const es = getService('es'); + + describe('get_status', () => { + afterEach(async () => { + await deleteCases(es); + }); + + it('should return case statuses', async () => { + await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send(postCaseReq); + const { body: postedCase } = await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send(postCaseReq); + await supertest + .patch(CASES_URL) + .set('kbn-xsrf', 'true') + .send({ + cases: [ + { + id: postedCase.id, + version: postedCase.version, + status: 'closed', + }, + ], + }) + .expect(200); + + const { body } = await supertest + .get(CASE_STATUS_URL) + .set('kbn-xsrf', 'true') + .send() + .expect(200); + + expect(body).to.eql({ + count_open_cases: 1, + count_closed_cases: 1, + }); + }); + }); +}; diff --git a/x-pack/test/case_api_integration/basic/tests/cases/tags/get_tags.ts b/x-pack/test/case_api_integration/basic/tests/cases/tags/get_tags.ts new file mode 100644 index 0000000000000..9b769e3c5eef4 --- /dev/null +++ b/x-pack/test/case_api_integration/basic/tests/cases/tags/get_tags.ts @@ -0,0 +1,43 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../../common/ftr_provider_context'; + +import { CASES_URL, CASE_TAGS_URL } from '../../../../../../plugins/case/common/constants'; +import { postCaseReq } from '../../../../common/lib/mock'; +import { deleteCases } from '../../../../common/lib/utils'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const supertest = getService('supertest'); + const es = getService('es'); + + describe('get_tags', () => { + afterEach(async () => { + await deleteCases(es); + }); + + it('should return case tags', async () => { + await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send(postCaseReq); + await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send({ ...postCaseReq, tags: ['unique'] }); + + const { body } = await supertest + .get(CASE_TAGS_URL) + .set('kbn-xsrf', 'true') + .send() + .expect(200); + + expect(body).to.eql(['defacement', 'unique']); + }); + }); +}; diff --git a/x-pack/test/case_api_integration/basic/tests/cases/user_actions/get_all_user_actions.ts b/x-pack/test/case_api_integration/basic/tests/cases/user_actions/get_all_user_actions.ts new file mode 100644 index 0000000000000..6bbd43eef1439 --- /dev/null +++ b/x-pack/test/case_api_integration/basic/tests/cases/user_actions/get_all_user_actions.ts @@ -0,0 +1,307 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../../../../common/ftr_provider_context'; + +import { CASE_CONFIGURE_URL, CASES_URL } from '../../../../../../plugins/case/common/constants'; +import { defaultUser, postCaseReq, postCommentReq } from '../../../../common/lib/mock'; +import { + deleteCases, + deleteCasesUserActions, + deleteComments, + deleteConfiguration, + getConfiguration, +} from '../../../../common/lib/utils'; + +// eslint-disable-next-line import/no-default-export +export default ({ getService }: FtrProviderContext): void => { + const supertest = getService('supertest'); + const es = getService('es'); + + describe('get_all_user_actions', () => { + afterEach(async () => { + await deleteCases(es); + await deleteComments(es); + await deleteConfiguration(es); + await deleteCasesUserActions(es); + }); + + it(`on new case, user action: 'create' should be called with actionFields: ['description', 'status', 'tags', 'title']`, async () => { + const { body: postedCase } = await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send(postCaseReq); + + const { body } = await supertest + .get(`${CASES_URL}/${postedCase.id}/user_actions`) + .set('kbn-xsrf', 'true') + .send() + .expect(200); + expect(body.length).to.eql(1); + + expect(body[0].action_field).to.eql(['description', 'status', 'tags', 'title']); + expect(body[0].action).to.eql('create'); + expect(body[0].old_value).to.eql(null); + expect(body[0].new_value).to.eql(JSON.stringify(postCaseReq)); + }); + + it(`on close case, user action: 'update' should be called with actionFields: ['status']`, async () => { + const { body: postedCase } = await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send(postCaseReq); + await supertest + .patch(CASES_URL) + .set('kbn-xsrf', 'true') + .send({ + cases: [ + { + id: postedCase.id, + version: postedCase.version, + status: 'closed', + }, + ], + }) + .expect(200); + + const { body } = await supertest + .get(`${CASES_URL}/${postedCase.id}/user_actions`) + .set('kbn-xsrf', 'true') + .send() + .expect(200); + expect(body.length).to.eql(2); + expect(body[1].action_field).to.eql(['status']); + expect(body[1].action).to.eql('update'); + expect(body[1].old_value).to.eql('open'); + expect(body[1].new_value).to.eql('closed'); + }); + + it(`on update case connector, user action: 'update' should be called with actionFields: ['connector_id']`, async () => { + const { body: postedCase } = await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send(postCaseReq); + const newConnectorId = '12345'; + await supertest + .patch(CASES_URL) + .set('kbn-xsrf', 'true') + .send({ + cases: [ + { + id: postedCase.id, + version: postedCase.version, + connector_id: newConnectorId, + }, + ], + }) + .expect(200); + + const { body } = await supertest + .get(`${CASES_URL}/${postedCase.id}/user_actions`) + .set('kbn-xsrf', 'true') + .send() + .expect(200); + expect(body.length).to.eql(2); + expect(body[1].action_field).to.eql(['connector_id']); + expect(body[1].action).to.eql('update'); + expect(body[1].old_value).to.eql('none'); + expect(body[1].new_value).to.eql(newConnectorId); + }); + + it(`on update tags, user action: 'add' and 'delete' should be called with actionFields: ['tags']`, async () => { + const { body: postedCase } = await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send(postCaseReq); + await supertest + .patch(CASES_URL) + .set('kbn-xsrf', 'true') + .send({ + cases: [ + { + id: postedCase.id, + version: postedCase.version, + tags: ['cool', 'neat'], + }, + ], + }) + .expect(200); + + const { body } = await supertest + .get(`${CASES_URL}/${postedCase.id}/user_actions`) + .set('kbn-xsrf', 'true') + .send() + .expect(200); + expect(body.length).to.eql(3); + expect(body[1].action_field).to.eql(['tags']); + expect(body[1].action).to.eql('add'); + expect(body[1].old_value).to.eql(null); + expect(body[1].new_value).to.eql('cool, neat'); + expect(body[2].action_field).to.eql(['tags']); + expect(body[2].action).to.eql('delete'); + expect(body[2].old_value).to.eql(null); + expect(body[2].new_value).to.eql('defacement'); + }); + + it(`on update title, user action: 'update' should be called with actionFields: ['title']`, async () => { + const { body: postedCase } = await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send(postCaseReq); + const newTitle = 'Such a great title'; + await supertest + .patch(CASES_URL) + .set('kbn-xsrf', 'true') + .send({ + cases: [ + { + id: postedCase.id, + version: postedCase.version, + title: newTitle, + }, + ], + }) + .expect(200); + + const { body } = await supertest + .get(`${CASES_URL}/${postedCase.id}/user_actions`) + .set('kbn-xsrf', 'true') + .send() + .expect(200); + expect(body.length).to.eql(2); + expect(body[1].action_field).to.eql(['title']); + expect(body[1].action).to.eql('update'); + expect(body[1].old_value).to.eql(postCaseReq.title); + expect(body[1].new_value).to.eql(newTitle); + }); + + it(`on update description, user action: 'update' should be called with actionFields: ['description']`, async () => { + const { body: postedCase } = await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send(postCaseReq); + const newDesc = 'Such a great description'; + await supertest + .patch(CASES_URL) + .set('kbn-xsrf', 'true') + .send({ + cases: [ + { + id: postedCase.id, + version: postedCase.version, + description: newDesc, + }, + ], + }) + .expect(200); + + const { body } = await supertest + .get(`${CASES_URL}/${postedCase.id}/user_actions`) + .set('kbn-xsrf', 'true') + .send() + .expect(200); + expect(body.length).to.eql(2); + expect(body[1].action_field).to.eql(['description']); + expect(body[1].action).to.eql('update'); + expect(body[1].old_value).to.eql(postCaseReq.description); + expect(body[1].new_value).to.eql(newDesc); + }); + + it(`on new comment, user action: 'create' should be called with actionFields: ['comments']`, async () => { + const { body: postedCase } = await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send(postCaseReq); + await supertest + .post(`${CASES_URL}/${postedCase.id}/comments`) + .set('kbn-xsrf', 'true') + .send(postCommentReq); + + const { body } = await supertest + .get(`${CASES_URL}/${postedCase.id}/user_actions`) + .set('kbn-xsrf', 'true') + .send() + .expect(200); + expect(body.length).to.eql(2); + + expect(body[1].action_field).to.eql(['comment']); + expect(body[1].action).to.eql('create'); + expect(body[1].old_value).to.eql(null); + expect(body[1].new_value).to.eql(postCommentReq.comment); + }); + + it(`on update comment, user action: 'update' should be called with actionFields: ['comments']`, async () => { + const { body: postedCase } = await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send(postCaseReq); + const { body: patchedCase } = await supertest + .post(`${CASES_URL}/${postedCase.id}/comments`) + .set('kbn-xsrf', 'true') + .send(postCommentReq); + const newComment = 'Well I decided to update my comment. So what? Deal with it.'; + await supertest + .patch(`${CASES_URL}/${postedCase.id}/comments`) + .set('kbn-xsrf', 'true') + .send({ + id: patchedCase.comments[0].id, + version: patchedCase.comments[0].version, + comment: newComment, + }); + + const { body } = await supertest + .get(`${CASES_URL}/${postedCase.id}/user_actions`) + .set('kbn-xsrf', 'true') + .send() + .expect(200); + expect(body.length).to.eql(3); + + expect(body[2].action_field).to.eql(['comment']); + expect(body[2].action).to.eql('update'); + expect(body[2].old_value).to.eql(postCommentReq.comment); + expect(body[2].new_value).to.eql(newComment); + }); + + it(`on new push to service, user action: 'push-to-service' should be called with actionFields: ['pushed']`, async () => { + const { body: configure } = await supertest + .post(CASE_CONFIGURE_URL) + .set('kbn-xsrf', 'true') + .send(getConfiguration()) + .expect(200); + const { body: postedCase } = await supertest + .post(CASES_URL) + .set('kbn-xsrf', 'true') + .send(postCaseReq); + + await supertest + .post(`${CASES_URL}/${postedCase.id}/_push`) + .set('kbn-xsrf', 'true') + .send({ + connector_id: configure.connector_id, + connector_name: configure.connector_name, + external_id: 'external_id', + external_title: 'external_title', + external_url: 'external_url', + }) + .expect(200); + + const { body } = await supertest + .get(`${CASES_URL}/${postedCase.id}/user_actions`) + .set('kbn-xsrf', 'true') + .send() + .expect(200); + expect(body.length).to.eql(2); + + expect(body[1].action_field).to.eql(['pushed']); + expect(body[1].action).to.eql('push-to-service'); + expect(body[1].old_value).to.eql(null); + const newValue = JSON.parse(body[1].new_value); + expect(newValue.connector_id).to.eql(configure.connector_id); + expect(newValue.pushed_by).to.eql(defaultUser); + }); + }); +}; diff --git a/x-pack/test/case_api_integration/basic/tests/index.ts b/x-pack/test/case_api_integration/basic/tests/index.ts index efd5369c019d8..b152a97a28616 100644 --- a/x-pack/test/case_api_integration/basic/tests/index.ts +++ b/x-pack/test/case_api_integration/basic/tests/index.ts @@ -12,9 +12,24 @@ export default ({ loadTestFile }: FtrProviderContext): void => { // Fastest ciGroup for the moment. this.tags('ciGroup2'); + loadTestFile(require.resolve('./cases/comments/delete_comment')); + loadTestFile(require.resolve('./cases/comments/find_comments')); + loadTestFile(require.resolve('./cases/comments/get_comment')); + loadTestFile(require.resolve('./cases/comments/patch_comment')); + loadTestFile(require.resolve('./cases/comments/post_comment')); + loadTestFile(require.resolve('./cases/delete_cases')); + loadTestFile(require.resolve('./cases/find_cases')); + loadTestFile(require.resolve('./cases/get_case')); + loadTestFile(require.resolve('./cases/patch_cases')); + loadTestFile(require.resolve('./cases/post_case')); + loadTestFile(require.resolve('./cases/push_case')); + loadTestFile(require.resolve('./cases/reporters/get_reporters')); + loadTestFile(require.resolve('./cases/status/get_status')); + loadTestFile(require.resolve('./cases/tags/get_tags')); + loadTestFile(require.resolve('./cases/user_actions/get_all_user_actions')); loadTestFile(require.resolve('./configure/get_configure')); - loadTestFile(require.resolve('./configure/post_configure')); - loadTestFile(require.resolve('./configure/patch_configure')); loadTestFile(require.resolve('./configure/get_connectors')); + loadTestFile(require.resolve('./configure/patch_configure')); + loadTestFile(require.resolve('./configure/post_configure')); }); }; diff --git a/x-pack/test/case_api_integration/common/lib/mock.ts b/x-pack/test/case_api_integration/common/lib/mock.ts new file mode 100644 index 0000000000000..728eaf88617e9 --- /dev/null +++ b/x-pack/test/case_api_integration/common/lib/mock.ts @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + CasePostRequest, + CaseResponse, + CasesFindResponse, +} from '../../../../plugins/case/common/api'; +export const defaultUser = { email: null, full_name: null, username: 'elastic' }; +export const postCaseReq: CasePostRequest = { + description: 'This is a brand new case of a bad meanie defacing data', + title: 'Super Bad Security Issue', + tags: ['defacement'], +}; + +export const postCommentReq: { comment: string } = { + comment: 'This is a cool comment', +}; + +export const postCaseResp = (id: string): Partial => ({ + ...postCaseReq, + id, + comments: [], + totalComment: 0, + connector_id: 'none', + closed_by: null, + created_by: defaultUser, + external_service: null, + status: 'open', + updated_by: null, +}); + +export const removeServerGeneratedPropertiesFromCase = ( + config: Partial +): Partial => { + const { closed_at, created_at, updated_at, version, ...rest } = config; + return rest; +}; + +export const findCasesResp: CasesFindResponse = { + page: 1, + per_page: 20, + total: 0, + cases: [], + count_open_cases: 0, + count_closed_cases: 0, +}; diff --git a/x-pack/test/case_api_integration/common/lib/utils.ts b/x-pack/test/case_api_integration/common/lib/utils.ts index df768ff09b368..4b1dc6ffa5891 100644 --- a/x-pack/test/case_api_integration/common/lib/utils.ts +++ b/x-pack/test/case_api_integration/common/lib/utils.ts @@ -30,6 +30,36 @@ export const removeServerGeneratedPropertiesFromConfigure = ( return rest; }; +export const deleteCasesUserActions = async (es: Client): Promise => { + await es.deleteByQuery({ + index: '.kibana', + q: 'type:cases-user-actions', + wait_for_completion: true, + refresh: true, + body: {}, + }); +}; + +export const deleteCases = async (es: Client): Promise => { + await es.deleteByQuery({ + index: '.kibana', + q: 'type:cases', + wait_for_completion: true, + refresh: true, + body: {}, + }); +}; + +export const deleteComments = async (es: Client): Promise => { + await es.deleteByQuery({ + index: '.kibana', + q: 'type:cases-comments', + wait_for_completion: true, + refresh: true, + body: {}, + }); +}; + export const deleteConfiguration = async (es: Client): Promise => { await es.deleteByQuery({ index: '.kibana', @@ -39,34 +69,3 @@ export const deleteConfiguration = async (es: Client): Promise => { body: {}, }); }; - -export const getConnector = () => ({ - name: 'ServiceNow Connector', - actionTypeId: '.servicenow', - secrets: { - username: 'admin', - password: 'admin', - }, - config: { - apiUrl: 'localhost', - casesConfiguration: { - mapping: [ - { - source: 'title', - target: 'short_description', - actionType: 'overwrite', - }, - { - source: 'description', - target: 'description', - actionType: 'overwrite', - }, - { - source: 'comments', - target: 'comments', - actionType: 'append', - }, - ], - }, - }, -}); diff --git a/x-pack/test/functional/apps/dashboard/reporting/download_csv.ts b/x-pack/test/functional/apps/dashboard/reporting/download_csv.ts new file mode 100644 index 0000000000000..b66f9d2baeb36 --- /dev/null +++ b/x-pack/test/functional/apps/dashboard/reporting/download_csv.ts @@ -0,0 +1,68 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { REPO_ROOT } from '@kbn/dev-utils'; +import expect from '@kbn/expect'; +import fs from 'fs'; +import path from 'path'; +import * as Rx from 'rxjs'; +import { filter, first, map, timeout } from 'rxjs/operators'; +import { FtrProviderContext } from '../../../ftr_provider_context'; + +const csvPath = path.resolve(REPO_ROOT, 'target/functional-tests/downloads/Ecommerce Data.csv'); + +export default function({ getService, getPageObjects }: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const browser = getService('browser'); + const dashboardPanelActions = getService('dashboardPanelActions'); + const log = getService('log'); + const testSubjects = getService('testSubjects'); + const PageObjects = getPageObjects(['reporting', 'common', 'dashboard']); + + describe('Reporting Download CSV', () => { + before('initialize tests', async () => { + log.debug('ReportingPage:initTests'); + await esArchiver.loadIfNeeded('reporting/ecommerce'); + await esArchiver.loadIfNeeded('reporting/ecommerce_kibana'); + await browser.setWindowSize(1600, 850); + }); + + after('clean up archives and previous file download', async () => { + await esArchiver.unload('reporting/ecommerce'); + await esArchiver.unload('reporting/ecommerce_kibana'); + try { + fs.unlinkSync(csvPath); + } catch (e) { + // nothing to worry + } + }); + + it('Downloads a CSV export of a saved search panel', async function() { + await PageObjects.common.navigateToApp('dashboard'); + await PageObjects.dashboard.loadSavedDashboard('Ecom Dashboard'); + const savedSearchPanel = await testSubjects.find('embeddablePanelHeading-EcommerceData'); + await dashboardPanelActions.toggleContextMenu(savedSearchPanel); + + await testSubjects.existOrFail('embeddablePanelAction-downloadCsvReport'); // wait for the full panel to display or else the test runner could click the wrong option! + await testSubjects.click('embeddablePanelAction-downloadCsvReport'); + await testSubjects.existOrFail('csvDownloadStarted'); // validate toast panel + + // check every 100ms for the file to exist in the download dir + // just wait up to 5 seconds + const success$ = Rx.interval(100).pipe( + map(() => fs.existsSync(csvPath)), + filter(value => value === true), + first(), + timeout(5000) + ); + + const fileExists = await success$.toPromise(); + expect(fileExists).to.be(true); + + // no need to validate download contents, API Integration tests do that some different variations + }); + }); +} diff --git a/x-pack/test/functional/apps/dashboard/reporting/index.ts b/x-pack/test/functional/apps/dashboard/reporting/index.ts index 796e15b4e270f..1dc2a958e3dd5 100644 --- a/x-pack/test/functional/apps/dashboard/reporting/index.ts +++ b/x-pack/test/functional/apps/dashboard/reporting/index.ts @@ -3,125 +3,11 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ - -import expect from '@kbn/expect'; -import fs from 'fs'; -import path from 'path'; -import { promisify } from 'util'; import { FtrProviderContext } from '../../../ftr_provider_context'; -import { checkIfPngsMatch } from './lib/compare_pngs'; - -const writeFileAsync = promisify(fs.writeFile); -const mkdirAsync = promisify(fs.mkdir); - -const REPORTS_FOLDER = path.resolve(__dirname, 'reports'); - -export default function({ getService, getPageObjects }: FtrProviderContext) { - const esArchiver = getService('esArchiver'); - const browser = getService('browser'); - const log = getService('log'); - const config = getService('config'); - const PageObjects = getPageObjects(['reporting', 'common', 'dashboard']); - - describe('Reporting', () => { - before('initialize tests', async () => { - log.debug('ReportingPage:initTests'); - await esArchiver.loadIfNeeded('reporting/ecommerce'); - await esArchiver.loadIfNeeded('reporting/ecommerce_kibana'); - await browser.setWindowSize(1600, 850); - }); - after('clean up archives', async () => { - await esArchiver.unload('reporting/ecommerce'); - await esArchiver.unload('reporting/ecommerce_kibana'); - }); - - describe('Print PDF button', () => { - it('is not available if new', async () => { - await PageObjects.common.navigateToApp('dashboard'); - await PageObjects.dashboard.clickNewDashboard(); - await PageObjects.reporting.openPdfReportingPanel(); - expect(await PageObjects.reporting.isGenerateReportButtonDisabled()).to.be('true'); - }); - - it('becomes available when saved', async () => { - await PageObjects.dashboard.saveDashboard('My PDF Dashboard'); - await PageObjects.reporting.openPdfReportingPanel(); - expect(await PageObjects.reporting.isGenerateReportButtonDisabled()).to.be(null); - }); - }); - - describe('Print Layout', () => { - it('downloads a PDF file', async function() { - // Generating and then comparing reports can take longer than the default 60s timeout because the comparePngs - // function is taking about 15 seconds per comparison in jenkins. - this.timeout(300000); - await PageObjects.common.navigateToApp('dashboard'); - await PageObjects.dashboard.loadSavedDashboard('Ecom Dashboard'); - await PageObjects.reporting.openPdfReportingPanel(); - await PageObjects.reporting.checkUsePrintLayout(); - await PageObjects.reporting.clickGenerateReportButton(); - - const url = await PageObjects.reporting.getReportURL(60000); - const res = await PageObjects.reporting.getResponse(url); - - expect(res.statusCode).to.equal(200); - expect(res.headers['content-type']).to.equal('application/pdf'); - }); - }); - - describe('Print PNG button', () => { - it('is not available if new', async () => { - await PageObjects.common.navigateToApp('dashboard'); - await PageObjects.dashboard.clickNewDashboard(); - await PageObjects.reporting.openPngReportingPanel(); - expect(await PageObjects.reporting.isGenerateReportButtonDisabled()).to.be('true'); - }); - - it('becomes available when saved', async () => { - await PageObjects.dashboard.saveDashboard('My PNG Dash'); - await PageObjects.reporting.openPngReportingPanel(); - expect(await PageObjects.reporting.isGenerateReportButtonDisabled()).to.be(null); - }); - }); - - describe('Preserve Layout', () => { - it('matches baseline report', async function() { - const writeSessionReport = async (name: string, rawPdf: Buffer, reportExt: string) => { - const sessionDirectory = path.resolve(REPORTS_FOLDER, 'session'); - await mkdirAsync(sessionDirectory, { recursive: true }); - const sessionReportPath = path.resolve(sessionDirectory, `${name}.${reportExt}`); - await writeFileAsync(sessionReportPath, rawPdf); - return sessionReportPath; - }; - const getBaselineReportPath = (fileName: string, reportExt: string) => { - const baselineFolder = path.resolve(REPORTS_FOLDER, 'baseline'); - const fullPath = path.resolve(baselineFolder, `${fileName}.${reportExt}`); - log.debug(`getBaselineReportPath (${fullPath})`); - return fullPath; - }; - - this.timeout(300000); - - await PageObjects.common.navigateToApp('dashboard'); - await PageObjects.dashboard.loadSavedDashboard('Ecom Dashboard'); - await PageObjects.reporting.openPngReportingPanel(); - await PageObjects.reporting.forceSharedItemsContainerSize({ width: 1405 }); - await PageObjects.reporting.clickGenerateReportButton(); - await PageObjects.reporting.removeForceSharedItemsContainerSize(); - - const url = await PageObjects.reporting.getReportURL(60000); - const reportData = await PageObjects.reporting.getRawPdfReportData(url); - const reportFileName = 'dashboard_preserve_layout'; - const sessionReportPath = await writeSessionReport(reportFileName, reportData, 'png'); - const percentSimilar = await checkIfPngsMatch( - sessionReportPath, - getBaselineReportPath(reportFileName, 'png'), - config.get('screenshots.directory'), - log - ); - expect(percentSimilar).to.be.lessThan(0.1); - }); - }); +export default function({ loadTestFile }: FtrProviderContext) { + describe('Reporting', function() { + loadTestFile(require.resolve('./screenshots')); + loadTestFile(require.resolve('./download_csv')); }); } diff --git a/x-pack/test/functional/apps/dashboard/reporting/screenshots.ts b/x-pack/test/functional/apps/dashboard/reporting/screenshots.ts new file mode 100644 index 0000000000000..2cc1686b8c7ca --- /dev/null +++ b/x-pack/test/functional/apps/dashboard/reporting/screenshots.ts @@ -0,0 +1,127 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import fs from 'fs'; +import path from 'path'; +import { promisify } from 'util'; +import { FtrProviderContext } from '../../../ftr_provider_context'; +import { checkIfPngsMatch } from './lib/compare_pngs'; + +const writeFileAsync = promisify(fs.writeFile); +const mkdirAsync = promisify(fs.mkdir); + +const REPORTS_FOLDER = path.resolve(__dirname, 'reports'); + +export default function({ getService, getPageObjects }: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const browser = getService('browser'); + const log = getService('log'); + const config = getService('config'); + const PageObjects = getPageObjects(['reporting', 'common', 'dashboard']); + + describe('Screenshots', () => { + before('initialize tests', async () => { + log.debug('ReportingPage:initTests'); + await esArchiver.loadIfNeeded('reporting/ecommerce'); + await esArchiver.loadIfNeeded('reporting/ecommerce_kibana'); + await browser.setWindowSize(1600, 850); + }); + after('clean up archives', async () => { + await esArchiver.unload('reporting/ecommerce'); + await esArchiver.unload('reporting/ecommerce_kibana'); + }); + + describe('Print PDF button', () => { + it('is not available if new', async () => { + await PageObjects.common.navigateToApp('dashboard'); + await PageObjects.dashboard.clickNewDashboard(); + await PageObjects.reporting.openPdfReportingPanel(); + expect(await PageObjects.reporting.isGenerateReportButtonDisabled()).to.be('true'); + }); + + it('becomes available when saved', async () => { + await PageObjects.dashboard.saveDashboard('My PDF Dashboard'); + await PageObjects.reporting.openPdfReportingPanel(); + expect(await PageObjects.reporting.isGenerateReportButtonDisabled()).to.be(null); + }); + }); + + describe('Print Layout', () => { + it('downloads a PDF file', async function() { + // Generating and then comparing reports can take longer than the default 60s timeout because the comparePngs + // function is taking about 15 seconds per comparison in jenkins. + this.timeout(300000); + await PageObjects.common.navigateToApp('dashboard'); + await PageObjects.dashboard.loadSavedDashboard('Ecom Dashboard'); + await PageObjects.reporting.openPdfReportingPanel(); + await PageObjects.reporting.checkUsePrintLayout(); + await PageObjects.reporting.clickGenerateReportButton(); + + const url = await PageObjects.reporting.getReportURL(60000); + const res = await PageObjects.reporting.getResponse(url); + + expect(res.statusCode).to.equal(200); + expect(res.headers['content-type']).to.equal('application/pdf'); + }); + }); + + describe('Print PNG button', () => { + it('is not available if new', async () => { + await PageObjects.common.navigateToApp('dashboard'); + await PageObjects.dashboard.clickNewDashboard(); + await PageObjects.reporting.openPngReportingPanel(); + expect(await PageObjects.reporting.isGenerateReportButtonDisabled()).to.be('true'); + }); + + it('becomes available when saved', async () => { + await PageObjects.dashboard.saveDashboard('My PNG Dash'); + await PageObjects.reporting.openPngReportingPanel(); + expect(await PageObjects.reporting.isGenerateReportButtonDisabled()).to.be(null); + }); + }); + + describe('Preserve Layout', () => { + it('matches baseline report', async function() { + const writeSessionReport = async (name: string, rawPdf: Buffer, reportExt: string) => { + const sessionDirectory = path.resolve(REPORTS_FOLDER, 'session'); + await mkdirAsync(sessionDirectory, { recursive: true }); + const sessionReportPath = path.resolve(sessionDirectory, `${name}.${reportExt}`); + await writeFileAsync(sessionReportPath, rawPdf); + return sessionReportPath; + }; + const getBaselineReportPath = (fileName: string, reportExt: string) => { + const baselineFolder = path.resolve(REPORTS_FOLDER, 'baseline'); + const fullPath = path.resolve(baselineFolder, `${fileName}.${reportExt}`); + log.debug(`getBaselineReportPath (${fullPath})`); + return fullPath; + }; + + this.timeout(300000); + + await PageObjects.common.navigateToApp('dashboard'); + await PageObjects.dashboard.loadSavedDashboard('Ecom Dashboard'); + await PageObjects.reporting.openPngReportingPanel(); + await PageObjects.reporting.forceSharedItemsContainerSize({ width: 1405 }); + await PageObjects.reporting.clickGenerateReportButton(); + await PageObjects.reporting.removeForceSharedItemsContainerSize(); + + const url = await PageObjects.reporting.getReportURL(60000); + const reportData = await PageObjects.reporting.getRawPdfReportData(url); + const reportFileName = 'dashboard_preserve_layout'; + const sessionReportPath = await writeSessionReport(reportFileName, reportData, 'png'); + const percentSimilar = await checkIfPngsMatch( + sessionReportPath, + getBaselineReportPath(reportFileName, 'png'), + config.get('screenshots.directory'), + log + ); + + expect(percentSimilar).to.be.lessThan(0.1); + }); + }); + }); +} diff --git a/x-pack/test/functional/page_objects/reporting_page.ts b/x-pack/test/functional/page_objects/reporting_page.ts index 2c20519a8d214..320171f8c89cd 100644 --- a/x-pack/test/functional/page_objects/reporting_page.ts +++ b/x-pack/test/functional/page_objects/reporting_page.ts @@ -9,10 +9,11 @@ import { FtrProviderContext } from 'test/functional/ftr_provider_context'; import { parse } from 'url'; export function ReportingPageProvider({ getService, getPageObjects }: FtrProviderContext) { - const retry = getService('retry'); + const browser = getService('browser'); const log = getService('log'); + const retry = getService('retry'); const testSubjects = getService('testSubjects'); - const browser = getService('browser'); + const PageObjects = getPageObjects(['common', 'security' as any, 'share', 'timePicker']); // FIXME: Security PageObject is not Typescript class ReportingPage {