diff --git a/docs/api.asciidoc b/docs/api.asciidoc index afe7722a0cec5c..a650d016ce9518 100644 --- a/docs/api.asciidoc +++ b/docs/api.asciidoc @@ -26,7 +26,7 @@ entirely. [float] == APIs - +* <> * <> * <> * <> @@ -34,6 +34,7 @@ entirely. * <> -- +include::api/spaces-management.asciidoc[] include::api/role-management.asciidoc[] include::api/saved-objects.asciidoc[] include::api/dashboard-import.asciidoc[] diff --git a/docs/api/role-management/put.asciidoc b/docs/api/role-management/put.asciidoc index a2b2ed73684311..864d315205dcba 100644 --- a/docs/api/role-management/put.asciidoc +++ b/docs/api/role-management/put.asciidoc @@ -30,7 +30,7 @@ that begin with `_` are reserved for system usage. `elasticsearch`:: (object) Optional {es} cluster and index privileges, valid keys are `cluster`, `indices` and `run_as`. For more information, see {xpack-ref}/defining-roles.html[Defining Roles]. -`kibana`:: (list) A list of objects that specify the <>. +`kibana`:: (object) An object that specifies the <>. Valid keys are `global` and `space`. Privileges defined in the `global` key will apply to all spaces within Kibana, and will take precedent over any privileges defined in the `space` key. For example, specifying `global: ["all"]` will grant full access to all spaces within Kibana, even if the role indicates that a specific space should only have `read` privileges. ===== Example @@ -52,9 +52,9 @@ PUT /api/security/role/my_kibana_role "query" : "{\"match\": {\"title\": \"foo\"}}" } ], }, - "kibana": [ { - "privileges": [ "all" ] - } ], + "kibana": { + "global": ["all"] + } } -------------------------------------------------- // KIBANA @@ -62,3 +62,37 @@ PUT /api/security/role/my_kibana_role ==== Response A successful call returns a response code of `204` and no response body. + + +==== Granting access to specific spaces +To grant access to individual spaces within {kib}, specify the space identifier within the `kibana` object. + +Note: granting access + +[source,js] +-------------------------------------------------- +PUT /api/security/role/my_kibana_role +{ + "metadata" : { + "version" : 1 + }, + "elasticsearch": { + "cluster" : [ "all" ], + "indices" : [ { + "names" : [ "index1", "index2" ], + "privileges" : [ "all" ], + "field_security" : { + "grant" : [ "title", "body" ] + }, + "query" : "{\"match\": {\"title\": \"foo\"}}" + } ], + }, + "kibana": { + "global": [], + "space": { + "marketing": ["all"], + "engineering": ["read"] + } + } +} +-------------------------------------------------- \ No newline at end of file diff --git a/docs/api/spaces-management.asciidoc b/docs/api/spaces-management.asciidoc new file mode 100644 index 00000000000000..f5f9a9d81c2fc0 --- /dev/null +++ b/docs/api/spaces-management.asciidoc @@ -0,0 +1,17 @@ +[role="xpack"] +[[spaces-api]] +== Kibana Spaces API + +experimental[This API is *experimental* and may be changed or removed completely in a future release. The underlying Spaces concepts are stable, but the APIs for managing Spaces are currently experimental.] + +The spaces API allows people to manage their spaces within {kib}. + +* <> +* <> +* <> +* <> + +include::spaces-management/post.asciidoc[] +include::spaces-management/put.asciidoc[] +include::spaces-management/get.asciidoc[] +include::spaces-management/delete.asciidoc[] diff --git a/docs/api/spaces-management/delete.asciidoc b/docs/api/spaces-management/delete.asciidoc new file mode 100644 index 00000000000000..c5ae025dd9e2e9 --- /dev/null +++ b/docs/api/spaces-management/delete.asciidoc @@ -0,0 +1,25 @@ +[[spaces-api-delete]] +=== Delete space + +experimental[This API is *experimental* and may be changed or removed completely in a future release. The underlying Spaces concepts are stable, but the APIs for managing Spaces are currently experimental.] + +[WARNING] +================================================== +Deleting a space will automatically delete all saved objects that belong to that space. This operation cannot be undone! +================================================== + +==== Request + +To delete a space, submit a DELETE request to the `/api/spaces/space/` +endpoint: + +[source,js] +-------------------------------------------------- +DELETE /api/spaces/space/marketing +-------------------------------------------------- +// KIBANA + +==== Response + +If the space is successfully deleted, the response code is `204`; otherwise, the response +code is 404. diff --git a/docs/api/spaces-management/get.asciidoc b/docs/api/spaces-management/get.asciidoc new file mode 100644 index 00000000000000..c79a883a80e4bb --- /dev/null +++ b/docs/api/spaces-management/get.asciidoc @@ -0,0 +1,77 @@ +[[spaces-api-get]] +=== Get Space + +experimental[This API is *experimental* and may be changed or removed completely in a future release. The underlying Spaces concepts are stable, but the APIs for managing Spaces are currently experimental.] + +Retrieves all {kib} spaces, or a specific space. + +==== Get all {kib} spaces + +===== Request + +To retrieve all spaces, issue a GET request to the +/api/spaces/space endpoint. + +[source,js] +-------------------------------------------------- +GET /api/spaces/space +-------------------------------------------------- +// KIBANA + +===== Response + +A successful call returns a response code of `200` and a response body containing a JSON +representation of the spaces. + +[source,js] +-------------------------------------------------- +[ + { + "id": "default", + "name": "Default", + "description" : "This is the Default Space", + "_reserved": true + }, + { + "id": "marketing", + "name": "Marketing", + "description" : "This is the Marketing Space", + "color": "#aabbcc", + "initials": "MK" + }, + { + "id": "sales", + "name": "Sales", + "initials": "MK" + }, +] +-------------------------------------------------- + +==== Get a specific space + +===== Request + +To retrieve a specific space, issue a GET request to +the `/api/spaces/space/` endpoint: + +[source,js] +-------------------------------------------------- +GET /api/spaces/space/marketing +-------------------------------------------------- +// KIBANA + +===== Response + +A successful call returns a response code of `200` and a response body containing a JSON +representation of the space. + +[source,js] +-------------------------------------------------- +{ + "id": "marketing", + "name": "Marketing", + "description" : "This is the Marketing Space", + "color": "#aabbcc", + "initials": "MK" +} +-------------------------------------------------- diff --git a/docs/api/spaces-management/post.asciidoc b/docs/api/spaces-management/post.asciidoc new file mode 100644 index 00000000000000..38ff647051335f --- /dev/null +++ b/docs/api/spaces-management/post.asciidoc @@ -0,0 +1,50 @@ +[[spaces-api-post]] +=== Create Space + +experimental[This API is *experimental* and may be changed or removed completely in a future release. The underlying Spaces concepts are stable, but the APIs for managing Spaces are currently experimental.] + +Creates a new {kib} space. To update an existing space, use the PUT command. + +==== Request + +To create a space, issue a POST request to the +`/api/spaces/space` endpoint. + +[source,js] +-------------------------------------------------- +POST /api/spaces/space +-------------------------------------------------- + +==== Request Body + +The following parameters can be specified in the body of a POST request to create a space: + +`id`:: (string) Required identifier for the space. This identifier becomes part of Kibana's URL when inside the space. This cannot be changed by the update operation. + +`name`:: (string) Required display name for the space. + +`description`:: (string) Optional description for the space. + +`initials`:: (string) Optionally specify the initials shown in the Space Avatar for this space. By default, the initials will be automatically generated from the space name. +If specified, initials should be either 1 or 2 characters. + +`color`:: (string) Optioanlly specify the hex color code used in the Space Avatar for this space. By default, the color will be automatically generated from the space name. + +===== Example + +[source,js] +-------------------------------------------------- +POST /api/spaces/space +{ + "id": "marketing", + "name": "Marketing", + "description" : "This is the Marketing Space", + "color": "#aabbcc", + "initials": "MK" +} +-------------------------------------------------- +// KIBANA + +==== Response + +A successful call returns a response code of `200` with the created Space. diff --git a/docs/api/spaces-management/put.asciidoc b/docs/api/spaces-management/put.asciidoc new file mode 100644 index 00000000000000..529742bf2ce666 --- /dev/null +++ b/docs/api/spaces-management/put.asciidoc @@ -0,0 +1,50 @@ +[[spaces-api-put]] +=== Update Space + +experimental[This API is *experimental* and may be changed or removed completely in a future release. The underlying Spaces concepts are stable, but the APIs for managing Spaces are currently experimental.] + +Updates an existing {kib} space. To create a new space, use the POST command. + +==== Request + +To update a space, issue a PUT request to the +`/api/spaces/space/` endpoint. + +[source,js] +-------------------------------------------------- +PUT /api/spaces/space/ +-------------------------------------------------- + +==== Request Body + +The following parameters can be specified in the body of a PUT request to update a space: + +`id`:: (string) Required identifier for the space. This identifier becomes part of Kibana's URL when inside the space. This cannot be changed by the update operation. + +`name`:: (string) Required display name for the space. + +`description`:: (string) Optional description for the space. + +`initials`:: (string) Optionally specify the initials shown in the Space Avatar for this space. By default, the initials will be automatically generated from the space name. +If specified, initials should be either 1 or 2 characters. + +`color`:: (string) Optioanlly specify the hex color code used in the Space Avatar for this space. By default, the color will be automatically generated from the space name. + +===== Example + +[source,js] +-------------------------------------------------- +PUT /api/spaces/space/marketing +{ + "id": "marketing", + "name": "Marketing", + "description" : "This is the Marketing Space", + "color": "#aabbcc", + "initials": "MK" +} +-------------------------------------------------- +// KIBANA + +==== Response + +A successful call returns a response code of `200` with the updated Space. diff --git a/docs/development/core/development-basepath.asciidoc b/docs/development/core/development-basepath.asciidoc index b7e0dec88bf50e..2da65079350150 100644 --- a/docs/development/core/development-basepath.asciidoc +++ b/docs/development/core/development-basepath.asciidoc @@ -48,15 +48,15 @@ $http.get(chrome.addBasePath('/api/plugin/things')); [float] ==== Server side -Append `config.get('server.basePath')` to any absolute URL path. +Append `request.getBasePath()` to any absolute URL path. ["source","shell"] ----------- const basePath = server.config().get('server.basePath'); server.route({ path: '/redirect', - handler(req, reply) { - reply.redirect(`${basePath}/otherLocation`); + handler(request, reply) { + reply.redirect(`${request.getBasePath()}/otherLocation`); } }); ----------- diff --git a/docs/index.asciidoc b/docs/index.asciidoc index a4fb428cf442d1..ac81e2af1e1a2a 100644 --- a/docs/index.asciidoc +++ b/docs/index.asciidoc @@ -52,6 +52,8 @@ include::monitoring/index.asciidoc[] include::management.asciidoc[] +include::spaces/index.asciidoc[] + include::security/index.asciidoc[] include::management/watcher-ui/index.asciidoc[] diff --git a/docs/security/authorization/index.asciidoc b/docs/security/authorization/index.asciidoc index ddc5bca0cb0424..3320843bc7a814 100644 --- a/docs/security/authorization/index.asciidoc +++ b/docs/security/authorization/index.asciidoc @@ -2,10 +2,13 @@ [[xpack-security-authorization]] === Authorization -Authorizing users to use {kib} in most configurations is as simple as assigning the user +Authorizing users to use {kib} in simple configurations is as easy as assigning the user either the `kibana_user` or `kibana_dashboard_only_user` reserved role. If you're running -a single tenant of {kib} against your {es} cluster, this is sufficient and no other -action is required. +a single tenant of {kib} against your {es} cluster, and you're not controlling access to individual spaces, then this is sufficient and no other action is required. + +==== Spaces + +If you want to control individual spaces in {kib}, do **not** use the `kibana_user` or `kibana_dashboard_only_user` roles. Users with these roles are able to access all spaces in Kibana. Instead, create your own roles that grant access to specific spaces. ==== Multi-tenant {kib} @@ -15,6 +18,8 @@ either the *Management / Security / Roles* page in {kib} or the <> at that tenant. After creating the custom role, you should assign this role to the user(s) that you wish to have access. +While multi-tenant installations are supported, the recommended approach to securing access to segments of {kib} is to grant users access to specific spaces. + ==== Legacy roles Prior to {kib} 6.4, {kib} users required index privileges to the `kibana.index` diff --git a/docs/spaces/getting-started.asciidoc b/docs/spaces/getting-started.asciidoc new file mode 100644 index 00000000000000..e6a96553873b7b --- /dev/null +++ b/docs/spaces/getting-started.asciidoc @@ -0,0 +1,8 @@ +[role="xpack"] +[[spaces-getting-started]] +=== Getting Started + +Spaces are automatically enabled in {kib}. If you don't wish to use this feature, you can disable it +by setting `xpack.spaces.enabled` to `false` in your `kibana.yml` configuration file. + +{kib} automatically creates a default space for you. If you are upgrading from another version of {kib}, then the default space will contain all of your existing saved objects. Although you can't delete the default space, you can customize it to your liking. \ No newline at end of file diff --git a/docs/spaces/images/delete-space.png b/docs/spaces/images/delete-space.png new file mode 100644 index 00000000000000..8237df1136a9e8 Binary files /dev/null and b/docs/spaces/images/delete-space.png differ diff --git a/docs/spaces/images/edit-space.png b/docs/spaces/images/edit-space.png new file mode 100644 index 00000000000000..dae7d01f665c05 Binary files /dev/null and b/docs/spaces/images/edit-space.png differ diff --git a/docs/spaces/images/securing-spaces.png b/docs/spaces/images/securing-spaces.png new file mode 100644 index 00000000000000..a94d2c36d4f5d4 Binary files /dev/null and b/docs/spaces/images/securing-spaces.png differ diff --git a/docs/spaces/images/space-management.png b/docs/spaces/images/space-management.png new file mode 100644 index 00000000000000..bd58605362024b Binary files /dev/null and b/docs/spaces/images/space-management.png differ diff --git a/docs/spaces/images/space-selector.png b/docs/spaces/images/space-selector.png new file mode 100644 index 00000000000000..a1977b01d1fa03 Binary files /dev/null and b/docs/spaces/images/space-selector.png differ diff --git a/docs/spaces/index.asciidoc b/docs/spaces/index.asciidoc new file mode 100644 index 00000000000000..b40c4267c2b49f --- /dev/null +++ b/docs/spaces/index.asciidoc @@ -0,0 +1,17 @@ +[role="xpack"] +[[xpack-spaces]] +== Spaces + +With spaces, you can organize your dashboards and other saved objects into meaningful categories. +After creating your own spaces, you will be asked to choose a space when you enter {kib}. Once inside a space, +you will only see the dashboards and other saved objects that belong to that space. You can change your active space at any time. + +With security enabled, you can control which users have access to individual spaces. + +[role="screenshot"] +image::spaces/images/space-selector.png["Space selector screen"] + +include::getting-started.asciidoc[] +include::managing-spaces.asciidoc[] +include::securing-spaces.asciidoc[] +include::moving-saved-objects.asciidoc[] diff --git a/docs/spaces/managing-spaces.asciidoc b/docs/spaces/managing-spaces.asciidoc new file mode 100644 index 00000000000000..73a21ff049b36f --- /dev/null +++ b/docs/spaces/managing-spaces.asciidoc @@ -0,0 +1,25 @@ +[role="xpack"] +[[spaces-managing]] +=== Managing spaces +You can manage spaces from the **Management > Spaces** page. Here you can create, edit, and delete your spaces. + +[NOTE] +{kib} has an <> if you want to create your spaces programatically. + +[role="screenshot"] +image::spaces/images/space-management.png["Space Management"] + +==== Creating and updating spaces +You can create as many spaces as you like, but each space must have a unique space identifier. The space identifier is a short string of text that is part of the {kib} URL when you are inside that space. {kib} automatically suggests a space identifier based on the name of your space, but you are free to customize this to your liking. + +[NOTE] +You cannot change the space identifier once the space is created. + +[role="screenshot"] +image::spaces/images/edit-space.png["Updating a space"] + +==== Deleting spaces +Deleting a space is a destructive operation, which cannot be undone. When you delete a space, all of the saved objects that belong to that space are also deleted. + +[role="screenshot"] +image::spaces/images/delete-space.png["Deleting a space"] \ No newline at end of file diff --git a/docs/spaces/moving-saved-objects.asciidoc b/docs/spaces/moving-saved-objects.asciidoc new file mode 100644 index 00000000000000..e6a116f54a252f --- /dev/null +++ b/docs/spaces/moving-saved-objects.asciidoc @@ -0,0 +1,14 @@ +[role="xpack"] +[[spaces-moving-objects]] +=== Moving saved objects between spaces +You can use {kib}'s <> interface to copy objects from one space to another: + +1. Navigate to the space that contains your saved objects. +2. Export your saved objects via the <> interface. +3. Navigate to the space you are importing to. +4. Import your saved objects via the <> interface. +5. (optional) Delete the saved objects from the space you exported from, if you don't want to keep a copy there. + + +[NOTE] +{kib} also has experimental <> and <> dashboard APIs if you are looking for a dashboard-centric way to automate this process. \ No newline at end of file diff --git a/docs/spaces/securing-spaces.asciidoc b/docs/spaces/securing-spaces.asciidoc new file mode 100644 index 00000000000000..1fd6e915bc4f80 --- /dev/null +++ b/docs/spaces/securing-spaces.asciidoc @@ -0,0 +1,7 @@ +[role="xpack"] +[[spaces-securing]] +=== Securing spaces + +With security enabled, you can control who has access to specific spaces. You can manage access in **Management > Roles**. + +image::spaces/images/securing-spaces.png["Securing spaces"] \ No newline at end of file diff --git a/packages/kbn-test/src/functional_tests/lib/run_elasticsearch.js b/packages/kbn-test/src/functional_tests/lib/run_elasticsearch.js index d0a3160018fead..d0cc7091d77679 100644 --- a/packages/kbn-test/src/functional_tests/lib/run_elasticsearch.js +++ b/packages/kbn-test/src/functional_tests/lib/run_elasticsearch.js @@ -25,12 +25,15 @@ import { setupUsers, DEFAULT_SUPERUSER_PASS } from './auth'; export async function runElasticsearch({ config, options }) { const { log, esFrom } = options; - const isOss = config.get('esTestCluster.license') === 'oss'; + const license = config.get('esTestCluster.license'); + const isTrialLicense = config.get('esTestCluster.license') === 'trial'; const cluster = createEsTestCluster({ port: config.get('servers.elasticsearch.port'), - password: !isOss ? DEFAULT_SUPERUSER_PASS : config.get('servers.elasticsearch.password'), - license: config.get('esTestCluster.license'), + password: isTrialLicense + ? DEFAULT_SUPERUSER_PASS + : config.get('servers.elasticsearch.password'), + license, log, basePath: resolve(KIBANA_ROOT, '.es'), esFrom: esFrom || config.get('esTestCluster.from'), @@ -40,7 +43,7 @@ export async function runElasticsearch({ config, options }) { await cluster.start(esArgs); - if (!isOss) { + if (isTrialLicense) { await setupUsers(log, config); } diff --git a/src/core_plugins/kibana/public/management/sections/settings/__snapshots__/advanced_settings.test.js.snap b/src/core_plugins/kibana/public/management/sections/settings/__snapshots__/advanced_settings.test.js.snap index 85abe0571d1167..00f953298c063b 100644 --- a/src/core_plugins/kibana/public/management/sections/settings/__snapshots__/advanced_settings.test.js.snap +++ b/src/core_plugins/kibana/public/management/sections/settings/__snapshots__/advanced_settings.test.js.snap @@ -51,6 +51,7 @@ exports[`AdvancedSettings should render normally 1`] = ` /> + @@ -396,6 +397,7 @@ exports[`AdvancedSettings should render specific setting if given setting key 1` /> + diff --git a/src/core_plugins/kibana/public/management/sections/settings/advanced_settings.js b/src/core_plugins/kibana/public/management/sections/settings/advanced_settings.js index 7ce4341f59ed8e..6ec6cd714556c6 100644 --- a/src/core_plugins/kibana/public/management/sections/settings/advanced_settings.js +++ b/src/core_plugins/kibana/public/management/sections/settings/advanced_settings.js @@ -35,7 +35,12 @@ import { Form } from './components/form'; import { getAriaName, toEditableConfig, DEFAULT_CATEGORY } from './lib'; import './advanced_settings.less'; -import { registerDefaultComponents, PAGE_TITLE_COMPONENT, PAGE_FOOTER_COMPONENT } from './components/default_component_registry'; +import { + registerDefaultComponents, + PAGE_TITLE_COMPONENT, + PAGE_SUBTITLE_COMPONENT, + PAGE_FOOTER_COMPONENT +} from './components/default_component_registry'; import { getSettingsComponent } from './components/component_registry'; export class AdvancedSettings extends Component { @@ -145,6 +150,7 @@ export class AdvancedSettings extends Component { const { filteredSettings, query, footerQueryMatched } = this.state; const PageTitle = getSettingsComponent(PAGE_TITLE_COMPONENT); + const PageSubtitle = getSettingsComponent(PAGE_SUBTITLE_COMPONENT); const PageFooter = getSettingsComponent(PAGE_FOOTER_COMPONENT); return ( @@ -161,6 +167,7 @@ export class AdvancedSettings extends Component { /> + diff --git a/src/core_plugins/kibana/public/management/sections/settings/components/default_component_registry.js b/src/core_plugins/kibana/public/management/sections/settings/components/default_component_registry.js index 221f8c2f82bf8c..41979d4bd66a6c 100644 --- a/src/core_plugins/kibana/public/management/sections/settings/components/default_component_registry.js +++ b/src/core_plugins/kibana/public/management/sections/settings/components/default_component_registry.js @@ -19,12 +19,15 @@ import { tryRegisterSettingsComponent } from './component_registry'; import { PageTitle } from './page_title'; +import { PageSubtitle } from './page_subtitle'; import { PageFooter } from './page_footer'; export const PAGE_TITLE_COMPONENT = 'advanced_settings_page_title'; +export const PAGE_SUBTITLE_COMPONENT = 'advanced_settings_page_subtitle'; export const PAGE_FOOTER_COMPONENT = 'advanced_settings_page_footer'; export function registerDefaultComponents() { tryRegisterSettingsComponent(PAGE_TITLE_COMPONENT, PageTitle); + tryRegisterSettingsComponent(PAGE_SUBTITLE_COMPONENT, PageSubtitle); tryRegisterSettingsComponent(PAGE_FOOTER_COMPONENT, PageFooter); } \ No newline at end of file diff --git a/src/core_plugins/kibana/public/management/sections/settings/components/page_subtitle/__snapshots__/page_subtitle.test.js.snap b/src/core_plugins/kibana/public/management/sections/settings/components/page_subtitle/__snapshots__/page_subtitle.test.js.snap new file mode 100644 index 00000000000000..24ec8954590386 --- /dev/null +++ b/src/core_plugins/kibana/public/management/sections/settings/components/page_subtitle/__snapshots__/page_subtitle.test.js.snap @@ -0,0 +1,3 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`PageSubtitle should render normally 1`] = `""`; diff --git a/src/core_plugins/kibana/public/management/sections/settings/components/page_subtitle/index.js b/src/core_plugins/kibana/public/management/sections/settings/components/page_subtitle/index.js new file mode 100644 index 00000000000000..76b6293b4c267a --- /dev/null +++ b/src/core_plugins/kibana/public/management/sections/settings/components/page_subtitle/index.js @@ -0,0 +1,20 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { PageSubtitle } from './page_subtitle'; diff --git a/src/core_plugins/kibana/public/management/sections/settings/components/page_subtitle/page_subtitle.js b/src/core_plugins/kibana/public/management/sections/settings/components/page_subtitle/page_subtitle.js new file mode 100644 index 00000000000000..35485fdc7b492c --- /dev/null +++ b/src/core_plugins/kibana/public/management/sections/settings/components/page_subtitle/page_subtitle.js @@ -0,0 +1,20 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export const PageSubtitle = () => null; \ No newline at end of file diff --git a/src/core_plugins/kibana/public/management/sections/settings/components/page_subtitle/page_subtitle.test.js b/src/core_plugins/kibana/public/management/sections/settings/components/page_subtitle/page_subtitle.test.js new file mode 100644 index 00000000000000..2b1d06ceeed414 --- /dev/null +++ b/src/core_plugins/kibana/public/management/sections/settings/components/page_subtitle/page_subtitle.test.js @@ -0,0 +1,28 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import React from 'react'; +import { shallow } from 'enzyme'; + +import { PageSubtitle } from './page_subtitle'; + +describe('PageSubtitle', () => { + it('should render normally', () => { + expect(shallow()).toMatchSnapshot(); + }); +}); \ No newline at end of file diff --git a/src/dev/typescript/projects.ts b/src/dev/typescript/projects.ts index f92ebae39e5173..b93d3a0ac53717 100644 --- a/src/dev/typescript/projects.ts +++ b/src/dev/typescript/projects.ts @@ -26,6 +26,7 @@ import { Project } from './project'; export const PROJECTS = [ new Project(resolve(REPO_ROOT, 'tsconfig.json')), new Project(resolve(REPO_ROOT, 'x-pack/tsconfig.json')), + new Project(resolve(REPO_ROOT, 'x-pack/test/tsconfig.json'), 'x-pack/test'), // NOTE: using glob.sync rather than glob-all or globby // because it takes less than 10 ms, while the other modules diff --git a/src/server/http/index.js b/src/server/http/index.js index 4df177099abc66..d7a79b0d02fa7a 100644 --- a/src/server/http/index.js +++ b/src/server/http/index.js @@ -22,9 +22,9 @@ import { resolve } from 'path'; import _ from 'lodash'; import Boom from 'boom'; import Hapi from 'hapi'; -import getDefaultRoute from './get_default_route'; import { setupVersionCheck } from './version_check'; import { registerHapiPlugins } from './register_hapi_plugins'; +import { setupBasePathProvider } from './setup_base_path_provider'; import { setupXsrf } from './xsrf'; export default async function (kbnServer, server, config) { @@ -33,6 +33,8 @@ export default async function (kbnServer, server, config) { server.connection(kbnServer.core.serverOptions); + setupBasePathProvider(server, config); + registerHapiPlugins(server); // provide a simple way to expose static directories @@ -86,11 +88,10 @@ export default async function (kbnServer, server, config) { server.route({ path: '/', method: 'GET', - handler: function (req, reply) { - return reply.view('root_redirect', { - hashRoute: `${config.get('server.basePath')}/app/kibana`, - defaultRoute: getDefaultRoute(kbnServer), - }); + handler(req, reply) { + const basePath = req.getBasePath(); + const defaultRoute = config.get('server.defaultRoute'); + reply.redirect(`${basePath}${defaultRoute}`); } }); @@ -102,7 +103,7 @@ export default async function (kbnServer, server, config) { if (path === '/' || path.charAt(path.length - 1) !== '/') { return reply(Boom.notFound()); } - const pathPrefix = config.get('server.basePath') ? `${config.get('server.basePath')}/` : ''; + const pathPrefix = req.getBasePath() ? `${req.getBasePath()}/` : ''; return reply.redirect(format({ search: req.url.search, pathname: pathPrefix + path.slice(0, -1), diff --git a/src/server/http/setup_base_path_provider.js b/src/server/http/setup_base_path_provider.js new file mode 100644 index 00000000000000..caba48c765b022 --- /dev/null +++ b/src/server/http/setup_base_path_provider.js @@ -0,0 +1,38 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export function setupBasePathProvider(server, config) { + + server.decorate('request', 'setBasePath', function (basePath) { + const request = this; + if (request.app._basePath) { + throw new Error(`Request basePath was previously set. Setting multiple times is not supported.`); + } + request.app._basePath = basePath; + }); + + server.decorate('request', 'getBasePath', function () { + const request = this; + + const serverBasePath = config.get('server.basePath'); + const requestBasePath = request.app._basePath || ''; + + return `${serverBasePath}${requestBasePath}`; + }); +} diff --git a/src/server/saved_objects/service/lib/__snapshots__/priority_collection.test.ts.snap b/src/server/saved_objects/service/lib/__snapshots__/priority_collection.test.ts.snap new file mode 100644 index 00000000000000..fd96c54450cf7e --- /dev/null +++ b/src/server/saved_objects/service/lib/__snapshots__/priority_collection.test.ts.snap @@ -0,0 +1,3 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`1, 1 throws Error 1`] = `"Already have entry with this priority"`; diff --git a/src/server/saved_objects/service/lib/priority_collection.test.ts b/src/server/saved_objects/service/lib/priority_collection.test.ts new file mode 100644 index 00000000000000..9256b2e913931c --- /dev/null +++ b/src/server/saved_objects/service/lib/priority_collection.test.ts @@ -0,0 +1,57 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { PriorityCollection } from './priority_collection'; + +test(`1, 2, 3`, () => { + const priorityCollection = new PriorityCollection(); + priorityCollection.add(1, 1); + priorityCollection.add(2, 2); + priorityCollection.add(3, 3); + expect(priorityCollection.toPrioritizedArray()).toEqual([1, 2, 3]); +}); + +test(`3, 2, 1`, () => { + const priorityCollection = new PriorityCollection(); + priorityCollection.add(3, 3); + priorityCollection.add(2, 2); + priorityCollection.add(1, 1); + expect(priorityCollection.toPrioritizedArray()).toEqual([1, 2, 3]); +}); + +test(`2, 3, 1`, () => { + const priorityCollection = new PriorityCollection(); + priorityCollection.add(2, 2); + priorityCollection.add(3, 3); + priorityCollection.add(1, 1); + expect(priorityCollection.toPrioritizedArray()).toEqual([1, 2, 3]); +}); + +test(`Number.MAX_VALUE, NUMBER.MIN_VALUE, 1`, () => { + const priorityCollection = new PriorityCollection(); + priorityCollection.add(Number.MAX_VALUE, 3); + priorityCollection.add(Number.MIN_VALUE, 1); + priorityCollection.add(1, 2); + expect(priorityCollection.toPrioritizedArray()).toEqual([1, 2, 3]); +}); + +test(`1, 1 throws Error`, () => { + const priorityCollection = new PriorityCollection(); + priorityCollection.add(1, 1); + expect(() => priorityCollection.add(1, 1)).toThrowErrorMatchingSnapshot(); +}); diff --git a/src/server/saved_objects/service/lib/priority_collection.ts b/src/server/saved_objects/service/lib/priority_collection.ts new file mode 100644 index 00000000000000..3c918f0c1e1fc2 --- /dev/null +++ b/src/server/saved_objects/service/lib/priority_collection.ts @@ -0,0 +1,44 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +interface PriorityCollectionEntry { + priority: number; + value: T; +} + +export class PriorityCollection { + private readonly array: Array> = []; + + public add(priority: number, value: T) { + const foundIndex = this.array.findIndex(current => { + if (priority === current.priority) { + throw new Error('Already have entry with this priority'); + } + + return priority < current.priority; + }); + + const spliceIndex = foundIndex === -1 ? this.array.length : foundIndex; + this.array.splice(spliceIndex, 0, { priority, value }); + } + + public toPrioritizedArray(): T[] { + return this.array.map(entry => entry.value); + } +} diff --git a/src/server/saved_objects/service/lib/repository.js b/src/server/saved_objects/service/lib/repository.js index 1517cfada50584..62b7070f359cac 100644 --- a/src/server/saved_objects/service/lib/repository.js +++ b/src/server/saved_objects/service/lib/repository.js @@ -49,6 +49,7 @@ export class SavedObjectsRepository { this._migrator = migrator; this._index = index; this._mappings = mappings; + this._schema = schema; this._type = getRootType(this._mappings); this._onBeforeWrite = onBeforeWrite; this._unwrappedCallCluster = callCluster; diff --git a/src/server/saved_objects/service/lib/scoped_client_provider.js b/src/server/saved_objects/service/lib/scoped_client_provider.js index ddcc9c1c3ff560..05cc97945ef0bb 100644 --- a/src/server/saved_objects/service/lib/scoped_client_provider.js +++ b/src/server/saved_objects/service/lib/scoped_client_provider.js @@ -16,13 +16,14 @@ * specific language governing permissions and limitations * under the License. */ +import { PriorityCollection } from './priority_collection'; /** * Provider for the Scoped Saved Object Client. */ export class ScopedSavedObjectsClientProvider { - _wrapperFactories = []; + _wrapperFactories = new PriorityCollection(); constructor({ defaultClientFactory @@ -30,16 +31,8 @@ export class ScopedSavedObjectsClientProvider { this._originalClientFactory = this._clientFactory = defaultClientFactory; } - // the client wrapper factories are put at the front of the array, so that - // when we use `reduce` below they're invoked in LIFO order. This is so that - // if multiple plugins register their client wrapper factories, then we can use - // the plugin dependencies/optionalDependencies to implicitly control the order - // in which these are used. For example, if we have a plugin a that declares a - // dependency on plugin b, that means that plugin b's client wrapper would want - // to be able to run first when the SavedObjectClient methods are invoked to - // provide additional context to plugin a's client wrapper. - addClientWrapperFactory(wrapperFactory) { - this._wrapperFactories.unshift(wrapperFactory); + addClientWrapperFactory(priority, wrapperFactory) { + this._wrapperFactories.add(priority, wrapperFactory); } setClientFactory(customClientFactory) { @@ -55,11 +48,13 @@ export class ScopedSavedObjectsClientProvider { request, }); - return this._wrapperFactories.reduce((clientToWrap, wrapperFactory) => { - return wrapperFactory({ - request, - client: clientToWrap, - }); - }, client); + return this._wrapperFactories + .toPrioritizedArray() + .reduceRight((clientToWrap, wrapperFactory) => { + return wrapperFactory({ + request, + client: clientToWrap, + }); + }, client); } } diff --git a/src/server/saved_objects/service/lib/scoped_client_provider.test.js b/src/server/saved_objects/service/lib/scoped_client_provider.test.js index 219f35559c8849..52a98c08edde5c 100644 --- a/src/server/saved_objects/service/lib/scoped_client_provider.test.js +++ b/src/server/saved_objects/service/lib/scoped_client_provider.test.js @@ -64,40 +64,20 @@ test(`throws error when more than one scoped saved objects client factory is set }).toThrowErrorMatchingSnapshot(); }); -test(`invokes and uses instance from single added wrapper factory`, () => { +test(`invokes and uses wrappers in specified order`, () => { const defaultClient = Symbol(); const defaultClientFactoryMock = jest.fn().mockReturnValue(defaultClient); const clientProvider = new ScopedSavedObjectsClientProvider({ defaultClientFactory: defaultClientFactoryMock }); - const wrappedClient = Symbol(); - const clientWrapperFactoryMock = jest.fn().mockReturnValue(wrappedClient); - const request = Symbol(); - - clientProvider.addClientWrapperFactory(clientWrapperFactoryMock); - const actualClient = clientProvider.getClient(request); - - expect(actualClient).toBe(wrappedClient); - expect(clientWrapperFactoryMock).toHaveBeenCalledWith({ - request, - client: defaultClient - }); -}); - -test(`invokes and uses wrappers in LIFO order`, () => { - const defaultClient = Symbol(); - const defaultClientFactoryMock = jest.fn().mockReturnValue(defaultClient); - const clientProvider = new ScopedSavedObjectsClientProvider({ - defaultClientFactory: defaultClientFactoryMock - }); - const firstWrappedClient = Symbol(); + const firstWrappedClient = Symbol('first client'); const firstClientWrapperFactoryMock = jest.fn().mockReturnValue(firstWrappedClient); - const secondWrapperClient = Symbol(); + const secondWrapperClient = Symbol('second client'); const secondClientWrapperFactoryMock = jest.fn().mockReturnValue(secondWrapperClient); const request = Symbol(); - clientProvider.addClientWrapperFactory(firstClientWrapperFactoryMock); - clientProvider.addClientWrapperFactory(secondClientWrapperFactoryMock); + clientProvider.addClientWrapperFactory(1, secondClientWrapperFactoryMock); + clientProvider.addClientWrapperFactory(0, firstClientWrapperFactoryMock); const actualClient = clientProvider.getClient(request); expect(actualClient).toBe(firstWrappedClient); diff --git a/src/ui/public/chrome/api/__tests__/nav.js b/src/ui/public/chrome/api/__tests__/nav.js index 169c9546a4a37e..1ca5f2689590d5 100644 --- a/src/ui/public/chrome/api/__tests__/nav.js +++ b/src/ui/public/chrome/api/__tests__/nav.js @@ -27,6 +27,7 @@ const basePath = '/someBasePath'; function init(customInternals = { basePath }) { const chrome = { + addBasePath: (path) => path, getBasePath: () => customInternals.basePath || '', }; const internals = { @@ -39,7 +40,7 @@ function init(customInternals = { basePath }) { describe('chrome nav apis', function () { describe('#getNavLinkById', () => { - it ('retrieves the correct nav link, given its ID', () => { + it('retrieves the correct nav link, given its ID', () => { const appUrlStore = new StubBrowserStorage(); const nav = [ { id: 'kibana:discover', title: 'Discover' } @@ -52,7 +53,7 @@ describe('chrome nav apis', function () { expect(navLink).to.eql(nav[0]); }); - it ('throws an error if the nav link with the given ID is not found', () => { + it('throws an error if the nav link with the given ID is not found', () => { const appUrlStore = new StubBrowserStorage(); const nav = [ { id: 'kibana:discover', title: 'Discover' } diff --git a/src/ui/public/chrome/api/nav.js b/src/ui/public/chrome/api/nav.js index 04aab308396c4c..329d1c463884df 100644 --- a/src/ui/public/chrome/api/nav.js +++ b/src/ui/public/chrome/api/nav.js @@ -131,8 +131,8 @@ export function initChromeNavApi(chrome, internals) { }; internals.nav.forEach(link => { - link.url = relativeToAbsolute(link.url); - link.subUrlBase = relativeToAbsolute(link.subUrlBase); + link.url = relativeToAbsolute(chrome.addBasePath(link.url)); + link.subUrlBase = relativeToAbsolute(chrome.addBasePath(link.subUrlBase)); }); // simulate a possible change in url to initialize the diff --git a/src/ui/public/chrome/index.d.ts b/src/ui/public/chrome/index.d.ts index 5e4c0c2490af00..533157cecf5ef1 100644 --- a/src/ui/public/chrome/index.d.ts +++ b/src/ui/public/chrome/index.d.ts @@ -28,6 +28,7 @@ declare class Chrome { public getXsrfToken(): string; public getKibanaVersion(): string; public getUiSettingsClient(): any; + public setVisible(visible: boolean): any; public getInjected(key: string, defaultValue?: any): any; } diff --git a/src/ui/public/filter_bar/lib/__tests__/change_time_filter.test.js b/src/ui/public/filter_bar/lib/__tests__/change_time_filter.test.js index e3ad2ba72b418a..eab08903ec029c 100644 --- a/src/ui/public/filter_bar/lib/__tests__/change_time_filter.test.js +++ b/src/ui/public/filter_bar/lib/__tests__/change_time_filter.test.js @@ -19,6 +19,7 @@ jest.mock('ui/chrome', () => ({ + getBasePath: () => `/some/base/path`, getUiSettingsClient: () => { return { get: (key) => { diff --git a/src/ui/public/management/index.js b/src/ui/public/management/index.js index 62f9850c839f5e..b4a0262bf88826 100644 --- a/src/ui/public/management/index.js +++ b/src/ui/public/management/index.js @@ -21,6 +21,7 @@ import { ManagementSection } from './section'; export { PAGE_TITLE_COMPONENT, + PAGE_SUBTITLE_COMPONENT, PAGE_FOOTER_COMPONENT, } from '../../../core_plugins/kibana/public/management/sections/settings/components/default_component_registry'; diff --git a/src/ui/public/persisted_log/create_log_key.js b/src/ui/public/persisted_log/create_log_key.js new file mode 100644 index 00000000000000..bbc91d65f11127 --- /dev/null +++ b/src/ui/public/persisted_log/create_log_key.js @@ -0,0 +1,31 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Sha256 } from '../crypto'; + +export function createLogKey(type, optionalIdentifier) { + const baseKey = `kibana.history.${type}`; + + if (!optionalIdentifier) { + return baseKey; + } + + const protectedIdentifier = new Sha256().update(optionalIdentifier, 'utf8').digest('base64'); + return `${baseKey}-${protectedIdentifier}`; +} \ No newline at end of file diff --git a/src/ui/public/persisted_log/create_log_key.test.js b/src/ui/public/persisted_log/create_log_key.test.js new file mode 100644 index 00000000000000..3f7f69a5271e0a --- /dev/null +++ b/src/ui/public/persisted_log/create_log_key.test.js @@ -0,0 +1,35 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { createLogKey } from './create_log_key'; + +describe('createLogKey', () => { + it('should create a key starting with "kibana.history"', () => { + expect(createLogKey('foo', 'bar')).toMatch(/^kibana\.history/); + }); + + it('should include a hashed suffix of the identifier when present', () => { + const expectedSuffix = `/N4rLtula/QIYB+3If6bXDONEO5CnqBPrlURto+/j7k=`; + expect(createLogKey('foo', 'bar')).toMatch(`kibana.history.foo-${expectedSuffix}`); + }); + + it('should not include a hashed suffix if the identifier is not present', () => { + expect(createLogKey('foo')).toEqual('kibana.history.foo'); + }); +}); \ No newline at end of file diff --git a/src/ui/public/persisted_log/persisted_log.test.js b/src/ui/public/persisted_log/persisted_log.test.js index ec0a659d6d063a..ee9c26d5735731 100644 --- a/src/ui/public/persisted_log/persisted_log.test.js +++ b/src/ui/public/persisted_log/persisted_log.test.js @@ -22,6 +22,12 @@ import sinon from 'sinon'; import expect from 'expect.js'; import { PersistedLog } from './'; +jest.mock('ui/chrome', () => { + return { + getBasePath: () => `/some/base/path` + }; +}); + const historyName = 'testHistory'; const historyLimit = 10; const payload = [ diff --git a/src/ui/public/persisted_log/recently_accessed.js b/src/ui/public/persisted_log/recently_accessed.js index af8280f6ab5b70..aed82fbc648e59 100644 --- a/src/ui/public/persisted_log/recently_accessed.js +++ b/src/ui/public/persisted_log/recently_accessed.js @@ -16,8 +16,9 @@ * specific language governing permissions and limitations * under the License. */ - +import chrome from 'ui/chrome'; import { PersistedLog } from './'; +import { createLogKey } from './create_log_key'; class RecentlyAccessed { constructor() { @@ -28,7 +29,8 @@ class RecentlyAccessed { return oldItem.id === newItem.id; } }; - this.history = new PersistedLog('kibana.history.recentlyAccessed', historyOptions); + const logKey = createLogKey('recentlyAccessed', chrome.getBasePath()); + this.history = new PersistedLog(logKey, historyOptions); } add(link, label, id) { diff --git a/src/ui/public/timefilter/timefilter.test.js b/src/ui/public/timefilter/timefilter.test.js index a7b3cb38d12393..7071a6c7f7a707 100644 --- a/src/ui/public/timefilter/timefilter.test.js +++ b/src/ui/public/timefilter/timefilter.test.js @@ -19,10 +19,11 @@ jest.mock('ui/chrome', () => ({ + getBasePath: () => `/some/base/path`, getUiSettingsClient: () => { return { get: (key) => { - switch(key) { + switch (key) { case 'timepicker:timeDefaults': return { from: 'now-15m', to: 'now', mode: 'quick' }; case 'timepicker:refreshIntervalDefaults': @@ -107,7 +108,7 @@ describe('setRefreshInterval', () => { let update; let fetch; - beforeEach(() => { + beforeEach(() => { update = sinon.spy(); fetch = sinon.spy(); timefilter.setRefreshInterval({ @@ -191,7 +192,7 @@ describe('setRefreshInterval', () => { describe('isTimeRangeSelectorEnabled', () => { let update; - beforeEach(() => { + beforeEach(() => { update = sinon.spy(); timefilter.on('enabledUpdated', update); }); @@ -212,7 +213,7 @@ describe('isTimeRangeSelectorEnabled', () => { describe('isAutoRefreshSelectorEnabled', () => { let update; - beforeEach(() => { + beforeEach(() => { update = sinon.spy(); timefilter.on('enabledUpdated', update); }); diff --git a/src/ui/public/utils/__tests__/brush_event.test.js b/src/ui/public/utils/__tests__/brush_event.test.js index 4f6c221b8650a9..701586bad92557 100644 --- a/src/ui/public/utils/__tests__/brush_event.test.js +++ b/src/ui/public/utils/__tests__/brush_event.test.js @@ -19,10 +19,11 @@ jest.mock('ui/chrome', () => ({ + getBasePath: () => `/some/base/path`, getUiSettingsClient: () => { return { get: (key) => { - switch(key) { + switch (key) { case 'timepicker:timeDefaults': return { from: 'now-15m', to: 'now', mode: 'quick' }; case 'timepicker:refreshIntervalDefaults': diff --git a/src/ui/ui_apps/ui_app.js b/src/ui/ui_apps/ui_app.js index 7aea264ae0e5f2..269bd044a56adf 100644 --- a/src/ui/ui_apps/ui_app.js +++ b/src/ui/ui_apps/ui_app.js @@ -60,7 +60,7 @@ export class UiApp { // unless an app is hidden it gets a navlink, but we only respond to `getNavLink()` // if the app is also listed. This means that all apps in the kibanaPayload will // have a navLink property since that list includes all normally accessible apps - this._navLink = new UiNavLink(kbnServer.config.get('server.basePath'), { + this._navLink = new UiNavLink({ id: this._id, title: this._title, order: this._order, diff --git a/src/ui/ui_nav_links/__tests__/ui_nav_link.js b/src/ui/ui_nav_links/__tests__/ui_nav_link.js index 0cac7634731469..6ac7bf55d18264 100644 --- a/src/ui/ui_nav_links/__tests__/ui_nav_link.js +++ b/src/ui/ui_nav_links/__tests__/ui_nav_link.js @@ -24,7 +24,6 @@ import { UiNavLink } from '../ui_nav_link'; describe('UiNavLink', () => { describe('constructor', () => { it('initializes the object properties as expected', () => { - const urlBasePath = 'http://localhost:5601/rnd'; const spec = { id: 'kibana:discover', title: 'Discover', @@ -36,13 +35,13 @@ describe('UiNavLink', () => { disabled: true }; - const link = new UiNavLink(urlBasePath, spec); + const link = new UiNavLink(spec); expect(link.toJSON()).to.eql({ id: spec.id, title: spec.title, order: spec.order, - url: `${urlBasePath}${spec.url}`, - subUrlBase: `${urlBasePath}${spec.url}`, + url: spec.url, + subUrlBase: spec.url, description: spec.description, icon: spec.icon, hidden: spec.hidden, @@ -54,22 +53,7 @@ describe('UiNavLink', () => { }); }); - it('initializes the url property without a base path when one is not specified in the spec', () => { - const urlBasePath = undefined; - const spec = { - id: 'kibana:discover', - title: 'Discover', - order: -1003, - url: '/app/kibana#/discover', - description: 'interactively explore your data', - icon: 'plugins/kibana/assets/discover.svg', - }; - const link = new UiNavLink(urlBasePath, spec); - expect(link.toJSON()).to.have.property('url', spec.url); - }); - it('initializes the order property to 0 when order is not specified in the spec', () => { - const urlBasePath = undefined; const spec = { id: 'kibana:discover', title: 'Discover', @@ -77,13 +61,12 @@ describe('UiNavLink', () => { description: 'interactively explore your data', icon: 'plugins/kibana/assets/discover.svg', }; - const link = new UiNavLink(urlBasePath, spec); + const link = new UiNavLink(spec); expect(link.toJSON()).to.have.property('order', 0); }); it('initializes the linkToLastSubUrl property to false when false is specified in the spec', () => { - const urlBasePath = undefined; const spec = { id: 'kibana:discover', title: 'Discover', @@ -93,13 +76,12 @@ describe('UiNavLink', () => { icon: 'plugins/kibana/assets/discover.svg', linkToLastSubUrl: false }; - const link = new UiNavLink(urlBasePath, spec); + const link = new UiNavLink(spec); expect(link.toJSON()).to.have.property('linkToLastSubUrl', false); }); it('initializes the linkToLastSubUrl property to true by default', () => { - const urlBasePath = undefined; const spec = { id: 'kibana:discover', title: 'Discover', @@ -108,13 +90,12 @@ describe('UiNavLink', () => { description: 'interactively explore your data', icon: 'plugins/kibana/assets/discover.svg', }; - const link = new UiNavLink(urlBasePath, spec); + const link = new UiNavLink(spec); expect(link.toJSON()).to.have.property('linkToLastSubUrl', true); }); it('initializes the hidden property to false by default', () => { - const urlBasePath = undefined; const spec = { id: 'kibana:discover', title: 'Discover', @@ -123,13 +104,12 @@ describe('UiNavLink', () => { description: 'interactively explore your data', icon: 'plugins/kibana/assets/discover.svg', }; - const link = new UiNavLink(urlBasePath, spec); + const link = new UiNavLink(spec); expect(link.toJSON()).to.have.property('hidden', false); }); it('initializes the disabled property to false by default', () => { - const urlBasePath = undefined; const spec = { id: 'kibana:discover', title: 'Discover', @@ -138,13 +118,12 @@ describe('UiNavLink', () => { description: 'interactively explore your data', icon: 'plugins/kibana/assets/discover.svg', }; - const link = new UiNavLink(urlBasePath, spec); + const link = new UiNavLink(spec); expect(link.toJSON()).to.have.property('disabled', false); }); it('initializes the tooltip property to an empty string by default', () => { - const urlBasePath = undefined; const spec = { id: 'kibana:discover', title: 'Discover', @@ -153,7 +132,7 @@ describe('UiNavLink', () => { description: 'interactively explore your data', icon: 'plugins/kibana/assets/discover.svg', }; - const link = new UiNavLink(urlBasePath, spec); + const link = new UiNavLink(spec); expect(link.toJSON()).to.have.property('tooltip', ''); }); diff --git a/src/ui/ui_nav_links/ui_nav_link.js b/src/ui/ui_nav_links/ui_nav_link.js index 8ecf6b2cb67825..fe2d7c84b40a17 100644 --- a/src/ui/ui_nav_links/ui_nav_link.js +++ b/src/ui/ui_nav_links/ui_nav_link.js @@ -18,7 +18,7 @@ */ export class UiNavLink { - constructor(urlBasePath, spec) { + constructor(spec) { const { id, title, @@ -36,8 +36,8 @@ export class UiNavLink { this._id = id; this._title = title; this._order = order; - this._url = `${urlBasePath || ''}${url}`; - this._subUrlBase = `${urlBasePath || ''}${subUrlBase || url}`; + this._url = url; + this._subUrlBase = subUrlBase || url; this._description = description; this._icon = icon; this._linkToLastSubUrl = linkToLastSubUrl; diff --git a/src/ui/ui_nav_links/ui_nav_links_mixin.js b/src/ui/ui_nav_links/ui_nav_links_mixin.js index 2c94135a113e77..ef51000a3b0af5 100644 --- a/src/ui/ui_nav_links/ui_nav_links_mixin.js +++ b/src/ui/ui_nav_links/ui_nav_links_mixin.js @@ -19,14 +19,13 @@ import { UiNavLink } from './ui_nav_link'; -export function uiNavLinksMixin(kbnServer, server, config) { +export function uiNavLinksMixin(kbnServer, server) { const uiApps = server.getAllUiApps(); const { navLinkSpecs = [] } = kbnServer.uiExports; - const urlBasePath = config.get('server.basePath'); const fromSpecs = navLinkSpecs - .map(navLinkSpec => new UiNavLink(urlBasePath, navLinkSpec)); + .map(navLinkSpec => new UiNavLink(navLinkSpec)); const fromApps = uiApps .map(app => app.getNavLink()) diff --git a/src/ui/ui_render/ui_render_mixin.js b/src/ui/ui_render/ui_render_mixin.js index bc19a27498dc90..30ca1357b349e1 100644 --- a/src/ui/ui_render/ui_render_mixin.js +++ b/src/ui/ui_render/ui_render_mixin.js @@ -124,7 +124,7 @@ export function uiRenderMixin(kbnServer, server, config) { branch: config.get('pkg.branch'), buildNum: config.get('pkg.buildNum'), buildSha: config.get('pkg.buildSha'), - basePath: config.get('server.basePath'), + basePath: request.getBasePath(), serverName: config.get('server.name'), devMode: config.get('env.dev'), uiSettings: await props({ @@ -138,7 +138,7 @@ export function uiRenderMixin(kbnServer, server, config) { try { const request = reply.request; const translations = await server.getUiTranslations(); - const basePath = config.get('server.basePath'); + const basePath = request.getBasePath(); return reply.view('ui_app', { uiPublicUrl: `${basePath}/ui`, diff --git a/src/ui/ui_settings/create_or_upgrade_saved_config/__tests__/create_or_upgrade_integration.js b/src/ui/ui_settings/create_or_upgrade_saved_config/__tests__/create_or_upgrade_integration.js index 1c8ade94456d0d..34415cb6aeef50 100644 --- a/src/ui/ui_settings/create_or_upgrade_saved_config/__tests__/create_or_upgrade_integration.js +++ b/src/ui/ui_settings/create_or_upgrade_saved_config/__tests__/create_or_upgrade_integration.js @@ -201,4 +201,4 @@ describe('createOrUpgradeSavedConfig()', () => { '5.4.0-rc1': true, }); }); -}); +}); \ No newline at end of file diff --git a/src/ui/ui_settings/create_or_upgrade_saved_config/__tests__/create_or_upgrade_saved_config.js b/src/ui/ui_settings/create_or_upgrade_saved_config/__tests__/create_or_upgrade_saved_config.js index e25002e9c9c1c1..b0b823800a660a 100644 --- a/src/ui/ui_settings/create_or_upgrade_saved_config/__tests__/create_or_upgrade_saved_config.js +++ b/src/ui/ui_settings/create_or_upgrade_saved_config/__tests__/create_or_upgrade_saved_config.js @@ -135,4 +135,4 @@ describe('uiSettings/createOrUpgradeSavedConfig', function () { ); }); }); -}); +}); \ No newline at end of file diff --git a/src/ui/ui_settings/create_or_upgrade_saved_config/create_or_upgrade_saved_config.js b/src/ui/ui_settings/create_or_upgrade_saved_config/create_or_upgrade_saved_config.js index bf3a49fcbdc3ea..4bd4d8e86b73a9 100644 --- a/src/ui/ui_settings/create_or_upgrade_saved_config/create_or_upgrade_saved_config.js +++ b/src/ui/ui_settings/create_or_upgrade_saved_config/create_or_upgrade_saved_config.js @@ -55,4 +55,4 @@ export async function createOrUpgradeSavedConfig(options) { attributes, { id: version } ); -} +} \ No newline at end of file diff --git a/src/ui/ui_settings/ui_settings_service.js b/src/ui/ui_settings/ui_settings_service.js index 6234884fdb8816..f7f97509e6596a 100644 --- a/src/ui/ui_settings/ui_settings_service.js +++ b/src/ui/ui_settings/ui_settings_service.js @@ -197,4 +197,4 @@ export class UiSettingsService { throw error; } } -} +} \ No newline at end of file diff --git a/src/ui/ui_settings/ui_settings_service_factory.js b/src/ui/ui_settings/ui_settings_service_factory.js index 595c8a2d904d5a..7112ddf7374e41 100644 --- a/src/ui/ui_settings/ui_settings_service_factory.js +++ b/src/ui/ui_settings/ui_settings_service_factory.js @@ -49,4 +49,4 @@ export function uiSettingsServiceFactory(server, options) { overrides, log: (...args) => server.log(...args), }); -} +} \ No newline at end of file diff --git a/test/api_integration/fixtures/es_archiver/saved_objects/basic/data.json.gz b/test/api_integration/fixtures/es_archiver/saved_objects/basic/data.json.gz index c07188439b0e07..ac2a10f42f4dc9 100644 Binary files a/test/api_integration/fixtures/es_archiver/saved_objects/basic/data.json.gz and b/test/api_integration/fixtures/es_archiver/saved_objects/basic/data.json.gz differ diff --git a/test/api_integration/fixtures/es_archiver/saved_objects/basic/mappings.json b/test/api_integration/fixtures/es_archiver/saved_objects/basic/mappings.json index 26c62bca335d94..4f3ead9f47c679 100644 --- a/test/api_integration/fixtures/es_archiver/saved_objects/basic/mappings.json +++ b/test/api_integration/fixtures/es_archiver/saved_objects/basic/mappings.json @@ -188,6 +188,9 @@ } } }, + "namespace": { + "type": "keyword" + }, "type": { "type": "keyword" }, @@ -249,4 +252,4 @@ } } } -} \ No newline at end of file +} diff --git a/tsconfig.json b/tsconfig.json index 6f43c1722cd1b1..0ca1bef98f432d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,14 +2,14 @@ "compilerOptions": { "baseUrl": ".", "paths": { - "ui/*": ["src/ui/public/*"] + "ui/*": [ + "src/ui/public/*" + ] }, // Support .tsx files and transform JSX into calls to React.createElement "jsx": "react", - // Enables all strict type checking options. "strict": true, - // enables "core language features" "lib": [ // ESNext auto includes previous versions all the way back to es5 @@ -17,39 +17,29 @@ // includes support for browser APIs "dom" ], - // Node 8 should support everything output by esnext, we override this // in webpack with loader-level compiler options "target": "esnext", - // Use commonjs for node, overridden in webpack to keep import statements // to maintain support for things like `await import()` "module": "commonjs", - // Allows default imports from modules with no default export. This does not affect code emit, just type checking. // We have to enable this option explicitly since `esModuleInterop` doesn't enable it automatically when ES2015 or // ESNext module format is used. "allowSyntheticDefaultImports": true, - // Emits __importStar and __importDefault helpers for runtime babel ecosystem compatibility. "esModuleInterop": true, - // Resolve modules in the same way as Node.js. Aka make `require` works the // same in TypeScript as it does in Node.js. "moduleResolution": "node", - // Disallow inconsistently-cased references to the same file. "forceConsistentCasingInFileNames": true, - // Disable the breaking keyof behaviour introduced in TS 2.9.2 until EUI is updated to support that too "keyofStringsOnly": true, - // Forbid unused local variables as the rule was deprecated by ts-lint "noUnusedLocals": true, - // Provide full support for iterables in for..of, spread and destructuring when targeting ES5 or ES3. "downlevelIteration": true, - // import tslib helpers rather than inlining helpers for iteration or spreading, for instance "importHelpers": true }, @@ -64,4 +54,4 @@ // the tsconfig.json file for public files correctly. // "src/**/public/**/*" ] -} +} \ No newline at end of file diff --git a/x-pack/index.js b/x-pack/index.js index 3f48ca4de922a9..5988b797990378 100644 --- a/x-pack/index.js +++ b/x-pack/index.js @@ -21,6 +21,7 @@ import { licenseManagement } from './plugins/license_management'; import { cloud } from './plugins/cloud'; import { indexManagement } from './plugins/index_management'; import { consoleExtensions } from './plugins/console_extensions'; +import { spaces } from './plugins/spaces'; import { notifications } from './plugins/notifications'; import { kueryAutocomplete } from './plugins/kuery_autocomplete'; import { canvas } from './plugins/canvas'; @@ -31,6 +32,7 @@ module.exports = function (kibana) { graph(kibana), monitoring(kibana), reporting(kibana), + spaces(kibana), security(kibana), searchprofiler(kibana), ml(kibana), diff --git a/x-pack/package.json b/x-pack/package.json index 11d725ec330d2c..b49161aabdd405 100644 --- a/x-pack/package.json +++ b/x-pack/package.json @@ -25,8 +25,12 @@ "@kbn/es": "link:../packages/kbn-es", "@kbn/plugin-helpers": "link:../packages/kbn-plugin-helpers", "@kbn/test": "link:../packages/kbn-test", + "@types/expect.js": "^0.3.29", "@types/jest": "^23.3.1", + "@types/joi": "^10.4.4", + "@types/mocha": "^5.2.5", "@types/pngjs": "^3.3.1", + "@types/supertest": "^2.0.5", "abab": "^1.0.4", "ansi-colors": "^3.0.5", "ansicolors": "0.3.2", @@ -212,4 +216,4 @@ "engines": { "yarn": "^1.6.0" } -} +} \ No newline at end of file diff --git a/x-pack/plugins/apm/public/components/app/Main/__test__/Breadcrumbs.test.js b/x-pack/plugins/apm/public/components/app/Main/__test__/Breadcrumbs.test.js index 24df0baec12afd..c69c320aace972 100644 --- a/x-pack/plugins/apm/public/components/app/Main/__test__/Breadcrumbs.test.js +++ b/x-pack/plugins/apm/public/components/app/Main/__test__/Breadcrumbs.test.js @@ -11,6 +11,28 @@ import { MemoryRouter } from 'react-router-dom'; import Breadcrumbs from '../Breadcrumbs'; import { toJson } from '../../../../utils/testHelpers'; +jest.mock( + 'ui/chrome', + () => ({ + getBasePath: () => `/some/base/path`, + getUiSettingsClient: () => { + return { + get: key => { + switch (key) { + case 'timepicker:timeDefaults': + return { from: 'now-15m', to: 'now', mode: 'quick' }; + case 'timepicker:refreshIntervalDefaults': + return { display: 'Off', pause: false, value: 0 }; + default: + throw new Error(`Unexpected config key: ${key}`); + } + } + }; + } + }), + { virtual: true } +); + function expectBreadcrumbToMatchSnapshot(route) { const wrapper = mount( diff --git a/x-pack/plugins/apm/public/components/app/TransactionOverview/__jest__/TransactionOverview.test.js b/x-pack/plugins/apm/public/components/app/TransactionOverview/__jest__/TransactionOverview.test.js index 4fee02f2627633..0e3e5b64208257 100644 --- a/x-pack/plugins/apm/public/components/app/TransactionOverview/__jest__/TransactionOverview.test.js +++ b/x-pack/plugins/apm/public/components/app/TransactionOverview/__jest__/TransactionOverview.test.js @@ -9,6 +9,34 @@ import { shallow } from 'enzyme'; import TransactionOverview from '../view'; import { toJson } from '../../../../utils/testHelpers'; +jest.mock( + 'ui/chrome', + () => ({ + getBasePath: () => `/some/base/path`, + getInjected: key => { + if (key === 'mlEnabled') { + return true; + } + throw new Error(`inexpected key ${key}`); + }, + getUiSettingsClient: () => { + return { + get: key => { + switch (key) { + case 'timepicker:timeDefaults': + return { from: 'now-15m', to: 'now', mode: 'quick' }; + case 'timepicker:refreshIntervalDefaults': + return { display: 'Off', pause: false, value: 0 }; + default: + throw new Error(`Unexpected config key: ${key}`); + } + } + }; + } + }), + { virtual: true } +); + const setup = () => { const props = { license: { diff --git a/x-pack/plugins/ml/public/explorer/explorer_charts/explorer_chart.test.js b/x-pack/plugins/ml/public/explorer/explorer_charts/explorer_chart.test.js index fbf4d97a89bcee..9a845cd148ac68 100644 --- a/x-pack/plugins/ml/public/explorer/explorer_charts/explorer_chart.test.js +++ b/x-pack/plugins/ml/public/explorer/explorer_charts/explorer_chart.test.js @@ -21,6 +21,12 @@ jest.mock('../../services/field_format_service', () => ({ getFieldFormat: jest.fn() } })); +jest.mock('ui/chrome', () => ({ + getBasePath: (path) => path, + getUiSettingsClient: () => ({ + get: () => null + }), +})); import { mount } from 'enzyme'; import React from 'react'; diff --git a/x-pack/plugins/ml/public/explorer/explorer_charts/explorer_charts_container.test.js b/x-pack/plugins/ml/public/explorer/explorer_charts/explorer_charts_container.test.js index b8e81abb07df08..1036caccfe168f 100644 --- a/x-pack/plugins/ml/public/explorer/explorer_charts/explorer_charts_container.test.js +++ b/x-pack/plugins/ml/public/explorer/explorer_charts/explorer_charts_container.test.js @@ -21,6 +21,12 @@ jest.mock('../../services/field_format_service', () => ({ getFieldFormat: jest.fn() } })); +jest.mock('ui/chrome', () => ({ + getBasePath: (path) => path, + getUiSettingsClient: () => ({ + get: () => null + }), +})); // The mocks for ui/chrome and ui/timefilter are copied from charts_utils.test.js // TODO: Refactor the involved tests to avoid this duplication diff --git a/x-pack/plugins/ml/public/explorer/explorer_charts/explorer_charts_container_service.test.js b/x-pack/plugins/ml/public/explorer/explorer_charts/explorer_charts_container_service.test.js index 6978494e151d21..df97ff35bd07b3 100644 --- a/x-pack/plugins/ml/public/explorer/explorer_charts/explorer_charts_container_service.test.js +++ b/x-pack/plugins/ml/public/explorer/explorer_charts/explorer_charts_container_service.test.js @@ -34,6 +34,13 @@ jest.mock('../../util/string_utils', () => ({ mlEscape(d) { return d; } })); +jest.mock('ui/chrome', () => ({ + getBasePath: (path) => path, + getUiSettingsClient: () => ({ + get: () => null + }), +})); + const mockMlSelectSeverityService = { state: { get() { return { display: 'warning', val: 0 }; } diff --git a/x-pack/plugins/ml/public/explorer/explorer_swimlane.test.js b/x-pack/plugins/ml/public/explorer/explorer_swimlane.test.js index 278ac487bfc2e5..723a8e858dcff7 100644 --- a/x-pack/plugins/ml/public/explorer/explorer_swimlane.test.js +++ b/x-pack/plugins/ml/public/explorer/explorer_swimlane.test.js @@ -12,6 +12,13 @@ import React from 'react'; import { ExplorerSwimlane } from './explorer_swimlane'; +jest.mock('ui/chrome', () => ({ + getBasePath: path => path, + getUiSettingsClient: () => ({ + get: jest.fn() + }), +})); + function getExplorerSwimlaneMocks() { const mlExplorerDashboardService = { allowCellRangeSelection: false, diff --git a/x-pack/plugins/monitoring/server/kibana_monitoring/bulk_uploader.js b/x-pack/plugins/monitoring/server/kibana_monitoring/bulk_uploader.js index b838ada1a86ca0..6eeab649f0e0de 100644 --- a/x-pack/plugins/monitoring/server/kibana_monitoring/bulk_uploader.js +++ b/x-pack/plugins/monitoring/server/kibana_monitoring/bulk_uploader.js @@ -40,7 +40,7 @@ export class BulkUploader { throw new Error('interval number of milliseconds is required'); } - this._timer = null; + this._timer = null; this._interval = interval; this._log = { debug: message => server.log(['debug', ...LOGGING_TAGS], message), diff --git a/x-pack/plugins/reporting/export_types/csv/server/__tests__/execute_job.js b/x-pack/plugins/reporting/export_types/csv/server/__tests__/execute_job.js index 1f0504db3681d9..1d409e45c092ca 100644 --- a/x-pack/plugins/reporting/export_types/csv/server/__tests__/execute_job.js +++ b/x-pack/plugins/reporting/export_types/csv/server/__tests__/execute_job.js @@ -109,13 +109,35 @@ describe('CSV Execute Job', function () { mockServer.config().get.withArgs('xpack.reporting.csv.scroll').returns({}); }); - describe('savedObjects', function () { - it('calls getScopedSavedObjectsClient with request containing decrypted headers', async function () { + describe('calls getScopedSavedObjectsClient with request', function () { + it('containing decrypted headers', async function () { const executeJob = executeJobFactory(mockServer); await executeJob({ headers: encryptedHeaders, fields: [], searchRequest: { index: null, body: null } }, cancellationToken); expect(mockServer.savedObjects.getScopedSavedObjectsClient.calledOnce).to.be(true); expect(mockServer.savedObjects.getScopedSavedObjectsClient.firstCall.args[0].headers).to.be.eql(headers); }); + + it(`containing getBasePath() returning server's basePath if the job doesn't have one`, async function () { + const serverBasePath = '/foo-server/basePath/'; + mockServer.config().get.withArgs('server.basePath').returns(serverBasePath); + const executeJob = executeJobFactory(mockServer); + await executeJob({ headers: encryptedHeaders, fields: [], searchRequest: { index: null, body: null } }, cancellationToken); + expect(mockServer.savedObjects.getScopedSavedObjectsClient.calledOnce).to.be(true); + expect(mockServer.savedObjects.getScopedSavedObjectsClient.firstCall.args[0].getBasePath()).to.be.eql(serverBasePath); + }); + + it(`containing getBasePath() returning job's basePath if the job has one`, async function () { + const serverBasePath = '/foo-server/basePath/'; + mockServer.config().get.withArgs('server.basePath').returns(serverBasePath); + const executeJob = executeJobFactory(mockServer); + const jobBasePath = 'foo-job/basePath/'; + await executeJob( + { headers: encryptedHeaders, fields: [], searchRequest: { index: null, body: null }, basePath: jobBasePath }, + cancellationToken + ); + expect(mockServer.savedObjects.getScopedSavedObjectsClient.calledOnce).to.be(true); + expect(mockServer.savedObjects.getScopedSavedObjectsClient.firstCall.args[0].getBasePath()).to.be.eql(jobBasePath); + }); }); describe('uiSettings', function () { diff --git a/x-pack/plugins/reporting/export_types/csv/server/create_job.js b/x-pack/plugins/reporting/export_types/csv/server/create_job.js index 02d78a97ef9bee..f116cbb763014b 100644 --- a/x-pack/plugins/reporting/export_types/csv/server/create_job.js +++ b/x-pack/plugins/reporting/export_types/csv/server/create_job.js @@ -21,6 +21,7 @@ function createJobFn(server) { return { headers: serializedEncryptedHeaders, indexPatternSavedObject: indexPatternSavedObject, + basePath: request.getBasePath(), ...jobParams }; }; diff --git a/x-pack/plugins/reporting/export_types/csv/server/execute_job.js b/x-pack/plugins/reporting/export_types/csv/server/execute_job.js index a407cacc63fefd..baa1cd458c8a02 100644 --- a/x-pack/plugins/reporting/export_types/csv/server/execute_job.js +++ b/x-pack/plugins/reporting/export_types/csv/server/execute_job.js @@ -16,9 +16,18 @@ function executeJobFn(server) { const config = server.config(); const logger = createTaggedLogger(server, ['reporting', 'csv', 'debug']); const generateCsv = createGenerateCsv(logger); + const serverBasePath = config.get('server.basePath'); return async function executeJob(job, cancellationToken) { - const { searchRequest, fields, indexPatternSavedObject, metaFields, conflictedTypesFields, headers: serializedEncryptedHeaders } = job; + const { + searchRequest, + fields, + indexPatternSavedObject, + metaFields, + conflictedTypesFields, + headers: serializedEncryptedHeaders, + basePath + } = job; let decryptedHeaders; try { @@ -31,6 +40,10 @@ function executeJobFn(server) { const fakeRequest = { headers: decryptedHeaders, + // This is used by the spaces SavedObjectClientWrapper to determine the existing space. + // We use the basePath from the saved job, which we'll have post spaces being implemented; + // or we use the server base path, which uses the default space + getBasePath: () => basePath || serverBasePath, }; const callEndpoint = (endpoint, clientParams = {}, options = {}) => { diff --git a/x-pack/plugins/reporting/export_types/printable_pdf/server/create_job/index.js b/x-pack/plugins/reporting/export_types/printable_pdf/server/create_job/index.js index bfd3cb6eaa9d56..bab11f10045522 100644 --- a/x-pack/plugins/reporting/export_types/printable_pdf/server/create_job/index.js +++ b/x-pack/plugins/reporting/export_types/printable_pdf/server/create_job/index.js @@ -18,7 +18,7 @@ function createJobFn(server) { relativeUrls, browserTimezone, layout - }, headers) { + }, headers, request) { const serializedEncryptedHeaders = await crypto.encrypt(headers); return { @@ -28,6 +28,7 @@ function createJobFn(server) { headers: serializedEncryptedHeaders, browserTimezone, layout, + basePath: request.getBasePath(), forceNow: new Date().toISOString(), }; }); diff --git a/x-pack/plugins/reporting/export_types/printable_pdf/server/execute_job/compatibility_shim.js b/x-pack/plugins/reporting/export_types/printable_pdf/server/execute_job/compatibility_shim.js index f2d5430f9e1fcf..df55bb75d2621b 100644 --- a/x-pack/plugins/reporting/export_types/printable_pdf/server/execute_job/compatibility_shim.js +++ b/x-pack/plugins/reporting/export_types/printable_pdf/server/execute_job/compatibility_shim.js @@ -10,28 +10,28 @@ import { getAbsoluteUrlFactory } from './get_absolute_url'; export function compatibilityShimFactory(server) { const getAbsoluteUrl = getAbsoluteUrlFactory(server); - const getSavedObjectAbsoluteUrl = (savedObj) => { - if (savedObj.urlHash) { - return getAbsoluteUrl({ hash: savedObj.urlHash }); + const getSavedObjectAbsoluteUrl = (job, savedObject) => { + if (savedObject.urlHash) { + return getAbsoluteUrl({ hash: savedObject.urlHash }); } - if (savedObj.relativeUrl) { - const { pathname: path, hash, search } = url.parse(savedObj.relativeUrl); - return getAbsoluteUrl({ path, hash, search }); + if (savedObject.relativeUrl) { + const { pathname: path, hash, search } = url.parse(savedObject.relativeUrl); + return getAbsoluteUrl({ basePath: job.basePath, path, hash, search }); } - if (savedObj.url.startsWith(getAbsoluteUrl())) { - return savedObj.url; + if (savedObject.url.startsWith(getAbsoluteUrl())) { + return savedObject.url; } - throw new Error(`Unable to generate report for url ${savedObj.url}, it's not a Kibana URL`); + throw new Error(`Unable to generate report for url ${savedObject.url}, it's not a Kibana URL`); }; return function (executeJob) { return async function (job, cancellationToken) { - const urls = job.objects.map(getSavedObjectAbsoluteUrl); + const urls = job.objects.map(savedObject => getSavedObjectAbsoluteUrl(job, savedObject)); return await executeJob({ ...job, urls }, cancellationToken); }; }; -} \ No newline at end of file +} diff --git a/x-pack/plugins/reporting/export_types/printable_pdf/server/execute_job/compatibility_shim.test.js b/x-pack/plugins/reporting/export_types/printable_pdf/server/execute_job/compatibility_shim.test.js index 7552ebc665cba1..f60bc0d83e1550 100644 --- a/x-pack/plugins/reporting/export_types/printable_pdf/server/execute_job/compatibility_shim.test.js +++ b/x-pack/plugins/reporting/export_types/printable_pdf/server/execute_job/compatibility_shim.test.js @@ -54,7 +54,7 @@ test(`it generates the absolute url if a urlHash is provided`, async () => { expect(mockCreateJob.mock.calls[0][0].urls[0]).toBe('http://localhost:5601/app/kibana#visualize'); }); -test(`it generates the absolute url if a relativeUrl is provided`, async () => { +test(`it generates the absolute url using server's basePath if a relativeUrl is provided`, async () => { const mockCreateJob = jest.fn(); const compatibilityShim = compatibilityShimFactory(createMockServer()); @@ -64,7 +64,17 @@ test(`it generates the absolute url if a relativeUrl is provided`, async () => { expect(mockCreateJob.mock.calls[0][0].urls[0]).toBe('http://localhost:5601/app/kibana#/visualize?'); }); -test(`it generates the absolute url if a relativeUrl with querystring is provided`, async () => { +test(`it generates the absolute url using job's basePath if a relativeUrl is provided`, async () => { + const mockCreateJob = jest.fn(); + const compatibilityShim = compatibilityShimFactory(createMockServer()); + + const relativeUrl = '/app/kibana#/visualize?'; + await compatibilityShim(mockCreateJob)({ basePath: '/s/marketing', objects: [ { relativeUrl } ] }); + expect(mockCreateJob.mock.calls.length).toBe(1); + expect(mockCreateJob.mock.calls[0][0].urls[0]).toBe('http://localhost:5601/s/marketing/app/kibana#/visualize?'); +}); + +test(`it generates the absolute url using server's basePath if a relativeUrl with querystring is provided`, async () => { const mockCreateJob = jest.fn(); const compatibilityShim = compatibilityShimFactory(createMockServer()); @@ -74,6 +84,16 @@ test(`it generates the absolute url if a relativeUrl with querystring is provide expect(mockCreateJob.mock.calls[0][0].urls[0]).toBe('http://localhost:5601/app/kibana?_t=123456789#/visualize?_g=()'); }); +test(`it generates the absolute url using job's basePath if a relativeUrl with querystring is provided`, async () => { + const mockCreateJob = jest.fn(); + const compatibilityShim = compatibilityShimFactory(createMockServer()); + + const relativeUrl = '/app/kibana?_t=123456789#/visualize?_g=()'; + await compatibilityShim(mockCreateJob)({ basePath: '/s/marketing', objects: [ { relativeUrl } ] }); + expect(mockCreateJob.mock.calls.length).toBe(1); + expect(mockCreateJob.mock.calls[0][0].urls[0]).toBe('http://localhost:5601/s/marketing/app/kibana?_t=123456789#/visualize?_g=()'); +}); + test(`it passes the provided browserTimezone through`, async () => { const mockCreateJob = jest.fn(); const compatibilityShim = compatibilityShimFactory(createMockServer()); diff --git a/x-pack/plugins/reporting/export_types/printable_pdf/server/execute_job/get_absolute_url.js b/x-pack/plugins/reporting/export_types/printable_pdf/server/execute_job/get_absolute_url.js index e2f594eec609fb..b224d0835fa945 100644 --- a/x-pack/plugins/reporting/export_types/printable_pdf/server/execute_job/get_absolute_url.js +++ b/x-pack/plugins/reporting/export_types/printable_pdf/server/execute_job/get_absolute_url.js @@ -11,6 +11,7 @@ function getAbsoluteUrlFn(server) { const config = server.config(); return function getAbsoluteUrl({ + basePath = config.get('server.basePath'), hash, path = '/app/kibana', search @@ -19,7 +20,7 @@ function getAbsoluteUrlFn(server) { protocol: config.get('xpack.reporting.kibanaServer.protocol') || server.info.protocol, hostname: config.get('xpack.reporting.kibanaServer.hostname') || config.get('server.host'), port: config.get('xpack.reporting.kibanaServer.port') || config.get('server.port'), - pathname: config.get('server.basePath') + path, + pathname: basePath + path, hash: hash, search }); diff --git a/x-pack/plugins/reporting/export_types/printable_pdf/server/execute_job/get_absolute_url.test.js b/x-pack/plugins/reporting/export_types/printable_pdf/server/execute_job/get_absolute_url.test.js index 1391d4665cb509..39ca6fd52f51e1 100644 --- a/x-pack/plugins/reporting/export_types/printable_pdf/server/execute_job/get_absolute_url.test.js +++ b/x-pack/plugins/reporting/export_types/printable_pdf/server/execute_job/get_absolute_url.test.js @@ -92,6 +92,14 @@ test(`uses the provided hash with queryString`, () => { expect(absoluteUrl).toBe(`http://something:8080/tst/app/kibana#${hash}`); }); +test(`uses the provided basePath`, () => { + const mockServer = createMockServer(); + + const getAbsoluteUrl = getAbsoluteUrlFactory(mockServer); + const absoluteUrl = getAbsoluteUrl({ basePath: '/s/marketing' }); + expect(absoluteUrl).toBe(`http://something:8080/s/marketing/app/kibana`); +}); + test(`uses the path`, () => { const mockServer = createMockServer(); @@ -109,3 +117,5 @@ test(`uses the search`, () => { const absoluteUrl = getAbsoluteUrl({ search }); expect(absoluteUrl).toBe(`http://something:8080/tst/app/kibana?${search}`); }); + + diff --git a/x-pack/plugins/reporting/export_types/printable_pdf/server/execute_job/index.js b/x-pack/plugins/reporting/export_types/printable_pdf/server/execute_job/index.js index 103e3b4c3293d3..72969083d98e3b 100644 --- a/x-pack/plugins/reporting/export_types/printable_pdf/server/execute_job/index.js +++ b/x-pack/plugins/reporting/export_types/printable_pdf/server/execute_job/index.js @@ -31,6 +31,8 @@ function executeJobFn(server) { const crypto = cryptoFactory(server); const compatibilityShim = compatibilityShimFactory(server); + const serverBasePath = server.config().get('server.basePath'); + const decryptJobHeaders = async (job) => { const decryptedHeaders = await crypto.decrypt(job.headers); return { job, decryptedHeaders }; @@ -44,6 +46,10 @@ function executeJobFn(server) { const getCustomLogo = async ({ job, filteredHeaders }) => { const fakeRequest = { headers: filteredHeaders, + // This is used by the spaces SavedObjectClientWrapper to determine the existing space. + // We use the basePath from the saved job, which we'll have post spaces being implemented; + // or we use the server base path, which uses the default space + getBasePath: () => job.basePath || serverBasePath }; const savedObjects = server.savedObjects; diff --git a/x-pack/plugins/reporting/export_types/printable_pdf/server/execute_job/index.test.js b/x-pack/plugins/reporting/export_types/printable_pdf/server/execute_job/index.test.js index 2b4ea67d5895c9..10c68f508a7369 100644 --- a/x-pack/plugins/reporting/export_types/printable_pdf/server/execute_job/index.test.js +++ b/x-pack/plugins/reporting/export_types/printable_pdf/server/execute_job/index.test.js @@ -42,7 +42,7 @@ beforeEach(() => { 'xpack.reporting.kibanaServer.protocol': 'http', 'xpack.reporting.kibanaServer.hostname': 'localhost', 'xpack.reporting.kibanaServer.port': 5601, - 'server.basePath': '' + 'server.basePath': '/sbp' }[key]; }); @@ -106,6 +106,37 @@ test(`omits blacklisted headers`, async () => { expect(generatePdfObservable).toBeCalledWith(undefined, [], undefined, permittedHeaders, undefined, undefined); }); +test('uses basePath from job when creating saved object service', async () => { + const encryptedHeaders = await encryptHeaders({}); + + const logo = 'custom-logo'; + mockServer.uiSettingsServiceFactory().get.mockReturnValue(logo); + + const generatePdfObservable = generatePdfObservableFactory(); + generatePdfObservable.mockReturnValue(Rx.of(Buffer.from(''))); + + const executeJob = executeJobFactory(mockServer); + const jobBasePath = '/sbp/s/marketing'; + await executeJob({ objects: [], headers: encryptedHeaders, basePath: jobBasePath }, cancellationToken); + + expect(mockServer.savedObjects.getScopedSavedObjectsClient.mock.calls[0][0].getBasePath()).toBe(jobBasePath); +}); + +test(`uses basePath from server if job doesn't have a basePath when creating saved object service`, async () => { + const encryptedHeaders = await encryptHeaders({}); + + const logo = 'custom-logo'; + mockServer.uiSettingsServiceFactory().get.mockReturnValue(logo); + + const generatePdfObservable = generatePdfObservableFactory(); + generatePdfObservable.mockReturnValue(Rx.of(Buffer.from(''))); + + const executeJob = executeJobFactory(mockServer); + await executeJob({ objects: [], headers: encryptedHeaders }, cancellationToken); + + expect(mockServer.savedObjects.getScopedSavedObjectsClient.mock.calls[0][0].getBasePath()).toBe('/sbp'); +}); + test(`gets logo from uiSettings`, async () => { const encryptedHeaders = await encryptHeaders({}); @@ -145,9 +176,9 @@ test(`adds forceNow to hash's query, if it exists`, async () => { const executeJob = executeJobFactory(mockServer); const forceNow = '2000-01-01T00:00:00.000Z'; - await executeJob({ objects: [{ relativeUrl: 'app/kibana#/something' }], forceNow, headers: encryptedHeaders }, cancellationToken); + await executeJob({ objects: [{ relativeUrl: '/app/kibana#/something' }], forceNow, headers: encryptedHeaders }, cancellationToken); - expect(generatePdfObservable).toBeCalledWith(undefined, ['http://localhost:5601/app/kibana#/something?forceNow=2000-01-01T00%3A00%3A00.000Z'], undefined, {}, undefined, undefined); + expect(generatePdfObservable).toBeCalledWith(undefined, ['http://localhost:5601/sbp/app/kibana#/something?forceNow=2000-01-01T00%3A00%3A00.000Z'], undefined, {}, undefined, undefined); }); test(`appends forceNow to hash's query, if it exists`, async () => { @@ -160,12 +191,12 @@ test(`appends forceNow to hash's query, if it exists`, async () => { const forceNow = '2000-01-01T00:00:00.000Z'; await executeJob({ - objects: [{ relativeUrl: 'app/kibana#/something?_g=something' }], + objects: [{ relativeUrl: '/app/kibana#/something?_g=something' }], forceNow, headers: encryptedHeaders }, cancellationToken); - expect(generatePdfObservable).toBeCalledWith(undefined, ['http://localhost:5601/app/kibana#/something?_g=something&forceNow=2000-01-01T00%3A00%3A00.000Z'], undefined, {}, undefined, undefined); + expect(generatePdfObservable).toBeCalledWith(undefined, ['http://localhost:5601/sbp/app/kibana#/something?_g=something&forceNow=2000-01-01T00%3A00%3A00.000Z'], undefined, {}, undefined, undefined); }); test(`doesn't append forceNow query to url, if it doesn't exists`, async () => { @@ -176,9 +207,9 @@ test(`doesn't append forceNow query to url, if it doesn't exists`, async () => { const executeJob = executeJobFactory(mockServer); - await executeJob({ objects: [{ relativeUrl: 'app/kibana#/something' }], headers: encryptedHeaders }, cancellationToken); + await executeJob({ objects: [{ relativeUrl: '/app/kibana#/something' }], headers: encryptedHeaders }, cancellationToken); - expect(generatePdfObservable).toBeCalledWith(undefined, ['http://localhost:5601/app/kibana#/something'], undefined, {}, undefined, undefined); + expect(generatePdfObservable).toBeCalledWith(undefined, ['http://localhost:5601/sbp/app/kibana#/something'], undefined, {}, undefined, undefined); }); test(`returns content_type of application/pdf`, async () => { diff --git a/x-pack/plugins/security/common/constants.js b/x-pack/plugins/security/common/constants.js index 1b762d5f15acc6..a62085787cd470 100644 --- a/x-pack/plugins/security/common/constants.js +++ b/x-pack/plugins/security/common/constants.js @@ -4,4 +4,5 @@ * you may not use this file except in compliance with the Elastic License. */ -export const ALL_RESOURCE = '*'; +export const GLOBAL_RESOURCE = '*'; +export const IGNORED_TYPES = ['space']; diff --git a/x-pack/test/rbac_api_integration/apis/index.js b/x-pack/plugins/security/common/model/index_privilege.ts similarity index 51% rename from x-pack/test/rbac_api_integration/apis/index.js rename to x-pack/plugins/security/common/model/index_privilege.ts index cf26e2e7cf4d85..560e8df5e126b2 100644 --- a/x-pack/test/rbac_api_integration/apis/index.js +++ b/x-pack/plugins/security/common/model/index_privilege.ts @@ -4,10 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -export default function ({ loadTestFile }) { - describe('apis RBAC', () => { - loadTestFile(require.resolve('./es')); - loadTestFile(require.resolve('./privileges')); - loadTestFile(require.resolve('./saved_objects')); - }); +export interface IndexPrivilege { + names: string[]; + privileges: string[]; + field_security?: { + grant?: string[]; + }; + query?: string; } diff --git a/x-pack/plugins/security/common/model/kibana_privilege.ts b/x-pack/plugins/security/common/model/kibana_privilege.ts new file mode 100644 index 00000000000000..20cac65b4ca792 --- /dev/null +++ b/x-pack/plugins/security/common/model/kibana_privilege.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export type KibanaPrivilege = 'none' | 'read' | 'all'; + +export const KibanaAppPrivileges: KibanaPrivilege[] = ['read', 'all']; diff --git a/x-pack/plugins/security/common/model/role.ts b/x-pack/plugins/security/common/model/role.ts new file mode 100644 index 00000000000000..5b1094c8c3a0ab --- /dev/null +++ b/x-pack/plugins/security/common/model/role.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { IndexPrivilege } from './index_privilege'; +import { KibanaPrivilege } from './kibana_privilege'; + +export interface Role { + name: string; + elasticsearch: { + cluster: string[]; + indices: IndexPrivilege[]; + run_as: string[]; + }; + kibana: { + global: KibanaPrivilege[]; + space: { + [spaceId: string]: KibanaPrivilege[]; + }; + }; + metadata?: { + [anyKey: string]: any; + }; + transient_metadata?: { + [anyKey: string]: any; + }; +} diff --git a/x-pack/plugins/security/index.js b/x-pack/plugins/security/index.js index 2abd107758dad3..7262eadb999c63 100644 --- a/x-pack/plugins/security/index.js +++ b/x-pack/plugins/security/index.js @@ -16,12 +16,12 @@ import { validateConfig } from './server/lib/validate_config'; import { authenticateFactory } from './server/lib/auth_redirect'; import { checkLicense } from './server/lib/check_license'; import { initAuthenticator } from './server/lib/authentication/authenticator'; -import { initPrivilegesApi } from './server/routes/api/v1/privileges'; import { SecurityAuditLogger } from './server/lib/audit_logger'; import { AuditLogger } from '../../server/lib/audit_logger'; -import { SecureSavedObjectsClient } from './server/lib/saved_objects_client/secure_saved_objects_client'; -import { initAuthorizationService, registerPrivilegesWithCluster } from './server/lib/authorization'; -import { watchStatusAndLicenseToInitialize } from './server/lib/watch_status_and_license_to_initialize'; +import { createAuthorizationService, registerPrivilegesWithCluster } from './server/lib/authorization'; +import { watchStatusAndLicenseToInitialize } from '../../server/lib/watch_status_and_license_to_initialize'; +import { SecureSavedObjectsClientWrapper } from './server/lib/saved_objects_client/secure_saved_objects_client_wrapper'; +import { deepFreeze } from './server/lib/deep_freeze'; export const security = (kibana) => new kibana.Plugin({ id: 'security', @@ -78,6 +78,7 @@ export const security = (kibana) => new kibana.Plugin({ return { secureCookies: config.get('xpack.security.secureCookies'), sessionTimeout: config.get('xpack.security.sessionTimeout'), + enableSpaceAwarePrivileges: config.get('xpack.spaces.enabled'), }; } }, @@ -105,7 +106,8 @@ export const security = (kibana) => new kibana.Plugin({ server.auth.strategy('session', 'login', 'required'); // exposes server.plugins.security.authorization - initAuthorizationService(server); + const authorization = createAuthorizationService(server, xpackInfoFeature); + server.expose('authorization', deepFreeze(authorization)); watchStatusAndLicenseToInitialize(xpackMainPlugin, plugin, async (license) => { if (license.allowRbac) { @@ -123,38 +125,46 @@ export const security = (kibana) => new kibana.Plugin({ const { callWithRequest, callWithInternalUser } = adminCluster; const callCluster = (...args) => callWithRequest(request, ...args); + if (authorization.mode.useRbacForRequest(request)) { + const internalRepository = savedObjects.getSavedObjectsRepository(callWithInternalUser); + return new savedObjects.SavedObjectsClient(internalRepository); + } + const callWithRequestRepository = savedObjects.getSavedObjectsRepository(callCluster); + return new savedObjects.SavedObjectsClient(callWithRequestRepository); + }); - if (!xpackInfoFeature.getLicenseCheckResults().allowRbac) { - return new savedObjects.SavedObjectsClient(callWithRequestRepository); + savedObjects.addScopedSavedObjectsClientWrapperFactory(Number.MIN_VALUE, ({ client, request }) => { + if (authorization.mode.useRbacForRequest(request)) { + const { spaces } = server.plugins; + + return new SecureSavedObjectsClientWrapper({ + actions: authorization.actions, + auditLogger, + baseClient: client, + checkPrivilegesWithRequest: authorization.checkPrivilegesWithRequest, + errors: savedObjects.SavedObjectsClient.errors, + request, + savedObjectTypes: savedObjects.types, + spaces, + }); } - const { authorization } = server.plugins.security; - const checkPrivileges = authorization.checkPrivilegesWithRequest(request); - const internalRepository = savedObjects.getSavedObjectsRepository(callWithInternalUser); - - return new SecureSavedObjectsClient({ - internalRepository, - callWithRequestRepository, - errors: savedObjects.SavedObjectsClient.errors, - checkPrivileges, - auditLogger, - actions: authorization.actions, - }); + return client; }); getUserProvider(server); - await initAuthenticator(server); + await initAuthenticator(server, authorization.mode); initAuthenticateApi(server); initUsersApi(server); initPublicRolesApi(server); initIndicesApi(server); - initPrivilegesApi(server); initLoginView(server, xpackMainPlugin); initLogoutView(server); server.injectUiAppVars('login', () => { + const { showLogin, loginMessage, allowLogin, layout = 'form' } = xpackInfo.feature(plugin.id).getLicenseCheckResults() || {}; return { diff --git a/x-pack/plugins/security/public/documentation_links.js b/x-pack/plugins/security/public/documentation_links.js index 8d9bb3c2256b4d..d357451d48ac74 100644 --- a/x-pack/plugins/security/public/documentation_links.js +++ b/x-pack/plugins/security/public/documentation_links.js @@ -7,5 +7,8 @@ import { ELASTIC_WEBSITE_URL, DOC_LINK_VERSION } from 'ui/documentation_links'; export const documentationLinks = { - dashboardViewMode: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/xpack-view-modes.html` + dashboardViewMode: `${ELASTIC_WEBSITE_URL}guide/en/kibana/${DOC_LINK_VERSION}/xpack-view-modes.html`, + esClusterPrivileges: `${ELASTIC_WEBSITE_URL}guide/en/x-pack/${DOC_LINK_VERSION}/security-privileges.html#security-privileges`, + esIndicesPrivileges: `${ELASTIC_WEBSITE_URL}guide/en/x-pack/${DOC_LINK_VERSION}/security-privileges.html#privileges-list-indices`, + esRunAsPrivileges: `${ELASTIC_WEBSITE_URL}guide/en/x-pack/${DOC_LINK_VERSION}/security-privileges.html#_run_as_privilege`, }; diff --git a/x-pack/plugins/security/public/lib/__tests__/role.js b/x-pack/plugins/security/public/lib/__tests__/role.js deleted file mode 100644 index efb22152ef7b19..00000000000000 --- a/x-pack/plugins/security/public/lib/__tests__/role.js +++ /dev/null @@ -1,35 +0,0 @@ -/* - * 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 'expect.js'; -import { isRoleEnabled } from '../role'; - -describe('role', () => { - describe('isRoleEnabled', () => { - it('should return false if role is explicitly not enabled', () => { - const testRole = { - transient_metadata: { - enabled: false - } - }; - expect(isRoleEnabled(testRole)).to.be(false); - }); - - it('should return true if role is explicitly enabled', () => { - const testRole = { - transient_metadata: { - enabled: true - } - }; - expect(isRoleEnabled(testRole)).to.be(true); - }); - - it('should return true if role is NOT explicitly enabled or disabled', () => { - const testRole = {}; - expect(isRoleEnabled(testRole)).to.be(true); - }); - }); -}); diff --git a/x-pack/plugins/security/public/lib/role.test.ts b/x-pack/plugins/security/public/lib/role.test.ts new file mode 100644 index 00000000000000..c86b250e034f6e --- /dev/null +++ b/x-pack/plugins/security/public/lib/role.test.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 { isReservedRole, isRoleEnabled } from './role'; + +describe('role', () => { + describe('isRoleEnabled', () => { + test('should return false if role is explicitly not enabled', () => { + const testRole = { + transient_metadata: { + enabled: false, + }, + }; + expect(isRoleEnabled(testRole)).toBe(false); + }); + + test('should return true if role is explicitly enabled', () => { + const testRole = { + transient_metadata: { + enabled: true, + }, + }; + expect(isRoleEnabled(testRole)).toBe(true); + }); + + test('should return true if role is NOT explicitly enabled or disabled', () => { + const testRole = {}; + expect(isRoleEnabled(testRole)).toBe(true); + }); + }); + + describe('isReservedRole', () => { + test('should return false if role is explicitly not reserved', () => { + const testRole = { + metadata: { + _reserved: false, + }, + }; + expect(isReservedRole(testRole)).toBe(false); + }); + + test('should return true if role is explicitly reserved', () => { + const testRole = { + metadata: { + _reserved: true, + }, + }; + expect(isReservedRole(testRole)).toBe(true); + }); + + test('should return false if role is NOT explicitly reserved or not reserved', () => { + const testRole = {}; + expect(isReservedRole(testRole)).toBe(false); + }); + }); +}); diff --git a/x-pack/plugins/security/public/lib/role.js b/x-pack/plugins/security/public/lib/role.ts similarity index 61% rename from x-pack/plugins/security/public/lib/role.js rename to x-pack/plugins/security/public/lib/role.ts index 89eade0f0584e2..d6221f7aecb4c3 100644 --- a/x-pack/plugins/security/public/lib/role.js +++ b/x-pack/plugins/security/public/lib/role.ts @@ -5,6 +5,7 @@ */ import { get } from 'lodash'; +import { Role } from '../../common/model/role'; /** * Returns whether given role is enabled or not @@ -12,6 +13,15 @@ import { get } from 'lodash'; * @param role Object Role JSON, as returned by roles API * @return Boolean true if role is enabled; false otherwise */ -export function isRoleEnabled(role) { +export function isRoleEnabled(role: Partial) { return get(role, 'transient_metadata.enabled', true); -} \ No newline at end of file +} + +/** + * Returns whether given role is reserved or not. + * + * @param {role} the Role as returned by roles API + */ +export function isReservedRole(role: Partial) { + return get(role, 'metadata._reserved', false); +} diff --git a/x-pack/plugins/security/public/objects/index.ts b/x-pack/plugins/security/public/objects/index.ts new file mode 100644 index 00000000000000..a6238ca879901c --- /dev/null +++ b/x-pack/plugins/security/public/objects/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { saveRole, deleteRole } from './lib/roles'; + +export { getFields } from './lib/get_fields'; diff --git a/x-pack/plugins/security/public/objects/lib/get_fields.ts b/x-pack/plugins/security/public/objects/lib/get_fields.ts new file mode 100644 index 00000000000000..e0998eb8b8f6be --- /dev/null +++ b/x-pack/plugins/security/public/objects/lib/get_fields.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { IHttpResponse } from 'angular'; +import chrome from 'ui/chrome'; + +const apiBase = chrome.addBasePath(`/api/security/v1/fields`); + +export async function getFields($http: any, query: string): Promise { + return await $http + .get(`${apiBase}/${query}`) + .then((response: IHttpResponse) => response.data || []); +} diff --git a/x-pack/plugins/security/public/objects/lib/roles.ts b/x-pack/plugins/security/public/objects/lib/roles.ts new file mode 100644 index 00000000000000..2551d7eabc4e71 --- /dev/null +++ b/x-pack/plugins/security/public/objects/lib/roles.ts @@ -0,0 +1,19 @@ +/* + * 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 { omit } from 'lodash'; +import chrome from 'ui/chrome'; +import { Role } from '../../../common/model/role'; + +const apiBase = chrome.addBasePath(`/api/security/role`); + +export async function saveRole($http: any, role: Role) { + const data = omit(role, 'name', 'transient_metadata', '_unrecognized_applications'); + return await $http.put(`${apiBase}/${role.name}`, data); +} + +export async function deleteRole($http: any, name: string) { + return await $http.delete(`${apiBase}/${name}`); +} diff --git a/x-pack/plugins/security/public/services/application_privilege.js b/x-pack/plugins/security/public/services/application_privilege.js index 615188cad33d7f..00db6cccfb13e4 100644 --- a/x-pack/plugins/security/public/services/application_privilege.js +++ b/x-pack/plugins/security/public/services/application_privilege.js @@ -10,5 +10,9 @@ import { uiModules } from 'ui/modules'; const module = uiModules.get('security', ['ngResource']); module.service('ApplicationPrivileges', ($resource, chrome) => { const baseUrl = chrome.addBasePath('/api/security/v1/privileges'); - return $resource(baseUrl); + return $resource(baseUrl, null, { + query: { + isArray: false, + } + }); }); diff --git a/x-pack/plugins/security/public/services/role_privileges.js b/x-pack/plugins/security/public/services/role_privileges.js new file mode 100644 index 00000000000000..794a4b30674e5b --- /dev/null +++ b/x-pack/plugins/security/public/services/role_privileges.js @@ -0,0 +1,42 @@ +/* + * 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. + */ + +const clusterPrivileges = [ + 'all', + 'monitor', + 'manage', + 'manage_security', + 'manage_index_templates', + 'manage_pipeline', + 'manage_ingest_pipelines', + 'transport_client', + 'manage_ml', + 'monitor_ml', + 'manage_watcher', + 'monitor_watcher', +]; +const indexPrivileges = [ + 'all', + 'manage', + 'monitor', + 'read', + 'index', + 'create', + 'delete', + 'write', + 'delete_index', + 'create_index', + 'view_index_metadata', + 'read_cross_cluster', +]; + +export function getClusterPrivileges() { + return [...clusterPrivileges]; +} + +export function getIndexPrivileges() { + return [...indexPrivileges]; +} diff --git a/x-pack/plugins/security/public/views/management/edit_role.html b/x-pack/plugins/security/public/views/management/edit_role.html deleted file mode 100644 index 490e1c0e8f8c5e..00000000000000 --- a/x-pack/plugins/security/public/views/management/edit_role.html +++ /dev/null @@ -1,196 +0,0 @@ - - -
- - -
- -
- -

- - New Role - - - “{{ role.name }}” Role - -

-
- -
- - -
- - Reserved -
-
-
- -
-
-
- - - This role contains application privileges for the {{ otherApplications.join(', ') }} application(s) that can't be edited. - If they are for other instances of Kibana, you must manage those privileges on that Kibana instance. - -
-
-
- - -
- -
- - - - -
- Name must begin with a letter or underscore and contain only letters, underscores, and numbers. -
- -
- Name is required. -
-
- -
- -
- -
- -
-
- - -
- - -
- -
-
- - -
- - - - {{$item}} - - -
-
-
-
- - -
- -
-
- -
- -
- - - - Cancel - -
-
-
-
-
diff --git a/x-pack/plugins/security/public/views/management/edit_role.js b/x-pack/plugins/security/public/views/management/edit_role.js deleted file mode 100644 index d2c7dd2cb39d80..00000000000000 --- a/x-pack/plugins/security/public/views/management/edit_role.js +++ /dev/null @@ -1,200 +0,0 @@ -/* - * 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 _ from 'lodash'; -import routes from 'ui/routes'; -import { fatalError, toastNotifications } from 'ui/notify'; -import { toggle } from 'plugins/security/lib/util'; -import { isRoleEnabled } from 'plugins/security/lib/role'; -import template from 'plugins/security/views/management/edit_role.html'; -import 'angular-ui-select'; -import 'plugins/security/services/application_privilege'; -import 'plugins/security/services/shield_user'; -import 'plugins/security/services/shield_role'; -import 'plugins/security/services/shield_privileges'; -import 'plugins/security/services/shield_indices'; - -import { IndexPatternsProvider } from 'ui/index_patterns/index_patterns'; -import { XPackInfoProvider } from 'plugins/xpack_main/services/xpack_info'; -import { checkLicenseError } from 'plugins/security/lib/check_license_error'; -import { GateKeeperProvider } from 'plugins/xpack_main/services/gate_keeper'; -import { EDIT_ROLES_PATH, ROLES_PATH } from './management_urls'; - -const getKibanaPrivilegesViewModel = (applicationPrivileges, roleKibanaPrivileges) => { - const viewModel = applicationPrivileges.reduce((acc, applicationPrivilege) => { - acc[applicationPrivilege.name] = false; - return acc; - }, {}); - - if (!roleKibanaPrivileges || roleKibanaPrivileges.length === 0) { - return viewModel; - } - - const assignedPrivileges = _.uniq(_.flatten(_.pluck(roleKibanaPrivileges, 'privileges'))); - assignedPrivileges.forEach(assignedPrivilege => { - // we don't want to display privileges that aren't in our expected list of privileges - if (assignedPrivilege in viewModel) { - viewModel[assignedPrivilege] = true; - } - }); - - return viewModel; -}; - -const getKibanaPrivileges = (kibanaPrivilegesViewModel) => { - const selectedPrivileges = Object.keys(kibanaPrivilegesViewModel).filter(key => kibanaPrivilegesViewModel[key]); - - // if we have any selected privileges, add a single application entry - if (selectedPrivileges.length > 0) { - return [ - { - privileges: selectedPrivileges - } - ]; - } - - return []; -}; - -routes.when(`${EDIT_ROLES_PATH}/:name?`, { - template, - resolve: { - tribeRedirect(Private) { - const gateKeeper = Private(GateKeeperProvider); - gateKeeper.redirectAndNotifyIfTribe(); - }, - - role($route, ShieldRole, kbnUrl, Promise) { - const name = $route.current.params.name; - if (name != null) { - return ShieldRole.get({ name }).$promise - .catch((response) => { - - if (response.status !== 404) { - return fatalError(response); - } - - toastNotifications.addDanger(`No "${name}" role found.`); - kbnUrl.redirect(ROLES_PATH); - return Promise.halt(); - }); - } - return new ShieldRole({ - elasticsearch: { - cluster: [], - indices: [], - run_as: [], - }, - kibana: [], - _unrecognized_applications: [] - }); - }, - applicationPrivileges(ApplicationPrivileges, kbnUrl, Promise, Private) { - return ApplicationPrivileges.query().$promise - .catch(checkLicenseError(kbnUrl, Promise, Private)); - }, - users(ShieldUser, kbnUrl, Promise, Private) { - // $promise is used here because the result is an ngResource, not a promise itself - return ShieldUser.query().$promise - .then(users => _.map(users, 'username')) - .catch(checkLicenseError(kbnUrl, Promise, Private)); - }, - indexPatterns(Private) { - const indexPatterns = Private(IndexPatternsProvider); - return indexPatterns.getTitles(); - } - }, - controllerAs: 'editRole', - controller($injector, $scope) { - const $route = $injector.get('$route'); - const kbnUrl = $injector.get('kbnUrl'); - const shieldPrivileges = $injector.get('shieldPrivileges'); - const Private = $injector.get('Private'); - const confirmModal = $injector.get('confirmModal'); - const shieldIndices = $injector.get('shieldIndices'); - - $scope.role = $route.current.locals.role; - $scope.users = $route.current.locals.users; - $scope.indexPatterns = $route.current.locals.indexPatterns; - $scope.privileges = shieldPrivileges; - - const applicationPrivileges = $route.current.locals.applicationPrivileges; - const role = $route.current.locals.role; - $scope.kibanaPrivilegesViewModel = getKibanaPrivilegesViewModel(applicationPrivileges, role.kibana); - $scope.otherApplications = role._unrecognized_applications; - - $scope.rolesHref = `#${ROLES_PATH}`; - - this.isNewRole = $route.current.params.name == null; - this.fieldOptions = {}; - - $scope.deleteRole = (role) => { - const doDelete = () => { - role.$delete() - .then(() => toastNotifications.addSuccess('Deleted role')) - .then($scope.goToRoleList) - .catch(error => toastNotifications.addDanger(_.get(error, 'data.message'))); - }; - const confirmModalOptions = { - confirmButtonText: 'Delete role', - onConfirm: doDelete - }; - confirmModal('Are you sure you want to delete this role? This action is irreversible!', confirmModalOptions); - }; - - $scope.saveRole = (role) => { - role.elasticsearch.indices = role.elasticsearch.indices.filter((index) => index.names.length); - role.elasticsearch.indices.forEach((index) => index.query || delete index.query); - - role.kibana = getKibanaPrivileges($scope.kibanaPrivilegesViewModel); - - return role.$save() - .then(() => toastNotifications.addSuccess('Updated role')) - .then($scope.goToRoleList) - .catch(error => toastNotifications.addDanger(_.get(error, 'data.message'))); - }; - - $scope.goToRoleList = () => { - kbnUrl.redirect(ROLES_PATH); - }; - - $scope.addIndex = indices => { - indices.push({ names: [], privileges: [], field_security: { grant: ['*'] } }); - }; - - $scope.areIndicesValid = (indices) => { - return indices - .filter((index) => index.names.length) - .find((index) => index.privileges.length === 0) == null; - }; - - $scope.fetchFieldOptions = (index) => { - const indices = index.names.join(','); - const fieldOptions = this.fieldOptions; - if (indices && fieldOptions[indices] == null) { - shieldIndices.getFields(indices) - .then((fields) => fieldOptions[indices] = fields) - .catch(() => fieldOptions[indices] = []); - } - }; - - $scope.isRoleEnabled = isRoleEnabled; - - const xpackInfo = Private(XPackInfoProvider); - $scope.allowDocumentLevelSecurity = xpackInfo.get('features.security.allowRoleDocumentLevelSecurity'); - $scope.allowFieldLevelSecurity = xpackInfo.get('features.security.allowRoleFieldLevelSecurity'); - - $scope.$watch('role.elasticsearch.indices', (indices) => { - if (!indices.length) $scope.addIndex(indices); - else indices.forEach($scope.fetchFieldOptions); - }, true); - - $scope.toggle = toggle; - $scope.includes = _.includes; - - $scope.union = _.flow(_.union, _.compact); - } -}); diff --git a/x-pack/plugins/security/public/views/management/edit_role/components/__snapshots__/collapsible_panel.test.tsx.snap b/x-pack/plugins/security/public/views/management/edit_role/components/__snapshots__/collapsible_panel.test.tsx.snap new file mode 100644 index 00000000000000..75c5d914936452 --- /dev/null +++ b/x-pack/plugins/security/public/views/management/edit_role/components/__snapshots__/collapsible_panel.test.tsx.snap @@ -0,0 +1,58 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`it renders without blowing up 1`] = ` + + + + +

+ + + Elasticsearch +

+
+
+ + + hide + + +
+ + +

+ child +

+
+
+`; diff --git a/x-pack/plugins/security/public/views/management/edit_role/components/collapsible_panel.less b/x-pack/plugins/security/public/views/management/edit_role/components/collapsible_panel.less new file mode 100644 index 00000000000000..ffb065880c5609 --- /dev/null +++ b/x-pack/plugins/security/public/views/management/edit_role/components/collapsible_panel.less @@ -0,0 +1,4 @@ +.collapsiblePanel__logo { + margin-right: 8px; + vertical-align: text-bottom; +} diff --git a/x-pack/plugins/security/public/views/management/edit_role/components/collapsible_panel.test.tsx b/x-pack/plugins/security/public/views/management/edit_role/components/collapsible_panel.test.tsx new file mode 100644 index 00000000000000..86f1e73b78e1b9 --- /dev/null +++ b/x-pack/plugins/security/public/views/management/edit_role/components/collapsible_panel.test.tsx @@ -0,0 +1,48 @@ +/* + * 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 { EuiLink } from '@elastic/eui'; +import { mount, shallow } from 'enzyme'; +import React from 'react'; +import { CollapsiblePanel } from './collapsible_panel'; + +test('it renders without blowing up', () => { + const wrapper = shallow( + +

child

+
+ ); + + expect(wrapper).toMatchSnapshot(); +}); + +test('it renders children by default', () => { + const wrapper = mount( + +

child 1

+

child 2

+
+ ); + + expect(wrapper.find(CollapsiblePanel)).toHaveLength(1); + expect(wrapper.find('.child')).toHaveLength(2); +}); + +test('it hides children when the "hide" link is clicked', () => { + const wrapper = mount( + +

child 1

+

child 2

+
+ ); + + expect(wrapper.find(CollapsiblePanel)).toHaveLength(1); + expect(wrapper.find('.child')).toHaveLength(2); + + wrapper.find(EuiLink).simulate('click'); + + expect(wrapper.find('.child')).toHaveLength(0); +}); diff --git a/x-pack/plugins/security/public/views/management/edit_role/components/collapsible_panel.tsx b/x-pack/plugins/security/public/views/management/edit_role/components/collapsible_panel.tsx new file mode 100644 index 00000000000000..a58042fda96978 --- /dev/null +++ b/x-pack/plugins/security/public/views/management/edit_role/components/collapsible_panel.tsx @@ -0,0 +1,83 @@ +/* + * 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 { + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiLink, + EuiPanel, + EuiSpacer, + EuiTitle, +} from '@elastic/eui'; +import React, { Component, Fragment } from 'react'; +import './collapsible_panel.less'; + +interface Props { + iconType: string | any; + title: string; +} + +interface State { + collapsed: boolean; +} + +export class CollapsiblePanel extends Component { + public state = { + collapsed: false, + }; + + public render() { + return ( + + {this.getTitle()} + {this.getForm()} + + ); + } + + public getTitle = () => { + return ( + // @ts-ignore + + + +

+ {' '} + {this.props.title} +

+
+
+ + {this.state.collapsed ? 'show' : 'hide'} + +
+ ); + }; + + public getForm = () => { + if (this.state.collapsed) { + return null; + } + + return ( + + + {this.props.children} + + ); + }; + + public toggleCollapsed = () => { + this.setState({ + collapsed: !this.state.collapsed, + }); + }; +} diff --git a/x-pack/plugins/security/public/views/management/edit_role/components/delete_role_button.test.tsx b/x-pack/plugins/security/public/views/management/edit_role/components/delete_role_button.test.tsx new file mode 100644 index 00000000000000..8a78748b462323 --- /dev/null +++ b/x-pack/plugins/security/public/views/management/edit_role/components/delete_role_button.test.tsx @@ -0,0 +1,39 @@ +/* + * 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 { + EuiButtonEmpty, + // @ts-ignore + EuiConfirmModal, +} from '@elastic/eui'; +import { mount, shallow } from 'enzyme'; +import React from 'react'; +import { DeleteRoleButton } from './delete_role_button'; + +test('it renders without crashing', () => { + const deleteHandler = jest.fn(); + const wrapper = shallow(); + expect(wrapper.find(EuiButtonEmpty)).toHaveLength(1); + expect(deleteHandler).toHaveBeenCalledTimes(0); +}); + +test('it shows a confirmation dialog when clicked', () => { + const deleteHandler = jest.fn(); + const wrapper = mount(); + + wrapper.find(EuiButtonEmpty).simulate('click'); + + expect(wrapper.find(EuiConfirmModal)).toHaveLength(1); + + expect(deleteHandler).toHaveBeenCalledTimes(0); +}); + +test('it renders nothing when canDelete is false', () => { + const deleteHandler = jest.fn(); + const wrapper = shallow(); + expect(wrapper.find('*')).toHaveLength(0); + expect(deleteHandler).toHaveBeenCalledTimes(0); +}); diff --git a/x-pack/plugins/security/public/views/management/edit_role/components/delete_role_button.tsx b/x-pack/plugins/security/public/views/management/edit_role/components/delete_role_button.tsx new file mode 100644 index 00000000000000..28b3107a96c42f --- /dev/null +++ b/x-pack/plugins/security/public/views/management/edit_role/components/delete_role_button.tsx @@ -0,0 +1,82 @@ +/* + * 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 { + EuiButtonEmpty, + // @ts-ignore + EuiConfirmModal, + // @ts-ignore + EuiOverlayMask, +} from '@elastic/eui'; +import React, { Component, Fragment } from 'react'; + +interface Props { + canDelete: boolean; + onDelete: () => void; +} + +interface State { + showModal: boolean; +} + +export class DeleteRoleButton extends Component { + public state = { + showModal: false, + }; + + public render() { + if (!this.props.canDelete) { + return null; + } + + return ( + + + Delete role + + {this.maybeShowModal()} + + ); + } + + public maybeShowModal = () => { + if (!this.state.showModal) { + return null; + } + return ( + + +

Are you sure you want to delete this role?

+

This action cannot be undone!

+
+
+ ); + }; + + public closeModal = () => { + this.setState({ + showModal: false, + }); + }; + + public showModal = () => { + this.setState({ + showModal: true, + }); + }; + + public onConfirmDelete = () => { + this.closeModal(); + this.props.onDelete(); + }; +} diff --git a/x-pack/plugins/security/public/views/management/edit_role/components/edit_role_page.tsx b/x-pack/plugins/security/public/views/management/edit_role/components/edit_role_page.tsx new file mode 100644 index 00000000000000..9d2457b38cfd85 --- /dev/null +++ b/x-pack/plugins/security/public/views/management/edit_role/components/edit_role_page.tsx @@ -0,0 +1,316 @@ +/* + * 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 { + EuiButton, + EuiButtonEmpty, + EuiFieldText, + EuiFlexGroup, + EuiFlexItem, + // @ts-ignore + EuiForm, + EuiFormRow, + EuiPage, + EuiPageBody, + EuiPanel, + EuiSpacer, + EuiText, + EuiTitle, +} from '@elastic/eui'; +import { get } from 'lodash'; +import React, { ChangeEvent, Component, Fragment, HTMLProps } from 'react'; +import { toastNotifications } from 'ui/notify'; +import { Space } from '../../../../../../spaces/common/model/space'; +import { UserProfile } from '../../../../../../xpack_main/public/services/user_profile'; +import { IndexPrivilege } from '../../../../../common/model/index_privilege'; +import { KibanaPrivilege } from '../../../../../common/model/kibana_privilege'; +import { Role } from '../../../../../common/model/role'; +import { isReservedRole } from '../../../../lib/role'; +import { deleteRole, saveRole } from '../../../../objects'; +import { ROLES_PATH } from '../../management_urls'; +import { RoleValidationResult, RoleValidator } from '../lib/validate_role'; +import { DeleteRoleButton } from './delete_role_button'; +import { ElasticsearchPrivileges, KibanaPrivileges } from './privileges'; +import { ReservedRoleBadge } from './reserved_role_badge'; + +interface Props { + role: Role; + runAsUsers: string[]; + indexPatterns: string[]; + httpClient: any; + rbacEnabled: boolean; + allowDocumentLevelSecurity: boolean; + allowFieldLevelSecurity: boolean; + kibanaAppPrivileges: KibanaPrivilege[]; + notifier: any; + spaces?: Space[]; + spacesEnabled: boolean; + userProfile: UserProfile; +} + +interface State { + role: Role; + formError: RoleValidationResult | null; +} + +export class EditRolePage extends Component { + private validator: RoleValidator; + + constructor(props: Props) { + super(props); + this.state = { + role: props.role, + formError: null, + }; + this.validator = new RoleValidator({ shouldValidate: false }); + } + + public render() { + const description = this.props.spacesEnabled + ? `Set privileges on your Elasticsearch data and control access to your Kibana spaces.` + : `Set privileges on your Elasticsearch data and control access to Kibana.`; + + return ( + + + + {this.getFormTitle()} + + + + {description} + + {isReservedRole(this.props.role) && ( + + + +

+ Reserved roles are built-in and cannot be removed or modified. +

+
+
+ )} + + + + {this.getRoleName()} + + {this.getElasticsearchPrivileges()} + + {this.getKibanaPrivileges()} + + + + {this.getFormButtons()} +
+
+
+ ); + } + + public getFormTitle = () => { + let titleText; + const props: HTMLProps = { + tabIndex: 0, + }; + if (isReservedRole(this.props.role)) { + titleText = 'Viewing role'; + props['aria-describedby'] = 'reservedRoleDescription'; + } else if (this.editingExistingRole()) { + titleText = 'Edit role'; + } else { + titleText = 'Create role'; + } + + return ( + +

+ {titleText} +

+
+ ); + }; + + public getActionButton = () => { + if (this.editingExistingRole() && !isReservedRole(this.props.role)) { + return ( + + + + ); + } + + return null; + }; + + public getRoleName = () => { + return ( + + + + + + ); + }; + + public onNameChange = (e: ChangeEvent) => { + const rawValue = e.target.value; + const name = rawValue.replace(/\s/g, '_'); + + this.setState({ + role: { + ...this.state.role, + name, + }, + }); + }; + + public getElasticsearchPrivileges() { + return ( +
+ + +
+ ); + } + + public onRoleChange = (role: Role) => { + this.setState({ + role, + }); + }; + + public getKibanaPrivileges = () => { + return ( +
+ + +
+ ); + }; + + public getFormButtons = () => { + if (isReservedRole(this.props.role)) { + return Return to role list; + } + + const saveText = this.editingExistingRole() ? 'Update role' : 'Create role'; + + return ( + + + + {saveText} + + + + + Cancel + + + + {this.getActionButton()} + + ); + }; + + public editingExistingRole = () => { + return !!this.props.role.name; + }; + + public isPlaceholderPrivilege = (indexPrivilege: IndexPrivilege) => { + return indexPrivilege.names.length === 0; + }; + + public saveRole = () => { + this.validator.enableValidation(); + + const result = this.validator.validateForSave(this.state.role); + if (result.isInvalid) { + this.setState({ + formError: result, + }); + } else { + this.setState({ + formError: null, + }); + + const { httpClient, notifier } = this.props; + + const role = { + ...this.state.role, + }; + + role.elasticsearch.indices = role.elasticsearch.indices.filter( + i => !this.isPlaceholderPrivilege(i) + ); + role.elasticsearch.indices.forEach(index => index.query || delete index.query); + + saveRole(httpClient, role) + .then(() => { + toastNotifications.addSuccess('Saved role'); + this.backToRoleList(); + }) + .catch((error: any) => { + notifier.error(get(error, 'data.message')); + }); + } + }; + + public handleDeleteRole = () => { + const { httpClient, role, notifier } = this.props; + + deleteRole(httpClient, role.name) + .then(() => { + toastNotifications.addSuccess('Deleted role'); + this.backToRoleList(); + }) + .catch((error: any) => { + notifier.error(get(error, 'data.message')); + }); + }; + + public backToRoleList = () => { + window.location.hash = ROLES_PATH; + }; +} diff --git a/x-pack/plugins/security/public/views/management/edit_role/components/index.ts b/x-pack/plugins/security/public/views/management/edit_role/components/index.ts new file mode 100644 index 00000000000000..1a0afb37c47917 --- /dev/null +++ b/x-pack/plugins/security/public/views/management/edit_role/components/index.ts @@ -0,0 +1,7 @@ +/* + * 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 { EditRolePage } from './edit_role_page'; diff --git a/x-pack/plugins/security/public/views/management/edit_role/components/privileges/es/__snapshots__/cluster_privileges.test.tsx.snap b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/es/__snapshots__/cluster_privileges.test.tsx.snap new file mode 100644 index 00000000000000..a8165ab6cb9b07 --- /dev/null +++ b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/es/__snapshots__/cluster_privileges.test.tsx.snap @@ -0,0 +1,80 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`it renders without crashing 1`] = ` + + + + + +`; diff --git a/x-pack/plugins/security/public/views/management/edit_role/components/privileges/es/__snapshots__/elasticsearch_privileges.test.tsx.snap b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/es/__snapshots__/elasticsearch_privileges.test.tsx.snap new file mode 100644 index 00000000000000..5e65d164d59c71 --- /dev/null +++ b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/es/__snapshots__/elasticsearch_privileges.test.tsx.snap @@ -0,0 +1,179 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`it renders without crashing 1`] = ` + + + + Manage the actions this role can perform against your cluster. + + + Learn more + +

+ } + fullWidth={false} + gutterSize="l" + title={ +

+ Cluster privileges +

+ } + titleSize="xs" + > + + + +
+ + + Allow requests to be submitted on the behalf of other users. + + + Learn more + +

+ } + fullWidth={false} + gutterSize="l" + title={ +

+ Run As privileges +

+ } + titleSize="xs" + > + + + +
+ + +

+ Index privileges +

+
+ + +

+ Control access to the data in your cluster. + + + Learn more + +

+
+ + + + Add index privilege + +
+
+`; diff --git a/x-pack/plugins/security/public/views/management/edit_role/components/privileges/es/__snapshots__/index_privilege_form.test.tsx.snap b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/es/__snapshots__/index_privilege_form.test.tsx.snap new file mode 100644 index 00000000000000..5280890edf5e6d --- /dev/null +++ b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/es/__snapshots__/index_privilege_form.test.tsx.snap @@ -0,0 +1,206 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`it renders without crashing 1`] = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; diff --git a/x-pack/plugins/security/public/views/management/edit_role/components/privileges/es/__snapshots__/index_privileges.test.tsx.snap b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/es/__snapshots__/index_privileges.test.tsx.snap new file mode 100644 index 00000000000000..189c4766c29f99 --- /dev/null +++ b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/es/__snapshots__/index_privileges.test.tsx.snap @@ -0,0 +1,3 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`it renders without crashing 1`] = `Array []`; diff --git a/x-pack/plugins/security/public/views/management/edit_role/components/privileges/es/cluster_privileges.test.tsx b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/es/cluster_privileges.test.tsx new file mode 100644 index 00000000000000..a3a52a2fc511af --- /dev/null +++ b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/es/cluster_privileges.test.tsx @@ -0,0 +1,28 @@ +/* + * 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 { shallow } from 'enzyme'; +import React from 'react'; +import { Role } from '../../../../../../../common/model/role'; +import { ClusterPrivileges } from './cluster_privileges'; + +test('it renders without crashing', () => { + const role: Role = { + name: '', + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: { + global: [], + space: {}, + }, + }; + + const wrapper = shallow(); + expect(wrapper).toMatchSnapshot(); +}); diff --git a/x-pack/plugins/security/public/views/management/edit_role/components/privileges/es/cluster_privileges.tsx b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/es/cluster_privileges.tsx new file mode 100644 index 00000000000000..929935ba7f6ac1 --- /dev/null +++ b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/es/cluster_privileges.tsx @@ -0,0 +1,51 @@ +/* + * 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 { EuiComboBox, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import React, { Component } from 'react'; +import { Role } from '../../../../../../../common/model/role'; +import { isReservedRole } from '../../../../../../lib/role'; +// @ts-ignore +import { getClusterPrivileges } from '../../../../../../services/role_privileges'; + +interface Props { + role: Role; + onChange: (privs: string[]) => void; +} + +export class ClusterPrivileges extends Component { + public render() { + const clusterPrivileges = getClusterPrivileges(); + + return {this.buildComboBox(clusterPrivileges)}; + } + + public buildComboBox = (items: string[]) => { + const role = this.props.role; + + const options = items.map(i => ({ + label: i, + isGroupLabelOption: false, + })); + + const selectedOptions = (role.elasticsearch.cluster || []).map(k => ({ label: k })); + + return ( + + + + ); + }; + + public onClusterPrivilegesChange = (selectedPrivileges: any) => { + this.props.onChange(selectedPrivileges.map((priv: any) => priv.label)); + }; +} diff --git a/x-pack/plugins/security/public/views/management/edit_role/components/privileges/es/elasticsearch_privileges.less b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/es/elasticsearch_privileges.less new file mode 100644 index 00000000000000..776ef72a4627e5 --- /dev/null +++ b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/es/elasticsearch_privileges.less @@ -0,0 +1,3 @@ +.editRole__learnMore { + margin-left: 5px; +} \ No newline at end of file diff --git a/x-pack/plugins/security/public/views/management/edit_role/components/privileges/es/elasticsearch_privileges.test.tsx b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/es/elasticsearch_privileges.test.tsx new file mode 100644 index 00000000000000..3a948801102a57 --- /dev/null +++ b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/es/elasticsearch_privileges.test.tsx @@ -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 { mount, shallow } from 'enzyme'; +import React from 'react'; +import { RoleValidator } from '../../../lib/validate_role'; +import { ClusterPrivileges } from './cluster_privileges'; +import { ElasticsearchPrivileges } from './elasticsearch_privileges'; +import { IndexPrivileges } from './index_privileges'; + +test('it renders without crashing', () => { + const props = { + role: { + name: '', + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: { + global: [], + space: {}, + }, + }, + editable: true, + httpClient: jest.fn(), + onChange: jest.fn(), + runAsUsers: [], + indexPatterns: [], + allowDocumentLevelSecurity: true, + allowFieldLevelSecurity: true, + validator: new RoleValidator(), + }; + const wrapper = shallow(); + expect(wrapper).toMatchSnapshot(); +}); + +test('it renders ClusterPrivileges', () => { + const props = { + role: { + name: '', + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: { + global: [], + space: {}, + }, + }, + editable: true, + httpClient: jest.fn(), + onChange: jest.fn(), + runAsUsers: [], + indexPatterns: [], + allowDocumentLevelSecurity: true, + allowFieldLevelSecurity: true, + validator: new RoleValidator(), + }; + const wrapper = mount(); + expect(wrapper.find(ClusterPrivileges)).toHaveLength(1); +}); + +test('it renders IndexPrivileges', () => { + const props = { + role: { + name: '', + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: { + global: [], + space: {}, + }, + }, + editable: true, + httpClient: jest.fn(), + onChange: jest.fn(), + runAsUsers: [], + indexPatterns: [], + allowDocumentLevelSecurity: true, + allowFieldLevelSecurity: true, + validator: new RoleValidator(), + }; + const wrapper = mount(); + expect(wrapper.find(IndexPrivileges)).toHaveLength(1); +}); diff --git a/x-pack/plugins/security/public/views/management/edit_role/components/privileges/es/elasticsearch_privileges.tsx b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/es/elasticsearch_privileges.tsx new file mode 100644 index 00000000000000..2625ff9879d3fb --- /dev/null +++ b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/es/elasticsearch_privileges.tsx @@ -0,0 +1,191 @@ +/* + * 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 { + EuiButton, + EuiComboBox, + // @ts-ignore + EuiDescribedFormGroup, + EuiFormRow, + EuiHorizontalRule, + EuiLink, + EuiSpacer, + EuiText, + EuiTitle, +} from '@elastic/eui'; +import React, { Component, Fragment } from 'react'; +import { Role } from '../../../../../../../common/model/role'; +// @ts-ignore +import { documentationLinks } from '../../../../../../documentation_links'; +import { RoleValidator } from '../../../lib/validate_role'; +import { CollapsiblePanel } from '../../collapsible_panel'; +import { ClusterPrivileges } from './cluster_privileges'; + +import { IndexPrivileges } from './index_privileges'; + +interface Props { + role: Role; + editable: boolean; + httpClient: any; + onChange: (role: Role) => void; + runAsUsers: string[]; + validator: RoleValidator; + indexPatterns: string[]; + allowDocumentLevelSecurity: boolean; + allowFieldLevelSecurity: boolean; +} + +export class ElasticsearchPrivileges extends Component { + public render() { + return ( + + {this.getForm()} + + ); + } + + public getForm = () => { + const { + role, + httpClient, + validator, + onChange, + indexPatterns, + allowDocumentLevelSecurity, + allowFieldLevelSecurity, + } = this.props; + + const indexProps = { + role, + httpClient, + validator, + indexPatterns, + allowDocumentLevelSecurity, + allowFieldLevelSecurity, + onChange, + }; + + return ( + + Cluster privileges} + description={ +

+ Manage the actions this role can perform against your cluster.{' '} + {this.learnMore(documentationLinks.esClusterPrivileges)} +

+ } + > + + + +
+ + + + Run As privileges} + description={ +

+ Allow requests to be submitted on the behalf of other users.{' '} + {this.learnMore(documentationLinks.esRunAsPrivileges)} +

+ } + > + + ({ + id: username, + label: username, + isGroupLabelOption: false, + }))} + selectedOptions={this.props.role.elasticsearch.run_as.map(u => ({ label: u }))} + onChange={this.onRunAsUserChange} + isDisabled={!this.props.editable} + /> + +
+ + + + +

Index privileges

+
+ + +

+ Control access to the data in your cluster.{' '} + {this.learnMore(documentationLinks.esIndicesPrivileges)} +

+
+ + + + + + {this.props.editable && ( + + Add index privilege + + )} +
+ ); + }; + + public learnMore = (href: string) => ( + + Learn more + + ); + + public addIndexPrivilege = () => { + const { role } = this.props; + + const newIndices = [ + ...role.elasticsearch.indices, + { + names: [], + privileges: [], + field_security: { + grant: ['*'], + }, + }, + ]; + + this.props.onChange({ + ...this.props.role, + elasticsearch: { + ...this.props.role.elasticsearch, + indices: newIndices, + }, + }); + }; + + public onClusterPrivilegesChange = (cluster: string[]) => { + const role = { + ...this.props.role, + elasticsearch: { + ...this.props.role.elasticsearch, + cluster, + }, + }; + + this.props.onChange(role); + }; + + public onRunAsUserChange = (users: any) => { + const role = { + ...this.props.role, + elasticsearch: { + ...this.props.role.elasticsearch, + run_as: users.map((u: any) => u.label), + }, + }; + + this.props.onChange(role); + }; +} diff --git a/x-pack/plugins/security/public/views/management/edit_role/components/privileges/es/index_privilege_form.test.tsx b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/es/index_privilege_form.test.tsx new file mode 100644 index 00000000000000..2d686766943044 --- /dev/null +++ b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/es/index_privilege_form.test.tsx @@ -0,0 +1,213 @@ +/* + * 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 { EuiButtonIcon, EuiSwitch, EuiTextArea } from '@elastic/eui'; +import { mount, shallow } from 'enzyme'; +import React from 'react'; +import { RoleValidator } from '../../../lib/validate_role'; +import { IndexPrivilegeForm } from './index_privilege_form'; + +test('it renders without crashing', () => { + const props = { + indexPrivilege: { + names: [], + privileges: [], + query: '', + field_security: { + grant: [], + }, + }, + formIndex: 0, + indexPatterns: [], + availableFields: [], + isReservedRole: false, + allowDelete: true, + allowDocumentLevelSecurity: true, + allowFieldLevelSecurity: true, + validator: new RoleValidator(), + onChange: jest.fn(), + onDelete: jest.fn(), + }; + + const wrapper = shallow(); + expect(wrapper).toMatchSnapshot(); +}); + +describe('delete button', () => { + const props = { + indexPrivilege: { + names: [], + privileges: [], + query: '', + field_security: { + grant: [], + }, + }, + formIndex: 0, + indexPatterns: [], + availableFields: [], + isReservedRole: false, + allowDelete: true, + allowDocumentLevelSecurity: true, + allowFieldLevelSecurity: true, + validator: new RoleValidator(), + onChange: jest.fn(), + onDelete: jest.fn(), + }; + + test('it is hidden when allowDelete is false', () => { + const testProps = { + ...props, + allowDelete: false, + }; + const wrapper = mount(); + expect(wrapper.find(EuiButtonIcon)).toHaveLength(0); + }); + + test('it is shown when allowDelete is true', () => { + const testProps = { + ...props, + allowDelete: true, + }; + const wrapper = mount(); + expect(wrapper.find(EuiButtonIcon)).toHaveLength(1); + }); + + test('it invokes onDelete when clicked', () => { + const testProps = { + ...props, + allowDelete: true, + }; + const wrapper = mount(); + wrapper.find(EuiButtonIcon).simulate('click'); + expect(testProps.onDelete).toHaveBeenCalledTimes(1); + }); +}); + +describe(`document level security`, () => { + const props = { + indexPrivilege: { + names: [], + privileges: [], + query: 'some query', + field_security: { + grant: [], + }, + }, + formIndex: 0, + indexPatterns: [], + availableFields: [], + isReservedRole: false, + allowDelete: true, + allowDocumentLevelSecurity: true, + allowFieldLevelSecurity: true, + validator: new RoleValidator(), + onChange: jest.fn(), + onDelete: jest.fn(), + }; + + test(`inputs are hidden when DLS is not allowed`, () => { + const testProps = { + ...props, + allowDocumentLevelSecurity: false, + }; + + const wrapper = mount(); + expect(wrapper.find(EuiSwitch)).toHaveLength(0); + expect(wrapper.find(EuiTextArea)).toHaveLength(0); + }); + + test('only the switch is shown when allowed, and query is empty', () => { + const testProps = { + ...props, + indexPrivilege: { + ...props.indexPrivilege, + query: '', + }, + }; + + const wrapper = mount(); + expect(wrapper.find(EuiSwitch)).toHaveLength(1); + expect(wrapper.find(EuiTextArea)).toHaveLength(0); + }); + + test('both inputs are shown when allowed, and query is not empty', () => { + const testProps = { + ...props, + }; + + const wrapper = mount(); + expect(wrapper.find(EuiSwitch)).toHaveLength(1); + expect(wrapper.find(EuiTextArea)).toHaveLength(1); + }); +}); + +describe('field level security', () => { + const props = { + indexPrivilege: { + names: [], + privileges: [], + query: '', + field_security: { + grant: ['foo*'], + }, + }, + formIndex: 0, + indexPatterns: [], + availableFields: [], + isReservedRole: false, + allowDelete: true, + allowDocumentLevelSecurity: true, + allowFieldLevelSecurity: true, + validator: new RoleValidator(), + onChange: jest.fn(), + onDelete: jest.fn(), + }; + + test(`input is hidden when FLS is not allowed`, () => { + const testProps = { + ...props, + allowFieldLevelSecurity: false, + }; + + const wrapper = mount(); + expect(wrapper.find('.indexPrivilegeForm__grantedFieldsRow')).toHaveLength(0); + }); + + test('input is shown when allowed', () => { + const testProps = { + ...props, + }; + + const wrapper = mount(); + expect(wrapper.find('div.indexPrivilegeForm__grantedFieldsRow')).toHaveLength(1); + }); + + test('it displays a warning when no fields are granted', () => { + const testProps = { + ...props, + indexPrivilege: { + ...props.indexPrivilege, + field_security: { + grant: [], + }, + }, + }; + + const wrapper = mount(); + expect(wrapper.find('div.indexPrivilegeForm__grantedFieldsRow')).toHaveLength(1); + expect(wrapper.find('.euiFormHelpText')).toHaveLength(1); + }); + + test('it does not display a warning when fields are granted', () => { + const testProps = { + ...props, + }; + + const wrapper = mount(); + expect(wrapper.find('div.indexPrivilegeForm__grantedFieldsRow')).toHaveLength(1); + expect(wrapper.find('.euiFormHelpText')).toHaveLength(0); + }); +}); diff --git a/x-pack/plugins/security/public/views/management/edit_role/components/privileges/es/index_privilege_form.tsx b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/es/index_privilege_form.tsx new file mode 100644 index 00000000000000..6f1416ab43552e --- /dev/null +++ b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/es/index_privilege_form.tsx @@ -0,0 +1,285 @@ +/* + * 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 { + EuiButtonIcon, + EuiComboBox, + EuiComboBoxOptionProps, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiHorizontalRule, + EuiSpacer, + EuiSwitch, + EuiTextArea, +} from '@elastic/eui'; +import React, { ChangeEvent, Component, Fragment } from 'react'; +import { IndexPrivilege } from '../../../../../../../common/model/index_privilege'; +// @ts-ignore +import { getIndexPrivileges } from '../../../../../../services/role_privileges'; +import { RoleValidator } from '../../../lib/validate_role'; + +const fromOption = (option: any) => option.label; +const toOption = (value: string) => ({ label: value, isGroupLabelOption: false }); + +interface Props { + formIndex: number; + indexPrivilege: IndexPrivilege; + indexPatterns: string[]; + availableFields: string[]; + onChange: (indexPrivilege: IndexPrivilege) => void; + onDelete: () => void; + isReservedRole: boolean; + allowDelete: boolean; + allowDocumentLevelSecurity: boolean; + allowFieldLevelSecurity: boolean; + validator: RoleValidator; +} + +interface State { + queryExpanded: boolean; + documentQuery?: string; +} + +export class IndexPrivilegeForm extends Component { + constructor(props: Props) { + super(props); + this.state = { + queryExpanded: !!props.indexPrivilege.query, + documentQuery: props.indexPrivilege.query, + }; + } + + public render() { + return ( + + + + {this.getPrivilegeForm()} + {this.props.allowDelete && ( + + + + + + )} + + + ); + } + + public getPrivilegeForm = () => { + return ( + + + + + + + + + + + + + {this.getGrantedFieldsControl()} + + + + + {this.getGrantedDocumentsControl()} + + ); + }; + + public getGrantedFieldsControl = () => { + const { allowFieldLevelSecurity, availableFields, indexPrivilege, isReservedRole } = this.props; + + if (!allowFieldLevelSecurity) { + return null; + } + + const { grant = [] } = indexPrivilege.field_security || {}; + + if (allowFieldLevelSecurity) { + return ( + + + + + + + + ); + } + + return null; + }; + + public getGrantedDocumentsControl = () => { + const { allowDocumentLevelSecurity, indexPrivilege } = this.props; + + if (!allowDocumentLevelSecurity) { + return null; + } + + return ( + // @ts-ignore + + {!this.props.isReservedRole && ( + + + + )} + {this.state.queryExpanded && ( + + + + + + )} + + ); + }; + + public toggleDocumentQuery = () => { + const willToggleOff = this.state.queryExpanded; + const willToggleOn = !willToggleOff; + + // If turning off, then save the current query in state so that we can restore it if the user changes their mind. + this.setState({ + queryExpanded: !this.state.queryExpanded, + documentQuery: willToggleOff ? this.props.indexPrivilege.query : this.state.documentQuery, + }); + + // If turning off, then remove the query from the Index Privilege + if (willToggleOff) { + this.props.onChange({ + ...this.props.indexPrivilege, + query: '', + }); + } + + // If turning on, then restore the saved query if available + if (willToggleOn && !this.props.indexPrivilege.query && this.state.documentQuery) { + this.props.onChange({ + ...this.props.indexPrivilege, + query: this.state.documentQuery, + }); + } + }; + + public onCreateIndexPatternOption = (option: any) => { + const newIndexPatterns = this.props.indexPrivilege.names.concat([option]); + + this.props.onChange({ + ...this.props.indexPrivilege, + names: newIndexPatterns, + }); + }; + + public onIndexPatternsChange = (newPatterns: EuiComboBoxOptionProps[]) => { + this.props.onChange({ + ...this.props.indexPrivilege, + names: newPatterns.map(fromOption), + }); + }; + + public onPrivilegeChange = (newPrivileges: EuiComboBoxOptionProps[]) => { + this.props.onChange({ + ...this.props.indexPrivilege, + privileges: newPrivileges.map(fromOption), + }); + }; + + public onQueryChange = (e: ChangeEvent) => { + this.props.onChange({ + ...this.props.indexPrivilege, + query: e.target.value, + }); + }; + + public onCreateGrantedField = (grant: string) => { + if ( + !this.props.indexPrivilege.field_security || + !this.props.indexPrivilege.field_security.grant + ) { + return; + } + + const newGrants = this.props.indexPrivilege.field_security.grant.concat([grant]); + + this.props.onChange({ + ...this.props.indexPrivilege, + field_security: { + ...this.props.indexPrivilege.field_security, + grant: newGrants, + }, + }); + }; + + public onGrantedFieldsChange = (grantedFields: EuiComboBoxOptionProps[]) => { + this.props.onChange({ + ...this.props.indexPrivilege, + field_security: { + ...this.props.indexPrivilege.field_security, + grant: grantedFields.map(fromOption), + }, + }); + }; +} diff --git a/x-pack/plugins/security/public/views/management/edit_role/components/privileges/es/index_privileges.test.tsx b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/es/index_privileges.test.tsx new file mode 100644 index 00000000000000..7e8b71fd93ae21 --- /dev/null +++ b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/es/index_privileges.test.tsx @@ -0,0 +1,70 @@ +/* + * 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 { mount, shallow } from 'enzyme'; +import React from 'react'; +import { RoleValidator } from '../../../lib/validate_role'; +import { IndexPrivilegeForm } from './index_privilege_form'; +import { IndexPrivileges } from './index_privileges'; + +test('it renders without crashing', () => { + const props = { + role: { + name: '', + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: { + global: [], + space: {}, + }, + }, + httpClient: jest.fn(), + onChange: jest.fn(), + indexPatterns: [], + allowDocumentLevelSecurity: true, + allowFieldLevelSecurity: true, + validator: new RoleValidator(), + }; + const wrapper = shallow(); + expect(wrapper).toMatchSnapshot(); +}); + +test('it renders a IndexPrivilegeForm for each privilege on the role', () => { + const props = { + role: { + name: '', + kibana: { + global: [], + space: {}, + }, + elasticsearch: { + cluster: [], + indices: [ + { + names: ['foo*'], + privileges: ['all'], + query: '*', + field_security: { + grant: ['some_field'], + }, + }, + ], + run_as: [], + }, + }, + httpClient: jest.fn(), + onChange: jest.fn(), + indexPatterns: [], + allowDocumentLevelSecurity: true, + allowFieldLevelSecurity: true, + validator: new RoleValidator(), + }; + const wrapper = mount(); + expect(wrapper.find(IndexPrivilegeForm)).toHaveLength(1); +}); diff --git a/x-pack/plugins/security/public/views/management/edit_role/components/privileges/es/index_privileges.tsx b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/es/index_privileges.tsx new file mode 100644 index 00000000000000..e5e5648db06ab9 --- /dev/null +++ b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/es/index_privileges.tsx @@ -0,0 +1,177 @@ +/* + * 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 _ from 'lodash'; +import React, { Component } from 'react'; +import { IndexPrivilege } from '../../../../../../../common/model/index_privilege'; +import { Role } from '../../../../../../../common/model/role'; +import { isReservedRole, isRoleEnabled } from '../../../../../../lib/role'; +import { getFields } from '../../../../../../objects'; +import { RoleValidator } from '../../../lib/validate_role'; +import { IndexPrivilegeForm } from './index_privilege_form'; + +interface Props { + role: Role; + indexPatterns: string[]; + allowDocumentLevelSecurity: boolean; + allowFieldLevelSecurity: boolean; + httpClient: any; + onChange: (role: Role) => void; + validator: RoleValidator; +} + +interface State { + availableFields: { + [indexPrivKey: string]: string[]; + }; +} + +export class IndexPrivileges extends Component { + constructor(props: Props) { + super(props); + this.state = { + availableFields: {}, + }; + } + + public componentDidMount() { + this.loadAvailableFields(this.props.role.elasticsearch.indices); + } + + public render() { + const { indices = [] } = this.props.role.elasticsearch; + + const { indexPatterns, allowDocumentLevelSecurity, allowFieldLevelSecurity } = this.props; + + const props = { + indexPatterns, + // If editing an existing role while that has been disabled, always show the FLS/DLS fields because currently + // a role is only marked as disabled if it has FLS/DLS setup (usually before the user changed to a license that + // doesn't permit FLS/DLS). + allowDocumentLevelSecurity: allowDocumentLevelSecurity || !isRoleEnabled(this.props.role), + allowFieldLevelSecurity: allowFieldLevelSecurity || !isRoleEnabled(this.props.role), + isReservedRole: isReservedRole(this.props.role), + }; + + const forms = indices.map((indexPrivilege: IndexPrivilege, idx) => ( + + )); + + return forms; + } + + public addIndexPrivilege = () => { + const { role } = this.props; + + const newIndices = [ + ...role.elasticsearch.indices, + { + names: [], + privileges: [], + field_security: { + grant: ['*'], + }, + }, + ]; + + this.props.onChange({ + ...this.props.role, + elasticsearch: { + ...this.props.role.elasticsearch, + indices: newIndices, + }, + }); + }; + + public onIndexPrivilegeChange = (privilegeIndex: number) => { + return (updatedPrivilege: IndexPrivilege) => { + const { role } = this.props; + const { indices } = role.elasticsearch; + + const newIndices = [...indices]; + newIndices[privilegeIndex] = updatedPrivilege; + + this.props.onChange({ + ...this.props.role, + elasticsearch: { + ...this.props.role.elasticsearch, + indices: newIndices, + }, + }); + + this.loadAvailableFields(newIndices); + }; + }; + + public onIndexPrivilegeDelete = (privilegeIndex: number) => { + return () => { + const { role } = this.props; + + const newIndices = [...role.elasticsearch.indices]; + newIndices.splice(privilegeIndex, 1); + + this.props.onChange({ + ...this.props.role, + elasticsearch: { + ...this.props.role.elasticsearch, + indices: newIndices, + }, + }); + }; + }; + + public isPlaceholderPrivilege = (indexPrivilege: IndexPrivilege) => { + return indexPrivilege.names.length === 0; + }; + + public loadAvailableFields(privileges: IndexPrivilege[]) { + // Reserved roles cannot be edited, and therefore do not need to fetch available fields. + if (isReservedRole(this.props.role)) { + return; + } + + const patterns = privileges.map(index => index.names.join(',')); + + const cachedPatterns = Object.keys(this.state.availableFields); + const patternsToFetch = _.difference(patterns, cachedPatterns); + + const fetchRequests = patternsToFetch.map(this.loadFieldsForPattern); + + Promise.all(fetchRequests).then(response => { + this.setState({ + availableFields: { + ...this.state.availableFields, + ...response.reduce((acc, o) => ({ ...acc, ...o }), {}), + }, + }); + }); + } + + public loadFieldsForPattern = async (pattern: string) => { + if (!pattern) { + return { [pattern]: [] }; + } + + try { + return { + [pattern]: await getFields(this.props.httpClient, pattern), + }; + } catch (e) { + return { + [pattern]: [], + }; + } + }; +} diff --git a/x-pack/plugins/security/public/views/management/edit_role/components/privileges/index.ts b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/index.ts new file mode 100644 index 00000000000000..a06b14f80fa486 --- /dev/null +++ b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/index.ts @@ -0,0 +1,8 @@ +/* + * 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 { ElasticsearchPrivileges } from './es/elasticsearch_privileges'; +export { KibanaPrivileges } from './kibana/kibana_privileges'; diff --git a/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/__snapshots__/impacted_spaces_flyout.test.tsx.snap b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/__snapshots__/impacted_spaces_flyout.test.tsx.snap new file mode 100644 index 00000000000000..c67cde88bb4447 --- /dev/null +++ b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/__snapshots__/impacted_spaces_flyout.test.tsx.snap @@ -0,0 +1,17 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` renders without crashing 1`] = ` + +
+ + View summary of spaces privileges + +
+
+`; diff --git a/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/__snapshots__/kibana_privileges.test.tsx.snap b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/__snapshots__/kibana_privileges.test.tsx.snap new file mode 100644 index 00000000000000..50ef26bbe56acc --- /dev/null +++ b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/__snapshots__/kibana_privileges.test.tsx.snap @@ -0,0 +1,56 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` renders without crashing 1`] = ` + + + +`; diff --git a/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/__snapshots__/privilege_callout_warning.test.tsx.snap b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/__snapshots__/privilege_callout_warning.test.tsx.snap new file mode 100644 index 00000000000000..53f3fc716d65e8 --- /dev/null +++ b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/__snapshots__/privilege_callout_warning.test.tsx.snap @@ -0,0 +1,94 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`PrivilegeCalloutWarning renders without crashing 1`] = ` + + +
+
+ + + Minimum privilege is too high to customize individual spaces + +
+ +
+

+ Setting the minimum privilege to + + all + + grants full access to all spaces. To customize privileges for individual spaces, the minimum privilege must be either + + read + + or + + none + + . +

+
+
+
+
+
+`; diff --git a/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/__snapshots__/privilege_space_form.test.tsx.snap b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/__snapshots__/privilege_space_form.test.tsx.snap new file mode 100644 index 00000000000000..c24b2d596ff221 --- /dev/null +++ b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/__snapshots__/privilege_space_form.test.tsx.snap @@ -0,0 +1,89 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` renders without crashing 1`] = ` + + + + + + + + + + + + + + + + + +`; diff --git a/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/__snapshots__/simple_privilege_form.test.tsx.snap b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/__snapshots__/simple_privilege_form.test.tsx.snap new file mode 100644 index 00000000000000..addaf7437816cc --- /dev/null +++ b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/__snapshots__/simple_privilege_form.test.tsx.snap @@ -0,0 +1,41 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` renders without crashing 1`] = ` + + + Specifies the Kibana privilege for this role. +

+ } + fullWidth={false} + gutterSize="l" + title={ +

+ Kibana privileges +

+ } + titleSize="xs" + > + + + +
+
+`; diff --git a/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/__snapshots__/space_aware_privilege_form.test.tsx.snap b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/__snapshots__/space_aware_privilege_form.test.tsx.snap new file mode 100644 index 00000000000000..eb59b664832922 --- /dev/null +++ b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/__snapshots__/space_aware_privilege_form.test.tsx.snap @@ -0,0 +1,255 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` hides the space table if there are no existing space privileges 1`] = ` + +`; + +exports[` renders without crashing 1`] = ` + + + Specify the minimum actions users can perform in your spaces. +

+ } + fullWidth={false} + gutterSize="l" + title={ +

+ Minimum privileges for all spaces +

+ } + titleSize="xs" + > + + + +
+ + + +

+ Higher privileges for individual spaces +

+
+ + +

+ Grant more privileges on a per space basis. For example, if the privileges are + + + read + + for all spaces, you can set the privileges to + + all + + + for an individual space. +

+
+ + + + + + + + Add space privilege + + + + + + +
+
+`; + +exports[` with user profile disabling "manageSpaces" renders a warning message instead of the privilege form 1`] = ` + + Insufficient Privileges +

+ } +> +

+ You are not authorized to view all available spaces. +

+

+ Please ensure your account has all privileges granted by the + + + kibana_user + + role, and try again. +

+
+`; diff --git a/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/__snapshots__/space_selector.test.tsx.snap b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/__snapshots__/space_selector.test.tsx.snap new file mode 100644 index 00000000000000..8cc8767a5f89f7 --- /dev/null +++ b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/__snapshots__/space_selector.test.tsx.snap @@ -0,0 +1,13 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`SpaceSelector renders without crashing 1`] = ` + +`; diff --git a/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/impacted_spaces_flyout.less b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/impacted_spaces_flyout.less new file mode 100644 index 00000000000000..19f6c14a4a6f9b --- /dev/null +++ b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/impacted_spaces_flyout.less @@ -0,0 +1,3 @@ +.showImpactedSpaces--flyout--footer, .showImpactedSpaces { + text-align: right; +} diff --git a/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/impacted_spaces_flyout.test.tsx b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/impacted_spaces_flyout.test.tsx new file mode 100644 index 00000000000000..aafe9d273c5c4a --- /dev/null +++ b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/impacted_spaces_flyout.test.tsx @@ -0,0 +1,156 @@ +/* + * 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 { EuiFlyout, EuiLink } from '@elastic/eui'; +import { mount, shallow } from 'enzyme'; +import React from 'react'; +import { ImpactedSpacesFlyout } from './impacted_spaces_flyout'; +import { PrivilegeSpaceTable } from './privilege_space_table'; + +const buildProps = (customProps = {}) => { + return { + role: { + name: '', + elasticsearch: { + cluster: ['manage'], + indices: [], + run_as: [], + }, + kibana: { + global: [], + space: {}, + }, + }, + spaces: [ + { + id: 'default', + name: 'Default Space', + _reserved: true, + }, + { + id: 'marketing', + name: 'Marketing', + }, + ], + userProfile: { + hasCapability: () => true, + }, + kibanaAppPrivileges: [ + { + name: 'all', + }, + { + name: 'read', + }, + ], + ...customProps, + }; +}; + +describe('', () => { + it('renders without crashing', () => { + expect(shallow()).toMatchSnapshot(); + }); + + it('does not immediately show the flyout', () => { + const wrapper = mount(); + expect(wrapper.find(EuiFlyout)).toHaveLength(0); + }); + + it('shows the flyout after clicking the link', () => { + const wrapper = mount(); + wrapper.find(EuiLink).simulate('click'); + expect(wrapper.find(EuiFlyout)).toHaveLength(1); + }); + + describe('with base privilege set to "all"', () => { + it('calculates the effective privileges correctly', () => { + const props = buildProps({ + role: { + elasticsearch: { + cluster: ['manage'], + }, + kibana: { + global: ['all'], + space: { + marketing: ['read'], + }, + }, + }, + }); + + const wrapper = shallow(); + wrapper.find(EuiLink).simulate('click'); + + const table = wrapper.find(PrivilegeSpaceTable); + expect(table.props()).toMatchObject({ + spacePrivileges: { + default: ['all'], + // base privilege of "all" supercedes specified privilege of "read" above + marketing: ['all'], + }, + }); + }); + }); + + describe('with base privilege set to "read"', () => { + it('calculates the effective privileges correctly', () => { + const props = buildProps({ + role: { + elasticsearch: { + cluster: ['manage'], + }, + kibana: { + global: ['read'], + space: { + marketing: ['all'], + }, + }, + }, + }); + + const wrapper = shallow(); + wrapper.find(EuiLink).simulate('click'); + + const table = wrapper.find(PrivilegeSpaceTable); + expect(table.props()).toMatchObject({ + spacePrivileges: { + default: ['read'], + marketing: ['all'], + }, + }); + }); + }); + + describe('with base privilege set to "none"', () => { + it('calculates the effective privileges correctly', () => { + const props = buildProps({ + role: { + elasticsearch: { + cluster: ['manage'], + }, + kibana: { + global: [], + space: { + marketing: ['all'], + }, + }, + }, + }); + + const wrapper = shallow(); + wrapper.find(EuiLink).simulate('click'); + + const table = wrapper.find(PrivilegeSpaceTable); + expect(table.props()).toMatchObject({ + spacePrivileges: { + default: ['none'], + marketing: ['all'], + }, + }); + }); + }); +}); diff --git a/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/impacted_spaces_flyout.tsx b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/impacted_spaces_flyout.tsx new file mode 100644 index 00000000000000..e8d53770270db1 --- /dev/null +++ b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/impacted_spaces_flyout.tsx @@ -0,0 +1,128 @@ +/* + * 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 { + EuiFlyout, + EuiFlyoutBody, + EuiFlyoutFooter, + EuiFlyoutHeader, + EuiLink, + EuiTitle, +} from '@elastic/eui'; +import React, { Component, Fragment } from 'react'; +import { PrivilegeSpaceTable } from './privilege_space_table'; + +import { Space } from '../../../../../../../../spaces/common/model/space'; +import { ManageSpacesButton } from '../../../../../../../../spaces/public/components'; +import { UserProfile } from '../../../../../../../../xpack_main/public/services/user_profile'; +import { KibanaPrivilege } from '../../../../../../../common/model/kibana_privilege'; +import { Role } from '../../../../../../../common/model/role'; +import { NO_PRIVILEGE_VALUE } from '../../../lib/constants'; +import './impacted_spaces_flyout.less'; + +interface Props { + role: Role; + spaces: Space[]; + userProfile: UserProfile; +} + +interface State { + showImpactedSpaces: boolean; +} + +export class ImpactedSpacesFlyout extends Component { + constructor(props: Props) { + super(props); + this.state = { + showImpactedSpaces: false, + }; + } + + public render() { + const flyout = this.getFlyout(); + return ( + +
+ + View summary of spaces privileges + +
+ {flyout} +
+ ); + } + + public toggleShowImpactedSpaces = () => { + this.setState({ + showImpactedSpaces: !this.state.showImpactedSpaces, + }); + }; + + public getHighestPrivilege(...privileges: KibanaPrivilege[]): KibanaPrivilege { + if (privileges.indexOf('all') >= 0) { + return 'all'; + } + if (privileges.indexOf('read') >= 0) { + return 'read'; + } + return 'none'; + } + + public getFlyout = () => { + if (!this.state.showImpactedSpaces) { + return null; + } + + const { role, spaces } = this.props; + + const assignedPrivileges = role.kibana; + const basePrivilege = assignedPrivileges.global.length + ? assignedPrivileges.global[0] + : NO_PRIVILEGE_VALUE; + + const allSpacePrivileges = spaces.reduce( + (acc, space) => { + const spacePrivilege = assignedPrivileges.space[space.id] + ? assignedPrivileges.space[space.id][0] + : basePrivilege; + const actualPrivilege = this.getHighestPrivilege(spacePrivilege, basePrivilege); + + return { + ...acc, + // Use the privilege assigned to the space, if provided. Otherwise, the baes privilege is used. + [space.id]: [actualPrivilege], + }; + }, + { ...role.kibana.space } + ); + + return ( + + + +

Summary of space privileges

+
+
+ + + + + {/* TODO: Hide footer if button is not available */} + + +
+ ); + }; +} diff --git a/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/kibana_privileges.test.tsx b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/kibana_privileges.test.tsx new file mode 100644 index 00000000000000..07dd40c5de8a59 --- /dev/null +++ b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/kibana_privileges.test.tsx @@ -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 { shallow } from 'enzyme'; +import React from 'react'; +import { KibanaPrivilege } from '../../../../../../../../security/common/model/kibana_privilege'; +import { RoleValidator } from '../../../lib/validate_role'; +import { KibanaPrivileges } from './kibana_privileges'; +import { SimplePrivilegeForm } from './simple_privilege_form'; +import { SpaceAwarePrivilegeForm } from './space_aware_privilege_form'; + +const buildProps = (customProps = {}) => { + return { + role: { + name: '', + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: { + global: [], + space: {}, + }, + }, + spacesEnabled: true, + spaces: [ + { + id: 'default', + name: 'Default Space', + _reserved: true, + }, + { + id: 'marketing', + name: 'Marketing', + }, + ], + userProfile: { hasCapability: () => true }, + editable: true, + kibanaAppPrivileges: ['all' as KibanaPrivilege], + onChange: jest.fn(), + validator: new RoleValidator(), + ...customProps, + }; +}; + +describe('', () => { + it('renders without crashing', () => { + expect(shallow()).toMatchSnapshot(); + }); + + it('renders the simple privilege form when spaces is disabled', () => { + const props = buildProps({ spacesEnabled: false }); + const wrapper = shallow(); + expect(wrapper.find(SimplePrivilegeForm)).toHaveLength(1); + expect(wrapper.find(SpaceAwarePrivilegeForm)).toHaveLength(0); + }); + + it('renders the space-aware privilege form when spaces is enabled', () => { + const props = buildProps({ spacesEnabled: true }); + const wrapper = shallow(); + expect(wrapper.find(SimplePrivilegeForm)).toHaveLength(0); + expect(wrapper.find(SpaceAwarePrivilegeForm)).toHaveLength(1); + }); +}); diff --git a/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/kibana_privileges.tsx b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/kibana_privileges.tsx new file mode 100644 index 00000000000000..80d848b5893d51 --- /dev/null +++ b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/kibana_privileges.tsx @@ -0,0 +1,72 @@ +/* + * 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 React, { Component } from 'react'; +import { Space } from '../../../../../../../../spaces/common/model/space'; +import { UserProfile } from '../../../../../../../../xpack_main/public/services/user_profile'; +import { KibanaPrivilege } from '../../../../../../../common/model/kibana_privilege'; +import { Role } from '../../../../../../../common/model/role'; +import { RoleValidator } from '../../../lib/validate_role'; +import { CollapsiblePanel } from '../../collapsible_panel'; +import { SimplePrivilegeForm } from './simple_privilege_form'; +import { SpaceAwarePrivilegeForm } from './space_aware_privilege_form'; + +interface Props { + role: Role; + spacesEnabled: boolean; + spaces?: Space[]; + userProfile: UserProfile; + editable: boolean; + kibanaAppPrivileges: KibanaPrivilege[]; + onChange: (role: Role) => void; + validator: RoleValidator; +} + +export class KibanaPrivileges extends Component { + public render() { + return ( + + {this.getForm()} + + ); + } + + public getForm = () => { + const { + kibanaAppPrivileges, + role, + spacesEnabled, + spaces = [], + userProfile, + onChange, + editable, + validator, + } = this.props; + + if (spacesEnabled) { + return ( + + ); + } else { + return ( + + ); + } + }; +} diff --git a/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/privilege_callout_warning.test.tsx b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/privilege_callout_warning.test.tsx new file mode 100644 index 00000000000000..1e8d3f3c391584 --- /dev/null +++ b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/privilege_callout_warning.test.tsx @@ -0,0 +1,17 @@ +/* + * 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 { mount } from 'enzyme'; +import React from 'react'; +import { PrivilegeCalloutWarning } from './privilege_callout_warning'; + +describe('PrivilegeCalloutWarning', () => { + it('renders without crashing', () => { + expect( + mount() + ).toMatchSnapshot(); + }); +}); diff --git a/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/privilege_callout_warning.tsx b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/privilege_callout_warning.tsx new file mode 100644 index 00000000000000..0b5666f3915734 --- /dev/null +++ b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/privilege_callout_warning.tsx @@ -0,0 +1,107 @@ +/* + * 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 { EuiCallOut } from '@elastic/eui'; +import React, { Component } from 'react'; +import { KibanaPrivilege } from '../../../../../../../common/model/kibana_privilege'; +import { NO_PRIVILEGE_VALUE } from '../../../lib/constants'; + +interface Props { + basePrivilege: KibanaPrivilege; + isReservedRole: boolean; +} + +interface State { + showImpactedSpaces: boolean; +} + +export class PrivilegeCalloutWarning extends Component { + public state = { + showImpactedSpaces: false, + }; + + public render() { + const { basePrivilege, isReservedRole } = this.props; + + let callout = null; + + if (basePrivilege === 'all') { + if (isReservedRole) { + callout = ( + +

+ This role always grants full access to all spaces. To customize privileges for + individual spaces, you must create a new role. +

+
+ ); + } else { + callout = ( + +

+ Setting the minimum privilege to all grants full access to all + spaces. To customize privileges for individual spaces, the minimum privilege must be + either read or none. +

+
+ ); + } + } + + if (basePrivilege === 'read') { + if (isReservedRole) { + callout = ( + +

+ This role always grants read access to all spaces. To customize privileges for + individual spaces, you must create a new role. +

+
+ ); + } else { + callout = ( + + The minimal possible privilege is read. + + } + /> + ); + } + } + + if (basePrivilege === NO_PRIVILEGE_VALUE && isReservedRole) { + callout = ( + +

+ This role never grants access to any spaces within Kibana. To customize privileges for + individual spaces, you must create a new role. +

+
+ ); + } + + return callout; + } +} diff --git a/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/privilege_selector.tsx b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/privilege_selector.tsx new file mode 100644 index 00000000000000..5f7d902cb7459d --- /dev/null +++ b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/privilege_selector.tsx @@ -0,0 +1,60 @@ +/* + * 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 { + // @ts-ignore + EuiSelect, +} from '@elastic/eui'; +import React, { ChangeEvent, Component } from 'react'; +import { KibanaPrivilege } from '../../../../../../../common/model/kibana_privilege'; +import { NO_PRIVILEGE_VALUE } from '../../../lib/constants'; + +interface Props { + ['data-test-subj']: string; + availablePrivileges: KibanaPrivilege[]; + onChange: (privilege: KibanaPrivilege) => void; + value: KibanaPrivilege | null; + allowNone?: boolean; + disabled?: boolean; + compressed?: boolean; +} + +export class PrivilegeSelector extends Component { + public state = {}; + + public render() { + const { availablePrivileges, value, disabled, allowNone, compressed } = this.props; + + const options = []; + + if (allowNone) { + options.push({ value: NO_PRIVILEGE_VALUE, text: 'none' }); + } + + options.push( + ...availablePrivileges.map(p => ({ + value: p, + text: p, + })) + ); + + return ( + + ); + } + + public onChange = (e: ChangeEvent) => { + this.props.onChange(e.target.value as KibanaPrivilege); + }; +} diff --git a/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/privilege_space_form.test.tsx b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/privilege_space_form.test.tsx new file mode 100644 index 00000000000000..95494567446b74 --- /dev/null +++ b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/privilege_space_form.test.tsx @@ -0,0 +1,45 @@ +/* + * 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 { shallow } from 'enzyme'; +import React from 'react'; +import { KibanaPrivilege } from '../../../../../../../common/model/kibana_privilege'; +import { RoleValidator } from '../../../lib/validate_role'; +import { PrivilegeSpaceForm } from './privilege_space_form'; + +const buildProps = (customProps = {}) => { + const availablePrivileges: KibanaPrivilege[] = ['all', 'read']; + const selectedPrivilege: KibanaPrivilege = 'none'; + + return { + availableSpaces: [ + { + id: 'default', + name: 'Default Space', + description: '', + _reserved: true, + }, + { + id: 'marketing', + name: 'Marketing', + description: '', + }, + ], + selectedSpaceIds: [], + availablePrivileges, + selectedPrivilege, + onChange: jest.fn(), + onDelete: jest.fn(), + validator: new RoleValidator(), + ...customProps, + }; +}; + +describe('', () => { + it('renders without crashing', () => { + expect(shallow()).toMatchSnapshot(); + }); +}); diff --git a/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/privilege_space_form.tsx b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/privilege_space_form.tsx new file mode 100644 index 00000000000000..cf7bcf8287b9d6 --- /dev/null +++ b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/privilege_space_form.tsx @@ -0,0 +1,94 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiButtonIcon, EuiFlexGroup, EuiFlexItem, EuiFormRow } from '@elastic/eui'; +import React, { Component } from 'react'; +import { Space } from '../../../../../../../../spaces/common/model/space'; +import { KibanaPrivilege } from '../../../../../../../common/model/kibana_privilege'; +import { RoleValidator } from '../../../lib/validate_role'; +import { PrivilegeSelector } from './privilege_selector'; +import { SpaceSelector } from './space_selector'; + +interface Props { + availableSpaces: Space[]; + selectedSpaceIds: string[]; + availablePrivileges: KibanaPrivilege[]; + selectedPrivilege: KibanaPrivilege | null; + onChange: ( + params: { + spaces: string[]; + privilege: KibanaPrivilege | null; + } + ) => void; + onDelete: () => void; + validator: RoleValidator; +} + +export class PrivilegeSpaceForm extends Component { + public render() { + const { + availableSpaces, + selectedSpaceIds, + availablePrivileges, + selectedPrivilege, + validator, + } = this.props; + + return ( + + + + + + + + + + + + + + + + + + ); + } + + public onSelectedSpacesChange = (selectedSpaceIds: string[]) => { + this.props.onChange({ + spaces: selectedSpaceIds, + privilege: this.props.selectedPrivilege, + }); + }; + + public onPrivilegeChange = (privilege: KibanaPrivilege) => { + this.props.onChange({ + spaces: this.props.selectedSpaceIds, + privilege, + }); + }; +} diff --git a/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/privilege_space_table.tsx b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/privilege_space_table.tsx new file mode 100644 index 00000000000000..05600c3f0e2f7e --- /dev/null +++ b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/privilege_space_table.tsx @@ -0,0 +1,191 @@ +/* + * 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 { + EuiButtonIcon, + EuiFlexGroup, + EuiFlexItem, + // @ts-ignore + EuiInMemoryTable, + EuiText, +} from '@elastic/eui'; +import React, { Component } from 'react'; +import { Space } from '../../../../../../../../spaces/common/model/space'; +import { SpaceAvatar } from '../../../../../../../../spaces/public/components'; +import { KibanaPrivilege } from '../../../../../../../common/model/kibana_privilege'; +import { Role } from '../../../../../../../common/model/role'; +import { isReservedRole } from '../../../../../../lib/role'; +import { PrivilegeSelector } from './privilege_selector'; + +interface Props { + role: Role; + spaces: Space[]; + availablePrivileges?: KibanaPrivilege[]; + spacePrivileges: any; + onChange?: (privs: { [spaceId: string]: KibanaPrivilege[] }) => void; + readonly?: boolean; +} + +interface State { + searchTerm: string; +} + +interface DeletedSpace extends Space { + deleted: boolean; +} + +export class PrivilegeSpaceTable extends Component { + public state = { + searchTerm: '', + }; + + public render() { + const { role, spaces, availablePrivileges, spacePrivileges } = this.props; + + const { searchTerm } = this.state; + + const allTableItems = Object.keys(spacePrivileges) + .map(spaceId => { + return { + space: spaces.find(s => s.id === spaceId) || { id: spaceId, name: '', deleted: true }, + privilege: spacePrivileges[spaceId][0], + }; + }) + .sort(item1 => { + const isDeleted = 'deleted' in item1.space; + return isDeleted ? 1 : -1; + }); + + const visibleTableItems = allTableItems.filter(item => { + const isDeleted = 'deleted' in item.space; + const searchField = isDeleted ? item.space.id : item.space.name; + return searchField.toLowerCase().indexOf(searchTerm) >= 0; + }); + + if (allTableItems.length === 0) { + return null; + } + + return ( + { + this.setState({ + searchTerm: search.queryText.toLowerCase(), + }); + }, + }} + items={visibleTableItems} + /> + ); + } + + public getTableColumns = (role: Role, availablePrivileges: KibanaPrivilege[] = []) => { + const columns: any[] = [ + { + field: 'space', + name: 'Space', + width: this.props.readonly ? '75%' : '50%', + render: (space: Space | DeletedSpace) => { + let content; + if ('deleted' in space) { + content = [ + + {space.id} (deleted) + , + ]; + } else { + content = [ + + + , + + {space.name} + , + ]; + } + return ( + + {content} + + ); + }, + }, + { + field: 'privilege', + name: 'Privilege', + width: this.props.readonly ? '25%' : undefined, + render: (privilege: KibanaPrivilege, record: any) => { + if (this.props.readonly || record.space.deleted) { + return privilege; + } + + return ( + + ); + }, + }, + ]; + if (!this.props.readonly) { + columns.push({ + name: 'Actions', + actions: [ + { + render: (record: any) => { + return ( + this.onDeleteSpacePermissionsClick(record)} + iconType={'trash'} + /> + ); + }, + }, + ], + }); + } + + return columns; + }; + + public onSpacePermissionChange = (record: any) => (selectedPrivilege: KibanaPrivilege) => { + const { id: spaceId } = record.space; + + const updatedPrivileges = { + ...this.props.spacePrivileges, + }; + updatedPrivileges[spaceId] = [selectedPrivilege]; + if (this.props.onChange) { + this.props.onChange(updatedPrivileges); + } + }; + + public onDeleteSpacePermissionsClick = (record: any) => { + const { id: spaceId } = record.space; + + const updatedPrivileges = { + ...this.props.spacePrivileges, + }; + delete updatedPrivileges[spaceId]; + if (this.props.onChange) { + this.props.onChange(updatedPrivileges); + } + }; +} diff --git a/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/simple_privilege_form.test.tsx b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/simple_privilege_form.test.tsx new file mode 100644 index 00000000000000..d6ecbdb705b746 --- /dev/null +++ b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/simple_privilege_form.test.tsx @@ -0,0 +1,82 @@ +/* + * 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 { mount, shallow } from 'enzyme'; +import React from 'react'; +import { PrivilegeSelector } from './privilege_selector'; +import { SimplePrivilegeForm } from './simple_privilege_form'; + +const buildProps = (customProps?: any) => { + return { + role: { + name: '', + elasticsearch: { + cluster: ['manage'], + indices: [], + run_as: [], + }, + kibana: { + global: [], + space: {}, + }, + }, + editable: true, + kibanaAppPrivileges: ['all', 'read'], + onChange: jest.fn(), + ...customProps, + }; +}; + +describe('', () => { + it('renders without crashing', () => { + expect(shallow()).toMatchSnapshot(); + }); + + it('displays "none" when no privilege is selected', () => { + const props = buildProps(); + const wrapper = shallow(); + const selector = wrapper.find(PrivilegeSelector); + expect(selector.props()).toMatchObject({ + value: 'none', + }); + }); + + it('displays the selected privilege', () => { + const props = buildProps({ + role: { + elasticsearch: {}, + kibana: { + global: ['read'], + }, + }, + }); + const wrapper = shallow(); + const selector = wrapper.find(PrivilegeSelector); + expect(selector.props()).toMatchObject({ + value: 'read', + }); + }); + + it('fires its onChange callback when the privilege changes', () => { + const props = buildProps(); + const wrapper = mount(); + const selector = wrapper.find(PrivilegeSelector).find('select'); + selector.simulate('change', { target: { value: 'all' } }); + + expect(props.onChange).toHaveBeenCalledWith({ + name: '', + elasticsearch: { + cluster: ['manage'], + indices: [], + run_as: [], + }, + kibana: { + global: ['all'], + space: {}, + }, + }); + }); +}); diff --git a/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/simple_privilege_form.tsx b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/simple_privilege_form.tsx new file mode 100644 index 00000000000000..71b881c4a85efe --- /dev/null +++ b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/simple_privilege_form.tsx @@ -0,0 +1,70 @@ +/* + * 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 { + // @ts-ignore + EuiDescribedFormGroup, + EuiFormRow, +} from '@elastic/eui'; +import React, { Component, Fragment } from 'react'; +import { KibanaPrivilege } from '../../../../../../../common/model/kibana_privilege'; +import { Role } from '../../../../../../../common/model/role'; +import { isReservedRole } from '../../../../../../lib/role'; +import { NO_PRIVILEGE_VALUE } from '../../../lib/constants'; +import { copyRole } from '../../../lib/copy_role'; +import { PrivilegeSelector } from './privilege_selector'; + +interface Props { + kibanaAppPrivileges: KibanaPrivilege[]; + role: Role; + onChange: (role: Role) => void; + editable: boolean; +} + +export class SimplePrivilegeForm extends Component { + public render() { + const { kibanaAppPrivileges, role } = this.props; + + const assignedPrivileges = role.kibana; + + const kibanaPrivilege: KibanaPrivilege = + assignedPrivileges.global.length > 0 + ? (assignedPrivileges.global[0] as KibanaPrivilege) + : NO_PRIVILEGE_VALUE; + + const description =

Specifies the Kibana privilege for this role.

; + + return ( + + Kibana privileges} description={description}> + + + + + + ); + } + + public onKibanaPrivilegeChange = (privilege: KibanaPrivilege) => { + const role = copyRole(this.props.role); + + // Remove base privilege value + role.kibana.global = []; + + if (privilege !== NO_PRIVILEGE_VALUE) { + role.kibana.global = [privilege]; + } + + this.props.onChange(role); + }; +} diff --git a/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/space_aware_privilege_form.test.tsx b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/space_aware_privilege_form.test.tsx new file mode 100644 index 00000000000000..b386fcafc614c6 --- /dev/null +++ b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/space_aware_privilege_form.test.tsx @@ -0,0 +1,251 @@ +/* + * 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 { mount, shallow } from 'enzyme'; +import React from 'react'; +import { RoleValidator } from '../../../lib/validate_role'; +import { PrivilegeCalloutWarning } from './privilege_callout_warning'; +import { PrivilegeSpaceForm } from './privilege_space_form'; +import { PrivilegeSpaceTable } from './privilege_space_table'; +import { SpaceAwarePrivilegeForm } from './space_aware_privilege_form'; + +const buildProps = (customProps: any = {}) => { + return { + role: { + name: '', + elasticsearch: { + cluster: ['manage'], + indices: [], + run_as: [], + }, + kibana: { + global: [], + space: {}, + }, + }, + spaces: [ + { + id: 'default', + name: 'Default Space', + _reserved: true, + }, + { + id: 'marketing', + name: 'Marketing', + }, + ], + userProfile: { hasCapability: () => true }, + editable: true, + kibanaAppPrivileges: ['all', 'read'], + onChange: jest.fn(), + validator: new RoleValidator(), + ...customProps, + }; +}; + +describe('', () => { + it('renders without crashing', () => { + expect(shallow()).toMatchSnapshot(); + }); + + it('shows the space table if exisitng space privileges are declared', () => { + const props = buildProps({ + role: { + elasticsearch: { + cluster: ['manage'], + }, + kibana: { + global: ['read'], + space: { + default: ['all'], + }, + }, + }, + }); + + const wrapper = mount(); + + const table = wrapper.find(PrivilegeSpaceTable); + expect(table).toHaveLength(1); + }); + + it('hides the space table if there are no existing space privileges', () => { + const props = buildProps(); + + const wrapper = mount(); + + const table = wrapper.find(PrivilegeSpaceTable); + expect(table).toMatchSnapshot(); + }); + + it('adds a form row when clicking the "Add Space Privilege" button', () => { + const props = buildProps({ + role: { + elasticsearch: { + cluster: ['manage'], + }, + kibana: { + global: ['read'], + space: { + default: ['all'], + }, + }, + }, + }); + + const wrapper = mount(); + expect(wrapper.find(PrivilegeSpaceForm)).toHaveLength(0); + + wrapper.find('button[data-test-subj="addSpacePrivilegeButton"]').simulate('click'); + + expect(wrapper.find(PrivilegeSpaceForm)).toHaveLength(1); + }); + + describe('with minimum privilege set to "all"', () => { + it('does not allow space privileges to be customized', () => { + const props = buildProps({ + role: { + elasticsearch: { + cluster: ['manage'], + }, + kibana: { + global: ['all'], + space: { + default: ['all'], + }, + }, + }, + }); + + const wrapper = mount(); + + const warning = wrapper.find(PrivilegeCalloutWarning); + expect(warning.props()).toMatchObject({ + basePrivilege: 'all', + }); + + const table = wrapper.find(PrivilegeSpaceTable); + expect(table).toHaveLength(0); + + const addPrivilegeButton = wrapper.find('[data-test-subj="addSpacePrivilegeButton"]'); + expect(addPrivilegeButton).toHaveLength(0); + }); + }); + + describe('with minimum privilege set to "read"', () => { + it('shows a warning about minimum privilege', () => { + const props = buildProps({ + role: { + elasticsearch: { + cluster: ['manage'], + }, + kibana: { + global: ['read'], + space: { + default: ['all'], + }, + }, + }, + }); + + const wrapper = mount(); + + const warning = wrapper.find(PrivilegeCalloutWarning); + expect(warning.props()).toMatchObject({ + basePrivilege: 'read', + }); + }); + + it('allows space privileges to be customized', () => { + const props = buildProps({ + role: { + elasticsearch: { + cluster: ['manage'], + }, + kibana: { + global: ['read'], + space: { + default: ['all'], + }, + }, + }, + }); + + const wrapper = mount(); + + const table = wrapper.find(PrivilegeSpaceTable); + expect(table).toHaveLength(1); + + const addPrivilegeButton = wrapper.find('button[data-test-subj="addSpacePrivilegeButton"]'); + expect(addPrivilegeButton).toHaveLength(1); + }); + }); + + describe('with minimum privilege set to "none"', () => { + it('does not show a warning about minimum privilege', () => { + const props = buildProps({ + role: { + elasticsearch: { + cluster: ['manage'], + }, + kibana: { + global: [], + space: { + default: ['all'], + }, + }, + }, + }); + + const wrapper = mount(); + + const warning = wrapper.find(PrivilegeCalloutWarning); + expect(warning).toHaveLength(0); + }); + + it('allows space privileges to be customized', () => { + const props = buildProps({ + role: { + elasticsearch: { + cluster: ['manage'], + }, + kibana: { + global: [], + space: { + default: ['all'], + }, + }, + }, + }); + + const wrapper = mount(); + + const table = wrapper.find(PrivilegeSpaceTable); + expect(table).toHaveLength(1); + + const addPrivilegeButton = wrapper.find('button[data-test-subj="addSpacePrivilegeButton"]'); + expect(addPrivilegeButton).toHaveLength(1); + }); + }); + + describe('with user profile disabling "manageSpaces"', () => { + it('renders a warning message instead of the privilege form', () => { + const props = buildProps({ + userProfile: { + hasCapability: (capability: string) => { + if (capability === 'manageSpaces') { + return false; + } + throw new Error(`unexpected call to hasCapability: ${capability}`); + }, + }, + }); + + const wrapper = shallow(); + expect(wrapper).toMatchSnapshot(); + }); + }); +}); diff --git a/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/space_aware_privilege_form.tsx b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/space_aware_privilege_form.tsx new file mode 100644 index 00000000000000..dcdaf055cae8db --- /dev/null +++ b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/space_aware_privilege_form.tsx @@ -0,0 +1,356 @@ +/* + * 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 { + EuiButton, + EuiCallOut, + // @ts-ignore + EuiDescribedFormGroup, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiSpacer, + EuiText, + EuiTitle, +} from '@elastic/eui'; +import React, { Component, Fragment } from 'react'; +import { Space } from '../../../../../../../../spaces/common/model/space'; +import { UserProfile } from '../../../../../../../../xpack_main/public/services/user_profile'; +import { KibanaPrivilege } from '../../../../../../../common/model/kibana_privilege'; +import { Role } from '../../../../../../../common/model/role'; +import { isReservedRole } from '../../../../../../lib/role'; +import { NO_PRIVILEGE_VALUE } from '../../../lib/constants'; +import { copyRole } from '../../../lib/copy_role'; +import { getAvailablePrivileges } from '../../../lib/get_available_privileges'; +import { RoleValidator } from '../../../lib/validate_role'; +import { ImpactedSpacesFlyout } from './impacted_spaces_flyout'; +import { PrivilegeCalloutWarning } from './privilege_callout_warning'; +import { PrivilegeSelector } from './privilege_selector'; +import { PrivilegeSpaceForm } from './privilege_space_form'; +import { PrivilegeSpaceTable } from './privilege_space_table'; + +interface Props { + kibanaAppPrivileges: KibanaPrivilege[]; + role: Role; + spaces: Space[]; + onChange: (role: Role) => void; + editable: boolean; + validator: RoleValidator; + userProfile: UserProfile; +} + +interface PrivilegeForm { + spaces: string[]; + privilege: KibanaPrivilege | null; +} + +interface SpacePrivileges { + [spaceId: string]: KibanaPrivilege[]; +} + +interface State { + spacePrivileges: SpacePrivileges; + privilegeForms: PrivilegeForm[]; +} + +export class SpaceAwarePrivilegeForm extends Component { + constructor(props: Props) { + super(props); + const { role } = props; + + const assignedPrivileges = role.kibana; + const spacePrivileges = { + ...assignedPrivileges.space, + }; + + this.state = { + spacePrivileges, + privilegeForms: [], + }; + } + + public render() { + const { kibanaAppPrivileges, role, userProfile } = this.props; + + if (!userProfile.hasCapability('manageSpaces')) { + return ( + Insufficient Privileges

} iconType="alert" color="danger"> +

You are not authorized to view all available spaces.

+

+ Please ensure your account has all privileges granted by the{' '} + kibana_user role, and try again. +

+
+ ); + } + + const assignedPrivileges = role.kibana; + + const basePrivilege = + assignedPrivileges.global.length > 0 ? assignedPrivileges.global[0] : NO_PRIVILEGE_VALUE; + + const description =

Specify the minimum actions users can perform in your spaces.

; + + let helptext; + if (basePrivilege === NO_PRIVILEGE_VALUE) { + helptext = 'No access to spaces'; + } else if (basePrivilege === 'all') { + helptext = 'View, edit, and share objects and apps within all spaces'; + } else if (basePrivilege === 'read') { + helptext = 'View objects and apps within all spaces'; + } + + return ( + + Minimum privileges for all spaces} + description={description} + > + + + + + + + + {this.renderSpacePrivileges(basePrivilege, kibanaAppPrivileges)} + + ); + } + + public renderSpacePrivileges = ( + basePrivilege: KibanaPrivilege, + availablePrivileges: KibanaPrivilege[] + ) => { + const { role, spaces } = this.props; + + const { spacePrivileges } = this.state; + + const availableSpaces = this.getAvailableSpaces(); + + const canAssignSpacePrivileges = basePrivilege !== 'all'; + const hasAssignedSpacePrivileges = Object.keys(this.state.spacePrivileges).length > 0; + + const showAddPrivilegeButton = + canAssignSpacePrivileges && this.props.editable && availableSpaces.length > 0; + + return ( + + +

Higher privileges for individual spaces

+
+ + +

+ Grant more privileges on a per space basis. For example, if the privileges are{' '} + read for all spaces, you can set the privileges to all{' '} + for an individual space. +

+
+ + {(basePrivilege !== NO_PRIVILEGE_VALUE || isReservedRole(this.props.role)) && ( + + )} + + {basePrivilege === 'read' && this.props.editable && } + + {canAssignSpacePrivileges && ( + + + + {hasAssignedSpacePrivileges && } + + {this.getSpaceForms(basePrivilege)} + + )} + + + {showAddPrivilegeButton && ( + + + Add space privilege + + + )} + + + + +
+ ); + }; + + public getSpaceForms = (basePrivilege: KibanaPrivilege) => { + if (!this.props.editable) { + return null; + } + + return this.state.privilegeForms.map((form, index) => + this.getSpaceForm(form, index, basePrivilege) + ); + }; + + public addSpacePrivilege = () => { + this.setState({ + privilegeForms: [ + ...this.state.privilegeForms, + { + spaces: [], + privilege: null, + }, + ], + }); + }; + + public getAvailableSpaces = (omitIndex?: number): Space[] => { + const { spacePrivileges } = this.state; + + return this.props.spaces.filter(space => { + const alreadyAssigned = Object.keys(spacePrivileges).indexOf(space.id) >= 0; + + if (alreadyAssigned) { + return false; + } + + const otherForms = [...this.state.privilegeForms]; + if (typeof omitIndex === 'number') { + otherForms.splice(omitIndex, 1); + } + + const inAnotherForm = otherForms.some(({ spaces }) => spaces.indexOf(space.id) >= 0); + + return !inAnotherForm; + }); + }; + + public getSpaceForm = (form: PrivilegeForm, index: number, basePrivilege: KibanaPrivilege) => { + const { spaces: selectedSpaceIds, privilege } = form; + + const availableSpaces = this.getAvailableSpaces(index); + + return ( + + + + + ); + }; + + public onPrivilegeSpacePermissionChange = (index: number) => (form: PrivilegeForm) => { + const existingPrivilegeForm = { ...this.state.privilegeForms[index] }; + const updatedPrivileges = [...this.state.privilegeForms]; + updatedPrivileges[index] = { + spaces: form.spaces, + privilege: form.privilege, + }; + + this.setState({ + privilegeForms: updatedPrivileges, + }); + + const role = copyRole(this.props.role); + + if (!form.spaces.length || !form.privilege) { + existingPrivilegeForm.spaces.forEach(spaceId => { + role.kibana.space[spaceId] = []; + }); + } else { + const privilege = form.privilege; + if (privilege) { + form.spaces.forEach(spaceId => { + role.kibana.space[spaceId] = [privilege]; + }); + } + } + + this.props.validator.setInProgressSpacePrivileges(updatedPrivileges); + this.props.onChange(role); + }; + + public onPrivilegeSpacePermissionDelete = (index: number) => () => { + const updatedPrivileges = [...this.state.privilegeForms]; + const removedPrivilege = updatedPrivileges.splice(index, 1)[0]; + + this.setState({ + privilegeForms: updatedPrivileges, + }); + + const role = copyRole(this.props.role); + + removedPrivilege.spaces.forEach(spaceId => { + delete role.kibana.space[spaceId]; + }); + + this.props.onChange(role); + }; + + public onExistingSpacePrivilegesChange = (assignedPrivileges: SpacePrivileges) => { + const role = copyRole(this.props.role); + + role.kibana.space = assignedPrivileges; + + this.setState({ + spacePrivileges: assignedPrivileges, + }); + + this.props.onChange(role); + }; + + public onKibanaBasePrivilegeChange = (privilege: KibanaPrivilege) => { + const role = copyRole(this.props.role); + + // Remove base privilege value + role.kibana.global = []; + + if (privilege !== NO_PRIVILEGE_VALUE) { + role.kibana.global = [privilege]; + } + + this.props.onChange(role); + }; +} diff --git a/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/space_selector.test.tsx b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/space_selector.test.tsx new file mode 100644 index 00000000000000..33579203e2f915 --- /dev/null +++ b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/space_selector.test.tsx @@ -0,0 +1,17 @@ +/* + * 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 { shallow } from 'enzyme'; +import React from 'react'; +import { SpaceSelector } from './space_selector'; + +describe('SpaceSelector', () => { + it('renders without crashing', () => { + expect( + shallow() + ).toMatchSnapshot(); + }); +}); diff --git a/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/space_selector.tsx b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/space_selector.tsx new file mode 100644 index 00000000000000..510217a9cbd652 --- /dev/null +++ b/x-pack/plugins/security/public/views/management/edit_role/components/privileges/kibana/space_selector.tsx @@ -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 { + EuiComboBox, + EuiComboBoxOptionProps, + EuiHealth, + // @ts-ignore + EuiHighlight, +} from '@elastic/eui'; +import React, { Component } from 'react'; +import { Space } from '../../../../../../../../spaces/common/model/space'; +import { getSpaceColor } from '../../../../../../../../spaces/common/space_attributes'; + +const spaceToOption = (space?: Space) => { + if (!space) { + return { label: '', isGroupLabelOption: false }; + } + + return { + id: space.id, + label: space.name, + color: getSpaceColor(space), + isGroupLabelOption: false, + }; +}; + +const spaceIdToOption = (spaces: Space[]) => (s: string) => + spaceToOption(spaces.find(space => space.id === s)); + +interface Props { + spaces: Space[]; + selectedSpaceIds: string[]; + onChange: (spaceIds: string[]) => void; + disabled?: boolean; +} + +export class SpaceSelector extends Component { + public render() { + const renderOption = (option: any, searchValue: string, contentClassName: string) => { + const { color, label } = option; + return ( + + + {label} + + + ); + }; + + return ( + + ); + } + + public onChange = (selectedSpaces: EuiComboBoxOptionProps[]) => { + this.props.onChange(selectedSpaces.map(s => s.id as string)); + }; +} diff --git a/x-pack/plugins/security/public/views/management/edit_role/components/reserved_role_badge.test.tsx b/x-pack/plugins/security/public/views/management/edit_role/components/reserved_role_badge.test.tsx new file mode 100644 index 00000000000000..91719960583a32 --- /dev/null +++ b/x-pack/plugins/security/public/views/management/edit_role/components/reserved_role_badge.test.tsx @@ -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 { EuiIcon } from '@elastic/eui'; +import { shallow } from 'enzyme'; +import React from 'react'; +import { Role } from '../../../../../common/model/role'; +import { ReservedRoleBadge } from './reserved_role_badge'; + +const reservedRole: Role = { + name: '', + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: { + global: [], + space: {}, + }, + metadata: { + _reserved: true, + }, +}; + +const unreservedRole = { + name: '', + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: { + global: [], + space: {}, + }, +}; + +test('it renders without crashing', () => { + const wrapper = shallow(); + expect(wrapper.find(EuiIcon)).toHaveLength(1); +}); + +test('it renders nothing for an unreserved role', () => { + const wrapper = shallow(); + expect(wrapper.find('*')).toHaveLength(0); +}); diff --git a/x-pack/plugins/security/public/views/management/edit_role/components/reserved_role_badge.tsx b/x-pack/plugins/security/public/views/management/edit_role/components/reserved_role_badge.tsx new file mode 100644 index 00000000000000..2966c78d2d92aa --- /dev/null +++ b/x-pack/plugins/security/public/views/management/edit_role/components/reserved_role_badge.tsx @@ -0,0 +1,28 @@ +/* + * 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 React from 'react'; + +import { EuiIcon, EuiToolTip } from '@elastic/eui'; +import { Role } from '../../../../../common/model/role'; +import { isReservedRole } from '../../../../lib/role'; + +interface Props { + role: Role; +} + +export const ReservedRoleBadge = (props: Props) => { + const { role } = props; + + if (isReservedRole(role)) { + return ( + + + + ); + } + return null; +}; diff --git a/x-pack/plugins/security/public/views/management/edit_role/edit_role.html b/x-pack/plugins/security/public/views/management/edit_role/edit_role.html new file mode 100644 index 00000000000000..292b1e517a69a9 --- /dev/null +++ b/x-pack/plugins/security/public/views/management/edit_role/edit_role.html @@ -0,0 +1,3 @@ + +
+ diff --git a/x-pack/plugins/security/public/views/management/edit_role/edit_role.less b/x-pack/plugins/security/public/views/management/edit_role/edit_role.less new file mode 100644 index 00000000000000..b3b4212bb1f1a9 --- /dev/null +++ b/x-pack/plugins/security/public/views/management/edit_role/edit_role.less @@ -0,0 +1,4 @@ +#editRoleReactRoot { + background: #f5f5f5; + min-height: ~"calc(100vh - 70px)"; +} diff --git a/x-pack/plugins/security/public/views/management/edit_role/index.js b/x-pack/plugins/security/public/views/management/edit_role/index.js new file mode 100644 index 00000000000000..996169d4fc70cc --- /dev/null +++ b/x-pack/plugins/security/public/views/management/edit_role/index.js @@ -0,0 +1,154 @@ +/* + * 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 _ from 'lodash'; +import chrome from 'ui/chrome'; +import routes from 'ui/routes'; +import { fatalError } from 'ui/notify'; +import template from 'plugins/security/views/management/edit_role/edit_role.html'; +import 'plugins/security/views/management/edit_role/edit_role.less'; +import 'angular-ui-select'; +import 'plugins/security/services/application_privilege'; +import 'plugins/security/services/shield_user'; +import 'plugins/security/services/shield_role'; +import 'plugins/security/services/shield_privileges'; +import 'plugins/security/services/shield_indices'; + +import { IndexPatternsProvider } from 'ui/index_patterns/index_patterns'; +import { XPackInfoProvider } from 'plugins/xpack_main/services/xpack_info'; +import { SpacesManager } from '../../../../../spaces/public/lib'; +import { UserProfileProvider } from 'plugins/xpack_main/services/user_profile'; +import { checkLicenseError } from 'plugins/security/lib/check_license_error'; +import { EDIT_ROLES_PATH, ROLES_PATH } from '../management_urls'; + +import { EditRolePage } from './components'; + +import React from 'react'; +import { render, unmountComponentAtNode } from 'react-dom'; +import { KibanaAppPrivileges } from '../../../../common/model/kibana_privilege'; + +routes.when(`${EDIT_ROLES_PATH}/:name?`, { + template, + resolve: { + role($route, ShieldRole, kbnUrl, Promise, Notifier) { + const name = $route.current.params.name; + + let role; + + if (name != null) { + role = ShieldRole.get({ name }).$promise + .catch((response) => { + + if (response.status !== 404) { + return fatalError(response); + } + + const notifier = new Notifier(); + notifier.error(`No "${name}" role found.`); + kbnUrl.redirect(ROLES_PATH); + return Promise.halt(); + }); + + } else { + role = Promise.resolve(new ShieldRole({ + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: { + global: [], + space: {}, + }, + _unrecognized_applications: [], + })); + } + + return role.then(res => res.toJSON()); + }, + users(ShieldUser, kbnUrl, Promise, Private) { + // $promise is used here because the result is an ngResource, not a promise itself + return ShieldUser.query().$promise + .then(users => _.map(users, 'username')) + .catch(checkLicenseError(kbnUrl, Promise, Private)); + }, + indexPatterns(Private) { + const indexPatterns = Private(IndexPatternsProvider); + return indexPatterns.getTitles(); + }, + spaces($http, chrome, spacesEnabled) { + if (spacesEnabled) { + return new SpacesManager($http, chrome).getSpaces(); + } + return []; + } + }, + controllerAs: 'editRole', + controller($injector, $scope, $http, enableSpaceAwarePrivileges) { + const $route = $injector.get('$route'); + const Private = $injector.get('Private'); + + const Notifier = $injector.get('Notifier'); + + const role = $route.current.locals.role; + + const xpackInfo = Private(XPackInfoProvider); + const userProfile = Private(UserProfileProvider); + const allowDocumentLevelSecurity = xpackInfo.get('features.security.allowRoleDocumentLevelSecurity'); + const allowFieldLevelSecurity = xpackInfo.get('features.security.allowRoleFieldLevelSecurity'); + const rbacApplication = chrome.getInjected('rbacApplication'); + + if (role.elasticsearch.indices.length === 0) { + const emptyOption = { + names: [], + privileges: [] + }; + + if (allowFieldLevelSecurity) { + emptyOption.field_security = { + grant: ['*'] + }; + } + + if (allowDocumentLevelSecurity) { + emptyOption.query = ''; + } + + role.elasticsearch.indices.push(emptyOption); + } + + const { + users, + indexPatterns, + spaces, + } = $route.current.locals; + + $scope.$$postDigest(() => { + const domNode = document.getElementById('editRoleReactRoot'); + + render(, domNode); + + // unmount react on controller destroy + $scope.$on('$destroy', () => { + unmountComponentAtNode(domNode); + }); + }); + } +}); diff --git a/x-pack/plugins/security/public/views/management/edit_role/lib/__snapshots__/get_available_privileges.test.ts.snap b/x-pack/plugins/security/public/views/management/edit_role/lib/__snapshots__/get_available_privileges.test.ts.snap new file mode 100644 index 00000000000000..177ffc17078363 --- /dev/null +++ b/x-pack/plugins/security/public/views/management/edit_role/lib/__snapshots__/get_available_privileges.test.ts.snap @@ -0,0 +1,3 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`getAvailablePrivileges throws when given an unexpected minimum privilege 1`] = `"Unexpected minimumPrivilege value: idk"`; diff --git a/x-pack/plugins/security/public/views/management/edit_role/lib/__snapshots__/validate_role.test.ts.snap b/x-pack/plugins/security/public/views/management/edit_role/lib/__snapshots__/validate_role.test.ts.snap new file mode 100644 index 00000000000000..1f4837b07d0b90 --- /dev/null +++ b/x-pack/plugins/security/public/views/management/edit_role/lib/__snapshots__/validate_role.test.ts.snap @@ -0,0 +1,3 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`validateIndexPrivileges it throws when indices is not an array 1`] = `"Expected role.elasticsearch.indices to be an array"`; diff --git a/x-pack/plugins/security/public/views/management/edit_role/lib/constants.ts b/x-pack/plugins/security/public/views/management/edit_role/lib/constants.ts new file mode 100644 index 00000000000000..7378251fc84ac5 --- /dev/null +++ b/x-pack/plugins/security/public/views/management/edit_role/lib/constants.ts @@ -0,0 +1,21 @@ +/* + * 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. + */ + +/*../../../../../common/model/kibana_privilege + * 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 { KibanaPrivilege } from '../../../../../common/model/kibana_privilege'; + +/* + * 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 const NO_PRIVILEGE_VALUE: KibanaPrivilege = 'none'; diff --git a/x-pack/plugins/security/public/views/management/edit_role/lib/copy_role.test.ts b/x-pack/plugins/security/public/views/management/edit_role/lib/copy_role.test.ts new file mode 100644 index 00000000000000..5bd7aa8d6aeca0 --- /dev/null +++ b/x-pack/plugins/security/public/views/management/edit_role/lib/copy_role.test.ts @@ -0,0 +1,34 @@ +/* + * 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 { Role } from '../../../../../common/model/role'; +import { copyRole } from './copy_role'; + +describe('copyRole', () => { + it('should perform a deep copy', () => { + const role: Role = { + name: '', + elasticsearch: { + cluster: ['all'], + indices: [{ names: ['index*'], privileges: ['all'] }], + run_as: ['user'], + }, + kibana: { + global: ['read'], + space: { + marketing: ['all'], + }, + }, + }; + + const result = copyRole(role); + expect(result).toEqual(role); + + role.elasticsearch.indices[0].names = ['something else']; + + expect(result).not.toEqual(role); + }); +}); diff --git a/x-pack/plugins/security/public/views/management/edit_role/lib/copy_role.ts b/x-pack/plugins/security/public/views/management/edit_role/lib/copy_role.ts new file mode 100644 index 00000000000000..395f14756c5470 --- /dev/null +++ b/x-pack/plugins/security/public/views/management/edit_role/lib/copy_role.ts @@ -0,0 +1,12 @@ +/* + * 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 { cloneDeep } from 'lodash'; +import { Role } from '../../../../../common/model/role'; + +export function copyRole(role: Role) { + return cloneDeep(role); +} diff --git a/x-pack/plugins/security/public/views/management/edit_role/lib/get_available_privileges.test.ts b/x-pack/plugins/security/public/views/management/edit_role/lib/get_available_privileges.test.ts new file mode 100644 index 00000000000000..3f7e8b13308126 --- /dev/null +++ b/x-pack/plugins/security/public/views/management/edit_role/lib/get_available_privileges.test.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { KibanaPrivilege } from '../../../../../common/model/kibana_privilege'; +import { NO_PRIVILEGE_VALUE } from './constants'; +import { getAvailablePrivileges } from './get_available_privileges'; + +describe('getAvailablePrivileges', () => { + it('throws when given an unexpected minimum privilege', () => { + expect(() => getAvailablePrivileges('idk' as KibanaPrivilege)).toThrowErrorMatchingSnapshot(); + }); + + it(`returns all privileges when the minimum privilege is none`, () => { + expect(getAvailablePrivileges(NO_PRIVILEGE_VALUE)).toEqual(['read', 'all']); + }); + + it(`returns all privileges when the minimum privilege is read`, () => { + expect(getAvailablePrivileges('read')).toEqual(['read', 'all']); + }); + + it(`returns just the "all" privilege when the minimum privilege is all`, () => { + expect(getAvailablePrivileges('all')).toEqual(['all']); + }); +}); diff --git a/x-pack/plugins/security/public/views/management/edit_role/lib/get_available_privileges.ts b/x-pack/plugins/security/public/views/management/edit_role/lib/get_available_privileges.ts new file mode 100644 index 00000000000000..89b6ff6cd8d80a --- /dev/null +++ b/x-pack/plugins/security/public/views/management/edit_role/lib/get_available_privileges.ts @@ -0,0 +1,21 @@ +/* + * 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 { KibanaPrivilege } from '../../../../../common/model/kibana_privilege'; +import { NO_PRIVILEGE_VALUE } from './constants'; + +export function getAvailablePrivileges(minimumPrivilege: KibanaPrivilege): KibanaPrivilege[] { + switch (minimumPrivilege) { + case NO_PRIVILEGE_VALUE: + return ['read', 'all']; + case 'read': + return ['read', 'all']; + case 'all': + return ['all']; + default: + throw new Error(`Unexpected minimumPrivilege value: ${minimumPrivilege}`); + } +} diff --git a/x-pack/plugins/security/public/views/management/edit_role/lib/validate_role.test.ts b/x-pack/plugins/security/public/views/management/edit_role/lib/validate_role.test.ts new file mode 100644 index 00000000000000..162cd0cd2eab64 --- /dev/null +++ b/x-pack/plugins/security/public/views/management/edit_role/lib/validate_role.test.ts @@ -0,0 +1,397 @@ +/* + * 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 { Role } from '../../../../../common/model/role'; +import { RoleValidator } from './validate_role'; + +let validator: RoleValidator; + +describe('validateRoleName', () => { + beforeEach(() => { + validator = new RoleValidator({ shouldValidate: true }); + }); + + test('it allows an alphanumeric role name', () => { + const role: Role = { + name: 'This-is-30-character-role-name', + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: { + global: [], + space: {}, + }, + }; + + expect(validator.validateRoleName(role)).toEqual({ isInvalid: false }); + }); + + test('it requires a non-empty value', () => { + const role = { + name: '', + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: { + global: [], + space: {}, + }, + }; + + expect(validator.validateRoleName(role)).toEqual({ + isInvalid: true, + error: `Please provide a role name`, + }); + }); + + test('it cannot exceed 1024 characters', () => { + const role = { + name: new Array(1026).join('A'), + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: { + global: [], + space: {}, + }, + }; + + expect(validator.validateRoleName(role)).toEqual({ + isInvalid: true, + error: `Name must not exceed 1024 characters`, + }); + }); + + const charList = `!#%^&*()+=[]{}\|';:"/,<>?`.split(''); + charList.forEach(element => { + test(`it cannot support the "${element}" character`, () => { + const role = { + name: `role-${element}`, + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: { + global: [], + space: {}, + }, + }; + + expect(validator.validateRoleName(role)).toEqual({ + isInvalid: true, + error: `Name must begin with a letter or underscore and contain only letters, underscores, and numbers.`, + }); + }); + }); +}); + +describe('validateIndexPrivileges', () => { + beforeEach(() => { + validator = new RoleValidator({ shouldValidate: true }); + }); + + test('it ignores privilegs with no indices defined', () => { + const role = { + name: '', + elasticsearch: { + indices: [ + { + names: [], + privileges: [], + }, + ], + cluster: [], + run_as: [], + }, + kibana: { + global: [], + space: {}, + }, + }; + + expect(validator.validateIndexPrivileges(role)).toEqual({ + isInvalid: false, + }); + }); + + test('it requires privilges when an index is defined', () => { + const role = { + name: '', + elasticsearch: { + cluster: [], + indices: [ + { + names: ['index-*'], + privileges: [], + }, + ], + run_as: [], + }, + kibana: { + global: [], + space: {}, + }, + }; + + expect(validator.validateIndexPrivileges(role)).toEqual({ + isInvalid: true, + }); + }); + + test('it throws when indices is not an array', () => { + const role = { + name: '', + elasticsearch: { + cluster: [], + indices: 'asdf', + run_as: [], + }, + kibana: { + global: [], + space: {}, + }, + }; + + // @ts-ignore + expect(() => validator.validateIndexPrivileges(role)).toThrowErrorMatchingSnapshot(); + }); +}); + +describe('validateInProgressSpacePrivileges', () => { + beforeEach(() => { + validator = new RoleValidator({ shouldValidate: true }); + }); + + it('should validate when both spaces and privilege is unassigned', () => { + const role = { + name: '', + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: { + global: [], + space: {}, + }, + }; + + validator.setInProgressSpacePrivileges([{}, {}]); + expect(validator.validateInProgressSpacePrivileges(role)).toEqual({ isInvalid: false }); + }); + + it('should invalidate when spaces are not assigned to a privilege', () => { + const role = { + name: '', + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: { + global: [], + space: {}, + }, + }; + + validator.setInProgressSpacePrivileges([ + { + privilege: 'all', + }, + ]); + + expect(validator.validateInProgressSpacePrivileges(role)).toMatchObject({ + isInvalid: true, + }); + }); + + it('should invalidate when a privilege is not assigned to a space', () => { + const role = { + name: '', + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: { + global: [], + space: {}, + }, + }; + + validator.setInProgressSpacePrivileges([ + { + spaces: ['marketing'], + }, + ]); + + expect(validator.validateInProgressSpacePrivileges(role)).toMatchObject({ + isInvalid: true, + }); + }); + + it('should validate when a privilege is assigned to a space', () => { + const role = { + name: '', + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: { + global: [], + space: {}, + }, + }; + + validator.setInProgressSpacePrivileges([ + { + spaces: ['marketing'], + privilege: 'all', + }, + ]); + + expect(validator.validateInProgressSpacePrivileges(role)).toEqual({ + isInvalid: false, + }); + }); + + it('should skip validation if the global privilege is set to "all"', () => { + const role = { + name: '', + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: { + global: ['all'], + space: {}, + }, + }; + + validator.setInProgressSpacePrivileges([ + { + spaces: ['marketing'], + }, + ]); + + expect(validator.validateInProgressSpacePrivileges(role as Role)).toMatchObject({ + isInvalid: false, + }); + }); +}); + +describe('validateSpacePrivileges', () => { + beforeEach(() => { + validator = new RoleValidator({ shouldValidate: true }); + }); + + it('should validate when no privileges are defined', () => { + const role = { + name: '', + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: { + global: [], + space: {}, + }, + }; + + expect(validator.validateSpacePrivileges(role)).toEqual({ isInvalid: false }); + }); + + it('should validate when a global privilege is defined', () => { + const role = { + name: '', + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: { + global: ['all'], + space: {}, + }, + }; + + expect(validator.validateSpacePrivileges(role as Role)).toEqual({ isInvalid: false }); + }); + + it('should validate when a space privilege is defined', () => { + const role = { + name: '', + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: { + global: [], + space: { + marketing: ['read'], + }, + }, + }; + + expect(validator.validateSpacePrivileges(role as Role)).toEqual({ isInvalid: false }); + }); + + it('should validate when both global and space privileges are defined', () => { + const role = { + name: '', + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: { + global: ['all'], + space: { + default: ['foo'], + marketing: ['read'], + }, + }, + }; + + expect(validator.validateSpacePrivileges(role as Role)).toEqual({ isInvalid: false }); + }); + + it('should invalidate when in-progress space privileges are not valid', () => { + const role = { + name: '', + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: { + global: ['read'], + space: { + default: ['foo'], + marketing: ['read'], + }, + }, + }; + + validator.setInProgressSpacePrivileges([ + { + spaces: ['marketing'], + }, + ]); + + expect(validator.validateSpacePrivileges(role as Role)).toEqual({ isInvalid: true }); + }); +}); diff --git a/x-pack/plugins/security/public/views/management/edit_role/lib/validate_role.ts b/x-pack/plugins/security/public/views/management/edit_role/lib/validate_role.ts new file mode 100644 index 00000000000000..f4ada14fde8efa --- /dev/null +++ b/x-pack/plugins/security/public/views/management/edit_role/lib/validate_role.ts @@ -0,0 +1,205 @@ +/* + * 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. + */ + +/* + * Copyright Elasticsearch B.V. ../../../../../common/model/index_privileger 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 { IndexPrivilege } from '../../../../../common/model/index_privilege'; +import { KibanaPrivilege } from '../../../../../common/model/kibana_privilege'; +import { Role } from '../../../../../common/model/role'; + +/* + * 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. + */ + +interface RoleValidatorOptions { + shouldValidate?: boolean; +} + +export interface RoleValidationResult { + isInvalid: boolean; + error?: string; +} + +export class RoleValidator { + private shouldValidate?: boolean; + + private inProgressSpacePrivileges: any[] = []; + + constructor(options: RoleValidatorOptions = {}) { + this.shouldValidate = options.shouldValidate; + } + + public enableValidation() { + this.shouldValidate = true; + } + + public disableValidation() { + this.shouldValidate = false; + } + + public validateRoleName(role: Role): RoleValidationResult { + if (!this.shouldValidate) { + return valid(); + } + + if (!role.name) { + return invalid(`Please provide a role name`); + } + if (role.name.length > 1024) { + return invalid(`Name must not exceed 1024 characters`); + } + if (!role.name.match(/^[a-zA-Z_][a-zA-Z0-9_@\-\$\.]*$/)) { + return invalid( + `Name must begin with a letter or underscore and contain only letters, underscores, and numbers.` + ); + } + return valid(); + } + + public validateIndexPrivileges(role: Role): RoleValidationResult { + if (!this.shouldValidate) { + return valid(); + } + + if (!Array.isArray(role.elasticsearch.indices)) { + throw new TypeError(`Expected role.elasticsearch.indices to be an array`); + } + + const areIndicesValid = + role.elasticsearch.indices + .map(indexPriv => this.validateIndexPrivilege(indexPriv)) + .find((result: RoleValidationResult) => result.isInvalid) == null; + + if (areIndicesValid) { + return valid(); + } + return invalid(); + } + + public validateIndexPrivilege(indexPrivilege: IndexPrivilege): RoleValidationResult { + if (!this.shouldValidate) { + return valid(); + } + + if (indexPrivilege.names.length && !indexPrivilege.privileges.length) { + return invalid(`At least one privilege is required`); + } + return valid(); + } + + public validateSelectedSpaces( + spaceIds: string[], + privilege: KibanaPrivilege | null + ): RoleValidationResult { + if (!this.shouldValidate) { + return valid(); + } + + // If no assigned privilege, then no spaces are OK + if (!privilege) { + return valid(); + } + + if (Array.isArray(spaceIds) && spaceIds.length > 0) { + return valid(); + } + return invalid('At least one space is required'); + } + + public validateSelectedPrivilege( + spaceIds: string[], + privilege: KibanaPrivilege | null + ): RoleValidationResult { + if (!this.shouldValidate) { + return valid(); + } + + // If no assigned spaces, then a missing privilege is OK + if (!spaceIds || spaceIds.length === 0) { + return valid(); + } + + if (privilege) { + return valid(); + } + return invalid('Privilege is required'); + } + + public setInProgressSpacePrivileges(inProgressSpacePrivileges: any[]) { + this.inProgressSpacePrivileges = [...inProgressSpacePrivileges]; + } + + public validateInProgressSpacePrivileges(role: Role): RoleValidationResult { + const { global } = role.kibana; + + // A Global privilege of "all" will ignore all in progress privileges, + // so the form should not block saving in this scenario. + const shouldValidate = this.shouldValidate && !global.includes('all'); + + if (!shouldValidate) { + return valid(); + } + + const allInProgressValid = this.inProgressSpacePrivileges.every(({ spaces, privilege }) => { + return ( + !this.validateSelectedSpaces(spaces, privilege).isInvalid && + !this.validateSelectedPrivilege(spaces, privilege).isInvalid + ); + }); + + if (allInProgressValid) { + return valid(); + } + return invalid(); + } + + public validateSpacePrivileges(role: Role): RoleValidationResult { + if (!this.shouldValidate) { + return valid(); + } + + const privileges = Object.values(role.kibana.space || {}); + + const arePrivilegesValid = privileges.every(assignedPrivilege => !!assignedPrivilege); + const areInProgressPrivilegesValid = !this.validateInProgressSpacePrivileges(role).isInvalid; + + if (arePrivilegesValid && areInProgressPrivilegesValid) { + return valid(); + } + return invalid(); + } + + public validateForSave(role: Role): RoleValidationResult { + const { isInvalid: isNameInvalid } = this.validateRoleName(role); + const { isInvalid: areIndicesInvalid } = this.validateIndexPrivileges(role); + const { isInvalid: areSpacePrivilegesInvalid } = this.validateSpacePrivileges(role); + + if (isNameInvalid || areIndicesInvalid || areSpacePrivilegesInvalid) { + return invalid(); + } + + return valid(); + } +} + +function invalid(error?: string): RoleValidationResult { + return { + isInvalid: true, + error, + }; +} + +function valid(): RoleValidationResult { + return { + isInvalid: false, + }; +} diff --git a/x-pack/plugins/security/public/views/management/index_privileges_form/index_privileges_form.html b/x-pack/plugins/security/public/views/management/index_privileges_form/index_privileges_form.html deleted file mode 100644 index 829b0dfaefa926..00000000000000 --- a/x-pack/plugins/security/public/views/management/index_privileges_form/index_privileges_form.html +++ /dev/null @@ -1,128 +0,0 @@ -
- - - -
-
- -
- -
- - - {{$item}} - -
-
-
- - -
- - - - {{$item}} - -
-
-
- - -
- Indices must contain at least one privilege. -
- - -
- - - -
-
- - -
- -
- - - - -
- - - - {{$item}} - -
-
-
- -
-
- - - -
-
-
-
-
-
diff --git a/x-pack/plugins/security/public/views/management/index_privileges_form/index_privileges_form.js b/x-pack/plugins/security/public/views/management/index_privileges_form/index_privileges_form.js deleted file mode 100644 index 1bb058f56d0960..00000000000000 --- a/x-pack/plugins/security/public/views/management/index_privileges_form/index_privileges_form.js +++ /dev/null @@ -1,60 +0,0 @@ -/* - * 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 _ from 'lodash'; -import { uiModules } from 'ui/modules'; -import template from './index_privileges_form.html'; - -const module = uiModules.get('security', ['kibana']); -module.directive('kbnIndexPrivilegesForm', function () { - return { - template, - scope: { - isNewRole: '=', - indices: '=', - indexPatterns: '=', - privileges: '=', - fieldOptions: '=', - isReserved: '=', - isEnabled: '=', - allowDocumentLevelSecurity: '=', - allowFieldLevelSecurity: '=', - addIndex: '&', - removeIndex: '&', - }, - restrict: 'E', - replace: true, - controllerAs: 'indexPrivilegesController', - controller: function ($scope) { - this.addIndex = function addIndex() { - $scope.addIndex({ indices: $scope.indices }); - }; - - this.removeIndex = function removeIndex(index) { - $scope.removeIndex({ indices: $scope.indices, index }); - }; - - this.getIndexTitle = function getIndexTitle(index) { - const indices = index.names.length ? index.names.join(', ') : 'No indices'; - const privileges = index.privileges.length ? index.privileges.join(', ') : 'No privileges'; - return `${indices} (${privileges})`; - }; - - this.union = _.flow(_.union, _.compact); - - // If editing an existing role while that has been disabled, always show the FLS/DLS fields because currently - // a role is only marked as disabled if it has FLS/DLS setup (usually before the user changed to a license that - // doesn't permit FLS/DLS). - if (!$scope.isNewRole && !$scope.isEnabled) { - this.showDocumentLevelSecurity = true; - this.showFieldLevelSecurity = true; - } else { - this.showDocumentLevelSecurity = $scope.allowDocumentLevelSecurity; - this.showFieldLevelSecurity = $scope.allowFieldLevelSecurity; - } - }, - }; -}); diff --git a/x-pack/plugins/security/public/views/management/index_privileges_form/index_privileges_form.less b/x-pack/plugins/security/public/views/management/index_privileges_form/index_privileges_form.less deleted file mode 100644 index edd7a4898f45a0..00000000000000 --- a/x-pack/plugins/security/public/views/management/index_privileges_form/index_privileges_form.less +++ /dev/null @@ -1,7 +0,0 @@ -.indexPrivilegesForm { - height: 550px; -} - -.indexPrivilegesList { - flex: 0 0 400px; -} diff --git a/x-pack/plugins/security/public/views/management/management.js b/x-pack/plugins/security/public/views/management/management.js index 4781a27dcd31aa..11ede2bb9e52e6 100644 --- a/x-pack/plugins/security/public/views/management/management.js +++ b/x-pack/plugins/security/public/views/management/management.js @@ -5,12 +5,11 @@ */ import 'plugins/security/views/management/change_password_form/change_password_form'; -import 'plugins/security/views/management/index_privileges_form/index_privileges_form'; import 'plugins/security/views/management/password_form/password_form'; import 'plugins/security/views/management/users'; import 'plugins/security/views/management/roles'; import 'plugins/security/views/management/edit_user'; -import 'plugins/security/views/management/edit_role'; +import 'plugins/security/views/management/edit_role/index'; import 'plugins/security/views/management/management.less'; import routes from 'ui/routes'; import { XPackInfoProvider } from 'plugins/xpack_main/services/xpack_info'; diff --git a/x-pack/plugins/security/public/views/management/management.less b/x-pack/plugins/security/public/views/management/management.less index 046f4b7eb8c535..fa87006e6593e5 100644 --- a/x-pack/plugins/security/public/views/management/management.less +++ b/x-pack/plugins/security/public/views/management/management.less @@ -1,5 +1,4 @@ @import '~plugins/xpack_main/style/main.less'; -@import './index_privileges_form/index_privileges_form'; .kuiFormFooter { display: flex; diff --git a/x-pack/plugins/security/public/views/management/management_urls.js b/x-pack/plugins/security/public/views/management/management_urls.ts similarity index 100% rename from x-pack/plugins/security/public/views/management/management_urls.js rename to x-pack/plugins/security/public/views/management/management_urls.ts diff --git a/x-pack/plugins/security/server/lib/__tests__/__fixtures__/server.js b/x-pack/plugins/security/server/lib/__tests__/__fixtures__/server.js index 78a015bc141058..3814c282a911e1 100644 --- a/x-pack/plugins/security/server/lib/__tests__/__fixtures__/server.js +++ b/x-pack/plugins/security/server/lib/__tests__/__fixtures__/server.js @@ -37,7 +37,9 @@ export function serverFixture() { authenticate: stub(), deauthenticate: stub(), authorization: { - checkPrivilegesWithRequest: stub(), + mode: { + useRbacForRequest: stub(), + }, actions: { login: 'stub-login-action', }, diff --git a/x-pack/plugins/security/server/lib/__tests__/check_license.js b/x-pack/plugins/security/server/lib/__tests__/check_license.js index 249e62af585744..365cf399519d9e 100644 --- a/x-pack/plugins/security/server/lib/__tests__/check_license.js +++ b/x-pack/plugins/security/server/lib/__tests__/check_license.js @@ -18,7 +18,7 @@ describe('check_license', function () { isXpackUnavailable: sinon.stub(), feature: sinon.stub(), license: sinon.stub({ - isOneOf() {}, + isOneOf() { }, }) }; diff --git a/x-pack/plugins/security/server/lib/authentication/__tests__/authenticator.js b/x-pack/plugins/security/server/lib/authentication/__tests__/authenticator.js index ab32f30c2e3156..7a11e694b8975f 100644 --- a/x-pack/plugins/security/server/lib/authentication/__tests__/authenticator.js +++ b/x-pack/plugins/security/server/lib/authentication/__tests__/authenticator.js @@ -22,6 +22,7 @@ describe('Authenticator', () => { let server; let session; let cluster; + let authorizationMode; beforeEach(() => { server = serverFixture(); session = sinon.createStubInstance(Session); @@ -34,6 +35,8 @@ describe('Authenticator', () => { cluster = sinon.stub({ callWithRequest() {} }); sandbox.stub(ClientShield, 'getClient').returns(cluster); + authorizationMode = { initialize: sinon.stub() }; + server.config.returns(config); server.register.yields(); @@ -83,7 +86,7 @@ describe('Authenticator', () => { server.plugins.kibana.systemApi.isSystemApiRequest.returns(true); session.clear.throws(new Error('`Session.clear` is not supposed to be called!')); - await initAuthenticator(server); + await initAuthenticator(server, authorizationMode); // Second argument will be a method we'd like to test. authenticate = server.expose.withArgs('authenticate').firstCall.args[1]; @@ -112,6 +115,18 @@ describe('Authenticator', () => { expect(authenticationResult.error).to.be(failureReason); }); + it(`doesn't initialize authorizationMode when authentication fails.`, async () => { + const request = requestFixture({ headers: { authorization: 'Basic ***' } }); + session.get.withArgs(request).returns(Promise.resolve(null)); + + const failureReason = new Error('Not Authorized'); + cluster.callWithRequest.withArgs(request).returns(Promise.reject(failureReason)); + + await authenticate(request); + + sinon.assert.notCalled(authorizationMode.initialize); + }); + it('returns user that authentication provider returns.', async () => { const request = requestFixture({ headers: { authorization: 'Basic ***' } }); const user = { username: 'user' }; @@ -125,6 +140,15 @@ describe('Authenticator', () => { }); }); + it('initiliazes authorizationMode when authentication succeeds.', async () => { + const request = requestFixture({ headers: { authorization: 'Basic ***' } }); + const user = { username: 'user' }; + cluster.callWithRequest.withArgs(request).returns(Promise.resolve(user)); + + await authenticate(request); + sinon.assert.calledWith(authorizationMode.initialize, request); + }); + it('creates session whenever authentication provider returns state to store.', async () => { const user = { username: 'user' }; const systemAPIRequest = requestFixture({ headers: { authorization: 'Basic xxx' } }); diff --git a/x-pack/plugins/security/server/lib/authentication/authenticator.js b/x-pack/plugins/security/server/lib/authentication/authenticator.js index 3f11f478921054..e731c6724e9d40 100644 --- a/x-pack/plugins/security/server/lib/authentication/authenticator.js +++ b/x-pack/plugins/security/server/lib/authentication/authenticator.js @@ -102,11 +102,13 @@ class Authenticator { * @param {Hapi.Server} server HapiJS Server instance. * @param {AuthScopeService} authScope AuthScopeService instance. * @param {Session} session Session instance. + * @param {AuthorizationMode} authorizationMode AuthorizationMode instance */ - constructor(server, authScope, session) { + constructor(server, authScope, session, authorizationMode) { this._server = server; this._authScope = authScope; this._session = session; + this._authorizationMode = authorizationMode; const config = this._server.config(); const authProviders = config.get('xpack.security.authProviders'); @@ -168,6 +170,8 @@ class Authenticator { } if (authenticationResult.succeeded()) { + // we have to do this here, as the auth scope's could be dependent on this + await this._authorizationMode.initialize(request); return AuthenticationResult.succeeded({ ...authenticationResult.user, // Complement user returned from the provider with scopes. @@ -269,10 +273,10 @@ class Authenticator { } } -export async function initAuthenticator(server) { +export async function initAuthenticator(server, authorizationMode) { const session = await Session.create(server); const authScope = new AuthScopeService(); - const authenticator = new Authenticator(server, authScope, session); + const authenticator = new Authenticator(server, authScope, session, authorizationMode); server.expose('authenticate', (request) => authenticator.authenticate(request)); server.expose('deauthenticate', (request) => authenticator.deauthenticate(request)); diff --git a/x-pack/plugins/security/server/lib/authorization/__snapshots__/check_privileges.test.js.snap b/x-pack/plugins/security/server/lib/authorization/__snapshots__/check_privileges.test.js.snap index 7609d57b702faf..634600a2549d62 100644 --- a/x-pack/plugins/security/server/lib/authorization/__snapshots__/check_privileges.test.js.snap +++ b/x-pack/plugins/security/server/lib/authorization/__snapshots__/check_privileges.test.js.snap @@ -1,13 +1,27 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`with a malformed Elasticsearch response throws a validation error when an extra index privilege is present in the response 1`] = `[Error: Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child "index" fails because [child "default-index" fails because ["oopsAnExtraPrivilege" is not allowed]]]`; +exports[`#checkPrivilegesAtSpace throws error when checking for login and user has login but doesn't have version 1`] = `[Error: Multiple versions of Kibana are running against the same Elasticsearch cluster, unable to authorize user.]`; -exports[`with a malformed Elasticsearch response throws a validation error when an extra privilege is present in the response 1`] = `[Error: Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child "application" fails because [child "kibana-our_application" fails because [child "*" fails because ["oops-an-unexpected-privilege" is not allowed]]]]`; +exports[`#checkPrivilegesAtSpace with a malformed Elasticsearch response throws a validation error when an extra privilege is present in the response 1`] = `[Error: Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child "application" fails because [child "kibana-our_application" fails because [child "space:space_1" fails because ["action:saved_objects/bar-type/get" is not allowed]]]]`; -exports[`with a malformed Elasticsearch response throws a validation error when index privileges are missing in the response 1`] = `[Error: Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child "index" fails because [child "default-index" fails because [child "read" fails because ["read" is required]]]]`; +exports[`#checkPrivilegesAtSpace with a malformed Elasticsearch response throws a validation error when privileges are missing in the response 1`] = `[Error: Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child "application" fails because [child "kibana-our_application" fails because [child "space:space_1" fails because [child "action:saved_objects/foo-type/get" fails because ["action:saved_objects/foo-type/get" is required]]]]]`; -exports[`with a malformed Elasticsearch response throws a validation error when privileges are missing in the response 1`] = `[Error: Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child "application" fails because [child "kibana-our_application" fails because [child "*" fails because [child "mock-action:version" fails because ["mock-action:version" is required]]]]]`; +exports[`#checkPrivilegesAtSpaces throws error when Elasticsearch returns malformed response 1`] = `[Error: Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child "application" fails because [child "kibana-our_application" fails because [child "space:space_1" fails because [child "mock-action:version" fails because ["mock-action:version" is required]]]]]`; -exports[`with index privileges throws error if missing version privilege and has login privilege 1`] = `[Error: Multiple versions of Kibana are running against the same Elasticsearch cluster, unable to authorize user.]`; +exports[`#checkPrivilegesAtSpaces throws error when checking for login and user has login but doesn't have version 1`] = `[Error: Multiple versions of Kibana are running against the same Elasticsearch cluster, unable to authorize user.]`; -exports[`with no index privileges throws error if missing version privilege and has login privilege 1`] = `[Error: Multiple versions of Kibana are running against the same Elasticsearch cluster, unable to authorize user.]`; +exports[`#checkPrivilegesAtSpaces with a malformed Elasticsearch response throws a validation error when an a space is missing in the response 1`] = `[Error: Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child "application" fails because [child "kibana-our_application" fails because [child "space:space_2" fails because ["space:space_2" is required]]]]`; + +exports[`#checkPrivilegesAtSpaces with a malformed Elasticsearch response throws a validation error when an extra privilege is present in the response 1`] = `[Error: Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child "application" fails because [child "kibana-our_application" fails because [child "space:space_2" fails because ["space:space_2" is required]]]]`; + +exports[`#checkPrivilegesAtSpaces with a malformed Elasticsearch response throws a validation error when an extra space is present in the response 1`] = `[Error: Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child "application" fails because [child "kibana-our_application" fails because ["space:space_3" is not allowed]]]`; + +exports[`#checkPrivilegesAtSpaces with a malformed Elasticsearch response throws a validation error when privileges are missing in the response 1`] = `[Error: Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child "application" fails because [child "kibana-our_application" fails because [child "space:space_2" fails because ["space:space_2" is required]]]]`; + +exports[`#checkPrivilegesGlobally throws error when Elasticsearch returns malformed response 1`] = `[Error: Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child "application" fails because [child "kibana-our_application" fails because [child "*" fails because [child "mock-action:version" fails because ["mock-action:version" is required]]]]]`; + +exports[`#checkPrivilegesGlobally throws error when checking for login and user has login but doesn't have version 1`] = `[Error: Multiple versions of Kibana are running against the same Elasticsearch cluster, unable to authorize user.]`; + +exports[`#checkPrivilegesGlobally with a malformed Elasticsearch response throws a validation error when an extra privilege is present in the response 1`] = `[Error: Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child "application" fails because [child "kibana-our_application" fails because [child "*" fails because ["action:saved_objects/bar-type/get" is not allowed]]]]`; + +exports[`#checkPrivilegesGlobally with a malformed Elasticsearch response throws a validation error when privileges are missing in the response 1`] = `[Error: Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child "application" fails because [child "kibana-our_application" fails because [child "*" fails because [child "action:saved_objects/foo-type/get" fails because ["action:saved_objects/foo-type/get" is required]]]]]`; diff --git a/x-pack/plugins/security/server/lib/authorization/__snapshots__/init.test.js.snap b/x-pack/plugins/security/server/lib/authorization/__snapshots__/init.test.js.snap deleted file mode 100644 index c65b0d2d6ae391..00000000000000 --- a/x-pack/plugins/security/server/lib/authorization/__snapshots__/init.test.js.snap +++ /dev/null @@ -1,9 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`deep freezes exposed service 1`] = `"Cannot delete property 'checkPrivilegesWithRequest' of #"`; - -exports[`deep freezes exposed service 2`] = `"Cannot add property foo, object is not extensible"`; - -exports[`deep freezes exposed service 3`] = `"Cannot assign to read only property 'login' of object '#'"`; - -exports[`deep freezes exposed service 4`] = `"Cannot assign to read only property 'application' of object '#'"`; diff --git a/x-pack/plugins/security/server/lib/authorization/__snapshots__/mode.test.js.snap b/x-pack/plugins/security/server/lib/authorization/__snapshots__/mode.test.js.snap new file mode 100644 index 00000000000000..66840335528ce2 --- /dev/null +++ b/x-pack/plugins/security/server/lib/authorization/__snapshots__/mode.test.js.snap @@ -0,0 +1,3 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`#initialize can't be initialized twice for the same request 1`] = `"Authorization mode is already intitialized"`; diff --git a/x-pack/plugins/security/server/lib/authorization/__snapshots__/space_application_privileges_serializer.test.js.snap b/x-pack/plugins/security/server/lib/authorization/__snapshots__/space_application_privileges_serializer.test.js.snap new file mode 100644 index 00000000000000..0a943137989ecb --- /dev/null +++ b/x-pack/plugins/security/server/lib/authorization/__snapshots__/space_application_privileges_serializer.test.js.snap @@ -0,0 +1,5 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`#privilege #deserialize throws error if privilege doesn't start with space_ 1`] = `"Space privilege should have started with space_"`; + +exports[`#resource #deserialize throws error if resource doesn't start with space: 1`] = `"Resource should have started with space:"`; diff --git a/x-pack/plugins/security/server/lib/authorization/__snapshots__/validate_es_response.test.js.snap b/x-pack/plugins/security/server/lib/authorization/__snapshots__/validate_es_response.test.js.snap index 5e1e26a9023ae7..226002545a378f 100644 --- a/x-pack/plugins/security/server/lib/authorization/__snapshots__/validate_es_response.test.js.snap +++ b/x-pack/plugins/security/server/lib/authorization/__snapshots__/validate_es_response.test.js.snap @@ -4,40 +4,18 @@ exports[`validateEsPrivilegeResponse fails validation when an action is malforme exports[`validateEsPrivilegeResponse fails validation when an action is missing in the response 1`] = `"Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child \\"application\\" fails because [child \\"foo-application\\" fails because [child \\"foo-resource\\" fails because [child \\"action2\\" fails because [\\"action2\\" is required]]]]"`; +exports[`validateEsPrivilegeResponse fails validation when an expected resource property is missing from the response 1`] = `"Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child \\"application\\" fails because [child \\"foo-application\\" fails because [child \\"bar-resource\\" fails because [\\"bar-resource\\" is required]]]"`; + exports[`validateEsPrivilegeResponse fails validation when an extra action is present in the response 1`] = `"Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child \\"application\\" fails because [child \\"foo-application\\" fails because [child \\"foo-resource\\" fails because [\\"action4\\" is not allowed]]]"`; exports[`validateEsPrivilegeResponse fails validation when an extra application is present in the response 1`] = `"Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child \\"application\\" fails because [\\"otherApplication\\" is not allowed]"`; -exports[`validateEsPrivilegeResponse fails validation when an unexpected resource property is present in the response 1`] = `"Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child \\"application\\" fails because [child \\"foo-application\\" fails because [child \\"foo-resource\\" fails because [\\"foo-resource\\" is required]]]"`; +exports[`validateEsPrivilegeResponse fails validation when an unexpected resource property is present in the response 1`] = `"Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child \\"application\\" fails because [child \\"foo-application\\" fails because [child \\"bar-resource\\" fails because [\\"bar-resource\\" is required]]]"`; exports[`validateEsPrivilegeResponse fails validation when the "application" property is missing from the response 1`] = `"Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child \\"application\\" fails because [\\"application\\" is required]"`; -exports[`validateEsPrivilegeResponse fails validation when the expected resource property is missing from the response 1`] = `"Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child \\"application\\" fails because [child \\"foo-application\\" fails because [child \\"foo-resource\\" fails because [\\"foo-resource\\" is required]]]"`; - exports[`validateEsPrivilegeResponse fails validation when the requested application is missing from the response 1`] = `"Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child \\"application\\" fails because [child \\"foo-application\\" fails because [\\"foo-application\\" is required]]"`; exports[`validateEsPrivilegeResponse fails validation when the resource propertry is malformed in the response 1`] = `"Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child \\"application\\" fails because [child \\"foo-application\\" fails because [child \\"foo-resource\\" fails because [\\"foo-resource\\" must be an object]]]"`; -exports[`validateEsPrivilegeResponse legacy should fail if the create index privilege is malformed 1`] = `"Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child \\"index\\" fails because [child \\".kibana\\" fails because [child \\"create\\" fails because [\\"create\\" must be a boolean]]]"`; - -exports[`validateEsPrivilegeResponse legacy should fail if the create index privilege is missing from the response 1`] = `"Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child \\"index\\" fails because [child \\".kibana\\" fails because [child \\"create\\" fails because [\\"create\\" is required]]]"`; - -exports[`validateEsPrivilegeResponse legacy should fail if the delete index privilege is malformed 1`] = `"Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child \\"index\\" fails because [child \\".kibana\\" fails because [child \\"delete\\" fails because [\\"delete\\" must be a boolean]]]"`; - -exports[`validateEsPrivilegeResponse legacy should fail if the delete index privilege is missing from the response 1`] = `"Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child \\"index\\" fails because [child \\".kibana\\" fails because [child \\"delete\\" fails because [\\"delete\\" is required]]]"`; - -exports[`validateEsPrivilegeResponse legacy should fail if the index privilege response contains an extra privilege 1`] = `"Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child \\"index\\" fails because [child \\".kibana\\" fails because [\\"foo-permission\\" is not allowed]]"`; - -exports[`validateEsPrivilegeResponse legacy should fail if the index privilege response returns an extra index 1`] = `"Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child \\"index\\" fails because [\\"anotherIndex\\" is not allowed]"`; - -exports[`validateEsPrivilegeResponse legacy should fail if the index property is missing 1`] = `"Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child \\"index\\" fails because [\\"index\\" is required]"`; - -exports[`validateEsPrivilegeResponse legacy should fail if the kibana index is missing from the response 1`] = `"Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child \\"index\\" fails because [child \\".kibana\\" fails because [\\".kibana\\" is required]]"`; - -exports[`validateEsPrivilegeResponse legacy should fail if the read index privilege is malformed 1`] = `"Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child \\"index\\" fails because [child \\".kibana\\" fails because [child \\"read\\" fails because [\\"read\\" must be a boolean]]]"`; - -exports[`validateEsPrivilegeResponse legacy should fail if the read index privilege is missing from the response 1`] = `"Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child \\"index\\" fails because [child \\".kibana\\" fails because [child \\"read\\" fails because [\\"read\\" is required]]]"`; - -exports[`validateEsPrivilegeResponse legacy should fail if the view_index_metadata index privilege is malformed 1`] = `"Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child \\"index\\" fails because [child \\".kibana\\" fails because [child \\"view_index_metadata\\" fails because [\\"view_index_metadata\\" must be a boolean]]]"`; - -exports[`validateEsPrivilegeResponse legacy should fail if the view_index_metadata index privilege is missing from the response 1`] = `"Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child \\"index\\" fails because [child \\".kibana\\" fails because [child \\"view_index_metadata\\" fails because [\\"view_index_metadata\\" is required]]]"`; +exports[`validateEsPrivilegeResponse fails validation when there are no resource properties in the response 1`] = `"Invalid response received from Elasticsearch has_privilege endpoint. ValidationError: child \\"application\\" fails because [child \\"foo-application\\" fails because [child \\"foo-resource\\" fails because [\\"foo-resource\\" is required]]]"`; diff --git a/x-pack/plugins/security/server/lib/authorization/actions.js b/x-pack/plugins/security/server/lib/authorization/actions.js index 432698a003cb35..e47e1edd5d4c40 100644 --- a/x-pack/plugins/security/server/lib/authorization/actions.js +++ b/x-pack/plugins/security/server/lib/authorization/actions.js @@ -22,5 +22,6 @@ export function actionsFactory(config) { }, login: `action:login`, version: `version:${kibanaVersion}`, + manageSpaces: 'action:manage_spaces/*', }; } diff --git a/x-pack/plugins/security/server/lib/authorization/actions.test.js b/x-pack/plugins/security/server/lib/authorization/actions.test.js index 17834438e17814..9ae2265557d21b 100644 --- a/x-pack/plugins/security/server/lib/authorization/actions.test.js +++ b/x-pack/plugins/security/server/lib/authorization/actions.test.js @@ -66,4 +66,14 @@ describe('#getSavedObjectAction()', () => { expect(() => actions.getSavedObjectAction('saved-object-type', action)).toThrowErrorMatchingSnapshot(); }); }); + + describe('#manageSpaces', () => { + test('returns action:manage_spaces/*', () => { + const mockConfig = createMockConfig(); + + const actions = actionsFactory(mockConfig); + + expect(actions.manageSpaces).toEqual('action:manage_spaces/*'); + }); + }); }); diff --git a/x-pack/plugins/security/server/lib/authorization/check_privileges.js b/x-pack/plugins/security/server/lib/authorization/check_privileges.js index b12658708f2d37..bdd9781f977717 100644 --- a/x-pack/plugins/security/server/lib/authorization/check_privileges.js +++ b/x-pack/plugins/security/server/lib/authorization/check_privileges.js @@ -4,94 +4,85 @@ * you may not use this file except in compliance with the Elastic License. */ -import { uniq } from 'lodash'; -import { ALL_RESOURCE } from '../../../common/constants'; -import { buildLegacyIndexPrivileges } from './privileges'; +import { pick, transform, uniq } from 'lodash'; +import { GLOBAL_RESOURCE } from '../../../common/constants'; +import { spaceApplicationPrivilegesSerializer } from './space_application_privileges_serializer'; import { validateEsPrivilegeResponse } from './validate_es_response'; -export const CHECK_PRIVILEGES_RESULT = { - UNAUTHORIZED: Symbol('Unauthorized'), - AUTHORIZED: Symbol('Authorized'), - LEGACY: Symbol('Legacy'), -}; - -export function checkPrivilegesWithRequestFactory(shieldClient, config, actions, application) { +export function checkPrivilegesWithRequestFactory(actions, application, shieldClient) { const { callWithRequest } = shieldClient; - const kibanaIndex = config.get('kibana.index'); - const hasIncompatibileVersion = (applicationPrivilegesResponse) => { - return !applicationPrivilegesResponse[actions.version] && applicationPrivilegesResponse[actions.login]; - }; - - const hasAllApplicationPrivileges = (applicationPrivilegesResponse) => { - return Object.values(applicationPrivilegesResponse).every(val => val === true); - }; - - const hasNoApplicationPrivileges = (applicationPrivilegesResponse) => { - return Object.values(applicationPrivilegesResponse).every(val => val === false); - }; - - const isLegacyFallbackEnabled = () => { - return config.get('xpack.security.authorization.legacyFallback.enabled'); - }; - - const hasLegacyPrivileges = (indexPrivilegesResponse) => { - return Object.values(indexPrivilegesResponse).includes(true); - }; - - const determineResult = (applicationPrivilegesResponse, indexPrivilegesResponse) => { - if (hasAllApplicationPrivileges(applicationPrivilegesResponse)) { - return CHECK_PRIVILEGES_RESULT.AUTHORIZED; - } - - if ( - isLegacyFallbackEnabled() && - hasNoApplicationPrivileges(applicationPrivilegesResponse) && - hasLegacyPrivileges(indexPrivilegesResponse) - ) { - return CHECK_PRIVILEGES_RESULT.LEGACY; - } - - return CHECK_PRIVILEGES_RESULT.UNAUTHORIZED; + return Object.values(applicationPrivilegesResponse).some(resource => !resource[actions.version] && resource[actions.login]); }; return function checkPrivilegesWithRequest(request) { - return async function checkPrivileges(privileges) { + const checkPrivilegesAtResources = async (resources, privilegeOrPrivileges) => { + const privileges = Array.isArray(privilegeOrPrivileges) ? privilegeOrPrivileges : [privilegeOrPrivileges]; const allApplicationPrivileges = uniq([actions.version, actions.login, ...privileges]); + const hasPrivilegesResponse = await callWithRequest(request, 'shield.hasPrivileges', { body: { applications: [{ application, - resources: [ALL_RESOURCE], + resources, privileges: allApplicationPrivileges }], - index: [{ - names: [kibanaIndex], - privileges: buildLegacyIndexPrivileges() - }], } }); - validateEsPrivilegeResponse(hasPrivilegesResponse, application, allApplicationPrivileges, [ALL_RESOURCE], kibanaIndex); + validateEsPrivilegeResponse(hasPrivilegesResponse, application, allApplicationPrivileges, resources); - const applicationPrivilegesResponse = hasPrivilegesResponse.application[application][ALL_RESOURCE]; - const indexPrivilegesResponse = hasPrivilegesResponse.index[kibanaIndex]; + const applicationPrivilegesResponse = hasPrivilegesResponse.application[application]; if (hasIncompatibileVersion(applicationPrivilegesResponse)) { throw new Error('Multiple versions of Kibana are running against the same Elasticsearch cluster, unable to authorize user.'); } return { - result: determineResult(applicationPrivilegesResponse, indexPrivilegesResponse), + hasAllRequested: hasPrivilegesResponse.has_all_requested, username: hasPrivilegesResponse.username, + // we need to filter out the non requested privileges from the response + resourcePrivileges: transform(applicationPrivilegesResponse, (result, value, key) => { + result[key] = pick(value, privileges); + }), + }; + }; - // we only return missing privileges that they're specifically checking for - missing: Object.keys(applicationPrivilegesResponse) - .filter(privilege => privileges.includes(privilege)) - .filter(privilege => !applicationPrivilegesResponse[privilege]) + const checkPrivilegesAtResource = async (resource, privilegeOrPrivileges) => { + const { hasAllRequested, username, resourcePrivileges } = await checkPrivilegesAtResources([resource], privilegeOrPrivileges); + return { + hasAllRequested, + username, + privileges: resourcePrivileges[resource], }; }; + + return { + // TODO: checkPrivileges.atResources isn't necessary once we have the ES API to list all privileges + // this should be removed when we switch to this API, and is not covered by unit tests currently + atResources: checkPrivilegesAtResources, + async atSpace(spaceId, privilegeOrPrivileges) { + const spaceResource = spaceApplicationPrivilegesSerializer.resource.serialize(spaceId); + return await checkPrivilegesAtResource(spaceResource, privilegeOrPrivileges); + }, + async atSpaces(spaceIds, privilegeOrPrivileges) { + const spaceResources = spaceIds.map(spaceId => spaceApplicationPrivilegesSerializer.resource.serialize(spaceId)); + const { hasAllRequested, username, resourcePrivileges } = await checkPrivilegesAtResources(spaceResources, privilegeOrPrivileges); + return { + hasAllRequested, + username, + // we need to turn the resource responses back into the space ids + spacePrivileges: transform(resourcePrivileges, (result, value, key) => { + result[spaceApplicationPrivilegesSerializer.resource.deserialize(key)] = value; + }), + }; + + }, + async globally(privilegeOrPrivileges) { + return await checkPrivilegesAtResource(GLOBAL_RESOURCE, privilegeOrPrivileges); + }, + }; }; } diff --git a/x-pack/plugins/security/server/lib/authorization/check_privileges.test.js b/x-pack/plugins/security/server/lib/authorization/check_privileges.test.js index 510ec3e4852b72..f74528d4fd20d6 100644 --- a/x-pack/plugins/security/server/lib/authorization/check_privileges.test.js +++ b/x-pack/plugins/security/server/lib/authorization/check_privileges.test.js @@ -5,37 +5,17 @@ */ import { uniq } from 'lodash'; -import { checkPrivilegesWithRequestFactory, CHECK_PRIVILEGES_RESULT } from './check_privileges'; - -import { ALL_RESOURCE } from '../../../common/constants'; +import { checkPrivilegesWithRequestFactory } from './check_privileges'; +import { GLOBAL_RESOURCE } from '../../../common/constants'; const application = 'kibana-our_application'; -const defaultVersion = 'default-version'; -const defaultKibanaIndex = 'default-index'; -const savedObjectTypes = ['foo-type', 'bar-type']; const mockActions = { login: 'mock-action:login', version: 'mock-action:version', }; -const createMockConfig = (settings = {}) => { - const mockConfig = { - get: jest.fn() - }; - - const defaultSettings = { - 'pkg.version': defaultVersion, - 'kibana.index': defaultKibanaIndex, - 'xpack.security.authorization.legacyFallback.enabled': true, - }; - - mockConfig.get.mockImplementation(key => { - return key in settings ? settings[key] : defaultSettings[key]; - }); - - return mockConfig; -}; +const savedObjectTypes = ['foo-type', 'bar-type']; const createMockShieldClient = (response) => { const mockCallWithRequest = jest.fn(); @@ -47,424 +27,841 @@ const createMockShieldClient = (response) => { }; }; -const checkPrivilegesTest = ( - description, { - settings, - privileges, - applicationPrivilegesResponse, - indexPrivilegesResponse, +describe('#checkPrivilegesAtSpace', () => { + const checkPrivilegesAtSpaceTest = (description, { + spaceId, + privilegeOrPrivileges, + esHasPrivilegesResponse, expectedResult, - expectErrorThrown, + expectErrorThrown }) => { + test(description, async () => { + const mockShieldClient = createMockShieldClient(esHasPrivilegesResponse); + const checkPrivilegesWithRequest = checkPrivilegesWithRequestFactory(mockActions, application, mockShieldClient); + const request = Symbol(); + const checkPrivileges = checkPrivilegesWithRequest(request); + + let actualResult; + let errorThrown = null; + try { + actualResult = await checkPrivileges.atSpace(spaceId, privilegeOrPrivileges); + } catch (err) { + errorThrown = err; + } - test(description, async () => { - const username = 'foo-username'; - const mockConfig = createMockConfig(settings); - const mockShieldClient = createMockShieldClient({ - username, - application: { - [application]: { - [ALL_RESOURCE]: applicationPrivilegesResponse + expect(mockShieldClient.callWithRequest).toHaveBeenCalledWith(request, 'shield.hasPrivileges', { + body: { + applications: [{ + application, + resources: [`space:${spaceId}`], + privileges: uniq([ + mockActions.version, + mockActions.login, + ...Array.isArray(privilegeOrPrivileges) ? privilegeOrPrivileges : [privilegeOrPrivileges], + ]) + }] } - }, - index: { - [defaultKibanaIndex]: indexPrivilegesResponse - }, - }); + }); - const checkPrivilegesWithRequest = checkPrivilegesWithRequestFactory(mockShieldClient, mockConfig, mockActions, application); - const request = Symbol(); - const checkPrivileges = checkPrivilegesWithRequest(request); - - let actualResult; - let errorThrown = null; - try { - actualResult = await checkPrivileges(privileges); - } catch (err) { - errorThrown = err; - } - - - expect(mockShieldClient.callWithRequest).toHaveBeenCalledWith(request, 'shield.hasPrivileges', { - body: { - applications: [{ - application, - resources: [ALL_RESOURCE], - privileges: uniq([ - mockActions.version, mockActions.login, ...privileges - ]) - }], - index: [{ - names: [defaultKibanaIndex], - privileges: ['create', 'delete', 'read', 'view_index_metadata'] - }], + if (expectedResult) { + expect(errorThrown).toBeNull(); + expect(actualResult).toEqual(expectedResult); } - }); - - if (expectedResult) { - expect(errorThrown).toBeNull(); - expect(actualResult).toEqual(expectedResult); - } - - if (expectErrorThrown) { - expect(errorThrown).toMatchSnapshot(); - } - }); -}; -describe(`with no index privileges`, () => { - const indexPrivilegesResponse = { - create: false, - delete: false, - read: false, - view_index_metadata: false, + if (expectErrorThrown) { + expect(errorThrown).toMatchSnapshot(); + } + }); }; - checkPrivilegesTest('returns authorized if they have all application privileges', { - username: 'foo-username', - privileges: [ - `action:saved_objects/${savedObjectTypes[0]}/get` - ], - applicationPrivilegesResponse: { - [mockActions.version]: true, - [mockActions.login]: true, - [`action:saved_objects/${savedObjectTypes[0]}/get`]: true, + checkPrivilegesAtSpaceTest('successful when checking for login and user has login', { + spaceId: 'space_1', + privilegeOrPrivileges: mockActions.login, + esHasPrivilegesResponse: { + has_all_requested: true, + username: 'foo-username', + application: { + [application]: { + 'space:space_1': { + [mockActions.login]: true, + [mockActions.version]: true, + } + } + } }, - indexPrivilegesResponse, expectedResult: { - result: CHECK_PRIVILEGES_RESULT.AUTHORIZED, + hasAllRequested: true, username: 'foo-username', - missing: [], - } + privileges: { + [mockActions.login]: true + } + }, }); - checkPrivilegesTest('returns unauthorized and missing application action when checking missing application action', { - username: 'foo-username', - privileges: [ - `action:saved_objects/${savedObjectTypes[0]}/get`, - `action:saved_objects/${savedObjectTypes[0]}/create`, - ], - applicationPrivilegesResponse: { - [mockActions.version]: true, - [mockActions.login]: true, - [`action:saved_objects/${savedObjectTypes[0]}/get`]: true, - [`action:saved_objects/${savedObjectTypes[0]}/create`]: false, + checkPrivilegesAtSpaceTest(`failure when checking for login and user doesn't have login`, { + spaceId: 'space_1', + privilegeOrPrivileges: mockActions.login, + esHasPrivilegesResponse: { + has_all_requested: false, + username: 'foo-username', + application: { + [application]: { + 'space:space_1': { + [mockActions.login]: false, + [mockActions.version]: true, + } + } + } }, - indexPrivilegesResponse, expectedResult: { - result: CHECK_PRIVILEGES_RESULT.UNAUTHORIZED, + hasAllRequested: false, username: 'foo-username', - missing: [`action:saved_objects/${savedObjectTypes[0]}/create`], - } + privileges: { + [mockActions.login]: false + } + }, + }); + + checkPrivilegesAtSpaceTest(`throws error when checking for login and user has login but doesn't have version`, { + spaceId: 'space_1', + privilegeOrPrivileges: mockActions.login, + esHasPrivilegesResponse: { + has_all_requested: false, + username: 'foo-username', + application: { + [application]: { + 'space:space_1': { + [mockActions.login]: true, + [mockActions.version]: false, + } + } + } + }, + expectErrorThrown: true, }); - checkPrivilegesTest('returns unauthorized and missing login when checking missing login action', { - username: 'foo-username', - privileges: [ - mockActions.login - ], - applicationPrivilegesResponse: { - [mockActions.login]: false, - [mockActions.version]: false, + checkPrivilegesAtSpaceTest(`successful when checking for two actions and the user has both`, { + spaceId: 'space_1', + privilegeOrPrivileges: [`action:saved_objects/${savedObjectTypes[0]}/get`, `action:saved_objects/${savedObjectTypes[1]}/get`], + esHasPrivilegesResponse: { + has_all_requested: true, + username: 'foo-username', + application: { + [application]: { + 'space:space_1': { + [mockActions.login]: true, + [mockActions.version]: true, + [`action:saved_objects/${savedObjectTypes[0]}/get`]: true, + [`action:saved_objects/${savedObjectTypes[1]}/get`]: true, + } + } + } }, - indexPrivilegesResponse, expectedResult: { - result: CHECK_PRIVILEGES_RESULT.UNAUTHORIZED, + hasAllRequested: true, username: 'foo-username', - missing: [mockActions.login], - } + privileges: { + [`action:saved_objects/${savedObjectTypes[0]}/get`]: true, + [`action:saved_objects/${savedObjectTypes[1]}/get`]: true, + } + }, }); - checkPrivilegesTest('returns unauthorized and missing version if checking missing version action', { - username: 'foo-username', - privileges: [ - mockActions.version - ], - applicationPrivilegesResponse: { - [mockActions.login]: false, - [mockActions.version]: false, + checkPrivilegesAtSpaceTest(`failure when checking for two actions and the user has only one`, { + spaceId: 'space_1', + privilegeOrPrivileges: [`action:saved_objects/${savedObjectTypes[0]}/get`, `action:saved_objects/${savedObjectTypes[1]}/get`], + esHasPrivilegesResponse: { + has_all_requested: false, + username: 'foo-username', + application: { + [application]: { + 'space:space_1': { + [mockActions.login]: true, + [mockActions.version]: true, + [`action:saved_objects/${savedObjectTypes[0]}/get`]: false, + [`action:saved_objects/${savedObjectTypes[1]}/get`]: true, + } + } + } }, - indexPrivilegesResponse, expectedResult: { - result: CHECK_PRIVILEGES_RESULT.UNAUTHORIZED, + hasAllRequested: false, username: 'foo-username', - missing: [mockActions.version], - } + privileges: { + [`action:saved_objects/${savedObjectTypes[0]}/get`]: false, + [`action:saved_objects/${savedObjectTypes[1]}/get`]: true, + } + }, }); - checkPrivilegesTest('throws error if missing version privilege and has login privilege', { - username: 'foo-username', - privileges: [ - `action:saved_objects/${savedObjectTypes[0]}/get` - ], - applicationPrivilegesResponse: { - [mockActions.login]: true, - [mockActions.version]: false, - [`action:saved_objects/${savedObjectTypes[0]}/get`]: true, - }, - indexPrivilegesResponse, - expectErrorThrown: true + describe('with a malformed Elasticsearch response', () => { + checkPrivilegesAtSpaceTest(`throws a validation error when an extra privilege is present in the response`, { + spaceId: 'space_1', + privilegeOrPrivileges: [`action:saved_objects/${savedObjectTypes[0]}/get`], + esHasPrivilegesResponse: { + has_all_requested: false, + username: 'foo-username', + application: { + [application]: { + 'space:space_1': { + [mockActions.login]: true, + [mockActions.version]: true, + [`action:saved_objects/${savedObjectTypes[0]}/get`]: false, + [`action:saved_objects/${savedObjectTypes[1]}/get`]: true, + } + } + } + }, + expectErrorThrown: true, + }); + + checkPrivilegesAtSpaceTest(`throws a validation error when privileges are missing in the response`, { + spaceId: 'space_1', + privilegeOrPrivileges: [`action:saved_objects/${savedObjectTypes[0]}/get`], + esHasPrivilegesResponse: { + has_all_requested: false, + username: 'foo-username', + application: { + [application]: { + 'space:space_1': { + [mockActions.login]: true, + [mockActions.version]: true, + } + } + } + }, + expectErrorThrown: true, + }); }); }); -describe(`with index privileges`, () => { - const indexPrivilegesResponse = { - create: true, - delete: true, - read: true, - view_index_metadata: true, +describe('#checkPrivilegesAtSpaces', () => { + const checkPrivilegesAtSpacesTest = (description, { + spaceIds, + privilegeOrPrivileges, + esHasPrivilegesResponse, + expectedResult, + expectErrorThrown + }) => { + test(description, async () => { + const mockShieldClient = createMockShieldClient(esHasPrivilegesResponse); + const checkPrivilegesWithRequest = checkPrivilegesWithRequestFactory(mockActions, application, mockShieldClient); + const request = Symbol(); + const checkPrivileges = checkPrivilegesWithRequest(request); + + let actualResult; + let errorThrown = null; + try { + actualResult = await checkPrivileges.atSpaces(spaceIds, privilegeOrPrivileges); + } catch (err) { + errorThrown = err; + } + + expect(mockShieldClient.callWithRequest).toHaveBeenCalledWith(request, 'shield.hasPrivileges', { + body: { + applications: [{ + application, + resources: spaceIds.map(spaceId => `space:${spaceId}`), + privileges: uniq([ + mockActions.version, + mockActions.login, + ...Array.isArray(privilegeOrPrivileges) ? privilegeOrPrivileges : [privilegeOrPrivileges], + ]) + }] + } + }); + + if (expectedResult) { + expect(errorThrown).toBeNull(); + expect(actualResult).toEqual(expectedResult); + } + + if (expectErrorThrown) { + expect(errorThrown).toMatchSnapshot(); + } + }); }; - checkPrivilegesTest('returns authorized if they have all application privileges', { - username: 'foo-username', - privileges: [ - `action:saved_objects/${savedObjectTypes[0]}/get` - ], - applicationPrivilegesResponse: { - [mockActions.version]: true, - [mockActions.login]: true, - [`action:saved_objects/${savedObjectTypes[0]}/get`]: true, + checkPrivilegesAtSpacesTest('successful when checking for login and user has login at both spaces', { + spaceIds: ['space_1', 'space_2'], + privilegeOrPrivileges: mockActions.login, + esHasPrivilegesResponse: { + has_all_requested: true, + username: 'foo-username', + application: { + [application]: { + 'space:space_1': { + [mockActions.login]: true, + [mockActions.version]: true, + }, + 'space:space_2': { + [mockActions.login]: true, + [mockActions.version]: true, + } + } + } }, - indexPrivilegesResponse, expectedResult: { - result: CHECK_PRIVILEGES_RESULT.AUTHORIZED, + hasAllRequested: true, username: 'foo-username', - missing: [], - } + spacePrivileges: { + space_1: { + [mockActions.login]: true + }, + space_2: { + [mockActions.login]: true + }, + } + }, }); - checkPrivilegesTest('returns unauthorized and missing application action when checking missing application action', { - username: 'foo-username', - privileges: [ - `action:saved_objects/${savedObjectTypes[0]}/get`, - `action:saved_objects/${savedObjectTypes[0]}/create`, - ], - applicationPrivilegesResponse: { - [mockActions.version]: true, - [mockActions.login]: true, - [`action:saved_objects/${savedObjectTypes[0]}/get`]: true, - [`action:saved_objects/${savedObjectTypes[0]}/create`]: false, + checkPrivilegesAtSpacesTest('failure when checking for login and user has login at only one space', { + spaceIds: ['space_1', 'space_2'], + privilegeOrPrivileges: mockActions.login, + esHasPrivilegesResponse: { + has_all_requested: false, + username: 'foo-username', + application: { + [application]: { + 'space:space_1': { + [mockActions.login]: true, + [mockActions.version]: true, + }, + 'space:space_2': { + [mockActions.login]: false, + [mockActions.version]: true, + } + } + } }, - indexPrivilegesResponse, expectedResult: { - result: CHECK_PRIVILEGES_RESULT.UNAUTHORIZED, + hasAllRequested: false, username: 'foo-username', - missing: [`action:saved_objects/${savedObjectTypes[0]}/create`], - } + spacePrivileges: { + space_1: { + [mockActions.login]: true + }, + space_2: { + [mockActions.login]: false + }, + } + }, }); - checkPrivilegesTest('returns legacy and missing login when checking missing login action and fallback is enabled', { - username: 'foo-username', - privileges: [ - mockActions.login - ], - applicationPrivilegesResponse: { - [mockActions.login]: false, - [mockActions.version]: false, - }, - indexPrivilegesResponse, - expectedResult: { - result: CHECK_PRIVILEGES_RESULT.LEGACY, + checkPrivilegesAtSpacesTest(`throws error when checking for login and user has login but doesn't have version`, { + spaceIds: ['space_1', 'space_2'], + privilegeOrPrivileges: mockActions.login, + esHasPrivilegesResponse: { + has_all_requested: false, username: 'foo-username', - missing: [mockActions.login], - } + application: { + [application]: { + 'space:space_1': { + [mockActions.login]: true, + [mockActions.version]: false, + }, + 'space:space_2': { + [mockActions.login]: true, + [mockActions.version]: false, + } + } + } + }, + expectErrorThrown: true, }); - checkPrivilegesTest('returns unauthorized and missing login when checking missing login action and fallback is disabled', { - settings: { - 'xpack.security.authorization.legacyFallback.enabled': false, + checkPrivilegesAtSpacesTest(`throws error when Elasticsearch returns malformed response`, { + spaceIds: ['space_1', 'space_2'], + privilegeOrPrivileges: [`action:saved_objects/${savedObjectTypes[0]}/get`, `action:saved_objects/${savedObjectTypes[1]}/get`], + esHasPrivilegesResponse: { + has_all_requested: true, + username: 'foo-username', + application: { + [application]: { + 'space:space_1': { + [`action:saved_objects/${savedObjectTypes[0]}/get`]: true, + [`action:saved_objects/${savedObjectTypes[1]}/get`]: true, + }, + 'space:space_2': { + [`action:saved_objects/${savedObjectTypes[0]}/get`]: true, + [`action:saved_objects/${savedObjectTypes[1]}/get`]: true, + } + } + } }, - username: 'foo-username', - privileges: [ - mockActions.login - ], - applicationPrivilegesResponse: { - [mockActions.login]: false, - [mockActions.version]: false, + expectErrorThrown: true, + }); + + checkPrivilegesAtSpacesTest(`successful when checking for two actions at two spaces and user has it all`, { + spaceIds: ['space_1', 'space_2'], + privilegeOrPrivileges: [`action:saved_objects/${savedObjectTypes[0]}/get`, `action:saved_objects/${savedObjectTypes[1]}/get`], + esHasPrivilegesResponse: { + has_all_requested: true, + username: 'foo-username', + application: { + [application]: { + 'space:space_1': { + [mockActions.login]: true, + [mockActions.version]: true, + [`action:saved_objects/${savedObjectTypes[0]}/get`]: true, + [`action:saved_objects/${savedObjectTypes[1]}/get`]: true, + }, + 'space:space_2': { + [mockActions.login]: true, + [mockActions.version]: true, + [`action:saved_objects/${savedObjectTypes[0]}/get`]: true, + [`action:saved_objects/${savedObjectTypes[1]}/get`]: true, + } + } + } }, - indexPrivilegesResponse, expectedResult: { - result: CHECK_PRIVILEGES_RESULT.UNAUTHORIZED, + hasAllRequested: true, username: 'foo-username', - missing: [mockActions.login], - } + spacePrivileges: { + space_1: { + [`action:saved_objects/${savedObjectTypes[0]}/get`]: true, + [`action:saved_objects/${savedObjectTypes[1]}/get`]: true, + }, + space_2: { + [`action:saved_objects/${savedObjectTypes[0]}/get`]: true, + [`action:saved_objects/${savedObjectTypes[1]}/get`]: true, + } + } + }, }); - checkPrivilegesTest('returns legacy and missing version if checking missing version action and fallback is enabled', { - username: 'foo-username', - privileges: [ - mockActions.version - ], - applicationPrivilegesResponse: { - [mockActions.login]: false, - [mockActions.version]: false, + checkPrivilegesAtSpacesTest(`failure when checking for two actions at two spaces and user has one action at one space`, { + spaceIds: ['space_1', 'space_2'], + privilegeOrPrivileges: [`action:saved_objects/${savedObjectTypes[0]}/get`, `action:saved_objects/${savedObjectTypes[1]}/get`], + esHasPrivilegesResponse: { + has_all_requested: false, + username: 'foo-username', + application: { + [application]: { + 'space:space_1': { + [mockActions.login]: true, + [mockActions.version]: true, + [`action:saved_objects/${savedObjectTypes[0]}/get`]: true, + [`action:saved_objects/${savedObjectTypes[1]}/get`]: false, + }, + 'space:space_2': { + [mockActions.login]: true, + [mockActions.version]: true, + [`action:saved_objects/${savedObjectTypes[0]}/get`]: false, + [`action:saved_objects/${savedObjectTypes[1]}/get`]: false, + } + } + } }, - indexPrivilegesResponse, expectedResult: { - result: CHECK_PRIVILEGES_RESULT.LEGACY, + hasAllRequested: false, username: 'foo-username', - missing: [mockActions.version], - } + spacePrivileges: { + space_1: { + [`action:saved_objects/${savedObjectTypes[0]}/get`]: true, + [`action:saved_objects/${savedObjectTypes[1]}/get`]: false, + }, + space_2: { + [`action:saved_objects/${savedObjectTypes[0]}/get`]: false, + [`action:saved_objects/${savedObjectTypes[1]}/get`]: false, + } + } + }, }); - checkPrivilegesTest('returns unauthorized and missing version if checking missing version action and fallback is disabled', { - settings: { - 'xpack.security.authorization.legacyFallback.enabled': false, - }, - username: 'foo-username', - privileges: [ - mockActions.version - ], - applicationPrivilegesResponse: { - [mockActions.login]: false, - [mockActions.version]: false, + checkPrivilegesAtSpacesTest(`failure when checking for two actions at two spaces and user has two actions at one space`, { + spaceIds: ['space_1', 'space_2'], + privilegeOrPrivileges: [`action:saved_objects/${savedObjectTypes[0]}/get`, `action:saved_objects/${savedObjectTypes[1]}/get`], + esHasPrivilegesResponse: { + has_all_requested: false, + username: 'foo-username', + application: { + [application]: { + 'space:space_1': { + [mockActions.login]: true, + [mockActions.version]: true, + [`action:saved_objects/${savedObjectTypes[0]}/get`]: true, + [`action:saved_objects/${savedObjectTypes[1]}/get`]: true, + }, + 'space:space_2': { + [mockActions.login]: true, + [mockActions.version]: true, + [`action:saved_objects/${savedObjectTypes[0]}/get`]: false, + [`action:saved_objects/${savedObjectTypes[1]}/get`]: false, + } + } + } }, - indexPrivilegesResponse, expectedResult: { - result: CHECK_PRIVILEGES_RESULT.UNAUTHORIZED, + hasAllRequested: false, username: 'foo-username', - missing: [mockActions.version], - } - }); - - checkPrivilegesTest('throws error if missing version privilege and has login privilege', { - username: 'foo-username', - privileges: [ - `action:saved_objects/${savedObjectTypes[0]}/get` - ], - applicationPrivilegesResponse: { - [mockActions.login]: true, - [mockActions.version]: false, - [`action:saved_objects/${savedObjectTypes[0]}/get`]: true, + spacePrivileges: { + space_1: { + [`action:saved_objects/${savedObjectTypes[0]}/get`]: true, + [`action:saved_objects/${savedObjectTypes[1]}/get`]: true, + }, + space_2: { + [`action:saved_objects/${savedObjectTypes[0]}/get`]: false, + [`action:saved_objects/${savedObjectTypes[1]}/get`]: false, + } + } }, - indexPrivilegesResponse, - expectErrorThrown: true }); -}); -describe('with no application privileges', () => { - ['create', 'delete', 'read', 'view_index_metadata'].forEach(indexPrivilege => { - checkPrivilegesTest(`returns legacy if they have ${indexPrivilege} privilege on the kibana index and fallback is enabled`, { - username: 'foo-username', - privileges: [ - `action:saved_objects/${savedObjectTypes[0]}/get` - ], - applicationPrivilegesResponse: { - [mockActions.version]: false, - [mockActions.login]: false, - [`action:saved_objects/${savedObjectTypes[0]}/get`]: false, - }, - indexPrivilegesResponse: { - create: false, - delete: false, - read: false, - view_index_metadata: false, - [indexPrivilege]: true + checkPrivilegesAtSpacesTest( + `failure when checking for two actions at two spaces and user has two actions at one space & one action at the other`, { + spaceIds: ['space_1', 'space_2'], + privilegeOrPrivileges: [`action:saved_objects/${savedObjectTypes[0]}/get`, `action:saved_objects/${savedObjectTypes[1]}/get`], + esHasPrivilegesResponse: { + has_all_requested: false, + username: 'foo-username', + application: { + [application]: { + 'space:space_1': { + [mockActions.login]: true, + [mockActions.version]: true, + [`action:saved_objects/${savedObjectTypes[0]}/get`]: true, + [`action:saved_objects/${savedObjectTypes[1]}/get`]: true, + }, + 'space:space_2': { + [mockActions.login]: true, + [mockActions.version]: true, + [`action:saved_objects/${savedObjectTypes[0]}/get`]: true, + [`action:saved_objects/${savedObjectTypes[1]}/get`]: false, + } + } + } }, expectedResult: { - result: CHECK_PRIVILEGES_RESULT.LEGACY, + hasAllRequested: false, username: 'foo-username', - missing: [`action:saved_objects/${savedObjectTypes[0]}/get`], - } + spacePrivileges: { + space_1: { + [`action:saved_objects/${savedObjectTypes[0]}/get`]: true, + [`action:saved_objects/${savedObjectTypes[1]}/get`]: true, + }, + space_2: { + [`action:saved_objects/${savedObjectTypes[0]}/get`]: true, + [`action:saved_objects/${savedObjectTypes[1]}/get`]: false, + } + } + }, }); - checkPrivilegesTest(`returns unauthorized if they have ${indexPrivilege} privilege on the kibana index and fallback is disabled`, { - settings: { - 'xpack.security.authorization.legacyFallback.enabled': false, + describe('with a malformed Elasticsearch response', () => { + checkPrivilegesAtSpacesTest(`throws a validation error when an extra privilege is present in the response`, { + spaceIds: ['space_1', 'space_2'], + privilegeOrPrivileges: [`action:saved_objects/${savedObjectTypes[0]}/get`], + esHasPrivilegesResponse: { + has_all_requested: false, + username: 'foo-username', + application: { + [application]: { + 'space:space_1': { + [mockActions.login]: true, + [mockActions.version]: true, + [`action:saved_objects/${savedObjectTypes[0]}/get`]: false, + [`action:saved_objects/${savedObjectTypes[1]}/get`]: true, + }, + 'space:space_1': { + [mockActions.login]: true, + [mockActions.version]: true, + [`action:saved_objects/${savedObjectTypes[0]}/get`]: false, + } + } + } }, - username: 'foo-username', - privileges: [ - `action:saved_objects/${savedObjectTypes[0]}/get` - ], - applicationPrivilegesResponse: { - [mockActions.version]: false, - [mockActions.login]: false, - [`action:saved_objects/${savedObjectTypes[0]}/get`]: false, + expectErrorThrown: true, + }); + + checkPrivilegesAtSpacesTest(`throws a validation error when privileges are missing in the response`, { + spaceIds: ['space_1', 'space_2'], + privilegeOrPrivileges: [`action:saved_objects/${savedObjectTypes[0]}/get`], + esHasPrivilegesResponse: { + has_all_requested: false, + username: 'foo-username', + application: { + [application]: { + 'space:space_1': { + [mockActions.login]: true, + [mockActions.version]: true, + }, + 'space:space_1': { + [mockActions.login]: true, + [mockActions.version]: true, + [`action:saved_objects/${savedObjectTypes[0]}/get`]: false, + } + } + } }, - indexPrivilegesResponse: { - create: false, - delete: false, - read: false, - view_index_metadata: false, - [indexPrivilege]: true + expectErrorThrown: true, + }); + + checkPrivilegesAtSpacesTest(`throws a validation error when an extra space is present in the response`, { + spaceIds: ['space_1', 'space_2'], + privilegeOrPrivileges: [`action:saved_objects/${savedObjectTypes[0]}/get`], + esHasPrivilegesResponse: { + has_all_requested: false, + username: 'foo-username', + application: { + [application]: { + 'space:space_1': { + [mockActions.login]: true, + [mockActions.version]: true, + [`action:saved_objects/${savedObjectTypes[0]}/get`]: false, + }, + 'space:space_2': { + [mockActions.login]: true, + [mockActions.version]: true, + [`action:saved_objects/${savedObjectTypes[0]}/get`]: false, + }, + 'space:space_3': { + [mockActions.login]: true, + [mockActions.version]: true, + [`action:saved_objects/${savedObjectTypes[0]}/get`]: false, + }, + } + } }, - expectedResult: { - result: CHECK_PRIVILEGES_RESULT.UNAUTHORIZED, + expectErrorThrown: true, + }); + + checkPrivilegesAtSpacesTest(`throws a validation error when an a space is missing in the response`, { + spaceIds: ['space_1', 'space_2'], + privilegeOrPrivileges: [`action:saved_objects/${savedObjectTypes[0]}/get`], + esHasPrivilegesResponse: { + has_all_requested: false, username: 'foo-username', - missing: [`action:saved_objects/${savedObjectTypes[0]}/get`], - } + application: { + [application]: { + 'space:space_1': { + [mockActions.login]: true, + [mockActions.version]: true, + [`action:saved_objects/${savedObjectTypes[0]}/get`]: false, + } + } + } + }, + expectErrorThrown: true, }); }); }); -describe('with a malformed Elasticsearch response', () => { - const indexPrivilegesResponse = { - create: true, - delete: true, - read: true, - view_index_metadata: true, +describe('#checkPrivilegesGlobally', () => { + const checkPrivilegesGloballyTest = (description, { + privilegeOrPrivileges, + esHasPrivilegesResponse, + expectedResult, + expectErrorThrown + }) => { + test(description, async () => { + const mockShieldClient = createMockShieldClient(esHasPrivilegesResponse); + const checkPrivilegesWithRequest = checkPrivilegesWithRequestFactory(mockActions, application, mockShieldClient); + const request = Symbol(); + const checkPrivileges = checkPrivilegesWithRequest(request); + + let actualResult; + let errorThrown = null; + try { + actualResult = await checkPrivileges.globally(privilegeOrPrivileges); + } catch (err) { + errorThrown = err; + } + + expect(mockShieldClient.callWithRequest).toHaveBeenCalledWith(request, 'shield.hasPrivileges', { + body: { + applications: [{ + application, + resources: [GLOBAL_RESOURCE], + privileges: uniq([ + mockActions.version, + mockActions.login, + ...Array.isArray(privilegeOrPrivileges) ? privilegeOrPrivileges : [privilegeOrPrivileges], + ]) + }] + } + }); + + if (expectedResult) { + expect(errorThrown).toBeNull(); + expect(actualResult).toEqual(expectedResult); + } + + if (expectErrorThrown) { + expect(errorThrown).toMatchSnapshot(); + } + }); }; - checkPrivilegesTest('throws a validation error when an extra privilege is present in the response', { - username: 'foo-username', - privileges: [ - `action:saved_objects/${savedObjectTypes[0]}/get`, - ], - applicationPrivilegesResponse: { - [mockActions.version]: true, - [mockActions.login]: true, - [`action:saved_objects/${savedObjectTypes[0]}/get`]: true, - ['oops-an-unexpected-privilege']: true, + checkPrivilegesGloballyTest('successful when checking for login and user has login', { + spaceId: 'space_1', + privilegeOrPrivileges: mockActions.login, + esHasPrivilegesResponse: { + has_all_requested: true, + username: 'foo-username', + application: { + [application]: { + [GLOBAL_RESOURCE]: { + [mockActions.login]: true, + [mockActions.version]: true, + } + } + } + }, + expectedResult: { + hasAllRequested: true, + username: 'foo-username', + privileges: { + [mockActions.login]: true + } + }, + }); + + checkPrivilegesGloballyTest(`failure when checking for login and user doesn't have login`, { + privilegeOrPrivileges: mockActions.login, + esHasPrivilegesResponse: { + has_all_requested: false, + username: 'foo-username', + application: { + [application]: { + [GLOBAL_RESOURCE]: { + [mockActions.login]: false, + [mockActions.version]: true, + } + } + } + }, + expectedResult: { + hasAllRequested: false, + username: 'foo-username', + privileges: { + [mockActions.login]: false + } + }, + }); + + checkPrivilegesGloballyTest(`throws error when checking for login and user has login but doesn't have version`, { + privilegeOrPrivileges: mockActions.login, + esHasPrivilegesResponse: { + has_all_requested: false, + username: 'foo-username', + application: { + [application]: { + [GLOBAL_RESOURCE]: { + [mockActions.login]: true, + [mockActions.version]: false, + } + } + } }, - indexPrivilegesResponse, expectErrorThrown: true, }); - checkPrivilegesTest('throws a validation error when privileges are missing in the response', { - username: 'foo-username', - privileges: [ - `action:saved_objects/${savedObjectTypes[0]}/get`, - ], - applicationPrivilegesResponse: { - [`action:saved_objects/${savedObjectTypes[0]}/get`]: true, + checkPrivilegesGloballyTest(`throws error when Elasticsearch returns malformed response`, { + privilegeOrPrivileges: [`action:saved_objects/${savedObjectTypes[0]}/get`, `action:saved_objects/${savedObjectTypes[1]}/get`], + esHasPrivilegesResponse: { + has_all_requested: false, + username: 'foo-username', + application: { + [application]: { + [GLOBAL_RESOURCE]: { + [`action:saved_objects/${savedObjectTypes[0]}/get`]: false, + [`action:saved_objects/${savedObjectTypes[1]}/get`]: true, + } + } + } }, - indexPrivilegesResponse, expectErrorThrown: true, }); - checkPrivilegesTest('throws a validation error when an extra index privilege is present in the response', { - username: 'foo-username', - privileges: [ - `action:saved_objects/${savedObjectTypes[0]}/get`, - ], - applicationPrivilegesResponse: { - [mockActions.version]: true, - [mockActions.login]: true, - [`action:saved_objects/${savedObjectTypes[0]}/get`]: true, + checkPrivilegesGloballyTest(`successful when checking for two actions and the user has both`, { + privilegeOrPrivileges: [`action:saved_objects/${savedObjectTypes[0]}/get`, `action:saved_objects/${savedObjectTypes[1]}/get`], + esHasPrivilegesResponse: { + has_all_requested: true, + username: 'foo-username', + application: { + [application]: { + [GLOBAL_RESOURCE]: { + [mockActions.login]: true, + [mockActions.version]: true, + [`action:saved_objects/${savedObjectTypes[0]}/get`]: true, + [`action:saved_objects/${savedObjectTypes[1]}/get`]: true, + } + } + } }, - indexPrivilegesResponse: { - ...indexPrivilegesResponse, - oopsAnExtraPrivilege: true, + expectedResult: { + hasAllRequested: true, + username: 'foo-username', + privileges: { + [`action:saved_objects/${savedObjectTypes[0]}/get`]: true, + [`action:saved_objects/${savedObjectTypes[1]}/get`]: true, + } }, - expectErrorThrown: true, }); - const missingIndexPrivileges = { - ...indexPrivilegesResponse - }; - delete missingIndexPrivileges.read; - - checkPrivilegesTest('throws a validation error when index privileges are missing in the response', { - username: 'foo-username', - privileges: [ - `action:saved_objects/${savedObjectTypes[0]}/get`, - ], - applicationPrivilegesResponse: { - [mockActions.version]: true, - [mockActions.login]: true, - [`action:saved_objects/${savedObjectTypes[0]}/get`]: true, + checkPrivilegesGloballyTest(`failure when checking for two actions and the user has only one`, { + privilegeOrPrivileges: [`action:saved_objects/${savedObjectTypes[0]}/get`, `action:saved_objects/${savedObjectTypes[1]}/get`], + esHasPrivilegesResponse: { + has_all_requested: false, + username: 'foo-username', + application: { + [application]: { + [GLOBAL_RESOURCE]: { + [mockActions.login]: true, + [mockActions.version]: true, + [`action:saved_objects/${savedObjectTypes[0]}/get`]: false, + [`action:saved_objects/${savedObjectTypes[1]}/get`]: true, + } + } + } }, - indexPrivilegesResponse: missingIndexPrivileges, - expectErrorThrown: true, + expectedResult: { + hasAllRequested: false, + username: 'foo-username', + privileges: { + [`action:saved_objects/${savedObjectTypes[0]}/get`]: false, + [`action:saved_objects/${savedObjectTypes[1]}/get`]: true, + } + }, + }); + + describe('with a malformed Elasticsearch response', () => { + checkPrivilegesGloballyTest(`throws a validation error when an extra privilege is present in the response`, { + privilegeOrPrivileges: [`action:saved_objects/${savedObjectTypes[0]}/get`], + esHasPrivilegesResponse: { + has_all_requested: false, + username: 'foo-username', + application: { + [application]: { + [GLOBAL_RESOURCE]: { + [mockActions.login]: true, + [mockActions.version]: true, + [`action:saved_objects/${savedObjectTypes[0]}/get`]: false, + [`action:saved_objects/${savedObjectTypes[1]}/get`]: true, + } + } + } + }, + expectErrorThrown: true, + }); + + checkPrivilegesGloballyTest(`throws a validation error when privileges are missing in the response`, { + privilegeOrPrivileges: [`action:saved_objects/${savedObjectTypes[0]}/get`], + esHasPrivilegesResponse: { + has_all_requested: false, + username: 'foo-username', + application: { + [application]: { + [GLOBAL_RESOURCE]: { + [mockActions.login]: true, + [mockActions.version]: true, + } + } + } + }, + expectErrorThrown: true, + }); }); }); diff --git a/x-pack/plugins/security/server/lib/authorization/index.js b/x-pack/plugins/security/server/lib/authorization/index.js index e0029a3caeafd1..1fb754202832fa 100644 --- a/x-pack/plugins/security/server/lib/authorization/index.js +++ b/x-pack/plugins/security/server/lib/authorization/index.js @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -export { CHECK_PRIVILEGES_RESULT } from './check_privileges'; export { registerPrivilegesWithCluster } from './register_privileges_with_cluster'; export { buildPrivilegeMap } from './privileges'; -export { initAuthorizationService } from './init'; +export { createAuthorizationService } from './service'; +export { spaceApplicationPrivilegesSerializer } from './space_application_privileges_serializer'; diff --git a/x-pack/plugins/security/server/lib/authorization/mode.js b/x-pack/plugins/security/server/lib/authorization/mode.js new file mode 100644 index 00000000000000..37800ca4e39113 --- /dev/null +++ b/x-pack/plugins/security/server/lib/authorization/mode.js @@ -0,0 +1,81 @@ +/* + * 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 { GLOBAL_RESOURCE } from '../../../common/constants'; +import { spaceApplicationPrivilegesSerializer } from './space_application_privileges_serializer'; + +const hasAnyPrivileges = privileges => { + return Object.values(privileges).some(hasPrivilege => hasPrivilege === true); +}; + +const hasAnyResourcePrivileges = resourcePrivileges => { + return Object.values(resourcePrivileges).some(resource => hasAnyPrivileges(resource)); +}; + +export function authorizationModeFactory( + actions, + checkPrivilegesWithRequest, + config, + plugins, + savedObjects, + xpackInfoFeature +) { + const useRbacForRequestCache = new WeakMap(); + + // TODO: This logic will change once we have the ES API to list all privileges + // and is not covered by unit tests currently + const shouldUseRbacForRequest = async (request) => { + if (!config.get('xpack.security.authorization.legacyFallback.enabled')) { + return true; + } + + const adminCluster = plugins.elasticsearch.getCluster('admin'); + const { callWithInternalUser } = adminCluster; + + const internalSavedObjectsRepository = savedObjects.getSavedObjectsRepository( + callWithInternalUser + ); + + const checkPrivileges = checkPrivilegesWithRequest(request); + if (!plugins.spaces) { + const { privileges } = await checkPrivileges.globally(actions.login); + return hasAnyPrivileges(privileges); + } + + const { saved_objects: spaceSavedObjects } = await internalSavedObjectsRepository.find({ type: 'space' }); + const spaceResources = spaceSavedObjects.map(space => spaceApplicationPrivilegesSerializer.resource.serialize(space.id)); + const allResources = [GLOBAL_RESOURCE, ...spaceResources]; + const { resourcePrivileges } = await checkPrivileges.atResources(allResources, actions.login); + return hasAnyResourcePrivileges(resourcePrivileges); + }; + + const isRbacEnabled = () => xpackInfoFeature.getLicenseCheckResults().allowRbac; + + return { + async initialize(request) { + if (useRbacForRequestCache.has(request)) { + throw new Error('Authorization mode is already intitialized'); + } + + if (!isRbacEnabled()) { + useRbacForRequestCache.set(request, true); + return; + } + + const result = await shouldUseRbacForRequest(request); + useRbacForRequestCache.set(request, result); + }, + + useRbacForRequest(request) { + // the following can happen when the user isn't authenticated. Either true or false would work here, + // but we're going to go with false as this is closer to the "legacy" behavior + if (!useRbacForRequestCache.has(request)) { + return false; + } + + return useRbacForRequestCache.get(request); + }, + }; +} diff --git a/x-pack/plugins/security/server/lib/authorization/mode.test.js b/x-pack/plugins/security/server/lib/authorization/mode.test.js new file mode 100644 index 00000000000000..5f14842710b561 --- /dev/null +++ b/x-pack/plugins/security/server/lib/authorization/mode.test.js @@ -0,0 +1,66 @@ +/* + * 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 { authorizationModeFactory } from './mode'; + +const createMockConfig = (settings) => { + const mockConfig = { + get: jest.fn() + }; + + mockConfig.get.mockImplementation(key => { + return settings[key]; + }); + + return mockConfig; +}; + +const createMockXpackInfoFeature = (allowRbac) => { + return { + getLicenseCheckResults() { + return { + allowRbac + }; + } + }; +}; + +describe(`#initialize`, () => { + test(`can't be initialized twice for the same request`, async () => { + const mockConfig = createMockConfig(); + const mockXpackInfoFeature = createMockXpackInfoFeature(); + const mode = authorizationModeFactory({}, {}, mockConfig, {}, {}, mockXpackInfoFeature); + const request = {}; + + await mode.initialize(request); + expect(mode.initialize(request)).rejects.toThrowErrorMatchingSnapshot(); + }); +}); + +describe(`#useRbacForRequest`, () => { + test(`return false if not initialized for request`, async () => { + const mockConfig = createMockConfig(); + const mockXpackInfoFeature = createMockXpackInfoFeature(); + const mode = authorizationModeFactory({}, {}, mockConfig, {}, {}, mockXpackInfoFeature); + const request = {}; + + const result = mode.useRbacForRequest(request); + expect(result).toBe(false); + }); + + test(`returns true if legacy fallback is disabled`, async () => { + const mockConfig = createMockConfig({ + 'xpack.security.authorization.legacyFallback.enabled': false, + }); + const mockXpackInfoFeature = createMockXpackInfoFeature(); + const mode = authorizationModeFactory({}, {}, mockConfig, {}, {}, mockXpackInfoFeature); + const request = {}; + + await mode.initialize(request); + const result = mode.useRbacForRequest(request); + expect(result).toBe(true); + }); +}); diff --git a/x-pack/plugins/security/server/lib/authorization/privileges.js b/x-pack/plugins/security/server/lib/authorization/privileges.js index 6f64871ed75566..7e9f53f873a0d0 100644 --- a/x-pack/plugins/security/server/lib/authorization/privileges.js +++ b/x-pack/plugins/security/server/lib/authorization/privileges.js @@ -4,9 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ -export function buildPrivilegeMap(savedObjectTypes, application, actions) { +import { IGNORED_TYPES } from '../../../common/constants'; + +export function buildPrivilegeMap(savedObjectTypes, actions) { const buildSavedObjectsActions = (savedObjectActions) => { return savedObjectTypes + .filter(type => !IGNORED_TYPES.includes(type)) .map(type => savedObjectActions.map(savedObjectAction => actions.getSavedObjectAction(type, savedObjectAction))) .reduce((acc, types) => [...acc, ...types], []); }; @@ -14,21 +17,43 @@ export function buildPrivilegeMap(savedObjectTypes, application, actions) { // the following list of privileges should only be added to, you can safely remove actions, but not privileges as // it's a backwards compatibility issue and we'll have to at least adjust registerPrivilegesWithCluster to support it return { - all: { - application, - name: 'all', - actions: [actions.version, 'action:*'], - metadata: {} + global: { + all: [ + actions.version, + 'action:*' + ], + read: [ + actions.version, + actions.login, + ...buildSavedObjectsActions([ + 'get', + 'bulk_get', + 'find' + ]) + ], + }, + space: { + all: [ + actions.version, + actions.login, + ...buildSavedObjectsActions([ + 'create', + 'bulk_create', + 'delete', + 'get', + 'bulk_get', + 'find', + 'update' + ]) + ], + read: [ + actions.version, + actions.login, + ...buildSavedObjectsActions([ + 'get', + 'bulk_get', + 'find']) + ], }, - read: { - application, - name: 'read', - actions: [actions.version, actions.login, ...buildSavedObjectsActions(['get', 'bulk_get', 'find'])], - metadata: {} - } }; } - -export function buildLegacyIndexPrivileges() { - return ['create', 'delete', 'read', 'view_index_metadata']; -} diff --git a/x-pack/plugins/security/server/lib/authorization/register_privileges_with_cluster.js b/x-pack/plugins/security/server/lib/authorization/register_privileges_with_cluster.js index 826cdab4b42040..6845dd7590e2de 100644 --- a/x-pack/plugins/security/server/lib/authorization/register_privileges_with_cluster.js +++ b/x-pack/plugins/security/server/lib/authorization/register_privileges_with_cluster.js @@ -7,6 +7,33 @@ import { difference, isEmpty, isEqual } from 'lodash'; import { buildPrivilegeMap } from './privileges'; import { getClient } from '../../../../../server/lib/get_client_shield'; +import { spaceApplicationPrivilegesSerializer } from './space_application_privileges_serializer'; + +const serializePrivileges = (application, privilegeMap) => { + return { + [application]: { + ...Object.entries(privilegeMap.global).reduce((acc, [privilegeName, privilegeActions]) => { + acc[privilegeName] = { + application, + name: privilegeName, + actions: privilegeActions, + metadata: {}, + }; + return acc; + }, {}), + ...Object.entries(privilegeMap.space).reduce((acc, [privilegeName, privilegeActions]) => { + const name = spaceApplicationPrivilegesSerializer.privilege.serialize(privilegeName); + acc[name] = { + application, + name, + actions: privilegeActions, + metadata: {}, + }; + return acc; + }, {}) + } + }; +}; export async function registerPrivilegesWithCluster(server) { @@ -14,6 +41,16 @@ export async function registerPrivilegesWithCluster(server) { const { types: savedObjectTypes } = server.savedObjects; const { actions, application } = authorization; + const arePrivilegesEqual = (existingPrivileges, expectedPrivileges) => { + // when comparing privileges, the order of the actions doesn't matter, lodash's isEqual + // doesn't know how to compare Sets + return isEqual(existingPrivileges, expectedPrivileges, (value, other, key) => { + if (key === 'actions' && Array.isArray(value) && Array.isArray(other)) { + return isEqual(value.sort(), other.sort()); + } + }); + }; + const shouldRemovePrivileges = (existingPrivileges, expectedPrivileges) => { if (isEmpty(existingPrivileges)) { return false; @@ -22,9 +59,8 @@ export async function registerPrivilegesWithCluster(server) { return difference(Object.keys(existingPrivileges[application]), Object.keys(expectedPrivileges[application])).length > 0; }; - const expectedPrivileges = { - [application]: buildPrivilegeMap(savedObjectTypes, application, actions) - }; + const privilegeMap = buildPrivilegeMap(savedObjectTypes, actions); + const expectedPrivileges = serializePrivileges(application, privilegeMap); server.log(['security', 'debug'], `Registering Kibana Privileges with Elasticsearch for ${application}`); @@ -34,7 +70,7 @@ export async function registerPrivilegesWithCluster(server) { // we only want to post the privileges when they're going to change as Elasticsearch has // to clear the role cache to get these changes reflected in the _has_privileges API const existingPrivileges = await callCluster(`shield.getPrivilege`, { privilege: application }); - if (isEqual(existingPrivileges, expectedPrivileges)) { + if (arePrivilegesEqual(existingPrivileges, expectedPrivileges)) { server.log(['security', 'debug'], `Kibana Privileges already registered with Elasticearch for ${application}`); return; } diff --git a/x-pack/plugins/security/server/lib/authorization/register_privileges_with_cluster.test.js b/x-pack/plugins/security/server/lib/authorization/register_privileges_with_cluster.test.js index f326d85fdeee3f..b2a391aa495734 100644 --- a/x-pack/plugins/security/server/lib/authorization/register_privileges_with_cluster.test.js +++ b/x-pack/plugins/security/server/lib/authorization/register_privileges_with_cluster.test.js @@ -14,10 +14,12 @@ jest.mock('./privileges', () => ({ buildPrivilegeMap: jest.fn(), })); +const application = 'default-application'; + const registerPrivilegesWithClusterTest = (description, { settings = {}, savedObjectTypes, - expectedPrivileges, + privilegeMap, existingPrivileges, throwErrorWhenGettingPrivileges, throwErrorWhenPuttingPrivileges, @@ -32,7 +34,6 @@ const registerPrivilegesWithClusterTest = (description, { }; const defaultVersion = 'default-version'; - const application = 'default-application'; const createMockServer = () => { const mockServer = { @@ -65,8 +66,8 @@ const registerPrivilegesWithClusterTest = (description, { return mockServer; }; - const createExpectUpdatedPrivileges = (mockServer, mockCallWithInternalUser, privileges, error) => { - return () => { + const createExpectUpdatedPrivileges = (mockServer, mockCallWithInternalUser, error) => { + return (postPrivilegesBody) => { expect(error).toBeUndefined(); expect(mockCallWithInternalUser).toHaveBeenCalledTimes(2); expect(mockCallWithInternalUser).toHaveBeenCalledWith('shield.getPrivilege', { @@ -75,9 +76,7 @@ const registerPrivilegesWithClusterTest = (description, { expect(mockCallWithInternalUser).toHaveBeenCalledWith( 'shield.postPrivileges', { - body: { - [application]: privileges - }, + body: postPrivilegesBody, } ); @@ -137,9 +136,7 @@ const registerPrivilegesWithClusterTest = (description, { return {}; } - return { - [application]: existingPrivileges - }; + return existingPrivileges; }) .mockImplementationOnce(async () => { if (throwErrorWhenPuttingPrivileges) { @@ -147,7 +144,7 @@ const registerPrivilegesWithClusterTest = (description, { } }); - buildPrivilegeMap.mockReturnValue(expectedPrivileges); + buildPrivilegeMap.mockReturnValue(privilegeMap); let error; try { @@ -157,7 +154,7 @@ const registerPrivilegesWithClusterTest = (description, { } assert({ - expectUpdatedPrivileges: createExpectUpdatedPrivileges(mockServer, mockCallWithInternalUser, expectedPrivileges, error), + expectUpdatedPrivileges: createExpectUpdatedPrivileges(mockServer, mockCallWithInternalUser, error), expectDidntUpdatePrivileges: createExpectDidntUpdatePrivileges(mockServer, mockCallWithInternalUser, error), expectErrorThrown: createExpectErrorThrown(mockServer, error), mocks: { @@ -168,10 +165,7 @@ const registerPrivilegesWithClusterTest = (description, { }); }; -registerPrivilegesWithClusterTest(`passes saved object types, application and actions to buildPrivilegeMap`, { - settings: { - 'pkg.version': 'foo-version' - }, +registerPrivilegesWithClusterTest(`passes saved object types, and actions to buildPrivilegeMap`, { savedObjectTypes: [ 'foo-type', 'bar-type', @@ -179,146 +173,249 @@ registerPrivilegesWithClusterTest(`passes saved object types, application and ac assert: ({ mocks }) => { expect(mocks.buildPrivilegeMap).toHaveBeenCalledWith( ['foo-type', 'bar-type'], - mocks.server.plugins.security.authorization.application, mocks.server.plugins.security.authorization.actions, ); }, }); registerPrivilegesWithClusterTest(`inserts privileges when we don't have any existing privileges`, { - expectedPrivileges: { - expected: true + privilegeMap: { + global: { + foo: ['action:foo'] + }, + space: { + bar: ['action:bar'] + } }, existingPrivileges: null, assert: ({ expectUpdatedPrivileges }) => { - expectUpdatedPrivileges(); - } -}); - -registerPrivilegesWithClusterTest(`updates privileges when simple top-level privileges values don't match`, { - expectedPrivileges: { - expected: true - }, - existingPrivileges: { - expected: false - }, - assert: ({ expectUpdatedPrivileges }) => { - expectUpdatedPrivileges(); - } -}); - -registerPrivilegesWithClusterTest(`throws error when we have two different top-level privileges`, { - expectedPrivileges: { - notExpected: true - }, - existingPrivileges: { - expected: true - }, - assert: ({ expectErrorThrown }) => { - expectErrorThrown(`Privileges are missing and can't be removed, currently.`); - } -}); - -registerPrivilegesWithClusterTest(`updates privileges when we want to add a top-level privilege`, { - expectedPrivileges: { - expected: true, - new: false, - }, - existingPrivileges: { - expected: true, - }, - assert: ({ expectUpdatedPrivileges }) => { - expectUpdatedPrivileges(); + expectUpdatedPrivileges({ + [application]: { + foo: { + application, + name: 'foo', + actions: ['action:foo'], + metadata: {}, + }, + space_bar: { + application, + name: 'space_bar', + actions: ['action:bar'], + metadata: {}, + } + } + }); } }); -registerPrivilegesWithClusterTest(`updates privileges when nested privileges values don't match`, { - expectedPrivileges: { - kibana: { - expected: true +registerPrivilegesWithClusterTest(`throws error when we should be removing privilege`, { + privilegeMap: { + global: { + foo: ['action:foo'], + }, + space: { + bar: ['action:bar'] } }, existingPrivileges: { - kibana: { - expected: false + [application]: { + foo: { + application, + name: 'foo', + actions: ['action:not-foo'], + metadata: {}, + }, + quz: { + application, + name: 'quz', + actions: ['action:not-quz'], + metadata: {}, + }, + space_bar: { + application, + name: 'space_bar', + actions: ['action:not-bar'], + metadata: {}, + } } }, - assert: ({ expectUpdatedPrivileges }) => { - expectUpdatedPrivileges(); + assert: ({ expectErrorThrown }) => { + expectErrorThrown(`Privileges are missing and can't be removed, currently.`); } }); -registerPrivilegesWithClusterTest(`updates privileges when we have two different nested privileges`, { - expectedPrivileges: { - kibana: { - notExpected: true +registerPrivilegesWithClusterTest(`updates privileges when actions don't match`, { + privilegeMap: { + global: { + foo: ['action:foo'] + }, + space: { + bar: ['action:bar'] } }, existingPrivileges: { - kibana: { - expected: false + [application]: { + foo: { + application, + name: 'foo', + actions: ['action:not-foo'], + metadata: {}, + }, + space_bar: { + application, + name: 'space_bar', + actions: ['action:not-bar'], + metadata: {}, + } } }, assert: ({ expectUpdatedPrivileges }) => { - expectUpdatedPrivileges(); + expectUpdatedPrivileges({ + [application]: { + foo: { + application, + name: 'foo', + actions: ['action:foo'], + metadata: {}, + }, + space_bar: { + application, + name: 'space_bar', + actions: ['action:bar'], + metadata: {}, + } + } + }); } }); -registerPrivilegesWithClusterTest(`updates privileges when nested privileges arrays don't match`, { - expectedPrivileges: { - kibana: { - expected: ['one', 'two'] +registerPrivilegesWithClusterTest(`updates privileges when global privilege added`, { + privilegeMap: { + global: { + foo: ['action:foo'], + quz: ['action:quz'] + }, + space: { + bar: ['action:bar'] } }, existingPrivileges: { - kibana: { - expected: ['one'] + [application]: { + foo: { + application, + name: 'foo', + actions: ['action:not-foo'], + metadata: {}, + }, + space_bar: { + application, + name: 'space_bar', + actions: ['action:not-bar'], + metadata: {}, + } } }, assert: ({ expectUpdatedPrivileges }) => { - expectUpdatedPrivileges(); + expectUpdatedPrivileges({ + [application]: { + foo: { + application, + name: 'foo', + actions: ['action:foo'], + metadata: {}, + }, + quz: { + application, + name: 'quz', + actions: ['action:quz'], + metadata: {}, + }, + space_bar: { + application, + name: 'space_bar', + actions: ['action:bar'], + metadata: {}, + } + } + }); } }); -registerPrivilegesWithClusterTest(`updates privileges when nested property array values are reordered`, { - expectedPrivileges: { - kibana: { - foo: ['one', 'two'] +registerPrivilegesWithClusterTest(`updates privileges when space privilege added`, { + privilegeMap: { + global: { + foo: ['action:foo'], + }, + space: { + bar: ['action:bar'], + quz: ['action:quz'] } }, existingPrivileges: { - kibana: { - foo: ['two', 'one'] + [application]: { + foo: { + application, + name: 'foo', + actions: ['action:not-foo'], + metadata: {}, + }, + space_bar: { + application, + name: 'space_bar', + actions: ['action:not-bar'], + metadata: {}, + } } }, assert: ({ expectUpdatedPrivileges }) => { - expectUpdatedPrivileges(); - } -}); - -registerPrivilegesWithClusterTest(`doesn't update privileges when simple top-level privileges match`, { - expectedPrivileges: { - expected: true - }, - existingPrivileges: { - expected: true - }, - assert: ({ expectDidntUpdatePrivileges }) => { - expectDidntUpdatePrivileges(); + expectUpdatedPrivileges({ + [application]: { + foo: { + application, + name: 'foo', + actions: ['action:foo'], + metadata: {}, + }, + space_bar: { + application, + name: 'space_bar', + actions: ['action:bar'], + metadata: {}, + }, + space_quz: { + application, + name: 'space_quz', + actions: ['action:quz'], + metadata: {}, + }, + } + }); } }); -registerPrivilegesWithClusterTest(`doesn't update privileges when nested properties are reordered`, { - expectedPrivileges: { - kibana: { - foo: true, - bar: false +registerPrivilegesWithClusterTest(`doesn't update privileges when order of actions differ`, { + privilegeMap: { + global: { + foo: ['action:foo', 'action:quz'] + }, + space: { + bar: ['action:bar'] } }, existingPrivileges: { - kibana: { - bar: false, - foo: true + [application]: { + foo: { + application, + name: 'foo', + actions: ['action:quz', 'action:foo'], + metadata: {}, + }, + space_bar: { + application, + name: 'space_bar', + actions: ['action:bar'], + metadata: {}, + } } }, assert: ({ expectDidntUpdatePrivileges }) => { @@ -327,6 +424,10 @@ registerPrivilegesWithClusterTest(`doesn't update privileges when nested propert }); registerPrivilegesWithClusterTest(`throws and logs error when errors getting privileges`, { + privilegeMap: { + global: {}, + space: {} + }, throwErrorWhenGettingPrivileges: new Error('Error getting privileges'), assert: ({ expectErrorThrown }) => { expectErrorThrown('Error getting privileges'); @@ -334,18 +435,15 @@ registerPrivilegesWithClusterTest(`throws and logs error when errors getting pri }); registerPrivilegesWithClusterTest(`throws and logs error when errors putting privileges`, { - expectedPrivileges: { - kibana: { - foo: false, - bar: false - } - }, - existingPrivileges: { - kibana: { - foo: true, - bar: true + privilegeMap: { + global: { + foo: [] + }, + space: { + bar: [] } }, + existingPrivileges: null, throwErrorWhenPuttingPrivileges: new Error('Error putting privileges'), assert: ({ expectErrorThrown }) => { expectErrorThrown('Error putting privileges'); diff --git a/x-pack/plugins/security/server/lib/authorization/init.js b/x-pack/plugins/security/server/lib/authorization/service.js similarity index 58% rename from x-pack/plugins/security/server/lib/authorization/init.js rename to x-pack/plugins/security/server/lib/authorization/service.js index f99bf6d25d26fe..1d02330b9b199c 100644 --- a/x-pack/plugins/security/server/lib/authorization/init.js +++ b/x-pack/plugins/security/server/lib/authorization/service.js @@ -5,20 +5,30 @@ */ import { actionsFactory } from './actions'; +import { authorizationModeFactory } from './mode'; import { checkPrivilegesWithRequestFactory } from './check_privileges'; -import { deepFreeze } from './deep_freeze'; import { getClient } from '../../../../../server/lib/get_client_shield'; -export function initAuthorizationService(server) { +export function createAuthorizationService(server, xpackInfoFeature) { const shieldClient = getClient(server); const config = server.config(); const actions = actionsFactory(config); const application = `kibana-${config.get('kibana.index')}`; + const checkPrivilegesWithRequest = checkPrivilegesWithRequestFactory(actions, application, shieldClient); + const mode = authorizationModeFactory( + actions, + checkPrivilegesWithRequest, + config, + server.plugins, + server.savedObjects, + xpackInfoFeature + ); - server.expose('authorization', deepFreeze({ + return { actions, application, - checkPrivilegesWithRequest: checkPrivilegesWithRequestFactory(shieldClient, config, actions, application), - })); + checkPrivilegesWithRequest, + mode, + }; } diff --git a/x-pack/plugins/security/server/lib/authorization/init.test.js b/x-pack/plugins/security/server/lib/authorization/service.test.js similarity index 60% rename from x-pack/plugins/security/server/lib/authorization/init.test.js rename to x-pack/plugins/security/server/lib/authorization/service.test.js index d70e08934c131f..f0bfe46c35b7c5 100644 --- a/x-pack/plugins/security/server/lib/authorization/init.test.js +++ b/x-pack/plugins/security/server/lib/authorization/service.test.js @@ -4,10 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import { initAuthorizationService } from './init'; +import { createAuthorizationService } from './service'; import { actionsFactory } from './actions'; import { checkPrivilegesWithRequestFactory } from './check_privileges'; import { getClient } from '../../../../../server/lib/get_client_shield'; +import { authorizationModeFactory } from './mode'; jest.mock('./check_privileges', () => ({ checkPrivilegesWithRequestFactory: jest.fn(), @@ -21,6 +22,10 @@ jest.mock('./actions', () => ({ actionsFactory: jest.fn(), })); +jest.mock('./mode', () => ({ + authorizationModeFactory: jest.fn(), +})); + const createMockConfig = (settings = {}) => { const mockConfig = { get: jest.fn() @@ -38,7 +43,9 @@ test(`calls server.expose with exposed services`, () => { }); const mockServer = { expose: jest.fn(), - config: jest.fn().mockReturnValue(mockConfig) + config: jest.fn().mockReturnValue(mockConfig), + plugins: Symbol(), + savedObjects: Symbol(), }; const mockShieldClient = Symbol(); getClient.mockReturnValue(mockShieldClient); @@ -47,37 +54,20 @@ test(`calls server.expose with exposed services`, () => { const mockActions = Symbol(); actionsFactory.mockReturnValue(mockActions); mockConfig.get.mock; + const mockXpackInfoFeature = Symbol(); - initAuthorizationService(mockServer); + createAuthorizationService(mockServer, mockXpackInfoFeature); const application = `kibana-${kibanaIndex}`; expect(getClient).toHaveBeenCalledWith(mockServer); expect(actionsFactory).toHaveBeenCalledWith(mockConfig); - expect(checkPrivilegesWithRequestFactory).toHaveBeenCalledWith(mockShieldClient, mockConfig, mockActions, application); - expect(mockServer.expose).toHaveBeenCalledWith('authorization', { - actions: mockActions, - application, - checkPrivilegesWithRequest: mockCheckPrivilegesWithRequest, - }); -}); - -test(`deep freezes exposed service`, () => { - const mockConfig = createMockConfig({ - 'kibana.index': '' - }); - const mockServer = { - expose: jest.fn(), - config: jest.fn().mockReturnValue(mockConfig) - }; - actionsFactory.mockReturnValue({ - login: 'login', - }); - - initAuthorizationService(mockServer); - - const exposed = mockServer.expose.mock.calls[0][1]; - expect(() => delete exposed.checkPrivilegesWithRequest).toThrowErrorMatchingSnapshot(); - expect(() => exposed.foo = 'bar').toThrowErrorMatchingSnapshot(); - expect(() => exposed.actions.login = 'not-login').toThrowErrorMatchingSnapshot(); - expect(() => exposed.application = 'changed').toThrowErrorMatchingSnapshot(); + expect(checkPrivilegesWithRequestFactory).toHaveBeenCalledWith(mockActions, application, mockShieldClient); + expect(authorizationModeFactory).toHaveBeenCalledWith( + mockActions, + mockCheckPrivilegesWithRequest, + mockConfig, + mockServer.plugins, + mockServer.savedObjects, + mockXpackInfoFeature, + ); }); diff --git a/x-pack/plugins/security/server/lib/authorization/space_application_privileges_serializer.js b/x-pack/plugins/security/server/lib/authorization/space_application_privileges_serializer.js new file mode 100644 index 00000000000000..2906f07e9f5ce4 --- /dev/null +++ b/x-pack/plugins/security/server/lib/authorization/space_application_privileges_serializer.js @@ -0,0 +1,35 @@ +/* + * 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. + */ + +const privilegePrefix = `space_`; +const resourcePrefix = `space:`; + +export const spaceApplicationPrivilegesSerializer = { + privilege: { + serialize(privilege) { + return `${privilegePrefix}${privilege}`; + }, + deserialize(privilege) { + if (!privilege.startsWith(privilegePrefix)) { + throw new Error(`Space privilege should have started with ${privilegePrefix}`); + } + + return privilege.slice(privilegePrefix.length); + }, + }, + resource: { + serialize(spaceId) { + return `${resourcePrefix}${spaceId}`; + }, + deserialize(resource) { + if (!resource.startsWith(resourcePrefix)) { + throw new Error(`Resource should have started with ${resourcePrefix}`); + } + + return resource.slice(resourcePrefix.length); + } + }, +}; diff --git a/x-pack/plugins/security/server/lib/authorization/space_application_privileges_serializer.test.js b/x-pack/plugins/security/server/lib/authorization/space_application_privileges_serializer.test.js new file mode 100644 index 00000000000000..2277d09e498db7 --- /dev/null +++ b/x-pack/plugins/security/server/lib/authorization/space_application_privileges_serializer.test.js @@ -0,0 +1,51 @@ +/* + * 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 { spaceApplicationPrivilegesSerializer } from './space_application_privileges_serializer'; + +describe('#privilege', () => { + describe('#serialize', () => { + test(`prepends privilege with space_`, () => { + const result = spaceApplicationPrivilegesSerializer.privilege.serialize('all'); + expect(result).toBe('space_all'); + }); + }); + + describe('#deserialize', () => { + test(`throws error if privilege doesn't start with space_`, () => { + expect( + () => spaceApplicationPrivilegesSerializer.privilege.deserialize('foo_space_all') + ).toThrowErrorMatchingSnapshot(); + }); + + test(`removes space_ from the start`, () => { + const result = spaceApplicationPrivilegesSerializer.privilege.deserialize('space_all'); + expect(result).toBe('all'); + }); + }); +}); + +describe('#resource', () => { + describe('#serialize', () => { + test(`prepends resource with space:`, () => { + const result = spaceApplicationPrivilegesSerializer.resource.serialize('marketing'); + expect(result).toBe('space:marketing'); + }); + }); + + describe('#deserialize', () => { + test(`throws error if resource doesn't start with space:`, () => { + expect( + () => spaceApplicationPrivilegesSerializer.resource.deserialize('foo:space:something') + ).toThrowErrorMatchingSnapshot(); + }); + + test(`removes space: from the start`, () => { + const result = spaceApplicationPrivilegesSerializer.resource.deserialize('space:marketing'); + expect(result).toBe('marketing'); + }); + }); +}); diff --git a/x-pack/plugins/security/server/lib/authorization/validate_es_response.js b/x-pack/plugins/security/server/lib/authorization/validate_es_response.js index 34d618398bc3d3..2819983cf43c8b 100644 --- a/x-pack/plugins/security/server/lib/authorization/validate_es_response.js +++ b/x-pack/plugins/security/server/lib/authorization/validate_es_response.js @@ -5,19 +5,9 @@ */ import Joi from 'joi'; -import { buildLegacyIndexPrivileges } from './privileges'; -const legacyIndexPrivilegesSchema = Joi.object({ - ...buildLegacyIndexPrivileges().reduce((acc, privilege) => { - return { - ...acc, - [privilege]: Joi.bool().required() - }; - }, {}) -}).required(); - -export function validateEsPrivilegeResponse(response, application, actions, resources, kibanaIndex) { - const schema = buildValidationSchema(application, actions, resources, kibanaIndex); +export function validateEsPrivilegeResponse(response, application, actions, resources) { + const schema = buildValidationSchema(application, actions, resources); const { error, value } = schema.validate(response); if (error) { @@ -38,7 +28,7 @@ function buildActionsValidationSchema(actions) { }).required(); } -function buildValidationSchema(application, actions, resources, kibanaIndex) { +function buildValidationSchema(application, actions, resources) { const actionValidationSchema = buildActionsValidationSchema(actions); @@ -58,8 +48,6 @@ function buildValidationSchema(application, actions, resources, kibanaIndex) { application: Joi.object({ [application]: resourceValidationSchema, }).required(), - index: Joi.object({ - [kibanaIndex]: legacyIndexPrivilegesSchema - }).required() + index: Joi.object(), }).required(); } diff --git a/x-pack/plugins/security/server/lib/authorization/validate_es_response.test.js b/x-pack/plugins/security/server/lib/authorization/validate_es_response.test.js index f3dbad1b56ac95..a7fba14229de28 100644 --- a/x-pack/plugins/security/server/lib/authorization/validate_es_response.test.js +++ b/x-pack/plugins/security/server/lib/authorization/validate_es_response.test.js @@ -5,11 +5,10 @@ */ import { validateEsPrivilegeResponse } from "./validate_es_response"; -import { buildLegacyIndexPrivileges } from "./privileges"; -const resource = 'foo-resource'; +const resource1 = 'foo-resource'; +const resource2 = 'bar-resource'; const application = 'foo-application'; -const kibanaIndex = '.kibana'; const commonResponse = { username: 'user', @@ -17,31 +16,27 @@ const commonResponse = { }; describe('validateEsPrivilegeResponse', () => { - const legacyIndexResponse = { - [kibanaIndex]: { - 'create': true, - 'delete': true, - 'read': true, - 'view_index_metadata': true, - } - }; it('should validate a proper response', () => { const response = { ...commonResponse, application: { [application]: { - [resource]: { + [resource1]: { + action1: true, + action2: true, + action3: true + }, + [resource2]: { action1: true, action2: true, action3: true } } - }, - index: legacyIndexResponse + } }; - const result = validateEsPrivilegeResponse(response, application, ['action1', 'action2', 'action3'], [resource], kibanaIndex); + const result = validateEsPrivilegeResponse(response, application, ['action1', 'action2', 'action3'], [resource1, resource2]); expect(result).toEqual(response); }); @@ -50,17 +45,21 @@ describe('validateEsPrivilegeResponse', () => { ...commonResponse, application: { [application]: { - [resource]: { + [resource1]: { action1: true, action3: true + }, + [resource2]: { + action1: true, + action2: true, + action3: true } } - }, - index: legacyIndexResponse + } }; expect(() => - validateEsPrivilegeResponse(response, application, ['action1', 'action2', 'action3'], [resource], kibanaIndex) + validateEsPrivilegeResponse(response, application, ['action1', 'action2', 'action3'], [resource1, resource2]) ).toThrowErrorMatchingSnapshot(); }); @@ -69,19 +68,23 @@ describe('validateEsPrivilegeResponse', () => { ...commonResponse, application: { [application]: { - [resource]: { + [resource1]: { action1: true, action2: true, action3: true, action4: true, + }, + [resource2]: { + action1: true, + action2: true, + action3: true } } - }, - index: legacyIndexResponse + } }; expect(() => - validateEsPrivilegeResponse(response, application, ['action1', 'action2', 'action3'], [resource], kibanaIndex) + validateEsPrivilegeResponse(response, application, ['action1', 'action2', 'action3'], [resource1, resource2]) ).toThrowErrorMatchingSnapshot(); }); @@ -90,18 +93,22 @@ describe('validateEsPrivilegeResponse', () => { ...commonResponse, application: { [application]: { - [resource]: { + [resource1]: { action1: true, action2: true, action3: 'not a boolean', + }, + [resource2]: { + action1: true, + action2: true, + action3: true, } } }, - index: legacyIndexResponse }; expect(() => - validateEsPrivilegeResponse(response, application, ['action1', 'action2', 'action3'], [resource], kibanaIndex) + validateEsPrivilegeResponse(response, application, ['action1', 'action2', 'action3'], [resource1, resource2]) ).toThrowErrorMatchingSnapshot(); }); @@ -110,25 +117,34 @@ describe('validateEsPrivilegeResponse', () => { ...commonResponse, application: { [application]: { - [resource]: { + [resource1]: { + action1: true, + action2: true, + action3: true, + }, + [resource2]: { action1: true, action2: true, action3: true, } }, otherApplication: { - [resource]: { + [resource1]: { + action1: true, + action2: true, + action3: true, + }, + [resource2]: { action1: true, action2: true, action3: true, } } }, - index: legacyIndexResponse }; expect(() => - validateEsPrivilegeResponse(response, application, ['action1', 'action2', 'action3'], [resource], kibanaIndex) + validateEsPrivilegeResponse(response, application, ['action1', 'action2', 'action3'], [resource1, resource2]) ).toThrowErrorMatchingSnapshot(); }); @@ -136,11 +152,10 @@ describe('validateEsPrivilegeResponse', () => { const response = { ...commonResponse, application: {}, - index: legacyIndexResponse }; expect(() => - validateEsPrivilegeResponse(response, application, ['action1', 'action2', 'action3'], [resource], kibanaIndex) + validateEsPrivilegeResponse(response, application, ['action1', 'action2', 'action3'], [resource1, resource2]) ).toThrowErrorMatchingSnapshot(); }); @@ -151,21 +166,40 @@ describe('validateEsPrivilegeResponse', () => { }; expect(() => - validateEsPrivilegeResponse(response, application, ['action1', 'action2', 'action3'], [resource], kibanaIndex) + validateEsPrivilegeResponse(response, application, ['action1', 'action2', 'action3'], [resource1, resource2]) + ).toThrowErrorMatchingSnapshot(); + }); + + it('fails validation when an expected resource property is missing from the response', () => { + const response = { + ...commonResponse, + application: { + [application]: { + [resource1]: { + action1: true, + action2: true, + action3: true, + }, + } + }, + }; + + expect(() => + validateEsPrivilegeResponse(response, application, ['action1', 'action2', 'action3'], [resource1, resource2]) ).toThrowErrorMatchingSnapshot(); }); - it('fails validation when the expected resource property is missing from the response', () => { + it('fails validation when there are no resource properties in the response', () => { const response = { ...commonResponse, application: { - [application]: {} + [application]: { + } }, - index: legacyIndexResponse }; expect(() => - validateEsPrivilegeResponse(response, application, ['action1', 'action2', 'action3'], [resource], kibanaIndex) + validateEsPrivilegeResponse(response, application, ['action1', 'action2', 'action3'], [resource1, resource2]) ).toThrowErrorMatchingSnapshot(); }); @@ -174,6 +208,11 @@ describe('validateEsPrivilegeResponse', () => { ...commonResponse, application: { [application]: { + [resource1]: { + action1: true, + action2: true, + action3: true, + }, 'other-resource': { action1: true, action2: true, @@ -181,11 +220,10 @@ describe('validateEsPrivilegeResponse', () => { } } }, - index: legacyIndexResponse }; expect(() => - validateEsPrivilegeResponse(response, application, ['action1', 'action2', 'action3'], [resource], kibanaIndex) + validateEsPrivilegeResponse(response, application, ['action1', 'action2', 'action3'], [resource1, resource2]) ).toThrowErrorMatchingSnapshot(); }); @@ -194,164 +232,18 @@ describe('validateEsPrivilegeResponse', () => { ...commonResponse, application: { [application]: { - [resource]: 'not-an-object' + [resource1]: 'not-an-object', + [resource2]: { + action1: true, + action2: true, + action3: true, + }, } }, - index: legacyIndexResponse }; expect(() => - validateEsPrivilegeResponse(response, application, ['action1', 'action2', 'action3'], [resource], kibanaIndex) + validateEsPrivilegeResponse(response, application, ['action1', 'action2', 'action3'], [resource1, resource2]) ).toThrowErrorMatchingSnapshot(); }); - - describe('legacy', () => { - it('should validate a proper response', () => { - const response = { - ...commonResponse, - application: { - [application]: { - [resource]: { - action1: true - } - } - }, - index: legacyIndexResponse - }; - - const result = validateEsPrivilegeResponse(response, application, ['action1'], [resource], kibanaIndex); - expect(result).toEqual(response); - }); - - it('should fail if the index property is missing', () => { - const response = { - ...commonResponse, - application: { - [application]: { - [resource]: { - action1: true - } - } - } - }; - - expect(() => - validateEsPrivilegeResponse(response, application, ['action1'], [resource], kibanaIndex) - ).toThrowErrorMatchingSnapshot(); - }); - - it('should fail if the kibana index is missing from the response', () => { - const response = { - ...commonResponse, - application: { - [application]: { - [resource]: { - action1: true - } - } - }, - index: {} - }; - - expect(() => - validateEsPrivilegeResponse(response, application, ['action1'], [resource], kibanaIndex) - ).toThrowErrorMatchingSnapshot(); - }); - - it('should fail if the index privilege response returns an extra index', () => { - const response = { - ...commonResponse, - application: { - [application]: { - [resource]: { - action1: true - } - } - }, - index: { - ...legacyIndexResponse, - 'anotherIndex': { - foo: true - } - } - }; - - expect(() => - validateEsPrivilegeResponse(response, application, ['action1'], [resource], kibanaIndex) - ).toThrowErrorMatchingSnapshot(); - }); - - it('should fail if the index privilege response contains an extra privilege', () => { - const response = { - ...commonResponse, - application: { - [application]: { - [resource]: { - action1: true - } - } - }, - index: { - [kibanaIndex]: { - ...legacyIndexResponse[kibanaIndex], - 'foo-permission': true - } - } - }; - - expect(() => - validateEsPrivilegeResponse(response, application, ['action1'], [resource], kibanaIndex) - ).toThrowErrorMatchingSnapshot(); - }); - - buildLegacyIndexPrivileges().forEach(privilege => { - test(`should fail if the ${privilege} index privilege is missing from the response`, () => { - const response = { - ...commonResponse, - application: { - [application]: { - [resource]: { - action1: true - } - } - }, - index: { - [kibanaIndex]: { - ...legacyIndexResponse[kibanaIndex] - } - } - }; - - delete response.index[kibanaIndex][privilege]; - - expect(() => - validateEsPrivilegeResponse(response, application, ['action1'], [resource], kibanaIndex) - ).toThrowErrorMatchingSnapshot(); - }); - - test(`should fail if the ${privilege} index privilege is malformed`, () => { - const response = { - ...commonResponse, - application: { - [application]: { - [resource]: { - action1: true - } - } - }, - index: { - [kibanaIndex]: { - ...legacyIndexResponse[kibanaIndex] - } - } - }; - - response.index[kibanaIndex][privilege] = 'not a boolean'; - - expect(() => - validateEsPrivilegeResponse(response, application, ['action1'], [resource], kibanaIndex) - ).toThrowErrorMatchingSnapshot(); - }); - }); - }); }); diff --git a/x-pack/plugins/security/server/lib/authorization/deep_freeze.js b/x-pack/plugins/security/server/lib/deep_freeze.js similarity index 100% rename from x-pack/plugins/security/server/lib/authorization/deep_freeze.js rename to x-pack/plugins/security/server/lib/deep_freeze.js diff --git a/x-pack/plugins/security/server/lib/authorization/deep_freeze.test.js b/x-pack/plugins/security/server/lib/deep_freeze.test.js similarity index 100% rename from x-pack/plugins/security/server/lib/authorization/deep_freeze.test.js rename to x-pack/plugins/security/server/lib/deep_freeze.test.js diff --git a/x-pack/plugins/security/server/lib/saved_objects_client/secure_saved_objects_client.js b/x-pack/plugins/security/server/lib/saved_objects_client/secure_saved_objects_client.js deleted file mode 100644 index df6052dc4bb3dc..00000000000000 --- a/x-pack/plugins/security/server/lib/saved_objects_client/secure_saved_objects_client.js +++ /dev/null @@ -1,122 +0,0 @@ -/* - * 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 { get, uniq } from 'lodash'; -import { CHECK_PRIVILEGES_RESULT } from '../authorization/check_privileges'; - -export class SecureSavedObjectsClient { - constructor(options) { - const { - errors, - internalRepository, - callWithRequestRepository, - checkPrivileges, - auditLogger, - actions, - } = options; - - this.errors = errors; - this._internalRepository = internalRepository; - this._callWithRequestRepository = callWithRequestRepository; - this._checkPrivileges = checkPrivileges; - this._auditLogger = auditLogger; - this._actions = actions; - } - - async create(type, attributes = {}, options = {}) { - return await this._execute( - type, - 'create', - { type, attributes, options }, - repository => repository.create(type, attributes, options), - ); - } - - async bulkCreate(objects, options = {}) { - const types = uniq(objects.map(o => o.type)); - return await this._execute( - types, - 'bulk_create', - { objects, options }, - repository => repository.bulkCreate(objects, options), - ); - } - - async delete(type, id, options = {}) { - return await this._execute( - type, - 'delete', - { type, id, options }, - repository => repository.delete(type, id, options), - ); - } - - async find(options = {}) { - return await this._execute( - options.type, - 'find', - { options }, - repository => repository.find(options) - ); - } - - async bulkGet(objects = [], options = {}) { - const types = uniq(objects.map(o => o.type)); - return await this._execute( - types, - 'bulk_get', - { objects, options }, - repository => repository.bulkGet(objects, options) - ); - } - - async get(type, id, options = {}) { - return await this._execute( - type, - 'get', - { type, id, options }, - repository => repository.get(type, id, options) - ); - } - - async update(type, id, attributes, options = {}) { - return await this._execute( - type, - 'update', - { type, id, attributes, options }, - repository => repository.update(type, id, attributes, options) - ); - } - - async _checkSavedObjectPrivileges(actions) { - try { - return await this._checkPrivileges(actions); - } catch (error) { - const { reason } = get(error, 'body.error', {}); - throw this.errors.decorateGeneralError(error, reason); - } - } - - async _execute(typeOrTypes, action, args, fn) { - const types = Array.isArray(typeOrTypes) ? typeOrTypes : [typeOrTypes]; - const actions = types.map(type => this._actions.getSavedObjectAction(type, action)); - const { result, username, missing } = await this._checkSavedObjectPrivileges(actions); - - switch (result) { - case CHECK_PRIVILEGES_RESULT.AUTHORIZED: - this._auditLogger.savedObjectsAuthorizationSuccess(username, action, types, args); - return await fn(this._internalRepository); - case CHECK_PRIVILEGES_RESULT.LEGACY: - return await fn(this._callWithRequestRepository); - case CHECK_PRIVILEGES_RESULT.UNAUTHORIZED: - this._auditLogger.savedObjectsAuthorizationFailure(username, action, types, missing, args); - const msg = `Unable to ${action} ${[...types].sort().join(',')}, missing ${[...missing].sort().join(',')}`; - throw this.errors.decorateForbiddenError(new Error(msg)); - default: - throw new Error('Unexpected result from hasPrivileges'); - } - } -} diff --git a/x-pack/plugins/security/server/lib/saved_objects_client/secure_saved_objects_client.test.js b/x-pack/plugins/security/server/lib/saved_objects_client/secure_saved_objects_client.test.js deleted file mode 100644 index 7be9d4358b06d2..00000000000000 --- a/x-pack/plugins/security/server/lib/saved_objects_client/secure_saved_objects_client.test.js +++ /dev/null @@ -1,1031 +0,0 @@ -/* - * 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 { SecureSavedObjectsClient } from './secure_saved_objects_client'; -import { CHECK_PRIVILEGES_RESULT } from '../authorization/check_privileges'; - -const createMockErrors = () => { - const forbiddenError = new Error('Mock ForbiddenError'); - const generalError = new Error('Mock GeneralError'); - - return { - forbiddenError, - decorateForbiddenError: jest.fn().mockReturnValue(forbiddenError), - generalError, - decorateGeneralError: jest.fn().mockReturnValue(generalError) - }; -}; - -const createMockAuditLogger = () => { - return { - savedObjectsAuthorizationFailure: jest.fn(), - savedObjectsAuthorizationSuccess: jest.fn(), - }; -}; - -const createMockActions = () => { - return { - getSavedObjectAction(type, action) { - return `mock-action:saved_objects/${type}/${action}`; - } - }; -}; - -describe('#errors', () => { - test(`assigns errors from constructor to .errors`, () => { - const errors = Symbol(); - - const client = new SecureSavedObjectsClient({ errors }); - - expect(client.errors).toBe(errors); - }); -}); - -describe('#create', () => { - test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { - const type = 'foo'; - const mockErrors = createMockErrors(); - const mockCheckPrivileges = jest.fn().mockImplementation(async () => { - throw new Error(); - }); - const mockAuditLogger = createMockAuditLogger(); - const mockActions = createMockActions(); - const client = new SecureSavedObjectsClient({ - errors: mockErrors, - checkPrivileges: mockCheckPrivileges, - auditLogger: mockAuditLogger, - actions: mockActions, - }); - - await expect(client.create(type)).rejects.toThrowError(mockErrors.generalError); - - expect(mockCheckPrivileges).toHaveBeenCalledWith([mockActions.getSavedObjectAction(type, 'create')]); - expect(mockErrors.decorateGeneralError).toHaveBeenCalledTimes(1); - expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); - expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); - }); - - test(`throws decorated ForbiddenError when unauthorized`, async () => { - const type = 'foo'; - const username = Symbol(); - const mockErrors = createMockErrors(); - const mockCheckPrivileges = jest.fn().mockImplementation(async privileges => ({ - result: CHECK_PRIVILEGES_RESULT.UNAUTHORIZED, - username, - missing: privileges, - })); - const mockAuditLogger = createMockAuditLogger(); - const mockActions = createMockActions(); - const client = new SecureSavedObjectsClient({ - errors: mockErrors, - checkPrivileges: mockCheckPrivileges, - auditLogger: mockAuditLogger, - actions: mockActions, - }); - const attributes = Symbol(); - const options = Symbol(); - - await expect(client.create(type, attributes, options)).rejects.toThrowError(mockErrors.forbiddenError); - - expect(mockCheckPrivileges).toHaveBeenCalledWith([mockActions.getSavedObjectAction(type, 'create')]); - expect(mockErrors.decorateForbiddenError).toHaveBeenCalledTimes(1); - expect(mockAuditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( - username, - 'create', - [type], - [mockActions.getSavedObjectAction(type, 'create')], - { - type, - attributes, - options, - } - ); - expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); - }); - - test(`returns result of internalRepository.create when authorized`, async () => { - const type = 'foo'; - const username = Symbol(); - const returnValue = Symbol(); - const mockRepository = { - create: jest.fn().mockReturnValue(returnValue) - }; - const mockCheckPrivileges = jest.fn().mockImplementation(async () => ({ - result: CHECK_PRIVILEGES_RESULT.AUTHORIZED, - username, - })); - const mockAuditLogger = createMockAuditLogger(); - const client = new SecureSavedObjectsClient({ - internalRepository: mockRepository, - checkPrivileges: mockCheckPrivileges, - auditLogger: mockAuditLogger, - actions: createMockActions(), - }); - const attributes = Symbol(); - const options = Symbol(); - - const result = await client.create(type, attributes, options); - - expect(result).toBe(returnValue); - expect(mockRepository.create).toHaveBeenCalledWith(type, attributes, options); - expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); - expect(mockAuditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith(username, 'create', [type], { - type, - attributes, - options, - }); - }); - - test(`returns result of callWithRequestRepository.create when legacy`, async () => { - const type = 'foo'; - const username = Symbol(); - const returnValue = Symbol(); - const mockRepository = { - create: jest.fn().mockReturnValue(returnValue) - }; - const mockCheckPrivileges = jest.fn().mockImplementation(async privileges => ({ - result: CHECK_PRIVILEGES_RESULT.LEGACY, - username, - missing: privileges, - })); - const mockAuditLogger = createMockAuditLogger(); - const client = new SecureSavedObjectsClient({ - callWithRequestRepository: mockRepository, - checkPrivileges: mockCheckPrivileges, - auditLogger: mockAuditLogger, - actions: createMockActions(), - }); - const attributes = Symbol(); - const options = Symbol(); - - const result = await client.create(type, attributes, options); - - expect(result).toBe(returnValue); - expect(mockRepository.create).toHaveBeenCalledWith(type, attributes, options); - expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); - expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); - }); -}); - -describe('#bulkCreate', () => { - test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { - const type = 'foo'; - const mockErrors = createMockErrors(); - const mockCheckPrivileges = jest.fn().mockImplementation(async () => { - throw new Error(); - }); - const mockAuditLogger = createMockAuditLogger(); - const mockActions = createMockActions(); - const client = new SecureSavedObjectsClient({ - errors: mockErrors, - checkPrivileges: mockCheckPrivileges, - auditLogger: mockAuditLogger, - actions: mockActions, - }); - - await expect(client.bulkCreate([{ type }])).rejects.toThrowError(mockErrors.generalError); - - expect(mockCheckPrivileges).toHaveBeenCalledWith([mockActions.getSavedObjectAction(type, 'bulk_create')]); - expect(mockErrors.decorateGeneralError).toHaveBeenCalledTimes(1); - expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); - expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); - }); - - test(`throws decorated ForbiddenError when unauthorized`, async () => { - const type1 = 'foo'; - const type2 = 'bar'; - const username = Symbol(); - const mockErrors = createMockErrors(); - const mockCheckPrivileges = jest.fn().mockImplementation(async privileges => ({ - result: CHECK_PRIVILEGES_RESULT.UNAUTHORIZED, - username, - missing: [ - privileges[0] - ], - })); - const mockAuditLogger = createMockAuditLogger(); - const mockActions = createMockActions(); - const client = new SecureSavedObjectsClient({ - errors: mockErrors, - checkPrivileges: mockCheckPrivileges, - auditLogger: mockAuditLogger, - actions: mockActions, - }); - const objects = [ - { type: type1 }, - { type: type1 }, - { type: type2 }, - ]; - const options = Symbol(); - - await expect(client.bulkCreate(objects, options)).rejects.toThrowError(mockErrors.forbiddenError); - - expect(mockCheckPrivileges).toHaveBeenCalledWith([ - mockActions.getSavedObjectAction(type1, 'bulk_create'), - mockActions.getSavedObjectAction(type2, 'bulk_create'), - ]); - expect(mockErrors.decorateForbiddenError).toHaveBeenCalledTimes(1); - expect(mockAuditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( - username, - 'bulk_create', - [type1, type2], - [mockActions.getSavedObjectAction(type1, 'bulk_create')], - { - objects, - options, - } - ); - }); - - test(`returns result of internalRepository.bulkCreate when authorized`, async () => { - const username = Symbol(); - const type1 = 'foo'; - const type2 = 'bar'; - const returnValue = Symbol(); - const mockRepository = { - bulkCreate: jest.fn().mockReturnValue(returnValue) - }; - const mockCheckPrivileges = jest.fn().mockImplementation(async () => ({ - result: CHECK_PRIVILEGES_RESULT.AUTHORIZED, - username, - })); - const mockAuditLogger = createMockAuditLogger(); - const client = new SecureSavedObjectsClient({ - internalRepository: mockRepository, - checkPrivileges: mockCheckPrivileges, - auditLogger: mockAuditLogger, - actions: createMockActions(), - }); - const objects = [ - { type: type1, otherThing: 'sup' }, - { type: type2, otherThing: 'everyone' }, - ]; - const options = Symbol(); - - const result = await client.bulkCreate(objects, options); - - expect(result).toBe(returnValue); - expect(mockRepository.bulkCreate).toHaveBeenCalledWith(objects, options); - expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); - expect(mockAuditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith(username, 'bulk_create', [type1, type2], { - objects, - options, - }); - }); - - test(`returns result of callWithRequestRepository.bulkCreate when legacy`, async () => { - const username = Symbol(); - const type1 = 'foo'; - const type2 = 'bar'; - const returnValue = Symbol(); - const mockRepository = { - bulkCreate: jest.fn().mockReturnValue(returnValue) - }; - const mockCheckPrivileges = jest.fn().mockImplementation(async privileges => ({ - result: CHECK_PRIVILEGES_RESULT.LEGACY, - username, - missing: privileges, - })); - const mockAuditLogger = createMockAuditLogger(); - const client = new SecureSavedObjectsClient({ - callWithRequestRepository: mockRepository, - checkPrivileges: mockCheckPrivileges, - auditLogger: mockAuditLogger, - actions: createMockActions(), - }); - const objects = [ - { type: type1, otherThing: 'sup' }, - { type: type2, otherThing: 'everyone' }, - ]; - const options = Symbol(); - - const result = await client.bulkCreate(objects, options); - - expect(result).toBe(returnValue); - expect(mockRepository.bulkCreate).toHaveBeenCalledWith(objects, options); - expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); - expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); - }); -}); - -describe('#delete', () => { - test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { - const type = 'foo'; - const mockErrors = createMockErrors(); - const mockCheckPrivileges = jest.fn().mockImplementation(async () => { - throw new Error(); - }); - const mockAuditLogger = createMockAuditLogger(); - const mockActions = createMockActions(); - const client = new SecureSavedObjectsClient({ - errors: mockErrors, - checkPrivileges: mockCheckPrivileges, - auditLogger: mockAuditLogger, - actions: mockActions, - }); - - await expect(client.delete(type)).rejects.toThrowError(mockErrors.generalError); - - expect(mockCheckPrivileges).toHaveBeenCalledWith([mockActions.getSavedObjectAction(type, 'delete')]); - expect(mockErrors.decorateGeneralError).toHaveBeenCalledTimes(1); - expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); - expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); - }); - - test(`throws decorated ForbiddenError when unauthorized`, async () => { - const type = 'foo'; - const username = Symbol(); - const mockErrors = createMockErrors(); - const mockCheckPrivileges = jest.fn().mockImplementation(async privileges => ({ - result: CHECK_PRIVILEGES_RESULT.UNAUTHORIZED, - username, - missing: privileges, - })); - const mockAuditLogger = createMockAuditLogger(); - const mockActions = createMockActions(); - const client = new SecureSavedObjectsClient({ - errors: mockErrors, - checkPrivileges: mockCheckPrivileges, - auditLogger: mockAuditLogger, - actions: mockActions, - }); - const id = Symbol(); - const options = Symbol(); - - await expect(client.delete(type, id, options)).rejects.toThrowError(mockErrors.forbiddenError); - - expect(mockCheckPrivileges).toHaveBeenCalledWith([mockActions.getSavedObjectAction(type, 'delete')]); - expect(mockErrors.decorateForbiddenError).toHaveBeenCalledTimes(1); - expect(mockAuditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( - username, - 'delete', - [type], - [mockActions.getSavedObjectAction(type, 'delete')], - { - type, - id, - options, - } - ); - expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); - }); - - test(`returns result of internalRepository.delete when authorized`, async () => { - const type = 'foo'; - const username = Symbol(); - const returnValue = Symbol(); - const mockRepository = { - delete: jest.fn().mockReturnValue(returnValue) - }; - const mockCheckPrivileges = jest.fn().mockImplementation(async () => ({ - result: CHECK_PRIVILEGES_RESULT.AUTHORIZED, - username, - })); - const mockAuditLogger = createMockAuditLogger(); - const client = new SecureSavedObjectsClient({ - internalRepository: mockRepository, - checkPrivileges: mockCheckPrivileges, - auditLogger: mockAuditLogger, - actions: createMockActions(), - }); - const id = Symbol(); - const options = Symbol(); - - const result = await client.delete(type, id, options); - - expect(result).toBe(returnValue); - expect(mockRepository.delete).toHaveBeenCalledWith(type, id, options); - expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); - expect(mockAuditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith(username, 'delete', [type], { - type, - id, - options, - }); - }); - - test(`returns result of internalRepository.delete when legacy`, async () => { - const type = 'foo'; - const username = Symbol(); - const returnValue = Symbol(); - const mockRepository = { - delete: jest.fn().mockReturnValue(returnValue) - }; - const mockCheckPrivileges = jest.fn().mockImplementation(async privileges => ({ - result: CHECK_PRIVILEGES_RESULT.LEGACY, - username, - missing: privileges, - })); - const mockAuditLogger = createMockAuditLogger(); - const client = new SecureSavedObjectsClient({ - callWithRequestRepository: mockRepository, - checkPrivileges: mockCheckPrivileges, - auditLogger: mockAuditLogger, - actions: createMockActions(), - }); - const id = Symbol(); - const options = Symbol(); - - const result = await client.delete(type, id, options); - - expect(result).toBe(returnValue); - expect(mockRepository.delete).toHaveBeenCalledWith(type, id, options); - expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); - expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); - }); -}); - -describe('#find', () => { - describe('type', () => { - test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { - const type = 'foo'; - const mockErrors = createMockErrors(); - const mockCheckPrivileges = jest.fn().mockImplementation(async () => { - throw new Error(); - }); - const mockAuditLogger = createMockAuditLogger(); - const mockActions = createMockActions(); - const client = new SecureSavedObjectsClient({ - errors: mockErrors, - checkPrivileges: mockCheckPrivileges, - auditLogger: mockAuditLogger, - actions: mockActions, - }); - - await expect(client.find({ type })).rejects.toThrowError(mockErrors.generalError); - - expect(mockCheckPrivileges).toHaveBeenCalledWith([mockActions.getSavedObjectAction(type, 'find')]); - expect(mockErrors.decorateGeneralError).toHaveBeenCalledTimes(1); - expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); - expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); - }); - - test(`throws decorated ForbiddenError when type's singular and unauthorized`, async () => { - const type = 'foo'; - const username = Symbol(); - const mockRepository = {}; - const mockErrors = createMockErrors(); - const mockCheckPrivileges = jest.fn().mockImplementation(async privileges => ({ - result: CHECK_PRIVILEGES_RESULT.UNAUTHORIZED, - username, - missing: privileges, - })); - const mockAuditLogger = createMockAuditLogger(); - const mockActions = createMockActions(); - const client = new SecureSavedObjectsClient({ - errors: mockErrors, - internalRepository: mockRepository, - checkPrivileges: mockCheckPrivileges, - auditLogger: mockAuditLogger, - actions: mockActions, - }); - const options = { type }; - - await expect(client.find(options)).rejects.toThrowError(mockErrors.forbiddenError); - - expect(mockCheckPrivileges).toHaveBeenCalledWith([mockActions.getSavedObjectAction(type, 'find')]); - expect(mockErrors.decorateForbiddenError).toHaveBeenCalledTimes(1); - expect(mockAuditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( - username, - 'find', - [type], - [mockActions.getSavedObjectAction(type, 'find')], - { - options - } - ); - expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); - }); - - test(`throws decorated ForbiddenError when type's an array and unauthorized`, async () => { - const type1 = 'foo'; - const type2 = 'bar'; - const username = Symbol(); - const mockErrors = createMockErrors(); - const mockCheckPrivileges = jest.fn().mockImplementation(async privileges => { - return { - result: CHECK_PRIVILEGES_RESULT.UNAUTHORIZED, - username, - missing: [ - privileges[0] - ], - }; - }); - const mockAuditLogger = createMockAuditLogger(); - const mockActions = createMockActions(); - const client = new SecureSavedObjectsClient({ - errors: mockErrors, - checkPrivileges: mockCheckPrivileges, - auditLogger: mockAuditLogger, - actions: mockActions, - }); - const options = { type: [type1, type2] }; - - await expect(client.find(options)).rejects.toThrowError(mockErrors.forbiddenError); - - expect(mockCheckPrivileges).toHaveBeenCalledWith([ - mockActions.getSavedObjectAction(type1, 'find'), - mockActions.getSavedObjectAction(type2, 'find') - ]); - expect(mockErrors.decorateForbiddenError).toHaveBeenCalledTimes(1); - expect(mockAuditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( - username, - 'find', - [type1, type2], - [mockActions.getSavedObjectAction(type1, 'find')], - { - options - } - ); - expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); - }); - - test(`returns result of internalRepository.find when authorized`, async () => { - const type = 'foo'; - const username = Symbol(); - const returnValue = Symbol(); - const mockRepository = { - find: jest.fn().mockReturnValue(returnValue) - }; - const mockCheckPrivileges = jest.fn().mockImplementation(async () => ({ - result: CHECK_PRIVILEGES_RESULT.AUTHORIZED, - username, - })); - const mockAuditLogger = createMockAuditLogger(); - const client = new SecureSavedObjectsClient({ - internalRepository: mockRepository, - checkPrivileges: mockCheckPrivileges, - auditLogger: mockAuditLogger, - actions: createMockActions(), - }); - const options = { type }; - - const result = await client.find(options); - - expect(result).toBe(returnValue); - expect(mockRepository.find).toHaveBeenCalledWith({ type }); - expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); - expect(mockAuditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith(username, 'find', [type], { - options, - }); - }); - - test(`returns result of callWithRequestRepository.find when legacy`, async () => { - const type = 'foo'; - const username = Symbol(); - const returnValue = Symbol(); - const mockRepository = { - find: jest.fn().mockReturnValue(returnValue) - }; - const mockCheckPrivileges = jest.fn().mockImplementation(async privileges => ({ - result: CHECK_PRIVILEGES_RESULT.LEGACY, - username, - missing: privileges, - })); - const mockAuditLogger = createMockAuditLogger(); - const client = new SecureSavedObjectsClient({ - callWithRequestRepository: mockRepository, - checkPrivileges: mockCheckPrivileges, - auditLogger: mockAuditLogger, - actions: createMockActions(), - }); - const options = { type }; - - const result = await client.find(options); - - expect(result).toBe(returnValue); - expect(mockRepository.find).toHaveBeenCalledWith({ type }); - expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); - expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); - }); - }); - - describe('no type', () => { - test(`throws error`, async () => { - const mockRepository = {}; - const mockErrors = createMockErrors(); - const mockCheckPrivileges = jest.fn().mockImplementation(async () => { - throw new Error(); - }); - const mockAuditLogger = createMockAuditLogger(); - const mockActions = createMockActions(); - const client = new SecureSavedObjectsClient({ - errors: mockErrors, - repository: mockRepository, - checkPrivileges: mockCheckPrivileges, - auditLogger: mockAuditLogger, - actions: mockActions, - }); - - await expect(client.find()).rejects.toThrowError(mockErrors.generalError); - - expect(mockCheckPrivileges).toHaveBeenCalledWith([ - mockActions.getSavedObjectAction(undefined, 'find'), - ]); - expect(mockErrors.decorateGeneralError).toHaveBeenCalledTimes(1); - expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); - expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); - }); - }); -}); - -describe('#bulkGet', () => { - test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { - const type = 'foo'; - const mockErrors = createMockErrors(); - const mockCheckPrivileges = jest.fn().mockImplementation(async () => { - throw new Error(); - }); - const mockAuditLogger = createMockAuditLogger(); - const mockActions = createMockActions(); - const client = new SecureSavedObjectsClient({ - errors: mockErrors, - checkPrivileges: mockCheckPrivileges, - auditLogger: mockAuditLogger, - actions: mockActions, - }); - - await expect(client.bulkGet([{ type }])).rejects.toThrowError(mockErrors.generalError); - - expect(mockCheckPrivileges).toHaveBeenCalledWith([mockActions.getSavedObjectAction(type, 'bulk_get')]); - expect(mockErrors.decorateGeneralError).toHaveBeenCalledTimes(1); - expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); - expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); - }); - - test(`throws decorated ForbiddenError when unauthorized`, async () => { - const type1 = 'foo'; - const type2 = 'bar'; - const username = Symbol(); - const mockErrors = createMockErrors(); - const mockCheckPrivileges = jest.fn().mockImplementation(async privileges => ({ - result: CHECK_PRIVILEGES_RESULT.UNAUTHORIZED, - username, - missing: [ - privileges[0] - ], - })); - const mockAuditLogger = createMockAuditLogger(); - const mockActions = createMockActions(); - const client = new SecureSavedObjectsClient({ - errors: mockErrors, - checkPrivileges: mockCheckPrivileges, - auditLogger: mockAuditLogger, - actions: mockActions, - }); - const objects = [ - { type: type1 }, - { type: type1 }, - { type: type2 }, - ]; - const options = Symbol(); - - await expect(client.bulkGet(objects, options)).rejects.toThrowError(mockErrors.forbiddenError); - - expect(mockCheckPrivileges).toHaveBeenCalledWith([ - mockActions.getSavedObjectAction(type1, 'bulk_get'), - mockActions.getSavedObjectAction(type2, 'bulk_get'), - ]); - expect(mockErrors.decorateForbiddenError).toHaveBeenCalledTimes(1); - expect(mockAuditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( - username, - 'bulk_get', - [type1, type2], - [mockActions.getSavedObjectAction(type1, 'bulk_get')], - { - objects, - options, - } - ); - expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); - }); - - test(`returns result of internalRepository.bulkGet when authorized`, async () => { - const type1 = 'foo'; - const type2 = 'bar'; - const username = Symbol(); - const returnValue = Symbol(); - const mockRepository = { - bulkGet: jest.fn().mockReturnValue(returnValue) - }; - const mockCheckPrivileges = jest.fn().mockImplementation(async () => ({ - result: CHECK_PRIVILEGES_RESULT.AUTHORIZED, - username, - })); - const mockAuditLogger = createMockAuditLogger(); - const client = new SecureSavedObjectsClient({ - internalRepository: mockRepository, - checkPrivileges: mockCheckPrivileges, - auditLogger: mockAuditLogger, - actions: createMockActions(), - }); - const objects = [ - { type: type1, id: 'foo-id' }, - { type: type2, id: 'bar-id' }, - ]; - const options = Symbol(); - - const result = await client.bulkGet(objects, options); - - expect(result).toBe(returnValue); - expect(mockRepository.bulkGet).toHaveBeenCalledWith(objects, options); - expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); - expect(mockAuditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith(username, 'bulk_get', [type1, type2], { - objects, - options, - }); - }); - - test(`returns result of callWithRequestRepository.bulkGet when legacy`, async () => { - const type1 = 'foo'; - const type2 = 'bar'; - const username = Symbol(); - const returnValue = Symbol(); - const mockRepository = { - bulkGet: jest.fn().mockReturnValue(returnValue) - }; - const mockCheckPrivileges = jest.fn().mockImplementation(async privileges => ({ - result: CHECK_PRIVILEGES_RESULT.LEGACY, - username, - missing: privileges, - })); - const mockAuditLogger = createMockAuditLogger(); - const client = new SecureSavedObjectsClient({ - callWithRequestRepository: mockRepository, - checkPrivileges: mockCheckPrivileges, - auditLogger: mockAuditLogger, - actions: createMockActions(), - }); - const objects = [ - { type: type1, id: 'foo-id' }, - { type: type2, id: 'bar-id' }, - ]; - const options = Symbol(); - - const result = await client.bulkGet(objects, options); - - expect(result).toBe(returnValue); - expect(mockRepository.bulkGet).toHaveBeenCalledWith(objects, options); - expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); - expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); - }); -}); - -describe('#get', () => { - test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { - const type = 'foo'; - const mockErrors = createMockErrors(); - const mockCheckPrivileges = jest.fn().mockImplementation(async () => { - throw new Error(); - }); - const mockAuditLogger = createMockAuditLogger(); - const mockActions = createMockActions(); - const client = new SecureSavedObjectsClient({ - errors: mockErrors, - checkPrivileges: mockCheckPrivileges, - auditLogger: mockAuditLogger, - actions: mockActions, - }); - - await expect(client.get(type)).rejects.toThrowError(mockErrors.generalError); - - expect(mockCheckPrivileges).toHaveBeenCalledWith([mockActions.getSavedObjectAction(type, 'get')]); - expect(mockErrors.decorateGeneralError).toHaveBeenCalledTimes(1); - expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); - expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); - }); - - test(`throws decorated ForbiddenError when unauthorized`, async () => { - const type = 'foo'; - const username = Symbol(); - const mockErrors = createMockErrors(); - const mockCheckPrivileges = jest.fn().mockImplementation(async privileges => ({ - result: CHECK_PRIVILEGES_RESULT.UNAUTHORIZED, - username, - missing: privileges, - })); - const mockAuditLogger = createMockAuditLogger(); - const mockActions = createMockActions(); - const client = new SecureSavedObjectsClient({ - errors: mockErrors, - checkPrivileges: mockCheckPrivileges, - auditLogger: mockAuditLogger, - actions: mockActions, - }); - const id = Symbol(); - const options = Symbol(); - - await expect(client.get(type, id, options)).rejects.toThrowError(mockErrors.forbiddenError); - - expect(mockCheckPrivileges).toHaveBeenCalledWith([mockActions.getSavedObjectAction(type, 'get')]); - expect(mockErrors.decorateForbiddenError).toHaveBeenCalledTimes(1); - expect(mockAuditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( - username, - 'get', - [type], - [mockActions.getSavedObjectAction(type, 'get')], - { - type, - id, - options, - } - ); - expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); - }); - - test(`returns result of internalRepository.get when authorized`, async () => { - const type = 'foo'; - const username = Symbol(); - const returnValue = Symbol(); - const mockRepository = { - get: jest.fn().mockReturnValue(returnValue) - }; - const mockCheckPrivileges = jest.fn().mockImplementation(async () => ({ - result: CHECK_PRIVILEGES_RESULT.AUTHORIZED, - username, - })); - const mockAuditLogger = createMockAuditLogger(); - const client = new SecureSavedObjectsClient({ - internalRepository: mockRepository, - checkPrivileges: mockCheckPrivileges, - auditLogger: mockAuditLogger, - actions: createMockActions(), - }); - const id = Symbol(); - const options = Symbol(); - - const result = await client.get(type, id, options); - - expect(result).toBe(returnValue); - expect(mockRepository.get).toHaveBeenCalledWith(type, id, options); - expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); - expect(mockAuditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith(username, 'get', [type], { - type, - id, - options, - }); - }); - - test(`returns result of callWithRequestRepository.get when user isn't authorized and has legacy fallback`, async () => { - const type = 'foo'; - const username = Symbol(); - const returnValue = Symbol(); - const mockRepository = { - get: jest.fn().mockReturnValue(returnValue) - }; - const mockCheckPrivileges = jest.fn().mockImplementation(async privileges => ({ - result: CHECK_PRIVILEGES_RESULT.LEGACY, - username, - missing: privileges, - })); - const mockAuditLogger = createMockAuditLogger(); - const client = new SecureSavedObjectsClient({ - callWithRequestRepository: mockRepository, - checkPrivileges: mockCheckPrivileges, - auditLogger: mockAuditLogger, - actions: createMockActions(), - }); - const id = Symbol(); - const options = Symbol(); - - const result = await client.get(type, id, options); - - expect(result).toBe(returnValue); - expect(mockRepository.get).toHaveBeenCalledWith(type, id, options); - expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); - expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); - }); -}); - -describe('#update', () => { - test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { - const type = 'foo'; - const mockErrors = createMockErrors(); - const mockCheckPrivileges = jest.fn().mockImplementation(async () => { - throw new Error(); - }); - const mockAuditLogger = createMockAuditLogger(); - const mockActions = createMockActions(); - const client = new SecureSavedObjectsClient({ - errors: mockErrors, - checkPrivileges: mockCheckPrivileges, - auditLogger: mockAuditLogger, - actions: mockActions, - }); - - await expect(client.update(type)).rejects.toThrowError(mockErrors.generalError); - - expect(mockCheckPrivileges).toHaveBeenCalledWith([mockActions.getSavedObjectAction(type, 'update')]); - expect(mockErrors.decorateGeneralError).toHaveBeenCalledTimes(1); - expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); - expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); - }); - - test(`throws decorated ForbiddenError when unauthorized`, async () => { - const type = 'foo'; - const username = Symbol(); - const mockErrors = createMockErrors(); - const mockCheckPrivileges = jest.fn().mockImplementation(async privileges => ({ - result: CHECK_PRIVILEGES_RESULT.UNAUTHORIZED, - username, - missing: privileges, - })); - const mockAuditLogger = createMockAuditLogger(); - const mockActions = createMockActions(); - const client = new SecureSavedObjectsClient({ - errors: mockErrors, - checkPrivileges: mockCheckPrivileges, - auditLogger: mockAuditLogger, - actions: mockActions, - }); - const id = Symbol(); - const attributes = Symbol(); - const options = Symbol(); - - await expect(client.update(type, id, attributes, options)).rejects.toThrowError(mockErrors.forbiddenError); - - expect(mockCheckPrivileges).toHaveBeenCalledWith([mockActions.getSavedObjectAction(type, 'update')]); - expect(mockErrors.decorateForbiddenError).toHaveBeenCalledTimes(1); - expect(mockAuditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( - username, - 'update', - [type], - [mockActions.getSavedObjectAction(type, 'update')], - { - type, - id, - attributes, - options, - } - ); - expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); - }); - - test(`returns result of repository.update when authorized`, async () => { - const type = 'foo'; - const username = Symbol(); - const returnValue = Symbol(); - const mockRepository = { - update: jest.fn().mockReturnValue(returnValue) - }; - const mockCheckPrivileges = jest.fn().mockImplementation(async () => ({ - result: CHECK_PRIVILEGES_RESULT.AUTHORIZED, - username, - })); - const mockAuditLogger = createMockAuditLogger(); - const client = new SecureSavedObjectsClient({ - internalRepository: mockRepository, - checkPrivileges: mockCheckPrivileges, - auditLogger: mockAuditLogger, - actions: createMockActions(), - }); - const id = Symbol(); - const attributes = Symbol(); - const options = Symbol(); - - const result = await client.update(type, id, attributes, options); - - expect(result).toBe(returnValue); - expect(mockRepository.update).toHaveBeenCalledWith(type, id, attributes, options); - expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); - expect(mockAuditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith(username, 'update', [type], { - type, - id, - attributes, - options, - }); - }); - - test(`returns result of repository.update when legacy`, async () => { - const type = 'foo'; - const username = Symbol(); - const returnValue = Symbol(); - const mockRepository = { - update: jest.fn().mockReturnValue(returnValue) - }; - const mockCheckPrivileges = jest.fn().mockImplementation(async privileges => ({ - result: CHECK_PRIVILEGES_RESULT.LEGACY, - username, - missing: privileges, - })); - const mockAuditLogger = createMockAuditLogger(); - const client = new SecureSavedObjectsClient({ - callWithRequestRepository: mockRepository, - checkPrivileges: mockCheckPrivileges, - auditLogger: mockAuditLogger, - actions: createMockActions(), - }); - const id = Symbol(); - const attributes = Symbol(); - const options = Symbol(); - - const result = await client.update(type, id, attributes, options); - - expect(result).toBe(returnValue); - expect(mockRepository.update).toHaveBeenCalledWith(type, id, attributes, options); - expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); - expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); - }); -}); diff --git a/x-pack/plugins/security/server/lib/saved_objects_client/secure_saved_objects_client_wrapper.js b/x-pack/plugins/security/server/lib/saved_objects_client/secure_saved_objects_client_wrapper.js new file mode 100644 index 00000000000000..01c41551a03442 --- /dev/null +++ b/x-pack/plugins/security/server/lib/saved_objects_client/secure_saved_objects_client_wrapper.js @@ -0,0 +1,143 @@ +/* + * 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 { get, uniq } from 'lodash'; + +export class SecureSavedObjectsClientWrapper { + constructor(options) { + const { + actions, + auditLogger, + baseClient, + checkPrivilegesWithRequest, + errors, + request, + savedObjectTypes, + spaces, + } = options; + + this.errors = errors; + this._actions = actions; + this._auditLogger = auditLogger; + this._baseClient = baseClient; + this._checkPrivileges = checkPrivilegesWithRequest(request); + this._request = request; + this._savedObjectTypes = savedObjectTypes; + this._spaces = spaces; + } + + async create(type, attributes = {}, options = {}) { + await this._ensureAuthorized( + type, + 'create', + { type, attributes, options }, + ); + + return await this._baseClient.create(type, attributes, options); + } + + async bulkCreate(objects, options = {}) { + const types = uniq(objects.map(o => o.type)); + await this._ensureAuthorized( + types, + 'bulk_create', + { objects, options }, + ); + + return await this._baseClient.bulkCreate(objects, options); + } + + async delete(type, id, options) { + await this._ensureAuthorized( + type, + 'delete', + { type, id, options }, + ); + + return await this._baseClient.delete(type, id, options); + } + + async find(options = {}) { + await this._ensureAuthorized( + options.type, + 'find', + { options } + ); + + return this._baseClient.find(options); + } + + async bulkGet(objects = [], options = {}) { + const types = uniq(objects.map(o => o.type)); + await this._ensureAuthorized( + types, + 'bulk_get', + { objects, options }, + ); + + return await this._baseClient.bulkGet(objects, options); + } + + async get(type, id, options = {}) { + await this._ensureAuthorized( + type, + 'get', + { type, id, options }, + ); + + return await this._baseClient.get(type, id, options); + } + + async update(type, id, attributes, options = {}) { + await this._ensureAuthorized( + type, + 'update', + { type, id, attributes, options }, + ); + + return await this._baseClient.update(type, id, attributes, options); + } + + async _checkSavedObjectPrivileges(actions) { + try { + if (this._spaces) { + const spaceId = this._spaces.getSpaceId(this._request); + return await this._checkPrivileges.atSpace(spaceId, actions); + } + else { + return await this._checkPrivileges.globally(actions); + } + } catch(error) { + const { reason } = get(error, 'body.error', {}); + throw this.errors.decorateGeneralError(error, reason); + } + } + + async _ensureAuthorized(typeOrTypes, action, args) { + const types = Array.isArray(typeOrTypes) ? typeOrTypes : [typeOrTypes]; + const actions = types.map(type => this._actions.getSavedObjectAction(type, action)); + const { hasAllRequested, username, privileges } = await this._checkSavedObjectPrivileges(actions); + + if (hasAllRequested) { + this._auditLogger.savedObjectsAuthorizationSuccess(username, action, types, args); + } else { + const missing = this._getMissingPrivileges(privileges); + this._auditLogger.savedObjectsAuthorizationFailure( + username, + action, + types, + missing, + args + ); + const msg = `Unable to ${action} ${[...types].sort().join(',')}, missing ${[...missing].sort().join(',')}`; + throw this.errors.decorateForbiddenError(new Error(msg)); + } + } + + _getMissingPrivileges(response) { + return Object.keys(response).filter(privilege => !response[privilege]); + } +} diff --git a/x-pack/plugins/security/server/lib/saved_objects_client/secure_saved_objects_client_wrapper.test.js b/x-pack/plugins/security/server/lib/saved_objects_client/secure_saved_objects_client_wrapper.test.js new file mode 100644 index 00000000000000..f4b3d31b8da0c4 --- /dev/null +++ b/x-pack/plugins/security/server/lib/saved_objects_client/secure_saved_objects_client_wrapper.test.js @@ -0,0 +1,2133 @@ +/* + * 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 { SecureSavedObjectsClientWrapper } from './secure_saved_objects_client_wrapper'; + +const createMockErrors = () => { + const forbiddenError = new Error('Mock ForbiddenError'); + const generalError = new Error('Mock GeneralError'); + + return { + forbiddenError, + decorateForbiddenError: jest.fn().mockReturnValue(forbiddenError), + generalError, + decorateGeneralError: jest.fn().mockReturnValue(generalError) + }; +}; + +const createMockAuditLogger = () => { + return { + savedObjectsAuthorizationFailure: jest.fn(), + savedObjectsAuthorizationSuccess: jest.fn(), + }; +}; + +const createMockActions = () => { + return { + getSavedObjectAction(type, action) { + return `mock-action:saved_objects/${type}/${action}`; + } + }; +}; + +describe('#errors', () => { + test(`assigns errors from constructor to .errors`, () => { + const errors = Symbol(); + + const client = new SecureSavedObjectsClientWrapper({ + checkPrivilegesWithRequest: () => {}, + errors + }); + + expect(client.errors).toBe(errors); + }); +}); + +describe(`spaces disabled`, () => { + describe('#create', () => { + test(`throws decorated GeneralError when checkPrivileges.globally rejects promise`, async () => { + const type = 'foo'; + const mockErrors = createMockErrors(); + const mockCheckPrivileges = { + globally: jest.fn(async () => { + throw new Error('An actual error would happen here'); + }) + }; + const mockCheckPrivilegesWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); + const mockRequest = Symbol(); + const mockAuditLogger = createMockAuditLogger(); + const mockActions = createMockActions(); + const client = new SecureSavedObjectsClientWrapper({ + actions: mockActions, + auditLogger: mockAuditLogger, + baseClient: null, + checkPrivilegesWithRequest: mockCheckPrivilegesWithRequest, + errors: mockErrors, + request: mockRequest, + savedObjectTypes: [], + spaces: null, + }); + + await expect(client.create(type)).rejects.toThrowError(mockErrors.generalError); + expect(mockCheckPrivilegesWithRequest).toHaveBeenCalledWith(mockRequest); + expect(mockCheckPrivileges.globally).toHaveBeenCalledWith([mockActions.getSavedObjectAction(type, 'create')]); + expect(mockErrors.decorateGeneralError).toHaveBeenCalledTimes(1); + expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); + expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); + }); + + test(`throws decorated ForbiddenError when unauthorized`, async () => { + const type = 'foo'; + const username = Symbol(); + const mockActions = createMockActions(); + const mockErrors = createMockErrors(); + const mockCheckPrivileges = { + globally: jest.fn(async () => ({ + hasAllRequested: false, + username, + privileges: { + [mockActions.getSavedObjectAction(type, 'create')]: false, + } + })) + }; + const mockCheckPrivilegesWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); + const mockRequest = Symbol(); + const mockAuditLogger = createMockAuditLogger(); + const client = new SecureSavedObjectsClientWrapper({ + actions: mockActions, + auditLogger: mockAuditLogger, + baseClient: null, + checkPrivilegesWithRequest: mockCheckPrivilegesWithRequest, + errors: mockErrors, + request: mockRequest, + savedObjectTypes: [], + spaces: null, + }); + const attributes = Symbol(); + const options = Symbol(); + + await expect(client.create(type, attributes, options)).rejects.toThrowError(mockErrors.forbiddenError); + + expect(mockCheckPrivilegesWithRequest).toHaveBeenCalledWith(mockRequest); + expect(mockCheckPrivileges.globally).toHaveBeenCalledWith([mockActions.getSavedObjectAction(type, 'create')]); + expect(mockErrors.decorateForbiddenError).toHaveBeenCalledTimes(1); + expect(mockAuditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( + username, + 'create', + [type], + [mockActions.getSavedObjectAction(type, 'create')], + { + type, + attributes, + options, + } + ); + expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); + }); + + test(`returns result of baseClient.create when authorized`, async () => { + const type = 'foo'; + const username = Symbol(); + const returnValue = Symbol(); + const mockActions = createMockActions(); + const mockBaseClient = { + create: jest.fn().mockReturnValue(returnValue) + }; + const mockCheckPrivileges = { + globally: jest.fn(async () => ({ + hasAllRequested: true, + username, + privileges: { + [mockActions.getSavedObjectAction(type, 'create')]: true, + } + })) + }; + const mockCheckPrivilegesWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); + const mockRequest = Symbol(); + const mockAuditLogger = createMockAuditLogger(); + const client = new SecureSavedObjectsClientWrapper({ + actions: mockActions, + auditLogger: mockAuditLogger, + baseClient: mockBaseClient, + checkPrivilegesWithRequest: mockCheckPrivilegesWithRequest, + errors: null, + request: mockRequest, + savedObjectTypes: [], + spaces: null, + }); + const attributes = Symbol(); + const options = Symbol(); + + const result = await client.create(type, attributes, options); + + expect(result).toBe(returnValue); + expect(mockCheckPrivilegesWithRequest).toHaveBeenCalledWith(mockRequest); + expect(mockCheckPrivileges.globally).toHaveBeenCalledWith([mockActions.getSavedObjectAction(type, 'create')]); + expect(mockBaseClient.create).toHaveBeenCalledWith(type, attributes, options); + expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); + expect(mockAuditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith(username, 'create', [type], { + type, + attributes, + options, + }); + }); + }); + + describe('#bulkCreate', () => { + test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { + const type = 'foo'; + const mockErrors = createMockErrors(); + const mockCheckPrivileges = { + globally: jest.fn(async () => { + throw new Error('An actual error would happen here'); + }) + }; + const mockCheckPrivilegesWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); + const mockRequest = Symbol(); + const mockAuditLogger = createMockAuditLogger(); + const mockActions = createMockActions(); + const client = new SecureSavedObjectsClientWrapper({ + actions: mockActions, + auditLogger: mockAuditLogger, + baseClient: null, + checkPrivilegesWithRequest: mockCheckPrivilegesWithRequest, + errors: mockErrors, + request: mockRequest, + savedObjectTypes: [], + spaces: null, + }); + + await expect(client.bulkCreate([{ type }])).rejects.toThrowError(mockErrors.generalError); + + expect(mockCheckPrivilegesWithRequest).toHaveBeenCalledWith(mockRequest); + expect(mockCheckPrivileges.globally).toHaveBeenCalledWith([mockActions.getSavedObjectAction(type, 'bulk_create')]); + expect(mockErrors.decorateGeneralError).toHaveBeenCalledTimes(1); + expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); + expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); + }); + + test(`throws decorated ForbiddenError when unauthorized`, async () => { + const type1 = 'foo'; + const type2 = 'bar'; + const username = Symbol(); + const mockActions = createMockActions(); + const mockErrors = createMockErrors(); + const mockCheckPrivileges = { + globally: jest.fn(async () => ({ + hasAllRequested: false, + username, + privileges: { + [mockActions.getSavedObjectAction(type1, 'bulk_create')]: false, + [mockActions.getSavedObjectAction(type2, 'bulk_create')]: true, + } + })) + }; + const mockCheckPrivilegesWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); + const mockRequest = Symbol(); + const mockAuditLogger = createMockAuditLogger(); + const client = new SecureSavedObjectsClientWrapper({ + actions: mockActions, + auditLogger: mockAuditLogger, + baseClient: null, + checkPrivilegesWithRequest: mockCheckPrivilegesWithRequest, + errors: mockErrors, + request: mockRequest, + savedObjectTypes: [], + spaces: null, + }); + const objects = [ + { type: type1 }, + { type: type1 }, + { type: type2 }, + ]; + const options = Symbol(); + + await expect(client.bulkCreate(objects, options)).rejects.toThrowError(mockErrors.forbiddenError); + + expect(mockCheckPrivilegesWithRequest).toHaveBeenCalledWith(mockRequest); + expect(mockCheckPrivileges.globally).toHaveBeenCalledWith([ + mockActions.getSavedObjectAction(type1, 'bulk_create'), + mockActions.getSavedObjectAction(type2, 'bulk_create'), + ]); + expect(mockErrors.decorateForbiddenError).toHaveBeenCalledTimes(1); + expect(mockAuditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( + username, + 'bulk_create', + [type1, type2], + [mockActions.getSavedObjectAction(type1, 'bulk_create')], + { + objects, + options, + } + ); + }); + + test(`returns result of baseClient.bulkCreate when authorized`, async () => { + const username = Symbol(); + const type1 = 'foo'; + const type2 = 'bar'; + const returnValue = Symbol(); + const mockBaseClient = { + bulkCreate: jest.fn().mockReturnValue(returnValue) + }; + const mockActions = createMockActions(); + const mockCheckPrivileges = { + globally: jest.fn(async () => ({ + hasAllRequested: true, + username, + privileges: { + [mockActions.getSavedObjectAction(type1, 'bulk_create')]: true, + [mockActions.getSavedObjectAction(type2, 'bulk_create')]: true, + } + })) + }; + const mockCheckPrivilegesWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); + const mockRequest = Symbol(); + const mockAuditLogger = createMockAuditLogger(); + const client = new SecureSavedObjectsClientWrapper({ + actions: mockActions, + auditLogger: mockAuditLogger, + baseClient: mockBaseClient, + checkPrivilegesWithRequest: mockCheckPrivilegesWithRequest, + errors: null, + request: mockRequest, + savedObjectTypes: [], + spaces: null, + }); + const objects = [ + { type: type1, otherThing: 'sup' }, + { type: type2, otherThing: 'everyone' }, + ]; + const options = Symbol(); + + const result = await client.bulkCreate(objects, options); + + expect(result).toBe(returnValue); + expect(mockCheckPrivilegesWithRequest).toHaveBeenCalledWith(mockRequest); + expect(mockCheckPrivileges.globally).toHaveBeenCalledWith([ + mockActions.getSavedObjectAction(type1, 'bulk_create'), + mockActions.getSavedObjectAction(type2, 'bulk_create'), + ]); + expect(mockBaseClient.bulkCreate).toHaveBeenCalledWith(objects, options); + expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); + expect(mockAuditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith(username, 'bulk_create', [type1, type2], { + objects, + options, + }); + }); + }); + + describe('#delete', () => { + test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { + const type = 'foo'; + const mockErrors = createMockErrors(); + const mockCheckPrivileges = { + globally: jest.fn(async () => { + throw new Error('An actual error would happen here'); + }) + }; + const mockCheckPrivilegesWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); + const mockRequest = Symbol(); + const mockAuditLogger = createMockAuditLogger(); + const mockActions = createMockActions(); + const client = new SecureSavedObjectsClientWrapper({ + actions: mockActions, + auditLogger: mockAuditLogger, + baseClient: null, + checkPrivilegesWithRequest: mockCheckPrivilegesWithRequest, + errors: mockErrors, + request: mockRequest, + savedObjectTypes: [], + spaces: null, + }); + + await expect(client.delete(type)).rejects.toThrowError(mockErrors.generalError); + + expect(mockCheckPrivilegesWithRequest).toHaveBeenCalledWith(mockRequest); + expect(mockCheckPrivileges.globally).toHaveBeenCalledWith([mockActions.getSavedObjectAction(type, 'delete')]); + expect(mockErrors.decorateGeneralError).toHaveBeenCalledTimes(1); + expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); + expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); + }); + + test(`throws decorated ForbiddenError when unauthorized`, async () => { + const type = 'foo'; + const username = Symbol(); + const mockActions = createMockActions(); + const mockErrors = createMockErrors(); + const mockCheckPrivileges = { + globally: jest.fn(async () => ({ + hasAllRequested: false, + username, + privileges: { + [mockActions.getSavedObjectAction(type, 'delete')]: false, + } + })) + }; + const mockCheckPrivilegesWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); + const mockRequest = Symbol(); + const mockAuditLogger = createMockAuditLogger(); + const client = new SecureSavedObjectsClientWrapper({ + actions: mockActions, + auditLogger: mockAuditLogger, + baseClient: null, + checkPrivilegesWithRequest: mockCheckPrivilegesWithRequest, + errors: mockErrors, + request: mockRequest, + savedObjectTypes: [], + spaces: null, + }); + const id = Symbol(); + + await expect(client.delete(type, id)).rejects.toThrowError(mockErrors.forbiddenError); + + expect(mockCheckPrivilegesWithRequest).toHaveBeenCalledWith(mockRequest); + expect(mockCheckPrivileges.globally).toHaveBeenCalledWith([mockActions.getSavedObjectAction(type, 'delete')]); + expect(mockErrors.decorateForbiddenError).toHaveBeenCalledTimes(1); + expect(mockAuditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( + username, + 'delete', + [type], + [mockActions.getSavedObjectAction(type, 'delete')], + { + type, + id, + } + ); + expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); + }); + + test(`returns result of internalRepository.delete when authorized`, async () => { + const type = 'foo'; + const username = Symbol(); + const returnValue = Symbol(); + const mockActions = createMockActions(); + const mockBaseClient = { + delete: jest.fn().mockReturnValue(returnValue) + }; + const mockCheckPrivileges = { + globally: jest.fn(async () => ({ + hasAllRequested: true, + username, + privileges: { + [mockActions.getSavedObjectAction(type, 'delete')]: true, + } + })) + }; + const mockCheckPrivilegesWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); + const mockRequest = Symbol(); + const mockAuditLogger = createMockAuditLogger(); + const client = new SecureSavedObjectsClientWrapper({ + actions: mockActions, + auditLogger: mockAuditLogger, + baseClient: mockBaseClient, + checkPrivilegesWithRequest: mockCheckPrivilegesWithRequest, + errors: null, + request: mockRequest, + savedObjectTypes: [], + spaces: null, + }); + const id = Symbol(); + const options = Symbol(); + + const result = await client.delete(type, id, options); + + expect(result).toBe(returnValue); + expect(mockCheckPrivilegesWithRequest).toHaveBeenCalledWith(mockRequest); + expect(mockCheckPrivileges.globally).toHaveBeenCalledWith([mockActions.getSavedObjectAction(type, 'delete')]); + expect(mockBaseClient.delete).toHaveBeenCalledWith(type, id, options); + expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); + expect(mockAuditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith(username, 'delete', [type], { + type, + id, + options, + }); + }); + }); + + describe('#find', () => { + test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { + const type = 'foo'; + const mockErrors = createMockErrors(); + const mockCheckPrivileges = { + globally: jest.fn(async () => { + throw new Error('An actual error would happen here'); + }) + }; + const mockCheckPrivilegesWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); + const mockRequest = Symbol(); + const mockAuditLogger = createMockAuditLogger(); + const mockActions = createMockActions(); + const client = new SecureSavedObjectsClientWrapper({ + actions: mockActions, + auditLogger: mockAuditLogger, + baseClient: null, + checkPrivilegesWithRequest: mockCheckPrivilegesWithRequest, + errors: mockErrors, + request: mockRequest, + savedObjectTypes: [], + spaces: null, + }); + + await expect(client.find({ type })).rejects.toThrowError(mockErrors.generalError); + + expect(mockCheckPrivilegesWithRequest).toHaveBeenCalledWith(mockRequest); + expect(mockCheckPrivileges.globally).toHaveBeenCalledWith([mockActions.getSavedObjectAction(type, 'find')]); + expect(mockErrors.decorateGeneralError).toHaveBeenCalledTimes(1); + expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); + expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); + }); + + test(`throws decorated ForbiddenError when type's singular and unauthorized`, async () => { + const type = 'foo'; + const username = Symbol(); + const mockActions = createMockActions(); + const mockErrors = createMockErrors(); + const mockCheckPrivileges = { + globally: jest.fn(async () => ({ + hasAllRequested: false, + username, + privileges: { + [mockActions.getSavedObjectAction(type, 'find')]: false, + } + })) + }; + const mockCheckPrivilegesWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); + const mockRequest = Symbol(); + const mockAuditLogger = createMockAuditLogger(); + const client = new SecureSavedObjectsClientWrapper({ + actions: mockActions, + auditLogger: mockAuditLogger, + baseClient: null, + checkPrivilegesWithRequest: mockCheckPrivilegesWithRequest, + errors: mockErrors, + request: mockRequest, + savedObjectTypes: [], + spaces: null, + }); + const options = { type }; + + await expect(client.find(options)).rejects.toThrowError(mockErrors.forbiddenError); + + expect(mockCheckPrivilegesWithRequest).toHaveBeenCalledWith(mockRequest); + expect(mockCheckPrivileges.globally).toHaveBeenCalledWith([mockActions.getSavedObjectAction(type, 'find')]); + expect(mockErrors.decorateForbiddenError).toHaveBeenCalledTimes(1); + expect(mockAuditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( + username, + 'find', + [type], + [mockActions.getSavedObjectAction(type, 'find')], + { + options + } + ); + expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); + }); + + test(`throws decorated ForbiddenError when type's an array and unauthorized`, async () => { + const type1 = 'foo'; + const type2 = 'bar'; + const username = Symbol(); + const mockActions = createMockActions(); + const mockErrors = createMockErrors(); + const mockCheckPrivileges = { + globally: jest.fn(async () => ({ + hasAllRequested: false, + username, + privileges: { + [mockActions.getSavedObjectAction(type1, 'find')]: false, + [mockActions.getSavedObjectAction(type2, 'find')]: true, + } + })) + }; + const mockCheckPrivilegesWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); + const mockRequest = Symbol(); + + const mockAuditLogger = createMockAuditLogger(); + const client = new SecureSavedObjectsClientWrapper({ + actions: mockActions, + auditLogger: mockAuditLogger, + baseClient: null, + checkPrivilegesWithRequest: mockCheckPrivilegesWithRequest, + errors: mockErrors, + request: mockRequest, + savedObjectTypes: [], + spaces: null, + }); + const options = { type: [type1, type2] }; + + await expect(client.find(options)).rejects.toThrowError(mockErrors.forbiddenError); + + expect(mockCheckPrivilegesWithRequest).toHaveBeenCalledWith(mockRequest); + expect(mockCheckPrivileges.globally).toHaveBeenCalledWith([ + mockActions.getSavedObjectAction(type1, 'find'), + mockActions.getSavedObjectAction(type2, 'find') + ]); + expect(mockErrors.decorateForbiddenError).toHaveBeenCalledTimes(1); + expect(mockAuditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( + username, + 'find', + [type1, type2], + [mockActions.getSavedObjectAction(type1, 'find')], + { + options + } + ); + expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); + }); + + test(`returns result of baseClient.find when authorized`, async () => { + const type = 'foo'; + const username = Symbol(); + const returnValue = Symbol(); + const mockActions = createMockActions(); + const mockBaseClient = { + find: jest.fn().mockReturnValue(returnValue) + }; + const mockCheckPrivileges = { + globally: jest.fn(async () => ({ + hasAllRequested: true, + username, + privileges: { + [mockActions.getSavedObjectAction(type, 'find')]: true, + } + })) + }; + const mockCheckPrivilegesWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); + const mockRequest = Symbol(); + const mockAuditLogger = createMockAuditLogger(); + const client = new SecureSavedObjectsClientWrapper({ + actions: mockActions, + auditLogger: mockAuditLogger, + baseClient: mockBaseClient, + checkPrivilegesWithRequest: mockCheckPrivilegesWithRequest, + errors: null, + request: mockRequest, + savedObjectTypes: [], + spaces: null, + }); + const options = { type }; + + const result = await client.find(options); + + expect(result).toBe(returnValue); + expect(mockCheckPrivilegesWithRequest).toHaveBeenCalledWith(mockRequest); + expect(mockCheckPrivileges.globally).toHaveBeenCalledWith([mockActions.getSavedObjectAction(type, 'find')]); + expect(mockBaseClient.find).toHaveBeenCalledWith({ type }); + expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); + expect(mockAuditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith(username, 'find', [type], { + options, + }); + }); + }); + + describe('#bulkGet', () => { + test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { + const type = 'foo'; + const mockErrors = createMockErrors(); + const mockCheckPrivileges = { + globally: jest.fn(async () => { + throw new Error('An actual error would happen here'); + }) + }; + const mockCheckPrivilegesWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); + const mockRequest = Symbol(); + const mockAuditLogger = createMockAuditLogger(); + const mockActions = createMockActions(); + const client = new SecureSavedObjectsClientWrapper({ + actions: mockActions, + auditLogger: mockAuditLogger, + baseClient: null, + checkPrivilegesWithRequest: mockCheckPrivilegesWithRequest, + errors: mockErrors, + request: mockRequest, + savedObjectTypes: [], + spaces: null, + }); + + await expect(client.bulkGet([{ type }])).rejects.toThrowError(mockErrors.generalError); + + expect(mockCheckPrivilegesWithRequest).toHaveBeenCalledWith(mockRequest); + expect(mockCheckPrivileges.globally).toHaveBeenCalledWith([mockActions.getSavedObjectAction(type, 'bulk_get')]); + expect(mockErrors.decorateGeneralError).toHaveBeenCalledTimes(1); + expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); + expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); + }); + + test(`throws decorated ForbiddenError when unauthorized`, async () => { + const type1 = 'foo'; + const type2 = 'bar'; + const username = Symbol(); + const mockActions = createMockActions(); + const mockErrors = createMockErrors(); + const mockCheckPrivileges = { + globally: jest.fn(async () => ({ + hasAllRequested: false, + username, + privileges: { + [mockActions.getSavedObjectAction(type1, 'bulk_get')]: false, + [mockActions.getSavedObjectAction(type2, 'bulk_get')]: true, + } + })) + }; + const mockCheckPrivilegesWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); + const mockRequest = Symbol(); + const mockAuditLogger = createMockAuditLogger(); + const client = new SecureSavedObjectsClientWrapper({ + actions: mockActions, + auditLogger: mockAuditLogger, + baseClient: null, + checkPrivilegesWithRequest: mockCheckPrivilegesWithRequest, + errors: mockErrors, + request: mockRequest, + savedObjectTypes: [], + spaces: null, + }); + const objects = [ + { type: type1 }, + { type: type1 }, + { type: type2 }, + ]; + const options = Symbol(); + + await expect(client.bulkGet(objects, options)).rejects.toThrowError(mockErrors.forbiddenError); + + expect(mockCheckPrivilegesWithRequest).toHaveBeenCalledWith(mockRequest); + expect(mockCheckPrivileges.globally).toHaveBeenCalledWith([ + mockActions.getSavedObjectAction(type1, 'bulk_get'), + mockActions.getSavedObjectAction(type2, 'bulk_get'), + ]); + expect(mockErrors.decorateForbiddenError).toHaveBeenCalledTimes(1); + expect(mockAuditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( + username, + 'bulk_get', + [type1, type2], + [mockActions.getSavedObjectAction(type1, 'bulk_get')], + { + objects, + options, + } + ); + expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); + }); + + test(`returns result of baseClient.bulkGet when authorized`, async () => { + const type1 = 'foo'; + const type2 = 'bar'; + const username = Symbol(); + const returnValue = Symbol(); + const mockActions = createMockActions(); + const mockBaseClient = { + bulkGet: jest.fn().mockReturnValue(returnValue) + }; + const mockCheckPrivileges = { + globally: jest.fn(async () => ({ + hasAllRequested: true, + username, + privileges: { + [mockActions.getSavedObjectAction(type1, 'bulk_get')]: true, + [mockActions.getSavedObjectAction(type2, 'bulk_get')]: true, + } + })) + }; + const mockCheckPrivilegesWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); + const mockRequest = Symbol(); + const mockAuditLogger = createMockAuditLogger(); + const client = new SecureSavedObjectsClientWrapper({ + actions: mockActions, + auditLogger: mockAuditLogger, + baseClient: mockBaseClient, + checkPrivilegesWithRequest: mockCheckPrivilegesWithRequest, + errors: null, + request: mockRequest, + savedObjectTypes: [], + spaces: null, + }); + const objects = [ + { type: type1, id: 'foo-id' }, + { type: type2, id: 'bar-id' }, + ]; + const options = Symbol(); + + const result = await client.bulkGet(objects, options); + + expect(result).toBe(returnValue); + expect(mockCheckPrivilegesWithRequest).toHaveBeenCalledWith(mockRequest); + expect(mockCheckPrivileges.globally).toHaveBeenCalledWith([ + mockActions.getSavedObjectAction(type1, 'bulk_get'), + mockActions.getSavedObjectAction(type2, 'bulk_get'), + ]); + expect(mockBaseClient.bulkGet).toHaveBeenCalledWith(objects, options); + expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); + expect(mockAuditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith(username, 'bulk_get', [type1, type2], { + objects, + options, + }); + }); + }); + + describe('#get', () => { + test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { + const type = 'foo'; + const mockErrors = createMockErrors(); + const mockCheckPrivileges = { + globally: jest.fn(async () => { + throw new Error('An actual error would happen here'); + }) + }; + const mockCheckPrivilegesWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); + const mockRequest = Symbol(); + const mockAuditLogger = createMockAuditLogger(); + const mockActions = createMockActions(); + const client = new SecureSavedObjectsClientWrapper({ + actions: mockActions, + auditLogger: mockAuditLogger, + baseClient: null, + checkPrivilegesWithRequest: mockCheckPrivilegesWithRequest, + errors: mockErrors, + request: mockRequest, + savedObjectTypes: [], + spaces: null, + }); + + await expect(client.get(type)).rejects.toThrowError(mockErrors.generalError); + + expect(mockCheckPrivilegesWithRequest).toHaveBeenCalledWith(mockRequest); + expect(mockCheckPrivileges.globally).toHaveBeenCalledWith([mockActions.getSavedObjectAction(type, 'get')]); + expect(mockErrors.decorateGeneralError).toHaveBeenCalledTimes(1); + expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); + expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); + }); + + test(`throws decorated ForbiddenError when unauthorized`, async () => { + const type = 'foo'; + const username = Symbol(); + const mockActions = createMockActions(); + const mockErrors = createMockErrors(); + const mockCheckPrivileges = { + globally: jest.fn(async () => ({ + hasAllRequested: false, + username, + privileges: { + [mockActions.getSavedObjectAction(type, 'get')]: false, + } + })) + }; + const mockCheckPrivilegesWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); + const mockRequest = Symbol(); + const mockAuditLogger = createMockAuditLogger(); + const client = new SecureSavedObjectsClientWrapper({ + actions: mockActions, + auditLogger: mockAuditLogger, + baseClient: null, + checkPrivilegesWithRequest: mockCheckPrivilegesWithRequest, + errors: mockErrors, + request: mockRequest, + savedObjectTypes: [], + spaces: null, + }); + const id = Symbol(); + const options = Symbol(); + + await expect(client.get(type, id, options)).rejects.toThrowError(mockErrors.forbiddenError); + + expect(mockCheckPrivilegesWithRequest).toHaveBeenCalledWith(mockRequest); + expect(mockCheckPrivileges.globally).toHaveBeenCalledWith([mockActions.getSavedObjectAction(type, 'get')]); + expect(mockErrors.decorateForbiddenError).toHaveBeenCalledTimes(1); + expect(mockAuditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( + username, + 'get', + [type], + [mockActions.getSavedObjectAction(type, 'get')], + { + type, + id, + options, + } + ); + expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); + }); + + test(`returns result of baseClient.get when authorized`, async () => { + const type = 'foo'; + const username = Symbol(); + const returnValue = Symbol(); + const mockActions = createMockActions(); + const mockBaseClient = { + get: jest.fn().mockReturnValue(returnValue) + }; + const mockCheckPrivileges = { + globally: jest.fn(async () => ({ + hasAllRequested: true, + username, + privileges: { + [mockActions.getSavedObjectAction(type, 'get')]: true, + } + })) + }; + const mockCheckPrivilegesWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); + const mockRequest = Symbol(); + const mockAuditLogger = createMockAuditLogger(); + const client = new SecureSavedObjectsClientWrapper({ + actions: mockActions, + auditLogger: mockAuditLogger, + baseClient: mockBaseClient, + checkPrivilegesWithRequest: mockCheckPrivilegesWithRequest, + errors: null, + request: mockRequest, + savedObjectTypes: [], + spaces: null, + }); + const id = Symbol(); + const options = Symbol(); + + const result = await client.get(type, id, options); + + expect(result).toBe(returnValue); + expect(mockCheckPrivilegesWithRequest).toHaveBeenCalledWith(mockRequest); + expect(mockCheckPrivileges.globally).toHaveBeenCalledWith([mockActions.getSavedObjectAction(type, 'get')]); + expect(mockBaseClient.get).toHaveBeenCalledWith(type, id, options); + expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); + expect(mockAuditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith(username, 'get', [type], { + type, + id, + options + }); + }); + }); + + describe('#update', () => { + test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { + const type = 'foo'; + const mockErrors = createMockErrors(); + const mockCheckPrivileges = { + globally: jest.fn(async () => { + throw new Error('An actual error would happen here'); + }) + }; + const mockCheckPrivilegesWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); + const mockRequest = Symbol(); + const mockAuditLogger = createMockAuditLogger(); + const mockActions = createMockActions(); + const client = new SecureSavedObjectsClientWrapper({ + actions: mockActions, + auditLogger: mockAuditLogger, + baseClient: null, + checkPrivilegesWithRequest: mockCheckPrivilegesWithRequest, + errors: mockErrors, + request: mockRequest, + savedObjectTypes: [], + spaces: null, + }); + + await expect(client.update(type)).rejects.toThrowError(mockErrors.generalError); + + expect(mockCheckPrivilegesWithRequest).toHaveBeenCalledWith(mockRequest); + expect(mockCheckPrivileges.globally).toHaveBeenCalledWith([mockActions.getSavedObjectAction(type, 'update')]); + expect(mockErrors.decorateGeneralError).toHaveBeenCalledTimes(1); + expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); + expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); + }); + + test(`throws decorated ForbiddenError when unauthorized`, async () => { + const type = 'foo'; + const username = Symbol(); + const mockActions = createMockActions(); + const mockErrors = createMockErrors(); + const mockCheckPrivileges = { + globally: jest.fn(async () => ({ + hasAllRequested: false, + username, + privileges: { + [mockActions.getSavedObjectAction(type, 'update')]: false, + } + })) + }; + const mockCheckPrivilegesWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); + const mockRequest = Symbol(); + const mockAuditLogger = createMockAuditLogger(); + const client = new SecureSavedObjectsClientWrapper({ + actions: mockActions, + auditLogger: mockAuditLogger, + baseClient: null, + checkPrivilegesWithRequest: mockCheckPrivilegesWithRequest, + errors: mockErrors, + request: mockRequest, + savedObjectTypes: [], + spaces: null, + }); + const id = Symbol(); + const attributes = Symbol(); + const options = Symbol(); + + await expect(client.update(type, id, attributes, options)).rejects.toThrowError(mockErrors.forbiddenError); + + expect(mockCheckPrivilegesWithRequest).toHaveBeenCalledWith(mockRequest); + expect(mockCheckPrivileges.globally).toHaveBeenCalledWith([mockActions.getSavedObjectAction(type, 'update')]); + expect(mockErrors.decorateForbiddenError).toHaveBeenCalledTimes(1); + expect(mockAuditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( + username, + 'update', + [type], + [mockActions.getSavedObjectAction(type, 'update')], + { + type, + id, + attributes, + options, + } + ); + expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); + }); + + test(`returns result of baseClient.update when authorized`, async () => { + const type = 'foo'; + const username = Symbol(); + const returnValue = Symbol(); + const mockActions = createMockActions(); + const mockBaseClient = { + update: jest.fn().mockReturnValue(returnValue) + }; + const mockCheckPrivileges = { + globally: jest.fn(async () => ({ + hasAllRequested: true, + username, + privileges: { + [mockActions.getSavedObjectAction(type, 'update')]: true, + } + })) + }; + const mockCheckPrivilegesWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); + const mockRequest = Symbol(); + const mockAuditLogger = createMockAuditLogger(); + const client = new SecureSavedObjectsClientWrapper({ + actions: mockActions, + auditLogger: mockAuditLogger, + baseClient: mockBaseClient, + checkPrivilegesWithRequest: mockCheckPrivilegesWithRequest, + errors: null, + request: mockRequest, + savedObjectTypes: [], + spaces: null, + }); + const id = Symbol(); + const attributes = Symbol(); + const options = Symbol(); + + const result = await client.update(type, id, attributes, options); + + expect(result).toBe(returnValue); + expect(mockCheckPrivilegesWithRequest).toHaveBeenCalledWith(mockRequest); + expect(mockCheckPrivileges.globally).toHaveBeenCalledWith([mockActions.getSavedObjectAction(type, 'update')]); + expect(mockBaseClient.update).toHaveBeenCalledWith(type, id, attributes, options); + expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); + expect(mockAuditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith(username, 'update', [type], { + type, + id, + attributes, + options, + }); + }); + }); +}); + +describe(`spaces enabled`, () => { + describe('#create', () => { + test(`throws decorated GeneralError when checkPrivileges.globally rejects promise`, async () => { + const spaceId = 'space_1'; + const type = 'foo'; + const mockErrors = createMockErrors(); + const mockCheckPrivileges = { + atSpace: jest.fn(async () => { + throw new Error('An actual error would happen here'); + }) + }; + const mockCheckPrivilegesWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); + const mockRequest = Symbol(); + const mockAuditLogger = createMockAuditLogger(); + const mockActions = createMockActions(); + const mockSpaces = { + getSpaceId: jest.fn().mockReturnValue(spaceId) + }; + const client = new SecureSavedObjectsClientWrapper({ + actions: mockActions, + auditLogger: mockAuditLogger, + baseClient: null, + checkPrivilegesWithRequest: mockCheckPrivilegesWithRequest, + errors: mockErrors, + request: mockRequest, + savedObjectTypes: [], + spaces: mockSpaces, + }); + + await expect(client.create(type)).rejects.toThrowError(mockErrors.generalError); + expect(mockSpaces.getSpaceId).toHaveBeenCalledWith(mockRequest); + expect(mockCheckPrivilegesWithRequest).toHaveBeenCalledWith(mockRequest); + expect(mockCheckPrivileges.atSpace).toHaveBeenCalledWith(spaceId, [mockActions.getSavedObjectAction(type, 'create')]); + expect(mockErrors.decorateGeneralError).toHaveBeenCalledTimes(1); + expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); + expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); + }); + + test(`throws decorated ForbiddenError when unauthorized`, async () => { + const spaceId = 'space_1'; + const type = 'foo'; + const username = Symbol(); + const mockActions = createMockActions(); + const mockErrors = createMockErrors(); + const mockCheckPrivileges = { + atSpace: jest.fn(async () => ({ + hasAllRequested: false, + username, + privileges: { + [mockActions.getSavedObjectAction(type, 'create')]: false, + } + })) + }; + const mockCheckPrivilegesWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); + const mockRequest = Symbol(); + const mockAuditLogger = createMockAuditLogger(); + const mockSpaces = { + getSpaceId: jest.fn().mockReturnValue(spaceId) + }; + const client = new SecureSavedObjectsClientWrapper({ + actions: mockActions, + auditLogger: mockAuditLogger, + baseClient: null, + checkPrivilegesWithRequest: mockCheckPrivilegesWithRequest, + errors: mockErrors, + request: mockRequest, + savedObjectTypes: [], + spaces: mockSpaces, + }); + const attributes = Symbol(); + const options = Symbol(); + + await expect(client.create(type, attributes, options)).rejects.toThrowError(mockErrors.forbiddenError); + + expect(mockSpaces.getSpaceId).toHaveBeenCalledWith(mockRequest); + expect(mockCheckPrivilegesWithRequest).toHaveBeenCalledWith(mockRequest); + expect(mockCheckPrivileges.atSpace).toHaveBeenCalledWith(spaceId, [mockActions.getSavedObjectAction(type, 'create')]); + expect(mockErrors.decorateForbiddenError).toHaveBeenCalledTimes(1); + expect(mockAuditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( + username, + 'create', + [type], + [mockActions.getSavedObjectAction(type, 'create')], + { + type, + attributes, + options, + } + ); + expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); + }); + + test(`returns result of baseClient.create when authorized`, async () => { + const spaceId = 'space_1'; + const type = 'foo'; + const username = Symbol(); + const returnValue = Symbol(); + const mockActions = createMockActions(); + const mockBaseClient = { + create: jest.fn().mockReturnValue(returnValue) + }; + const mockCheckPrivileges = { + atSpace: jest.fn(async () => ({ + hasAllRequested: true, + username, + privileges: { + [mockActions.getSavedObjectAction(type, 'create')]: true, + } + })) + }; + const mockCheckPrivilegesWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); + const mockRequest = Symbol(); + const mockAuditLogger = createMockAuditLogger(); + const mockSpaces = { + getSpaceId: jest.fn().mockReturnValue(spaceId) + }; + const client = new SecureSavedObjectsClientWrapper({ + actions: mockActions, + auditLogger: mockAuditLogger, + baseClient: mockBaseClient, + checkPrivilegesWithRequest: mockCheckPrivilegesWithRequest, + errors: null, + request: mockRequest, + savedObjectTypes: [], + spaces: mockSpaces, + }); + const attributes = Symbol(); + const options = Symbol(); + + const result = await client.create(type, attributes, options); + + expect(result).toBe(returnValue); + expect(mockSpaces.getSpaceId).toHaveBeenCalledWith(mockRequest); + expect(mockCheckPrivilegesWithRequest).toHaveBeenCalledWith(mockRequest); + expect(mockCheckPrivileges.atSpace).toHaveBeenCalledWith(spaceId, [mockActions.getSavedObjectAction(type, 'create')]); + expect(mockBaseClient.create).toHaveBeenCalledWith(type, attributes, options); + expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); + expect(mockAuditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith(username, 'create', [type], { + type, + attributes, + options, + }); + }); + }); + + describe('#bulkCreate', () => { + test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { + const spaceId = 'space_1'; + const type = 'foo'; + const mockErrors = createMockErrors(); + const mockCheckPrivileges = { + atSpace: jest.fn(async () => { + throw new Error('An actual error would happen here'); + }) + }; + const mockCheckPrivilegesWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); + const mockRequest = Symbol(); + const mockAuditLogger = createMockAuditLogger(); + const mockActions = createMockActions(); + const mockSpaces = { + getSpaceId: jest.fn().mockReturnValue(spaceId) + }; + const client = new SecureSavedObjectsClientWrapper({ + actions: mockActions, + auditLogger: mockAuditLogger, + baseClient: null, + checkPrivilegesWithRequest: mockCheckPrivilegesWithRequest, + errors: mockErrors, + request: mockRequest, + savedObjectTypes: [], + spaces: mockSpaces, + }); + + await expect(client.bulkCreate([{ type }])).rejects.toThrowError(mockErrors.generalError); + + expect(mockSpaces.getSpaceId).toHaveBeenCalledWith(mockRequest); + expect(mockCheckPrivilegesWithRequest).toHaveBeenCalledWith(mockRequest); + expect(mockCheckPrivileges.atSpace).toHaveBeenCalledWith(spaceId, [mockActions.getSavedObjectAction(type, 'bulk_create')]); + expect(mockErrors.decorateGeneralError).toHaveBeenCalledTimes(1); + expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); + expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); + }); + + test(`throws decorated ForbiddenError when unauthorized`, async () => { + const spaceId = 'space_1'; + const type1 = 'foo'; + const type2 = 'bar'; + const username = Symbol(); + const mockActions = createMockActions(); + const mockErrors = createMockErrors(); + const mockCheckPrivileges = { + atSpace: jest.fn(async () => ({ + hasAllRequested: false, + username, + privileges: { + [mockActions.getSavedObjectAction(type1, 'bulk_create')]: false, + [mockActions.getSavedObjectAction(type2, 'bulk_create')]: true, + } + })) + }; + const mockCheckPrivilegesWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); + const mockRequest = Symbol(); + const mockAuditLogger = createMockAuditLogger(); + const mockSpaces = { + getSpaceId: jest.fn().mockReturnValue(spaceId) + }; + const client = new SecureSavedObjectsClientWrapper({ + actions: mockActions, + auditLogger: mockAuditLogger, + baseClient: null, + checkPrivilegesWithRequest: mockCheckPrivilegesWithRequest, + errors: mockErrors, + request: mockRequest, + savedObjectTypes: [], + spaces: mockSpaces, + }); + const objects = [ + { type: type1 }, + { type: type1 }, + { type: type2 }, + ]; + const options = Symbol(); + + await expect(client.bulkCreate(objects, options)).rejects.toThrowError(mockErrors.forbiddenError); + + expect(mockSpaces.getSpaceId).toHaveBeenCalledWith(mockRequest); + expect(mockCheckPrivilegesWithRequest).toHaveBeenCalledWith(mockRequest); + expect(mockCheckPrivileges.atSpace).toHaveBeenCalledWith(spaceId, [ + mockActions.getSavedObjectAction(type1, 'bulk_create'), + mockActions.getSavedObjectAction(type2, 'bulk_create'), + ]); + expect(mockErrors.decorateForbiddenError).toHaveBeenCalledTimes(1); + expect(mockAuditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( + username, + 'bulk_create', + [type1, type2], + [mockActions.getSavedObjectAction(type1, 'bulk_create')], + { + objects, + options, + } + ); + }); + + test(`returns result of baseClient.bulkCreate when authorized`, async () => { + const spaceId = 'space_1'; + const username = Symbol(); + const type1 = 'foo'; + const type2 = 'bar'; + const returnValue = Symbol(); + const mockBaseClient = { + bulkCreate: jest.fn().mockReturnValue(returnValue) + }; + const mockActions = createMockActions(); + const mockCheckPrivileges = { + atSpace: jest.fn(async () => ({ + hasAllRequested: true, + username, + privileges: { + [mockActions.getSavedObjectAction(type1, 'bulk_create')]: true, + [mockActions.getSavedObjectAction(type2, 'bulk_create')]: true, + } + })) + }; + const mockCheckPrivilegesWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); + const mockRequest = Symbol(); + const mockAuditLogger = createMockAuditLogger(); + const mockSpaces = { + getSpaceId: jest.fn().mockReturnValue(spaceId) + }; + const client = new SecureSavedObjectsClientWrapper({ + actions: mockActions, + auditLogger: mockAuditLogger, + baseClient: mockBaseClient, + checkPrivilegesWithRequest: mockCheckPrivilegesWithRequest, + errors: null, + request: mockRequest, + savedObjectTypes: [], + spaces: mockSpaces, + }); + const objects = [ + { type: type1, otherThing: 'sup' }, + { type: type2, otherThing: 'everyone' }, + ]; + const options = Symbol(); + + const result = await client.bulkCreate(objects, options); + + expect(result).toBe(returnValue); + expect(mockSpaces.getSpaceId).toHaveBeenCalledWith(mockRequest); + expect(mockCheckPrivilegesWithRequest).toHaveBeenCalledWith(mockRequest); + expect(mockCheckPrivileges.atSpace).toHaveBeenCalledWith(spaceId, [ + mockActions.getSavedObjectAction(type1, 'bulk_create'), + mockActions.getSavedObjectAction(type2, 'bulk_create'), + ]); + expect(mockBaseClient.bulkCreate).toHaveBeenCalledWith(objects, options); + expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); + expect(mockAuditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith(username, 'bulk_create', [type1, type2], { + objects, + options, + }); + }); + }); + + describe('#delete', () => { + test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { + const spaceId = 'space_1'; + const type = 'foo'; + const mockErrors = createMockErrors(); + const mockCheckPrivileges = { + atSpace: jest.fn(async () => { + throw new Error('An actual error would happen here'); + }) + }; + const mockCheckPrivilegesWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); + const mockRequest = Symbol(); + const mockAuditLogger = createMockAuditLogger(); + const mockActions = createMockActions(); + const mockSpaces = { + getSpaceId: jest.fn().mockReturnValue(spaceId) + }; + const client = new SecureSavedObjectsClientWrapper({ + actions: mockActions, + auditLogger: mockAuditLogger, + baseClient: null, + checkPrivilegesWithRequest: mockCheckPrivilegesWithRequest, + errors: mockErrors, + request: mockRequest, + savedObjectTypes: [], + spaces: mockSpaces, + }); + + await expect(client.delete(type)).rejects.toThrowError(mockErrors.generalError); + + expect(mockSpaces.getSpaceId).toHaveBeenCalledWith(mockRequest); + expect(mockCheckPrivilegesWithRequest).toHaveBeenCalledWith(mockRequest); + expect(mockCheckPrivileges.atSpace).toHaveBeenCalledWith(spaceId, [mockActions.getSavedObjectAction(type, 'delete')]); + expect(mockErrors.decorateGeneralError).toHaveBeenCalledTimes(1); + expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); + expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); + }); + + test(`throws decorated ForbiddenError when unauthorized`, async () => { + const spaceId = 'space_1'; + const type = 'foo'; + const username = Symbol(); + const mockActions = createMockActions(); + const mockErrors = createMockErrors(); + const mockCheckPrivileges = { + atSpace: jest.fn(async () => ({ + hasAllRequested: false, + username, + privileges: { + [mockActions.getSavedObjectAction(type, 'delete')]: false, + } + })) + }; + const mockCheckPrivilegesWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); + const mockRequest = Symbol(); + const mockAuditLogger = createMockAuditLogger(); + const mockSpaces = { + getSpaceId: jest.fn().mockReturnValue(spaceId) + }; + const client = new SecureSavedObjectsClientWrapper({ + actions: mockActions, + auditLogger: mockAuditLogger, + baseClient: null, + checkPrivilegesWithRequest: mockCheckPrivilegesWithRequest, + errors: mockErrors, + request: mockRequest, + savedObjectTypes: [], + spaces: mockSpaces, + }); + const id = Symbol(); + + await expect(client.delete(type, id)).rejects.toThrowError(mockErrors.forbiddenError); + + expect(mockSpaces.getSpaceId).toHaveBeenCalledWith(mockRequest); + expect(mockCheckPrivilegesWithRequest).toHaveBeenCalledWith(mockRequest); + expect(mockCheckPrivileges.atSpace).toHaveBeenCalledWith(spaceId, [mockActions.getSavedObjectAction(type, 'delete')]); + expect(mockErrors.decorateForbiddenError).toHaveBeenCalledTimes(1); + expect(mockAuditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( + username, + 'delete', + [type], + [mockActions.getSavedObjectAction(type, 'delete')], + { + type, + id, + } + ); + expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); + }); + + test(`returns result of internalRepository.delete when authorized`, async () => { + const spaceId = 'space_1'; + const type = 'foo'; + const username = Symbol(); + const returnValue = Symbol(); + const mockActions = createMockActions(); + const mockBaseClient = { + delete: jest.fn().mockReturnValue(returnValue) + }; + const mockCheckPrivileges = { + atSpace: jest.fn(async () => ({ + hasAllRequested: true, + username, + privileges: { + [mockActions.getSavedObjectAction(type, 'delete')]: true, + } + })) + }; + const mockCheckPrivilegesWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); + const mockRequest = Symbol(); + const mockAuditLogger = createMockAuditLogger(); + const mockSpaces = { + getSpaceId: jest.fn().mockReturnValue(spaceId) + }; + const client = new SecureSavedObjectsClientWrapper({ + actions: mockActions, + auditLogger: mockAuditLogger, + baseClient: mockBaseClient, + checkPrivilegesWithRequest: mockCheckPrivilegesWithRequest, + errors: null, + request: mockRequest, + savedObjectTypes: [], + spaces: mockSpaces, + }); + const id = Symbol(); + const options = Symbol(); + + const result = await client.delete(type, id, options); + + expect(result).toBe(returnValue); + expect(mockSpaces.getSpaceId).toHaveBeenCalledWith(mockRequest); + expect(mockCheckPrivilegesWithRequest).toHaveBeenCalledWith(mockRequest); + expect(mockCheckPrivileges.atSpace).toHaveBeenCalledWith(spaceId, [mockActions.getSavedObjectAction(type, 'delete')]); + expect(mockBaseClient.delete).toHaveBeenCalledWith(type, id, options); + expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); + expect(mockAuditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith(username, 'delete', [type], { + type, + id, + options, + }); + }); + }); + + describe('#find', () => { + test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { + const spaceId = 'space_1'; + const type = 'foo'; + const mockErrors = createMockErrors(); + const mockCheckPrivileges = { + atSpace: jest.fn(async () => { + throw new Error('An actual error would happen here'); + }) + }; + const mockCheckPrivilegesWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); + const mockRequest = Symbol(); + const mockAuditLogger = createMockAuditLogger(); + const mockActions = createMockActions(); + const mockSpaces = { + getSpaceId: jest.fn().mockReturnValue(spaceId) + }; + const client = new SecureSavedObjectsClientWrapper({ + actions: mockActions, + auditLogger: mockAuditLogger, + baseClient: null, + checkPrivilegesWithRequest: mockCheckPrivilegesWithRequest, + errors: mockErrors, + request: mockRequest, + savedObjectTypes: [], + spaces: mockSpaces, + }); + + await expect(client.find({ type })).rejects.toThrowError(mockErrors.generalError); + + expect(mockSpaces.getSpaceId).toHaveBeenCalledWith(mockRequest); + expect(mockCheckPrivilegesWithRequest).toHaveBeenCalledWith(mockRequest); + expect(mockCheckPrivileges.atSpace).toHaveBeenCalledWith(spaceId, [mockActions.getSavedObjectAction(type, 'find')]); + expect(mockErrors.decorateGeneralError).toHaveBeenCalledTimes(1); + expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); + expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); + }); + + test(`throws decorated ForbiddenError when type's singular and unauthorized`, async () => { + const spaceId = 'space_1'; + const type = 'foo'; + const username = Symbol(); + const mockActions = createMockActions(); + const mockErrors = createMockErrors(); + const mockCheckPrivileges = { + atSpace: jest.fn(async () => ({ + hasAllRequested: false, + username, + privileges: { + [mockActions.getSavedObjectAction(type, 'find')]: false, + } + })) + }; + const mockCheckPrivilegesWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); + const mockRequest = Symbol(); + const mockAuditLogger = createMockAuditLogger(); + const mockSpaces = { + getSpaceId: jest.fn().mockReturnValue(spaceId) + }; + const client = new SecureSavedObjectsClientWrapper({ + actions: mockActions, + auditLogger: mockAuditLogger, + baseClient: null, + checkPrivilegesWithRequest: mockCheckPrivilegesWithRequest, + errors: mockErrors, + request: mockRequest, + savedObjectTypes: [], + spaces: mockSpaces, + }); + const options = { type }; + + await expect(client.find(options)).rejects.toThrowError(mockErrors.forbiddenError); + + expect(mockSpaces.getSpaceId).toHaveBeenCalledWith(mockRequest); + expect(mockCheckPrivilegesWithRequest).toHaveBeenCalledWith(mockRequest); + expect(mockCheckPrivileges.atSpace).toHaveBeenCalledWith(spaceId, [mockActions.getSavedObjectAction(type, 'find')]); + expect(mockErrors.decorateForbiddenError).toHaveBeenCalledTimes(1); + expect(mockAuditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( + username, + 'find', + [type], + [mockActions.getSavedObjectAction(type, 'find')], + { + options + } + ); + expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); + }); + + test(`throws decorated ForbiddenError when type's an array and unauthorized`, async () => { + const spaceId = 'space_1'; + const type1 = 'foo'; + const type2 = 'bar'; + const username = Symbol(); + const mockActions = createMockActions(); + const mockErrors = createMockErrors(); + const mockCheckPrivileges = { + atSpace: jest.fn(async () => ({ + hasAllRequested: false, + username, + privileges: { + [mockActions.getSavedObjectAction(type1, 'find')]: false, + [mockActions.getSavedObjectAction(type2, 'find')]: true, + } + })) + }; + const mockCheckPrivilegesWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); + const mockRequest = Symbol(); + + const mockAuditLogger = createMockAuditLogger(); + const mockSpaces = { + getSpaceId: jest.fn().mockReturnValue(spaceId) + }; + const client = new SecureSavedObjectsClientWrapper({ + actions: mockActions, + auditLogger: mockAuditLogger, + baseClient: null, + checkPrivilegesWithRequest: mockCheckPrivilegesWithRequest, + errors: mockErrors, + request: mockRequest, + savedObjectTypes: [], + spaces: mockSpaces, + }); + const options = { type: [type1, type2] }; + + await expect(client.find(options)).rejects.toThrowError(mockErrors.forbiddenError); + + expect(mockSpaces.getSpaceId).toHaveBeenCalledWith(mockRequest); + expect(mockCheckPrivilegesWithRequest).toHaveBeenCalledWith(mockRequest); + expect(mockCheckPrivileges.atSpace).toHaveBeenCalledWith(spaceId, [ + mockActions.getSavedObjectAction(type1, 'find'), + mockActions.getSavedObjectAction(type2, 'find') + ]); + expect(mockErrors.decorateForbiddenError).toHaveBeenCalledTimes(1); + expect(mockAuditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( + username, + 'find', + [type1, type2], + [mockActions.getSavedObjectAction(type1, 'find')], + { + options + } + ); + expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); + }); + + test(`returns result of baseClient.find when authorized`, async () => { + const spaceId = 'space_1'; + const type = 'foo'; + const username = Symbol(); + const returnValue = Symbol(); + const mockActions = createMockActions(); + const mockBaseClient = { + find: jest.fn().mockReturnValue(returnValue) + }; + const mockCheckPrivileges = { + atSpace: jest.fn(async () => ({ + hasAllRequested: true, + username, + privileges: { + [mockActions.getSavedObjectAction(type, 'find')]: true, + } + })) + }; + const mockCheckPrivilegesWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); + const mockRequest = Symbol(); + const mockAuditLogger = createMockAuditLogger(); + const mockSpaces = { + getSpaceId: jest.fn().mockReturnValue(spaceId) + }; + const client = new SecureSavedObjectsClientWrapper({ + actions: mockActions, + auditLogger: mockAuditLogger, + baseClient: mockBaseClient, + checkPrivilegesWithRequest: mockCheckPrivilegesWithRequest, + errors: null, + request: mockRequest, + savedObjectTypes: [], + spaces: mockSpaces, + }); + const options = { type }; + + const result = await client.find(options); + + expect(result).toBe(returnValue); + expect(mockSpaces.getSpaceId).toHaveBeenCalledWith(mockRequest); + expect(mockCheckPrivilegesWithRequest).toHaveBeenCalledWith(mockRequest); + expect(mockCheckPrivileges.atSpace).toHaveBeenCalledWith(spaceId, [mockActions.getSavedObjectAction(type, 'find')]); + expect(mockBaseClient.find).toHaveBeenCalledWith({ type }); + expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); + expect(mockAuditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith(username, 'find', [type], { + options, + }); + }); + }); + + describe('#bulkGet', () => { + test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { + const spaceId = 'space_1'; + const type = 'foo'; + const mockErrors = createMockErrors(); + const mockCheckPrivileges = { + atSpace: jest.fn(async () => { + throw new Error('An actual error would happen here'); + }) + }; + const mockCheckPrivilegesWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); + const mockRequest = Symbol(); + const mockAuditLogger = createMockAuditLogger(); + const mockActions = createMockActions(); + const mockSpaces = { + getSpaceId: jest.fn().mockReturnValue(spaceId) + }; + const client = new SecureSavedObjectsClientWrapper({ + actions: mockActions, + auditLogger: mockAuditLogger, + baseClient: null, + checkPrivilegesWithRequest: mockCheckPrivilegesWithRequest, + errors: mockErrors, + request: mockRequest, + savedObjectTypes: [], + spaces: mockSpaces, + }); + + await expect(client.bulkGet([{ type }])).rejects.toThrowError(mockErrors.generalError); + + expect(mockSpaces.getSpaceId).toHaveBeenCalledWith(mockRequest); + expect(mockCheckPrivilegesWithRequest).toHaveBeenCalledWith(mockRequest); + expect(mockCheckPrivileges.atSpace).toHaveBeenCalledWith(spaceId, [mockActions.getSavedObjectAction(type, 'bulk_get')]); + expect(mockErrors.decorateGeneralError).toHaveBeenCalledTimes(1); + expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); + expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); + }); + + test(`throws decorated ForbiddenError when unauthorized`, async () => { + const spaceId = 'space_1'; + const type1 = 'foo'; + const type2 = 'bar'; + const username = Symbol(); + const mockActions = createMockActions(); + const mockErrors = createMockErrors(); + const mockCheckPrivileges = { + atSpace: jest.fn(async () => ({ + hasAllRequested: false, + username, + privileges: { + [mockActions.getSavedObjectAction(type1, 'bulk_get')]: false, + [mockActions.getSavedObjectAction(type2, 'bulk_get')]: true, + } + })) + }; + const mockCheckPrivilegesWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); + const mockRequest = Symbol(); + const mockAuditLogger = createMockAuditLogger(); + const mockSpaces = { + getSpaceId: jest.fn().mockReturnValue(spaceId) + }; + const client = new SecureSavedObjectsClientWrapper({ + actions: mockActions, + auditLogger: mockAuditLogger, + baseClient: null, + checkPrivilegesWithRequest: mockCheckPrivilegesWithRequest, + errors: mockErrors, + request: mockRequest, + savedObjectTypes: [], + spaces: mockSpaces, + }); + const objects = [ + { type: type1 }, + { type: type1 }, + { type: type2 }, + ]; + const options = Symbol(); + + await expect(client.bulkGet(objects, options)).rejects.toThrowError(mockErrors.forbiddenError); + + expect(mockSpaces.getSpaceId).toHaveBeenCalledWith(mockRequest); + expect(mockCheckPrivilegesWithRequest).toHaveBeenCalledWith(mockRequest); + expect(mockCheckPrivileges.atSpace).toHaveBeenCalledWith(spaceId, [ + mockActions.getSavedObjectAction(type1, 'bulk_get'), + mockActions.getSavedObjectAction(type2, 'bulk_get'), + ]); + expect(mockErrors.decorateForbiddenError).toHaveBeenCalledTimes(1); + expect(mockAuditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( + username, + 'bulk_get', + [type1, type2], + [mockActions.getSavedObjectAction(type1, 'bulk_get')], + { + objects, + options, + } + ); + expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); + }); + + test(`returns result of baseClient.bulkGet when authorized`, async () => { + const spaceId = 'space_1'; + const type1 = 'foo'; + const type2 = 'bar'; + const username = Symbol(); + const returnValue = Symbol(); + const mockActions = createMockActions(); + const mockBaseClient = { + bulkGet: jest.fn().mockReturnValue(returnValue) + }; + const mockCheckPrivileges = { + atSpace: jest.fn(async () => ({ + hasAllRequested: true, + username, + privileges: { + [mockActions.getSavedObjectAction(type1, 'bulk_get')]: true, + [mockActions.getSavedObjectAction(type2, 'bulk_get')]: true, + } + })) + }; + const mockCheckPrivilegesWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); + const mockRequest = Symbol(); + const mockAuditLogger = createMockAuditLogger(); + const mockSpaces = { + getSpaceId: jest.fn().mockReturnValue(spaceId) + }; + const client = new SecureSavedObjectsClientWrapper({ + actions: mockActions, + auditLogger: mockAuditLogger, + baseClient: mockBaseClient, + checkPrivilegesWithRequest: mockCheckPrivilegesWithRequest, + errors: null, + request: mockRequest, + savedObjectTypes: [], + spaces: mockSpaces, + }); + const objects = [ + { type: type1, id: 'foo-id' }, + { type: type2, id: 'bar-id' }, + ]; + const options = Symbol(); + + const result = await client.bulkGet(objects, options); + + expect(result).toBe(returnValue); + expect(mockSpaces.getSpaceId).toHaveBeenCalledWith(mockRequest); + expect(mockCheckPrivilegesWithRequest).toHaveBeenCalledWith(mockRequest); + expect(mockCheckPrivileges.atSpace).toHaveBeenCalledWith(spaceId, [ + mockActions.getSavedObjectAction(type1, 'bulk_get'), + mockActions.getSavedObjectAction(type2, 'bulk_get'), + ]); + expect(mockBaseClient.bulkGet).toHaveBeenCalledWith(objects, options); + expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); + expect(mockAuditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith(username, 'bulk_get', [type1, type2], { + objects, + options, + }); + }); + }); + + describe('#get', () => { + test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { + const spaceId = 'space_1'; + const type = 'foo'; + const mockErrors = createMockErrors(); + const mockCheckPrivileges = { + atSpace: jest.fn(async () => { + throw new Error('An actual error would happen here'); + }) + }; + const mockCheckPrivilegesWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); + const mockRequest = Symbol(); + const mockAuditLogger = createMockAuditLogger(); + const mockActions = createMockActions(); + const mockSpaces = { + getSpaceId: jest.fn().mockReturnValue(spaceId) + }; + const client = new SecureSavedObjectsClientWrapper({ + actions: mockActions, + auditLogger: mockAuditLogger, + baseClient: null, + checkPrivilegesWithRequest: mockCheckPrivilegesWithRequest, + errors: mockErrors, + request: mockRequest, + savedObjectTypes: [], + spaces: mockSpaces, + }); + + await expect(client.get(type)).rejects.toThrowError(mockErrors.generalError); + + expect(mockSpaces.getSpaceId).toHaveBeenCalledWith(mockRequest); + expect(mockCheckPrivilegesWithRequest).toHaveBeenCalledWith(mockRequest); + expect(mockCheckPrivileges.atSpace).toHaveBeenCalledWith(spaceId, [mockActions.getSavedObjectAction(type, 'get')]); + expect(mockErrors.decorateGeneralError).toHaveBeenCalledTimes(1); + expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); + expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); + }); + + test(`throws decorated ForbiddenError when unauthorized`, async () => { + const spaceId = 'space_1'; + const type = 'foo'; + const username = Symbol(); + const mockActions = createMockActions(); + const mockErrors = createMockErrors(); + const mockCheckPrivileges = { + atSpace: jest.fn(async () => ({ + hasAllRequested: false, + username, + privileges: { + [mockActions.getSavedObjectAction(type, 'get')]: false, + } + })) + }; + const mockCheckPrivilegesWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); + const mockRequest = Symbol(); + const mockAuditLogger = createMockAuditLogger(); + const mockSpaces = { + getSpaceId: jest.fn().mockReturnValue(spaceId) + }; + const client = new SecureSavedObjectsClientWrapper({ + actions: mockActions, + auditLogger: mockAuditLogger, + baseClient: null, + checkPrivilegesWithRequest: mockCheckPrivilegesWithRequest, + errors: mockErrors, + request: mockRequest, + savedObjectTypes: [], + spaces: mockSpaces, + }); + const id = Symbol(); + const options = Symbol(); + + await expect(client.get(type, id, options)).rejects.toThrowError(mockErrors.forbiddenError); + + expect(mockSpaces.getSpaceId).toHaveBeenCalledWith(mockRequest); + expect(mockCheckPrivilegesWithRequest).toHaveBeenCalledWith(mockRequest); + expect(mockCheckPrivileges.atSpace).toHaveBeenCalledWith(spaceId, [mockActions.getSavedObjectAction(type, 'get')]); + expect(mockErrors.decorateForbiddenError).toHaveBeenCalledTimes(1); + expect(mockAuditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( + username, + 'get', + [type], + [mockActions.getSavedObjectAction(type, 'get')], + { + type, + id, + options, + } + ); + expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); + }); + + test(`returns result of baseClient.get when authorized`, async () => { + const spaceId = 'space_1'; + const type = 'foo'; + const username = Symbol(); + const returnValue = Symbol(); + const mockActions = createMockActions(); + const mockBaseClient = { + get: jest.fn().mockReturnValue(returnValue) + }; + const mockCheckPrivileges = { + atSpace: jest.fn(async () => ({ + hasAllRequested: true, + username, + privileges: { + [mockActions.getSavedObjectAction(type, 'get')]: true, + } + })) + }; + const mockCheckPrivilegesWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); + const mockRequest = Symbol(); + const mockAuditLogger = createMockAuditLogger(); + const mockSpaces = { + getSpaceId: jest.fn().mockReturnValue(spaceId) + }; + const client = new SecureSavedObjectsClientWrapper({ + actions: mockActions, + auditLogger: mockAuditLogger, + baseClient: mockBaseClient, + checkPrivilegesWithRequest: mockCheckPrivilegesWithRequest, + errors: null, + request: mockRequest, + savedObjectTypes: [], + spaces: mockSpaces, + }); + const id = Symbol(); + const options = Symbol(); + + const result = await client.get(type, id, options); + + expect(result).toBe(returnValue); + expect(mockSpaces.getSpaceId).toHaveBeenCalledWith(mockRequest); + expect(mockCheckPrivilegesWithRequest).toHaveBeenCalledWith(mockRequest); + expect(mockCheckPrivileges.atSpace).toHaveBeenCalledWith(spaceId, [mockActions.getSavedObjectAction(type, 'get')]); + expect(mockBaseClient.get).toHaveBeenCalledWith(type, id, options); + expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); + expect(mockAuditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith(username, 'get', [type], { + type, + id, + options + }); + }); + }); + + describe('#update', () => { + test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { + const spaceId = 'space_1'; + const type = 'foo'; + const mockErrors = createMockErrors(); + const mockCheckPrivileges = { + atSpace: jest.fn(async () => { + throw new Error('An actual error would happen here'); + }) + }; + const mockCheckPrivilegesWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); + const mockRequest = Symbol(); + const mockAuditLogger = createMockAuditLogger(); + const mockActions = createMockActions(); + const mockSpaces = { + getSpaceId: jest.fn().mockReturnValue(spaceId) + }; + const client = new SecureSavedObjectsClientWrapper({ + actions: mockActions, + auditLogger: mockAuditLogger, + baseClient: null, + checkPrivilegesWithRequest: mockCheckPrivilegesWithRequest, + errors: mockErrors, + request: mockRequest, + savedObjectTypes: [], + spaces: mockSpaces, + }); + + await expect(client.update(type)).rejects.toThrowError(mockErrors.generalError); + + expect(mockSpaces.getSpaceId).toHaveBeenCalledWith(mockRequest); + expect(mockCheckPrivilegesWithRequest).toHaveBeenCalledWith(mockRequest); + expect(mockCheckPrivileges.atSpace).toHaveBeenCalledWith(spaceId, [mockActions.getSavedObjectAction(type, 'update')]); + expect(mockErrors.decorateGeneralError).toHaveBeenCalledTimes(1); + expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); + expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); + }); + + test(`throws decorated ForbiddenError when unauthorized`, async () => { + const spaceId = 'space_1'; + const type = 'foo'; + const username = Symbol(); + const mockActions = createMockActions(); + const mockErrors = createMockErrors(); + const mockCheckPrivileges = { + atSpace: jest.fn(async () => ({ + hasAllRequested: false, + username, + privileges: { + [mockActions.getSavedObjectAction(type, 'update')]: false, + } + })) + }; + const mockCheckPrivilegesWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); + const mockRequest = Symbol(); + const mockAuditLogger = createMockAuditLogger(); + const mockSpaces = { + getSpaceId: jest.fn().mockReturnValue(spaceId) + }; + const client = new SecureSavedObjectsClientWrapper({ + actions: mockActions, + auditLogger: mockAuditLogger, + baseClient: null, + checkPrivilegesWithRequest: mockCheckPrivilegesWithRequest, + errors: mockErrors, + request: mockRequest, + savedObjectTypes: [], + spaces: mockSpaces, + }); + const id = Symbol(); + const attributes = Symbol(); + const options = Symbol(); + + await expect(client.update(type, id, attributes, options)).rejects.toThrowError(mockErrors.forbiddenError); + + expect(mockSpaces.getSpaceId).toHaveBeenCalledWith(mockRequest); + expect(mockCheckPrivilegesWithRequest).toHaveBeenCalledWith(mockRequest); + expect(mockCheckPrivileges.atSpace).toHaveBeenCalledWith(spaceId, [mockActions.getSavedObjectAction(type, 'update')]); + expect(mockErrors.decorateForbiddenError).toHaveBeenCalledTimes(1); + expect(mockAuditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( + username, + 'update', + [type], + [mockActions.getSavedObjectAction(type, 'update')], + { + type, + id, + attributes, + options, + } + ); + expect(mockAuditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); + }); + + test(`returns result of baseClient.update when authorized`, async () => { + const spaceId = 'space_1'; + const type = 'foo'; + const username = Symbol(); + const returnValue = Symbol(); + const mockActions = createMockActions(); + const mockBaseClient = { + update: jest.fn().mockReturnValue(returnValue) + }; + const mockCheckPrivileges = { + atSpace: jest.fn(async () => ({ + hasAllRequested: true, + username, + privileges: { + [mockActions.getSavedObjectAction(type, 'update')]: true, + } + })) + }; + const mockCheckPrivilegesWithRequest = jest.fn().mockReturnValue(mockCheckPrivileges); + const mockRequest = Symbol(); + const mockAuditLogger = createMockAuditLogger(); + const mockSpaces = { + getSpaceId: jest.fn().mockReturnValue(spaceId) + }; + const client = new SecureSavedObjectsClientWrapper({ + actions: mockActions, + auditLogger: mockAuditLogger, + baseClient: mockBaseClient, + checkPrivilegesWithRequest: mockCheckPrivilegesWithRequest, + errors: null, + request: mockRequest, + savedObjectTypes: [], + spaces: mockSpaces, + }); + const id = Symbol(); + const attributes = Symbol(); + const options = Symbol(); + + const result = await client.update(type, id, attributes, options); + + expect(result).toBe(returnValue); + expect(mockSpaces.getSpaceId).toHaveBeenCalledWith(mockRequest); + expect(mockCheckPrivilegesWithRequest).toHaveBeenCalledWith(mockRequest); + expect(mockCheckPrivileges.atSpace).toHaveBeenCalledWith(spaceId, [mockActions.getSavedObjectAction(type, 'update')]); + expect(mockBaseClient.update).toHaveBeenCalledWith(type, id, attributes, options); + expect(mockAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); + expect(mockAuditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith(username, 'update', [type], { + type, + id, + attributes, + options, + }); + }); + }); +}); diff --git a/x-pack/plugins/security/server/routes/api/public/roles/get.js b/x-pack/plugins/security/server/routes/api/public/roles/get.js index 9ae89a97c36f1a..797a76110d21af 100644 --- a/x-pack/plugins/security/server/routes/api/public/roles/get.js +++ b/x-pack/plugins/security/server/routes/api/public/roles/get.js @@ -5,17 +5,36 @@ */ import _ from 'lodash'; import Boom from 'boom'; -import { ALL_RESOURCE } from '../../../../../common/constants'; +import { GLOBAL_RESOURCE } from '../../../../../common/constants'; import { wrapError } from '../../../../lib/errors'; +import { spaceApplicationPrivilegesSerializer } from '../../../../lib/authorization'; export function initGetRolesApi(server, callWithRequest, routePreCheckLicenseFn, application) { const transformKibanaApplicationsFromEs = (roleApplications) => { - return roleApplications - .filter(roleApplication => roleApplication.application === application) - .filter(roleApplication => roleApplication.resources.length > 0) - .filter(roleApplication => roleApplication.resources.every(resource => resource === ALL_RESOURCE)) - .map(roleApplication => ({ privileges: roleApplication.privileges })); + const roleKibanaApplications = roleApplications + .filter(roleApplication => roleApplication.application === application); + + const resourcePrivileges = _.flatten(roleKibanaApplications + .map(({ resources, privileges }) => resources.map(resource => ({ resource, privileges }))) + ); + + return resourcePrivileges.reduce((result, { resource, privileges }) => { + if (resource === GLOBAL_RESOURCE) { + result.global = _.uniq([...result.global, ...privileges]); + return result; + } + + const spaceId = spaceApplicationPrivilegesSerializer.resource.deserialize(resource); + result.space[spaceId] = _.uniq([ + ...result.space[spaceId] || [], + ...privileges.map(privilege => spaceApplicationPrivilegesSerializer.privilege.deserialize(privilege)) + ]); + return result; + }, { + global: [], + space: {}, + }); }; const transformUnrecognizedApplicationsFromEs = (roleApplications) => { @@ -46,13 +65,13 @@ export function initGetRolesApi(server, callWithRequest, routePreCheckLicenseFn, server.route({ method: 'GET', path: '/api/security/role', - handler(request, reply) { - return callWithRequest(request, 'shield.getRole').then( - (response) => { - return reply(transformRolesFromEs(response)); - }, - _.flow(wrapError, reply) - ); + async handler(request, reply) { + try { + const response = await callWithRequest(request, 'shield.getRole'); + return reply(transformRolesFromEs(response)); + } catch (error) { + reply(wrapError(error)); + } }, config: { pre: [routePreCheckLicenseFn] @@ -62,14 +81,18 @@ export function initGetRolesApi(server, callWithRequest, routePreCheckLicenseFn, server.route({ method: 'GET', path: '/api/security/role/{name}', - handler(request, reply) { + async handler(request, reply) { const name = request.params.name; - return callWithRequest(request, 'shield.getRole', { name }).then( - (response) => { - if (response[name]) return reply(transformRoleFromEs(response[name], name)); - return reply(Boom.notFound()); - }, - _.flow(wrapError, reply)); + try { + const response = await callWithRequest(request, 'shield.getRole', { name }); + if (response[name]) { + return reply(transformRoleFromEs(response[name], name)); + } + + return reply(Boom.notFound()); + } catch (error) { + reply(wrapError(error)); + } }, config: { pre: [routePreCheckLicenseFn] diff --git a/x-pack/plugins/security/server/routes/api/public/roles/get.test.js b/x-pack/plugins/security/server/routes/api/public/roles/get.test.js index a8dd3a38bdeb91..28f754248d829e 100644 --- a/x-pack/plugins/security/server/routes/api/public/roles/get.test.js +++ b/x-pack/plugins/security/server/routes/api/public/roles/get.test.js @@ -88,6 +88,37 @@ describe('GET roles', () => { }, }, }); + + getRolesTest(`throws error if resource isn't * and doesn't have the space: prefix`, { + callWithRequestImpl: async () => ({ + first_role: { + cluster: [], + indices: [], + applications: [ + { + application, + privileges: ['read'], + resources: ['default'], + }, + ], + run_as: [], + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + }, + }), + asserts: { + statusCode: 500, + result: { + error: 'Internal Server Error', + message: 'An internal server error occurred', + statusCode: 500 + } + }, + }); }); describe('success', () => { @@ -132,14 +163,17 @@ describe('GET roles', () => { ], run_as: ['other_user'], }, - kibana: [], + kibana: { + global: [], + space: {}, + }, _unrecognized_applications: [], }, ], }, }); - getRolesTest(`transforms matching applications to kibana privileges`, { + getRolesTest(`transforms matching applications with * resource to kibana global privileges`, { callWithRequestImpl: async () => ({ first_role: { cluster: [], @@ -181,21 +215,17 @@ describe('GET roles', () => { indices: [], run_as: [], }, - kibana: [ - { - privileges: ['read'], - }, - { - privileges: ['all'], - }, - ], + kibana: { + global: ['read', 'all'], + space: {}, + }, _unrecognized_applications: [], }, ], }, }); - getRolesTest(`excludes resources other than * from kibana privileges`, { + getRolesTest(`transforms matching applications with space resources to kibana space privileges`, { callWithRequestImpl: async () => ({ first_role: { cluster: [], @@ -203,19 +233,68 @@ describe('GET roles', () => { applications: [ { application, - privileges: ['read'], - // Elasticsearch should prevent this from happening - resources: [], + privileges: ['space_read'], + resources: ['space:marketing'], }, { application, - privileges: ['read'], - resources: ['default', '*'], + privileges: ['space_all'], + resources: ['space:marketing'], + }, + { + application, + privileges: ['space_read'], + resources: ['space:engineering'], + }, + ], + run_as: [], + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + }, + }), + asserts: { + statusCode: 200, + result: [ + { + name: 'first_role', + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + elasticsearch: { + cluster: [], + indices: [], + run_as: [], }, + kibana: { + global: [], + space: { + marketing: ['read', 'all'], + engineering: ['read'], + } + }, + _unrecognized_applications: [], + }, + ], + }, + }); + + getRolesTest(`ignores empty resources even though this shouldn't happen`, { + callWithRequestImpl: async () => ({ + first_role: { + cluster: [], + indices: [], + applications: [ { application, privileges: ['read'], - resources: ['some-other-space'], + resources: [], }, ], run_as: [], @@ -243,7 +322,10 @@ describe('GET roles', () => { indices: [], run_as: [], }, - kibana: [], + kibana: { + global: [], + space: {} + }, _unrecognized_applications: [], }, ], @@ -287,7 +369,10 @@ describe('GET roles', () => { indices: [], run_as: [], }, - kibana: [], + kibana: { + global: [], + space: {}, + }, _unrecognized_applications: ['kibana-.another-kibana'] }, ], @@ -372,6 +457,38 @@ describe('GET role', () => { }, }, }); + + getRoleTest(`throws error if resource isn't * and doesn't have the space: prefix`, { + name: 'first_role', + callWithRequestImpl: async () => ({ + first_role: { + cluster: [], + indices: [], + applications: [ + { + application, + privileges: ['read'], + resources: ['default'], + }, + ], + run_as: [], + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + }, + }), + asserts: { + statusCode: 500, + result: { + error: 'Internal Server Error', + message: 'An internal server error occurred', + statusCode: 500 + } + }, + }); }); describe('success', () => { @@ -416,13 +533,16 @@ describe('GET role', () => { ], run_as: ['other_user'], }, - kibana: [], + kibana: { + global: [], + space: {}, + }, _unrecognized_applications: [], }, }, }); - getRoleTest(`transforms matching applications to kibana privileges`, { + getRoleTest(`transforms matching applications with * resource to kibana global privileges`, { name: 'first_role', callWithRequestImpl: async () => ({ first_role: { @@ -464,20 +584,16 @@ describe('GET role', () => { indices: [], run_as: [], }, - kibana: [ - { - privileges: ['read'], - }, - { - privileges: ['all'], - }, - ], + kibana: { + global: ['read', 'all'], + space: {}, + }, _unrecognized_applications: [], }, }, }); - getRoleTest(`excludes resources other than * from kibana privileges`, { + getRoleTest(`transforms matching applications with space resource to kibana space privileges`, { name: 'first_role', callWithRequestImpl: async () => ({ first_role: { @@ -486,19 +602,67 @@ describe('GET role', () => { applications: [ { application, - privileges: ['read'], - // Elasticsearch should prevent this from happening - resources: [], + privileges: ['space_read'], + resources: ['space:marketing'], }, { application, - privileges: ['read'], - resources: ['default', '*'], + privileges: ['space_all'], + resources: ['space:marketing'], }, + { + application, + privileges: ['space_read'], + resources: ['space:engineering'], + }, + ], + run_as: [], + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + }, + }), + asserts: { + statusCode: 200, + result: { + name: 'first_role', + metadata: { + _reserved: true, + }, + transient_metadata: { + enabled: true, + }, + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: { + global: [], + space: { + marketing: ['read', 'all'], + engineering: ['read'] + }, + }, + _unrecognized_applications: [], + }, + }, + }); + + getRoleTest(`ignores empty resources even though this shouldn't happen`, { + name: 'first_role', + callWithRequestImpl: async () => ({ + first_role: { + cluster: [], + indices: [], + applications: [ { application, privileges: ['read'], - resources: ['some-other-space'], + resources: [], }, ], run_as: [], @@ -525,7 +689,10 @@ describe('GET role', () => { indices: [], run_as: [], }, - kibana: [], + kibana: { + global: [], + space: {}, + }, _unrecognized_applications: [], }, }, @@ -568,7 +735,10 @@ describe('GET role', () => { indices: [], run_as: [], }, - kibana: [], + kibana: { + global: [], + space: {}, + }, _unrecognized_applications: ['kibana-.another-kibana'], }, }, diff --git a/x-pack/plugins/security/server/routes/api/public/roles/index.js b/x-pack/plugins/security/server/routes/api/public/roles/index.js index 5425af0a1202d3..8bdde88123ee4f 100644 --- a/x-pack/plugins/security/server/routes/api/public/roles/index.js +++ b/x-pack/plugins/security/server/routes/api/public/roles/index.js @@ -17,7 +17,7 @@ export function initPublicRolesApi(server) { const { application, actions } = server.plugins.security.authorization; const savedObjectTypes = server.savedObjects.types; - const privilegeMap = buildPrivilegeMap(savedObjectTypes, application, actions); + const privilegeMap = buildPrivilegeMap(savedObjectTypes, actions); initGetRolesApi(server, callWithRequest, routePreCheckLicenseFn, application); initPutRolesApi(server, callWithRequest, routePreCheckLicenseFn, privilegeMap, application); diff --git a/x-pack/plugins/security/server/routes/api/public/roles/put.js b/x-pack/plugins/security/server/routes/api/public/roles/put.js index 123ce128e15099..0152453a10a26d 100644 --- a/x-pack/plugins/security/server/routes/api/public/roles/put.js +++ b/x-pack/plugins/security/server/routes/api/public/roles/put.js @@ -6,40 +6,9 @@ import { pick, identity } from 'lodash'; import Joi from 'joi'; -import { ALL_RESOURCE } from '../../../../../common/constants'; +import { GLOBAL_RESOURCE } from '../../../../../common/constants'; import { wrapError } from '../../../../lib/errors'; - -const transformKibanaPrivilegeToEs = (application, kibanaPrivilege) => { - return { - privileges: kibanaPrivilege.privileges, - application, - resources: [ALL_RESOURCE], - }; -}; - -const transformRolesToEs = ( - application, - payload, - existingApplications = [] -) => { - const { elasticsearch = {}, kibana = [] } = payload; - const otherApplications = existingApplications.filter( - roleApplication => roleApplication.application !== application - ); - - return pick({ - metadata: payload.metadata, - cluster: elasticsearch.cluster || [], - indices: elasticsearch.indices || [], - run_as: elasticsearch.run_as || [], - applications: [ - ...kibana.map(kibanaPrivilege => - transformKibanaPrivilegeToEs(application, kibanaPrivilege) - ), - ...otherApplications, - ], - }, identity); -}; +import { spaceApplicationPrivilegesSerializer } from '../../../../lib/authorization'; export function initPutRolesApi( server, @@ -49,6 +18,50 @@ export function initPutRolesApi( application ) { + const transformKibanaPrivilegesToEs = (kibanaPrivileges) => { + const kibanaApplicationPrivileges = []; + if (kibanaPrivileges.global && kibanaPrivileges.global.length) { + kibanaApplicationPrivileges.push({ + privileges: kibanaPrivileges.global, + application, + resources: [GLOBAL_RESOURCE], + }); + } + + if (kibanaPrivileges.space) { + for(const [spaceId, privileges] of Object.entries(kibanaPrivileges.space)) { + kibanaApplicationPrivileges.push({ + privileges: privileges.map(privilege => spaceApplicationPrivilegesSerializer.privilege.serialize(privilege)), + application, + resources: [spaceApplicationPrivilegesSerializer.resource.serialize(spaceId)] + }); + } + } + + return kibanaApplicationPrivileges; + }; + + const transformRolesToEs = ( + payload, + existingApplications = [] + ) => { + const { elasticsearch = {}, kibana = {} } = payload; + const otherApplications = existingApplications.filter( + roleApplication => roleApplication.application !== application + ); + + return pick({ + metadata: payload.metadata, + cluster: elasticsearch.cluster || [], + indices: elasticsearch.indices || [], + run_as: elasticsearch.run_as || [], + applications: [ + ...transformKibanaPrivilegesToEs(kibana), + ...otherApplications, + ], + }, identity); + }; + const schema = Joi.object().keys({ metadata: Joi.object().optional(), elasticsearch: Joi.object().keys({ @@ -64,9 +77,10 @@ export function initPutRolesApi( }), run_as: Joi.array().items(Joi.string()), }), - kibana: Joi.array().items({ - privileges: Joi.array().items(Joi.string().valid(Object.keys(privilegeMap))), - }), + kibana: Joi.object().keys({ + global: Joi.array().items(Joi.string().valid(Object.keys(privilegeMap.global))), + space: Joi.object().pattern(/^[a-z0-9_-]+$/, Joi.array().items(Joi.string().valid(Object.keys(privilegeMap.space)))) + }) }); server.route({ @@ -81,7 +95,6 @@ export function initPutRolesApi( }); const body = transformRolesToEs( - application, request.payload, existingRoleResponse[name] ? existingRoleResponse[name].applications : [] ); diff --git a/x-pack/plugins/security/server/routes/api/public/roles/put.test.js b/x-pack/plugins/security/server/routes/api/public/roles/put.test.js index d6c32ce00ac563..ffa7b247f99d2b 100644 --- a/x-pack/plugins/security/server/routes/api/public/roles/put.test.js +++ b/x-pack/plugins/security/server/routes/api/public/roles/put.test.js @@ -7,7 +7,7 @@ import Hapi from 'hapi'; import Boom from 'boom'; import { initPutRolesApi } from './put'; -import { ALL_RESOURCE } from '../../../../../common/constants'; +import { GLOBAL_RESOURCE } from '../../../../../common/constants'; const application = 'kibana-.kibana'; @@ -20,9 +20,16 @@ const createMockServer = () => { const defaultPreCheckLicenseImpl = (request, reply) => reply(); const privilegeMap = { - 'test-kibana-privilege-1': {}, - 'test-kibana-privilege-2': {}, - 'test-kibana-privilege-3': {}, + global: { + 'test-global-privilege-1': [], + 'test-global-privilege-2': [], + 'test-global-privilege-3': [], + }, + space: { + 'test-space-privilege-1': [], + 'test-space-privilege-2': [], + 'test-space-privilege-3': [], + } }; const putRoleTest = ( @@ -112,24 +119,92 @@ describe('PUT role', () => { }, }); - putRoleTest(`only allows known Kibana privileges`, { + putRoleTest(`only allows known Kibana global privileges`, { name: 'foo-role', payload: { - kibana: [ - { - privileges: ['foo'] + kibana: { + global: ['foo'] + } + }, + asserts: { + statusCode: 400, + result: { + error: 'Bad Request', + //eslint-disable-next-line max-len + message: `child \"kibana\" fails because [child \"global\" fails because [\"global\" at position 0 fails because [\"0\" must be one of [test-global-privilege-1, test-global-privilege-2, test-global-privilege-3]]]]`, + statusCode: 400, + validation: { + keys: ['kibana.global.0'], + source: 'payload', + }, + }, + }, + }); + + putRoleTest(`only allows known Kibana space privileges`, { + name: 'foo-role', + payload: { + kibana: { + space: { + quz: ['foo'] } - ] + } }, asserts: { statusCode: 400, result: { error: 'Bad Request', //eslint-disable-next-line max-len - message: `child "kibana" fails because ["kibana" at position 0 fails because [child "privileges" fails because ["privileges" at position 0 fails because ["0" must be one of [test-kibana-privilege-1, test-kibana-privilege-2, test-kibana-privilege-3]]]]]`, + message: `child \"kibana\" fails because [child \"space\" fails because [child \"quz\" fails because [\"quz\" at position 0 fails because [\"0\" must be one of [test-space-privilege-1, test-space-privilege-2, test-space-privilege-3]]]]]`, + statusCode: 400, + validation: { + keys: ['kibana.space.quz.0'], + source: 'payload', + }, + }, + }, + }); + + putRoleTest(`doesn't allow * space ID`, { + name: 'foo-role', + payload: { + kibana: { + space: { + '*': ['test-space-privilege-1'] + } + } + }, + asserts: { + statusCode: 400, + result: { + error: 'Bad Request', + message: `child \"kibana\" fails because [child \"space\" fails because [\"*\" is not allowed]]`, statusCode: 400, validation: { - keys: ['kibana.0.privileges.0'], + keys: ['kibana.space.*'], + source: 'payload', + }, + }, + }, + }); + + putRoleTest(`doesn't allow * in a space ID`, { + name: 'foo-role', + payload: { + kibana: { + space: { + 'foo-*': ['test-space-privilege-1'] + } + } + }, + asserts: { + statusCode: 400, + result: { + error: 'Bad Request', + message: `child \"kibana\" fails because [child \"space\" fails because [\"foo-*\" is not allowed]]`, + statusCode: 400, + validation: { + keys: ['kibana.space.foo-*'], source: 'payload', }, }, @@ -157,7 +232,7 @@ describe('PUT role', () => { name: 'foo-role', payload: {}, preCheckLicenseImpl: defaultPreCheckLicenseImpl, - callWithRequestImpls: [async () => ({}), async () => {}], + callWithRequestImpls: [async () => ({}), async () => { }], asserts: { callWithRequests: [ ['shield.getRole', { name: 'foo-role', ignore: [404] }], @@ -191,7 +266,7 @@ describe('PUT role', () => { { field_security: { grant: ['test-field-security-grant-1', 'test-field-security-grant-2'], - except: [ 'test-field-security-except-1', 'test-field-security-except-2' ] + except: ['test-field-security-except-1', 'test-field-security-except-2'] }, names: ['test-index-name-1', 'test-index-name-2'], privileges: ['test-index-privilege-1', 'test-index-privilege-2'], @@ -200,17 +275,16 @@ describe('PUT role', () => { ], run_as: ['test-run-as-1', 'test-run-as-2'], }, - kibana: [ - { - privileges: ['test-kibana-privilege-1', 'test-kibana-privilege-2'], - }, - { - privileges: ['test-kibana-privilege-3'], - }, - ], + kibana: { + global: ['test-global-privilege-1', 'test-global-privilege-2', 'test-global-privilege-3'], + space: { + 'test-space-1': ['test-space-privilege-1', 'test-space-privilege-2'], + 'test-space-2': ['test-space-privilege-3'], + } + }, }, preCheckLicenseImpl: defaultPreCheckLicenseImpl, - callWithRequestImpls: [async () => ({}), async () => {}], + callWithRequestImpls: [async () => ({}), async () => { }], asserts: { callWithRequests: [ ['shield.getRole', { name: 'foo-role', ignore: [404] }], @@ -223,15 +297,26 @@ describe('PUT role', () => { { application, privileges: [ - 'test-kibana-privilege-1', - 'test-kibana-privilege-2', + 'test-global-privilege-1', + 'test-global-privilege-2', + 'test-global-privilege-3' ], - resources: [ALL_RESOURCE], + resources: [GLOBAL_RESOURCE], }, { application, - privileges: ['test-kibana-privilege-3'], - resources: [ALL_RESOURCE], + privileges: [ + 'space_test-space-privilege-1', + 'space_test-space-privilege-2' + ], + resources: ['space:test-space-1'] + }, + { + application, + privileges: [ + 'space_test-space-privilege-3', + ], + resources: ['space:test-space-2'] }, ], cluster: ['test-cluster-privilege'], @@ -239,7 +324,7 @@ describe('PUT role', () => { { field_security: { grant: ['test-field-security-grant-1', 'test-field-security-grant-2'], - except: [ 'test-field-security-except-1', 'test-field-security-except-2' ] + except: ['test-field-security-except-1', 'test-field-security-except-2'] }, names: ['test-index-name-1', 'test-index-name-2'], privileges: [ @@ -272,7 +357,7 @@ describe('PUT role', () => { { field_security: { grant: ['test-field-security-grant-1', 'test-field-security-grant-2'], - except: [ 'test-field-security-except-1', 'test-field-security-except-2' ] + except: ['test-field-security-except-1', 'test-field-security-except-2'] }, names: ['test-index-name-1', 'test-index-name-2'], privileges: ['test-index-privilege-1', 'test-index-privilege-2'], @@ -281,14 +366,13 @@ describe('PUT role', () => { ], run_as: ['test-run-as-1', 'test-run-as-2'], }, - kibana: [ - { - privileges: ['test-kibana-privilege-1', 'test-kibana-privilege-2'], - }, - { - privileges: ['test-kibana-privilege-3'], - }, - ], + kibana: { + global: ['test-global-privilege-1', 'test-global-privilege-2', 'test-global-privilege-3'], + space: { + 'test-space-1': ['test-space-privilege-1', 'test-space-privilege-2'], + 'test-space-2': ['test-space-privilege-3'], + } + }, }, preCheckLicenseImpl: defaultPreCheckLicenseImpl, callWithRequestImpls: [ @@ -305,7 +389,7 @@ describe('PUT role', () => { { field_security: { grant: ['old-field-security-grant-1', 'old-field-security-grant-2'], - except: [ 'old-field-security-except-1', 'old-field-security-except-2' ] + except: ['old-field-security-except-1', 'old-field-security-except-2'] }, names: ['old-index-name'], privileges: ['old-privilege'], @@ -322,7 +406,7 @@ describe('PUT role', () => { ], }, }), - async () => {}, + async () => { }, ], asserts: { callWithRequests: [ @@ -336,15 +420,26 @@ describe('PUT role', () => { { application, privileges: [ - 'test-kibana-privilege-1', - 'test-kibana-privilege-2', + 'test-global-privilege-1', + 'test-global-privilege-2', + 'test-global-privilege-3' + ], + resources: [GLOBAL_RESOURCE], + }, + { + application, + privileges: [ + 'space_test-space-privilege-1', + 'space_test-space-privilege-2' ], - resources: [ALL_RESOURCE], + resources: ['space:test-space-1'] }, { application, - privileges: ['test-kibana-privilege-3'], - resources: [ALL_RESOURCE], + privileges: [ + 'space_test-space-privilege-3', + ], + resources: ['space:test-space-2'] }, ], cluster: ['test-cluster-privilege'], @@ -352,7 +447,7 @@ describe('PUT role', () => { { field_security: { grant: ['test-field-security-grant-1', 'test-field-security-grant-2'], - except: [ 'test-field-security-except-1', 'test-field-security-except-2' ] + except: ['test-field-security-except-1', 'test-field-security-except-2'] }, names: ['test-index-name-1', 'test-index-name-2'], privileges: [ @@ -394,17 +489,13 @@ describe('PUT role', () => { ], run_as: ['test-run-as-1', 'test-run-as-2'], }, - kibana: [ - { - privileges: [ - 'test-kibana-privilege-1', - 'test-kibana-privilege-2', - ], - }, - { - privileges: ['test-kibana-privilege-3'], - }, - ], + kibana: { + global: [ + 'test-global-privilege-1', + 'test-global-privilege-2', + 'test-global-privilege-3' + ], + }, }, preCheckLicenseImpl: defaultPreCheckLicenseImpl, callWithRequestImpls: [ @@ -443,7 +534,7 @@ describe('PUT role', () => { ], }, }), - async () => {}, + async () => { }, ], asserts: { callWithRequests: [ @@ -457,15 +548,11 @@ describe('PUT role', () => { { application, privileges: [ - 'test-kibana-privilege-1', - 'test-kibana-privilege-2', + 'test-global-privilege-1', + 'test-global-privilege-2', + 'test-global-privilege-3' ], - resources: [ALL_RESOURCE], - }, - { - application, - privileges: ['test-kibana-privilege-3'], - resources: [ALL_RESOURCE], + resources: [GLOBAL_RESOURCE], }, { application: 'logstash-foo', diff --git a/x-pack/plugins/security/server/routes/api/v1/__tests__/authenticate.js b/x-pack/plugins/security/server/routes/api/v1/__tests__/authenticate.js index 4139569e51b345..155fc041f24a55 100644 --- a/x-pack/plugins/security/server/routes/api/v1/__tests__/authenticate.js +++ b/x-pack/plugins/security/server/routes/api/v1/__tests__/authenticate.js @@ -15,7 +15,6 @@ import { AuthenticationResult } from '../../../../../server/lib/authentication/a import { BasicCredentials } from '../../../../../server/lib/authentication/providers/basic'; import { initAuthenticateApi } from '../authenticate'; import { DeauthenticationResult } from '../../../../lib/authentication/deauthentication_result'; -import { CHECK_PRIVILEGES_RESULT } from '../../../../lib/authorization'; describe('Authentication routes', () => { let serverStub; @@ -34,7 +33,7 @@ describe('Authentication routes', () => { let loginRoute; let request; let authenticateStub; - let checkPrivilegesWithRequestStub; + let authorizationModeStub; beforeEach(() => { loginRoute = serverStub.route @@ -50,7 +49,7 @@ describe('Authentication routes', () => { authenticateStub = serverStub.plugins.security.authenticate.withArgs( sinon.match(BasicCredentials.decorateRequest({ headers: {} }, 'user', 'password')) ); - checkPrivilegesWithRequestStub = serverStub.plugins.security.authorization.checkPrivilegesWithRequest; + authorizationModeStub = serverStub.plugins.security.authorization.mode; }); it('correctly defines route.', async () => { @@ -134,59 +133,37 @@ describe('Authentication routes', () => { const getDeprecationMessage = username => `${username} relies on index privileges on the Kibana index. This is deprecated and will be removed in Kibana 7.0`; - it(`returns user data and doesn't log deprecation warning if checkPrivileges result is authorized.`, async () => { + it(`returns user data and doesn't log deprecation warning if authorization.mode.useRbacForRequest returns true.`, async () => { const user = { username: 'user' }; authenticateStub.returns( Promise.resolve(AuthenticationResult.succeeded(user)) ); - const checkPrivilegesStub = sinon.stub().returns({ result: CHECK_PRIVILEGES_RESULT.AUTHORIZED }); - checkPrivilegesWithRequestStub.returns(checkPrivilegesStub); + authorizationModeStub.useRbacForRequest.returns(true); await loginRoute.handler(request, replyStub); - sinon.assert.calledWithExactly(checkPrivilegesWithRequestStub, request); - sinon.assert.calledWithExactly(checkPrivilegesStub, [serverStub.plugins.security.authorization.actions.login]); + sinon.assert.calledWithExactly(authorizationModeStub.useRbacForRequest, request); sinon.assert.neverCalledWith(serverStub.log, ['warning', 'deprecated', 'security'], getDeprecationMessage(user.username)); sinon.assert.notCalled(replyStub); sinon.assert.calledOnce(replyStub.continue); sinon.assert.calledWithExactly(replyStub.continue, { credentials: user }); }); - it(`returns user data and logs deprecation warning if checkPrivileges result is legacy.`, async () => { + it(`returns user data and logs deprecation warning if authorization.mode.useRbacForRequest returns false.`, async () => { const user = { username: 'user' }; authenticateStub.returns( Promise.resolve(AuthenticationResult.succeeded(user)) ); - const checkPrivilegesStub = sinon.stub().returns({ result: CHECK_PRIVILEGES_RESULT.LEGACY }); - checkPrivilegesWithRequestStub.returns(checkPrivilegesStub); + authorizationModeStub.useRbacForRequest.returns(false); await loginRoute.handler(request, replyStub); - sinon.assert.calledWithExactly(checkPrivilegesWithRequestStub, request); - sinon.assert.calledWithExactly(checkPrivilegesStub, [serverStub.plugins.security.authorization.actions.login]); + sinon.assert.calledWithExactly(authorizationModeStub.useRbacForRequest, request); sinon.assert.calledWith(serverStub.log, ['warning', 'deprecated', 'security'], getDeprecationMessage(user.username)); sinon.assert.notCalled(replyStub); sinon.assert.calledOnce(replyStub.continue); sinon.assert.calledWithExactly(replyStub.continue, { credentials: user }); }); - - it(`returns user data and doesn't log deprecation warning if checkPrivileges result is unauthorized.`, async () => { - const user = { username: 'user' }; - authenticateStub.returns( - Promise.resolve(AuthenticationResult.succeeded(user)) - ); - const checkPrivilegesStub = sinon.stub().returns({ result: CHECK_PRIVILEGES_RESULT.UNAUTHORIZED }); - checkPrivilegesWithRequestStub.returns(checkPrivilegesStub); - - await loginRoute.handler(request, replyStub); - - sinon.assert.calledWithExactly(checkPrivilegesWithRequestStub, request); - sinon.assert.calledWithExactly(checkPrivilegesStub, [serverStub.plugins.security.authorization.actions.login]); - sinon.assert.neverCalledWith(serverStub.log, ['warning', 'deprecated', 'security'], getDeprecationMessage(user.username)); - sinon.assert.notCalled(replyStub); - sinon.assert.calledOnce(replyStub.continue); - sinon.assert.calledWithExactly(replyStub.continue, { credentials: user }); - }); }); }); diff --git a/x-pack/plugins/security/server/routes/api/v1/authenticate.js b/x-pack/plugins/security/server/routes/api/v1/authenticate.js index e92a0a2a9536c9..c791def49e9d25 100644 --- a/x-pack/plugins/security/server/routes/api/v1/authenticate.js +++ b/x-pack/plugins/security/server/routes/api/v1/authenticate.js @@ -9,7 +9,6 @@ import Joi from 'joi'; import { wrapError } from '../../../lib/errors'; import { BasicCredentials } from '../../../../server/lib/authentication/providers/basic'; import { canRedirectRequest } from '../../../lib/can_redirect_request'; -import { CHECK_PRIVILEGES_RESULT } from '../../../../server/lib/authorization'; export function initAuthenticateApi(server) { @@ -41,9 +40,7 @@ export function initAuthenticateApi(server) { } const { authorization } = server.plugins.security; - const checkPrivileges = authorization.checkPrivilegesWithRequest(request); - const privilegeCheck = await checkPrivileges([authorization.actions.login]); - if (privilegeCheck.result === CHECK_PRIVILEGES_RESULT.LEGACY) { + if (!authorization.mode.useRbacForRequest(request)) { const msg = `${username} relies on index privileges on the Kibana index. This is deprecated and will be removed in Kibana 7.0`; server.log(['warning', 'deprecated', 'security'], msg); } diff --git a/x-pack/plugins/security/server/routes/api/v1/privileges.js b/x-pack/plugins/security/server/routes/api/v1/privileges.js deleted file mode 100644 index 4bf1b2c5cc7a5e..00000000000000 --- a/x-pack/plugins/security/server/routes/api/v1/privileges.js +++ /dev/null @@ -1,25 +0,0 @@ -/* - * 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 { buildPrivilegeMap } from '../../../lib/authorization'; - -export function initPrivilegesApi(server) { - const { authorization } = server.plugins.security; - const savedObjectTypes = server.savedObjects.types; - - server.route({ - method: 'GET', - path: '/api/security/v1/privileges', - handler(request, reply) { - // we're returning our representation of the privileges, as opposed to the ones that are stored - // in Elasticsearch because our current thinking is that we'll associate additional structure/metadata - // with our view of them to allow users to more efficiently edit privileges for roles, and serialize it - // into a different structure for enforcement within Elasticsearch - const privileges = buildPrivilegeMap(savedObjectTypes, authorization.application, authorization.actions); - reply(Object.values(privileges)); - } - }); -} diff --git a/x-pack/plugins/spaces/common/constants.ts b/x-pack/plugins/spaces/common/constants.ts new file mode 100644 index 00000000000000..50423517bc9184 --- /dev/null +++ b/x-pack/plugins/spaces/common/constants.ts @@ -0,0 +1,23 @@ +/* + * 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 const DEFAULT_SPACE_ID = `default`; + +/** + * The minimum number of spaces required to show a search control. + */ +export const SPACE_SEARCH_COUNT_THRESHOLD = 8; + +/** + * The maximum number of characters allowed in the Space Avatar's initials + */ +export const MAX_SPACE_INITIALS = 2; + +/** + * The type name used within the Monitoring index to publish spaces stats. + * @type {string} + */ +export const KIBANA_SPACES_STATS_TYPE = 'spaces'; diff --git a/x-pack/plugins/spaces/common/index.ts b/x-pack/plugins/spaces/common/index.ts new file mode 100644 index 00000000000000..0e605562ea3ea4 --- /dev/null +++ b/x-pack/plugins/spaces/common/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { isReservedSpace } from './is_reserved_space'; +export { MAX_SPACE_INITIALS } from './constants'; + +export { getSpaceInitials, getSpaceColor } from './space_attributes'; diff --git a/x-pack/plugins/spaces/common/is_reserved_space.test.ts b/x-pack/plugins/spaces/common/is_reserved_space.test.ts new file mode 100644 index 00000000000000..7c0bfb74b86eb6 --- /dev/null +++ b/x-pack/plugins/spaces/common/is_reserved_space.test.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 { isReservedSpace } from './is_reserved_space'; +import { Space } from './model/space'; + +test('it returns true for reserved spaces', () => { + const space: Space = { + id: '', + name: '', + _reserved: true, + }; + + expect(isReservedSpace(space)).toEqual(true); +}); + +test('it returns false for non-reserved spaces', () => { + const space: Space = { + id: '', + name: '', + }; + + expect(isReservedSpace(space)).toEqual(false); +}); + +test('it handles empty input', () => { + // @ts-ignore + expect(isReservedSpace()).toEqual(false); +}); diff --git a/x-pack/plugins/spaces/common/is_reserved_space.ts b/x-pack/plugins/spaces/common/is_reserved_space.ts new file mode 100644 index 00000000000000..788ef80c194ce2 --- /dev/null +++ b/x-pack/plugins/spaces/common/is_reserved_space.ts @@ -0,0 +1,18 @@ +/* + * 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 { get } from 'lodash'; +import { Space } from './model/space'; + +/** + * Returns whether the given Space is reserved or not. + * + * @param space the space + * @returns boolean + */ +export function isReservedSpace(space?: Partial | null): boolean { + return get(space, '_reserved', false); +} diff --git a/x-pack/plugins/spaces/common/model/space.ts b/x-pack/plugins/spaces/common/model/space.ts new file mode 100644 index 00000000000000..15148231984fc3 --- /dev/null +++ b/x-pack/plugins/spaces/common/model/space.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export interface Space { + id: string; + name: string; + description?: string; + color?: string; + initials?: string; + _reserved?: boolean; +} diff --git a/x-pack/plugins/spaces/common/space_attributes.test.ts b/x-pack/plugins/spaces/common/space_attributes.test.ts new file mode 100644 index 00000000000000..c999dde2756436 --- /dev/null +++ b/x-pack/plugins/spaces/common/space_attributes.test.ts @@ -0,0 +1,81 @@ +/* + * 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 { getSpaceColor, getSpaceInitials } from './space_attributes'; + +describe('getSpaceColor', () => { + test('uses color on the space, when provided', () => { + const space = { + name: 'Foo', + color: '#aabbcc', + }; + + expect(getSpaceColor(space)).toEqual('#aabbcc'); + }); + + test('derives color from space name if necessary', () => { + const space = { + name: 'Foo', + }; + + expect(getSpaceColor(space)).toMatch(/^#[a-f0-9]{6}$/i); + }); + + test('derives the same color for the same name', () => { + const space = { + name: 'FooBar', + }; + + const expectedColor = getSpaceColor(space); + + for (let i = 0; i < 100; i++) { + expect(getSpaceColor(space)).toEqual(expectedColor); + } + }); +}); + +describe('getSpaceInitials', () => { + test('uses initials on the space, when provided', () => { + const space = { + name: 'Foo', + initials: 'JK', + }; + + expect(getSpaceInitials(space)).toEqual('JK'); + }); + + test('derives initials from space name if necessary', () => { + const space = { + name: 'Foo', + }; + + expect(getSpaceInitials(space)).toEqual('F'); + }); + + test('uses words from the space name when deriving initials', () => { + const space = { + name: 'Foo Bar', + }; + + expect(getSpaceInitials(space)).toEqual('FB'); + }); + + test('only uses the first two words of the space name when deriving initials', () => { + const space = { + name: 'Very Special Name', + }; + + expect(getSpaceInitials(space)).toEqual('VS'); + }); + + test('maintains case when deriving initials', () => { + const space = { + name: 'some Space', + }; + + expect(getSpaceInitials(space)).toEqual('sS'); + }); +}); diff --git a/x-pack/plugins/spaces/common/space_attributes.ts b/x-pack/plugins/spaces/common/space_attributes.ts new file mode 100644 index 00000000000000..c73a4b5aca7aaf --- /dev/null +++ b/x-pack/plugins/spaces/common/space_attributes.ts @@ -0,0 +1,54 @@ +/* + * 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 { VISUALIZATION_COLORS } from '@elastic/eui'; +import { MAX_SPACE_INITIALS } from './constants'; +import { Space } from './model/space'; + +// code point for lowercase "a" +const FALLBACK_CODE_POINT = 97; + +/** + * Determines the color for the provided space. + * If a color is present on the Space itself, then that is used. + * Otherwise, a color is provided from EUI's Visualization Colors based on the space name. + * + * @param {Space} space + */ +export function getSpaceColor(space: Partial = {}) { + const { color, name = '' } = space; + + if (color) { + return color; + } + + const firstCodePoint = name.codePointAt(0) || FALLBACK_CODE_POINT; + + return VISUALIZATION_COLORS[firstCodePoint % VISUALIZATION_COLORS.length]; +} + +/** + * Determines the initials for the provided space. + * If initials are present on the Space itself, then that is used. + * Otherwise, the initials are calculated based off the words in the space name, with a max length of 2 characters. + * + * @param {Space} space + */ +export function getSpaceInitials(space: Partial = {}) { + const { initials, name = '' } = space; + + if (initials) { + return initials; + } + + const words = name.split(' '); + + const numInitials = Math.min(MAX_SPACE_INITIALS, words.length); + + words.splice(numInitials, words.length); + + return words.map(word => word.substring(0, 1)).join(''); +} diff --git a/x-pack/plugins/spaces/index.ts b/x-pack/plugins/spaces/index.ts new file mode 100644 index 00000000000000..9c44c11751dfcc --- /dev/null +++ b/x-pack/plugins/spaces/index.ts @@ -0,0 +1,165 @@ +/* + * 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 { resolve } from 'path'; +// @ts-ignore +import { AuditLogger } from '../../server/lib/audit_logger'; +// @ts-ignore +import { watchStatusAndLicenseToInitialize } from '../../server/lib/watch_status_and_license_to_initialize'; +import { registerUserProfileCapabilityFactory } from '../xpack_main/server/lib/user_profile_registry'; +import mappings from './mappings.json'; +import { SpacesAuditLogger } from './server/lib/audit_logger'; +import { checkLicense } from './server/lib/check_license'; +import { createDefaultSpace } from './server/lib/create_default_space'; +import { createSpacesService } from './server/lib/create_spaces_service'; +import { wrapError } from './server/lib/errors'; +import { getActiveSpace } from './server/lib/get_active_space'; +import { getSpaceSelectorUrl } from './server/lib/get_space_selector_url'; +import { getSpacesUsageCollector } from './server/lib/get_spaces_usage_collector'; +import { spacesSavedObjectsClientWrapperFactory } from './server/lib/saved_objects_client/saved_objects_client_wrapper_factory'; +import { initSpacesRequestInterceptors } from './server/lib/space_request_interceptors'; +import { SpacesClient } from './server/lib/spaces_client'; +import { createSpacesTutorialContextFactory } from './server/lib/spaces_tutorial_context_factory'; +import { initPublicSpacesApi } from './server/routes/api/public'; +import { initPrivateApis } from './server/routes/api/v1'; + +export const spaces = (kibana: any) => + new kibana.Plugin({ + id: 'spaces', + configPrefix: 'xpack.spaces', + publicDir: resolve(__dirname, 'public'), + require: ['kibana', 'elasticsearch', 'xpack_main'], + + config(Joi: any) { + return Joi.object({ + enabled: Joi.boolean().default(true), + }).default(); + }, + + uiExports: { + chromeNavControls: ['plugins/spaces/views/nav_control'], + managementSections: ['plugins/spaces/views/management'], + apps: [ + { + id: 'space_selector', + title: 'Spaces', + main: 'plugins/spaces/views/space_selector', + url: 'space_selector', + hidden: true, + }, + ], + hacks: [], + mappings, + savedObjectSchemas: { + space: { + isNamespaceAgnostic: true, + }, + }, + home: ['plugins/spaces/register_feature'], + injectDefaultVars(server: any) { + return { + spaces: [], + activeSpace: null, + spaceSelectorURL: getSpaceSelectorUrl(server.config()), + }; + }, + async replaceInjectedVars(vars: any, request: any, server: any) { + const spacesClient = server.plugins.spaces.spacesClient.getScopedClient(request); + try { + vars.activeSpace = { + valid: true, + space: await getActiveSpace( + spacesClient, + request.getBasePath(), + server.config().get('server.basePath') + ), + }; + } catch (e) { + vars.activeSpace = { + valid: false, + error: wrapError(e).output.payload, + }; + } + return vars; + }, + }, + + async init(server: any) { + const thisPlugin = this; + const xpackMainPlugin = server.plugins.xpack_main; + + watchStatusAndLicenseToInitialize(xpackMainPlugin, thisPlugin, async () => { + await createDefaultSpace(server); + }); + + // Register a function that is called whenever the xpack info changes, + // to re-compute the license check results for this plugin + xpackMainPlugin.info + .feature(thisPlugin.id) + .registerLicenseCheckResultsGenerator(checkLicense); + + const spacesService = createSpacesService(server); + server.expose('getSpaceId', (request: any) => spacesService.getSpaceId(request)); + + const config = server.config(); + + const spacesAuditLogger = new SpacesAuditLogger(config, new AuditLogger(server, 'spaces')); + + server.expose('spacesClient', { + getScopedClient: (request: any) => { + const adminCluster = server.plugins.elasticsearch.getCluster('admin'); + const { callWithRequest, callWithInternalUser } = adminCluster; + const callCluster = (...args: any[]) => callWithRequest(request, ...args); + const { savedObjects } = server; + const internalRepository = savedObjects.getSavedObjectsRepository(callWithInternalUser); + const callWithRequestRepository = savedObjects.getSavedObjectsRepository(callCluster); + const authorization = server.plugins.security + ? server.plugins.security.authorization + : null; + return new SpacesClient( + spacesAuditLogger, + authorization, + callWithRequestRepository, + internalRepository, + request + ); + }, + }); + + const { addScopedSavedObjectsClientWrapperFactory, types } = server.savedObjects; + addScopedSavedObjectsClientWrapperFactory( + Number.MAX_VALUE, + spacesSavedObjectsClientWrapperFactory(spacesService, types) + ); + + server.addScopedTutorialContextFactory(createSpacesTutorialContextFactory(spacesService)); + + initPrivateApis(server); + initPublicSpacesApi(server); + + initSpacesRequestInterceptors(server); + + registerUserProfileCapabilityFactory(async request => { + const spacesClient = server.plugins.spaces.spacesClient.getScopedClient(request); + + let manageSecurity = false; + + if (server.plugins.security) { + const { showLinks = false } = + xpackMainPlugin.info.feature('security').getLicenseCheckResults() || {}; + manageSecurity = showLinks; + } + + return { + manageSpaces: await spacesClient.canEnumerateSpaces(), + manageSecurity, + }; + }); + + // Register a function with server to manage the collection of usage stats + server.usage.collectorSet.register(getSpacesUsageCollector(server)); + }, + }); diff --git a/x-pack/plugins/spaces/mappings.json b/x-pack/plugins/spaces/mappings.json new file mode 100644 index 00000000000000..6c91d9b020ff62 --- /dev/null +++ b/x-pack/plugins/spaces/mappings.json @@ -0,0 +1,27 @@ +{ + "space": { + "properties": { + "name": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 2048 + } + } + }, + "description": { + "type": "text" + }, + "initials": { + "type": "keyword" + }, + "color": { + "type": "keyword" + }, + "_reserved": { + "type": "boolean" + } + } + } +} diff --git a/x-pack/plugins/spaces/public/components/__snapshots__/manage_spaces_button.test.tsx.snap b/x-pack/plugins/spaces/public/components/__snapshots__/manage_spaces_button.test.tsx.snap new file mode 100644 index 00000000000000..174b16e0c56564 --- /dev/null +++ b/x-pack/plugins/spaces/public/components/__snapshots__/manage_spaces_button.test.tsx.snap @@ -0,0 +1,17 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ManageSpacesButton doesn't render if user profile forbids managing spaces 1`] = `""`; + +exports[`ManageSpacesButton renders as expected 1`] = ` + + Manage spaces + +`; diff --git a/x-pack/plugins/spaces/public/components/__snapshots__/space_avatar.test.tsx.snap b/x-pack/plugins/spaces/public/components/__snapshots__/space_avatar.test.tsx.snap new file mode 100644 index 00000000000000..d45aa825db89ce --- /dev/null +++ b/x-pack/plugins/spaces/public/components/__snapshots__/space_avatar.test.tsx.snap @@ -0,0 +1,13 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renders without crashing 1`] = ` + +`; diff --git a/x-pack/plugins/spaces/public/components/index.ts b/x-pack/plugins/spaces/public/components/index.ts new file mode 100644 index 00000000000000..2e73f0c704f8c5 --- /dev/null +++ b/x-pack/plugins/spaces/public/components/index.ts @@ -0,0 +1,8 @@ +/* + * 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 { SpaceAvatar } from './space_avatar'; +export { ManageSpacesButton } from './manage_spaces_button'; diff --git a/x-pack/plugins/spaces/public/components/manage_spaces_button.test.tsx b/x-pack/plugins/spaces/public/components/manage_spaces_button.test.tsx new file mode 100644 index 00000000000000..374ffc5d365003 --- /dev/null +++ b/x-pack/plugins/spaces/public/components/manage_spaces_button.test.tsx @@ -0,0 +1,25 @@ +/* + * 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 { shallow } from 'enzyme'; +import React from 'react'; +import { UserProfileProvider } from '../../../xpack_main/public/services/user_profile'; +import { ManageSpacesButton } from './manage_spaces_button'; + +const buildUserProfile = (canManageSpaces: boolean) => { + return UserProfileProvider({ manageSpaces: canManageSpaces }); +}; + +describe('ManageSpacesButton', () => { + it('renders as expected', () => { + const component = ; + expect(shallow(component)).toMatchSnapshot(); + }); + + it(`doesn't render if user profile forbids managing spaces`, () => { + const component = ; + expect(shallow(component)).toMatchSnapshot(); + }); +}); diff --git a/x-pack/plugins/spaces/public/components/manage_spaces_button.tsx b/x-pack/plugins/spaces/public/components/manage_spaces_button.tsx new file mode 100644 index 00000000000000..eb70629255ed03 --- /dev/null +++ b/x-pack/plugins/spaces/public/components/manage_spaces_button.tsx @@ -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 { EuiButton } from '@elastic/eui'; +import React, { Component, CSSProperties } from 'react'; +import { UserProfile } from '../../../xpack_main/public/services/user_profile'; +import { MANAGE_SPACES_URL } from '../lib/constants'; + +interface Props { + isDisabled?: boolean; + size?: 's' | 'l'; + style?: CSSProperties; + userProfile: UserProfile; +} + +export class ManageSpacesButton extends Component { + public render() { + if (!this.props.userProfile.hasCapability('manageSpaces')) { + return null; + } + + return ( + + Manage spaces + + ); + } + + private navigateToManageSpaces = () => { + window.location.replace(MANAGE_SPACES_URL); + }; +} diff --git a/x-pack/plugins/spaces/public/components/space_avatar.test.tsx b/x-pack/plugins/spaces/public/components/space_avatar.test.tsx new file mode 100644 index 00000000000000..47f1b29bd55a15 --- /dev/null +++ b/x-pack/plugins/spaces/public/components/space_avatar.test.tsx @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { shallow } from 'enzyme'; +import React from 'react'; +import { SpaceAvatar } from './space_avatar'; + +test('renders without crashing', () => { + const wrapper = shallow(); + expect(wrapper).toMatchSnapshot(); +}); diff --git a/x-pack/plugins/spaces/public/components/space_avatar.tsx b/x-pack/plugins/spaces/public/components/space_avatar.tsx new file mode 100644 index 00000000000000..d89bcbc6e24658 --- /dev/null +++ b/x-pack/plugins/spaces/public/components/space_avatar.tsx @@ -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. + */ + +import { EuiAvatar } from '@elastic/eui'; +import React from 'react'; +import { getSpaceColor, getSpaceInitials, MAX_SPACE_INITIALS } from '../../common'; +import { Space } from '../../common/model/space'; + +interface Props { + space: Partial; + size?: 's' | 'm' | 'l' | 'xl'; + className?: string; +} + +export const SpaceAvatar = (props: Props) => { + const { space, size, ...rest } = props; + + return ( + + ); +}; diff --git a/x-pack/plugins/spaces/public/lib/constants.ts b/x-pack/plugins/spaces/public/lib/constants.ts new file mode 100644 index 00000000000000..93f21f0e466293 --- /dev/null +++ b/x-pack/plugins/spaces/public/lib/constants.ts @@ -0,0 +1,11 @@ +/* + * 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 chrome from 'ui/chrome'; + +export const SPACES_FEATURE_DESCRIPTION = `Organize your dashboards and other saved objects into meaningful categories.`; + +export const MANAGE_SPACES_URL = chrome.addBasePath(`/app/kibana#/management/spaces/list`); diff --git a/x-pack/plugins/spaces/public/lib/index.ts b/x-pack/plugins/spaces/public/lib/index.ts new file mode 100644 index 00000000000000..538dd77e053f57 --- /dev/null +++ b/x-pack/plugins/spaces/public/lib/index.ts @@ -0,0 +1,7 @@ +/* + * 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 { SpacesManager } from './spaces_manager'; diff --git a/x-pack/plugins/spaces/public/lib/spaces_manager.ts b/x-pack/plugins/spaces/public/lib/spaces_manager.ts new file mode 100644 index 00000000000000..8a11e0137897f8 --- /dev/null +++ b/x-pack/plugins/spaces/public/lib/spaces_manager.ts @@ -0,0 +1,73 @@ +/* + * 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 { toastNotifications } from 'ui/notify'; + +import { IHttpResponse } from 'angular'; +import { EventEmitter } from 'events'; +import { Space } from '../../common/model/space'; + +export class SpacesManager extends EventEmitter { + private httpAgent: any; + private baseUrl: any; + private spaceSelectorURL: string; + + constructor(httpAgent: any, chrome: any, spaceSelectorURL: string) { + super(); + this.httpAgent = httpAgent; + this.baseUrl = chrome.addBasePath(`/api/spaces`); + this.spaceSelectorURL = spaceSelectorURL; + } + + public async getSpaces(): Promise { + return await this.httpAgent + .get(`${this.baseUrl}/space`) + .then((response: IHttpResponse) => response.data); + } + + public async getSpace(id: string): Promise { + return await this.httpAgent.get(`${this.baseUrl}/space/${id}`); + } + + public async createSpace(space: Space) { + return await this.httpAgent.post(`${this.baseUrl}/space`, space); + } + + public async updateSpace(space: Space) { + return await this.httpAgent.put(`${this.baseUrl}/space/${space.id}?overwrite=true`, space); + } + + public async deleteSpace(space: Space) { + return await this.httpAgent.delete(`${this.baseUrl}/space/${space.id}`); + } + + public async changeSelectedSpace(space: Space) { + return await this.httpAgent + .post(`${this.baseUrl}/v1/space/${space.id}/select`) + .then((response: IHttpResponse) => { + if (response.data && response.data.location) { + window.location = response.data.location; + } else { + this._displayError(); + } + }) + .catch(() => this._displayError()); + } + + public redirectToSpaceSelector() { + window.location.href = this.spaceSelectorURL; + } + + public async requestRefresh() { + this.emit('request_refresh'); + } + + public _displayError() { + toastNotifications.addDanger({ + title: 'Unable to change your Space', + text: 'please try again later', + }); + } +} diff --git a/x-pack/plugins/spaces/public/register_feature.ts b/x-pack/plugins/spaces/public/register_feature.ts new file mode 100644 index 00000000000000..6744590e7d35a2 --- /dev/null +++ b/x-pack/plugins/spaces/public/register_feature.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. + */ + +import { + FeatureCatalogueCategory, + FeatureCatalogueRegistryProvider, + // @ts-ignore +} from 'ui/registry/feature_catalogue'; +import { SPACES_FEATURE_DESCRIPTION } from './lib/constants'; + +FeatureCatalogueRegistryProvider.register(() => { + return { + id: 'spaces', + title: 'Spaces', + description: SPACES_FEATURE_DESCRIPTION, + icon: 'spacesApp', + path: '/app/kibana#/management/spaces/list', + showOnHomePage: true, + category: FeatureCatalogueCategory.ADMIN, + }; +}); diff --git a/x-pack/plugins/spaces/public/views/components/index.ts b/x-pack/plugins/spaces/public/views/components/index.ts new file mode 100644 index 00000000000000..9a6f95a3d9ed91 --- /dev/null +++ b/x-pack/plugins/spaces/public/views/components/index.ts @@ -0,0 +1,7 @@ +/* + * 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 { SpaceCards } from './space_cards'; diff --git a/x-pack/plugins/spaces/public/views/components/space_card.less b/x-pack/plugins/spaces/public/views/components/space_card.less new file mode 100644 index 00000000000000..8245a16b9f43c6 --- /dev/null +++ b/x-pack/plugins/spaces/public/views/components/space_card.less @@ -0,0 +1,8 @@ +.euiCard.euiCard--isClickable.spaceCard { + width: 240px; + min-height: 200px; +} + +.spaceCard .euiCard__content{ + overflow: hidden; +} diff --git a/x-pack/plugins/spaces/public/views/components/space_card.test.tsx b/x-pack/plugins/spaces/public/views/components/space_card.test.tsx new file mode 100644 index 00000000000000..26f8a226315b8e --- /dev/null +++ b/x-pack/plugins/spaces/public/views/components/space_card.test.tsx @@ -0,0 +1,34 @@ +/* + * 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 { mount, shallow } from 'enzyme'; +import React from 'react'; +import { SpaceCard } from './space_card'; + +test('it renders without crashing', () => { + const space = { + id: '', + name: 'space name', + description: 'space description', + }; + + shallow(); +}); + +test('it is clickable', () => { + const space = { + id: '', + name: 'space name', + description: 'space description', + }; + + const clickHandler = jest.fn(); + + const wrapper = mount(); + wrapper.simulate('click'); + + expect(clickHandler).toHaveBeenCalledTimes(1); +}); diff --git a/x-pack/plugins/spaces/public/views/components/space_card.tsx b/x-pack/plugins/spaces/public/views/components/space_card.tsx new file mode 100644 index 00000000000000..fc5a2c18b97865 --- /dev/null +++ b/x-pack/plugins/spaces/public/views/components/space_card.tsx @@ -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. + */ +// @ts-nocheck + +import { + // FIXME: need updated typedefs + // @ts-ignore + EuiCard, +} from '@elastic/eui'; +import React from 'react'; +import { Space } from '../../../common/model/space'; +import { SpaceAvatar } from '../../components'; +import './space_card.less'; + +interface Props { + space: Space; + onClick: () => void; +} +export const SpaceCard = (props: Props) => { + const { space, onClick } = props; + + return ( + + ); +}; + +function renderSpaceAvatar(space: Space) { + return ; +} + +function renderSpaceDescription(space: Space) { + let description: JSX.Element | string = space.description || ''; + const needsTruncation = description.length > 120; + if (needsTruncation) { + description = description.substr(0, 120) + '…'; + } + + return ( + + {description} + + ); +} diff --git a/x-pack/plugins/spaces/public/views/components/space_cards.less b/x-pack/plugins/spaces/public/views/components/space_cards.less new file mode 100644 index 00000000000000..8108285e3dce76 --- /dev/null +++ b/x-pack/plugins/spaces/public/views/components/space_cards.less @@ -0,0 +1,4 @@ +.spaceCards { + max-width: 1200px; + margin: auto; +} \ No newline at end of file diff --git a/x-pack/plugins/spaces/public/views/components/space_cards.test.tsx b/x-pack/plugins/spaces/public/views/components/space_cards.test.tsx new file mode 100644 index 00000000000000..591f8c1507b7a6 --- /dev/null +++ b/x-pack/plugins/spaces/public/views/components/space_cards.test.tsx @@ -0,0 +1,19 @@ +/* + * 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 { shallow } from 'enzyme'; +import React from 'react'; +import { SpaceCards } from './space_cards'; + +test('it renders without crashing', () => { + const space = { + id: 'space-id', + name: 'space name', + description: 'space description', + }; + + shallow(); +}); diff --git a/x-pack/plugins/spaces/public/views/components/space_cards.tsx b/x-pack/plugins/spaces/public/views/components/space_cards.tsx new file mode 100644 index 00000000000000..20f93030d79075 --- /dev/null +++ b/x-pack/plugins/spaces/public/views/components/space_cards.tsx @@ -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 { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import React, { Component } from 'react'; +import { Space } from '../../../common/model/space'; +import { SpaceCard } from './space_card'; +import './space_cards.less'; + +interface Props { + spaces: Space[]; + onSpaceSelect: (space: Space) => void; +} + +export class SpaceCards extends Component { + public render() { + return ( +
+ + {this.props.spaces.map(this.renderSpace)} + +
+ ); + } + + public renderSpace = (space: Space) => ( + + + + ); + + public createSpaceClickHandler = (space: Space) => { + return () => { + this.props.onSpaceSelect(space); + }; + }; +} diff --git a/x-pack/plugins/spaces/public/views/management/components/__snapshots__/confirm_delete_modal.test.tsx.snap b/x-pack/plugins/spaces/public/views/management/components/__snapshots__/confirm_delete_modal.test.tsx.snap new file mode 100644 index 00000000000000..7f00705b160fbb --- /dev/null +++ b/x-pack/plugins/spaces/public/views/management/components/__snapshots__/confirm_delete_modal.test.tsx.snap @@ -0,0 +1,53 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ConfirmDeleteModal renders as expected 1`] = ` + + +

+ Deleting a space permanently removes the space and all of its contents. You can't undo this action. +

+ + + + + + You are about to delete your current space + + ( + + My Space + + ) + + . You will be redirected to choose a different space if you continue. + + +
+
+`; diff --git a/x-pack/plugins/spaces/public/views/management/components/__snapshots__/unauthorized_prompt.test.tsx.snap b/x-pack/plugins/spaces/public/views/management/components/__snapshots__/unauthorized_prompt.test.tsx.snap new file mode 100644 index 00000000000000..673576cd01b3f6 --- /dev/null +++ b/x-pack/plugins/spaces/public/views/management/components/__snapshots__/unauthorized_prompt.test.tsx.snap @@ -0,0 +1,20 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`UnauthorizedPrompt renders as expected 1`] = ` + + You do not have permission to manage spaces. +

+ } + iconColor="danger" + iconType="spacesApp" + title={ +

+ Permission denied +

+ } +/> +`; diff --git a/x-pack/plugins/spaces/public/views/management/components/advanced_settings_subtitle/__snapshots__/advanced_settings_subtitle.test.tsx.snap b/x-pack/plugins/spaces/public/views/management/components/advanced_settings_subtitle/__snapshots__/advanced_settings_subtitle.test.tsx.snap new file mode 100644 index 00000000000000..0d539fffe6e348 --- /dev/null +++ b/x-pack/plugins/spaces/public/views/management/components/advanced_settings_subtitle/__snapshots__/advanced_settings_subtitle.test.tsx.snap @@ -0,0 +1,23 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`AdvancedSettingsSubtitle renders as expected 1`] = ` + + + + The settings on this page apply to the + + My Space + + space, unless otherwise specified. +

+ } + /> +
+`; diff --git a/x-pack/plugins/spaces/public/views/management/components/advanced_settings_subtitle/advanced_settings_subtitle.test.tsx b/x-pack/plugins/spaces/public/views/management/components/advanced_settings_subtitle/advanced_settings_subtitle.test.tsx new file mode 100644 index 00000000000000..39444cf01e88b7 --- /dev/null +++ b/x-pack/plugins/spaces/public/views/management/components/advanced_settings_subtitle/advanced_settings_subtitle.test.tsx @@ -0,0 +1,18 @@ +/* + * 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 { shallow } from 'enzyme'; +import React from 'react'; +import { AdvancedSettingsSubtitle } from './advanced_settings_subtitle'; + +describe('AdvancedSettingsSubtitle', () => { + it('renders as expected', () => { + const space = { + id: 'my-space', + name: 'My Space', + }; + expect(shallow()).toMatchSnapshot(); + }); +}); diff --git a/x-pack/plugins/spaces/public/views/management/components/advanced_settings_subtitle/advanced_settings_subtitle.tsx b/x-pack/plugins/spaces/public/views/management/components/advanced_settings_subtitle/advanced_settings_subtitle.tsx new file mode 100644 index 00000000000000..0eef1703fc8563 --- /dev/null +++ b/x-pack/plugins/spaces/public/views/management/components/advanced_settings_subtitle/advanced_settings_subtitle.tsx @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiCallOut, EuiSpacer } from '@elastic/eui'; +import React, { Fragment } from 'react'; +import { Space } from '../../../../../common/model/space'; + +interface Props { + space: Space; +} + +export const AdvancedSettingsSubtitle = (props: Props) => ( + + + + The settings on this page apply to the {props.space.name} space, unless + otherwise specified. +

+ } + /> +
+); diff --git a/x-pack/plugins/spaces/public/views/management/components/advanced_settings_subtitle/index.ts b/x-pack/plugins/spaces/public/views/management/components/advanced_settings_subtitle/index.ts new file mode 100644 index 00000000000000..f403caf3eeebe7 --- /dev/null +++ b/x-pack/plugins/spaces/public/views/management/components/advanced_settings_subtitle/index.ts @@ -0,0 +1,7 @@ +/* + * 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 { AdvancedSettingsSubtitle } from './advanced_settings_subtitle'; diff --git a/x-pack/plugins/spaces/public/views/management/components/advanced_settings_title/__snapshots__/advanced_settings_title.test.tsx.snap b/x-pack/plugins/spaces/public/views/management/components/advanced_settings_title/__snapshots__/advanced_settings_title.test.tsx.snap new file mode 100644 index 00000000000000..4bffc7433987ce --- /dev/null +++ b/x-pack/plugins/spaces/public/views/management/components/advanced_settings_title/__snapshots__/advanced_settings_title.test.tsx.snap @@ -0,0 +1,46 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`AdvancedSettingsTitle renders as expected 1`] = ` + + + + + + +

+ Settings +

+
+
+
+`; diff --git a/x-pack/plugins/spaces/public/views/management/components/advanced_settings_title/advanced_settings_title.test.tsx b/x-pack/plugins/spaces/public/views/management/components/advanced_settings_title/advanced_settings_title.test.tsx new file mode 100644 index 00000000000000..d46848f2c103fa --- /dev/null +++ b/x-pack/plugins/spaces/public/views/management/components/advanced_settings_title/advanced_settings_title.test.tsx @@ -0,0 +1,18 @@ +/* + * 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 { shallow } from 'enzyme'; +import React from 'react'; +import { AdvancedSettingsTitle } from './advanced_settings_title'; + +describe('AdvancedSettingsTitle', () => { + it('renders as expected', () => { + const space = { + id: 'my-space', + name: 'My Space', + }; + expect(shallow()).toMatchSnapshot(); + }); +}); diff --git a/x-pack/plugins/spaces/public/views/management/components/advanced_settings_title/advanced_settings_title.tsx b/x-pack/plugins/spaces/public/views/management/components/advanced_settings_title/advanced_settings_title.tsx new file mode 100644 index 00000000000000..c0a4924e9c072a --- /dev/null +++ b/x-pack/plugins/spaces/public/views/management/components/advanced_settings_title/advanced_settings_title.tsx @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; +import React from 'react'; +import { Space } from '../../../../../common/model/space'; +import { SpaceAvatar } from '../../../../components'; + +interface Props { + space: Space; +} + +export const AdvancedSettingsTitle = (props: Props) => ( + + + + + + +

Settings

+
+
+
+); diff --git a/x-pack/plugins/spaces/public/views/management/components/advanced_settings_title/index.ts b/x-pack/plugins/spaces/public/views/management/components/advanced_settings_title/index.ts new file mode 100644 index 00000000000000..60fdf51dca70e0 --- /dev/null +++ b/x-pack/plugins/spaces/public/views/management/components/advanced_settings_title/index.ts @@ -0,0 +1,7 @@ +/* + * 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 { AdvancedSettingsTitle } from './advanced_settings_title'; diff --git a/x-pack/plugins/spaces/public/views/management/components/confirm_delete_modal.test.tsx b/x-pack/plugins/spaces/public/views/management/components/confirm_delete_modal.test.tsx new file mode 100644 index 00000000000000..2fbfd6da452e4c --- /dev/null +++ b/x-pack/plugins/spaces/public/views/management/components/confirm_delete_modal.test.tsx @@ -0,0 +1,99 @@ +/* + * 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 { mount, shallow } from 'enzyme'; +import React from 'react'; +import { SpacesManager } from '../../../lib'; +import { SpacesNavState } from '../../nav_control'; +import { ConfirmDeleteModal } from './confirm_delete_modal'; + +const buildMockChrome = () => { + return { + addBasePath: (path: string) => path, + }; +}; + +describe('ConfirmDeleteModal', () => { + it('renders as expected', () => { + const space = { + id: 'my-space', + name: 'My Space', + }; + + const mockHttp = { + delete: jest.fn(() => Promise.resolve()), + }; + const mockChrome = buildMockChrome(); + + const spacesManager = new SpacesManager(mockHttp, mockChrome, '/'); + + const spacesNavState: SpacesNavState = { + getActiveSpace: () => space, + refreshSpacesList: jest.fn(), + }; + + const onCancel = jest.fn(); + const onConfirm = jest.fn(); + + expect( + shallow( + + ) + ).toMatchSnapshot(); + }); + + it(`requires the space name to be typed before confirming`, () => { + const space = { + id: 'my-space', + name: 'My Space', + }; + + const mockHttp = { + delete: jest.fn(() => Promise.resolve()), + }; + const mockChrome = buildMockChrome(); + + const spacesManager = new SpacesManager(mockHttp, mockChrome, '/'); + + const spacesNavState: SpacesNavState = { + getActiveSpace: () => space, + refreshSpacesList: jest.fn(), + }; + + const onCancel = jest.fn(); + const onConfirm = jest.fn(); + + const wrapper = mount( + + ); + + const input = wrapper.find('input'); + expect(input).toHaveLength(1); + + input.simulate('change', { target: { value: 'My Invalid Space Name ' } }); + + const confirmButton = wrapper.find('button[data-test-subj="confirmModalConfirmButton"]'); + confirmButton.simulate('click'); + + expect(onConfirm).not.toHaveBeenCalled(); + + input.simulate('change', { target: { value: 'My Space' } }); + confirmButton.simulate('click'); + + expect(onConfirm).toHaveBeenCalledTimes(1); + }); +}); diff --git a/x-pack/plugins/spaces/public/views/management/components/confirm_delete_modal.tsx b/x-pack/plugins/spaces/public/views/management/components/confirm_delete_modal.tsx new file mode 100644 index 00000000000000..28515ba71593d7 --- /dev/null +++ b/x-pack/plugins/spaces/public/views/management/components/confirm_delete_modal.tsx @@ -0,0 +1,122 @@ +/* + * 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 { + EuiCallOut, + // @ts-ignore + EuiConfirmModal, + EuiFieldText, + EuiFormRow, + EuiOverlayMask, + EuiText, +} from '@elastic/eui'; +import { SpacesNavState } from 'plugins/spaces/views/nav_control'; +import React, { ChangeEvent, Component } from 'react'; +import { Space } from '../../../../common/model/space'; +import { SpacesManager } from '../../../lib'; + +interface Props { + space: Space; + spacesManager: SpacesManager; + spacesNavState: SpacesNavState; + onCancel: () => void; + onConfirm: () => void; +} + +interface State { + confirmSpaceName: string; + error: boolean | null; +} + +export class ConfirmDeleteModal extends Component { + public state = { + confirmSpaceName: '', + error: null, + }; + + public render() { + const { space, spacesNavState, onCancel } = this.props; + + let warning = null; + if (isDeletingCurrentSpace(space, spacesNavState)) { + const name = ( + + ({space.name}) + + ); + warning = ( + + + You are about to delete your current space {name}. You will be redirected to choose a + different space if you continue. + + + ); + } + + return ( + + +

+ Deleting a space permanently removes the space and all of its contents. You can't undo + this action. +

+ + + + + + {warning} +
+
+ ); + } + + private onSpaceNameChange = (e: ChangeEvent) => { + if (typeof this.state.error === 'boolean') { + this.setState({ + confirmSpaceName: e.target.value, + error: e.target.value !== this.props.space.name, + }); + } else { + this.setState({ + confirmSpaceName: e.target.value, + }); + } + }; + + private onConfirm = async () => { + if (this.state.confirmSpaceName === this.props.space.name) { + const needsRedirect = isDeletingCurrentSpace(this.props.space, this.props.spacesNavState); + const spacesManager = this.props.spacesManager; + + await this.props.onConfirm(); + if (needsRedirect) { + spacesManager.redirectToSpaceSelector(); + } + } else { + this.setState({ + error: true, + }); + } + }; +} + +function isDeletingCurrentSpace(space: Space, spacesNavState: SpacesNavState) { + return space.id === spacesNavState.getActiveSpace().id; +} diff --git a/x-pack/plugins/spaces/public/views/management/components/index.ts b/x-pack/plugins/spaces/public/views/management/components/index.ts new file mode 100644 index 00000000000000..91f4964e1da068 --- /dev/null +++ b/x-pack/plugins/spaces/public/views/management/components/index.ts @@ -0,0 +1,8 @@ +/* + * 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 { ConfirmDeleteModal } from './confirm_delete_modal'; +export { UnauthorizedPrompt } from './unauthorized_prompt'; diff --git a/x-pack/plugins/spaces/public/views/management/components/secure_space_message/__snapshots__/secure_space_message.test.tsx.snap b/x-pack/plugins/spaces/public/views/management/components/secure_space_message/__snapshots__/secure_space_message.test.tsx.snap new file mode 100644 index 00000000000000..793806db8c5449 --- /dev/null +++ b/x-pack/plugins/spaces/public/views/management/components/secure_space_message/__snapshots__/secure_space_message.test.tsx.snap @@ -0,0 +1,28 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`SecureSpaceMessage doesn't render if user profile does not allow security to be managed 1`] = `""`; + +exports[`SecureSpaceMessage renders if user profile allows security to be managed 1`] = ` + + + +

+ Want to assign a role to a space? Go to Management and select + + + Roles + + . +

+
+
+`; diff --git a/x-pack/plugins/spaces/public/views/management/components/secure_space_message/index.ts b/x-pack/plugins/spaces/public/views/management/components/secure_space_message/index.ts new file mode 100644 index 00000000000000..4526dc791a224d --- /dev/null +++ b/x-pack/plugins/spaces/public/views/management/components/secure_space_message/index.ts @@ -0,0 +1,7 @@ +/* + * 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 { SecureSpaceMessage } from './secure_space_message'; diff --git a/x-pack/plugins/spaces/public/views/management/components/secure_space_message/secure_space_message.test.tsx b/x-pack/plugins/spaces/public/views/management/components/secure_space_message/secure_space_message.test.tsx new file mode 100644 index 00000000000000..65b6bc2c120061 --- /dev/null +++ b/x-pack/plugins/spaces/public/views/management/components/secure_space_message/secure_space_message.test.tsx @@ -0,0 +1,36 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { shallow } from 'enzyme'; +import React from 'react'; +import { SecureSpaceMessage } from './secure_space_message'; + +describe('SecureSpaceMessage', () => { + it(`doesn't render if user profile does not allow security to be managed`, () => { + const userProfile = { + hasCapability: (key: string) => { + if (key === 'manageSecurity') { + return false; + } + throw new Error(`unexpected capability ${key}`); + }, + }; + + expect(shallow()).toMatchSnapshot(); + }); + + it(`renders if user profile allows security to be managed`, () => { + const userProfile = { + hasCapability: (key: string) => { + if (key === 'manageSecurity') { + return true; + } + throw new Error(`unexpected capability ${key}`); + }, + }; + + expect(shallow()).toMatchSnapshot(); + }); +}); diff --git a/x-pack/plugins/spaces/public/views/management/components/secure_space_message/secure_space_message.tsx b/x-pack/plugins/spaces/public/views/management/components/secure_space_message/secure_space_message.tsx new file mode 100644 index 00000000000000..db77c1ad6d113a --- /dev/null +++ b/x-pack/plugins/spaces/public/views/management/components/secure_space_message/secure_space_message.tsx @@ -0,0 +1,30 @@ +/* + * 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 { EuiLink, EuiSpacer, EuiText } from '@elastic/eui'; +import { UserProfile } from 'plugins/xpack_main/services/user_profile'; +import React, { Fragment } from 'react'; + +interface Props { + userProfile: UserProfile; +} + +export const SecureSpaceMessage = (props: Props) => { + if (props.userProfile.hasCapability('manageSecurity')) { + return ( + + + +

+ Want to assign a role to a space? Go to Management and select{' '} + Roles. +

+
+
+ ); + } + return null; +}; diff --git a/x-pack/plugins/spaces/public/views/management/components/unauthorized_prompt.test.tsx b/x-pack/plugins/spaces/public/views/management/components/unauthorized_prompt.test.tsx new file mode 100644 index 00000000000000..35fdd69f64e8b4 --- /dev/null +++ b/x-pack/plugins/spaces/public/views/management/components/unauthorized_prompt.test.tsx @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { shallow } from 'enzyme'; +import React from 'react'; +import { UnauthorizedPrompt } from './unauthorized_prompt'; + +describe('UnauthorizedPrompt', () => { + it('renders as expected', () => { + expect(shallow()).toMatchSnapshot(); + }); +}); diff --git a/x-pack/plugins/spaces/public/views/management/components/unauthorized_prompt.tsx b/x-pack/plugins/spaces/public/views/management/components/unauthorized_prompt.tsx new file mode 100644 index 00000000000000..a0888b86fa0eb7 --- /dev/null +++ b/x-pack/plugins/spaces/public/views/management/components/unauthorized_prompt.tsx @@ -0,0 +1,19 @@ +/* + * 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 { EuiEmptyPrompt } from '@elastic/eui'; +import React from 'react'; + +export const UnauthorizedPrompt = () => ( + Permission denied} + body={ +

You do not have permission to manage spaces.

+ } + /> +); diff --git a/x-pack/plugins/spaces/public/views/management/edit_space/__snapshots__/customize_space_avatar.test.tsx.snap b/x-pack/plugins/spaces/public/views/management/edit_space/__snapshots__/customize_space_avatar.test.tsx.snap new file mode 100644 index 00000000000000..cbc3efafff90e4 --- /dev/null +++ b/x-pack/plugins/spaces/public/views/management/edit_space/__snapshots__/customize_space_avatar.test.tsx.snap @@ -0,0 +1,23 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renders without crashing 1`] = ` + + + + Customize + + + +`; diff --git a/x-pack/plugins/spaces/public/views/management/edit_space/__snapshots__/delete_spaces_button.test.tsx.snap b/x-pack/plugins/spaces/public/views/management/edit_space/__snapshots__/delete_spaces_button.test.tsx.snap new file mode 100644 index 00000000000000..efb34025573aa1 --- /dev/null +++ b/x-pack/plugins/spaces/public/views/management/edit_space/__snapshots__/delete_spaces_button.test.tsx.snap @@ -0,0 +1,16 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`DeleteSpacesButton renders as expected 1`] = ` + + + Delete space + + +`; diff --git a/x-pack/plugins/spaces/public/views/management/edit_space/__snapshots__/space_identifier.test.tsx.snap b/x-pack/plugins/spaces/public/views/management/edit_space/__snapshots__/space_identifier.test.tsx.snap new file mode 100644 index 00000000000000..a88906a10237e5 --- /dev/null +++ b/x-pack/plugins/spaces/public/views/management/edit_space/__snapshots__/space_identifier.test.tsx.snap @@ -0,0 +1,50 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`renders without crashing 1`] = ` + + + If the identifier is + + engineering + + , the Kibana URL is +
+ https://my-kibana.example + + /s/engineering/ + + app/kibana. +

+ } + isInvalid={false} + label={ +

+ URL identifier + + [edit] + +

+ } + > + +
+
+`; diff --git a/x-pack/plugins/spaces/public/views/management/edit_space/customize_space_avatar.test.tsx b/x-pack/plugins/spaces/public/views/management/edit_space/customize_space_avatar.test.tsx new file mode 100644 index 00000000000000..180ed9b0dfef75 --- /dev/null +++ b/x-pack/plugins/spaces/public/views/management/edit_space/customize_space_avatar.test.tsx @@ -0,0 +1,58 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +// @ts-ignore +import { EuiColorPicker, EuiFieldText, EuiLink } from '@elastic/eui'; +import { mount, shallow } from 'enzyme'; +import React from 'react'; +import { CustomizeSpaceAvatar } from './customize_space_avatar'; + +const space = { + id: '', + name: '', +}; + +test('renders without crashing', () => { + const wrapper = shallow(); + expect(wrapper).toMatchSnapshot(); +}); + +test('renders a "customize" link by default', () => { + const wrapper = mount(); + expect(wrapper.find(EuiLink)).toHaveLength(1); +}); + +test('shows customization fields when the "customize" link is clicked', () => { + const wrapper = mount(); + wrapper.find(EuiLink).simulate('click'); + + expect(wrapper.find(EuiLink)).toHaveLength(0); + expect(wrapper.find(EuiFieldText)).toHaveLength(1); + expect(wrapper.find(EuiColorPicker)).toHaveLength(1); +}); + +test('invokes onChange callback when avatar is customized', () => { + const customizedSpace = { + id: '', + name: 'Unit Test Space', + initials: 'SP', + color: '#ABCDEF', + }; + + const changeHandler = jest.fn(); + + const wrapper = mount(); + wrapper.find(EuiLink).simulate('click'); + + wrapper + .find(EuiFieldText) + .find('input') + .simulate('change', { target: { value: 'NV' } }); + + expect(changeHandler).toHaveBeenCalledWith({ + ...customizedSpace, + initials: 'NV', + }); +}); diff --git a/x-pack/plugins/spaces/public/views/management/edit_space/customize_space_avatar.tsx b/x-pack/plugins/spaces/public/views/management/edit_space/customize_space_avatar.tsx new file mode 100644 index 00000000000000..be279536ccd88f --- /dev/null +++ b/x-pack/plugins/spaces/public/views/management/edit_space/customize_space_avatar.tsx @@ -0,0 +1,132 @@ +/* + * 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. + */ +// @ts-ignore +import { EuiColorPicker, EuiFieldText, EuiFlexItem, EuiFormRow, EuiLink } from '@elastic/eui'; +import React, { ChangeEvent, Component, Fragment } from 'react'; +import { MAX_SPACE_INITIALS } from '../../../../common/constants'; +import { Space } from '../../../../common/model/space'; +import { getSpaceColor, getSpaceInitials } from '../../../../common/space_attributes'; + +interface Props { + space: Partial; + onChange: (space: Partial) => void; +} + +interface State { + expanded: boolean; + initialsHasFocus: boolean; + pendingInitials?: string | null; +} + +export class CustomizeSpaceAvatar extends Component { + private initialsRef: HTMLInputElement | null = null; + + constructor(props: Props) { + super(props); + this.state = { + expanded: false, + initialsHasFocus: false, + }; + } + + public render() { + return this.state.expanded ? this.getCustomizeFields() : this.getCustomizeLink(); + } + + public getCustomizeFields = () => { + const { space } = this.props; + + const { initialsHasFocus, pendingInitials } = this.state; + + return ( + + + + + + + + + + + + + ); + }; + + public initialsInputRef = (ref: HTMLInputElement) => { + if (ref) { + this.initialsRef = ref; + this.initialsRef.addEventListener('focus', this.onInitialsFocus); + this.initialsRef.addEventListener('blur', this.onInitialsBlur); + } else { + if (this.initialsRef) { + this.initialsRef.removeEventListener('focus', this.onInitialsFocus); + this.initialsRef.removeEventListener('blur', this.onInitialsBlur); + this.initialsRef = null; + } + } + }; + + public onInitialsFocus = () => { + this.setState({ + initialsHasFocus: true, + pendingInitials: getSpaceInitials(this.props.space), + }); + }; + + public onInitialsBlur = () => { + this.setState({ + initialsHasFocus: false, + pendingInitials: null, + }); + }; + + public getCustomizeLink = () => { + return ( + + + + Customize + + + + ); + }; + + public showFields = () => { + this.setState({ + expanded: true, + }); + }; + + public onInitialsChange = (e: ChangeEvent) => { + const initials = (e.target.value || '').substring(0, MAX_SPACE_INITIALS); + + this.setState({ + pendingInitials: initials, + }); + + this.props.onChange({ + ...this.props.space, + initials, + }); + }; + + public onColorChange = (color: string) => { + this.props.onChange({ + ...this.props.space, + color, + }); + }; +} diff --git a/x-pack/plugins/spaces/public/views/management/edit_space/delete_spaces_button.test.tsx b/x-pack/plugins/spaces/public/views/management/edit_space/delete_spaces_button.test.tsx new file mode 100644 index 00000000000000..456a6d1ccbb436 --- /dev/null +++ b/x-pack/plugins/spaces/public/views/management/edit_space/delete_spaces_button.test.tsx @@ -0,0 +1,47 @@ +/* + * 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 { shallow } from 'enzyme'; +import React from 'react'; +import { SpacesManager } from '../../../lib'; +import { SpacesNavState } from '../../nav_control'; +import { DeleteSpacesButton } from './delete_spaces_button'; + +const space = { + id: 'my-space', + name: 'My Space', +}; +const buildMockChrome = () => { + return { + addBasePath: (path: string) => path, + }; +}; + +describe('DeleteSpacesButton', () => { + it('renders as expected', () => { + const mockHttp = { + delete: jest.fn(() => Promise.resolve()), + }; + const mockChrome = buildMockChrome(); + + const spacesManager = new SpacesManager(mockHttp, mockChrome, '/'); + + const spacesNavState: SpacesNavState = { + getActiveSpace: () => space, + refreshSpacesList: jest.fn(), + }; + + const wrapper = shallow( + + ); + expect(wrapper).toMatchSnapshot(); + }); +}); diff --git a/x-pack/plugins/spaces/public/views/management/edit_space/delete_spaces_button.tsx b/x-pack/plugins/spaces/public/views/management/edit_space/delete_spaces_button.tsx new file mode 100644 index 00000000000000..25aea985e01ac3 --- /dev/null +++ b/x-pack/plugins/spaces/public/views/management/edit_space/delete_spaces_button.tsx @@ -0,0 +1,115 @@ +/* + * 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 { EuiButton, EuiButtonIcon, EuiButtonIconProps } from '@elastic/eui'; +import { SpacesNavState } from 'plugins/spaces/views/nav_control'; +import React, { Component, Fragment } from 'react'; +// @ts-ignore +import { toastNotifications } from 'ui/notify'; +import { Space } from '../../../../common/model/space'; +import { SpacesManager } from '../../../lib/spaces_manager'; +import { ConfirmDeleteModal } from '../components/confirm_delete_modal'; + +interface Props { + style?: 'button' | 'icon'; + space: Space; + spacesManager: SpacesManager; + spacesNavState: SpacesNavState; + onDelete: () => void; +} + +interface State { + showConfirmDeleteModal: boolean; + showConfirmRedirectModal: boolean; +} + +export class DeleteSpacesButton extends Component { + public state = { + showConfirmDeleteModal: false, + showConfirmRedirectModal: false, + }; + + public render() { + const buttonText = `Delete space`; + + let ButtonComponent: any = EuiButton; + + const extraProps: EuiButtonIconProps = {}; + + if (this.props.style === 'icon') { + ButtonComponent = EuiButtonIcon; + extraProps.iconType = 'trash'; + } + + return ( + + + {buttonText} + + {this.getConfirmDeleteModal()} + + ); + } + + public onDeleteClick = () => { + this.setState({ + showConfirmDeleteModal: true, + }); + }; + + public getConfirmDeleteModal = () => { + if (!this.state.showConfirmDeleteModal) { + return null; + } + + const { spacesNavState, spacesManager } = this.props; + + return ( + { + this.setState({ + showConfirmDeleteModal: false, + }); + }} + onConfirm={this.deleteSpaces} + /> + ); + }; + + public deleteSpaces = async () => { + const { spacesManager, space, spacesNavState } = this.props; + + try { + await spacesManager.deleteSpace(space); + } catch (error) { + const { message: errorMessage = '' } = error.data || {}; + + toastNotifications.addDanger(`Error deleting space: ${errorMessage}`); + } + + this.setState({ + showConfirmDeleteModal: false, + }); + + const message = `Deleted "${space.name}" space.`; + + toastNotifications.addSuccess(message); + + if (this.props.onDelete) { + this.props.onDelete(); + } + + spacesNavState.refreshSpacesList(); + }; +} diff --git a/x-pack/plugins/spaces/public/views/management/edit_space/index.ts b/x-pack/plugins/spaces/public/views/management/edit_space/index.ts new file mode 100644 index 00000000000000..65018a5e9271c2 --- /dev/null +++ b/x-pack/plugins/spaces/public/views/management/edit_space/index.ts @@ -0,0 +1,7 @@ +/* + * 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. + */ +// @ts-ignore +export { ManageSpacePage } from './manage_space_page'; diff --git a/x-pack/plugins/spaces/public/views/management/edit_space/manage_space_page.test.tsx b/x-pack/plugins/spaces/public/views/management/edit_space/manage_space_page.test.tsx new file mode 100644 index 00000000000000..e560af2d3d99a6 --- /dev/null +++ b/x-pack/plugins/spaces/public/views/management/edit_space/manage_space_page.test.tsx @@ -0,0 +1,134 @@ +/* + * 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 { mount } from 'enzyme'; +import React from 'react'; +import { UserProfileProvider } from '../../../../../xpack_main/public/services/user_profile'; +import { SpacesManager } from '../../../lib'; +import { SpacesNavState } from '../../nav_control'; +import { ManageSpacePage } from './manage_space_page'; + +const space = { + id: 'my-space', + name: 'My Space', +}; +const buildMockChrome = () => { + return { + addBasePath: (path: string) => path, + }; +}; + +const buildUserProfile = (canManageSpaces: boolean) => { + return UserProfileProvider({ manageSpaces: canManageSpaces }); +}; + +describe('ManageSpacePage', () => { + it('allows a space to be created', async () => { + const mockHttp = { + delete: jest.fn(() => Promise.resolve()), + }; + const mockChrome = buildMockChrome(); + + const spacesManager = new SpacesManager(mockHttp, mockChrome, '/'); + spacesManager.createSpace = jest.fn(spacesManager.createSpace); + + const spacesNavState: SpacesNavState = { + getActiveSpace: () => space, + refreshSpacesList: jest.fn(), + }; + + const userProfile = buildUserProfile(true); + + const wrapper = mount( + + ); + const nameInput = wrapper.find('input[name="name"]'); + const descriptionInput = wrapper.find('input[name="description"]'); + + nameInput.simulate('change', { target: { value: 'New Space Name' } }); + descriptionInput.simulate('change', { target: { value: 'some description' } }); + + const createButton = wrapper.find('button[data-test-subj="save-space-button"]'); + createButton.simulate('click'); + await Promise.resolve(); + + expect(spacesManager.createSpace).toHaveBeenCalledWith({ + id: 'new-space-name', + name: 'New Space Name', + description: 'some description', + color: undefined, + initials: undefined, + }); + }); + + it('allows a space to be updated', async () => { + const mockHttp = { + get: jest.fn(async () => { + return Promise.resolve({ + data: { + id: 'existing-space', + name: 'Existing Space', + description: 'hey an existing space', + color: '#aabbcc', + initials: 'AB', + }, + }); + }), + delete: jest.fn(() => Promise.resolve()), + }; + const mockChrome = buildMockChrome(); + + const spacesManager = new SpacesManager(mockHttp, mockChrome, '/'); + spacesManager.getSpace = jest.fn(spacesManager.getSpace); + spacesManager.updateSpace = jest.fn(spacesManager.updateSpace); + + const spacesNavState: SpacesNavState = { + getActiveSpace: () => space, + refreshSpacesList: jest.fn(), + }; + + const userProfile = buildUserProfile(true); + + const wrapper = mount( + + ); + + await Promise.resolve(); + + expect(mockHttp.get).toHaveBeenCalledWith('/api/spaces/space/existing-space'); + + await Promise.resolve(); + + wrapper.update(); + + const nameInput = wrapper.find('input[name="name"]'); + const descriptionInput = wrapper.find('input[name="description"]'); + + nameInput.simulate('change', { target: { value: 'New Space Name' } }); + descriptionInput.simulate('change', { target: { value: 'some description' } }); + + const createButton = wrapper.find('button[data-test-subj="save-space-button"]'); + createButton.simulate('click'); + await Promise.resolve(); + + expect(spacesManager.updateSpace).toHaveBeenCalledWith({ + id: 'existing-space', + name: 'New Space Name', + description: 'some description', + color: '#aabbcc', + initials: 'AB', + }); + }); +}); diff --git a/x-pack/plugins/spaces/public/views/management/edit_space/manage_space_page.tsx b/x-pack/plugins/spaces/public/views/management/edit_space/manage_space_page.tsx new file mode 100644 index 00000000000000..e8b4890173ea7b --- /dev/null +++ b/x-pack/plugins/spaces/public/views/management/edit_space/manage_space_page.tsx @@ -0,0 +1,358 @@ +/* + * 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 { + EuiButton, + EuiButtonEmpty, + EuiFieldText, + EuiFlexGroup, + EuiFlexItem, + EuiForm, + EuiFormRow, + EuiHorizontalRule, + EuiLoadingSpinner, + EuiPage, + EuiPageBody, + EuiPageContent, + EuiPageContentBody, + EuiSpacer, + EuiTitle, +} from '@elastic/eui'; +import React, { ChangeEvent, Component, Fragment } from 'react'; + +import { SpacesNavState } from 'plugins/spaces/views/nav_control'; +import { UserProfile } from 'plugins/xpack_main/services/user_profile'; +// @ts-ignore +import { toastNotifications } from 'ui/notify'; +import { isReservedSpace } from '../../../../common'; +import { Space } from '../../../../common/model/space'; +import { SpaceAvatar } from '../../../components'; +import { SpacesManager } from '../../../lib'; +import { SecureSpaceMessage } from '../components/secure_space_message'; +import { UnauthorizedPrompt } from '../components/unauthorized_prompt'; +import { toSpaceIdentifier } from '../lib'; +import { SpaceValidator } from '../lib/validate_space'; +import { CustomizeSpaceAvatar } from './customize_space_avatar'; +import { DeleteSpacesButton } from './delete_spaces_button'; +import { ReservedSpaceBadge } from './reserved_space_badge'; +import { SpaceIdentifier } from './space_identifier'; + +interface Props { + spacesManager: SpacesManager; + spaceId?: string; + userProfile: UserProfile; + spacesNavState: SpacesNavState; +} + +interface State { + space: Partial; + isLoading: boolean; + formError?: { + isInvalid: boolean; + error?: string; + }; +} + +export class ManageSpacePage extends Component { + private readonly validator: SpaceValidator; + + constructor(props: Props) { + super(props); + this.validator = new SpaceValidator({ shouldValidate: false }); + this.state = { + isLoading: true, + space: {}, + }; + } + + public componentDidMount() { + const { spaceId, spacesManager } = this.props; + + if (spaceId) { + spacesManager + .getSpace(spaceId) + .then((result: any) => { + if (result.data) { + this.setState({ + space: result.data, + isLoading: false, + }); + } + }) + .catch(error => { + const { message = '' } = error.data || {}; + + toastNotifications.addDanger(`Error loading space: ${message}`); + this.backToSpacesList(); + }); + } else { + this.setState({ isLoading: false }); + } + } + + public render() { + const content = this.state.isLoading ? this.getLoadingIndicator() : this.getForm(); + + return ( + + + + {content} + + {this.maybeGetSecureSpacesMessage()} + + + ); + } + + public getLoadingIndicator = () => { + return ( +
+ {' '} + +

Loading...

+
+
+ ); + }; + + public getForm = () => { + const { userProfile } = this.props; + + if (!userProfile.hasCapability('manageSpaces')) { + return ; + } + + const { name = '', description = '' } = this.state.space; + + return ( + + {this.getFormHeading()} + + + + + + + + + + {name && ( + + + + + + + + + + + )} + + + + + {this.state.space && isReservedSpace(this.state.space) ? null : ( + + + + )} + + + + + + + + {this.getFormButtons()} + + ); + }; + + public getFormHeading = () => { + return ( + +

+ {this.getTitle()} +

+
+ ); + }; + + public getTitle = () => { + if (this.editingExistingSpace()) { + return `Edit space`; + } + return `Create space`; + }; + + public maybeGetSecureSpacesMessage = () => { + if (this.editingExistingSpace()) { + return ; + } + return null; + }; + + public getFormButtons = () => { + const saveText = this.editingExistingSpace() ? 'Update space' : 'Create space'; + return ( + + + + {saveText} + + + + + Cancel + + + + {this.getActionButton()} + + ); + }; + + public getActionButton = () => { + if (this.state.space && this.editingExistingSpace() && !isReservedSpace(this.state.space)) { + return ( + + + + ); + } + + return null; + }; + + public onNameChange = (e: ChangeEvent) => { + if (!this.state.space) { + return; + } + + const canUpdateId = !this.editingExistingSpace(); + + let { id } = this.state.space; + + if (canUpdateId) { + id = toSpaceIdentifier(e.target.value); + } + + this.setState({ + space: { + ...this.state.space, + name: e.target.value, + id, + }, + }); + }; + + public onDescriptionChange = (e: ChangeEvent) => { + this.setState({ + space: { + ...this.state.space, + description: e.target.value, + }, + }); + }; + + public onSpaceIdentifierChange = (e: ChangeEvent) => { + this.setState({ + space: { + ...this.state.space, + id: toSpaceIdentifier(e.target.value), + }, + }); + }; + + public onAvatarChange = (space: Partial) => { + this.setState({ + space, + }); + }; + + public saveSpace = () => { + this.validator.enableValidation(); + + const result = this.validator.validateForSave(this.state.space as Space); + if (result.isInvalid) { + this.setState({ + formError: result, + }); + + return; + } + + this.performSave(); + }; + + private performSave = () => { + if (!this.state.space) { + return; + } + + const name = this.state.space.name || ''; + const { id = toSpaceIdentifier(name), description, initials, color } = this.state.space; + + const params = { + name, + id, + description, + initials, + color, + }; + + let action; + if (this.editingExistingSpace()) { + action = this.props.spacesManager.updateSpace(params); + } else { + action = this.props.spacesManager.createSpace(params); + } + + action + .then(() => { + this.props.spacesNavState.refreshSpacesList(); + toastNotifications.addSuccess(`'${name}' was saved`); + window.location.hash = `#/management/spaces/list`; + }) + .catch(error => { + const { message = '' } = error.data || {}; + + toastNotifications.addDanger(`Error saving space: ${message}`); + }); + }; + + private backToSpacesList = () => { + window.location.hash = `#/management/spaces/list`; + }; + + private editingExistingSpace = () => !!this.props.spaceId; +} diff --git a/x-pack/plugins/spaces/public/views/management/edit_space/reserved_space_badge.test.tsx b/x-pack/plugins/spaces/public/views/management/edit_space/reserved_space_badge.test.tsx new file mode 100644 index 00000000000000..ae584ba715b63b --- /dev/null +++ b/x-pack/plugins/spaces/public/views/management/edit_space/reserved_space_badge.test.tsx @@ -0,0 +1,31 @@ +/* + * 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 { EuiIcon } from '@elastic/eui'; +import { shallow } from 'enzyme'; +import React from 'react'; +import { ReservedSpaceBadge } from './reserved_space_badge'; + +const reservedSpace = { + id: '', + name: '', + _reserved: true, +}; + +const unreservedSpace = { + id: '', + name: '', +}; + +test('it renders without crashing', () => { + const wrapper = shallow(); + expect(wrapper.find(EuiIcon)).toHaveLength(1); +}); + +test('it renders nothing for an unreserved space', () => { + const wrapper = shallow(); + expect(wrapper.find('*')).toHaveLength(0); +}); diff --git a/x-pack/plugins/spaces/public/views/management/edit_space/reserved_space_badge.tsx b/x-pack/plugins/spaces/public/views/management/edit_space/reserved_space_badge.tsx new file mode 100644 index 00000000000000..5c358cb4fd5caa --- /dev/null +++ b/x-pack/plugins/spaces/public/views/management/edit_space/reserved_space_badge.tsx @@ -0,0 +1,28 @@ +/* + * 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 React from 'react'; + +import { EuiIcon, EuiToolTip } from '@elastic/eui'; +import { isReservedSpace } from '../../../../common'; +import { Space } from '../../../../common/model/space'; + +interface Props { + space?: Space; +} + +export const ReservedSpaceBadge = (props: Props) => { + const { space } = props; + + if (space && isReservedSpace(space)) { + return ( + + + + ); + } + return null; +}; diff --git a/x-pack/plugins/spaces/public/views/management/edit_space/space_identifier.test.tsx b/x-pack/plugins/spaces/public/views/management/edit_space/space_identifier.test.tsx new file mode 100644 index 00000000000000..f4f9b11f94b2d2 --- /dev/null +++ b/x-pack/plugins/spaces/public/views/management/edit_space/space_identifier.test.tsx @@ -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. + */ + +import { shallow } from 'enzyme'; +import React from 'react'; +import { SpaceValidator } from '../lib'; +import { SpaceIdentifier } from './space_identifier'; + +test('renders without crashing', () => { + const props = { + space: { + id: '', + name: '', + }, + editable: true, + onChange: jest.fn(), + validator: new SpaceValidator(), + }; + const wrapper = shallow(); + expect(wrapper).toMatchSnapshot(); +}); diff --git a/x-pack/plugins/spaces/public/views/management/edit_space/space_identifier.tsx b/x-pack/plugins/spaces/public/views/management/edit_space/space_identifier.tsx new file mode 100644 index 00000000000000..1f9b006ef91ea2 --- /dev/null +++ b/x-pack/plugins/spaces/public/views/management/edit_space/space_identifier.tsx @@ -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 { + EuiFieldText, + EuiFormRow, + EuiLink, +} from '@elastic/eui'; +import React, { ChangeEvent, Component, Fragment } from 'react'; +import { Space } from '../../../../common/model/space'; +import { SpaceValidator } from '../lib'; + +interface Props { + space: Partial; + editable: boolean; + validator: SpaceValidator; + onChange: (e: ChangeEvent) => void; +} + +interface State { + editing: boolean; +} + +export class SpaceIdentifier extends Component { + + private textFieldRef: HTMLInputElement | null = null; + + constructor(props: Props) { + super(props); + this.state = { + editing: false, + }; + } + + public render() { + const { + id = '' + } = this.props.space; + + return ( + + + this.textFieldRef = ref} + /> + + + ); + } + + public getLabel = () => { + if (!this.props.editable) { + return (

URL identifier

); + } + + const editLinkText = this.state.editing ? `[stop editing]` : `[edit]`; + return (

URL identifier {editLinkText}

); + }; + + public getHelpText = () => { + return (

If the identifier is engineering, the Kibana URL is
https://my-kibana.example/s/engineering/app/kibana.

); + }; + + public onEditClick = () => { + this.setState({ + editing: !this.state.editing + }, () => { + if (this.textFieldRef && this.state.editing) { + this.textFieldRef.focus(); + } + }); + }; + + public onChange = (e: ChangeEvent) => { + if (!this.state.editing) { return; } + this.props.onChange(e); + }; +} diff --git a/x-pack/plugins/spaces/public/views/management/index.tsx b/x-pack/plugins/spaces/public/views/management/index.tsx new file mode 100644 index 00000000000000..0f32ca154cbe53 --- /dev/null +++ b/x-pack/plugins/spaces/public/views/management/index.tsx @@ -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 'plugins/spaces/views/management/page_routes'; +import React from 'react'; +import { + management, + PAGE_SUBTITLE_COMPONENT, + PAGE_TITLE_COMPONENT, + registerSettingsComponent, + // @ts-ignore +} from 'ui/management'; +// @ts-ignore +import routes from 'ui/routes'; +import { AdvancedSettingsSubtitle } from './components/advanced_settings_subtitle'; +import { AdvancedSettingsTitle } from './components/advanced_settings_title'; + +const MANAGE_SPACES_KEY = 'manage_spaces'; + +routes.defaults(/\/management/, { + resolve: { + spacesManagementSection(activeSpace: any) { + function getKibanaSection() { + return management.getSection('kibana'); + } + + function deregisterSpaces() { + getKibanaSection().deregister(MANAGE_SPACES_KEY); + } + + function ensureSpagesRegistered() { + const kibanaSection = getKibanaSection(); + + if (!kibanaSection.hasItem(MANAGE_SPACES_KEY)) { + kibanaSection.register(MANAGE_SPACES_KEY, { + name: 'spacesManagementLink', + order: 10, + display: 'Spaces', + url: `#/management/spaces/list`, + }); + } + + const PageTitle = () => ; + registerSettingsComponent(PAGE_TITLE_COMPONENT, PageTitle, true); + + const SubTitle = () => ; + registerSettingsComponent(PAGE_SUBTITLE_COMPONENT, SubTitle, true); + } + + deregisterSpaces(); + + ensureSpagesRegistered(); + }, + }, +}); diff --git a/x-pack/plugins/spaces/public/views/management/lib/index.ts b/x-pack/plugins/spaces/public/views/management/lib/index.ts new file mode 100644 index 00000000000000..4a158168febd8c --- /dev/null +++ b/x-pack/plugins/spaces/public/views/management/lib/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { toSpaceIdentifier, isValidSpaceIdentifier } from './space_identifier_utils'; + +export { SpaceValidator } from './validate_space'; diff --git a/x-pack/plugins/spaces/public/views/management/lib/space_identifier_utils.test.ts b/x-pack/plugins/spaces/public/views/management/lib/space_identifier_utils.test.ts new file mode 100644 index 00000000000000..c180f380d6845c --- /dev/null +++ b/x-pack/plugins/spaces/public/views/management/lib/space_identifier_utils.test.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. + */ +import { toSpaceIdentifier } from './space_identifier_utils'; + +test('it converts whitespace to dashes', () => { + const input = `this is a test`; + expect(toSpaceIdentifier(input)).toEqual('this-is-a-test'); +}); + +test('it converts everything to lowercase', () => { + const input = `THIS IS A TEST`; + expect(toSpaceIdentifier(input)).toEqual('this-is-a-test'); +}); + +test('it converts non-alphanumeric characters except for "_" to dashes', () => { + const input = `~!@#$%^&*()+-=[]{}\|';:"/.,<>?` + '`'; + + const expectedResult = new Array(input.length + 1).join('-'); + + expect(toSpaceIdentifier(input)).toEqual(expectedResult); +}); diff --git a/x-pack/plugins/spaces/public/views/management/lib/space_identifier_utils.ts b/x-pack/plugins/spaces/public/views/management/lib/space_identifier_utils.ts new file mode 100644 index 00000000000000..d7defc266b715c --- /dev/null +++ b/x-pack/plugins/spaces/public/views/management/lib/space_identifier_utils.ts @@ -0,0 +1,13 @@ +/* + * 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 function toSpaceIdentifier(value = '') { + return value.toLowerCase().replace(/[^a-z0-9_]/g, '-'); +} + +export function isValidSpaceIdentifier(value = '') { + return value === toSpaceIdentifier(value); +} diff --git a/x-pack/plugins/spaces/public/views/management/lib/validate_space.test.ts b/x-pack/plugins/spaces/public/views/management/lib/validate_space.test.ts new file mode 100644 index 00000000000000..bcaab2bfa0ec43 --- /dev/null +++ b/x-pack/plugins/spaces/public/views/management/lib/validate_space.test.ts @@ -0,0 +1,117 @@ +/* + * 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 { SpaceValidator } from './validate_space'; + +let validator: SpaceValidator; + +describe('validateSpaceName', () => { + beforeEach(() => { + validator = new SpaceValidator({ shouldValidate: true }); + }); + + test('it allows a name with special characters', () => { + const space = { + id: '', + name: 'This is the name of my Space! @#$%^&*()_+-=', + }; + + expect(validator.validateSpaceName(space)).toEqual({ isInvalid: false }); + }); + + test('it requires a non-empty value', () => { + const space = { + id: '', + name: '', + }; + + expect(validator.validateSpaceName(space)).toEqual({ + isInvalid: true, + error: `Name is required`, + }); + }); + + test('it cannot exceed 1024 characters', () => { + const space = { + id: '', + name: new Array(1026).join('A'), + }; + + expect(validator.validateSpaceName(space)).toEqual({ + isInvalid: true, + error: `Name must not exceed 1024 characters`, + }); + }); +}); + +describe('validateSpaceDescription', () => { + test('is optional', () => { + const space = { + id: '', + name: '', + }; + + expect(validator.validateSpaceDescription(space)).toEqual({ isInvalid: false }); + }); + + test('it cannot exceed 2000 characters', () => { + const space = { + id: '', + name: '', + description: new Array(2002).join('A'), + }; + + expect(validator.validateSpaceDescription(space)).toEqual({ + isInvalid: true, + error: `Description must not exceed 2000 characters`, + }); + }); +}); + +describe('validateURLIdentifier', () => { + test('it does not validate reserved spaces', () => { + const space = { + id: '', + name: '', + _reserved: true, + }; + + expect(validator.validateURLIdentifier(space)).toEqual({ isInvalid: false }); + }); + + test('it requires a non-empty value', () => { + const space = { + id: '', + name: '', + }; + + expect(validator.validateURLIdentifier(space)).toEqual({ + isInvalid: true, + error: `URL identifier is required`, + }); + }); + + test('it requires a valid Space Identifier', () => { + const space = { + id: 'invalid identifier', + name: '', + }; + + expect(validator.validateURLIdentifier(space)).toEqual({ + isInvalid: true, + error: 'URL identifier can only contain a-z, 0-9, and the characters "_" and "-"', + }); + }); + + test('it allows a valid Space Identifier', () => { + const space = { + id: '01-valid-context-01', + name: '', + }; + + expect(validator.validateURLIdentifier(space)).toEqual({ isInvalid: false }); + }); +}); diff --git a/x-pack/plugins/spaces/public/views/management/lib/validate_space.ts b/x-pack/plugins/spaces/public/views/management/lib/validate_space.ts new file mode 100644 index 00000000000000..66b931e7f4c9a9 --- /dev/null +++ b/x-pack/plugins/spaces/public/views/management/lib/validate_space.ts @@ -0,0 +1,101 @@ +/* + * 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 { isReservedSpace } from '../../../../common/is_reserved_space'; +import { Space } from '../../../../common/model/space'; +import { isValidSpaceIdentifier } from './space_identifier_utils'; + +interface SpaceValidatorOptions { + shouldValidate?: boolean; +} + +export class SpaceValidator { + private shouldValidate: boolean; + + constructor(options: SpaceValidatorOptions = {}) { + this.shouldValidate = options.shouldValidate || false; + } + + public enableValidation() { + this.shouldValidate = true; + } + + public disableValidation() { + this.shouldValidate = false; + } + + public validateSpaceName(space: Partial) { + if (!this.shouldValidate) { + return valid(); + } + + if (!space.name) { + return invalid(`Name is required`); + } + + if (space.name.length > 1024) { + return invalid(`Name must not exceed 1024 characters`); + } + + return valid(); + } + + public validateSpaceDescription(space: Partial) { + if (!this.shouldValidate) { + return valid(); + } + + if (space.description && space.description.length > 2000) { + return invalid(`Description must not exceed 2000 characters`); + } + + return valid(); + } + + public validateURLIdentifier(space: Partial) { + if (!this.shouldValidate) { + return valid(); + } + + if (isReservedSpace(space)) { + return valid(); + } + + if (!space.id) { + return invalid(`URL identifier is required`); + } + + if (!isValidSpaceIdentifier(space.id)) { + return invalid('URL identifier can only contain a-z, 0-9, and the characters "_" and "-"'); + } + + return valid(); + } + + public validateForSave(space: Space) { + const { isInvalid: isNameInvalid } = this.validateSpaceName(space); + const { isInvalid: isDescriptionInvalid } = this.validateSpaceDescription(space); + const { isInvalid: isIdentifierInvalid } = this.validateURLIdentifier(space); + + if (isNameInvalid || isDescriptionInvalid || isIdentifierInvalid) { + return invalid(); + } + + return valid(); + } +} + +function invalid(error: string = '') { + return { + isInvalid: true, + error, + }; +} + +function valid() { + return { + isInvalid: false, + }; +} diff --git a/x-pack/plugins/spaces/public/views/management/manage_spaces.less b/x-pack/plugins/spaces/public/views/management/manage_spaces.less new file mode 100644 index 00000000000000..15f085df6d1b6f --- /dev/null +++ b/x-pack/plugins/spaces/public/views/management/manage_spaces.less @@ -0,0 +1,22 @@ +.manageSpaces__application, .manageSpaces__.euiPanel, #manageSpacesReactRoot { + background: #f5f5f5; +} + +#manageSpacesReactRoot{ + flex-grow: 1; +} + +.manageSpace__euiPage { + padding: 0; +} + +.manageSpacePage, .spacesGridPage { + min-height: ~"calc(100vh - 70px)"; +} + +.manageSpacePage__content { + max-width: 760px; + margin-left: auto; + margin-right: auto; + flex-grow: 0; +} diff --git a/x-pack/plugins/spaces/public/views/management/page_routes.tsx b/x-pack/plugins/spaces/public/views/management/page_routes.tsx new file mode 100644 index 00000000000000..fa7f26a32aa839 --- /dev/null +++ b/x-pack/plugins/spaces/public/views/management/page_routes.tsx @@ -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 'plugins/spaces/views/management/manage_spaces.less'; +// @ts-ignore +import template from 'plugins/spaces/views/management/template.html'; +// @ts-ignore +import { UserProfileProvider } from 'plugins/xpack_main/services/user_profile'; +import 'ui/autoload/styles'; + +import { SpacesNavState } from 'plugins/spaces/views/nav_control'; +import React from 'react'; +import { render, unmountComponentAtNode } from 'react-dom'; +// @ts-ignore +import routes from 'ui/routes'; +import { SpacesManager } from '../../lib/spaces_manager'; +import { ManageSpacePage } from './edit_space'; +import { SpacesGridPage } from './spaces_grid'; + +const reactRootNodeId = 'manageSpacesReactRoot'; + +routes.when('/management/spaces/list', { + template, + controller( + $scope: any, + $http: any, + chrome: any, + Private: any, + spacesNavState: SpacesNavState, + spaceSelectorURL: string + ) { + const userProfile = Private(UserProfileProvider); + + $scope.$$postDigest(() => { + const domNode = document.getElementById(reactRootNodeId); + + const spacesManager = new SpacesManager($http, chrome, spaceSelectorURL); + + render( + , + domNode + ); + + // unmount react on controller destroy + $scope.$on('$destroy', () => { + if (domNode) { + unmountComponentAtNode(domNode); + } + }); + }); + }, +}); + +routes.when('/management/spaces/create', { + template, + controller( + $scope: any, + $http: any, + chrome: any, + Private: any, + spacesNavState: SpacesNavState, + spaceSelectorURL: string + ) { + const userProfile = Private(UserProfileProvider); + + $scope.$$postDigest(() => { + const domNode = document.getElementById(reactRootNodeId); + + const spacesManager = new SpacesManager($http, chrome, spaceSelectorURL); + + render( + , + domNode + ); + + // unmount react on controller destroy + $scope.$on('$destroy', () => { + if (domNode) { + unmountComponentAtNode(domNode); + } + }); + }); + }, +}); + +routes.when('/management/spaces/edit', { + redirectTo: '/management/spaces/list', +}); + +routes.when('/management/spaces/edit/:spaceId', { + template, + controller( + $scope: any, + $http: any, + $route: any, + chrome: any, + Private: any, + spacesNavState: SpacesNavState, + spaceSelectorURL: string + ) { + const userProfile = Private(UserProfileProvider); + + $scope.$$postDigest(() => { + const domNode = document.getElementById(reactRootNodeId); + + const { spaceId } = $route.current.params; + + const spacesManager = new SpacesManager($http, chrome, spaceSelectorURL); + + render( + , + domNode + ); + + // unmount react on controller destroy + $scope.$on('$destroy', () => { + if (domNode) { + unmountComponentAtNode(domNode); + } + }); + }); + }, +}); diff --git a/x-pack/plugins/spaces/public/views/management/spaces_grid/index.ts b/x-pack/plugins/spaces/public/views/management/spaces_grid/index.ts new file mode 100644 index 00000000000000..1aead143e5d572 --- /dev/null +++ b/x-pack/plugins/spaces/public/views/management/spaces_grid/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +export { SpacesGridPage } from './spaces_grid_page'; diff --git a/x-pack/plugins/spaces/public/views/management/spaces_grid/spaces_grid_page.tsx b/x-pack/plugins/spaces/public/views/management/spaces_grid/spaces_grid_page.tsx new file mode 100644 index 00000000000000..8debabf65c43da --- /dev/null +++ b/x-pack/plugins/spaces/public/views/management/spaces_grid/spaces_grid_page.tsx @@ -0,0 +1,275 @@ +/* + * 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 React, { Component, Fragment } from 'react'; + +import { + EuiButton, + EuiFlexGroup, + EuiFlexItem, + // @ts-ignore + EuiInMemoryTable, + EuiLink, + EuiPage, + EuiPageBody, + EuiPageContent, + EuiSpacer, + EuiText, +} from '@elastic/eui'; +// @ts-ignore +import { toastNotifications } from 'ui/notify'; + +import { SpacesNavState } from 'plugins/spaces/views/nav_control'; +import { UserProfile } from '../../../../../xpack_main/public/services/user_profile'; +import { isReservedSpace } from '../../../../common'; +import { Space } from '../../../../common/model/space'; +import { SpaceAvatar } from '../../../components'; +import { SpacesManager } from '../../../lib/spaces_manager'; +import { ConfirmDeleteModal } from '../components/confirm_delete_modal'; +import { SecureSpaceMessage } from '../components/secure_space_message'; +import { UnauthorizedPrompt } from '../components/unauthorized_prompt'; + +interface Props { + spacesManager: SpacesManager; + spacesNavState: SpacesNavState; + userProfile: UserProfile; +} + +interface State { + spaces: Space[]; + loading: boolean; + showConfirmDeleteModal: boolean; + selectedSpace: Space | null; + error: Error | null; +} + +export class SpacesGridPage extends Component { + constructor(props: Props) { + super(props); + this.state = { + spaces: [], + loading: true, + showConfirmDeleteModal: false, + selectedSpace: null, + error: null, + }; + } + + public componentDidMount() { + this.loadGrid(); + } + + public render() { + return ( + + + {this.getPageContent()} + + + {this.getConfirmDeleteModal()} + + ); + } + + public getPageContent() { + if (!this.props.userProfile.hasCapability('manageSpaces')) { + return ; + } + + return ( + + + + +

Spaces

+
+
+ {this.getPrimaryActionButton()} +
+ + + +
+ ); + } + + public getPrimaryActionButton() { + return ( + { + window.location.hash = `#/management/spaces/create`; + }} + > + Create space + + ); + } + + public getConfirmDeleteModal = () => { + if (!this.state.showConfirmDeleteModal || !this.state.selectedSpace) { + return null; + } + + const { spacesNavState, spacesManager } = this.props; + + return ( + { + this.setState({ + showConfirmDeleteModal: false, + }); + }} + onConfirm={this.deleteSpace} + /> + ); + }; + + public deleteSpace = async () => { + const { spacesManager, spacesNavState } = this.props; + + const space = this.state.selectedSpace; + + if (!space) { + return; + } + + try { + await spacesManager.deleteSpace(space); + } catch (error) { + const { message: errorMessage = '' } = error.data || {}; + + toastNotifications.addDanger(`Error deleting space: ${errorMessage}`); + } + + this.setState({ + showConfirmDeleteModal: false, + }); + + this.loadGrid(); + + const message = `Deleted "${space.name}" space.`; + + toastNotifications.addSuccess(message); + + spacesNavState.refreshSpacesList(); + }; + + public loadGrid = () => { + const { spacesManager } = this.props; + + this.setState({ + loading: true, + spaces: [], + }); + + const setSpaces = (spaces: Space[]) => { + this.setState({ + loading: false, + spaces, + }); + }; + + spacesManager + .getSpaces() + .then(spaces => { + setSpaces(spaces); + }) + .catch(error => { + this.setState({ + loading: false, + error, + }); + }); + }; + + public getColumnConfig() { + return [ + { + field: 'name', + name: 'Space', + sortable: true, + render: (value: string, record: Space) => { + return ( + { + this.onEditSpaceClick(record); + }} + > + + + + + + {record.name} + + + + ); + }, + }, + { + field: 'id', + name: 'Identifier', + sortable: true, + }, + { + field: 'description', + name: 'Description', + sortable: true, + }, + { + name: 'Actions', + actions: [ + { + name: 'Edit', + description: 'Edit this space.', + onClick: this.onEditSpaceClick, + type: 'icon', + icon: 'pencil', + color: 'primary', + }, + { + available: (record: Space) => !isReservedSpace(record), + name: 'Delete', + description: 'Delete this space.', + onClick: this.onDeleteSpaceClick, + type: 'icon', + icon: 'trash', + color: 'danger', + }, + ], + }, + ]; + } + + private onEditSpaceClick = (space: Space) => { + window.location.hash = `#/management/spaces/edit/${encodeURIComponent(space.id)}`; + }; + + private onDeleteSpaceClick = (space: Space) => { + this.setState({ + selectedSpace: space, + showConfirmDeleteModal: true, + }); + }; +} diff --git a/x-pack/plugins/spaces/public/views/management/template.html b/x-pack/plugins/spaces/public/views/management/template.html new file mode 100644 index 00000000000000..b6df9d36f8cb02 --- /dev/null +++ b/x-pack/plugins/spaces/public/views/management/template.html @@ -0,0 +1,3 @@ + +
+ diff --git a/x-pack/plugins/spaces/public/views/nav_control/__snapshots__/nav_control_popover.test.tsx.snap b/x-pack/plugins/spaces/public/views/nav_control/__snapshots__/nav_control_popover.test.tsx.snap new file mode 100644 index 00000000000000..2c4d562c1e4f6d --- /dev/null +++ b/x-pack/plugins/spaces/public/views/nav_control/__snapshots__/nav_control_popover.test.tsx.snap @@ -0,0 +1,56 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`NavControlPopover renders without crashing 1`] = ` + + +
+ + + +
+
+ + foo + +
+
+
+ } + closePopover={[Function]} + data-test-subj="spacesNavSelector" + hasArrow={true} + id="spacesMenuPopover" + isOpen={false} + ownFocus={true} + panelPaddingSize="none" +> + + +`; diff --git a/x-pack/plugins/spaces/public/views/nav_control/components/__snapshots__/spaces_description.test.tsx.snap b/x-pack/plugins/spaces/public/views/nav_control/components/__snapshots__/spaces_description.test.tsx.snap new file mode 100644 index 00000000000000..ae120ec8fbab25 --- /dev/null +++ b/x-pack/plugins/spaces/public/views/nav_control/components/__snapshots__/spaces_description.test.tsx.snap @@ -0,0 +1,37 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`SpacesDescription renders without crashing 1`] = ` + + +

+ Organize your dashboards and other saved objects into meaningful categories. +

+
+
+ +
+
+`; diff --git a/x-pack/plugins/spaces/public/views/nav_control/components/spaces_description.less b/x-pack/plugins/spaces/public/views/nav_control/components/spaces_description.less new file mode 100644 index 00000000000000..ceff24115bcf34 --- /dev/null +++ b/x-pack/plugins/spaces/public/views/nav_control/components/spaces_description.less @@ -0,0 +1,8 @@ +.spacesDescription { + max-width: 300px; +} + +.spacesDescription__text, +.spacesDescription__manageButtonWrapper { + padding: 12px; +} diff --git a/x-pack/plugins/spaces/public/views/nav_control/components/spaces_description.test.tsx b/x-pack/plugins/spaces/public/views/nav_control/components/spaces_description.test.tsx new file mode 100644 index 00000000000000..9d8dd36999c751 --- /dev/null +++ b/x-pack/plugins/spaces/public/views/nav_control/components/spaces_description.test.tsx @@ -0,0 +1,17 @@ +/* + * 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 { shallow } from 'enzyme'; +import React from 'react'; +import { SpacesDescription } from './spaces_description'; + +describe('SpacesDescription', () => { + it('renders without crashing', () => { + expect( + shallow( true }} />) + ).toMatchSnapshot(); + }); +}); diff --git a/x-pack/plugins/spaces/public/views/nav_control/components/spaces_description.tsx b/x-pack/plugins/spaces/public/views/nav_control/components/spaces_description.tsx new file mode 100644 index 00000000000000..8d033996f6a8d9 --- /dev/null +++ b/x-pack/plugins/spaces/public/views/nav_control/components/spaces_description.tsx @@ -0,0 +1,34 @@ +/* + * 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 { EuiContextMenuPanel, EuiText } from '@elastic/eui'; +import React, { SFC } from 'react'; +import { UserProfile } from '../../../../../xpack_main/public/services/user_profile'; +import { ManageSpacesButton } from '../../../components'; +import { SPACES_FEATURE_DESCRIPTION } from '../../../lib/constants'; +import './spaces_description.less'; + +interface Props { + userProfile: UserProfile; +} + +export const SpacesDescription: SFC = (props: Props) => { + const panelProps = { + className: 'spacesDescription', + title: 'Spaces', + }; + + return ( + + +

{SPACES_FEATURE_DESCRIPTION}

+
+
+ +
+
+ ); +}; diff --git a/x-pack/plugins/spaces/public/views/nav_control/components/spaces_menu.less b/x-pack/plugins/spaces/public/views/nav_control/components/spaces_menu.less new file mode 100644 index 00000000000000..4ae51c954b00ac --- /dev/null +++ b/x-pack/plugins/spaces/public/views/nav_control/components/spaces_menu.less @@ -0,0 +1,9 @@ +.spacesMenu__spacesList { + max-height: 320px; + overflow-y: auto; +} + +.spacesMenu__searchFieldWrapper, +.spacesMenu__manageButtonWrapper { + padding: 12px; +} diff --git a/x-pack/plugins/spaces/public/views/nav_control/components/spaces_menu.tsx b/x-pack/plugins/spaces/public/views/nav_control/components/spaces_menu.tsx new file mode 100644 index 00000000000000..7827e26555230d --- /dev/null +++ b/x-pack/plugins/spaces/public/views/nav_control/components/spaces_menu.tsx @@ -0,0 +1,167 @@ +/* + * 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 { EuiContextMenuItem, EuiContextMenuPanel, EuiFieldSearch, EuiText } from '@elastic/eui'; +import React, { Component } from 'react'; +import { UserProfile } from '../../../../../xpack_main/public/services/user_profile'; +import { SPACE_SEARCH_COUNT_THRESHOLD } from '../../../../common/constants'; +import { Space } from '../../../../common/model/space'; +import { ManageSpacesButton, SpaceAvatar } from '../../../components'; +import './spaces_menu.less'; + +interface Props { + spaces: Space[]; + onSelectSpace: (space: Space) => void; + userProfile: UserProfile; +} + +interface State { + searchTerm: string; + allowSpacesListFocus: boolean; +} + +export class SpacesMenu extends Component { + public state = { + searchTerm: '', + allowSpacesListFocus: false, + }; + + public render() { + const { searchTerm } = this.state; + + const items = this.getVisibleSpaces(searchTerm).map(this.renderSpaceMenuItem); + + const panelProps = { + className: 'spacesMenu', + title: 'Change current space', + watchedItemProps: ['data-search-term'], + }; + + if (this.props.spaces.length >= SPACE_SEARCH_COUNT_THRESHOLD) { + return ( + + {this.renderSearchField()} + {this.renderSpacesListPanel(items, searchTerm)} + {this.renderManageButton()} + + ); + } + + items.push(this.renderManageButton()); + + return ; + } + + private getVisibleSpaces = (searchTerm: string): Space[] => { + const { spaces } = this.props; + + let filteredSpaces = spaces; + if (searchTerm) { + filteredSpaces = spaces.filter(space => { + const { name, description = '' } = space; + return ( + name.toLowerCase().indexOf(searchTerm) >= 0 || + description.toLowerCase().indexOf(searchTerm) >= 0 + ); + }); + } + + return filteredSpaces; + }; + + private renderSpacesListPanel = (items: JSX.Element[], searchTerm: string) => { + if (items.length === 0) { + return ( + + {' '} + no spaces found{' '} + + ); + } + + return ( + + ); + }; + + private renderSearchField = () => { + return ( +
+ +
+ ); + }; + + private onSearchKeyDown = (e: any) => { + // 9: tab + // 13: enter + // 40: arrow-down + const focusableKeyCodes = [9, 13, 40]; + + const keyCode = e.keyCode; + if (focusableKeyCodes.includes(keyCode)) { + // Allows the spaces list panel to recieve focus. This enables keyboard and screen reader navigation + this.setState({ + allowSpacesListFocus: true, + }); + } + }; + + private onSearchFocus = () => { + this.setState({ + allowSpacesListFocus: false, + }); + }; + + private renderManageButton = () => { + return ( +
+ +
+ ); + }; + + private onSearch = (searchTerm: string) => { + this.setState({ + searchTerm: searchTerm.trim().toLowerCase(), + }); + }; + + private renderSpaceMenuItem = (space: Space): JSX.Element => { + const icon = ; + return ( + + {space.name} + + ); + }; +} diff --git a/x-pack/plugins/spaces/public/views/nav_control/index.ts b/x-pack/plugins/spaces/public/views/nav_control/index.ts new file mode 100644 index 00000000000000..541c79a8fd4a36 --- /dev/null +++ b/x-pack/plugins/spaces/public/views/nav_control/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import './nav_control'; + +export { SpacesNavState } from './nav_control'; diff --git a/x-pack/plugins/spaces/public/views/nav_control/nav_control.html b/x-pack/plugins/spaces/public/views/nav_control/nav_control.html new file mode 100644 index 00000000000000..284866cc43bed5 --- /dev/null +++ b/x-pack/plugins/spaces/public/views/nav_control/nav_control.html @@ -0,0 +1,3 @@ +
+
+
diff --git a/x-pack/plugins/spaces/public/views/nav_control/nav_control.less b/x-pack/plugins/spaces/public/views/nav_control/nav_control.less new file mode 100644 index 00000000000000..6feccde1080a2b --- /dev/null +++ b/x-pack/plugins/spaces/public/views/nav_control/nav_control.less @@ -0,0 +1,3 @@ +.global-nav-link__icon .spaceNavGraphic { + margin-top: 0.5em; +} diff --git a/x-pack/plugins/spaces/public/views/nav_control/nav_control.tsx b/x-pack/plugins/spaces/public/views/nav_control/nav_control.tsx new file mode 100644 index 00000000000000..690ae64f775362 --- /dev/null +++ b/x-pack/plugins/spaces/public/views/nav_control/nav_control.tsx @@ -0,0 +1,87 @@ +/* + * 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 { constant } from 'lodash'; +import { SpacesManager } from 'plugins/spaces/lib/spaces_manager'; +// @ts-ignore +import template from 'plugins/spaces/views/nav_control/nav_control.html'; +import 'plugins/spaces/views/nav_control/nav_control.less'; +import { UserProfileProvider } from 'plugins/xpack_main/services/user_profile'; +// @ts-ignore +import { uiModules } from 'ui/modules'; +// @ts-ignore +import { chromeNavControlsRegistry } from 'ui/registry/chrome_nav_controls'; + +import { NavControlPopover } from 'plugins/spaces/views/nav_control/nav_control_popover'; +import React from 'react'; +import { render, unmountComponentAtNode } from 'react-dom'; +import { Space } from '../../../common/model/space'; + +chromeNavControlsRegistry.register( + constant({ + name: 'spaces', + order: 90, + template, + }) +); + +const module = uiModules.get('spaces_nav', ['kibana']); + +export interface SpacesNavState { + getActiveSpace: () => Space; + refreshSpacesList: () => void; +} + +let spacesManager: SpacesManager; + +module.controller( + 'spacesNavController', + ($scope: any, $http: any, chrome: any, Private: any, activeSpace: any) => { + const userProfile = Private(UserProfileProvider); + + const domNode = document.getElementById(`spacesNavReactRoot`); + const spaceSelectorURL = chrome.getInjected('spaceSelectorURL'); + + spacesManager = new SpacesManager($http, chrome, spaceSelectorURL); + + let mounted = false; + + $scope.$parent.$watch('isVisible', function isVisibleWatcher(isVisible: boolean) { + if (isVisible && !mounted) { + render( + , + domNode + ); + mounted = true; + } + }); + + // unmount react on controller destroy + $scope.$on('$destroy', () => { + if (domNode) { + unmountComponentAtNode(domNode); + } + mounted = false; + }); + } +); + +module.service('spacesNavState', (activeSpace: any) => { + return { + getActiveSpace: () => { + return activeSpace.space; + }, + refreshSpacesList: () => { + if (spacesManager) { + spacesManager.requestRefresh(); + } + }, + } as SpacesNavState; +}); diff --git a/x-pack/plugins/spaces/public/views/nav_control/nav_control_popover.test.tsx b/x-pack/plugins/spaces/public/views/nav_control/nav_control_popover.test.tsx new file mode 100644 index 00000000000000..67f6e81df956e6 --- /dev/null +++ b/x-pack/plugins/spaces/public/views/nav_control/nav_control_popover.test.tsx @@ -0,0 +1,87 @@ +/* + * 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 { mount, shallow } from 'enzyme'; +import React from 'react'; +import { SpaceAvatar } from '../../components'; +import { SpacesManager } from '../../lib/spaces_manager'; +import { NavControlPopover } from './nav_control_popover'; + +const mockChrome = { + addBasePath: jest.fn((a: string) => a), +}; + +const createMockHttpAgent = (withSpaces = false) => { + const spaces = [ + { + id: '', + name: 'space 1', + }, + { + id: '', + name: 'space 2', + }, + ]; + + const mockHttpAgent = { + get: async () => { + const result = withSpaces ? spaces : []; + + return { + data: result, + }; + }, + }; + return mockHttpAgent; +}; + +describe('NavControlPopover', () => { + it('renders without crashing', () => { + const activeSpace = { + space: { id: '', name: 'foo' }, + valid: true, + }; + + const spacesManager = new SpacesManager(createMockHttpAgent(), mockChrome, '/'); + + const wrapper = shallow( + true }} + /> + ); + expect(wrapper).toMatchSnapshot(); + }); + + it('renders a SpaceAvatar with the active space', async () => { + const activeSpace = { + space: { id: '', name: 'foo' }, + valid: true, + }; + + const mockAgent = createMockHttpAgent(true); + + const spacesManager = new SpacesManager(mockAgent, mockChrome, '/'); + + const wrapper = mount( + true }} + /> + ); + + return new Promise(resolve => { + setTimeout(() => { + expect(wrapper.state().spaces).toHaveLength(2); + wrapper.update(); + expect(wrapper.find(SpaceAvatar)).toHaveLength(1); + resolve(); + }, 20); + }); + }); +}); diff --git a/x-pack/plugins/spaces/public/views/nav_control/nav_control_popover.tsx b/x-pack/plugins/spaces/public/views/nav_control/nav_control_popover.tsx new file mode 100644 index 00000000000000..2a7882b683ef83 --- /dev/null +++ b/x-pack/plugins/spaces/public/views/nav_control/nav_control_popover.tsx @@ -0,0 +1,160 @@ +/* + * 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 { EuiAvatar, EuiPopover } from '@elastic/eui'; +import React, { Component } from 'react'; +import { UserProfile } from '../../../../xpack_main/public/services/user_profile'; +import { Space } from '../../../common/model/space'; +import { SpaceAvatar } from '../../components'; +import { SpacesManager } from '../../lib/spaces_manager'; +import { SpacesDescription } from './components/spaces_description'; +import { SpacesMenu } from './components/spaces_menu'; + +interface Props { + spacesManager: SpacesManager; + activeSpace: { + valid: boolean; + error?: string; + space: Space; + }; + userProfile: UserProfile; +} + +interface State { + showSpaceSelector: boolean; + loading: boolean; + activeSpace: Space | null; + spaces: Space[]; +} + +export class NavControlPopover extends Component { + constructor(props: Props) { + super(props); + this.state = { + showSpaceSelector: false, + loading: false, + activeSpace: props.activeSpace.space, + spaces: [], + }; + } + + public componentDidMount() { + this.loadSpaces(); + + if (this.props.spacesManager) { + this.props.spacesManager.on('request_refresh', () => { + this.loadSpaces(); + }); + } + } + + public render() { + const button = this.getActiveSpaceButton(); + if (!button) { + return null; + } + + let element: React.ReactNode; + if (this.state.spaces.length < 2) { + element = ; + } else { + element = ( + + ); + } + + return ( + + {element} + + ); + } + + private async loadSpaces() { + const { spacesManager, activeSpace } = this.props; + + this.setState({ + loading: true, + }); + + const spaces = await spacesManager.getSpaces(); + + // Update the active space definition, if it changed since the last load operation + let activeSpaceEntry: Space | null = activeSpace.space; + + if (activeSpace.valid) { + activeSpaceEntry = spaces.find(space => space.id === this.props.activeSpace.space.id) || null; + } + + this.setState({ + spaces, + activeSpace: activeSpaceEntry, + loading: false, + }); + } + + private getActiveSpaceButton = () => { + const { activeSpace } = this.state; + + if (!activeSpace) { + return this.getButton( + , + 'error' + ); + } + + return this.getButton( + , + (activeSpace as Space).name + ); + }; + + private getButton = (linkIcon: JSX.Element, linkTitle: string) => { + // Mimics the current angular-based navigation link + return ( + + ); + }; + + private toggleSpaceSelector = () => { + const isOpening = !this.state.showSpaceSelector; + if (isOpening) { + this.loadSpaces(); + } + + this.setState({ + showSpaceSelector: !this.state.showSpaceSelector, + }); + }; + + private closeSpaceSelector = () => { + this.setState({ + showSpaceSelector: false, + }); + }; + + private onSelectSpace = (space: Space) => { + this.props.spacesManager.changeSelectedSpace(space); + }; +} diff --git a/x-pack/plugins/spaces/public/views/space_selector/__snapshots__/space_selector.test.tsx.snap b/x-pack/plugins/spaces/public/views/space_selector/__snapshots__/space_selector.test.tsx.snap new file mode 100644 index 00000000000000..6dcb552b2ac78c --- /dev/null +++ b/x-pack/plugins/spaces/public/views/space_selector/__snapshots__/space_selector.test.tsx.snap @@ -0,0 +1,89 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`it renders without crashing 1`] = ` + + + + +
+ +
+ + +

+ Select your space +

+
+
+
+ + + + +

+ You can change your space at anytime. +

+
+
+
+ + + + + + No spaces match search criteria + + +
+
+
+`; diff --git a/x-pack/plugins/spaces/public/views/space_selector/index.tsx b/x-pack/plugins/spaces/public/views/space_selector/index.tsx new file mode 100644 index 00000000000000..e9b9a1795cf20a --- /dev/null +++ b/x-pack/plugins/spaces/public/views/space_selector/index.tsx @@ -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 { SpacesManager } from 'plugins/spaces/lib/spaces_manager'; +// @ts-ignore +import template from 'plugins/spaces/views/space_selector/space_selector.html'; +import 'plugins/spaces/views/space_selector/space_selector.less'; +import 'ui/autoload/styles'; +import chrome from 'ui/chrome'; +// @ts-ignore +import { uiModules } from 'ui/modules'; + +import React from 'react'; +import { render, unmountComponentAtNode } from 'react-dom'; +import { Space } from '../../../common/model/space'; +import { SpaceSelector } from './space_selector'; + +const module = uiModules.get('spaces_selector', []); +module.controller( + 'spacesSelectorController', + ($scope: any, $http: any, spaces: Space[], spaceSelectorURL: string) => { + const domNode = document.getElementById('spaceSelectorRoot'); + + const spacesManager = new SpacesManager($http, chrome, spaceSelectorURL); + + render(, domNode); + + // unmount react on controller destroy + $scope.$on('$destroy', () => { + if (domNode) { + unmountComponentAtNode(domNode); + } + }); + } +); + +chrome.setVisible(false).setRootTemplate(template); diff --git a/x-pack/plugins/spaces/public/views/space_selector/space_selector.html b/x-pack/plugins/spaces/public/views/space_selector/space_selector.html new file mode 100644 index 00000000000000..2dbf9fac3f68b0 --- /dev/null +++ b/x-pack/plugins/spaces/public/views/space_selector/space_selector.html @@ -0,0 +1,3 @@ +
+
+
diff --git a/x-pack/plugins/spaces/public/views/space_selector/space_selector.less b/x-pack/plugins/spaces/public/views/space_selector/space_selector.less new file mode 100644 index 00000000000000..33191ad976b44b --- /dev/null +++ b/x-pack/plugins/spaces/public/views/space_selector/space_selector.less @@ -0,0 +1,46 @@ +@import "~ui/styles/variables"; + +#spaceSelectorRootWrap, #spaceSelectorRoot { + background-color: @globalColorLightestGray; +} + +#spaceSelectorRootWrap { + flex-grow: 1; +} +.spaceSelector__page { + padding: 0; +} + +.spaceSelector__pageContent { + background-color: transparent; + box-shadow: none; + border: none; + text-align: center; +} + +.spaceSelector__heading { + padding: 40px 16px; + background-image: linear-gradient(-194deg, #027AA5 0%, #24A1AB 75%, #3BBBAF 100%, #3EBEB0 100%); + justify-content: center; + text-align: center; +} + +.spaceSelector__logoCircle { + margin: 0 auto; + width: 80px; + height: 80px; + line-height: 80px; + text-align: center; + background-color: @globalColorWhite; + border-radius: 50%; + box-shadow: + 0 6px 12px -1px fadeout(darken(@globalColorBlue, 10%), 80%), + 0 4px 4px -1px fadeout(darken(@globalColorBlue, 10%), 80%), + 0 2px 2px 0 fadeout(darken(@globalColorBlue, 10%), 80%); +} + + +.spaceSelector__searchHolder { + width: 400px; // make sure it's as wide as our default form element width + max-width: 100%; +} diff --git a/x-pack/plugins/spaces/public/views/space_selector/space_selector.test.tsx b/x-pack/plugins/spaces/public/views/space_selector/space_selector.test.tsx new file mode 100644 index 00000000000000..96d99f0a293609 --- /dev/null +++ b/x-pack/plugins/spaces/public/views/space_selector/space_selector.test.tsx @@ -0,0 +1,73 @@ +/* + * 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 { render, shallow } from 'enzyme'; +import React from 'react'; +import chrome from 'ui/chrome'; +import { Space } from '../../../common/model/space'; +import { SpacesManager } from '../../lib/spaces_manager'; +import { SpaceSelector } from './space_selector'; + +function getHttpAgent(spaces: Space[] = []) { + const httpAgent: any = () => { + return; + }; + httpAgent.get = jest.fn(() => Promise.resolve({ data: spaces })); + + return httpAgent; +} + +function getSpacesManager(spaces: Space[] = []) { + const manager = new SpacesManager(getHttpAgent(spaces), chrome, '/'); + + const origGet = manager.getSpaces; + manager.getSpaces = jest.fn(origGet); + + return manager; +} + +test('it renders without crashing', () => { + const spacesManager = getSpacesManager(); + const component = shallow(); + expect(component).toMatchSnapshot(); +}); + +test('it uses the spaces on props, when provided', () => { + const spacesManager = getSpacesManager(); + + const spaces = [ + { + id: 'space-1', + name: 'Space 1', + description: 'This is the first space', + }, + ]; + + const component = render(); + + return Promise.resolve().then(() => { + expect(component.find('.spaceCard')).toHaveLength(1); + expect(spacesManager.getSpaces).toHaveBeenCalledTimes(0); + }); +}); + +test('it queries for spaces when not provided on props', () => { + const spaces = [ + { + id: 'space-1', + name: 'Space 1', + description: 'This is the first space', + }, + ]; + + const spacesManager = getSpacesManager(spaces); + + shallow(); + + return Promise.resolve().then(() => { + expect(spacesManager.getSpaces).toHaveBeenCalledTimes(1); + }); +}); diff --git a/x-pack/plugins/spaces/public/views/space_selector/space_selector.tsx b/x-pack/plugins/spaces/public/views/space_selector/space_selector.tsx new file mode 100644 index 00000000000000..a4975f23508efe --- /dev/null +++ b/x-pack/plugins/spaces/public/views/space_selector/space_selector.tsx @@ -0,0 +1,165 @@ +/* + * 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 { + EuiFieldSearch, + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiPage, + EuiPageBody, + EuiPageContent, + EuiPageHeader, + EuiPageHeaderSection, + EuiSpacer, + EuiText, + EuiTitle, +} from '@elastic/eui'; +import { SpacesManager } from 'plugins/spaces/lib'; +import React, { Component, Fragment } from 'react'; +import { SPACE_SEARCH_COUNT_THRESHOLD } from '../../../common/constants'; +import { Space } from '../../../common/model/space'; +import { SpaceCards } from '../components/space_cards'; + +interface Props { + spaces?: Space[]; + spacesManager: SpacesManager; +} + +interface State { + loading: boolean; + searchTerm: string; + spaces: Space[]; +} + +export class SpaceSelector extends Component { + constructor(props: Props) { + super(props); + + const state: State = { + loading: false, + searchTerm: '', + spaces: [], + }; + + if (Array.isArray(props.spaces)) { + state.spaces = [...props.spaces]; + } + + this.state = state; + } + + public componentDidMount() { + if (this.state.spaces.length === 0) { + this.loadSpaces(); + } + } + + public loadSpaces() { + this.setState({ loading: true }); + const { spacesManager } = this.props; + + spacesManager.getSpaces().then(spaces => { + this.setState({ + loading: false, + spaces, + }); + }); + } + + public render() { + const { spaces, searchTerm } = this.state; + + let filteredSpaces = spaces; + if (searchTerm) { + filteredSpaces = spaces.filter( + space => + space.name.toLowerCase().indexOf(searchTerm) >= 0 || + (space.description || '').toLowerCase().indexOf(searchTerm) >= 0 + ); + } + + return ( + + + + +
+ +
+ + + + +

Select your space

+
+
+
+ + + {this.getSearchField()} + + + +

You can change your space at anytime.

+
+
+
+ + + + + + {filteredSpaces.length === 0 && ( + + + + No spaces match search criteria + + + )} +
+
+
+ ); + } + + public getSearchField = () => { + if (!this.props.spaces || this.props.spaces.length < SPACE_SEARCH_COUNT_THRESHOLD) { + return null; + } + return ( + + + + ); + }; + + public onSearch = (searchTerm = '') => { + this.setState({ + searchTerm: searchTerm.trim().toLowerCase(), + }); + }; + + public onSelectSpace = (space: Space) => { + this.props.spacesManager.changeSelectedSpace(space); + }; +} diff --git a/x-pack/plugins/spaces/server/lib/__snapshots__/create_default_space.test.ts.snap b/x-pack/plugins/spaces/server/lib/__snapshots__/create_default_space.test.ts.snap new file mode 100644 index 00000000000000..bbb3b1918718df --- /dev/null +++ b/x-pack/plugins/spaces/server/lib/__snapshots__/create_default_space.test.ts.snap @@ -0,0 +1,5 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`it throws all other errors from the saved objects client when checking for the default space 1`] = `"unit test: unexpected exception condition"`; + +exports[`it throws other errors if there is an error creating the default space 1`] = `"unit test: some other unexpected error"`; diff --git a/x-pack/plugins/spaces/server/lib/__snapshots__/spaces_client.test.ts.snap b/x-pack/plugins/spaces/server/lib/__snapshots__/spaces_client.test.ts.snap new file mode 100644 index 00000000000000..4b0c0274cedf98 --- /dev/null +++ b/x-pack/plugins/spaces/server/lib/__snapshots__/spaces_client.test.ts.snap @@ -0,0 +1,17 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`#create useRbacForRequest is true throws Boom.forbidden if the user isn't authorized at space 1`] = `"Unauthorized to create spaces"`; + +exports[`#delete authorization is null throws Boom.badRequest when the space is reserved 1`] = `"This Space cannot be deleted because it is reserved."`; + +exports[`#delete authorization.mode.useRbacForRequest returns false throws Boom.badRequest when the space is reserved 1`] = `"This Space cannot be deleted because it is reserved."`; + +exports[`#delete authorization.mode.useRbacForRequest returns true throws Boom.badRequest if the user is authorized but the space is reserved 1`] = `"This Space cannot be deleted because it is reserved."`; + +exports[`#delete authorization.mode.useRbacForRequest returns true throws Boom.forbidden if the user isn't authorized 1`] = `"Unauthorized to delete spaces"`; + +exports[`#get useRbacForRequest is true throws Boom.forbidden if the user isn't authorized at space 1`] = `"Unauthorized to get foo-space space"`; + +exports[`#getAll useRbacForRequest is true throws Boom.forbidden when user isn't authorized for any spaces 1`] = `"Forbidden"`; + +exports[`#update useRbacForRequest is true throws Boom.forbidden when user isn't authorized at space 1`] = `"Unauthorized to update spaces"`; diff --git a/x-pack/plugins/spaces/server/lib/__snapshots__/spaces_url_parser.test.ts.snap b/x-pack/plugins/spaces/server/lib/__snapshots__/spaces_url_parser.test.ts.snap new file mode 100644 index 00000000000000..d08be39f9282ea --- /dev/null +++ b/x-pack/plugins/spaces/server/lib/__snapshots__/spaces_url_parser.test.ts.snap @@ -0,0 +1,3 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`addSpaceIdToPath it throws an error when the requested path does not start with a slash 1`] = `"path must start with a /"`; diff --git a/x-pack/plugins/spaces/server/lib/audit_logger.test.ts b/x-pack/plugins/spaces/server/lib/audit_logger.test.ts new file mode 100644 index 00000000000000..53d0befd01380f --- /dev/null +++ b/x-pack/plugins/spaces/server/lib/audit_logger.test.ts @@ -0,0 +1,140 @@ +/* + * 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 { SpacesAuditLogger } from './audit_logger'; + +const createMockConfig = (settings: any) => { + const mockConfig = { + get: jest.fn(), + }; + + mockConfig.get.mockImplementation(key => { + return settings[key]; + }); + + return mockConfig; +}; + +const createMockAuditLogger = () => { + return { + log: jest.fn(), + }; +}; + +describe(`#savedObjectsAuthorizationFailure`, () => { + test(`doesn't log anything when xpack.security.audit.enabled is false`, () => { + const config = createMockConfig({ + 'xpack.security.audit.enabled': false, + }); + const auditLogger = createMockAuditLogger(); + + const securityAuditLogger = new SpacesAuditLogger(config, auditLogger); + securityAuditLogger.spacesAuthorizationFailure('foo-user', 'foo-action'); + + expect(auditLogger.log).toHaveBeenCalledTimes(0); + }); + + test('logs with spaceIds via auditLogger when xpack.security.audit.enabled is true', () => { + const config = createMockConfig({ + 'xpack.security.audit.enabled': true, + }); + const auditLogger = createMockAuditLogger(); + const securityAuditLogger = new SpacesAuditLogger(config, auditLogger); + const username = 'foo-user'; + const action = 'foo-action'; + const spaceIds = ['foo-space-1', 'foo-space-2']; + + securityAuditLogger.spacesAuthorizationFailure(username, action, spaceIds); + + expect(auditLogger.log).toHaveBeenCalledWith( + 'spaces_authorization_failure', + expect.stringContaining(`${username} unauthorized to ${action} ${spaceIds.join(',')} spaces`), + { + username, + action, + spaceIds, + } + ); + }); + + test('logs without spaceIds via auditLogger when xpack.security.audit.enabled is true', () => { + const config = createMockConfig({ + 'xpack.security.audit.enabled': true, + }); + const auditLogger = createMockAuditLogger(); + const securityAuditLogger = new SpacesAuditLogger(config, auditLogger); + const username = 'foo-user'; + const action = 'foo-action'; + + securityAuditLogger.spacesAuthorizationFailure(username, action); + + expect(auditLogger.log).toHaveBeenCalledWith( + 'spaces_authorization_failure', + expect.stringContaining(`${username} unauthorized to ${action} spaces`), + { + username, + action, + } + ); + }); +}); + +describe(`#savedObjectsAuthorizationSuccess`, () => { + test(`doesn't log anything when xpack.security.audit.enabled is false`, () => { + const config = createMockConfig({ + 'xpack.security.audit.enabled': false, + }); + const auditLogger = createMockAuditLogger(); + + const securityAuditLogger = new SpacesAuditLogger(config, auditLogger); + securityAuditLogger.spacesAuthorizationSuccess('foo-user', 'foo-action'); + + expect(auditLogger.log).toHaveBeenCalledTimes(0); + }); + + test('logs with spaceIds via auditLogger when xpack.security.audit.enabled is true', () => { + const config = createMockConfig({ + 'xpack.security.audit.enabled': true, + }); + const auditLogger = createMockAuditLogger(); + const securityAuditLogger = new SpacesAuditLogger(config, auditLogger); + const username = 'foo-user'; + const action = 'foo-action'; + const spaceIds = ['foo-space-1', 'foo-space-2']; + + securityAuditLogger.spacesAuthorizationSuccess(username, action, spaceIds); + + expect(auditLogger.log).toHaveBeenCalledWith( + 'spaces_authorization_success', + expect.stringContaining(`${username} authorized to ${action} ${spaceIds.join(',')} spaces`), + { + username, + action, + spaceIds, + } + ); + }); + + test('logs without spaceIds via auditLogger when xpack.security.audit.enabled is true', () => { + const config = createMockConfig({ + 'xpack.security.audit.enabled': true, + }); + const auditLogger = createMockAuditLogger(); + const securityAuditLogger = new SpacesAuditLogger(config, auditLogger); + const username = 'foo-user'; + const action = 'foo-action'; + + securityAuditLogger.spacesAuthorizationSuccess(username, action); + + expect(auditLogger.log).toHaveBeenCalledWith( + 'spaces_authorization_success', + expect.stringContaining(`${username} authorized to ${action} spaces`), + { + username, + action, + } + ); + }); +}); diff --git a/x-pack/plugins/spaces/server/lib/audit_logger.ts b/x-pack/plugins/spaces/server/lib/audit_logger.ts new file mode 100644 index 00000000000000..b9bd3f5fe03998 --- /dev/null +++ b/x-pack/plugins/spaces/server/lib/audit_logger.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. + */ + +export class SpacesAuditLogger { + private readonly enabled: boolean; + private readonly auditLogger: any; + + constructor(config: any, auditLogger: any) { + this.enabled = config.get('xpack.security.audit.enabled'); + this.auditLogger = auditLogger; + } + public spacesAuthorizationFailure(username: string, action: string, spaceIds?: string[]) { + if (!this.enabled) { + return; + } + + this.auditLogger.log( + 'spaces_authorization_failure', + `${username} unauthorized to ${action}${spaceIds ? ' ' + spaceIds.join(',') : ''} spaces`, + { + username, + action, + spaceIds, + } + ); + } + + public spacesAuthorizationSuccess(username: string, action: string, spaceIds?: string[]) { + if (!this.enabled) { + return; + } + + this.auditLogger.log( + 'spaces_authorization_success', + `${username} authorized to ${action}${spaceIds ? ' ' + spaceIds.join(',') : ''} spaces`, + { + username, + action, + spaceIds, + } + ); + } +} diff --git a/x-pack/plugins/spaces/server/lib/check_license.ts b/x-pack/plugins/spaces/server/lib/check_license.ts new file mode 100644 index 00000000000000..e7fc63e724feb7 --- /dev/null +++ b/x-pack/plugins/spaces/server/lib/check_license.ts @@ -0,0 +1,35 @@ +/* + * 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 LicenseCheckResult { + showSpaces: boolean; +} + +/** + * Returns object that defines behavior of the spaces related features based + * on the license information extracted from the xPackInfo. + * @param {XPackInfo} xPackInfo XPackInfo instance to extract license information from. + * @returns {LicenseCheckResult} + */ +export function checkLicense(xPackInfo: any): LicenseCheckResult { + if (!xPackInfo.isAvailable()) { + return { + showSpaces: false, + }; + } + + const isAnyXpackLicense = xPackInfo.license.isOneOf(['basic', 'platinum', 'trial']); + + if (!isAnyXpackLicense) { + return { + showSpaces: false, + }; + } + + return { + showSpaces: true, + }; +} diff --git a/x-pack/plugins/spaces/server/lib/create_default_space.test.ts b/x-pack/plugins/spaces/server/lib/create_default_space.test.ts new file mode 100644 index 00000000000000..a71278a737188d --- /dev/null +++ b/x-pack/plugins/spaces/server/lib/create_default_space.test.ts @@ -0,0 +1,152 @@ +/* + * 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. + */ +jest.mock('../../../../server/lib/get_client_shield', () => ({ + getClient: jest.fn(), +})); + +import Boom from 'boom'; +// @ts-ignore +import { getClient } from '../../../../server/lib/get_client_shield'; +import { createDefaultSpace } from './create_default_space'; + +let mockCallWithRequest; +beforeEach(() => { + mockCallWithRequest = jest.fn(); + getClient.mockReturnValue({ + callWithRequest: mockCallWithRequest, + }); +}); +interface MockServerSettings { + defaultExists?: boolean; + simulateGetErrorCondition?: boolean; + simulateCreateErrorCondition?: boolean; + simulateConflict?: boolean; + [invalidKeys: string]: any; +} +const createMockServer = (settings: MockServerSettings = {}) => { + const { + defaultExists = false, + simulateGetErrorCondition = false, + simulateConflict = false, + simulateCreateErrorCondition = false, + } = settings; + + const mockGet = jest.fn().mockImplementation(() => { + if (simulateGetErrorCondition) { + throw new Error('unit test: unexpected exception condition'); + } + + if (defaultExists) { + return; + } + throw Boom.notFound('unit test: default space not found'); + }); + + const mockCreate = jest.fn().mockImplementation(() => { + if (simulateConflict) { + throw new Error('unit test: default space already exists'); + } + if (simulateCreateErrorCondition) { + throw new Error('unit test: some other unexpected error'); + } + + return null; + }); + + const mockServer = { + config: jest.fn().mockReturnValue({ + get: jest.fn(), + }), + savedObjects: { + SavedObjectsClient: { + errors: { + isNotFoundError: (e: Error) => e.message === 'unit test: default space not found', + isConflictError: (e: Error) => e.message === 'unit test: default space already exists', + }, + }, + getSavedObjectsRepository: jest.fn().mockImplementation(() => { + return { + get: mockGet, + create: mockCreate, + }; + }), + }, + }; + + mockServer.config().get.mockImplementation((key: string) => { + return settings[key]; + }); + + return mockServer; +}; + +test(`it creates the default space when one does not exist`, async () => { + const server = createMockServer({ + defaultExists: false, + }); + + await createDefaultSpace(server); + + const repository = server.savedObjects.getSavedObjectsRepository(); + + expect(repository.get).toHaveBeenCalledTimes(1); + expect(repository.create).toHaveBeenCalledTimes(1); + expect(repository.create).toHaveBeenCalledWith( + 'space', + { + _reserved: true, + description: 'This is your default space!', + name: 'Default', + color: '#00bfb3', + }, + { id: 'default' } + ); +}); + +test(`it does not attempt to recreate the default space if it already exists`, async () => { + const server = createMockServer({ + defaultExists: true, + }); + + await createDefaultSpace(server); + + const repository = server.savedObjects.getSavedObjectsRepository(); + + expect(repository.get).toHaveBeenCalledTimes(1); + expect(repository.create).toHaveBeenCalledTimes(0); +}); + +test(`it throws all other errors from the saved objects client when checking for the default space`, async () => { + const server = createMockServer({ + defaultExists: true, + simulateGetErrorCondition: true, + }); + + expect(createDefaultSpace(server)).rejects.toThrowErrorMatchingSnapshot(); +}); + +test(`it ignores conflict errors if the default space already exists`, async () => { + const server = createMockServer({ + defaultExists: false, + simulateConflict: true, + }); + + await createDefaultSpace(server); + + const repository = server.savedObjects.getSavedObjectsRepository(); + + expect(repository.get).toHaveBeenCalledTimes(1); + expect(repository.create).toHaveBeenCalledTimes(1); +}); + +test(`it throws other errors if there is an error creating the default space`, async () => { + const server = createMockServer({ + defaultExists: false, + simulateCreateErrorCondition: true, + }); + + expect(createDefaultSpace(server)).rejects.toThrowErrorMatchingSnapshot(); +}); diff --git a/x-pack/plugins/spaces/server/lib/create_default_space.ts b/x-pack/plugins/spaces/server/lib/create_default_space.ts new file mode 100644 index 00000000000000..c33283033b7e97 --- /dev/null +++ b/x-pack/plugins/spaces/server/lib/create_default_space.ts @@ -0,0 +1,62 @@ +/* + * 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. + */ +// @ts-ignore +import { getClient } from '../../../../server/lib/get_client_shield'; +import { DEFAULT_SPACE_ID } from '../../common/constants'; + +export async function createDefaultSpace(server: any) { + const { callWithInternalUser: callCluster } = getClient(server); + + const { getSavedObjectsRepository, SavedObjectsClient } = server.savedObjects; + + const savedObjectsRepository = getSavedObjectsRepository(callCluster); + + const defaultSpaceExists = await doesDefaultSpaceExist( + SavedObjectsClient, + savedObjectsRepository + ); + + if (defaultSpaceExists) { + return; + } + + const options = { + id: DEFAULT_SPACE_ID, + }; + + try { + await savedObjectsRepository.create( + 'space', + { + name: 'Default', + description: 'This is your default space!', + color: '#00bfb3', + _reserved: true, + }, + options + ); + } catch (error) { + // Ignore conflict errors. + // It is possible that another Kibana instance, or another invocation of this function + // created the default space in the time it took this to complete. + if (SavedObjectsClient.errors.isConflictError(error)) { + return; + } + throw error; + } +} + +async function doesDefaultSpaceExist(SavedObjectsClient: any, savedObjectsRepository: any) { + try { + await savedObjectsRepository.get('space', DEFAULT_SPACE_ID); + return true; + } catch (e) { + if (SavedObjectsClient.errors.isNotFoundError(e)) { + return false; + } + throw e; + } +} diff --git a/x-pack/plugins/spaces/server/lib/create_spaces_service.test.ts b/x-pack/plugins/spaces/server/lib/create_spaces_service.test.ts new file mode 100644 index 00000000000000..3eec4535547026 --- /dev/null +++ b/x-pack/plugins/spaces/server/lib/create_spaces_service.test.ts @@ -0,0 +1,54 @@ +/* + * 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 { DEFAULT_SPACE_ID } from '../../common/constants'; +import { createSpacesService } from './create_spaces_service'; + +const createRequest = (spaceId?: string, serverBasePath = '') => ({ + getBasePath: () => + spaceId && spaceId !== DEFAULT_SPACE_ID ? `${serverBasePath}/s/${spaceId}` : serverBasePath, +}); + +const createMockServer = (config: any) => { + return { + config: jest.fn(() => { + return { + get: jest.fn((key: string) => { + return config[key]; + }), + }; + }), + }; +}; + +test('returns the default space ID', () => { + const server = createMockServer({ + 'server.basePath': '', + }); + + const service = createSpacesService(server); + expect(service.getSpaceId(createRequest())).toEqual(DEFAULT_SPACE_ID); +}); + +test('returns the id for the current space', () => { + const request = createRequest('my-space-context'); + const server = createMockServer({ + 'server.basePath': '', + }); + + const service = createSpacesService(server); + expect(service.getSpaceId(request)).toEqual('my-space-context'); +}); + +test(`returns the id for the current space when a server basepath is defined`, () => { + const request = createRequest('my-space-context', '/foo'); + const server = createMockServer({ + 'server.basePath': '/foo', + }); + + const service = createSpacesService(server); + expect(service.getSpaceId(request)).toEqual('my-space-context'); +}); diff --git a/x-pack/plugins/spaces/server/lib/create_spaces_service.ts b/x-pack/plugins/spaces/server/lib/create_spaces_service.ts new file mode 100644 index 00000000000000..3269142a9cf17c --- /dev/null +++ b/x-pack/plugins/spaces/server/lib/create_spaces_service.ts @@ -0,0 +1,38 @@ +/* + * 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 { getSpaceIdFromPath } from './spaces_url_parser'; + +export interface SpacesService { + getSpaceId: (req: any) => string; +} + +export function createSpacesService(server: any): SpacesService { + const serverBasePath = server.config().get('server.basePath'); + + const contextCache = new WeakMap(); + + function getSpaceId(request: any) { + if (!contextCache.has(request)) { + populateCache(request); + } + + const { spaceId } = contextCache.get(request); + return spaceId; + } + + function populateCache(request: any) { + const spaceId = getSpaceIdFromPath(request.getBasePath(), serverBasePath); + + contextCache.set(request, { + spaceId, + }); + } + + return { + getSpaceId, + }; +} diff --git a/x-pack/plugins/spaces/server/lib/errors.ts b/x-pack/plugins/spaces/server/lib/errors.ts new file mode 100644 index 00000000000000..4f95c175b0f159 --- /dev/null +++ b/x-pack/plugins/spaces/server/lib/errors.ts @@ -0,0 +1,11 @@ +/* + * 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. + */ +// @ts-ignore +import { wrap as wrapBoom } from 'boom'; + +export function wrapError(error: any) { + return wrapBoom(error, error.status); +} diff --git a/x-pack/plugins/spaces/server/lib/get_active_space.ts b/x-pack/plugins/spaces/server/lib/get_active_space.ts new file mode 100644 index 00000000000000..907b7b164b69b3 --- /dev/null +++ b/x-pack/plugins/spaces/server/lib/get_active_space.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. + */ + +import { Space } from '../../common/model/space'; +import { wrapError } from './errors'; +import { SpacesClient } from './spaces_client'; +import { getSpaceIdFromPath } from './spaces_url_parser'; + +export async function getActiveSpace( + spacesClient: SpacesClient, + requestBasePath: string, + serverBasePath: string +): Promise { + const spaceId = getSpaceIdFromPath(requestBasePath, serverBasePath); + + try { + return spacesClient.get(spaceId); + } catch (e) { + throw wrapError(e); + } +} diff --git a/x-pack/plugins/spaces/server/lib/get_space_selector_url.test.ts b/x-pack/plugins/spaces/server/lib/get_space_selector_url.test.ts new file mode 100644 index 00000000000000..77d7db1328c147 --- /dev/null +++ b/x-pack/plugins/spaces/server/lib/get_space_selector_url.test.ts @@ -0,0 +1,30 @@ +/* + * 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 { getSpaceSelectorUrl } from './get_space_selector_url'; + +const buildServerConfig = (serverBasePath?: string) => { + return { + get: (key: string) => { + if (key === 'server.basePath') { + return serverBasePath; + } + throw new Error(`unexpected config request: ${key}`); + }, + }; +}; + +describe('getSpaceSelectorUrl', () => { + it('returns / when no server base path is defined', () => { + const serverConfig = buildServerConfig(); + expect(getSpaceSelectorUrl(serverConfig)).toEqual('/'); + }); + + it('returns the server base path when defined', () => { + const serverConfig = buildServerConfig('/my/server/base/path'); + expect(getSpaceSelectorUrl(serverConfig)).toEqual('/my/server/base/path'); + }); +}); diff --git a/x-pack/plugins/spaces/server/lib/get_space_selector_url.ts b/x-pack/plugins/spaces/server/lib/get_space_selector_url.ts new file mode 100644 index 00000000000000..3f24553306b2bb --- /dev/null +++ b/x-pack/plugins/spaces/server/lib/get_space_selector_url.ts @@ -0,0 +1,13 @@ +/* + * 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. + */ + +interface Config { + get(key: string): string | boolean | number | null | undefined; +} + +export function getSpaceSelectorUrl(serverConfig: Config) { + return serverConfig.get('server.basePath') || '/'; +} diff --git a/x-pack/plugins/spaces/server/lib/get_spaces_usage_collector.test.ts b/x-pack/plugins/spaces/server/lib/get_spaces_usage_collector.test.ts new file mode 100644 index 00000000000000..ef4cb9b01ec1e4 --- /dev/null +++ b/x-pack/plugins/spaces/server/lib/get_spaces_usage_collector.test.ts @@ -0,0 +1,163 @@ +/* + * 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 { getSpacesUsageCollector, UsageStats } from './get_spaces_usage_collector'; + +function getServerMock(customization?: any) { + class MockUsageCollector { + private fetch: any; + + constructor(server: any, { fetch }: any) { + this.fetch = fetch; + } + // to make typescript happy + public fakeFetchUsage() { + return this.fetch; + } + } + + const getLicenseCheckResults = jest.fn().mockReturnValue({}); + const defaultServerMock = { + plugins: { + security: { + isAuthenticated: jest.fn().mockReturnValue(true), + }, + xpack_main: { + info: { + isAvailable: jest.fn().mockReturnValue(true), + feature: () => ({ + getLicenseCheckResults, + }), + license: { + isOneOf: jest.fn().mockReturnValue(false), + getType: jest.fn().mockReturnValue('platinum'), + }, + toJSON: () => ({ b: 1 }), + }, + }, + }, + expose: () => { + return; + }, + log: () => { + return; + }, + config: () => ({ + get: (key: string) => { + if (key === 'xpack.spaces.enabled') { + return true; + } + }, + }), + usage: { + collectorSet: { + makeUsageCollector: (options: any) => { + return new MockUsageCollector(defaultServerMock, options); + }, + }, + }, + savedObjects: { + getSavedObjectsRepository: jest.fn(() => { + return { + find() { + return { + saved_objects: ['a', 'b'], + }; + }, + }; + }), + }, + }; + return Object.assign(defaultServerMock, customization); +} + +test('sets enabled to false when spaces is turned off', async () => { + const mockConfigGet = jest.fn(key => { + if (key === 'xpack.spaces.enabled') { + return false; + } else if (key.indexOf('xpack.spaces') >= 0) { + throw new Error('Unknown config key!'); + } + }); + const serverMock = getServerMock({ config: () => ({ get: mockConfigGet }) }); + const callClusterMock = jest.fn(); + const { fetch: getSpacesUsage } = getSpacesUsageCollector(serverMock); + const usageStats: UsageStats = await getSpacesUsage(callClusterMock); + expect(usageStats.enabled).toBe(false); +}); + +describe('with a basic license', async () => { + let usageStats: UsageStats; + beforeAll(async () => { + const serverWithBasicLicenseMock = getServerMock(); + serverWithBasicLicenseMock.plugins.xpack_main.info.license.getType = jest + .fn() + .mockReturnValue('basic'); + const callClusterMock = jest.fn(() => Promise.resolve({})); + const { fetch: getSpacesUsage } = getSpacesUsageCollector(serverWithBasicLicenseMock); + usageStats = await getSpacesUsage(callClusterMock); + }); + + test('sets enabled to true', async () => { + expect(usageStats.enabled).toBe(true); + }); + + test('sets available to true', async () => { + expect(usageStats.available).toBe(true); + }); + + test('sets the number of spaces', async () => { + expect(usageStats.count).toBe(2); + }); +}); + +describe('with no license', async () => { + let usageStats: UsageStats; + beforeAll(async () => { + const serverWithNoLicenseMock = getServerMock(); + serverWithNoLicenseMock.plugins.xpack_main.info.isAvailable = jest.fn().mockReturnValue(false); + const callClusterMock = jest.fn(() => Promise.resolve({})); + const { fetch: getSpacesUsage } = getSpacesUsageCollector(serverWithNoLicenseMock); + usageStats = await getSpacesUsage(callClusterMock); + }); + + test('sets enabled to false', async () => { + expect(usageStats.enabled).toBe(false); + }); + + test('sets available to false', async () => { + expect(usageStats.available).toBe(false); + }); + + test('does not set the number of spaces', async () => { + expect(usageStats.count).toBeUndefined(); + }); +}); + +describe('with platinum license', async () => { + let usageStats: UsageStats; + beforeAll(async () => { + const serverWithPlatinumLicenseMock = getServerMock(); + serverWithPlatinumLicenseMock.plugins.xpack_main.info.license.getType = jest + .fn() + .mockReturnValue('platinum'); + const callClusterMock = jest.fn(() => Promise.resolve({})); + const { fetch: getSpacesUsage } = getSpacesUsageCollector(serverWithPlatinumLicenseMock); + usageStats = await getSpacesUsage(callClusterMock); + }); + + test('sets enabled to true', async () => { + expect(usageStats.enabled).toBe(true); + }); + + test('sets available to true', async () => { + expect(usageStats.available).toBe(true); + }); + + test('sets the number of spaces', async () => { + expect(usageStats.count).toBe(2); + }); +}); diff --git a/x-pack/plugins/spaces/server/lib/get_spaces_usage_collector.ts b/x-pack/plugins/spaces/server/lib/get_spaces_usage_collector.ts new file mode 100644 index 00000000000000..9361bfaf18b373 --- /dev/null +++ b/x-pack/plugins/spaces/server/lib/get_spaces_usage_collector.ts @@ -0,0 +1,81 @@ +/* + * 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. + */ +// @ts-ignore +import { KIBANA_STATS_TYPE_MONITORING } from '../../../monitoring/common/constants'; +import { KIBANA_SPACES_STATS_TYPE } from '../../common/constants'; + +/** + * + * @param callCluster + * @param server + * @param {boolean} spacesAvailable + * @param withinDayRange + * @return {ReportingUsageStats} + */ +async function getSpacesUsage(callCluster: any, server: any, spacesAvailable: boolean) { + if (!spacesAvailable) { + return {}; + } + + const { getSavedObjectsRepository } = server.savedObjects; + + const savedObjectsRepository = getSavedObjectsRepository(callCluster); + + const { saved_objects: spaces } = await savedObjectsRepository.find({ type: 'space' }); + + return { + count: spaces.length, + }; +} + +export interface UsageStats { + available: boolean; + enabled: boolean; + count?: number; +} +/* + * @param {Object} server + * @return {Object} kibana usage stats type collection object + */ +export function getSpacesUsageCollector(server: any) { + const { collectorSet } = server.usage; + return collectorSet.makeUsageCollector({ + type: KIBANA_SPACES_STATS_TYPE, + fetch: async (callCluster: any) => { + const xpackInfo = server.plugins.xpack_main.info; + const config = server.config(); + const available = xpackInfo && xpackInfo.isAvailable(); // some form of spaces is available for all valid licenses + const enabled = config.get('xpack.spaces.enabled'); + const spacesAvailableAndEnabled = available && enabled; + + const usageStats = await getSpacesUsage(callCluster, server, spacesAvailableAndEnabled); + + return { + available, + enabled: spacesAvailableAndEnabled, // similar behavior as _xpack API in ES + ...usageStats, + } as UsageStats; + }, + + /* + * Format the response data into a model for internal upload + * 1. Make this data part of the "kibana_stats" type + * 2. Organize the payload in the usage.xpack.spaces namespace of the data payload + */ + formatForBulkUpload: (result: UsageStats) => { + return { + type: KIBANA_STATS_TYPE_MONITORING, + payload: { + usage: { + xpack: { + spaces: result, + }, + }, + }, + }; + }, + }); +} diff --git a/x-pack/plugins/spaces/server/lib/route_pre_check_license.ts b/x-pack/plugins/spaces/server/lib/route_pre_check_license.ts new file mode 100644 index 00000000000000..449836633993c4 --- /dev/null +++ b/x-pack/plugins/spaces/server/lib/route_pre_check_license.ts @@ -0,0 +1,20 @@ +/* + * 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 Boom from 'boom'; + +export function routePreCheckLicense(server: any) { + const xpackMainPlugin = server.plugins.xpack_main; + const pluginId = 'spaces'; + return function forbidApiAccess(request: any, reply: any) { + const licenseCheckResults = xpackMainPlugin.info.feature(pluginId).getLicenseCheckResults(); + if (!licenseCheckResults.showSpaces) { + reply(Boom.forbidden(licenseCheckResults.linksMessage)); + } else { + reply(); + } + }; +} diff --git a/x-pack/plugins/spaces/server/lib/saved_objects_client/__snapshots__/spaces_saved_objects_client.test.ts.snap b/x-pack/plugins/spaces/server/lib/saved_objects_client/__snapshots__/spaces_saved_objects_client.test.ts.snap new file mode 100644 index 00000000000000..e52af9a98001a0 --- /dev/null +++ b/x-pack/plugins/spaces/server/lib/saved_objects_client/__snapshots__/spaces_saved_objects_client.test.ts.snap @@ -0,0 +1,65 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`default space #bulkCreate throws error if objects type is space 1`] = `"Spaces can not be accessed using the SavedObjectsClient"`; + +exports[`default space #bulkCreate throws error if options.namespace is specified 1`] = `"Spaces currently determines the namespaces"`; + +exports[`default space #bulkGet throws error if objects type is space 1`] = `"Spaces can not be accessed using the SavedObjectsClient"`; + +exports[`default space #bulkGet throws error if options.namespace is specified 1`] = `"Spaces currently determines the namespaces"`; + +exports[`default space #create throws error if options.namespace is specified 1`] = `"Spaces currently determines the namespaces"`; + +exports[`default space #create throws error if type is space 1`] = `"Spaces can not be accessed using the SavedObjectsClient"`; + +exports[`default space #delete throws error if options.namespace is specified 1`] = `"Spaces currently determines the namespaces"`; + +exports[`default space #delete throws error if type is space 1`] = `"Spaces can not be accessed using the SavedObjectsClient"`; + +exports[`default space #find if options.type isn't provided specifies options.type based on the types excluding the space 1`] = `"Spaces can not be accessed using the SavedObjectsClient"`; + +exports[`default space #find throws error if options.namespace is specified 1`] = `"Spaces currently determines the namespaces"`; + +exports[`default space #find throws error if options.type is array containing space 1`] = `"Spaces can not be accessed using the SavedObjectsClient"`; + +exports[`default space #find throws error if options.type is space 1`] = `"Spaces can not be accessed using the SavedObjectsClient"`; + +exports[`default space #get throws error if options.namespace is specified 1`] = `"Spaces currently determines the namespaces"`; + +exports[`default space #get throws error if type is space 1`] = `"Spaces can not be accessed using the SavedObjectsClient"`; + +exports[`default space #update throws error if options.namespace is specified 1`] = `"Spaces currently determines the namespaces"`; + +exports[`default space #update throws error if type is space 1`] = `"Spaces can not be accessed using the SavedObjectsClient"`; + +exports[`space_1 space #bulkCreate throws error if objects type is space 1`] = `"Spaces can not be accessed using the SavedObjectsClient"`; + +exports[`space_1 space #bulkCreate throws error if options.namespace is specified 1`] = `"Spaces currently determines the namespaces"`; + +exports[`space_1 space #bulkGet throws error if objects type is space 1`] = `"Spaces can not be accessed using the SavedObjectsClient"`; + +exports[`space_1 space #bulkGet throws error if options.namespace is specified 1`] = `"Spaces currently determines the namespaces"`; + +exports[`space_1 space #create throws error if options.namespace is specified 1`] = `"Spaces currently determines the namespaces"`; + +exports[`space_1 space #create throws error if type is space 1`] = `"Spaces can not be accessed using the SavedObjectsClient"`; + +exports[`space_1 space #delete throws error if options.namespace is specified 1`] = `"Spaces currently determines the namespaces"`; + +exports[`space_1 space #delete throws error if type is space 1`] = `"Spaces can not be accessed using the SavedObjectsClient"`; + +exports[`space_1 space #find if options.type isn't provided specifies options.type based on the types excluding the space 1`] = `"Spaces can not be accessed using the SavedObjectsClient"`; + +exports[`space_1 space #find throws error if options.namespace is specified 1`] = `"Spaces currently determines the namespaces"`; + +exports[`space_1 space #find throws error if options.type is array containing space 1`] = `"Spaces can not be accessed using the SavedObjectsClient"`; + +exports[`space_1 space #find throws error if options.type is space 1`] = `"Spaces can not be accessed using the SavedObjectsClient"`; + +exports[`space_1 space #get throws error if options.namespace is specified 1`] = `"Spaces currently determines the namespaces"`; + +exports[`space_1 space #get throws error if type is space 1`] = `"Spaces can not be accessed using the SavedObjectsClient"`; + +exports[`space_1 space #update throws error if options.namespace is specified 1`] = `"Spaces currently determines the namespaces"`; + +exports[`space_1 space #update throws error if type is space 1`] = `"Spaces can not be accessed using the SavedObjectsClient"`; diff --git a/x-pack/plugins/spaces/server/lib/saved_objects_client/saved_objects_client_types.ts b/x-pack/plugins/spaces/server/lib/saved_objects_client/saved_objects_client_types.ts new file mode 100644 index 00000000000000..b2cdc09d66a1b7 --- /dev/null +++ b/x-pack/plugins/spaces/server/lib/saved_objects_client/saved_objects_client_types.ts @@ -0,0 +1,97 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export interface BaseOptions { + namespace?: string; +} + +export interface CreateOptions extends BaseOptions { + id?: string; + override?: boolean; +} + +export interface BulkCreateObject { + id?: string; + type: string; + attributes: SavedObjectAttributes; + extraDocumentProperties?: string[]; +} + +export interface BulkCreateResponse { + savedObjects: SavedObject[]; +} + +export interface FindOptions extends BaseOptions { + page?: number; + perPage?: number; + sortField?: string; + sortOrder?: string; + fields?: string[]; + type?: string | string[]; +} + +export interface FindResponse { + savedObjects: SavedObject[]; + total: number; + perPage: number; + page: number; +} + +export interface UpdateOptions extends BaseOptions { + version?: number; +} + +export interface BulkGetObject { + id: string; + type: string; +} +export type BulkGetObjects = BulkGetObject[]; + +export interface BulkGetResponse { + savedObjects: SavedObject[]; +} + +export interface SavedObjectAttributes { + [key: string]: string | number | boolean | null; +} + +export interface SavedObject { + id: string; + type: string; + version?: number; + updatedAt?: string; + error?: { + message: string; + }; + attributes: SavedObjectAttributes; +} + +export interface SavedObjectsClient { + errors: any; + create: ( + type: string, + attributes: SavedObjectAttributes, + options?: CreateOptions + ) => Promise; + bulkCreate: (objects: BulkCreateObject[], options?: CreateOptions) => Promise; + delete: (type: string, id: string, options?: BaseOptions) => Promise<{}>; + find: (options: FindOptions) => Promise; + bulkGet: (objects: BulkGetObjects, options?: BaseOptions) => Promise; + get: (type: string, id: string, options?: BaseOptions) => Promise; + update: ( + type: string, + id: string, + attributes: SavedObjectAttributes, + options?: UpdateOptions + ) => Promise; +} + +export interface SOCWrapperOptions { + client: SavedObjectsClient; + request: any; +} + +export type SOCWrapperFactory = (options: SOCWrapperOptions) => SavedObjectsClient; diff --git a/x-pack/plugins/spaces/server/lib/saved_objects_client/saved_objects_client_wrapper_factory.ts b/x-pack/plugins/spaces/server/lib/saved_objects_client/saved_objects_client_wrapper_factory.ts new file mode 100644 index 00000000000000..9e19556abd20cf --- /dev/null +++ b/x-pack/plugins/spaces/server/lib/saved_objects_client/saved_objects_client_wrapper_factory.ts @@ -0,0 +1,22 @@ +/* + * 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 { SpacesService } from '../create_spaces_service'; +import { SOCWrapperOptions } from './saved_objects_client_types'; +import { SpacesSavedObjectsClient } from './spaces_saved_objects_client'; + +export function spacesSavedObjectsClientWrapperFactory( + spacesService: SpacesService, + types: string[] +) { + return ({ client, request }: SOCWrapperOptions) => + new SpacesSavedObjectsClient({ + baseClient: client, + request, + spacesService, + types, + }); +} diff --git a/x-pack/plugins/spaces/server/lib/saved_objects_client/spaces_saved_objects_client.test.ts b/x-pack/plugins/spaces/server/lib/saved_objects_client/spaces_saved_objects_client.test.ts new file mode 100644 index 00000000000000..5a6ca30dba8334 --- /dev/null +++ b/x-pack/plugins/spaces/server/lib/saved_objects_client/spaces_saved_objects_client.test.ts @@ -0,0 +1,543 @@ +/* + * 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 { DEFAULT_SPACE_ID } from '../../../common/constants'; +import { Space } from '../../../common/model/space'; +import { createSpacesService } from '../create_spaces_service'; +import { SpacesSavedObjectsClient } from './spaces_saved_objects_client'; + +const config: any = { + 'server.basePath': '/', +}; + +const types = ['foo', 'bar', 'space']; + +const server = { + config: () => ({ + get: (key: string) => { + return config[key]; + }, + }), +}; + +const createMockRequest = (space: Partial) => ({ + getBasePath: () => (space.id !== DEFAULT_SPACE_ID ? `/s/${space.id}` : ''), +}); + +const createMockClient = () => { + const errors = Symbol(); + + return { + get: jest.fn(), + bulkGet: jest.fn(), + find: jest.fn(), + create: jest.fn(), + bulkCreate: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + errors, + }; +}; + +[ + { id: DEFAULT_SPACE_ID, expectedNamespace: undefined }, + { id: 'space_1', expectedNamespace: 'space_1' }, +].forEach(currentSpace => { + describe(`${currentSpace.id} space`, () => { + describe('#get', () => { + test(`throws error if options.namespace is specified`, async () => { + const request = createMockRequest({ id: currentSpace.id }); + const baseClient = createMockClient(); + const spacesService = createSpacesService(server); + + const client = new SpacesSavedObjectsClient({ + request, + baseClient, + spacesService, + types, + }); + + await expect( + client.get('foo', '', { namespace: 'bar' }) + ).rejects.toThrowErrorMatchingSnapshot(); + }); + + test(`throws error if type is space`, async () => { + const request = createMockRequest({ id: currentSpace.id }); + const baseClient = createMockClient(); + const spacesService = createSpacesService(server); + + const client = new SpacesSavedObjectsClient({ + request, + baseClient, + spacesService, + types, + }); + + await expect(client.get('space', '')).rejects.toThrowErrorMatchingSnapshot(); + }); + + test(`supplements options with undefined namespace`, async () => { + const request = createMockRequest({ id: currentSpace.id }); + const baseClient = createMockClient(); + const expectedReturnValue = Symbol(); + baseClient.get.mockReturnValue(expectedReturnValue); + const spacesService = createSpacesService(server); + + const client = new SpacesSavedObjectsClient({ + request, + baseClient, + spacesService, + types, + }); + const type = Symbol(); + const id = Symbol(); + const options = Object.freeze({ foo: 'bar' }); + // @ts-ignore + const actualReturnValue = await client.get(type, id, options); + + expect(actualReturnValue).toBe(expectedReturnValue); + expect(baseClient.get).toHaveBeenCalledWith(type, id, { + foo: 'bar', + namespace: currentSpace.expectedNamespace, + }); + }); + }); + + describe('#bulkGet', () => { + test(`throws error if options.namespace is specified`, async () => { + const request = createMockRequest({ id: currentSpace.id }); + const baseClient = createMockClient(); + const spacesService = createSpacesService(server); + + const client = new SpacesSavedObjectsClient({ + request, + baseClient, + spacesService, + types, + }); + + await expect( + client.bulkGet([{ id: '', type: 'foo' }], { namespace: 'bar' }) + ).rejects.toThrowErrorMatchingSnapshot(); + }); + + test(`throws error if objects type is space`, async () => { + const request = createMockRequest({ id: currentSpace.id }); + const baseClient = createMockClient(); + const spacesService = createSpacesService(server); + + const client = new SpacesSavedObjectsClient({ + request, + baseClient, + spacesService, + types, + }); + + await expect( + client.bulkGet([{ id: '', type: 'foo' }, { id: '', type: 'space' }], { namespace: 'bar' }) + ).rejects.toThrowErrorMatchingSnapshot(); + }); + + test(`supplements options with undefined namespace`, async () => { + const request = createMockRequest({ id: currentSpace.id }); + const baseClient = createMockClient(); + const expectedReturnValue = Symbol(); + baseClient.bulkGet.mockReturnValue(expectedReturnValue); + const spacesService = createSpacesService(server); + + const client = new SpacesSavedObjectsClient({ + request, + baseClient, + spacesService, + types, + }); + + const objects = [{ type: 'foo' }]; + const options = Object.freeze({ foo: 'bar' }); + // @ts-ignore + const actualReturnValue = await client.bulkGet(objects, options); + + expect(actualReturnValue).toBe(expectedReturnValue); + expect(baseClient.bulkGet).toHaveBeenCalledWith(objects, { + foo: 'bar', + namespace: currentSpace.expectedNamespace, + }); + }); + }); + + describe('#find', () => { + test(`throws error if options.namespace is specified`, async () => { + const request = createMockRequest({ id: currentSpace.id }); + const baseClient = createMockClient(); + const spacesService = createSpacesService(server); + + const client = new SpacesSavedObjectsClient({ + request, + baseClient, + spacesService, + types, + }); + + await expect(client.find({ namespace: 'bar' })).rejects.toThrowErrorMatchingSnapshot(); + }); + + test(`throws error if options.type is space`, async () => { + const request = createMockRequest({ id: currentSpace.id }); + const baseClient = createMockClient(); + const expectedReturnValue = Symbol(); + baseClient.find.mockReturnValue(expectedReturnValue); + const spacesService = createSpacesService(server); + + const client = new SpacesSavedObjectsClient({ + request, + baseClient, + spacesService, + types, + }); + + await expect(client.find({ type: 'space' })).rejects.toThrowErrorMatchingSnapshot(); + }); + + test(`passes options.type to baseClient if valid singular type specified`, async () => { + const request = createMockRequest({ id: currentSpace.id }); + const baseClient = createMockClient(); + const expectedReturnValue = Symbol(); + baseClient.find.mockReturnValue(expectedReturnValue); + const spacesService = createSpacesService(server); + + const client = new SpacesSavedObjectsClient({ + request, + baseClient, + spacesService, + types, + }); + const options = Object.freeze({ type: 'foo' }); + + const actualReturnValue = await client.find(options); + + expect(actualReturnValue).toBe(expectedReturnValue); + expect(baseClient.find).toHaveBeenCalledWith({ + type: ['foo'], + namespace: currentSpace.expectedNamespace, + }); + }); + + test(`throws error if options.type is array containing space`, async () => { + const request = createMockRequest({ id: currentSpace.id }); + const baseClient = createMockClient(); + const expectedReturnValue = Symbol(); + baseClient.find.mockReturnValue(expectedReturnValue); + const spacesService = createSpacesService(server); + + const client = new SpacesSavedObjectsClient({ + request, + baseClient, + spacesService, + types, + }); + + await expect( + client.find({ type: ['space', 'foo'] }) + ).rejects.toThrowErrorMatchingSnapshot(); + }); + + test(`if options.type isn't provided specifies options.type based on the types excluding the space`, async () => { + const request = createMockRequest({ id: currentSpace.id }); + const baseClient = createMockClient(); + const expectedReturnValue = Symbol(); + baseClient.find.mockReturnValue(expectedReturnValue); + const spacesService = createSpacesService(server); + + const client = new SpacesSavedObjectsClient({ + request, + baseClient, + spacesService, + types, + }); + + await expect( + client.find({ type: ['space', 'foo'] }) + ).rejects.toThrowErrorMatchingSnapshot(); + }); + + test(`supplements options with undefined namespace`, async () => { + const request = createMockRequest({ id: currentSpace.id }); + const baseClient = createMockClient(); + const expectedReturnValue = Symbol(); + baseClient.find.mockReturnValue(expectedReturnValue); + const spacesService = createSpacesService(server); + + const client = new SpacesSavedObjectsClient({ + request, + baseClient, + spacesService, + types, + }); + + const options = Object.freeze({ type: ['foo', 'bar'] }); + const actualReturnValue = await client.find(options); + + expect(actualReturnValue).toBe(expectedReturnValue); + expect(baseClient.find).toHaveBeenCalledWith({ + type: ['foo', 'bar'], + namespace: currentSpace.expectedNamespace, + }); + }); + }); + + describe('#create', () => { + test(`throws error if options.namespace is specified`, async () => { + const request = createMockRequest({ id: currentSpace.id }); + const baseClient = createMockClient(); + const spacesService = createSpacesService(server); + + const client = new SpacesSavedObjectsClient({ + request, + baseClient, + spacesService, + types, + }); + + await expect( + client.create('foo', {}, { namespace: 'bar' }) + ).rejects.toThrowErrorMatchingSnapshot(); + }); + + test(`throws error if type is space`, async () => { + const request = createMockRequest({ id: currentSpace.id }); + const baseClient = createMockClient(); + const spacesService = createSpacesService(server); + + const client = new SpacesSavedObjectsClient({ + request, + baseClient, + spacesService, + types, + }); + + await expect(client.create('space', {})).rejects.toThrowErrorMatchingSnapshot(); + }); + + test(`supplements options with undefined namespace`, async () => { + const request = createMockRequest({ id: currentSpace.id }); + const baseClient = createMockClient(); + const expectedReturnValue = Symbol(); + baseClient.create.mockReturnValue(expectedReturnValue); + const spacesService = createSpacesService(server); + + const client = new SpacesSavedObjectsClient({ + request, + baseClient, + spacesService, + types, + }); + + const type = Symbol(); + const attributes = Symbol(); + const options = Object.freeze({ foo: 'bar' }); + // @ts-ignore + const actualReturnValue = await client.create(type, attributes, options); + + expect(actualReturnValue).toBe(expectedReturnValue); + expect(baseClient.create).toHaveBeenCalledWith(type, attributes, { + foo: 'bar', + namespace: currentSpace.expectedNamespace, + }); + }); + }); + + describe('#bulkCreate', () => { + test(`throws error if options.namespace is specified`, async () => { + const request = createMockRequest({ id: currentSpace.id }); + const baseClient = createMockClient(); + const spacesService = createSpacesService(server); + + const client = new SpacesSavedObjectsClient({ + request, + baseClient, + spacesService, + types, + }); + + await expect( + client.bulkCreate([{ id: '', type: 'foo', attributes: {} }], { namespace: 'bar' }) + ).rejects.toThrowErrorMatchingSnapshot(); + }); + + test(`throws error if objects type is space`, async () => { + const request = createMockRequest({ id: currentSpace.id }); + const baseClient = createMockClient(); + const spacesService = createSpacesService(server); + + const client = new SpacesSavedObjectsClient({ + request, + baseClient, + spacesService, + types, + }); + + await expect( + client.bulkCreate([ + { id: '', type: 'foo', attributes: {} }, + { id: '', type: 'space', attributes: {} }, + ]) + ).rejects.toThrowErrorMatchingSnapshot(); + }); + + test(`supplements options with undefined namespace`, async () => { + const request = createMockRequest({ id: currentSpace.id }); + const baseClient = createMockClient(); + const expectedReturnValue = Symbol(); + baseClient.bulkCreate.mockReturnValue(expectedReturnValue); + const spacesService = createSpacesService(server); + + const client = new SpacesSavedObjectsClient({ + request, + baseClient, + spacesService, + types, + }); + + const objects = [{ type: 'foo' }]; + const options = Object.freeze({ foo: 'bar' }); + // @ts-ignore + const actualReturnValue = await client.bulkCreate(objects, options); + + expect(actualReturnValue).toBe(expectedReturnValue); + expect(baseClient.bulkCreate).toHaveBeenCalledWith(objects, { + foo: 'bar', + namespace: currentSpace.expectedNamespace, + }); + }); + }); + + describe('#update', () => { + test(`throws error if options.namespace is specified`, async () => { + const request = createMockRequest({ id: currentSpace.id }); + const baseClient = createMockClient(); + const spacesService = createSpacesService(server); + + const client = new SpacesSavedObjectsClient({ + request, + baseClient, + spacesService, + types, + }); + + await expect( + // @ts-ignore + client.update(null, null, null, { namespace: 'bar' }) + ).rejects.toThrowErrorMatchingSnapshot(); + }); + + test(`throws error if type is space`, async () => { + const request = createMockRequest({ id: currentSpace.id }); + const baseClient = createMockClient(); + const spacesService = createSpacesService(server); + + const client = new SpacesSavedObjectsClient({ + request, + baseClient, + spacesService, + types, + }); + + await expect(client.update('space', '', {})).rejects.toThrowErrorMatchingSnapshot(); + }); + + test(`supplements options with undefined namespace`, async () => { + const request = createMockRequest({ id: currentSpace.id }); + const baseClient = createMockClient(); + const expectedReturnValue = Symbol(); + baseClient.update.mockReturnValue(expectedReturnValue); + const spacesService = createSpacesService(server); + + const client = new SpacesSavedObjectsClient({ + request, + baseClient, + spacesService, + types, + }); + + const type = Symbol(); + const id = Symbol(); + const attributes = Symbol(); + const options = Object.freeze({ foo: 'bar' }); + // @ts-ignore + const actualReturnValue = await client.update(type, id, attributes, options); + + expect(actualReturnValue).toBe(expectedReturnValue); + expect(baseClient.update).toHaveBeenCalledWith(type, id, attributes, { + foo: 'bar', + namespace: currentSpace.expectedNamespace, + }); + }); + }); + + describe('#delete', () => { + test(`throws error if options.namespace is specified`, async () => { + const request = createMockRequest({ id: currentSpace.id }); + const baseClient = createMockClient(); + const spacesService = createSpacesService(server); + + const client = new SpacesSavedObjectsClient({ + request, + baseClient, + spacesService, + types, + }); + + await expect( + // @ts-ignore + client.delete(null, null, { namespace: 'bar' }) + ).rejects.toThrowErrorMatchingSnapshot(); + }); + + test(`throws error if type is space`, async () => { + const request = createMockRequest({ id: currentSpace.id }); + const baseClient = createMockClient(); + const spacesService = createSpacesService(server); + + const client = new SpacesSavedObjectsClient({ + request, + baseClient, + spacesService, + types, + }); + + await expect(client.delete('space', 'foo')).rejects.toThrowErrorMatchingSnapshot(); + }); + + test(`supplements options with undefined namespace`, async () => { + const request = createMockRequest({ id: currentSpace.id }); + const baseClient = createMockClient(); + const expectedReturnValue = Symbol(); + baseClient.delete.mockReturnValue(expectedReturnValue); + const spacesService = createSpacesService(server); + + const client = new SpacesSavedObjectsClient({ + request, + baseClient, + spacesService, + types, + }); + + const type = Symbol(); + const id = Symbol(); + const options = Object.freeze({ foo: 'bar' }); + // @ts-ignore + const actualReturnValue = await client.delete(type, id, options); + + expect(actualReturnValue).toBe(expectedReturnValue); + expect(baseClient.delete).toHaveBeenCalledWith(type, id, { + foo: 'bar', + namespace: currentSpace.expectedNamespace, + }); + }); + }); + }); +}); diff --git a/x-pack/plugins/spaces/server/lib/saved_objects_client/spaces_saved_objects_client.ts b/x-pack/plugins/spaces/server/lib/saved_objects_client/spaces_saved_objects_client.ts new file mode 100644 index 00000000000000..dc2131afee2b24 --- /dev/null +++ b/x-pack/plugins/spaces/server/lib/saved_objects_client/spaces_saved_objects_client.ts @@ -0,0 +1,232 @@ +/* + * 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 { DEFAULT_SPACE_ID } from '../../../common/constants'; +import { SpacesService } from '../create_spaces_service'; +import { + BaseOptions, + BulkCreateObject, + BulkGetObjects, + CreateOptions, + FindOptions, + SavedObjectAttributes, + SavedObjectsClient, + UpdateOptions, +} from './saved_objects_client_types'; + +interface SpacesSavedObjectsClientOptions { + baseClient: SavedObjectsClient; + request: any; + spacesService: SpacesService; + types: string[]; +} + +const coerceToArray = (param: string | string[]) => { + if (Array.isArray(param)) { + return param; + } + + return [param]; +}; + +const getNamespace = (spaceId: string) => { + if (spaceId === DEFAULT_SPACE_ID) { + return undefined; + } + + return spaceId; +}; + +const throwErrorIfNamespaceSpecified = (options: any) => { + if (options.namespace) { + throw new Error('Spaces currently determines the namespaces'); + } +}; + +const throwErrorIfTypeIsSpace = (type: string) => { + if (type === 'space') { + throw new Error('Spaces can not be accessed using the SavedObjectsClient'); + } +}; + +const throwErrorIfTypesContainsSpace = (types: string[]) => { + for (const type of types) { + throwErrorIfTypeIsSpace(type); + } +}; + +export class SpacesSavedObjectsClient implements SavedObjectsClient { + public readonly errors: any; + private readonly client: SavedObjectsClient; + private readonly spaceId: string; + private readonly types: string[]; + + constructor(options: SpacesSavedObjectsClientOptions) { + const { baseClient, request, spacesService, types } = options; + + this.errors = baseClient.errors; + this.client = baseClient; + this.spaceId = spacesService.getSpaceId(request); + this.types = types; + } + + /** + * Persists an object + * + * @param {string} type + * @param {object} attributes + * @param {object} [options={}] + * @property {string} [options.id] - force id on creation, not recommended + * @property {boolean} [options.overwrite=false] + * @property {string} [options.namespace] + * @returns {promise} - { id, type, version, attributes } + */ + public async create(type: string, attributes = {}, options: CreateOptions = {}) { + throwErrorIfTypeIsSpace(type); + throwErrorIfNamespaceSpecified(options); + + return await this.client.create(type, attributes, { + ...options, + namespace: getNamespace(this.spaceId), + }); + } + + /** + * Creates multiple documents at once + * + * @param {array} objects - [{ type, id, attributes, extraDocumentProperties }] + * @param {object} [options={}] + * @property {boolean} [options.overwrite=false] - overwrites existing documents + * @property {string} [options.namespace] + * @returns {promise} - { saved_objects: [{ id, type, version, attributes, error: { message } }]} + */ + public async bulkCreate(objects: BulkCreateObject[], options: BaseOptions = {}) { + throwErrorIfTypesContainsSpace(objects.map(object => object.type)); + throwErrorIfNamespaceSpecified(options); + + return await this.client.bulkCreate(objects, { + ...options, + namespace: getNamespace(this.spaceId), + }); + } + + /** + * Deletes an object + * + * @param {string} type + * @param {string} id + * @param {object} [options={}] + * @property {string} [options.namespace] + * @returns {promise} + */ + public async delete(type: string, id: string, options: BaseOptions = {}) { + throwErrorIfTypeIsSpace(type); + throwErrorIfNamespaceSpecified(options); + + return await this.client.delete(type, id, { + ...options, + namespace: getNamespace(this.spaceId), + }); + } + + /** + * @param {object} [options={}] + * @property {(string|Array)} [options.type] + * @property {string} [options.search] + * @property {Array} [options.searchFields] - see Elasticsearch Simple Query String + * Query field argument for more information + * @property {integer} [options.page=1] + * @property {integer} [options.perPage=20] + * @property {string} [options.sortField] + * @property {string} [options.sortOrder] + * @property {Array} [options.fields] + * @property {string} [options.namespace] + * @returns {promise} - { saved_objects: [{ id, type, version, attributes }], total, per_page, page } + */ + public async find(options: FindOptions = {}) { + if (options.type) { + throwErrorIfTypesContainsSpace(coerceToArray(options.type)); + } + + throwErrorIfNamespaceSpecified(options); + + return await this.client.find({ + ...options, + type: (options.type ? coerceToArray(options.type) : this.types).filter( + type => type !== 'space' + ), + namespace: getNamespace(this.spaceId), + }); + } + + /** + * Returns an array of objects by id + * + * @param {array} objects - an array ids, or an array of objects containing id and optionally type + * @param {object} [options={}] + * @property {string} [options.namespace] + * @returns {promise} - { saved_objects: [{ id, type, version, attributes }] } + * @example + * + * bulkGet([ + * { id: 'one', type: 'config' }, + * { id: 'foo', type: 'index-pattern' } + * ]) + */ + public async bulkGet(objects: BulkGetObjects = [], options: BaseOptions = {}) { + throwErrorIfTypesContainsSpace(objects.map(object => object.type)); + throwErrorIfNamespaceSpecified(options); + + return await this.client.bulkGet(objects, { + ...options, + namespace: getNamespace(this.spaceId), + }); + } + + /** + * Gets a single object + * + * @param {string} type + * @param {string} id + * @param {object} [options={}] + * @property {string} [options.namespace] + * @returns {promise} - { id, type, version, attributes } + */ + public async get(type: string, id: string, options: BaseOptions = {}) { + throwErrorIfTypeIsSpace(type); + throwErrorIfNamespaceSpecified(options); + + return await this.client.get(type, id, { + ...options, + namespace: getNamespace(this.spaceId), + }); + } + + /** + * Updates an object + * + * @param {string} type + * @param {string} id + * @param {object} [options={}] + * @property {integer} options.version - ensures version matches that of persisted object + * @property {string} [options.namespace] + * @returns {promise} + */ + public async update( + type: string, + id: string, + attributes: SavedObjectAttributes, + options: UpdateOptions = {} + ) { + throwErrorIfTypeIsSpace(type); + throwErrorIfNamespaceSpecified(options); + + return await this.client.update(type, id, attributes, { + ...options, + namespace: getNamespace(this.spaceId), + }); + } +} diff --git a/x-pack/plugins/spaces/server/lib/space_request_interceptors.test.ts b/x-pack/plugins/spaces/server/lib/space_request_interceptors.test.ts new file mode 100644 index 00000000000000..5b8792c45de372 --- /dev/null +++ b/x-pack/plugins/spaces/server/lib/space_request_interceptors.test.ts @@ -0,0 +1,435 @@ +/* + * 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. + */ +// @ts-ignore +import { Server } from 'hapi'; +import sinon from 'sinon'; +import { SavedObject } from './saved_objects_client/saved_objects_client_types'; +import { initSpacesRequestInterceptors } from './space_request_interceptors'; + +describe('interceptors', () => { + const sandbox = sinon.sandbox.create(); + const teardowns: Array<() => void> = []; + const headers = { + authorization: 'foo', + }; + let server: any; + let request: any; + + beforeEach(() => { + teardowns.push(() => sandbox.restore()); + request = async ( + path: string, + setupFn: (ser: any) => void = () => { + return; + }, + testConfig = {} + ) => { + server = new Server(); + + server.connection({ port: 0 }); + + interface Config { + [key: string]: any; + } + const config: Config = { + 'server.basePath': '/foo', + ...testConfig, + }; + + server.decorate( + 'server', + 'config', + jest.fn(() => { + return { + get: jest.fn(key => { + return config[key]; + }), + }; + }) + ); + + server.savedObjects = { + SavedObjectsClient: { + errors: { + isNotFoundError: (e: Error) => e.message === 'space not found', + }, + }, + getSavedObjectsRepository: jest.fn().mockImplementation(() => { + return { + get: (type: string, id: string) => { + if (type === 'space') { + if (id === 'not-found') { + throw new Error('space not found'); + } + return { + id, + name: 'test space', + }; + } + }, + create: () => null, + }; + }), + }; + + server.plugins = { + spaces: { + spacesClient: { + getScopedClient: jest.fn(), + }, + }, + }; + + initSpacesRequestInterceptors(server); + + server.route([ + { + method: 'GET', + path: '/', + handler: (req: any, reply: any) => { + return reply({ path: req.path, url: req.url, basePath: req.getBasePath() }); + }, + }, + { + method: 'GET', + path: '/app/kibana', + handler: (req: any, reply: any) => { + return reply({ path: req.path, url: req.url, basePath: req.getBasePath() }); + }, + }, + { + method: 'GET', + path: '/api/foo', + handler: (req: any, reply: any) => { + return reply({ path: req.path, url: req.url, basePath: req.getBasePath() }); + }, + }, + ]); + + await setupFn(server); + + let basePath: string | undefined; + server.decorate('request', 'getBasePath', () => basePath); + server.decorate('request', 'setBasePath', (newPath: string) => { + basePath = newPath; + }); + + teardowns.push(() => server.stop()); + + return await server.inject({ + method: 'GET', + url: path, + headers, + }); + }; + }); + + afterEach(async () => { + await Promise.all(teardowns.splice(0).map(fn => fn())); + }); + + describe('onRequest', () => { + test('handles paths without a space identifier', async () => { + const testHandler = jest.fn((req, reply) => { + expect(req.path).toBe('/'); + return reply.continue(); + }); + + await request('/', (hapiServer: any) => { + hapiServer.ext('onRequest', testHandler); + }); + + expect(testHandler).toHaveBeenCalledTimes(1); + }); + + test('strips the Space URL Context from the request', async () => { + const testHandler = jest.fn((req, reply) => { + expect(req.path).toBe('/'); + return reply.continue(); + }); + + await request('/s/foo', (hapiServer: any) => { + hapiServer.ext('onRequest', testHandler); + }); + + expect(testHandler).toHaveBeenCalledTimes(1); + }); + + test('ignores space identifiers in the middle of the path', async () => { + const testHandler = jest.fn((req, reply) => { + expect(req.path).toBe('/some/path/s/foo/bar'); + return reply.continue(); + }); + + await request('/some/path/s/foo/bar', (hapiServer: any) => { + hapiServer.ext('onRequest', testHandler); + }); + + expect(testHandler).toHaveBeenCalledTimes(1); + }); + + test('strips the Space URL Context from the request, maintaining the rest of the path', async () => { + const testHandler = jest.fn((req, reply) => { + expect(req.path).toBe('/i/love/spaces.html'); + expect(req.query).toEqual({ + queryParam: 'queryValue', + }); + return reply.continue(); + }); + + await request('/s/foo/i/love/spaces.html?queryParam=queryValue', (hapiServer: any) => { + hapiServer.ext('onRequest', testHandler); + }); + + expect(testHandler).toHaveBeenCalledTimes(1); + }); + }); + + describe('onPostAuth', () => { + const serverBasePath = '/my/base/path'; + const defaultRoute = '/app/custom-app'; + + const config = { + 'server.basePath': serverBasePath, + 'server.defaultRoute': defaultRoute, + }; + + const setupTest = (hapiServer: any, spaces: SavedObject[], testHandler: any) => { + hapiServer.plugins.spaces.spacesClient.getScopedClient.mockReturnValue({ + getAll() { + return spaces; + }, + }); + + // Register test inspector + hapiServer.ext('onPreResponse', testHandler); + }; + + describe('when accessing an app within a non-existent space', () => { + it('redirects to the space selector screen', async () => { + const testHandler = jest.fn((req, reply) => { + const { response } = req; + + if (response && response.isBoom) { + throw response; + } + + expect(response.statusCode).toEqual(302); + expect(response.headers.location).toEqual(serverBasePath); + + return reply.continue(); + }); + + const spaces = [ + { + id: 'a-space', + type: 'space', + attributes: { + name: 'a space', + }, + }, + ]; + + await request( + '/s/not-found/app/kibana', + (hapiServer: any) => { + setupTest(hapiServer, spaces, testHandler); + }, + config + ); + + expect(testHandler).toHaveBeenCalledTimes(1); + }); + }); + + describe('when accessing an API endpoint within a non-existent space', () => { + it('allows the request to continue', async () => { + const testHandler = jest.fn((req, reply) => { + const { response } = req; + + if (response && response.isBoom) { + throw response; + } + + expect(response.statusCode).toEqual(200); + + return reply.continue(); + }); + + const spaces = [ + { + id: 'a-space', + type: 'space', + attributes: { + name: 'a space', + }, + }, + ]; + + await request( + '/s/not-found/api/foo', + (hapiServer: any) => { + setupTest(hapiServer, spaces, testHandler); + }, + config + ); + + expect(testHandler).toHaveBeenCalledTimes(1); + }); + }); + + describe('with a single available space', () => { + test('it redirects to the defaultRoute within the context of the single Space when navigating to Kibana root', async () => { + const testHandler = jest.fn((req, reply) => { + const { response } = req; + + if (response && response.isBoom) { + throw response; + } + + expect(response.statusCode).toEqual(302); + expect(response.headers.location).toEqual(`${serverBasePath}/s/a-space${defaultRoute}`); + + return reply.continue(); + }); + + const spaces = [ + { + id: 'a-space', + type: 'space', + attributes: { + name: 'a space', + }, + }, + ]; + + await request( + '/', + (hapiServer: any) => { + setupTest(server, spaces, testHandler); + }, + config + ); + + expect(testHandler).toHaveBeenCalledTimes(1); + expect(server.plugins.spaces.spacesClient.getScopedClient).toHaveBeenCalledWith( + expect.objectContaining({ + headers: expect.objectContaining({ + authorization: headers.authorization, + }), + }) + ); + }); + + test('it redirects to the defaultRoute within the context of the Default Space when navigating to Kibana root', async () => { + // This is very similar to the test above, but this handles the condition where the only available space is the Default Space, + // which does not have a URL Context. In this scenario, the end result is the same as the other test, but the final URL the user + // is redirected to does not contain a space identifier (e.g., /s/foo) + + const testHandler = jest.fn((req, reply) => { + const { response } = req; + + if (response && response.isBoom) { + throw response; + } + + expect(response.statusCode).toEqual(302); + expect(response.headers.location).toEqual(`${serverBasePath}${defaultRoute}`); + + return reply.continue(); + }); + + const spaces = [ + { + id: 'default', + type: 'space', + attributes: { + name: 'Default Space', + }, + }, + ]; + + await request( + '/', + (hapiServer: any) => { + setupTest(hapiServer, spaces, testHandler); + }, + config + ); + + expect(testHandler).toHaveBeenCalledTimes(1); + expect(server.plugins.spaces.spacesClient.getScopedClient).toHaveBeenCalledWith( + expect.objectContaining({ + headers: expect.objectContaining({ + authorization: headers.authorization, + }), + }) + ); + }); + }); + + describe('with multiple available spaces', () => { + test('it redirects to the Space Selector App when navigating to Kibana root', async () => { + const spaces = [ + { + id: 'a-space', + type: 'space', + attributes: { + name: 'a space', + }, + }, + { + id: 'b-space', + type: 'space', + attributes: { + name: 'b space', + }, + }, + ]; + + const getHiddenUiAppHandler = jest.fn(() => '
space selector
'); + + const testHandler = jest.fn((req, reply) => { + const { response } = req; + + if (response && response.isBoom) { + throw response; + } + + expect(response.statusCode).toEqual(200); + expect(response.source).toEqual({ app: '
space selector
', renderApp: true }); + + return reply.continue(); + }); + + await request( + '/', + (hapiServer: any) => { + server.decorate('server', 'getHiddenUiAppById', getHiddenUiAppHandler); + server.decorate('reply', 'renderApp', function renderAppHandler(app: any) { + // @ts-ignore + this({ renderApp: true, app }); + }); + + setupTest(hapiServer, spaces, testHandler); + }, + config + ); + + expect(getHiddenUiAppHandler).toHaveBeenCalledTimes(1); + expect(getHiddenUiAppHandler).toHaveBeenCalledWith('space_selector'); + expect(testHandler).toHaveBeenCalledTimes(1); + expect(server.plugins.spaces.spacesClient.getScopedClient).toHaveBeenCalledWith( + expect.objectContaining({ + headers: expect.objectContaining({ + authorization: headers.authorization, + }), + }) + ); + }); + }); + }); +}); diff --git a/x-pack/plugins/spaces/server/lib/space_request_interceptors.ts b/x-pack/plugins/spaces/server/lib/space_request_interceptors.ts new file mode 100644 index 00000000000000..dd5b865d5fd8e2 --- /dev/null +++ b/x-pack/plugins/spaces/server/lib/space_request_interceptors.ts @@ -0,0 +1,102 @@ +/* + * 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 { DEFAULT_SPACE_ID } from '../../common/constants'; +import { wrapError } from './errors'; +import { getSpaceSelectorUrl } from './get_space_selector_url'; +import { addSpaceIdToPath, getSpaceIdFromPath } from './spaces_url_parser'; + +export function initSpacesRequestInterceptors(server: any) { + const serverBasePath = server.config().get('server.basePath'); + + server.ext('onRequest', async function spacesOnRequestHandler(request: any, reply: any) { + const path = request.path; + + // If navigating within the context of a space, then we store the Space's URL Context on the request, + // and rewrite the request to not include the space identifier in the URL. + const spaceId = getSpaceIdFromPath(path, serverBasePath); + + if (spaceId !== DEFAULT_SPACE_ID) { + const reqBasePath = `/s/${spaceId}`; + request.setBasePath(reqBasePath); + + const newLocation = path.substr(reqBasePath.length) || '/'; + + const newUrl = { + ...request.url, + path: newLocation, + pathname: newLocation, + href: newLocation, + }; + + request.setUrl(newUrl); + } + + return reply.continue(); + }); + + server.ext('onPostAuth', async function spacesOnRequestHandler(request: any, reply: any) { + const path = request.path; + + const isRequestingKibanaRoot = path === '/'; + const isRequestingApplication = path.startsWith('/app'); + + // if requesting the application root, then show the Space Selector UI to allow the user to choose which space + // they wish to visit. This is done "onPostAuth" to allow the Saved Objects Client to use the request's auth scope, + // which is not available at the time of "onRequest". + if (isRequestingKibanaRoot) { + try { + const spacesClient = server.plugins.spaces.spacesClient.getScopedClient(request); + const spaces = await spacesClient.getAll(); + + const config = server.config(); + const basePath = config.get('server.basePath'); + const defaultRoute = config.get('server.defaultRoute'); + + if (spaces.length === 1) { + // If only one space is available, then send user there directly. + // No need for an interstitial screen where there is only one possible outcome. + const space = spaces[0]; + + const destination = addSpaceIdToPath(basePath, space.id, defaultRoute); + return reply.redirect(destination); + } + + if (spaces.length > 0) { + // render spaces selector instead of home page + const app = server.getHiddenUiAppById('space_selector'); + return reply.renderApp(app, { + spaces, + }); + } + } catch (error) { + return reply(wrapError(error)); + } + } + + // This condition should only happen after selecting a space, or when transitioning from one application to another + // e.g.: Navigating from Dashboard to Timelion + if (isRequestingApplication) { + let spaceId; + try { + const spacesClient = server.plugins.spaces.spacesClient.getScopedClient(request); + spaceId = getSpaceIdFromPath(request.getBasePath(), serverBasePath); + + server.log(['spaces', 'debug'], `Verifying access to space "${spaceId}"`); + + await spacesClient.get(spaceId); + } catch (error) { + server.log( + ['spaces', 'error'], + `Unable to navigate to space "${spaceId}", redirecting to Space Selector. ${error}` + ); + // Space doesn't exist, or user not authorized for space, or some other issue retrieving the active space. + return reply.redirect(getSpaceSelectorUrl(server.config())); + } + } + return reply.continue(); + }); +} diff --git a/x-pack/plugins/spaces/server/lib/space_schema.ts b/x-pack/plugins/spaces/server/lib/space_schema.ts new file mode 100644 index 00000000000000..043856235acba1 --- /dev/null +++ b/x-pack/plugins/spaces/server/lib/space_schema.ts @@ -0,0 +1,17 @@ +/* + * 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 Joi from 'joi'; +import { MAX_SPACE_INITIALS } from '../../common/constants'; + +export const spaceSchema = Joi.object({ + id: Joi.string().regex(/[a-z0-9_\-]*/, `lower case, a-z, 0-9, "_", and "-" are allowed`), + name: Joi.string().required(), + description: Joi.string(), + initials: Joi.string().max(MAX_SPACE_INITIALS), + color: Joi.string().regex(/^#[a-z0-9]{6}$/, `6 digit hex color, starting with a #`), + _reserved: Joi.boolean(), +}).default(); diff --git a/x-pack/plugins/spaces/server/lib/spaces_client.test.ts b/x-pack/plugins/spaces/server/lib/spaces_client.test.ts new file mode 100644 index 00000000000000..1c0c50c6a19688 --- /dev/null +++ b/x-pack/plugins/spaces/server/lib/spaces_client.test.ts @@ -0,0 +1,1032 @@ +/* + * 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 { SpacesClient } from './spaces_client'; + +const createMockAuditLogger = () => { + return { + spacesAuthorizationFailure: jest.fn(), + spacesAuthorizationSuccess: jest.fn(), + }; +}; + +const createMockAuthorization = () => { + const mockCheckPrivilegesAtSpace = jest.fn(); + const mockCheckPrivilegesAtSpaces = jest.fn(); + const mockCheckPrivilegesGlobally = jest.fn(); + + const mockAuthorization = { + actions: { + login: 'action:login', + manageSpaces: 'action:manageSpaces', + }, + checkPrivilegesWithRequest: jest.fn(() => ({ + atSpaces: mockCheckPrivilegesAtSpaces, + atSpace: mockCheckPrivilegesAtSpace, + globally: mockCheckPrivilegesGlobally, + })), + mode: { + useRbacForRequest: jest.fn(), + }, + }; + + return { + mockCheckPrivilegesAtSpaces, + mockCheckPrivilegesAtSpace, + mockCheckPrivilegesGlobally, + mockAuthorization, + }; +}; + +describe('#getAll', () => { + const savedObjects = [ + { + id: 'foo', + attributes: { + name: 'foo-name', + description: 'foo-description', + bar: 'foo-bar', + }, + }, + { + id: 'bar', + attributes: { + name: 'bar-name', + description: 'bar-description', + bar: 'bar-bar', + }, + }, + ]; + + const expectedSpaces = [ + { + id: 'foo', + name: 'foo-name', + description: 'foo-description', + bar: 'foo-bar', + }, + { + id: 'bar', + name: 'bar-name', + description: 'bar-description', + bar: 'bar-bar', + }, + ]; + + describe('authorization is null', () => { + test(`finds spaces using callWithRequestRepository`, async () => { + const mockAuditLogger = createMockAuditLogger(); + const authorization = null; + const mockCallWithRequestRepository = { + find: jest.fn(), + }; + mockCallWithRequestRepository.find.mockReturnValue({ + saved_objects: savedObjects, + }); + const request = Symbol(); + + const client = new SpacesClient( + mockAuditLogger as any, + authorization, + mockCallWithRequestRepository, + null, + request + ); + const actualSpaces = await client.getAll(); + + expect(actualSpaces).toEqual(expectedSpaces); + expect(mockCallWithRequestRepository.find).toHaveBeenCalledWith({ + type: 'space', + page: 1, + perPage: 1000, + sortField: 'name.keyword', + }); + expect(mockAuditLogger.spacesAuthorizationFailure).toHaveBeenCalledTimes(0); + expect(mockAuditLogger.spacesAuthorizationSuccess).toHaveBeenCalledTimes(0); + }); + }); + + describe(`authorization.mode.useRbacForRequest returns false`, () => { + test(`finds spaces using callWithRequestRepository`, async () => { + const mockAuditLogger = createMockAuditLogger(); + const { mockAuthorization } = createMockAuthorization(); + mockAuthorization.mode.useRbacForRequest.mockReturnValue(false); + const mockCallWithRequestRepository = { + find: jest.fn().mockReturnValue({ + saved_objects: savedObjects, + }), + }; + const request = Symbol(); + + const client = new SpacesClient( + mockAuditLogger as any, + mockAuthorization, + mockCallWithRequestRepository, + null, + request + ); + const actualSpaces = await client.getAll(); + + expect(actualSpaces).toEqual(expectedSpaces); + expect(mockCallWithRequestRepository.find).toHaveBeenCalledWith({ + type: 'space', + page: 1, + perPage: 1000, + sortField: 'name.keyword', + }); + expect(mockAuthorization.mode.useRbacForRequest).toHaveBeenCalledWith(request); + expect(mockAuditLogger.spacesAuthorizationFailure).toHaveBeenCalledTimes(0); + expect(mockAuditLogger.spacesAuthorizationSuccess).toHaveBeenCalledTimes(0); + }); + }); + + describe('useRbacForRequest is true', () => { + test(`throws Boom.forbidden when user isn't authorized for any spaces`, async () => { + const username = Symbol(); + const mockAuditLogger = createMockAuditLogger(); + const { mockAuthorization, mockCheckPrivilegesAtSpaces } = createMockAuthorization(); + mockAuthorization.mode.useRbacForRequest.mockReturnValue(true); + mockCheckPrivilegesAtSpaces.mockReturnValue({ + username, + spacePrivileges: { + [savedObjects[0].id]: { + [mockAuthorization.actions.login]: false, + }, + [savedObjects[1].id]: { + [mockAuthorization.actions.login]: false, + }, + }, + }); + const mockInternalRepository = { + find: jest.fn().mockReturnValue({ + saved_objects: savedObjects, + }), + }; + const request = Symbol(); + + const client = new SpacesClient( + mockAuditLogger as any, + mockAuthorization, + null, + mockInternalRepository, + request + ); + await expect(client.getAll()).rejects.toThrowErrorMatchingSnapshot(); + + expect(mockInternalRepository.find).toHaveBeenCalledWith({ + type: 'space', + page: 1, + perPage: 1000, + sortField: 'name.keyword', + }); + expect(mockAuthorization.mode.useRbacForRequest).toHaveBeenCalledWith(request); + expect(mockAuthorization.checkPrivilegesWithRequest).toHaveBeenCalledWith(request); + expect(mockCheckPrivilegesAtSpaces).toHaveBeenCalledWith( + savedObjects.map(savedObject => savedObject.id), + mockAuthorization.actions.login + ); + expect(mockAuditLogger.spacesAuthorizationFailure).toHaveBeenCalledWith(username, 'getAll'); + expect(mockAuditLogger.spacesAuthorizationSuccess).toHaveBeenCalledTimes(0); + }); + + test(`returns spaces that the user is authorized for`, async () => { + const username = Symbol(); + const mockAuditLogger = createMockAuditLogger(); + const { mockAuthorization, mockCheckPrivilegesAtSpaces } = createMockAuthorization(); + mockAuthorization.mode.useRbacForRequest.mockReturnValue(true); + mockCheckPrivilegesAtSpaces.mockReturnValue({ + username, + spacePrivileges: { + [savedObjects[0].id]: { + [mockAuthorization.actions.login]: true, + }, + [savedObjects[1].id]: { + [mockAuthorization.actions.login]: false, + }, + }, + }); + const mockInternalRepository = { + find: jest.fn().mockReturnValue({ + saved_objects: savedObjects, + }), + }; + const request = Symbol(); + + const client = new SpacesClient( + mockAuditLogger as any, + mockAuthorization, + null, + mockInternalRepository, + request + ); + const actualSpaces = await client.getAll(); + + expect(actualSpaces).toEqual([expectedSpaces[0]]); + expect(mockInternalRepository.find).toHaveBeenCalledWith({ + type: 'space', + page: 1, + perPage: 1000, + sortField: 'name.keyword', + }); + expect(mockAuthorization.mode.useRbacForRequest).toHaveBeenCalledWith(request); + expect(mockAuthorization.checkPrivilegesWithRequest).toHaveBeenCalledWith(request); + expect(mockCheckPrivilegesAtSpaces).toHaveBeenCalledWith( + savedObjects.map(savedObject => savedObject.id), + mockAuthorization.actions.login + ); + expect(mockAuditLogger.spacesAuthorizationFailure).toHaveBeenCalledTimes(0); + expect(mockAuditLogger.spacesAuthorizationSuccess).toHaveBeenCalledWith(username, 'getAll', [ + savedObjects[0].id, + ]); + }); + }); +}); + +describe('#canEnumerateSpaces', () => { + describe(`authorization is null`, () => { + test(`returns true`, async () => { + const mockAuditLogger = createMockAuditLogger(); + const authorization = null; + const request = Symbol(); + + const client = new SpacesClient(mockAuditLogger as any, authorization, null, null, request); + + const canEnumerateSpaces = await client.canEnumerateSpaces(); + expect(canEnumerateSpaces).toEqual(true); + expect(mockAuditLogger.spacesAuthorizationFailure).toHaveBeenCalledTimes(0); + expect(mockAuditLogger.spacesAuthorizationSuccess).toHaveBeenCalledTimes(0); + }); + }); + + describe(`authorization.mode.useRbacForRequest is false`, () => { + test(`returns true`, async () => { + const mockAuditLogger = createMockAuditLogger(); + const { mockAuthorization } = createMockAuthorization(); + mockAuthorization.mode.useRbacForRequest.mockReturnValue(false); + const request = Symbol(); + + const client = new SpacesClient( + mockAuditLogger as any, + mockAuthorization, + null, + null, + request + ); + const canEnumerateSpaces = await client.canEnumerateSpaces(); + + expect(canEnumerateSpaces).toEqual(true); + expect(mockAuditLogger.spacesAuthorizationFailure).toHaveBeenCalledTimes(0); + expect(mockAuditLogger.spacesAuthorizationSuccess).toHaveBeenCalledTimes(0); + }); + }); + + describe('useRbacForRequest is true', () => { + test(`returns false if user is not authorized to enumerate spaces`, async () => { + const username = Symbol(); + const mockAuditLogger = createMockAuditLogger(); + const { mockAuthorization, mockCheckPrivilegesGlobally } = createMockAuthorization(); + mockAuthorization.mode.useRbacForRequest.mockReturnValue(true); + mockCheckPrivilegesGlobally.mockReturnValue({ + username, + hasAllRequested: false, + }); + const request = Symbol(); + + const client = new SpacesClient( + mockAuditLogger as any, + mockAuthorization, + null, + null, + request + ); + + const canEnumerateSpaces = await client.canEnumerateSpaces(); + expect(canEnumerateSpaces).toEqual(false); + + expect(mockAuthorization.checkPrivilegesWithRequest).toHaveBeenCalledWith(request); + expect(mockCheckPrivilegesGlobally).toHaveBeenCalledWith( + mockAuthorization.actions.manageSpaces + ); + + expect(mockAuditLogger.spacesAuthorizationFailure).toHaveBeenCalledTimes(0); + expect(mockAuditLogger.spacesAuthorizationSuccess).toHaveBeenCalledTimes(0); + }); + + test(`returns true if user is authorized to enumerate spaces`, async () => { + const username = Symbol(); + const mockAuditLogger = createMockAuditLogger(); + const { mockAuthorization, mockCheckPrivilegesGlobally } = createMockAuthorization(); + mockAuthorization.mode.useRbacForRequest.mockReturnValue(true); + mockCheckPrivilegesGlobally.mockReturnValue({ + username, + hasAllRequested: true, + }); + const request = Symbol(); + + const client = new SpacesClient( + mockAuditLogger as any, + mockAuthorization, + null, + null, + request + ); + + const canEnumerateSpaces = await client.canEnumerateSpaces(); + expect(canEnumerateSpaces).toEqual(true); + + expect(mockAuthorization.checkPrivilegesWithRequest).toHaveBeenCalledWith(request); + expect(mockCheckPrivilegesGlobally).toHaveBeenCalledWith( + mockAuthorization.actions.manageSpaces + ); + + expect(mockAuditLogger.spacesAuthorizationFailure).toHaveBeenCalledTimes(0); + expect(mockAuditLogger.spacesAuthorizationSuccess).toHaveBeenCalledTimes(0); + }); + }); +}); + +describe('#get', () => { + const savedObject = { + id: 'foo', + attributes: { + name: 'foo-name', + description: 'foo-description', + bar: 'foo-bar', + }, + }; + + const expectedSpace = { + id: 'foo', + name: 'foo-name', + description: 'foo-description', + bar: 'foo-bar', + }; + + describe(`authorization is null`, () => { + test(`gets space using callWithRequestRepository`, async () => { + const mockAuditLogger = createMockAuditLogger(); + const authorization = null; + const mockCallWithRequestRepository = { + get: jest.fn().mockReturnValue(savedObject), + }; + const request = Symbol(); + + const client = new SpacesClient( + mockAuditLogger as any, + authorization, + mockCallWithRequestRepository, + null, + request + ); + const id = savedObject.id; + const actualSpace = await client.get(id); + + expect(actualSpace).toEqual(expectedSpace); + expect(mockCallWithRequestRepository.get).toHaveBeenCalledWith('space', id); + expect(mockAuditLogger.spacesAuthorizationFailure).toHaveBeenCalledTimes(0); + expect(mockAuditLogger.spacesAuthorizationSuccess).toHaveBeenCalledTimes(0); + }); + }); + + describe(`authorization.mode.useRbacForRequest returns false`, () => { + test(`gets space using callWithRequestRepository`, async () => { + const mockAuditLogger = createMockAuditLogger(); + const { mockAuthorization } = createMockAuthorization(); + mockAuthorization.mode.useRbacForRequest.mockReturnValue(false); + const mockCallWithRequestRepository = { + get: jest.fn().mockReturnValue(savedObject), + }; + const request = Symbol(); + + const client = new SpacesClient( + mockAuditLogger as any, + mockAuthorization, + mockCallWithRequestRepository, + null, + request + ); + const id = savedObject.id; + const actualSpace = await client.get(id); + + expect(actualSpace).toEqual(expectedSpace); + expect(mockAuthorization.mode.useRbacForRequest).toHaveBeenCalledWith(request); + expect(mockCallWithRequestRepository.get).toHaveBeenCalledWith('space', id); + expect(mockAuditLogger.spacesAuthorizationFailure).toHaveBeenCalledTimes(0); + expect(mockAuditLogger.spacesAuthorizationSuccess).toHaveBeenCalledTimes(0); + }); + }); + + describe('useRbacForRequest is true', () => { + test(`throws Boom.forbidden if the user isn't authorized at space`, async () => { + const username = Symbol(); + const mockAuditLogger = createMockAuditLogger(); + const { mockAuthorization, mockCheckPrivilegesAtSpace } = createMockAuthorization(); + mockAuthorization.mode.useRbacForRequest.mockReturnValue(true); + mockCheckPrivilegesAtSpace.mockReturnValue({ + username, + hasAllRequested: false, + }); + const request = Symbol(); + + const client = new SpacesClient( + mockAuditLogger as any, + mockAuthorization, + null, + null, + request + ); + const id = 'foo-space'; + + await expect(client.get(id)).rejects.toThrowErrorMatchingSnapshot(); + + expect(mockAuthorization.checkPrivilegesWithRequest).toHaveBeenCalledWith(request); + expect(mockCheckPrivilegesAtSpace).toHaveBeenCalledWith(id, mockAuthorization.actions.login); + expect(mockAuditLogger.spacesAuthorizationFailure).toHaveBeenCalledWith(username, 'get', [ + id, + ]); + expect(mockAuditLogger.spacesAuthorizationSuccess).toHaveBeenCalledTimes(0); + }); + + test(`returns space using internalRepository if the user is authorized at space`, async () => { + const username = Symbol(); + const mockAuditLogger = createMockAuditLogger(); + const { mockAuthorization, mockCheckPrivilegesAtSpace } = createMockAuthorization(); + mockAuthorization.mode.useRbacForRequest.mockReturnValue(true); + mockCheckPrivilegesAtSpace.mockReturnValue({ + username, + hasAllRequested: true, + }); + const request = Symbol(); + const mockInternalRepository = { + get: jest.fn().mockReturnValue(savedObject), + }; + + const client = new SpacesClient( + mockAuditLogger as any, + mockAuthorization, + null, + mockInternalRepository, + request + ); + const id = savedObject.id; + + const space = await client.get(id); + + expect(space).toEqual(expectedSpace); + expect(mockAuthorization.checkPrivilegesWithRequest).toHaveBeenCalledWith(request); + expect(mockCheckPrivilegesAtSpace).toHaveBeenCalledWith(id, mockAuthorization.actions.login); + expect(mockInternalRepository.get).toHaveBeenCalledWith('space', id); + expect(mockAuditLogger.spacesAuthorizationFailure).toHaveBeenCalledTimes(0); + expect(mockAuditLogger.spacesAuthorizationSuccess).toHaveBeenCalledWith(username, 'get', [ + id, + ]); + }); + }); +}); + +describe('#create', () => { + const id = 'foo'; + + const spaceToCreate = { + id, + name: 'foo-name', + description: 'foo-description', + bar: 'foo-bar', + _reserved: true, + }; + + const attributes = { + name: 'foo-name', + description: 'foo-description', + bar: 'foo-bar', + }; + + const savedObject = { + id, + attributes: { + name: 'foo-name', + description: 'foo-description', + bar: 'foo-bar', + }, + }; + + const expectedReturnedSpace = { + id, + name: 'foo-name', + description: 'foo-description', + bar: 'foo-bar', + }; + + describe(`authorization is null`, () => { + test(`creates space using callWithRequestRepository`, async () => { + const mockAuditLogger = createMockAuditLogger(); + const authorization = null; + const mockCallWithRequestRepository = { + create: jest.fn().mockReturnValue(savedObject), + }; + const request = Symbol(); + + const client = new SpacesClient( + mockAuditLogger as any, + authorization, + mockCallWithRequestRepository, + null, + request + ); + + const actualSpace = await client.create(spaceToCreate); + + expect(actualSpace).toEqual(expectedReturnedSpace); + expect(mockCallWithRequestRepository.create).toHaveBeenCalledWith('space', attributes, { + id, + }); + expect(mockAuditLogger.spacesAuthorizationFailure).toHaveBeenCalledTimes(0); + expect(mockAuditLogger.spacesAuthorizationSuccess).toHaveBeenCalledTimes(0); + }); + }); + + describe(`authorization.mode.useRbacForRequest returns false`, () => { + test(`creates space using callWithRequestRepository`, async () => { + const mockAuditLogger = createMockAuditLogger(); + const { mockAuthorization } = createMockAuthorization(); + mockAuthorization.mode.useRbacForRequest.mockReturnValue(false); + const mockCallWithRequestRepository = { + create: jest.fn().mockReturnValue(savedObject), + }; + const request = Symbol(); + + const client = new SpacesClient( + mockAuditLogger as any, + mockAuthorization, + mockCallWithRequestRepository, + null, + request + ); + + const actualSpace = await client.create(spaceToCreate); + + expect(actualSpace).toEqual(expectedReturnedSpace); + expect(mockAuthorization.mode.useRbacForRequest).toHaveBeenCalledWith(request); + expect(mockCallWithRequestRepository.create).toHaveBeenCalledWith('space', attributes, { + id, + }); + expect(mockAuditLogger.spacesAuthorizationFailure).toHaveBeenCalledTimes(0); + expect(mockAuditLogger.spacesAuthorizationSuccess).toHaveBeenCalledTimes(0); + }); + }); + + describe('useRbacForRequest is true', () => { + test(`throws Boom.forbidden if the user isn't authorized at space`, async () => { + const username = Symbol(); + const mockAuditLogger = createMockAuditLogger(); + const { mockAuthorization, mockCheckPrivilegesGlobally } = createMockAuthorization(); + mockAuthorization.mode.useRbacForRequest.mockReturnValue(true); + mockCheckPrivilegesGlobally.mockReturnValue({ + username, + hasAllRequested: false, + }); + const request = Symbol(); + + const client = new SpacesClient( + mockAuditLogger as any, + mockAuthorization, + null, + null, + request + ); + + await expect(client.create(spaceToCreate)).rejects.toThrowErrorMatchingSnapshot(); + + expect(mockAuthorization.mode.useRbacForRequest).toHaveBeenCalledWith(request); + expect(mockAuthorization.checkPrivilegesWithRequest).toHaveBeenCalledWith(request); + expect(mockCheckPrivilegesGlobally).toHaveBeenCalledWith( + mockAuthorization.actions.manageSpaces + ); + expect(mockAuditLogger.spacesAuthorizationFailure).toHaveBeenCalledWith(username, 'create'); + expect(mockAuditLogger.spacesAuthorizationSuccess).toHaveBeenCalledTimes(0); + }); + + test(`creates space using internalRepository if the user is authorized`, async () => { + const username = Symbol(); + const mockAuditLogger = createMockAuditLogger(); + const { mockAuthorization, mockCheckPrivilegesGlobally } = createMockAuthorization(); + mockAuthorization.mode.useRbacForRequest.mockReturnValue(true); + mockCheckPrivilegesGlobally.mockReturnValue({ + username, + hasAllRequested: true, + }); + const mockInternalRepository = { + create: jest.fn().mockReturnValue(savedObject), + }; + const request = Symbol(); + + const client = new SpacesClient( + mockAuditLogger as any, + mockAuthorization, + null, + mockInternalRepository, + request + ); + + const actualSpace = await client.create(spaceToCreate); + + expect(actualSpace).toEqual(expectedReturnedSpace); + expect(mockAuthorization.mode.useRbacForRequest).toHaveBeenCalledWith(request); + expect(mockAuthorization.checkPrivilegesWithRequest).toHaveBeenCalledWith(request); + expect(mockCheckPrivilegesGlobally).toHaveBeenCalledWith( + mockAuthorization.actions.manageSpaces + ); + expect(mockAuditLogger.spacesAuthorizationFailure).toHaveBeenCalledTimes(0); + expect(mockAuditLogger.spacesAuthorizationSuccess).toHaveBeenCalledWith(username, 'create'); + }); + }); +}); + +describe('#update', () => { + const spaceToUpdate = { + id: 'foo', + name: 'foo-name', + description: 'foo-description', + bar: 'foo-bar', + _reserved: false, + }; + + const attributes = { + name: 'foo-name', + description: 'foo-description', + bar: 'foo-bar', + }; + + const savedObject = { + id: 'foo', + attributes: { + name: 'foo-name', + description: 'foo-description', + bar: 'foo-bar', + _reserved: true, + }, + }; + + const expectedReturnedSpace = { + id: 'foo', + name: 'foo-name', + description: 'foo-description', + bar: 'foo-bar', + _reserved: true, + }; + + describe(`authorization is null`, () => { + test(`updates space using callWithRequestRepository`, async () => { + const mockAuditLogger = createMockAuditLogger(); + const authorization = null; + const mockCallWithRequestRepository = { + update: jest.fn(), + get: jest.fn().mockReturnValue(savedObject), + }; + const request = Symbol(); + + const client = new SpacesClient( + mockAuditLogger as any, + authorization, + mockCallWithRequestRepository, + null, + request + ); + const id = savedObject.id; + const actualSpace = await client.update(id, spaceToUpdate); + + expect(actualSpace).toEqual(expectedReturnedSpace); + expect(mockCallWithRequestRepository.update).toHaveBeenCalledWith('space', id, attributes); + expect(mockCallWithRequestRepository.get).toHaveBeenCalledWith('space', id); + expect(mockAuditLogger.spacesAuthorizationFailure).toHaveBeenCalledTimes(0); + expect(mockAuditLogger.spacesAuthorizationSuccess).toHaveBeenCalledTimes(0); + }); + }); + describe(`authorization.mode.useRbacForRequest returns false`, () => { + test(`updates space using callWithRequestRepository`, async () => { + const mockAuditLogger = createMockAuditLogger(); + const { mockAuthorization } = createMockAuthorization(); + mockAuthorization.mode.useRbacForRequest.mockReturnValue(false); + const mockCallWithRequestRepository = { + update: jest.fn(), + get: jest.fn().mockReturnValue(savedObject), + }; + const request = Symbol(); + + const client = new SpacesClient( + mockAuditLogger as any, + mockAuthorization, + mockCallWithRequestRepository, + null, + request + ); + const id = savedObject.id; + const actualSpace = await client.update(id, spaceToUpdate); + + expect(actualSpace).toEqual(expectedReturnedSpace); + expect(mockAuthorization.mode.useRbacForRequest).toHaveBeenCalledWith(request); + expect(mockCallWithRequestRepository.update).toHaveBeenCalledWith('space', id, attributes); + expect(mockCallWithRequestRepository.get).toHaveBeenCalledWith('space', id); + expect(mockAuditLogger.spacesAuthorizationFailure).toHaveBeenCalledTimes(0); + expect(mockAuditLogger.spacesAuthorizationSuccess).toHaveBeenCalledTimes(0); + }); + }); + + describe('useRbacForRequest is true', () => { + test(`throws Boom.forbidden when user isn't authorized at space`, async () => { + const username = Symbol(); + const mockAuditLogger = createMockAuditLogger(); + const { mockAuthorization, mockCheckPrivilegesGlobally } = createMockAuthorization(); + mockCheckPrivilegesGlobally.mockReturnValue({ + hasAllRequested: false, + username, + }); + mockAuthorization.mode.useRbacForRequest.mockReturnValue(true); + const request = Symbol(); + + const client = new SpacesClient( + mockAuditLogger as any, + mockAuthorization, + null, + null, + request + ); + const id = savedObject.id; + await expect(client.update(id, spaceToUpdate)).rejects.toThrowErrorMatchingSnapshot(); + + expect(mockAuthorization.mode.useRbacForRequest).toHaveBeenCalledWith(request); + expect(mockAuthorization.checkPrivilegesWithRequest).toHaveBeenCalledWith(request); + expect(mockCheckPrivilegesGlobally).toHaveBeenCalledWith( + mockAuthorization.actions.manageSpaces + ); + expect(mockAuditLogger.spacesAuthorizationFailure).toHaveBeenCalledWith(username, 'update'); + expect(mockAuditLogger.spacesAuthorizationSuccess).toHaveBeenCalledTimes(0); + }); + + test(`updates space using internalRepository if user is authorized`, async () => { + const username = Symbol(); + const mockAuditLogger = createMockAuditLogger(); + const { mockAuthorization, mockCheckPrivilegesGlobally } = createMockAuthorization(); + mockCheckPrivilegesGlobally.mockReturnValue({ + hasAllRequested: true, + username, + }); + mockAuthorization.mode.useRbacForRequest.mockReturnValue(true); + const mockInternalRepository = { + update: jest.fn(), + get: jest.fn().mockReturnValue(savedObject), + }; + const request = Symbol(); + + const client = new SpacesClient( + mockAuditLogger as any, + mockAuthorization, + null, + mockInternalRepository, + request + ); + const id = savedObject.id; + const actualSpace = await client.update(id, spaceToUpdate); + + expect(actualSpace).toEqual(expectedReturnedSpace); + expect(mockAuthorization.mode.useRbacForRequest).toHaveBeenCalledWith(request); + expect(mockAuthorization.checkPrivilegesWithRequest).toHaveBeenCalledWith(request); + expect(mockCheckPrivilegesGlobally).toHaveBeenCalledWith( + mockAuthorization.actions.manageSpaces + ); + expect(mockInternalRepository.update).toHaveBeenCalledWith('space', id, attributes); + expect(mockInternalRepository.get).toHaveBeenCalledWith('space', id); + expect(mockAuditLogger.spacesAuthorizationFailure).toHaveBeenCalledTimes(0); + expect(mockAuditLogger.spacesAuthorizationSuccess).toHaveBeenCalledWith(username, 'update'); + }); + }); +}); + +describe('#delete', () => { + const id = 'foo'; + + const reservedSavedObject = { + id, + attributes: { + name: 'foo-name', + description: 'foo-description', + bar: 'foo-bar', + _reserved: true, + }, + }; + + const notReservedSavedObject = { + id, + attributes: { + name: 'foo-name', + description: 'foo-description', + bar: 'foo-bar', + }, + }; + + describe(`authorization is null`, () => { + test(`throws Boom.badRequest when the space is reserved`, async () => { + const mockAuditLogger = createMockAuditLogger(); + const authorization = null; + const mockCallWithRequestRepository = { + get: jest.fn().mockReturnValue(reservedSavedObject), + }; + const request = Symbol(); + + const client = new SpacesClient( + mockAuditLogger as any, + authorization, + mockCallWithRequestRepository, + null, + request + ); + + await expect(client.delete(id)).rejects.toThrowErrorMatchingSnapshot(); + + expect(mockCallWithRequestRepository.get).toHaveBeenCalledWith('space', id); + expect(mockAuditLogger.spacesAuthorizationFailure).toHaveBeenCalledTimes(0); + expect(mockAuditLogger.spacesAuthorizationSuccess).toHaveBeenCalledTimes(0); + }); + + test(`deletes space using callWithRequestRepository when space isn't reserved`, async () => { + const mockAuditLogger = createMockAuditLogger(); + const authorization = null; + const mockCallWithRequestRepository = { + get: jest.fn().mockReturnValue(notReservedSavedObject), + delete: jest.fn(), + }; + const request = Symbol(); + + const client = new SpacesClient( + mockAuditLogger as any, + authorization, + mockCallWithRequestRepository, + null, + request + ); + + await client.delete(id); + + expect(mockCallWithRequestRepository.get).toHaveBeenCalledWith('space', id); + expect(mockCallWithRequestRepository.delete).toHaveBeenCalledWith('space', id); + expect(mockAuditLogger.spacesAuthorizationFailure).toHaveBeenCalledTimes(0); + expect(mockAuditLogger.spacesAuthorizationSuccess).toHaveBeenCalledTimes(0); + }); + }); + + describe(`authorization.mode.useRbacForRequest returns false`, () => { + test(`throws Boom.badRequest when the space is reserved`, async () => { + const mockAuditLogger = createMockAuditLogger(); + const { mockAuthorization } = createMockAuthorization(); + mockAuthorization.mode.useRbacForRequest.mockReturnValue(false); + const mockCallWithRequestRepository = { + get: jest.fn().mockReturnValue(reservedSavedObject), + }; + const request = Symbol(); + + const client = new SpacesClient( + mockAuditLogger as any, + mockAuthorization, + mockCallWithRequestRepository, + null, + request + ); + + await expect(client.delete(id)).rejects.toThrowErrorMatchingSnapshot(); + + expect(mockAuthorization.mode.useRbacForRequest).toHaveBeenCalledWith(request); + expect(mockCallWithRequestRepository.get).toHaveBeenCalledWith('space', id); + expect(mockAuditLogger.spacesAuthorizationFailure).toHaveBeenCalledTimes(0); + expect(mockAuditLogger.spacesAuthorizationSuccess).toHaveBeenCalledTimes(0); + }); + + test(`deletes space using callWithRequestRepository when space isn't reserved`, async () => { + const mockAuditLogger = createMockAuditLogger(); + const { mockAuthorization } = createMockAuthorization(); + mockAuthorization.mode.useRbacForRequest.mockReturnValue(false); + const mockCallWithRequestRepository = { + get: jest.fn().mockReturnValue(notReservedSavedObject), + delete: jest.fn(), + }; + const request = Symbol(); + + const client = new SpacesClient( + mockAuditLogger as any, + mockAuthorization, + mockCallWithRequestRepository, + null, + request + ); + + await client.delete(id); + + expect(mockAuthorization.mode.useRbacForRequest).toHaveBeenCalledWith(request); + expect(mockCallWithRequestRepository.get).toHaveBeenCalledWith('space', id); + expect(mockCallWithRequestRepository.delete).toHaveBeenCalledWith('space', id); + expect(mockAuditLogger.spacesAuthorizationFailure).toHaveBeenCalledTimes(0); + expect(mockAuditLogger.spacesAuthorizationSuccess).toHaveBeenCalledTimes(0); + }); + }); + + describe('authorization.mode.useRbacForRequest returns true', () => { + test(`throws Boom.forbidden if the user isn't authorized`, async () => { + const username = Symbol(); + const mockAuditLogger = createMockAuditLogger(); + const { mockAuthorization, mockCheckPrivilegesGlobally } = createMockAuthorization(); + mockAuthorization.mode.useRbacForRequest.mockReturnValue(true); + mockCheckPrivilegesGlobally.mockReturnValue({ + username, + hasAllRequested: false, + }); + const request = Symbol(); + const client = new SpacesClient( + mockAuditLogger as any, + mockAuthorization, + null, + null, + request + ); + + await expect(client.delete(id)).rejects.toThrowErrorMatchingSnapshot(); + + expect(mockAuthorization.mode.useRbacForRequest).toHaveBeenCalledWith(request); + expect(mockAuthorization.checkPrivilegesWithRequest).toHaveBeenCalledWith(request); + expect(mockCheckPrivilegesGlobally).toHaveBeenCalledWith( + mockAuthorization.actions.manageSpaces + ); + expect(mockAuditLogger.spacesAuthorizationFailure).toHaveBeenCalledWith(username, 'delete'); + expect(mockAuditLogger.spacesAuthorizationSuccess).toHaveBeenCalledTimes(0); + }); + + test(`throws Boom.badRequest if the user is authorized but the space is reserved`, async () => { + const username = Symbol(); + const mockAuditLogger = createMockAuditLogger(); + const { mockAuthorization, mockCheckPrivilegesGlobally } = createMockAuthorization(); + mockAuthorization.mode.useRbacForRequest.mockReturnValue(true); + mockCheckPrivilegesGlobally.mockReturnValue({ + username, + hasAllRequested: true, + }); + const mockInternalRepository = { + get: jest.fn().mockReturnValue(reservedSavedObject), + }; + const request = Symbol(); + const client = new SpacesClient( + mockAuditLogger as any, + mockAuthorization, + null, + mockInternalRepository, + request + ); + + await expect(client.delete(id)).rejects.toThrowErrorMatchingSnapshot(); + + expect(mockAuthorization.mode.useRbacForRequest).toHaveBeenCalledWith(request); + expect(mockAuthorization.checkPrivilegesWithRequest).toHaveBeenCalledWith(request); + expect(mockCheckPrivilegesGlobally).toHaveBeenCalledWith( + mockAuthorization.actions.manageSpaces + ); + expect(mockInternalRepository.get).toHaveBeenCalledWith('space', id); + expect(mockAuditLogger.spacesAuthorizationFailure).toHaveBeenCalledTimes(0); + expect(mockAuditLogger.spacesAuthorizationSuccess).toHaveBeenCalledWith(username, 'delete'); + }); + + test(`deletes space using internalRepository if the user is authorized and the space isn't reserved`, async () => { + const username = Symbol(); + const mockAuditLogger = createMockAuditLogger(); + const { mockAuthorization, mockCheckPrivilegesGlobally } = createMockAuthorization(); + mockAuthorization.mode.useRbacForRequest.mockReturnValue(true); + mockCheckPrivilegesGlobally.mockReturnValue({ + username, + hasAllRequested: true, + }); + const mockInternalRepository = { + get: jest.fn().mockReturnValue(notReservedSavedObject), + delete: jest.fn(), + }; + const request = Symbol(); + const client = new SpacesClient( + mockAuditLogger as any, + mockAuthorization, + null, + mockInternalRepository, + request + ); + + await client.delete(id); + + expect(mockAuthorization.mode.useRbacForRequest).toHaveBeenCalledWith(request); + expect(mockAuthorization.checkPrivilegesWithRequest).toHaveBeenCalledWith(request); + expect(mockCheckPrivilegesGlobally).toHaveBeenCalledWith( + mockAuthorization.actions.manageSpaces + ); + expect(mockInternalRepository.get).toHaveBeenCalledWith('space', id); + expect(mockInternalRepository.delete).toHaveBeenCalledWith('space', id); + expect(mockAuditLogger.spacesAuthorizationFailure).toHaveBeenCalledTimes(0); + expect(mockAuditLogger.spacesAuthorizationSuccess).toHaveBeenCalledWith(username, 'delete'); + }); + }); +}); diff --git a/x-pack/plugins/spaces/server/lib/spaces_client.ts b/x-pack/plugins/spaces/server/lib/spaces_client.ts new file mode 100644 index 00000000000000..5e29875c3c8ec0 --- /dev/null +++ b/x-pack/plugins/spaces/server/lib/spaces_client.ts @@ -0,0 +1,202 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import Boom from 'boom'; +import { omit } from 'lodash'; +import { isReservedSpace } from '../../common/is_reserved_space'; +import { Space } from '../../common/model/space'; +import { SpacesAuditLogger } from './audit_logger'; + +export class SpacesClient { + private readonly auditLogger: SpacesAuditLogger; + private readonly authorization: any; + private readonly callWithRequestSavedObjectRepository: any; + private readonly internalSavedObjectRepository: any; + private readonly request: any; + + constructor( + auditLogger: SpacesAuditLogger, + authorization: any, + callWithRequestSavedObjectRepository: any, + internalSavedObjectRepository: any, + request: any + ) { + this.auditLogger = auditLogger; + this.authorization = authorization; + this.callWithRequestSavedObjectRepository = callWithRequestSavedObjectRepository; + this.internalSavedObjectRepository = internalSavedObjectRepository; + this.request = request; + } + + public async canEnumerateSpaces(): Promise { + if (this.useRbac()) { + const checkPrivileges = this.authorization.checkPrivilegesWithRequest(this.request); + const { hasAllRequested } = await checkPrivileges.globally( + this.authorization.actions.manageSpaces + ); + return hasAllRequested; + } + + // If not RBAC, then we are legacy, and all legacy users can enumerate all spaces + return true; + } + + public async getAll(): Promise<[Space]> { + if (this.useRbac()) { + const { saved_objects } = await this.internalSavedObjectRepository.find({ + type: 'space', + page: 1, + perPage: 1000, + sortField: 'name.keyword', + }); + + const spaces = saved_objects.map(this.transformSavedObjectToSpace); + + const spaceIds = spaces.map((space: Space) => space.id); + const checkPrivileges = this.authorization.checkPrivilegesWithRequest(this.request); + const { username, spacePrivileges } = await checkPrivileges.atSpaces( + spaceIds, + this.authorization.actions.login + ); + + const authorized = Object.keys(spacePrivileges).filter(spaceId => { + return spacePrivileges[spaceId][this.authorization.actions.login]; + }); + + if (authorized.length === 0) { + this.auditLogger.spacesAuthorizationFailure(username, 'getAll'); + throw Boom.forbidden(); + } + + this.auditLogger.spacesAuthorizationSuccess(username, 'getAll', authorized); + return spaces.filter((space: any) => authorized.includes(space.id)); + } else { + const { saved_objects } = await this.callWithRequestSavedObjectRepository.find({ + type: 'space', + page: 1, + perPage: 1000, + sortField: 'name.keyword', + }); + + return saved_objects.map(this.transformSavedObjectToSpace); + } + } + + public async get(id: string): Promise { + if (this.useRbac()) { + await this.ensureAuthorizedAtSpace( + id, + this.authorization.actions.login, + 'get', + `Unauthorized to get ${id} space` + ); + } + const repository = this.useRbac() + ? this.internalSavedObjectRepository + : this.callWithRequestSavedObjectRepository; + + const savedObject = await repository.get('space', id); + return this.transformSavedObjectToSpace(savedObject); + } + + public async create(space: Space) { + if (this.useRbac()) { + await this.ensureAuthorizedGlobally( + this.authorization.actions.manageSpaces, + 'create', + 'Unauthorized to create spaces' + ); + } + const repository = this.useRbac() + ? this.internalSavedObjectRepository + : this.callWithRequestSavedObjectRepository; + + const attributes = omit(space, ['id', '_reserved']); + const id = space.id; + const createdSavedObject = await repository.create('space', attributes, { id }); + return this.transformSavedObjectToSpace(createdSavedObject); + } + + public async update(id: string, space: Space) { + if (this.useRbac()) { + await this.ensureAuthorizedGlobally( + this.authorization.actions.manageSpaces, + 'update', + 'Unauthorized to update spaces' + ); + } + const repository = this.useRbac() + ? this.internalSavedObjectRepository + : this.callWithRequestSavedObjectRepository; + + const attributes = omit(space, 'id', '_reserved'); + await repository.update('space', id, attributes); + const updatedSavedObject = await repository.get('space', id); + return this.transformSavedObjectToSpace(updatedSavedObject); + } + + public async delete(id: string) { + if (this.useRbac()) { + await this.ensureAuthorizedGlobally( + this.authorization.actions.manageSpaces, + 'delete', + 'Unauthorized to delete spaces' + ); + } + + const repository = this.useRbac() + ? this.internalSavedObjectRepository + : this.callWithRequestSavedObjectRepository; + + const existingSavedObject = await repository.get('space', id); + if (isReservedSpace(this.transformSavedObjectToSpace(existingSavedObject))) { + throw Boom.badRequest('This Space cannot be deleted because it is reserved.'); + } + + await repository.delete('space', id); + } + + private useRbac(): boolean { + return this.authorization && this.authorization.mode.useRbacForRequest(this.request); + } + + private async ensureAuthorizedGlobally(action: string, method: string, forbiddenMessage: string) { + const checkPrivileges = this.authorization.checkPrivilegesWithRequest(this.request); + const { username, hasAllRequested } = await checkPrivileges.globally(action); + + if (hasAllRequested) { + this.auditLogger.spacesAuthorizationSuccess(username, method); + return; + } else { + this.auditLogger.spacesAuthorizationFailure(username, method); + throw Boom.forbidden(forbiddenMessage); + } + } + + private async ensureAuthorizedAtSpace( + spaceId: string, + action: string, + method: string, + forbiddenMessage: string + ) { + const checkPrivileges = this.authorization.checkPrivilegesWithRequest(this.request); + const { username, hasAllRequested } = await checkPrivileges.atSpace(spaceId, action); + + if (hasAllRequested) { + this.auditLogger.spacesAuthorizationSuccess(username, method, [spaceId]); + return; + } else { + this.auditLogger.spacesAuthorizationFailure(username, method, [spaceId]); + throw Boom.forbidden(forbiddenMessage); + } + } + + private transformSavedObjectToSpace(savedObject: any): Space { + return { + id: savedObject.id, + ...savedObject.attributes, + } as Space; + } +} diff --git a/x-pack/plugins/spaces/server/lib/spaces_tutorial_context_factory.test.ts b/x-pack/plugins/spaces/server/lib/spaces_tutorial_context_factory.test.ts new file mode 100644 index 00000000000000..4ed548d64b5748 --- /dev/null +++ b/x-pack/plugins/spaces/server/lib/spaces_tutorial_context_factory.test.ts @@ -0,0 +1,55 @@ +/* + * 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 { DEFAULT_SPACE_ID } from '../../common/constants'; +import { createSpacesService } from './create_spaces_service'; +import { createSpacesTutorialContextFactory } from './spaces_tutorial_context_factory'; + +const server = { + config: () => { + return { + get: (key: string) => { + if (key === 'server.basePath') { + return '/foo'; + } + throw new Error('unexpected key ' + key); + }, + }; + }, +}; + +describe('createSpacesTutorialContextFactory', () => { + it('should create a valid context factory', () => { + const spacesService = createSpacesService(server); + expect(typeof createSpacesTutorialContextFactory(spacesService)).toEqual('function'); + }); + + it('should create context with the current space id for space my-space-id', () => { + const spacesService = createSpacesService(server); + const contextFactory = createSpacesTutorialContextFactory(spacesService); + + const request = { + getBasePath: () => '/foo/s/my-space-id', + }; + + expect(contextFactory(request)).toEqual({ + spaceId: 'my-space-id', + }); + }); + + it('should create context with the current space id for the default space', () => { + const spacesService = createSpacesService(server); + const contextFactory = createSpacesTutorialContextFactory(spacesService); + + const request = { + getBasePath: () => '/foo', + }; + + expect(contextFactory(request)).toEqual({ + spaceId: DEFAULT_SPACE_ID, + }); + }); +}); diff --git a/x-pack/plugins/spaces/server/lib/spaces_tutorial_context_factory.ts b/x-pack/plugins/spaces/server/lib/spaces_tutorial_context_factory.ts new file mode 100644 index 00000000000000..b3254fd3b3c07f --- /dev/null +++ b/x-pack/plugins/spaces/server/lib/spaces_tutorial_context_factory.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SpacesService } from './create_spaces_service'; + +export function createSpacesTutorialContextFactory(spacesService: SpacesService) { + return function spacesTutorialContextFactory(request: any) { + return { + spaceId: spacesService.getSpaceId(request), + }; + }; +} diff --git a/x-pack/plugins/spaces/server/lib/spaces_url_parser.test.ts b/x-pack/plugins/spaces/server/lib/spaces_url_parser.test.ts new file mode 100644 index 00000000000000..5878272c849246 --- /dev/null +++ b/x-pack/plugins/spaces/server/lib/spaces_url_parser.test.ts @@ -0,0 +1,72 @@ +/* + * 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 { DEFAULT_SPACE_ID } from '../../common/constants'; +import { addSpaceIdToPath, getSpaceIdFromPath } from './spaces_url_parser'; + +describe('getSpaceIdFromPath', () => { + describe('without a serverBasePath defined', () => { + test('it identifies the space url context', () => { + const basePath = `/s/my-awesome-space-lives-here`; + expect(getSpaceIdFromPath(basePath)).toEqual('my-awesome-space-lives-here'); + }); + + test('ignores space identifiers in the middle of the path', () => { + const basePath = `/this/is/a/crazy/path/s/my-awesome-space-lives-here`; + expect(getSpaceIdFromPath(basePath)).toEqual(DEFAULT_SPACE_ID); + }); + + test('it handles base url without a space url context', () => { + const basePath = `/this/is/a/crazy/path/s`; + expect(getSpaceIdFromPath(basePath)).toEqual(DEFAULT_SPACE_ID); + }); + }); + + describe('with a serverBasePath defined', () => { + test('it identifies the space url context', () => { + const basePath = `/s/my-awesome-space-lives-here`; + expect(getSpaceIdFromPath(basePath, '/')).toEqual('my-awesome-space-lives-here'); + }); + + test('it identifies the space url context following the server base path', () => { + const basePath = `/server-base-path-here/s/my-awesome-space-lives-here`; + expect(getSpaceIdFromPath(basePath, '/server-base-path-here')).toEqual( + 'my-awesome-space-lives-here' + ); + }); + + test('ignores space identifiers in the middle of the path', () => { + const basePath = `/this/is/a/crazy/path/s/my-awesome-space-lives-here`; + expect(getSpaceIdFromPath(basePath, '/this/is/a')).toEqual(DEFAULT_SPACE_ID); + }); + + test('it handles base url without a space url context', () => { + const basePath = `/this/is/a/crazy/path/s`; + expect(getSpaceIdFromPath(basePath, basePath)).toEqual(DEFAULT_SPACE_ID); + }); + }); +}); + +describe('addSpaceIdToPath', () => { + test('handles no parameters', () => { + expect(addSpaceIdToPath()).toEqual(`/`); + }); + + test('it adds to the basePath correctly', () => { + expect(addSpaceIdToPath('/my/base/path', 'url-context')).toEqual('/my/base/path/s/url-context'); + }); + + test('it appends the requested path to the end of the url context', () => { + expect(addSpaceIdToPath('/base', 'context', '/final/destination')).toEqual( + '/base/s/context/final/destination' + ); + }); + + test('it throws an error when the requested path does not start with a slash', () => { + expect(() => { + addSpaceIdToPath('', '', 'foo'); + }).toThrowErrorMatchingSnapshot(); + }); +}); diff --git a/x-pack/plugins/spaces/server/lib/spaces_url_parser.ts b/x-pack/plugins/spaces/server/lib/spaces_url_parser.ts new file mode 100644 index 00000000000000..14113cbf9d8070 --- /dev/null +++ b/x-pack/plugins/spaces/server/lib/spaces_url_parser.ts @@ -0,0 +1,47 @@ +/* + * 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 { DEFAULT_SPACE_ID } from '../../common/constants'; + +export function getSpaceIdFromPath( + requestBasePath: string = '/', + serverBasePath: string = '/' +): string { + let pathToCheck: string = requestBasePath; + + if (serverBasePath && serverBasePath !== '/' && requestBasePath.startsWith(serverBasePath)) { + pathToCheck = requestBasePath.substr(serverBasePath.length); + } + // Look for `/s/space-url-context` in the base path + const matchResult = pathToCheck.match(/^\/s\/([a-z0-9_\-]+)/); + + if (!matchResult || matchResult.length === 0) { + return DEFAULT_SPACE_ID; + } + + // Ignoring first result, we only want the capture group result at index 1 + const [, spaceId] = matchResult; + + if (!spaceId) { + throw new Error(`Unable to determine Space ID from request path: ${requestBasePath}`); + } + + return spaceId; +} + +export function addSpaceIdToPath( + basePath: string = '/', + spaceId: string = '', + requestedPath: string = '' +): string { + if (requestedPath && !requestedPath.startsWith('/')) { + throw new Error(`path must start with a /`); + } + + if (spaceId && spaceId !== DEFAULT_SPACE_ID) { + return `${basePath}/s/${spaceId}${requestedPath}`; + } + return `${basePath}${requestedPath}`; +} diff --git a/x-pack/plugins/spaces/server/routes/api/__fixtures__/create_spaces.ts b/x-pack/plugins/spaces/server/routes/api/__fixtures__/create_spaces.ts new file mode 100644 index 00000000000000..85284e3fc3a1c0 --- /dev/null +++ b/x-pack/plugins/spaces/server/routes/api/__fixtures__/create_spaces.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export function createSpaces() { + return [ + { + id: 'a-space', + attributes: { + name: 'a space', + }, + }, + { + id: 'b-space', + attributes: { + name: 'b space', + }, + }, + { + id: 'default', + attributes: { + name: 'Default Space', + _reserved: true, + }, + }, + ]; +} diff --git a/x-pack/plugins/spaces/server/routes/api/__fixtures__/create_test_handler.ts b/x-pack/plugins/spaces/server/routes/api/__fixtures__/create_test_handler.ts new file mode 100644 index 00000000000000..44c7f9a2e65004 --- /dev/null +++ b/x-pack/plugins/spaces/server/routes/api/__fixtures__/create_test_handler.ts @@ -0,0 +1,196 @@ +/* + * 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. + */ + +// @ts-ignore +import { Server } from 'hapi'; +import { SpacesClient } from '../../../lib/spaces_client'; +import { createSpaces } from './create_spaces'; + +export interface TestConfig { + [configKey: string]: any; +} + +export interface TestOptions { + setupFn?: (server: any) => void; + testConfig?: TestConfig; + payload?: any; + preCheckLicenseImpl?: (req: any, reply: any) => any; + expectSpacesClientCall?: boolean; +} + +export type TeardownFn = () => void; + +export interface RequestRunnerResult { + server: any; + mockSavedObjectsRepository: any; + response: any; +} + +export type RequestRunner = ( + method: string, + path: string, + options?: TestOptions +) => Promise; + +export const defaultPreCheckLicenseImpl = (request: any, reply: any) => reply(); + +const baseConfig: TestConfig = { + 'server.basePath': '', +}; + +export function createTestHandler(initApiFn: (server: any, preCheckLicenseImpl: any) => void) { + const teardowns: TeardownFn[] = []; + + const spaces = createSpaces(); + + const request: RequestRunner = async ( + method: string, + path: string, + options: TestOptions = {} + ) => { + const { + setupFn = () => { + return; + }, + testConfig = {}, + payload, + preCheckLicenseImpl = defaultPreCheckLicenseImpl, + expectSpacesClientCall = true, + } = options; + + let pre = jest.fn(); + if (preCheckLicenseImpl) { + pre = pre.mockImplementation(preCheckLicenseImpl); + } + + const server = new Server(); + + const config = { + ...baseConfig, + ...testConfig, + }; + + server.connection({ port: 0 }); + + await setupFn(server); + + server.decorate( + 'server', + 'config', + jest.fn(() => { + return { + get: (key: string) => config[key], + }; + }) + ); + + initApiFn(server, pre); + + server.decorate('request', 'getBasePath', jest.fn()); + server.decorate('request', 'setBasePath', jest.fn()); + + const mockSavedObjectsRepository = { + get: jest.fn((type, id) => { + const result = spaces.filter(s => s.id === id); + if (!result.length) { + throw new Error(`not found: [${type}:${id}]`); + } + return result[0]; + }), + find: jest.fn(() => { + return { + total: spaces.length, + saved_objects: spaces, + }; + }), + create: jest.fn((type, attributes, { id }) => { + if (spaces.find(s => s.id === id)) { + throw new Error('conflict'); + } + return {}; + }), + update: jest.fn((type, id) => { + if (!spaces.find(s => s.id === id)) { + throw new Error('not found: during update'); + } + return {}; + }), + delete: jest.fn((type: string, id: string) => { + return {}; + }), + }; + + server.savedObjects = { + SavedObjectsClient: { + errors: { + isNotFoundError: jest.fn((e: any) => e.message.startsWith('not found:')), + isConflictError: jest.fn((e: any) => e.message.startsWith('conflict')), + }, + }, + }; + + server.plugins.spaces = { + spacesClient: { + getScopedClient: jest.fn((req: any) => { + return new SpacesClient( + null as any, + null, + mockSavedObjectsRepository, + mockSavedObjectsRepository, + req + ); + }), + }, + }; + + teardowns.push(() => server.stop()); + + const headers = { + authorization: 'foo', + }; + + const testRun = async () => { + const response = await server.inject({ + method, + url: path, + headers, + payload, + }); + + if (preCheckLicenseImpl) { + expect(pre).toHaveBeenCalled(); + } else { + expect(pre).not.toHaveBeenCalled(); + } + + if (expectSpacesClientCall) { + expect(server.plugins.spaces.spacesClient.getScopedClient).toHaveBeenCalledWith( + expect.objectContaining({ + headers: expect.objectContaining({ + authorization: headers.authorization, + }), + }) + ); + } else { + expect(server.plugins.spaces.spacesClient.getScopedClient).not.toHaveBeenCalled(); + } + + return response; + }; + + return { + server, + headers, + mockSavedObjectsRepository, + response: await testRun(), + }; + }; + + return { + request, + teardowns, + }; +} diff --git a/x-pack/plugins/spaces/server/routes/api/__fixtures__/index.ts b/x-pack/plugins/spaces/server/routes/api/__fixtures__/index.ts new file mode 100644 index 00000000000000..37fe32c80032e5 --- /dev/null +++ b/x-pack/plugins/spaces/server/routes/api/__fixtures__/index.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { createSpaces } from './create_spaces'; +export { + createTestHandler, + TestConfig, + TestOptions, + TeardownFn, + RequestRunner, + RequestRunnerResult, +} from './create_test_handler'; diff --git a/x-pack/plugins/spaces/server/routes/api/public/delete.test.ts b/x-pack/plugins/spaces/server/routes/api/public/delete.test.ts new file mode 100644 index 00000000000000..21948e28c56d6a --- /dev/null +++ b/x-pack/plugins/spaces/server/routes/api/public/delete.test.ts @@ -0,0 +1,86 @@ +/* + * 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. + */ + +jest.mock('../../../lib/route_pre_check_license', () => { + return { + routePreCheckLicense: () => (request: any, reply: any) => reply.continue(), + }; +}); + +jest.mock('../../../../../../server/lib/get_client_shield', () => { + return { + getClient: () => { + return { + callWithInternalUser: jest.fn(() => { + return; + }), + }; + }, + }; +}); +import Boom from 'boom'; +import { createTestHandler, RequestRunner, TeardownFn } from '../__fixtures__'; +import { initDeleteSpacesApi } from './delete'; + +describe('Spaces Public API', () => { + let request: RequestRunner; + let teardowns: TeardownFn[]; + + beforeEach(() => { + const setup = createTestHandler(initDeleteSpacesApi); + + request = setup.request; + teardowns = setup.teardowns; + }); + + afterEach(async () => { + await Promise.all(teardowns.splice(0).map(fn => fn())); + }); + + test(`'DELETE spaces/{id}' deletes the space`, async () => { + const { response } = await request('DELETE', '/api/spaces/space/a-space'); + + const { statusCode } = response; + + expect(statusCode).toEqual(204); + }); + + test(`returns result of routePreCheckLicense`, async () => { + const { response } = await request('DELETE', '/api/spaces/space/a-space', { + preCheckLicenseImpl: (req: any, reply: any) => + reply(Boom.forbidden('test forbidden message')), + expectSpacesClientCall: false, + }); + + const { statusCode, payload } = response; + + expect(statusCode).toEqual(403); + expect(JSON.parse(payload)).toMatchObject({ + message: 'test forbidden message', + }); + }); + + test('DELETE spaces/{id} throws when deleting a non-existent space', async () => { + const { response } = await request('DELETE', '/api/spaces/space/not-a-space'); + + const { statusCode } = response; + + expect(statusCode).toEqual(404); + }); + + test(`'DELETE spaces/{id}' cannot delete reserved spaces`, async () => { + const { response } = await request('DELETE', '/api/spaces/space/default'); + + const { statusCode, payload } = response; + + expect(statusCode).toEqual(400); + expect(JSON.parse(payload)).toEqual({ + statusCode: 400, + error: 'Bad Request', + message: 'This Space cannot be deleted because it is reserved.', + }); + }); +}); diff --git a/x-pack/plugins/spaces/server/routes/api/public/delete.ts b/x-pack/plugins/spaces/server/routes/api/public/delete.ts new file mode 100644 index 00000000000000..080c765dd4a44d --- /dev/null +++ b/x-pack/plugins/spaces/server/routes/api/public/delete.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import Boom from 'boom'; +import { wrapError } from '../../../lib/errors'; +import { SpacesClient } from '../../../lib/spaces_client'; + +export function initDeleteSpacesApi(server: any, routePreCheckLicenseFn: any) { + server.route({ + method: 'DELETE', + path: '/api/spaces/space/{id}', + async handler(request: any, reply: any) { + const { SavedObjectsClient } = server.savedObjects; + const spacesClient: SpacesClient = server.plugins.spaces.spacesClient.getScopedClient( + request + ); + + const id = request.params.id; + + let result; + + try { + result = await spacesClient.delete(id); + } catch (error) { + if (SavedObjectsClient.errors.isNotFoundError(error)) { + return reply(Boom.notFound()); + } + return reply(wrapError(error)); + } + + return reply(result).code(204); + }, + config: { + pre: [routePreCheckLicenseFn], + }, + }); +} diff --git a/x-pack/plugins/spaces/server/routes/api/public/get.test.ts b/x-pack/plugins/spaces/server/routes/api/public/get.test.ts new file mode 100644 index 00000000000000..ad3e758853e01d --- /dev/null +++ b/x-pack/plugins/spaces/server/routes/api/public/get.test.ts @@ -0,0 +1,87 @@ +/* + * 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. + */ + +jest.mock('../../../lib/route_pre_check_license', () => { + return { + routePreCheckLicense: () => (request: any, reply: any) => reply.continue(), + }; +}); + +jest.mock('../../../../../../server/lib/get_client_shield', () => { + return { + getClient: () => { + return { + callWithInternalUser: jest.fn(() => { + return; + }), + }; + }, + }; +}); +import Boom from 'boom'; +import { Space } from '../../../../common/model/space'; +import { createSpaces, createTestHandler, RequestRunner, TeardownFn } from '../__fixtures__'; +import { initGetSpacesApi } from './get'; + +describe('GET spaces', () => { + let request: RequestRunner; + let teardowns: TeardownFn[]; + const spaces = createSpaces(); + + beforeEach(() => { + const setup = createTestHandler(initGetSpacesApi); + + request = setup.request; + teardowns = setup.teardowns; + }); + + afterEach(async () => { + await Promise.all(teardowns.splice(0).map(fn => fn())); + }); + + test(`'GET spaces' returns all available spaces`, async () => { + const { response } = await request('GET', '/api/spaces/space'); + + const { statusCode, payload } = response; + + expect(statusCode).toEqual(200); + const resultSpaces: Space[] = JSON.parse(payload); + expect(resultSpaces.map(s => s.id)).toEqual(spaces.map(s => s.id)); + }); + + test(`returns result of routePreCheckLicense`, async () => { + const { response } = await request('GET', '/api/spaces/space', { + preCheckLicenseImpl: (req: any, reply: any) => + reply(Boom.forbidden('test forbidden message')), + expectSpacesClientCall: false, + }); + + const { statusCode, payload } = response; + + expect(statusCode).toEqual(403); + expect(JSON.parse(payload)).toMatchObject({ + message: 'test forbidden message', + }); + }); + + test(`'GET spaces/{id}' returns the space with that id`, async () => { + const { response } = await request('GET', '/api/spaces/space/default'); + + const { statusCode, payload } = response; + + expect(statusCode).toEqual(200); + const resultSpace = JSON.parse(payload); + expect(resultSpace.id).toEqual('default'); + }); + + test(`'GET spaces/{id}' returns 404 when retrieving a non-existent space`, async () => { + const { response } = await request('GET', '/api/spaces/space/not-a-space'); + + const { statusCode } = response; + + expect(statusCode).toEqual(404); + }); +}); diff --git a/x-pack/plugins/spaces/server/routes/api/public/get.ts b/x-pack/plugins/spaces/server/routes/api/public/get.ts new file mode 100644 index 00000000000000..ae3a083c50123a --- /dev/null +++ b/x-pack/plugins/spaces/server/routes/api/public/get.ts @@ -0,0 +1,60 @@ +/* + * 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 Boom from 'boom'; +import { Space } from '../../../../common/model/space'; +import { wrapError } from '../../../lib/errors'; +import { SpacesClient } from '../../../lib/spaces_client'; + +export function initGetSpacesApi(server: any, routePreCheckLicenseFn: any) { + server.route({ + method: 'GET', + path: '/api/spaces/space', + async handler(request: any, reply: any) { + const spacesClient: SpacesClient = server.plugins.spaces.spacesClient.getScopedClient( + request + ); + + let spaces: Space[]; + + try { + spaces = await spacesClient.getAll(); + } catch (error) { + return reply(wrapError(error)); + } + + return reply(spaces); + }, + config: { + pre: [routePreCheckLicenseFn], + }, + }); + + server.route({ + method: 'GET', + path: '/api/spaces/space/{id}', + async handler(request: any, reply: any) { + const spaceId = request.params.id; + + const { SavedObjectsClient } = server.savedObjects; + const spacesClient: SpacesClient = server.plugins.spaces.spacesClient.getScopedClient( + request + ); + + try { + return reply(await spacesClient.get(spaceId)); + } catch (error) { + if (SavedObjectsClient.errors.isNotFoundError(error)) { + return reply(Boom.notFound()); + } + return reply(wrapError(error)); + } + }, + config: { + pre: [routePreCheckLicenseFn], + }, + }); +} diff --git a/x-pack/plugins/spaces/server/routes/api/public/index.ts b/x-pack/plugins/spaces/server/routes/api/public/index.ts new file mode 100644 index 00000000000000..602b62ab26d06f --- /dev/null +++ b/x-pack/plugins/spaces/server/routes/api/public/index.ts @@ -0,0 +1,20 @@ +/* + * 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 { routePreCheckLicense } from '../../../lib/route_pre_check_license'; +import { initDeleteSpacesApi } from './delete'; +import { initGetSpacesApi } from './get'; +import { initPostSpacesApi } from './post'; +import { initPutSpacesApi } from './put'; + +export function initPublicSpacesApi(server: any) { + const routePreCheckLicenseFn = routePreCheckLicense(server); + + initDeleteSpacesApi(server, routePreCheckLicenseFn); + initGetSpacesApi(server, routePreCheckLicenseFn); + initPostSpacesApi(server, routePreCheckLicenseFn); + initPutSpacesApi(server, routePreCheckLicenseFn); +} diff --git a/x-pack/plugins/spaces/server/routes/api/public/post.test.ts b/x-pack/plugins/spaces/server/routes/api/public/post.test.ts new file mode 100644 index 00000000000000..b554d5fc67354a --- /dev/null +++ b/x-pack/plugins/spaces/server/routes/api/public/post.test.ts @@ -0,0 +1,106 @@ +/* + * 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. + */ + +jest.mock('../../../lib/route_pre_check_license', () => { + return { + routePreCheckLicense: () => (request: any, reply: any) => reply.continue(), + }; +}); + +jest.mock('../../../../../../server/lib/get_client_shield', () => { + return { + getClient: () => { + return { + callWithInternalUser: jest.fn(() => { + return; + }), + }; + }, + }; +}); + +import Boom from 'boom'; +import { createTestHandler, RequestRunner, TeardownFn } from '../__fixtures__'; +import { initPostSpacesApi } from './post'; + +describe('Spaces Public API', () => { + let request: RequestRunner; + let teardowns: TeardownFn[]; + + beforeEach(() => { + const setup = createTestHandler(initPostSpacesApi); + + request = setup.request; + teardowns = setup.teardowns; + }); + + afterEach(async () => { + await Promise.all(teardowns.splice(0).map(fn => fn())); + }); + + test('POST /space should create a new space with the provided ID', async () => { + const payload = { + id: 'my-space-id', + name: 'my new space', + description: 'with a description', + }; + + const { mockSavedObjectsRepository, response } = await request('POST', '/api/spaces/space', { + payload, + }); + + const { statusCode } = response; + + expect(statusCode).toEqual(200); + expect(mockSavedObjectsRepository.create).toHaveBeenCalledTimes(1); + expect(mockSavedObjectsRepository.create).toHaveBeenCalledWith( + 'space', + { name: 'my new space', description: 'with a description' }, + { id: 'my-space-id' } + ); + }); + + test(`returns result of routePreCheckLicense`, async () => { + const payload = { + id: 'my-space-id', + name: 'my new space', + description: 'with a description', + }; + + const { response } = await request('POST', '/api/spaces/space', { + preCheckLicenseImpl: (req: any, reply: any) => + reply(Boom.forbidden('test forbidden message')), + expectSpacesClientCall: false, + payload, + }); + + const { statusCode, payload: responsePayload } = response; + + expect(statusCode).toEqual(403); + expect(JSON.parse(responsePayload)).toMatchObject({ + message: 'test forbidden message', + }); + }); + + test('POST /space should not allow a space to be updated', async () => { + const payload = { + id: 'a-space', + name: 'my updated space', + description: 'with a description', + }; + + const { response } = await request('POST', '/api/spaces/space', { payload }); + + const { statusCode, payload: responsePayload } = response; + + expect(statusCode).toEqual(409); + expect(JSON.parse(responsePayload)).toEqual({ + error: 'Conflict', + message: 'A space with the identifier a-space already exists.', + statusCode: 409, + }); + }); +}); diff --git a/x-pack/plugins/spaces/server/routes/api/public/post.ts b/x-pack/plugins/spaces/server/routes/api/public/post.ts new file mode 100644 index 00000000000000..a4c1e04a73831b --- /dev/null +++ b/x-pack/plugins/spaces/server/routes/api/public/post.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 Boom from 'boom'; +import { wrapError } from '../../../lib/errors'; +import { spaceSchema } from '../../../lib/space_schema'; +import { SpacesClient } from '../../../lib/spaces_client'; + +export function initPostSpacesApi(server: any, routePreCheckLicenseFn: any) { + server.route({ + method: 'POST', + path: '/api/spaces/space', + async handler(request: any, reply: any) { + const { SavedObjectsClient } = server.savedObjects; + const spacesClient: SpacesClient = server.plugins.spaces.spacesClient.getScopedClient( + request + ); + + const space = request.payload; + + try { + return reply(await spacesClient.create(space)); + } catch (error) { + if (SavedObjectsClient.errors.isConflictError(error)) { + return reply(Boom.conflict(`A space with the identifier ${space.id} already exists.`)); + } + return reply(wrapError(error)); + } + }, + config: { + validate: { + payload: spaceSchema, + }, + pre: [routePreCheckLicenseFn], + }, + }); +} diff --git a/x-pack/plugins/spaces/server/routes/api/public/put.test.ts b/x-pack/plugins/spaces/server/routes/api/public/put.test.ts new file mode 100644 index 00000000000000..e02fb58da1d613 --- /dev/null +++ b/x-pack/plugins/spaces/server/routes/api/public/put.test.ts @@ -0,0 +1,102 @@ +/* + * 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. + */ +jest.mock('../../../lib/route_pre_check_license', () => { + return { + routePreCheckLicense: () => (request: any, reply: any) => reply.continue(), + }; +}); + +jest.mock('../../../../../../server/lib/get_client_shield', () => { + return { + getClient: () => { + return { + callWithInternalUser: jest.fn(() => { + return; + }), + }; + }, + }; +}); +import Boom from 'boom'; +import { createTestHandler, RequestRunner, TeardownFn } from '../__fixtures__'; +import { initPutSpacesApi } from './put'; + +describe('Spaces Public API', () => { + let request: RequestRunner; + let teardowns: TeardownFn[]; + + beforeEach(() => { + const setup = createTestHandler(initPutSpacesApi); + + request = setup.request; + teardowns = setup.teardowns; + }); + + afterEach(async () => { + await Promise.all(teardowns.splice(0).map(fn => fn())); + }); + + test('PUT /space should update an existing space with the provided ID', async () => { + const payload = { + id: 'a-space', + name: 'my updated space', + description: 'with a description', + }; + + const { mockSavedObjectsRepository, response } = await request( + 'PUT', + '/api/spaces/space/a-space', + { + payload, + } + ); + + const { statusCode } = response; + + expect(statusCode).toEqual(200); + expect(mockSavedObjectsRepository.update).toHaveBeenCalledTimes(1); + expect(mockSavedObjectsRepository.update).toHaveBeenCalledWith('space', 'a-space', { + name: 'my updated space', + description: 'with a description', + }); + }); + + test(`returns result of routePreCheckLicense`, async () => { + const payload = { + id: 'a-space', + name: 'my updated space', + description: 'with a description', + }; + + const { response } = await request('PUT', '/api/spaces/space/a-space', { + preCheckLicenseImpl: (req: any, reply: any) => + reply(Boom.forbidden('test forbidden message')), + expectSpacesClientCall: false, + payload, + }); + + const { statusCode, payload: responsePayload } = response; + + expect(statusCode).toEqual(403); + expect(JSON.parse(responsePayload)).toMatchObject({ + message: 'test forbidden message', + }); + }); + + test('PUT /space should not allow a new space to be created', async () => { + const payload = { + id: 'a-new-space', + name: 'my new space', + description: 'with a description', + }; + + const { response } = await request('PUT', '/api/spaces/space/a-new-space', { payload }); + + const { statusCode } = response; + + expect(statusCode).toEqual(404); + }); +}); diff --git a/x-pack/plugins/spaces/server/routes/api/public/put.ts b/x-pack/plugins/spaces/server/routes/api/public/put.ts new file mode 100644 index 00000000000000..dea7e3a79d5c0b --- /dev/null +++ b/x-pack/plugins/spaces/server/routes/api/public/put.ts @@ -0,0 +1,45 @@ +/* + * 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 Boom from 'boom'; +import { Space } from '../../../../common/model/space'; +import { wrapError } from '../../../lib/errors'; +import { spaceSchema } from '../../../lib/space_schema'; +import { SpacesClient } from '../../../lib/spaces_client'; + +export function initPutSpacesApi(server: any, routePreCheckLicenseFn: any) { + server.route({ + method: 'PUT', + path: '/api/spaces/space/{id}', + async handler(request: any, reply: any) { + const { SavedObjectsClient } = server.savedObjects; + const spacesClient: SpacesClient = server.plugins.spaces.spacesClient.getScopedClient( + request + ); + + const space: Space = request.payload; + const id = request.params.id; + + let result: Space; + try { + result = await spacesClient.update(id, { ...space }); + } catch (error) { + if (SavedObjectsClient.errors.isNotFoundError(error)) { + return reply(Boom.notFound()); + } + return reply(wrapError(error)); + } + + return reply(result); + }, + config: { + validate: { + payload: spaceSchema, + }, + pre: [routePreCheckLicenseFn], + }, + }); +} diff --git a/x-pack/plugins/spaces/server/routes/api/v1/index.ts b/x-pack/plugins/spaces/server/routes/api/v1/index.ts new file mode 100644 index 00000000000000..75659c14c03aee --- /dev/null +++ b/x-pack/plugins/spaces/server/routes/api/v1/index.ts @@ -0,0 +1,13 @@ +/* + * 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 { routePreCheckLicense } from '../../../lib/route_pre_check_license'; +import { initPrivateSpacesApi } from './spaces'; + +export function initPrivateApis(server: any) { + const routePreCheckLicenseFn = routePreCheckLicense(server); + initPrivateSpacesApi(server, routePreCheckLicenseFn); +} diff --git a/x-pack/plugins/spaces/server/routes/api/v1/spaces.test.ts b/x-pack/plugins/spaces/server/routes/api/v1/spaces.test.ts new file mode 100644 index 00000000000000..0758ceb32746ca --- /dev/null +++ b/x-pack/plugins/spaces/server/routes/api/v1/spaces.test.ts @@ -0,0 +1,94 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +jest.mock('../../../lib/route_pre_check_license', () => { + return { + routePreCheckLicense: () => (request: any, reply: any) => reply.continue(), + }; +}); + +jest.mock('../../../../../../server/lib/get_client_shield', () => { + return { + getClient: () => { + return { + callWithInternalUser: jest.fn(() => { + return; + }), + }; + }, + }; +}); + +import Boom from 'boom'; +import { createTestHandler, RequestRunner, TeardownFn } from '../__fixtures__'; +import { initPrivateSpacesApi } from './spaces'; + +describe('Spaces API', () => { + let request: RequestRunner; + let teardowns: TeardownFn[]; + + beforeEach(() => { + const setup = createTestHandler(initPrivateSpacesApi); + + request = setup.request; + teardowns = setup.teardowns; + }); + + afterEach(async () => { + await Promise.all(teardowns.splice(0).map(fn => fn())); + }); + + test('POST space/{id}/select should respond with the new space location', async () => { + const { response } = await request('POST', '/api/spaces/v1/space/a-space/select'); + + const { statusCode, payload } = response; + + expect(statusCode).toEqual(200); + + const result = JSON.parse(payload); + expect(result.location).toEqual('/s/a-space'); + }); + + test(`returns result of routePreCheckLicense`, async () => { + const { response } = await request('POST', '/api/spaces/v1/space/a-space/select', { + preCheckLicenseImpl: (req: any, reply: any) => + reply(Boom.forbidden('test forbidden message')), + expectSpacesClientCall: false, + }); + + const { statusCode, payload } = response; + + expect(statusCode).toEqual(403); + expect(JSON.parse(payload)).toMatchObject({ + message: 'test forbidden message', + }); + }); + + test('POST space/{id}/select should respond with 404 when the space is not found', async () => { + const { response } = await request('POST', '/api/spaces/v1/space/not-a-space/select'); + + const { statusCode } = response; + + expect(statusCode).toEqual(404); + }); + + test('POST space/{id}/select should respond with the new space location when a server.basePath is in use', async () => { + const testConfig = { + 'server.basePath': '/my/base/path', + }; + + const { response } = await request('POST', '/api/spaces/v1/space/a-space/select', { + testConfig, + }); + + const { statusCode, payload } = response; + + expect(statusCode).toEqual(200); + + const result = JSON.parse(payload); + expect(result.location).toEqual('/my/base/path/s/a-space'); + }); +}); diff --git a/x-pack/plugins/spaces/server/routes/api/v1/spaces.ts b/x-pack/plugins/spaces/server/routes/api/v1/spaces.ts new file mode 100644 index 00000000000000..6f09d1831bff9a --- /dev/null +++ b/x-pack/plugins/spaces/server/routes/api/v1/spaces.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 Boom from 'boom'; +import { Space } from '../../../../common/model/space'; +import { wrapError } from '../../../lib/errors'; +import { SpacesClient } from '../../../lib/spaces_client'; +import { addSpaceIdToPath } from '../../../lib/spaces_url_parser'; +import { getSpaceById } from '../../lib'; + +export function initPrivateSpacesApi(server: any, routePreCheckLicenseFn: any) { + server.route({ + method: 'POST', + path: '/api/spaces/v1/space/{id}/select', + async handler(request: any, reply: any) { + const { SavedObjectsClient } = server.savedObjects; + const spacesClient: SpacesClient = server.plugins.spaces.spacesClient.getScopedClient( + request + ); + + const id = request.params.id; + + try { + const existingSpace: Space | null = await getSpaceById( + spacesClient, + id, + SavedObjectsClient.errors + ); + if (!existingSpace) { + return reply(Boom.notFound()); + } + + const config = server.config(); + + return reply({ + location: addSpaceIdToPath( + config.get('server.basePath'), + existingSpace.id, + config.get('server.defaultRoute') + ), + }); + } catch (error) { + return reply(wrapError(error)); + } + }, + config: { + pre: [routePreCheckLicenseFn], + }, + }); +} diff --git a/x-pack/plugins/spaces/server/routes/lib/convert_saved_object_to_space.test.ts b/x-pack/plugins/spaces/server/routes/lib/convert_saved_object_to_space.test.ts new file mode 100644 index 00000000000000..31738ff5628658 --- /dev/null +++ b/x-pack/plugins/spaces/server/routes/lib/convert_saved_object_to_space.test.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { convertSavedObjectToSpace } from './convert_saved_object_to_space'; + +describe('convertSavedObjectToSpace', () => { + it('converts a saved object representation to a Space object', () => { + const savedObject = { + id: 'foo', + attributes: { + name: 'Foo Space', + description: 'no fighting', + _reserved: false, + }, + }; + + expect(convertSavedObjectToSpace(savedObject)).toEqual({ + id: 'foo', + name: 'Foo Space', + description: 'no fighting', + _reserved: false, + }); + }); +}); diff --git a/x-pack/plugins/spaces/server/routes/lib/convert_saved_object_to_space.ts b/x-pack/plugins/spaces/server/routes/lib/convert_saved_object_to_space.ts new file mode 100644 index 00000000000000..d3ee173a2e80fe --- /dev/null +++ b/x-pack/plugins/spaces/server/routes/lib/convert_saved_object_to_space.ts @@ -0,0 +1,20 @@ +/* + * 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 { Space } from '../../../common/model/space'; + +/* + * 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 function convertSavedObjectToSpace(savedObject: any): Space { + return { + id: savedObject.id, + ...savedObject.attributes, + }; +} diff --git a/x-pack/plugins/spaces/server/routes/lib/get_space_by_id.ts b/x-pack/plugins/spaces/server/routes/lib/get_space_by_id.ts new file mode 100644 index 00000000000000..eaa789b32c39b2 --- /dev/null +++ b/x-pack/plugins/spaces/server/routes/lib/get_space_by_id.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. + */ +import { Space } from '../../../common/model/space'; +import { SpacesClient } from '../../lib/spaces_client'; +import { convertSavedObjectToSpace } from './convert_saved_object_to_space'; + +export async function getSpaceById( + client: SpacesClient, + spaceId: string, + errors: any +): Promise { + try { + const existingSpace = await client.get(spaceId); + return convertSavedObjectToSpace(existingSpace); + } catch (error) { + if (errors.isNotFoundError(error)) { + return null; + } + throw error; + } +} diff --git a/x-pack/plugins/spaces/server/routes/lib/index.ts b/x-pack/plugins/spaces/server/routes/lib/index.ts new file mode 100644 index 00000000000000..af673887925658 --- /dev/null +++ b/x-pack/plugins/spaces/server/routes/lib/index.ts @@ -0,0 +1,8 @@ +/* + * 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 { convertSavedObjectToSpace } from './convert_saved_object_to_space'; +export { getSpaceById } from './get_space_by_id'; diff --git a/x-pack/plugins/xpack_main/index.js b/x-pack/plugins/xpack_main/index.js index 0d1aeb32fe89c3..6073f96860d75e 100644 --- a/x-pack/plugins/xpack_main/index.js +++ b/x-pack/plugins/xpack_main/index.js @@ -82,12 +82,20 @@ export const xpackMain = (kibana) => { value: null } }, + savedObjectSchemas: { + telemetry: { + isNamespaceAgnostic: true, + }, + }, injectDefaultVars(server) { const config = server.config(); return { telemetryUrl: config.get('xpack.xpack_main.telemetry.url'), telemetryEnabled: isTelemetryEnabled(config), telemetryOptedIn: null, + activeSpace: null, + spacesEnabled: config.get('xpack.spaces.enabled'), + userProfile: {}, }; }, hacks: [ diff --git a/x-pack/plugins/xpack_main/public/components/telemetry/telemetry_form.js b/x-pack/plugins/xpack_main/public/components/telemetry/telemetry_form.js index 1a7826f56d0d2f..54ba58832f0ea8 100644 --- a/x-pack/plugins/xpack_main/public/components/telemetry/telemetry_form.js +++ b/x-pack/plugins/xpack_main/public/components/telemetry/telemetry_form.js @@ -7,6 +7,7 @@ import React, { Component, Fragment } from 'react'; import PropTypes from 'prop-types'; import { + EuiCallOut, EuiPanel, EuiForm, EuiFlexGroup, @@ -27,6 +28,8 @@ export class TelemetryForm extends Component { telemetryOptInProvider: PropTypes.object.isRequired, query: PropTypes.object, onQueryMatchChange: PropTypes.func.isRequired, + spacesEnabled: PropTypes.bool.isRequired, + activeSpace: PropTypes.object, }; state = { @@ -80,6 +83,8 @@ export class TelemetryForm extends Component { + + {this.maybeGetSpacesWarning()} { + if (!this.props.spacesEnabled) { + return null; + } + return ( + This setting applies to all of Kibana.

+ } + /> + ); + } + renderDescription = () => (

{CONFIG_TELEMETRY_DESC}

diff --git a/x-pack/plugins/xpack_main/public/components/telemetry/telemetry_form.test.js b/x-pack/plugins/xpack_main/public/components/telemetry/telemetry_form.test.js index 53717aa0b15a25..7bdef2120f3361 100644 --- a/x-pack/plugins/xpack_main/public/components/telemetry/telemetry_form.test.js +++ b/x-pack/plugins/xpack_main/public/components/telemetry/telemetry_form.test.js @@ -41,7 +41,12 @@ const buildTelemetryOptInProvider = () => { describe('TelemetryForm', () => { it('renders as expected', () => { expect(shallow( - ) + ) ).toMatchSnapshot(); }); }); \ No newline at end of file diff --git a/x-pack/plugins/xpack_main/public/services/user_profile.test.ts b/x-pack/plugins/xpack_main/public/services/user_profile.test.ts new file mode 100644 index 00000000000000..45507ab6042844 --- /dev/null +++ b/x-pack/plugins/xpack_main/public/services/user_profile.test.ts @@ -0,0 +1,42 @@ +/* + * 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 { UserProfileProvider } from './user_profile'; + +describe('UserProfile', () => { + it('should return true when the specified capability is enabled', () => { + const capabilities = { + test1: true, + test2: false, + }; + + const userProfile = UserProfileProvider(capabilities); + + expect(userProfile.hasCapability('test1')).toEqual(true); + }); + + it('should return false when the specified capability is disabled', () => { + const capabilities = { + test1: true, + test2: false, + }; + + const userProfile = UserProfileProvider(capabilities); + + expect(userProfile.hasCapability('test2')).toEqual(false); + }); + + it('should return the default value when the specified capability is not defined', () => { + const capabilities = { + test1: true, + test2: false, + }; + + const userProfile = UserProfileProvider(capabilities); + + expect(userProfile.hasCapability('test3')).toEqual(true); + expect(userProfile.hasCapability('test3', false)).toEqual(false); + }); +}); diff --git a/x-pack/plugins/xpack_main/public/services/user_profile.ts b/x-pack/plugins/xpack_main/public/services/user_profile.ts new file mode 100644 index 00000000000000..09b257aa80e3fe --- /dev/null +++ b/x-pack/plugins/xpack_main/public/services/user_profile.ts @@ -0,0 +1,31 @@ +/* + * 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. + */ + +interface Capabilities { + [capability: string]: boolean; +} + +export interface UserProfile { + hasCapability: (capability: string) => boolean; +} + +export function UserProfileProvider(userProfile: Capabilities) { + class UserProfileClass implements UserProfile { + private capabilities: Capabilities; + + constructor(profileData: Capabilities = {}) { + this.capabilities = { + ...profileData, + }; + } + + public hasCapability(capability: string, defaultValue: boolean = true): boolean { + return capability in this.capabilities ? this.capabilities[capability] : defaultValue; + } + } + + return new UserProfileClass(userProfile); +} diff --git a/x-pack/plugins/xpack_main/public/views/management/management.js b/x-pack/plugins/xpack_main/public/views/management/management.js index 8c244f8ae933fd..9e580f109d5369 100644 --- a/x-pack/plugins/xpack_main/public/views/management/management.js +++ b/x-pack/plugins/xpack_main/public/views/management/management.js @@ -12,9 +12,25 @@ import { TelemetryForm } from '../../components'; routes.defaults(/\/management/, { resolve: { - telemetryManagementSection: function (Private) { + telemetryManagementSection: function (Private, spacesEnabled, activeSpace) { const telemetryOptInProvider = Private(TelemetryOptInProvider); - const Component = (props) => ; + + const spaceProps = { + spacesEnabled, + }; + + if (spacesEnabled) { + spaceProps.activeSpace = activeSpace ? activeSpace.space : null; + } + + const Component = (props) => ( + + ); registerSettingsComponent(PAGE_FOOTER_COMPONENT, Component, true); } diff --git a/x-pack/plugins/xpack_main/server/lib/__tests__/replace_injected_vars.js b/x-pack/plugins/xpack_main/server/lib/__tests__/replace_injected_vars.js index f75ec5678f1c6b..863f07725ad296 100644 --- a/x-pack/plugins/xpack_main/server/lib/__tests__/replace_injected_vars.js +++ b/x-pack/plugins/xpack_main/server/lib/__tests__/replace_injected_vars.js @@ -9,7 +9,7 @@ import expect from 'expect.js'; import { replaceInjectedVars } from '../replace_injected_vars'; -const buildRequest = (telemetryOptedIn = null) => { +const buildRequest = (telemetryOptedIn = null, path = '/app/kibana') => { const get = sinon.stub(); if (telemetryOptedIn === null) { get.withArgs('telemetry', 'telemetry').returns(Promise.reject(new Error('not found exception'))); @@ -18,6 +18,7 @@ const buildRequest = (telemetryOptedIn = null) => { } return { + path, getSavedObjectsClient: () => { return { get, @@ -45,7 +46,8 @@ describe('replaceInjectedVars uiExport', () => { telemetryOptedIn: null, xpackInitialInfo: { b: 1 - } + }, + userProfile: {}, }); sinon.assert.calledOnce(server.plugins.security.isAuthenticated); @@ -64,7 +66,8 @@ describe('replaceInjectedVars uiExport', () => { telemetryOptedIn: null, xpackInitialInfo: { b: 1 - } + }, + userProfile: {}, }); }); @@ -80,7 +83,8 @@ describe('replaceInjectedVars uiExport', () => { telemetryOptedIn: null, xpackInitialInfo: { b: 1 - } + }, + userProfile: {}, }); }); @@ -96,7 +100,8 @@ describe('replaceInjectedVars uiExport', () => { telemetryOptedIn: false, xpackInitialInfo: { b: 1 - } + }, + userProfile: {}, }); }); @@ -112,7 +117,25 @@ describe('replaceInjectedVars uiExport', () => { telemetryOptedIn: true, xpackInitialInfo: { b: 1 - } + }, + userProfile: {}, + }); + }); + + it('indicates that telemetry is opted-out when not loading an application', async () => { + const originalInjectedVars = { a: 1 }; + const request = buildRequest(true, '/'); + const server = mockServer(); + server.plugins.xpack_main.info.license.isOneOf.returns(true); + + const newVars = await replaceInjectedVars(originalInjectedVars, request, server); + expect(newVars).to.eql({ + a: 1, + telemetryOptedIn: false, + xpackInitialInfo: { + b: 1 + }, + userProfile: {}, }); }); @@ -147,7 +170,8 @@ describe('replaceInjectedVars uiExport', () => { expect(newVars).to.eql({ a: 1, telemetryOptedIn: null, - xpackInitialInfo: undefined + xpackInitialInfo: undefined, + userProfile: {}, }); }); diff --git a/x-pack/plugins/xpack_main/server/lib/get_telemetry_opt_in.js b/x-pack/plugins/xpack_main/server/lib/get_telemetry_opt_in.js index 1f9d6c5849e2f4..690f9021fbefdb 100644 --- a/x-pack/plugins/xpack_main/server/lib/get_telemetry_opt_in.js +++ b/x-pack/plugins/xpack_main/server/lib/get_telemetry_opt_in.js @@ -5,6 +5,13 @@ */ export async function getTelemetryOptIn(request) { + const isRequestingApplication = request.path.startsWith('/app'); + + // Prevent interstitial screens (such as the space selector) from prompting for telemetry + if (!isRequestingApplication) { + return false; + } + const savedObjectsClient = request.getSavedObjectsClient(); try { diff --git a/x-pack/plugins/xpack_main/server/lib/replace_injected_vars.js b/x-pack/plugins/xpack_main/server/lib/replace_injected_vars.js index 990e4e1a7d53a5..b8362c6549e16d 100644 --- a/x-pack/plugins/xpack_main/server/lib/replace_injected_vars.js +++ b/x-pack/plugins/xpack_main/server/lib/replace_injected_vars.js @@ -5,13 +5,15 @@ */ import { getTelemetryOptIn } from "./get_telemetry_opt_in"; +import { buildUserProfile } from './user_profile_registry'; export async function replaceInjectedVars(originalInjectedVars, request, server) { const xpackInfo = server.plugins.xpack_main.info; const withXpackInfo = async () => ({ ...originalInjectedVars, telemetryOptedIn: await getTelemetryOptIn(request), - xpackInitialInfo: xpackInfo.isAvailable() ? xpackInfo.toJSON() : undefined + xpackInitialInfo: xpackInfo.isAvailable() ? xpackInfo.toJSON() : undefined, + userProfile: await buildUserProfile(request), }); // security feature is disabled diff --git a/x-pack/plugins/xpack_main/server/lib/user_profile_registry.test.ts b/x-pack/plugins/xpack_main/server/lib/user_profile_registry.test.ts new file mode 100644 index 00000000000000..22a0b58b60c2ab --- /dev/null +++ b/x-pack/plugins/xpack_main/server/lib/user_profile_registry.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 { + buildUserProfile, + registerUserProfileCapabilityFactory, + removeAllFactories, +} from './user_profile_registry'; + +describe('UserProfileRegistry', () => { + beforeEach(() => removeAllFactories()); + + it('should produce an empty user profile', async () => { + expect(await buildUserProfile(null)).toEqual({}); + }); + + it('should accumulate the results of all registered factories', async () => { + registerUserProfileCapabilityFactory(async () => ({ + foo: true, + bar: false, + })); + + registerUserProfileCapabilityFactory(async () => ({ + anotherCapability: true, + })); + + expect(await buildUserProfile(null)).toEqual({ + foo: true, + bar: false, + anotherCapability: true, + }); + }); +}); diff --git a/x-pack/plugins/xpack_main/server/lib/user_profile_registry.ts b/x-pack/plugins/xpack_main/server/lib/user_profile_registry.ts new file mode 100644 index 00000000000000..417341165fde40 --- /dev/null +++ b/x-pack/plugins/xpack_main/server/lib/user_profile_registry.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. + */ + +export type CapabilityFactory = (request: any) => Promise<{ [capability: string]: boolean }>; + +let factories: CapabilityFactory[] = []; + +export function removeAllFactories() { + factories = []; +} + +export function registerUserProfileCapabilityFactory(factory: CapabilityFactory) { + factories.push(factory); +} + +export async function buildUserProfile(request: any) { + const factoryPromises = factories.map(async factory => ({ + ...(await factory(request)), + })); + + const factoryResults = await Promise.all(factoryPromises); + + return factoryResults.reduce((acc, capabilities) => { + return { + ...acc, + ...capabilities, + }; + }, {}); +} diff --git a/x-pack/scripts/functional_tests.js b/x-pack/scripts/functional_tests.js index e7e4579bbb2e57..63a051f480f9d5 100644 --- a/x-pack/scripts/functional_tests.js +++ b/x-pack/scripts/functional_tests.js @@ -6,12 +6,16 @@ require('@kbn/plugin-helpers').babelRegister(); require('@kbn/test').runTestsCli([ - require.resolve('../test/reporting/configs/chromium_api.js'), - require.resolve('../test/reporting/configs/chromium_functional.js'), - require.resolve('../test/reporting/configs/phantom_api.js'), - require.resolve('../test/reporting/configs/phantom_functional.js'), - require.resolve('../test/functional/config.js'), - require.resolve('../test/api_integration/config.js'), - require.resolve('../test/saml_api_integration/config.js'), - require.resolve('../test/rbac_api_integration/config.js'), + // require.resolve('../test/reporting/configs/chromium_api.js'), + // require.resolve('../test/reporting/configs/chromium_functional.js'), + // require.resolve('../test/reporting/configs/phantom_api.js'), + // require.resolve('../test/reporting/configs/phantom_functional.js'), + // require.resolve('../test/functional/config.js'), + // require.resolve('../test/api_integration/config.js'), + // require.resolve('../test/saml_api_integration/config.js'), + // require.resolve('../test/spaces_api_integration/spaces_only/config'), + // require.resolve('../test/spaces_api_integration/security_and_spaces/config'), + require.resolve('../test/saved_object_api_integration/security_and_spaces/config'), + require.resolve('../test/saved_object_api_integration/security_only/config'), + require.resolve('../test/saved_object_api_integration/spaces_only/config'), ]); diff --git a/x-pack/plugins/security/server/lib/watch_status_and_license_to_initialize.js b/x-pack/server/lib/watch_status_and_license_to_initialize.js similarity index 100% rename from x-pack/plugins/security/server/lib/watch_status_and_license_to_initialize.js rename to x-pack/server/lib/watch_status_and_license_to_initialize.js diff --git a/x-pack/plugins/security/server/lib/watch_status_and_license_to_initialize.test.js b/x-pack/server/lib/watch_status_and_license_to_initialize.test.js similarity index 100% rename from x-pack/plugins/security/server/lib/watch_status_and_license_to_initialize.test.js rename to x-pack/server/lib/watch_status_and_license_to_initialize.test.js diff --git a/x-pack/test/rbac_api_integration/apis/es/has_privileges.js b/x-pack/test/api_integration/apis/es/has_privileges.js similarity index 100% rename from x-pack/test/rbac_api_integration/apis/es/has_privileges.js rename to x-pack/test/api_integration/apis/es/has_privileges.js diff --git a/x-pack/test/rbac_api_integration/apis/es/index.js b/x-pack/test/api_integration/apis/es/index.js similarity index 100% rename from x-pack/test/rbac_api_integration/apis/es/index.js rename to x-pack/test/api_integration/apis/es/index.js diff --git a/x-pack/test/rbac_api_integration/apis/es/post_privileges.js b/x-pack/test/api_integration/apis/es/post_privileges.js similarity index 100% rename from x-pack/test/rbac_api_integration/apis/es/post_privileges.js rename to x-pack/test/api_integration/apis/es/post_privileges.js diff --git a/x-pack/test/api_integration/apis/index.js b/x-pack/test/api_integration/apis/index.js index 7f105650141d95..85b11bb9ef71eb 100644 --- a/x-pack/test/api_integration/apis/index.js +++ b/x-pack/test/api_integration/apis/index.js @@ -6,6 +6,7 @@ export default function ({ loadTestFile }) { describe('apis', () => { + loadTestFile(require.resolve('./es')); loadTestFile(require.resolve('./security')); loadTestFile(require.resolve('./monitoring')); loadTestFile(require.resolve('./xpack_main')); diff --git a/x-pack/test/api_integration/apis/security/roles.js b/x-pack/test/api_integration/apis/security/roles.js index f77ea88bde2c8a..7e05e11944a0ca 100644 --- a/x-pack/test/api_integration/apis/security/roles.js +++ b/x-pack/test/api_integration/apis/security/roles.js @@ -32,7 +32,7 @@ export default function ({ getService }) { { field_security: { grant: ['*'], - except: [ 'geo.*' ] + except: ['geo.*'] }, names: ['logstash-*'], privileges: ['read', 'view_index_metadata'], @@ -41,14 +41,10 @@ export default function ({ getService }) { ], run_as: ['watcher_user'], }, - kibana: [ - { - privileges: ['all'], - }, - { - privileges: ['read'], - }, - ], + kibana: { + global: ['all', 'read'], + space: {} + } }) .expect(204); @@ -62,7 +58,7 @@ export default function ({ getService }) { privileges: ['read', 'view_index_metadata'], field_security: { grant: ['*'], - except: [ 'geo.*' ] + except: ['geo.*'] }, query: `{ "match": { "geo.src": "CN" } }`, }, @@ -70,12 +66,7 @@ export default function ({ getService }) { applications: [ { application: 'kibana-.kibana', - privileges: ['all'], - resources: ['*'], - }, - { - application: 'kibana-.kibana', - privileges: ['read'], + privileges: ['all', 'read'], resources: ['*'], } ], @@ -102,8 +93,8 @@ export default function ({ getService }) { names: ['beats-*'], privileges: ['write'], field_security: { - grant: [ 'request.*' ], - except: [ 'response.*' ] + grant: ['request.*'], + except: ['response.*'] }, query: `{ "match": { "host.name": "localhost" } }`, }, @@ -139,7 +130,7 @@ export default function ({ getService }) { { field_security: { grant: ['*'], - except: [ 'geo.*' ] + except: ['geo.*'] }, names: ['logstash-*'], privileges: ['read', 'view_index_metadata'], @@ -148,14 +139,10 @@ export default function ({ getService }) { ], run_as: ['watcher_user'], }, - kibana: [ - { - privileges: ['all'], - }, - { - privileges: ['read'], - }, - ], + kibana: { + global: ['all', 'read'], + space: {} + } }) .expect(204); @@ -169,7 +156,7 @@ export default function ({ getService }) { privileges: ['read', 'view_index_metadata'], field_security: { grant: ['*'], - except: [ 'geo.*' ] + except: ['geo.*'] }, query: `{ "match": { "geo.src": "CN" } }`, }, @@ -177,12 +164,7 @@ export default function ({ getService }) { applications: [ { application: 'kibana-.kibana', - privileges: ['all'], - resources: ['*'], - }, - { - application: 'kibana-.kibana', - privileges: ['read'], + privileges: ['all', 'read'], resources: ['*'], }, { diff --git a/x-pack/test/functional/apps/dashboard_mode/dashboard_view_mode.js b/x-pack/test/functional/apps/dashboard_mode/dashboard_view_mode.js index 8dc31bb586911e..4740ac013d644c 100644 --- a/x-pack/test/functional/apps/dashboard_mode/dashboard_view_mode.js +++ b/x-pack/test/functional/apps/dashboard_mode/dashboard_view_mode.js @@ -115,7 +115,7 @@ export default function ({ getService, getPageObjects }) { expect(collapseLinkExists).to.be(true); const navLinks = await find.allByCssSelector('.global-nav-link'); - expect(navLinks.length).to.equal(4); + expect(navLinks.length).to.equal(5); }); it('shows the dashboard landing page by default', async () => { diff --git a/x-pack/test/functional/apps/security/doc_level_security_roles.js b/x-pack/test/functional/apps/security/doc_level_security_roles.js index 9a44d595367d4f..535a0c2c4164a8 100644 --- a/x-pack/test/functional/apps/security/doc_level_security_roles.js +++ b/x-pack/test/functional/apps/security/doc_level_security_roles.js @@ -34,12 +34,16 @@ export default function ({ getService, getPageObjects }) { it('should add new role myroleEast', async function () { await PageObjects.security.addRole('myroleEast', { - - "indices": [{ - "names": [ "dlstest" ], - "privileges": [ "read", "view_index_metadata" ], - "query": "{\"match\": {\"region\": \"EAST\"}}" - }] + elasticsearch: { + "indices": [{ + "names": ["dlstest"], + "privileges": ["read", "view_index_metadata"], + "query": "{\"match\": {\"region\": \"EAST\"}}" + }] + }, + kibana: { + global: ['all'] + } }); const roles = indexBy(await PageObjects.security.getElasticsearchRoles(), 'rolename'); log.debug('actualRoles = %j', roles); @@ -50,9 +54,11 @@ export default function ({ getService, getPageObjects }) { it('should add new user userEAST ', async function () { await PageObjects.security.clickElasticsearchUsers(); - await PageObjects.security.addUser({ username: 'userEast', password: 'changeme', + await PageObjects.security.addUser({ + username: 'userEast', password: 'changeme', confirmPassword: 'changeme', fullname: 'dls EAST', - email: 'dlstest@elastic.com', save: true, roles: ['kibana_user', 'myroleEast'] }); + email: 'dlstest@elastic.com', save: true, roles: ['kibana_user', 'myroleEast'] + }); const users = indexBy(await PageObjects.security.getElasticsearchUsers(), 'username'); log.debug('actualUsers = %j', users); expect(users.userEast.roles).to.eql(['kibana_user', 'myroleEast']); diff --git a/x-pack/test/functional/apps/security/field_level_security.js b/x-pack/test/functional/apps/security/field_level_security.js index 89f5416135c578..a0fc042f58540c 100644 --- a/x-pack/test/functional/apps/security/field_level_security.js +++ b/x-pack/test/functional/apps/security/field_level_security.js @@ -14,7 +14,7 @@ export default function ({ getService, getPageObjects }) { const log = getService('log'); const PageObjects = getPageObjects(['security', 'settings', 'common', 'discover', 'header']); - describe('field_level_security', () => { + describe('field_level_security', () => { before('initialize tests', async () => { await esArchiver.loadIfNeeded('security/flstest'); await esArchiver.load('empty_kibana'); @@ -28,11 +28,16 @@ export default function ({ getService, getPageObjects }) { await PageObjects.settings.navigateTo(); await PageObjects.security.clickElasticsearchRoles(); await PageObjects.security.addRole('viewssnrole', { - "indices": [{ - "names": [ "flstest" ], - "privileges": [ "read", "view_index_metadata" ], - "field_security": { "grant": ["customer_ssn", "customer_name", "customer_region", "customer_type"] } - }] + elasticsearch: { + "indices": [{ + "names": ["flstest"], + "privileges": ["read", "view_index_metadata"], + "field_security": { "grant": ["customer_ssn", "customer_name", "customer_region", "customer_type"] } + }] + }, + kibana: { + global: ['all'] + } }); await PageObjects.common.sleep(1000); @@ -44,11 +49,16 @@ export default function ({ getService, getPageObjects }) { it('should add new role view_no_ssn_role', async function () { await PageObjects.security.addRole('view_no_ssn_role', { - "indices": [{ - "names": [ "flstest" ], - "privileges": [ "read", "view_index_metadata" ], - "field_security": { "grant": ["customer_name", "customer_region", "customer_type"] } - }] + elasticsearch: { + "indices": [{ + "names": ["flstest"], + "privileges": ["read", "view_index_metadata"], + "field_security": { "grant": ["customer_name", "customer_region", "customer_type"] } + }] + }, + kibana: { + global: ['all'] + } }); await PageObjects.common.sleep(1000); const roles = indexBy(await PageObjects.security.getElasticsearchRoles(), 'rolename'); @@ -59,9 +69,11 @@ export default function ({ getService, getPageObjects }) { it('should add new user customer1 ', async function () { await PageObjects.security.clickElasticsearchUsers(); - await PageObjects.security.addUser({ username: 'customer1', password: 'changeme', + await PageObjects.security.addUser({ + username: 'customer1', password: 'changeme', confirmPassword: 'changeme', fullname: 'customer one', email: 'flstest@elastic.com', save: true, - roles: ['kibana_user', 'viewssnrole'] }); + roles: ['kibana_user', 'viewssnrole'] + }); const users = indexBy(await PageObjects.security.getElasticsearchUsers(), 'username'); log.debug('actualUsers = %j', users); expect(users.customer1.roles).to.eql(['kibana_user', 'viewssnrole']); @@ -69,9 +81,11 @@ export default function ({ getService, getPageObjects }) { it('should add new user customer2 ', async function () { await PageObjects.security.clickElasticsearchUsers(); - await PageObjects.security.addUser({ username: 'customer2', password: 'changeme', + await PageObjects.security.addUser({ + username: 'customer2', password: 'changeme', confirmPassword: 'changeme', fullname: 'customer two', email: 'flstest@elastic.com', save: true, - roles: ['kibana_user', 'view_no_ssn_role'] }); + roles: ['kibana_user', 'view_no_ssn_role'] + }); const users = indexBy(await PageObjects.security.getElasticsearchUsers(), 'username'); log.debug('actualUsers = %j', users); expect(users.customer2.roles).to.eql(['kibana_user', 'view_no_ssn_role']); diff --git a/x-pack/test/functional/apps/security/management.js b/x-pack/test/functional/apps/security/management.js index 7fbb9adcfe5ed3..32b6eef2d96bb1 100644 --- a/x-pack/test/functional/apps/security/management.js +++ b/x-pack/test/functional/apps/security/management.js @@ -16,8 +16,6 @@ export default function ({ getService, getPageObjects }) { const kibanaServer = getService('kibanaServer'); const testSubjects = getService('testSubjects'); const remote = getService('remote'); - const retry = getService('retry'); - const find = getService('find'); const PageObjects = getPageObjects(['security', 'settings', 'common', 'header']); describe('Management', () => { @@ -146,23 +144,6 @@ export default function ({ getService, getPageObjects }) { const currentUrl = await remote.getCurrentUrl(); expect(currentUrl).to.contain(EDIT_ROLES_PATH); }); - - it('Reserved roles are not editable', async () => { - // wait for role tab to finish loading from previous test - await PageObjects.header.waitUntilLoadingHasFinished(); - - const allInputs = await find.allByCssSelector('input'); - for (let i = 0; i < allInputs.length; i++) { - const input = allInputs[i]; - // Angular can take a little bit to set the input to disabled, - // so this accounts for that delay - retry.try(async () => { - if (!(await input.getProperty('disabled'))) { - throw new Error('input is not disabled'); - } - }); - } - }); }); }); }); diff --git a/x-pack/test/functional/apps/security/rbac_phase1.js b/x-pack/test/functional/apps/security/rbac_phase1.js index ddab249003b5bd..7492c53522ed48 100644 --- a/x-pack/test/functional/apps/security/rbac_phase1.js +++ b/x-pack/test/functional/apps/security/rbac_phase1.js @@ -25,20 +25,28 @@ export default function ({ getService, getPageObjects }) { await PageObjects.settings.navigateTo(); await PageObjects.security.clickElasticsearchRoles(); await PageObjects.security.addRole('rbac_all', { - "kibana": ["all"], - "indices": [{ - "names": ["logstash-*"], - "privileges": ["read", "view_index_metadata"] - }] + kibana: { + global: ['all'] + }, + elasticsearch: { + "indices": [{ + "names": ["logstash-*"], + "privileges": ["read", "view_index_metadata"] + }] + } }); await PageObjects.security.clickElasticsearchRoles(); await PageObjects.security.addRole('rbac_read', { - "kibana": ["read"], - "indices": [{ - "names": ["logstash-*"], - "privileges": ["read", "view_index_metadata"] - }] + kibana: { + global: ['read'] + }, + elasticsearch: { + "indices": [{ + "names": ["logstash-*"], + "privileges": ["read", "view_index_metadata"] + }] + } }); await PageObjects.security.clickElasticsearchUsers(); log.debug('After Add user new: , userObj.userName'); diff --git a/x-pack/test/functional/apps/security/secure_roles_perm.js b/x-pack/test/functional/apps/security/secure_roles_perm.js index 97f6b35f258714..a185c193d9db45 100644 --- a/x-pack/test/functional/apps/security/secure_roles_perm.js +++ b/x-pack/test/functional/apps/security/secure_roles_perm.js @@ -32,20 +32,27 @@ export default function ({ getService, getPageObjects }) { it('should add new role logstash_reader', async function () { await PageObjects.security.clickElasticsearchRoles(); await PageObjects.security.addRole('logstash_reader', { - "indices": [{ - "names": [ "logstash-*" ], - "privileges": [ "read", "view_index_metadata" ] - }] + elasticsearch: { + "indices": [{ + "names": ["logstash-*"], + "privileges": ["read", "view_index_metadata"] + }] + }, + kibana: { + global: ['all'] + } }); }); it('should add new user', async function () { await PageObjects.security.clickElasticsearchUsers(); log.debug('After Add user new: , userObj.userName'); - await PageObjects.security.addUser({ username: 'Rashmi', password: 'changeme', + await PageObjects.security.addUser({ + username: 'Rashmi', password: 'changeme', confirmPassword: 'changeme', fullname: 'RashmiFirst RashmiLast', email: 'rashmi@myEmail.com', save: true, - roles: ['logstash_reader', 'kibana_user'] }); + roles: ['logstash_reader', 'kibana_user'] + }); log.debug('After Add user: , userObj.userName'); const users = indexBy(await PageObjects.security.getElasticsearchUsers(), 'username'); log.debug('actualUsers = %j', users); diff --git a/x-pack/test/functional/apps/security/security.js b/x-pack/test/functional/apps/security/security.js index 3cb09a3adf8a46..0975278b6ee94b 100644 --- a/x-pack/test/functional/apps/security/security.js +++ b/x-pack/test/functional/apps/security/security.js @@ -29,7 +29,7 @@ export default function ({ getService, getPageObjects }) { }); it('displays message if login fails', async () => { - await PageObjects.security.loginPage.login('wrong-user', 'wrong-password', false); + await PageObjects.security.loginPage.login('wrong-user', 'wrong-password', { expectSuccess: false }); const errorMessage = await PageObjects.security.loginPage.getErrorMessage(); expect(errorMessage).to.be('Oops! Error. Try again.'); }); diff --git a/x-pack/test/functional/apps/spaces/index.ts b/x-pack/test/functional/apps/spaces/index.ts new file mode 100644 index 00000000000000..455692b86fc81f --- /dev/null +++ b/x-pack/test/functional/apps/spaces/index.ts @@ -0,0 +1,13 @@ +/* + * 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 { TestInvoker } from './lib/types'; + +// tslint:disable:no-default-export +export default function spacesApp({ loadTestFile }: TestInvoker) { + describe('Spaces app', function spacesAppTestSuite() { + loadTestFile(require.resolve('./spaces_selection')); + }); +} diff --git a/x-pack/test/functional/apps/spaces/lib/types.ts b/x-pack/test/functional/apps/spaces/lib/types.ts new file mode 100644 index 00000000000000..2ed91406e5f48d --- /dev/null +++ b/x-pack/test/functional/apps/spaces/lib/types.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export type DescribeFn = (text: string, fn: () => void) => void; + +export interface TestDefinitionAuthentication { + username?: string; + password?: string; +} + +export type LoadTestFileFn = (path: string) => string; + +export type GetServiceFn = (service: string) => any; + +export type ReadConfigFileFn = (path: string) => any; + +export type GetPageObjectsFn = (pageObjects: string[]) => any; + +export interface TestInvoker { + getService: GetServiceFn; + getPageObjects: GetPageObjectsFn; + loadTestFile: LoadTestFileFn; + readConfigFile: ReadConfigFileFn; +} diff --git a/x-pack/test/functional/apps/spaces/spaces_selection.ts b/x-pack/test/functional/apps/spaces/spaces_selection.ts new file mode 100644 index 00000000000000..be2b59d69d8649 --- /dev/null +++ b/x-pack/test/functional/apps/spaces/spaces_selection.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 { TestInvoker } from './lib/types'; + +// tslint:disable:no-default-export +export default function spaceSelectorFunctonalTests({ getService, getPageObjects }: TestInvoker) { + const esArchiver = getService('esArchiver'); + const PageObjects = getPageObjects(['security', 'spaceSelector', 'home']); + + describe('Spaces', () => { + describe('Space Selector', () => { + before(async () => await esArchiver.load('spaces')); + after(async () => await esArchiver.unload('spaces')); + + afterEach(async () => { + await PageObjects.security.logout(); + }); + + it('allows user to navigate to different spaces', async () => { + const spaceId = 'another-space'; + + await PageObjects.security.login(null, null, { + expectSpaceSelector: true, + }); + + await PageObjects.spaceSelector.clickSpaceCard(spaceId); + + await PageObjects.spaceSelector.expectHomePage(spaceId); + + await PageObjects.spaceSelector.openSpacesNav(); + + // change spaces + + await PageObjects.spaceSelector.clickSpaceAvatar('default'); + + await PageObjects.spaceSelector.expectHomePage('default'); + }); + }); + }); +} diff --git a/x-pack/test/functional/config.js b/x-pack/test/functional/config.js index 77c0aee49b83ef..7633a95fa9e56b 100644 --- a/x-pack/test/functional/config.js +++ b/x-pack/test/functional/config.js @@ -16,6 +16,7 @@ import { GrokDebuggerPageProvider, WatcherPageProvider, ReportingPageProvider, + SpaceSelectorPageProvider, } from './page_objects'; import { @@ -64,6 +65,7 @@ export default async function ({ readConfigFile }) { resolve(__dirname, './apps/watcher'), resolve(__dirname, './apps/dashboard_mode'), resolve(__dirname, './apps/security'), + resolve(__dirname, './apps/spaces'), resolve(__dirname, './apps/logstash'), resolve(__dirname, './apps/grok_debugger'), ], @@ -113,6 +115,7 @@ export default async function ({ readConfigFile }) { grokDebugger: GrokDebuggerPageProvider, watcher: WatcherPageProvider, reporting: ReportingPageProvider, + spaceSelector: SpaceSelectorPageProvider, }, servers: kibanaFunctionalConfig.get('servers'), @@ -159,6 +162,9 @@ export default async function ({ readConfigFile }) { pathname: '/app/kibana', hash: '/dev_tools/grokdebugger' }, + spaceSelector: { + pathname: '/', + } }, // choose where esArchiver should load archives from diff --git a/x-pack/test/functional/es_archives/dashboard_view_mode/data.json.gz b/x-pack/test/functional/es_archives/dashboard_view_mode/data.json.gz index 583406fa4da4fa..00e55c17876e9c 100644 Binary files a/x-pack/test/functional/es_archives/dashboard_view_mode/data.json.gz and b/x-pack/test/functional/es_archives/dashboard_view_mode/data.json.gz differ diff --git a/x-pack/test/functional/es_archives/dashboard_view_mode/mappings.json b/x-pack/test/functional/es_archives/dashboard_view_mode/mappings.json index d8899a4dedb278..890f4be575fae6 100644 --- a/x-pack/test/functional/es_archives/dashboard_view_mode/mappings.json +++ b/x-pack/test/functional/es_archives/dashboard_view_mode/mappings.json @@ -268,9 +268,37 @@ "type": "integer" } } + }, + "spaceId": { + "type": "keyword" + }, + "space": { + "properties": { + "name": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 2048 + } + } + }, + "description": { + "type": "text" + }, + "initials": { + "type": "keyword" + }, + "color": { + "type": "keyword" + }, + "_reserved": { + "type": "boolean" + } + } } } } } } -} \ No newline at end of file +} diff --git a/x-pack/test/functional/es_archives/discover/data.json.gz b/x-pack/test/functional/es_archives/discover/data.json.gz index 020ca814620a85..df05dfd8998f40 100644 Binary files a/x-pack/test/functional/es_archives/discover/data.json.gz and b/x-pack/test/functional/es_archives/discover/data.json.gz differ diff --git a/x-pack/test/functional/es_archives/discover/mappings.json b/x-pack/test/functional/es_archives/discover/mappings.json index 724d6cdd018f8b..f72b7c5a91dc04 100644 --- a/x-pack/test/functional/es_archives/discover/mappings.json +++ b/x-pack/test/functional/es_archives/discover/mappings.json @@ -268,9 +268,37 @@ "type": "text" } } + }, + "spaceId": { + "type": "keyword" + }, + "space": { + "properties": { + "name": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 2048 + } + } + }, + "description": { + "type": "text" + }, + "initials": { + "type": "keyword" + }, + "color": { + "type": "keyword" + }, + "_reserved": { + "type": "boolean" + } + } } } } } } -} \ No newline at end of file +} diff --git a/x-pack/test/functional/es_archives/empty_kibana/data.json.gz b/x-pack/test/functional/es_archives/empty_kibana/data.json.gz index bffef555b9bf34..d9708ad59f56f1 100644 Binary files a/x-pack/test/functional/es_archives/empty_kibana/data.json.gz and b/x-pack/test/functional/es_archives/empty_kibana/data.json.gz differ diff --git a/x-pack/test/functional/es_archives/empty_kibana/mappings.json b/x-pack/test/functional/es_archives/empty_kibana/mappings.json index 81888c31185da8..aeea7f7bcea4b4 100644 --- a/x-pack/test/functional/es_archives/empty_kibana/mappings.json +++ b/x-pack/test/functional/es_archives/empty_kibana/mappings.json @@ -247,9 +247,37 @@ "type": "text" } } + }, + "spaceId": { + "type": "keyword" + }, + "space": { + "properties": { + "name": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 2048 + } + } + }, + "description": { + "type": "text" + }, + "initials": { + "type": "keyword" + }, + "color": { + "type": "keyword" + }, + "_reserved": { + "type": "boolean" + } + } } } } } } -} \ No newline at end of file +} diff --git a/x-pack/test/functional/es_archives/logstash/empty/data.json.gz b/x-pack/test/functional/es_archives/logstash/empty/data.json.gz index 2ac732942acb2e..3b5fcac425b877 100644 Binary files a/x-pack/test/functional/es_archives/logstash/empty/data.json.gz and b/x-pack/test/functional/es_archives/logstash/empty/data.json.gz differ diff --git a/x-pack/test/functional/es_archives/logstash/empty/mappings.json b/x-pack/test/functional/es_archives/logstash/empty/mappings.json index b54e80bf19626f..8961a5608e5894 100644 --- a/x-pack/test/functional/es_archives/logstash/empty/mappings.json +++ b/x-pack/test/functional/es_archives/logstash/empty/mappings.json @@ -329,6 +329,34 @@ "type": "text" } } + }, + "spaceId": { + "type": "keyword" + }, + "space": { + "properties": { + "name": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 2048 + } + } + }, + "description": { + "type": "text" + }, + "initials": { + "type": "keyword" + }, + "color": { + "type": "keyword" + }, + "_reserved": { + "type": "boolean" + } + } } } } diff --git a/x-pack/test/functional/es_archives/logstash/example_pipelines/data.json.gz b/x-pack/test/functional/es_archives/logstash/example_pipelines/data.json.gz index 8ef6743e020913..ab1126b826cf9f 100644 Binary files a/x-pack/test/functional/es_archives/logstash/example_pipelines/data.json.gz and b/x-pack/test/functional/es_archives/logstash/example_pipelines/data.json.gz differ diff --git a/x-pack/test/functional/es_archives/logstash/example_pipelines/mappings.json b/x-pack/test/functional/es_archives/logstash/example_pipelines/mappings.json index b54e80bf19626f..8961a5608e5894 100644 --- a/x-pack/test/functional/es_archives/logstash/example_pipelines/mappings.json +++ b/x-pack/test/functional/es_archives/logstash/example_pipelines/mappings.json @@ -329,6 +329,34 @@ "type": "text" } } + }, + "spaceId": { + "type": "keyword" + }, + "space": { + "properties": { + "name": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 2048 + } + } + }, + "description": { + "type": "text" + }, + "initials": { + "type": "keyword" + }, + "color": { + "type": "keyword" + }, + "_reserved": { + "type": "boolean" + } + } } } } diff --git a/x-pack/test/functional/es_archives/spaces/data.json b/x-pack/test/functional/es_archives/spaces/data.json new file mode 100644 index 00000000000000..f76f2c6874b34b --- /dev/null +++ b/x-pack/test/functional/es_archives/spaces/data.json @@ -0,0 +1,47 @@ +{ + "type": "doc", + "value": { + "index": ".kibana", + "type": "doc", + "id": "config:6.0.0-alpha1", + "source": { + "type": "config", + "config": { + "buildNum": 8467, + "dateFormat:tz": "UTC" + } + } + } +} + +{ + "type": "doc", + "value": { + "index": ".kibana", + "type": "doc", + "id": "space:default", + "source": { + "type": "space", + "space": { + "name": "Default", + "description": "This is the default space!" + } + } + } +} + +{ + "type": "doc", + "value": { + "index": ".kibana", + "type": "doc", + "id": "space:another-space", + "source": { + "type": "space", + "space": { + "name": "Another Space", + "description": "This is another space" + } + } + } +} \ No newline at end of file diff --git a/x-pack/test/functional/es_archives/spaces/mappings.json b/x-pack/test/functional/es_archives/spaces/mappings.json new file mode 100644 index 00000000000000..aeea7f7bcea4b4 --- /dev/null +++ b/x-pack/test/functional/es_archives/spaces/mappings.json @@ -0,0 +1,283 @@ +{ + "type": "index", + "value": { + "index": ".kibana", + "settings": { + "index": { + "number_of_shards": "1", + "number_of_replicas": "1" + } + }, + "mappings": { + "doc": { + "properties": { + "type": { + "type": "keyword" + }, + "server": { + "dynamic": "strict", + "properties": { + "uuid": { + "type": "keyword" + } + } + }, + "search": { + "dynamic": "strict", + "properties": { + "columns": { + "type": "keyword" + }, + "description": { + "type": "text" + }, + "hits": { + "type": "integer" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "sort": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "timelion-sheet": { + "dynamic": "strict", + "properties": { + "description": { + "type": "text" + }, + "hits": { + "type": "integer" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "timelion_chart_height": { + "type": "integer" + }, + "timelion_columns": { + "type": "integer" + }, + "timelion_interval": { + "type": "keyword" + }, + "timelion_other_interval": { + "type": "keyword" + }, + "timelion_rows": { + "type": "integer" + }, + "timelion_sheet": { + "type": "text" + }, + "title": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "visualization": { + "dynamic": "strict", + "properties": { + "description": { + "type": "text" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "savedSearchId": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "uiStateJSON": { + "type": "text" + }, + "version": { + "type": "integer" + }, + "visState": { + "type": "text" + } + } + }, + "config": { + "dynamic": "true", + "properties": { + "buildNum": { + "type": "keyword" + }, + "dateFormat:tz": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + } + } + }, + "url": { + "dynamic": "strict", + "properties": { + "accessCount": { + "type": "long" + }, + "accessDate": { + "type": "date" + }, + "createDate": { + "type": "date" + }, + "url": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 2048 + } + } + } + } + }, + "dashboard": { + "dynamic": "strict", + "properties": { + "description": { + "type": "text" + }, + "hits": { + "type": "integer" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "optionsJSON": { + "type": "text" + }, + "panelsJSON": { + "type": "text" + }, + "refreshInterval": { + "properties": { + "display": { + "type": "keyword" + }, + "pause": { + "type": "boolean" + }, + "section": { + "type": "integer" + }, + "value": { + "type": "integer" + } + } + }, + "timeFrom": { + "type": "keyword" + }, + "timeRestore": { + "type": "boolean" + }, + "timeTo": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "uiStateJSON": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "index-pattern": { + "dynamic": "strict", + "properties": { + "fieldFormatMap": { + "type": "text" + }, + "fields": { + "type": "text" + }, + "intervalName": { + "type": "keyword" + }, + "notExpandable": { + "type": "boolean" + }, + "sourceFilters": { + "type": "text" + }, + "timeFieldName": { + "type": "keyword" + }, + "title": { + "type": "text" + } + } + }, + "spaceId": { + "type": "keyword" + }, + "space": { + "properties": { + "name": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 2048 + } + } + }, + "description": { + "type": "text" + }, + "initials": { + "type": "keyword" + }, + "color": { + "type": "keyword" + }, + "_reserved": { + "type": "boolean" + } + } + } + } + } + } + } +} diff --git a/x-pack/test/functional/page_objects/index.js b/x-pack/test/functional/page_objects/index.js index 9c505cee719e02..62af59b3a8d0f3 100644 --- a/x-pack/test/functional/page_objects/index.js +++ b/x-pack/test/functional/page_objects/index.js @@ -11,3 +11,4 @@ export { GraphPageProvider } from './graph_page'; export { GrokDebuggerPageProvider } from './grok_debugger_page'; export { WatcherPageProvider } from './watcher_page'; export { ReportingPageProvider } from './reporting_page'; +export { SpaceSelectorPageProvider } from './space_selector_page'; diff --git a/x-pack/test/functional/page_objects/security_page.js b/x-pack/test/functional/page_objects/security_page.js index 308e992ee6c204..0bd43e4b674ac7 100644 --- a/x-pack/test/functional/page_objects/security_page.js +++ b/x-pack/test/functional/page_objects/security_page.js @@ -19,19 +19,26 @@ export function SecurityPageProvider({ getService, getPageObjects }) { const PageObjects = getPageObjects(['common', 'header', 'settings', 'home']); class LoginPage { - async login(username, password, expectSuccess = true) { + async login(username, password, options = {}) { const [superUsername, superPassword] = config.get('servers.elasticsearch.auth').split(':'); username = username || superUsername; password = password || superPassword; + const expectSpaceSelector = options.expectSpaceSelector || false; + const expectSuccess = options.expectSuccess; + await PageObjects.common.navigateToApp('login'); await testSubjects.setValue('loginUsername', username); await testSubjects.setValue('loginPassword', password); await testSubjects.click('loginSubmit'); - // wait for either kibanaChrome or loginErrorMessage - if (expectSuccess) { - await remote.setFindTimeout(20000).findByCssSelector('[data-test-subj="kibanaChrome"] nav:not(.ng-hide)'); + + // wait for either space selector, kibanaChrome or loginErrorMessage + if (expectSpaceSelector) { + await retry.try(() => testSubjects.find('kibanaSpaceSelector')); + log.debug(`Finished login process, landed on space selector. currentUrl = ${await remote.getCurrentUrl()}`); + } else if (expectSuccess) { + await remote.setFindTimeout(20000).findByCssSelector('[data-test-subj="kibanaChrome"] nav:not(.ng-hide) '); log.debug(`Finished login process currentUrl = ${await remote.getCurrentUrl()}`); } } @@ -63,8 +70,12 @@ export function SecurityPageProvider({ getService, getPageObjects }) { remote.setWindowSize(1600, 1000); } - async login(username, password) { - await this.loginPage.login(username, password); + async login(username, password, options = {}) { + await this.loginPage.login(username, password, options); + + if (options.expectSpaceSelector) { + return; + } await retry.try(async () => { const logoutLinkExists = await find.existsByLinkText('Logout'); @@ -92,14 +103,21 @@ export function SecurityPageProvider({ getService, getPageObjects }) { // long it takes the home screen to query Elastic to see if it's a // new Kibana instance. if (isWelcomeShowing) { + log.debug('welcome screen showing when attempting logout'); await PageObjects.home.hideWelcomeScreen(); } await find.clickByLinkText('Logout'); await retry.try(async () => { - const logoutLinkExists = await find.existsByDisplayedByCssSelector('.login-form'); - if (!logoutLinkExists) { + const loginFormExists = await find.existsByDisplayedByCssSelector('.login-form'); + + const logoutLinkExists = await find.existsByLinkText('Logout'); + if (logoutLinkExists) { + await find.clickByLinkText('Logout'); + } + + if (!loginFormExists) { throw new Error('Logout is not completed yet'); } }); @@ -147,7 +165,7 @@ export function SecurityPageProvider({ getService, getPageObjects }) { async addIndexToRole(index) { log.debug(`Adding index ${index} to role`); - const indexInput = await retry.try(() => find.byCssSelector('[data-test-subj="indicesInput0"] > div > input')); + const indexInput = await retry.try(() => find.byCssSelector('[data-test-subj="indicesInput0"] input')); await indexInput.type(index); await indexInput.type('\n'); } @@ -155,9 +173,18 @@ export function SecurityPageProvider({ getService, getPageObjects }) { async addPrivilegeToRole(privilege) { log.debug(`Adding privilege ${privilege} to role`); const privilegeInput = - await retry.try(() => find.byCssSelector('[data-test-subj="privilegesInput0"] > div > input')); + await retry.try(() => find.byCssSelector('[data-test-subj="privilegesInput0"] input')); await privilegeInput.type(privilege); - await privilegeInput.type('\n'); + + const btn = await find.byButtonText(privilege); + await btn.click(); + + // const options = await find.byCssSelector(`.euiComboBoxOption`); + // Object.entries(options).forEach(([key, prop]) => { + // console.log({ key, proto: prop.__proto__ }); + // }); + + // await options.click(); } async assignRoleToUser(role) { @@ -193,7 +220,7 @@ export function SecurityPageProvider({ getService, getPageObjects }) { const fullnameElement = await user.findByCssSelector('[data-test-subj="userRowFullName"]'); const usernameElement = await user.findByCssSelector('[data-test-subj="userRowUserName"]'); const rolesElement = await user.findByCssSelector('[data-test-subj="userRowRoles"]'); - const isReservedElementVisible = await user.findByCssSelector('td:last-child'); + const isReservedElementVisible = await user.findByCssSelector('td:last-child'); return { username: await usernameElement.getVisibleText(), @@ -208,9 +235,9 @@ export function SecurityPageProvider({ getService, getPageObjects }) { const users = await testSubjects.findAll('roleRow'); return mapAsync(users, async role => { const rolenameElement = await role.findByCssSelector('[data-test-subj="roleRowName"]'); - const isReservedElementVisible = await role.findByCssSelector('td:nth-child(3)'); + const isReservedElementVisible = await role.findByCssSelector('td:nth-child(3)'); - return { + return { rolename: await rolenameElement.getVisibleText(), reserved: (await isReservedElementVisible.getProperty('innerHTML')).includes('roleRowReserved') }; @@ -248,27 +275,25 @@ export function SecurityPageProvider({ getService, getPageObjects }) { } addRole(roleName, userObj) { + const self = this; + return this.clickNewRole() .then(function () { // We have to use non-test-subject selectors because this markup is generated by ui-select. - log.debug('userObj.indices[0].names = ' + userObj.indices[0].names); + log.debug('userObj.indices[0].names = ' + userObj.elasticsearch.indices[0].names); return testSubjects.append('roleFormNameInput', roleName); }) .then(function () { return remote.setFindTimeout(defaultFindTimeout) - // We have to use non-test-subject selectors because this markup is generated by ui-select. - .findByCssSelector('[data-test-subj="indicesInput0"] .ui-select-search') - .type(userObj.indices[0].names); + .findByCssSelector('[data-test-subj="indicesInput0"] input') + .type(userObj.elasticsearch.indices[0].names + '\n'); }) .then(function () { - return remote.setFindTimeout(defaultFindTimeout) - // We have to use non-test-subject selectors because this markup is generated by ui-select. - .findByCssSelector('span.ui-select-choices-row-inner > div[ng-bind-html="indexPattern"]') - .click(); + return testSubjects.click('restrictDocumentsQuery0'); }) .then(function () { - if (userObj.indices[0].query) { - return testSubjects.setValue('queryInput0', userObj.indices[0].query); + if (userObj.elasticsearch.indices[0].query) { + return testSubjects.setValue('queryInput0', userObj.elasticsearch.indices[0].query); } }) @@ -281,19 +306,17 @@ export function SecurityPageProvider({ getService, getPageObjects }) { // We have to use non-test-subject selectors because this markup is generated by ui-select. return promise - .then(function () { + .then(async function () { log.debug('priv item = ' + privName); - remote.setFindTimeout(defaultFindTimeout) - .findByCssSelector(`[data-test-subj="kibanaPrivileges-${privName}"]`) - .click(); + return find.byCssSelector(`[data-test-subj="kibanaMinimumPrivilege"] option[value="${privName}"]`); }) - .then(function () { - return PageObjects.common.sleep(500); + .then(function (element) { + return element.click(); }); }, Promise.resolve()); } - return userObj.kibana ? addKibanaPriv(userObj.kibana) : Promise.resolve(); + return userObj.kibana.global ? addKibanaPriv(userObj.kibana.global) : Promise.resolve(); }) .then(function () { @@ -302,25 +325,10 @@ export function SecurityPageProvider({ getService, getPageObjects }) { return priv.reduce(function (promise, privName) { // We have to use non-test-subject selectors because this markup is generated by ui-select. - return promise - .then(function () { - return remote.setFindTimeout(defaultFindTimeout) - .findByCssSelector('[data-test-subj="privilegesInput0"] .ui-select-search') - .click(); - }) - .then(function () { - log.debug('priv item = ' + privName); - remote.setFindTimeout(defaultFindTimeout) - .findByCssSelector(`[data-test-subj="privilegeOption-${privName}"]`) - .click(); - }) - .then(function () { - return PageObjects.common.sleep(500); - }); - + return promise.then(() => self.addPrivilegeToRole(privName)).then(() => PageObjects.common.sleep(250)); }, Promise.resolve()); } - return addPriv(userObj.indices[0].privileges); + return addPriv(userObj.elasticsearch.indices[0].privileges); }) //clicking the Granted fields and removing the asterix .then(function () { @@ -330,8 +338,8 @@ export function SecurityPageProvider({ getService, getPageObjects }) { return promise .then(function () { return remote.setFindTimeout(defaultFindTimeout) - .findByCssSelector('[data-test-subj="fieldInput0"] .ui-select-search') - .type(fieldName + '\t'); + .findByCssSelector('[data-test-subj="fieldInput0"] input') + .type(fieldName + '\n'); }) .then(function () { return PageObjects.common.sleep(1000); @@ -340,13 +348,13 @@ export function SecurityPageProvider({ getService, getPageObjects }) { }, Promise.resolve()); } - if (userObj.indices[0].field_security) { + if (userObj.elasticsearch.indices[0].field_security) { // have to remove the '*' return remote.setFindTimeout(defaultFindTimeout) - .findByCssSelector('div[data-test-subj="fieldInput0"] > div > span > span > span > span.ui-select-match-close') + .findByCssSelector('div[data-test-subj="fieldInput0"] .euiBadge[title="*"]') .click() .then(function () { - return addGrantedField(userObj.indices[0].field_security.grant); + return addGrantedField(userObj.elasticsearch.indices[0].field_security.grant); }); } }) //clicking save button @@ -382,10 +390,10 @@ export function SecurityPageProvider({ getService, getPageObjects }) { .then(() => { return PageObjects.common.sleep(2000); }) - .then (() => { + .then(() => { return testSubjects.getVisibleText('confirmModalBodyText'); }) - .then ((alert) => { + .then((alert) => { alertText = alert; log.debug('Delete user alert text = ' + alertText); return testSubjects.click('confirmModalConfirmButton'); diff --git a/x-pack/test/functional/page_objects/space_selector_page.js b/x-pack/test/functional/page_objects/space_selector_page.js new file mode 100644 index 00000000000000..aebe065fe6ef94 --- /dev/null +++ b/x-pack/test/functional/page_objects/space_selector_page.js @@ -0,0 +1,64 @@ +/* + * 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 'expect.js'; + +export function SpaceSelectorPageProvider({ getService, getPageObjects }) { + const retry = getService('retry'); + const log = getService('log'); + const testSubjects = getService('testSubjects'); + const remote = getService('remote'); + const PageObjects = getPageObjects(['common', 'home', 'security']); + + class SpaceSelectorPage { + async initTests() { + log.debug('SpaceSelectorPage:initTests'); + } + + async clickSpaceCard(spaceId) { + return await retry.try(async () => { + log.info(`SpaceSelectorPage:clickSpaceCard(${spaceId})`); + await testSubjects.click(`space-card-${spaceId}`); + await PageObjects.common.sleep(1000); + }); + } + + async expectHomePage(spaceId) { + return await retry.try(async () => { + log.debug(`expectHomePage(${spaceId})`); + await this.dismissWelcomeScreen(); + await remote.setFindTimeout(20000).findByCssSelector('[data-test-subj="kibanaChrome"] nav:not(.ng-hide) '); + const url = await remote.getCurrentUrl(); + if (spaceId === 'default') { + expect(url).to.contain(`/app/kibana#/home`); + } else { + expect(url).to.contain(`/s/${spaceId}/app/kibana#/home`); + } + }); + } + + async dismissWelcomeScreen() { + if (await PageObjects.home.isWelcomeShowing()) { + await PageObjects.home.hideWelcomeScreen(); + } + } + + async openSpacesNav() { + log.debug('openSpacesNav()'); + return await testSubjects.click('spacesNavSelector'); + } + + async clickSpaceAvatar(spaceId) { + return await retry.try(async () => { + log.info(`SpaceSelectorPage:clickSpaceAvatar(${spaceId})`); + await testSubjects.click(`space-avatar-${spaceId}`); + await PageObjects.common.sleep(1000); + }); + } + } + + return new SpaceSelectorPage(); +} diff --git a/x-pack/test/rbac_api_integration/apis/privileges/index.js b/x-pack/test/rbac_api_integration/apis/privileges/index.js deleted file mode 100644 index 5f407431f24a8f..00000000000000 --- a/x-pack/test/rbac_api_integration/apis/privileges/index.js +++ /dev/null @@ -1,75 +0,0 @@ -/* - * 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 'expect.js'; - -export default function ({ getService }) { - describe('privileges', () => { - it(`get should return privileges`, async () => { - const supertest = getService('supertest'); - const kibanaServer = getService('kibanaServer'); - const version = await kibanaServer.version.get(); - - await supertest - .get(`/api/security/v1/privileges`) - .expect(200) - .then(resp => { - expect(resp.body).to.eql([ - { - application: 'kibana-.kibana', - name: 'all', - actions: [`version:${version}`, 'action:*'], - metadata: {}, - }, - { - application: 'kibana-.kibana', - name: 'read', - actions: [ - `version:${version}`, - 'action:login', - 'action:saved_objects/config/get', - 'action:saved_objects/config/bulk_get', - 'action:saved_objects/config/find', - "action:saved_objects/migrationVersion/get", - "action:saved_objects/migrationVersion/bulk_get", - "action:saved_objects/migrationVersion/find", - 'action:saved_objects/timelion-sheet/get', - 'action:saved_objects/timelion-sheet/bulk_get', - 'action:saved_objects/timelion-sheet/find', - 'action:saved_objects/telemetry/get', - 'action:saved_objects/telemetry/bulk_get', - 'action:saved_objects/telemetry/find', - 'action:saved_objects/graph-workspace/get', - 'action:saved_objects/graph-workspace/bulk_get', - 'action:saved_objects/graph-workspace/find', - 'action:saved_objects/canvas-workpad/get', - 'action:saved_objects/canvas-workpad/bulk_get', - 'action:saved_objects/canvas-workpad/find', - 'action:saved_objects/index-pattern/get', - 'action:saved_objects/index-pattern/bulk_get', - 'action:saved_objects/index-pattern/find', - 'action:saved_objects/visualization/get', - 'action:saved_objects/visualization/bulk_get', - 'action:saved_objects/visualization/find', - 'action:saved_objects/search/get', - 'action:saved_objects/search/bulk_get', - 'action:saved_objects/search/find', - 'action:saved_objects/dashboard/get', - 'action:saved_objects/dashboard/bulk_get', - 'action:saved_objects/dashboard/find', - 'action:saved_objects/url/get', - 'action:saved_objects/url/bulk_get', - 'action:saved_objects/url/find', - 'action:saved_objects/server/get', - 'action:saved_objects/server/bulk_get', - 'action:saved_objects/server/find', - ], - metadata: {}, - }, - ]); - }); - }); - }); -} diff --git a/x-pack/test/rbac_api_integration/apis/saved_objects/bulk_get.js b/x-pack/test/rbac_api_integration/apis/saved_objects/bulk_get.js deleted file mode 100644 index 6785859e42fbfb..00000000000000 --- a/x-pack/test/rbac_api_integration/apis/saved_objects/bulk_get.js +++ /dev/null @@ -1,201 +0,0 @@ -/* - * 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 'expect.js'; -import { AUTHENTICATION } from './lib/authentication'; - -export default function ({ getService }) { - const supertest = getService('supertestWithoutAuth'); - const esArchiver = getService('esArchiver'); - - const BULK_REQUESTS = [ - { - type: 'visualization', - id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab', - }, - { - type: 'dashboard', - id: 'does not exist', - }, - { - type: 'config', - id: '7.0.0-alpha1', - }, - ]; - - describe('_bulk_get', () => { - const expectResults = resp => { - expect(resp.body).to.eql({ - saved_objects: [ - { - id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab', - type: 'visualization', - updated_at: '2017-09-21T18:51:23.794Z', - version: resp.body.saved_objects[0].version, - attributes: { - title: 'Count of requests', - description: '', - version: 1, - // cheat for some of the more complex attributes - visState: resp.body.saved_objects[0].attributes.visState, - uiStateJSON: resp.body.saved_objects[0].attributes.uiStateJSON, - kibanaSavedObjectMeta: - resp.body.saved_objects[0].attributes.kibanaSavedObjectMeta, - }, - }, - { - id: 'does not exist', - type: 'dashboard', - error: { - statusCode: 404, - message: 'Not found', - }, - }, - { - id: '7.0.0-alpha1', - type: 'config', - updated_at: '2017-09-21T18:49:16.302Z', - version: resp.body.saved_objects[2].version, - attributes: { - buildNum: 8467, - defaultIndex: '91200a00-9efd-11e7-acb3-3dab96693fab', - }, - }, - ], - }); - }; - - const expectRbacForbidden = resp => { - //eslint-disable-next-line max-len - const missingActions = `action:saved_objects/config/bulk_get,action:saved_objects/dashboard/bulk_get,action:saved_objects/visualization/bulk_get`; - expect(resp.body).to.eql({ - statusCode: 403, - error: 'Forbidden', - message: `Unable to bulk_get config,dashboard,visualization, missing ${missingActions}` - }); - }; - - const bulkGetTest = (description, { auth, tests }) => { - describe(description, () => { - before(() => esArchiver.load('saved_objects/basic')); - after(() => esArchiver.unload('saved_objects/basic')); - - it(`should return ${tests.default.statusCode}`, async () => { - await supertest - .post(`/api/saved_objects/_bulk_get`) - .auth(auth.username, auth.password) - .send(BULK_REQUESTS) - .expect(tests.default.statusCode) - .then(tests.default.response); - }); - }); - }; - - bulkGetTest(`not a kibana user`, { - auth: { - username: AUTHENTICATION.NOT_A_KIBANA_USER.USERNAME, - password: AUTHENTICATION.NOT_A_KIBANA_USER.PASSWORD, - }, - tests: { - default: { - statusCode: 403, - response: expectRbacForbidden, - } - } - }); - - bulkGetTest(`superuser`, { - auth: { - username: AUTHENTICATION.SUPERUSER.USERNAME, - password: AUTHENTICATION.SUPERUSER.PASSWORD, - }, - tests: { - default: { - statusCode: 200, - response: expectResults, - }, - } - }); - - bulkGetTest(`kibana legacy user`, { - auth: { - username: AUTHENTICATION.KIBANA_LEGACY_USER.USERNAME, - password: AUTHENTICATION.KIBANA_LEGACY_USER.PASSWORD, - }, - tests: { - default: { - statusCode: 200, - response: expectResults, - }, - } - }); - - bulkGetTest(`kibana legacy dashboard only user`, { - auth: { - username: AUTHENTICATION.KIBANA_LEGACY_DASHBOARD_ONLY_USER.USERNAME, - password: AUTHENTICATION.KIBANA_LEGACY_DASHBOARD_ONLY_USER.PASSWORD, - }, - tests: { - default: { - statusCode: 200, - response: expectResults, - }, - } - }); - - bulkGetTest(`kibana dual-privileges user`, { - auth: { - username: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_USER.USERNAME, - password: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_USER.PASSWORD, - }, - tests: { - default: { - statusCode: 200, - response: expectResults, - }, - } - }); - - bulkGetTest(`kibana dual-privileges dashboard only user`, { - auth: { - username: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER.USERNAME, - password: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER.PASSWORD, - }, - tests: { - default: { - statusCode: 200, - response: expectResults, - }, - } - }); - - bulkGetTest(`kibana rbac user`, { - auth: { - username: AUTHENTICATION.KIBANA_RBAC_USER.USERNAME, - password: AUTHENTICATION.KIBANA_RBAC_USER.PASSWORD, - }, - tests: { - default: { - statusCode: 200, - response: expectResults, - }, - } - }); - - bulkGetTest(`kibana rbac dashboard only user`, { - auth: { - username: AUTHENTICATION.KIBANA_RBAC_DASHBOARD_ONLY_USER.USERNAME, - password: AUTHENTICATION.KIBANA_RBAC_DASHBOARD_ONLY_USER.PASSWORD, - }, - tests: { - default: { - statusCode: 200, - response: expectResults, - }, - } - }); - }); -} diff --git a/x-pack/test/rbac_api_integration/apis/saved_objects/create.js b/x-pack/test/rbac_api_integration/apis/saved_objects/create.js deleted file mode 100644 index 6a949004371f8f..00000000000000 --- a/x-pack/test/rbac_api_integration/apis/saved_objects/create.js +++ /dev/null @@ -1,172 +0,0 @@ -/* - * 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 'expect.js'; -import { AUTHENTICATION } from './lib/authentication'; - -export default function ({ getService }) { - const supertest = getService('supertestWithoutAuth'); - const esArchiver = getService('esArchiver'); - - describe('create', () => { - const expectResults = (resp) => { - expect(resp.body).to.have.property('id').match(/^[0-9a-f-]{36}$/); - - // loose ISO8601 UTC time with milliseconds validation - expect(resp.body).to.have.property('updated_at').match(/^[\d-]{10}T[\d:\.]{12}Z$/); - - expect(resp.body).to.eql({ - id: resp.body.id, - type: 'visualization', - updated_at: resp.body.updated_at, - version: 1, - attributes: { - title: 'My favorite vis' - } - }); - }; - - const expectRbacForbidden = resp => { - expect(resp.body).to.eql({ - statusCode: 403, - error: 'Forbidden', - message: `Unable to create visualization, missing action:saved_objects/visualization/create` - }); - }; - - const createExpectLegacyForbidden = username => resp => { - expect(resp.body).to.eql({ - statusCode: 403, - error: 'Forbidden', - //eslint-disable-next-line max-len - message: `action [indices:data/write/index] is unauthorized for user [${username}]: [security_exception] action [indices:data/write/index] is unauthorized for user [${username}]` - }); - }; - - const createTest = (description, { auth, tests }) => { - describe(description, () => { - before(() => esArchiver.load('saved_objects/basic')); - after(() => esArchiver.unload('saved_objects/basic')); - it(`should return ${tests.default.statusCode}`, async () => { - await supertest - .post(`/api/saved_objects/visualization`) - .auth(auth.username, auth.password) - .send({ - attributes: { - title: 'My favorite vis' - } - }) - .expect(tests.default.statusCode) - .then(tests.default.response); - }); - }); - }; - - createTest(`not a kibana user`, { - auth: { - username: AUTHENTICATION.NOT_A_KIBANA_USER.USERNAME, - password: AUTHENTICATION.NOT_A_KIBANA_USER.PASSWORD, - }, - tests: { - default: { - statusCode: 403, - response: expectRbacForbidden, - }, - } - }); - - createTest(`superuser`, { - auth: { - username: AUTHENTICATION.SUPERUSER.USERNAME, - password: AUTHENTICATION.SUPERUSER.PASSWORD, - }, - tests: { - default: { - statusCode: 200, - response: expectResults, - }, - } - }); - - createTest(`kibana legacy user`, { - auth: { - username: AUTHENTICATION.KIBANA_LEGACY_USER.USERNAME, - password: AUTHENTICATION.KIBANA_LEGACY_USER.PASSWORD, - }, - tests: { - default: { - statusCode: 200, - response: expectResults, - }, - } - }); - - createTest(`kibana legacy dashboard only user`, { - auth: { - username: AUTHENTICATION.KIBANA_LEGACY_DASHBOARD_ONLY_USER.USERNAME, - password: AUTHENTICATION.KIBANA_LEGACY_DASHBOARD_ONLY_USER.PASSWORD, - }, - tests: { - default: { - statusCode: 403, - response: createExpectLegacyForbidden(AUTHENTICATION.KIBANA_LEGACY_DASHBOARD_ONLY_USER.USERNAME), - }, - } - }); - - createTest(`kibana dual-privileges user`, { - auth: { - username: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_USER.USERNAME, - password: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_USER.PASSWORD, - }, - tests: { - default: { - statusCode: 200, - response: expectResults, - }, - } - }); - - createTest(`kibana dual-privileges dashboard only user`, { - auth: { - username: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER.USERNAME, - password: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER.PASSWORD, - }, - tests: { - default: { - statusCode: 403, - response: expectRbacForbidden, - }, - } - }); - - createTest(`kibana rbac user`, { - auth: { - username: AUTHENTICATION.KIBANA_RBAC_USER.USERNAME, - password: AUTHENTICATION.KIBANA_RBAC_USER.PASSWORD, - }, - tests: { - default: { - statusCode: 200, - response: expectResults, - }, - } - }); - - createTest(`kibana rbac dashboard only user`, { - auth: { - username: AUTHENTICATION.KIBANA_RBAC_DASHBOARD_ONLY_USER.USERNAME, - password: AUTHENTICATION.KIBANA_RBAC_DASHBOARD_ONLY_USER.PASSWORD, - }, - tests: { - default: { - statusCode: 403, - response: expectRbacForbidden, - }, - } - }); - }); -} diff --git a/x-pack/test/rbac_api_integration/apis/saved_objects/delete.js b/x-pack/test/rbac_api_integration/apis/saved_objects/delete.js deleted file mode 100644 index 5885eb7919c7bd..00000000000000 --- a/x-pack/test/rbac_api_integration/apis/saved_objects/delete.js +++ /dev/null @@ -1,204 +0,0 @@ -/* - * 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 'expect.js'; -import { AUTHENTICATION } from './lib/authentication'; - -export default function ({ getService }) { - const supertest = getService('supertestWithoutAuth'); - const esArchiver = getService('esArchiver'); - - describe('delete', () => { - - const expectEmpty = (resp) => { - expect(resp.body).to.eql({}); - }; - - const expectNotFound = (resp) => { - expect(resp.body).to.eql({ - statusCode: 404, - error: 'Not Found', - message: 'Saved object [dashboard/not-a-real-id] not found' - }); - }; - - const expectRbacForbidden = resp => { - expect(resp.body).to.eql({ - statusCode: 403, - error: 'Forbidden', - message: `Unable to delete dashboard, missing action:saved_objects/dashboard/delete` - }); - }; - - const createExpectLegacyForbidden = username => resp => { - expect(resp.body).to.eql({ - statusCode: 403, - error: 'Forbidden', - //eslint-disable-next-line max-len - message: `action [indices:data/write/delete] is unauthorized for user [${username}]: [security_exception] action [indices:data/write/delete] is unauthorized for user [${username}]` - }); - }; - - const deleteTest = (description, { auth, tests }) => { - describe(description, () => { - before(() => esArchiver.load('saved_objects/basic')); - after(() => esArchiver.unload('saved_objects/basic')); - - it(`should return ${tests.actualId.statusCode} when deleting a doc`, async () => ( - await supertest - .delete(`/api/saved_objects/dashboard/be3733a0-9efe-11e7-acb3-3dab96693fab`) - .auth(auth.username, auth.password) - .expect(tests.actualId.statusCode) - .then(tests.actualId.response) - )); - - it(`should return ${tests.invalidId.statusCode} when deleting an unknown doc`, async () => ( - await supertest - .delete(`/api/saved_objects/dashboard/not-a-real-id`) - .auth(auth.username, auth.password) - .expect(tests.invalidId.statusCode) - .then(tests.invalidId.response) - )); - }); - }; - - deleteTest(`not a kibana user`, { - auth: { - username: AUTHENTICATION.NOT_A_KIBANA_USER.USERNAME, - password: AUTHENTICATION.NOT_A_KIBANA_USER.PASSWORD, - }, - tests: { - actualId: { - statusCode: 403, - response: expectRbacForbidden, - }, - invalidId: { - statusCode: 403, - response: expectRbacForbidden, - } - } - }); - - deleteTest(`superuser`, { - auth: { - username: AUTHENTICATION.SUPERUSER.USERNAME, - password: AUTHENTICATION.SUPERUSER.PASSWORD, - }, - tests: { - actualId: { - statusCode: 200, - response: expectEmpty, - }, - invalidId: { - statusCode: 404, - response: expectNotFound, - } - } - }); - - deleteTest(`kibana legacy user`, { - auth: { - username: AUTHENTICATION.KIBANA_LEGACY_USER.USERNAME, - password: AUTHENTICATION.KIBANA_LEGACY_USER.PASSWORD, - }, - tests: { - actualId: { - statusCode: 200, - response: expectEmpty, - }, - invalidId: { - statusCode: 404, - response: expectNotFound, - } - } - }); - - deleteTest(`kibana legacy dashboard only user`, { - auth: { - username: AUTHENTICATION.KIBANA_LEGACY_DASHBOARD_ONLY_USER.USERNAME, - password: AUTHENTICATION.KIBANA_LEGACY_DASHBOARD_ONLY_USER.PASSWORD, - }, - tests: { - actualId: { - statusCode: 403, - response: createExpectLegacyForbidden(AUTHENTICATION.KIBANA_LEGACY_DASHBOARD_ONLY_USER.USERNAME), - }, - invalidId: { - statusCode: 403, - response: createExpectLegacyForbidden(AUTHENTICATION.KIBANA_LEGACY_DASHBOARD_ONLY_USER.USERNAME), - } - } - }); - - deleteTest(`kibana dual-privileges user`, { - auth: { - username: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_USER.USERNAME, - password: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_USER.PASSWORD, - }, - tests: { - actualId: { - statusCode: 200, - response: expectEmpty, - }, - invalidId: { - statusCode: 404, - response: expectNotFound, - } - } - }); - - deleteTest(`kibana dual-privileges dashboard only user`, { - auth: { - username: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER.USERNAME, - password: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER.PASSWORD, - }, - tests: { - actualId: { - statusCode: 403, - response: expectRbacForbidden, - }, - invalidId: { - statusCode: 403, - response: expectRbacForbidden, - } - } - }); - - deleteTest(`kibana rbac user`, { - auth: { - username: AUTHENTICATION.KIBANA_RBAC_USER.USERNAME, - password: AUTHENTICATION.KIBANA_RBAC_USER.PASSWORD, - }, - tests: { - actualId: { - statusCode: 200, - response: expectEmpty, - }, - invalidId: { - statusCode: 404, - response: expectNotFound, - } - } - }); - - deleteTest(`kibana rbac dashboard only user`, { - auth: { - username: AUTHENTICATION.KIBANA_RBAC_DASHBOARD_ONLY_USER.USERNAME, - password: AUTHENTICATION.KIBANA_RBAC_DASHBOARD_ONLY_USER.PASSWORD, - }, - tests: { - actualId: { - statusCode: 403, - response: expectRbacForbidden, - }, - invalidId: { - statusCode: 403, - response: expectRbacForbidden, - } - } - }); - }); -} diff --git a/x-pack/test/rbac_api_integration/apis/saved_objects/find.js b/x-pack/test/rbac_api_integration/apis/saved_objects/find.js deleted file mode 100644 index 9f2c4fde33581c..00000000000000 --- a/x-pack/test/rbac_api_integration/apis/saved_objects/find.js +++ /dev/null @@ -1,390 +0,0 @@ -/* - * 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 'expect.js'; -import { AUTHENTICATION } from './lib/authentication'; - -export default function ({ getService }) { - const supertest = getService('supertestWithoutAuth'); - const esArchiver = getService('esArchiver'); - - describe('find', () => { - - const expectVisualizationResults = (resp) => { - expect(resp.body).to.eql({ - page: 1, - per_page: 20, - total: 1, - saved_objects: [ - { - type: 'visualization', - id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab', - version: 1, - attributes: { - 'title': 'Count of requests' - } - } - ] - }); - }; - - const expectBadRequest = (resp) => { - expect(resp.body).to.eql({ - error: 'Bad Request', - message: 'child "type" fails because ["type" is required]', - statusCode: 400, - validation: { - keys: ['type'], - source: 'query' - } - }); - }; - - const createExpectEmpty = (page, perPage, total) => (resp) => { - expect(resp.body).to.eql({ - page: page, - per_page: perPage, - total: total, - saved_objects: [] - }); - }; - - const createExpectRbacForbidden = (type) => resp => { - expect(resp.body).to.eql({ - statusCode: 403, - error: 'Forbidden', - message: `Unable to find ${type}, missing action:saved_objects/${type}/find` - }); - }; - - const findTest = (description, { auth, tests }) => { - describe(description, () => { - before(() => esArchiver.load('saved_objects/basic')); - after(() => esArchiver.unload('saved_objects/basic')); - - it(`should return ${tests.normal.statusCode} with ${tests.normal.description}`, async () => ( - await supertest - .get('/api/saved_objects/_find?type=visualization&fields=title') - .auth(auth.username, auth.password) - .expect(tests.normal.statusCode) - .then(tests.normal.response) - )); - - describe('unknown type', () => { - it(`should return ${tests.unknownType.statusCode} with ${tests.unknownType.description}`, async () => ( - await supertest - .get('/api/saved_objects/_find?type=wigwags') - .auth(auth.username, auth.password) - .expect(tests.unknownType.statusCode) - .then(tests.unknownType.response) - )); - }); - - describe('page beyond total', () => { - it(`should return ${tests.pageBeyondTotal.statusCode} with ${tests.pageBeyondTotal.description}`, async () => ( - await supertest - .get('/api/saved_objects/_find?type=visualization&page=100&per_page=100') - .auth(auth.username, auth.password) - .expect(tests.pageBeyondTotal.statusCode) - .then(tests.pageBeyondTotal.response) - )); - }); - - describe('unknown search field', () => { - it(`should return ${tests.unknownSearchField.statusCode} with ${tests.unknownSearchField.description}`, async () => ( - await supertest - .get('/api/saved_objects/_find?type=wigwags&search_fields=a') - .auth(auth.username, auth.password) - .expect(tests.unknownSearchField.statusCode) - .then(tests.unknownSearchField.response) - )); - }); - - describe('no type', () => { - it(`should return ${tests.noType.statusCode} with ${tests.noType.description}`, async () => ( - await supertest - .get('/api/saved_objects/_find') - .auth(auth.username, auth.password) - .expect(tests.noType.statusCode) - .then(tests.noType.response) - )); - }); - }); - }; - - findTest(`not a kibana user`, { - auth: { - username: AUTHENTICATION.NOT_A_KIBANA_USER.USERNAME, - password: AUTHENTICATION.NOT_A_KIBANA_USER.PASSWORD, - }, - tests: { - normal: { - description: 'forbidden login and find visualization message', - statusCode: 403, - response: createExpectRbacForbidden('visualization'), - }, - unknownType: { - description: 'forbidden login and find wigwags message', - statusCode: 403, - response: createExpectRbacForbidden('wigwags'), - }, - pageBeyondTotal: { - description: 'forbidden login and find visualization message', - statusCode: 403, - response: createExpectRbacForbidden('visualization'), - }, - unknownSearchField: { - description: 'forbidden login and find wigwags message', - statusCode: 403, - response: createExpectRbacForbidden('wigwags'), - }, - noType: { - description: `forbidded can't find any types`, - statusCode: 400, - response: expectBadRequest, - } - } - }); - - findTest(`superuser`, { - auth: { - username: AUTHENTICATION.SUPERUSER.USERNAME, - password: AUTHENTICATION.SUPERUSER.PASSWORD, - }, - tests: { - normal: { - description: 'only the visualization', - statusCode: 200, - response: expectVisualizationResults, - }, - unknownType: { - description: 'empty result', - statusCode: 200, - response: createExpectEmpty(1, 20, 0), - }, - pageBeyondTotal: { - description: 'empty result', - statusCode: 200, - response: createExpectEmpty(100, 100, 1), - }, - unknownSearchField: { - description: 'empty result', - statusCode: 200, - response: createExpectEmpty(1, 20, 0), - }, - noType: { - description: 'all objects', - statusCode: 400, - response: expectBadRequest, - }, - }, - }); - - findTest(`kibana legacy user`, { - auth: { - username: AUTHENTICATION.KIBANA_LEGACY_USER.USERNAME, - password: AUTHENTICATION.KIBANA_LEGACY_USER.PASSWORD, - }, - tests: { - normal: { - description: 'only the visualization', - statusCode: 200, - response: expectVisualizationResults, - }, - unknownType: { - description: 'empty result', - statusCode: 200, - response: createExpectEmpty(1, 20, 0), - }, - pageBeyondTotal: { - description: 'empty result', - statusCode: 200, - response: createExpectEmpty(100, 100, 1), - }, - unknownSearchField: { - description: 'empty result', - statusCode: 200, - response: createExpectEmpty(1, 20, 0), - }, - noType: { - description: 'all objects', - statusCode: 400, - response: expectBadRequest, - }, - }, - }); - - findTest(`kibana legacy dashboard only user`, { - auth: { - username: AUTHENTICATION.KIBANA_LEGACY_DASHBOARD_ONLY_USER.USERNAME, - password: AUTHENTICATION.KIBANA_LEGACY_DASHBOARD_ONLY_USER.PASSWORD, - }, - tests: { - normal: { - description: 'only the visualization', - statusCode: 200, - response: expectVisualizationResults, - }, - unknownType: { - description: 'empty result', - statusCode: 200, - response: createExpectEmpty(1, 20, 0), - }, - pageBeyondTotal: { - description: 'empty result', - statusCode: 200, - response: createExpectEmpty(100, 100, 1), - }, - unknownSearchField: { - description: 'empty result', - statusCode: 200, - response: createExpectEmpty(1, 20, 0), - }, - noType: { - description: 'all objects', - statusCode: 400, - response: expectBadRequest, - }, - } - }); - - findTest(`kibana dual-privileges user`, { - auth: { - username: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_USER.USERNAME, - password: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_USER.PASSWORD, - }, - tests: { - normal: { - description: 'only the visualization', - statusCode: 200, - response: expectVisualizationResults, - }, - unknownType: { - description: 'empty result', - statusCode: 200, - response: createExpectEmpty(1, 20, 0), - }, - pageBeyondTotal: { - description: 'empty result', - statusCode: 200, - response: createExpectEmpty(100, 100, 1), - }, - unknownSearchField: { - description: 'empty result', - statusCode: 200, - response: createExpectEmpty(1, 20, 0), - }, - noType: { - description: 'all objects', - statusCode: 400, - response: expectBadRequest, - }, - }, - }); - - findTest(`kibana dual-privileges dashboard only user`, { - auth: { - username: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER.USERNAME, - password: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER.PASSWORD, - }, - tests: { - normal: { - description: 'only the visualization', - statusCode: 200, - response: expectVisualizationResults, - }, - unknownType: { - description: 'forbidden find wigwags message', - statusCode: 403, - response: createExpectRbacForbidden('wigwags'), - }, - pageBeyondTotal: { - description: 'empty result', - statusCode: 200, - response: createExpectEmpty(100, 100, 1), - }, - unknownSearchField: { - description: 'forbidden find wigwags message', - statusCode: 403, - response: createExpectRbacForbidden('wigwags'), - }, - noType: { - description: 'all objects', - statusCode: 400, - response: expectBadRequest, - }, - } - }); - - findTest(`kibana rbac user`, { - auth: { - username: AUTHENTICATION.KIBANA_RBAC_USER.USERNAME, - password: AUTHENTICATION.KIBANA_RBAC_USER.PASSWORD, - }, - tests: { - normal: { - description: 'only the visualization', - statusCode: 200, - response: expectVisualizationResults, - }, - unknownType: { - description: 'empty result', - statusCode: 200, - response: createExpectEmpty(1, 20, 0), - }, - pageBeyondTotal: { - description: 'empty result', - statusCode: 200, - response: createExpectEmpty(100, 100, 1), - }, - unknownSearchField: { - description: 'empty result', - statusCode: 200, - response: createExpectEmpty(1, 20, 0), - }, - noType: { - description: 'all objects', - statusCode: 400, - response: expectBadRequest, - }, - }, - }); - - findTest(`kibana rbac dashboard only user`, { - auth: { - username: AUTHENTICATION.KIBANA_RBAC_DASHBOARD_ONLY_USER.USERNAME, - password: AUTHENTICATION.KIBANA_RBAC_DASHBOARD_ONLY_USER.PASSWORD, - }, - tests: { - normal: { - description: 'only the visualization', - statusCode: 200, - response: expectVisualizationResults, - }, - unknownType: { - description: 'forbidden find wigwags message', - statusCode: 403, - response: createExpectRbacForbidden('wigwags'), - }, - pageBeyondTotal: { - description: 'empty result', - statusCode: 200, - response: createExpectEmpty(100, 100, 1), - }, - unknownSearchField: { - description: 'forbidden find wigwags message', - statusCode: 403, - response: createExpectRbacForbidden('wigwags'), - }, - noType: { - description: 'all objects', - statusCode: 400, - response: expectBadRequest, - }, - } - }); - }); -} diff --git a/x-pack/test/rbac_api_integration/apis/saved_objects/get.js b/x-pack/test/rbac_api_integration/apis/saved_objects/get.js deleted file mode 100644 index b640d120555932..00000000000000 --- a/x-pack/test/rbac_api_integration/apis/saved_objects/get.js +++ /dev/null @@ -1,211 +0,0 @@ -/* - * 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 'expect.js'; -import { AUTHENTICATION } from './lib/authentication'; - -export default function ({ getService }) { - const supertest = getService('supertestWithoutAuth'); - const esArchiver = getService('esArchiver'); - - describe('get', () => { - - const expectResults = (resp) => { - expect(resp.body).to.eql({ - id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab', - type: 'visualization', - updated_at: '2017-09-21T18:51:23.794Z', - version: resp.body.version, - attributes: { - title: 'Count of requests', - description: '', - version: 1, - // cheat for some of the more complex attributes - visState: resp.body.attributes.visState, - uiStateJSON: resp.body.attributes.uiStateJSON, - kibanaSavedObjectMeta: resp.body.attributes.kibanaSavedObjectMeta - } - }); - }; - - const expectNotFound = (resp) => { - expect(resp.body).to.eql({ - error: 'Not Found', - message: 'Saved object [visualization/foobar] not found', - statusCode: 404, - }); - }; - - const expectRbacForbidden = resp => { - expect(resp.body).to.eql({ - statusCode: 403, - error: 'Forbidden', - message: `Unable to get visualization, missing action:saved_objects/visualization/get` - }); - }; - - const getTest = (description, { auth, tests }) => { - describe(description, () => { - before(() => esArchiver.load('saved_objects/basic')); - after(() => esArchiver.unload('saved_objects/basic')); - - it(`should return ${tests.exists.statusCode}`, async () => ( - await supertest - .get(`/api/saved_objects/visualization/dd7caf20-9efd-11e7-acb3-3dab96693fab`) - .auth(auth.username, auth.password) - .expect(tests.exists.statusCode) - .then(tests.exists.response) - )); - - describe('document does not exist', () => { - it(`should return ${tests.doesntExist.statusCode}`, async () => ( - await supertest - .get(`/api/saved_objects/visualization/foobar`) - .auth(auth.username, auth.password) - .expect(tests.doesntExist.statusCode) - .then(tests.doesntExist.response) - )); - }); - }); - }; - - getTest(`not a kibana user`, { - auth: { - username: AUTHENTICATION.NOT_A_KIBANA_USER.USERNAME, - password: AUTHENTICATION.NOT_A_KIBANA_USER.PASSWORD, - }, - tests: { - exists: { - statusCode: 403, - response: expectRbacForbidden, - }, - doesntExist: { - statusCode: 403, - response: expectRbacForbidden, - }, - } - }); - - getTest(`superuser`, { - auth: { - username: AUTHENTICATION.SUPERUSER.USERNAME, - password: AUTHENTICATION.SUPERUSER.PASSWORD, - }, - tests: { - exists: { - statusCode: 200, - response: expectResults, - }, - doesntExist: { - statusCode: 404, - response: expectNotFound, - }, - } - }); - - getTest(`kibana legacy user`, { - auth: { - username: AUTHENTICATION.KIBANA_LEGACY_USER.USERNAME, - password: AUTHENTICATION.KIBANA_LEGACY_USER.PASSWORD, - }, - tests: { - exists: { - statusCode: 200, - response: expectResults, - }, - doesntExist: { - statusCode: 404, - response: expectNotFound, - }, - } - }); - - getTest(`kibana legacy dashboard only user`, { - auth: { - username: AUTHENTICATION.KIBANA_LEGACY_DASHBOARD_ONLY_USER.USERNAME, - password: AUTHENTICATION.KIBANA_LEGACY_DASHBOARD_ONLY_USER.PASSWORD, - }, - tests: { - exists: { - statusCode: 200, - response: expectResults, - }, - doesntExist: { - statusCode: 404, - response: expectNotFound, - }, - } - }); - - getTest(`kibana dual-privileges user`, { - auth: { - username: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_USER.USERNAME, - password: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_USER.PASSWORD, - }, - tests: { - exists: { - statusCode: 200, - response: expectResults, - }, - doesntExist: { - statusCode: 404, - response: expectNotFound, - }, - } - }); - - getTest(`kibana dual-privileges dashboard only user`, { - auth: { - username: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER.USERNAME, - password: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER.PASSWORD, - }, - tests: { - exists: { - statusCode: 200, - response: expectResults, - }, - doesntExist: { - statusCode: 404, - response: expectNotFound, - }, - } - }); - - getTest(`kibana rbac user`, { - auth: { - username: AUTHENTICATION.KIBANA_RBAC_USER.USERNAME, - password: AUTHENTICATION.KIBANA_RBAC_USER.PASSWORD, - }, - tests: { - exists: { - statusCode: 200, - response: expectResults, - }, - doesntExist: { - statusCode: 404, - response: expectNotFound, - }, - } - }); - - getTest(`kibana rbac dashboard only user`, { - auth: { - username: AUTHENTICATION.KIBANA_RBAC_DASHBOARD_ONLY_USER.USERNAME, - password: AUTHENTICATION.KIBANA_RBAC_DASHBOARD_ONLY_USER.PASSWORD, - }, - tests: { - exists: { - statusCode: 200, - response: expectResults, - }, - doesntExist: { - statusCode: 404, - response: expectNotFound, - }, - } - }); - }); -} diff --git a/x-pack/test/rbac_api_integration/apis/saved_objects/index.js b/x-pack/test/rbac_api_integration/apis/saved_objects/index.js deleted file mode 100644 index bdbd23f6dabdf4..00000000000000 --- a/x-pack/test/rbac_api_integration/apis/saved_objects/index.js +++ /dev/null @@ -1,160 +0,0 @@ -/* - * 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 { AUTHENTICATION } from "./lib/authentication"; - -export default function ({ loadTestFile, getService }) { - const es = getService('es'); - const supertest = getService('supertest'); - - describe('saved_objects', () => { - before(async () => { - await supertest.put('/api/security/role/kibana_legacy_user') - .send({ - elasticsearch: { - indices: [{ - names: ['.kibana'], - privileges: ['manage', 'read', 'index', 'delete'] - }] - } - }); - - await supertest.put('/api/security/role/kibana_legacy_dashboard_only_user') - .send({ - elasticsearch: { - indices: [{ - names: ['.kibana'], - privileges: ['read', 'view_index_metadata'] - }] - } - }); - - await supertest.put('/api/security/role/kibana_dual_privileges_user') - .send({ - elasticsearch: { - indices: [{ - names: ['.kibana'], - privileges: ['manage', 'read', 'index', 'delete'] - }] - }, - kibana: [ - { - privileges: ['all'] - } - ] - }); - - await supertest.put('/api/security/role/kibana_dual_privileges_dashboard_only_user') - .send({ - elasticsearch: { - indices: [{ - names: ['.kibana'], - privileges: ['read', 'view_index_metadata'] - }] - }, - kibana: [ - { - privileges: ['read'] - } - ] - }); - - await supertest.put('/api/security/role/kibana_rbac_user') - .send({ - kibana: [ - { - privileges: ['all'] - } - ] - }); - - await supertest.put('/api/security/role/kibana_rbac_dashboard_only_user') - .send({ - kibana: [ - { - privileges: ['read'] - } - ] - }); - - await es.shield.putUser({ - username: AUTHENTICATION.NOT_A_KIBANA_USER.USERNAME, - body: { - password: AUTHENTICATION.NOT_A_KIBANA_USER.PASSWORD, - roles: [], - full_name: 'not a kibana user', - email: 'not_a_kibana_user@elastic.co', - } - }); - - await es.shield.putUser({ - username: AUTHENTICATION.KIBANA_LEGACY_USER.USERNAME, - body: { - password: AUTHENTICATION.KIBANA_LEGACY_USER.PASSWORD, - roles: ['kibana_legacy_user'], - full_name: 'a kibana legacy user', - email: 'a_kibana_legacy_user@elastic.co', - } - }); - - await es.shield.putUser({ - username: AUTHENTICATION.KIBANA_LEGACY_DASHBOARD_ONLY_USER.USERNAME, - body: { - password: AUTHENTICATION.KIBANA_LEGACY_DASHBOARD_ONLY_USER.PASSWORD, - roles: ["kibana_legacy_dashboard_only_user"], - full_name: 'a kibana legacy dashboard only user', - email: 'a_kibana_legacy_dashboard_only_user@elastic.co', - } - }); - - await es.shield.putUser({ - username: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_USER.USERNAME, - body: { - password: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_USER.PASSWORD, - roles: ['kibana_dual_privileges_user'], - full_name: 'a kibana dual_privileges user', - email: 'a_kibana_dual_privileges_user@elastic.co', - } - }); - - await es.shield.putUser({ - username: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER.USERNAME, - body: { - password: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER.PASSWORD, - roles: ["kibana_dual_privileges_dashboard_only_user"], - full_name: 'a kibana dual_privileges dashboard only user', - email: 'a_kibana_dual_privileges_dashboard_only_user@elastic.co', - } - }); - - await es.shield.putUser({ - username: AUTHENTICATION.KIBANA_RBAC_USER.USERNAME, - body: { - password: AUTHENTICATION.KIBANA_RBAC_USER.PASSWORD, - roles: ['kibana_rbac_user'], - full_name: 'a kibana user', - email: 'a_kibana_rbac_user@elastic.co', - } - }); - - await es.shield.putUser({ - username: AUTHENTICATION.KIBANA_RBAC_DASHBOARD_ONLY_USER.USERNAME, - body: { - password: AUTHENTICATION.KIBANA_RBAC_DASHBOARD_ONLY_USER.PASSWORD, - roles: ["kibana_rbac_dashboard_only_user"], - full_name: 'a kibana dashboard only user', - email: 'a_kibana_rbac_dashboard_only_user@elastic.co', - } - }); - }); - loadTestFile(require.resolve('./bulk_get')); - loadTestFile(require.resolve('./create')); - loadTestFile(require.resolve('./delete')); - loadTestFile(require.resolve('./find')); - loadTestFile(require.resolve('./get')); - loadTestFile(require.resolve('./update')); - }); -} diff --git a/x-pack/test/rbac_api_integration/apis/saved_objects/lib/authentication.js b/x-pack/test/rbac_api_integration/apis/saved_objects/lib/authentication.js deleted file mode 100644 index 5b158a6c8bf377..00000000000000 --- a/x-pack/test/rbac_api_integration/apis/saved_objects/lib/authentication.js +++ /dev/null @@ -1,40 +0,0 @@ -/* - * 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 const AUTHENTICATION = { - NOT_A_KIBANA_USER: { - USERNAME: 'not_a_kibana_user', - PASSWORD: 'password' - }, - SUPERUSER: { - USERNAME: 'elastic', - PASSWORD: 'changeme' - }, - KIBANA_LEGACY_USER: { - USERNAME: 'a_kibana_legacy_user', - PASSWORD: 'password' - }, - KIBANA_LEGACY_DASHBOARD_ONLY_USER: { - USERNAME: 'a_kibana_legacy_dashboard_only_user', - PASSWORD: 'password' - }, - KIBANA_DUAL_PRIVILEGES_USER: { - USERNAME: 'a_kibana_dual_privileges_user', - PASSWORD: 'password' - }, - KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER: { - USERNAME: 'a_kibana_dual_privileges_dashboard_only_user', - PASSWORD: 'password' - }, - KIBANA_RBAC_USER: { - USERNAME: 'a_kibana_rbac_user', - PASSWORD: 'password' - }, - KIBANA_RBAC_DASHBOARD_ONLY_USER: { - USERNAME: 'a_kibana_rbac_dashboard_only_user', - PASSWORD: 'password' - } -}; diff --git a/x-pack/test/rbac_api_integration/apis/saved_objects/update.js b/x-pack/test/rbac_api_integration/apis/saved_objects/update.js deleted file mode 100644 index a4a17ba67fd5eb..00000000000000 --- a/x-pack/test/rbac_api_integration/apis/saved_objects/update.js +++ /dev/null @@ -1,229 +0,0 @@ -/* - * 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 'expect.js'; -import { AUTHENTICATION } from './lib/authentication'; - -export default function ({ getService }) { - const supertest = getService('supertestWithoutAuth'); - const esArchiver = getService('esArchiver'); - - describe('update', () => { - const expectResults = resp => { - // loose uuid validation - expect(resp.body).to.have.property('id').match(/^[0-9a-f-]{36}$/); - - // loose ISO8601 UTC time with milliseconds validation - expect(resp.body).to.have.property('updated_at').match(/^[\d-]{10}T[\d:\.]{12}Z$/); - - expect(resp.body).to.eql({ - id: resp.body.id, - type: 'visualization', - updated_at: resp.body.updated_at, - version: 2, - attributes: { - title: 'My second favorite vis' - } - }); - }; - - const expectNotFound = resp => { - expect(resp.body).eql({ - statusCode: 404, - error: 'Not Found', - message: 'Saved object [visualization/not an id] not found' - }); - }; - - const expectRbacForbidden = resp => { - expect(resp.body).to.eql({ - statusCode: 403, - error: 'Forbidden', - message: `Unable to update visualization, missing action:saved_objects/visualization/update` - }); - }; - - const createExpectLegacyForbidden = username => resp => { - expect(resp.body).to.eql({ - statusCode: 403, - error: 'Forbidden', - //eslint-disable-next-line max-len - message: `action [indices:data/write/update] is unauthorized for user [${username}]: [security_exception] action [indices:data/write/update] is unauthorized for user [${username}]` - }); - }; - - const updateTest = (description, { auth, tests }) => { - describe(description, () => { - before(() => esArchiver.load('saved_objects/basic')); - after(() => esArchiver.unload('saved_objects/basic')); - it(`should return ${tests.exists.statusCode}`, async () => { - await supertest - .put(`/api/saved_objects/visualization/dd7caf20-9efd-11e7-acb3-3dab96693fab`) - .auth(auth.username, auth.password) - .send({ - attributes: { - title: 'My second favorite vis' - } - }) - .expect(tests.exists.statusCode) - .then(tests.exists.response); - }); - - describe('unknown id', () => { - it(`should return ${tests.doesntExist.statusCode}`, async () => { - await supertest - .put(`/api/saved_objects/visualization/not an id`) - .auth(auth.username, auth.password) - .send({ - attributes: { - title: 'My second favorite vis' - } - }) - .expect(tests.doesntExist.statusCode) - .then(tests.doesntExist.response); - }); - }); - }); - }; - - updateTest(`not a kibana user`, { - auth: { - username: AUTHENTICATION.NOT_A_KIBANA_USER.USERNAME, - password: AUTHENTICATION.NOT_A_KIBANA_USER.PASSWORD, - }, - tests: { - exists: { - statusCode: 403, - response: expectRbacForbidden, - }, - doesntExist: { - statusCode: 403, - response: expectRbacForbidden, - }, - } - }); - - updateTest(`superuser`, { - auth: { - username: AUTHENTICATION.SUPERUSER.USERNAME, - password: AUTHENTICATION.SUPERUSER.PASSWORD, - }, - tests: { - exists: { - statusCode: 200, - response: expectResults, - }, - doesntExist: { - statusCode: 404, - response: expectNotFound, - }, - } - }); - - updateTest(`kibana legacy user`, { - auth: { - username: AUTHENTICATION.KIBANA_LEGACY_USER.USERNAME, - password: AUTHENTICATION.KIBANA_LEGACY_USER.PASSWORD, - }, - tests: { - exists: { - statusCode: 200, - response: expectResults, - }, - doesntExist: { - statusCode: 404, - response: expectNotFound, - }, - } - }); - - updateTest(`kibana legacy dashboard only user`, { - auth: { - username: AUTHENTICATION.KIBANA_LEGACY_DASHBOARD_ONLY_USER.USERNAME, - password: AUTHENTICATION.KIBANA_LEGACY_DASHBOARD_ONLY_USER.PASSWORD, - }, - tests: { - exists: { - statusCode: 403, - response: createExpectLegacyForbidden(AUTHENTICATION.KIBANA_LEGACY_DASHBOARD_ONLY_USER.USERNAME), - }, - doesntExist: { - statusCode: 403, - response: createExpectLegacyForbidden(AUTHENTICATION.KIBANA_LEGACY_DASHBOARD_ONLY_USER.USERNAME), - }, - } - }); - - updateTest(`kibana dual-privileges user`, { - auth: { - username: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_USER.USERNAME, - password: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_USER.PASSWORD, - }, - tests: { - exists: { - statusCode: 200, - response: expectResults, - }, - doesntExist: { - statusCode: 404, - response: expectNotFound, - }, - } - }); - - updateTest(`kibana dual-privileges dashboard only user`, { - auth: { - username: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER.USERNAME, - password: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER.PASSWORD, - }, - tests: { - exists: { - statusCode: 403, - response: expectRbacForbidden, - }, - doesntExist: { - statusCode: 403, - response: expectRbacForbidden, - }, - } - }); - - updateTest(`kibana rbac user`, { - auth: { - username: AUTHENTICATION.KIBANA_RBAC_USER.USERNAME, - password: AUTHENTICATION.KIBANA_RBAC_USER.PASSWORD, - }, - tests: { - exists: { - statusCode: 200, - response: expectResults, - }, - doesntExist: { - statusCode: 404, - response: expectNotFound, - }, - } - }); - - updateTest(`kibana rbac dashboard only user`, { - auth: { - username: AUTHENTICATION.KIBANA_RBAC_DASHBOARD_ONLY_USER.USERNAME, - password: AUTHENTICATION.KIBANA_RBAC_DASHBOARD_ONLY_USER.PASSWORD, - }, - tests: { - exists: { - statusCode: 403, - response: expectRbacForbidden, - }, - doesntExist: { - statusCode: 403, - response: expectRbacForbidden, - }, - } - }); - - }); -} diff --git a/x-pack/test/rbac_api_integration/config.js b/x-pack/test/rbac_api_integration/config.js deleted file mode 100644 index 3ea5546f09cf10..00000000000000 --- a/x-pack/test/rbac_api_integration/config.js +++ /dev/null @@ -1,63 +0,0 @@ -/* - * 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 path from 'path'; -import { resolveKibanaPath } from '@kbn/plugin-helpers'; -import { EsProvider } from './services/es'; - -export default async function ({ readConfigFile }) { - - const config = { - kibana: { - api: await readConfigFile(resolveKibanaPath('test/api_integration/config.js')), - functional: await readConfigFile(require.resolve('../../../test/functional/config.js')) - }, - xpack: { - api: await readConfigFile(require.resolve('../api_integration/config.js')) - } - }; - - return { - testFiles: [require.resolve('./apis')], - servers: config.xpack.api.get('servers'), - services: { - es: EsProvider, - esSupertestWithoutAuth: config.xpack.api.get('services.esSupertestWithoutAuth'), - supertest: config.kibana.api.get('services.supertest'), - supertestWithoutAuth: config.xpack.api.get('services.supertestWithoutAuth'), - esArchiver: config.kibana.functional.get('services.esArchiver'), - kibanaServer: config.kibana.functional.get('services.kibanaServer'), - }, - junit: { - reportName: 'X-Pack RBAC API Integration Tests', - }, - - // The saved_objects/basic archives are almost an exact replica of the ones in OSS - // with the exception of a bogus "not-a-visualization" type that I added to make sure - // the find filtering without a type specified worked correctly. Once we have the ability - // to specify more granular access to the objects via the Kibana privileges, this should - // no longer be necessary, and it's only required as long as we do read/all privileges. - esArchiver: { - directory: path.join(__dirname, 'fixtures', 'es_archiver') - }, - - esTestCluster: { - ...config.xpack.api.get('esTestCluster'), - serverArgs: [ - ...config.xpack.api.get('esTestCluster.serverArgs'), - ], - }, - - kbnTestServer: { - ...config.xpack.api.get('kbnTestServer'), - serverArgs: [ - ...config.xpack.api.get('kbnTestServer.serverArgs'), - '--optimize.enabled=false', - '--server.xsrf.disableProtection=true', - ], - }, - }; -} diff --git a/x-pack/test/rbac_api_integration/fixtures/es_archiver/saved_objects/basic/data.json.gz b/x-pack/test/rbac_api_integration/fixtures/es_archiver/saved_objects/basic/data.json.gz deleted file mode 100644 index 910382479979df..00000000000000 Binary files a/x-pack/test/rbac_api_integration/fixtures/es_archiver/saved_objects/basic/data.json.gz and /dev/null differ diff --git a/x-pack/test/reporting/configs/chromium_api.js b/x-pack/test/reporting/configs/chromium_api.js index 9ebbfce0ac3ac4..461c6c0df5271b 100644 --- a/x-pack/test/reporting/configs/chromium_api.js +++ b/x-pack/test/reporting/configs/chromium_api.js @@ -21,6 +21,7 @@ export default async function ({ readConfigFile }) { serverArgs: [ ...reportingApiConfig.kbnTestServer.serverArgs, `--xpack.reporting.capture.browser.type=chromium`, + `--xpack.spaces.enabled=false`, ], }, }; diff --git a/x-pack/test/saved_object_api_integration/common/config.ts b/x-pack/test/saved_object_api_integration/common/config.ts new file mode 100644 index 00000000000000..de1eb29ac63aba --- /dev/null +++ b/x-pack/test/saved_object_api_integration/common/config.ts @@ -0,0 +1,73 @@ +/* + * 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. + */ + +// @ts-ignore +import { resolveKibanaPath } from '@kbn/plugin-helpers'; +import path from 'path'; +import { TestInvoker } from './lib/types'; +// @ts-ignore +import { EsProvider } from './services/es'; + +interface CreateTestConfigOptions { + license: string; + disabledPlugins?: string[]; +} + +export function createTestConfig(name: string, options: CreateTestConfigOptions) { + const { license = 'trial', disabledPlugins = [] } = options; + + return async ({ readConfigFile }: TestInvoker) => { + const config = { + kibana: { + api: await readConfigFile(resolveKibanaPath('test/api_integration/config.js')), + functional: await readConfigFile(require.resolve('../../../../test/functional/config.js')), + }, + xpack: { + api: await readConfigFile(require.resolve('../../api_integration/config.js')), + }, + }; + + return { + testFiles: [require.resolve(`../${name}/apis/`)], + servers: config.xpack.api.get('servers'), + services: { + es: EsProvider, + esSupertestWithoutAuth: config.xpack.api.get('services.esSupertestWithoutAuth'), + supertest: config.kibana.api.get('services.supertest'), + supertestWithoutAuth: config.xpack.api.get('services.supertestWithoutAuth'), + esArchiver: config.kibana.functional.get('services.esArchiver'), + kibanaServer: config.kibana.functional.get('services.kibanaServer'), + }, + junit: { + reportName: 'X-Pack Saved Object API Integration Tests -- ' + name, + }, + + esArchiver: { + directory: path.join(__dirname, 'fixtures', 'es_archiver'), + }, + + esTestCluster: { + ...config.xpack.api.get('esTestCluster'), + license, + serverArgs: [ + `xpack.license.self_generated.type=${license}`, + `xpack.security.enabled=${!disabledPlugins.includes('security') && license === 'trial'}`, + ], + }, + + kbnTestServer: { + ...config.xpack.api.get('kbnTestServer'), + serverArgs: [ + ...config.xpack.api.get('kbnTestServer.serverArgs'), + '--optimize.enabled=false', + '--server.xsrf.disableProtection=true', + `--plugin-path=${path.join(__dirname, 'fixtures', 'namespace_agnostic_type_plugin')}`, + ...disabledPlugins.map(key => `--xpack.${key}.enabled=false`), + ], + }, + }; + }; +} diff --git a/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/data.json b/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/data.json new file mode 100644 index 00000000000000..5da6fb43ff1d45 --- /dev/null +++ b/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/data.json @@ -0,0 +1,349 @@ +{ + "type": "doc", + "value": { + "index": ".kibana", + "type": "doc", + "id": "space:default", + "source": { + "type": "space", + "updated_at": "2017-09-21T18:49:16.270Z", + "space": { + "name": "Default Space", + "description": "This is the default space", + "_reserved": true + } + } + } +} + +{ + "type": "doc", + "value": { + "index": ".kibana", + "type": "doc", + "id": "space:space_1", + "source": { + "type": "space", + "updated_at": "2017-09-21T18:49:16.270Z", + "space": { + "name": "Space 1", + "description": "This is the first test space" + } + } + } +} + +{ + "type": "doc", + "value": { + "index": ".kibana", + "type": "doc", + "id": "space:space_2", + "source": { + "type": "space", + "updated_at": "2017-09-21T18:49:16.270Z", + "space": { + "name": "Space 2", + "description": "This is the second test space" + } + } + } +} + +{ + "type": "doc", + "value": { + "index": ".kibana", + "type": "doc", + "id": "index-pattern:91200a00-9efd-11e7-acb3-3dab96693fab", + "source": { + "type": "index-pattern", + "updated_at": "2017-09-21T18:49:16.270Z", + "index-pattern": { + "title": "logstash-*", + "timeFieldName": "@timestamp", + "fields": "[{\"name\":\"@message\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"@message.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"@tags\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"@tags.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"@timestamp\",\"type\":\"date\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"_id\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"_index\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"_score\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"_source\",\"type\":\"_source\",\"count\":0,\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"_type\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"agent\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"agent.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"bytes\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"clientip\",\"type\":\"ip\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"extension\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"extension.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"geo.coordinates\",\"type\":\"geo_point\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"geo.dest\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"geo.src\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"geo.srcdest\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"headings\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"headings.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"host\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"host.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"id\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"index\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"index.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"ip\",\"type\":\"ip\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"links\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"links.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"machine.os\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"machine.os.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"machine.ram\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"memory\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"meta.char\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"meta.related\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"meta.user.firstname\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"meta.user.lastname\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"phpmemory\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"referer\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.article:modified_time\",\"type\":\"date\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.article:published_time\",\"type\":\"date\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.article:section\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.article:section.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.article:tag\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.article:tag.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:description\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:description.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:image\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:image.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:image:height\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:image:height.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:image:width\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:image:width.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:site_name\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:site_name.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:title\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:title.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:type\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:type.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:url\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:url.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.twitter:card\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.twitter:card.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.twitter:description\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.twitter:description.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.twitter:image\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.twitter:image.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.twitter:site\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.twitter:site.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.twitter:title\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.twitter:title.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.url\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.url.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"request\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"request.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"response\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"response.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"spaces\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"spaces.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"type\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"url\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"url.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"utc_time\",\"type\":\"date\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"xss\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"xss.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true}]" + } + } + } +} + +{ + "type": "doc", + "value": { + "index": ".kibana", + "type": "doc", + "id": "config:7.0.0-alpha1", + "source": { + "type": "config", + "updated_at": "2017-09-21T18:49:16.302Z", + "config": { + "buildNum": 8467, + "defaultIndex": "91200a00-9efd-11e7-acb3-3dab96693fab" + } + } + } +} + +{ + "type": "doc", + "value": { + "index": ".kibana", + "type": "doc", + "id": "visualization:dd7caf20-9efd-11e7-acb3-3dab96693fab", + "source": { + "type": "visualization", + "updated_at": "2017-09-21T18:51:23.794Z", + "visualization": { + "title": "Count of requests", + "visState": "{\"title\":\"Count of requests\",\"type\":\"area\",\"params\":{\"type\":\"area\",\"grid\":{\"categoryLines\":false,\"style\":{\"color\":\"#eee\"}},\"categoryAxes\":[{\"id\":\"CategoryAxis-1\",\"type\":\"category\",\"position\":\"bottom\",\"show\":true,\"style\":{},\"scale\":{\"type\":\"linear\"},\"labels\":{\"show\":true,\"truncate\":100},\"title\":{}}],\"valueAxes\":[{\"id\":\"ValueAxis-1\",\"name\":\"LeftAxis-1\",\"type\":\"value\",\"position\":\"left\",\"show\":true,\"style\":{},\"scale\":{\"type\":\"linear\",\"mode\":\"normal\"},\"labels\":{\"show\":true,\"rotate\":0,\"filter\":false,\"truncate\":100},\"title\":{\"text\":\"Count\"}}],\"seriesParams\":[{\"show\":\"true\",\"type\":\"area\",\"mode\":\"stacked\",\"data\":{\"label\":\"Count\",\"id\":\"1\"},\"drawLinesBetweenPoints\":true,\"showCircles\":true,\"interpolate\":\"linear\",\"valueAxis\":\"ValueAxis-1\"}],\"addTooltip\":true,\"addLegend\":true,\"legendPosition\":\"right\",\"times\":[],\"addTimeMarker\":false},\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"count\",\"schema\":\"metric\",\"params\":{}},{\"id\":\"2\",\"enabled\":true,\"type\":\"date_histogram\",\"schema\":\"segment\",\"params\":{\"field\":\"@timestamp\",\"interval\":\"auto\",\"customInterval\":\"2h\",\"min_doc_count\":1,\"extended_bounds\":{}}}]}", + "uiStateJSON": "{\"spy\":{\"mode\":{\"name\":null,\"fill\":false}}}", + "description": "", + "version": 1, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"index\":\"91200a00-9efd-11e7-acb3-3dab96693fab\",\"filter\":[],\"query\":{\"query\":\"\",\"language\":\"lucene\"}}" + } + } + } + } +} + +{ + "type": "doc", + "value": { + "index": ".kibana", + "type": "doc", + "id": "dashboard:be3733a0-9efe-11e7-acb3-3dab96693fab", + "source": { + "type": "dashboard", + "updated_at": "2017-09-21T18:57:40.826Z", + "dashboard": { + "title": "Requests", + "hits": 0, + "description": "", + "panelsJSON": "[{\"size_x\":6,\"size_y\":3,\"panelIndex\":1,\"type\":\"visualization\",\"id\":\"dd7caf20-9efd-11e7-acb3-3dab96693fab\",\"col\":1,\"row\":1}]", + "optionsJSON": "{\"darkTheme\":false}", + "uiStateJSON": "{}", + "version": 1, + "timeRestore": true, + "timeTo": "Fri Sep 18 2015 12:24:38 GMT-0700", + "timeFrom": "Wed Sep 16 2015 22:52:17 GMT-0700", + "refreshInterval": { + "display": "Off", + "pause": false, + "value": 0 + }, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"filter\":[],\"highlightAll\":true,\"version\":true}" + } + } + } + } +} + +{ + "type": "doc", + "value": { + "index": ".kibana", + "type": "doc", + "id": "space_1:index-pattern:space_1-91200a00-9efd-11e7-acb3-3dab96693fab", + "source": { + "type": "index-pattern", + "updated_at": "2017-09-21T18:49:16.270Z", + "namespace": "space_1", + "index-pattern": { + "title": "logstash-*", + "timeFieldName": "@timestamp", + "fields": "[{\"name\":\"@message\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"@message.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"@tags\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"@tags.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"@timestamp\",\"type\":\"date\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"_id\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"_index\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"_score\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"_source\",\"type\":\"_source\",\"count\":0,\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"_type\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"agent\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"agent.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"bytes\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"clientip\",\"type\":\"ip\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"extension\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"extension.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"geo.coordinates\",\"type\":\"geo_point\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"geo.dest\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"geo.src\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"geo.srcdest\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"headings\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"headings.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"host\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"host.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"id\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"index\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"index.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"ip\",\"type\":\"ip\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"links\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"links.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"machine.os\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"machine.os.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"machine.ram\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"memory\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"meta.char\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"meta.related\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"meta.user.firstname\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"meta.user.lastname\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"phpmemory\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"referer\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.article:modified_time\",\"type\":\"date\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.article:published_time\",\"type\":\"date\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.article:section\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.article:section.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.article:tag\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.article:tag.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:description\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:description.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:image\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:image.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:image:height\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:image:height.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:image:width\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:image:width.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:site_name\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:site_name.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:title\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:title.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:type\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:type.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:url\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:url.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.twitter:card\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.twitter:card.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.twitter:description\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.twitter:description.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.twitter:image\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.twitter:image.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.twitter:site\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.twitter:site.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.twitter:title\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.twitter:title.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.url\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.url.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"request\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"request.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"response\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"response.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"spaces\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"spaces.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"type\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"url\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"url.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"utc_time\",\"type\":\"date\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"xss\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"xss.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true}]" + } + } + } +} + +{ + "type": "doc", + "value": { + "index": ".kibana", + "type": "doc", + "id": "space_1:config:7.0.0-alpha1", + "source": { + "type": "config", + "updated_at": "2017-09-21T18:49:16.302Z", + "namespace": "space_1", + "config": { + "buildNum": 8467, + "defaultIndex": "91200a00-9efd-11e7-acb3-3dab96693fab" + } + } + } +} + +{ + "type": "doc", + "value": { + "index": ".kibana", + "type": "doc", + "id": "space_1:visualization:space_1-dd7caf20-9efd-11e7-acb3-3dab96693fab", + "source": { + "type": "visualization", + "updated_at": "2017-09-21T18:51:23.794Z", + "namespace": "space_1", + "visualization": { + "title": "Count of requests", + "visState": "{\"title\":\"Count of requests\",\"type\":\"area\",\"params\":{\"type\":\"area\",\"grid\":{\"categoryLines\":false,\"style\":{\"color\":\"#eee\"}},\"categoryAxes\":[{\"id\":\"CategoryAxis-1\",\"type\":\"category\",\"position\":\"bottom\",\"show\":true,\"style\":{},\"scale\":{\"type\":\"linear\"},\"labels\":{\"show\":true,\"truncate\":100},\"title\":{}}],\"valueAxes\":[{\"id\":\"ValueAxis-1\",\"name\":\"LeftAxis-1\",\"type\":\"value\",\"position\":\"left\",\"show\":true,\"style\":{},\"scale\":{\"type\":\"linear\",\"mode\":\"normal\"},\"labels\":{\"show\":true,\"rotate\":0,\"filter\":false,\"truncate\":100},\"title\":{\"text\":\"Count\"}}],\"seriesParams\":[{\"show\":\"true\",\"type\":\"area\",\"mode\":\"stacked\",\"data\":{\"label\":\"Count\",\"id\":\"1\"},\"drawLinesBetweenPoints\":true,\"showCircles\":true,\"interpolate\":\"linear\",\"valueAxis\":\"ValueAxis-1\"}],\"addTooltip\":true,\"addLegend\":true,\"legendPosition\":\"right\",\"times\":[],\"addTimeMarker\":false},\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"count\",\"schema\":\"metric\",\"params\":{}},{\"id\":\"2\",\"enabled\":true,\"type\":\"date_histogram\",\"schema\":\"segment\",\"params\":{\"field\":\"@timestamp\",\"interval\":\"auto\",\"customInterval\":\"2h\",\"min_doc_count\":1,\"extended_bounds\":{}}}]}", + "uiStateJSON": "{\"spy\":{\"mode\":{\"name\":null,\"fill\":false}}}", + "description": "", + "version": 1, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"index\":\"91200a00-9efd-11e7-acb3-3dab96693fab\",\"filter\":[],\"query\":{\"query\":\"\",\"language\":\"lucene\"}}" + } + } + } + } +} + +{ + "type": "doc", + "value": { + "index": ".kibana", + "type": "doc", + "id": "space_1:dashboard:space_1-be3733a0-9efe-11e7-acb3-3dab96693fab", + "source": { + "type": "dashboard", + "updated_at": "2017-09-21T18:57:40.826Z", + "namespace": "space_1", + "dashboard": { + "title": "Requests", + "hits": 0, + "description": "", + "panelsJSON": "[{\"size_x\":6,\"size_y\":3,\"panelIndex\":1,\"type\":\"visualization\",\"id\":\"dd7caf20-9efd-11e7-acb3-3dab96693fab\",\"col\":1,\"row\":1}]", + "optionsJSON": "{\"darkTheme\":false}", + "uiStateJSON": "{}", + "version": 1, + "timeRestore": true, + "timeTo": "Fri Sep 18 2015 12:24:38 GMT-0700", + "timeFrom": "Wed Sep 16 2015 22:52:17 GMT-0700", + "refreshInterval": { + "display": "Off", + "pause": false, + "value": 0 + }, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"filter\":[],\"highlightAll\":true,\"version\":true}" + } + } + } + } +} + + +{ + "type": "doc", + "value": { + "index": ".kibana", + "type": "doc", + "id": "space_2:index-pattern:space_2-91200a00-9efd-11e7-acb3-3dab96693fab", + "source": { + "type": "index-pattern", + "updated_at": "2017-09-21T18:49:16.270Z", + "namespace": "space_2", + "index-pattern": { + "title": "logstash-*", + "timeFieldName": "@timestamp", + "fields": "[{\"name\":\"@message\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"@message.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"@tags\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"@tags.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"@timestamp\",\"type\":\"date\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"_id\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"_index\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"_score\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"_source\",\"type\":\"_source\",\"count\":0,\"scripted\":false,\"searchable\":false,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"_type\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":false},{\"name\":\"agent\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"agent.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"bytes\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"clientip\",\"type\":\"ip\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"extension\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"extension.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"geo.coordinates\",\"type\":\"geo_point\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"geo.dest\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"geo.src\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"geo.srcdest\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"headings\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"headings.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"host\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"host.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"id\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"index\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"index.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"ip\",\"type\":\"ip\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"links\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"links.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"machine.os\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"machine.os.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"machine.ram\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"memory\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"meta.char\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"meta.related\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"meta.user.firstname\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"meta.user.lastname\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"phpmemory\",\"type\":\"number\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"referer\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.article:modified_time\",\"type\":\"date\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.article:published_time\",\"type\":\"date\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.article:section\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.article:section.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.article:tag\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.article:tag.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:description\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:description.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:image\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:image.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:image:height\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:image:height.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:image:width\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:image:width.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:site_name\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:site_name.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:title\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:title.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:type\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:type.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.og:url\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.og:url.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.twitter:card\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.twitter:card.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.twitter:description\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.twitter:description.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.twitter:image\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.twitter:image.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.twitter:site\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.twitter:site.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.twitter:title\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.twitter:title.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"relatedContent.url\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"relatedContent.url.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"request\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"request.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"response\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"response.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"spaces\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"spaces.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"type\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"url\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"url.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"utc_time\",\"type\":\"date\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true},{\"name\":\"xss\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":false,\"readFromDocValues\":false},{\"name\":\"xss.raw\",\"type\":\"string\",\"count\":0,\"scripted\":false,\"searchable\":true,\"aggregatable\":true,\"readFromDocValues\":true}]" + } + } + } +} + +{ + "type": "doc", + "value": { + "index": ".kibana", + "type": "doc", + "id": "space_2:config:7.0.0-alpha1", + "source": { + "type": "config", + "updated_at": "2017-09-21T18:49:16.302Z", + "namespace": "space_2", + "config": { + "buildNum": 8467, + "defaultIndex": "91200a00-9efd-11e7-acb3-3dab96693fab" + } + } + } +} + +{ + "type": "doc", + "value": { + "index": ".kibana", + "type": "doc", + "id": "space_2:visualization:space_2-dd7caf20-9efd-11e7-acb3-3dab96693fab", + "source": { + "type": "visualization", + "updated_at": "2017-09-21T18:51:23.794Z", + "namespace": "space_2", + "visualization": { + "title": "Count of requests", + "visState": "{\"title\":\"Count of requests\",\"type\":\"area\",\"params\":{\"type\":\"area\",\"grid\":{\"categoryLines\":false,\"style\":{\"color\":\"#eee\"}},\"categoryAxes\":[{\"id\":\"CategoryAxis-1\",\"type\":\"category\",\"position\":\"bottom\",\"show\":true,\"style\":{},\"scale\":{\"type\":\"linear\"},\"labels\":{\"show\":true,\"truncate\":100},\"title\":{}}],\"valueAxes\":[{\"id\":\"ValueAxis-1\",\"name\":\"LeftAxis-1\",\"type\":\"value\",\"position\":\"left\",\"show\":true,\"style\":{},\"scale\":{\"type\":\"linear\",\"mode\":\"normal\"},\"labels\":{\"show\":true,\"rotate\":0,\"filter\":false,\"truncate\":100},\"title\":{\"text\":\"Count\"}}],\"seriesParams\":[{\"show\":\"true\",\"type\":\"area\",\"mode\":\"stacked\",\"data\":{\"label\":\"Count\",\"id\":\"1\"},\"drawLinesBetweenPoints\":true,\"showCircles\":true,\"interpolate\":\"linear\",\"valueAxis\":\"ValueAxis-1\"}],\"addTooltip\":true,\"addLegend\":true,\"legendPosition\":\"right\",\"times\":[],\"addTimeMarker\":false},\"aggs\":[{\"id\":\"1\",\"enabled\":true,\"type\":\"count\",\"schema\":\"metric\",\"params\":{}},{\"id\":\"2\",\"enabled\":true,\"type\":\"date_histogram\",\"schema\":\"segment\",\"params\":{\"field\":\"@timestamp\",\"interval\":\"auto\",\"customInterval\":\"2h\",\"min_doc_count\":1,\"extended_bounds\":{}}}]}", + "uiStateJSON": "{\"spy\":{\"mode\":{\"name\":null,\"fill\":false}}}", + "description": "", + "version": 1, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"index\":\"91200a00-9efd-11e7-acb3-3dab96693fab\",\"filter\":[],\"query\":{\"query\":\"\",\"language\":\"lucene\"}}" + } + } + } + } +} + +{ + "type": "doc", + "value": { + "index": ".kibana", + "type": "doc", + "id": "space_2:dashboard:space_2-be3733a0-9efe-11e7-acb3-3dab96693fab", + "source": { + "type": "dashboard", + "updated_at": "2017-09-21T18:57:40.826Z", + "namespace": "space_2", + "dashboard": { + "title": "Requests", + "hits": 0, + "description": "", + "panelsJSON": "[{\"size_x\":6,\"size_y\":3,\"panelIndex\":1,\"type\":\"visualization\",\"id\":\"dd7caf20-9efd-11e7-acb3-3dab96693fab\",\"col\":1,\"row\":1}]", + "optionsJSON": "{\"darkTheme\":false}", + "uiStateJSON": "{}", + "version": 1, + "timeRestore": true, + "timeTo": "Fri Sep 18 2015 12:24:38 GMT-0700", + "timeFrom": "Wed Sep 16 2015 22:52:17 GMT-0700", + "refreshInterval": { + "display": "Off", + "pause": false, + "value": 0 + }, + "kibanaSavedObjectMeta": { + "searchSourceJSON": "{\"query\":{\"query\":\"\",\"language\":\"lucene\"},\"filter\":[],\"highlightAll\":true,\"version\":true}" + } + } + } + } +} + +{ + "type": "doc", + "value": { + "index": ".kibana", + "type": "doc", + "id": "globaltype:8121a00-8efd-21e7-1cb3-34ab966434445", + "source": { + "type": "globaltype", + "updated_at": "2017-09-21T18:59:16.270Z", + "globaltype": { + "name": "My favorite global object" + } + } + } +} diff --git a/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/mappings.json b/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/mappings.json new file mode 100644 index 00000000000000..6cd530559c8f2d --- /dev/null +++ b/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/mappings.json @@ -0,0 +1,324 @@ +{ + "type": "index", + "value": { + "index": ".kibana", + "settings": { + "index": { + "number_of_shards": "1", + "auto_expand_replicas": "0-1", + "number_of_replicas": "0" + } + }, + "mappings": { + "doc": { + "dynamic": "strict", + "properties": { + "config": { + "dynamic": "true", + "properties": { + "buildNum": { + "type": "keyword" + }, + "defaultIndex": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + } + } + }, + "namespace": { + "type": "keyword" + }, + "space": { + "properties": { + "name": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 2048 + } + } + }, + "description": { + "type": "text" + }, + "initials": { + "type": "keyword" + }, + "color": { + "type": "keyword" + }, + "_reserved": { + "type": "boolean" + } + } + }, + "dashboard": { + "properties": { + "description": { + "type": "text" + }, + "hits": { + "type": "integer" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "optionsJSON": { + "type": "text" + }, + "panelsJSON": { + "type": "text" + }, + "refreshInterval": { + "properties": { + "display": { + "type": "keyword" + }, + "pause": { + "type": "boolean" + }, + "section": { + "type": "integer" + }, + "value": { + "type": "integer" + } + } + }, + "timeFrom": { + "type": "keyword" + }, + "timeRestore": { + "type": "boolean" + }, + "timeTo": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "uiStateJSON": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "graph-workspace": { + "properties": { + "description": { + "type": "text" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "numLinks": { + "type": "integer" + }, + "numVertices": { + "type": "integer" + }, + "title": { + "type": "text" + }, + "version": { + "type": "integer" + }, + "wsState": { + "type": "text" + } + } + }, + "index-pattern": { + "properties": { + "fieldFormatMap": { + "type": "text" + }, + "fields": { + "type": "text" + }, + "intervalName": { + "type": "keyword" + }, + "notExpandable": { + "type": "boolean" + }, + "sourceFilters": { + "type": "text" + }, + "timeFieldName": { + "type": "keyword" + }, + "title": { + "type": "text" + } + } + }, + "search": { + "properties": { + "columns": { + "type": "keyword" + }, + "description": { + "type": "text" + }, + "hits": { + "type": "integer" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "sort": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "server": { + "properties": { + "uuid": { + "type": "keyword" + } + } + }, + "timelion-sheet": { + "properties": { + "description": { + "type": "text" + }, + "hits": { + "type": "integer" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "timelion_chart_height": { + "type": "integer" + }, + "timelion_columns": { + "type": "integer" + }, + "timelion_interval": { + "type": "keyword" + }, + "timelion_other_interval": { + "type": "keyword" + }, + "timelion_rows": { + "type": "integer" + }, + "timelion_sheet": { + "type": "text" + }, + "title": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "type": { + "type": "keyword" + }, + "updated_at": { + "type": "date" + }, + "url": { + "properties": { + "accessCount": { + "type": "long" + }, + "accessDate": { + "type": "date" + }, + "createDate": { + "type": "date" + }, + "url": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 2048 + } + } + } + } + }, + "visualization": { + "properties": { + "description": { + "type": "text" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "savedSearchId": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "uiStateJSON": { + "type": "text" + }, + "version": { + "type": "integer" + }, + "visState": { + "type": "text" + } + } + }, + "globaltype": { + "properties": { + "name": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 2048 + } + } + } + } + } + } + } + }, + "aliases": {} + } +} diff --git a/x-pack/test/saved_object_api_integration/common/fixtures/namespace_agnostic_type_plugin/index.js b/x-pack/test/saved_object_api_integration/common/fixtures/namespace_agnostic_type_plugin/index.js new file mode 100644 index 00000000000000..3fdbb6b9a2509f --- /dev/null +++ b/x-pack/test/saved_object_api_integration/common/fixtures/namespace_agnostic_type_plugin/index.js @@ -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. + */ + +import mappings from './mappings.json'; + +export default function (kibana) { + return new kibana.Plugin({ + require: [], + name: 'namespace_agnostic_type_plugin', + uiExports: { + savedObjectSchemas: { + globaltype: { + isNamespaceAgnostic: true + } + }, + mappings, + }, + + config() {}, + }); +} diff --git a/x-pack/test/saved_object_api_integration/common/fixtures/namespace_agnostic_type_plugin/mappings.json b/x-pack/test/saved_object_api_integration/common/fixtures/namespace_agnostic_type_plugin/mappings.json new file mode 100644 index 00000000000000..b30a2c3877b885 --- /dev/null +++ b/x-pack/test/saved_object_api_integration/common/fixtures/namespace_agnostic_type_plugin/mappings.json @@ -0,0 +1,15 @@ +{ + "globaltype": { + "properties": { + "name": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 2048 + } + } + } + } + } +} diff --git a/x-pack/test/saved_object_api_integration/common/fixtures/namespace_agnostic_type_plugin/package.json b/x-pack/test/saved_object_api_integration/common/fixtures/namespace_agnostic_type_plugin/package.json new file mode 100644 index 00000000000000..1a0afbc6bfcb30 --- /dev/null +++ b/x-pack/test/saved_object_api_integration/common/fixtures/namespace_agnostic_type_plugin/package.json @@ -0,0 +1,7 @@ +{ + "name": "namespace_agnostic_type_plugin", + "version": "0.0.0", + "kibana": { + "version": "kibana" + } +} diff --git a/x-pack/test/saved_object_api_integration/common/lib/authentication.ts b/x-pack/test/saved_object_api_integration/common/lib/authentication.ts new file mode 100644 index 00000000000000..19210e3818b67b --- /dev/null +++ b/x-pack/test/saved_object_api_integration/common/lib/authentication.ts @@ -0,0 +1,56 @@ +/* + * 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 const AUTHENTICATION = { + NOT_A_KIBANA_USER: { + username: 'not_a_kibana_user', + password: 'password', + }, + SUPERUSER: { + username: 'elastic', + password: 'changeme', + }, + KIBANA_LEGACY_USER: { + username: 'a_kibana_legacy_user', + password: 'password', + }, + KIBANA_LEGACY_DASHBOARD_ONLY_USER: { + username: 'a_kibana_legacy_dashboard_only_user', + password: 'password', + }, + KIBANA_DUAL_PRIVILEGES_USER: { + username: 'a_kibana_dual_privileges_user', + password: 'password', + }, + KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER: { + username: 'a_kibana_dual_privileges_dashboard_only_user', + password: 'password', + }, + KIBANA_RBAC_USER: { + username: 'a_kibana_rbac_user', + password: 'password', + }, + KIBANA_RBAC_DASHBOARD_ONLY_USER: { + username: 'a_kibana_rbac_dashboard_only_user', + password: 'password', + }, + KIBANA_RBAC_DEFAULT_SPACE_ALL_USER: { + username: 'a_kibana_rbac_default_space_all_user', + password: 'password', + }, + KIBANA_RBAC_DEFAULT_SPACE_READ_USER: { + username: 'a_kibana_rbac_default_space_read_user', + password: 'password', + }, + KIBANA_RBAC_SPACE_1_ALL_USER: { + username: 'a_kibana_rbac_space_1_all_user', + password: 'password', + }, + KIBANA_RBAC_SPACE_1_READ_USER: { + username: 'a_kibana_rbac_space_1_read_user', + password: 'password', + }, +}; diff --git a/x-pack/test/saved_object_api_integration/common/lib/create_users_and_roles.ts b/x-pack/test/saved_object_api_integration/common/lib/create_users_and_roles.ts new file mode 100644 index 00000000000000..b5e1b97fdf0a63 --- /dev/null +++ b/x-pack/test/saved_object_api_integration/common/lib/create_users_and_roles.ts @@ -0,0 +1,213 @@ +/* + * 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 { SuperTest } from 'supertest'; +import { AUTHENTICATION } from './authentication'; + +export const createUsersAndRoles = async (es: any, supertest: SuperTest) => { + await supertest.put('/api/security/role/kibana_legacy_user').send({ + elasticsearch: { + indices: [ + { + names: ['.kibana'], + privileges: ['manage', 'read', 'index', 'delete'], + }, + ], + }, + }); + + await supertest.put('/api/security/role/kibana_legacy_dashboard_only_user').send({ + elasticsearch: { + indices: [ + { + names: ['.kibana'], + privileges: ['read', 'view_index_metadata'], + }, + ], + }, + }); + + await supertest.put('/api/security/role/kibana_dual_privileges_user').send({ + elasticsearch: { + indices: [ + { + names: ['.kibana'], + privileges: ['manage', 'read', 'index', 'delete'], + }, + ], + }, + kibana: { + global: ['all'], + }, + }); + + await supertest.put('/api/security/role/kibana_dual_privileges_dashboard_only_user').send({ + elasticsearch: { + indices: [ + { + names: ['.kibana'], + privileges: ['read', 'view_index_metadata'], + }, + ], + }, + kibana: { + global: ['read'], + }, + }); + + await supertest.put('/api/security/role/kibana_rbac_user').send({ + kibana: { + global: ['all'], + }, + }); + + await supertest.put('/api/security/role/kibana_rbac_dashboard_only_user').send({ + kibana: { + global: ['read'], + }, + }); + + await supertest.put('/api/security/role/kibana_rbac_default_space_all_user').send({ + kibana: { + space: { + default: ['all'], + }, + }, + }); + + await supertest.put('/api/security/role/kibana_rbac_default_space_read_user').send({ + kibana: { + space: { + default: ['read'], + }, + }, + }); + + await supertest.put('/api/security/role/kibana_rbac_space_1_all_user').send({ + kibana: { + space: { + space_1: ['all'], + }, + }, + }); + + await supertest.put('/api/security/role/kibana_rbac_space_1_read_user').send({ + kibana: { + space: { + space_1: ['read'], + }, + }, + }); + + await es.shield.putUser({ + username: AUTHENTICATION.NOT_A_KIBANA_USER.username, + body: { + password: AUTHENTICATION.NOT_A_KIBANA_USER.password, + roles: [], + full_name: 'not a kibana user', + email: 'not_a_kibana_user@elastic.co', + }, + }); + + await es.shield.putUser({ + username: AUTHENTICATION.KIBANA_LEGACY_USER.username, + body: { + password: AUTHENTICATION.KIBANA_LEGACY_USER.password, + roles: ['kibana_legacy_user'], + full_name: 'a kibana legacy user', + email: 'a_kibana_legacy_user@elastic.co', + }, + }); + + await es.shield.putUser({ + username: AUTHENTICATION.KIBANA_LEGACY_DASHBOARD_ONLY_USER.username, + body: { + password: AUTHENTICATION.KIBANA_LEGACY_DASHBOARD_ONLY_USER.password, + roles: ['kibana_legacy_dashboard_only_user'], + full_name: 'a kibana legacy dashboard only user', + email: 'a_kibana_legacy_dashboard_only_user@elastic.co', + }, + }); + + await es.shield.putUser({ + username: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_USER.username, + body: { + password: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_USER.password, + roles: ['kibana_dual_privileges_user'], + full_name: 'a kibana dual_privileges user', + email: 'a_kibana_dual_privileges_user@elastic.co', + }, + }); + + await es.shield.putUser({ + username: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER.username, + body: { + password: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER.password, + roles: ['kibana_dual_privileges_dashboard_only_user'], + full_name: 'a kibana dual_privileges dashboard only user', + email: 'a_kibana_dual_privileges_dashboard_only_user@elastic.co', + }, + }); + + await es.shield.putUser({ + username: AUTHENTICATION.KIBANA_RBAC_USER.username, + body: { + password: AUTHENTICATION.KIBANA_RBAC_USER.password, + roles: ['kibana_rbac_user'], + full_name: 'a kibana user', + email: 'a_kibana_rbac_user@elastic.co', + }, + }); + + await es.shield.putUser({ + username: AUTHENTICATION.KIBANA_RBAC_DASHBOARD_ONLY_USER.username, + body: { + password: AUTHENTICATION.KIBANA_RBAC_DASHBOARD_ONLY_USER.password, + roles: ['kibana_rbac_dashboard_only_user'], + full_name: 'a kibana dashboard only user', + email: 'a_kibana_rbac_dashboard_only_user@elastic.co', + }, + }); + + await es.shield.putUser({ + username: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_ALL_USER.username, + body: { + password: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_ALL_USER.password, + roles: ['kibana_rbac_default_space_all_user'], + full_name: 'a kibana default space all user', + email: 'a_kibana_rbac_default_space_all_user@elastic.co', + }, + }); + + await es.shield.putUser({ + username: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_READ_USER.username, + body: { + password: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_READ_USER.password, + roles: ['kibana_rbac_default_space_read_user'], + full_name: 'a kibana default space read-only user', + email: 'a_kibana_rbac_default_space_read_user@elastic.co', + }, + }); + + await es.shield.putUser({ + username: AUTHENTICATION.KIBANA_RBAC_SPACE_1_ALL_USER.username, + body: { + password: AUTHENTICATION.KIBANA_RBAC_SPACE_1_ALL_USER.password, + roles: ['kibana_rbac_space_1_all_user'], + full_name: 'a kibana rbac space 1 all user', + email: 'a_kibana_rbac_space_1_all_user@elastic.co', + }, + }); + + await es.shield.putUser({ + username: AUTHENTICATION.KIBANA_RBAC_SPACE_1_READ_USER.username, + body: { + password: AUTHENTICATION.KIBANA_RBAC_SPACE_1_READ_USER.password, + roles: ['kibana_rbac_space_1_read_user'], + full_name: 'a kibana rbac space 1 read-only user', + email: 'a_kibana_rbac_space_1_readonly_user@elastic.co', + }, + }); +}; diff --git a/x-pack/test/saved_object_api_integration/common/lib/space_test_utils.ts b/x-pack/test/saved_object_api_integration/common/lib/space_test_utils.ts new file mode 100644 index 00000000000000..1619d77761c842 --- /dev/null +++ b/x-pack/test/saved_object_api_integration/common/lib/space_test_utils.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. + */ + +import { DEFAULT_SPACE_ID } from '../../../../plugins/spaces/common/constants'; + +export function getUrlPrefix(spaceId: string) { + return spaceId && spaceId !== DEFAULT_SPACE_ID ? `/s/${spaceId}` : ``; +} + +export function getIdPrefix(spaceId: string) { + return spaceId === DEFAULT_SPACE_ID ? '' : `${spaceId}-`; +} + +export function getExpectedSpaceIdProperty(spaceId: string) { + if (spaceId === DEFAULT_SPACE_ID) { + return {}; + } + return { + spaceId, + }; +} diff --git a/x-pack/test/saved_object_api_integration/common/lib/spaces.ts b/x-pack/test/saved_object_api_integration/common/lib/spaces.ts new file mode 100644 index 00000000000000..a9c552d4ccd789 --- /dev/null +++ b/x-pack/test/saved_object_api_integration/common/lib/spaces.ts @@ -0,0 +1,17 @@ +/* + * 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 const SPACES = { + SPACE_1: { + spaceId: 'space_1', + }, + SPACE_2: { + spaceId: 'space_2', + }, + DEFAULT: { + spaceId: 'default', + }, +}; diff --git a/x-pack/test/saved_object_api_integration/common/lib/types.ts b/x-pack/test/saved_object_api_integration/common/lib/types.ts new file mode 100644 index 00000000000000..fc6d3d8745fb93 --- /dev/null +++ b/x-pack/test/saved_object_api_integration/common/lib/types.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. + */ + +export type DescribeFn = (text: string, fn: () => void) => void; + +export interface TestDefinitionAuthentication { + username?: string; + password?: string; +} + +export type LoadTestFileFn = (path: string) => string; + +export type GetServiceFn = (service: string) => any; + +export type ReadConfigFileFn = (path: string) => any; + +export interface TestInvoker { + getService: GetServiceFn; + loadTestFile: LoadTestFileFn; + readConfigFile: ReadConfigFileFn; +} diff --git a/x-pack/test/saved_object_api_integration/common/services/es.js b/x-pack/test/saved_object_api_integration/common/services/es.js new file mode 100644 index 00000000000000..f5ef3be4b4bde2 --- /dev/null +++ b/x-pack/test/saved_object_api_integration/common/services/es.js @@ -0,0 +1,21 @@ +/* + * 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 { format as formatUrl } from 'url'; + +import elasticsearch from 'elasticsearch'; +import shieldPlugin from '../../../../server/lib/esjs_shield_plugin'; +import { TestInvoker } from '../lib/types'; + +export function EsProvider({ getService }: TestInvoker) { + const config = getService('config'); + + return new elasticsearch.Client({ + host: formatUrl(config.get('servers.elasticsearch')), + requestTimeout: config.get('timeouts.esRequestTimeout'), + plugins: [shieldPlugin], + }); +} diff --git a/x-pack/test/saved_object_api_integration/common/suites/bulk_create.ts b/x-pack/test/saved_object_api_integration/common/suites/bulk_create.ts new file mode 100644 index 00000000000000..cb93afedcb1d94 --- /dev/null +++ b/x-pack/test/saved_object_api_integration/common/suites/bulk_create.ts @@ -0,0 +1,192 @@ +/* + * 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 'expect.js'; +import { SuperTest } from 'supertest'; +import { DEFAULT_SPACE_ID } from '../../../../plugins/spaces/common/constants'; +import { getIdPrefix, getUrlPrefix } from '../lib/space_test_utils'; +import { DescribeFn, TestDefinitionAuthentication } from '../lib/types'; + +interface BulkCreateTest { + statusCode: number; + response: (resp: { [key: string]: any }) => void; +} + +interface BulkCreateCustomTest extends BulkCreateTest { + description: string; + requestBody: { + [key: string]: any; + }; +} + +interface BulkCreateTests { + default: BulkCreateTest; + custom?: BulkCreateCustomTest; +} + +interface BulkCreateTestDefinition { + user?: TestDefinitionAuthentication; + spaceId?: string; + tests: BulkCreateTests; +} + +const createBulkRequests = (spaceId: string) => [ + { + type: 'visualization', + id: `${getIdPrefix(spaceId)}dd7caf20-9efd-11e7-acb3-3dab96693fab`, + attributes: { + title: 'An existing visualization', + }, + }, + { + type: 'dashboard', + id: `${getIdPrefix(spaceId)}a01b2f57-fcfd-4864-b735-09e28f0d815e`, + attributes: { + title: 'A great new dashboard', + }, + }, + { + type: 'globaltype', + id: '05976c65-1145-4858-bbf0-d225cc78a06e', + attributes: { + name: 'A new globaltype object', + }, + }, + { + type: 'globaltype', + id: '8121a00-8efd-21e7-1cb3-34ab966434445', + attributes: { + name: 'An existing globaltype', + }, + }, +]; + +const isGlobalType = (type: string) => type === 'globaltype'; + +export function bulkCreateTestSuiteFactory(es: any, esArchiver: any, supertest: SuperTest) { + const createExpectLegacyForbidden = (username: string) => (resp: { [key: string]: any }) => { + expect(resp.body).to.eql({ + statusCode: 403, + error: 'Forbidden', + // eslint-disable-next-line max-len + message: `action [indices:data/write/bulk] is unauthorized for user [${username}]: [security_exception] action [indices:data/write/bulk] is unauthorized for user [${username}]`, + }); + }; + + const createExpectResults = (spaceId = DEFAULT_SPACE_ID) => async (resp: { + [key: string]: any; + }) => { + expect(resp.body).to.eql({ + saved_objects: [ + { + type: 'visualization', + id: `${getIdPrefix(spaceId)}dd7caf20-9efd-11e7-acb3-3dab96693fab`, + error: { + message: 'version conflict, document already exists', + statusCode: 409, + }, + }, + { + type: 'dashboard', + id: `${getIdPrefix(spaceId)}a01b2f57-fcfd-4864-b735-09e28f0d815e`, + updated_at: resp.body.saved_objects[1].updated_at, + version: 1, + attributes: { + title: 'A great new dashboard', + }, + }, + { + type: 'globaltype', + id: `05976c65-1145-4858-bbf0-d225cc78a06e`, + updated_at: resp.body.saved_objects[2].updated_at, + version: 1, + attributes: { + name: 'A new globaltype object', + }, + }, + { + type: 'globaltype', + id: '8121a00-8efd-21e7-1cb3-34ab966434445', + error: { + message: 'version conflict, document already exists', + statusCode: 409, + }, + }, + ], + }); + + for (const savedObject of createBulkRequests(spaceId)) { + const expectedSpacePrefix = + spaceId === DEFAULT_SPACE_ID || isGlobalType(savedObject.type) ? '' : `${spaceId}:`; + + // query ES directory to ensure namespace was or wasn't specified + const { _source } = await es.get({ + id: `${expectedSpacePrefix}${savedObject.type}:${savedObject.id}`, + type: 'doc', + index: '.kibana', + }); + + const { namespace: actualNamespace } = _source; + + if (spaceId === DEFAULT_SPACE_ID || isGlobalType(savedObject.type)) { + expect(actualNamespace).to.eql(undefined); + } else { + expect(actualNamespace).to.eql(spaceId); + } + } + }; + + const expectRbacForbidden = (resp: { [key: string]: any }) => { + expect(resp.body).to.eql({ + statusCode: 403, + error: 'Forbidden', + message: `Unable to bulk_create dashboard,globaltype,visualization, missing action:saved_objects/dashboard/bulk_create,action:saved_objects/globaltype/bulk_create,action:saved_objects/visualization/bulk_create`, + }); + }; + + const makeBulkCreateTest = (describeFn: DescribeFn) => ( + description: string, + definition: BulkCreateTestDefinition + ) => { + const { user = {}, spaceId = DEFAULT_SPACE_ID, tests } = definition; + + describeFn(description, () => { + before(() => esArchiver.load('saved_objects/spaces')); + after(() => esArchiver.unload('saved_objects/spaces')); + + it(`should return ${tests.default.statusCode}`, async () => { + await supertest + .post(`${getUrlPrefix(spaceId)}/api/saved_objects/_bulk_create`) + .auth(user.username, user.password) + .send(createBulkRequests(spaceId)) + .expect(tests.default.statusCode) + .then(tests.default.response); + }); + + if (tests.custom) { + it(tests.custom!.description, async () => { + await supertest + .post(`${getUrlPrefix(spaceId)}/api/saved_objects/_bulk_create`) + .auth(user.username, user.password) + .send(tests.custom!.requestBody) + .expect(tests.custom!.statusCode) + .then(tests.custom!.response); + }); + } + }); + }; + + const bulkCreateTest = makeBulkCreateTest(describe); + // @ts-ignore + bulkCreateTest.only = makeBulkCreateTest(describe.only); + + return { + bulkCreateTest, + createExpectLegacyForbidden, + createExpectResults, + expectRbacForbidden, + }; +} diff --git a/x-pack/test/saved_object_api_integration/common/suites/bulk_get.ts b/x-pack/test/saved_object_api_integration/common/suites/bulk_get.ts new file mode 100644 index 00000000000000..dc5b7eaf3c75e0 --- /dev/null +++ b/x-pack/test/saved_object_api_integration/common/suites/bulk_get.ts @@ -0,0 +1,165 @@ +/* + * 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 'expect.js'; +import { SuperTest } from 'supertest'; +import { DEFAULT_SPACE_ID } from '../../../../plugins/spaces/common/constants'; +import { getIdPrefix, getUrlPrefix } from '../lib/space_test_utils'; +import { DescribeFn, TestDefinitionAuthentication } from '../lib/types'; + +interface BulkGetTest { + statusCode: number; + response: (resp: { [key: string]: any }) => void; +} + +interface BulkGetTests { + default: BulkGetTest; +} + +interface BulkGetTestDefinition { + user?: TestDefinitionAuthentication; + spaceId?: string; + otherSpaceId?: string; + tests: BulkGetTests; +} + +const createBulkRequests = (spaceId: string) => [ + { + type: 'visualization', + id: `${getIdPrefix(spaceId)}dd7caf20-9efd-11e7-acb3-3dab96693fab`, + }, + { + type: 'dashboard', + id: `${getIdPrefix(spaceId)}does not exist`, + }, + { + type: 'globaltype', + id: '8121a00-8efd-21e7-1cb3-34ab966434445', + }, +]; + +export function bulkGetTestSuiteFactory(esArchiver: any, supertest: SuperTest) { + const createExpectLegacyForbidden = (username: string) => (resp: { [key: string]: any }) => { + expect(resp.body).to.eql({ + statusCode: 403, + error: 'Forbidden', + // eslint-disable-next-line max-len + message: `action [indices:data/read/mget] is unauthorized for user [${username}]: [security_exception] action [indices:data/read/mget] is unauthorized for user [${username}]`, + }); + }; + + const createExpectNotFoundResults = (spaceId: string) => (resp: { [key: string]: any }) => { + expect(resp.body).to.eql({ + saved_objects: [ + { + id: `${getIdPrefix(spaceId)}dd7caf20-9efd-11e7-acb3-3dab96693fab`, + type: 'visualization', + error: { + statusCode: 404, + message: 'Not found', + }, + }, + { + id: `${getIdPrefix(spaceId)}does not exist`, + type: 'dashboard', + error: { + statusCode: 404, + message: 'Not found', + }, + }, + { + id: `8121a00-8efd-21e7-1cb3-34ab966434445`, + type: 'globaltype', + updated_at: '2017-09-21T18:59:16.270Z', + version: resp.body.saved_objects[2].version, + attributes: { + name: 'My favorite global object', + }, + }, + ], + }); + }; + + const expectRbacForbidden = (resp: { [key: string]: any }) => { + expect(resp.body).to.eql({ + statusCode: 403, + error: 'Forbidden', + message: `Unable to bulk_get dashboard,globaltype,visualization, missing action:saved_objects/dashboard/bulk_get,action:saved_objects/globaltype/bulk_get,action:saved_objects/visualization/bulk_get`, + }); + }; + + const createExpectResults = (spaceId = DEFAULT_SPACE_ID) => (resp: { [key: string]: any }) => { + expect(resp.body).to.eql({ + saved_objects: [ + { + id: `${getIdPrefix(spaceId)}dd7caf20-9efd-11e7-acb3-3dab96693fab`, + type: 'visualization', + updated_at: '2017-09-21T18:51:23.794Z', + version: resp.body.saved_objects[0].version, + attributes: { + title: 'Count of requests', + description: '', + version: 1, + // cheat for some of the more complex attributes + visState: resp.body.saved_objects[0].attributes.visState, + uiStateJSON: resp.body.saved_objects[0].attributes.uiStateJSON, + kibanaSavedObjectMeta: resp.body.saved_objects[0].attributes.kibanaSavedObjectMeta, + }, + }, + { + id: `${getIdPrefix(spaceId)}does not exist`, + type: 'dashboard', + error: { + statusCode: 404, + message: 'Not found', + }, + }, + { + id: `8121a00-8efd-21e7-1cb3-34ab966434445`, + type: 'globaltype', + updated_at: '2017-09-21T18:59:16.270Z', + version: resp.body.saved_objects[2].version, + attributes: { + name: 'My favorite global object', + }, + }, + ], + }); + }; + + const makeBulkGetTest = (describeFn: DescribeFn) => ( + description: string, + definition: BulkGetTestDefinition + ) => { + const { user = {}, spaceId = DEFAULT_SPACE_ID, otherSpaceId, tests } = definition; + + describeFn(description, () => { + before(() => esArchiver.load('saved_objects/spaces')); + after(() => esArchiver.unload('saved_objects/spaces')); + + it(`should return ${tests.default.statusCode}`, async () => { + await supertest + .post(`${getUrlPrefix(spaceId)}/api/saved_objects/_bulk_get`) + .auth(user.username, user.password) + .send(createBulkRequests(otherSpaceId || spaceId)) + .expect(tests.default.statusCode) + .then(tests.default.response); + }); + }); + }; + + const bulkGetTest = makeBulkGetTest(describe); + // @ts-ignore + bulkGetTest.only = makeBulkGetTest(describe.only); + + return { + bulkGetTest, + createExpectLegacyForbidden, + createExpectNotFoundResults, + createExpectResults, + expectRbacForbidden, + }; +} diff --git a/x-pack/test/saved_object_api_integration/common/suites/create.ts b/x-pack/test/saved_object_api_integration/common/suites/create.ts new file mode 100644 index 00000000000000..87c94c1d13b28f --- /dev/null +++ b/x-pack/test/saved_object_api_integration/common/suites/create.ts @@ -0,0 +1,191 @@ +/* + * 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 'expect.js'; +import { SuperTest } from 'supertest'; +import { DEFAULT_SPACE_ID } from '../../../../plugins/spaces/common/constants'; +import { getUrlPrefix } from '../lib/space_test_utils'; +import { DescribeFn, TestDefinitionAuthentication } from '../lib/types'; + +interface CreateTest { + statusCode: number; + response: (resp: { [key: string]: any }) => void; +} + +interface CreateCustomTest extends CreateTest { + type: string; + description: string; + requestBody: any; +} + +interface CreateTests { + spaceAware: CreateTest; + notSpaceAware: CreateTest; + custom?: CreateCustomTest; +} + +interface CreateTestDefinition { + user?: TestDefinitionAuthentication; + spaceId?: string; + tests: CreateTests; +} + +const spaceAwareType = 'visualization'; +const notSpaceAwareType = 'globaltype'; + +export function createTestSuiteFactory(es: any, esArchiver: any, supertest: SuperTest) { + const createExpectLegacyForbidden = (username: string) => (resp: { [key: string]: any }) => { + expect(resp.body).to.eql({ + statusCode: 403, + error: 'Forbidden', + // eslint-disable-next-line max-len + message: `action [indices:data/write/index] is unauthorized for user [${username}]: [security_exception] action [indices:data/write/index] is unauthorized for user [${username}]`, + }); + }; + + const createExpectRbacForbidden = (type: string) => (resp: { [key: string]: any }) => { + expect(resp.body).to.eql({ + statusCode: 403, + error: 'Forbidden', + message: `Unable to create ${type}, missing action:saved_objects/${type}/create`, + }); + }; + + const createExpectSpaceAwareResults = (spaceId = DEFAULT_SPACE_ID) => async (resp: { + [key: string]: any; + }) => { + expect(resp.body) + .to.have.property('id') + .match(/^[0-9a-f-]{36}$/); + + // loose ISO8601 UTC time with milliseconds validation + expect(resp.body) + .to.have.property('updated_at') + .match(/^[\d-]{10}T[\d:\.]{12}Z$/); + + expect(resp.body).to.eql({ + id: resp.body.id, + type: spaceAwareType, + updated_at: resp.body.updated_at, + version: 1, + attributes: { + title: 'My favorite vis', + }, + }); + + const expectedSpacePrefix = spaceId === DEFAULT_SPACE_ID ? '' : `${spaceId}:`; + + // query ES directory to ensure namespace was or wasn't specified + const { _source } = await es.get({ + id: `${expectedSpacePrefix}${spaceAwareType}:${resp.body.id}`, + type: 'doc', + index: '.kibana', + }); + + const { namespace: actualNamespace } = _source; + + if (spaceId === DEFAULT_SPACE_ID) { + expect(actualNamespace).to.eql(undefined); + } else { + expect(actualNamespace).to.eql(spaceId); + } + }; + + const expectNotSpaceAwareRbacForbidden = createExpectRbacForbidden(notSpaceAwareType); + + const expectNotSpaceAwareResults = async (resp: { [key: string]: any }) => { + expect(resp.body) + .to.have.property('id') + .match(/^[0-9a-f-]{36}$/); + + // loose ISO8601 UTC time with milliseconds validation + expect(resp.body) + .to.have.property('updated_at') + .match(/^[\d-]{10}T[\d:\.]{12}Z$/); + + expect(resp.body).to.eql({ + id: resp.body.id, + type: notSpaceAwareType, + updated_at: resp.body.updated_at, + version: 1, + attributes: { + name: `Can't be contained to a space`, + }, + }); + + // query ES directory to ensure namespace wasn't specified + const { _source } = await es.get({ + id: `${notSpaceAwareType}:${resp.body.id}`, + type: 'doc', + index: '.kibana', + }); + + const { namespace: actualNamespace } = _source; + + expect(actualNamespace).to.eql(undefined); + }; + + const expectSpaceAwareRbacForbidden = createExpectRbacForbidden(spaceAwareType); + + const makeCreateTest = (describeFn: DescribeFn) => ( + description: string, + definition: CreateTestDefinition + ) => { + const { user = {}, spaceId = DEFAULT_SPACE_ID, tests } = definition; + describeFn(description, () => { + before(() => esArchiver.load('saved_objects/spaces')); + after(() => esArchiver.unload('saved_objects/spaces')); + it(`should return ${tests.spaceAware.statusCode} for a space-aware type`, async () => { + await supertest + .post(`${getUrlPrefix(spaceId)}/api/saved_objects/${spaceAwareType}`) + .auth(user.username, user.password) + .send({ + attributes: { + title: 'My favorite vis', + }, + }) + .expect(tests.spaceAware.statusCode) + .then(tests.spaceAware.response); + }); + + it(`should return ${tests.notSpaceAware.statusCode} for a non space-aware type`, async () => { + await supertest + .post(`${getUrlPrefix(spaceId)}/api/saved_objects/${notSpaceAwareType}`) + .auth(user.username, user.password) + .send({ + attributes: { + name: `Can't be contained to a space`, + }, + }) + .expect(tests.notSpaceAware.statusCode) + .then(tests.notSpaceAware.response); + }); + + if (tests.custom) { + it(tests.custom.description, async () => { + await supertest + .post(`${getUrlPrefix(spaceId)}/api/saved_objects/${tests.custom!.type}`) + .auth(user.username, user.password) + .send(tests.custom!.requestBody) + .expect(tests.custom!.statusCode) + .then(tests.custom!.response); + }); + } + }); + }; + + const createTest = makeCreateTest(describe); + // @ts-ignore + createTest.only = makeCreateTest(describe.only); + + return { + createExpectLegacyForbidden, + createExpectSpaceAwareResults, + createTest, + expectNotSpaceAwareRbacForbidden, + expectNotSpaceAwareResults, + expectSpaceAwareRbacForbidden, + }; +} diff --git a/x-pack/test/saved_object_api_integration/common/suites/delete.ts b/x-pack/test/saved_object_api_integration/common/suites/delete.ts new file mode 100644 index 00000000000000..9230415453d37f --- /dev/null +++ b/x-pack/test/saved_object_api_integration/common/suites/delete.ts @@ -0,0 +1,142 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from 'expect.js'; +import { SuperTest } from 'supertest'; +import { DEFAULT_SPACE_ID } from '../../../../plugins/spaces/common/constants'; +import { getIdPrefix, getUrlPrefix } from '../lib/space_test_utils'; +import { DescribeFn, TestDefinitionAuthentication } from '../lib/types'; + +interface DeleteTest { + statusCode: number; + response: (resp: { [key: string]: any }) => void; +} + +interface DeleteTests { + spaceAware: DeleteTest; + notSpaceAware: DeleteTest; + invalidId: DeleteTest; +} + +interface DeleteTestDefinition { + user?: TestDefinitionAuthentication; + spaceId?: string; + otherSpaceId?: string; + tests: DeleteTests; +} + +export function deleteTestSuiteFactory(esArchiver: any, supertest: SuperTest) { + const createExpectLegacyForbidden = (username: string) => (resp: { [key: string]: any }) => { + expect(resp.body).to.eql({ + statusCode: 403, + error: 'Forbidden', + // eslint-disable-next-line max-len + message: `action [indices:data/write/delete] is unauthorized for user [${username}]: [security_exception] action [indices:data/write/delete] is unauthorized for user [${username}]`, + }); + }; + + const createExpectNotFound = (spaceId: string, type: string, id: string) => (resp: { + [key: string]: any; + }) => { + expect(resp.body).to.eql({ + statusCode: 404, + error: 'Not Found', + message: `Saved object [${type}/${getIdPrefix(spaceId)}${id}] not found`, + }); + }; + + const createExpectRbacForbidden = (type: string) => (resp: { [key: string]: any }) => { + expect(resp.body).to.eql({ + statusCode: 403, + error: 'Forbidden', + message: `Unable to delete ${type}, missing action:saved_objects/${type}/delete`, + }); + }; + + const createExpectSpaceAwareNotFound = (spaceId: string = DEFAULT_SPACE_ID) => (resp: { + [key: string]: any; + }) => { + createExpectNotFound(spaceId, 'dashboard', 'be3733a0-9efe-11e7-acb3-3dab96693fab')(resp); + }; + + const createExpectUnknownDocNotFound = (spaceId: string = DEFAULT_SPACE_ID) => (resp: { + [key: string]: any; + }) => { + createExpectNotFound(spaceId, 'dashboard', `not-a-real-id`)(resp); + }; + + const expectEmpty = (resp: { [key: string]: any }) => { + expect(resp.body).to.eql({}); + }; + + const expectRbacInvalidIdForbidden = createExpectRbacForbidden('dashboard'); + + const expectRbacNotSpaceAwareForbidden = createExpectRbacForbidden('globaltype'); + + const expectRbacSpaceAwareForbidden = createExpectRbacForbidden('dashboard'); + + const makeDeleteTest = (describeFn: DescribeFn) => ( + description: string, + definition: DeleteTestDefinition + ) => { + const { user = {}, spaceId = DEFAULT_SPACE_ID, otherSpaceId, tests } = definition; + + describeFn(description, () => { + before(() => esArchiver.load('saved_objects/spaces')); + after(() => esArchiver.unload('saved_objects/spaces')); + + it(`should return ${tests.spaceAware.statusCode} when deleting a space-aware doc`, async () => + await supertest + .delete( + `${getUrlPrefix(spaceId)}/api/saved_objects/dashboard/${getIdPrefix( + otherSpaceId || spaceId + )}be3733a0-9efe-11e7-acb3-3dab96693fab` + ) + .auth(user.username, user.password) + .expect(tests.spaceAware.statusCode) + .then(tests.spaceAware.response)); + + it(`should return ${ + tests.notSpaceAware.statusCode + } when deleting a non-space-aware doc`, async () => + await supertest + .delete( + `${getUrlPrefix( + spaceId + )}/api/saved_objects/globaltype/8121a00-8efd-21e7-1cb3-34ab966434445` + ) + .auth(user.username, user.password) + .expect(tests.notSpaceAware.statusCode) + .then(tests.notSpaceAware.response)); + + it(`should return ${tests.invalidId.statusCode} when deleting an unknown doc`, async () => + await supertest + .delete( + `${getUrlPrefix(spaceId)}/api/saved_objects/dashboard/${getIdPrefix( + otherSpaceId || spaceId + )}not-a-real-id` + ) + .auth(user.username, user.password) + .expect(tests.invalidId.statusCode) + .then(tests.invalidId.response)); + }); + }; + + const deleteTest = makeDeleteTest(describe); + // @ts-ignore + deleteTest.only = makeDeleteTest(describe.only); + + return { + createExpectLegacyForbidden, + createExpectSpaceAwareNotFound, + createExpectUnknownDocNotFound, + deleteTest, + expectEmpty, + expectRbacInvalidIdForbidden, + expectRbacNotSpaceAwareForbidden, + expectRbacSpaceAwareForbidden, + }; +} diff --git a/x-pack/test/saved_object_api_integration/common/suites/find.ts b/x-pack/test/saved_object_api_integration/common/suites/find.ts new file mode 100644 index 00000000000000..d7bc0180f8e2bf --- /dev/null +++ b/x-pack/test/saved_object_api_integration/common/suites/find.ts @@ -0,0 +1,205 @@ +/* + * 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 'expect.js'; +import { SuperTest } from 'supertest'; +import { DEFAULT_SPACE_ID } from '../../../../plugins/spaces/common/constants'; +import { getIdPrefix, getUrlPrefix } from '../lib/space_test_utils'; +import { DescribeFn, TestDefinitionAuthentication } from '../lib/types'; + +interface FindTest { + statusCode: number; + description: string; + response: (resp: { [key: string]: any }) => void; +} + +interface FindTests { + spaceAwareType: FindTest; + notSpaceAwareType: FindTest; + unknownType: FindTest; + pageBeyondTotal: FindTest; + unknownSearchField: FindTest; + noType: FindTest; +} + +interface FindTestDefinition { + user?: TestDefinitionAuthentication; + spaceId?: string; + tests: FindTests; +} + +export function findTestSuiteFactory(esArchiver: any, supertest: SuperTest) { + const createExpectEmpty = (page: number, perPage: number, total: number) => (resp: { + [key: string]: any; + }) => { + expect(resp.body).to.eql({ + page, + per_page: perPage, + total, + saved_objects: [], + }); + }; + + const createExpectRbacForbidden = (type?: string) => (resp: { [key: string]: any }) => { + const message = type + ? `Unable to find ${type}, missing action:saved_objects/${type}/find` + : `Not authorized to find saved_object`; + + expect(resp.body).to.eql({ + statusCode: 403, + error: 'Forbidden', + message, + }); + }; + + const createExpectLegacyForbidden = (username: string) => (resp: { [key: string]: any }) => { + expect(resp.body).to.eql({ + statusCode: 403, + error: 'Forbidden', + // eslint-disable-next-line max-len + message: `action [indices:data/read/search] is unauthorized for user [${username}]: [security_exception] action [indices:data/read/search] is unauthorized for user [${username}]`, + }); + }; + + const expectNotSpaceAwareResults = (resp: { [key: string]: any }) => { + expect(resp.body).to.eql({ + page: 1, + per_page: 20, + total: 1, + saved_objects: [ + { + type: 'globaltype', + id: `8121a00-8efd-21e7-1cb3-34ab966434445`, + version: 1, + attributes: { + name: 'My favorite global object', + }, + }, + ], + }); + }; + + const expectTypeRequired = (resp: { [key: string]: any }) => { + expect(resp.body).to.eql({ + error: 'Bad Request', + message: 'child "type" fails because ["type" is required]', + statusCode: 400, + validation: { + keys: ['type'], + source: 'query', + }, + }); + }; + + const createExpectVisualizationResults = (spaceId = DEFAULT_SPACE_ID) => (resp: { + [key: string]: any; + }) => { + expect(resp.body).to.eql({ + page: 1, + per_page: 20, + total: 1, + saved_objects: [ + { + type: 'visualization', + id: `${getIdPrefix(spaceId)}dd7caf20-9efd-11e7-acb3-3dab96693fab`, + version: 1, + attributes: { + title: 'Count of requests', + }, + }, + ], + }); + }; + + const makeFindTest = (describeFn: DescribeFn) => ( + description: string, + definition: FindTestDefinition + ) => { + const { user = {}, spaceId = DEFAULT_SPACE_ID, tests } = definition; + + describeFn(description, () => { + before(() => esArchiver.load('saved_objects/spaces')); + after(() => esArchiver.unload('saved_objects/spaces')); + + it(`space aware type should return ${tests.spaceAwareType.statusCode} with ${ + tests.spaceAwareType.description + }`, async () => + await supertest + .get(`${getUrlPrefix(spaceId)}/api/saved_objects/_find?type=visualization&fields=title`) + .auth(user.username, user.password) + .expect(tests.spaceAwareType.statusCode) + .then(tests.spaceAwareType.response)); + + it(`not space aware type should return ${tests.spaceAwareType.statusCode} with ${ + tests.notSpaceAwareType.description + }`, async () => + await supertest + .get(`${getUrlPrefix(spaceId)}/api/saved_objects/_find?type=globaltype&fields=name`) + .auth(user.username, user.password) + .expect(tests.notSpaceAwareType.statusCode) + .then(tests.notSpaceAwareType.response)); + + describe('unknown type', () => { + it(`should return ${tests.unknownType.statusCode} with ${ + tests.unknownType.description + }`, async () => + await supertest + .get(`${getUrlPrefix(spaceId)}/api/saved_objects/_find?type=wigwags`) + .auth(user.username, user.password) + .expect(tests.unknownType.statusCode) + .then(tests.unknownType.response)); + }); + + describe('page beyond total', () => { + it(`should return ${tests.pageBeyondTotal.statusCode} with ${ + tests.pageBeyondTotal.description + }`, async () => + await supertest + .get( + `${getUrlPrefix( + spaceId + )}/api/saved_objects/_find?type=visualization&page=100&per_page=100` + ) + .auth(user.username, user.password) + .expect(tests.pageBeyondTotal.statusCode) + .then(tests.pageBeyondTotal.response)); + }); + + describe('unknown search field', () => { + it(`should return ${tests.unknownSearchField.statusCode} with ${ + tests.unknownSearchField.description + }`, async () => + await supertest + .get(`${getUrlPrefix(spaceId)}/api/saved_objects/_find?type=wigwags&search_fields=a`) + .auth(user.username, user.password) + .expect(tests.unknownSearchField.statusCode) + .then(tests.unknownSearchField.response)); + }); + + describe('no type', () => { + it(`should return ${tests.noType.statusCode} with ${tests.noType.description}`, async () => + await supertest + .get(`${getUrlPrefix(spaceId)}/api/saved_objects/_find`) + .auth(user.username, user.password) + .expect(tests.noType.statusCode) + .then(tests.noType.response)); + }); + }); + }; + + const findTest = makeFindTest(describe); + // @ts-ignore + findTest.only = makeFindTest(describe.only); + + return { + createExpectEmpty, + createExpectLegacyForbidden, + createExpectRbacForbidden, + createExpectVisualizationResults, + expectNotSpaceAwareResults, + expectTypeRequired, + findTest, + }; +} diff --git a/x-pack/test/saved_object_api_integration/common/suites/get.ts b/x-pack/test/saved_object_api_integration/common/suites/get.ts new file mode 100644 index 00000000000000..85dcda1214ad4e --- /dev/null +++ b/x-pack/test/saved_object_api_integration/common/suites/get.ts @@ -0,0 +1,181 @@ +/* + * 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 'expect.js'; +import { SuperTest } from 'supertest'; +import { DEFAULT_SPACE_ID } from '../../../../plugins/spaces/common/constants'; +import { getIdPrefix, getUrlPrefix } from '../lib/space_test_utils'; +import { DescribeFn, TestDefinitionAuthentication } from '../lib/types'; + +interface GetTest { + statusCode: number; + response: (resp: { [key: string]: any }) => void; +} + +interface GetTests { + spaceAware: GetTest; + notSpaceAware: GetTest; + doesntExist: GetTest; +} + +interface GetTestDefinition { + user?: TestDefinitionAuthentication; + spaceId?: string; + otherSpaceId?: string; + tests: GetTests; +} + +const spaceAwareId = 'dd7caf20-9efd-11e7-acb3-3dab96693fab'; +const notSpaceAwareId = '8121a00-8efd-21e7-1cb3-34ab966434445'; +const doesntExistId = 'foobar'; + +export function getTestSuiteFactory(esArchiver: any, supertest: SuperTest) { + const createExpectDoesntExistNotFound = (spaceId = DEFAULT_SPACE_ID) => { + return createExpectNotFound(doesntExistId, spaceId); + }; + + const createExpectLegacyForbidden = (username: string) => (resp: { [key: string]: any }) => { + expect(resp.body).to.eql({ + statusCode: 403, + error: 'Forbidden', + // eslint-disable-next-line max-len + message: `action [indices:data/read/get] is unauthorized for user [${username}]: [security_exception] action [indices:data/read/get] is unauthorized for user [${username}]`, + }); + }; + + const createExpectNotFound = (id: string, spaceId = DEFAULT_SPACE_ID) => (resp: { + [key: string]: any; + }) => { + expect(resp.body).to.eql({ + error: 'Not Found', + message: `Saved object [visualization/${getIdPrefix(spaceId)}${id}] not found`, + statusCode: 404, + }); + }; + + const createExpectNotSpaceAwareNotFound = (spaceId = DEFAULT_SPACE_ID) => { + return createExpectNotFound(spaceAwareId, spaceId); + }; + + const createExpectNotSpaceAwareRbacForbidden = () => (resp: { [key: string]: any }) => { + expect(resp.body).to.eql({ + error: 'Forbidden', + message: `Unable to get globaltype, missing action:saved_objects/globaltype/get`, + statusCode: 403, + }); + }; + + const createExpectNotSpaceAwareResults = (spaceId = DEFAULT_SPACE_ID) => (resp: { + [key: string]: any; + }) => { + expect(resp.body).to.eql({ + id: `${notSpaceAwareId}`, + type: 'globaltype', + updated_at: '2017-09-21T18:59:16.270Z', + version: resp.body.version, + attributes: { + name: 'My favorite global object', + }, + }); + }; + + const createExpectSpaceAwareNotFound = (spaceId = DEFAULT_SPACE_ID) => { + return createExpectNotFound(spaceAwareId, spaceId); + }; + + const createExpectSpaceAwareRbacForbidden = () => (resp: { [key: string]: any }) => { + expect(resp.body).to.eql({ + error: 'Forbidden', + message: `Unable to get visualization, missing action:saved_objects/visualization/get`, + statusCode: 403, + }); + }; + + const createExpectSpaceAwareResults = (spaceId = DEFAULT_SPACE_ID) => (resp: { + [key: string]: any; + }) => { + expect(resp.body).to.eql({ + id: `${getIdPrefix(spaceId)}dd7caf20-9efd-11e7-acb3-3dab96693fab`, + type: 'visualization', + updated_at: '2017-09-21T18:51:23.794Z', + version: resp.body.version, + attributes: { + title: 'Count of requests', + description: '', + version: 1, + // cheat for some of the more complex attributes + visState: resp.body.attributes.visState, + uiStateJSON: resp.body.attributes.uiStateJSON, + kibanaSavedObjectMeta: resp.body.attributes.kibanaSavedObjectMeta, + }, + }); + }; + + const makeGetTest = (describeFn: DescribeFn) => ( + description: string, + definition: GetTestDefinition + ) => { + const { user = {}, spaceId = DEFAULT_SPACE_ID, otherSpaceId, tests } = definition; + + describeFn(description, () => { + before(() => esArchiver.load('saved_objects/spaces')); + after(() => esArchiver.unload('saved_objects/spaces')); + + it(`should return ${ + tests.spaceAware.statusCode + } when getting a space aware doc`, async () => { + await supertest + .get( + `${getUrlPrefix(spaceId)}/api/saved_objects/visualization/${getIdPrefix( + otherSpaceId || spaceId + )}${spaceAwareId}` + ) + .auth(user.username, user.password) + .expect(tests.spaceAware.statusCode) + .then(tests.spaceAware.response); + }); + + it(`should return ${ + tests.notSpaceAware.statusCode + } when deleting a non-space-aware doc`, async () => { + await supertest + .get(`${getUrlPrefix(spaceId)}/api/saved_objects/globaltype/${notSpaceAwareId}`) + .auth(user.username, user.password) + .expect(tests.notSpaceAware.statusCode) + .then(tests.notSpaceAware.response); + }); + + describe('document does not exist', () => { + it(`should return ${tests.doesntExist.statusCode}`, async () => { + await supertest + .get( + `${getUrlPrefix(spaceId)}/api/saved_objects/visualization/${getIdPrefix( + otherSpaceId || spaceId + )}${doesntExistId}` + ) + .auth(user.username, user.password) + .expect(tests.doesntExist.statusCode) + .then(tests.doesntExist.response); + }); + }); + }); + }; + + const getTest = makeGetTest(describe); + // @ts-ignore + getTest.only = makeGetTest(describe.only); + + return { + createExpectDoesntExistNotFound, + createExpectLegacyForbidden, + createExpectNotSpaceAwareNotFound, + createExpectNotSpaceAwareRbacForbidden, + createExpectNotSpaceAwareResults, + createExpectSpaceAwareNotFound, + createExpectSpaceAwareRbacForbidden, + createExpectSpaceAwareResults, + getTest, + }; +} diff --git a/x-pack/test/saved_object_api_integration/common/suites/update.ts b/x-pack/test/saved_object_api_integration/common/suites/update.ts new file mode 100644 index 00000000000000..e45fa1928b809d --- /dev/null +++ b/x-pack/test/saved_object_api_integration/common/suites/update.ts @@ -0,0 +1,196 @@ +/* + * 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 'expect.js'; +import { SuperTest } from 'supertest'; +import { DEFAULT_SPACE_ID } from '../../../../plugins/spaces/common/constants'; +import { getIdPrefix, getUrlPrefix } from '../lib/space_test_utils'; +import { DescribeFn, TestDefinitionAuthentication } from '../lib/types'; + +interface UpdateTest { + statusCode: number; + response: (resp: { [key: string]: any }) => void; +} + +interface UpdateTests { + spaceAware: UpdateTest; + notSpaceAware: UpdateTest; + doesntExist: UpdateTest; +} + +interface UpdateTestDefinition { + user?: TestDefinitionAuthentication; + spaceId?: string; + otherSpaceId?: string; + tests: UpdateTests; +} + +export function updateTestSuiteFactory(esArchiver: any, supertest: SuperTest) { + const createExpectLegacyForbidden = (username: string) => (resp: { [key: string]: any }) => { + expect(resp.body).to.eql({ + statusCode: 403, + error: 'Forbidden', + // eslint-disable-next-line max-len + message: `action [indices:data/write/update] is unauthorized for user [${username}]: [security_exception] action [indices:data/write/update] is unauthorized for user [${username}]`, + }); + }; + + const createExpectNotFound = (type: string, id: string, spaceId = DEFAULT_SPACE_ID) => (resp: { + [key: string]: any; + }) => { + expect(resp.body).eql({ + statusCode: 404, + error: 'Not Found', + message: `Saved object [${type}/${getIdPrefix(spaceId)}${id}] not found`, + }); + }; + + const createExpectDoesntExistNotFound = (spaceId?: string) => { + return createExpectNotFound('visualization', 'not an id', spaceId); + }; + + const createExpectSpaceAwareNotFound = (spaceId?: string) => { + return createExpectNotFound('visualization', 'dd7caf20-9efd-11e7-acb3-3dab96693fab', spaceId); + }; + + const createExpectRbacForbidden = (type: string) => (resp: { [key: string]: any }) => { + expect(resp.body).to.eql({ + statusCode: 403, + error: 'Forbidden', + message: `Unable to update ${type}, missing action:saved_objects/${type}/update`, + }); + }; + + const expectDoesntExistRbacForbidden = createExpectRbacForbidden('visualization'); + + const expectNotSpaceAwareRbacForbidden = createExpectRbacForbidden('globaltype'); + + const expectNotSpaceAwareResults = (resp: { [key: string]: any }) => { + // loose uuid validation + expect(resp.body) + .to.have.property('id') + .match(/^[0-9a-f-]{36}$/); + + // loose ISO8601 UTC time with milliseconds validation + expect(resp.body) + .to.have.property('updated_at') + .match(/^[\d-]{10}T[\d:\.]{12}Z$/); + + expect(resp.body).to.eql({ + id: resp.body.id, + type: 'globaltype', + updated_at: resp.body.updated_at, + version: 2, + attributes: { + name: 'My second favorite', + }, + }); + }; + + const expectSpaceAwareRbacForbidden = createExpectRbacForbidden('visualization'); + + const expectSpaceAwareResults = (resp: { [key: string]: any }) => { + // loose uuid validation ignoring prefix + expect(resp.body) + .to.have.property('id') + .match(/[0-9a-f-]{36}$/); + + // loose ISO8601 UTC time with milliseconds validation + expect(resp.body) + .to.have.property('updated_at') + .match(/^[\d-]{10}T[\d:\.]{12}Z$/); + + expect(resp.body).to.eql({ + id: resp.body.id, + type: 'visualization', + updated_at: resp.body.updated_at, + version: 2, + attributes: { + title: 'My second favorite vis', + }, + }); + }; + + const makeUpdateTest = (describeFn: DescribeFn) => ( + description: string, + definition: UpdateTestDefinition + ) => { + const { user = {}, spaceId = DEFAULT_SPACE_ID, otherSpaceId, tests } = definition; + + describeFn(description, () => { + before(() => esArchiver.load('saved_objects/spaces')); + after(() => esArchiver.unload('saved_objects/spaces')); + it(`should return ${tests.spaceAware.statusCode} for a space-aware doc`, async () => { + await supertest + .put( + `${getUrlPrefix(spaceId)}/api/saved_objects/visualization/${getIdPrefix( + otherSpaceId || spaceId + )}dd7caf20-9efd-11e7-acb3-3dab96693fab` + ) + .auth(user.username, user.password) + .send({ + attributes: { + title: 'My second favorite vis', + }, + }) + .expect(tests.spaceAware.statusCode) + .then(tests.spaceAware.response); + }); + + it(`should return ${tests.notSpaceAware.statusCode} for a non space-aware doc`, async () => { + await supertest + .put( + `${getUrlPrefix( + otherSpaceId || spaceId + )}/api/saved_objects/globaltype/8121a00-8efd-21e7-1cb3-34ab966434445` + ) + .auth(user.username, user.password) + .send({ + attributes: { + name: 'My second favorite', + }, + }) + .expect(tests.notSpaceAware.statusCode) + .then(tests.notSpaceAware.response); + }); + + describe('unknown id', () => { + it(`should return ${tests.doesntExist.statusCode}`, async () => { + await supertest + .put( + `${getUrlPrefix(spaceId)}/api/saved_objects/visualization/${getIdPrefix( + spaceId + )}not an id` + ) + .auth(user.username, user.password) + .send({ + attributes: { + title: 'My second favorite vis', + }, + }) + .expect(tests.doesntExist.statusCode) + .then(tests.doesntExist.response); + }); + }); + }); + }; + + const updateTest = makeUpdateTest(describe); + // @ts-ignore + updateTest.only = makeUpdateTest(describe.only); + + return { + createExpectLegacyForbidden, + createExpectDoesntExistNotFound, + createExpectSpaceAwareNotFound, + expectDoesntExistRbacForbidden, + expectNotSpaceAwareRbacForbidden, + expectNotSpaceAwareResults, + expectSpaceAwareRbacForbidden, + expectSpaceAwareResults, + updateTest, + }; +} diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_create.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_create.ts new file mode 100644 index 00000000000000..4dc1542fd25435 --- /dev/null +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_create.ts @@ -0,0 +1,182 @@ +/* + * 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 { AUTHENTICATION } from '../../common/lib/authentication'; +import { SPACES } from '../../common/lib/spaces'; +import { TestInvoker } from '../../common/lib/types'; +import { bulkCreateTestSuiteFactory } from '../../common/suites/bulk_create'; + +// tslint:disable:no-default-export +export default function({ getService }: TestInvoker) { + const supertest = getService('supertestWithoutAuth'); + const esArchiver = getService('esArchiver'); + const es = getService('es'); + + const { + bulkCreateTest, + createExpectLegacyForbidden, + createExpectResults, + expectRbacForbidden, + } = bulkCreateTestSuiteFactory(es, esArchiver, supertest); + + describe('_bulk_create', () => { + [ + { + spaceId: SPACES.DEFAULT.spaceId, + users: { + noAccess: AUTHENTICATION.NOT_A_KIBANA_USER, + superuser: AUTHENTICATION.SUPERUSER, + legacyAll: AUTHENTICATION.KIBANA_LEGACY_USER, + legacyRead: AUTHENTICATION.KIBANA_LEGACY_DASHBOARD_ONLY_USER, + allGlobally: AUTHENTICATION.KIBANA_RBAC_USER, + readGlobally: AUTHENTICATION.KIBANA_RBAC_DASHBOARD_ONLY_USER, + dualAll: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_USER, + dualRead: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER, + allAtSpace: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_ALL_USER, + readAtSpace: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_READ_USER, + allAtOtherSpace: AUTHENTICATION.KIBANA_RBAC_SPACE_1_ALL_USER, + }, + }, + { + spaceId: SPACES.SPACE_1.spaceId, + users: { + noAccess: AUTHENTICATION.NOT_A_KIBANA_USER, + superuser: AUTHENTICATION.SUPERUSER, + legacyAll: AUTHENTICATION.KIBANA_LEGACY_USER, + legacyRead: AUTHENTICATION.KIBANA_LEGACY_DASHBOARD_ONLY_USER, + allGlobally: AUTHENTICATION.KIBANA_RBAC_USER, + readGlobally: AUTHENTICATION.KIBANA_RBAC_DASHBOARD_ONLY_USER, + dualAll: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_USER, + dualRead: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER, + allAtSpace: AUTHENTICATION.KIBANA_RBAC_SPACE_1_ALL_USER, + readAtSpace: AUTHENTICATION.KIBANA_RBAC_SPACE_1_READ_USER, + allAtOtherSpace: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_ALL_USER, + }, + }, + ].forEach(scenario => { + bulkCreateTest(`user with no access within the ${scenario.spaceId} space`, { + user: scenario.users.noAccess, + spaceId: scenario.spaceId, + tests: { + default: { + statusCode: 403, + response: createExpectLegacyForbidden(scenario.users.noAccess.username), + }, + }, + }); + + bulkCreateTest(`superuser within the ${scenario.spaceId} space`, { + user: scenario.users.superuser, + spaceId: scenario.spaceId, + tests: { + default: { + statusCode: 200, + response: createExpectResults(scenario.spaceId), + }, + }, + }); + + bulkCreateTest(`legacy user within the ${scenario.spaceId} space`, { + user: scenario.users.legacyAll, + spaceId: scenario.spaceId, + tests: { + default: { + statusCode: 200, + response: createExpectResults(scenario.spaceId), + }, + }, + }); + + bulkCreateTest(`legacy readonly user within the ${scenario.spaceId} space`, { + user: scenario.users.legacyRead, + spaceId: scenario.spaceId, + tests: { + default: { + statusCode: 403, + response: createExpectLegacyForbidden(scenario.users.legacyRead.username), + }, + }, + }); + + bulkCreateTest(`dual-privileges user within the ${scenario.spaceId} space`, { + user: scenario.users.dualAll, + spaceId: scenario.spaceId, + tests: { + default: { + statusCode: 200, + response: createExpectResults(scenario.spaceId), + }, + }, + }); + + bulkCreateTest(`dual-privileges readonly user within the ${scenario.spaceId} space`, { + user: scenario.users.dualRead, + spaceId: scenario.spaceId, + tests: { + default: { + statusCode: 403, + response: expectRbacForbidden, + }, + }, + }); + + bulkCreateTest(`rbac user with all globally within the ${scenario.spaceId} space`, { + user: scenario.users.allGlobally, + spaceId: scenario.spaceId, + tests: { + default: { + statusCode: 200, + response: createExpectResults(scenario.spaceId), + }, + }, + }); + + bulkCreateTest(`rbac user with read globally within the ${scenario.spaceId} space`, { + user: scenario.users.readGlobally, + spaceId: scenario.spaceId, + tests: { + default: { + statusCode: 403, + response: expectRbacForbidden, + }, + }, + }); + + bulkCreateTest(`rbac user with all at the space within the ${scenario.spaceId} space`, { + user: scenario.users.allAtSpace, + spaceId: scenario.spaceId, + tests: { + default: { + statusCode: 200, + response: createExpectResults(scenario.spaceId), + }, + }, + }); + + bulkCreateTest(`rbac user with read at the space within the ${scenario.spaceId} space`, { + user: scenario.users.readAtSpace, + spaceId: scenario.spaceId, + tests: { + default: { + statusCode: 403, + response: expectRbacForbidden, + }, + }, + }); + + bulkCreateTest(`rbac user with all at other space within the ${scenario.spaceId} space`, { + user: scenario.users.allAtOtherSpace, + spaceId: scenario.spaceId, + tests: { + default: { + statusCode: 403, + response: expectRbacForbidden, + }, + }, + }); + }); + }); +} diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_get.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_get.ts new file mode 100644 index 00000000000000..71a7f7ac677564 --- /dev/null +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_get.ts @@ -0,0 +1,181 @@ +/* + * 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 { AUTHENTICATION } from '../../common/lib/authentication'; +import { SPACES } from '../../common/lib/spaces'; +import { TestInvoker } from '../../common/lib/types'; +import { bulkGetTestSuiteFactory } from '../../common/suites/bulk_get'; + +// tslint:disable:no-default-export +export default function({ getService }: TestInvoker) { + const supertest = getService('supertestWithoutAuth'); + const esArchiver = getService('esArchiver'); + + const { + bulkGetTest, + createExpectLegacyForbidden, + createExpectResults, + expectRbacForbidden, + } = bulkGetTestSuiteFactory(esArchiver, supertest); + + describe('_bulk_get', () => { + [ + { + spaceId: SPACES.DEFAULT.spaceId, + users: { + noAccess: AUTHENTICATION.NOT_A_KIBANA_USER, + superuser: AUTHENTICATION.SUPERUSER, + legacyAll: AUTHENTICATION.KIBANA_LEGACY_USER, + legacyRead: AUTHENTICATION.KIBANA_LEGACY_DASHBOARD_ONLY_USER, + allGlobally: AUTHENTICATION.KIBANA_RBAC_USER, + readGlobally: AUTHENTICATION.KIBANA_RBAC_DASHBOARD_ONLY_USER, + dualAll: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_USER, + dualRead: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER, + allAtSpace: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_ALL_USER, + readAtSpace: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_READ_USER, + allAtOtherSpace: AUTHENTICATION.KIBANA_RBAC_SPACE_1_ALL_USER, + }, + }, + { + spaceId: SPACES.SPACE_1.spaceId, + users: { + noAccess: AUTHENTICATION.NOT_A_KIBANA_USER, + superuser: AUTHENTICATION.SUPERUSER, + legacyAll: AUTHENTICATION.KIBANA_LEGACY_USER, + legacyRead: AUTHENTICATION.KIBANA_LEGACY_DASHBOARD_ONLY_USER, + allGlobally: AUTHENTICATION.KIBANA_RBAC_USER, + readGlobally: AUTHENTICATION.KIBANA_RBAC_DASHBOARD_ONLY_USER, + dualAll: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_USER, + dualRead: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER, + allAtSpace: AUTHENTICATION.KIBANA_RBAC_SPACE_1_ALL_USER, + readAtSpace: AUTHENTICATION.KIBANA_RBAC_SPACE_1_READ_USER, + allAtOtherSpace: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_ALL_USER, + }, + }, + ].forEach(scenario => { + bulkGetTest(`user with no access within the ${scenario.spaceId} space`, { + user: scenario.users.noAccess, + spaceId: scenario.spaceId, + tests: { + default: { + statusCode: 403, + response: createExpectLegacyForbidden(scenario.users.noAccess.username), + }, + }, + }); + + bulkGetTest(`superuser within the ${scenario.spaceId} space`, { + user: scenario.users.superuser, + spaceId: scenario.spaceId, + tests: { + default: { + statusCode: 200, + response: createExpectResults(scenario.spaceId), + }, + }, + }); + + bulkGetTest(`legacy user within the ${scenario.spaceId} space`, { + user: scenario.users.legacyAll, + spaceId: scenario.spaceId, + tests: { + default: { + statusCode: 200, + response: createExpectResults(scenario.spaceId), + }, + }, + }); + + bulkGetTest(`legacy readonly user within the ${scenario.spaceId} space`, { + user: scenario.users.legacyRead, + spaceId: scenario.spaceId, + tests: { + default: { + statusCode: 200, + response: createExpectResults(scenario.spaceId), + }, + }, + }); + + bulkGetTest(`dual-privileges user within the ${scenario.spaceId} space`, { + user: scenario.users.dualAll, + spaceId: scenario.spaceId, + tests: { + default: { + statusCode: 200, + response: createExpectResults(scenario.spaceId), + }, + }, + }); + + bulkGetTest(`dual-privileges readonly user within the ${scenario.spaceId} space`, { + user: scenario.users.dualRead, + spaceId: scenario.spaceId, + tests: { + default: { + statusCode: 200, + response: createExpectResults(scenario.spaceId), + }, + }, + }); + + bulkGetTest(`rbac user with all globally within the ${scenario.spaceId} space`, { + user: scenario.users.allGlobally, + spaceId: scenario.spaceId, + tests: { + default: { + statusCode: 200, + response: createExpectResults(scenario.spaceId), + }, + }, + }); + + bulkGetTest(`rbac user with read globally within the ${scenario.spaceId} space`, { + user: scenario.users.readGlobally, + spaceId: scenario.spaceId, + tests: { + default: { + statusCode: 200, + response: createExpectResults(scenario.spaceId), + }, + }, + }); + + bulkGetTest(`rbac user with all at the space within the ${scenario.spaceId} space`, { + user: scenario.users.allAtSpace, + spaceId: scenario.spaceId, + tests: { + default: { + statusCode: 200, + response: createExpectResults(scenario.spaceId), + }, + }, + }); + + bulkGetTest(`rbac user with read at the space within the ${scenario.spaceId} space`, { + user: scenario.users.readAtSpace, + spaceId: scenario.spaceId, + tests: { + default: { + statusCode: 200, + response: createExpectResults(scenario.spaceId), + }, + }, + }); + + bulkGetTest(`rbac user with all at other space within the ${scenario.spaceId} space`, { + user: scenario.users.allAtOtherSpace, + spaceId: scenario.spaceId, + tests: { + default: { + statusCode: 403, + response: expectRbacForbidden, + }, + }, + }); + }); + }); +} diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/create.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/create.ts new file mode 100644 index 00000000000000..8aeb43d9d7f364 --- /dev/null +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/create.ts @@ -0,0 +1,228 @@ +/* + * 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 { AUTHENTICATION } from '../../common/lib/authentication'; +import { SPACES } from '../../common/lib/spaces'; +import { TestInvoker } from '../../common/lib/types'; +import { createTestSuiteFactory } from '../../common/suites/create'; + +// tslint:disable:no-default-export +export default function({ getService }: TestInvoker) { + const supertestWithoutAuth = getService('supertestWithoutAuth'); + const es = getService('es'); + const esArchiver = getService('esArchiver'); + + const { + createTest, + createExpectLegacyForbidden, + createExpectSpaceAwareResults, + expectNotSpaceAwareResults, + expectNotSpaceAwareRbacForbidden, + expectSpaceAwareRbacForbidden, + } = createTestSuiteFactory(es, esArchiver, supertestWithoutAuth); + + describe('create', () => { + [ + { + spaceId: SPACES.DEFAULT.spaceId, + users: { + noAccess: AUTHENTICATION.NOT_A_KIBANA_USER, + superuser: AUTHENTICATION.SUPERUSER, + legacyAll: AUTHENTICATION.KIBANA_LEGACY_USER, + legacyRead: AUTHENTICATION.KIBANA_LEGACY_DASHBOARD_ONLY_USER, + allGlobally: AUTHENTICATION.KIBANA_RBAC_USER, + readGlobally: AUTHENTICATION.KIBANA_RBAC_DASHBOARD_ONLY_USER, + dualAll: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_USER, + dualRead: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER, + allAtSpace: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_ALL_USER, + readAtSpace: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_READ_USER, + allAtOtherSpace: AUTHENTICATION.KIBANA_RBAC_SPACE_1_ALL_USER, + }, + }, + { + spaceId: SPACES.SPACE_1.spaceId, + users: { + noAccess: AUTHENTICATION.NOT_A_KIBANA_USER, + superuser: AUTHENTICATION.SUPERUSER, + legacyAll: AUTHENTICATION.KIBANA_LEGACY_USER, + legacyRead: AUTHENTICATION.KIBANA_LEGACY_DASHBOARD_ONLY_USER, + allGlobally: AUTHENTICATION.KIBANA_RBAC_USER, + readGlobally: AUTHENTICATION.KIBANA_RBAC_DASHBOARD_ONLY_USER, + dualAll: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_USER, + dualRead: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER, + allAtSpace: AUTHENTICATION.KIBANA_RBAC_SPACE_1_ALL_USER, + readAtSpace: AUTHENTICATION.KIBANA_RBAC_SPACE_1_READ_USER, + allAtOtherSpace: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_ALL_USER, + }, + }, + ].forEach(scenario => { + createTest(`user with no access within the ${scenario.spaceId} space`, { + user: scenario.users.noAccess, + spaceId: scenario.spaceId, + tests: { + spaceAware: { + statusCode: 403, + response: createExpectLegacyForbidden(scenario.users.noAccess.username), + }, + notSpaceAware: { + statusCode: 403, + response: createExpectLegacyForbidden(scenario.users.noAccess.username), + }, + }, + }); + + createTest(`superuser within the ${scenario.spaceId} space`, { + user: scenario.users.superuser, + spaceId: scenario.spaceId, + tests: { + spaceAware: { + statusCode: 200, + response: createExpectSpaceAwareResults(scenario.spaceId), + }, + notSpaceAware: { + statusCode: 200, + response: expectNotSpaceAwareResults, + }, + }, + }); + + createTest(`legacy user within the ${scenario.spaceId} space`, { + user: scenario.users.legacyAll, + spaceId: scenario.spaceId, + tests: { + spaceAware: { + statusCode: 200, + response: createExpectSpaceAwareResults(scenario.spaceId), + }, + notSpaceAware: { + statusCode: 200, + response: expectNotSpaceAwareResults, + }, + }, + }); + + createTest(`legacy readonly user within the ${scenario.spaceId} space`, { + user: scenario.users.legacyRead, + spaceId: scenario.spaceId, + tests: { + spaceAware: { + statusCode: 403, + response: createExpectLegacyForbidden(scenario.users.legacyRead.username), + }, + notSpaceAware: { + statusCode: 403, + response: createExpectLegacyForbidden(scenario.users.legacyRead.username), + }, + }, + }); + + createTest(`dual-privileges user within the ${scenario.spaceId} space`, { + user: scenario.users.dualAll, + spaceId: scenario.spaceId, + tests: { + spaceAware: { + statusCode: 200, + response: createExpectSpaceAwareResults(scenario.spaceId), + }, + notSpaceAware: { + statusCode: 200, + response: expectNotSpaceAwareResults, + }, + }, + }); + + createTest(`dual-privileges readonly user within the ${scenario.spaceId} space`, { + user: scenario.users.dualRead, + spaceId: scenario.spaceId, + tests: { + spaceAware: { + statusCode: 403, + response: expectSpaceAwareRbacForbidden, + }, + notSpaceAware: { + statusCode: 403, + response: expectNotSpaceAwareRbacForbidden, + }, + }, + }); + + createTest(`rbac user with all globally within the ${scenario.spaceId} space`, { + user: scenario.users.allGlobally, + spaceId: scenario.spaceId, + tests: { + spaceAware: { + statusCode: 200, + response: createExpectSpaceAwareResults(scenario.spaceId), + }, + notSpaceAware: { + statusCode: 200, + response: expectNotSpaceAwareResults, + }, + }, + }); + + createTest(`rbac user with read globally within the ${scenario.spaceId} space`, { + user: scenario.users.readGlobally, + spaceId: scenario.spaceId, + tests: { + spaceAware: { + statusCode: 403, + response: expectSpaceAwareRbacForbidden, + }, + notSpaceAware: { + statusCode: 403, + response: expectNotSpaceAwareRbacForbidden, + }, + }, + }); + + createTest(`rbac user with all at the space within the ${scenario.spaceId} space`, { + user: scenario.users.allAtSpace, + spaceId: scenario.spaceId, + tests: { + spaceAware: { + statusCode: 200, + response: createExpectSpaceAwareResults(scenario.spaceId), + }, + notSpaceAware: { + statusCode: 200, + response: expectNotSpaceAwareResults, + }, + }, + }); + + createTest(`rbac user with read at the space within the ${scenario.spaceId} space`, { + user: scenario.users.readAtSpace, + spaceId: scenario.spaceId, + tests: { + spaceAware: { + statusCode: 403, + response: expectSpaceAwareRbacForbidden, + }, + notSpaceAware: { + statusCode: 403, + response: expectNotSpaceAwareRbacForbidden, + }, + }, + }); + + createTest(`rbac user with all at other space within the ${scenario.spaceId} space`, { + user: scenario.users.allAtOtherSpace, + spaceId: scenario.spaceId, + tests: { + spaceAware: { + statusCode: 403, + response: expectSpaceAwareRbacForbidden, + }, + notSpaceAware: { + statusCode: 403, + response: expectNotSpaceAwareRbacForbidden, + }, + }, + }); + }); + }); +} diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/delete.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/delete.ts new file mode 100644 index 00000000000000..c12699cb89803a --- /dev/null +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/delete.ts @@ -0,0 +1,272 @@ +/* + * 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 { AUTHENTICATION } from '../../common/lib/authentication'; +import { SPACES } from '../../common/lib/spaces'; +import { TestInvoker } from '../../common/lib/types'; +import { deleteTestSuiteFactory } from '../../common/suites/delete'; + +// tslint:disable:no-default-export +export default function({ getService }: TestInvoker) { + const supertest = getService('supertestWithoutAuth'); + const esArchiver = getService('esArchiver'); + + describe('delete', () => { + const { + createExpectLegacyForbidden, + createExpectUnknownDocNotFound, + deleteTest, + expectEmpty, + expectRbacSpaceAwareForbidden, + expectRbacNotSpaceAwareForbidden, + expectRbacInvalidIdForbidden, + } = deleteTestSuiteFactory(esArchiver, supertest); + + [ + { + spaceId: SPACES.DEFAULT.spaceId, + users: { + noAccess: AUTHENTICATION.NOT_A_KIBANA_USER, + superuser: AUTHENTICATION.SUPERUSER, + legacyAll: AUTHENTICATION.KIBANA_LEGACY_USER, + legacyRead: AUTHENTICATION.KIBANA_LEGACY_DASHBOARD_ONLY_USER, + allGlobally: AUTHENTICATION.KIBANA_RBAC_USER, + readGlobally: AUTHENTICATION.KIBANA_RBAC_DASHBOARD_ONLY_USER, + dualAll: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_USER, + dualRead: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER, + allAtSpace: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_ALL_USER, + readAtSpace: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_READ_USER, + allAtOtherSpace: AUTHENTICATION.KIBANA_RBAC_SPACE_1_ALL_USER, + }, + }, + { + spaceId: SPACES.SPACE_1.spaceId, + users: { + noAccess: AUTHENTICATION.NOT_A_KIBANA_USER, + superuser: AUTHENTICATION.SUPERUSER, + legacyAll: AUTHENTICATION.KIBANA_LEGACY_USER, + legacyRead: AUTHENTICATION.KIBANA_LEGACY_DASHBOARD_ONLY_USER, + allGlobally: AUTHENTICATION.KIBANA_RBAC_USER, + readGlobally: AUTHENTICATION.KIBANA_RBAC_DASHBOARD_ONLY_USER, + dualAll: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_USER, + dualRead: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER, + allAtSpace: AUTHENTICATION.KIBANA_RBAC_SPACE_1_ALL_USER, + readAtSpace: AUTHENTICATION.KIBANA_RBAC_SPACE_1_READ_USER, + allAtOtherSpace: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_ALL_USER, + }, + }, + ].forEach(scenario => { + deleteTest(`user with no access within the ${scenario.spaceId} space`, { + user: scenario.users.noAccess, + spaceId: scenario.spaceId, + tests: { + spaceAware: { + statusCode: 403, + response: createExpectLegacyForbidden(scenario.users.noAccess.username), + }, + notSpaceAware: { + statusCode: 403, + response: createExpectLegacyForbidden(scenario.users.noAccess.username), + }, + invalidId: { + statusCode: 403, + response: createExpectLegacyForbidden(scenario.users.noAccess.username), + }, + }, + }); + + deleteTest(`superuser within the ${scenario.spaceId} space`, { + user: scenario.users.superuser, + spaceId: scenario.spaceId, + tests: { + spaceAware: { + statusCode: 200, + response: expectEmpty, + }, + notSpaceAware: { + statusCode: 200, + response: expectEmpty, + }, + invalidId: { + statusCode: 404, + response: createExpectUnknownDocNotFound(scenario.spaceId), + }, + }, + }); + + deleteTest(`legacy user within the ${scenario.spaceId} space`, { + user: scenario.users.legacyAll, + spaceId: scenario.spaceId, + tests: { + spaceAware: { + statusCode: 200, + response: expectEmpty, + }, + notSpaceAware: { + statusCode: 200, + response: expectEmpty, + }, + invalidId: { + statusCode: 404, + response: createExpectUnknownDocNotFound(scenario.spaceId), + }, + }, + }); + + deleteTest(`legacy readonly user within the ${scenario.spaceId} space`, { + user: scenario.users.legacyRead, + spaceId: scenario.spaceId, + tests: { + spaceAware: { + statusCode: 403, + response: createExpectLegacyForbidden(scenario.users.legacyRead.username), + }, + notSpaceAware: { + statusCode: 403, + response: createExpectLegacyForbidden(scenario.users.legacyRead.username), + }, + invalidId: { + statusCode: 403, + response: createExpectLegacyForbidden(scenario.users.legacyRead.username), + }, + }, + }); + + deleteTest(`dual-privileges user within the ${scenario.spaceId} space`, { + user: scenario.users.dualAll, + spaceId: scenario.spaceId, + tests: { + spaceAware: { + statusCode: 200, + response: expectEmpty, + }, + notSpaceAware: { + statusCode: 200, + response: expectEmpty, + }, + invalidId: { + statusCode: 404, + response: createExpectUnknownDocNotFound(scenario.spaceId), + }, + }, + }); + + deleteTest(`dual-privileges readonly user within the ${scenario.spaceId} space`, { + user: scenario.users.dualRead, + spaceId: scenario.spaceId, + tests: { + spaceAware: { + statusCode: 403, + response: expectRbacSpaceAwareForbidden, + }, + notSpaceAware: { + statusCode: 403, + response: expectRbacNotSpaceAwareForbidden, + }, + invalidId: { + statusCode: 403, + response: expectRbacInvalidIdForbidden, + }, + }, + }); + + deleteTest(`rbac user with all globally within the ${scenario.spaceId} space`, { + user: scenario.users.allGlobally, + spaceId: scenario.spaceId, + tests: { + spaceAware: { + statusCode: 200, + response: expectEmpty, + }, + notSpaceAware: { + statusCode: 200, + response: expectEmpty, + }, + invalidId: { + statusCode: 404, + response: createExpectUnknownDocNotFound(scenario.spaceId), + }, + }, + }); + + deleteTest(`rbac user with read globally within the ${scenario.spaceId} space`, { + user: scenario.users.readGlobally, + spaceId: scenario.spaceId, + tests: { + spaceAware: { + statusCode: 403, + response: expectRbacSpaceAwareForbidden, + }, + notSpaceAware: { + statusCode: 403, + response: expectRbacNotSpaceAwareForbidden, + }, + invalidId: { + statusCode: 403, + response: expectRbacInvalidIdForbidden, + }, + }, + }); + + deleteTest(`rbac user with all at the space within the ${scenario.spaceId} space`, { + user: scenario.users.allAtSpace, + spaceId: scenario.spaceId, + tests: { + spaceAware: { + statusCode: 200, + response: expectEmpty, + }, + notSpaceAware: { + statusCode: 200, + response: expectEmpty, + }, + invalidId: { + statusCode: 404, + response: createExpectUnknownDocNotFound(scenario.spaceId), + }, + }, + }); + + deleteTest(`rbac user with read at the space within the ${scenario.spaceId} space`, { + user: scenario.users.readAtSpace, + spaceId: scenario.spaceId, + tests: { + spaceAware: { + statusCode: 403, + response: expectRbacSpaceAwareForbidden, + }, + notSpaceAware: { + statusCode: 403, + response: expectRbacNotSpaceAwareForbidden, + }, + invalidId: { + statusCode: 403, + response: expectRbacInvalidIdForbidden, + }, + }, + }); + + deleteTest(`rbac user with all at other space within the ${scenario.spaceId} space`, { + user: scenario.users.allAtOtherSpace, + spaceId: scenario.spaceId, + tests: { + spaceAware: { + statusCode: 403, + response: expectRbacSpaceAwareForbidden, + }, + notSpaceAware: { + statusCode: 403, + response: expectRbacNotSpaceAwareForbidden, + }, + invalidId: { + statusCode: 403, + response: expectRbacInvalidIdForbidden, + }, + }, + }); + }); + }); +} diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/find.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/find.ts new file mode 100644 index 00000000000000..d7ce2413daf42d --- /dev/null +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/find.ts @@ -0,0 +1,470 @@ +/* + * 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 { AUTHENTICATION } from '../../common/lib/authentication'; +import { SPACES } from '../../common/lib/spaces'; +import { TestInvoker } from '../../common/lib/types'; +import { findTestSuiteFactory } from '../../common/suites/find'; + +// tslint:disable:no-default-export +export default function({ getService }: TestInvoker) { + const supertest = getService('supertestWithoutAuth'); + const esArchiver = getService('esArchiver'); + + describe('find', () => { + const { + createExpectEmpty, + createExpectRbacForbidden, + createExpectLegacyForbidden, + createExpectVisualizationResults, + expectNotSpaceAwareResults, + expectTypeRequired, + findTest, + } = findTestSuiteFactory(esArchiver, supertest); + + [ + { + spaceId: SPACES.DEFAULT.spaceId, + users: { + noAccess: AUTHENTICATION.NOT_A_KIBANA_USER, + superuser: AUTHENTICATION.SUPERUSER, + legacyAll: AUTHENTICATION.KIBANA_LEGACY_USER, + legacyRead: AUTHENTICATION.KIBANA_LEGACY_DASHBOARD_ONLY_USER, + allGlobally: AUTHENTICATION.KIBANA_RBAC_USER, + readGlobally: AUTHENTICATION.KIBANA_RBAC_DASHBOARD_ONLY_USER, + dualAll: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_USER, + dualRead: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER, + allAtSpace: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_ALL_USER, + readAtSpace: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_READ_USER, + allAtOtherSpace: AUTHENTICATION.KIBANA_RBAC_SPACE_1_ALL_USER, + }, + }, + { + spaceId: SPACES.SPACE_1.spaceId, + users: { + noAccess: AUTHENTICATION.NOT_A_KIBANA_USER, + superuser: AUTHENTICATION.SUPERUSER, + legacyAll: AUTHENTICATION.KIBANA_LEGACY_USER, + legacyRead: AUTHENTICATION.KIBANA_LEGACY_DASHBOARD_ONLY_USER, + allGlobally: AUTHENTICATION.KIBANA_RBAC_USER, + readGlobally: AUTHENTICATION.KIBANA_RBAC_DASHBOARD_ONLY_USER, + dualAll: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_USER, + dualRead: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER, + allAtSpace: AUTHENTICATION.KIBANA_RBAC_SPACE_1_ALL_USER, + readAtSpace: AUTHENTICATION.KIBANA_RBAC_SPACE_1_READ_USER, + allAtOtherSpace: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_ALL_USER, + }, + }, + ].forEach(scenario => { + findTest(`user with no access within the ${scenario.spaceId} space`, { + user: scenario.users.noAccess, + spaceId: scenario.spaceId, + tests: { + spaceAwareType: { + description: 'forbidden login and find visualization message', + statusCode: 403, + response: createExpectLegacyForbidden(AUTHENTICATION.NOT_A_KIBANA_USER.username), + }, + notSpaceAwareType: { + description: 'forbidden login and find globaltype message', + statusCode: 403, + response: createExpectLegacyForbidden(AUTHENTICATION.NOT_A_KIBANA_USER.username), + }, + unknownType: { + description: 'forbidden login and find wigwags message', + statusCode: 403, + response: createExpectLegacyForbidden(AUTHENTICATION.NOT_A_KIBANA_USER.username), + }, + pageBeyondTotal: { + description: 'forbidden login and find visualization message', + statusCode: 403, + response: createExpectLegacyForbidden(AUTHENTICATION.NOT_A_KIBANA_USER.username), + }, + unknownSearchField: { + description: 'forbidden login and find wigwags message', + statusCode: 403, + response: createExpectLegacyForbidden(AUTHENTICATION.NOT_A_KIBANA_USER.username), + }, + noType: { + description: 'bad request, type is required', + statusCode: 400, + response: expectTypeRequired, + }, + }, + }); + + findTest(`superuser within the ${scenario.spaceId} space`, { + user: scenario.users.superuser, + spaceId: scenario.spaceId, + tests: { + spaceAwareType: { + description: 'only the visualization', + statusCode: 200, + response: createExpectVisualizationResults(scenario.spaceId), + }, + notSpaceAwareType: { + description: 'only the globaltype', + statusCode: 200, + response: expectNotSpaceAwareResults, + }, + unknownType: { + description: 'empty result', + statusCode: 200, + response: createExpectEmpty(1, 20, 0), + }, + pageBeyondTotal: { + description: 'empty result', + statusCode: 200, + response: createExpectEmpty(100, 100, 1), + }, + unknownSearchField: { + description: 'empty result', + statusCode: 200, + response: createExpectEmpty(1, 20, 0), + }, + noType: { + description: 'bad request, type is required', + statusCode: 400, + response: expectTypeRequired, + }, + }, + }); + + findTest(`legacy user within the ${scenario.spaceId} space`, { + user: scenario.users.legacyAll, + spaceId: scenario.spaceId, + tests: { + spaceAwareType: { + description: 'only the visualization', + statusCode: 200, + response: createExpectVisualizationResults(scenario.spaceId), + }, + notSpaceAwareType: { + description: 'only the globaltype', + statusCode: 200, + response: expectNotSpaceAwareResults, + }, + unknownType: { + description: 'empty result', + statusCode: 200, + response: createExpectEmpty(1, 20, 0), + }, + pageBeyondTotal: { + description: 'empty result', + statusCode: 200, + response: createExpectEmpty(100, 100, 1), + }, + unknownSearchField: { + description: 'empty result', + statusCode: 200, + response: createExpectEmpty(1, 20, 0), + }, + noType: { + description: 'bad request, type is required', + statusCode: 400, + response: expectTypeRequired, + }, + }, + }); + + findTest(`legacy readonly user within the ${scenario.spaceId} space`, { + user: scenario.users.legacyRead, + spaceId: scenario.spaceId, + tests: { + spaceAwareType: { + description: 'only the visualization', + statusCode: 200, + response: createExpectVisualizationResults(scenario.spaceId), + }, + notSpaceAwareType: { + description: 'only the globaltype', + statusCode: 200, + response: expectNotSpaceAwareResults, + }, + unknownType: { + description: 'empty result', + statusCode: 200, + response: createExpectEmpty(1, 20, 0), + }, + pageBeyondTotal: { + description: 'empty result', + statusCode: 200, + response: createExpectEmpty(100, 100, 1), + }, + unknownSearchField: { + description: 'empty result', + statusCode: 200, + response: createExpectEmpty(1, 20, 0), + }, + noType: { + description: 'bad request, type is required', + statusCode: 400, + response: expectTypeRequired, + }, + }, + }); + + findTest(`dual-privileges user within the ${scenario.spaceId} space`, { + user: scenario.users.dualAll, + spaceId: scenario.spaceId, + tests: { + spaceAwareType: { + description: 'only the visualization', + statusCode: 200, + response: createExpectVisualizationResults(scenario.spaceId), + }, + notSpaceAwareType: { + description: 'only the globaltype', + statusCode: 200, + response: expectNotSpaceAwareResults, + }, + unknownType: { + description: 'empty result', + statusCode: 200, + response: createExpectEmpty(1, 20, 0), + }, + pageBeyondTotal: { + description: 'empty result', + statusCode: 200, + response: createExpectEmpty(100, 100, 1), + }, + unknownSearchField: { + description: 'empty result', + statusCode: 200, + response: createExpectEmpty(1, 20, 0), + }, + noType: { + description: 'bad request, type is required', + statusCode: 400, + response: expectTypeRequired, + }, + }, + }); + + findTest(`dual-privileges readonly user within the ${scenario.spaceId} space`, { + user: scenario.users.dualRead, + spaceId: scenario.spaceId, + tests: { + spaceAwareType: { + description: 'only the visualization', + statusCode: 200, + response: createExpectVisualizationResults(scenario.spaceId), + }, + notSpaceAwareType: { + description: 'only the globaltype', + statusCode: 200, + response: expectNotSpaceAwareResults, + }, + unknownType: { + description: 'forbidden find wigwags message', + statusCode: 403, + response: createExpectRbacForbidden('wigwags'), + }, + pageBeyondTotal: { + description: 'empty result', + statusCode: 200, + response: createExpectEmpty(100, 100, 1), + }, + unknownSearchField: { + description: 'forbidden find wigwags message', + statusCode: 403, + response: createExpectRbacForbidden('wigwags'), + }, + noType: { + description: 'bad request, type is required', + statusCode: 400, + response: expectTypeRequired, + }, + }, + }); + + findTest(`rbac user with all globally within the ${scenario.spaceId} space`, { + user: scenario.users.allGlobally, + spaceId: scenario.spaceId, + tests: { + spaceAwareType: { + description: 'only the visualization', + statusCode: 200, + response: createExpectVisualizationResults(scenario.spaceId), + }, + notSpaceAwareType: { + description: 'only the globaltype', + statusCode: 200, + response: expectNotSpaceAwareResults, + }, + unknownType: { + description: 'empty result', + statusCode: 200, + response: createExpectEmpty(1, 20, 0), + }, + pageBeyondTotal: { + description: 'empty result', + statusCode: 200, + response: createExpectEmpty(100, 100, 1), + }, + unknownSearchField: { + description: 'empty result', + statusCode: 200, + response: createExpectEmpty(1, 20, 0), + }, + noType: { + description: 'bad request, type is required', + statusCode: 400, + response: expectTypeRequired, + }, + }, + }); + + findTest(`rbac user with read globally within the ${scenario.spaceId} space`, { + user: scenario.users.readGlobally, + spaceId: scenario.spaceId, + tests: { + spaceAwareType: { + description: 'only the visualization', + statusCode: 200, + response: createExpectVisualizationResults(scenario.spaceId), + }, + notSpaceAwareType: { + description: 'only the globaltype', + statusCode: 200, + response: expectNotSpaceAwareResults, + }, + unknownType: { + description: 'forbidden find wigwags message', + statusCode: 403, + response: createExpectRbacForbidden('wigwags'), + }, + pageBeyondTotal: { + description: 'empty result', + statusCode: 200, + response: createExpectEmpty(100, 100, 1), + }, + unknownSearchField: { + description: 'forbidden find wigwags message', + statusCode: 403, + response: createExpectRbacForbidden('wigwags'), + }, + noType: { + description: 'bad request, type is required', + statusCode: 400, + response: expectTypeRequired, + }, + }, + }); + + findTest(`rbac user with all at the space within the ${scenario.spaceId} space`, { + user: scenario.users.allAtSpace, + spaceId: scenario.spaceId, + tests: { + spaceAwareType: { + description: 'only the visualization', + statusCode: 200, + response: createExpectVisualizationResults(scenario.spaceId), + }, + notSpaceAwareType: { + description: 'only the globaltype', + statusCode: 200, + response: expectNotSpaceAwareResults, + }, + unknownType: { + description: 'forbidden and find wigwags message', + statusCode: 403, + response: createExpectRbacForbidden('wigwags'), + }, + pageBeyondTotal: { + description: 'empty result', + statusCode: 200, + response: createExpectEmpty(100, 100, 1), + }, + unknownSearchField: { + description: 'forbidden and find wigwags message', + statusCode: 403, + response: createExpectRbacForbidden('wigwags'), + }, + noType: { + description: 'bad request, type is required', + statusCode: 400, + response: expectTypeRequired, + }, + }, + }); + + findTest(`rbac user with read at the space within the ${scenario.spaceId} space`, { + user: scenario.users.readAtSpace, + spaceId: scenario.spaceId, + tests: { + spaceAwareType: { + description: 'only the visualization', + statusCode: 200, + response: createExpectVisualizationResults(scenario.spaceId), + }, + notSpaceAwareType: { + description: 'only the globaltype', + statusCode: 200, + response: expectNotSpaceAwareResults, + }, + unknownType: { + description: 'forbidden and find wigwags message', + statusCode: 403, + response: createExpectRbacForbidden('wigwags'), + }, + pageBeyondTotal: { + description: 'empty result', + statusCode: 200, + response: createExpectEmpty(100, 100, 1), + }, + unknownSearchField: { + description: 'forbidden and find wigwags message', + statusCode: 403, + response: createExpectRbacForbidden('wigwags'), + }, + noType: { + description: 'bad request, type is required', + statusCode: 400, + response: expectTypeRequired, + }, + }, + }); + + findTest(`rbac user with all at other space within the ${scenario.spaceId} space`, { + user: scenario.users.allAtOtherSpace, + spaceId: scenario.spaceId, + tests: { + spaceAwareType: { + description: 'forbidden login and find visualization message', + statusCode: 403, + response: createExpectRbacForbidden('visualization'), + }, + notSpaceAwareType: { + description: 'forbidden login and find globaltype message', + statusCode: 403, + response: createExpectRbacForbidden('globaltype'), + }, + unknownType: { + description: 'forbidden login and find wigwags message', + statusCode: 403, + response: createExpectRbacForbidden('wigwags'), + }, + pageBeyondTotal: { + description: 'forbidden login and find visualization message', + statusCode: 403, + response: createExpectRbacForbidden('visualization'), + }, + unknownSearchField: { + description: 'forbidden login and find wigwags message', + statusCode: 403, + response: createExpectRbacForbidden('wigwags'), + }, + noType: { + description: 'bad request, type is required', + statusCode: 400, + response: expectTypeRequired, + }, + }, + }); + }); + }); +} diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/get.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/get.ts new file mode 100644 index 00000000000000..eaa374098bd339 --- /dev/null +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/get.ts @@ -0,0 +1,272 @@ +/* + * 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 { AUTHENTICATION } from '../../common/lib/authentication'; +import { SPACES } from '../../common/lib/spaces'; +import { TestInvoker } from '../../common/lib/types'; +import { getTestSuiteFactory } from '../../common/suites/get'; + +// tslint:disable:no-default-export +export default function({ getService }: TestInvoker) { + const supertest = getService('supertestWithoutAuth'); + const esArchiver = getService('esArchiver'); + + const { + createExpectDoesntExistNotFound, + createExpectLegacyForbidden, + createExpectSpaceAwareRbacForbidden, + createExpectSpaceAwareResults, + createExpectNotSpaceAwareResults, + createExpectNotSpaceAwareRbacForbidden, + getTest, + } = getTestSuiteFactory(esArchiver, supertest); + + describe('get', () => { + [ + { + spaceId: SPACES.DEFAULT.spaceId, + users: { + noAccess: AUTHENTICATION.NOT_A_KIBANA_USER, + superuser: AUTHENTICATION.SUPERUSER, + legacyAll: AUTHENTICATION.KIBANA_LEGACY_USER, + legacyRead: AUTHENTICATION.KIBANA_LEGACY_DASHBOARD_ONLY_USER, + allGlobally: AUTHENTICATION.KIBANA_RBAC_USER, + readGlobally: AUTHENTICATION.KIBANA_RBAC_DASHBOARD_ONLY_USER, + dualAll: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_USER, + dualRead: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER, + allAtSpace: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_ALL_USER, + readAtSpace: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_READ_USER, + allAtOtherSpace: AUTHENTICATION.KIBANA_RBAC_SPACE_1_ALL_USER, + }, + }, + { + spaceId: SPACES.SPACE_1.spaceId, + users: { + noAccess: AUTHENTICATION.NOT_A_KIBANA_USER, + superuser: AUTHENTICATION.SUPERUSER, + legacyAll: AUTHENTICATION.KIBANA_LEGACY_USER, + legacyRead: AUTHENTICATION.KIBANA_LEGACY_DASHBOARD_ONLY_USER, + allGlobally: AUTHENTICATION.KIBANA_RBAC_USER, + readGlobally: AUTHENTICATION.KIBANA_RBAC_DASHBOARD_ONLY_USER, + dualAll: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_USER, + dualRead: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER, + allAtSpace: AUTHENTICATION.KIBANA_RBAC_SPACE_1_ALL_USER, + readAtSpace: AUTHENTICATION.KIBANA_RBAC_SPACE_1_READ_USER, + allAtOtherSpace: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_ALL_USER, + }, + }, + ].forEach(scenario => { + getTest(`user with no access within the ${scenario.spaceId} space`, { + user: scenario.users.noAccess, + spaceId: scenario.spaceId, + tests: { + spaceAware: { + statusCode: 403, + response: createExpectLegacyForbidden(scenario.users.noAccess.username), + }, + notSpaceAware: { + statusCode: 403, + response: createExpectLegacyForbidden(scenario.users.noAccess.username), + }, + doesntExist: { + statusCode: 403, + response: createExpectLegacyForbidden(scenario.users.noAccess.username), + }, + }, + }); + + getTest(`superuser within the ${scenario.spaceId} space`, { + user: scenario.users.superuser, + spaceId: scenario.spaceId, + tests: { + spaceAware: { + statusCode: 200, + response: createExpectSpaceAwareResults(scenario.spaceId), + }, + notSpaceAware: { + statusCode: 200, + response: createExpectNotSpaceAwareResults(scenario.spaceId), + }, + doesntExist: { + statusCode: 404, + response: createExpectDoesntExistNotFound(scenario.spaceId), + }, + }, + }); + + getTest(`legacy user within the ${scenario.spaceId} space`, { + user: scenario.users.legacyAll, + spaceId: scenario.spaceId, + tests: { + spaceAware: { + statusCode: 200, + response: createExpectSpaceAwareResults(scenario.spaceId), + }, + notSpaceAware: { + statusCode: 200, + response: createExpectNotSpaceAwareResults(scenario.spaceId), + }, + doesntExist: { + statusCode: 404, + response: createExpectDoesntExistNotFound(scenario.spaceId), + }, + }, + }); + + getTest(`legacy readonly user within the ${scenario.spaceId} space`, { + user: scenario.users.legacyRead, + spaceId: scenario.spaceId, + tests: { + spaceAware: { + statusCode: 200, + response: createExpectSpaceAwareResults(scenario.spaceId), + }, + notSpaceAware: { + statusCode: 200, + response: createExpectNotSpaceAwareResults(scenario.spaceId), + }, + doesntExist: { + statusCode: 404, + response: createExpectDoesntExistNotFound(scenario.spaceId), + }, + }, + }); + + getTest(`dual-privileges user within the ${scenario.spaceId} space`, { + user: scenario.users.dualAll, + spaceId: scenario.spaceId, + tests: { + spaceAware: { + statusCode: 200, + response: createExpectSpaceAwareResults(scenario.spaceId), + }, + notSpaceAware: { + statusCode: 200, + response: createExpectNotSpaceAwareResults(scenario.spaceId), + }, + doesntExist: { + statusCode: 404, + response: createExpectDoesntExistNotFound(scenario.spaceId), + }, + }, + }); + + getTest(`dual-privileges readonly user within the ${scenario.spaceId} space`, { + user: scenario.users.dualRead, + spaceId: scenario.spaceId, + tests: { + spaceAware: { + statusCode: 200, + response: createExpectSpaceAwareResults(scenario.spaceId), + }, + notSpaceAware: { + statusCode: 200, + response: createExpectNotSpaceAwareResults(scenario.spaceId), + }, + doesntExist: { + statusCode: 404, + response: createExpectDoesntExistNotFound(scenario.spaceId), + }, + }, + }); + + getTest(`rbac user with all globally within the ${scenario.spaceId} space`, { + user: scenario.users.allGlobally, + spaceId: scenario.spaceId, + tests: { + spaceAware: { + statusCode: 200, + response: createExpectSpaceAwareResults(scenario.spaceId), + }, + notSpaceAware: { + statusCode: 200, + response: createExpectNotSpaceAwareResults(scenario.spaceId), + }, + doesntExist: { + statusCode: 404, + response: createExpectDoesntExistNotFound(scenario.spaceId), + }, + }, + }); + + getTest(`rbac user with read globall within the ${scenario.spaceId} space`, { + user: scenario.users.readGlobally, + spaceId: scenario.spaceId, + tests: { + spaceAware: { + statusCode: 200, + response: createExpectSpaceAwareResults(scenario.spaceId), + }, + notSpaceAware: { + statusCode: 200, + response: createExpectNotSpaceAwareResults(scenario.spaceId), + }, + doesntExist: { + statusCode: 404, + response: createExpectDoesntExistNotFound(scenario.spaceId), + }, + }, + }); + + getTest(`rbac user with all at the space within the ${scenario.spaceId} space`, { + user: scenario.users.allAtSpace, + spaceId: scenario.spaceId, + tests: { + spaceAware: { + statusCode: 200, + response: createExpectSpaceAwareResults(scenario.spaceId), + }, + notSpaceAware: { + statusCode: 200, + response: createExpectNotSpaceAwareResults(scenario.spaceId), + }, + doesntExist: { + statusCode: 404, + response: createExpectDoesntExistNotFound(scenario.spaceId), + }, + }, + }); + + getTest(`rbac user with read at the space within the ${scenario.spaceId} space`, { + user: scenario.users.readAtSpace, + spaceId: scenario.spaceId, + tests: { + spaceAware: { + statusCode: 200, + response: createExpectSpaceAwareResults(scenario.spaceId), + }, + notSpaceAware: { + statusCode: 200, + response: createExpectNotSpaceAwareResults(scenario.spaceId), + }, + doesntExist: { + statusCode: 404, + response: createExpectDoesntExistNotFound(scenario.spaceId), + }, + }, + }); + + getTest(`rbac user with all at other space within the ${scenario.spaceId} space`, { + user: scenario.users.allAtOtherSpace, + spaceId: scenario.spaceId, + tests: { + spaceAware: { + statusCode: 403, + response: createExpectSpaceAwareRbacForbidden(), + }, + notSpaceAware: { + statusCode: 403, + response: createExpectNotSpaceAwareRbacForbidden(), + }, + doesntExist: { + statusCode: 403, + response: createExpectSpaceAwareRbacForbidden(), + }, + }, + }); + }); + }); +} diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/index.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/index.ts new file mode 100644 index 00000000000000..d25a9b852b789d --- /dev/null +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/index.ts @@ -0,0 +1,28 @@ +/* + * 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 { createUsersAndRoles } from '../../common/lib/create_users_and_roles'; +import { TestInvoker } from '../../common/lib/types'; + +// tslint:disable:no-default-export +export default function({ getService, loadTestFile }: TestInvoker) { + const es = getService('es'); + const supertest = getService('supertest'); + + describe('saved objects security and spaces enabled', () => { + before(async () => { + await createUsersAndRoles(es, supertest); + }); + + loadTestFile(require.resolve('./bulk_create')); + loadTestFile(require.resolve('./bulk_get')); + loadTestFile(require.resolve('./create')); + loadTestFile(require.resolve('./delete')); + loadTestFile(require.resolve('./find')); + loadTestFile(require.resolve('./get')); + loadTestFile(require.resolve('./update')); + }); +} diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/update.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/update.ts new file mode 100644 index 00000000000000..fa02c2c6e60b38 --- /dev/null +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/update.ts @@ -0,0 +1,273 @@ +/* + * 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 { AUTHENTICATION } from '../../common/lib/authentication'; +import { SPACES } from '../../common/lib/spaces'; +import { TestInvoker } from '../../common/lib/types'; +import { updateTestSuiteFactory } from '../../common/suites/update'; + +// tslint:disable:no-default-export +export default function({ getService }: TestInvoker) { + const supertest = getService('supertestWithoutAuth'); + const esArchiver = getService('esArchiver'); + + describe('update', () => { + const { + createExpectLegacyForbidden, + createExpectDoesntExistNotFound, + expectDoesntExistRbacForbidden, + expectNotSpaceAwareResults, + expectNotSpaceAwareRbacForbidden, + expectSpaceAwareRbacForbidden, + expectSpaceAwareResults, + updateTest, + } = updateTestSuiteFactory(esArchiver, supertest); + + [ + { + spaceId: SPACES.DEFAULT.spaceId, + users: { + noAccess: AUTHENTICATION.NOT_A_KIBANA_USER, + superuser: AUTHENTICATION.SUPERUSER, + legacyAll: AUTHENTICATION.KIBANA_LEGACY_USER, + legacyRead: AUTHENTICATION.KIBANA_LEGACY_DASHBOARD_ONLY_USER, + allGlobally: AUTHENTICATION.KIBANA_RBAC_USER, + readGlobally: AUTHENTICATION.KIBANA_RBAC_DASHBOARD_ONLY_USER, + dualAll: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_USER, + dualRead: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER, + allAtSpace: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_ALL_USER, + readAtSpace: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_READ_USER, + allAtOtherSpace: AUTHENTICATION.KIBANA_RBAC_SPACE_1_ALL_USER, + }, + }, + { + spaceId: SPACES.SPACE_1.spaceId, + users: { + noAccess: AUTHENTICATION.NOT_A_KIBANA_USER, + superuser: AUTHENTICATION.SUPERUSER, + legacyAll: AUTHENTICATION.KIBANA_LEGACY_USER, + legacyRead: AUTHENTICATION.KIBANA_LEGACY_DASHBOARD_ONLY_USER, + allGlobally: AUTHENTICATION.KIBANA_RBAC_USER, + readGlobally: AUTHENTICATION.KIBANA_RBAC_DASHBOARD_ONLY_USER, + dualAll: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_USER, + dualRead: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER, + allAtSpace: AUTHENTICATION.KIBANA_RBAC_SPACE_1_ALL_USER, + readAtSpace: AUTHENTICATION.KIBANA_RBAC_SPACE_1_READ_USER, + allAtOtherSpace: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_ALL_USER, + }, + }, + ].forEach(scenario => { + updateTest(`user with no access within the ${scenario.spaceId} space`, { + user: scenario.users.noAccess, + spaceId: scenario.spaceId, + tests: { + spaceAware: { + statusCode: 403, + response: createExpectLegacyForbidden(AUTHENTICATION.NOT_A_KIBANA_USER.username), + }, + notSpaceAware: { + statusCode: 403, + response: createExpectLegacyForbidden(AUTHENTICATION.NOT_A_KIBANA_USER.username), + }, + doesntExist: { + statusCode: 403, + response: createExpectLegacyForbidden(AUTHENTICATION.NOT_A_KIBANA_USER.username), + }, + }, + }); + + updateTest(`superuser within the ${scenario.spaceId} space`, { + user: scenario.users.superuser, + spaceId: scenario.spaceId, + tests: { + spaceAware: { + statusCode: 200, + response: expectSpaceAwareResults, + }, + notSpaceAware: { + statusCode: 200, + response: expectNotSpaceAwareResults, + }, + doesntExist: { + statusCode: 404, + response: createExpectDoesntExistNotFound(scenario.spaceId), + }, + }, + }); + + updateTest(`legacy user within the ${scenario.spaceId} space`, { + user: scenario.users.legacyAll, + spaceId: scenario.spaceId, + tests: { + spaceAware: { + statusCode: 200, + response: expectSpaceAwareResults, + }, + notSpaceAware: { + statusCode: 200, + response: expectNotSpaceAwareResults, + }, + doesntExist: { + statusCode: 404, + response: createExpectDoesntExistNotFound(scenario.spaceId), + }, + }, + }); + + updateTest(`legacy readonly user within the ${scenario.spaceId} space`, { + user: scenario.users.legacyRead, + spaceId: scenario.spaceId, + tests: { + spaceAware: { + statusCode: 403, + response: createExpectLegacyForbidden(scenario.users.legacyRead.username), + }, + notSpaceAware: { + statusCode: 403, + response: createExpectLegacyForbidden(scenario.users.legacyRead.username), + }, + doesntExist: { + statusCode: 403, + response: createExpectLegacyForbidden(scenario.users.legacyRead.username), + }, + }, + }); + + updateTest(`dual-privileges user within the ${scenario.spaceId} space`, { + user: scenario.users.dualAll, + spaceId: scenario.spaceId, + tests: { + spaceAware: { + statusCode: 200, + response: expectSpaceAwareResults, + }, + notSpaceAware: { + statusCode: 200, + response: expectNotSpaceAwareResults, + }, + doesntExist: { + statusCode: 404, + response: createExpectDoesntExistNotFound(scenario.spaceId), + }, + }, + }); + + updateTest(`dual-privileges readonly user within the ${scenario.spaceId} space`, { + user: scenario.users.dualRead, + spaceId: scenario.spaceId, + tests: { + spaceAware: { + statusCode: 403, + response: expectSpaceAwareRbacForbidden, + }, + notSpaceAware: { + statusCode: 403, + response: expectNotSpaceAwareRbacForbidden, + }, + doesntExist: { + statusCode: 403, + response: expectDoesntExistRbacForbidden, + }, + }, + }); + + updateTest(`rbac user with all globally within the ${scenario.spaceId} space`, { + user: scenario.users.allGlobally, + spaceId: scenario.spaceId, + tests: { + spaceAware: { + statusCode: 200, + response: expectSpaceAwareResults, + }, + notSpaceAware: { + statusCode: 200, + response: expectNotSpaceAwareResults, + }, + doesntExist: { + statusCode: 404, + response: createExpectDoesntExistNotFound(scenario.spaceId), + }, + }, + }); + + updateTest(`rbac user with read globally within the ${scenario.spaceId} space`, { + user: scenario.users.readGlobally, + spaceId: scenario.spaceId, + tests: { + spaceAware: { + statusCode: 403, + response: expectSpaceAwareRbacForbidden, + }, + notSpaceAware: { + statusCode: 403, + response: expectNotSpaceAwareRbacForbidden, + }, + doesntExist: { + statusCode: 403, + response: expectDoesntExistRbacForbidden, + }, + }, + }); + + updateTest(`rbac user with all at the space within the ${scenario.spaceId} space`, { + user: scenario.users.allAtSpace, + spaceId: scenario.spaceId, + tests: { + spaceAware: { + statusCode: 200, + response: expectSpaceAwareResults, + }, + notSpaceAware: { + statusCode: 200, + response: expectNotSpaceAwareResults, + }, + doesntExist: { + statusCode: 404, + response: createExpectDoesntExistNotFound(scenario.spaceId), + }, + }, + }); + + updateTest(`rbac user with read at the space within the ${scenario.spaceId} space`, { + user: scenario.users.readAtSpace, + spaceId: scenario.spaceId, + tests: { + spaceAware: { + statusCode: 403, + response: expectSpaceAwareRbacForbidden, + }, + notSpaceAware: { + statusCode: 403, + response: expectNotSpaceAwareRbacForbidden, + }, + doesntExist: { + statusCode: 403, + response: expectDoesntExistRbacForbidden, + }, + }, + }); + + updateTest(`rbac user with all at other space within the ${scenario.spaceId} space`, { + user: scenario.users.allAtOtherSpace, + spaceId: scenario.spaceId, + tests: { + spaceAware: { + statusCode: 403, + response: expectSpaceAwareRbacForbidden, + }, + notSpaceAware: { + statusCode: 403, + response: expectNotSpaceAwareRbacForbidden, + }, + doesntExist: { + statusCode: 403, + response: expectDoesntExistRbacForbidden, + }, + }, + }); + }); + }); +} diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/config.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/config.ts new file mode 100644 index 00000000000000..81cf9d85671d18 --- /dev/null +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/config.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { createTestConfig } from '../common/config'; + +// tslint:disable:no-default-export +export default createTestConfig('security_and_spaces', { license: 'trial' }); diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/bulk_create.ts b/x-pack/test/saved_object_api_integration/security_only/apis/bulk_create.ts new file mode 100644 index 00000000000000..4e7d9ea6fb148f --- /dev/null +++ b/x-pack/test/saved_object_api_integration/security_only/apis/bulk_create.ts @@ -0,0 +1,155 @@ +/* + * 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 { AUTHENTICATION } from '../../common/lib/authentication'; +import { TestInvoker } from '../../common/lib/types'; +import { bulkCreateTestSuiteFactory } from '../../common/suites/bulk_create'; + +// tslint:disable:no-default-export +export default function({ getService }: TestInvoker) { + const supertest = getService('supertestWithoutAuth'); + const esArchiver = getService('esArchiver'); + const es = getService('es'); + + const { + bulkCreateTest, + createExpectLegacyForbidden, + createExpectResults, + expectRbacForbidden, + } = bulkCreateTestSuiteFactory(es, esArchiver, supertest); + + describe('_bulk_create', () => { + bulkCreateTest(`user with no access`, { + user: AUTHENTICATION.NOT_A_KIBANA_USER, + tests: { + default: { + statusCode: 403, + response: createExpectLegacyForbidden(AUTHENTICATION.NOT_A_KIBANA_USER.username), + }, + }, + }); + + bulkCreateTest(`superuser`, { + user: AUTHENTICATION.SUPERUSER, + tests: { + default: { + statusCode: 200, + response: createExpectResults(), + }, + }, + }); + + bulkCreateTest(`legacy user`, { + user: AUTHENTICATION.KIBANA_LEGACY_USER, + tests: { + default: { + statusCode: 200, + response: createExpectResults(), + }, + }, + }); + + bulkCreateTest(`legacy readonly user`, { + user: AUTHENTICATION.KIBANA_LEGACY_DASHBOARD_ONLY_USER, + tests: { + default: { + statusCode: 403, + response: createExpectLegacyForbidden( + AUTHENTICATION.KIBANA_LEGACY_DASHBOARD_ONLY_USER.username + ), + }, + }, + }); + + bulkCreateTest(`dual-privileges user`, { + user: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_USER, + tests: { + default: { + statusCode: 200, + response: createExpectResults(), + }, + }, + }); + + bulkCreateTest(`dual-privileges readonly user`, { + user: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER, + tests: { + default: { + statusCode: 403, + response: expectRbacForbidden, + }, + }, + }); + + bulkCreateTest(`rbac user with all globally`, { + user: AUTHENTICATION.KIBANA_RBAC_USER, + tests: { + default: { + statusCode: 200, + response: createExpectResults(), + }, + }, + }); + + bulkCreateTest(`rbac readonly user`, { + user: AUTHENTICATION.KIBANA_RBAC_DASHBOARD_ONLY_USER, + tests: { + default: { + statusCode: 403, + response: expectRbacForbidden, + }, + }, + }); + + bulkCreateTest(`rbac user with all at default space`, { + user: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_ALL_USER, + tests: { + default: { + statusCode: 403, + response: createExpectLegacyForbidden( + AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_ALL_USER.username + ), + }, + }, + }); + + bulkCreateTest(`rbac user with read at default space`, { + user: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_READ_USER, + tests: { + default: { + statusCode: 403, + response: createExpectLegacyForbidden( + AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_READ_USER.username + ), + }, + }, + }); + + bulkCreateTest(`rbac user with all at space_1`, { + user: AUTHENTICATION.KIBANA_RBAC_SPACE_1_ALL_USER, + tests: { + default: { + statusCode: 403, + response: createExpectLegacyForbidden( + AUTHENTICATION.KIBANA_RBAC_SPACE_1_ALL_USER.username + ), + }, + }, + }); + + bulkCreateTest(`rbac user with read at space_1`, { + user: AUTHENTICATION.KIBANA_RBAC_SPACE_1_READ_USER, + tests: { + default: { + statusCode: 403, + response: createExpectLegacyForbidden( + AUTHENTICATION.KIBANA_RBAC_SPACE_1_READ_USER.username + ), + }, + }, + }); + }); +} diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/bulk_get.ts b/x-pack/test/saved_object_api_integration/security_only/apis/bulk_get.ts new file mode 100644 index 00000000000000..9376eb6b5995ff --- /dev/null +++ b/x-pack/test/saved_object_api_integration/security_only/apis/bulk_get.ts @@ -0,0 +1,150 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { AUTHENTICATION } from '../../common/lib/authentication'; +import { TestInvoker } from '../../common/lib/types'; +import { bulkGetTestSuiteFactory } from '../../common/suites/bulk_get'; + +// tslint:disable:no-default-export +export default function({ getService }: TestInvoker) { + const supertest = getService('supertestWithoutAuth'); + const esArchiver = getService('esArchiver'); + + const { bulkGetTest, createExpectLegacyForbidden, createExpectResults } = bulkGetTestSuiteFactory( + esArchiver, + supertest + ); + + describe('_bulk_get', () => { + bulkGetTest(`user with no access`, { + user: AUTHENTICATION.NOT_A_KIBANA_USER, + tests: { + default: { + statusCode: 403, + response: createExpectLegacyForbidden(AUTHENTICATION.NOT_A_KIBANA_USER.username), + }, + }, + }); + + bulkGetTest(`superuser`, { + user: AUTHENTICATION.SUPERUSER, + tests: { + default: { + statusCode: 200, + response: createExpectResults(), + }, + }, + }); + + bulkGetTest(`legacy user`, { + user: AUTHENTICATION.KIBANA_LEGACY_USER, + tests: { + default: { + statusCode: 200, + response: createExpectResults(), + }, + }, + }); + + bulkGetTest(`legacy reeadonly user`, { + user: AUTHENTICATION.KIBANA_LEGACY_DASHBOARD_ONLY_USER, + tests: { + default: { + statusCode: 200, + response: createExpectResults(), + }, + }, + }); + + bulkGetTest(`dual-privileges user`, { + user: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_USER, + tests: { + default: { + statusCode: 200, + response: createExpectResults(), + }, + }, + }); + + bulkGetTest(`dual-privileges readonly user`, { + user: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER, + tests: { + default: { + statusCode: 200, + response: createExpectResults(), + }, + }, + }); + + bulkGetTest(`rbac user with all globally`, { + user: AUTHENTICATION.KIBANA_RBAC_USER, + tests: { + default: { + statusCode: 200, + response: createExpectResults(), + }, + }, + }); + + bulkGetTest(`rbac user with read globally`, { + user: AUTHENTICATION.KIBANA_RBAC_DASHBOARD_ONLY_USER, + tests: { + default: { + statusCode: 200, + response: createExpectResults(), + }, + }, + }); + + bulkGetTest(`rbac user with all at default space`, { + user: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_ALL_USER, + tests: { + default: { + statusCode: 403, + response: createExpectLegacyForbidden( + AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_ALL_USER.username + ), + }, + }, + }); + + bulkGetTest(`rbac user with read at default space`, { + user: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_READ_USER, + tests: { + default: { + statusCode: 403, + response: createExpectLegacyForbidden( + AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_READ_USER.username + ), + }, + }, + }); + + bulkGetTest(`rbac user with all at space_1`, { + user: AUTHENTICATION.KIBANA_RBAC_SPACE_1_ALL_USER, + tests: { + default: { + statusCode: 403, + response: createExpectLegacyForbidden( + AUTHENTICATION.KIBANA_RBAC_SPACE_1_ALL_USER.username + ), + }, + }, + }); + + bulkGetTest(`rbac user with read at space_1`, { + user: AUTHENTICATION.KIBANA_RBAC_SPACE_1_READ_USER, + tests: { + default: { + statusCode: 403, + response: createExpectLegacyForbidden( + AUTHENTICATION.KIBANA_RBAC_SPACE_1_READ_USER.username + ), + }, + }, + }); + }); +} diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/create.ts b/x-pack/test/saved_object_api_integration/security_only/apis/create.ts new file mode 100644 index 00000000000000..3bceeeeee33b2d --- /dev/null +++ b/x-pack/test/saved_object_api_integration/security_only/apis/create.ts @@ -0,0 +1,215 @@ +/* + * 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 { AUTHENTICATION } from '../../common/lib/authentication'; +import { TestInvoker } from '../../common/lib/types'; +import { createTestSuiteFactory } from '../../common/suites/create'; + +// tslint:disable:no-default-export +export default function({ getService }: TestInvoker) { + const supertestWithoutAuth = getService('supertestWithoutAuth'); + const es = getService('es'); + const esArchiver = getService('esArchiver'); + + const { + createTest, + createExpectLegacyForbidden, + createExpectSpaceAwareResults, + expectNotSpaceAwareResults, + expectNotSpaceAwareRbacForbidden, + expectSpaceAwareRbacForbidden, + } = createTestSuiteFactory(es, esArchiver, supertestWithoutAuth); + + describe('create', () => { + createTest(`user with no access`, { + user: AUTHENTICATION.NOT_A_KIBANA_USER, + tests: { + spaceAware: { + statusCode: 403, + response: createExpectLegacyForbidden(AUTHENTICATION.NOT_A_KIBANA_USER.username), + }, + notSpaceAware: { + statusCode: 403, + response: createExpectLegacyForbidden(AUTHENTICATION.NOT_A_KIBANA_USER.username), + }, + }, + }); + + createTest(`superuser`, { + user: AUTHENTICATION.SUPERUSER, + tests: { + spaceAware: { + statusCode: 200, + response: createExpectSpaceAwareResults(), + }, + notSpaceAware: { + statusCode: 200, + response: expectNotSpaceAwareResults, + }, + }, + }); + + createTest(`legacy user`, { + user: AUTHENTICATION.KIBANA_LEGACY_USER, + tests: { + spaceAware: { + statusCode: 200, + response: createExpectSpaceAwareResults(), + }, + notSpaceAware: { + statusCode: 200, + response: expectNotSpaceAwareResults, + }, + }, + }); + + createTest(`legacy readonly user`, { + user: AUTHENTICATION.KIBANA_LEGACY_DASHBOARD_ONLY_USER, + tests: { + spaceAware: { + statusCode: 403, + response: createExpectLegacyForbidden( + AUTHENTICATION.KIBANA_LEGACY_DASHBOARD_ONLY_USER.username + ), + }, + notSpaceAware: { + statusCode: 403, + response: createExpectLegacyForbidden( + AUTHENTICATION.KIBANA_LEGACY_DASHBOARD_ONLY_USER.username + ), + }, + }, + }); + + createTest(`dual-privileges user`, { + user: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_USER, + tests: { + spaceAware: { + statusCode: 200, + response: createExpectSpaceAwareResults(), + }, + notSpaceAware: { + statusCode: 200, + response: expectNotSpaceAwareResults, + }, + }, + }); + + createTest(`dual-privileges readonly user`, { + user: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER, + tests: { + spaceAware: { + statusCode: 403, + response: expectSpaceAwareRbacForbidden, + }, + notSpaceAware: { + statusCode: 403, + response: expectNotSpaceAwareRbacForbidden, + }, + }, + }); + + createTest(`rbac user with all globally`, { + user: AUTHENTICATION.KIBANA_RBAC_USER, + tests: { + spaceAware: { + statusCode: 200, + response: createExpectSpaceAwareResults(), + }, + notSpaceAware: { + statusCode: 200, + response: expectNotSpaceAwareResults, + }, + }, + }); + + createTest(`rbac user with read globally`, { + user: AUTHENTICATION.KIBANA_RBAC_DASHBOARD_ONLY_USER, + tests: { + spaceAware: { + statusCode: 403, + response: expectSpaceAwareRbacForbidden, + }, + notSpaceAware: { + statusCode: 403, + response: expectNotSpaceAwareRbacForbidden, + }, + }, + }); + + createTest(`rbac user with all at default space`, { + user: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_ALL_USER, + tests: { + spaceAware: { + statusCode: 403, + response: createExpectLegacyForbidden( + AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_ALL_USER.username + ), + }, + notSpaceAware: { + statusCode: 403, + response: createExpectLegacyForbidden( + AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_ALL_USER.username + ), + }, + }, + }); + + createTest(`rbac user with read at default space`, { + user: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_READ_USER, + tests: { + spaceAware: { + statusCode: 403, + response: createExpectLegacyForbidden( + AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_READ_USER.username + ), + }, + notSpaceAware: { + statusCode: 403, + response: createExpectLegacyForbidden( + AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_READ_USER.username + ), + }, + }, + }); + + createTest(`rbac user with all at space_1`, { + user: AUTHENTICATION.KIBANA_RBAC_SPACE_1_ALL_USER, + tests: { + spaceAware: { + statusCode: 403, + response: createExpectLegacyForbidden( + AUTHENTICATION.KIBANA_RBAC_SPACE_1_ALL_USER.username + ), + }, + notSpaceAware: { + statusCode: 403, + response: createExpectLegacyForbidden( + AUTHENTICATION.KIBANA_RBAC_SPACE_1_ALL_USER.username + ), + }, + }, + }); + + createTest(`rbac user with read at space_1`, { + user: AUTHENTICATION.KIBANA_RBAC_SPACE_1_READ_USER, + tests: { + spaceAware: { + statusCode: 403, + response: createExpectLegacyForbidden( + AUTHENTICATION.KIBANA_RBAC_SPACE_1_READ_USER.username + ), + }, + notSpaceAware: { + statusCode: 403, + response: createExpectLegacyForbidden( + AUTHENTICATION.KIBANA_RBAC_SPACE_1_READ_USER.username + ), + }, + }, + }); + }); +} diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/delete.ts b/x-pack/test/saved_object_api_integration/security_only/apis/delete.ts new file mode 100644 index 00000000000000..27b2375ae9129e --- /dev/null +++ b/x-pack/test/saved_object_api_integration/security_only/apis/delete.ts @@ -0,0 +1,273 @@ +/* + * 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 { AUTHENTICATION } from '../../common/lib/authentication'; +import { TestInvoker } from '../../common/lib/types'; +import { deleteTestSuiteFactory } from '../../common/suites/delete'; + +// tslint:disable:no-default-export +export default function({ getService }: TestInvoker) { + const supertest = getService('supertestWithoutAuth'); + const esArchiver = getService('esArchiver'); + + describe('delete', () => { + const { + createExpectLegacyForbidden, + createExpectUnknownDocNotFound, + deleteTest, + expectEmpty, + expectRbacSpaceAwareForbidden, + expectRbacNotSpaceAwareForbidden, + expectRbacInvalidIdForbidden, + } = deleteTestSuiteFactory(esArchiver, supertest); + + deleteTest(`user with no access`, { + user: AUTHENTICATION.NOT_A_KIBANA_USER, + tests: { + spaceAware: { + statusCode: 403, + response: createExpectLegacyForbidden(AUTHENTICATION.NOT_A_KIBANA_USER.username), + }, + notSpaceAware: { + statusCode: 403, + response: createExpectLegacyForbidden(AUTHENTICATION.NOT_A_KIBANA_USER.username), + }, + invalidId: { + statusCode: 403, + response: createExpectLegacyForbidden(AUTHENTICATION.NOT_A_KIBANA_USER.username), + }, + }, + }); + + deleteTest(`superuser`, { + user: AUTHENTICATION.SUPERUSER, + tests: { + spaceAware: { + statusCode: 200, + response: expectEmpty, + }, + notSpaceAware: { + statusCode: 200, + response: expectEmpty, + }, + invalidId: { + statusCode: 404, + response: createExpectUnknownDocNotFound(), + }, + }, + }); + + deleteTest(`legacy user`, { + user: AUTHENTICATION.KIBANA_LEGACY_USER, + tests: { + spaceAware: { + statusCode: 200, + response: expectEmpty, + }, + notSpaceAware: { + statusCode: 200, + response: expectEmpty, + }, + invalidId: { + statusCode: 404, + response: createExpectUnknownDocNotFound(), + }, + }, + }); + + deleteTest(`legacy readonly user`, { + user: AUTHENTICATION.KIBANA_LEGACY_DASHBOARD_ONLY_USER, + tests: { + spaceAware: { + statusCode: 403, + response: createExpectLegacyForbidden( + AUTHENTICATION.KIBANA_LEGACY_DASHBOARD_ONLY_USER.username + ), + }, + notSpaceAware: { + statusCode: 403, + response: createExpectLegacyForbidden( + AUTHENTICATION.KIBANA_LEGACY_DASHBOARD_ONLY_USER.username + ), + }, + invalidId: { + statusCode: 403, + response: createExpectLegacyForbidden( + AUTHENTICATION.KIBANA_LEGACY_DASHBOARD_ONLY_USER.username + ), + }, + }, + }); + + deleteTest(`dual-privileges user`, { + user: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_USER, + tests: { + spaceAware: { + statusCode: 200, + response: expectEmpty, + }, + notSpaceAware: { + statusCode: 200, + response: expectEmpty, + }, + invalidId: { + statusCode: 404, + response: createExpectUnknownDocNotFound(), + }, + }, + }); + + deleteTest(`dual-privileges readonly user`, { + user: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER, + tests: { + spaceAware: { + statusCode: 403, + response: expectRbacSpaceAwareForbidden, + }, + notSpaceAware: { + statusCode: 403, + response: expectRbacNotSpaceAwareForbidden, + }, + invalidId: { + statusCode: 403, + response: expectRbacInvalidIdForbidden, + }, + }, + }); + + deleteTest(`rbac user with all globally`, { + user: AUTHENTICATION.KIBANA_RBAC_USER, + tests: { + spaceAware: { + statusCode: 200, + response: expectEmpty, + }, + notSpaceAware: { + statusCode: 200, + response: expectEmpty, + }, + invalidId: { + statusCode: 404, + response: createExpectUnknownDocNotFound(), + }, + }, + }); + + deleteTest(`rbac user with read globally`, { + user: AUTHENTICATION.KIBANA_RBAC_DASHBOARD_ONLY_USER, + tests: { + spaceAware: { + statusCode: 403, + response: expectRbacSpaceAwareForbidden, + }, + notSpaceAware: { + statusCode: 403, + response: expectRbacNotSpaceAwareForbidden, + }, + invalidId: { + statusCode: 403, + response: expectRbacInvalidIdForbidden, + }, + }, + }); + + deleteTest(`rbac user with all at default space`, { + user: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_ALL_USER, + tests: { + spaceAware: { + statusCode: 403, + response: createExpectLegacyForbidden( + AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_ALL_USER.username + ), + }, + notSpaceAware: { + statusCode: 403, + response: createExpectLegacyForbidden( + AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_ALL_USER.username + ), + }, + invalidId: { + statusCode: 403, + response: createExpectLegacyForbidden( + AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_ALL_USER.username + ), + }, + }, + }); + + deleteTest(`rbac user with read at default space`, { + user: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_READ_USER, + tests: { + spaceAware: { + statusCode: 403, + response: createExpectLegacyForbidden( + AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_READ_USER.username + ), + }, + notSpaceAware: { + statusCode: 403, + response: createExpectLegacyForbidden( + AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_READ_USER.username + ), + }, + invalidId: { + statusCode: 403, + response: createExpectLegacyForbidden( + AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_READ_USER.username + ), + }, + }, + }); + + deleteTest(`rbac user with all at space_1`, { + user: AUTHENTICATION.KIBANA_RBAC_SPACE_1_ALL_USER, + tests: { + spaceAware: { + statusCode: 403, + response: createExpectLegacyForbidden( + AUTHENTICATION.KIBANA_RBAC_SPACE_1_ALL_USER.username + ), + }, + notSpaceAware: { + statusCode: 403, + response: createExpectLegacyForbidden( + AUTHENTICATION.KIBANA_RBAC_SPACE_1_ALL_USER.username + ), + }, + invalidId: { + statusCode: 403, + response: createExpectLegacyForbidden( + AUTHENTICATION.KIBANA_RBAC_SPACE_1_ALL_USER.username + ), + }, + }, + }); + + deleteTest(`rbac user with readonly at space_1`, { + user: AUTHENTICATION.KIBANA_RBAC_SPACE_1_READ_USER, + tests: { + spaceAware: { + statusCode: 403, + response: createExpectLegacyForbidden( + AUTHENTICATION.KIBANA_RBAC_SPACE_1_READ_USER.username + ), + }, + notSpaceAware: { + statusCode: 403, + response: createExpectLegacyForbidden( + AUTHENTICATION.KIBANA_RBAC_SPACE_1_READ_USER.username + ), + }, + invalidId: { + statusCode: 403, + response: createExpectLegacyForbidden( + AUTHENTICATION.KIBANA_RBAC_SPACE_1_READ_USER.username + ), + }, + }, + }); + }); +} diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/find.ts b/x-pack/test/saved_object_api_integration/security_only/apis/find.ts new file mode 100644 index 00000000000000..ee664e43375c07 --- /dev/null +++ b/x-pack/test/saved_object_api_integration/security_only/apis/find.ts @@ -0,0 +1,499 @@ +/* + * 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 { AUTHENTICATION } from '../../common/lib/authentication'; +import { TestInvoker } from '../../common/lib/types'; +import { findTestSuiteFactory } from '../../common/suites/find'; + +// tslint:disable:no-default-export +export default function({ getService }: TestInvoker) { + const supertest = getService('supertestWithoutAuth'); + const esArchiver = getService('esArchiver'); + + describe('find', () => { + const { + createExpectEmpty, + createExpectRbacForbidden, + createExpectLegacyForbidden, + createExpectVisualizationResults, + expectNotSpaceAwareResults, + expectTypeRequired, + findTest, + } = findTestSuiteFactory(esArchiver, supertest); + + findTest(`user with no access`, { + user: AUTHENTICATION.NOT_A_KIBANA_USER, + tests: { + spaceAwareType: { + description: 'forbidden login and find visualization message', + statusCode: 403, + response: createExpectLegacyForbidden(AUTHENTICATION.NOT_A_KIBANA_USER.username), + }, + notSpaceAwareType: { + description: 'forbidden legacy message', + statusCode: 403, + response: createExpectLegacyForbidden(AUTHENTICATION.NOT_A_KIBANA_USER.username), + }, + unknownType: { + description: 'forbidden login and find wigwags message', + statusCode: 403, + response: createExpectLegacyForbidden(AUTHENTICATION.NOT_A_KIBANA_USER.username), + }, + pageBeyondTotal: { + description: 'forbidden login and find visualization message', + statusCode: 403, + response: createExpectLegacyForbidden(AUTHENTICATION.NOT_A_KIBANA_USER.username), + }, + unknownSearchField: { + description: 'forbidden login and find wigwags message', + statusCode: 403, + response: createExpectLegacyForbidden(AUTHENTICATION.NOT_A_KIBANA_USER.username), + }, + noType: { + description: 'bad request, type is required', + statusCode: 400, + response: expectTypeRequired, + }, + }, + }); + + findTest(`superuser`, { + user: AUTHENTICATION.SUPERUSER, + tests: { + spaceAwareType: { + description: 'only the visualization', + statusCode: 200, + response: createExpectVisualizationResults(), + }, + notSpaceAwareType: { + description: 'only the globaltype', + statusCode: 200, + response: expectNotSpaceAwareResults, + }, + unknownType: { + description: 'empty result', + statusCode: 200, + response: createExpectEmpty(1, 20, 0), + }, + pageBeyondTotal: { + description: 'empty result', + statusCode: 200, + response: createExpectEmpty(100, 100, 1), + }, + unknownSearchField: { + description: 'empty result', + statusCode: 200, + response: createExpectEmpty(1, 20, 0), + }, + noType: { + description: 'bad request, type is required', + statusCode: 400, + response: expectTypeRequired, + }, + }, + }); + + findTest(`legacy user`, { + user: AUTHENTICATION.KIBANA_LEGACY_USER, + tests: { + spaceAwareType: { + description: 'only the visualization', + statusCode: 200, + response: createExpectVisualizationResults(), + }, + notSpaceAwareType: { + description: 'only the globaltype', + statusCode: 200, + response: expectNotSpaceAwareResults, + }, + unknownType: { + description: 'empty result', + statusCode: 200, + response: createExpectEmpty(1, 20, 0), + }, + pageBeyondTotal: { + description: 'empty result', + statusCode: 200, + response: createExpectEmpty(100, 100, 1), + }, + unknownSearchField: { + description: 'empty result', + statusCode: 200, + response: createExpectEmpty(1, 20, 0), + }, + noType: { + description: 'bad request, type is required', + statusCode: 400, + response: expectTypeRequired, + }, + }, + }); + + findTest(`legacy readonly user`, { + user: AUTHENTICATION.KIBANA_LEGACY_DASHBOARD_ONLY_USER, + tests: { + spaceAwareType: { + description: 'only the visualization', + statusCode: 200, + response: createExpectVisualizationResults(), + }, + notSpaceAwareType: { + description: 'only the globaltype', + statusCode: 200, + response: expectNotSpaceAwareResults, + }, + unknownType: { + description: 'empty result', + statusCode: 200, + response: createExpectEmpty(1, 20, 0), + }, + pageBeyondTotal: { + description: 'empty result', + statusCode: 200, + response: createExpectEmpty(100, 100, 1), + }, + unknownSearchField: { + description: 'empty result', + statusCode: 200, + response: createExpectEmpty(1, 20, 0), + }, + noType: { + description: 'bad request, type is required', + statusCode: 400, + response: expectTypeRequired, + }, + }, + }); + + findTest(`dual-privileges user`, { + user: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_USER, + tests: { + spaceAwareType: { + description: 'only the visualization', + statusCode: 200, + response: createExpectVisualizationResults(), + }, + notSpaceAwareType: { + description: 'only the globaltype', + statusCode: 200, + response: expectNotSpaceAwareResults, + }, + unknownType: { + description: 'empty result', + statusCode: 200, + response: createExpectEmpty(1, 20, 0), + }, + pageBeyondTotal: { + description: 'empty result', + statusCode: 200, + response: createExpectEmpty(100, 100, 1), + }, + unknownSearchField: { + description: 'empty result', + statusCode: 200, + response: createExpectEmpty(1, 20, 0), + }, + noType: { + description: 'bad request, type is required', + statusCode: 400, + response: expectTypeRequired, + }, + }, + }); + + findTest(`dual-privileges readonly user`, { + user: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER, + tests: { + spaceAwareType: { + description: 'only the visualization', + statusCode: 200, + response: createExpectVisualizationResults(), + }, + notSpaceAwareType: { + description: 'only the globaltype', + statusCode: 200, + response: expectNotSpaceAwareResults, + }, + unknownType: { + description: 'forbidden find wigwags message', + statusCode: 403, + response: createExpectRbacForbidden('wigwags'), + }, + pageBeyondTotal: { + description: 'empty result', + statusCode: 200, + response: createExpectEmpty(100, 100, 1), + }, + unknownSearchField: { + description: 'forbidden find wigwags message', + statusCode: 403, + response: createExpectRbacForbidden('wigwags'), + }, + noType: { + description: 'bad request, type is required', + statusCode: 400, + response: expectTypeRequired, + }, + }, + }); + + findTest(`rbac user with all globally`, { + user: AUTHENTICATION.KIBANA_RBAC_USER, + tests: { + spaceAwareType: { + description: 'only the visualization', + statusCode: 200, + response: createExpectVisualizationResults(), + }, + notSpaceAwareType: { + description: 'only the globaltype', + statusCode: 200, + response: expectNotSpaceAwareResults, + }, + unknownType: { + description: 'empty result', + statusCode: 200, + response: createExpectEmpty(1, 20, 0), + }, + pageBeyondTotal: { + description: 'empty result', + statusCode: 200, + response: createExpectEmpty(100, 100, 1), + }, + unknownSearchField: { + description: 'empty result', + statusCode: 200, + response: createExpectEmpty(1, 20, 0), + }, + noType: { + description: 'bad request, type is required', + statusCode: 400, + response: expectTypeRequired, + }, + }, + }); + + findTest(`rbac user with read globally`, { + user: AUTHENTICATION.KIBANA_RBAC_DASHBOARD_ONLY_USER, + tests: { + spaceAwareType: { + description: 'only the visualization', + statusCode: 200, + response: createExpectVisualizationResults(), + }, + notSpaceAwareType: { + description: 'only the globaltype', + statusCode: 200, + response: expectNotSpaceAwareResults, + }, + unknownType: { + description: 'forbidden find wigwags message', + statusCode: 403, + response: createExpectRbacForbidden('wigwags'), + }, + pageBeyondTotal: { + description: 'empty result', + statusCode: 200, + response: createExpectEmpty(100, 100, 1), + }, + unknownSearchField: { + description: 'forbidden find wigwags message', + statusCode: 403, + response: createExpectRbacForbidden('wigwags'), + }, + noType: { + description: 'bad request, type is required', + statusCode: 400, + response: expectTypeRequired, + }, + }, + }); + + findTest(`rbac user with all at default space`, { + user: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_ALL_USER, + tests: { + spaceAwareType: { + description: 'only the visualization', + statusCode: 403, + response: createExpectLegacyForbidden( + AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_ALL_USER.username + ), + }, + notSpaceAwareType: { + description: 'only the globaltype', + statusCode: 403, + response: createExpectLegacyForbidden( + AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_ALL_USER.username + ), + }, + unknownType: { + description: 'empty result', + statusCode: 403, + response: createExpectLegacyForbidden( + AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_ALL_USER.username + ), + }, + pageBeyondTotal: { + description: 'empty result', + statusCode: 403, + response: createExpectLegacyForbidden( + AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_ALL_USER.username + ), + }, + unknownSearchField: { + description: 'empty result', + statusCode: 403, + response: createExpectLegacyForbidden( + AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_ALL_USER.username + ), + }, + noType: { + description: 'bad request, type is required', + statusCode: 400, + response: expectTypeRequired, + }, + }, + }); + + findTest(`rbac user with read at default space`, { + user: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_READ_USER, + tests: { + spaceAwareType: { + description: 'only the visualization', + statusCode: 403, + response: createExpectLegacyForbidden( + AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_READ_USER.username + ), + }, + notSpaceAwareType: { + description: 'only the globaltype', + statusCode: 403, + response: createExpectLegacyForbidden( + AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_READ_USER.username + ), + }, + unknownType: { + description: 'empty result', + statusCode: 403, + response: createExpectLegacyForbidden( + AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_READ_USER.username + ), + }, + pageBeyondTotal: { + description: 'empty result', + statusCode: 403, + response: createExpectLegacyForbidden( + AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_READ_USER.username + ), + }, + unknownSearchField: { + description: 'empty result', + statusCode: 403, + response: createExpectLegacyForbidden( + AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_READ_USER.username + ), + }, + noType: { + description: 'bad request, type is required', + statusCode: 400, + response: expectTypeRequired, + }, + }, + }); + + findTest(`rbac user with all at space_1`, { + user: AUTHENTICATION.KIBANA_RBAC_SPACE_1_ALL_USER, + tests: { + spaceAwareType: { + description: 'forbidden login and find visualization message', + statusCode: 403, + response: createExpectLegacyForbidden( + AUTHENTICATION.KIBANA_RBAC_SPACE_1_ALL_USER.username + ), + }, + notSpaceAwareType: { + description: 'only the globaltype', + statusCode: 403, + response: createExpectLegacyForbidden( + AUTHENTICATION.KIBANA_RBAC_SPACE_1_ALL_USER.username + ), + }, + unknownType: { + description: 'forbidden login and find wigwags message', + statusCode: 403, + response: createExpectLegacyForbidden( + AUTHENTICATION.KIBANA_RBAC_SPACE_1_ALL_USER.username + ), + }, + pageBeyondTotal: { + description: 'forbidden login and find visualization message', + statusCode: 403, + response: createExpectLegacyForbidden( + AUTHENTICATION.KIBANA_RBAC_SPACE_1_ALL_USER.username + ), + }, + unknownSearchField: { + description: 'forbidden login and find wigwags message', + statusCode: 403, + response: createExpectLegacyForbidden( + AUTHENTICATION.KIBANA_RBAC_SPACE_1_ALL_USER.username + ), + }, + noType: { + description: 'bad request, type is required', + statusCode: 400, + response: expectTypeRequired, + }, + }, + }); + + findTest(`rbac user with read at space_1`, { + user: AUTHENTICATION.KIBANA_RBAC_SPACE_1_READ_USER, + tests: { + spaceAwareType: { + description: 'forbidden login and find visualization message', + statusCode: 403, + response: createExpectLegacyForbidden( + AUTHENTICATION.KIBANA_RBAC_SPACE_1_READ_USER.username + ), + }, + notSpaceAwareType: { + description: 'only the globaltype', + statusCode: 403, + response: createExpectLegacyForbidden( + AUTHENTICATION.KIBANA_RBAC_SPACE_1_READ_USER.username + ), + }, + unknownType: { + description: 'forbidden login and find wigwags message', + statusCode: 403, + response: createExpectLegacyForbidden( + AUTHENTICATION.KIBANA_RBAC_SPACE_1_READ_USER.username + ), + }, + pageBeyondTotal: { + description: 'forbidden login and find visualization message', + statusCode: 403, + response: createExpectLegacyForbidden( + AUTHENTICATION.KIBANA_RBAC_SPACE_1_READ_USER.username + ), + }, + unknownSearchField: { + description: 'forbidden login and find wigwags message', + statusCode: 403, + response: createExpectLegacyForbidden( + AUTHENTICATION.KIBANA_RBAC_SPACE_1_READ_USER.username + ), + }, + noType: { + description: 'bad request, type is required', + statusCode: 400, + response: expectTypeRequired, + }, + }, + }); + }); +} diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/get.ts b/x-pack/test/saved_object_api_integration/security_only/apis/get.ts new file mode 100644 index 00000000000000..48698a56f892fc --- /dev/null +++ b/x-pack/test/saved_object_api_integration/security_only/apis/get.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 { AUTHENTICATION } from '../../common/lib/authentication'; +import { TestInvoker } from '../../common/lib/types'; +import { getTestSuiteFactory } from '../../common/suites/get'; + +// tslint:disable:no-default-export +export default function({ getService }: TestInvoker) { + const supertest = getService('supertestWithoutAuth'); + const esArchiver = getService('esArchiver'); + + const { + createExpectDoesntExistNotFound, + createExpectLegacyForbidden, + createExpectSpaceAwareResults, + createExpectNotSpaceAwareResults, + getTest, + } = getTestSuiteFactory(esArchiver, supertest); + + describe('get', () => { + getTest(`user with no access`, { + user: AUTHENTICATION.NOT_A_KIBANA_USER, + tests: { + spaceAware: { + statusCode: 403, + response: createExpectLegacyForbidden(AUTHENTICATION.NOT_A_KIBANA_USER.username), + }, + notSpaceAware: { + statusCode: 403, + response: createExpectLegacyForbidden(AUTHENTICATION.NOT_A_KIBANA_USER.username), + }, + doesntExist: { + statusCode: 403, + response: createExpectLegacyForbidden(AUTHENTICATION.NOT_A_KIBANA_USER.username), + }, + }, + }); + + getTest(`superuser`, { + user: AUTHENTICATION.SUPERUSER, + tests: { + spaceAware: { + statusCode: 200, + response: createExpectSpaceAwareResults(), + }, + notSpaceAware: { + statusCode: 200, + response: createExpectNotSpaceAwareResults(), + }, + doesntExist: { + statusCode: 404, + response: createExpectDoesntExistNotFound(), + }, + }, + }); + + getTest(`legacy user`, { + user: AUTHENTICATION.KIBANA_LEGACY_USER, + tests: { + spaceAware: { + statusCode: 200, + response: createExpectSpaceAwareResults(), + }, + notSpaceAware: { + statusCode: 200, + response: createExpectNotSpaceAwareResults(), + }, + doesntExist: { + statusCode: 404, + response: createExpectDoesntExistNotFound(), + }, + }, + }); + + getTest(`legacy readonly user`, { + user: AUTHENTICATION.KIBANA_LEGACY_DASHBOARD_ONLY_USER, + tests: { + spaceAware: { + statusCode: 200, + response: createExpectSpaceAwareResults(), + }, + notSpaceAware: { + statusCode: 200, + response: createExpectNotSpaceAwareResults(), + }, + doesntExist: { + statusCode: 404, + response: createExpectDoesntExistNotFound(), + }, + }, + }); + + getTest(`dual-privileges user`, { + user: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_USER, + tests: { + spaceAware: { + statusCode: 200, + response: createExpectSpaceAwareResults(), + }, + notSpaceAware: { + statusCode: 200, + response: createExpectNotSpaceAwareResults(), + }, + doesntExist: { + statusCode: 404, + response: createExpectDoesntExistNotFound(), + }, + }, + }); + + getTest(`dual-privileges readonly user`, { + user: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER, + tests: { + spaceAware: { + statusCode: 200, + response: createExpectSpaceAwareResults(), + }, + notSpaceAware: { + statusCode: 200, + response: createExpectNotSpaceAwareResults(), + }, + doesntExist: { + statusCode: 404, + response: createExpectDoesntExistNotFound(), + }, + }, + }); + + getTest(`rbac user with all globally`, { + user: AUTHENTICATION.KIBANA_RBAC_USER, + tests: { + spaceAware: { + statusCode: 200, + response: createExpectSpaceAwareResults(), + }, + notSpaceAware: { + statusCode: 200, + response: createExpectNotSpaceAwareResults(), + }, + doesntExist: { + statusCode: 404, + response: createExpectDoesntExistNotFound(), + }, + }, + }); + + getTest(`rbac user with read globally`, { + user: AUTHENTICATION.KIBANA_RBAC_DASHBOARD_ONLY_USER, + tests: { + spaceAware: { + statusCode: 200, + response: createExpectSpaceAwareResults(), + }, + notSpaceAware: { + statusCode: 200, + response: createExpectNotSpaceAwareResults(), + }, + doesntExist: { + statusCode: 404, + response: createExpectDoesntExistNotFound(), + }, + }, + }); + + getTest(`rbac user with all at default space`, { + user: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_ALL_USER, + tests: { + spaceAware: { + statusCode: 403, + response: createExpectLegacyForbidden( + AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_ALL_USER.username + ), + }, + notSpaceAware: { + statusCode: 403, + response: createExpectLegacyForbidden( + AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_ALL_USER.username + ), + }, + doesntExist: { + statusCode: 403, + response: createExpectLegacyForbidden( + AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_ALL_USER.username + ), + }, + }, + }); + + getTest(`rbac user with read at default space`, { + user: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_READ_USER, + tests: { + spaceAware: { + statusCode: 403, + response: createExpectLegacyForbidden( + AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_READ_USER.username + ), + }, + notSpaceAware: { + statusCode: 403, + response: createExpectLegacyForbidden( + AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_READ_USER.username + ), + }, + doesntExist: { + statusCode: 403, + response: createExpectLegacyForbidden( + AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_READ_USER.username + ), + }, + }, + }); + + getTest(`rbac user with all at space_1`, { + user: AUTHENTICATION.KIBANA_RBAC_SPACE_1_ALL_USER, + tests: { + spaceAware: { + statusCode: 403, + response: createExpectLegacyForbidden( + AUTHENTICATION.KIBANA_RBAC_SPACE_1_ALL_USER.username + ), + }, + notSpaceAware: { + statusCode: 403, + response: createExpectLegacyForbidden( + AUTHENTICATION.KIBANA_RBAC_SPACE_1_ALL_USER.username + ), + }, + doesntExist: { + statusCode: 403, + response: createExpectLegacyForbidden( + AUTHENTICATION.KIBANA_RBAC_SPACE_1_ALL_USER.username + ), + }, + }, + }); + + getTest(`rbac user with read at space_1`, { + user: AUTHENTICATION.KIBANA_RBAC_SPACE_1_READ_USER, + tests: { + spaceAware: { + statusCode: 403, + response: createExpectLegacyForbidden( + AUTHENTICATION.KIBANA_RBAC_SPACE_1_READ_USER.username + ), + }, + notSpaceAware: { + statusCode: 403, + response: createExpectLegacyForbidden( + AUTHENTICATION.KIBANA_RBAC_SPACE_1_READ_USER.username + ), + }, + doesntExist: { + statusCode: 403, + response: createExpectLegacyForbidden( + AUTHENTICATION.KIBANA_RBAC_SPACE_1_READ_USER.username + ), + }, + }, + }); + }); +} diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/index.ts b/x-pack/test/saved_object_api_integration/security_only/apis/index.ts new file mode 100644 index 00000000000000..c9be7152f96eae --- /dev/null +++ b/x-pack/test/saved_object_api_integration/security_only/apis/index.ts @@ -0,0 +1,28 @@ +/* + * 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 { createUsersAndRoles } from '../../common/lib/create_users_and_roles'; +import { TestInvoker } from '../../common/lib/types'; + +// tslint:disable:no-default-export +export default function({ getService, loadTestFile }: TestInvoker) { + const es = getService('es'); + const supertest = getService('supertest'); + + describe('saved objects security only enabled', () => { + before(async () => { + await createUsersAndRoles(es, supertest); + }); + + loadTestFile(require.resolve('./bulk_create')); + loadTestFile(require.resolve('./bulk_get')); + loadTestFile(require.resolve('./create')); + loadTestFile(require.resolve('./delete')); + loadTestFile(require.resolve('./find')); + loadTestFile(require.resolve('./get')); + loadTestFile(require.resolve('./update')); + }); +} diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/update.ts b/x-pack/test/saved_object_api_integration/security_only/apis/update.ts new file mode 100644 index 00000000000000..de506b4186e056 --- /dev/null +++ b/x-pack/test/saved_object_api_integration/security_only/apis/update.ts @@ -0,0 +1,274 @@ +/* + * 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 { AUTHENTICATION } from '../../common/lib/authentication'; +import { TestInvoker } from '../../common/lib/types'; +import { updateTestSuiteFactory } from '../../common/suites/update'; + +// tslint:disable:no-default-export +export default function({ getService }: TestInvoker) { + const supertest = getService('supertestWithoutAuth'); + const esArchiver = getService('esArchiver'); + + describe('update', () => { + const { + createExpectDoesntExistNotFound, + createExpectLegacyForbidden, + expectDoesntExistRbacForbidden, + expectNotSpaceAwareResults, + expectNotSpaceAwareRbacForbidden, + expectSpaceAwareRbacForbidden, + expectSpaceAwareResults, + updateTest, + } = updateTestSuiteFactory(esArchiver, supertest); + + updateTest(`user with no access`, { + user: AUTHENTICATION.NOT_A_KIBANA_USER, + tests: { + spaceAware: { + statusCode: 403, + response: createExpectLegacyForbidden(AUTHENTICATION.NOT_A_KIBANA_USER.username), + }, + notSpaceAware: { + statusCode: 403, + response: createExpectLegacyForbidden(AUTHENTICATION.NOT_A_KIBANA_USER.username), + }, + doesntExist: { + statusCode: 403, + response: createExpectLegacyForbidden(AUTHENTICATION.NOT_A_KIBANA_USER.username), + }, + }, + }); + + updateTest(`superuser`, { + user: AUTHENTICATION.SUPERUSER, + tests: { + spaceAware: { + statusCode: 200, + response: expectSpaceAwareResults, + }, + notSpaceAware: { + statusCode: 200, + response: expectNotSpaceAwareResults, + }, + doesntExist: { + statusCode: 404, + response: createExpectDoesntExistNotFound(), + }, + }, + }); + + updateTest(`legacy user`, { + user: AUTHENTICATION.KIBANA_LEGACY_USER, + tests: { + spaceAware: { + statusCode: 200, + response: expectSpaceAwareResults, + }, + notSpaceAware: { + statusCode: 200, + response: expectNotSpaceAwareResults, + }, + doesntExist: { + statusCode: 404, + response: createExpectDoesntExistNotFound(), + }, + }, + }); + + updateTest(`legacy readonly user`, { + user: AUTHENTICATION.KIBANA_LEGACY_DASHBOARD_ONLY_USER, + tests: { + spaceAware: { + statusCode: 403, + response: createExpectLegacyForbidden( + AUTHENTICATION.KIBANA_LEGACY_DASHBOARD_ONLY_USER.username + ), + }, + notSpaceAware: { + statusCode: 403, + response: createExpectLegacyForbidden( + AUTHENTICATION.KIBANA_LEGACY_DASHBOARD_ONLY_USER.username + ), + }, + doesntExist: { + statusCode: 403, + response: createExpectLegacyForbidden( + AUTHENTICATION.KIBANA_LEGACY_DASHBOARD_ONLY_USER.username + ), + }, + }, + }); + + updateTest(`dual-privileges user`, { + user: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_USER, + tests: { + spaceAware: { + statusCode: 200, + response: expectSpaceAwareResults, + }, + notSpaceAware: { + statusCode: 200, + response: expectNotSpaceAwareResults, + }, + doesntExist: { + statusCode: 404, + response: createExpectDoesntExistNotFound(), + }, + }, + }); + + updateTest(`dual-privileges readonly user`, { + user: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER, + tests: { + spaceAware: { + statusCode: 403, + response: expectSpaceAwareRbacForbidden, + }, + notSpaceAware: { + statusCode: 403, + response: expectNotSpaceAwareRbacForbidden, + }, + doesntExist: { + statusCode: 403, + response: expectDoesntExistRbacForbidden, + }, + }, + }); + + updateTest(`rbac user with all globally`, { + user: AUTHENTICATION.KIBANA_RBAC_USER, + tests: { + spaceAware: { + statusCode: 200, + response: expectSpaceAwareResults, + }, + notSpaceAware: { + statusCode: 200, + response: expectNotSpaceAwareResults, + }, + doesntExist: { + statusCode: 404, + response: createExpectDoesntExistNotFound(), + }, + }, + }); + + updateTest(`rbac user with read globally`, { + user: AUTHENTICATION.KIBANA_RBAC_DASHBOARD_ONLY_USER, + tests: { + spaceAware: { + statusCode: 403, + response: expectSpaceAwareRbacForbidden, + }, + notSpaceAware: { + statusCode: 403, + response: expectNotSpaceAwareRbacForbidden, + }, + doesntExist: { + statusCode: 403, + response: expectDoesntExistRbacForbidden, + }, + }, + }); + + updateTest(`rbac user with all at default space`, { + user: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_ALL_USER, + tests: { + spaceAware: { + statusCode: 403, + response: createExpectLegacyForbidden( + AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_ALL_USER.username + ), + }, + notSpaceAware: { + statusCode: 403, + response: createExpectLegacyForbidden( + AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_ALL_USER.username + ), + }, + doesntExist: { + statusCode: 403, + response: createExpectLegacyForbidden( + AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_ALL_USER.username + ), + }, + }, + }); + + updateTest(`rbac user with read at default space`, { + user: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_READ_USER, + tests: { + spaceAware: { + statusCode: 403, + response: createExpectLegacyForbidden( + AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_READ_USER.username + ), + }, + notSpaceAware: { + statusCode: 403, + response: createExpectLegacyForbidden( + AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_READ_USER.username + ), + }, + doesntExist: { + statusCode: 403, + response: createExpectLegacyForbidden( + AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_READ_USER.username + ), + }, + }, + }); + + updateTest(`rbac user with all at space_1`, { + user: AUTHENTICATION.KIBANA_RBAC_SPACE_1_ALL_USER, + tests: { + spaceAware: { + statusCode: 403, + response: createExpectLegacyForbidden( + AUTHENTICATION.KIBANA_RBAC_SPACE_1_ALL_USER.username + ), + }, + notSpaceAware: { + statusCode: 403, + response: createExpectLegacyForbidden( + AUTHENTICATION.KIBANA_RBAC_SPACE_1_ALL_USER.username + ), + }, + doesntExist: { + statusCode: 403, + response: createExpectLegacyForbidden( + AUTHENTICATION.KIBANA_RBAC_SPACE_1_ALL_USER.username + ), + }, + }, + }); + + updateTest(`rbac user with read at space_1`, { + user: AUTHENTICATION.KIBANA_RBAC_SPACE_1_READ_USER, + tests: { + spaceAware: { + statusCode: 403, + response: createExpectLegacyForbidden( + AUTHENTICATION.KIBANA_RBAC_SPACE_1_READ_USER.username + ), + }, + notSpaceAware: { + statusCode: 403, + response: createExpectLegacyForbidden( + AUTHENTICATION.KIBANA_RBAC_SPACE_1_READ_USER.username + ), + }, + doesntExist: { + statusCode: 403, + response: createExpectLegacyForbidden( + AUTHENTICATION.KIBANA_RBAC_SPACE_1_READ_USER.username + ), + }, + }, + }); + }); +} diff --git a/x-pack/test/saved_object_api_integration/security_only/config.ts b/x-pack/test/saved_object_api_integration/security_only/config.ts new file mode 100644 index 00000000000000..f71cc9207b3cc7 --- /dev/null +++ b/x-pack/test/saved_object_api_integration/security_only/config.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { createTestConfig } from '../common/config'; + +// tslint:disable:no-default-export +export default createTestConfig('security_only', { disabledPlugins: ['spaces'], license: 'trial' }); diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_create.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_create.ts new file mode 100644 index 00000000000000..cf794079a42ead --- /dev/null +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_create.ts @@ -0,0 +1,86 @@ +/* + * 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 'expect.js'; +import { SPACES } from '../../common/lib/spaces'; +import { TestInvoker } from '../../common/lib/types'; +import { bulkCreateTestSuiteFactory } from '../../common/suites/bulk_create'; + +const expectNamespaceSpecifiedBadRequest = (resp: { [key: string]: any }) => { + expect(resp.body).to.eql({ + error: 'Bad Request', + message: + '"value" at position 0 fails because ["namespace" is not allowed]. "value" does not contain 1 required value(s)', + statusCode: 400, + validation: { + keys: ['0.namespace', 'value'], + source: 'payload', + }, + }); +}; + +// tslint:disable:no-default-export +export default function({ getService }: TestInvoker) { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + const es = getService('es'); + + const { bulkCreateTest, createExpectResults } = bulkCreateTestSuiteFactory( + es, + esArchiver, + supertest + ); + + describe('_bulk_create', () => { + bulkCreateTest('in the current space (space_1)', { + ...SPACES.SPACE_1, + tests: { + default: { + statusCode: 200, + response: createExpectResults(SPACES.SPACE_1.spaceId), + }, + custom: { + description: 'when a namespace is specified on the saved object', + requestBody: [ + { + type: 'visualization', + namespace: 'space_1', + attributes: { + title: 'something', + }, + }, + ], + statusCode: 400, + response: expectNamespaceSpecifiedBadRequest, + }, + }, + }); + + bulkCreateTest('in the default space', { + ...SPACES.DEFAULT, + tests: { + default: { + statusCode: 200, + response: createExpectResults(SPACES.DEFAULT.spaceId), + }, + custom: { + description: 'when a namespace is specified on the saved object', + requestBody: [ + { + type: 'visualization', + namespace: 'space_1', + attributes: { + title: 'something', + }, + }, + ], + statusCode: 400, + response: expectNamespaceSpecifiedBadRequest, + }, + }, + }); + }); +} diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_get.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_get.ts new file mode 100644 index 00000000000000..cfb4b301bccfb3 --- /dev/null +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_get.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 { SPACES } from '../../common/lib/spaces'; +import { TestInvoker } from '../../common/lib/types'; +import { bulkGetTestSuiteFactory } from '../../common/suites/bulk_get'; + +// tslint:disable:no-default-export +export default function({ getService }: TestInvoker) { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + + const { bulkGetTest, createExpectResults, createExpectNotFoundResults } = bulkGetTestSuiteFactory( + esArchiver, + supertest + ); + + describe('_bulk_get', () => { + bulkGetTest(`objects within the current space (space_1)`, { + ...SPACES.SPACE_1, + tests: { + default: { + statusCode: 200, + response: createExpectResults(SPACES.SPACE_1.spaceId), + }, + }, + }); + + bulkGetTest(`objects within another space`, { + ...SPACES.SPACE_1, + otherSpaceId: SPACES.SPACE_2.spaceId, + tests: { + default: { + statusCode: 200, + response: createExpectNotFoundResults(SPACES.SPACE_2.spaceId), + }, + }, + }); + }); +} diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/create.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/create.ts new file mode 100644 index 00000000000000..129023130e7160 --- /dev/null +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/create.ts @@ -0,0 +1,89 @@ +/* + * 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 'expect.js'; +import { SPACES } from '../../common/lib/spaces'; +import { TestInvoker } from '../../common/lib/types'; +import { createTestSuiteFactory } from '../../common/suites/create'; + +const expectNamespaceSpecifiedBadRequest = (resp: { [key: string]: any }) => { + expect(resp.body).to.eql({ + error: 'Bad Request', + message: '"namespace" is not allowed', + statusCode: 400, + validation: { + keys: ['namespace'], + source: 'payload', + }, + }); +}; + +// tslint:disable:no-default-export +export default function({ getService }: TestInvoker) { + const supertestWithoutAuth = getService('supertestWithoutAuth'); + const es = getService('es'); + const esArchiver = getService('esArchiver'); + + const { + createTest, + createExpectSpaceAwareResults, + expectNotSpaceAwareResults, + } = createTestSuiteFactory(es, esArchiver, supertestWithoutAuth); + + describe('create', () => { + createTest('in the current space (space_1)', { + ...SPACES.SPACE_1, + tests: { + spaceAware: { + statusCode: 200, + response: createExpectSpaceAwareResults(SPACES.SPACE_1.spaceId), + }, + notSpaceAware: { + statusCode: 200, + response: expectNotSpaceAwareResults, + }, + custom: { + description: 'when a namespace is specified on the saved object', + type: 'visualization', + requestBody: { + namespace: 'space_1', + attributes: { + title: 'something', + }, + }, + statusCode: 400, + response: expectNamespaceSpecifiedBadRequest, + }, + }, + }); + + createTest('in the default space', { + ...SPACES.DEFAULT, + tests: { + spaceAware: { + statusCode: 200, + response: createExpectSpaceAwareResults(SPACES.DEFAULT.spaceId), + }, + notSpaceAware: { + statusCode: 200, + response: expectNotSpaceAwareResults, + }, + custom: { + description: 'when a namespace is specified on the saved object', + type: 'visualization', + requestBody: { + namespace: 'space_1', + attributes: { + title: 'something', + }, + }, + statusCode: 400, + response: expectNamespaceSpecifiedBadRequest, + }, + }, + }); + }); +} diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/delete.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/delete.ts new file mode 100644 index 00000000000000..04db10af003888 --- /dev/null +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/delete.ts @@ -0,0 +1,79 @@ +/* + * 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 { SPACES } from '../../common/lib/spaces'; +import { TestInvoker } from '../../common/lib/types'; +import { deleteTestSuiteFactory } from '../../common/suites/delete'; + +// tslint:disable:no-default-export +export default function({ getService }: TestInvoker) { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + + describe('delete', () => { + const { + createExpectSpaceAwareNotFound, + createExpectUnknownDocNotFound, + deleteTest, + expectEmpty, + } = deleteTestSuiteFactory(esArchiver, supertest); + + deleteTest(`in the default space`, { + ...SPACES.DEFAULT, + tests: { + spaceAware: { + statusCode: 200, + response: expectEmpty, + }, + notSpaceAware: { + statusCode: 200, + response: expectEmpty, + }, + invalidId: { + statusCode: 404, + response: createExpectUnknownDocNotFound(SPACES.DEFAULT.spaceId), + }, + }, + }); + + deleteTest(`in the current space (space_1)`, { + ...SPACES.SPACE_1, + tests: { + spaceAware: { + statusCode: 200, + response: expectEmpty, + }, + notSpaceAware: { + statusCode: 200, + response: expectEmpty, + }, + invalidId: { + statusCode: 404, + response: createExpectUnknownDocNotFound(SPACES.SPACE_1.spaceId), + }, + }, + }); + + deleteTest(`in another space (space_2)`, { + spaceId: SPACES.SPACE_1.spaceId, + otherSpaceId: SPACES.SPACE_2.spaceId, + tests: { + spaceAware: { + statusCode: 404, + response: createExpectSpaceAwareNotFound(SPACES.SPACE_2.spaceId), + }, + notSpaceAware: { + statusCode: 200, + response: expectEmpty, + }, + invalidId: { + statusCode: 404, + response: createExpectUnknownDocNotFound(SPACES.SPACE_2.spaceId), + }, + }, + }); + }); +} diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/find.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/find.ts new file mode 100644 index 00000000000000..4f704b3d382197 --- /dev/null +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/find.ts @@ -0,0 +1,97 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SPACES } from '../../common/lib/spaces'; +import { TestInvoker } from '../../common/lib/types'; +import { findTestSuiteFactory } from '../../common/suites/find'; + +// tslint:disable:no-default-export +export default function({ getService }: TestInvoker) { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + + const { + createExpectEmpty, + createExpectVisualizationResults, + expectNotSpaceAwareResults, + expectTypeRequired, + findTest, + } = findTestSuiteFactory(esArchiver, supertest); + + describe('find', () => { + findTest(`objects only within the current space (space_1)`, { + ...SPACES.SPACE_1, + tests: { + spaceAwareType: { + description: 'only the visualization', + statusCode: 200, + response: createExpectVisualizationResults(SPACES.SPACE_1.spaceId), + }, + notSpaceAwareType: { + description: 'only the visualization', + statusCode: 200, + response: expectNotSpaceAwareResults, + }, + unknownType: { + description: 'empty result', + statusCode: 200, + response: createExpectEmpty(1, 20, 0), + }, + pageBeyondTotal: { + description: 'empty result', + statusCode: 200, + response: createExpectEmpty(100, 100, 1), + }, + unknownSearchField: { + description: 'empty result', + statusCode: 200, + response: createExpectEmpty(1, 20, 0), + }, + noType: { + description: 'bad request, type is required', + statusCode: 400, + response: expectTypeRequired, + }, + }, + }); + + findTest(`objects only within the current space (default)`, { + ...SPACES.DEFAULT, + tests: { + spaceAwareType: { + description: 'only the visualization', + statusCode: 200, + response: createExpectVisualizationResults(SPACES.DEFAULT.spaceId), + }, + notSpaceAwareType: { + description: 'only the visualization', + statusCode: 200, + response: expectNotSpaceAwareResults, + }, + unknownType: { + description: 'empty result', + statusCode: 200, + response: createExpectEmpty(1, 20, 0), + }, + pageBeyondTotal: { + description: 'empty result', + statusCode: 200, + response: createExpectEmpty(100, 100, 1), + }, + unknownSearchField: { + description: 'empty result', + statusCode: 200, + response: createExpectEmpty(1, 20, 0), + }, + noType: { + description: 'bad request, type is required', + statusCode: 400, + response: expectTypeRequired, + }, + }, + }); + }); +} diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/get.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/get.ts new file mode 100644 index 00000000000000..012df684920455 --- /dev/null +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/get.ts @@ -0,0 +1,80 @@ +/* + * 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 { SPACES } from '../../common/lib/spaces'; +import { TestInvoker } from '../../common/lib/types'; +import { getTestSuiteFactory } from '../../common/suites/get'; + +// tslint:disable:no-default-export +export default function({ getService }: TestInvoker) { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + + const { + createExpectDoesntExistNotFound, + createExpectSpaceAwareNotFound, + createExpectSpaceAwareResults, + createExpectNotSpaceAwareResults, + getTest, + } = getTestSuiteFactory(esArchiver, supertest); + + describe('get', () => { + getTest(`can access objects belonging to the current space (default)`, { + ...SPACES.DEFAULT, + tests: { + spaceAware: { + statusCode: 200, + response: createExpectSpaceAwareResults(SPACES.DEFAULT.spaceId), + }, + notSpaceAware: { + statusCode: 200, + response: createExpectNotSpaceAwareResults(SPACES.DEFAULT.spaceId), + }, + doesntExist: { + statusCode: 404, + response: createExpectDoesntExistNotFound(SPACES.DEFAULT.spaceId), + }, + }, + }); + + getTest(`can access objects belonging to the current space (space_1)`, { + ...SPACES.SPACE_1, + tests: { + spaceAware: { + statusCode: 200, + response: createExpectSpaceAwareResults(SPACES.SPACE_1.spaceId), + }, + notSpaceAware: { + statusCode: 200, + response: createExpectNotSpaceAwareResults(SPACES.SPACE_1.spaceId), + }, + doesntExist: { + statusCode: 404, + response: createExpectDoesntExistNotFound(SPACES.SPACE_1.spaceId), + }, + }, + }); + + getTest(`can't access space aware objects belonging to another space (space_1)`, { + spaceId: SPACES.DEFAULT.spaceId, + otherSpaceId: SPACES.SPACE_1.spaceId, + tests: { + spaceAware: { + statusCode: 404, + response: createExpectSpaceAwareNotFound(SPACES.SPACE_1.spaceId), + }, + notSpaceAware: { + statusCode: 200, + response: createExpectNotSpaceAwareResults(SPACES.SPACE_1.spaceId), + }, + doesntExist: { + statusCode: 404, + response: createExpectDoesntExistNotFound(SPACES.SPACE_1.spaceId), + }, + }, + }); + }); +} diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/index.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/index.ts new file mode 100644 index 00000000000000..113cf86454d5ff --- /dev/null +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/index.ts @@ -0,0 +1,20 @@ +/* + * 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 { TestInvoker } from '../../common/lib/types'; + +// tslint:disable:no-default-export +export default function({ loadTestFile }: TestInvoker) { + describe('saved objects spaces only enabled', () => { + loadTestFile(require.resolve('./bulk_create')); + loadTestFile(require.resolve('./bulk_get')); + loadTestFile(require.resolve('./create')); + loadTestFile(require.resolve('./delete')); + loadTestFile(require.resolve('./find')); + loadTestFile(require.resolve('./get')); + loadTestFile(require.resolve('./update')); + }); +} diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/update.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/update.ts new file mode 100644 index 00000000000000..6779ed4733ec59 --- /dev/null +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/update.ts @@ -0,0 +1,80 @@ +/* + * 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 { SPACES } from '../../common/lib/spaces'; +import { TestInvoker } from '../../common/lib/types'; +import { updateTestSuiteFactory } from '../../common/suites/update'; + +// tslint:disable:no-default-export +export default function({ getService }: TestInvoker) { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + + describe('update', () => { + const { + createExpectSpaceAwareNotFound, + expectSpaceAwareResults, + createExpectDoesntExistNotFound, + expectNotSpaceAwareResults, + updateTest, + } = updateTestSuiteFactory(esArchiver, supertest); + + updateTest(`in the default space`, { + spaceId: SPACES.DEFAULT.spaceId, + tests: { + spaceAware: { + statusCode: 200, + response: expectSpaceAwareResults, + }, + notSpaceAware: { + statusCode: 200, + response: expectNotSpaceAwareResults, + }, + doesntExist: { + statusCode: 404, + response: createExpectDoesntExistNotFound(SPACES.DEFAULT.spaceId), + }, + }, + }); + + updateTest('in the current space (space_1)', { + spaceId: SPACES.SPACE_1.spaceId, + tests: { + spaceAware: { + statusCode: 200, + response: expectSpaceAwareResults, + }, + notSpaceAware: { + statusCode: 200, + response: expectNotSpaceAwareResults, + }, + doesntExist: { + statusCode: 404, + response: createExpectDoesntExistNotFound(SPACES.SPACE_1.spaceId), + }, + }, + }); + + updateTest('objects that exist in another space (space_1)', { + spaceId: SPACES.DEFAULT.spaceId, + otherSpaceId: SPACES.SPACE_1.spaceId, + tests: { + spaceAware: { + statusCode: 404, + response: createExpectSpaceAwareNotFound(SPACES.SPACE_1.spaceId), + }, + notSpaceAware: { + statusCode: 200, + response: expectNotSpaceAwareResults, + }, + doesntExist: { + statusCode: 404, + response: createExpectDoesntExistNotFound(), + }, + }, + }); + }); +} diff --git a/x-pack/test/saved_object_api_integration/spaces_only/config.ts b/x-pack/test/saved_object_api_integration/spaces_only/config.ts new file mode 100644 index 00000000000000..38d65bab1b107b --- /dev/null +++ b/x-pack/test/saved_object_api_integration/spaces_only/config.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { createTestConfig } from '../common/config'; + +// tslint:disable:no-default-export +export default createTestConfig('spaces_only', { license: 'basic' }); diff --git a/x-pack/test/spaces_api_integration/common/config.ts b/x-pack/test/spaces_api_integration/common/config.ts new file mode 100644 index 00000000000000..97a92cd4e666f3 --- /dev/null +++ b/x-pack/test/spaces_api_integration/common/config.ts @@ -0,0 +1,72 @@ +/* + * 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. + */ + +// @ts-ignore +import { resolveKibanaPath } from '@kbn/plugin-helpers'; +import path from 'path'; +import { TestInvoker } from './lib/types'; +// @ts-ignore +import { EsProvider } from './services/es'; + +interface CreateTestConfigOptions { + license: string; + disabledPlugins?: string[]; +} + +export function createTestConfig(name: string, options: CreateTestConfigOptions) { + const { license, disabledPlugins = [] } = options; + + return async ({ readConfigFile }: TestInvoker) => { + const config = { + kibana: { + api: await readConfigFile(resolveKibanaPath('test/api_integration/config.js')), + functional: await readConfigFile(require.resolve('../../../../test/functional/config.js')), + }, + xpack: { + api: await readConfigFile(require.resolve('../../api_integration/config.js')), + }, + }; + + return { + testFiles: [require.resolve(`../${name}/apis/`)], + servers: config.xpack.api.get('servers'), + services: { + es: EsProvider, + esSupertestWithoutAuth: config.xpack.api.get('services.esSupertestWithoutAuth'), + supertest: config.kibana.api.get('services.supertest'), + supertestWithoutAuth: config.xpack.api.get('services.supertestWithoutAuth'), + esArchiver: config.kibana.functional.get('services.esArchiver'), + kibanaServer: config.kibana.functional.get('services.kibanaServer'), + }, + junit: { + reportName: 'X-Pack Spaces API Integration Tests -- ' + name, + }, + + esArchiver: { + directory: path.join(__dirname, 'fixtures', 'es_archiver'), + }, + + esTestCluster: { + ...config.xpack.api.get('esTestCluster'), + license, + serverArgs: [ + `xpack.license.self_generated.type=${license}`, + `xpack.security.enabled=${!disabledPlugins.includes('security') && license === 'trial'}`, + ], + }, + + kbnTestServer: { + ...config.xpack.api.get('kbnTestServer'), + serverArgs: [ + ...config.xpack.api.get('kbnTestServer.serverArgs'), + '--optimize.enabled=false', + '--server.xsrf.disableProtection=true', + ...disabledPlugins.map(key => `--xpack.${key}.enabled=false`), + ], + }, + }; + }; +} diff --git a/x-pack/test/spaces_api_integration/common/fixtures/es_archiver/saved_objects/spaces/data.json b/x-pack/test/spaces_api_integration/common/fixtures/es_archiver/saved_objects/spaces/data.json new file mode 100644 index 00000000000000..383f7083ff0705 --- /dev/null +++ b/x-pack/test/spaces_api_integration/common/fixtures/es_archiver/saved_objects/spaces/data.json @@ -0,0 +1,51 @@ +{ + "type": "doc", + "value": { + "index": ".kibana", + "type": "doc", + "id": "space:default", + "source": { + "type": "space", + "updated_at": "2017-09-21T18:49:16.270Z", + "space": { + "name": "Default Space", + "description": "This is the default space", + "_reserved": true + } + } + } +} + +{ + "type": "doc", + "value": { + "index": ".kibana", + "type": "doc", + "id": "space:space_1", + "source": { + "type": "space", + "updated_at": "2017-09-21T18:49:16.270Z", + "space": { + "name": "Space 1", + "description": "This is the first test space" + } + } + } +} + +{ + "type": "doc", + "value": { + "index": ".kibana", + "type": "doc", + "id": "space:space_2", + "source": { + "type": "space", + "updated_at": "2017-09-21T18:49:16.270Z", + "space": { + "name": "Space 2", + "description": "This is the second test space" + } + } + } +} diff --git a/x-pack/test/rbac_api_integration/fixtures/es_archiver/saved_objects/basic/mappings.json b/x-pack/test/spaces_api_integration/common/fixtures/es_archiver/saved_objects/spaces/mappings.json similarity index 91% rename from x-pack/test/rbac_api_integration/fixtures/es_archiver/saved_objects/basic/mappings.json rename to x-pack/test/spaces_api_integration/common/fixtures/es_archiver/saved_objects/spaces/mappings.json index 107a45fab187bc..d8d4c52985bf56 100644 --- a/x-pack/test/rbac_api_integration/fixtures/es_archiver/saved_objects/basic/mappings.json +++ b/x-pack/test/spaces_api_integration/common/fixtures/es_archiver/saved_objects/spaces/mappings.json @@ -30,6 +30,31 @@ } } }, + "space": { + "properties": { + "name": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 2048 + } + } + }, + "description": { + "type": "text" + }, + "initials": { + "type": "keyword" + }, + "color": { + "type": "keyword" + }, + "_reserved": { + "type": "boolean" + } + } + }, "dashboard": { "properties": { "description": { @@ -280,4 +305,4 @@ }, "aliases": {} } -} \ No newline at end of file +} diff --git a/x-pack/test/spaces_api_integration/common/lib/authentication.ts b/x-pack/test/spaces_api_integration/common/lib/authentication.ts new file mode 100644 index 00000000000000..d4dcb333cd61a2 --- /dev/null +++ b/x-pack/test/spaces_api_integration/common/lib/authentication.ts @@ -0,0 +1,72 @@ +/* + * 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 const AUTHENTICATION = { + NOT_A_KIBANA_USER: { + username: 'not_a_kibana_user', + password: 'password', + }, + SUPERUSER: { + username: 'elastic', + password: 'changeme', + }, + KIBANA_LEGACY_USER: { + username: 'a_kibana_legacy_user', + password: 'password', + }, + KIBANA_LEGACY_DASHBOARD_ONLY_USER: { + username: 'a_kibana_legacy_dashboard_only_user', + password: 'password', + }, + KIBANA_DUAL_PRIVILEGES_USER: { + username: 'a_kibana_dual_privileges_user', + password: 'password', + }, + KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER: { + username: 'a_kibana_dual_privileges_dashboard_only_user', + password: 'password', + }, + KIBANA_RBAC_USER: { + username: 'a_kibana_rbac_user', + password: 'password', + }, + KIBANA_RBAC_DASHBOARD_ONLY_USER: { + username: 'a_kibana_rbac_dashboard_only_user', + password: 'password', + }, + KIBANA_RBAC_DEFAULT_SPACE_ALL_USER: { + username: 'a_kibana_rbac_default_space_all_user', + password: 'password', + }, + KIBANA_RBAC_DEFAULT_SPACE_READ_USER: { + username: 'a_kibana_rbac_default_space_read_user', + password: 'password', + }, + KIBANA_RBAC_SPACE_1_ALL_USER: { + username: 'a_kibana_rbac_space_1_all_user', + password: 'password', + }, + KIBANA_RBAC_SPACE_1_READ_USER: { + username: 'a_kibana_rbac_space_1_read_user', + password: 'password', + }, + KIBANA_RBAC_SPACE_2_ALL_USER: { + username: 'a_kibana_rbac_space_2_all_user', + password: 'password', + }, + KIBANA_RBAC_SPACE_2_READ_USER: { + username: 'a_kibana_rbac_space_2_read_user', + password: 'password', + }, + KIBANA_RBAC_SPACE_1_2_ALL_USER: { + username: 'a_kibana_rbac_space_1_2_all_user', + password: 'password', + }, + KIBANA_RBAC_SPACE_1_2_READ_USER: { + username: 'a_kibana_rbac_space_1_2_read_user', + password: 'password', + }, +}; diff --git a/x-pack/test/spaces_api_integration/common/lib/create_users_and_roles.ts b/x-pack/test/spaces_api_integration/common/lib/create_users_and_roles.ts new file mode 100644 index 00000000000000..0017888c635677 --- /dev/null +++ b/x-pack/test/spaces_api_integration/common/lib/create_users_and_roles.ts @@ -0,0 +1,287 @@ +/* + * 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 { SuperTest } from 'supertest'; +import { AUTHENTICATION } from './authentication'; + +export const createUsersAndRoles = async (es: any, supertest: SuperTest) => { + await supertest.put('/api/security/role/kibana_legacy_user').send({ + elasticsearch: { + indices: [ + { + names: ['.kibana'], + privileges: ['manage', 'read', 'index', 'delete'], + }, + ], + }, + }); + + await supertest.put('/api/security/role/kibana_legacy_dashboard_only_user').send({ + elasticsearch: { + indices: [ + { + names: ['.kibana'], + privileges: ['read', 'view_index_metadata'], + }, + ], + }, + }); + + await supertest.put('/api/security/role/kibana_dual_privileges_user').send({ + elasticsearch: { + indices: [ + { + names: ['.kibana'], + privileges: ['manage', 'read', 'index', 'delete'], + }, + ], + }, + kibana: { + global: ['all'], + }, + }); + + await supertest.put('/api/security/role/kibana_dual_privileges_dashboard_only_user').send({ + elasticsearch: { + indices: [ + { + names: ['.kibana'], + privileges: ['read', 'view_index_metadata'], + }, + ], + }, + kibana: { + global: ['read'], + }, + }); + + await supertest.put('/api/security/role/kibana_rbac_user').send({ + kibana: { + global: ['all'], + }, + }); + + await supertest.put('/api/security/role/kibana_rbac_dashboard_only_user').send({ + kibana: { + global: ['read'], + }, + }); + + await supertest.put('/api/security/role/kibana_rbac_default_space_all_user').send({ + kibana: { + space: { + default: ['all'], + }, + }, + }); + + await supertest.put('/api/security/role/kibana_rbac_default_space_read_user').send({ + kibana: { + space: { + default: ['read'], + }, + }, + }); + + await supertest.put('/api/security/role/kibana_rbac_space_1_all_user').send({ + kibana: { + space: { + space_1: ['all'], + }, + }, + }); + + await supertest.put('/api/security/role/kibana_rbac_space_1_read_user').send({ + kibana: { + space: { + space_1: ['read'], + }, + }, + }); + + await supertest.put('/api/security/role/kibana_rbac_space_2_all_user').send({ + kibana: { + space: { + space_2: ['all'], + }, + }, + }); + + await supertest.put('/api/security/role/kibana_rbac_space_2_read_user').send({ + kibana: { + space: { + space_2: ['read'], + }, + }, + }); + + await supertest.put('/api/security/role/kibana_rbac_space_1_2_all_user').send({ + kibana: { + space: { + space_1: ['all'], + space_2: ['all'], + }, + }, + }); + + await supertest.put('/api/security/role/kibana_rbac_space_1_2_read_user').send({ + kibana: { + space: { + space_1: ['read'], + space_2: ['read'], + }, + }, + }); + + await es.shield.putUser({ + username: AUTHENTICATION.NOT_A_KIBANA_USER.username, + body: { + password: AUTHENTICATION.NOT_A_KIBANA_USER.password, + roles: [], + full_name: 'not a kibana user', + email: 'not_a_kibana_user@elastic.co', + }, + }); + + await es.shield.putUser({ + username: AUTHENTICATION.KIBANA_LEGACY_USER.username, + body: { + password: AUTHENTICATION.KIBANA_LEGACY_USER.password, + roles: ['kibana_legacy_user'], + full_name: 'a kibana legacy user', + email: 'a_kibana_legacy_user@elastic.co', + }, + }); + + await es.shield.putUser({ + username: AUTHENTICATION.KIBANA_LEGACY_DASHBOARD_ONLY_USER.username, + body: { + password: AUTHENTICATION.KIBANA_LEGACY_DASHBOARD_ONLY_USER.password, + roles: ['kibana_legacy_dashboard_only_user'], + full_name: 'a kibana legacy dashboard only user', + email: 'a_kibana_legacy_dashboard_only_user@elastic.co', + }, + }); + + await es.shield.putUser({ + username: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_USER.username, + body: { + password: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_USER.password, + roles: ['kibana_dual_privileges_user'], + full_name: 'a kibana dual_privileges user', + email: 'a_kibana_dual_privileges_user@elastic.co', + }, + }); + + await es.shield.putUser({ + username: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER.username, + body: { + password: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER.password, + roles: ['kibana_dual_privileges_dashboard_only_user'], + full_name: 'a kibana dual_privileges dashboard only user', + email: 'a_kibana_dual_privileges_dashboard_only_user@elastic.co', + }, + }); + + await es.shield.putUser({ + username: AUTHENTICATION.KIBANA_RBAC_USER.username, + body: { + password: AUTHENTICATION.KIBANA_RBAC_USER.password, + roles: ['kibana_rbac_user'], + full_name: 'a kibana user', + email: 'a_kibana_rbac_user@elastic.co', + }, + }); + + await es.shield.putUser({ + username: AUTHENTICATION.KIBANA_RBAC_DASHBOARD_ONLY_USER.username, + body: { + password: AUTHENTICATION.KIBANA_RBAC_DASHBOARD_ONLY_USER.password, + roles: ['kibana_rbac_dashboard_only_user'], + full_name: 'a kibana dashboard only user', + email: 'a_kibana_rbac_dashboard_only_user@elastic.co', + }, + }); + + await es.shield.putUser({ + username: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_ALL_USER.username, + body: { + password: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_ALL_USER.password, + roles: ['kibana_rbac_default_space_all_user'], + full_name: 'a kibana default space all user', + email: 'a_kibana_rbac_default_space_all_user@elastic.co', + }, + }); + + await es.shield.putUser({ + username: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_READ_USER.username, + body: { + password: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_READ_USER.password, + roles: ['kibana_rbac_default_space_read_user'], + full_name: 'a kibana default space read-only user', + email: 'a_kibana_rbac_default_space_read_user@elastic.co', + }, + }); + + await es.shield.putUser({ + username: AUTHENTICATION.KIBANA_RBAC_SPACE_1_ALL_USER.username, + body: { + password: AUTHENTICATION.KIBANA_RBAC_SPACE_1_ALL_USER.password, + roles: ['kibana_rbac_space_1_all_user'], + full_name: 'a kibana rbac space 1 all user', + email: 'a_kibana_rbac_space_1_all_user@elastic.co', + }, + }); + + await es.shield.putUser({ + username: AUTHENTICATION.KIBANA_RBAC_SPACE_1_READ_USER.username, + body: { + password: AUTHENTICATION.KIBANA_RBAC_SPACE_1_READ_USER.password, + roles: ['kibana_rbac_space_1_read_user'], + full_name: 'a kibana rbac space 1 read-only user', + email: 'a_kibana_rbac_space_1_readonly_user@elastic.co', + }, + }); + + await es.shield.putUser({ + username: AUTHENTICATION.KIBANA_RBAC_SPACE_2_ALL_USER.username, + body: { + password: AUTHENTICATION.KIBANA_RBAC_SPACE_2_ALL_USER.password, + roles: ['kibana_rbac_space_2_all_user'], + full_name: 'a kibana rbac space 2 all user', + email: 'a_kibana_rbac_space_2_all_user@elastic.co', + }, + }); + + await es.shield.putUser({ + username: AUTHENTICATION.KIBANA_RBAC_SPACE_2_READ_USER.username, + body: { + password: AUTHENTICATION.KIBANA_RBAC_SPACE_2_READ_USER.password, + roles: ['kibana_rbac_space_2_read_user'], + full_name: 'a kibana rbac space 2 read-only user', + email: 'a_kibana_rbac_space_2_readonly_user@elastic.co', + }, + }); + + await es.shield.putUser({ + username: AUTHENTICATION.KIBANA_RBAC_SPACE_1_2_ALL_USER.username, + body: { + password: AUTHENTICATION.KIBANA_RBAC_SPACE_1_2_ALL_USER.password, + roles: ['kibana_rbac_space_1_2_all_user'], + full_name: 'a kibana rbac space 1 and 2 all user', + email: 'a_kibana_rbac_space_1_2_all_user@elastic.co', + }, + }); + + await es.shield.putUser({ + username: AUTHENTICATION.KIBANA_RBAC_SPACE_1_2_READ_USER.username, + body: { + password: AUTHENTICATION.KIBANA_RBAC_SPACE_1_2_READ_USER.password, + roles: ['kibana_rbac_space_1_2_read_user'], + full_name: 'a kibana rbac space 1 and 2 read-only user', + email: 'a_kibana_rbac_space_1_2_readonly_user@elastic.co', + }, + }); +}; diff --git a/x-pack/test/spaces_api_integration/common/lib/space_test_utils.ts b/x-pack/test/spaces_api_integration/common/lib/space_test_utils.ts new file mode 100644 index 00000000000000..f233bc1d11d7cc --- /dev/null +++ b/x-pack/test/spaces_api_integration/common/lib/space_test_utils.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { DEFAULT_SPACE_ID } from '../../../../plugins/spaces/common/constants'; + +export function getUrlPrefix(spaceId?: string) { + return spaceId && spaceId !== DEFAULT_SPACE_ID ? `/s/${spaceId}` : ``; +} + +export function getIdPrefix(spaceId?: string) { + return spaceId === DEFAULT_SPACE_ID ? '' : `${spaceId}-`; +} diff --git a/x-pack/test/spaces_api_integration/common/lib/spaces.ts b/x-pack/test/spaces_api_integration/common/lib/spaces.ts new file mode 100644 index 00000000000000..a9c552d4ccd789 --- /dev/null +++ b/x-pack/test/spaces_api_integration/common/lib/spaces.ts @@ -0,0 +1,17 @@ +/* + * 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 const SPACES = { + SPACE_1: { + spaceId: 'space_1', + }, + SPACE_2: { + spaceId: 'space_2', + }, + DEFAULT: { + spaceId: 'default', + }, +}; diff --git a/x-pack/test/spaces_api_integration/common/lib/types.ts b/x-pack/test/spaces_api_integration/common/lib/types.ts new file mode 100644 index 00000000000000..f149ad02cc1f73 --- /dev/null +++ b/x-pack/test/spaces_api_integration/common/lib/types.ts @@ -0,0 +1,23 @@ +/* + * 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 type DescribeFn = (text: string, fn: () => void) => void; + +export interface TestDefinitionAuthentication { + username?: string; + password?: string; +} +export type LoadTestFileFn = (path: string) => string; + +export type GetServiceFn = (service: string) => any; + +export type ReadConfigFileFn = (path: string) => any; + +export interface TestInvoker { + getService: GetServiceFn; + loadTestFile: LoadTestFileFn; + readConfigFile: ReadConfigFileFn; +} diff --git a/x-pack/test/rbac_api_integration/services/es.js b/x-pack/test/spaces_api_integration/common/services/es.js similarity index 89% rename from x-pack/test/rbac_api_integration/services/es.js rename to x-pack/test/spaces_api_integration/common/services/es.js index 420541fa7ec5f6..c4fa7c504e12cd 100644 --- a/x-pack/test/rbac_api_integration/services/es.js +++ b/x-pack/test/spaces_api_integration/common/services/es.js @@ -7,7 +7,7 @@ import { format as formatUrl } from 'url'; import elasticsearch from 'elasticsearch'; -import shieldPlugin from '../../../server/lib/esjs_shield_plugin'; +import shieldPlugin from '../../../../server/lib/esjs_shield_plugin'; export function EsProvider({ getService }) { const config = getService('config'); diff --git a/x-pack/test/spaces_api_integration/common/suites/create.ts b/x-pack/test/spaces_api_integration/common/suites/create.ts new file mode 100644 index 00000000000000..7b750f4ee3ff45 --- /dev/null +++ b/x-pack/test/spaces_api_integration/common/suites/create.ts @@ -0,0 +1,142 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from 'expect.js'; +import { SuperTest } from 'supertest'; +import { getUrlPrefix } from '../lib/space_test_utils'; +import { DescribeFn, TestDefinitionAuthentication } from '../lib/types'; + +interface CreateTest { + statusCode: number; + response: (resp: { [key: string]: any }) => void; +} + +interface CreateTests { + newSpace: CreateTest; + alreadyExists: CreateTest; + reservedSpecified: CreateTest; +} + +interface CreateTestDefinition { + user?: TestDefinitionAuthentication; + spaceId: string; + tests: CreateTests; +} + +export function createTestSuiteFactory(esArchiver: any, supertest: SuperTest) { + const createExpectLegacyForbiddenResponse = (username: string) => (resp: { + [key: string]: any; + }) => { + expect(resp.body).to.eql({ + statusCode: 403, + error: 'Forbidden', + message: `action [indices:data/write/index] is unauthorized for user [${username}]: [security_exception] action [indices:data/write/index] is unauthorized for user [${username}]`, + }); + }; + + const expectConflictResponse = (resp: { [key: string]: any }) => { + expect(resp.body).to.only.have.keys(['error', 'message', 'statusCode']); + expect(resp.body.error).to.equal('Conflict'); + expect(resp.body.statusCode).to.equal(409); + expect(resp.body.message).to.match(new RegExp(`A space with the identifier .*`)); + }; + + const expectNewSpaceResult = (resp: { [key: string]: any }) => { + expect(resp.body).to.eql({ + name: 'marketing', + id: 'marketing', + description: 'a description', + color: '#5c5959', + }); + }; + + const expectRbacForbiddenResponse = (resp: { [key: string]: any }) => { + expect(resp.body).to.eql({ + statusCode: 403, + error: 'Forbidden', + message: 'Unauthorized to create spaces', + }); + }; + + const expectReservedSpecifiedResult = (resp: { [key: string]: any }) => { + expect(resp.body).to.eql({ + name: 'reserved space', + id: 'reserved', + description: 'a description', + color: '#5c5959', + }); + }; + + const makeCreateTest = (describeFn: DescribeFn) => ( + description: string, + { user = {}, spaceId, tests }: CreateTestDefinition + ) => { + describeFn(description, () => { + before(() => esArchiver.load('saved_objects/spaces')); + after(() => esArchiver.unload('saved_objects/spaces')); + + it(`should return ${tests.newSpace.statusCode}`, async () => { + return supertest + .post(`${getUrlPrefix(spaceId)}/api/spaces/space`) + .auth(user.username, user.password) + .send({ + name: 'marketing', + id: 'marketing', + description: 'a description', + color: '#5c5959', + }) + .expect(tests.newSpace.statusCode) + .then(tests.newSpace.response); + }); + + describe('when it already exists', () => { + it(`should return ${tests.alreadyExists.statusCode}`, async () => { + return supertest + .post(`${getUrlPrefix(spaceId)}/api/spaces/space`) + .auth(user.username, user.password) + .send({ + name: 'space_1', + id: 'space_1', + color: '#ffffff', + description: 'a description', + }) + .expect(tests.alreadyExists.statusCode) + .then(tests.alreadyExists.response); + }); + }); + + describe('when _reserved is specified', () => { + it(`should return ${tests.reservedSpecified.statusCode} and ignore _reserved`, async () => { + return supertest + .post(`${getUrlPrefix(spaceId)}/api/spaces/space`) + .auth(user.username, user.password) + .send({ + name: 'reserved space', + id: 'reserved', + description: 'a description', + color: '#5c5959', + _reserved: true, + }) + .expect(tests.reservedSpecified.statusCode) + .then(tests.reservedSpecified.response); + }); + }); + }); + }; + + const createTest = makeCreateTest(describe); + // @ts-ignore + createTest.only = makeCreateTest(describe.only); + + return { + createExpectLegacyForbiddenResponse, + createTest, + expectConflictResponse, + expectNewSpaceResult, + expectRbacForbiddenResponse, + expectReservedSpecifiedResult, + }; +} diff --git a/x-pack/test/spaces_api_integration/common/suites/delete.ts b/x-pack/test/spaces_api_integration/common/suites/delete.ts new file mode 100644 index 00000000000000..3376e73420fb2a --- /dev/null +++ b/x-pack/test/spaces_api_integration/common/suites/delete.ts @@ -0,0 +1,121 @@ +/* + * 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 'expect.js'; +import { SuperTest } from 'supertest'; +import { getUrlPrefix } from '../lib/space_test_utils'; +import { DescribeFn, TestDefinitionAuthentication } from '../lib/types'; + +interface DeleteTest { + statusCode: number; + response: (resp: { [key: string]: any }) => void; +} + +interface DeleteTests { + exists: DeleteTest; + reservedSpace: DeleteTest; + doesntExist: DeleteTest; +} + +interface DeleteTestDefinition { + user?: TestDefinitionAuthentication; + spaceId: string; + tests: DeleteTests; +} + +export function deleteTestSuiteFactory(esArchiver: any, supertest: SuperTest) { + const createExpectLegacyForbidden = (username: string, action: string) => (resp: { + [key: string]: any; + }) => { + expect(resp.body).to.eql({ + statusCode: 403, + error: 'Forbidden', + message: `action [indices:data/${action}] is unauthorized for user [${username}]: [security_exception] action [indices:data/${action}] is unauthorized for user [${username}]`, + }); + }; + + const createExpectResult = (expectedResult: any) => (resp: { [key: string]: any }) => { + expect(resp.body).to.eql(expectedResult); + }; + + const expectEmptyResult = (resp: { [key: string]: any }) => { + expect(resp.body).to.eql(''); + }; + + const expectNotFound = (resp: { [key: string]: any }) => { + expect(resp.body).to.eql({ + error: 'Not Found', + statusCode: 404, + }); + }; + + const expectRbacForbidden = (resp: { [key: string]: any }) => { + expect(resp.body).to.eql({ + statusCode: 403, + error: 'Forbidden', + message: 'Unauthorized to delete spaces', + }); + }; + + const expectReservedSpaceResult = (resp: { [key: string]: any }) => { + expect(resp.body).to.eql({ + error: 'Bad Request', + statusCode: 400, + message: `This Space cannot be deleted because it is reserved.`, + }); + }; + + const makeDeleteTest = (describeFn: DescribeFn) => ( + description: string, + { user = {}, spaceId, tests }: DeleteTestDefinition + ) => { + describeFn(description, () => { + before(() => esArchiver.load('saved_objects/spaces')); + after(() => esArchiver.unload('saved_objects/spaces')); + + it(`should return ${tests.exists.statusCode}`, async () => { + return supertest + .delete(`${getUrlPrefix(spaceId)}/api/spaces/space/space_2`) + .auth(user.username, user.password) + .expect(tests.exists.statusCode) + .then(tests.exists.response); + }); + + describe(`when the space is reserved`, async () => { + it(`should return ${tests.reservedSpace.statusCode}`, async () => { + return supertest + .delete(`${getUrlPrefix(spaceId)}/api/spaces/space/default`) + .auth(user.username, user.password) + .expect(tests.reservedSpace.statusCode) + .then(tests.reservedSpace.response); + }); + }); + + describe(`when the space doesn't exist`, () => { + it(`should return ${tests.doesntExist.statusCode}`, async () => { + return supertest + .delete(`${getUrlPrefix(spaceId)}/api/spaces/space/space_3`) + .auth(user.username, user.password) + .expect(tests.doesntExist.statusCode) + .then(tests.doesntExist.response); + }); + }); + }); + }; + + const deleteTest = makeDeleteTest(describe); + // @ts-ignore + deleteTest.only = makeDeleteTest(describe.only); + + return { + createExpectLegacyForbidden, + createExpectResult, + deleteTest, + expectEmptyResult, + expectNotFound, + expectRbacForbidden, + expectReservedSpaceResult, + }; +} diff --git a/x-pack/test/spaces_api_integration/common/suites/get.ts b/x-pack/test/spaces_api_integration/common/suites/get.ts new file mode 100644 index 00000000000000..50cddc4a7dac35 --- /dev/null +++ b/x-pack/test/spaces_api_integration/common/suites/get.ts @@ -0,0 +1,110 @@ +/* + * 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 'expect.js'; +import { SuperAgent } from 'superagent'; +import { getUrlPrefix } from '../lib/space_test_utils'; +import { DescribeFn, TestDefinitionAuthentication } from '../lib/types'; + +interface GetTest { + statusCode: number; + response: (resp: { [key: string]: any }) => void; +} + +interface GetTests { + default: GetTest; +} + +interface GetTestDefinition { + user?: TestDefinitionAuthentication; + currentSpaceId: string; + spaceId: string; + tests: GetTests; +} + +const nonExistantSpaceId = 'not-a-space'; + +export function getTestSuiteFactory(esArchiver: any, supertest: SuperAgent) { + const createExpectEmptyResult = () => (resp: { [key: string]: any }) => { + expect(resp.body).to.eql(''); + }; + + const createExpectLegacyForbidden = (username: string) => (resp: { [key: string]: any }) => { + expect(resp.body).to.eql({ + statusCode: 403, + error: 'Forbidden', + message: `action [indices:data/read/get] is unauthorized for user [${username}]: [security_exception] action [indices:data/read/get] is unauthorized for user [${username}]`, + }); + }; + + const createExpectNotFoundResult = () => (resp: { [key: string]: any }) => { + expect(resp.body).to.eql({ + error: 'Not Found', + statusCode: 404, + }); + }; + + const createExpectRbacForbidden = (spaceId: string) => (resp: { [key: string]: any }) => { + expect(resp.body).to.eql({ + statusCode: 403, + error: 'Forbidden', + message: `Unauthorized to get ${spaceId} space`, + }); + }; + + const createExpectResults = (spaceId: string) => (resp: { [key: string]: any }) => { + const allSpaces = [ + { + id: 'default', + name: 'Default Space', + description: 'This is the default space', + _reserved: true, + }, + { + id: 'space_1', + name: 'Space 1', + description: 'This is the first test space', + }, + { + id: 'space_2', + name: 'Space 2', + description: 'This is the second test space', + }, + ]; + expect(resp.body).to.eql(allSpaces.find(space => space.id === spaceId)); + }; + + const makeGetTest = (describeFn: DescribeFn) => ( + description: string, + { user = {}, currentSpaceId, spaceId, tests }: GetTestDefinition + ) => { + describeFn(description, () => { + before(() => esArchiver.load('saved_objects/spaces')); + after(() => esArchiver.unload('saved_objects/spaces')); + + it(`should return ${tests.default.statusCode}`, async () => { + return supertest + .get(`${getUrlPrefix(currentSpaceId)}/api/spaces/space/${spaceId}`) + .auth(user.username, user.password) + .expect(tests.default.statusCode) + .then(tests.default.response); + }); + }); + }; + + const getTest = makeGetTest(describe); + // @ts-ignore + getTest.only = makeGetTest(describe); + + return { + createExpectResults, + createExpectRbacForbidden, + createExpectEmptyResult, + createExpectNotFoundResult, + createExpectLegacyForbidden, + getTest, + nonExistantSpaceId, + }; +} diff --git a/x-pack/test/spaces_api_integration/common/suites/get_all.ts b/x-pack/test/spaces_api_integration/common/suites/get_all.ts new file mode 100644 index 00000000000000..3bc1be39c9c702 --- /dev/null +++ b/x-pack/test/spaces_api_integration/common/suites/get_all.ts @@ -0,0 +1,89 @@ +/* + * 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 'expect.js'; +import { SuperTest } from 'supertest'; +import { getUrlPrefix } from '../lib/space_test_utils'; +import { DescribeFn, TestDefinitionAuthentication } from '../lib/types'; + +interface GetAllTest { + statusCode: number; + response: (resp: { [key: string]: any }) => void; +} + +interface GetAllTests { + exists: GetAllTest; +} + +interface GetAllTestDefinition { + user?: TestDefinitionAuthentication; + spaceId: string; + tests: GetAllTests; +} + +export function getAllTestSuiteFactory(esArchiver: any, supertest: SuperTest) { + const createExpectLegacyForbidden = (username: string) => (resp: { [key: string]: any }) => { + expect(resp.body).to.eql({ + statusCode: 403, + error: 'Forbidden', + message: `action [indices:data/read/search] is unauthorized for user [${username}]: [security_exception] action [indices:data/read/search] is unauthorized for user [${username}]`, + }); + }; + + const createExpectResults = (...spaceIds: string[]) => (resp: { [key: string]: any }) => { + const expectedBody = [ + { + id: 'default', + name: 'Default Space', + description: 'This is the default space', + _reserved: true, + }, + { + id: 'space_1', + name: 'Space 1', + description: 'This is the first test space', + }, + { + id: 'space_2', + name: 'Space 2', + description: 'This is the second test space', + }, + ].filter(entry => spaceIds.includes(entry.id)); + expect(resp.body).to.eql(expectedBody); + }; + + const expectEmptyResult = (resp: { [key: string]: any }) => { + expect(resp.body).to.eql(''); + }; + + const makeGetAllTest = (describeFn: DescribeFn) => ( + description: string, + { user = {}, spaceId, tests }: GetAllTestDefinition + ) => { + describeFn(description, () => { + before(() => esArchiver.load('saved_objects/spaces')); + after(() => esArchiver.unload('saved_objects/spaces')); + + it(`should return ${tests.exists.statusCode}`, async () => { + return supertest + .get(`${getUrlPrefix(spaceId)}/api/spaces/space`) + .auth(user.username, user.password) + .expect(tests.exists.statusCode) + .then(tests.exists.response); + }); + }); + }; + + const getAllTest = makeGetAllTest(describe); + // @ts-ignore + getAllTest.only = makeGetAllTest(describe.only); + + return { + createExpectResults, + createExpectLegacyForbidden, + getAllTest, + expectEmptyResult, + }; +} diff --git a/x-pack/test/spaces_api_integration/common/suites/select.ts b/x-pack/test/spaces_api_integration/common/suites/select.ts new file mode 100644 index 00000000000000..ca4d3556a04cba --- /dev/null +++ b/x-pack/test/spaces_api_integration/common/suites/select.ts @@ -0,0 +1,130 @@ +/* + * 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 'expect.js'; +import { SuperTest } from 'supertest'; +import { DEFAULT_SPACE_ID } from '../../../../plugins/spaces/common/constants'; +import { getUrlPrefix } from '../lib/space_test_utils'; +import { DescribeFn, TestDefinitionAuthentication } from '../lib/types'; + +interface SelectTest { + statusCode: number; + response: (resp: { [key: string]: any }) => void; +} + +interface SelectTests { + default: SelectTest; +} + +interface SelectTestDefinition { + user?: TestDefinitionAuthentication; + currentSpaceId: string; + selectSpaceId: string; + tests: SelectTests; +} + +const nonExistantSpaceId = 'not-a-space'; + +export function selectTestSuiteFactory(esArchiver: any, supertest: SuperTest) { + const createExpectEmptyResult = () => (resp: { [key: string]: any }) => { + expect(resp.body).to.eql(''); + }; + + const createExpectLegacyForbidden = (username: string) => (resp: { [key: string]: any }) => { + expect(resp.body).to.eql({ + statusCode: 403, + error: 'Forbidden', + message: `action [indices:data/read/get] is unauthorized for user [${username}]: [security_exception] action [indices:data/read/get] is unauthorized for user [${username}]`, + }); + }; + + const createExpectNotFoundResult = () => (resp: { [key: string]: any }) => { + expect(resp.body).to.eql({ + error: 'Not Found', + statusCode: 404, + }); + }; + + const createExpectRbacForbidden = (spaceId: any) => (resp: { [key: string]: any }) => { + expect(resp.body).to.eql({ + statusCode: 403, + error: 'Forbidden', + message: `Unauthorized to get ${spaceId} space`, + }); + }; + + const createExpectResults = (spaceId: string) => (resp: { [key: string]: any }) => { + const allSpaces = [ + { + id: 'default', + name: 'Default Space', + description: 'This is the default space', + _reserved: true, + }, + { + id: 'space_1', + name: 'Space 1', + description: 'This is the first test space', + }, + { + id: 'space_2', + name: 'Space 2', + description: 'This is the second test space', + }, + ]; + expect(resp.body).to.eql(allSpaces.find(space => space.id === spaceId)); + }; + + const createExpectSpaceResponse = (spaceId: string) => (resp: { [key: string]: any }) => { + if (spaceId === DEFAULT_SPACE_ID) { + expectDefaultSpaceResponse(resp); + } else { + expect(resp.body).to.eql({ + location: `/s/${spaceId}/app/kibana`, + }); + } + }; + + const expectDefaultSpaceResponse = (resp: { [key: string]: any }) => { + expect(resp.body).to.eql({ + location: `/app/kibana`, + }); + }; + + const makeSelectTest = (describeFn: DescribeFn) => ( + description: string, + { user = {}, currentSpaceId, selectSpaceId, tests }: SelectTestDefinition + ) => { + describeFn(description, () => { + before(() => esArchiver.load('saved_objects/spaces')); + after(() => esArchiver.unload('saved_objects/spaces')); + + it(`should return ${tests.default.statusCode}`, async () => { + return supertest + .post(`${getUrlPrefix(currentSpaceId)}/api/spaces/v1/space/${selectSpaceId}/select`) + .auth(user.username, user.password) + .expect(tests.default.statusCode) + .then(tests.default.response); + }); + }); + }; + + const selectTest = makeSelectTest(describe); + // @ts-ignore + selectTest.only = makeSelectTest(describe.only); + + return { + createExpectEmptyResult, + createExpectLegacyForbidden, + createExpectNotFoundResult, + createExpectRbacForbidden, + createExpectResults, + createExpectSpaceResponse, + expectDefaultSpaceResponse, + nonExistantSpaceId, + selectTest, + }; +} diff --git a/x-pack/test/spaces_api_integration/common/suites/update.ts b/x-pack/test/spaces_api_integration/common/suites/update.ts new file mode 100644 index 00000000000000..cfd1123e2b72c8 --- /dev/null +++ b/x-pack/test/spaces_api_integration/common/suites/update.ts @@ -0,0 +1,143 @@ +/* + * 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 'expect.js'; +import { SuperTest } from 'supertest'; +import { getUrlPrefix } from '../lib/space_test_utils'; +import { DescribeFn, TestDefinitionAuthentication } from '../lib/types'; + +interface UpdateTest { + statusCode: number; + response: (resp: { [key: string]: any }) => void; +} + +interface UpdateTests { + alreadyExists: UpdateTest; + defaultSpace: UpdateTest; + newSpace: UpdateTest; +} + +interface UpdateTestDefinition { + user?: TestDefinitionAuthentication; + spaceId: string; + tests: UpdateTests; +} + +export function updateTestSuiteFactory(esArchiver: any, supertest: SuperTest) { + const expectRbacForbidden = (resp: { [key: string]: any }) => { + expect(resp.body).to.eql({ + statusCode: 403, + error: 'Forbidden', + message: 'Unauthorized to update spaces', + }); + }; + + const createExpectLegacyForbidden = (username: string) => (resp: { [key: string]: any }) => { + expect(resp.body).to.eql({ + statusCode: 403, + error: 'Forbidden', + message: `action [indices:data/write/update] is unauthorized for user [${username}]: [security_exception] action [indices:data/write/update] is unauthorized for user [${username}]`, + }); + }; + + const expectNotFound = (resp: { [key: string]: any }) => { + expect(resp.body).to.eql({ + error: 'Not Found', + statusCode: 404, + }); + }; + + const expectDefaultSpaceResult = (resp: { [key: string]: any }) => { + expect(resp.body).to.eql({ + name: 'the new default', + id: 'default', + description: 'a description', + color: '#ffffff', + _reserved: true, + }); + }; + + const expectAlreadyExistsResult = (resp: { [key: string]: any }) => { + expect(resp.body).to.eql({ + name: 'space 1', + id: 'space_1', + description: 'a description', + color: '#5c5959', + }); + }; + + const makeUpdateTest = (describeFn: DescribeFn) => ( + description: string, + { user = {}, spaceId, tests }: UpdateTestDefinition + ) => { + describeFn(description, () => { + before(() => esArchiver.load('saved_objects/spaces')); + after(() => esArchiver.unload('saved_objects/spaces')); + + describe('space_1', () => { + it(`should return ${tests.alreadyExists.statusCode}`, async () => { + return supertest + .put(`${getUrlPrefix(spaceId)}/api/spaces/space/space_1`) + .auth(user.username, user.password) + .send({ + name: 'space 1', + id: 'space_1', + description: 'a description', + color: '#5c5959', + _reserved: true, + }) + .expect(tests.alreadyExists.statusCode) + .then(tests.alreadyExists.response); + }); + }); + + describe(`default space`, () => { + it(`should return ${tests.defaultSpace.statusCode}`, async () => { + return supertest + .put(`${getUrlPrefix(spaceId)}/api/spaces/space/default`) + .auth(user.username, user.password) + .send({ + name: 'the new default', + id: 'default', + description: 'a description', + color: '#ffffff', + _reserved: false, + }) + .expect(tests.defaultSpace.statusCode) + .then(tests.defaultSpace.response); + }); + }); + + describe(`when space doesn't exist`, () => { + it(`should return ${tests.newSpace.statusCode}`, async () => { + return supertest + .put(`${getUrlPrefix(spaceId)}/api/spaces/space/marketing`) + .auth(user.username, user.password) + .send({ + name: 'marketing', + id: 'marketing', + description: 'a description', + color: '#5c5959', + }) + .expect(tests.newSpace.statusCode) + .then(tests.newSpace.response); + }); + }); + }); + }; + + const updateTest = makeUpdateTest(describe); + // @ts-ignore + updateTest.only = makeUpdateTest(describe.only); + + return { + createExpectLegacyForbidden, + expectAlreadyExistsResult, + expectDefaultSpaceResult, + expectNotFound, + expectRbacForbidden, + updateTest, + }; +} diff --git a/x-pack/test/spaces_api_integration/security_and_spaces/apis/create.ts b/x-pack/test/spaces_api_integration/security_and_spaces/apis/create.ts new file mode 100644 index 00000000000000..007b8351778c6c --- /dev/null +++ b/x-pack/test/spaces_api_integration/security_and_spaces/apis/create.ts @@ -0,0 +1,229 @@ +/* + * 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 { AUTHENTICATION } from '../../common/lib/authentication'; +import { SPACES } from '../../common/lib/spaces'; +import { TestInvoker } from '../../common/lib/types'; +import { createTestSuiteFactory } from '../../common/suites/create'; + +// tslint:disable:no-default-export +export default function createSpacesOnlySuite({ getService }: TestInvoker) { + const supertestWithoutAuth = getService('supertestWithoutAuth'); + const esArchiver = getService('esArchiver'); + + const { + createTest, + expectNewSpaceResult, + expectReservedSpecifiedResult, + expectConflictResponse, + expectRbacForbiddenResponse, + createExpectLegacyForbiddenResponse, + } = createTestSuiteFactory(esArchiver, supertestWithoutAuth); + + describe('create', () => { + [ + { + spaceId: SPACES.DEFAULT.spaceId, + users: { + noAccess: AUTHENTICATION.NOT_A_KIBANA_USER, + superuser: AUTHENTICATION.SUPERUSER, + allGlobally: AUTHENTICATION.KIBANA_RBAC_USER, + readGlobally: AUTHENTICATION.KIBANA_RBAC_DASHBOARD_ONLY_USER, + allAtSpace: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_ALL_USER, + legacyAll: AUTHENTICATION.KIBANA_LEGACY_USER, + legacyRead: AUTHENTICATION.KIBANA_LEGACY_DASHBOARD_ONLY_USER, + dualAll: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_USER, + dualRead: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER, + }, + }, + { + spaceId: SPACES.SPACE_1.spaceId, + users: { + noAccess: AUTHENTICATION.NOT_A_KIBANA_USER, + superuser: AUTHENTICATION.SUPERUSER, + allGlobally: AUTHENTICATION.KIBANA_RBAC_USER, + readGlobally: AUTHENTICATION.KIBANA_RBAC_DASHBOARD_ONLY_USER, + allAtSpace: AUTHENTICATION.KIBANA_RBAC_SPACE_1_ALL_USER, + legacyAll: AUTHENTICATION.KIBANA_LEGACY_USER, + legacyRead: AUTHENTICATION.KIBANA_LEGACY_DASHBOARD_ONLY_USER, + dualAll: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_USER, + dualRead: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER, + }, + }, + ].forEach(scenario => { + createTest(`user with no access from the ${scenario.spaceId} space`, { + spaceId: scenario.spaceId, + user: scenario.users.noAccess, + tests: { + newSpace: { + statusCode: 403, + response: createExpectLegacyForbiddenResponse(scenario.users.noAccess.username), + }, + alreadyExists: { + statusCode: 403, + response: createExpectLegacyForbiddenResponse(scenario.users.noAccess.username), + }, + reservedSpecified: { + statusCode: 403, + response: createExpectLegacyForbiddenResponse(scenario.users.noAccess.username), + }, + }, + }); + + createTest(`superuser from the ${scenario.spaceId} space`, { + spaceId: scenario.spaceId, + user: scenario.users.superuser, + tests: { + newSpace: { + statusCode: 200, + response: expectNewSpaceResult, + }, + alreadyExists: { + statusCode: 409, + response: expectConflictResponse, + }, + reservedSpecified: { + statusCode: 200, + response: expectReservedSpecifiedResult, + }, + }, + }); + + createTest(`rbac user with all globally from the ${scenario.spaceId} space`, { + spaceId: scenario.spaceId, + user: scenario.users.allGlobally, + tests: { + newSpace: { + statusCode: 200, + response: expectNewSpaceResult, + }, + alreadyExists: { + statusCode: 409, + response: expectConflictResponse, + }, + reservedSpecified: { + statusCode: 200, + response: expectReservedSpecifiedResult, + }, + }, + }); + + createTest(`dual-privileges user from the ${scenario.spaceId} space`, { + spaceId: scenario.spaceId, + user: scenario.users.dualAll, + tests: { + newSpace: { + statusCode: 200, + response: expectNewSpaceResult, + }, + alreadyExists: { + statusCode: 409, + response: expectConflictResponse, + }, + reservedSpecified: { + statusCode: 200, + response: expectReservedSpecifiedResult, + }, + }, + }); + + createTest(`legacy user from the ${scenario.spaceId} space`, { + spaceId: scenario.spaceId, + user: scenario.users.legacyAll, + tests: { + newSpace: { + statusCode: 200, + response: expectNewSpaceResult, + }, + alreadyExists: { + statusCode: 409, + response: expectConflictResponse, + }, + reservedSpecified: { + statusCode: 200, + response: expectReservedSpecifiedResult, + }, + }, + }); + + createTest(`rbac user with read globally from the ${scenario.spaceId} space`, { + spaceId: scenario.spaceId, + user: scenario.users.readGlobally, + tests: { + newSpace: { + statusCode: 403, + response: expectRbacForbiddenResponse, + }, + alreadyExists: { + statusCode: 403, + response: expectRbacForbiddenResponse, + }, + reservedSpecified: { + statusCode: 403, + response: expectRbacForbiddenResponse, + }, + }, + }); + + createTest(`dual-privileges readonly user from the ${scenario.spaceId} space`, { + spaceId: scenario.spaceId, + user: scenario.users.dualRead, + tests: { + newSpace: { + statusCode: 403, + response: expectRbacForbiddenResponse, + }, + alreadyExists: { + statusCode: 403, + response: expectRbacForbiddenResponse, + }, + reservedSpecified: { + statusCode: 403, + response: expectRbacForbiddenResponse, + }, + }, + }); + + createTest(`legacy readonly user from the ${scenario.spaceId} space`, { + spaceId: scenario.spaceId, + user: scenario.users.legacyRead, + tests: { + newSpace: { + statusCode: 403, + response: createExpectLegacyForbiddenResponse(scenario.users.legacyRead.username), + }, + alreadyExists: { + statusCode: 403, + response: createExpectLegacyForbiddenResponse(scenario.users.legacyRead.username), + }, + reservedSpecified: { + statusCode: 403, + response: createExpectLegacyForbiddenResponse(scenario.users.legacyRead.username), + }, + }, + }); + + createTest(`rbac user with all at space from the ${scenario.spaceId} space`, { + spaceId: scenario.spaceId, + user: scenario.users.allAtSpace, + tests: { + newSpace: { + statusCode: 403, + response: expectRbacForbiddenResponse, + }, + alreadyExists: { + statusCode: 403, + response: expectRbacForbiddenResponse, + }, + reservedSpecified: { + statusCode: 403, + response: expectRbacForbiddenResponse, + }, + }, + }); + }); + }); +} diff --git a/x-pack/test/spaces_api_integration/security_and_spaces/apis/delete.ts b/x-pack/test/spaces_api_integration/security_and_spaces/apis/delete.ts new file mode 100644 index 00000000000000..7d2d124f1a3744 --- /dev/null +++ b/x-pack/test/spaces_api_integration/security_and_spaces/apis/delete.ts @@ -0,0 +1,232 @@ +/* + * 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 { AUTHENTICATION } from '../../common/lib/authentication'; +import { SPACES } from '../../common/lib/spaces'; +import { TestInvoker } from '../../common/lib/types'; +import { deleteTestSuiteFactory } from '../../common/suites/delete'; + +// tslint:disable:no-default-export +export default function deleteSpaceTestSuite({ getService }: TestInvoker) { + const supertestWithoutAuth = getService('supertestWithoutAuth'); + const esArchiver = getService('esArchiver'); + + const { + deleteTest, + createExpectLegacyForbidden, + expectRbacForbidden, + expectEmptyResult, + expectNotFound, + expectReservedSpaceResult, + } = deleteTestSuiteFactory(esArchiver, supertestWithoutAuth); + + describe('delete', () => { + [ + { + spaceId: SPACES.DEFAULT.spaceId, + users: { + noAccess: AUTHENTICATION.NOT_A_KIBANA_USER, + superuser: AUTHENTICATION.SUPERUSER, + allGlobally: AUTHENTICATION.KIBANA_RBAC_USER, + readGlobally: AUTHENTICATION.KIBANA_RBAC_DASHBOARD_ONLY_USER, + allAtSpace: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_ALL_USER, + legacyAll: AUTHENTICATION.KIBANA_LEGACY_USER, + legacyRead: AUTHENTICATION.KIBANA_LEGACY_DASHBOARD_ONLY_USER, + dualAll: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_USER, + dualRead: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER, + }, + }, + { + spaceId: SPACES.SPACE_1.spaceId, + users: { + noAccess: AUTHENTICATION.NOT_A_KIBANA_USER, + superuser: AUTHENTICATION.SUPERUSER, + allGlobally: AUTHENTICATION.KIBANA_RBAC_USER, + readGlobally: AUTHENTICATION.KIBANA_RBAC_DASHBOARD_ONLY_USER, + allAtSpace: AUTHENTICATION.KIBANA_RBAC_SPACE_1_ALL_USER, + legacyAll: AUTHENTICATION.KIBANA_LEGACY_USER, + legacyRead: AUTHENTICATION.KIBANA_LEGACY_DASHBOARD_ONLY_USER, + dualAll: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_USER, + dualRead: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER, + }, + }, + ].forEach(scenario => { + deleteTest(`user with no access from the ${scenario.spaceId} space`, { + spaceId: scenario.spaceId, + user: scenario.users.noAccess, + tests: { + exists: { + statusCode: 403, + response: createExpectLegacyForbidden(scenario.users.noAccess.username, 'read/get'), + }, + reservedSpace: { + statusCode: 403, + response: createExpectLegacyForbidden(scenario.users.noAccess.username, 'read/get'), + }, + doesntExist: { + statusCode: 403, + response: createExpectLegacyForbidden(scenario.users.noAccess.username, 'read/get'), + }, + }, + }); + + deleteTest(`superuser from the ${scenario.spaceId} space`, { + spaceId: scenario.spaceId, + user: scenario.users.superuser, + tests: { + exists: { + statusCode: 204, + response: expectEmptyResult, + }, + reservedSpace: { + statusCode: 400, + response: expectReservedSpaceResult, + }, + doesntExist: { + statusCode: 404, + response: expectNotFound, + }, + }, + }); + + deleteTest(`rbac user with all globally from the ${scenario.spaceId} space`, { + spaceId: scenario.spaceId, + user: scenario.users.allGlobally, + tests: { + exists: { + statusCode: 204, + response: expectEmptyResult, + }, + reservedSpace: { + statusCode: 400, + response: expectReservedSpaceResult, + }, + doesntExist: { + statusCode: 404, + response: expectNotFound, + }, + }, + }); + + deleteTest(`dual-privileges user from the ${scenario.spaceId} space`, { + spaceId: scenario.spaceId, + user: scenario.users.dualAll, + tests: { + exists: { + statusCode: 204, + response: expectEmptyResult, + }, + reservedSpace: { + statusCode: 400, + response: expectReservedSpaceResult, + }, + doesntExist: { + statusCode: 404, + response: expectNotFound, + }, + }, + }); + + deleteTest(`legacy user from the ${scenario.spaceId} space`, { + spaceId: scenario.spaceId, + user: scenario.users.legacyAll, + tests: { + exists: { + statusCode: 204, + response: expectEmptyResult, + }, + reservedSpace: { + statusCode: 400, + response: expectReservedSpaceResult, + }, + doesntExist: { + statusCode: 404, + response: expectNotFound, + }, + }, + }); + + deleteTest(`rbac user with read globally from the ${scenario.spaceId} space`, { + spaceId: scenario.spaceId, + user: scenario.users.readGlobally, + tests: { + exists: { + statusCode: 403, + response: expectRbacForbidden, + }, + reservedSpace: { + statusCode: 403, + response: expectRbacForbidden, + }, + doesntExist: { + statusCode: 403, + response: expectRbacForbidden, + }, + }, + }); + + deleteTest(`dual-privileges readonly user from the ${scenario.spaceId} space`, { + spaceId: scenario.spaceId, + user: scenario.users.dualRead, + tests: { + exists: { + statusCode: 403, + response: expectRbacForbidden, + }, + reservedSpace: { + statusCode: 403, + response: expectRbacForbidden, + }, + doesntExist: { + statusCode: 403, + response: expectRbacForbidden, + }, + }, + }); + + deleteTest(`legacy readonly user from the ${scenario.spaceId} space`, { + spaceId: scenario.spaceId, + user: scenario.users.legacyRead, + tests: { + exists: { + statusCode: 403, + response: createExpectLegacyForbidden( + scenario.users.legacyRead.username, + 'write/delete' + ), + }, + reservedSpace: { + statusCode: 400, + response: expectReservedSpaceResult, + }, + doesntExist: { + statusCode: 404, + response: expectNotFound, + }, + }, + }); + + deleteTest(`rbac user with all at space from the ${scenario.spaceId} space`, { + spaceId: scenario.spaceId, + user: scenario.users.allAtSpace, + tests: { + exists: { + statusCode: 403, + response: expectRbacForbidden, + }, + reservedSpace: { + statusCode: 403, + response: expectRbacForbidden, + }, + doesntExist: { + statusCode: 403, + response: expectRbacForbidden, + }, + }, + }); + }); + }); +} diff --git a/x-pack/test/spaces_api_integration/security_and_spaces/apis/get.ts b/x-pack/test/spaces_api_integration/security_and_spaces/apis/get.ts new file mode 100644 index 00000000000000..bff11d41602ac6 --- /dev/null +++ b/x-pack/test/spaces_api_integration/security_and_spaces/apis/get.ts @@ -0,0 +1,291 @@ +/* + * 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 { AUTHENTICATION } from '../../common/lib/authentication'; +import { SPACES } from '../../common/lib/spaces'; +import { TestInvoker } from '../../common/lib/types'; +import { getTestSuiteFactory } from '../../common/suites/get'; + +// tslint:disable:no-default-export +export default function getSpaceTestSuite({ getService }: TestInvoker) { + const supertestWithoutAuth = getService('supertestWithoutAuth'); + const esArchiver = getService('esArchiver'); + + const { + getTest, + createExpectResults, + createExpectNotFoundResult, + createExpectRbacForbidden, + createExpectLegacyForbidden, + nonExistantSpaceId, + } = getTestSuiteFactory(esArchiver, supertestWithoutAuth); + + describe('get', () => { + [ + { + spaceId: SPACES.DEFAULT.spaceId, + otherSpaceId: SPACES.SPACE_1.spaceId, + users: { + noAccess: AUTHENTICATION.NOT_A_KIBANA_USER, + superuser: AUTHENTICATION.SUPERUSER, + allGlobally: AUTHENTICATION.KIBANA_RBAC_USER, + readGlobally: AUTHENTICATION.KIBANA_RBAC_DASHBOARD_ONLY_USER, + allAtSpace: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_ALL_USER, + readAtSpace: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_READ_USER, + allAtOtherSpace: AUTHENTICATION.KIBANA_RBAC_SPACE_1_ALL_USER, + legacyAll: AUTHENTICATION.KIBANA_LEGACY_USER, + legacyRead: AUTHENTICATION.KIBANA_LEGACY_DASHBOARD_ONLY_USER, + dualAll: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_USER, + dualRead: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER, + }, + }, + { + spaceId: SPACES.SPACE_1.spaceId, + otherSpaceId: SPACES.DEFAULT.spaceId, + users: { + noAccess: AUTHENTICATION.NOT_A_KIBANA_USER, + superuser: AUTHENTICATION.SUPERUSER, + allGlobally: AUTHENTICATION.KIBANA_RBAC_USER, + readGlobally: AUTHENTICATION.KIBANA_RBAC_DASHBOARD_ONLY_USER, + allAtSpace: AUTHENTICATION.KIBANA_RBAC_SPACE_1_ALL_USER, + readAtSpace: AUTHENTICATION.KIBANA_RBAC_SPACE_1_READ_USER, + allAtOtherSpace: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_ALL_USER, + legacyAll: AUTHENTICATION.KIBANA_LEGACY_USER, + legacyRead: AUTHENTICATION.KIBANA_LEGACY_DASHBOARD_ONLY_USER, + dualAll: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_USER, + dualRead: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER, + }, + }, + ].forEach(scenario => { + getTest(`user with no access`, { + currentSpaceId: scenario.spaceId, + spaceId: scenario.spaceId, + user: scenario.users.noAccess, + tests: { + default: { + statusCode: 403, + response: createExpectLegacyForbidden(scenario.users.noAccess.username), + }, + }, + }); + + getTest(`superuser`, { + currentSpaceId: scenario.spaceId, + spaceId: scenario.spaceId, + user: scenario.users.superuser, + tests: { + default: { + statusCode: 200, + response: createExpectResults(scenario.spaceId), + }, + }, + }); + + getTest(`rbac user with all globally`, { + currentSpaceId: scenario.spaceId, + spaceId: scenario.spaceId, + user: scenario.users.allGlobally, + tests: { + default: { + statusCode: 200, + response: createExpectResults(scenario.spaceId), + }, + }, + }); + + getTest(`dual-privileges user`, { + currentSpaceId: scenario.spaceId, + spaceId: scenario.spaceId, + user: scenario.users.dualAll, + tests: { + default: { + statusCode: 200, + response: createExpectResults(scenario.spaceId), + }, + }, + }); + + getTest(`legacy user`, { + currentSpaceId: scenario.spaceId, + spaceId: scenario.spaceId, + user: scenario.users.legacyAll, + tests: { + default: { + statusCode: 200, + response: createExpectResults(scenario.spaceId), + }, + }, + }); + + getTest(`rbac user with read globally`, { + currentSpaceId: scenario.spaceId, + spaceId: scenario.spaceId, + user: scenario.users.readGlobally, + tests: { + default: { + statusCode: 200, + response: createExpectResults(scenario.spaceId), + }, + }, + }); + + getTest(`dual-privileges readonly user`, { + currentSpaceId: scenario.spaceId, + spaceId: scenario.spaceId, + user: scenario.users.dualRead, + tests: { + default: { + statusCode: 200, + response: createExpectResults(scenario.spaceId), + }, + }, + }); + + getTest(`legacy readonly`, { + currentSpaceId: scenario.spaceId, + spaceId: scenario.spaceId, + user: scenario.users.legacyRead, + tests: { + default: { + statusCode: 200, + response: createExpectResults(scenario.spaceId), + }, + }, + }); + + getTest(`rbac user with read at space from the ${scenario.spaceId} space`, { + currentSpaceId: scenario.spaceId, + spaceId: scenario.spaceId, + user: scenario.users.readAtSpace, + tests: { + default: { + statusCode: 200, + response: createExpectResults(scenario.spaceId), + }, + }, + }); + + getTest( + `rbac user with all at other space from the ${scenario.otherSpaceId} getting the ${ + scenario.spaceId + }`, + { + currentSpaceId: scenario.otherSpaceId, + spaceId: scenario.spaceId, + user: scenario.users.allAtOtherSpace, + tests: { + default: { + statusCode: 403, + response: createExpectRbacForbidden(scenario.spaceId), + }, + }, + } + ); + }); + + describe('non-existant space', () => { + [ + { + spaceId: SPACES.DEFAULT.spaceId, + otherSpaceId: nonExistantSpaceId, + users: { + allGlobally: AUTHENTICATION.KIBANA_RBAC_USER, + readGlobally: AUTHENTICATION.KIBANA_RBAC_DASHBOARD_ONLY_USER, + allAtDefaultSpace: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_ALL_USER, + legacyAll: AUTHENTICATION.KIBANA_LEGACY_USER, + legacyRead: AUTHENTICATION.KIBANA_LEGACY_DASHBOARD_ONLY_USER, + dualAll: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_USER, + dualRead: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER, + }, + }, + ].forEach(scenario => { + getTest(`rbac user with all globally`, { + currentSpaceId: scenario.spaceId, + spaceId: scenario.otherSpaceId, + user: scenario.users.allGlobally, + tests: { + default: { + statusCode: 404, + response: createExpectNotFoundResult(), + }, + }, + }); + + getTest(`dual-privileges user`, { + currentSpaceId: scenario.spaceId, + spaceId: scenario.otherSpaceId, + user: scenario.users.dualAll, + tests: { + default: { + statusCode: 404, + response: createExpectNotFoundResult(), + }, + }, + }); + + getTest(`legacy user`, { + currentSpaceId: scenario.spaceId, + spaceId: scenario.otherSpaceId, + user: scenario.users.legacyAll, + tests: { + default: { + statusCode: 404, + response: createExpectNotFoundResult(), + }, + }, + }); + + getTest(`rbac user with read globally`, { + currentSpaceId: scenario.spaceId, + spaceId: scenario.otherSpaceId, + user: scenario.users.readGlobally, + tests: { + default: { + statusCode: 404, + response: createExpectNotFoundResult(), + }, + }, + }); + + getTest(`dual-privileges readonly user`, { + currentSpaceId: scenario.spaceId, + spaceId: scenario.otherSpaceId, + user: scenario.users.dualRead, + tests: { + default: { + statusCode: 404, + response: createExpectNotFoundResult(), + }, + }, + }); + + getTest(`legacy readonly user`, { + currentSpaceId: scenario.spaceId, + spaceId: scenario.otherSpaceId, + user: scenario.users.legacyRead, + tests: { + default: { + statusCode: 404, + response: createExpectNotFoundResult(), + }, + }, + }); + + getTest(`rbac user with all at default space`, { + currentSpaceId: scenario.spaceId, + spaceId: scenario.otherSpaceId, + user: scenario.users.allAtDefaultSpace, + tests: { + default: { + statusCode: 403, + response: createExpectRbacForbidden(scenario.otherSpaceId), + }, + }, + }); + }); + }); + }); +} diff --git a/x-pack/test/spaces_api_integration/security_and_spaces/apis/get_all.ts b/x-pack/test/spaces_api_integration/security_and_spaces/apis/get_all.ts new file mode 100644 index 00000000000000..d7dd0d9468d6d3 --- /dev/null +++ b/x-pack/test/spaces_api_integration/security_and_spaces/apis/get_all.ts @@ -0,0 +1,198 @@ +/* + * 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 { AUTHENTICATION } from '../../common/lib/authentication'; +import { SPACES } from '../../common/lib/spaces'; +import { TestInvoker } from '../../common/lib/types'; +import { getAllTestSuiteFactory } from '../../common/suites/get_all'; + +// tslint:disable:no-default-export +export default function getAllSpacesTestSuite({ getService }: TestInvoker) { + const supertestWithoutAuth = getService('supertestWithoutAuth'); + const esArchiver = getService('esArchiver'); + + const { getAllTest, createExpectResults, createExpectLegacyForbidden } = getAllTestSuiteFactory( + esArchiver, + supertestWithoutAuth + ); + + describe('get all', () => { + [ + { + spaceId: SPACES.DEFAULT.spaceId, + users: { + noAccess: AUTHENTICATION.NOT_A_KIBANA_USER, + superuser: AUTHENTICATION.SUPERUSER, + allGlobally: AUTHENTICATION.KIBANA_RBAC_USER, + readGlobally: AUTHENTICATION.KIBANA_RBAC_DASHBOARD_ONLY_USER, + allAtSpace_1: AUTHENTICATION.KIBANA_RBAC_SPACE_1_ALL_USER, + readAtSpace_1: AUTHENTICATION.KIBANA_RBAC_SPACE_1_READ_USER, + allAtDefaultSpace: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_ALL_USER, + readAtDefaultSpace: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_READ_USER, + legacyAll: AUTHENTICATION.KIBANA_LEGACY_USER, + legacyRead: AUTHENTICATION.KIBANA_LEGACY_DASHBOARD_ONLY_USER, + dualAll: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_USER, + dualRead: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER, + }, + }, + { + spaceId: SPACES.SPACE_1.spaceId, + users: { + noAccess: AUTHENTICATION.NOT_A_KIBANA_USER, + superuser: AUTHENTICATION.SUPERUSER, + allGlobally: AUTHENTICATION.KIBANA_RBAC_USER, + readGlobally: AUTHENTICATION.KIBANA_RBAC_DASHBOARD_ONLY_USER, + allAtSpace_1: AUTHENTICATION.KIBANA_RBAC_SPACE_1_ALL_USER, + readAtSpace_1: AUTHENTICATION.KIBANA_RBAC_SPACE_1_READ_USER, + allAtDefaultSpace: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_ALL_USER, + readAtDefaultSpace: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_READ_USER, + legacyAll: AUTHENTICATION.KIBANA_LEGACY_USER, + legacyRead: AUTHENTICATION.KIBANA_LEGACY_DASHBOARD_ONLY_USER, + dualAll: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_USER, + dualRead: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER, + }, + }, + ].forEach(scenario => { + getAllTest(`user with no access can't access any spaces from ${scenario.spaceId}`, { + spaceId: scenario.spaceId, + user: scenario.users.noAccess, + tests: { + exists: { + statusCode: 403, + response: createExpectLegacyForbidden(scenario.users.noAccess.username), + }, + }, + }); + + getAllTest(`superuser can access all spaces from ${scenario.spaceId}`, { + spaceId: scenario.spaceId, + user: scenario.users.superuser, + tests: { + exists: { + statusCode: 200, + response: createExpectResults('default', 'space_1', 'space_2'), + }, + }, + }); + + getAllTest(`rbac user with all globally can access all spaces from ${scenario.spaceId}`, { + spaceId: scenario.spaceId, + user: scenario.users.allGlobally, + tests: { + exists: { + statusCode: 200, + response: createExpectResults('default', 'space_1', 'space_2'), + }, + }, + }); + + getAllTest(`dual-privileges user can access all spaces from ${scenario.spaceId}`, { + spaceId: scenario.spaceId, + user: scenario.users.dualAll, + tests: { + exists: { + statusCode: 200, + response: createExpectResults('default', 'space_1', 'space_2'), + }, + }, + }); + + getAllTest(`legacy user can access all spaces from ${scenario.spaceId}`, { + spaceId: scenario.spaceId, + user: scenario.users.legacyAll, + tests: { + exists: { + statusCode: 200, + response: createExpectResults('default', 'space_1', 'space_2'), + }, + }, + }); + + getAllTest(`rbac user with read globally can access all spaces from ${scenario.spaceId}`, { + spaceId: scenario.spaceId, + user: scenario.users.readGlobally, + tests: { + exists: { + statusCode: 200, + response: createExpectResults('default', 'space_1', 'space_2'), + }, + }, + }); + + getAllTest(`dual-privileges readonly user can access all spaces from ${scenario.spaceId}`, { + spaceId: scenario.spaceId, + user: scenario.users.dualRead, + tests: { + exists: { + statusCode: 200, + response: createExpectResults('default', 'space_1', 'space_2'), + }, + }, + }); + + getAllTest(`legacy readonly user can access all spaces from ${scenario.spaceId}`, { + spaceId: scenario.spaceId, + user: scenario.users.legacyRead, + tests: { + exists: { + statusCode: 200, + response: createExpectResults('default', 'space_1', 'space_2'), + }, + }, + }); + + getAllTest(`rbac user with all at space_1 can access space_1 from ${scenario.spaceId}`, { + spaceId: scenario.spaceId, + user: scenario.users.allAtSpace_1, + tests: { + exists: { + statusCode: 200, + response: createExpectResults('space_1'), + }, + }, + }); + + getAllTest(`rbac user with read at space_1 can access space_1 from ${scenario.spaceId}`, { + spaceId: scenario.spaceId, + user: scenario.users.readAtSpace_1, + tests: { + exists: { + statusCode: 200, + response: createExpectResults('space_1'), + }, + }, + }); + + getAllTest( + `rbac user with all at default space can access default from ${scenario.spaceId}`, + { + spaceId: scenario.spaceId, + user: scenario.users.allAtDefaultSpace, + tests: { + exists: { + statusCode: 200, + response: createExpectResults('default'), + }, + }, + } + ); + + getAllTest( + `rbac user with read at default space can access default from ${scenario.spaceId}`, + { + spaceId: scenario.spaceId, + user: scenario.users.readAtDefaultSpace, + tests: { + exists: { + statusCode: 200, + response: createExpectResults('default'), + }, + }, + } + ); + }); + }); +} diff --git a/x-pack/test/spaces_api_integration/security_and_spaces/apis/index.ts b/x-pack/test/spaces_api_integration/security_and_spaces/apis/index.ts new file mode 100644 index 00000000000000..044670a822ac2f --- /dev/null +++ b/x-pack/test/spaces_api_integration/security_and_spaces/apis/index.ts @@ -0,0 +1,31 @@ +/* + * 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. + */ +/* + * 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 { createUsersAndRoles } from '../../common/lib/create_users_and_roles'; +import { TestInvoker } from '../../common/lib/types'; + +// tslint:disable:no-default-export +export default function({ loadTestFile, getService }: TestInvoker) { + const es = getService('es'); + const supertest = getService('supertest'); + + describe('spaces api with security', () => { + before(async () => { + await createUsersAndRoles(es, supertest); + }); + + loadTestFile(require.resolve('./create')); + loadTestFile(require.resolve('./delete')); + loadTestFile(require.resolve('./get_all')); + loadTestFile(require.resolve('./get')); + loadTestFile(require.resolve('./select')); + loadTestFile(require.resolve('./update')); + }); +} diff --git a/x-pack/test/spaces_api_integration/security_and_spaces/apis/select.ts b/x-pack/test/spaces_api_integration/security_and_spaces/apis/select.ts new file mode 100644 index 00000000000000..3e9e249c2f4bd7 --- /dev/null +++ b/x-pack/test/spaces_api_integration/security_and_spaces/apis/select.ts @@ -0,0 +1,370 @@ +/* + * 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 { AUTHENTICATION } from '../../common/lib/authentication'; +import { SPACES } from '../../common/lib/spaces'; +import { TestInvoker } from '../../common/lib/types'; +import { selectTestSuiteFactory } from '../../common/suites/select'; + +// tslint:disable:no-default-export +export default function selectSpaceTestSuite({ getService }: TestInvoker) { + const supertestWithoutAuth = getService('supertestWithoutAuth'); + const esArchiver = getService('esArchiver'); + + const { + selectTest, + nonExistantSpaceId, + createExpectSpaceResponse, + createExpectRbacForbidden, + createExpectNotFoundResult, + createExpectLegacyForbidden, + } = selectTestSuiteFactory(esArchiver, supertestWithoutAuth); + + describe('select', () => { + // Tests with users that have privileges globally in Kibana + [ + { + currentSpaceId: SPACES.DEFAULT.spaceId, + selectSpaceId: SPACES.SPACE_1.spaceId, + users: { + noAccess: AUTHENTICATION.NOT_A_KIBANA_USER, + superuser: AUTHENTICATION.SUPERUSER, + allGlobally: AUTHENTICATION.KIBANA_RBAC_USER, + readGlobally: AUTHENTICATION.KIBANA_RBAC_DASHBOARD_ONLY_USER, + legacyAll: AUTHENTICATION.KIBANA_LEGACY_USER, + legacyRead: AUTHENTICATION.KIBANA_LEGACY_DASHBOARD_ONLY_USER, + dualAll: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_USER, + dualRead: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER, + }, + }, + { + currentSpaceId: SPACES.SPACE_1.spaceId, + selectSpaceId: SPACES.DEFAULT.spaceId, + users: { + noAccess: AUTHENTICATION.NOT_A_KIBANA_USER, + superuser: AUTHENTICATION.SUPERUSER, + allGlobally: AUTHENTICATION.KIBANA_RBAC_USER, + readGlobally: AUTHENTICATION.KIBANA_RBAC_DASHBOARD_ONLY_USER, + legacyAll: AUTHENTICATION.KIBANA_LEGACY_USER, + legacyRead: AUTHENTICATION.KIBANA_LEGACY_DASHBOARD_ONLY_USER, + dualAll: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_USER, + dualRead: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER, + }, + }, + ].forEach(scenario => { + selectTest( + `user with no access selects ${scenario.selectSpaceId} space from the ${ + scenario.currentSpaceId + } space`, + { + currentSpaceId: scenario.currentSpaceId, + selectSpaceId: scenario.selectSpaceId, + user: scenario.users.noAccess, + tests: { + default: { + statusCode: 403, + response: createExpectLegacyForbidden(scenario.users.noAccess.username), + }, + }, + } + ); + + selectTest( + `superuser selects ${scenario.selectSpaceId} space from the ${ + scenario.currentSpaceId + } space`, + { + currentSpaceId: scenario.currentSpaceId, + selectSpaceId: scenario.selectSpaceId, + user: scenario.users.superuser, + tests: { + default: { + statusCode: 200, + response: createExpectSpaceResponse(scenario.selectSpaceId), + }, + }, + } + ); + + selectTest( + `rbac user with all globally selects ${scenario.selectSpaceId} space from the ${ + scenario.currentSpaceId + } space`, + { + currentSpaceId: scenario.currentSpaceId, + selectSpaceId: scenario.selectSpaceId, + user: scenario.users.allGlobally, + tests: { + default: { + statusCode: 200, + response: createExpectSpaceResponse(scenario.selectSpaceId), + }, + }, + } + ); + + selectTest( + `dual-privileges user selects ${scenario.selectSpaceId} space from the ${ + scenario.currentSpaceId + }`, + { + currentSpaceId: scenario.currentSpaceId, + selectSpaceId: scenario.selectSpaceId, + user: scenario.users.dualAll, + tests: { + default: { + statusCode: 200, + response: createExpectSpaceResponse(scenario.selectSpaceId), + }, + }, + } + ); + + selectTest( + `legacy user selects ${scenario.selectSpaceId} space from the ${scenario.currentSpaceId}`, + { + currentSpaceId: scenario.currentSpaceId, + selectSpaceId: scenario.selectSpaceId, + user: scenario.users.legacyAll, + tests: { + default: { + statusCode: 200, + response: createExpectSpaceResponse(scenario.selectSpaceId), + }, + }, + } + ); + + selectTest( + `user with read globally selects ${scenario.selectSpaceId} space from the + ${scenario.currentSpaceId} space`, + { + currentSpaceId: scenario.currentSpaceId, + selectSpaceId: scenario.selectSpaceId, + user: scenario.users.readGlobally, + tests: { + default: { + statusCode: 200, + response: createExpectSpaceResponse(scenario.selectSpaceId), + }, + }, + } + ); + + selectTest( + `dual-privileges readonly user selects ${scenario.selectSpaceId} space from + the ${scenario.currentSpaceId}`, + { + currentSpaceId: scenario.currentSpaceId, + selectSpaceId: scenario.selectSpaceId, + user: scenario.users.dualRead, + tests: { + default: { + statusCode: 200, + response: createExpectSpaceResponse(scenario.selectSpaceId), + }, + }, + } + ); + + selectTest( + `legacy readonly user selects ${scenario.selectSpaceId} space + from the ${scenario.currentSpaceId} space`, + { + currentSpaceId: scenario.currentSpaceId, + selectSpaceId: scenario.selectSpaceId, + user: scenario.users.legacyRead, + tests: { + default: { + statusCode: 200, + response: createExpectSpaceResponse(scenario.selectSpaceId), + }, + }, + } + ); + }); + + // Select the same space that you're currently in with users which have space specific privileges. + // Our intent is to ensure that you have privileges at the space that you're selecting. + [ + { + spaceId: SPACES.DEFAULT.spaceId, + users: { + allAtSpace: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_ALL_USER, + readAtSpace: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_READ_USER, + allAtOtherSpace: AUTHENTICATION.KIBANA_RBAC_SPACE_1_ALL_USER, + }, + }, + { + spaceId: SPACES.SPACE_1.spaceId, + users: { + allAtSpace: AUTHENTICATION.KIBANA_RBAC_SPACE_1_ALL_USER, + readAtSpace: AUTHENTICATION.KIBANA_RBAC_SPACE_1_READ_USER, + allAtOtherSpace: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_ALL_USER, + }, + }, + ].forEach(scenario => { + selectTest( + `rbac user with all at space can select ${scenario.spaceId} + from the same space`, + { + currentSpaceId: scenario.spaceId, + selectSpaceId: scenario.spaceId, + user: scenario.users.allAtSpace, + tests: { + default: { + statusCode: 200, + response: createExpectSpaceResponse(scenario.spaceId), + }, + }, + } + ); + + selectTest( + `rbac user with read at space can select ${scenario.spaceId} + from the same space`, + { + currentSpaceId: scenario.spaceId, + selectSpaceId: scenario.spaceId, + user: scenario.users.readAtSpace, + tests: { + default: { + statusCode: 200, + response: createExpectSpaceResponse(scenario.spaceId), + }, + }, + } + ); + + selectTest( + `rbac user with all at other space cannot select ${scenario.spaceId} + from the same space`, + { + currentSpaceId: scenario.spaceId, + selectSpaceId: scenario.spaceId, + user: scenario.users.allAtOtherSpace, + tests: { + default: { + statusCode: 403, + response: createExpectRbacForbidden(scenario.spaceId), + }, + }, + } + ); + }); + + // Select a different space with users that only have privileges at certain spaces. Our intent + // is to ensure that a user can select a space based on their privileges at the space that they're selecting + // not at the space that they're currently in. + [ + { + currentSpaceId: SPACES.SPACE_2.spaceId, + selectSpaceId: SPACES.SPACE_1.spaceId, + users: { + userWithAllAtSpace: AUTHENTICATION.KIBANA_RBAC_SPACE_1_ALL_USER, + userWithAllAtOtherSpace: AUTHENTICATION.KIBANA_RBAC_SPACE_2_ALL_USER, + userWithAllAtBothSpaces: AUTHENTICATION.KIBANA_RBAC_SPACE_1_2_ALL_USER, + }, + }, + ].forEach(scenario => { + selectTest( + `rbac user with all at ${scenario.selectSpaceId} can select ${scenario.selectSpaceId} + from ${scenario.currentSpaceId}`, + { + currentSpaceId: scenario.currentSpaceId, + selectSpaceId: scenario.selectSpaceId, + user: scenario.users.userWithAllAtSpace, + tests: { + default: { + statusCode: 200, + response: createExpectSpaceResponse(scenario.selectSpaceId), + }, + }, + } + ); + + selectTest( + `rbac user with all at both spaces can select ${scenario.selectSpaceId} + from ${scenario.currentSpaceId}`, + { + currentSpaceId: scenario.currentSpaceId, + selectSpaceId: scenario.selectSpaceId, + user: scenario.users.userWithAllAtBothSpaces, + tests: { + default: { + statusCode: 200, + response: createExpectSpaceResponse(scenario.selectSpaceId), + }, + }, + } + ); + + selectTest( + `rbac user with all at ${scenario.currentSpaceId} space cannot select ${ + scenario.selectSpaceId + } + from ${scenario.currentSpaceId}`, + { + currentSpaceId: scenario.currentSpaceId, + selectSpaceId: scenario.selectSpaceId, + user: scenario.users.userWithAllAtOtherSpace, + tests: { + default: { + statusCode: 403, + response: createExpectRbacForbidden(scenario.selectSpaceId), + }, + }, + } + ); + }); + + // Select non-existent spaces and ensure we get a 404 or a 403 + describe('non-existent space', () => { + [ + { + currentSpaceId: SPACES.DEFAULT.spaceId, + selectSpaceId: nonExistantSpaceId, + users: { + userWithAllGlobally: AUTHENTICATION.KIBANA_RBAC_USER, + userWithAllAtSpace: AUTHENTICATION.KIBANA_RBAC_DEFAULT_SPACE_ALL_USER, + }, + }, + { + currentSpaceId: SPACES.SPACE_1.spaceId, + selectSpaceId: nonExistantSpaceId, + users: { + userWithAllGlobally: AUTHENTICATION.KIBANA_RBAC_USER, + userWithAllAtSpace: AUTHENTICATION.KIBANA_RBAC_SPACE_1_ALL_USER, + }, + }, + ].forEach(scenario => { + selectTest(`rbac user with all globally cannot access non-existent space`, { + currentSpaceId: scenario.currentSpaceId, + selectSpaceId: scenario.selectSpaceId, + user: scenario.users.userWithAllGlobally, + tests: { + default: { + statusCode: 404, + response: createExpectNotFoundResult(), + }, + }, + }); + + selectTest(`rbac user with all at space cannot access non-existent space`, { + currentSpaceId: scenario.currentSpaceId, + selectSpaceId: scenario.selectSpaceId, + user: scenario.users.userWithAllAtSpace, + tests: { + default: { + statusCode: 403, + response: createExpectRbacForbidden(scenario.selectSpaceId), + }, + }, + }); + }); + }); + }); +} diff --git a/x-pack/test/spaces_api_integration/security_and_spaces/apis/update.ts b/x-pack/test/spaces_api_integration/security_and_spaces/apis/update.ts new file mode 100644 index 00000000000000..0b828a7d02f073 --- /dev/null +++ b/x-pack/test/spaces_api_integration/security_and_spaces/apis/update.ts @@ -0,0 +1,250 @@ +/* + * 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 { AUTHENTICATION } from '../../common/lib/authentication'; +import { SPACES } from '../../common/lib/spaces'; +import { TestInvoker } from '../../common/lib/types'; +import { updateTestSuiteFactory } from '../../common/suites/update'; + +// tslint:disable:no-default-export +export default function updateSpaceTestSuite({ getService }: TestInvoker) { + const supertestWithoutAuth = getService('supertestWithoutAuth'); + const esArchiver = getService('esArchiver'); + + const { + updateTest, + expectNotFound, + expectAlreadyExistsResult, + expectDefaultSpaceResult, + expectRbacForbidden, + createExpectLegacyForbidden, + } = updateTestSuiteFactory(esArchiver, supertestWithoutAuth); + + describe('update', () => { + [ + { + spaceId: SPACES.DEFAULT.spaceId, + users: { + noAccess: AUTHENTICATION.NOT_A_KIBANA_USER, + superuser: AUTHENTICATION.SUPERUSER, + allGlobally: AUTHENTICATION.KIBANA_RBAC_USER, + readGlobally: AUTHENTICATION.KIBANA_RBAC_DASHBOARD_ONLY_USER, + allAtSpace: AUTHENTICATION.KIBANA_RBAC_SPACE_1_ALL_USER, + readAtSpace: AUTHENTICATION.KIBANA_RBAC_SPACE_1_READ_USER, + legacyAll: AUTHENTICATION.KIBANA_LEGACY_USER, + legacyRead: AUTHENTICATION.KIBANA_LEGACY_DASHBOARD_ONLY_USER, + dualAll: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_USER, + dualRead: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER, + }, + }, + { + spaceId: SPACES.SPACE_1.spaceId, + users: { + noAccess: AUTHENTICATION.NOT_A_KIBANA_USER, + superuser: AUTHENTICATION.SUPERUSER, + allGlobally: AUTHENTICATION.KIBANA_RBAC_USER, + readGlobally: AUTHENTICATION.KIBANA_RBAC_DASHBOARD_ONLY_USER, + allAtSpace: AUTHENTICATION.KIBANA_RBAC_SPACE_1_ALL_USER, + readAtSpace: AUTHENTICATION.KIBANA_RBAC_SPACE_1_READ_USER, + legacyAll: AUTHENTICATION.KIBANA_LEGACY_USER, + legacyRead: AUTHENTICATION.KIBANA_LEGACY_DASHBOARD_ONLY_USER, + dualAll: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_USER, + dualRead: AUTHENTICATION.KIBANA_DUAL_PRIVILEGES_DASHBOARD_ONLY_USER, + }, + }, + ].forEach(scenario => { + updateTest(`user with no access from the ${scenario.spaceId} space`, { + spaceId: scenario.spaceId, + user: scenario.users.noAccess, + tests: { + alreadyExists: { + statusCode: 403, + response: createExpectLegacyForbidden(scenario.users.noAccess.username), + }, + defaultSpace: { + statusCode: 403, + response: createExpectLegacyForbidden(scenario.users.noAccess.username), + }, + newSpace: { + statusCode: 403, + response: createExpectLegacyForbidden(scenario.users.noAccess.username), + }, + }, + }); + + updateTest(`superuser from the ${scenario.spaceId} space`, { + spaceId: scenario.spaceId, + user: scenario.users.superuser, + tests: { + alreadyExists: { + statusCode: 200, + response: expectAlreadyExistsResult, + }, + defaultSpace: { + statusCode: 200, + response: expectDefaultSpaceResult, + }, + newSpace: { + statusCode: 404, + response: expectNotFound, + }, + }, + }); + + updateTest(`rbac user with all globally from the ${scenario.spaceId} space`, { + spaceId: scenario.spaceId, + user: scenario.users.allGlobally, + tests: { + alreadyExists: { + statusCode: 200, + response: expectAlreadyExistsResult, + }, + defaultSpace: { + statusCode: 200, + response: expectDefaultSpaceResult, + }, + newSpace: { + statusCode: 404, + response: expectNotFound, + }, + }, + }); + + updateTest(`dual-privileges used from the ${scenario.spaceId} space`, { + spaceId: scenario.spaceId, + user: scenario.users.dualAll, + tests: { + alreadyExists: { + statusCode: 200, + response: expectAlreadyExistsResult, + }, + defaultSpace: { + statusCode: 200, + response: expectDefaultSpaceResult, + }, + newSpace: { + statusCode: 404, + response: expectNotFound, + }, + }, + }); + + updateTest(`legacy user from the ${scenario.spaceId} space`, { + spaceId: scenario.spaceId, + user: scenario.users.legacyAll, + tests: { + alreadyExists: { + statusCode: 200, + response: expectAlreadyExistsResult, + }, + defaultSpace: { + statusCode: 200, + response: expectDefaultSpaceResult, + }, + newSpace: { + statusCode: 404, + response: expectNotFound, + }, + }, + }); + + updateTest(`rbac user with read globally from the ${scenario.spaceId} space`, { + spaceId: scenario.spaceId, + user: scenario.users.readGlobally, + tests: { + alreadyExists: { + statusCode: 403, + response: expectRbacForbidden, + }, + defaultSpace: { + statusCode: 403, + response: expectRbacForbidden, + }, + newSpace: { + statusCode: 403, + response: expectRbacForbidden, + }, + }, + }); + + updateTest(`dual-privileges readonly user from the ${scenario.spaceId} space`, { + spaceId: scenario.spaceId, + user: scenario.users.dualRead, + tests: { + alreadyExists: { + statusCode: 403, + response: expectRbacForbidden, + }, + defaultSpace: { + statusCode: 403, + response: expectRbacForbidden, + }, + newSpace: { + statusCode: 403, + response: expectRbacForbidden, + }, + }, + }); + + updateTest(`legacy readonly user from the ${scenario.spaceId} space`, { + spaceId: scenario.spaceId, + user: scenario.users.legacyRead, + tests: { + alreadyExists: { + statusCode: 403, + response: createExpectLegacyForbidden(scenario.users.legacyRead.username), + }, + defaultSpace: { + statusCode: 403, + response: createExpectLegacyForbidden(scenario.users.legacyRead.username), + }, + newSpace: { + statusCode: 403, + response: createExpectLegacyForbidden(scenario.users.legacyRead.username), + }, + }, + }); + + updateTest(`rbac user with all at space from the ${scenario.spaceId} space`, { + spaceId: scenario.spaceId, + user: scenario.users.allAtSpace, + tests: { + alreadyExists: { + statusCode: 403, + response: expectRbacForbidden, + }, + defaultSpace: { + statusCode: 403, + response: expectRbacForbidden, + }, + newSpace: { + statusCode: 403, + response: expectRbacForbidden, + }, + }, + }); + + updateTest(`rbac user with read at space from the ${scenario.spaceId} space`, { + spaceId: scenario.spaceId, + user: scenario.users.readAtSpace, + tests: { + alreadyExists: { + statusCode: 403, + response: expectRbacForbidden, + }, + defaultSpace: { + statusCode: 403, + response: expectRbacForbidden, + }, + newSpace: { + statusCode: 403, + response: expectRbacForbidden, + }, + }, + }); + }); + }); +} diff --git a/x-pack/test/spaces_api_integration/security_and_spaces/config.ts b/x-pack/test/spaces_api_integration/security_and_spaces/config.ts new file mode 100644 index 00000000000000..81cf9d85671d18 --- /dev/null +++ b/x-pack/test/spaces_api_integration/security_and_spaces/config.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { createTestConfig } from '../common/config'; + +// tslint:disable:no-default-export +export default createTestConfig('security_and_spaces', { license: 'trial' }); diff --git a/x-pack/test/spaces_api_integration/spaces_only/apis/create.ts b/x-pack/test/spaces_api_integration/spaces_only/apis/create.ts new file mode 100644 index 00000000000000..fb01fd18527c21 --- /dev/null +++ b/x-pack/test/spaces_api_integration/spaces_only/apis/create.ts @@ -0,0 +1,51 @@ +/* + * 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 { SPACES } from '../../common/lib/spaces'; +import { TestInvoker } from '../../common/lib/types'; +import { createTestSuiteFactory } from '../../common/suites/create'; + +// tslint:disable:no-default-export +export default function createSpacesOnlySuite({ getService }: TestInvoker) { + const supertestWithoutAuth = getService('supertestWithoutAuth'); + const esArchiver = getService('esArchiver'); + + const { + createTest, + expectNewSpaceResult, + expectConflictResponse, + expectReservedSpecifiedResult, + } = createTestSuiteFactory(esArchiver, supertestWithoutAuth); + + describe('create', () => { + [ + { + spaceId: SPACES.DEFAULT.spaceId, + }, + { + spaceId: SPACES.SPACE_1.spaceId, + }, + ].forEach(scenario => { + createTest(`from the ${scenario.spaceId} space`, { + spaceId: scenario.spaceId, + tests: { + newSpace: { + statusCode: 200, + response: expectNewSpaceResult, + }, + alreadyExists: { + statusCode: 409, + response: expectConflictResponse, + }, + reservedSpecified: { + statusCode: 200, + response: expectReservedSpecifiedResult, + }, + }, + }); + }); + }); +} diff --git a/x-pack/test/spaces_api_integration/spaces_only/apis/delete.ts b/x-pack/test/spaces_api_integration/spaces_only/apis/delete.ts new file mode 100644 index 00000000000000..a0902281f4c73d --- /dev/null +++ b/x-pack/test/spaces_api_integration/spaces_only/apis/delete.ts @@ -0,0 +1,51 @@ +/* + * 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 { SPACES } from '../../common/lib/spaces'; +import { TestInvoker } from '../../common/lib/types'; +import { deleteTestSuiteFactory } from '../../common/suites/delete'; + +// tslint:disable:no-default-export +export default function deleteSpaceTestSuite({ getService }: TestInvoker) { + const supertestWithoutAuth = getService('supertestWithoutAuth'); + const esArchiver = getService('esArchiver'); + + const { + deleteTest, + expectEmptyResult, + expectReservedSpaceResult, + expectNotFound, + } = deleteTestSuiteFactory(esArchiver, supertestWithoutAuth); + + describe('delete', () => { + [ + { + spaceId: SPACES.DEFAULT.spaceId, + }, + { + spaceId: SPACES.SPACE_1.spaceId, + }, + ].forEach(scenario => { + deleteTest(`from the ${scenario.spaceId} space`, { + spaceId: scenario.spaceId, + tests: { + exists: { + statusCode: 204, + response: expectEmptyResult, + }, + reservedSpace: { + statusCode: 400, + response: expectReservedSpaceResult, + }, + doesntExist: { + statusCode: 404, + response: expectNotFound, + }, + }, + }); + }); + }); +} diff --git a/x-pack/test/spaces_api_integration/spaces_only/apis/get.ts b/x-pack/test/spaces_api_integration/spaces_only/apis/get.ts new file mode 100644 index 00000000000000..8017e3c62eec8a --- /dev/null +++ b/x-pack/test/spaces_api_integration/spaces_only/apis/get.ts @@ -0,0 +1,74 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SPACES } from '../../common/lib/spaces'; +import { TestInvoker } from '../../common/lib/types'; +import { getTestSuiteFactory } from '../../common/suites/get'; + +// tslint:disable:no-default-export +export default function getSpaceTestSuite({ getService }: TestInvoker) { + const supertestWithoutAuth = getService('supertestWithoutAuth'); + const esArchiver = getService('esArchiver'); + + const { + getTest, + createExpectResults, + createExpectNotFoundResult, + nonExistantSpaceId, + } = getTestSuiteFactory(esArchiver, supertestWithoutAuth); + + describe('get', () => { + // valid spaces + [ + { + currentSpaceId: SPACES.DEFAULT.spaceId, + spaceId: SPACES.DEFAULT.spaceId, + }, + { + currentSpaceId: SPACES.DEFAULT.spaceId, + spaceId: SPACES.SPACE_1.spaceId, + }, + { + currentSpaceId: SPACES.SPACE_1.spaceId, + spaceId: SPACES.DEFAULT.spaceId, + }, + { + currentSpaceId: SPACES.SPACE_1.spaceId, + spaceId: SPACES.SPACE_1.spaceId, + }, + ].forEach(scenario => { + getTest(`can access ${scenario.spaceId} from within the ${scenario.currentSpaceId} space`, { + spaceId: scenario.spaceId, + currentSpaceId: scenario.currentSpaceId, + tests: { + default: { + statusCode: 200, + response: createExpectResults(scenario.spaceId), + }, + }, + }); + }); + + // invalid spaces + [ + { + currentSpaceId: SPACES.DEFAULT.spaceId, + spaceId: nonExistantSpaceId, + }, + ].forEach(scenario => { + getTest(`can't access ${scenario.spaceId} from within the ${scenario.currentSpaceId} space`, { + spaceId: scenario.spaceId, + currentSpaceId: scenario.currentSpaceId, + tests: { + default: { + statusCode: 404, + response: createExpectNotFoundResult(), + }, + }, + }); + }); + }); +} diff --git a/x-pack/test/spaces_api_integration/spaces_only/apis/get_all.ts b/x-pack/test/spaces_api_integration/spaces_only/apis/get_all.ts new file mode 100644 index 00000000000000..4380df0d196f9a --- /dev/null +++ b/x-pack/test/spaces_api_integration/spaces_only/apis/get_all.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 { SPACES } from '../../common/lib/spaces'; +import { TestInvoker } from '../../common/lib/types'; +import { getAllTestSuiteFactory } from '../../common/suites/get_all'; + +// tslint:disable:no-default-export +export default function getAllSpacesTestSuite({ getService }: TestInvoker) { + const supertestWithoutAuth = getService('supertestWithoutAuth'); + const esArchiver = getService('esArchiver'); + + const { getAllTest, createExpectResults } = getAllTestSuiteFactory( + esArchiver, + supertestWithoutAuth + ); + + describe('get all', () => { + [ + { + spaceId: SPACES.DEFAULT.spaceId, + }, + { + spaceId: SPACES.SPACE_1.spaceId, + }, + ].forEach(scenario => { + getAllTest(`can access all spaces from ${scenario.spaceId}`, { + spaceId: scenario.spaceId, + tests: { + exists: { + statusCode: 200, + response: createExpectResults('default', 'space_1', 'space_2'), + }, + }, + }); + }); + }); +} diff --git a/x-pack/test/spaces_api_integration/spaces_only/apis/index.ts b/x-pack/test/spaces_api_integration/spaces_only/apis/index.ts new file mode 100644 index 00000000000000..6864ee7fbda946 --- /dev/null +++ b/x-pack/test/spaces_api_integration/spaces_only/apis/index.ts @@ -0,0 +1,19 @@ +/* + * 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 { TestInvoker } from '../../common/lib/types'; + +// tslint:disable:no-default-export +export default function spacesOnlyTestSuite({ loadTestFile }: TestInvoker) { + describe('spaces api without security', () => { + loadTestFile(require.resolve('./create')); + loadTestFile(require.resolve('./delete')); + loadTestFile(require.resolve('./get_all')); + loadTestFile(require.resolve('./get')); + loadTestFile(require.resolve('./select')); + loadTestFile(require.resolve('./update')); + }); +} diff --git a/x-pack/test/spaces_api_integration/spaces_only/apis/select.ts b/x-pack/test/spaces_api_integration/spaces_only/apis/select.ts new file mode 100644 index 00000000000000..d7f6562c6e715b --- /dev/null +++ b/x-pack/test/spaces_api_integration/spaces_only/apis/select.ts @@ -0,0 +1,74 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SPACES } from '../../common/lib/spaces'; +import { TestInvoker } from '../../common/lib/types'; +import { selectTestSuiteFactory } from '../../common/suites/select'; + +// tslint:disable:no-default-export +export default function selectSpaceTestSuite({ getService }: TestInvoker) { + const supertestWithoutAuth = getService('supertestWithoutAuth'); + const esArchiver = getService('esArchiver'); + + const { + selectTest, + createExpectSpaceResponse, + createExpectNotFoundResult, + nonExistantSpaceId, + } = selectTestSuiteFactory(esArchiver, supertestWithoutAuth); + + describe('select', () => { + [ + { + spaceId: SPACES.DEFAULT.spaceId, + otherSpaceId: SPACES.SPACE_1.spaceId, + }, + { + spaceId: SPACES.SPACE_1.spaceId, + otherSpaceId: SPACES.DEFAULT.spaceId, + }, + { + spaceId: SPACES.SPACE_1.spaceId, + otherSpaceId: SPACES.SPACE_2.spaceId, + }, + ].forEach(scenario => { + selectTest(`can select ${scenario.otherSpaceId} from ${scenario.spaceId}`, { + currentSpaceId: scenario.spaceId, + selectSpaceId: scenario.otherSpaceId, + tests: { + default: { + statusCode: 200, + response: createExpectSpaceResponse(scenario.otherSpaceId), + }, + }, + }); + }); + + describe('non-existant space', () => { + [ + { + spaceId: SPACES.DEFAULT.spaceId, + otherSpaceId: nonExistantSpaceId, + }, + { + spaceId: SPACES.SPACE_1.spaceId, + otherSpaceId: nonExistantSpaceId, + }, + ].forEach(scenario => { + selectTest(`cannot select non-existant space from ${scenario.spaceId}`, { + currentSpaceId: scenario.spaceId, + selectSpaceId: scenario.otherSpaceId, + tests: { + default: { + statusCode: 404, + response: createExpectNotFoundResult(), + }, + }, + }); + }); + }); + }); +} diff --git a/x-pack/test/spaces_api_integration/spaces_only/apis/update.ts b/x-pack/test/spaces_api_integration/spaces_only/apis/update.ts new file mode 100644 index 00000000000000..649fb07c8cbdb6 --- /dev/null +++ b/x-pack/test/spaces_api_integration/spaces_only/apis/update.ts @@ -0,0 +1,51 @@ +/* + * 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 { SPACES } from '../../common/lib/spaces'; +import { TestInvoker } from '../../common/lib/types'; +import { updateTestSuiteFactory } from '../../common/suites/update'; + +// tslint:disable:no-default-export +export default function updateSpaceTestSuite({ getService }: TestInvoker) { + const supertestWithoutAuth = getService('supertestWithoutAuth'); + const esArchiver = getService('esArchiver'); + + const { + updateTest, + expectAlreadyExistsResult, + expectDefaultSpaceResult, + expectNotFound, + } = updateTestSuiteFactory(esArchiver, supertestWithoutAuth); + + describe('update', () => { + [ + { + spaceId: SPACES.DEFAULT.spaceId, + }, + { + spaceId: SPACES.SPACE_1.spaceId, + }, + ].forEach(scenario => { + updateTest(`can update from the ${scenario.spaceId} space`, { + spaceId: scenario.spaceId, + tests: { + alreadyExists: { + statusCode: 200, + response: expectAlreadyExistsResult, + }, + defaultSpace: { + statusCode: 200, + response: expectDefaultSpaceResult, + }, + newSpace: { + statusCode: 404, + response: expectNotFound, + }, + }, + }); + }); + }); +} diff --git a/x-pack/test/spaces_api_integration/spaces_only/config.ts b/x-pack/test/spaces_api_integration/spaces_only/config.ts new file mode 100644 index 00000000000000..49e31da77dd741 --- /dev/null +++ b/x-pack/test/spaces_api_integration/spaces_only/config.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { createTestConfig } from '../common/config'; + +// tslint:disable:no-default-export +export default createTestConfig('spaces_only', { license: 'basic' }); diff --git a/x-pack/test/tsconfig.json b/x-pack/test/tsconfig.json new file mode 100644 index 00000000000000..87e34dc754cdc0 --- /dev/null +++ b/x-pack/test/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "types": [ + "expect.js", + "mocha", + "node" + ] + }, + "include": [ + "**/*", + ], + "exclude": [], +} diff --git a/x-pack/tsconfig.json b/x-pack/tsconfig.json index a01f0127c96cb1..44b418ba975ecd 100644 --- a/x-pack/tsconfig.json +++ b/x-pack/tsconfig.json @@ -3,6 +3,26 @@ "include": [ "common/**/*", "server/**/*", - "plugins/**/*" - ] -} + "plugins/**/*", + ], + "exclude": [ + "test/**/*" + ], + "compilerOptions": { + "paths": { + "ui/*": [ + "src/ui/public/*" + ], + "plugins/xpack_main/*": [ + "x-pack/plugins/xpack_main/public/*" + ], + "plugins/spaces/*": [ + "x-pack/plugins/spaces/public/*" + ] + }, + "types": [ + "node", + "jest" + ] + } +} \ No newline at end of file diff --git a/x-pack/yarn.lock b/x-pack/yarn.lock index 5f1ca23c29ed32..215c2932194916 100644 --- a/x-pack/yarn.lock +++ b/x-pack/yarn.lock @@ -143,6 +143,10 @@ url-join "^4.0.0" ws "^4.1.0" +"@types/cookiejar@*": + version "2.1.0" + resolved "https://registry.yarnpkg.com/@types/cookiejar/-/cookiejar-2.1.0.tgz#4b7daf2c51696cfc70b942c11690528229d1a1ce" + "@types/delay@^2.0.1": version "2.0.1" resolved "https://registry.yarnpkg.com/@types/delay/-/delay-2.0.1.tgz#61bcf318a74b61e79d1658fbf054f984c90ef901" @@ -151,6 +155,10 @@ version "1.2.0" resolved "https://registry.yarnpkg.com/@types/events/-/events-1.2.0.tgz#81a6731ce4df43619e5c8c945383b3e62a89ea86" +"@types/expect.js@^0.3.29": + version "0.3.29" + resolved "https://registry.yarnpkg.com/@types/expect.js/-/expect.js-0.3.29.tgz#28dd359155b84b8ecb094afc3f4b74c3222dca3b" + "@types/form-data@^2.2.1": version "2.2.1" resolved "https://registry.yarnpkg.com/@types/form-data/-/form-data-2.2.1.tgz#ee2b3b8eaa11c0938289953606b745b738c54b1e" @@ -173,10 +181,18 @@ version "23.3.1" resolved "https://registry.yarnpkg.com/@types/jest/-/jest-23.3.1.tgz#a4319aedb071d478e6f407d1c4578ec8156829cf" +"@types/joi@^10.4.4": + version "10.6.4" + resolved "https://registry.yarnpkg.com/@types/joi/-/joi-10.6.4.tgz#0989d69e792a7db13e951852e6949df6787f113f" + "@types/loglevel@^1.5.3": version "1.5.3" resolved "https://registry.yarnpkg.com/@types/loglevel/-/loglevel-1.5.3.tgz#adfce55383edc5998a2170ad581b3e23d6adb5b8" +"@types/mocha@^5.2.5": + version "5.2.5" + resolved "https://registry.yarnpkg.com/@types/mocha/-/mocha-5.2.5.tgz#8a4accfc403c124a0bafe8a9fc61a05ec1032073" + "@types/moment-timezone@^0.5.8": version "0.5.8" resolved "https://registry.yarnpkg.com/@types/moment-timezone/-/moment-timezone-0.5.8.tgz#92aba9bc238cabf69a27a1a4f52e0ebb8f10f896" @@ -215,6 +231,19 @@ version "0.10.2" resolved "https://registry.yarnpkg.com/@types/retry/-/retry-0.10.2.tgz#bd1740c4ad51966609b058803ee6874577848b37" +"@types/superagent@*": + version "3.8.4" + resolved "https://registry.yarnpkg.com/@types/superagent/-/superagent-3.8.4.tgz#24a5973c7d1a9c024b4bbda742a79267c33fb86a" + dependencies: + "@types/cookiejar" "*" + "@types/node" "*" + +"@types/supertest@^2.0.5": + version "2.0.5" + resolved "https://registry.yarnpkg.com/@types/supertest/-/supertest-2.0.5.tgz#18d082a667eaed22759be98f4923e0061ae70c62" + dependencies: + "@types/superagent" "*" + "@types/url-join@^0.8.2": version "0.8.2" resolved "https://registry.yarnpkg.com/@types/url-join/-/url-join-0.8.2.tgz#1181ecbe1d97b7034e0ea1e35e62e86cc26b422d" @@ -1293,14 +1322,10 @@ brace-expansion@^1.0.0, brace-expansion@^1.1.7: balanced-match "^1.0.0" concat-map "0.0.1" -brace@0.11.1: +brace@0.11.1, brace@^0.11.0: version "0.11.1" resolved "https://registry.yarnpkg.com/brace/-/brace-0.11.1.tgz#4896fcc9d544eef45f4bb7660db320d3b379fe58" -brace@^0.11.0: - version "0.11.0" - resolved "https://registry.yarnpkg.com/brace/-/brace-0.11.0.tgz#155cd80607687dc8cb908f0df94e62a033c1d563" - braces@^1.8.2: version "1.8.5" resolved "https://registry.yarnpkg.com/braces/-/braces-1.8.5.tgz#ba77962e12dff969d6b76711e914b737857bf6a7" @@ -2993,8 +3018,8 @@ focus-trap-react@^3.0.4, focus-trap-react@^3.1.1: focus-trap "^2.0.1" focus-trap@^2.0.1: - version "2.4.2" - resolved "https://registry.yarnpkg.com/focus-trap/-/focus-trap-2.4.2.tgz#44ea1c55a9c22c2b6529dcebbde6390eb2ee4c88" + version "2.4.5" + resolved "https://registry.yarnpkg.com/focus-trap/-/focus-trap-2.4.5.tgz#91c9c9ffb907f8f4446d80202dda9c12c2853ddb" dependencies: tabbable "^1.0.3" @@ -3874,7 +3899,7 @@ icalendar@0.7.1: version "0.7.1" resolved "https://registry.yarnpkg.com/icalendar/-/icalendar-0.7.1.tgz#d0d3486795f8f1c5cf4f8cafac081b4b4e7a32ae" -iconv-lite@0.4.19, iconv-lite@^0.4.19, iconv-lite@~0.4.13: +iconv-lite@0.4.19, iconv-lite@^0.4.19: version "0.4.19" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.19.tgz#f7468f60135f5e5dad3399c0a81be9a1603a082b" @@ -3884,6 +3909,12 @@ iconv-lite@0.4.23: dependencies: safer-buffer ">= 2.1.2 < 3" +iconv-lite@~0.4.13: + version "0.4.24" + resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" + dependencies: + safer-buffer ">= 2.1.2 < 3" + ieee754@^1.1.4: version "1.1.8" resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.1.8.tgz#be33d40ac10ef1926701f6f08a2d86fbfd1ad3e4" @@ -6880,8 +6911,8 @@ react-clipboard.js@^1.1.2: prop-types "^15.5.0" react-color@^2.13.8: - version "2.13.8" - resolved "https://registry.yarnpkg.com/react-color/-/react-color-2.13.8.tgz#bcc58f79a722b9bfc37c402e68cd18f26970aee4" + version "2.14.1" + resolved "https://registry.yarnpkg.com/react-color/-/react-color-2.14.1.tgz#db8ad4f45d81e74896fc2e1c99508927c6d084e0" dependencies: lodash "^4.0.1" material-colors "^1.2.1" @@ -7199,7 +7230,7 @@ read-pkg@^1.0.0: isarray "0.0.1" string_decoder "~0.10.x" -readable-stream@^2.0.0, readable-stream@^2.0.1, readable-stream@^2.0.2, readable-stream@^2.0.4, readable-stream@^2.0.5, readable-stream@^2.0.6, readable-stream@^2.1.4, readable-stream@^2.1.5, readable-stream@^2.2.2: +readable-stream@^2.0.0, readable-stream@^2.0.1, readable-stream@^2.0.2, readable-stream@^2.0.4, readable-stream@^2.0.5, readable-stream@^2.0.6, readable-stream@^2.1.4, readable-stream@^2.1.5: version "2.3.3" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.3.tgz#368f2512d79f9d46fdfc71349ae7878bbc1eb95c" dependencies: @@ -7211,6 +7242,18 @@ readable-stream@^2.0.0, readable-stream@^2.0.1, readable-stream@^2.0.2, readable string_decoder "~1.0.3" util-deprecate "~1.0.1" +readable-stream@^2.2.2: + version "2.3.6" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.6.tgz#b11c27d88b8ff1fbe070643cf94b0c79ae1b0aaf" + dependencies: + core-util-is "~1.0.0" + inherits "~2.0.3" + isarray "~1.0.0" + process-nextick-args "~2.0.0" + safe-buffer "~5.1.1" + string_decoder "~1.1.1" + util-deprecate "~1.0.1" + readable-stream@^2.3.3, readable-stream@^2.3.5: version "2.3.5" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.5.tgz#b4f85003a938cbb6ecbce2a124fb1012bd1a838d" @@ -8222,6 +8265,12 @@ string_decoder@~1.0.3: dependencies: safe-buffer "~5.1.0" +string_decoder@~1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.1.1.tgz#9cf1611ba62685d7030ae9e4ba34149c3af03fc8" + dependencies: + safe-buffer "~5.1.0" + stringstream@~0.0.4, stringstream@~0.0.5: version "0.0.5" resolved "https://registry.yarnpkg.com/stringstream/-/stringstream-0.0.5.tgz#4e484cd4de5a0bbbee18e46307710a8a81621878" @@ -8736,14 +8785,10 @@ typescript@^3.0.3: version "3.0.3" resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.0.3.tgz#4853b3e275ecdaa27f78fda46dc273a7eb7fc1c8" -ua-parser-js@^0.7.18: +ua-parser-js@^0.7.18, ua-parser-js@^0.7.9: version "0.7.18" resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.18.tgz#a7bfd92f56edfb117083b69e31d2aa8882d4b1ed" -ua-parser-js@^0.7.9: - version "0.7.17" - resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.17.tgz#e9ec5f9498b9ec910e7ae3ac626a805c4d09ecac" - uc.micro@^1.0.1, uc.micro@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/uc.micro/-/uc.micro-1.0.5.tgz#0c65f15f815aa08b560a61ce8b4db7ffc3f45376" @@ -8899,11 +8944,11 @@ uuid@3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.0.1.tgz#6544bba2dfda8c1cf17e629a3a305e2bb1fee6c1" -uuid@^3.0.0, uuid@^3.1.0: +uuid@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.1.0.tgz#3dd3d3e790abc24d7b0d3a034ffababe28ebbc04" -uuid@^3.3.2: +uuid@^3.1.0, uuid@^3.3.2: version "3.3.2" resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.3.2.tgz#1b4af4955eb3077c501c23872fc6513811587131" @@ -9089,8 +9134,8 @@ whatwg-encoding@^1.0.4: iconv-lite "0.4.23" whatwg-fetch@>=0.10.0: - version "2.0.3" - resolved "https://registry.yarnpkg.com/whatwg-fetch/-/whatwg-fetch-2.0.3.tgz#9c84ec2dcf68187ff00bc64e1274b442176e1c84" + version "2.0.4" + resolved "https://registry.yarnpkg.com/whatwg-fetch/-/whatwg-fetch-2.0.4.tgz#dde6a5df315f9d39991aa17621853d720b85566f" whatwg-mimetype@^2.1.0: version "2.2.0" diff --git a/yarn.lock b/yarn.lock index 76e2cd0c528335..8b3ec4e44989bf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3276,11 +3276,11 @@ core-js@^1.0.0: version "1.2.7" resolved "https://registry.yarnpkg.com/core-js/-/core-js-1.2.7.tgz#652294c14651db28fa93bd2d5ff2983a4f08c636" -core-js@^2.2.0, core-js@^2.4.0, core-js@^2.5.0, core-js@^2.5.1: +core-js@^2.2.0, core-js@^2.5.0: version "2.5.3" resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.5.3.tgz#8acc38345824f16d8365b7c9b4259168e8ed603e" -core-js@^2.5.7: +core-js@^2.4.0, core-js@^2.5.1, core-js@^2.5.7: version "2.5.7" resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.5.7.tgz#f972608ff0cead68b841a16a932d0b183791814e" @@ -5387,8 +5387,8 @@ focus-trap-react@^3.0.4, focus-trap-react@^3.1.1: focus-trap "^2.0.1" focus-trap@^2.0.1: - version "2.4.3" - resolved "https://registry.yarnpkg.com/focus-trap/-/focus-trap-2.4.3.tgz#95edc23e77829b7772cb2486d61fd6371ce112f9" + version "2.4.5" + resolved "https://registry.yarnpkg.com/focus-trap/-/focus-trap-2.4.5.tgz#91c9c9ffb907f8f4446d80202dda9c12c2853ddb" dependencies: tabbable "^1.0.3" @@ -6553,7 +6553,7 @@ icalendar@0.7.1: version "0.7.1" resolved "https://registry.yarnpkg.com/icalendar/-/icalendar-0.7.1.tgz#d0d3486795f8f1c5cf4f8cafac081b4b4e7a32ae" -iconv-lite@0.4, iconv-lite@0.4.19, iconv-lite@^0.4.17, iconv-lite@^0.4.19, iconv-lite@~0.4.13: +iconv-lite@0.4, iconv-lite@0.4.19, iconv-lite@^0.4.17, iconv-lite@^0.4.19: version "0.4.19" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.19.tgz#f7468f60135f5e5dad3399c0a81be9a1603a082b" @@ -6561,7 +6561,7 @@ iconv-lite@0.4.13: version "0.4.13" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.13.tgz#1f88aba4ab0b1508e8312acc39345f36e992e2f2" -iconv-lite@^0.4.22: +iconv-lite@^0.4.22, iconv-lite@~0.4.13: version "0.4.23" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.23.tgz#297871f63be507adcfbfca715d0cd0eed84e9a63" dependencies: @@ -11049,7 +11049,16 @@ rc@^1.0.1, rc@^1.1.6, rc@^1.1.7: minimist "^1.2.0" strip-json-comments "~2.0.1" -react-ace@^5.5.0, react-ace@^5.9.0: +react-ace@^5.5.0: + version "5.10.0" + resolved "https://registry.yarnpkg.com/react-ace/-/react-ace-5.10.0.tgz#e328b37ac52759f700be5afdb86ada2f5ec84c5e" + dependencies: + brace "^0.11.0" + lodash.get "^4.4.2" + lodash.isequal "^4.1.1" + prop-types "^15.5.8" + +react-ace@^5.9.0: version "5.9.0" resolved "https://registry.yarnpkg.com/react-ace/-/react-ace-5.9.0.tgz#427a1cc4869b960a6f9748aa7eb169a9269fc336" dependencies: @@ -11197,6 +11206,10 @@ react-lib-adler32@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/react-lib-adler32/-/react-lib-adler32-1.0.1.tgz#01f7a0e24fe715580aadb8a827c39a850e1ccc4e" +react-lifecycles-compat@^3.0.4: + version "3.0.4" + resolved "https://registry.yarnpkg.com/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz#4f1a273afdfc8f3488a8c516bfda78f872352362" + react-markdown-renderer@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/react-markdown-renderer/-/react-markdown-renderer-1.4.0.tgz#f3b95bd9fc7f7bf8ab3f0150aa696b41740e7d01" @@ -11359,14 +11372,15 @@ react-toggle@4.0.2: classnames "^2.2.5" react-virtualized@^9.18.5: - version "9.18.5" - resolved "https://registry.yarnpkg.com/react-virtualized/-/react-virtualized-9.18.5.tgz#42dd390ebaa7ea809bfcaf775d39872641679b89" + version "9.19.1" + resolved "https://registry.yarnpkg.com/react-virtualized/-/react-virtualized-9.19.1.tgz#84b53253df2d9df61c85ce037141edccc70a73fd" dependencies: babel-runtime "^6.26.0" classnames "^2.2.3" dom-helpers "^2.4.0 || ^3.0.0" loose-envify "^1.3.0" prop-types "^15.6.0" + react-lifecycles-compat "^3.0.4" react-vis@1.10.2: version "1.10.2" @@ -11500,7 +11514,7 @@ read-pkg@^2.0.0: normalize-package-data "^2.3.2" path-type "^2.0.0" -"readable-stream@1 || 2": +"readable-stream@1 || 2", readable-stream@^2.2.2: version "2.3.6" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.6.tgz#b11c27d88b8ff1fbe070643cf94b0c79ae1b0aaf" dependencies: @@ -11512,7 +11526,7 @@ read-pkg@^2.0.0: string_decoder "~1.1.1" util-deprecate "~1.0.1" -readable-stream@^2.0.0, readable-stream@^2.0.1, readable-stream@^2.0.2, readable-stream@^2.0.4, readable-stream@^2.0.5, readable-stream@^2.0.6, readable-stream@^2.1.4, readable-stream@^2.1.5, readable-stream@^2.2.2, readable-stream@^2.3.3, readable-stream@^2.3.5, readable-stream@~2.3.3: +readable-stream@^2.0.0, readable-stream@^2.0.1, readable-stream@^2.0.2, readable-stream@^2.0.4, readable-stream@^2.0.5, readable-stream@^2.0.6, readable-stream@^2.1.4, readable-stream@^2.1.5, readable-stream@^2.3.3, readable-stream@^2.3.5, readable-stream@~2.3.3: version "2.3.5" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.5.tgz#b4f85003a938cbb6ecbce2a124fb1012bd1a838d" dependencies: @@ -13135,8 +13149,8 @@ tabbable@1.1.0: resolved "https://registry.yarnpkg.com/tabbable/-/tabbable-1.1.0.tgz#2c9a9c9f09db5bb0659f587d532548dd6ef2067b" tabbable@^1.0.3, tabbable@^1.1.0: - version "1.1.2" - resolved "https://registry.yarnpkg.com/tabbable/-/tabbable-1.1.2.tgz#b171680aea6e0a3e9281ff23532e2e5de11c0d94" + version "1.1.3" + resolved "https://registry.yarnpkg.com/tabbable/-/tabbable-1.1.3.tgz#0e4ee376f3631e42d7977a074dbd2b3827843081" table@^4.0.3: version "4.0.3" @@ -13668,14 +13682,10 @@ typescript@^3.0.3: version "3.0.3" resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.0.3.tgz#4853b3e275ecdaa27f78fda46dc273a7eb7fc1c8" -ua-parser-js@^0.7.18: +ua-parser-js@^0.7.18, ua-parser-js@^0.7.9: version "0.7.18" resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.18.tgz#a7bfd92f56edfb117083b69e31d2aa8882d4b1ed" -ua-parser-js@^0.7.9: - version "0.7.17" - resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.17.tgz#e9ec5f9498b9ec910e7ae3ac626a805c4d09ecac" - uc.micro@^1.0.1, uc.micro@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/uc.micro/-/uc.micro-1.0.5.tgz#0c65f15f815aa08b560a61ce8b4db7ffc3f45376" @@ -14559,7 +14569,11 @@ whatwg-encoding@^1.0.1, whatwg-encoding@^1.0.3: dependencies: iconv-lite "0.4.19" -whatwg-fetch@>=0.10.0, whatwg-fetch@^2.0.3: +whatwg-fetch@>=0.10.0: + version "2.0.4" + resolved "https://registry.yarnpkg.com/whatwg-fetch/-/whatwg-fetch-2.0.4.tgz#dde6a5df315f9d39991aa17621853d720b85566f" + +whatwg-fetch@^2.0.3: version "2.0.3" resolved "https://registry.yarnpkg.com/whatwg-fetch/-/whatwg-fetch-2.0.3.tgz#9c84ec2dcf68187ff00bc64e1274b442176e1c84"