mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
Spaces Phase 1 (#21408)
### Review notes This is generally ready for review. We are awaiting https://github.com/elastic/elasticsearch/issues/32777 to improve handling when users do not have any access to Kibana, but this should not hold up the overall review for this PR. This PR is massive, there's no denying that. Here's what to focus on: 1) `x-pack/plugins/spaces`: This is, well, the Spaces plugin. Everything in here is brand new. The server code is arguably more important, but feel free to review whatever you see fit. 2) `x-pack/plugins/security`: There are large and significant changes here to allow Spaces to be securable. To save a bit of time, you are free to ignore changes in `x-pack/plugins/security/public`: These are the UI changes for the role management screen, which were previously reviewed by both us and the design team. 3) `x-pack/test/saved_object_api_integration` and `x-pack/test/spaces_api_integration`: These are the API test suites which verify functionality for: a) Both security and spaces enabled b) Only security enabled c) Only spaces enabled What to ignore: 1) As mentioned above, you are free to ignore changes in `x-pack/plugins/security/public` 2) Changes to `kibana/src/server/*`: These changes are part of a [different PR that we're targeting against master](https://github.com/elastic/kibana/pull/23378) for easier review. ## Saved Objects Client Extensions A bulk of the changes to the saved objects service are in the namespaces PR, but we have a couple of important changes included here. ### Priority Queue for wrappers We have implemented a priority queue which allows plugins to specify the order in which their SOC wrapper should be applied: `kibana/src/server/saved_objects/service/lib/priority_collection.ts`. We are leveraging this to ensure that both the security SOC wrapper and the spaces SOC wrapper are applied in the correct order (more details below). ### Spaces SOC Wrapper This wrapper is very simple, and it is only responsible for two things: 1) Prevent users from interacting with any `space` objects (use the Spaces client instead, described below) 2) Provide a `namespace` to the underlying Saved Objects Client, and ensure that no other wrappers/callers have provided a namespace. In order to accomplish this, the Spaces wrapper uses the priority queue to ensure that it is the last wrapper invoked before calling the underlying client. ### Security SOC Wrapper This wrapper is responsible for performing authorization checks. It uses the priority queue to ensure that it is the first wrapper invoked. To say another way, if the authorization checks fail, then no other wrappers will be called, and the base client will not be called either. This wrapper authorizes users in one of two ways: RBAC or Legacy. More details on this are below. ### Examples: `GET /s/marketing/api/saved_objects/index-pattern/foo` **When both Security and Spaces are enabled:** 1) Saved objects API retrieves an instance of the SOC via `savedObjects.getScopedClient()`, and invokes its `get` function 2) The Security wrapper is invoked. a) Authorization checks are performed to ensure user can access this particular saved object at this space. 3) The Spaces wrapper is invoked. a) Spaces applies a `namespace` to be used by the underlying client 4) The underlying client/repository are invoked to retrieve the object from ES. **When only Spaces are enabled:** 1) Saved objects API retrieves an instance of the SOC via `savedObjects.getScopedClient()`, and invokes its `get` function 2) The Spaces wrapper is invoked. a) Spaces applies a `namespace` to be used by the underlying client 3) The underlying client/repository are invoked to retrieve the object from ES. **When only Security is enabled:** (assume `/s/marketing` is no longer part of the request) 1) Saved objects API retrieves an instance of the SOC via `savedObjects.getScopedClient()`, and invokes its `get` function 2) The Security wrapper is invoked. a) Authorization checks are performed to ensure user can access this particular saved object globally. 3) The underlying client/repository are invoked to retrieve the object from ES. ## Authorization Authorization changes for this project are centered around Saved Objects, and builds on the work introduced in RBAC Phase 1. ### Saved objects client #### Security without spaces When security is enabled, but spaces is disabled, then the authorization model behaves the same way as before: If the user is taking advantage of Kibana Privileges, then we check their privileges "globally" before proceeding. A "global" privilege check specifies `resources: ['*']` when calling the [ES _has_privileges api.](https://www.elastic.co/guide/en/elasticsearch/reference/current/security-api-has-privileges.html). Legacy users (non-rbac) will continue to use the underlying index privileges for authorization. #### Security with spaces When both plugins are enabled, then the authorization model becomes more fine-tuned. Rather than checking privileges globally, the privileges are checked against a specific resource that matches the user's active space. In order to accomplish this, the Security plugin needs to know if Spaces is enabled, and if so, it needs to ask Spaces for the user's active space. The subsequent call to the `ES _has_privileges api` would use `resources: ['space:marketing']` to verify that the user is authorized at the `marketing` space. Legacy users (non-rbac) will continue to use the underlying index privileges for authorization. **NOTE** The legacy behavior implies that those users will have access to all spaces. The read/write restrictions are still enforced, but there is no way to restrict access to a specific space for legacy auth users. #### Spaces without security No authorization performed. Everyone can access everything. ### Spaces client Spaces, when enabled, prevents saved objects of type `space` from being CRUD'd via the Saved Objects Client. Instead, the only "approved" way to work with these objects is through the new Spaces client (`kibana/x-pack/plugins/spaces/lib/spaces_client.ts`). When security is enabled, the Spaces client performs its own set of authorization checks before allowing the request to proceed. The Spaces client knows which authorization checks need to happen for a particular request, but it doesn't know _how_ to check privileges. To accomplish this, the spaces client will delegate the check security's authorization service. #### FAQ: Why oh why can't you used the Saved Objects Client instead!? That's a great question! We did this primarily to simplify the authorization model (at least for our initial release). Accessing regular saved objects follows a predictible authorization pattern (described above). Spaces themselves inform the authorization model, and this interplay would have greatly increased the complexity. We are brainstorming ideas to obselete the Spaces client in favor of using the Saved Objects Client everywhere, but that's certainly out of scope for this release. ## Test Coverage ### Saved Objects API A bulk of the changes to enable spaces are centered around saved objects, so we have spent a majority of our time automating tests against the saved objects api. **`x-pack/test/saved_object_api_integration/`** contains the test suites for the saved objects api. There is a `common/suites` subfolder which contains a bulk of the test logic. The suites defined here are used in the following test configurations: 1) Spaces only: `./spaces_only` 2) Security and spaces: `./security_and_spaces` 3) Security only: `./security_only` Each of these test configurations will start up ES/Kibana with the appropriate license and plugin set. Each set runs through the entire test suite described in `common/suites`. Each test with in each suite is run multiple times with different inputs, to test the various permutations of authentication, authorization type (legacy vs RBAC), space-level privileges, and the user's active space. ### Spaces API Spaces provides an experimental public API. **`x-pack/test/spaces_api_integration`** contains the test suites for the Spaces API. Similar to the Saved Objects API tests described above, there is a `common/suites` folder which contains a bulk of the test logic. The suites defined here are used in the following test configurations: 1) Spaces only: `./spaces_only` 2) Security and spaces: `./security_and_spaces` ### Role Management UI We did not provide any new functional UI tests for role management, but the existing suite was updated to accomidate the screen rewrite. We do have a decent suite of jest unit tests for the various components that make up the new role management screen. They're nested within `kibana/x-pack/plugins/security/public/views/management/edit_role` ### Spaces Management UI We did not provide any new functional UI tests for spaces management, but the components that make up the screens are well-tested, and can be found within `kibana/x-pack/plugins/spaces/public/views/management/edit_space` ### Spaces Functional UI Tests There are a couple of UI tests that verify _basic_ functionality. They assert that a user can login, select a space, and then choose a different space once inside: `kibana/x-pack/test/functional/apps/spaces` ## Reference Notable child PRs are listed below for easier digesting. Note that some of these PRs are built on other PRs, so the deltas in the links below may be outdated. Cross reference with this PR when in doubt. ### UI - Reactify Role Management Screen: https://github.com/elastic/kibana/pull/19035 - Space Aware Privileges UI: https://github.com/elastic/kibana/pull/21049 - Space Selector (in Kibana Nav): https://github.com/elastic/kibana/pull/19497 - Recently viewed Widget: https://github.com/elastic/kibana/pull/22492 - Support Space rename/delete: https://github.com/elastic/kibana/pull/22586 ### Saved Objects Client - ~~Space Aware Saved Objects: https://github.com/elastic/kibana/pull/18862~~ - ~~Add Space ID to document id: https://github.com/elastic/kibana/pull/21372~~ - Saved object namespaces (supercedes #18862 and #21372): https://github.com/elastic/kibana/pull/22357 - Securing saved objects: https://github.com/elastic/kibana/pull/21995 - Dedicated Spaces client (w/ security): https://github.com/elastic/kibana/pull/21995 ### Other - Public Spaces API (experimental): https://github.com/elastic/kibana/pull/22501 - Telemetry: https://github.com/elastic/kibana/pull/20581 - Reporting: https://github.com/elastic/kibana/pull/21457 - Spencer's original Spaces work: https://github.com/elastic/kibana/pull/18664 - Expose `spaceId` to "Add Data" tutorials: https://github.com/elastic/kibana/pull/22760 Closes #18948 "Release Note: Create spaces within Kibana to organize dashboards, visualizations, and other saved objects. Secure access to each space when X-Pack Security is enabled"
This commit is contained in:
parent
76c0a0a546
commit
1f38026731
479 changed files with 31603 additions and 5034 deletions
|
@ -26,7 +26,7 @@ entirely.
|
|||
|
||||
[float]
|
||||
== APIs
|
||||
|
||||
* <<spaces-api>>
|
||||
* <<role-management-api>>
|
||||
* <<saved-objects-api>>
|
||||
* <<dashboard-import-api>>
|
||||
|
@ -34,6 +34,7 @@ entirely.
|
|||
* <<url-shortening-api>>
|
||||
--
|
||||
|
||||
include::api/spaces-management.asciidoc[]
|
||||
include::api/role-management.asciidoc[]
|
||||
include::api/saved-objects.asciidoc[]
|
||||
include::api/dashboard-import.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-privileges>>.
|
||||
`kibana`:: (object) An object that specifies the <<kibana-privileges>>. 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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
--------------------------------------------------
|
17
docs/api/spaces-management.asciidoc
Normal file
17
docs/api/spaces-management.asciidoc
Normal file
|
@ -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}.
|
||||
|
||||
* <<spaces-api-post>>
|
||||
* <<spaces-api-put>>
|
||||
* <<spaces-api-get>>
|
||||
* <<spaces-api-delete>>
|
||||
|
||||
include::spaces-management/post.asciidoc[]
|
||||
include::spaces-management/put.asciidoc[]
|
||||
include::spaces-management/get.asciidoc[]
|
||||
include::spaces-management/delete.asciidoc[]
|
25
docs/api/spaces-management/delete.asciidoc
Normal file
25
docs/api/spaces-management/delete.asciidoc
Normal file
|
@ -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/<space_id>`
|
||||
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.
|
77
docs/api/spaces-management/get.asciidoc
Normal file
77
docs/api/spaces-management/get.asciidoc
Normal file
|
@ -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/<space_id>` 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"
|
||||
}
|
||||
--------------------------------------------------
|
50
docs/api/spaces-management/post.asciidoc
Normal file
50
docs/api/spaces-management/post.asciidoc
Normal file
|
@ -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.
|
50
docs/api/spaces-management/put.asciidoc
Normal file
50
docs/api/spaces-management/put.asciidoc
Normal file
|
@ -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/<space_id>` endpoint.
|
||||
|
||||
[source,js]
|
||||
--------------------------------------------------
|
||||
PUT /api/spaces/space/<space_id>
|
||||
--------------------------------------------------
|
||||
|
||||
==== 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.
|
|
@ -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`);
|
||||
}
|
||||
});
|
||||
-----------
|
||||
|
|
|
@ -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[]
|
||||
|
|
|
@ -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 <<role-managemen
|
|||
to assign a specific <<kibana-privileges, Kibana privilege>> 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`
|
||||
|
|
8
docs/spaces/getting-started.asciidoc
Normal file
8
docs/spaces/getting-started.asciidoc
Normal file
|
@ -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.
|
BIN
docs/spaces/images/delete-space.png
Normal file
BIN
docs/spaces/images/delete-space.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 144 KiB |
BIN
docs/spaces/images/edit-space.png
Normal file
BIN
docs/spaces/images/edit-space.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 147 KiB |
BIN
docs/spaces/images/securing-spaces.png
Normal file
BIN
docs/spaces/images/securing-spaces.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 201 KiB |
BIN
docs/spaces/images/space-management.png
Normal file
BIN
docs/spaces/images/space-management.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 157 KiB |
BIN
docs/spaces/images/space-selector.png
Normal file
BIN
docs/spaces/images/space-selector.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 148 KiB |
17
docs/spaces/index.asciidoc
Normal file
17
docs/spaces/index.asciidoc
Normal file
|
@ -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[]
|
25
docs/spaces/managing-spaces.asciidoc
Normal file
25
docs/spaces/managing-spaces.asciidoc
Normal file
|
@ -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 <<spaces-api, experimental API>> 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"]
|
14
docs/spaces/moving-saved-objects.asciidoc
Normal file
14
docs/spaces/moving-saved-objects.asciidoc
Normal file
|
@ -0,0 +1,14 @@
|
|||
[role="xpack"]
|
||||
[[spaces-moving-objects]]
|
||||
=== Moving saved objects between spaces
|
||||
You can use {kib}'s <<managing-saved-objects-export-objects, import/export>> 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 <<managing-saved-objects-export-objects, import/export>> interface.
|
||||
3. Navigate to the space you are importing to.
|
||||
4. Import your saved objects via the <<managing-saved-objects-export-objects, import/export>> 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 <<dashboard-import-api-import, import>> and <<dashboard-import-api-export, export>> dashboard APIs if you are looking for a dashboard-centric way to automate this process.
|
7
docs/spaces/securing-spaces.asciidoc
Normal file
7
docs/spaces/securing-spaces.asciidoc
Normal file
|
@ -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"]
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
@ -51,6 +51,7 @@ exports[`AdvancedSettings should render normally 1`] = `
|
|||
/>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
<advanced_settings_page_subtitle />
|
||||
<EuiSpacer
|
||||
size="m"
|
||||
/>
|
||||
|
@ -396,6 +397,7 @@ exports[`AdvancedSettings should render specific setting if given setting key 1`
|
|||
/>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
<advanced_settings_page_subtitle />
|
||||
<EuiSpacer
|
||||
size="m"
|
||||
/>
|
||||
|
|
|
@ -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 {
|
|||
/>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
<PageSubtitle />
|
||||
<EuiSpacer size="m" />
|
||||
<CallOuts />
|
||||
<EuiSpacer size="m" />
|
||||
|
|
|
@ -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);
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`PageSubtitle should render normally 1`] = `""`;
|
|
@ -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';
|
|
@ -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;
|
|
@ -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(<PageSubtitle />)).toMatchSnapshot();
|
||||
});
|
||||
});
|
|
@ -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
|
||||
|
|
|
@ -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),
|
||||
|
|
38
src/server/http/setup_base_path_provider.js
Normal file
38
src/server/http/setup_base_path_provider.js
Normal file
|
@ -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}`;
|
||||
});
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`1, 1 throws Error 1`] = `"Already have entry with this priority"`;
|
|
@ -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();
|
||||
});
|
44
src/server/saved_objects/service/lib/priority_collection.ts
Normal file
44
src/server/saved_objects/service/lib/priority_collection.ts
Normal file
|
@ -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<T> {
|
||||
priority: number;
|
||||
value: T;
|
||||
}
|
||||
|
||||
export class PriorityCollection<T> {
|
||||
private readonly array: Array<PriorityCollectionEntry<T>> = [];
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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' }
|
||||
|
|
|
@ -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
|
||||
|
|
1
src/ui/public/chrome/index.d.ts
vendored
1
src/ui/public/chrome/index.d.ts
vendored
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -19,6 +19,7 @@
|
|||
|
||||
jest.mock('ui/chrome',
|
||||
() => ({
|
||||
getBasePath: () => `/some/base/path`,
|
||||
getUiSettingsClient: () => {
|
||||
return {
|
||||
get: (key) => {
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
|
31
src/ui/public/persisted_log/create_log_key.js
Normal file
31
src/ui/public/persisted_log/create_log_key.js
Normal file
|
@ -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}`;
|
||||
}
|
35
src/ui/public/persisted_log/create_log_key.test.js
Normal file
35
src/ui/public/persisted_log/create_log_key.test.js
Normal file
|
@ -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');
|
||||
});
|
||||
});
|
|
@ -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 = [
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
|
|
|
@ -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':
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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', '');
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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())
|
||||
|
|
|
@ -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`,
|
||||
|
|
|
@ -201,4 +201,4 @@ describe('createOrUpgradeSavedConfig()', () => {
|
|||
'5.4.0-rc1': true,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -135,4 +135,4 @@ describe('uiSettings/createOrUpgradeSavedConfig', function () {
|
|||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -55,4 +55,4 @@ export async function createOrUpgradeSavedConfig(options) {
|
|||
attributes,
|
||||
{ id: version }
|
||||
);
|
||||
}
|
||||
}
|
|
@ -197,4 +197,4 @@ export class UiSettingsService {
|
|||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -49,4 +49,4 @@ export function uiSettingsServiceFactory(server, options) {
|
|||
overrides,
|
||||
log: (...args) => server.log(...args),
|
||||
});
|
||||
}
|
||||
}
|
Binary file not shown.
|
@ -188,6 +188,9 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"namespace": {
|
||||
"type": "keyword"
|
||||
},
|
||||
"type": {
|
||||
"type": "keyword"
|
||||
},
|
||||
|
@ -249,4 +252,4 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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/**/*"
|
||||
]
|
||||
}
|
||||
}
|
|
@ -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),
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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(
|
||||
<MemoryRouter initialEntries={[`${route}?_g=myG&kuery=myKuery`]}>
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 }; }
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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 () {
|
||||
|
|
|
@ -21,6 +21,7 @@ function createJobFn(server) {
|
|||
return {
|
||||
headers: serializedEncryptedHeaders,
|
||||
indexPatternSavedObject: indexPatternSavedObject,
|
||||
basePath: request.getBasePath(),
|
||||
...jobParams
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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 = {}) => {
|
||||
|
|
|
@ -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(),
|
||||
};
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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());
|
||||
|
|
|
@ -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
|
||||
});
|
||||
|
|
|
@ -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}`);
|
||||
});
|
||||
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 () => {
|
||||
|
|
|
@ -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'];
|
||||
|
|
|
@ -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;
|
||||
}
|
9
x-pack/plugins/security/common/model/kibana_privilege.ts
Normal file
9
x-pack/plugins/security/common/model/kibana_privilege.ts
Normal file
|
@ -0,0 +1,9 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* 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'];
|
29
x-pack/plugins/security/common/model/role.ts
Normal file
29
x-pack/plugins/security/common/model/role.ts
Normal file
|
@ -0,0 +1,29 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* 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;
|
||||
};
|
||||
}
|
|
@ -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);
|
||||
|
||||
const callWithRequestRepository = savedObjects.getSavedObjectsRepository(callCluster);
|
||||
|
||||
if (!xpackInfoFeature.getLicenseCheckResults().allowRbac) {
|
||||
return new savedObjects.SavedObjectsClient(callWithRequestRepository);
|
||||
if (authorization.mode.useRbacForRequest(request)) {
|
||||
const internalRepository = savedObjects.getSavedObjectsRepository(callWithInternalUser);
|
||||
return new savedObjects.SavedObjectsClient(internalRepository);
|
||||
}
|
||||
|
||||
const { authorization } = server.plugins.security;
|
||||
const checkPrivileges = authorization.checkPrivilegesWithRequest(request);
|
||||
const internalRepository = savedObjects.getSavedObjectsRepository(callWithInternalUser);
|
||||
const callWithRequestRepository = savedObjects.getSavedObjectsRepository(callCluster);
|
||||
return new savedObjects.SavedObjectsClient(callWithRequestRepository);
|
||||
});
|
||||
|
||||
return new SecureSavedObjectsClient({
|
||||
internalRepository,
|
||||
callWithRequestRepository,
|
||||
errors: savedObjects.SavedObjectsClient.errors,
|
||||
checkPrivileges,
|
||||
auditLogger,
|
||||
actions: authorization.actions,
|
||||
});
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
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 {
|
||||
|
|
|
@ -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`,
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
59
x-pack/plugins/security/public/lib/role.test.ts
Normal file
59
x-pack/plugins/security/public/lib/role.test.ts
Normal file
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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<Role>) {
|
||||
return get(role, 'transient_metadata.enabled', true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether given role is reserved or not.
|
||||
*
|
||||
* @param {role} the Role as returned by roles API
|
||||
*/
|
||||
export function isReservedRole(role: Partial<Role>) {
|
||||
return get(role, 'metadata._reserved', false);
|
||||
}
|
9
x-pack/plugins/security/public/objects/index.ts
Normal file
9
x-pack/plugins/security/public/objects/index.ts
Normal file
|
@ -0,0 +1,9 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* 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';
|
15
x-pack/plugins/security/public/objects/lib/get_fields.ts
Normal file
15
x-pack/plugins/security/public/objects/lib/get_fields.ts
Normal file
|
@ -0,0 +1,15 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* 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<string[]> {
|
||||
return await $http
|
||||
.get(`${apiBase}/${query}`)
|
||||
.then((response: IHttpResponse<string[]>) => response.data || []);
|
||||
}
|
19
x-pack/plugins/security/public/objects/lib/roles.ts
Normal file
19
x-pack/plugins/security/public/objects/lib/roles.ts
Normal file
|
@ -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}`);
|
||||
}
|
|
@ -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,
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
42
x-pack/plugins/security/public/services/role_privileges.js
Normal file
42
x-pack/plugins/security/public/services/role_privileges.js
Normal file
|
@ -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];
|
||||
}
|
|
@ -1,196 +0,0 @@
|
|||
<kbn-management-app section="security" omit-breadcrumb-pages="['edit']">
|
||||
<!-- This content gets injected below the localNav. -->
|
||||
<div class="kuiViewContent kuiViewContent--constrainedWidth kuiViewContentItem">
|
||||
|
||||
<!-- Subheader -->
|
||||
<div class="kuiBar kuiVerticalRhythm">
|
||||
|
||||
<div class="kuiBarSection">
|
||||
<!-- Title -->
|
||||
<h1 class="kuiTitle">
|
||||
<span ng-if="editRole.isNewRole">
|
||||
New Role
|
||||
</span>
|
||||
<span ng-if="!editRole.isNewRole">
|
||||
“{{ role.name }}” Role
|
||||
</span>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div class="kuiBarSection">
|
||||
<!-- Delete button -->
|
||||
<button
|
||||
ng-if="!editRole.isNewRole && !role.metadata._reserved"
|
||||
class="kuiButton kuiButton--danger kuiButton--iconText"
|
||||
ng-click="deleteRole(role)"
|
||||
tooltip="Delete Role"
|
||||
>
|
||||
<span class="kuiButton__inner">
|
||||
<span class="kuiButton__icon kuiIcon fa-trash"></span>
|
||||
<span>Delete role</span>
|
||||
</span>
|
||||
</button>
|
||||
<div
|
||||
ng-if="role.metadata._reserved"
|
||||
class="kuiBadge kuiBadge--default"
|
||||
tooltip="Reserved roles are built-in and cannot be removed or modified."
|
||||
>
|
||||
<span class="kuiIcon fa-lock"></span>
|
||||
Reserved
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="kuiBar kuiVerticalRhythm" ng-if="otherApplications.length > 0">
|
||||
<div class="kuiInfoPanel kuiInfoPanel--warning">
|
||||
<div class="kuiInfoPanelHeader">
|
||||
<span class="kuiInfoPanelHeader__icon kuiIcon kuiIcon--warning fa-warning"></span>
|
||||
<span class="kuiInfoPanelHeader__title">
|
||||
This role contains application privileges for the {{ otherApplications.join(', ') }} application(s) that can't be edited.
|
||||
If they are for other instances of Kibana, you must manage those privileges on that Kibana instance.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Form -->
|
||||
<form name="form" novalidate class="kuiVerticalRhythm">
|
||||
<!-- Name -->
|
||||
<div class="kuiFormSection">
|
||||
<label for="name" class="kuiFormLabel">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
class="fullWidth"
|
||||
ng-class="::editRole.isNewRole ? 'kuiTextInput' : 'kuiStaticInput'"
|
||||
ng-disabled="!editRole.isNewRole"
|
||||
id="name"
|
||||
name="name"
|
||||
ng-model="role.name"
|
||||
required
|
||||
pattern="[a-zA-Z_][a-zA-Z0-9_@\-\$\.]*"
|
||||
maxlength="1024"
|
||||
data-test-subj="roleFormNameInput"
|
||||
/>
|
||||
|
||||
<!-- Errors -->
|
||||
<div
|
||||
class="kuiInputNote kuiInputNote--danger"
|
||||
ng-show="form.name.$error.pattern"
|
||||
>
|
||||
Name must begin with a letter or underscore and contain only letters, underscores, and numbers.
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="kuiInputNote kuiInputNote--danger"
|
||||
ng-show="form.name.$touched && form.name.$error.required"
|
||||
>
|
||||
Name is required.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="kuiVerticalRhythm">
|
||||
<!-- Cluster privileges -->
|
||||
<div class="kuiFormSection">
|
||||
<label class="kuiFormLabel">
|
||||
Cluster Privileges
|
||||
</label>
|
||||
<div ng-repeat="privilege in privileges.cluster">
|
||||
<label>
|
||||
<input
|
||||
class="kuiCheckBox"
|
||||
type="checkbox"
|
||||
ng-checked="includes(role.elasticsearch.cluster, privilege)"
|
||||
ng-click="toggle(role.elasticsearch.cluster, privilege)"
|
||||
ng-disabled="role.metadata._reserved || !isRoleEnabled(role)"
|
||||
data-test-subj="clusterPrivileges-{{privilege}}"
|
||||
/>
|
||||
<span class="kuiOptionLabel">{{privilege}}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Kibana custom privileges -->
|
||||
<div class="kuiFormSection">
|
||||
<label class="kuiFormLabel">
|
||||
Kibana Privileges
|
||||
</label>
|
||||
|
||||
<div ng-repeat="(key, value) in kibanaPrivilegesViewModel">
|
||||
<label>
|
||||
<input
|
||||
class="kuiCheckBox"
|
||||
type="checkbox"
|
||||
ng-model="kibanaPrivilegesViewModel[key]"
|
||||
ng-disabled="role.metadata._reserved || !isRoleEnabled(role)"
|
||||
data-test-subj="kibanaPrivileges-{{key}}"
|
||||
/>
|
||||
<span class="kuiOptionLabel">{{key}}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Run-as privileges -->
|
||||
<div class="kuiFormSection">
|
||||
<label class="kuiFormLabel">
|
||||
Run As Privileges
|
||||
</label>
|
||||
<ui-select
|
||||
multiple
|
||||
ng-model="role.elasticsearch.run_as"
|
||||
ng-disabled="role.metadata._reserved || !isRoleEnabled(role)"
|
||||
>
|
||||
<ui-select-match placeholder="Add a user...">
|
||||
{{$item}}
|
||||
</ui-select-match>
|
||||
<ui-select-choices repeat="user as user in union([$select.search], users) | filter:$select.search">
|
||||
<div ng-bind-html="user"></div>
|
||||
</ui-select-choices>
|
||||
</ui-select>
|
||||
</div>
|
||||
|
||||
<!-- Index privileges -->
|
||||
<div class="kuiFormSection">
|
||||
<kbn-index-privileges-form
|
||||
is-new-role="editRole.isNewRole"
|
||||
indices="role.elasticsearch.indices"
|
||||
index-patterns="indexPatterns"
|
||||
privileges="privileges"
|
||||
field-options="editRole.fieldOptions"
|
||||
is-reserved="role.metadata._reserved"
|
||||
is-enabled="isRoleEnabled(role)"
|
||||
allow-document-level-security="allowDocumentLevelSecurity"
|
||||
allow-field-level-security="allowFieldLevelSecurity"
|
||||
add-index="addIndex(indices)"
|
||||
remove-index="toggle(indices, index)"
|
||||
></kbn-index-privileges-form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="kuiVerticalRhythm">
|
||||
<!-- Form actions -->
|
||||
<div class="kuiFormSection kuiFormFooter">
|
||||
<button
|
||||
class="kuiButton kuiButton--primary"
|
||||
ng-click="saveRole(role)"
|
||||
ng-if="!role.metadata._reserved && isRoleEnabled(role)"
|
||||
ng-disabled="form.$invalid || !areIndicesValid(role.elasticsearch.indices)"
|
||||
data-test-subj="roleFormSaveButton"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
|
||||
<a
|
||||
class="kuiButton kuiButton--basic"
|
||||
ng-if="!role.metadata._reserved"
|
||||
ng-href="{{rolesHref}}"
|
||||
data-test-subj="roleFormCancelButton"
|
||||
>
|
||||
Cancel
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</kbn-management-app>
|
|
@ -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);
|
||||
}
|
||||
});
|
|
@ -0,0 +1,58 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`it renders without blowing up 1`] = `
|
||||
<EuiPanel
|
||||
grow={true}
|
||||
hasShadow={false}
|
||||
paddingSize="m"
|
||||
>
|
||||
<EuiFlexGroup
|
||||
alignItems="baseline"
|
||||
component="div"
|
||||
direction="row"
|
||||
gutterSize="s"
|
||||
justifyContent="flexStart"
|
||||
responsive={false}
|
||||
wrap={false}
|
||||
>
|
||||
<EuiFlexItem
|
||||
component="div"
|
||||
grow={false}
|
||||
>
|
||||
<EuiTitle
|
||||
size="m"
|
||||
>
|
||||
<h2>
|
||||
<EuiIcon
|
||||
className="collapsiblePanel__logo"
|
||||
size="xl"
|
||||
type="logoElasticsearch"
|
||||
/>
|
||||
|
||||
Elasticsearch
|
||||
</h2>
|
||||
</EuiTitle>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem
|
||||
component="div"
|
||||
grow={false}
|
||||
>
|
||||
<EuiLink
|
||||
color="primary"
|
||||
onClick={[Function]}
|
||||
type="button"
|
||||
>
|
||||
hide
|
||||
</EuiLink>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
<React.Fragment>
|
||||
<EuiSpacer
|
||||
size="l"
|
||||
/>
|
||||
<p>
|
||||
child
|
||||
</p>
|
||||
</React.Fragment>
|
||||
</EuiPanel>
|
||||
`;
|
|
@ -0,0 +1,4 @@
|
|||
.collapsiblePanel__logo {
|
||||
margin-right: 8px;
|
||||
vertical-align: text-bottom;
|
||||
}
|
|
@ -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(
|
||||
<CollapsiblePanel iconType="logoElasticsearch" title="Elasticsearch">
|
||||
<p>child</p>
|
||||
</CollapsiblePanel>
|
||||
);
|
||||
|
||||
expect(wrapper).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('it renders children by default', () => {
|
||||
const wrapper = mount(
|
||||
<CollapsiblePanel iconType="logoElasticsearch" title="Elasticsearch">
|
||||
<p className="child">child 1</p>
|
||||
<p className="child">child 2</p>
|
||||
</CollapsiblePanel>
|
||||
);
|
||||
|
||||
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(
|
||||
<CollapsiblePanel iconType="logoElasticsearch" title="Elasticsearch">
|
||||
<p className="child">child 1</p>
|
||||
<p className="child">child 2</p>
|
||||
</CollapsiblePanel>
|
||||
);
|
||||
|
||||
expect(wrapper.find(CollapsiblePanel)).toHaveLength(1);
|
||||
expect(wrapper.find('.child')).toHaveLength(2);
|
||||
|
||||
wrapper.find(EuiLink).simulate('click');
|
||||
|
||||
expect(wrapper.find('.child')).toHaveLength(0);
|
||||
});
|
|
@ -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<Props, State> {
|
||||
public state = {
|
||||
collapsed: false,
|
||||
};
|
||||
|
||||
public render() {
|
||||
return (
|
||||
<EuiPanel>
|
||||
{this.getTitle()}
|
||||
{this.getForm()}
|
||||
</EuiPanel>
|
||||
);
|
||||
}
|
||||
|
||||
public getTitle = () => {
|
||||
return (
|
||||
// @ts-ignore
|
||||
<EuiFlexGroup alignItems={'baseline'} gutterSize="s" responsive={false}>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiTitle>
|
||||
<h2>
|
||||
<EuiIcon
|
||||
type={this.props.iconType}
|
||||
size={'xl'}
|
||||
className={'collapsiblePanel__logo'}
|
||||
/>{' '}
|
||||
{this.props.title}
|
||||
</h2>
|
||||
</EuiTitle>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiLink onClick={this.toggleCollapsed}>{this.state.collapsed ? 'show' : 'hide'}</EuiLink>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
};
|
||||
|
||||
public getForm = () => {
|
||||
if (this.state.collapsed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<EuiSpacer />
|
||||
{this.props.children}
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
public toggleCollapsed = () => {
|
||||
this.setState({
|
||||
collapsed: !this.state.collapsed,
|
||||
});
|
||||
};
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue