diff --git a/docs/api.asciidoc b/docs/api.asciidoc
index afe7722a0cec5..a650d016ce951 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 a2b2ed7368431..864d315205dcb 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 0000000000000..f5f9a9d81c2fc
--- /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 0000000000000..c5ae025dd9e2e
--- /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 0000000000000..c79a883a80e4b
--- /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 0000000000000..38ff647051335
--- /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 0000000000000..529742bf2ce66
--- /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 b7e0dec88bf50..2da6507935015 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 47c3f16dc500b..e26c26a8aa9f4 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 ddc5bca0cb042..3320843bc7a81 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 0000000000000..e6a96553873b7
--- /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 0000000000000..8237df1136a9e
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 0000000000000..dae7d01f665c0
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 0000000000000..a94d2c36d4f5d
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 0000000000000..bd58605362024
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 0000000000000..a1977b01d1fa0
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 0000000000000..b40c4267c2b49
--- /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 0000000000000..73a21ff049b36
--- /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 0000000000000..e6a116f54a252
--- /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 0000000000000..1fd6e915bc4f8
--- /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 d0a3160018fea..d0cc7091d7767 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 85abe0571d116..00f953298c063 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 7ce4341f59ed8..6ec6cd714556c 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 221f8c2f82bf8..41979d4bd66a6 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 0000000000000..24ec895459038
--- /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 0000000000000..76b6293b4c267
--- /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 0000000000000..35485fdc7b492
--- /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 0000000000000..2b1d06ceeed41
--- /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 f92ebae39e517..b93d3a0ac5371 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 7012b095a8658..d7a79b0d02fa7 100644
--- a/src/server/http/index.js
+++ b/src/server/http/index.js
@@ -24,6 +24,7 @@ import Boom from 'boom';
import Hapi from 'hapi';
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) {
@@ -32,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,7 +89,7 @@ export default async function (kbnServer, server, config) {
path: '/',
method: 'GET',
handler(req, reply) {
- const basePath = config.get('server.basePath');
+ const basePath = req.getBasePath();
const defaultRoute = config.get('server.defaultRoute');
reply.redirect(`${basePath}${defaultRoute}`);
}
@@ -100,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 0000000000000..caba48c765b02
--- /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 0000000000000..fd96c54450cf7
--- /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 0000000000000..9256b2e913931
--- /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 0000000000000..3c918f0c1e1fc
--- /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 1517cfada5058..62b7070f359ca 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 ddcc9c1c3ff56..05cc97945ef0b 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 219f35559c884..52a98c08edde5 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 169c9546a4a37..1ca5f2689590d 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 04aab308396c4..329d1c463884d 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 5e4c0c2490af0..533157cecf5ef 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 e3ad2ba72b418..eab08903ec029 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 62f9850c839f5..b4a0262bf8882 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 0000000000000..bbc91d65f1112
--- /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 0000000000000..3f7f69a5271e0
--- /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 ec0a659d6d063..ee9c26d573573 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 af8280f6ab5b7..aed82fbc648e5 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 a7b3cb38d1239..7071a6c7f7a70 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 4f6c221b8650a..701586bad9255 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 7aea264ae0e5f..269bd044a56ad 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 0cac763473146..6ac7bf55d1826 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 8ecf6b2cb6782..fe2d7c84b40a1 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 2c94135a113e7..ef51000a3b0af 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 bc19a27498dc9..30ca1357b349e 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 1c8ade94456d0..34415cb6aeef5 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 e25002e9c9c1c..b0b823800a660 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 bf3a49fcbdc3e..4bd4d8e86b73a 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 6234884fdb881..f7f97509e6596 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 595c8a2d904d5..7112ddf7374e4 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 c07188439b0e0..ac2a10f42f4dc 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 26c62bca335d9..4f3ead9f47c67 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 6f43c1722cd1b..0ca1bef98f432 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 3f48ca4de922a..5988b79799037 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 135b7542e7047..c5e88b41f434c 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 24df0baec12af..c69c320aace97 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 4fee02f262763..0e3e5b6420825 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 fbf4d97a89bce..9a845cd148ac6 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 b8e81abb07df0..1036caccfe168 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 6978494e151d2..df97ff35bd07b 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 278ac487bfc2e..723a8e858dcff 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 b838ada1a86ca..6eeab649f0e0d 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 1f0504db3681d..1d409e45c092c 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 02d78a97ef9be..f116cbb763014 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 a407cacc63fef..baa1cd458c8a0 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 bfd3cb6eaa9d5..bab11f1004552 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 f2d5430f9e1fc..df55bb75d2621 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 7552ebc665cba..f60bc0d83e155 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 e2f594eec609f..b224d0835fa94 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 1391d4665cb50..39ca6fd52f51e 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 103e3b4c3293d..72969083d98e3 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 2b4ea67d5895c..10c68f508a736 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 1b762d5f15acc..a62085787cd47 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 cf26e2e7cf4d8..560e8df5e126b 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 0000000000000..20cac65b4ca79
--- /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 0000000000000..5b1094c8c3a0a
--- /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 2abd107758dad..7262eadb999c6 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 8d9bb3c2256b4..d357451d48ac7 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 efb22152ef7b1..0000000000000
--- 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 0000000000000..c86b250e034f6
--- /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 89eade0f0584e..d6221f7aecb4c 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 0000000000000..a6238ca879901
--- /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 0000000000000..e0998eb8b8f6b
--- /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 0000000000000..2551d7eabc4e7
--- /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 615188cad33d7..00db6cccfb13e 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 0000000000000..794a4b30674e5
--- /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 490e1c0e8f8c5..0000000000000
--- a/x-pack/plugins/security/public/views/management/edit_role.html
+++ /dev/null
@@ -1,196 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
- New Role
-
-
- “{{ role.name }}” Role
-
-
-
-
-
-
-
-
-
- Delete role
-
-
-
-
- Reserved
-
-
-
-
-
-
-
-
-
-
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 6a3f3a79573ff..0000000000000
--- a/x-pack/plugins/security/public/views/management/edit_role.js
+++ /dev/null
@@ -1,194 +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 { 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: {
- 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 0000000000000..75c5d91493645
--- /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 0000000000000..ffb065880c560
--- /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 0000000000000..86f1e73b78e1b
--- /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 0000000000000..a58042fda9697
--- /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 0000000000000..8a78748b46232
--- /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 0000000000000..28b3107a96c42
--- /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 0000000000000..9d2457b38cfd8
--- /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 0000000000000..1a0afb37c4791
--- /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 0000000000000..a8165ab6cb9b0
--- /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 0000000000000..5e65d164d59c7
--- /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 0000000000000..5280890edf5e6
--- /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 0000000000000..189c4766c29f9
--- /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 0000000000000..a3a52a2fc511a
--- /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 0000000000000..929935ba7f6ac
--- /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 0000000000000..776ef72a4627e
--- /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 0000000000000..3a948801102a5
--- /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 0000000000000..2625ff9879d3f
--- /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 0000000000000..2d68676694304
--- /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 0000000000000..6f1416ab43552
--- /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 0000000000000..7e8b71fd93ae2
--- /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 0000000000000..e5e5648db06ab
--- /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 0000000000000..a06b14f80fa48
--- /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 0000000000000..c67cde88bb444
--- /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 0000000000000..50ef26bbe56ac
--- /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 0000000000000..53f3fc716d65e
--- /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 0000000000000..c24b2d596ff22
--- /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 0000000000000..addaf7437816c
--- /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 0000000000000..eb59b66483292
--- /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 0000000000000..8cc8767a5f89f
--- /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 0000000000000..19f6c14a4a6f9
--- /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 0000000000000..aafe9d273c5c4
--- /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 0000000000000..e8d53770270db
--- /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 0000000000000..07dd40c5de8a5
--- /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 0000000000000..80d848b5893d5
--- /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 0000000000000..1e8d3f3c39158
--- /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 0000000000000..0b5666f391573
--- /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 0000000000000..5f7d902cb7459
--- /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 0000000000000..95494567446b7
--- /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 0000000000000..cf7bcf8287b9d
--- /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 0000000000000..05600c3f0e2f7
--- /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 0000000000000..d6ecbdb705b74
--- /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 0000000000000..71b881c4a85ef
--- /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 0000000000000..b386fcafc614c
--- /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 0000000000000..dcdaf055cae8d
--- /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 0000000000000..33579203e2f91
--- /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 0000000000000..510217a9cbd65
--- /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 0000000000000..91719960583a3
--- /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 0000000000000..2966c78d2d92a
--- /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 0000000000000..292b1e517a69a
--- /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 0000000000000..b3b4212bb1f1a
--- /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 0000000000000..996169d4fc70c
--- /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 0000000000000..177ffc1707836
--- /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 0000000000000..1f4837b07d0b9
--- /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 0000000000000..7378251fc84ac
--- /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 0000000000000..5bd7aa8d6aeca
--- /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 0000000000000..395f14756c547
--- /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 0000000000000..3f7e8b1330812
--- /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 0000000000000..89b6ff6cd8d80
--- /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 0000000000000..162cd0cd2eab6
--- /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 0000000000000..f4ada14fde8ef
--- /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 829b0dfaefa92..0000000000000
--- a/x-pack/plugins/security/public/views/management/index_privileges_form/index_privileges_form.html
+++ /dev/null
@@ -1,128 +0,0 @@
-
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 1bb058f56d096..0000000000000
--- 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 edd7a4898f45a..0000000000000
--- 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 b2bd09e67e9bf..f7000478cbfc7 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 046f4b7eb8c53..fa87006e6593e 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 78a015bc14105..3814c282a911e 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 249e62af58574..365cf399519d9 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 ab32f30c2e315..7a11e694b8975 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 3f11f47892105..e731c6724e9d4 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 7609d57b702fa..634600a2549d6 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 c65b0d2d6ae39..0000000000000
--- 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 0000000000000..66840335528ce
--- /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 0000000000000..0a943137989ec
--- /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 5e1e26a9023ae..226002545a378 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 432698a003cb3..e47e1edd5d4c4 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 17834438e1781..9ae2265557d21 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 b12658708f2d3..bdd9781f97771 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 510ec3e4852b7..f74528d4fd20d 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 e0029a3caeafd..1fb754202832f 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 0000000000000..37800ca4e3911
--- /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 0000000000000..5f14842710b56
--- /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 6f64871ed7556..7e9f53f873a0d 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 826cdab4b4204..6845dd7590e2d 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 f326d85fdeee3..b2a391aa49573 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 f99bf6d25d26f..1d02330b9b199 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 d70e08934c131..f0bfe46c35b7c 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 0000000000000..2906f07e9f5ce
--- /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 0000000000000..2277d09e498db
--- /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 34d618398bc3d..2819983cf43c8 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 f3dbad1b56ac9..a7fba14229de2 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 df6052dc4bb3d..0000000000000
--- 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 7be9d4358b06d..0000000000000
--- 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 0000000000000..01c41551a0344
--- /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 0000000000000..f4b3d31b8da0c
--- /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 9ae89a97c36f1..797a76110d21a 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 a8dd3a38bdeb9..28f754248d829 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 5425af0a1202d..8bdde88123ee4 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 123ce128e1509..0152453a10a26 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 d6c32ce00ac56..ffa7b247f99d2 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 4139569e51b34..155fc041f24a5 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 e92a0a2a9536c..c791def49e9d2 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 4bf1b2c5cc7a5..0000000000000
--- 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 0000000000000..50423517bc918
--- /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 0000000000000..0e605562ea3ea
--- /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 0000000000000..7c0bfb74b86eb
--- /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 0000000000000..788ef80c194ce
--- /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 0000000000000..15148231984fc
--- /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 0000000000000..c999dde275643
--- /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 0000000000000..c73a4b5aca7aa
--- /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 0000000000000..9c44c11751dfc
--- /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 0000000000000..6c91d9b020ff6
--- /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 0000000000000..174b16e0c5656
--- /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 0000000000000..d45aa825db89c
--- /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 0000000000000..2e73f0c704f8c
--- /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 0000000000000..374ffc5d36500
--- /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 0000000000000..eb70629255ed0
--- /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 0000000000000..47f1b29bd55a1
--- /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 0000000000000..d89bcbc6e2465
--- /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 0000000000000..93f21f0e46629
--- /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 0000000000000..538dd77e053f5
--- /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 0000000000000..8a11e0137897f
--- /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 0000000000000..6744590e7d35a
--- /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 0000000000000..9a6f95a3d9ed9
--- /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 0000000000000..8245a16b9f43c
--- /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 0000000000000..26f8a226315b8
--- /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 0000000000000..fc5a2c18b9786
--- /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 0000000000000..8108285e3dce7
--- /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 0000000000000..591f8c1507b7a
--- /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 0000000000000..20f93030d7907
--- /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 0000000000000..7f00705b160fb
--- /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 0000000000000..673576cd01b3f
--- /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 0000000000000..0d539fffe6e34
--- /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 0000000000000..39444cf01e88b
--- /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 0000000000000..0eef1703fc856
--- /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 0000000000000..f403caf3eeebe
--- /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 0000000000000..4bffc7433987c
--- /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 0000000000000..d46848f2c103f
--- /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 0000000000000..c0a4924e9c072
--- /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 0000000000000..60fdf51dca70e
--- /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 0000000000000..2fbfd6da452e4
--- /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 0000000000000..28515ba71593d
--- /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 0000000000000..91f4964e1da06
--- /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 0000000000000..793806db8c544
--- /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 0000000000000..4526dc791a224
--- /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 0000000000000..65b6bc2c12006
--- /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 0000000000000..db77c1ad6d113
--- /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 0000000000000..35fdd69f64e8b
--- /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 0000000000000..a0888b86fa0eb
--- /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 0000000000000..cbc3efafff90e
--- /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 0000000000000..efb34025573aa
--- /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 0000000000000..a88906a10237e
--- /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 0000000000000..180ed9b0dfef7
--- /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 0000000000000..be279536ccd88
--- /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 0000000000000..456a6d1ccbb43
--- /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 0000000000000..25aea985e01ac
--- /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 0000000000000..65018a5e9271c
--- /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 0000000000000..e560af2d3d99a
--- /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 0000000000000..e8b4890173ea7
--- /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 0000000000000..ae584ba715b63
--- /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 0000000000000..5c358cb4fd5ca
--- /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 0000000000000..f4f9b11f94b2d
--- /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 0000000000000..1f9b006ef91ea
--- /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 0000000000000..0f32ca154cbe5
--- /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 0000000000000..4a158168febd8
--- /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 0000000000000..c180f380d6845
--- /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 0000000000000..d7defc266b715
--- /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 0000000000000..bcaab2bfa0ec4
--- /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 0000000000000..66b931e7f4c9a
--- /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 0000000000000..15f085df6d1b6
--- /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 0000000000000..fa7f26a32aa83
--- /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 0000000000000..1aead143e5d57
--- /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 0000000000000..8debabf65c43d
--- /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 0000000000000..b6df9d36f8cb0
--- /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 0000000000000..2c4d562c1e4f6
--- /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 0000000000000..ae120ec8fbab2
--- /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 0000000000000..ceff24115bcf3
--- /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 0000000000000..9d8dd36999c75
--- /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 0000000000000..8d033996f6a8d
--- /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 0000000000000..4ae51c954b00a
--- /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 0000000000000..7827e26555230
--- /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 0000000000000..541c79a8fd4a3
--- /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 0000000000000..284866cc43bed
--- /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 0000000000000..6feccde1080a2
--- /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 0000000000000..690ae64f77536
--- /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 0000000000000..67f6e81df956e
--- /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 0000000000000..2a7882b683ef8
--- /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 (
+
+ );
+ }
+
+ 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 0000000000000..6dcb552b2ac78
--- /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 0000000000000..e9b9a1795cf20
--- /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 0000000000000..2dbf9fac3f68b
--- /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 0000000000000..33191ad976b44
--- /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 0000000000000..96d99f0a29360
--- /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 0000000000000..a4975f23508ef
--- /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 0000000000000..bbb3b1918718d
--- /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 0000000000000..4b0c0274cedf9
--- /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 0000000000000..d08be39f9282e
--- /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 0000000000000..53d0befd01380
--- /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 0000000000000..b9bd3f5fe0399
--- /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 0000000000000..e7fc63e724feb
--- /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 0000000000000..a71278a737188
--- /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 0000000000000..c33283033b7e9
--- /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 0000000000000..3eec453554702
--- /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 0000000000000..3269142a9cf17
--- /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 0000000000000..4f95c175b0f15
--- /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 0000000000000..907b7b164b69b
--- /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 0000000000000..77d7db1328c14
--- /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 0000000000000..3f24553306b2b
--- /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 0000000000000..ef4cb9b01ec1e
--- /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 0000000000000..9361bfaf18b37
--- /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 0000000000000..449836633993c
--- /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 0000000000000..e52af9a98001a
--- /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 0000000000000..b2cdc09d66a1b
--- /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 0000000000000..9e19556abd20c
--- /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 0000000000000..5a6ca30dba833
--- /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 0000000000000..dc2131afee2b2
--- /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 0000000000000..5b8792c45de37
--- /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 0000000000000..dd5b865d5fd8e
--- /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 0000000000000..043856235acba
--- /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 0000000000000..1c0c50c6a1968
--- /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 0000000000000..5e29875c3c8ec
--- /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 0000000000000..4ed548d64b574
--- /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 0000000000000..b3254fd3b3c07
--- /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 0000000000000..5878272c84924
--- /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 0000000000000..14113cbf9d807
--- /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 0000000000000..85284e3fc3a1c
--- /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 0000000000000..44c7f9a2e6500
--- /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 0000000000000..37fe32c80032e
--- /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 0000000000000..21948e28c56d6
--- /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 0000000000000..080c765dd4a44
--- /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 0000000000000..ad3e758853e01
--- /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 0000000000000..ae3a083c50123
--- /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 0000000000000..602b62ab26d06
--- /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 0000000000000..b554d5fc67354
--- /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 0000000000000..a4c1e04a73831
--- /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 0000000000000..e02fb58da1d61
--- /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 0000000000000..dea7e3a79d5c0
--- /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 0000000000000..75659c14c03ae
--- /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 0000000000000..0758ceb32746c
--- /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 0000000000000..6f09d1831bff9
--- /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 0000000000000..31738ff562865
--- /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 0000000000000..d3ee173a2e80f
--- /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 0000000000000..eaa789b32c39b
--- /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 0000000000000..af67388792565
--- /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 6e6ed25893ced..28ef9a49f165d 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 1a7826f56d0d2..54ba58832f0ea 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 53717aa0b15a2..7bdef2120f336 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 0000000000000..45507ab604284
--- /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 0000000000000..09b257aa80e3f
--- /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 8c244f8ae933f..9e580f109d536 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 f75ec5678f1c6..863f07725ad29 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 1f9d6c5849e2f..690f9021fbefd 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 990e4e1a7d53a..b8362c6549e16 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 0000000000000..22a0b58b60c2a
--- /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 0000000000000..417341165fde4
--- /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 e7e4579bbb2e5..63a051f480f9d 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 7f105650141d9..85b11bb9ef71e 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 f77ea88bde2c8..7e05e11944a0c 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 8dc31bb586911..4740ac013d644 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 9a44d595367d4..535a0c2c4164a 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 89f5416135c57..a0fc042f58540 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 7fbb9adcfe5ed..32b6eef2d96bb 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 ddab249003b5b..7492c53522ed4 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 97f6b35f25871..a185c193d9db4 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 3cb09a3adf8a4..0975278b6ee94 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 0000000000000..455692b86fc81
--- /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 0000000000000..2ed91406e5f48
--- /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 0000000000000..be2b59d69d864
--- /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 aa4f6c76705bc..7a902aafe36bf 100644
--- a/x-pack/test/functional/config.js
+++ b/x-pack/test/functional/config.js
@@ -16,6 +16,7 @@ import {
GrokDebuggerPageProvider,
WatcherPageProvider,
ReportingPageProvider,
+ SpaceSelectorPageProvider,
AccountSettingProvider,
} from './page_objects';
@@ -65,6 +66,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'),
],
@@ -115,6 +117,7 @@ export default async function ({ readConfigFile }) {
grokDebugger: GrokDebuggerPageProvider,
watcher: WatcherPageProvider,
reporting: ReportingPageProvider,
+ spaceSelector: SpaceSelectorPageProvider,
},
servers: kibanaFunctionalConfig.get('servers'),
@@ -161,6 +164,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 583406fa4da4f..00e55c17876e9 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 d8899a4dedb27..890f4be575fae 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 020ca814620a8..df05dfd8998f4 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 724d6cdd018f8..f72b7c5a91dc0 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 bffef555b9bf3..d9708ad59f56f 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 81888c31185da..aeea7f7bcea4b 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 2ac732942acb2..3b5fcac425b87 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 b54e80bf19626..8961a5608e589 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 8ef6743e02091..ab1126b826cf9 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 b54e80bf19626..8961a5608e589 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 0000000000000..f76f2c6874b34
--- /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 0000000000000..aeea7f7bcea4b
--- /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 2a66d83a91ad9..3ad10d395997c 100644
--- a/x-pack/test/functional/page_objects/index.js
+++ b/x-pack/test/functional/page_objects/index.js
@@ -11,4 +11,5 @@ 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';
export { AccountSettingProvider } from './accountsetting_page';
diff --git a/x-pack/test/functional/page_objects/security_page.js b/x-pack/test/functional/page_objects/security_page.js
index 748dd36cfb603..a1f5e1b9af2bc 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) {
@@ -194,7 +221,7 @@ export function SecurityPageProvider({ getService, getPageObjects }) {
const usernameElement = await user.findByCssSelector('[data-test-subj="userRowUserName"]');
const emailElement = await user.findByCssSelector('[data-header="Email Address"]');
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(),
@@ -210,9 +237,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')
};
@@ -250,27 +277,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);
}
})
@@ -283,19 +308,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 () {
@@ -304,25 +327,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 () {
@@ -332,8 +340,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);
@@ -342,13 +350,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
@@ -384,10 +392,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 0000000000000..aebe065fe6ef9
--- /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 5f407431f24a8..0000000000000
--- 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 6785859e42fbf..0000000000000
--- 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 6a949004371f8..0000000000000
--- 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 5885eb7919c7b..0000000000000
--- 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 9f2c4fde33581..0000000000000
--- 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 b640d12055593..0000000000000
--- 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 bdbd23f6dabdf..0000000000000
--- 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 5b158a6c8bf37..0000000000000
--- 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 a4a17ba67fd5e..0000000000000
--- 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 3ea5546f09cf1..0000000000000
--- 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 910382479979d..0000000000000
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 9ebbfce0ac3ac..461c6c0df5271 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 0000000000000..de1eb29ac63ab
--- /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 0000000000000..5da6fb43ff1d4
--- /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 0000000000000..6cd530559c8f2
--- /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 0000000000000..3fdbb6b9a2509
--- /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 0000000000000..b30a2c3877b88
--- /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 0000000000000..1a0afbc6bfcb3
--- /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 0000000000000..19210e3818b67
--- /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 0000000000000..b5e1b97fdf0a6
--- /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 0000000000000..1619d77761c84
--- /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 0000000000000..a9c552d4ccd78
--- /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 0000000000000..fc6d3d8745fb9
--- /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 0000000000000..f5ef3be4b4bde
--- /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 0000000000000..cb93afedcb1d9
--- /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 0000000000000..dc5b7eaf3c75e
--- /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 0000000000000..87c94c1d13b28
--- /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 0000000000000..9230415453d37
--- /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 0000000000000..d7bc0180f8e2b
--- /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 0000000000000..85dcda1214ad4
--- /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 0000000000000..e45fa1928b809
--- /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 0000000000000..4dc1542fd2543
--- /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 0000000000000..71a7f7ac67756
--- /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 0000000000000..8aeb43d9d7f36
--- /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 0000000000000..c12699cb89803
--- /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 0000000000000..d7ce2413daf42
--- /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 0000000000000..eaa374098bd33
--- /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 0000000000000..d25a9b852b789
--- /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 0000000000000..fa02c2c6e60b3
--- /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 0000000000000..81cf9d85671d1
--- /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 0000000000000..4e7d9ea6fb148
--- /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 0000000000000..9376eb6b5995f
--- /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 0000000000000..3bceeeeee33b2
--- /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 0000000000000..27b2375ae9129
--- /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 0000000000000..ee664e43375c0
--- /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 0000000000000..48698a56f892f
--- /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 0000000000000..c9be7152f96ea
--- /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 0000000000000..de506b4186e05
--- /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 0000000000000..f71cc9207b3cc
--- /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 0000000000000..cf794079a42ea
--- /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 0000000000000..cfb4b301bccfb
--- /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 0000000000000..129023130e716
--- /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 0000000000000..04db10af00388
--- /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 0000000000000..4f704b3d38219
--- /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 0000000000000..012df68492045
--- /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 0000000000000..113cf86454d5f
--- /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 0000000000000..6779ed4733ec5
--- /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 0000000000000..38d65bab1b107
--- /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 0000000000000..97a92cd4e666f
--- /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 0000000000000..383f7083ff070
--- /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 107a45fab187b..d8d4c52985bf5 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 0000000000000..d4dcb333cd61a
--- /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 0000000000000..0017888c63567
--- /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 0000000000000..f233bc1d11d7c
--- /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 0000000000000..a9c552d4ccd78
--- /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 0000000000000..f149ad02cc1f7
--- /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 420541fa7ec5f..c4fa7c504e12c 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 0000000000000..7b750f4ee3ff4
--- /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 0000000000000..3376e73420fb2
--- /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 0000000000000..50cddc4a7dac3
--- /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 0000000000000..3bc1be39c9c70
--- /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 0000000000000..ca4d3556a04cb
--- /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 0000000000000..cfd1123e2b72c
--- /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 0000000000000..007b8351778c6
--- /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 0000000000000..7d2d124f1a374
--- /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 0000000000000..bff11d41602ac
--- /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 0000000000000..d7dd0d9468d6d
--- /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 0000000000000..044670a822ac2
--- /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 0000000000000..3e9e249c2f4bd
--- /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 0000000000000..0b828a7d02f07
--- /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 0000000000000..81cf9d85671d1
--- /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 0000000000000..fb01fd18527c2
--- /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 0000000000000..a0902281f4c73
--- /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 0000000000000..8017e3c62eec8
--- /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 0000000000000..4380df0d196f9
--- /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 0000000000000..6864ee7fbda94
--- /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 0000000000000..d7f6562c6e715
--- /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 0000000000000..649fb07c8cbdb
--- /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 0000000000000..49e31da77dd74
--- /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 0000000000000..87e34dc754cdc
--- /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 a01f0127c96cb..44b418ba975ec 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 5d8cbafd57436..773c8ed1e5a37 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"
@@ -1297,14 +1326,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"
@@ -2997,8 +3022,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"
@@ -3878,7 +3903,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"
@@ -3888,6 +3913,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"
@@ -6884,8 +6915,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"
@@ -7203,7 +7234,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:
@@ -7215,6 +7246,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"
@@ -8226,6 +8269,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"
@@ -8740,14 +8789,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"
@@ -8903,11 +8948,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"
@@ -9093,8 +9138,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 37322d520eac2..b7432f023e971 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -3306,11 +3306,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"
@@ -5423,8 +5423,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"
@@ -6589,7 +6589,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"
@@ -6597,7 +6597,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:
@@ -11097,7 +11097,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:
@@ -11245,6 +11254,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"
@@ -11407,14 +11420,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"
@@ -11548,7 +11562,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:
@@ -11560,7 +11574,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:
@@ -13183,8 +13197,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"
@@ -13716,14 +13730,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"
@@ -14611,7 +14621,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"