diff --git a/docs/api/saved-objects/bulk_create.asciidoc b/docs/api/saved-objects/bulk_create.asciidoc index 4f572b49ee5ff..5149cef3d30c6 100644 --- a/docs/api/saved-objects/bulk_create.asciidoc +++ b/docs/api/saved-objects/bulk_create.asciidoc @@ -41,6 +41,10 @@ experimental[] Create multiple {kib} saved objects. `references`:: (Optional, array) Objects with `name`, `id`, and `type` properties that describe the other saved objects in the referenced object. To refer to the other saved object, use `name` in the attributes. Never use `id` to refer to the other saved object. `id` can be automatically updated during migrations, import, or export. +`namespaces`:: + (Optional, string array) Identifiers for the <> in which this object should be created. If this is not provided, the + object will be created in the current space. + `version`:: (Optional, number) Specifies the version. diff --git a/docs/api/saved-objects/create.asciidoc b/docs/api/saved-objects/create.asciidoc index e6f3301bfea2b..c8cd9c8bfca27 100644 --- a/docs/api/saved-objects/create.asciidoc +++ b/docs/api/saved-objects/create.asciidoc @@ -46,6 +46,10 @@ any data that you send to the API is properly formed. `references`:: (Optional, array) Objects with `name`, `id`, and `type` properties that describe the other saved objects that this object references. Use `name` in attributes to refer to the other saved object, but never the `id`, which can update automatically during migrations or import/export. +`namespaces`:: + (Optional, string array) Identifiers for the <> in which this object should be created. If this is not provided, the + object will be created in the current space. + [[saved-objects-api-create-request-codes]] ==== Response code diff --git a/docs/api/saved-objects/delete.asciidoc b/docs/api/saved-objects/delete.asciidoc index af587b0e7af10..9c342cb4d843e 100644 --- a/docs/api/saved-objects/delete.asciidoc +++ b/docs/api/saved-objects/delete.asciidoc @@ -27,6 +27,14 @@ WARNING: Once you delete a saved object, _it cannot be recovered_. `id`:: (Required, string) The object ID that you want to remove. +[[saved-objects-api-delete-query-params]] +==== Query parameters + +`force`:: + (Optional, boolean) When true, forces an object to be deleted if it exists in multiple namespaces. ++ +TIP: Use this if you attempted to delete an object and received an HTTP 400 error with the following message: _"Unable to delete saved object that exists in multiple namespaces, use the `force` option to delete it anyway"_ + [[saved-objects-api-delete-response-codes]] ==== Response code diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsclient.delete.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsclient.delete.md index 3b5f5630e8060..3a5dcb51e2c42 100644 --- a/docs/development/core/public/kibana-plugin-core-public.savedobjectsclient.delete.md +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsclient.delete.md @@ -9,5 +9,5 @@ Deletes an object Signature: ```typescript -delete: (type: string, id: string) => ReturnType; +delete: (type: string, id: string, options?: SavedObjectsDeleteOptions | undefined) => ReturnType; ``` diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsclient.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsclient.md index 904b9cce09d4e..6e53b169b8bed 100644 --- a/docs/development/core/public/kibana-plugin-core-public.savedobjectsclient.md +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsclient.md @@ -23,7 +23,7 @@ The constructor for this class is marked as internal. Third-party code should no | [bulkCreate](./kibana-plugin-core-public.savedobjectsclient.bulkcreate.md) | | (objects?: SavedObjectsBulkCreateObject[], options?: SavedObjectsBulkCreateOptions) => Promise<SavedObjectsBatchResponse<unknown>> | Creates multiple documents at once | | [bulkGet](./kibana-plugin-core-public.savedobjectsclient.bulkget.md) | | (objects?: Array<{
id: string;
type: string;
}>) => Promise<SavedObjectsBatchResponse<unknown>> | Returns an array of objects by id | | [create](./kibana-plugin-core-public.savedobjectsclient.create.md) | | <T = unknown>(type: string, attributes: T, options?: SavedObjectsCreateOptions) => Promise<SimpleSavedObject<T>> | Persists an object | -| [delete](./kibana-plugin-core-public.savedobjectsclient.delete.md) | | (type: string, id: string) => ReturnType<SavedObjectsApi['delete']> | Deletes an object | +| [delete](./kibana-plugin-core-public.savedobjectsclient.delete.md) | | (type: string, id: string, options?: SavedObjectsDeleteOptions | undefined) => ReturnType<SavedObjectsApi['delete']> | Deletes an object | | [find](./kibana-plugin-core-public.savedobjectsclient.find.md) | | <T = unknown>(options: SavedObjectsFindOptions) => Promise<SavedObjectsFindResponsePublic<T>> | Search for objects | | [get](./kibana-plugin-core-public.savedobjectsclient.get.md) | | <T = unknown>(type: string, id: string) => Promise<SimpleSavedObject<T>> | Fetches a single object | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsbulkcreateobject.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsbulkcreateobject.md index 019d30570ab36..aabbfeeff75af 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsbulkcreateobject.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsbulkcreateobject.md @@ -18,6 +18,7 @@ export interface SavedObjectsBulkCreateObject | [attributes](./kibana-plugin-core-server.savedobjectsbulkcreateobject.attributes.md) | T | | | [id](./kibana-plugin-core-server.savedobjectsbulkcreateobject.id.md) | string | | | [migrationVersion](./kibana-plugin-core-server.savedobjectsbulkcreateobject.migrationversion.md) | SavedObjectsMigrationVersion | Information about the migrations that have been applied to this SavedObject. When Kibana starts up, KibanaMigrator detects outdated documents and migrates them based on this value. For each migration that has been applied, the plugin's name is used as a key and the latest migration version as the value. | +| [namespaces](./kibana-plugin-core-server.savedobjectsbulkcreateobject.namespaces.md) | string[] | Optional initial namespaces for the object to be created in. If this is defined, it will supersede the namespace ID that is in [SavedObjectsCreateOptions](./kibana-plugin-core-server.savedobjectscreateoptions.md).Note: this can only be used for multi-namespace object types. | | [originId](./kibana-plugin-core-server.savedobjectsbulkcreateobject.originid.md) | string | Optional ID of the original saved object, if this object's id was regenerated | | [references](./kibana-plugin-core-server.savedobjectsbulkcreateobject.references.md) | SavedObjectReference[] | | | [type](./kibana-plugin-core-server.savedobjectsbulkcreateobject.type.md) | string | | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsbulkcreateobject.namespaces.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsbulkcreateobject.namespaces.md new file mode 100644 index 0000000000000..7db1c53c67b52 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsbulkcreateobject.namespaces.md @@ -0,0 +1,15 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsBulkCreateObject](./kibana-plugin-core-server.savedobjectsbulkcreateobject.md) > [namespaces](./kibana-plugin-core-server.savedobjectsbulkcreateobject.namespaces.md) + +## SavedObjectsBulkCreateObject.namespaces property + +Optional initial namespaces for the object to be created in. If this is defined, it will supersede the namespace ID that is in [SavedObjectsCreateOptions](./kibana-plugin-core-server.savedobjectscreateoptions.md). + +Note: this can only be used for multi-namespace object types. + +Signature: + +```typescript +namespaces?: string[]; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectscreateoptions.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectscreateoptions.md index d936829443753..63aebf6c5e791 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectscreateoptions.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectscreateoptions.md @@ -17,6 +17,7 @@ export interface SavedObjectsCreateOptions extends SavedObjectsBaseOptions | --- | --- | --- | | [id](./kibana-plugin-core-server.savedobjectscreateoptions.id.md) | string | (not recommended) Specify an id for the document | | [migrationVersion](./kibana-plugin-core-server.savedobjectscreateoptions.migrationversion.md) | SavedObjectsMigrationVersion | Information about the migrations that have been applied to this SavedObject. When Kibana starts up, KibanaMigrator detects outdated documents and migrates them based on this value. For each migration that has been applied, the plugin's name is used as a key and the latest migration version as the value. | +| [namespaces](./kibana-plugin-core-server.savedobjectscreateoptions.namespaces.md) | string[] | Optional initial namespaces for the object to be created in. If this is defined, it will supersede the namespace ID that is in [SavedObjectsCreateOptions](./kibana-plugin-core-server.savedobjectscreateoptions.md).Note: this can only be used for multi-namespace object types. | | [originId](./kibana-plugin-core-server.savedobjectscreateoptions.originid.md) | string | Optional ID of the original saved object, if this object's id was regenerated | | [overwrite](./kibana-plugin-core-server.savedobjectscreateoptions.overwrite.md) | boolean | Overwrite existing documents (defaults to false) | | [references](./kibana-plugin-core-server.savedobjectscreateoptions.references.md) | SavedObjectReference[] | | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectscreateoptions.namespaces.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectscreateoptions.namespaces.md new file mode 100644 index 0000000000000..67804999dfd44 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectscreateoptions.namespaces.md @@ -0,0 +1,15 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsCreateOptions](./kibana-plugin-core-server.savedobjectscreateoptions.md) > [namespaces](./kibana-plugin-core-server.savedobjectscreateoptions.namespaces.md) + +## SavedObjectsCreateOptions.namespaces property + +Optional initial namespaces for the object to be created in. If this is defined, it will supersede the namespace ID that is in [SavedObjectsCreateOptions](./kibana-plugin-core-server.savedobjectscreateoptions.md). + +Note: this can only be used for multi-namespace object types. + +Signature: + +```typescript +namespaces?: string[]; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsdeleteoptions.force.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsdeleteoptions.force.md new file mode 100644 index 0000000000000..f869d1f863a9f --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsdeleteoptions.force.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsDeleteOptions](./kibana-plugin-core-server.savedobjectsdeleteoptions.md) > [force](./kibana-plugin-core-server.savedobjectsdeleteoptions.force.md) + +## SavedObjectsDeleteOptions.force property + +Force deletion of an object that exists in multiple namespaces + +Signature: + +```typescript +force?: boolean; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsdeleteoptions.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsdeleteoptions.md index 760c30edcdfb5..245819e44d37d 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsdeleteoptions.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsdeleteoptions.md @@ -15,5 +15,6 @@ export interface SavedObjectsDeleteOptions extends SavedObjectsBaseOptions | Property | Type | Description | | --- | --- | --- | +| [force](./kibana-plugin-core-server.savedobjectsdeleteoptions.force.md) | boolean | Force deletion of an object that exists in multiple namespaces | | [refresh](./kibana-plugin-core-server.savedobjectsdeleteoptions.refresh.md) | MutatingOperationRefreshSetting | The Elasticsearch Refresh setting for this operation | diff --git a/docs/user/dashboard/vega-reference.asciidoc b/docs/user/dashboard/vega-reference.asciidoc index 0bc77ab0a417e..6fd30690b988e 100644 --- a/docs/user/dashboard/vega-reference.asciidoc +++ b/docs/user/dashboard/vega-reference.asciidoc @@ -14,7 +14,7 @@ For additional *Vega* and *Vega-Lite* information, refer to the reference sectio * Automatic sizing * Default theme to match {kib} * Writing {es} queries using the time range and filters from dashboards -* Using the Elastic Map Service in Vega maps +* experimental[] Using the Elastic Map Service in Vega maps * Additional tooltip styling * Advanced setting to enable URL loading from any domain * Limited debugging support using the browser dev tools diff --git a/package.json b/package.json index 30d614aa43f7b..b2252e2bd264b 100644 --- a/package.json +++ b/package.json @@ -153,6 +153,7 @@ "chokidar": "^3.4.2", "color": "1.0.3", "commander": "^3.0.2", + "core-js": "^3.6.5", "cypress-promise": "^1.1.0", "deep-freeze-strict": "^1.1.1", "del": "^5.1.0", diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index 08491dc76cd27..f52d5b6fbd6a5 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -1030,8 +1030,9 @@ export class SavedObjectsClient { }>) => Promise>; bulkUpdate(objects?: SavedObjectsBulkUpdateObject[]): Promise>; create: (type: string, attributes: T, options?: SavedObjectsCreateOptions) => Promise>; + // Warning: (ae-forgotten-export) The symbol "SavedObjectsDeleteOptions" needs to be exported by the entry point index.d.ts // Warning: (ae-forgotten-export) The symbol "SavedObjectsClientContract" needs to be exported by the entry point index.d.ts - delete: (type: string, id: string) => ReturnType; + delete: (type: string, id: string, options?: SavedObjectsDeleteOptions | undefined) => ReturnType; // Warning: (ae-forgotten-export) The symbol "SavedObjectsFindOptions" needs to be exported by the entry point index.d.ts find: (options: SavedObjectsFindOptions_2) => Promise>; get: (type: string, id: string) => Promise>; diff --git a/src/core/public/saved_objects/saved_objects_client.test.ts b/src/core/public/saved_objects/saved_objects_client.test.ts index 20824af38af0f..fab651379ea6a 100644 --- a/src/core/public/saved_objects/saved_objects_client.test.ts +++ b/src/core/public/saved_objects/saved_objects_client.test.ts @@ -132,7 +132,9 @@ describe('SavedObjectsClient', () => { Object { "body": undefined, "method": "DELETE", - "query": undefined, + "query": Object { + "force": false, + }, }, ] `); diff --git a/src/core/public/saved_objects/saved_objects_client.ts b/src/core/public/saved_objects/saved_objects_client.ts index 6a10eb44d9ca4..beed3e6fe0a18 100644 --- a/src/core/public/saved_objects/saved_objects_client.ts +++ b/src/core/public/saved_objects/saved_objects_client.ts @@ -96,6 +96,12 @@ export interface SavedObjectsBatchResponse { savedObjects: Array>; } +/** @public */ +export interface SavedObjectsDeleteOptions { + /** Force deletion of an object that exists in multiple namespaces */ + force?: boolean; +} + /** * Return type of the Saved Objects `find()` method. * @@ -261,12 +267,20 @@ export class SavedObjectsClient { * @param id * @returns */ - public delete = (type: string, id: string): ReturnType => { + public delete = ( + type: string, + id: string, + options?: SavedObjectsDeleteOptions + ): ReturnType => { if (!type || !id) { return Promise.reject(new Error('requires type and id')); } - return this.savedObjectsFetch(this.getPath([type, id]), { method: 'DELETE' }); + const query = { + force: !!options?.force, + }; + + return this.savedObjectsFetch(this.getPath([type, id]), { method: 'DELETE', query }); }; /** diff --git a/src/core/server/saved_objects/routes/bulk_create.ts b/src/core/server/saved_objects/routes/bulk_create.ts index af1a7bd2af9b7..0f925d61ead98 100644 --- a/src/core/server/saved_objects/routes/bulk_create.ts +++ b/src/core/server/saved_objects/routes/bulk_create.ts @@ -44,6 +44,7 @@ export const registerBulkCreateRoute = (router: IRouter) => { }) ) ), + namespaces: schema.maybe(schema.arrayOf(schema.string(), { minSize: 1 })), }) ), }, diff --git a/src/core/server/saved_objects/routes/create.ts b/src/core/server/saved_objects/routes/create.ts index 6cf906a3b2895..191dbfaa0dbf1 100644 --- a/src/core/server/saved_objects/routes/create.ts +++ b/src/core/server/saved_objects/routes/create.ts @@ -44,15 +44,16 @@ export const registerCreateRoute = (router: IRouter) => { }) ) ), + namespaces: schema.maybe(schema.arrayOf(schema.string(), { minSize: 1 })), }), }, }, router.handleLegacyErrors(async (context, req, res) => { const { type, id } = req.params; const { overwrite } = req.query; - const { attributes, migrationVersion, references } = req.body; + const { attributes, migrationVersion, references, namespaces } = req.body; - const options = { id, overwrite, migrationVersion, references }; + const options = { id, overwrite, migrationVersion, references, namespaces }; const result = await context.core.savedObjects.client.create(type, attributes, options); return res.ok({ body: result }); }) diff --git a/src/core/server/saved_objects/routes/delete.ts b/src/core/server/saved_objects/routes/delete.ts index d119455336212..d99397d2a050c 100644 --- a/src/core/server/saved_objects/routes/delete.ts +++ b/src/core/server/saved_objects/routes/delete.ts @@ -29,11 +29,15 @@ export const registerDeleteRoute = (router: IRouter) => { type: schema.string(), id: schema.string(), }), + query: schema.object({ + force: schema.maybe(schema.boolean()), + }), }, }, router.handleLegacyErrors(async (context, req, res) => { const { type, id } = req.params; - const result = await context.core.savedObjects.client.delete(type, id); + const { force } = req.query; + const result = await context.core.savedObjects.client.delete(type, id, { force }); return res.ok({ body: result }); }) ); diff --git a/src/core/server/saved_objects/routes/integration_tests/delete.test.ts b/src/core/server/saved_objects/routes/integration_tests/delete.test.ts index a58f400ec3e1d..ff8642a34929f 100644 --- a/src/core/server/saved_objects/routes/integration_tests/delete.test.ts +++ b/src/core/server/saved_objects/routes/integration_tests/delete.test.ts @@ -58,6 +58,19 @@ describe('DELETE /api/saved_objects/{type}/{id}', () => { .delete('/api/saved_objects/index-pattern/logstash-*') .expect(200); - expect(savedObjectsClient.delete).toHaveBeenCalledWith('index-pattern', 'logstash-*'); + expect(savedObjectsClient.delete).toHaveBeenCalledWith('index-pattern', 'logstash-*', { + force: undefined, + }); + }); + + it('can specify `force` option', async () => { + await supertest(httpSetup.server.listener) + .delete('/api/saved_objects/index-pattern/logstash-*') + .query({ force: true }) + .expect(200); + + expect(savedObjectsClient.delete).toHaveBeenCalledWith('index-pattern', 'logstash-*', { + force: true, + }); }); }); diff --git a/src/core/server/saved_objects/service/lib/repository.test.js b/src/core/server/saved_objects/service/lib/repository.test.js index 0e72ad2fec06c..10c7f143e52b9 100644 --- a/src/core/server/saved_objects/service/lib/repository.test.js +++ b/src/core/server/saved_objects/service/lib/repository.test.js @@ -20,6 +20,7 @@ import { SavedObjectsRepository } from './repository'; import * as getSearchDslNS from './search_dsl/search_dsl'; import { SavedObjectsErrorHelpers } from './errors'; +import { ALL_NAMESPACES_STRING } from './utils'; import { SavedObjectsSerializer } from '../../serialization'; import { encodeHitVersion } from '../../version'; import { SavedObjectTypeRegistry } from '../../saved_objects_type_registry'; @@ -634,6 +635,32 @@ describe('SavedObjectsRepository', () => { await test(namespace); }); + it(`adds namespaces instead of namespace`, async () => { + const test = async (namespace) => { + const ns2 = 'bar-namespace'; + const ns3 = 'baz-namespace'; + const objects = [ + { ...obj1, type: MULTI_NAMESPACE_TYPE, namespaces: [ns2] }, + { ...obj2, type: MULTI_NAMESPACE_TYPE, namespaces: [ns3] }, + ]; + await bulkCreateSuccess(objects, { namespace, overwrite: true }); + const body = [ + expect.any(Object), + expect.objectContaining({ namespaces: [ns2] }), + expect.any(Object), + expect.objectContaining({ namespaces: [ns3] }), + ]; + expect(client.bulk).toHaveBeenCalledWith( + expect.objectContaining({ body }), + expect.anything() + ); + client.bulk.mockClear(); + client.mget.mockClear(); + }; + await test(undefined); + await test(namespace); + }); + it(`doesn't add namespaces to request body for any types that are not multi-namespace`, async () => { const test = async (namespace) => { const objects = [obj1, { ...obj2, type: NAMESPACE_AGNOSTIC_TYPE }]; @@ -725,6 +752,40 @@ describe('SavedObjectsRepository', () => { }); }; + it(`throws when options.namespace is '*'`, async () => { + await expect( + savedObjectsRepository.bulkCreate([obj3], { namespace: ALL_NAMESPACES_STRING }) + ).rejects.toThrowError(createBadRequestError('"options.namespace" cannot be "*"')); + }); + + it(`returns error when namespaces is used with a non-multi-namespace object`, async () => { + const test = async (objType) => { + const obj = { ...obj3, type: objType, namespaces: [] }; + await bulkCreateError( + obj, + undefined, + expectErrorResult( + obj, + createBadRequestError('"namespaces" can only be used on multi-namespace types') + ) + ); + }; + await test('dashboard'); + await test(NAMESPACE_AGNOSTIC_TYPE); + }); + + it(`throws when options.namespaces is used with a multi-namespace type and is empty`, async () => { + const obj = { ...obj3, type: MULTI_NAMESPACE_TYPE, namespaces: [] }; + await bulkCreateError( + obj, + undefined, + expectErrorResult( + obj, + createBadRequestError('"namespaces" must be a non-empty array of strings') + ) + ); + }); + it(`returns error when type is invalid`, async () => { const obj = { ...obj3, type: 'unknownType' }; await bulkCreateError(obj, undefined, expectErrorInvalidType(obj)); @@ -1042,6 +1103,13 @@ describe('SavedObjectsRepository', () => { }); }; + it(`throws when options.namespace is '*'`, async () => { + const obj = { type: 'dashboard', id: 'three' }; + await expect( + savedObjectsRepository.bulkGet([obj], { namespace: ALL_NAMESPACES_STRING }) + ).rejects.toThrowError(createBadRequestError('"options.namespace" cannot be "*"')); + }); + it(`returns error when type is invalid`, async () => { const obj = { type: 'unknownType', id: 'three' }; await bulkGetErrorInvalidType([obj1, obj, obj2]); @@ -1467,6 +1535,12 @@ describe('SavedObjectsRepository', () => { }); }; + it(`throws when options.namespace is '*'`, async () => { + await expect( + savedObjectsRepository.bulkUpdate([obj], { namespace: ALL_NAMESPACES_STRING }) + ).rejects.toThrowError(createBadRequestError('"options.namespace" cannot be "*"')); + }); + it(`returns error when type is invalid`, async () => { const _obj = { ...obj, type: 'unknownType' }; await bulkUpdateError(_obj, undefined, expectErrorNotFound(_obj)); @@ -1477,6 +1551,15 @@ describe('SavedObjectsRepository', () => { await bulkUpdateError(_obj, undefined, expectErrorNotFound(_obj)); }); + it(`returns error when object namespace is '*'`, async () => { + const _obj = { ...obj, namespace: '*' }; + await bulkUpdateError( + _obj, + undefined, + expectErrorResult(obj, createBadRequestError('"namespace" cannot be "*"')) + ); + }); + it(`returns error when ES is unable to find the document (mget)`, async () => { const _obj = { ...obj, type: MULTI_NAMESPACE_TYPE, found: false }; const mgetResponse = getMockMgetResponse([_obj]); @@ -1630,7 +1713,7 @@ describe('SavedObjectsRepository', () => { ); }; - describe('cluster calls', () => { + describe('client calls', () => { it(`doesn't make a cluster call if the objects array is empty`, async () => { await checkConflicts([]); expect(client.mget).not.toHaveBeenCalled(); @@ -1662,6 +1745,14 @@ describe('SavedObjectsRepository', () => { }); }); + describe('errors', () => { + it(`throws when options.namespace is '*'`, async () => { + await expect( + savedObjectsRepository.checkConflicts([obj1], { namespace: ALL_NAMESPACES_STRING }) + ).rejects.toThrowError(createBadRequestError('"options.namespace" cannot be "*"')); + }); + }); + describe('returns', () => { it(`expected results`, async () => { const unknownTypeObj = { type: 'unknownType', id: 'three' }; @@ -1858,21 +1949,23 @@ describe('SavedObjectsRepository', () => { ); }); - it(`prepends namespace to the id when providing namespace for single-namespace type`, async () => { + it(`prepends namespace to the id and adds namespace to the body when providing namespace for single-namespace type`, async () => { await createSuccess(type, attributes, { id, namespace }); expect(client.create).toHaveBeenCalledWith( expect.objectContaining({ id: `${namespace}:${type}:${id}`, + body: expect.objectContaining({ namespace }), }), expect.anything() ); }); - it(`doesn't prepend namespace to the id when providing no namespace for single-namespace type`, async () => { + it(`doesn't prepend namespace to the id or add namespace to the body when providing no namespace for single-namespace type`, async () => { await createSuccess(type, attributes, { id }); expect(client.create).toHaveBeenCalledWith( expect.objectContaining({ id: `${type}:${id}`, + body: expect.not.objectContaining({ namespace: expect.anything() }), }), expect.anything() ); @@ -1883,25 +1976,44 @@ describe('SavedObjectsRepository', () => { expect(client.create).toHaveBeenCalledWith( expect.objectContaining({ id: `${type}:${id}`, + body: expect.not.objectContaining({ namespace: expect.anything() }), }), expect.anything() ); }); - it(`doesn't prepend namespace to the id when not using single-namespace type`, async () => { - await createSuccess(NAMESPACE_AGNOSTIC_TYPE, attributes, { id, namespace }); + it(`doesn't prepend namespace to the id and adds namespaces to body when using multi-namespace type`, async () => { + await createSuccess(MULTI_NAMESPACE_TYPE, attributes, { id, namespace }); expect(client.create).toHaveBeenCalledWith( expect.objectContaining({ - id: `${NAMESPACE_AGNOSTIC_TYPE}:${id}`, + id: `${MULTI_NAMESPACE_TYPE}:${id}`, + body: expect.objectContaining({ namespaces: [namespace] }), }), expect.anything() ); - client.create.mockClear(); + }); - await createSuccess(MULTI_NAMESPACE_TYPE, attributes, { id, namespace }); + it(`adds namespaces instead of namespace`, async () => { + const options = { id, namespace, namespaces: ['bar-namespace', 'baz-namespace'] }; + await createSuccess(MULTI_NAMESPACE_TYPE, attributes, options); expect(client.create).toHaveBeenCalledWith( expect.objectContaining({ id: `${MULTI_NAMESPACE_TYPE}:${id}`, + body: expect.objectContaining({ namespaces: options.namespaces }), + }), + expect.anything() + ); + }); + + it(`doesn't prepend namespace to the id or add namespace or namespaces fields when using namespace-agnostic type`, async () => { + await createSuccess(NAMESPACE_AGNOSTIC_TYPE, attributes, { id, namespace }); + expect(client.create).toHaveBeenCalledWith( + expect.objectContaining({ + id: `${NAMESPACE_AGNOSTIC_TYPE}:${id}`, + body: expect.not.objectContaining({ + namespace: expect.anything(), + namespaces: expect.anything(), + }), }), expect.anything() ); @@ -1909,6 +2021,32 @@ describe('SavedObjectsRepository', () => { }); describe('errors', () => { + it(`throws when options.namespaces is used with a non-multi-namespace object`, async () => { + const test = async (objType) => { + await expect( + savedObjectsRepository.create(objType, attributes, { namespaces: [namespace] }) + ).rejects.toThrowError( + createBadRequestError('"options.namespaces" can only be used on multi-namespace types') + ); + }; + await test('dashboard'); + await test(NAMESPACE_AGNOSTIC_TYPE); + }); + + it(`throws when options.namespaces is used with a multi-namespace type and is empty`, async () => { + await expect( + savedObjectsRepository.create(MULTI_NAMESPACE_TYPE, attributes, { namespaces: [] }) + ).rejects.toThrowError( + createBadRequestError('"options.namespaces" must be a non-empty array of strings') + ); + }); + + it(`throws when options.namespace is '*'`, async () => { + await expect( + savedObjectsRepository.create(type, attributes, { namespace: ALL_NAMESPACES_STRING }) + ).rejects.toThrowError(createBadRequestError('"options.namespace" cannot be "*"')); + }); + it(`throws when type is invalid`, async () => { await expect(savedObjectsRepository.create('unknownType', attributes)).rejects.toThrowError( createUnsupportedTypeError('unknownType') @@ -2043,31 +2181,17 @@ describe('SavedObjectsRepository', () => { describe('client calls', () => { it(`should use the ES delete action when not using a multi-namespace type`, async () => { await deleteSuccess(type, id); + expect(client.get).not.toHaveBeenCalled(); expect(client.delete).toHaveBeenCalledTimes(1); }); - it(`should use ES get action then delete action when using a multi-namespace type with no namespaces remaining`, async () => { + it(`should use ES get action then delete action when using a multi-namespace type`, async () => { await deleteSuccess(MULTI_NAMESPACE_TYPE, id); expect(client.get).toHaveBeenCalledTimes(1); expect(client.delete).toHaveBeenCalledTimes(1); }); - it(`should use ES get action then update action when using a multi-namespace type with one or more namespaces remaining`, async () => { - const mockResponse = getMockGetResponse({ type: MULTI_NAMESPACE_TYPE, id }); - mockResponse._source.namespaces = ['default', 'some-other-nameespace']; - client.get.mockResolvedValueOnce( - elasticsearchClientMock.createSuccessTransportRequestPromise(mockResponse) - ); - client.update.mockResolvedValueOnce( - elasticsearchClientMock.createSuccessTransportRequestPromise({ result: 'updated' }) - ); - - await savedObjectsRepository.delete(MULTI_NAMESPACE_TYPE, id); - expect(client.get).toHaveBeenCalledTimes(1); - expect(client.update).toHaveBeenCalledTimes(1); - }); - - it(`includes the version of the existing document when type is multi-namespace`, async () => { + it(`includes the version of the existing document when using a multi-namespace type`, async () => { await deleteSuccess(MULTI_NAMESPACE_TYPE, id); const versionProperties = { if_seq_no: mockVersionProps._seq_no, @@ -2134,6 +2258,12 @@ describe('SavedObjectsRepository', () => { ); }; + it(`throws when options.namespace is '*'`, async () => { + await expect( + savedObjectsRepository.delete(type, id, { namespace: ALL_NAMESPACES_STRING }) + ).rejects.toThrowError(createBadRequestError('"options.namespace" cannot be "*"')); + }); + it(`throws when type is invalid`, async () => { await expectNotFoundError('unknownType', id); expect(client.delete).not.toHaveBeenCalled(); @@ -2169,19 +2299,32 @@ describe('SavedObjectsRepository', () => { expect(client.get).toHaveBeenCalledTimes(1); }); - it(`throws when ES is unable to find the document during update`, async () => { - const mockResponse = getMockGetResponse({ type: MULTI_NAMESPACE_TYPE, id }); - mockResponse._source.namespaces = ['default', 'some-other-nameespace']; + it(`throws when the type is multi-namespace and the document has multiple namespaces and the force option is not enabled`, async () => { + const response = getMockGetResponse({ type: MULTI_NAMESPACE_TYPE, id, namespace }); + response._source.namespaces = [namespace, 'bar-namespace']; client.get.mockResolvedValueOnce( - elasticsearchClientMock.createSuccessTransportRequestPromise(mockResponse) + elasticsearchClientMock.createSuccessTransportRequestPromise(response) ); - client.update.mockResolvedValueOnce( - elasticsearchClientMock.createSuccessTransportRequestPromise({}, { statusCode: 404 }) + await expect( + savedObjectsRepository.delete(MULTI_NAMESPACE_TYPE, id, { namespace }) + ).rejects.toThrowError( + 'Unable to delete saved object that exists in multiple namespaces, use the `force` option to delete it anyway' ); + expect(client.get).toHaveBeenCalledTimes(1); + }); - await expectNotFoundError(MULTI_NAMESPACE_TYPE, id); + it(`throws when the type is multi-namespace and the document has all namespaces and the force option is not enabled`, async () => { + const response = getMockGetResponse({ type: MULTI_NAMESPACE_TYPE, id, namespace }); + response._source.namespaces = ['*']; + client.get.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise(response) + ); + await expect( + savedObjectsRepository.delete(MULTI_NAMESPACE_TYPE, id, { namespace }) + ).rejects.toThrowError( + 'Unable to delete saved object that exists in multiple namespaces, use the `force` option to delete it anyway' + ); expect(client.get).toHaveBeenCalledTimes(1); - expect(client.update).toHaveBeenCalledTimes(1); }); it(`throws when ES is unable to find the document during delete`, async () => { @@ -2267,7 +2410,7 @@ describe('SavedObjectsRepository', () => { }); describe('errors', () => { - it(`throws when namespace is not a string`, async () => { + it(`throws when namespace is not a string or is '*'`, async () => { const test = async (namespace) => { await expect(savedObjectsRepository.deleteByNamespace(namespace)).rejects.toThrowError( `namespace is required, and must be a string` @@ -2278,6 +2421,7 @@ describe('SavedObjectsRepository', () => { await test(['namespace']); await test(123); await test(true); + await test(ALL_NAMESPACES_STRING); }); }); @@ -2876,6 +3020,12 @@ describe('SavedObjectsRepository', () => { ); }; + it(`throws when options.namespace is '*'`, async () => { + await expect( + savedObjectsRepository.get(type, id, { namespace: ALL_NAMESPACES_STRING }) + ).rejects.toThrowError(createBadRequestError('"options.namespace" cannot be "*"')); + }); + it(`throws when type is invalid`, async () => { await expectNotFoundError('unknownType', id); expect(client.get).not.toHaveBeenCalled(); @@ -3067,6 +3217,14 @@ describe('SavedObjectsRepository', () => { ); }; + it(`throws when options.namespace is '*'`, async () => { + await expect( + savedObjectsRepository.incrementCounter(type, id, field, { + namespace: ALL_NAMESPACES_STRING, + }) + ).rejects.toThrowError(createBadRequestError('"options.namespace" cannot be "*"')); + }); + it(`throws when type is not a string`, async () => { const test = async (type) => { await expect( @@ -3723,6 +3881,12 @@ describe('SavedObjectsRepository', () => { ); }; + it(`throws when options.namespace is '*'`, async () => { + await expect( + savedObjectsRepository.update(type, id, attributes, { namespace: ALL_NAMESPACES_STRING }) + ).rejects.toThrowError(createBadRequestError('"options.namespace" cannot be "*"')); + }); + it(`throws when type is invalid`, async () => { await expectNotFoundError('unknownType', id); expect(client.update).not.toHaveBeenCalled(); diff --git a/src/core/server/saved_objects/service/lib/repository.ts b/src/core/server/saved_objects/service/lib/repository.ts index a83c86e585628..bae96ceec2783 100644 --- a/src/core/server/saved_objects/service/lib/repository.ts +++ b/src/core/server/saved_objects/service/lib/repository.ts @@ -67,7 +67,12 @@ import { } from '../../types'; import { SavedObjectTypeRegistry } from '../../saved_objects_type_registry'; import { validateConvertFilterToKueryNode } from './filter_utils'; -import { FIND_DEFAULT_PAGE, FIND_DEFAULT_PER_PAGE, SavedObjectsUtils } from './utils'; +import { + ALL_NAMESPACES_STRING, + FIND_DEFAULT_PAGE, + FIND_DEFAULT_PER_PAGE, + SavedObjectsUtils, +} from './utils'; // BEWARE: The SavedObjectClient depends on the implementation details of the SavedObjectsRepository // so any breaking changes to this repository are considered breaking changes to the SavedObjectsClient. @@ -225,10 +230,23 @@ export class SavedObjectsRepository { references = [], refresh = DEFAULT_REFRESH_SETTING, originId, + namespaces, version, } = options; const namespace = normalizeNamespace(options.namespace); + if (namespaces) { + if (!this._registry.isMultiNamespace(type)) { + throw SavedObjectsErrorHelpers.createBadRequestError( + '"options.namespaces" can only be used on multi-namespace types' + ); + } else if (!namespaces.length) { + throw SavedObjectsErrorHelpers.createBadRequestError( + '"options.namespaces" must be a non-empty array of strings' + ); + } + } + if (!this._allowedTypes.includes(type)) { throw SavedObjectsErrorHelpers.createUnsupportedTypeError(type); } @@ -242,9 +260,11 @@ export class SavedObjectsRepository { } else if (this._registry.isMultiNamespace(type)) { if (id && overwrite) { // we will overwrite a multi-namespace saved object if it exists; if that happens, ensure we preserve its included namespaces - savedObjectNamespaces = await this.preflightGetNamespaces(type, id, namespace); + // note: this check throws an error if the object is found but does not exist in this namespace + const existingNamespaces = await this.preflightGetNamespaces(type, id, namespace); + savedObjectNamespaces = namespaces || existingNamespaces; } else { - savedObjectNamespaces = getSavedObjectNamespaces(namespace); + savedObjectNamespaces = namespaces || getSavedObjectNamespaces(namespace); } } @@ -300,14 +320,25 @@ export class SavedObjectsRepository { let bulkGetRequestIndexCounter = 0; const expectedResults: Either[] = objects.map((object) => { + let error: DecoratedError | undefined; if (!this._allowedTypes.includes(object.type)) { + error = SavedObjectsErrorHelpers.createUnsupportedTypeError(object.type); + } else if (object.namespaces) { + if (!this._registry.isMultiNamespace(object.type)) { + error = SavedObjectsErrorHelpers.createBadRequestError( + '"namespaces" can only be used on multi-namespace types' + ); + } else if (!object.namespaces.length) { + error = SavedObjectsErrorHelpers.createBadRequestError( + '"namespaces" must be a non-empty array of strings' + ); + } + } + + if (error) { return { tag: 'Left' as 'Left', - error: { - id: object.id, - type: object.type, - error: errorContent(SavedObjectsErrorHelpers.createUnsupportedTypeError(object.type)), - }, + error: { id: object.id, type: object.type, error: errorContent(error) }, }; } @@ -357,7 +388,7 @@ export class SavedObjectsRepository { let versionProperties; const { esRequestIndex, - object: { version, ...object }, + object: { namespaces, version, ...object }, method, } = expectedBulkGetResult.value; if (esRequestIndex !== undefined) { @@ -378,13 +409,14 @@ export class SavedObjectsRepository { }, }; } - savedObjectNamespaces = getSavedObjectNamespaces(namespace, docFound && actualResult); + savedObjectNamespaces = + namespaces || getSavedObjectNamespaces(namespace, docFound && actualResult); versionProperties = getExpectedVersionProperties(version, actualResult); } else { if (this._registry.isSingleNamespace(object.type)) { savedObjectNamespace = namespace; } else if (this._registry.isMultiNamespace(object.type)) { - savedObjectNamespaces = getSavedObjectNamespaces(namespace); + savedObjectNamespaces = namespaces || getSavedObjectNamespaces(namespace); } versionProperties = getExpectedVersionProperties(version); } @@ -553,7 +585,7 @@ export class SavedObjectsRepository { throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); } - const { refresh = DEFAULT_REFRESH_SETTING } = options; + const { refresh = DEFAULT_REFRESH_SETTING, force } = options; const namespace = normalizeNamespace(options.namespace); const rawId = this._serializer.generateRawId(namespace, type, id); @@ -561,38 +593,14 @@ export class SavedObjectsRepository { if (this._registry.isMultiNamespace(type)) { preflightResult = await this.preflightCheckIncludesNamespace(type, id, namespace); - const existingNamespaces = getSavedObjectNamespaces(undefined, preflightResult); - const remainingNamespaces = existingNamespaces?.filter( - (x) => x !== SavedObjectsUtils.namespaceIdToString(namespace) - ); - - if (remainingNamespaces?.length) { - // if there is 1 or more namespace remaining, update the saved object - const time = this._getCurrentTime(); - - const doc = { - updated_at: time, - namespaces: remainingNamespaces, - }; - - const { statusCode } = await this.client.update( - { - id: rawId, - index: this.getIndexForType(type), - ...getExpectedVersionProperties(undefined, preflightResult), - refresh, - body: { - doc, - }, - }, - { ignore: [404] } + const existingNamespaces = getSavedObjectNamespaces(undefined, preflightResult) ?? []; + if ( + !force && + (existingNamespaces.length > 1 || existingNamespaces.includes(ALL_NAMESPACES_STRING)) + ) { + throw SavedObjectsErrorHelpers.createBadRequestError( + 'Unable to delete saved object that exists in multiple namespaces, use the `force` option to delete it anyway' ); - - if (statusCode === 404) { - // see "404s from missing index" above - throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); - } - return {}; } } @@ -637,8 +645,8 @@ export class SavedObjectsRepository { namespace: string, options: SavedObjectsDeleteByNamespaceOptions = {} ): Promise { - if (!namespace || typeof namespace !== 'string') { - throw new TypeError(`namespace is required, and must be a string`); + if (!namespace || typeof namespace !== 'string' || namespace === '*') { + throw new TypeError(`namespace is required, and must be a string that is not equal to '*'`); } const allTypes = Object.keys(getRootPropertiesObjects(this._mappings)); @@ -1253,6 +1261,19 @@ export class SavedObjectsRepository { } const { attributes, references, version, namespace: objectNamespace } = object; + + if (objectNamespace === ALL_NAMESPACES_STRING) { + return { + tag: 'Left' as 'Left', + error: { + id, + type, + error: errorContent( + SavedObjectsErrorHelpers.createBadRequestError('"namespace" cannot be "*"') + ), + }, + }; + } // `objectNamespace` is a namespace string, while `namespace` is a namespace ID. // The object namespace string, if defined, will supersede the operation's namespace ID. @@ -1568,7 +1589,10 @@ export class SavedObjectsRepository { } const namespaces = raw._source.namespaces; - return namespaces?.includes(SavedObjectsUtils.namespaceIdToString(namespace)) ?? false; + const existsInNamespace = + namespaces?.includes(SavedObjectsUtils.namespaceIdToString(namespace)) || + namespaces?.includes('*'); + return existsInNamespace ?? false; } /** @@ -1695,8 +1719,15 @@ function getSavedObjectNamespaces( * Ensure that a namespace is always in its namespace ID representation. * This allows `'default'` to be used interchangeably with `undefined`. */ -const normalizeNamespace = (namespace?: string) => - namespace === undefined ? namespace : SavedObjectsUtils.namespaceStringToId(namespace); +const normalizeNamespace = (namespace?: string) => { + if (namespace === ALL_NAMESPACES_STRING) { + throw SavedObjectsErrorHelpers.createBadRequestError('"options.namespace" cannot be "*"'); + } else if (namespace === undefined) { + return namespace; + } else { + return SavedObjectsUtils.namespaceStringToId(namespace); + } +}; /** * Extracts the contents of a decorated error to return the attributes for bulk operations. diff --git a/src/core/server/saved_objects/service/lib/search_dsl/query_params.test.ts b/src/core/server/saved_objects/service/lib/search_dsl/query_params.test.ts index e13c67a720400..330fa5066051f 100644 --- a/src/core/server/saved_objects/service/lib/search_dsl/query_params.test.ts +++ b/src/core/server/saved_objects/service/lib/search_dsl/query_params.test.ts @@ -22,6 +22,7 @@ import { esKuery } from '../../../es_query'; type KueryNode = any; import { typeRegistryMock } from '../../../saved_objects_type_registry.mock'; +import { ALL_NAMESPACES_STRING } from '../utils'; import { getQueryParams } from './query_params'; const registry = typeRegistryMock.create(); @@ -52,9 +53,10 @@ const ALL_TYPE_SUBSETS = ALL_TYPES.reduce( const createTypeClause = (type: string, namespaces?: string[]) => { if (registry.isMultiNamespace(type)) { + const array = [...(namespaces ?? ['default']), ALL_NAMESPACES_STRING]; return { bool: { - must: expect.arrayContaining([{ terms: { namespaces: namespaces ?? ['default'] } }]), + must: expect.arrayContaining([{ terms: { namespaces: array } }]), must_not: [{ exists: { field: 'namespace' } }], }, }; diff --git a/src/core/server/saved_objects/service/lib/search_dsl/query_params.ts b/src/core/server/saved_objects/service/lib/search_dsl/query_params.ts index eaddc05fa921c..8bd9c7d8312ee 100644 --- a/src/core/server/saved_objects/service/lib/search_dsl/query_params.ts +++ b/src/core/server/saved_objects/service/lib/search_dsl/query_params.ts @@ -22,7 +22,7 @@ type KueryNode = any; import { getRootPropertiesObjects, IndexMapping } from '../../../mappings'; import { ISavedObjectTypeRegistry } from '../../../saved_objects_type_registry'; -import { DEFAULT_NAMESPACE_STRING } from '../utils'; +import { ALL_NAMESPACES_STRING, DEFAULT_NAMESPACE_STRING } from '../utils'; /** * Gets the types based on the type. Uses mappings to support @@ -84,7 +84,10 @@ function getClauseForType( if (registry.isMultiNamespace(type)) { return { bool: { - must: [{ term: { type } }, { terms: { namespaces } }], + must: [ + { term: { type } }, + { terms: { namespaces: [...namespaces, ALL_NAMESPACES_STRING] } }, + ], must_not: [{ exists: { field: 'namespace' } }], }, }; diff --git a/src/core/server/saved_objects/service/lib/utils.ts b/src/core/server/saved_objects/service/lib/utils.ts index 3efe8614da1d7..69abc37089218 100644 --- a/src/core/server/saved_objects/service/lib/utils.ts +++ b/src/core/server/saved_objects/service/lib/utils.ts @@ -21,6 +21,7 @@ import { SavedObjectsFindOptions } from '../../types'; import { SavedObjectsFindResponse } from '..'; export const DEFAULT_NAMESPACE_STRING = 'default'; +export const ALL_NAMESPACES_STRING = '*'; export const FIND_DEFAULT_PAGE = 1; export const FIND_DEFAULT_PER_PAGE = 20; diff --git a/src/core/server/saved_objects/service/saved_objects_client.ts b/src/core/server/saved_objects/service/saved_objects_client.ts index 8c96116de49cb..d2b3b89b928c7 100644 --- a/src/core/server/saved_objects/service/saved_objects_client.ts +++ b/src/core/server/saved_objects/service/saved_objects_client.ts @@ -50,6 +50,13 @@ export interface SavedObjectsCreateOptions extends SavedObjectsBaseOptions { refresh?: MutatingOperationRefreshSetting; /** Optional ID of the original saved object, if this object's `id` was regenerated */ originId?: string; + /** + * Optional initial namespaces for the object to be created in. If this is defined, it will supersede the namespace ID that is in + * {@link SavedObjectsCreateOptions}. + * + * Note: this can only be used for multi-namespace object types. + */ + namespaces?: string[]; } /** @@ -66,6 +73,13 @@ export interface SavedObjectsBulkCreateObject { migrationVersion?: SavedObjectsMigrationVersion; /** Optional ID of the original saved object, if this object's `id` was regenerated */ originId?: string; + /** + * Optional initial namespaces for the object to be created in. If this is defined, it will supersede the namespace ID that is in + * {@link SavedObjectsCreateOptions}. + * + * Note: this can only be used for multi-namespace object types. + */ + namespaces?: string[]; } /** @@ -211,6 +225,8 @@ export interface SavedObjectsBulkUpdateOptions extends SavedObjectsBaseOptions { export interface SavedObjectsDeleteOptions extends SavedObjectsBaseOptions { /** The Elasticsearch Refresh setting for this operation */ refresh?: MutatingOperationRefreshSetting; + /** Force deletion of an object that exists in multiple namespaces */ + force?: boolean; } /** diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index 450be3b0e9a6c..7742dad150cfa 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -1820,6 +1820,7 @@ export interface SavedObjectsBulkCreateObject { // (undocumented) id?: string; migrationVersion?: SavedObjectsMigrationVersion; + namespaces?: string[]; originId?: string; // (undocumented) references?: SavedObjectReference[]; @@ -1977,6 +1978,7 @@ export interface SavedObjectsCoreFieldMapping { export interface SavedObjectsCreateOptions extends SavedObjectsBaseOptions { id?: string; migrationVersion?: SavedObjectsMigrationVersion; + namespaces?: string[]; originId?: string; overwrite?: boolean; // (undocumented) @@ -2002,6 +2004,7 @@ export interface SavedObjectsDeleteFromNamespacesResponse { // @public (undocumented) export interface SavedObjectsDeleteOptions extends SavedObjectsBaseOptions { + force?: boolean; refresh?: MutatingOperationRefreshSetting; } diff --git a/src/plugins/region_map/public/region_map_type.js b/src/plugins/region_map/public/region_map_type.js index 4cd30d32698ed..ec32d582ce15b 100644 --- a/src/plugins/region_map/public/region_map_type.js +++ b/src/plugins/region_map/public/region_map_type.js @@ -32,7 +32,7 @@ export function createRegionMapTypeDefinition(dependencies) { return { name: 'region_map', - getDeprecationMessage, + getInfoMessage: getDeprecationMessage, title: i18n.translate('regionMap.mapVis.regionMapTitle', { defaultMessage: 'Region Map' }), description: i18n.translate('regionMap.mapVis.regionMapDescription', { defaultMessage: diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.test.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.test.tsx index 1bc3dc8066520..0adf55ed6bebb 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.test.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.test.tsx @@ -544,11 +544,13 @@ describe('SavedObjectsTable', () => { expect(mockSavedObjectsClient.bulkGet).toHaveBeenCalledWith(mockSelectedSavedObjects); expect(mockSavedObjectsClient.delete).toHaveBeenCalledWith( mockSavedObjects[0].type, - mockSavedObjects[0].id + mockSavedObjects[0].id, + { force: true } ); expect(mockSavedObjectsClient.delete).toHaveBeenCalledWith( mockSavedObjects[1].type, - mockSavedObjects[1].id + mockSavedObjects[1].id, + { force: true } ); expect(component.state('selectedSavedObjects').length).toBe(0); }); diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.tsx index d879a71cc2269..5011c0299abe8 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.tsx @@ -468,7 +468,7 @@ export class SavedObjectsTable extends Component - savedObjectsClient.delete(object.type, object.id) + savedObjectsClient.delete(object.type, object.id, { force: true }) ); await Promise.all(deletes); diff --git a/src/plugins/tile_map/public/tile_map_type.js b/src/plugins/tile_map/public/tile_map_type.js index cc19a8bbcef91..411eaa96d8bfe 100644 --- a/src/plugins/tile_map/public/tile_map_type.js +++ b/src/plugins/tile_map/public/tile_map_type.js @@ -33,7 +33,7 @@ export function createTileMapTypeDefinition(dependencies) { return { name: 'tile_map', - getDeprecationMessage, + getInfoMessage: getDeprecationMessage, title: i18n.translate('tileMap.vis.mapTitle', { defaultMessage: 'Coordinate Map', }), diff --git a/src/plugins/vis_type_vega/public/components/experimental_map_vis_info.tsx b/src/plugins/vis_type_vega/public/components/experimental_map_vis_info.tsx new file mode 100644 index 0000000000000..4f8bc50bb1b3b --- /dev/null +++ b/src/plugins/vis_type_vega/public/components/experimental_map_vis_info.tsx @@ -0,0 +1,71 @@ +/* + * 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 { parse } from 'hjson'; +import React from 'react'; +import { EuiCallOut, EuiLink } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { Vis } from '../../../visualizations/public'; + +function ExperimentalMapLayerInfo() { + const title = ( + + GitHub + + ), + }} + /> + ); + + return ( + + ); +} + +export const getInfoMessage = (vis: Vis) => { + if (vis.params.spec) { + try { + const spec = parse(vis.params.spec, { legacyRoot: false, keepWsc: true }); + + if (spec.config?.kibana?.type === 'map') { + return ; + } + } catch (e) { + // spec is invalid + } + } + + return null; +}; diff --git a/src/plugins/vis_type_vega/public/vega_type.ts b/src/plugins/vis_type_vega/public/vega_type.ts index 46fd2fbc5587e..a9651c1f5eb33 100644 --- a/src/plugins/vis_type_vega/public/vega_type.ts +++ b/src/plugins/vis_type_vega/public/vega_type.ts @@ -29,6 +29,8 @@ import { getDefaultSpec } from './default_spec'; import { createInspectorAdapters } from './vega_inspector'; import { VIS_EVENT_TO_TRIGGER } from '../../visualizations/public'; +import { getInfoMessage } from './components/experimental_map_vis_info'; + export const createVegaTypeDefinition = (dependencies: VegaVisualizationDependencies) => { const requestHandler = createVegaRequestHandler(dependencies); const visualization = createVegaVisualization(dependencies); @@ -36,6 +38,7 @@ export const createVegaTypeDefinition = (dependencies: VegaVisualizationDependen return { name: 'vega', title: 'Vega', + getInfoMessage, description: i18n.translate('visTypeVega.type.vegaDescription', { defaultMessage: 'Create custom visualizations using Vega and Vega-Lite', description: 'Vega and Vega-Lite are product names and should not be translated', diff --git a/src/plugins/visualizations/public/vis_types/base_vis_type.ts b/src/plugins/visualizations/public/vis_types/base_vis_type.ts index de1afc254e0d3..4763bc9de9d27 100644 --- a/src/plugins/visualizations/public/vis_types/base_vis_type.ts +++ b/src/plugins/visualizations/public/vis_types/base_vis_type.ts @@ -44,7 +44,7 @@ interface CommonBaseVisTypeOptions { useCustomNoDataScreen?: boolean; inspectorAdapters?: Adapters | (() => Adapters); isDeprecated?: boolean; - getDeprecationMessage?: (vis: Vis) => ReactElement<{}>; + getInfoMessage?: (vis: Vis) => ReactElement<{}> | null; } interface ExpressionBaseVisTypeOptions extends CommonBaseVisTypeOptions { @@ -84,7 +84,7 @@ export class BaseVisType { useCustomNoDataScreen: boolean; inspectorAdapters?: Adapters | (() => Adapters); toExpressionAst?: VisToExpressionAst; - getDeprecationMessage?: (vis: Vis) => ReactElement<{}>; + getInfoMessage?: (vis: Vis) => ReactElement<{}> | null; constructor(opts: BaseVisTypeOptions) { if (!opts.icon && !opts.image) { @@ -122,7 +122,7 @@ export class BaseVisType { this.useCustomNoDataScreen = opts.useCustomNoDataScreen || false; this.inspectorAdapters = opts.inspectorAdapters; this.toExpressionAst = opts.toExpressionAst; - this.getDeprecationMessage = opts.getDeprecationMessage; + this.getInfoMessage = opts.getInfoMessage; } public get schemas() { diff --git a/src/plugins/visualize/public/application/components/visualize_editor_common.tsx b/src/plugins/visualize/public/application/components/visualize_editor_common.tsx index 4b7b4dae02d0a..37f564aaa3a18 100644 --- a/src/plugins/visualize/public/application/components/visualize_editor_common.tsx +++ b/src/plugins/visualize/public/application/components/visualize_editor_common.tsx @@ -79,7 +79,7 @@ export const VisualizeEditorCommon = ({ /> )} {visInstance?.vis?.type?.isExperimental && } - {visInstance?.vis?.type?.getDeprecationMessage?.(visInstance.vis)} + {visInstance?.vis?.type?.getInfoMessage?.(visInstance.vis)} {visInstance && (

diff --git a/x-pack/plugins/beats_management/public/components/navigation/connected_link.tsx b/x-pack/plugins/beats_management/public/components/navigation/connected_link.tsx index ebac34afa016b..57fb6d4fc33bb 100644 --- a/x-pack/plugins/beats_management/public/components/navigation/connected_link.tsx +++ b/x-pack/plugins/beats_management/public/components/navigation/connected_link.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import _ from 'lodash'; +import { get } from 'lodash'; import React from 'react'; import { EuiLink } from '@elastic/eui'; @@ -31,7 +31,7 @@ export const ConnectedLinkComponent = ({ } // Shorthand for pathname - const pathname = path || _.get(props.to, 'pathname') || location.pathname; + const pathname = path || get(props.to, 'pathname') || location.pathname; return ( { }); it('should use helper function to convert users yaml in tag to config object', async () => { - const convertedBlocks = lib.userConfigsToJson([ + const convertedBlocks = await lib.userConfigsToJson([ { id: 'foo', tag: 'basic', @@ -42,7 +42,7 @@ describe('Tags Client Domain Lib', () => { }); it('should use helper function to convert user config to json with undefined `other`', async () => { - const convertedTag = lib.userConfigsToJson([ + const convertedTag = await lib.userConfigsToJson([ { id: 'foo', tag: 'basic', @@ -61,7 +61,7 @@ describe('Tags Client Domain Lib', () => { }); it('should use helper function to convert users yaml in tag to config object, where empty other leads to no other fields saved', async () => { - const convertedTag = lib.userConfigsToJson([ + const convertedTag = await lib.userConfigsToJson([ { id: 'foo', tag: 'basic', @@ -83,7 +83,7 @@ describe('Tags Client Domain Lib', () => { }); it('should convert tokenized fields to JSON', async () => { - const convertedTag = lib.userConfigsToJson([ + const convertedTag = await lib.userConfigsToJson([ { id: 'foo', tag: 'basic', @@ -106,7 +106,7 @@ describe('Tags Client Domain Lib', () => { }); it('should use helper function to convert config object to users yaml', async () => { - const convertedTag = lib.jsonConfigToUserYaml([ + const convertedTag = await lib.jsonConfigToUserYaml([ { id: 'foo', tag: 'basic', @@ -127,7 +127,7 @@ describe('Tags Client Domain Lib', () => { }); it('should use helper function to convert config object to users yaml with empty `other`', async () => { - const convertedTag = lib.jsonConfigToUserYaml([ + const convertedTag = await lib.jsonConfigToUserYaml([ { id: 'foo', tag: 'basic', diff --git a/x-pack/plugins/beats_management/public/lib/configuration_blocks.ts b/x-pack/plugins/beats_management/public/lib/configuration_blocks.ts index 09c079ea129e6..d84bd21381c3e 100644 --- a/x-pack/plugins/beats_management/public/lib/configuration_blocks.ts +++ b/x-pack/plugins/beats_management/public/lib/configuration_blocks.ts @@ -4,7 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -import yaml from 'js-yaml'; import { set } from '@elastic/safer-lodash-set'; import { get, has, omit } from 'lodash'; import { ConfigBlockSchema, ConfigurationBlock } from '../../common/domain_types'; @@ -19,16 +18,17 @@ export class ConfigBlocksLib { ) {} public upsert = async (blocks: ConfigurationBlock[]) => { - return await this.adapter.upsert(this.userConfigsToJson(blocks)); + return await this.adapter.upsert(await this.userConfigsToJson(blocks)); }; public getForTags = async (tagIds: string[], page: number) => { const result = await this.adapter.getForTags(tagIds, page); - result.list = this.jsonConfigToUserYaml(result.list); + result.list = await this.jsonConfigToUserYaml(result.list); return result; }; - public jsonConfigToUserYaml(blocks: ConfigurationBlock[]): ConfigurationBlock[] { + public async jsonConfigToUserYaml(blocks: ConfigurationBlock[]): Promise { + const yaml = await import('js-yaml'); // configuration_blocks yaml, JS cant read YAML so we parse it into JS, // because beats flattens all fields, and we need more structure. // we take tagConfigs, grab the config that applies here, render what we can into @@ -73,7 +73,8 @@ export class ConfigBlocksLib { }); } - public userConfigsToJson(blocks: ConfigurationBlock[]): ConfigurationBlock[] { + public async userConfigsToJson(blocks: ConfigurationBlock[]): Promise { + const yaml = await import('js-yaml'); // configurations is the JS representation of the config yaml, // so here we take that JS and convert it into a YAML string. // we do so while also flattening "other" into the flat yaml beats expect diff --git a/x-pack/plugins/global_search/common/constants.ts b/x-pack/plugins/global_search/common/constants.ts new file mode 100644 index 0000000000000..423cf5f8be5a8 --- /dev/null +++ b/x-pack/plugins/global_search/common/constants.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const defaultMaxProviderResults = 40; diff --git a/x-pack/plugins/global_search/public/services/search_service.ts b/x-pack/plugins/global_search/public/services/search_service.ts index 68970b75ad975..62b347d925868 100644 --- a/x-pack/plugins/global_search/public/services/search_service.ts +++ b/x-pack/plugins/global_search/public/services/search_service.ts @@ -12,6 +12,7 @@ import { HttpStart } from 'src/core/public'; import { GlobalSearchProviderResult, GlobalSearchBatchedResults } from '../../common/types'; import { GlobalSearchFindError } from '../../common/errors'; import { takeInArray } from '../../common/operators'; +import { defaultMaxProviderResults } from '../../common/constants'; import { processProviderResult } from '../../common/process_result'; import { ILicenseChecker } from '../../common/license_checker'; import { GlobalSearchResultProvider } from '../types'; @@ -79,7 +80,6 @@ interface StartDeps { licenseChecker: ILicenseChecker; } -const defaultMaxProviderResults = 20; const mapToUndefined = () => undefined; /** @internal */ diff --git a/x-pack/plugins/global_search/server/services/search_service.ts b/x-pack/plugins/global_search/server/services/search_service.ts index d79f3781c6bec..1897a24196cf1 100644 --- a/x-pack/plugins/global_search/server/services/search_service.ts +++ b/x-pack/plugins/global_search/server/services/search_service.ts @@ -11,6 +11,7 @@ import { KibanaRequest, CoreStart, IBasePath } from 'src/core/server'; import { GlobalSearchProviderResult, GlobalSearchBatchedResults } from '../../common/types'; import { GlobalSearchFindError } from '../../common/errors'; import { takeInArray } from '../../common/operators'; +import { defaultMaxProviderResults } from '../../common/constants'; import { ILicenseChecker } from '../../common/license_checker'; import { processProviderResult } from '../../common/process_result'; @@ -80,7 +81,6 @@ interface StartDeps { licenseChecker: ILicenseChecker; } -const defaultMaxProviderResults = 20; const mapToUndefined = () => undefined; /** @internal */ diff --git a/x-pack/plugins/global_search_bar/public/components/search_bar.test.tsx b/x-pack/plugins/global_search_bar/public/components/search_bar.test.tsx index 6fad3335c5efc..3c86c4e70e346 100644 --- a/x-pack/plugins/global_search_bar/public/components/search_bar.test.tsx +++ b/x-pack/plugins/global_search_bar/public/components/search_bar.test.tsx @@ -5,17 +5,19 @@ */ import React from 'react'; -import { wait } from '@testing-library/react'; -import { of } from 'rxjs'; +import { waitFor, act } from '@testing-library/react'; +import { ReactWrapper } from 'enzyme'; +import { of, BehaviorSubject } from 'rxjs'; +import { filter, map } from 'rxjs/operators'; import { mountWithIntl } from 'test_utils/enzyme_helpers'; -import { httpServiceMock, uiSettingsServiceMock } from '../../../../../src/core/public/mocks'; -import { - GlobalSearchBatchedResults, - GlobalSearchPluginStart, - GlobalSearchResult, -} from '../../../global_search/public'; +import { applicationServiceMock } from '../../../../../src/core/public/mocks'; +import { GlobalSearchBatchedResults, GlobalSearchResult } from '../../../global_search/public'; import { globalSearchPluginMock } from '../../../global_search/public/mocks'; -import { SearchBar } from '../components/search_bar'; +import { SearchBar } from './search_bar'; + +jest.mock('@elastic/eui/lib/services/accessibility/html_id_generator', () => ({ + htmlIdGenerator: () => () => 'mockId', +})); type Result = { id: string; type: string } | string; @@ -38,30 +40,46 @@ const createBatch = (...results: Result[]): GlobalSearchBatchedResults => ({ results: results.map(createResult), }); -jest.mock('@elastic/eui/lib/services/accessibility/html_id_generator', () => ({ - htmlIdGenerator: () => () => 'mockId', -})); - const getSelectableProps: any = (component: any) => component.find('EuiSelectable').props(); const getSearchProps: any = (component: any) => component.find('EuiFieldSearch').props(); describe('SearchBar', () => { - let searchService: GlobalSearchPluginStart; - let findSpy: jest.SpyInstance; - const http = httpServiceMock.createSetupContract({ basePath: '/test' }); - const basePathUrl = http.basePath.prepend('/plugins/globalSearchBar/assets/'); - const uiSettings = uiSettingsServiceMock.createStartContract(); - const darkMode = uiSettings.get('theme:darkMode'); + let searchService: ReturnType; + let applications: ReturnType; + const basePathUrl = '/plugins/globalSearchBar/assets/'; + const darkMode = false; + + let component: ReactWrapper; beforeEach(() => { + applications = applicationServiceMock.createStartContract(); searchService = globalSearchPluginMock.createStartContract(); - findSpy = jest.spyOn(searchService, 'find'); jest.useFakeTimers(); }); + const triggerFocus = () => { + component.find('input[data-test-subj="header-search"]').simulate('focus'); + }; + + const update = () => { + act(() => { + jest.runAllTimers(); + }); + component.update(); + }; + + const simulateTypeChar = async (text: string) => { + await waitFor(() => + getSearchProps(component).onKeyUpCapture({ currentTarget: { value: text } }) + ); + }; + + const getDisplayedOptionsLabel = () => { + return getSelectableProps(component).options.map((option: any) => option.label); + }; + it('correctly filters and sorts results', async () => { - const navigate = jest.fn(); - findSpy + searchService.find .mockReturnValueOnce( of( createBatch('Discover', 'Canvas'), @@ -70,35 +88,37 @@ describe('SearchBar', () => { ) .mockReturnValueOnce(of(createBatch('Discover', { id: 'My Dashboard', type: 'test' }))); - const component = mountWithIntl( + component = mountWithIntl( ); - expect(findSpy).toHaveBeenCalledTimes(0); - component.find('input[data-test-subj="header-search"]').simulate('focus'); - jest.runAllTimers(); - component.update(); - expect(findSpy).toHaveBeenCalledTimes(1); - expect(findSpy).toHaveBeenCalledWith('', {}); + expect(searchService.find).toHaveBeenCalledTimes(0); + + triggerFocus(); + update(); + + expect(searchService.find).toHaveBeenCalledTimes(1); + expect(searchService.find).toHaveBeenCalledWith('', {}); expect(getSelectableProps(component).options).toMatchSnapshot(); - await wait(() => getSearchProps(component).onKeyUpCapture({ currentTarget: { value: 'd' } })); - jest.runAllTimers(); - component.update(); + + await simulateTypeChar('d'); + update(); + expect(getSelectableProps(component).options).toMatchSnapshot(); - expect(findSpy).toHaveBeenCalledTimes(2); - expect(findSpy).toHaveBeenCalledWith('d', {}); + expect(searchService.find).toHaveBeenCalledTimes(2); + expect(searchService.find).toHaveBeenCalledWith('d', {}); }); it('supports keyboard shortcuts', () => { mountWithIntl( @@ -113,4 +133,44 @@ describe('SearchBar', () => { expect(document.activeElement).toMatchSnapshot(); }); + + it('only display results from the last search', async () => { + const firstSearchTrigger = new BehaviorSubject(false); + const firstSearch = firstSearchTrigger.pipe( + filter((event) => event), + map(() => { + return createBatch('Discover', 'Canvas'); + }) + ); + const secondSearch = of(createBatch('Visualize', 'Map')); + + searchService.find.mockReturnValueOnce(firstSearch).mockReturnValueOnce(secondSearch); + + component = mountWithIntl( + + ); + + triggerFocus(); + update(); + + expect(searchService.find).toHaveBeenCalledTimes(1); + + await simulateTypeChar('d'); + update(); + + expect(getDisplayedOptionsLabel().length).toBe(2); + expect(getDisplayedOptionsLabel()).toEqual(expect.arrayContaining(['Visualize', 'Map'])); + + firstSearchTrigger.next(true); + + update(); + + expect(getDisplayedOptionsLabel().length).toBe(2); + expect(getDisplayedOptionsLabel()).toEqual(expect.arrayContaining(['Visualize', 'Map'])); + }); }); diff --git a/x-pack/plugins/global_search_bar/public/components/search_bar.tsx b/x-pack/plugins/global_search_bar/public/components/search_bar.tsx index 4ca0f8cf81b7b..ea2271286883d 100644 --- a/x-pack/plugins/global_search_bar/public/components/search_bar.tsx +++ b/x-pack/plugins/global_search_bar/public/components/search_bar.tsx @@ -18,8 +18,9 @@ import { } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; +import { Subscription } from 'rxjs'; import { ApplicationStart } from 'kibana/public'; -import React, { useCallback, useState } from 'react'; +import React, { useCallback, useState, useRef } from 'react'; import useDebounce from 'react-use/lib/useDebounce'; import useEvent from 'react-use/lib/useEvent'; import useMountedState from 'react-use/lib/useMountedState'; @@ -45,48 +46,73 @@ const clearField = (field: HTMLInputElement) => { const cleanMeta = (str: string) => (str.charAt(0).toUpperCase() + str.slice(1)).replace(/-/g, ' '); const blurEvent = new FocusEvent('blur'); +const sortByScore = (a: GlobalSearchResult, b: GlobalSearchResult): number => { + if (a.score < b.score) return 1; + if (a.score > b.score) return -1; + return 0; +}; + +const sortByTitle = (a: GlobalSearchResult, b: GlobalSearchResult): number => { + const titleA = a.title.toUpperCase(); // ignore upper and lowercase + const titleB = b.title.toUpperCase(); // ignore upper and lowercase + if (titleA < titleB) return -1; + if (titleA > titleB) return 1; + return 0; +}; + +const resultToOption = (result: GlobalSearchResult): EuiSelectableTemplateSitewideOption => { + const { id, title, url, icon, type, meta } = result; + const option: EuiSelectableTemplateSitewideOption = { + key: id, + label: title, + url, + }; + + if (icon) { + option.icon = { type: icon }; + } + + if (type === 'application') { + option.meta = [{ text: meta?.categoryLabel as string }]; + } else { + option.meta = [{ text: cleanMeta(type) }]; + } + + return option; +}; + export function SearchBar({ globalSearch, navigateToUrl, basePathUrl, darkMode }: Props) { const isMounted = useMountedState(); const [searchValue, setSearchValue] = useState(''); const [searchRef, setSearchRef] = useState(null); + const searchSubscription = useRef(null); const [options, _setOptions] = useState([] as EuiSelectableTemplateSitewideOption[]); const isMac = navigator.platform.toLowerCase().indexOf('mac') >= 0; const setOptions = useCallback( (_options: GlobalSearchResult[]) => { - if (!isMounted()) return; - - _setOptions([ - ..._options.map(({ id, title, url, icon, type, meta }) => { - const option: EuiSelectableTemplateSitewideOption = { - key: id, - label: title, - url, - }; - - if (icon) option.icon = { type: icon }; - - if (type === 'application') option.meta = [{ text: meta?.categoryLabel as string }]; - else option.meta = [{ text: cleanMeta(type) }]; + if (!isMounted()) { + return; + } - return option; - }), - ]); + _setOptions(_options.map(resultToOption)); }, [isMounted, _setOptions] ); useDebounce( () => { + // cancel pending search if not completed yet + if (searchSubscription.current) { + searchSubscription.current.unsubscribe(); + searchSubscription.current = null; + } + let arr: GlobalSearchResult[] = []; - globalSearch(searchValue, {}).subscribe({ + searchSubscription.current = globalSearch(searchValue, {}).subscribe({ next: ({ results }) => { if (searchValue.length > 0) { - arr = [...results, ...arr].sort((a, b) => { - if (a.score < b.score) return 1; - if (a.score > b.score) return -1; - return 0; - }); + arr = [...results, ...arr].sort(sortByScore); setOptions(arr); return; } @@ -94,13 +120,7 @@ export function SearchBar({ globalSearch, navigateToUrl, basePathUrl, darkMode } // if searchbar is empty, filter to only applications and sort alphabetically results = results.filter(({ type }: GlobalSearchResult) => type === 'application'); - arr = [...results, ...arr].sort((a, b) => { - const titleA = a.title.toUpperCase(); // ignore upper and lowercase - const titleB = b.title.toUpperCase(); // ignore upper and lowercase - if (titleA < titleB) return -1; - if (titleA > titleB) return 1; - return 0; - }); + arr = [...results, ...arr].sort(sortByTitle); setOptions(arr); }, diff --git a/x-pack/plugins/global_search_providers/server/providers/saved_objects/provider.test.ts b/x-pack/plugins/global_search_providers/server/providers/saved_objects/provider.test.ts index 352191658ed0d..b556e2785b4b4 100644 --- a/x-pack/plugins/global_search_providers/server/providers/saved_objects/provider.test.ts +++ b/x-pack/plugins/global_search_providers/server/providers/saved_objects/provider.test.ts @@ -5,6 +5,7 @@ */ import { EMPTY } from 'rxjs'; +import { toArray } from 'rxjs/operators'; import { TestScheduler } from 'rxjs/testing'; import { SavedObjectsFindResponse, @@ -114,8 +115,8 @@ describe('savedObjectsResultProvider', () => { expect(provider.id).toBe('savedObjects'); }); - it('calls `savedObjectClient.find` with the correct parameters', () => { - provider.find('term', defaultOption, context); + it('calls `savedObjectClient.find` with the correct parameters', async () => { + await provider.find('term', defaultOption, context).toPromise(); expect(context.core.savedObjects.client.find).toHaveBeenCalledTimes(1); expect(context.core.savedObjects.client.find).toHaveBeenCalledWith({ @@ -128,6 +129,13 @@ describe('savedObjectsResultProvider', () => { }); }); + it('does not call `savedObjectClient.find` if `term` is empty', async () => { + const results = await provider.find('', defaultOption, context).pipe(toArray()).toPromise(); + + expect(context.core.savedObjects.client.find).not.toHaveBeenCalled(); + expect(results).toEqual([[]]); + }); + it('converts the saved objects to results', async () => { context.core.savedObjects.client.find.mockResolvedValue( createFindResponse([ diff --git a/x-pack/plugins/global_search_providers/server/providers/saved_objects/provider.ts b/x-pack/plugins/global_search_providers/server/providers/saved_objects/provider.ts index 1c79380fe17fd..3861858a53626 100644 --- a/x-pack/plugins/global_search_providers/server/providers/saved_objects/provider.ts +++ b/x-pack/plugins/global_search_providers/server/providers/saved_objects/provider.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { from, combineLatest } from 'rxjs'; +import { from, combineLatest, of } from 'rxjs'; import { map, takeUntil, first } from 'rxjs/operators'; import { GlobalSearchResultProvider } from '../../../../global_search/server'; import { mapToResults } from './map_object_to_result'; @@ -13,6 +13,10 @@ export const createSavedObjectsResultProvider = (): GlobalSearchResultProvider = return { id: 'savedObjects', find: (term, { aborted$, maxResults, preference }, { core }) => { + if (!term) { + return of([]); + } + const { capabilities, savedObjects: { client, typeRegistry }, diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/dimension_container.scss b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/dimension_container.scss index f200e25453a2a..bd2789cf645c7 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/dimension_container.scss +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/dimension_container.scss @@ -13,17 +13,7 @@ animation: euiFlyout $euiAnimSpeedNormal $euiAnimSlightResistance; } -.lnsDimensionContainer--noAnimation { - animation: none; -} - .lnsDimensionContainer__footer, .lnsDimensionContainer__header { padding: $euiSizeS; } - -.lnsDimensionContainer__trigger { - width: 100%; - display: block; - word-break: break-word; -} diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/dimension_container.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/dimension_container.tsx index 19f4c0428260e..8f1b441d1d285 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/dimension_container.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/dimension_container.tsx @@ -16,89 +16,42 @@ import { EuiOutsideClickDetector, } from '@elastic/eui'; -import classNames from 'classnames'; import { i18n } from '@kbn/i18n'; -import { VisualizationDimensionGroupConfig } from '../../../types'; -import { DimensionContainerState } from './types'; export function DimensionContainer({ - dimensionContainerState, - setDimensionContainerState, - groups, - accessor, - groupId, - trigger, + isOpen, + groupLabel, + handleClose, panel, - panelTitle, }: { - dimensionContainerState: DimensionContainerState; - setDimensionContainerState: (newState: DimensionContainerState) => void; - groups: VisualizationDimensionGroupConfig[]; - accessor: string; - groupId: string; - trigger: React.ReactElement; + isOpen: boolean; + handleClose: () => void; panel: React.ReactElement; - panelTitle: React.ReactNode; + groupLabel: string; }) { - const [openByCreation, setIsOpenByCreation] = useState( - dimensionContainerState.openId === accessor - ); const [focusTrapIsEnabled, setFocusTrapIsEnabled] = useState(false); - const [flyoutIsVisible, setFlyoutIsVisible] = useState(false); - - const noMatch = dimensionContainerState.isOpen - ? !groups.some((d) => d.accessors.includes(accessor)) - : false; const closeFlyout = () => { - setDimensionContainerState({ - isOpen: false, - openId: null, - addingToGroupId: null, - }); - setIsOpenByCreation(false); + handleClose(); setFocusTrapIsEnabled(false); - setFlyoutIsVisible(false); - }; - - const openFlyout = () => { - setFlyoutIsVisible(true); - setTimeout(() => { - setFocusTrapIsEnabled(true); - }, 255); }; - const flyoutShouldBeOpen = - dimensionContainerState.isOpen && - (dimensionContainerState.openId === accessor || - (noMatch && dimensionContainerState.addingToGroupId === groupId)); - useEffect(() => { - if (flyoutShouldBeOpen) { - openFlyout(); + if (isOpen) { + // without setTimeout here the flyout pushes content when animating + setTimeout(() => { + setFocusTrapIsEnabled(true); + }, 255); } - }); + }, [isOpen]); - useEffect(() => { - if (!flyoutShouldBeOpen) { - if (flyoutIsVisible) { - setFlyoutIsVisible(false); - } - if (focusTrapIsEnabled) { - setFocusTrapIsEnabled(false); - } - } - }, [flyoutShouldBeOpen, flyoutIsVisible, focusTrapIsEnabled]); - - const flyout = flyoutIsVisible && ( + return isOpen ? ( - +
@@ -109,7 +62,14 @@ export function DimensionContainer({ iconType="sortLeft" flush="left" > - {panelTitle} + + {i18n.translate('xpack.lens.configure.configurePanelTitle', { + defaultMessage: '{groupLabel} configuration', + values: { + groupLabel, + }, + })} + @@ -126,12 +86,5 @@ export function DimensionContainer({
- ); - - return ( - <> - {trigger} - {flyout} - - ); + ) : null; } diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.test.tsx index a9e2d6dc696ab..44dc22d20a4fe 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.test.tsx @@ -14,7 +14,6 @@ import { } from '../../mocks'; import { ChildDragDropProvider } from '../../../drag_drop'; import { EuiFormRow } from '@elastic/eui'; -import { mount } from 'enzyme'; import { mountWithIntl } from 'test_utils/enzyme_helpers'; import { Visualization } from '../../../types'; import { LayerPanel } from './layer_panel'; @@ -211,7 +210,7 @@ describe('LayerPanel', () => { groupId: 'a', accessors: ['newid'], filterOperations: () => true, - supportsMoreColumns: false, + supportsMoreColumns: true, dataTestSubj: 'lnsGroup', enableDimensionEditor: true, }, @@ -220,11 +219,14 @@ describe('LayerPanel', () => { mockVisualization.renderDimensionEditor = jest.fn(); const component = mountWithIntl(); + act(() => { + component.find('[data-test-subj="lns-empty-dimension"]').first().simulate('click'); + }); + component.update(); - const group = component.find('DimensionContainer'); - const panel = mount(group.prop('panel')); - - expect(panel.children()).toHaveLength(2); + const group = component.find('DimensionContainer').first(); + const panel: React.ReactElement = group.prop('panel'); + expect(panel.props.children).toHaveLength(2); }); it('should keep the DimensionContainer open when configuring a new dimension', () => { @@ -263,11 +265,8 @@ describe('LayerPanel', () => { }); const component = mountWithIntl(); - - const group = component.find('DimensionContainer'); - const triggerButton = mountWithIntl(group.prop('trigger')); act(() => { - triggerButton.find('[data-test-subj="lns-empty-dimension"]').first().simulate('click'); + component.find('[data-test-subj="lns-empty-dimension"]').first().simulate('click'); }); component.update(); @@ -312,10 +311,8 @@ describe('LayerPanel', () => { const component = mountWithIntl(); - const group = component.find('DimensionContainer'); - const triggerButton = mountWithIntl(group.prop('trigger')); act(() => { - triggerButton.find('[data-test-subj="lns-empty-dimension"]').first().simulate('click'); + component.find('[data-test-subj="lns-empty-dimension"]').first().simulate('click'); }); component.update(); expect(component.find('EuiFlyoutHeader').exists()).toBe(true); diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx index ce2955da890d7..e72bf75b010c3 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx @@ -23,13 +23,11 @@ import { DragContext, DragDrop, ChildDragDropProvider } from '../../../drag_drop import { LayerSettings } from './layer_settings'; import { trackUiEvent } from '../../../lens_ui_telemetry'; import { generateId } from '../../../id_generator'; -import { ConfigPanelWrapperProps, DimensionContainerState } from './types'; +import { ConfigPanelWrapperProps, ActiveDimensionState } from './types'; import { DimensionContainer } from './dimension_container'; -const initialDimensionContainerState = { - isOpen: false, - openId: null, - addingToGroupId: null, +const initialActiveDimensionState = { + isNew: false, }; function isConfiguration( @@ -70,15 +68,15 @@ export function LayerPanel( } ) { const dragDropContext = useContext(DragContext); - const [dimensionContainerState, setDimensionContainerState] = useState( - initialDimensionContainerState + const [activeDimension, setActiveDimension] = useState( + initialActiveDimensionState ); const { framePublicAPI, layerId, isOnlyLayer, onRemoveLayer, dataTestSubj } = props; const datasourcePublicAPI = framePublicAPI.datasourceLayers[layerId]; useEffect(() => { - setDimensionContainerState(initialDimensionContainerState); + setActiveDimension(initialActiveDimensionState); }, [props.activeVisualizationId]); if ( @@ -117,7 +115,7 @@ export function LayerPanel( const { groups } = activeVisualization.getConfiguration(layerVisualizationConfigProps); const isEmptyLayer = !groups.some((d) => d.accessors.length > 0); - + const { activeId, activeGroup } = activeDimension; return ( @@ -196,31 +194,6 @@ export function LayerPanel( > <> {group.accessors.map((accessor) => { - const datasourceDimensionEditor = ( - - ); - const visDimensionEditor = - activeVisualization.renderDimensionEditor && group.enableDimensionEditor ? ( -
- -
- ) : null; return (
- { - if (dimensionContainerState.isOpen) { - setDimensionContainerState(initialDimensionContainerState); - } else { - setDimensionContainerState({ - isOpen: true, - openId: accessor, - addingToGroupId: null, // not set for existing dimension - }); - } - }, - }} - /> - } - panel={ - <> - {datasourceDimensionEditor} - {visDimensionEditor} - - } - panelTitle={i18n.translate('xpack.lens.configure.configurePanelTitle', { - defaultMessage: '{groupLabel} configuration', - values: { - groupLabel: group.groupLabel, + { + if (activeId) { + setActiveDimension(initialActiveDimensionState); + } else { + setActiveDimension({ + isNew: false, + activeGroup: group, + activeId: accessor, + }); + } }, - })} + }} /> -
- { - if (dimensionContainerState.isOpen) { - setDimensionContainerState(initialDimensionContainerState); - } else { - setDimensionContainerState({ - isOpen: true, - openId: newId, - addingToGroupId: group.groupId, - }); - } - }} - > - - - } - panelTitle={i18n.translate('xpack.lens.configure.configurePanelTitle', { - defaultMessage: '{groupLabel} configuration', - values: { - groupLabel: group.groupLabel, - }, - })} - panel={ - { - props.updateAll( - datasourceId, - newState, - activeVisualization.setDimension({ - layerId, - groupId: group.groupId, - columnId: newId, - prevState: props.visualizationState, - }) - ); - setDimensionContainerState({ - isOpen: true, - openId: newId, - addingToGroupId: null, // clear now that dimension exists - }); - }, - }} - /> - } - /> + { + if (activeId) { + setActiveDimension(initialActiveDimensionState); + } else { + setActiveDimension({ + isNew: true, + activeGroup: group, + activeId: newId, + }); + } + }} + > + +
) : null} @@ -472,6 +378,60 @@ export function LayerPanel( ); })} + setActiveDimension(initialActiveDimensionState)} + panel={ + <> + {activeGroup && activeId && ( + { + props.updateAll( + datasourceId, + newState, + activeVisualization.setDimension({ + layerId, + groupId: activeGroup.groupId, + columnId: activeId, + prevState: props.visualizationState, + }) + ); + setActiveDimension({ + ...activeDimension, + isNew: false, + }); + }, + }} + /> + )} + {activeGroup && + activeId && + !activeDimension.isNew && + activeVisualization.renderDimensionEditor && + activeGroup?.enableDimensionEditor && ( +
+ +
+ )} + + } + /> diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/types.ts b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/types.ts index d42c5c3b99e53..c172c6da6848c 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/types.ts +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/types.ts @@ -10,6 +10,7 @@ import { FramePublicAPI, Datasource, DatasourceDimensionEditorProps, + VisualizationDimensionGroupConfig, } from '../../../types'; export interface ConfigPanelWrapperProps { @@ -30,8 +31,8 @@ export interface ConfigPanelWrapperProps { core: DatasourceDimensionEditorProps['core']; } -export interface DimensionContainerState { - isOpen: boolean; - openId: string | null; - addingToGroupId: string | null; +export interface ActiveDimensionState { + isNew: boolean; + activeId?: string; + activeGroup?: VisualizationDimensionGroupConfig; } diff --git a/x-pack/plugins/security/common/constants.ts b/x-pack/plugins/security/common/constants.ts index 44b6601daa7ff..a0d63c0a9dd6f 100644 --- a/x-pack/plugins/security/common/constants.ts +++ b/x-pack/plugins/security/common/constants.ts @@ -4,6 +4,16 @@ * you may not use this file except in compliance with the Elastic License. */ +/** + * The identifier in a saved object's `namespaces` array when it is shared globally to all spaces. + */ +export const ALL_SPACES_ID = '*'; + +/** + * The identifier in a saved object's `namespaces` array when it is shared to an unknown space (e.g., one that the end user is not authorized to see). + */ +export const UNKNOWN_SPACE = '?'; + export const GLOBAL_RESOURCE = '*'; export const APPLICATION_PREFIX = 'kibana-'; export const RESERVED_PRIVILEGES_APPLICATION_WILDCARD = 'kibana-*'; diff --git a/x-pack/plugins/security/server/authorization/actions/__snapshots__/saved_object.test.ts.snap b/x-pack/plugins/security/server/authorization/actions/__snapshots__/saved_object.test.ts.snap deleted file mode 100644 index 117947fc22f50..0000000000000 --- a/x-pack/plugins/security/server/authorization/actions/__snapshots__/saved_object.test.ts.snap +++ /dev/null @@ -1,25 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`#get operation of "" throws error 1`] = `"type is required and must be a string"`; - -exports[`#get operation of {} throws error 1`] = `"type is required and must be a string"`; - -exports[`#get operation of 1 throws error 1`] = `"type is required and must be a string"`; - -exports[`#get operation of null throws error 1`] = `"type is required and must be a string"`; - -exports[`#get operation of true throws error 1`] = `"type is required and must be a string"`; - -exports[`#get operation of undefined throws error 1`] = `"type is required and must be a string"`; - -exports[`#get type of "" throws error 1`] = `"type is required and must be a string"`; - -exports[`#get type of {} throws error 1`] = `"type is required and must be a string"`; - -exports[`#get type of 1 throws error 1`] = `"type is required and must be a string"`; - -exports[`#get type of null throws error 1`] = `"type is required and must be a string"`; - -exports[`#get type of true throws error 1`] = `"type is required and must be a string"`; - -exports[`#get type of undefined throws error 1`] = `"type is required and must be a string"`; diff --git a/x-pack/plugins/security/server/authorization/actions/saved_object.test.ts b/x-pack/plugins/security/server/authorization/actions/saved_object.test.ts index 9e8bfb6ad795f..90448a5dd0422 100644 --- a/x-pack/plugins/security/server/authorization/actions/saved_object.test.ts +++ b/x-pack/plugins/security/server/authorization/actions/saved_object.test.ts @@ -12,14 +12,18 @@ describe('#get', () => { [null, undefined, '', 1, true, {}].forEach((type: any) => { test(`type of ${JSON.stringify(type)} throws error`, () => { const savedObjectActions = new SavedObjectActions(version); - expect(() => savedObjectActions.get(type, 'foo-action')).toThrowErrorMatchingSnapshot(); + expect(() => savedObjectActions.get(type, 'foo-action')).toThrowError( + 'type is required and must be a string' + ); }); }); [null, undefined, '', 1, true, {}].forEach((operation: any) => { test(`operation of ${JSON.stringify(operation)} throws error`, () => { const savedObjectActions = new SavedObjectActions(version); - expect(() => savedObjectActions.get('foo-type', operation)).toThrowErrorMatchingSnapshot(); + expect(() => savedObjectActions.get('foo-type', operation)).toThrowError( + 'operation is required and must be a string' + ); }); }); diff --git a/x-pack/plugins/security/server/authorization/actions/saved_object.ts b/x-pack/plugins/security/server/authorization/actions/saved_object.ts index e3a02d3807399..6bd094d64ec74 100644 --- a/x-pack/plugins/security/server/authorization/actions/saved_object.ts +++ b/x-pack/plugins/security/server/authorization/actions/saved_object.ts @@ -19,7 +19,7 @@ export class SavedObjectActions { } if (!operation || !isString(operation)) { - throw new Error('type is required and must be a string'); + throw new Error('operation is required and must be a string'); } return `${this.prefix}${type}/${operation}`; diff --git a/x-pack/plugins/security/server/authorization/check_saved_objects_privileges.test.ts b/x-pack/plugins/security/server/authorization/check_saved_objects_privileges.test.ts index f287cc04280ac..4a2426a9e8a40 100644 --- a/x-pack/plugins/security/server/authorization/check_saved_objects_privileges.test.ts +++ b/x-pack/plugins/security/server/authorization/check_saved_objects_privileges.test.ts @@ -95,6 +95,38 @@ describe('#checkSavedObjectsPrivileges', () => { ]; expect(mockCheckPrivileges.atSpaces).toHaveBeenCalledWith(spaceIds, { kibana: actions }); }); + + test(`uses checkPrivileges.globally when checking for "all spaces" (*)`, async () => { + const expectedResult = Symbol(); + mockCheckPrivileges.globally.mockReturnValue(expectedResult as any); + mockSpacesService = undefined; + const checkSavedObjectsPrivileges = createFactory(); + + const namespaces = [undefined, 'default', namespace1, namespace1, '*']; + const result = await checkSavedObjectsPrivileges(actions, namespaces); + + expect(result).toBe(expectedResult); + expect(mockCheckPrivilegesWithRequest).toHaveBeenCalledTimes(1); + expect(mockCheckPrivilegesWithRequest).toHaveBeenCalledWith(request); + expect(mockCheckPrivileges.globally).toHaveBeenCalledTimes(1); + expect(mockCheckPrivileges.globally).toHaveBeenCalledWith({ kibana: actions }); + }); + + test(`uses checkPrivileges.globally when Spaces is disabled`, async () => { + const expectedResult = Symbol(); + mockCheckPrivileges.globally.mockReturnValue(expectedResult as any); + mockSpacesService = undefined; + const checkSavedObjectsPrivileges = createFactory(); + + const namespaces = [undefined, 'default', namespace1, namespace1, '*']; + const result = await checkSavedObjectsPrivileges(actions, namespaces); + + expect(result).toBe(expectedResult); + expect(mockCheckPrivilegesWithRequest).toHaveBeenCalledTimes(1); + expect(mockCheckPrivilegesWithRequest).toHaveBeenCalledWith(request); + expect(mockCheckPrivileges.globally).toHaveBeenCalledTimes(1); + expect(mockCheckPrivileges.globally).toHaveBeenCalledWith({ kibana: actions }); + }); }); describe('when checking a single namespace', () => { @@ -115,6 +147,21 @@ describe('#checkSavedObjectsPrivileges', () => { expect(mockCheckPrivileges.atSpace).toHaveBeenCalledWith(spaceId, { kibana: actions }); }); + test(`uses checkPrivileges.globally when checking for "all spaces" (*)`, async () => { + const expectedResult = Symbol(); + mockCheckPrivileges.globally.mockReturnValue(expectedResult as any); + mockSpacesService = undefined; + const checkSavedObjectsPrivileges = createFactory(); + + const result = await checkSavedObjectsPrivileges(actions, '*'); + + expect(result).toBe(expectedResult); + expect(mockCheckPrivilegesWithRequest).toHaveBeenCalledTimes(1); + expect(mockCheckPrivilegesWithRequest).toHaveBeenCalledWith(request); + expect(mockCheckPrivileges.globally).toHaveBeenCalledTimes(1); + expect(mockCheckPrivileges.globally).toHaveBeenCalledWith({ kibana: actions }); + }); + test(`uses checkPrivileges.globally when Spaces is disabled`, async () => { const expectedResult = Symbol(); mockCheckPrivileges.globally.mockReturnValue(expectedResult as any); diff --git a/x-pack/plugins/security/server/authorization/check_saved_objects_privileges.ts b/x-pack/plugins/security/server/authorization/check_saved_objects_privileges.ts index 7c0ca7dcaa392..6b70e25eb448d 100644 --- a/x-pack/plugins/security/server/authorization/check_saved_objects_privileges.ts +++ b/x-pack/plugins/security/server/authorization/check_saved_objects_privileges.ts @@ -5,6 +5,7 @@ */ import { KibanaRequest } from '../../../../../src/core/server'; +import { ALL_SPACES_ID } from '../../common/constants'; import { SpacesService } from '../plugin'; import { CheckPrivilegesWithRequest, CheckPrivilegesResponse } from './types'; @@ -33,24 +34,32 @@ export const checkSavedObjectsPrivilegesWithRequestFactory = ( namespaceOrNamespaces?: string | Array ) { const spacesService = getSpacesService(); - if (!spacesService) { - // Spaces disabled, authorizing globally - return await checkPrivilegesWithRequest(request).globally({ kibana: actions }); - } else if (Array.isArray(namespaceOrNamespaces)) { - // Spaces enabled, authorizing against multiple spaces - if (!namespaceOrNamespaces.length) { - throw new Error(`Can't check saved object privileges for 0 namespaces`); + const privileges = { kibana: actions }; + + if (spacesService) { + if (Array.isArray(namespaceOrNamespaces)) { + // Spaces enabled, authorizing against multiple spaces + if (!namespaceOrNamespaces.length) { + throw new Error(`Can't check saved object privileges for 0 namespaces`); + } + const spaceIds = uniq( + namespaceOrNamespaces.map((x) => spacesService.namespaceToSpaceId(x)) + ); + + if (!spaceIds.includes(ALL_SPACES_ID)) { + return await checkPrivilegesWithRequest(request).atSpaces(spaceIds, privileges); + } + } else { + // Spaces enabled, authorizing against a single space + const spaceId = spacesService.namespaceToSpaceId(namespaceOrNamespaces); + if (spaceId !== ALL_SPACES_ID) { + return await checkPrivilegesWithRequest(request).atSpace(spaceId, privileges); + } } - const spaceIds = uniq( - namespaceOrNamespaces.map((x) => spacesService.namespaceToSpaceId(x)) - ); - - return await checkPrivilegesWithRequest(request).atSpaces(spaceIds, { kibana: actions }); - } else { - // Spaces enabled, authorizing against a single space - const spaceId = spacesService.namespaceToSpaceId(namespaceOrNamespaces); - return await checkPrivilegesWithRequest(request).atSpace(spaceId, { kibana: actions }); } + + // Spaces plugin is disabled OR we are checking privileges for "all spaces", authorizing globally + return await checkPrivilegesWithRequest(request).globally(privileges); }; }; }; diff --git a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/saved_object.ts b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/saved_object.ts index 0dd89f2c5f3c1..6da0b93e1461f 100644 --- a/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/saved_object.ts +++ b/x-pack/plugins/security/server/authorization/privileges/feature_privilege_builder/saved_object.ts @@ -9,7 +9,14 @@ import { FeatureKibanaPrivileges } from '../../../../../features/server'; import { BaseFeaturePrivilegeBuilder } from './feature_privilege_builder'; const readOperations: string[] = ['bulk_get', 'get', 'find']; -const writeOperations: string[] = ['create', 'bulk_create', 'update', 'bulk_update', 'delete']; +const writeOperations: string[] = [ + 'create', + 'bulk_create', + 'update', + 'bulk_update', + 'delete', + 'share_to_space', +]; const allOperations: string[] = [...readOperations, ...writeOperations]; export class FeaturePrivilegeSavedObjectBuilder extends BaseFeaturePrivilegeBuilder { diff --git a/x-pack/plugins/security/server/authorization/privileges/privileges.test.ts b/x-pack/plugins/security/server/authorization/privileges/privileges.test.ts index 6f721c91fbd67..2b1268b11a0ff 100644 --- a/x-pack/plugins/security/server/authorization/privileges/privileges.test.ts +++ b/x-pack/plugins/security/server/authorization/privileges/privileges.test.ts @@ -106,6 +106,7 @@ describe('features', () => { actions.savedObject.get('all-savedObject-all-1', 'update'), actions.savedObject.get('all-savedObject-all-1', 'bulk_update'), actions.savedObject.get('all-savedObject-all-1', 'delete'), + actions.savedObject.get('all-savedObject-all-1', 'share_to_space'), actions.savedObject.get('all-savedObject-all-2', 'bulk_get'), actions.savedObject.get('all-savedObject-all-2', 'get'), actions.savedObject.get('all-savedObject-all-2', 'find'), @@ -114,6 +115,7 @@ describe('features', () => { actions.savedObject.get('all-savedObject-all-2', 'update'), actions.savedObject.get('all-savedObject-all-2', 'bulk_update'), actions.savedObject.get('all-savedObject-all-2', 'delete'), + actions.savedObject.get('all-savedObject-all-2', 'share_to_space'), actions.savedObject.get('all-savedObject-read-1', 'bulk_get'), actions.savedObject.get('all-savedObject-read-1', 'get'), actions.savedObject.get('all-savedObject-read-1', 'find'), @@ -135,6 +137,7 @@ describe('features', () => { actions.savedObject.get('read-savedObject-all-1', 'update'), actions.savedObject.get('read-savedObject-all-1', 'bulk_update'), actions.savedObject.get('read-savedObject-all-1', 'delete'), + actions.savedObject.get('read-savedObject-all-1', 'share_to_space'), actions.savedObject.get('read-savedObject-all-2', 'bulk_get'), actions.savedObject.get('read-savedObject-all-2', 'get'), actions.savedObject.get('read-savedObject-all-2', 'find'), @@ -143,6 +146,7 @@ describe('features', () => { actions.savedObject.get('read-savedObject-all-2', 'update'), actions.savedObject.get('read-savedObject-all-2', 'bulk_update'), actions.savedObject.get('read-savedObject-all-2', 'delete'), + actions.savedObject.get('read-savedObject-all-2', 'share_to_space'), actions.savedObject.get('read-savedObject-read-1', 'bulk_get'), actions.savedObject.get('read-savedObject-read-1', 'get'), actions.savedObject.get('read-savedObject-read-1', 'find'), @@ -276,6 +280,7 @@ describe('features', () => { actions.savedObject.get('all-savedObject-all-1', 'update'), actions.savedObject.get('all-savedObject-all-1', 'bulk_update'), actions.savedObject.get('all-savedObject-all-1', 'delete'), + actions.savedObject.get('all-savedObject-all-1', 'share_to_space'), actions.savedObject.get('all-savedObject-all-2', 'bulk_get'), actions.savedObject.get('all-savedObject-all-2', 'get'), actions.savedObject.get('all-savedObject-all-2', 'find'), @@ -284,6 +289,7 @@ describe('features', () => { actions.savedObject.get('all-savedObject-all-2', 'update'), actions.savedObject.get('all-savedObject-all-2', 'bulk_update'), actions.savedObject.get('all-savedObject-all-2', 'delete'), + actions.savedObject.get('all-savedObject-all-2', 'share_to_space'), actions.savedObject.get('all-savedObject-read-1', 'bulk_get'), actions.savedObject.get('all-savedObject-read-1', 'get'), actions.savedObject.get('all-savedObject-read-1', 'find'), @@ -304,6 +310,7 @@ describe('features', () => { actions.savedObject.get('read-savedObject-all-1', 'update'), actions.savedObject.get('read-savedObject-all-1', 'bulk_update'), actions.savedObject.get('read-savedObject-all-1', 'delete'), + actions.savedObject.get('read-savedObject-all-1', 'share_to_space'), actions.savedObject.get('read-savedObject-all-2', 'bulk_get'), actions.savedObject.get('read-savedObject-all-2', 'get'), actions.savedObject.get('read-savedObject-all-2', 'find'), @@ -312,6 +319,7 @@ describe('features', () => { actions.savedObject.get('read-savedObject-all-2', 'update'), actions.savedObject.get('read-savedObject-all-2', 'bulk_update'), actions.savedObject.get('read-savedObject-all-2', 'delete'), + actions.savedObject.get('read-savedObject-all-2', 'share_to_space'), actions.savedObject.get('read-savedObject-read-1', 'bulk_get'), actions.savedObject.get('read-savedObject-read-1', 'get'), actions.savedObject.get('read-savedObject-read-1', 'find'), @@ -387,6 +395,7 @@ describe('features', () => { actions.savedObject.get('read-savedObject-all-1', 'update'), actions.savedObject.get('read-savedObject-all-1', 'bulk_update'), actions.savedObject.get('read-savedObject-all-1', 'delete'), + actions.savedObject.get('read-savedObject-all-1', 'share_to_space'), actions.savedObject.get('read-savedObject-all-2', 'bulk_get'), actions.savedObject.get('read-savedObject-all-2', 'get'), actions.savedObject.get('read-savedObject-all-2', 'find'), @@ -395,6 +404,7 @@ describe('features', () => { actions.savedObject.get('read-savedObject-all-2', 'update'), actions.savedObject.get('read-savedObject-all-2', 'bulk_update'), actions.savedObject.get('read-savedObject-all-2', 'delete'), + actions.savedObject.get('read-savedObject-all-2', 'share_to_space'), actions.savedObject.get('read-savedObject-read-1', 'bulk_get'), actions.savedObject.get('read-savedObject-read-1', 'get'), actions.savedObject.get('read-savedObject-read-1', 'find'), @@ -692,6 +702,7 @@ describe('reserved', () => { actions.savedObject.get('savedObject-all-1', 'update'), actions.savedObject.get('savedObject-all-1', 'bulk_update'), actions.savedObject.get('savedObject-all-1', 'delete'), + actions.savedObject.get('savedObject-all-1', 'share_to_space'), actions.savedObject.get('savedObject-all-2', 'bulk_get'), actions.savedObject.get('savedObject-all-2', 'get'), actions.savedObject.get('savedObject-all-2', 'find'), @@ -700,6 +711,7 @@ describe('reserved', () => { actions.savedObject.get('savedObject-all-2', 'update'), actions.savedObject.get('savedObject-all-2', 'bulk_update'), actions.savedObject.get('savedObject-all-2', 'delete'), + actions.savedObject.get('savedObject-all-2', 'share_to_space'), actions.savedObject.get('savedObject-read-1', 'bulk_get'), actions.savedObject.get('savedObject-read-1', 'get'), actions.savedObject.get('savedObject-read-1', 'find'), @@ -822,6 +834,7 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'update'), actions.savedObject.get('all-sub-feature-type', 'bulk_update'), actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('all-sub-feature-type', 'share_to_space'), actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), actions.savedObject.get('read-sub-feature-type', 'find'), @@ -950,6 +963,7 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'update'), actions.savedObject.get('all-sub-feature-type', 'bulk_update'), actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('all-sub-feature-type', 'share_to_space'), actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), actions.savedObject.get('read-sub-feature-type', 'find'), @@ -967,6 +981,7 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'update'), actions.savedObject.get('all-sub-feature-type', 'bulk_update'), actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('all-sub-feature-type', 'share_to_space'), actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), actions.savedObject.get('read-sub-feature-type', 'find'), @@ -991,6 +1006,7 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'update'), actions.savedObject.get('all-sub-feature-type', 'bulk_update'), actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('all-sub-feature-type', 'share_to_space'), actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), actions.savedObject.get('read-sub-feature-type', 'find'), @@ -1021,6 +1037,7 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'update'), actions.savedObject.get('all-sub-feature-type', 'bulk_update'), actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('all-sub-feature-type', 'share_to_space'), actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), actions.savedObject.get('read-sub-feature-type', 'find'), @@ -1038,6 +1055,7 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'update'), actions.savedObject.get('all-sub-feature-type', 'bulk_update'), actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('all-sub-feature-type', 'share_to_space'), actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), actions.savedObject.get('read-sub-feature-type', 'find'), @@ -1056,6 +1074,7 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'update'), actions.savedObject.get('all-sub-feature-type', 'bulk_update'), actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('all-sub-feature-type', 'share_to_space'), actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), actions.savedObject.get('read-sub-feature-type', 'find'), @@ -1073,6 +1092,7 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'update'), actions.savedObject.get('all-sub-feature-type', 'bulk_update'), actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('all-sub-feature-type', 'share_to_space'), actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), actions.savedObject.get('read-sub-feature-type', 'find'), @@ -1151,6 +1171,7 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'update'), actions.savedObject.get('all-sub-feature-type', 'bulk_update'), actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('all-sub-feature-type', 'share_to_space'), actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), actions.savedObject.get('read-sub-feature-type', 'find'), @@ -1168,6 +1189,7 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'update'), actions.savedObject.get('all-sub-feature-type', 'bulk_update'), actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('all-sub-feature-type', 'share_to_space'), actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), actions.savedObject.get('read-sub-feature-type', 'find'), @@ -1192,6 +1214,7 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'update'), actions.savedObject.get('all-sub-feature-type', 'bulk_update'), actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('all-sub-feature-type', 'share_to_space'), actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), actions.savedObject.get('read-sub-feature-type', 'find'), @@ -1292,6 +1315,7 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'update'), actions.savedObject.get('all-sub-feature-type', 'bulk_update'), actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('all-sub-feature-type', 'share_to_space'), actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), actions.savedObject.get('read-sub-feature-type', 'find'), @@ -1309,6 +1333,7 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'update'), actions.savedObject.get('all-sub-feature-type', 'bulk_update'), actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('all-sub-feature-type', 'share_to_space'), actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), actions.savedObject.get('read-sub-feature-type', 'find'), @@ -1351,6 +1376,7 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'update'), actions.savedObject.get('all-sub-feature-type', 'bulk_update'), actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('all-sub-feature-type', 'share_to_space'), actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), actions.savedObject.get('read-sub-feature-type', 'find'), @@ -1374,6 +1400,7 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'update'), actions.savedObject.get('all-sub-feature-type', 'bulk_update'), actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('all-sub-feature-type', 'share_to_space'), actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), actions.savedObject.get('read-sub-feature-type', 'find'), @@ -1457,6 +1484,7 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'update'), actions.savedObject.get('all-sub-feature-type', 'bulk_update'), actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('all-sub-feature-type', 'share_to_space'), actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), actions.savedObject.get('read-sub-feature-type', 'find'), @@ -1474,6 +1502,7 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'update'), actions.savedObject.get('all-sub-feature-type', 'bulk_update'), actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('all-sub-feature-type', 'share_to_space'), actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), actions.savedObject.get('read-sub-feature-type', 'find'), @@ -1588,6 +1617,7 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'update'), actions.savedObject.get('all-sub-feature-type', 'bulk_update'), actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('all-sub-feature-type', 'share_to_space'), actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), actions.savedObject.get('read-sub-feature-type', 'find'), @@ -1608,6 +1638,7 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'update'), actions.savedObject.get('all-sub-feature-type', 'bulk_update'), actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('all-sub-feature-type', 'share_to_space'), actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), actions.savedObject.get('read-sub-feature-type', 'find'), @@ -1634,6 +1665,7 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'update'), actions.savedObject.get('all-sub-feature-type', 'bulk_update'), actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('all-sub-feature-type', 'share_to_space'), actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), actions.savedObject.get('read-sub-feature-type', 'find'), @@ -1651,6 +1683,7 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'update'), actions.savedObject.get('all-sub-feature-type', 'bulk_update'), actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('all-sub-feature-type', 'share_to_space'), actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), actions.savedObject.get('read-sub-feature-type', 'find'), @@ -1669,6 +1702,7 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'update'), actions.savedObject.get('all-sub-feature-type', 'bulk_update'), actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('all-sub-feature-type', 'share_to_space'), actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), actions.savedObject.get('read-sub-feature-type', 'find'), @@ -1686,6 +1720,7 @@ describe('subFeatures', () => { actions.savedObject.get('all-sub-feature-type', 'update'), actions.savedObject.get('all-sub-feature-type', 'bulk_update'), actions.savedObject.get('all-sub-feature-type', 'delete'), + actions.savedObject.get('all-sub-feature-type', 'share_to_space'), actions.savedObject.get('read-sub-feature-type', 'bulk_get'), actions.savedObject.get('read-sub-feature-type', 'get'), actions.savedObject.get('read-sub-feature-type', 'find'), diff --git a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts index 86d1b68ba761e..c7e5cee1ed18c 100644 --- a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts +++ b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts @@ -120,7 +120,7 @@ const expectSuccess = async (fn: Function, args: Record, action?: s const expectPrivilegeCheck = async ( fn: Function, args: Record, - namespacesOverride?: Array + namespaceOrNamespaces: string | undefined | Array ) => { clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockImplementation( getMockCheckPrivilegesFailure @@ -135,7 +135,7 @@ const expectPrivilegeCheck = async ( expect(clientOpts.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledTimes(1); expect(clientOpts.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith( actions, - namespacesOverride ?? args.options?.namespace ?? args.options?.namespaces + namespaceOrNamespaces ); }; @@ -154,7 +154,7 @@ const expectObjectNamespaceFiltering = async ( ); const authorizedNamespace = args.options?.namespace || 'default'; - const namespaces = ['some-other-namespace', authorizedNamespace]; + const namespaces = ['some-other-namespace', '*', authorizedNamespace]; const returnValue = { namespaces, foo: 'bar' }; // we don't know which base client method will be called; mock them all clientOpts.baseClient.create.mockReturnValue(returnValue as any); @@ -164,14 +164,15 @@ const expectObjectNamespaceFiltering = async ( clientOpts.baseClient.deleteFromNamespaces.mockReturnValue(returnValue as any); const result = await fn.bind(client)(...Object.values(args)); - expect(result).toEqual(expect.objectContaining({ namespaces: [authorizedNamespace, '?'] })); + // we will never redact the "All Spaces" ID + expect(result).toEqual(expect.objectContaining({ namespaces: ['*', authorizedNamespace, '?'] })); expect(clientOpts.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledTimes( privilegeChecks + 1 ); expect(clientOpts.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenLastCalledWith( 'login:', - namespaces + namespaces.filter((x) => x !== '*') // when we check what namespaces to redact, we don't check privileges for '*', only actual space IDs ); }; @@ -282,8 +283,7 @@ describe('#addToNamespaces', () => { const newNs2 = 'bar-namespace'; const namespaces = [newNs1, newNs2]; const currentNs = 'default'; - const privilege1 = `mock-saved_object:${type}/create`; - const privilege2 = `mock-saved_object:${type}/update`; + const privilege = `mock-saved_object:${type}/share_to_space`; test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { await expectGeneralError(client.addToNamespaces, { type, id, namespaces }); @@ -305,7 +305,7 @@ describe('#addToNamespaces', () => { 'addToNamespacesCreate', [type], namespaces.sort(), - [{ privilege: privilege1, spaceId: newNs1 }], + [{ privilege, spaceId: newNs1 }], { id, type, namespaces, options: {} } ); expect(clientOpts.auditLogger.savedObjectsAuthorizationSuccess).not.toHaveBeenCalled(); @@ -332,7 +332,7 @@ describe('#addToNamespaces', () => { 'addToNamespacesUpdate', [type], [currentNs], - [{ privilege: privilege2, spaceId: currentNs }], + [{ privilege, spaceId: currentNs }], { id, type, namespaces, options: {} } ); expect(clientOpts.auditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledTimes(1); @@ -350,7 +350,7 @@ describe('#addToNamespaces', () => { expect(clientOpts.auditLogger.savedObjectsAuthorizationSuccess).toHaveBeenNthCalledWith( 1, USERNAME, - 'addToNamespacesCreate', // action for privilege check is 'create', but auditAction is 'addToNamespacesCreate' + 'addToNamespacesCreate', // action for privilege check is 'share_to_space', but auditAction is 'addToNamespacesCreate' [type], namespaces.sort(), { type, id, namespaces, options: {} } @@ -358,7 +358,7 @@ describe('#addToNamespaces', () => { expect(clientOpts.auditLogger.savedObjectsAuthorizationSuccess).toHaveBeenNthCalledWith( 2, USERNAME, - 'addToNamespacesUpdate', // action for privilege check is 'update', but auditAction is 'addToNamespacesUpdate' + 'addToNamespacesUpdate', // action for privilege check is 'share_to_space', but auditAction is 'addToNamespacesUpdate' [type], [currentNs], { type, id, namespaces, options: {} } @@ -378,12 +378,12 @@ describe('#addToNamespaces', () => { expect(clientOpts.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledTimes(2); expect(clientOpts.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenNthCalledWith( 1, - [privilege1], + [privilege], namespaces ); expect(clientOpts.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenNthCalledWith( 2, - [privilege2], + [privilege], undefined // default namespace ); }); @@ -398,7 +398,7 @@ describe('#bulkCreate', () => { const attributes = { some: 'attr' }; const obj1 = Object.freeze({ type: 'foo', otherThing: 'sup', attributes }); const obj2 = Object.freeze({ type: 'bar', otherThing: 'everyone', attributes }); - const options = Object.freeze({ namespace: 'some-ns' }); + const namespace = 'some-ns'; test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { const objects = [obj1]; @@ -407,6 +407,7 @@ describe('#bulkCreate', () => { test(`throws decorated ForbiddenError when unauthorized`, async () => { const objects = [obj1, obj2]; + const options = { namespace }; await expectForbiddenError(client.bulkCreate, { objects, options }); }); @@ -415,17 +416,33 @@ describe('#bulkCreate', () => { clientOpts.baseClient.bulkCreate.mockReturnValue(apiCallReturnValue as any); const objects = [obj1, obj2]; + const options = { namespace }; const result = await expectSuccess(client.bulkCreate, { objects, options }); expect(result).toEqual(apiCallReturnValue); }); test(`checks privileges for user, actions, and namespace`, async () => { const objects = [obj1, obj2]; - await expectPrivilegeCheck(client.bulkCreate, { objects, options }); + const options = { namespace }; + await expectPrivilegeCheck(client.bulkCreate, { objects, options }, [namespace]); + }); + + test(`checks privileges for user, actions, namespace, and namespaces`, async () => { + const objects = [ + { ...obj1, namespaces: 'another-ns' }, + { ...obj2, namespaces: 'yet-another-ns' }, + ]; + const options = { namespace }; + await expectPrivilegeCheck(client.bulkCreate, { objects, options }, [ + namespace, + 'another-ns', + 'yet-another-ns', + ]); }); test(`filters namespaces that the user doesn't have access to`, async () => { const objects = [obj1, obj2]; + const options = { namespace }; await expectObjectsNamespaceFiltering(client.bulkCreate, { objects, options }); }); }); @@ -433,7 +450,7 @@ describe('#bulkCreate', () => { describe('#bulkGet', () => { const obj1 = Object.freeze({ type: 'foo', id: 'foo-id' }); const obj2 = Object.freeze({ type: 'bar', id: 'bar-id' }); - const options = Object.freeze({ namespace: 'some-ns' }); + const namespace = 'some-ns'; test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { const objects = [obj1]; @@ -442,6 +459,7 @@ describe('#bulkGet', () => { test(`throws decorated ForbiddenError when unauthorized`, async () => { const objects = [obj1, obj2]; + const options = { namespace }; await expectForbiddenError(client.bulkGet, { objects, options }); }); @@ -450,17 +468,20 @@ describe('#bulkGet', () => { clientOpts.baseClient.bulkGet.mockReturnValue(apiCallReturnValue as any); const objects = [obj1, obj2]; + const options = { namespace }; const result = await expectSuccess(client.bulkGet, { objects, options }); expect(result).toEqual(apiCallReturnValue); }); test(`checks privileges for user, actions, and namespace`, async () => { const objects = [obj1, obj2]; - await expectPrivilegeCheck(client.bulkGet, { objects, options }); + const options = { namespace }; + await expectPrivilegeCheck(client.bulkGet, { objects, options }, namespace); }); test(`filters namespaces that the user doesn't have access to`, async () => { const objects = [obj1, obj2]; + const options = { namespace }; await expectObjectsNamespaceFiltering(client.bulkGet, { objects, options }); }); }); @@ -468,7 +489,7 @@ describe('#bulkGet', () => { describe('#bulkUpdate', () => { const obj1 = Object.freeze({ type: 'foo', id: 'foo-id', attributes: { some: 'attr' } }); const obj2 = Object.freeze({ type: 'bar', id: 'bar-id', attributes: { other: 'attr' } }); - const options = Object.freeze({ namespace: 'some-ns' }); + const namespace = 'some-ns'; test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { const objects = [obj1]; @@ -477,6 +498,7 @@ describe('#bulkUpdate', () => { test(`throws decorated ForbiddenError when unauthorized`, async () => { const objects = [obj1, obj2]; + const options = { namespace }; await expectForbiddenError(client.bulkUpdate, { objects, options }); }); @@ -485,14 +507,16 @@ describe('#bulkUpdate', () => { clientOpts.baseClient.bulkUpdate.mockReturnValue(apiCallReturnValue as any); const objects = [obj1, obj2]; + const options = { namespace }; const result = await expectSuccess(client.bulkUpdate, { objects, options }); expect(result).toEqual(apiCallReturnValue); }); test(`checks privileges for user, actions, and namespace`, async () => { const objects = [obj1, obj2]; - const namespacesOverride = [options.namespace]; // the bulkCreate function checks privileges as an array - await expectPrivilegeCheck(client.bulkUpdate, { objects, options }, namespacesOverride); + const options = { namespace }; + const namespaces = [options.namespace]; // the bulkUpdate function always checks privileges as an array + await expectPrivilegeCheck(client.bulkUpdate, { objects, options }, namespaces); }); test(`checks privileges for object namespaces if present`, async () => { @@ -500,13 +524,14 @@ describe('#bulkUpdate', () => { { ...obj1, namespace: 'foo-ns' }, { ...obj2, namespace: 'bar-ns' }, ]; - const namespacesOverride = [undefined, 'foo-ns', 'bar-ns']; - // use the default namespace for the options - await expectPrivilegeCheck(client.bulkUpdate, { objects, options: {} }, namespacesOverride); + const namespaces = [undefined, 'foo-ns', 'bar-ns']; + const options = {}; // use the default namespace for the options + await expectPrivilegeCheck(client.bulkUpdate, { objects, options }, namespaces); }); test(`filters namespaces that the user doesn't have access to`, async () => { const objects = [obj1, obj2]; + const options = { namespace }; await expectObjectsNamespaceFiltering(client.bulkUpdate, { objects, options }); }); }); @@ -514,7 +539,7 @@ describe('#bulkUpdate', () => { describe('#checkConflicts', () => { const obj1 = Object.freeze({ type: 'foo', id: 'foo-id' }); const obj2 = Object.freeze({ type: 'bar', id: 'bar-id' }); - const options = Object.freeze({ namespace: 'some-ns' }); + const namespace = 'some-ns'; test(`throws decorated GeneralError when checkPrivileges.globally rejects promise`, async () => { const objects = [obj1, obj2]; @@ -523,6 +548,7 @@ describe('#checkConflicts', () => { test(`throws decorated ForbiddenError when unauthorized`, async () => { const objects = [obj1, obj2]; + const options = { namespace }; await expectForbiddenError(client.checkConflicts, { objects, options }, 'checkConflicts'); }); @@ -531,6 +557,7 @@ describe('#checkConflicts', () => { clientOpts.baseClient.checkConflicts.mockResolvedValue(apiCallReturnValue as any); const objects = [obj1, obj2]; + const options = { namespace }; const result = await expectSuccess( client.checkConflicts, { objects, options }, @@ -541,20 +568,22 @@ describe('#checkConflicts', () => { test(`checks privileges for user, actions, and namespace`, async () => { const objects = [obj1, obj2]; - await expectPrivilegeCheck(client.checkConflicts, { objects, options }); + const options = { namespace }; + await expectPrivilegeCheck(client.checkConflicts, { objects, options }, namespace); }); }); describe('#create', () => { const type = 'foo'; const attributes = { some_attr: 's' }; - const options = Object.freeze({ namespace: 'some-ns' }); + const namespace = 'some-ns'; test(`throws decorated GeneralError when checkPrivileges.globally rejects promise`, async () => { await expectGeneralError(client.create, { type }); }); test(`throws decorated ForbiddenError when unauthorized`, async () => { + const options = { namespace }; await expectForbiddenError(client.create, { type, attributes, options }); }); @@ -562,15 +591,27 @@ describe('#create', () => { const apiCallReturnValue = Symbol(); clientOpts.baseClient.create.mockResolvedValue(apiCallReturnValue as any); + const options = { namespace }; const result = await expectSuccess(client.create, { type, attributes, options }); expect(result).toBe(apiCallReturnValue); }); test(`checks privileges for user, actions, and namespace`, async () => { - await expectPrivilegeCheck(client.create, { type, attributes, options }); + const options = { namespace }; + await expectPrivilegeCheck(client.create, { type, attributes, options }, [namespace]); + }); + + test(`checks privileges for user, actions, namespace, and namespaces`, async () => { + const options = { namespace, namespaces: ['another-ns', 'yet-another-ns'] }; + await expectPrivilegeCheck(client.create, { type, attributes, options }, [ + namespace, + 'another-ns', + 'yet-another-ns', + ]); }); test(`filters namespaces that the user doesn't have access to`, async () => { + const options = { namespace }; await expectObjectNamespaceFiltering(client.create, { type, attributes, options }); }); }); @@ -578,13 +619,14 @@ describe('#create', () => { describe('#delete', () => { const type = 'foo'; const id = `${type}-id`; - const options = Object.freeze({ namespace: 'some-ns' }); + const namespace = 'some-ns'; test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { await expectGeneralError(client.delete, { type, id }); }); test(`throws decorated ForbiddenError when unauthorized`, async () => { + const options = { namespace }; await expectForbiddenError(client.delete, { type, id, options }); }); @@ -592,18 +634,21 @@ describe('#delete', () => { const apiCallReturnValue = Symbol(); clientOpts.baseClient.delete.mockReturnValue(apiCallReturnValue as any); + const options = { namespace }; const result = await expectSuccess(client.delete, { type, id, options }); expect(result).toBe(apiCallReturnValue); }); test(`checks privileges for user, actions, and namespace`, async () => { - await expectPrivilegeCheck(client.delete, { type, id, options }); + const options = { namespace }; + await expectPrivilegeCheck(client.delete, { type, id, options }, namespace); }); }); describe('#find', () => { const type1 = 'foo'; const type2 = 'bar'; + const namespaces = ['some-ns']; test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { await expectGeneralError(client.find, { type: type1 }); @@ -634,7 +679,7 @@ describe('#find', () => { const apiCallReturnValue = { saved_objects: [], foo: 'bar' }; clientOpts.baseClient.find.mockReturnValue(apiCallReturnValue as any); - const options = Object.freeze({ type: type1, namespaces: ['some-ns'] }); + const options = { type: type1, namespaces }; const result = await expectSuccess(client.find, { options }); expect(clientOpts.baseClient.find.mock.calls[0][0]).toEqual({ ...options, @@ -699,19 +744,19 @@ describe('#find', () => { getMockCheckPrivilegesSuccess ); - const options = Object.freeze({ type: [type1, type2], namespaces: ['some-ns'] }); + const options = { type: [type1, type2], namespaces }; await expect(client.find(options)).rejects.toThrowErrorMatchingInlineSnapshot( `"_find across namespaces is not permitted when the Spaces plugin is disabled."` ); }); test(`checks privileges for user, actions, and namespaces`, async () => { - const options = Object.freeze({ type: [type1, type2], namespaces: ['some-ns'] }); - await expectPrivilegeCheck(client.find, { options }); + const options = { type: [type1, type2], namespaces }; + await expectPrivilegeCheck(client.find, { options }, namespaces); }); test(`filters namespaces that the user doesn't have access to`, async () => { - const options = Object.freeze({ type: [type1, type2], namespaces: ['some-ns'] }); + const options = { type: [type1, type2], namespaces }; await expectObjectsNamespaceFiltering(client.find, { options }); }); }); @@ -719,13 +764,14 @@ describe('#find', () => { describe('#get', () => { const type = 'foo'; const id = `${type}-id`; - const options = Object.freeze({ namespace: 'some-ns' }); + const namespace = 'some-ns'; test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { await expectGeneralError(client.get, { type, id }); }); test(`throws decorated ForbiddenError when unauthorized`, async () => { + const options = { namespace }; await expectForbiddenError(client.get, { type, id, options }); }); @@ -733,15 +779,18 @@ describe('#get', () => { const apiCallReturnValue = Symbol(); clientOpts.baseClient.get.mockReturnValue(apiCallReturnValue as any); + const options = { namespace }; const result = await expectSuccess(client.get, { type, id, options }); expect(result).toBe(apiCallReturnValue); }); test(`checks privileges for user, actions, and namespace`, async () => { - await expectPrivilegeCheck(client.get, { type, id, options }); + const options = { namespace }; + await expectPrivilegeCheck(client.get, { type, id, options }, namespace); }); test(`filters namespaces that the user doesn't have access to`, async () => { + const options = { namespace }; await expectObjectNamespaceFiltering(client.get, { type, id, options }); }); }); @@ -752,7 +801,7 @@ describe('#deleteFromNamespaces', () => { const namespace1 = 'foo-namespace'; const namespace2 = 'bar-namespace'; const namespaces = [namespace1, namespace2]; - const privilege = `mock-saved_object:${type}/delete`; + const privilege = `mock-saved_object:${type}/share_to_space`; test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { await expectGeneralError(client.deleteFromNamespaces, { type, id, namespaces }); @@ -771,7 +820,7 @@ describe('#deleteFromNamespaces', () => { expect(clientOpts.auditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledTimes(1); expect(clientOpts.auditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledWith( USERNAME, - 'deleteFromNamespaces', // action for privilege check is 'delete', but auditAction is 'deleteFromNamespaces' + 'deleteFromNamespaces', // action for privilege check is 'share_to_space', but auditAction is 'deleteFromNamespaces' [type], namespaces.sort(), [{ privilege, spaceId: namespace1 }], @@ -791,7 +840,7 @@ describe('#deleteFromNamespaces', () => { expect(clientOpts.auditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledTimes(1); expect(clientOpts.auditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith( USERNAME, - 'deleteFromNamespaces', // action for privilege check is 'delete', but auditAction is 'deleteFromNamespaces' + 'deleteFromNamespaces', // action for privilege check is 'share_to_space', but auditAction is 'deleteFromNamespaces' [type], namespaces.sort(), { type, id, namespaces, options: {} } @@ -821,13 +870,14 @@ describe('#update', () => { const type = 'foo'; const id = `${type}-id`; const attributes = { some: 'attr' }; - const options = Object.freeze({ namespace: 'some-ns' }); + const namespace = 'some-ns'; test(`throws decorated GeneralError when hasPrivileges rejects promise`, async () => { await expectGeneralError(client.update, { type, id, attributes }); }); test(`throws decorated ForbiddenError when unauthorized`, async () => { + const options = { namespace }; await expectForbiddenError(client.update, { type, id, attributes, options }); }); @@ -835,15 +885,18 @@ describe('#update', () => { const apiCallReturnValue = Symbol(); clientOpts.baseClient.update.mockReturnValue(apiCallReturnValue as any); + const options = { namespace }; const result = await expectSuccess(client.update, { type, id, attributes, options }); expect(result).toBe(apiCallReturnValue); }); test(`checks privileges for user, actions, and namespace`, async () => { - await expectPrivilegeCheck(client.update, { type, id, attributes, options }); + const options = { namespace }; + await expectPrivilegeCheck(client.update, { type, id, attributes, options }, namespace); }); test(`filters namespaces that the user doesn't have access to`, async () => { + const options = { namespace }; await expectObjectNamespaceFiltering(client.update, { type, id, attributes, options }); }); }); diff --git a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.ts b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.ts index f5de8f4b226f3..ad0bc085eb8e2 100644 --- a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.ts +++ b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.ts @@ -18,6 +18,7 @@ import { SavedObjectsDeleteFromNamespacesOptions, SavedObjectsUtils, } from '../../../../../src/core/server'; +import { ALL_SPACES_ID, UNKNOWN_SPACE } from '../../common/constants'; import { SecurityAuditLogger } from '../audit'; import { Actions, CheckSavedObjectsPrivileges } from '../authorization'; import { CheckPrivilegesResponse } from '../authorization/types'; @@ -85,7 +86,8 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra options: SavedObjectsCreateOptions = {} ) { const args = { type, attributes, options }; - await this.ensureAuthorized(type, 'create', options.namespace, { args }); + const namespaces = [options.namespace, ...(options.namespaces || [])]; + await this.ensureAuthorized(type, 'create', namespaces, { args }); const savedObject = await this.baseClient.create(type, attributes, options); return await this.redactSavedObjectNamespaces(savedObject); @@ -111,13 +113,17 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra options: SavedObjectsBaseOptions = {} ) { const args = { objects, options }; - await this.ensureAuthorized( - this.getUniqueObjectTypes(objects), - 'bulk_create', - options.namespace, - { args } + const namespaces = objects.reduce( + (acc, { namespaces: initialNamespaces = [] }) => { + return acc.concat(initialNamespaces); + }, + [options.namespace] ); + await this.ensureAuthorized(this.getUniqueObjectTypes(objects), 'bulk_create', namespaces, { + args, + }); + const response = await this.baseClient.bulkCreate(objects, options); return await this.redactSavedObjectsNamespaces(response); } @@ -207,17 +213,17 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra ) { const args = { type, id, namespaces, options }; const { namespace } = options; - // To share an object, the user must have the "create" permission in each of the destination namespaces. - await this.ensureAuthorized(type, 'create', namespaces, { + // To share an object, the user must have the "share_to_space" permission in each of the destination namespaces. + await this.ensureAuthorized(type, 'share_to_space', namespaces, { args, auditAction: 'addToNamespacesCreate', }); - // To share an object, the user must also have the "update" permission in one or more of the source namespaces. Because the - // `addToNamespaces` operation is scoped to the current namespace, we can just check if the user has the "update" permission in the - // current namespace. If the user has permission, but the saved object doesn't exist in this namespace, the base client operation will - // result in a 404 error. - await this.ensureAuthorized(type, 'update', namespace, { + // To share an object, the user must also have the "share_to_space" permission in one or more of the source namespaces. Because the + // `addToNamespaces` operation is scoped to the current namespace, we can just check if the user has the "share_to_space" permission in + // the current namespace. If the user has permission, but the saved object doesn't exist in this namespace, the base client operation + // will result in a 404 error. + await this.ensureAuthorized(type, 'share_to_space', namespace, { args, auditAction: 'addToNamespacesUpdate', }); @@ -233,8 +239,8 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra options: SavedObjectsDeleteFromNamespacesOptions = {} ) { const args = { type, id, namespaces, options }; - // To un-share an object, the user must have the "delete" permission in each of the target namespaces. - await this.ensureAuthorized(type, 'delete', namespaces, { + // To un-share an object, the user must have the "share_to_space" permission in each of the target namespaces. + await this.ensureAuthorized(type, 'share_to_space', namespaces, { args, auditAction: 'deleteFromNamespaces', }); @@ -383,7 +389,9 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra } private redactAndSortNamespaces(spaceIds: string[], privilegeMap: Record) { - return spaceIds.map((x) => (privilegeMap[x] ? x : '?')).sort(namespaceComparator); + return spaceIds + .map((x) => (x === ALL_SPACES_ID || privilegeMap[x] ? x : UNKNOWN_SPACE)) + .sort(namespaceComparator); } private async redactSavedObjectNamespaces( @@ -397,7 +405,12 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra return savedObject; } - const privilegeMap = await this.getNamespacesPrivilegeMap(savedObject.namespaces); + const namespaces = savedObject.namespaces.filter((x) => x !== ALL_SPACES_ID); // all users can see the "all spaces" ID + if (namespaces.length === 0) { + return savedObject; + } + + const privilegeMap = await this.getNamespacesPrivilegeMap(namespaces); return { ...savedObject, @@ -412,7 +425,9 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra return response; } const { saved_objects: savedObjects } = response; - const namespaces = uniq(savedObjects.flatMap((savedObject) => savedObject.namespaces || [])); + const namespaces = uniq( + savedObjects.flatMap((savedObject) => savedObject.namespaces || []) + ).filter((x) => x !== ALL_SPACES_ID); // all users can see the "all spaces" ID if (namespaces.length === 0) { return response; } @@ -445,9 +460,9 @@ function uniq(arr: T[]): T[] { function namespaceComparator(a: string, b: string) { const A = a.toUpperCase(); const B = b.toUpperCase(); - if (A === '?' && B !== '?') { + if (A === UNKNOWN_SPACE && B !== UNKNOWN_SPACE) { return 1; - } else if (A !== '?' && B === '?') { + } else if (A !== UNKNOWN_SPACE && B === UNKNOWN_SPACE) { return -1; } return A > B ? 1 : A < B ? -1 : 0; diff --git a/x-pack/plugins/spaces/common/constants.ts b/x-pack/plugins/spaces/common/constants.ts index 33f1aae70ea00..bd47fe7b8b877 100644 --- a/x-pack/plugins/spaces/common/constants.ts +++ b/x-pack/plugins/spaces/common/constants.ts @@ -6,6 +6,16 @@ export const DEFAULT_SPACE_ID = `default`; +/** + * The identifier in a saved object's `namespaces` array when it is shared globally to all spaces. + */ +export const ALL_SPACES_ID = '*'; + +/** + * The identifier in a saved object's `namespaces` array when it is shared to an unknown space (e.g., one that the end user is not authorized to see). + */ +export const UNKNOWN_SPACE = '?'; + /** * The minimum number of spaces required to show a search control. */ diff --git a/x-pack/plugins/spaces/public/lib/documentation_links.test.ts b/x-pack/plugins/spaces/public/lib/documentation_links.test.ts new file mode 100644 index 0000000000000..5531953030405 --- /dev/null +++ b/x-pack/plugins/spaces/public/lib/documentation_links.test.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { docLinksServiceMock } from '../../../../../src/core/public/mocks'; +import { DocumentationLinksService } from './documentation_links'; + +describe('DocumentationLinksService', () => { + const setup = () => { + const docLinks = docLinksServiceMock.createStartContract(); + const service = new DocumentationLinksService(docLinks); + return { docLinks, service }; + }; + + describe('#getKibanaPrivilegesDocUrl', () => { + it('returns expected value', () => { + const { service } = setup(); + expect(service.getKibanaPrivilegesDocUrl()).toMatchInlineSnapshot( + `"https://www.elastic.co/guide/en/kibana/mocked-test-branch/kibana-privileges.html"` + ); + }); + }); +}); diff --git a/x-pack/plugins/spaces/public/lib/documentation_links.ts b/x-pack/plugins/spaces/public/lib/documentation_links.ts new file mode 100644 index 0000000000000..71ba89d5b87e2 --- /dev/null +++ b/x-pack/plugins/spaces/public/lib/documentation_links.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { DocLinksStart } from 'src/core/public'; + +export class DocumentationLinksService { + private readonly kbn: string; + + constructor(docLinks: DocLinksStart) { + this.kbn = `${docLinks.ELASTIC_WEBSITE_URL}guide/en/kibana/${docLinks.DOC_LINK_VERSION}/`; + } + + public getKibanaPrivilegesDocUrl() { + return `${this.kbn}kibana-privileges.html`; + } +} diff --git a/x-pack/plugins/spaces/public/lib/index.ts b/x-pack/plugins/spaces/public/lib/index.ts new file mode 100644 index 0000000000000..87b54dd4e2ef3 --- /dev/null +++ b/x-pack/plugins/spaces/public/lib/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { DocumentationLinksService } from './documentation_links'; diff --git a/x-pack/plugins/spaces/public/plugin.tsx b/x-pack/plugins/spaces/public/plugin.tsx index cd31a4aa17fc3..2a08f41266456 100644 --- a/x-pack/plugins/spaces/public/plugin.tsx +++ b/x-pack/plugins/spaces/public/plugin.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { CoreSetup, CoreStart, Plugin } from 'src/core/public'; +import { CoreSetup, CoreStart, Plugin, StartServicesAccessor } from 'src/core/public'; import { HomePublicPluginSetup } from 'src/plugins/home/public'; import { SavedObjectsManagementPluginSetup } from 'src/plugins/saved_objects_management/public'; import { ManagementStart, ManagementSetup } from 'src/plugins/management/public'; @@ -53,7 +53,7 @@ export class SpacesPlugin implements Plugin['getStartServices'], + getStartServices: core.getStartServices as StartServicesAccessor, spacesManager: this.spacesManager, securityLicense: plugins.security?.license, }); @@ -73,6 +73,7 @@ export class SpacesPlugin implements Plugin, }); const copySavedObjectsToSpaceService = new CopySavedObjectsToSpaceService(); copySavedObjectsToSpaceService.setup({ diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/no_spaces_available.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/no_spaces_available.tsx new file mode 100644 index 0000000000000..afa65cc7ad7df --- /dev/null +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/no_spaces_available.tsx @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React from 'react'; +import { EuiLink, EuiSpacer, EuiText } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { ApplicationStart } from 'src/core/public'; + +interface Props { + application: ApplicationStart; +} + +export const NoSpacesAvailable = (props: Props) => { + const { capabilities, getUrlForApp } = props.application; + const canCreateNewSpaces = capabilities.spaces.manage; + if (!canCreateNewSpaces) { + return null; + } + + return ( + <> + + + + + + ), + }} + /> + + + ); +}; diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/selectable_spaces_control.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/selectable_spaces_control.tsx index 82a30dabe5beb..29303a478daeb 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/selectable_spaces_control.tsx +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/selectable_spaces_control.tsx @@ -5,8 +5,24 @@ */ import './selectable_spaces_control.scss'; -import React, { Fragment } from 'react'; -import { EuiBadge, EuiSelectable, EuiSelectableOption, EuiLoadingSpinner } from '@elastic/eui'; +import React from 'react'; +import { + EuiBadge, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiLink, + EuiSelectable, + EuiSelectableOption, + EuiSpacer, + EuiText, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { NoSpacesAvailable } from './no_spaces_available'; +import { useKibana } from '../../../../../../src/plugins/kibana_react/public'; +import { ALL_SPACES_ID, UNKNOWN_SPACE } from '../../../common/constants'; +import { DocumentationLinksService } from '../../lib'; import { SpaceAvatar } from '../../space_avatar'; import { SpaceTarget } from '../types'; @@ -14,11 +30,11 @@ interface Props { spaces: SpaceTarget[]; selectedSpaceIds: string[]; onChange: (selectedSpaceIds: string[]) => void; - disabled?: boolean; } type SpaceOption = EuiSelectableOption & { ['data-space-id']: string }; +const ROW_HEIGHT = 40; const activeSpaceProps = { append: Current, disabled: true, @@ -26,51 +42,127 @@ const activeSpaceProps = { }; export const SelectableSpacesControl = (props: Props) => { - if (props.spaces.length === 0) { - return ; - } + const { spaces, selectedSpaceIds, onChange } = props; + const { services } = useKibana(); + const { application, docLinks } = services; - const options = props.spaces + const isGlobalControlChecked = selectedSpaceIds.includes(ALL_SPACES_ID); + const options = spaces .sort((a, b) => (a.isActiveSpace ? -1 : b.isActiveSpace ? 1 : 0)) .map((space) => ({ label: space.name, prepend: , - checked: props.selectedSpaceIds.includes(space.id) ? 'on' : undefined, + checked: selectedSpaceIds.includes(space.id) ? 'on' : undefined, ['data-space-id']: space.id, ['data-test-subj']: `sts-space-selector-row-${space.id}`, ...(space.isActiveSpace ? activeSpaceProps : {}), + ...(isGlobalControlChecked && { disabled: true }), })); - function updateSelectedSpaces(selectedOptions: SpaceOption[]) { - if (props.disabled) return; - - const selectedSpaceIds = selectedOptions - .filter((opt) => opt.checked && !opt.disabled) - .map((opt) => opt['data-space-id']); + function updateSelectedSpaces(spaceOptions: SpaceOption[]) { + const selectedOptions = spaceOptions + .filter(({ checked, disabled }) => checked && !disabled) + .map((x) => x['data-space-id']); + const updatedSpaceIds = [ + ...selectedOptions, + ...selectedSpaceIds.filter((x) => x === UNKNOWN_SPACE), // preserve any unknown spaces (to keep the "selected spaces" count accurate) + ]; - props.onChange(selectedSpaceIds); + onChange(updatedSpaceIds); } + const getUnknownSpacesLabel = () => { + const count = selectedSpaceIds.filter((id) => id === UNKNOWN_SPACE).length; + if (!count) { + return null; + } + + const kibanaPrivilegesUrl = new DocumentationLinksService( + docLinks! + ).getKibanaPrivilegesDocUrl(); + return ( + <> + + + + + + ), + }} + /> + + + ); + }; + const getNoSpacesAvailable = () => { + if (spaces.length < 2) { + return ; + } + return null; + }; + + const selectedCount = + selectedSpaceIds.filter((id) => id !== ALL_SPACES_ID && id !== UNKNOWN_SPACE).length + 1; + const hiddenCount = selectedSpaceIds.filter((id) => id === UNKNOWN_SPACE).length; + const selectSpacesLabel = i18n.translate( + 'xpack.spaces.management.shareToSpace.shareModeControl.selectSpacesLabel', + { defaultMessage: 'Select spaces' } + ); + const selectedSpacesLabel = i18n.translate( + 'xpack.spaces.management.shareToSpace.shareModeControl.selectedCountLabel', + { defaultMessage: '{selectedCount} selected', values: { selectedCount } } + ); + const hiddenSpacesLabel = i18n.translate( + 'xpack.spaces.management.shareToSpace.shareModeControl.hiddenCountLabel', + { defaultMessage: '+{hiddenCount} hidden', values: { hiddenCount } } + ); + const hiddenSpaces = hiddenCount ? {hiddenSpacesLabel} : null; return ( - updateSelectedSpaces(newOptions as SpaceOption[])} - listProps={{ - bordered: true, - rowHeight: 40, - className: 'spcShareToSpace__spacesList', - 'data-test-subj': 'sts-form-space-selector', - }} - searchable + + + {selectedSpacesLabel} + + {hiddenSpaces} + + } + fullWidth > - {(list, search) => { - return ( - - {search} - {list} - - ); - }} - + <> + updateSelectedSpaces(newOptions as SpaceOption[])} + listProps={{ + bordered: true, + rowHeight: ROW_HEIGHT, + className: 'spcShareToSpace__spacesList', + 'data-test-subj': 'sts-form-space-selector', + }} + height={ROW_HEIGHT * 3.5} + searchable + > + {(list, search) => { + return ( + <> + {search} + {list} + + ); + }} + + {getUnknownSpacesLabel()} + {getNoSpacesAvailable()} + + ); }; diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_mode_control.scss b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_mode_control.scss new file mode 100644 index 0000000000000..3baa21f68d4f3 --- /dev/null +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_mode_control.scss @@ -0,0 +1,3 @@ +.euiCheckableCard__children { + width: 100%; // required to expand the contents of EuiCheckableCard to the full width +} diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_mode_control.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_mode_control.tsx new file mode 100644 index 0000000000000..dca52e6e529a1 --- /dev/null +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_mode_control.tsx @@ -0,0 +1,150 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import './share_mode_control.scss'; +import React from 'react'; +import { + EuiCheckableCard, + EuiFlexGroup, + EuiFlexItem, + EuiFormFieldset, + EuiIconTip, + EuiLoadingSpinner, + EuiSpacer, + EuiText, + EuiTitle, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { SelectableSpacesControl } from './selectable_spaces_control'; +import { ALL_SPACES_ID } from '../../../common/constants'; +import { SpaceTarget } from '../types'; + +interface Props { + spaces: SpaceTarget[]; + canShareToAllSpaces: boolean; + selectedSpaceIds: string[]; + onChange: (selectedSpaceIds: string[]) => void; + disabled?: boolean; +} + +function createLabel({ + title, + text, + disabled, + tooltip, +}: { + title: string; + text: string; + disabled: boolean; + tooltip?: string; +}) { + return ( + <> + + + {title} + + {tooltip && ( + + + + )} + + + + {text} + + + ); +} + +export const ShareModeControl = (props: Props) => { + const { spaces, canShareToAllSpaces, selectedSpaceIds, onChange } = props; + + if (spaces.length === 0) { + return ; + } + + const isGlobalControlChecked = selectedSpaceIds.includes(ALL_SPACES_ID); + const shareToAllSpaces = { + id: 'shareToAllSpaces', + title: i18n.translate( + 'xpack.spaces.management.shareToSpace.shareModeControl.shareToAllSpaces.title', + { defaultMessage: 'All spaces' } + ), + text: i18n.translate( + 'xpack.spaces.management.shareToSpace.shareModeControl.shareToAllSpaces.text', + { defaultMessage: 'Make object available in all current and future spaces.' } + ), + ...(!canShareToAllSpaces && { + tooltip: isGlobalControlChecked + ? i18n.translate( + 'xpack.spaces.management.shareToSpace.shareModeControl.shareToAllSpaces.cannotUncheckTooltip', + { defaultMessage: 'You need additional privileges to change this option.' } + ) + : i18n.translate( + 'xpack.spaces.management.shareToSpace.shareModeControl.shareToAllSpaces.cannotCheckTooltip', + { defaultMessage: 'You need additional privileges to use this option.' } + ), + }), + disabled: !canShareToAllSpaces, + }; + const shareToExplicitSpaces = { + id: 'shareToExplicitSpaces', + title: i18n.translate( + 'xpack.spaces.management.shareToSpace.shareModeControl.shareToExplicitSpaces.title', + { defaultMessage: 'Select spaces' } + ), + text: i18n.translate( + 'xpack.spaces.management.shareToSpace.shareModeControl.shareToExplicitSpaces.text', + { defaultMessage: 'Make object available in selected spaces only.' } + ), + disabled: !canShareToAllSpaces && isGlobalControlChecked, + }; + const shareOptionsTitle = i18n.translate( + 'xpack.spaces.management.shareToSpace.shareModeControl.shareOptionsTitle', + { defaultMessage: 'Share options' } + ); + + const toggleShareOption = (allSpaces: boolean) => { + const updatedSpaceIds = allSpaces + ? [ALL_SPACES_ID, ...selectedSpaceIds] + : selectedSpaceIds.filter((id) => id !== ALL_SPACES_ID); + onChange(updatedSpaceIds); + }; + + return ( + <> + + {shareOptionsTitle} + + ), + }} + > + toggleShareOption(false)} + disabled={shareToExplicitSpaces.disabled} + > + + + + toggleShareOption(true)} + disabled={shareToAllSpaces.disabled} + /> + + + ); +}; diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout.test.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout.test.tsx index c17a2dcb1a831..ad49161ddd705 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout.test.tsx +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout.test.tsx @@ -8,16 +8,18 @@ import Boom from 'boom'; import { mountWithIntl, nextTick } from 'test_utils/enzyme_helpers'; import { ShareSavedObjectsToSpaceFlyout } from './share_to_space_flyout'; import { ShareToSpaceForm } from './share_to_space_form'; -import { EuiLoadingSpinner, EuiEmptyPrompt } from '@elastic/eui'; +import { EuiLoadingSpinner } from '@elastic/eui'; import { Space } from '../../../common/model/space'; import { findTestSubject } from 'test_utils/find_test_subject'; import { SelectableSpacesControl } from './selectable_spaces_control'; import { act } from '@testing-library/react'; import { spacesManagerMock } from '../../spaces_manager/mocks'; import { SpacesManager } from '../../spaces_manager'; +import { coreMock } from '../../../../../../src/core/public/mocks'; import { ToastsApi } from 'src/core/public'; import { EuiCallOut } from '@elastic/eui'; import { CopySavedObjectsToSpaceFlyout } from '../../copy_saved_objects_to_space/components'; +import { NoSpacesAvailable } from './no_spaces_available'; import { SavedObjectsManagementRecord } from 'src/plugins/saved_objects_management/public'; interface SetupOpts { @@ -63,6 +65,8 @@ const setup = async (opts: SetupOpts = {}) => { ] ); + mockSpacesManager.getShareSavedObjectPermissions.mockResolvedValue({ shareToAllSpaces: true }); + const mockToastNotifications = { addError: jest.fn(), addSuccess: jest.fn(), @@ -81,6 +85,14 @@ const setup = async (opts: SetupOpts = {}) => { namespaces: opts.namespaces || ['my-active-space', 'space-1'], } as SavedObjectsManagementRecord; + const { getStartServices } = coreMock.createSetup(); + const startServices = coreMock.createStart(); + startServices.application.capabilities = { + ...startServices.application.capabilities, + spaces: { manage: true }, + }; + getStartServices.mockResolvedValue([startServices, , ,]); + const wrapper = mountWithIntl( { toastNotifications={(mockToastNotifications as unknown) as ToastsApi} onClose={onClose} onObjectUpdated={onObjectUpdated} + getStartServices={getStartServices} /> ); @@ -111,7 +124,7 @@ describe('ShareToSpaceFlyout', () => { const { wrapper } = await setup({ returnBeforeSpacesLoad: true }); expect(wrapper.find(ShareToSpaceForm)).toHaveLength(0); - expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(0); + expect(wrapper.find(NoSpacesAvailable)).toHaveLength(0); expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(1); await act(async () => { @@ -121,26 +134,28 @@ describe('ShareToSpaceFlyout', () => { expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); - expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(0); + expect(wrapper.find(NoSpacesAvailable)).toHaveLength(0); }); - it('shows a message within an EuiEmptyPrompt when no spaces are available', async () => { - const { wrapper, onClose } = await setup({ mockSpaces: [] }); + it('shows a message within a NoSpacesAvailable when no spaces are available', async () => { + const { wrapper, onClose } = await setup({ + mockSpaces: [{ id: 'my-active-space', name: 'my active space', disabledFeatures: [] }], + }); - expect(wrapper.find(ShareToSpaceForm)).toHaveLength(0); + expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); - expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(1); + expect(wrapper.find(NoSpacesAvailable)).toHaveLength(1); expect(onClose).toHaveBeenCalledTimes(0); }); - it('shows a message within an EuiEmptyPrompt when only the active space is available', async () => { + it('shows a message within a NoSpacesAvailable when only the active space is available', async () => { const { wrapper, onClose } = await setup({ mockSpaces: [{ id: 'my-active-space', name: '', disabledFeatures: [] }], }); - expect(wrapper.find(ShareToSpaceForm)).toHaveLength(0); + expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); - expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(1); + expect(wrapper.find(NoSpacesAvailable)).toHaveLength(1); expect(onClose).toHaveBeenCalledTimes(0); }); @@ -176,9 +191,9 @@ describe('ShareToSpaceFlyout', () => { expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); - expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(0); + expect(wrapper.find(NoSpacesAvailable)).toHaveLength(0); - const copyButton = findTestSubject(wrapper, 'sts-copy-button'); // this button is only present in the warning callout + const copyButton = findTestSubject(wrapper, 'sts-copy-link'); // this link is only present in the warning callout await act(async () => { copyButton.simulate('click'); @@ -199,7 +214,7 @@ describe('ShareToSpaceFlyout', () => { expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); - expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(0); + expect(wrapper.find(NoSpacesAvailable)).toHaveLength(0); // Using props callback instead of simulating clicks, // because EuiSelectable uses a virtualized list, which isn't easily testable via test subjects @@ -230,7 +245,7 @@ describe('ShareToSpaceFlyout', () => { expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); - expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(0); + expect(wrapper.find(NoSpacesAvailable)).toHaveLength(0); // Using props callback instead of simulating clicks, // because EuiSelectable uses a virtualized list, which isn't easily testable via test subjects @@ -263,7 +278,7 @@ describe('ShareToSpaceFlyout', () => { expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); - expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(0); + expect(wrapper.find(NoSpacesAvailable)).toHaveLength(0); // Using props callback instead of simulating clicks, // because EuiSelectable uses a virtualized list, which isn't easily testable via test subjects @@ -302,7 +317,7 @@ describe('ShareToSpaceFlyout', () => { expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); - expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(0); + expect(wrapper.find(NoSpacesAvailable)).toHaveLength(0); // Using props callback instead of simulating clicks, // because EuiSelectable uses a virtualized list, which isn't easily testable via test subjects @@ -341,7 +356,7 @@ describe('ShareToSpaceFlyout', () => { expect(wrapper.find(ShareToSpaceForm)).toHaveLength(1); expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); - expect(wrapper.find(EuiEmptyPrompt)).toHaveLength(0); + expect(wrapper.find(NoSpacesAvailable)).toHaveLength(0); // Using props callback instead of simulating clicks, // because EuiSelectable uses a virtualized list, which isn't easily testable via test subjects diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout.tsx index 053fcb4fdabf8..6461a3299f09b 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout.tsx +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout.tsx @@ -16,19 +16,21 @@ import { EuiFlexGroup, EuiFlexItem, EuiHorizontalRule, - EuiEmptyPrompt, EuiButton, EuiButtonEmpty, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import { ToastsStart } from 'src/core/public'; +import { ToastsStart, StartServicesAccessor, CoreStart } from 'src/core/public'; import { SavedObjectsManagementRecord } from '../../../../../../src/plugins/saved_objects_management/public'; +import { createKibanaReactContext } from '../../../../../../src/plugins/kibana_react/public'; +import { ALL_SPACES_ID, UNKNOWN_SPACE } from '../../../common/constants'; import { Space } from '../../../common/model/space'; import { SpacesManager } from '../../spaces_manager'; import { ShareToSpaceForm } from './share_to_space_form'; import { ShareOptions, SpaceTarget } from '../types'; import { CopySavedObjectsToSpaceFlyout } from '../../copy_saved_objects_to_space/components'; +import { PluginsStart } from '../../plugin'; interface Props { onClose: () => void; @@ -36,15 +38,25 @@ interface Props { savedObject: SavedObjectsManagementRecord; spacesManager: SpacesManager; toastNotifications: ToastsStart; + getStartServices: StartServicesAccessor; } const arraysAreEqual = (a: unknown[], b: unknown[]) => a.every((x) => b.includes(x)) && b.every((x) => a.includes(x)); export const ShareSavedObjectsToSpaceFlyout = (props: Props) => { - const { onClose, onObjectUpdated, savedObject, spacesManager, toastNotifications } = props; + const { + getStartServices, + onClose, + onObjectUpdated, + savedObject, + spacesManager, + toastNotifications, + } = props; const { namespaces: currentNamespaces = [] } = savedObject; + const [coreStart, setCoreStart] = useState(); const [shareOptions, setShareOptions] = useState({ selectedSpaceIds: [] }); + const [canShareToAllSpaces, setCanShareToAllSpaces] = useState(false); const [showMakeCopy, setShowMakeCopy] = useState(false); const [{ isLoading, spaces }, setSpacesState] = useState<{ @@ -54,8 +66,15 @@ export const ShareSavedObjectsToSpaceFlyout = (props: Props) => { useEffect(() => { const getSpaces = spacesManager.getSpaces('shareSavedObjectsIntoSpace'); const getActiveSpace = spacesManager.getActiveSpace(); - Promise.all([getSpaces, getActiveSpace]) - .then(([allSpaces, activeSpace]) => { + const getPermissions = spacesManager.getShareSavedObjectPermissions(savedObject.type); + Promise.all([getSpaces, getActiveSpace, getPermissions, getStartServices()]) + .then(([allSpaces, activeSpace, permissions, startServices]) => { + const [coreStartValue] = startServices; + setCoreStart(coreStartValue); + setShareOptions({ + selectedSpaceIds: currentNamespaces.filter((spaceId) => spaceId !== activeSpace.id), + }); + setCanShareToAllSpaces(permissions.shareToAllSpaces); const createSpaceTarget = (space: Space): SpaceTarget => ({ ...space, isActiveSpace: space.id === activeSpace.id, @@ -64,9 +83,6 @@ export const ShareSavedObjectsToSpaceFlyout = (props: Props) => { isLoading: false, spaces: allSpaces.map((space) => createSpaceTarget(space)), }); - setShareOptions({ - selectedSpaceIds: currentNamespaces.filter((spaceId) => spaceId !== activeSpace.id), - }); }) .catch((e) => { toastNotifications.addError(e, { @@ -75,25 +91,50 @@ export const ShareSavedObjectsToSpaceFlyout = (props: Props) => { }), }); }); - }, [currentNamespaces, spacesManager, toastNotifications]); + }, [currentNamespaces, spacesManager, savedObject, toastNotifications, getStartServices]); const getSelectionChanges = () => { const activeSpace = spaces.find((space) => space.isActiveSpace); if (!activeSpace) { - return { changed: false, spacesToAdd: [], spacesToRemove: [] }; + return { isSelectionChanged: false, spacesToAdd: [], spacesToRemove: [] }; } const initialSelection = currentNamespaces.filter( - (spaceId) => spaceId !== activeSpace.id && spaceId !== '?' + (spaceId) => spaceId !== activeSpace.id && spaceId !== UNKNOWN_SPACE ); const { selectedSpaceIds } = shareOptions; - const changed = !arraysAreEqual(initialSelection, selectedSpaceIds); - const spacesToAdd = selectedSpaceIds.filter((spaceId) => !initialSelection.includes(spaceId)); - const spacesToRemove = initialSelection.filter( - (spaceId) => !selectedSpaceIds.includes(spaceId) + const filteredSelection = selectedSpaceIds.filter((x) => x !== UNKNOWN_SPACE); + const isSharedToAllSpaces = + !initialSelection.includes(ALL_SPACES_ID) && filteredSelection.includes(ALL_SPACES_ID); + const isUnsharedFromAllSpaces = + initialSelection.includes(ALL_SPACES_ID) && !filteredSelection.includes(ALL_SPACES_ID); + const selectedSpacesChanged = + !filteredSelection.includes(ALL_SPACES_ID) && + !arraysAreEqual(initialSelection, filteredSelection); + const isSelectionChanged = + isSharedToAllSpaces || + isUnsharedFromAllSpaces || + (!isSharedToAllSpaces && !isUnsharedFromAllSpaces && selectedSpacesChanged); + + const selectedSpacesToAdd = filteredSelection.filter( + (spaceId) => !initialSelection.includes(spaceId) + ); + const selectedSpacesToRemove = initialSelection.filter( + (spaceId) => !filteredSelection.includes(spaceId) ); - return { changed, spacesToAdd, spacesToRemove }; + + const spacesToAdd = isSharedToAllSpaces + ? [ALL_SPACES_ID] + : isUnsharedFromAllSpaces + ? [activeSpace.id, ...selectedSpacesToAdd] + : selectedSpacesToAdd; + const spacesToRemove = isUnsharedFromAllSpaces + ? [ALL_SPACES_ID] + : isSharedToAllSpaces + ? [activeSpace.id, ...initialSelection] + : selectedSpacesToRemove; + return { isSelectionChanged, spacesToAdd, spacesToRemove }; }; - const { changed: isSelectionChanged, spacesToAdd, spacesToRemove } = getSelectionChanges(); + const { isSelectionChanged, spacesToAdd, spacesToRemove } = getSelectionChanges(); const [shareInProgress, setShareInProgress] = useState(false); @@ -109,32 +150,28 @@ export const ShareSavedObjectsToSpaceFlyout = (props: Props) => { : i18n.translate('xpack.spaces.management.shareToSpace.shareEditSuccessTitle', { defaultMessage: 'Object was updated', }); + const isSharedToAllSpaces = spacesToAdd.includes(ALL_SPACES_ID); if (spacesToAdd.length > 0) { await spacesManager.shareSavedObjectAdd({ type, id }, spacesToAdd); - const spaceNames = spacesToAdd.map( - (spaceId) => spaces.find((space) => space.id === spaceId)!.name - ); - const spaceCount = spaceNames.length; + const spaceTargets = isSharedToAllSpaces ? 'all' : `${spacesToAdd.length}`; const text = - spaceCount === 1 + !isSharedToAllSpaces && spacesToAdd.length === 1 ? i18n.translate('xpack.spaces.management.shareToSpace.shareAddSuccessTextSingular', { defaultMessage: `'{object}' was added to 1 space.`, values: { object: meta.title }, }) : i18n.translate('xpack.spaces.management.shareToSpace.shareAddSuccessTextPlural', { - defaultMessage: `'{object}' was added to {spaceCount} spaces.`, - values: { object: meta.title, spaceCount }, + defaultMessage: `'{object}' was added to {spaceTargets} spaces.`, + values: { object: meta.title, spaceTargets }, }); toastNotifications.addSuccess({ title, text }); } if (spacesToRemove.length > 0) { await spacesManager.shareSavedObjectRemove({ type, id }, spacesToRemove); - const spaceNames = spacesToRemove.map( - (spaceId) => spaces.find((space) => space.id === spaceId)!.name - ); - const spaceCount = spaceNames.length; + const isUnsharedFromAllSpaces = spacesToRemove.includes(ALL_SPACES_ID); + const spaceTargets = isUnsharedFromAllSpaces ? 'all' : `${spacesToRemove.length}`; const text = - spaceCount === 1 + !isUnsharedFromAllSpaces && spacesToRemove.length === 1 ? i18n.translate( 'xpack.spaces.management.shareToSpace.shareRemoveSuccessTextSingular', { @@ -143,10 +180,12 @@ export const ShareSavedObjectsToSpaceFlyout = (props: Props) => { } ) : i18n.translate('xpack.spaces.management.shareToSpace.shareRemoveSuccessTextPlural', { - defaultMessage: `'{object}' was removed from {spaceCount} spaces.`, - values: { object: meta.title, spaceCount }, + defaultMessage: `'{object}' was removed from {spaceTargets} spaces.`, + values: { object: meta.title, spaceTargets }, }); - toastNotifications.addSuccess({ title, text }); + if (!isSharedToAllSpaces) { + toastNotifications.addSuccess({ title, text }); + } } onObjectUpdated(); onClose(); @@ -166,41 +205,26 @@ export const ShareSavedObjectsToSpaceFlyout = (props: Props) => { return ; } - // Step 1a: assets loaded, but no spaces are available for share. - // The `spaces` array includes the current space, so at minimum it will have a length of 1. - if (spaces.length < 2) { - return ( - - -

- } - title={ -

- -

- } - /> - ); - } - - const showShareWarning = currentNamespaces.length === 1; + const activeSpace = spaces.find((x) => x.isActiveSpace)!; + const showShareWarning = + spaces.length > 1 && arraysAreEqual(currentNamespaces, [activeSpace.id]); + const { application, docLinks } = coreStart!; + const { Provider: KibanaReactContextProvider } = createKibanaReactContext({ + application, + docLinks, + }); // Step 2: Share has not been initiated yet; User must fill out form to continue. return ( - setShowMakeCopy(true)} - /> + + setShowMakeCopy(true)} + /> + ); }; @@ -216,7 +240,7 @@ export const ShareSavedObjectsToSpaceFlyout = (props: Props) => { } return ( - + @@ -274,7 +298,7 @@ export const ShareSavedObjectsToSpaceFlyout = (props: Props) => { > diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_form.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_form.tsx index ad84ea85d5e54..bc196208ab35c 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_form.tsx +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_form.tsx @@ -6,25 +6,28 @@ import './share_to_space_form.scss'; import React, { Fragment } from 'react'; -import { EuiHorizontalRule, EuiFormRow, EuiCallOut, EuiButton, EuiSpacer } from '@elastic/eui'; +import { EuiHorizontalRule, EuiCallOut, EuiLink } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { ShareOptions, SpaceTarget } from '../types'; -import { SelectableSpacesControl } from './selectable_spaces_control'; +import { ShareModeControl } from './share_mode_control'; interface Props { spaces: SpaceTarget[]; onUpdate: (shareOptions: ShareOptions) => void; shareOptions: ShareOptions; showShareWarning: boolean; + canShareToAllSpaces: boolean; makeCopy: () => void; } export const ShareToSpaceForm = (props: Props) => { + const { spaces, onUpdate, shareOptions, showShareWarning, canShareToAllSpaces, makeCopy } = props; + const setSelectedSpaceIds = (selectedSpaceIds: string[]) => - props.onUpdate({ ...props.shareOptions, selectedSpaceIds }); + onUpdate({ ...shareOptions, selectedSpaceIds }); const getShareWarning = () => { - if (!props.showShareWarning) { + if (!showShareWarning) { return null; } @@ -42,20 +45,18 @@ export const ShareToSpaceForm = (props: Props) => { > makeCopy()}> + + + ), + }} /> - - props.makeCopy()} - color="warning" - data-test-subj="sts-copy-button" - size="s" - > - - @@ -67,28 +68,12 @@ export const ShareToSpaceForm = (props: Props) => {
{getShareWarning()} - - } - labelAppend={ - - } - fullWidth - > - setSelectedSpaceIds(selection)} - /> - + setSelectedSpaceIds(selection)} + />
); }; diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_action.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_action.tsx index ba9a6473999df..677632e942e9a 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_action.tsx +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_action.tsx @@ -5,13 +5,14 @@ */ import React from 'react'; import { i18n } from '@kbn/i18n'; -import { NotificationsStart } from 'src/core/public'; +import { NotificationsStart, StartServicesAccessor } from 'src/core/public'; import { SavedObjectsManagementAction, SavedObjectsManagementRecord, } from '../../../../../src/plugins/saved_objects_management/public'; import { ShareSavedObjectsToSpaceFlyout } from './components'; import { SpacesManager } from '../spaces_manager'; +import { PluginsStart } from '../plugin'; export class ShareToSpaceSavedObjectsManagementAction extends SavedObjectsManagementAction { public id: string = 'share_saved_objects_to_space'; @@ -39,7 +40,8 @@ export class ShareToSpaceSavedObjectsManagementAction extends SavedObjectsManage constructor( private readonly spacesManager: SpacesManager, - private readonly notifications: NotificationsStart + private readonly notifications: NotificationsStart, + private readonly getStartServices: StartServicesAccessor ) { super(); } @@ -56,6 +58,7 @@ export class ShareToSpaceSavedObjectsManagementAction extends SavedObjectsManage savedObject={this.record} spacesManager={this.spacesManager} toastNotifications={this.notifications.toasts} + getStartServices={this.getStartServices} /> ); }; diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_column.test.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_column.test.tsx new file mode 100644 index 0000000000000..041728a3eac0d --- /dev/null +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_column.test.tsx @@ -0,0 +1,206 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { shallowWithIntl } from 'test_utils/enzyme_helpers'; +import { SpacesManager } from '../spaces_manager'; +import { spacesManagerMock } from '../spaces_manager/mocks'; +import { ShareToSpaceSavedObjectsManagementColumn } from './share_saved_objects_to_space_column'; +import { SpaceTarget } from './types'; + +const ACTIVE_SPACE: SpaceTarget = { + id: 'default', + name: 'Default', + color: '#ffffff', + isActiveSpace: true, +}; +const getSpaceData = (inactiveSpaceCount: number = 0) => { + const inactive = ['Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo', 'Foxtrot', 'Golf', 'Hotel'] + .map((name) => ({ + id: name.toLowerCase(), + name, + color: `#123456`, // must be a valid color as `render()` is used below + isActiveSpace: false, + })) + .slice(0, inactiveSpaceCount); + const spaceTargets = [ACTIVE_SPACE, ...inactive]; + const namespaces = spaceTargets.map(({ id }) => id); + return { spaceTargets, namespaces }; +}; + +describe('ShareToSpaceSavedObjectsManagementColumn', () => { + let spacesManager: SpacesManager; + beforeEach(() => { + spacesManager = spacesManagerMock.create(); + }); + + const createColumn = (spaceTargets: SpaceTarget[], namespaces: string[]) => { + const column = new ShareToSpaceSavedObjectsManagementColumn(spacesManager); + column.data = spaceTargets.reduce( + (acc, cur) => acc.set(cur.id, cur), + new Map() + ); + const element = column.euiColumn.render(namespaces); + return shallowWithIntl(element); + }; + + /** + * This node displays up to five named spaces (and an indicator for any number of unauthorized spaces) by default. The active space is + * omitted from this list. If more than five named spaces would be displayed, the extras (along with the unauthorized spaces indicator, if + * present) are hidden behind a button. + * If '*' (aka "All spaces") is present, it supersedes all of the above and just displays a single badge without a button. + */ + describe('#euiColumn.render', () => { + describe('with only the active space', () => { + const { spaceTargets, namespaces } = getSpaceData(); + const wrapper = createColumn(spaceTargets, namespaces); + + it('does not show badges or button', async () => { + const badges = wrapper.find('EuiBadge'); + expect(badges).toHaveLength(0); + const button = wrapper.find('EuiButtonEmpty'); + expect(button).toHaveLength(0); + }); + }); + + describe('with the active space and one inactive space', () => { + const { spaceTargets, namespaces } = getSpaceData(1); + const wrapper = createColumn(spaceTargets, namespaces); + + it('shows one badge without button', async () => { + const badges = wrapper.find('EuiBadge'); + expect(badges).toMatchInlineSnapshot(` + + Alpha + + `); + const button = wrapper.find('EuiButtonEmpty'); + expect(button).toHaveLength(0); + }); + }); + + describe('with the active space and five inactive spaces', () => { + const { spaceTargets, namespaces } = getSpaceData(5); + const wrapper = createColumn(spaceTargets, namespaces); + + it('shows badges without button', async () => { + const badgeText = wrapper.find('EuiBadge').map((x) => x.render().text()); + expect(badgeText).toEqual(['Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo']); + const button = wrapper.find('EuiButtonEmpty'); + expect(button).toHaveLength(0); + }); + }); + + describe('with the active space, five inactive spaces, and one unauthorized space', () => { + const { spaceTargets, namespaces } = getSpaceData(5); + const wrapper = createColumn(spaceTargets, [...namespaces, '?']); + + it('shows badges without button', async () => { + const badgeText = wrapper.find('EuiBadge').map((x) => x.render().text()); + expect(badgeText).toEqual(['Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo', '+1']); + const button = wrapper.find('EuiButtonEmpty'); + expect(button).toHaveLength(0); + }); + }); + + describe('with the active space, five inactive spaces, and two unauthorized spaces', () => { + const { spaceTargets, namespaces } = getSpaceData(5); + const wrapper = createColumn(spaceTargets, [...namespaces, '?', '?']); + + it('shows badges without button', async () => { + const badgeText = wrapper.find('EuiBadge').map((x) => x.render().text()); + expect(badgeText).toEqual(['Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo', '+2']); + const button = wrapper.find('EuiButtonEmpty'); + expect(button).toHaveLength(0); + }); + }); + + describe('with the active space and six inactive spaces', () => { + const { spaceTargets, namespaces } = getSpaceData(6); + const wrapper = createColumn(spaceTargets, namespaces); + + it('shows badges with button', async () => { + let badgeText = wrapper.find('EuiBadge').map((x) => x.render().text()); + expect(badgeText).toEqual(['Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo']); + const button = wrapper.find('EuiButtonEmpty'); + expect(button.find('FormattedMessage').props()).toEqual({ + defaultMessage: '+{count} more', + id: 'xpack.spaces.management.shareToSpace.showMoreSpacesLink', + values: { count: 1 }, + }); + + button.simulate('click'); + badgeText = wrapper.find('EuiBadge').map((x) => x.render().text()); + expect(badgeText).toEqual(['Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo', 'Foxtrot']); + }); + }); + + describe('with the active space, six inactive spaces, and one unauthorized space', () => { + const { spaceTargets, namespaces } = getSpaceData(6); + const wrapper = createColumn(spaceTargets, [...namespaces, '?']); + + it('shows badges with button', async () => { + let badgeText = wrapper.find('EuiBadge').map((x) => x.render().text()); + expect(badgeText).toEqual(['Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo']); + const button = wrapper.find('EuiButtonEmpty'); + expect(button.find('FormattedMessage').props()).toEqual({ + defaultMessage: '+{count} more', + id: 'xpack.spaces.management.shareToSpace.showMoreSpacesLink', + values: { count: 2 }, + }); + + button.simulate('click'); + badgeText = wrapper.find('EuiBadge').map((x) => x.render().text()); + expect(badgeText).toEqual(['Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo', 'Foxtrot', '+1']); + }); + }); + + describe('with the active space, six inactive spaces, and two unauthorized spaces', () => { + const { spaceTargets, namespaces } = getSpaceData(6); + const wrapper = createColumn(spaceTargets, [...namespaces, '?', '?']); + + it('shows badges with button', async () => { + let badgeText = wrapper.find('EuiBadge').map((x) => x.render().text()); + expect(badgeText).toEqual(['Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo']); + const button = wrapper.find('EuiButtonEmpty'); + expect(button.find('FormattedMessage').props()).toEqual({ + defaultMessage: '+{count} more', + id: 'xpack.spaces.management.shareToSpace.showMoreSpacesLink', + values: { count: 3 }, + }); + + button.simulate('click'); + badgeText = wrapper.find('EuiBadge').map((x) => x.render().text()); + expect(badgeText).toEqual(['Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo', 'Foxtrot', '+2']); + }); + }); + + describe('with only "all spaces"', () => { + const wrapper = createColumn([], ['*']); + + it('shows one badge without button', async () => { + const badgeText = wrapper.find('EuiBadge').map((x) => x.render().text()); + expect(badgeText).toEqual(['* All spaces']); + const button = wrapper.find('EuiButtonEmpty'); + expect(button).toHaveLength(0); + }); + }); + + describe('with "all spaces", the active space, six inactive spaces, and one unauthorized space', () => { + // same as assertions 'with only "all spaces"' test case; if "all spaces" is present, it supersedes everything else + const { spaceTargets, namespaces } = getSpaceData(6); + const wrapper = createColumn(spaceTargets, ['*', ...namespaces, '?']); + + it('shows one badge without button', async () => { + const badgeText = wrapper.find('EuiBadge').map((x) => x.render().text()); + expect(badgeText).toEqual(['* All spaces']); + const button = wrapper.find('EuiButtonEmpty'); + expect(button).toHaveLength(0); + }); + }); + }); +}); diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_column.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_column.tsx index 93d7bb0170519..0f988cf5a152d 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_column.tsx +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_column.tsx @@ -10,12 +10,10 @@ import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { EuiToolTip } from '@elastic/eui'; import { EuiButtonEmpty } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; -import { - SavedObjectsManagementColumn, - SavedObjectsManagementRecord, -} from '../../../../../src/plugins/saved_objects_management/public'; +import { SavedObjectsManagementColumn } from '../../../../../src/plugins/saved_objects_management/public'; import { SpaceTarget } from './types'; import { SpacesManager } from '../spaces_manager'; +import { ALL_SPACES_ID, UNKNOWN_SPACE } from '../../common/constants'; import { getSpaceColor } from '..'; const SPACES_DISPLAY_COUNT = 5; @@ -33,64 +31,77 @@ const ColumnDisplay = ({ namespaces, data }: ColumnDataProps) => { return null; } - const authorized = namespaces?.filter((namespace) => namespace !== '?') ?? []; - const authorizedSpaceTargets: SpaceTarget[] = []; - authorized.forEach((namespace) => { - const spaceTarget = data.get(namespace); - if (spaceTarget === undefined) { - // in the event that a new space was created after this page has loaded, fall back to displaying the space ID - authorizedSpaceTargets.push({ - id: namespace, - name: namespace, - disabledFeatures: [], + const isSharedToAllSpaces = namespaces?.includes(ALL_SPACES_ID); + const unauthorizedCount = (namespaces?.filter((namespace) => namespace === UNKNOWN_SPACE) ?? []) + .length; + let displayedSpaces: SpaceTarget[]; + let button: ReactNode = null; + + if (isSharedToAllSpaces) { + displayedSpaces = [ + { + id: ALL_SPACES_ID, + name: i18n.translate('xpack.spaces.management.shareToSpace.allSpacesLabel', { + defaultMessage: `* All spaces`, + }), isActiveSpace: false, - }); - } else if (!spaceTarget.isActiveSpace) { - authorizedSpaceTargets.push(spaceTarget); - } - }); - const unauthorizedCount = (namespaces?.filter((namespace) => namespace === '?') ?? []).length; - const unauthorizedTooltip = i18n.translate( - 'xpack.spaces.management.shareToSpace.columnUnauthorizedLabel', - { defaultMessage: `You don't have permission to view these spaces.` } - ); + color: '#D3DAE6', + }, + ]; + } else { + const authorized = namespaces?.filter((namespace) => namespace !== UNKNOWN_SPACE) ?? []; + const authorizedSpaceTargets: SpaceTarget[] = []; + authorized.forEach((namespace) => { + const spaceTarget = data.get(namespace); + if (spaceTarget === undefined) { + // in the event that a new space was created after this page has loaded, fall back to displaying the space ID + authorizedSpaceTargets.push({ id: namespace, name: namespace, isActiveSpace: false }); + } else if (!spaceTarget.isActiveSpace) { + authorizedSpaceTargets.push(spaceTarget); + } + }); + displayedSpaces = isExpanded + ? authorizedSpaceTargets + : authorizedSpaceTargets.slice(0, SPACES_DISPLAY_COUNT); - const displayedSpaces = isExpanded - ? authorizedSpaceTargets - : authorizedSpaceTargets.slice(0, SPACES_DISPLAY_COUNT); - const showButton = authorizedSpaceTargets.length > SPACES_DISPLAY_COUNT; + if (authorizedSpaceTargets.length > SPACES_DISPLAY_COUNT) { + button = isExpanded ? ( + setIsExpanded(false)}> + + + ) : ( + setIsExpanded(true)}> + + + ); + } + } const unauthorizedCountBadge = - (isExpanded || !showButton) && unauthorizedCount > 0 ? ( + !isSharedToAllSpaces && (isExpanded || button === null) && unauthorizedCount > 0 ? ( - + + } + > +{unauthorizedCount} ) : null; - let button: ReactNode = null; - if (showButton) { - button = isExpanded ? ( - setIsExpanded(false)}> - - - ) : ( - setIsExpanded(true)}> - - - ); - } - return ( {displayedSpaces.map(({ id, name, color }) => ( @@ -117,7 +128,7 @@ export class ShareToSpaceSavedObjectsManagementColumn description: i18n.translate('xpack.spaces.management.shareToSpace.columnDescription', { defaultMessage: 'The other spaces that this object is currently shared to', }), - render: (namespaces: string[] | undefined, _object: SavedObjectsManagementRecord) => ( + render: (namespaces: string[] | undefined) => ( ), }; diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_service.test.ts b/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_service.test.ts index 0f0fa7d22214f..6ce4c49c528af 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_service.test.ts +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_service.test.ts @@ -8,7 +8,7 @@ import { ShareToSpaceSavedObjectsManagementAction } from './share_saved_objects_ // import { ShareToSpaceSavedObjectsManagementColumn } from './share_saved_objects_to_space_column'; import { spacesManagerMock } from '../spaces_manager/mocks'; import { ShareSavedObjectsToSpaceService } from '.'; -import { notificationServiceMock } from 'src/core/public/mocks'; +import { coreMock, notificationServiceMock } from 'src/core/public/mocks'; import { savedObjectsManagementPluginMock } from '../../../../../src/plugins/saved_objects_management/public/mocks'; describe('ShareSavedObjectsToSpaceService', () => { @@ -18,6 +18,7 @@ describe('ShareSavedObjectsToSpaceService', () => { spacesManager: spacesManagerMock.create(), notificationsSetup: notificationServiceMock.createSetupContract(), savedObjectsManagementSetup: savedObjectsManagementPluginMock.createSetupContract(), + getStartServices: coreMock.createSetup().getStartServices, }; const service = new ShareSavedObjectsToSpaceService(); diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_service.ts b/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_service.ts index 9f6e57c355380..892731a0c5739 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_service.ts +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/share_saved_objects_to_space_service.ts @@ -4,21 +4,32 @@ * you may not use this file except in compliance with the Elastic License. */ -import { NotificationsSetup } from 'src/core/public'; +import { NotificationsSetup, StartServicesAccessor } from 'src/core/public'; import { SavedObjectsManagementPluginSetup } from 'src/plugins/saved_objects_management/public'; import { ShareToSpaceSavedObjectsManagementAction } from './share_saved_objects_to_space_action'; // import { ShareToSpaceSavedObjectsManagementColumn } from './share_saved_objects_to_space_column'; import { SpacesManager } from '../spaces_manager'; +import { PluginsStart } from '../plugin'; interface SetupDeps { spacesManager: SpacesManager; savedObjectsManagementSetup: SavedObjectsManagementPluginSetup; notificationsSetup: NotificationsSetup; + getStartServices: StartServicesAccessor; } export class ShareSavedObjectsToSpaceService { - public setup({ spacesManager, savedObjectsManagementSetup, notificationsSetup }: SetupDeps) { - const action = new ShareToSpaceSavedObjectsManagementAction(spacesManager, notificationsSetup); + public setup({ + spacesManager, + savedObjectsManagementSetup, + notificationsSetup, + getStartServices, + }: SetupDeps) { + const action = new ShareToSpaceSavedObjectsManagementAction( + spacesManager, + notificationsSetup, + getStartServices + ); savedObjectsManagementSetup.actions.register(action); // Note: this column is hidden for now because no saved objects are shareable. It should be uncommented when at least one saved object type is multi-namespace. // const column = new ShareToSpaceSavedObjectsManagementColumn(spacesManager); diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/types.ts b/x-pack/plugins/spaces/public/share_saved_objects_to_space/types.ts index fe41f4a5fadc8..8b62166baaaa6 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/types.ts +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/types.ts @@ -17,6 +17,6 @@ export interface ShareSavedObjectsToSpaceResponse { [spaceId: string]: SavedObjectsImportResponse; } -export interface SpaceTarget extends Space { +export interface SpaceTarget extends Omit { isActiveSpace: boolean; } diff --git a/x-pack/plugins/spaces/public/spaces_manager/spaces_manager.mock.ts b/x-pack/plugins/spaces/public/spaces_manager/spaces_manager.mock.ts index f666c823bd365..0888448753353 100644 --- a/x-pack/plugins/spaces/public/spaces_manager/spaces_manager.mock.ts +++ b/x-pack/plugins/spaces/public/spaces_manager/spaces_manager.mock.ts @@ -21,6 +21,7 @@ function createSpacesManagerMock() { shareSavedObjectAdd: jest.fn().mockResolvedValue(undefined), shareSavedObjectRemove: jest.fn().mockResolvedValue(undefined), resolveCopySavedObjectsErrors: jest.fn().mockResolvedValue(undefined), + getShareSavedObjectPermissions: jest.fn().mockResolvedValue(undefined), redirectToSpaceSelector: jest.fn().mockResolvedValue(undefined), } as unknown) as jest.Mocked; } diff --git a/x-pack/plugins/spaces/public/spaces_manager/spaces_manager.test.ts b/x-pack/plugins/spaces/public/spaces_manager/spaces_manager.test.ts index 508669361c23f..7f005e37d96e9 100644 --- a/x-pack/plugins/spaces/public/spaces_manager/spaces_manager.test.ts +++ b/x-pack/plugins/spaces/public/spaces_manager/spaces_manager.test.ts @@ -104,4 +104,24 @@ describe('SpacesManager', () => { ); }); }); + + describe('#getShareSavedObjectPermissions', () => { + it('retrieves share permissions for the specified type and returns result', async () => { + const coreStart = coreMock.createStart(); + const shareToAllSpaces = Symbol(); + coreStart.http.get.mockResolvedValue({ shareToAllSpaces }); + const spacesManager = new SpacesManager(coreStart.http); + expect(coreStart.http.get).toHaveBeenCalledTimes(1); // initial call to get active space + + const result = await spacesManager.getShareSavedObjectPermissions('foo'); + expect(coreStart.http.get).toHaveBeenCalledTimes(2); + expect(coreStart.http.get).toHaveBeenLastCalledWith( + '/internal/spaces/_share_saved_object_permissions', + { + query: { type: 'foo' }, + } + ); + expect(result).toEqual({ shareToAllSpaces }); + }); + }); }); diff --git a/x-pack/plugins/spaces/public/spaces_manager/spaces_manager.ts b/x-pack/plugins/spaces/public/spaces_manager/spaces_manager.ts index 2daf9ab420efc..c81f7c17b7770 100644 --- a/x-pack/plugins/spaces/public/spaces_manager/spaces_manager.ts +++ b/x-pack/plugins/spaces/public/spaces_manager/spaces_manager.ts @@ -106,6 +106,12 @@ export class SpacesManager { }); } + public async getShareSavedObjectPermissions( + type: string + ): Promise<{ shareToAllSpaces: boolean }> { + return this.http.get('/internal/spaces/_share_saved_object_permissions', { query: { type } }); + } + public async shareSavedObjectAdd(object: SavedObject, spaces: string[]): Promise { return this.http.post(`/api/spaces/_share_saved_object_add`, { body: JSON.stringify({ object, spaces }), diff --git a/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.mock.ts b/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.mock.ts index 10f6292abf319..e38842b8799ac 100644 --- a/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.mock.ts +++ b/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.mock.ts @@ -10,7 +10,6 @@ import { SpacesClient } from './spaces_client'; const createSpacesClientMock = () => (({ - canEnumerateSpaces: jest.fn().mockResolvedValue(true), getAll: jest.fn().mockResolvedValue([ { id: DEFAULT_SPACE_ID, diff --git a/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.test.ts b/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.test.ts index 1090b029069d2..4502b081034aa 100644 --- a/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.test.ts +++ b/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.test.ts @@ -375,131 +375,6 @@ describe('#getAll', () => { }); }); -describe('#canEnumerateSpaces', () => { - describe(`authorization is null`, () => { - test(`returns true`, async () => { - const mockAuditLogger = createMockAuditLogger(); - const mockDebugLogger = createMockDebugLogger(); - const mockConfig = createMockConfig(); - const authorization = null; - const request = Symbol() as any; - - const client = new SpacesClient( - mockAuditLogger as any, - mockDebugLogger, - authorization, - null, - mockConfig, - null, - request - ); - - const canEnumerateSpaces = await client.canEnumerateSpaces(); - expect(canEnumerateSpaces).toEqual(true); - expect(mockAuditLogger.spacesAuthorizationFailure).toHaveBeenCalledTimes(0); - expect(mockAuditLogger.spacesAuthorizationSuccess).toHaveBeenCalledTimes(0); - }); - }); - - describe(`authorization.mode.useRbacForRequest is false`, () => { - test(`returns true`, async () => { - const mockAuditLogger = createMockAuditLogger(); - const mockDebugLogger = createMockDebugLogger(); - const mockConfig = createMockConfig(); - const { mockAuthorization } = createMockAuthorization(); - mockAuthorization.mode.useRbacForRequest.mockReturnValue(false); - const request = Symbol() as any; - - const client = new SpacesClient( - mockAuditLogger as any, - mockDebugLogger, - mockAuthorization, - null, - mockConfig, - null, - request - ); - const canEnumerateSpaces = await client.canEnumerateSpaces(); - - expect(canEnumerateSpaces).toEqual(true); - expect(mockAuditLogger.spacesAuthorizationFailure).toHaveBeenCalledTimes(0); - expect(mockAuditLogger.spacesAuthorizationSuccess).toHaveBeenCalledTimes(0); - }); - }); - - describe('useRbacForRequest is true', () => { - test(`returns false if user is not authorized to enumerate spaces`, async () => { - const username = Symbol(); - const mockAuditLogger = createMockAuditLogger(); - const mockDebugLogger = createMockDebugLogger(); - const mockConfig = createMockConfig(); - const { mockAuthorization, mockCheckPrivilegesGlobally } = createMockAuthorization(); - mockAuthorization.mode.useRbacForRequest.mockReturnValue(true); - mockCheckPrivilegesGlobally.mockReturnValue({ - username, - hasAllRequested: false, - }); - const request = Symbol() as any; - - const client = new SpacesClient( - mockAuditLogger as any, - mockDebugLogger, - mockAuthorization, - null, - mockConfig, - null, - request - ); - - const canEnumerateSpaces = await client.canEnumerateSpaces(); - expect(canEnumerateSpaces).toEqual(false); - - expect(mockAuthorization.checkPrivilegesWithRequest).toHaveBeenCalledWith(request); - expect(mockCheckPrivilegesGlobally).toHaveBeenCalledWith({ - kibana: mockAuthorization.actions.space.manage, - }); - - expect(mockAuditLogger.spacesAuthorizationFailure).toHaveBeenCalledTimes(0); - expect(mockAuditLogger.spacesAuthorizationSuccess).toHaveBeenCalledTimes(0); - }); - - test(`returns true if user is authorized to enumerate spaces`, async () => { - const username = Symbol(); - const mockAuditLogger = createMockAuditLogger(); - const mockDebugLogger = createMockDebugLogger(); - const mockConfig = createMockConfig(); - const { mockAuthorization, mockCheckPrivilegesGlobally } = createMockAuthorization(); - mockAuthorization.mode.useRbacForRequest.mockReturnValue(true); - mockCheckPrivilegesGlobally.mockReturnValue({ - username, - hasAllRequested: true, - }); - const request = Symbol() as any; - - const client = new SpacesClient( - mockAuditLogger as any, - mockDebugLogger, - mockAuthorization, - null, - mockConfig, - null, - request - ); - - const canEnumerateSpaces = await client.canEnumerateSpaces(); - expect(canEnumerateSpaces).toEqual(true); - - expect(mockAuthorization.checkPrivilegesWithRequest).toHaveBeenCalledWith(request); - expect(mockCheckPrivilegesGlobally).toHaveBeenCalledWith({ - kibana: mockAuthorization.actions.space.manage, - }); - - expect(mockAuditLogger.spacesAuthorizationFailure).toHaveBeenCalledTimes(0); - expect(mockAuditLogger.spacesAuthorizationSuccess).toHaveBeenCalledTimes(0); - }); - }); -}); - describe('#get', () => { const savedObject = { id: 'foo', diff --git a/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.ts b/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.ts index 5ef0b5375d796..50e7182b76686 100644 --- a/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.ts +++ b/x-pack/plugins/spaces/server/lib/spaces_client/spaces_client.ts @@ -47,21 +47,6 @@ export class SpacesClient { private readonly request: KibanaRequest ) {} - public async canEnumerateSpaces(): Promise { - if (this.useRbac()) { - const checkPrivileges = this.authorization!.checkPrivilegesWithRequest(this.request); - const { hasAllRequested } = await checkPrivileges.globally({ - kibana: this.authorization!.actions.space.manage, - }); - this.debugLogger(`SpacesClient.canEnumerateSpaces, using RBAC. Result: ${hasAllRequested}`); - return hasAllRequested; - } - - // If not RBAC, then security isn't enabled and we can enumerate all spaces - this.debugLogger(`SpacesClient.canEnumerateSpaces, NOT USING RBAC. Result: true`); - return true; - } - public async getAll(purpose: GetSpacePurpose = 'any'): Promise { if (!SUPPORTED_GET_SPACE_PURPOSES.includes(purpose)) { throw Boom.badRequest(`unsupported space purpose: ${purpose}`); diff --git a/x-pack/plugins/spaces/server/plugin.ts b/x-pack/plugins/spaces/server/plugin.ts index af54effcaeca6..a9ba5ac2dc6de 100644 --- a/x-pack/plugins/spaces/server/plugin.ts +++ b/x-pack/plugins/spaces/server/plugin.ts @@ -107,6 +107,7 @@ export class Plugin { getStartServices: core.getStartServices, getImportExportObjectLimit: core.savedObjects.getImportExportObjectLimit, spacesService, + authorization: plugins.security ? plugins.security.authz : null, }); const internalRouter = core.http.createRouter(); diff --git a/x-pack/plugins/spaces/server/routes/api/__fixtures__/create_mock_so_service.ts b/x-pack/plugins/spaces/server/routes/api/__fixtures__/create_mock_so_service.ts index ce93591f492f1..fa8ef1882099c 100644 --- a/x-pack/plugins/spaces/server/routes/api/__fixtures__/create_mock_so_service.ts +++ b/x-pack/plugins/spaces/server/routes/api/__fixtures__/create_mock_so_service.ts @@ -4,48 +4,45 @@ * you may not use this file except in compliance with the Elastic License. */ -import { SavedObjectsClientContract, SavedObjectsErrorHelpers } from 'src/core/server'; -import { coreMock, savedObjectsTypeRegistryMock } from '../../../../../../../src/core/server/mocks'; +import { SavedObject, SavedObjectsUpdateResponse, SavedObjectsErrorHelpers } from 'src/core/server'; +import { + coreMock, + savedObjectsClientMock, + savedObjectsTypeRegistryMock, +} from '../../../../../../../src/core/server/mocks'; export const createMockSavedObjectsService = (spaces: any[] = []) => { - const mockSavedObjectsClientContract = ({ - get: jest.fn((type, id) => { - const result = spaces.filter((s) => s.id === id); - if (!result.length) { - throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); - } - return result[0]; - }), - find: jest.fn(() => { - return { - total: spaces.length, - saved_objects: spaces, - }; - }), - create: jest.fn((type, attributes, { id }) => { - if (spaces.find((s) => s.id === id)) { - throw SavedObjectsErrorHelpers.decorateConflictError(new Error(), 'space conflict'); - } - return {}; - }), - update: jest.fn((type, id) => { - if (!spaces.find((s) => s.id === id)) { - throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); - } - return {}; - }), - delete: jest.fn((type: string, id: string) => { - return {}; - }), - deleteByNamespace: jest.fn(), - } as unknown) as jest.Mocked; + const typeRegistry = savedObjectsTypeRegistryMock.create(); + const savedObjectsClient = savedObjectsClientMock.create(); + savedObjectsClient.get.mockImplementation((type, id) => { + const result = spaces.filter((s) => s.id === id); + if (!result.length) { + throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); + } + return Promise.resolve(result[0]); + }); + savedObjectsClient.find.mockResolvedValue({ + page: 1, + per_page: 20, + total: spaces.length, + saved_objects: spaces, + }); + savedObjectsClient.create.mockImplementation((_type, _attributes, options) => { + if (spaces.find((s) => s.id === options?.id)) { + throw SavedObjectsErrorHelpers.decorateConflictError(new Error(), 'space conflict'); + } + return Promise.resolve({} as SavedObject); + }); + savedObjectsClient.update.mockImplementation((type, id, _attributes, _options) => { + if (!spaces.find((s) => s.id === id)) { + throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); + } + return Promise.resolve({} as SavedObjectsUpdateResponse); + }); const { savedObjects } = coreMock.createStart(); - - const typeRegistry = savedObjectsTypeRegistryMock.create(); savedObjects.getTypeRegistry.mockReturnValue(typeRegistry); + savedObjects.getScopedClient.mockReturnValue(savedObjectsClient); - savedObjects.getScopedClient.mockReturnValue(mockSavedObjectsClientContract); - - return savedObjects; + return { savedObjects, typeRegistry, savedObjectsClient }; }; diff --git a/x-pack/plugins/spaces/server/routes/api/external/copy_to_space.test.ts b/x-pack/plugins/spaces/server/routes/api/external/copy_to_space.test.ts index dce6de908cfcb..341e5cf3bfbe0 100644 --- a/x-pack/plugins/spaces/server/routes/api/external/copy_to_space.test.ts +++ b/x-pack/plugins/spaces/server/routes/api/external/copy_to_space.test.ts @@ -71,7 +71,8 @@ describe('copy to space', () => { const log = loggingSystemMock.create().get('spaces'); const coreStart = coreMock.createStart(); - coreStart.savedObjects = createMockSavedObjectsService(spaces); + const { savedObjects } = createMockSavedObjectsService(spaces); + coreStart.savedObjects = savedObjects; const service = new SpacesService(log); const spacesService = await service.setup({ @@ -102,6 +103,7 @@ describe('copy to space', () => { getImportExportObjectLimit: () => 1000, log, spacesService, + authorization: null, // not needed for this route }); const [ diff --git a/x-pack/plugins/spaces/server/routes/api/external/delete.test.ts b/x-pack/plugins/spaces/server/routes/api/external/delete.test.ts index 5461aaf1e36ea..4fe81027c3508 100644 --- a/x-pack/plugins/spaces/server/routes/api/external/delete.test.ts +++ b/x-pack/plugins/spaces/server/routes/api/external/delete.test.ts @@ -73,6 +73,7 @@ describe('Spaces Public API', () => { getImportExportObjectLimit: () => 1000, log, spacesService, + authorization: null, // not needed for this route }); const [routeDefinition, routeHandler] = router.delete.mock.calls[0]; diff --git a/x-pack/plugins/spaces/server/routes/api/external/get.test.ts b/x-pack/plugins/spaces/server/routes/api/external/get.test.ts index ac9a46ee9c3fa..4786399936662 100644 --- a/x-pack/plugins/spaces/server/routes/api/external/get.test.ts +++ b/x-pack/plugins/spaces/server/routes/api/external/get.test.ts @@ -67,6 +67,7 @@ describe('GET space', () => { getImportExportObjectLimit: () => 1000, log, spacesService, + authorization: null, // not needed for this route }); return { diff --git a/x-pack/plugins/spaces/server/routes/api/external/get_all.test.ts b/x-pack/plugins/spaces/server/routes/api/external/get_all.test.ts index a9b701a8ea395..deb8434497ae7 100644 --- a/x-pack/plugins/spaces/server/routes/api/external/get_all.test.ts +++ b/x-pack/plugins/spaces/server/routes/api/external/get_all.test.ts @@ -67,6 +67,7 @@ describe('GET /spaces/space', () => { getImportExportObjectLimit: () => 1000, log, spacesService, + authorization: null, // not needed for this route }); return { diff --git a/x-pack/plugins/spaces/server/routes/api/external/index.ts b/x-pack/plugins/spaces/server/routes/api/external/index.ts index 079f690bfe546..f093f26b4bdee 100644 --- a/x-pack/plugins/spaces/server/routes/api/external/index.ts +++ b/x-pack/plugins/spaces/server/routes/api/external/index.ts @@ -5,6 +5,7 @@ */ import { Logger, IRouter, CoreSetup } from 'src/core/server'; +import { SecurityPluginSetup } from '../../../../../security/server'; import { initDeleteSpacesApi } from './delete'; import { initGetSpaceApi } from './get'; import { initGetAllSpacesApi } from './get_all'; @@ -12,8 +13,7 @@ import { initPostSpacesApi } from './post'; import { initPutSpacesApi } from './put'; import { SpacesServiceSetup } from '../../../spaces_service/spaces_service'; import { initCopyToSpacesApi } from './copy_to_space'; -import { initShareAddSpacesApi } from './share_add_spaces'; -import { initShareRemoveSpacesApi } from './share_remove_spaces'; +import { initShareToSpacesApi } from './share_to_space'; export interface ExternalRouteDeps { externalRouter: IRouter; @@ -21,6 +21,7 @@ export interface ExternalRouteDeps { getImportExportObjectLimit: () => number; spacesService: SpacesServiceSetup; log: Logger; + authorization: SecurityPluginSetup['authz'] | null; } export function initExternalSpacesApi(deps: ExternalRouteDeps) { @@ -30,6 +31,5 @@ export function initExternalSpacesApi(deps: ExternalRouteDeps) { initPostSpacesApi(deps); initPutSpacesApi(deps); initCopyToSpacesApi(deps); - initShareAddSpacesApi(deps); - initShareRemoveSpacesApi(deps); + initShareToSpacesApi(deps); } diff --git a/x-pack/plugins/spaces/server/routes/api/external/post.test.ts b/x-pack/plugins/spaces/server/routes/api/external/post.test.ts index 6aa89b36b020a..6aeec251e33e4 100644 --- a/x-pack/plugins/spaces/server/routes/api/external/post.test.ts +++ b/x-pack/plugins/spaces/server/routes/api/external/post.test.ts @@ -67,6 +67,7 @@ describe('Spaces Public API', () => { getImportExportObjectLimit: () => 1000, log, spacesService, + authorization: null, // not needed for this route }); const [routeDefinition, routeHandler] = router.post.mock.calls[0]; diff --git a/x-pack/plugins/spaces/server/routes/api/external/put.test.ts b/x-pack/plugins/spaces/server/routes/api/external/put.test.ts index ebdffa20a6c8e..326837f8995f0 100644 --- a/x-pack/plugins/spaces/server/routes/api/external/put.test.ts +++ b/x-pack/plugins/spaces/server/routes/api/external/put.test.ts @@ -68,6 +68,7 @@ describe('PUT /api/spaces/space', () => { getImportExportObjectLimit: () => 1000, log, spacesService, + authorization: null, // not needed for this route }); const [routeDefinition, routeHandler] = router.put.mock.calls[0]; diff --git a/x-pack/plugins/spaces/server/routes/api/external/share_add_spaces.ts b/x-pack/plugins/spaces/server/routes/api/external/share_add_spaces.ts deleted file mode 100644 index ee61ccd2d5e41..0000000000000 --- a/x-pack/plugins/spaces/server/routes/api/external/share_add_spaces.ts +++ /dev/null @@ -1,62 +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 { schema } from '@kbn/config-schema'; -import { wrapError } from '../../../lib/errors'; -import { ExternalRouteDeps } from '.'; -import { SPACE_ID_REGEX } from '../../../lib/space_schema'; -import { createLicensedRouteHandler } from '../../lib'; - -const uniq = (arr: T[]): T[] => Array.from(new Set(arr)); -export function initShareAddSpacesApi(deps: ExternalRouteDeps) { - const { externalRouter, getStartServices } = deps; - - externalRouter.post( - { - path: '/api/spaces/_share_saved_object_add', - validate: { - body: schema.object({ - spaces: schema.arrayOf( - schema.string({ - validate: (value) => { - if (!SPACE_ID_REGEX.test(value)) { - return `lower case, a-z, 0-9, "_", and "-" are allowed`; - } - }, - }), - { - validate: (spaceIds) => { - if (!spaceIds.length) { - return 'must specify one or more space ids'; - } else if (uniq(spaceIds).length !== spaceIds.length) { - return 'duplicate space ids are not allowed'; - } - }, - } - ), - object: schema.object({ - type: schema.string(), - id: schema.string(), - }), - }), - }, - }, - createLicensedRouteHandler(async (_context, request, response) => { - const [startServices] = await getStartServices(); - const scopedClient = startServices.savedObjects.getScopedClient(request); - - const spaces = request.body.spaces; - const { type, id } = request.body.object; - - try { - await scopedClient.addToNamespaces(type, id, spaces); - } catch (error) { - return response.customError(wrapError(error)); - } - return response.noContent(); - }) - ); -} diff --git a/x-pack/plugins/spaces/server/routes/api/external/share_remove_spaces.ts b/x-pack/plugins/spaces/server/routes/api/external/share_remove_spaces.ts deleted file mode 100644 index d03185ea7aa09..0000000000000 --- a/x-pack/plugins/spaces/server/routes/api/external/share_remove_spaces.ts +++ /dev/null @@ -1,62 +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 { schema } from '@kbn/config-schema'; -import { wrapError } from '../../../lib/errors'; -import { ExternalRouteDeps } from '.'; -import { SPACE_ID_REGEX } from '../../../lib/space_schema'; -import { createLicensedRouteHandler } from '../../lib'; - -const uniq = (arr: T[]): T[] => Array.from(new Set(arr)); -export function initShareRemoveSpacesApi(deps: ExternalRouteDeps) { - const { externalRouter, getStartServices } = deps; - - externalRouter.post( - { - path: '/api/spaces/_share_saved_object_remove', - validate: { - body: schema.object({ - spaces: schema.arrayOf( - schema.string({ - validate: (value) => { - if (!SPACE_ID_REGEX.test(value)) { - return `lower case, a-z, 0-9, "_", and "-" are allowed`; - } - }, - }), - { - validate: (spaceIds) => { - if (!spaceIds.length) { - return 'must specify one or more space ids'; - } else if (uniq(spaceIds).length !== spaceIds.length) { - return 'duplicate space ids are not allowed'; - } - }, - } - ), - object: schema.object({ - type: schema.string(), - id: schema.string(), - }), - }), - }, - }, - createLicensedRouteHandler(async (_context, request, response) => { - const [startServices] = await getStartServices(); - const scopedClient = startServices.savedObjects.getScopedClient(request); - - const spaces = request.body.spaces; - const { type, id } = request.body.object; - - try { - await scopedClient.deleteFromNamespaces(type, id, spaces); - } catch (error) { - return response.customError(wrapError(error)); - } - return response.noContent(); - }) - ); -} diff --git a/x-pack/plugins/spaces/server/routes/api/external/share_to_space.test.ts b/x-pack/plugins/spaces/server/routes/api/external/share_to_space.test.ts new file mode 100644 index 0000000000000..3af1d9d245d10 --- /dev/null +++ b/x-pack/plugins/spaces/server/routes/api/external/share_to_space.test.ts @@ -0,0 +1,325 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import * as Rx from 'rxjs'; +import { + createSpaces, + createMockSavedObjectsRepository, + mockRouteContext, + mockRouteContextWithInvalidLicense, + createMockSavedObjectsService, +} from '../__fixtures__'; +import { CoreSetup, kibanaResponseFactory, RouteValidatorConfig } from 'src/core/server'; +import { + loggingSystemMock, + httpServiceMock, + httpServerMock, + coreMock, +} from 'src/core/server/mocks'; +import { SpacesService } from '../../../spaces_service'; +import { SpacesAuditLogger } from '../../../lib/audit_logger'; +import { SpacesClient } from '../../../lib/spaces_client'; +import { initShareToSpacesApi } from './share_to_space'; +import { spacesConfig } from '../../../lib/__fixtures__'; +import { securityMock } from '../../../../../security/server/mocks'; +import { ObjectType } from '@kbn/config-schema'; +import { SecurityPluginSetup } from '../../../../../security/server'; + +describe('share to space', () => { + const spacesSavedObjects = createSpaces(); + const spaces = spacesSavedObjects.map((s) => ({ id: s.id, ...s.attributes })); + + const setup = async ({ + authorization = null, + }: { authorization?: SecurityPluginSetup['authz'] | null } = {}) => { + const httpService = httpServiceMock.createSetupContract(); + const router = httpService.createRouter(); + const savedObjectsRepositoryMock = createMockSavedObjectsRepository(spacesSavedObjects); + const log = loggingSystemMock.create().get('spaces'); + const coreStart = coreMock.createStart(); + const { savedObjects, savedObjectsClient } = createMockSavedObjectsService(spaces); + coreStart.savedObjects = savedObjects; + + const service = new SpacesService(log); + const spacesService = await service.setup({ + http: (httpService as unknown) as CoreSetup['http'], + getStartServices: async () => [coreStart, {}, {}], + authorization, + auditLogger: {} as SpacesAuditLogger, + config$: Rx.of(spacesConfig), + }); + + spacesService.scopedClient = jest.fn((req: any) => { + return Promise.resolve( + new SpacesClient( + null as any, + () => null, + null, + savedObjectsRepositoryMock, + spacesConfig, + savedObjectsRepositoryMock, + req + ) + ); + }); + + initShareToSpacesApi({ + externalRouter: router, + getStartServices: async () => [coreStart, {}, {}], + getImportExportObjectLimit: () => 1000, + log, + spacesService, + authorization, + }); + + const [ + [shareAdd, ctsRouteHandler], + [shareRemove, resolveRouteHandler], + ] = router.post.mock.calls; + + const [[, permissionsRouteHandler]] = router.get.mock.calls; + + return { + coreStart, + savedObjectsClient, + shareAdd: { + routeValidation: shareAdd.validate as RouteValidatorConfig<{}, {}, {}>, + routeHandler: ctsRouteHandler, + }, + shareRemove: { + routeValidation: shareRemove.validate as RouteValidatorConfig<{}, {}, {}>, + routeHandler: resolveRouteHandler, + }, + sharePermissions: { + routeHandler: permissionsRouteHandler, + }, + savedObjectsRepositoryMock, + }; + }; + + describe('GET /internal/spaces/_share_saved_object_permissions', () => { + it('returns true when security is not enabled', async () => { + const { sharePermissions } = await setup(); + + const request = httpServerMock.createKibanaRequest({ query: { type: 'foo' }, method: 'get' }); + const response = await sharePermissions.routeHandler( + mockRouteContext, + request, + kibanaResponseFactory + ); + + const { status, payload } = response; + expect(status).toEqual(200); + expect(payload).toEqual({ shareToAllSpaces: true }); + }); + + it('returns false when the user is not authorized globally', async () => { + const authorization = securityMock.createSetup().authz; + const globalPrivilegesCheck = jest.fn().mockResolvedValue({ hasAllRequested: false }); + authorization.checkPrivilegesWithRequest.mockReturnValue({ + globally: globalPrivilegesCheck, + }); + const { sharePermissions } = await setup({ authorization }); + + const request = httpServerMock.createKibanaRequest({ query: { type: 'foo' }, method: 'get' }); + const response = await sharePermissions.routeHandler( + mockRouteContext, + request, + kibanaResponseFactory + ); + + const { status, payload } = response; + expect(status).toEqual(200); + expect(payload).toEqual({ shareToAllSpaces: false }); + + expect(authorization.checkPrivilegesWithRequest).toHaveBeenCalledTimes(1); + expect(authorization.checkPrivilegesWithRequest).toHaveBeenCalledWith(request); + }); + + it('returns true when the user is authorized globally', async () => { + const authorization = securityMock.createSetup().authz; + const globalPrivilegesCheck = jest.fn().mockResolvedValue({ hasAllRequested: true }); + authorization.checkPrivilegesWithRequest.mockReturnValue({ + globally: globalPrivilegesCheck, + }); + const { sharePermissions } = await setup({ authorization }); + + const request = httpServerMock.createKibanaRequest({ query: { type: 'foo' }, method: 'get' }); + const response = await sharePermissions.routeHandler( + mockRouteContext, + request, + kibanaResponseFactory + ); + + const { status, payload } = response; + expect(status).toEqual(200); + expect(payload).toEqual({ shareToAllSpaces: true }); + + expect(authorization.checkPrivilegesWithRequest).toHaveBeenCalledTimes(1); + expect(authorization.checkPrivilegesWithRequest).toHaveBeenCalledWith(request); + }); + }); + + describe('POST /api/spaces/_share_saved_object_add', () => { + const object = { id: 'foo', type: 'bar' }; + + it(`returns http/403 when the license is invalid`, async () => { + const { shareAdd } = await setup(); + + const request = httpServerMock.createKibanaRequest({ method: 'post' }); + const response = await shareAdd.routeHandler( + mockRouteContextWithInvalidLicense, + request, + kibanaResponseFactory + ); + + expect(response.status).toEqual(403); + expect(response.payload).toEqual({ + message: 'License is invalid for spaces', + }); + }); + + it(`requires at least 1 space ID`, async () => { + const { shareAdd } = await setup(); + const payload = { spaces: [], object }; + + expect(() => + (shareAdd.routeValidation.body as ObjectType).validate(payload) + ).toThrowErrorMatchingInlineSnapshot(`"[spaces]: must specify one or more space ids"`); + }); + + it(`requires space IDs to be unique`, async () => { + const { shareAdd } = await setup(); + const payload = { spaces: ['a-space', 'a-space'], object }; + + expect(() => + (shareAdd.routeValidation.body as ObjectType).validate(payload) + ).toThrowErrorMatchingInlineSnapshot(`"[spaces]: duplicate space ids are not allowed"`); + }); + + it(`requires well-formed space IDS`, async () => { + const { shareAdd } = await setup(); + const payload = { spaces: ['a-space', 'a-space-invalid-!@#$%^&*()'], object }; + + expect(() => + (shareAdd.routeValidation.body as ObjectType).validate(payload) + ).toThrowErrorMatchingInlineSnapshot( + `"[spaces.1]: lower case, a-z, 0-9, \\"_\\", and \\"-\\" are allowed, OR \\"*\\""` + ); + }); + + it(`allows all spaces ("*")`, async () => { + const { shareAdd } = await setup(); + const payload = { spaces: ['*'], object }; + + expect(() => + (shareAdd.routeValidation.body as ObjectType).validate(payload) + ).not.toThrowError(); + }); + + it('adds the object to the specified space(s)', async () => { + const { shareAdd, savedObjectsClient } = await setup(); + const payload = { spaces: ['a-space', 'b-space'], object }; + + const request = httpServerMock.createKibanaRequest({ body: payload, method: 'post' }); + const response = await shareAdd.routeHandler( + mockRouteContext, + request, + kibanaResponseFactory + ); + + const { status } = response; + expect(status).toEqual(204); + expect(savedObjectsClient.addToNamespaces).toHaveBeenCalledTimes(1); + expect(savedObjectsClient.addToNamespaces).toHaveBeenCalledWith( + payload.object.type, + payload.object.id, + payload.spaces + ); + }); + }); + + describe('POST /api/spaces/_share_saved_object_remove', () => { + const object = { id: 'foo', type: 'bar' }; + + it(`returns http/403 when the license is invalid`, async () => { + const { shareRemove } = await setup(); + + const request = httpServerMock.createKibanaRequest({ + method: 'post', + }); + + const response = await shareRemove.routeHandler( + mockRouteContextWithInvalidLicense, + request, + kibanaResponseFactory + ); + + expect(response.status).toEqual(403); + expect(response.payload).toEqual({ + message: 'License is invalid for spaces', + }); + }); + + it(`requires at least 1 space ID`, async () => { + const { shareRemove } = await setup(); + const payload = { spaces: [], object }; + + expect(() => + (shareRemove.routeValidation.body as ObjectType).validate(payload) + ).toThrowErrorMatchingInlineSnapshot(`"[spaces]: must specify one or more space ids"`); + }); + + it(`requires space IDs to be unique`, async () => { + const { shareRemove } = await setup(); + const payload = { spaces: ['a-space', 'a-space'], object }; + + expect(() => + (shareRemove.routeValidation.body as ObjectType).validate(payload) + ).toThrowErrorMatchingInlineSnapshot(`"[spaces]: duplicate space ids are not allowed"`); + }); + + it(`requires well-formed space IDS`, async () => { + const { shareRemove } = await setup(); + const payload = { spaces: ['a-space', 'a-space-invalid-!@#$%^&*()'], object }; + + expect(() => + (shareRemove.routeValidation.body as ObjectType).validate(payload) + ).toThrowErrorMatchingInlineSnapshot( + `"[spaces.1]: lower case, a-z, 0-9, \\"_\\", and \\"-\\" are allowed, OR \\"*\\""` + ); + }); + + it(`allows all spaces ("*")`, async () => { + const { shareRemove } = await setup(); + const payload = { spaces: ['*'], object }; + + expect(() => + (shareRemove.routeValidation.body as ObjectType).validate(payload) + ).not.toThrowError(); + }); + + it('removes the object from the specified space(s)', async () => { + const { shareRemove, savedObjectsClient } = await setup(); + const payload = { spaces: ['a-space', 'b-space'], object }; + + const request = httpServerMock.createKibanaRequest({ body: payload, method: 'post' }); + const response = await shareRemove.routeHandler( + mockRouteContext, + request, + kibanaResponseFactory + ); + + const { status } = response; + expect(status).toEqual(204); + expect(savedObjectsClient.deleteFromNamespaces).toHaveBeenCalledTimes(1); + expect(savedObjectsClient.deleteFromNamespaces).toHaveBeenCalledWith( + payload.object.type, + payload.object.id, + payload.spaces + ); + }); + }); +}); diff --git a/x-pack/plugins/spaces/server/routes/api/external/share_to_space.ts b/x-pack/plugins/spaces/server/routes/api/external/share_to_space.ts new file mode 100644 index 0000000000000..7acf9e3e6e3d0 --- /dev/null +++ b/x-pack/plugins/spaces/server/routes/api/external/share_to_space.ts @@ -0,0 +1,100 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema } from '@kbn/config-schema'; +import { wrapError } from '../../../lib/errors'; +import { ExternalRouteDeps } from '.'; +import { ALL_SPACES_ID } from '../../../../common/constants'; +import { SPACE_ID_REGEX } from '../../../lib/space_schema'; +import { createLicensedRouteHandler } from '../../lib'; + +const uniq = (arr: T[]): T[] => Array.from(new Set(arr)); +export function initShareToSpacesApi(deps: ExternalRouteDeps) { + const { externalRouter, getStartServices, authorization } = deps; + + const shareSchema = schema.object({ + spaces: schema.arrayOf( + schema.string({ + validate: (value) => { + if (value !== ALL_SPACES_ID && !SPACE_ID_REGEX.test(value)) { + return `lower case, a-z, 0-9, "_", and "-" are allowed, OR "*"`; + } + }, + }), + { + validate: (spaceIds) => { + if (!spaceIds.length) { + return 'must specify one or more space ids'; + } else if (uniq(spaceIds).length !== spaceIds.length) { + return 'duplicate space ids are not allowed'; + } + }, + } + ), + object: schema.object({ type: schema.string(), id: schema.string() }), + }); + + externalRouter.get( + { + path: '/internal/spaces/_share_saved_object_permissions', + validate: { query: schema.object({ type: schema.string() }) }, + }, + createLicensedRouteHandler(async (_context, request, response) => { + let shareToAllSpaces = true; + const { type } = request.query; + + if (authorization) { + try { + const checkPrivileges = authorization.checkPrivilegesWithRequest(request); + shareToAllSpaces = ( + await checkPrivileges.globally({ + kibana: authorization.actions.savedObject.get(type, 'share_to_space'), + }) + ).hasAllRequested; + } catch (error) { + return response.customError(wrapError(error)); + } + } + return response.ok({ body: { shareToAllSpaces } }); + }) + ); + + externalRouter.post( + { path: '/api/spaces/_share_saved_object_add', validate: { body: shareSchema } }, + createLicensedRouteHandler(async (_context, request, response) => { + const [startServices] = await getStartServices(); + const scopedClient = startServices.savedObjects.getScopedClient(request); + + const spaces = request.body.spaces; + const { type, id } = request.body.object; + + try { + await scopedClient.addToNamespaces(type, id, spaces); + } catch (error) { + return response.customError(wrapError(error)); + } + return response.noContent(); + }) + ); + + externalRouter.post( + { path: '/api/spaces/_share_saved_object_remove', validate: { body: shareSchema } }, + createLicensedRouteHandler(async (_context, request, response) => { + const [startServices] = await getStartServices(); + const scopedClient = startServices.savedObjects.getScopedClient(request); + + const spaces = request.body.spaces; + const { type, id } = request.body.object; + + try { + await scopedClient.deleteFromNamespaces(type, id, spaces); + } catch (error) { + return response.customError(wrapError(error)); + } + return response.noContent(); + }) + ); +} diff --git a/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.ts b/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.ts index a65e0431aef92..4c8e93acd68ac 100644 --- a/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.ts +++ b/x-pack/plugins/spaces/server/saved_objects/spaces_saved_objects_client.ts @@ -20,6 +20,7 @@ import { SavedObjectsUtils, ISavedObjectTypeRegistry, } from '../../../../../src/core/server'; +import { ALL_SPACES_ID } from '../../common/constants'; import { SpacesServiceSetup } from '../spaces_service/spaces_service'; import { spaceIdToNamespace } from '../lib/utils/namespace'; import { SpacesClient } from '../lib/spaces_client'; @@ -169,7 +170,7 @@ export class SpacesSavedObjectsClient implements SavedObjectsClientContract { try { const availableSpaces = await spacesClient.getAll('findSavedObjects'); - if (namespaces.includes('*')) { + if (namespaces.includes(ALL_SPACES_ID)) { namespaces = availableSpaces.map((space) => space.id); } else { namespaces = namespaces.filter((namespace) => diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api.ts b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api.ts index d5711a3e8c919..6dfba82bdf5c9 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/alert_api.ts @@ -11,13 +11,7 @@ import { fold } from 'fp-ts/lib/Either'; import { pick } from 'lodash'; import { alertStateSchema, AlertingFrameworkHealth } from '../../../../alerts/common'; import { BASE_ALERT_API_PATH } from '../constants'; -import { - Alert, - AlertType, - AlertWithoutId, - AlertTaskState, - AlertInstanceSummary, -} from '../../types'; +import { Alert, AlertType, AlertUpdates, AlertTaskState, AlertInstanceSummary } from '../../types'; export async function loadAlertTypes({ http }: { http: HttpSetup }): Promise { return await http.get(`${BASE_ALERT_API_PATH}/list_alert_types`); @@ -70,12 +64,14 @@ export async function loadAlerts({ searchText, typesFilter, actionTypesFilter, + alertStatusesFilter, }: { http: HttpSetup; page: { index: number; size: number }; searchText?: string; typesFilter?: string[]; actionTypesFilter?: string[]; + alertStatusesFilter?: string[]; }): Promise<{ page: number; perPage: number; @@ -97,6 +93,9 @@ export async function loadAlerts({ ].join('') ); } + if (alertStatusesFilter && alertStatusesFilter.length) { + filters.push(`alert.attributes.executionStatus.status:(${alertStatusesFilter.join(' or ')})`); + } return await http.get(`${BASE_ALERT_API_PATH}/_find`, { query: { page: page.index + 1, @@ -137,7 +136,7 @@ export async function createAlert({ }: { http: HttpSetup; alert: Omit< - AlertWithoutId, + AlertUpdates, 'createdBy' | 'updatedBy' | 'muteAll' | 'mutedInstanceIds' | 'executionStatus' >; }): Promise { @@ -152,7 +151,7 @@ export async function updateAlert({ id, }: { http: HttpSetup; - alert: Pick; + alert: Pick; id: string; }): Promise { return await http.put(`${BASE_ALERT_API_PATH}/alert/${id}`, { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.test.tsx index 5c9969221cfc3..51c3e030f44eb 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.test.tsx @@ -15,6 +15,7 @@ import { EuiSwitch, EuiBetaBadge, EuiButtonEmpty, + EuiText, } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { ViewInApp } from './view_in_app'; @@ -142,6 +143,38 @@ describe('alert_details', () => { ).toBeTruthy(); }); + it('renders the alert error banner with error message, when alert status is an error', () => { + const alert = mockAlert({ + executionStatus: { + status: 'error', + lastExecutionDate: new Date('2020-08-20T19:23:38Z'), + error: { + reason: 'unknown', + message: 'test', + }, + }, + }); + const alertType = { + id: '.noop', + name: 'No Op', + actionGroups: [{ id: 'default', name: 'Default' }], + actionVariables: { context: [], state: [], params: [] }, + defaultActionGroupId: 'default', + producer: ALERTS_FEATURE_ID, + authorizedConsumers, + }; + + expect( + shallow( + + ).containsMatchingElement( + + {'test'} + + ) + ).toBeTruthy(); + }); + describe('actions', () => { it('renders an alert action', () => { const alert = mockAlert({ diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.tsx index 6ee7915e2be71..42a25b399ddd3 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_details/components/alert_details.tsx @@ -24,6 +24,7 @@ import { EuiSpacer, EuiBetaBadge, EuiButtonEmpty, + EuiButton, } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; @@ -42,6 +43,7 @@ import { PLUGIN } from '../../../constants/plugin'; import { AlertEdit } from '../../alert_form'; import { AlertsContextProvider } from '../../../context/alerts_context'; import { routeToAlertDetails } from '../../../constants'; +import { alertsErrorReasonTranslationsMapping } from '../../alerts_list/translations'; type AlertDetailsProps = { alert: Alert; @@ -105,11 +107,20 @@ export const AlertDetails: React.FunctionComponent = ({ const [isEnabled, setIsEnabled] = useState(alert.enabled); const [isMuted, setIsMuted] = useState(alert.muteAll); const [editFlyoutVisible, setEditFlyoutVisibility] = useState(false); + const [dissmissAlertErrors, setDissmissAlertErrors] = useState(false); const setAlert = async () => { history.push(routeToAlertDetails.replace(`:alertId`, alert.id)); }; + const getAlertStatusErrorReasonText = () => { + if (alert.executionStatus.error && alert.executionStatus.error.reason) { + return alertsErrorReasonTranslationsMapping[alert.executionStatus.error.reason]; + } else { + return alertsErrorReasonTranslationsMapping.unknown; + } + }; + return ( @@ -275,6 +286,30 @@ export const AlertDetails: React.FunctionComponent = ({
+ {!dissmissAlertErrors && alert.executionStatus.status === 'error' ? ( + + + + + {alert.executionStatus.error?.message} + + + setDissmissAlertErrors(true)}> + + + + + + ) : null} {alert.enabled ? ( diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alert_status_filter.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alert_status_filter.tsx new file mode 100644 index 0000000000000..87e7a82cd8f23 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alert_status_filter.tsx @@ -0,0 +1,105 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { useEffect, useState } from 'react'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + EuiFilterGroup, + EuiPopover, + EuiFilterButton, + EuiFilterSelectItem, + EuiHealth, +} from '@elastic/eui'; +import { + AlertExecutionStatuses, + AlertExecutionStatusValues, +} from '../../../../../../alerts/common'; +import { alertsStatusesTranslationsMapping } from '../translations'; + +interface AlertStatusFilterProps { + selectedStatuses: string[]; + onChange?: (selectedAlertStatusesIds: string[]) => void; +} + +export const AlertStatusFilter: React.FunctionComponent = ({ + selectedStatuses, + onChange, +}: AlertStatusFilterProps) => { + const [selectedValues, setSelectedValues] = useState(selectedStatuses); + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + + useEffect(() => { + if (onChange) { + onChange(selectedValues); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedValues]); + + useEffect(() => { + setSelectedValues(selectedStatuses); + }, [selectedStatuses]); + + return ( + + setIsPopoverOpen(false)} + button={ + 0} + numActiveFilters={selectedValues.length} + numFilters={selectedValues.length} + onClick={() => setIsPopoverOpen(!isPopoverOpen)} + > + + + } + > +
+ {[...AlertExecutionStatusValues].sort().map((item: AlertExecutionStatuses) => { + const healthColor = getHealthColor(item); + return ( + { + const isPreviouslyChecked = selectedValues.includes(item); + if (isPreviouslyChecked) { + setSelectedValues(selectedValues.filter((val) => val !== item)); + } else { + setSelectedValues(selectedValues.concat(item)); + } + }} + checked={selectedValues.includes(item) ? 'on' : undefined} + > + {alertsStatusesTranslationsMapping[item]} + + ); + })} +
+
+
+ ); +}; + +export function getHealthColor(status: AlertExecutionStatuses) { + switch (status) { + case 'active': + return 'primary'; + case 'error': + return 'danger'; + case 'ok': + return 'subdued'; + case 'pending': + return 'success'; + default: + return 'warning'; + } +} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.test.tsx index ada881cb93d1e..86b9afd9565f8 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.test.tsx @@ -150,7 +150,7 @@ describe('alerts_list component with items', () => { loadAlerts.mockResolvedValue({ page: 1, perPage: 10000, - total: 2, + total: 4, data: [ { id: '1', @@ -168,10 +168,59 @@ describe('alerts_list component with items', () => { throttle: '1m', muteAll: false, mutedInstanceIds: [], + executionStatus: { + status: 'active', + lastExecutionDate: new Date('2020-08-20T19:23:38Z'), + error: null, + }, }, { id: '2', - name: 'test alert 2', + name: 'test alert ok', + tags: ['tag1'], + enabled: true, + alertTypeId: 'test_alert_type', + schedule: { interval: '5d' }, + actions: [], + params: { name: 'test alert type name' }, + scheduledTaskId: null, + createdBy: null, + updatedBy: null, + apiKeyOwner: null, + throttle: '1m', + muteAll: false, + mutedInstanceIds: [], + executionStatus: { + status: 'ok', + lastExecutionDate: new Date('2020-08-20T19:23:38Z'), + error: null, + }, + }, + { + id: '3', + name: 'test alert pending', + tags: ['tag1'], + enabled: true, + alertTypeId: 'test_alert_type', + schedule: { interval: '5d' }, + actions: [], + params: { name: 'test alert type name' }, + scheduledTaskId: null, + createdBy: null, + updatedBy: null, + apiKeyOwner: null, + throttle: '1m', + muteAll: false, + mutedInstanceIds: [], + executionStatus: { + status: 'pending', + lastExecutionDate: new Date('2020-08-20T19:23:38Z'), + error: null, + }, + }, + { + id: '4', + name: 'test alert error', tags: ['tag1'], enabled: true, alertTypeId: 'test_alert_type', @@ -185,6 +234,14 @@ describe('alerts_list component with items', () => { throttle: '1m', muteAll: false, mutedInstanceIds: [], + executionStatus: { + status: 'error', + lastExecutionDate: new Date('2020-08-20T19:23:38Z'), + error: { + reason: 'unknown', + message: 'test', + }, + }, }, ], }); @@ -245,7 +302,13 @@ describe('alerts_list component with items', () => { it('renders table of alerts', async () => { await setup(); expect(wrapper.find('EuiBasicTable')).toHaveLength(1); - expect(wrapper.find('EuiTableRow')).toHaveLength(2); + expect(wrapper.find('EuiTableRow')).toHaveLength(4); + expect(wrapper.find('[data-test-subj="alertsTableCell-status"]').length).toBeGreaterThan(0); + expect(wrapper.find('[data-test-subj="alertStatus-active"]').length).toBeGreaterThan(0); + expect(wrapper.find('[data-test-subj="alertStatus-error"]').length).toBeGreaterThan(0); + expect(wrapper.find('[data-test-subj="alertStatus-ok"]').length).toBeGreaterThan(0); + expect(wrapper.find('[data-test-subj="alertStatus-pending"]').length).toBeGreaterThan(0); + expect(wrapper.find('[data-test-subj="alertStatus-unknown"]').length).toBe(0); }); }); @@ -351,6 +414,11 @@ describe('alerts_list with show only capability', () => { throttle: '1m', muteAll: false, mutedInstanceIds: [], + executionStatus: { + status: 'active', + lastExecutionDate: new Date('2020-08-20T19:23:38Z'), + error: null, + }, }, { id: '2', @@ -368,6 +436,11 @@ describe('alerts_list with show only capability', () => { throttle: '1m', muteAll: false, mutedInstanceIds: [], + executionStatus: { + status: 'active', + lastExecutionDate: new Date('2020-08-20T19:23:38Z'), + error: null, + }, }, ], }); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.tsx index 07e0fd7ae19e5..95082bc6ca99f 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/components/alerts_list.tsx @@ -4,6 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ +/* eslint-disable react-hooks/exhaustive-deps */ + import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import React, { useEffect, useState, Fragment } from 'react'; @@ -19,6 +21,10 @@ import { EuiLink, EuiLoadingSpinner, EuiEmptyPrompt, + EuiCallOut, + EuiButtonEmpty, + EuiHealth, + EuiText, } from '@elastic/eui'; import { useHistory } from 'react-router-dom'; @@ -32,14 +38,20 @@ import { AlertQuickEditButtonsWithApi as AlertQuickEditButtons } from '../../com import { CollapsedItemActionsWithApi as CollapsedItemActions } from './collapsed_item_actions'; import { TypeFilter } from './type_filter'; import { ActionTypeFilter } from './action_type_filter'; +import { AlertStatusFilter, getHealthColor } from './alert_status_filter'; import { loadAlerts, loadAlertTypes, deleteAlerts } from '../../../lib/alert_api'; import { loadActionTypes } from '../../../lib/action_connector_api'; import { hasExecuteActionsCapability } from '../../../lib/capabilities'; import { routeToAlertDetails, DEFAULT_SEARCH_PAGE_SIZE } from '../../../constants'; import { DeleteModalConfirmation } from '../../../components/delete_modal_confirmation'; import { EmptyPrompt } from '../../../components/prompts/empty_prompt'; -import { ALERTS_FEATURE_ID } from '../../../../../../alerts/common'; +import { + AlertExecutionStatus, + AlertExecutionStatusValues, + ALERTS_FEATURE_ID, +} from '../../../../../../alerts/common'; import { hasAllPrivilege } from '../../../lib/capabilities'; +import { alertsStatusesTranslationsMapping } from '../translations'; const ENTER_KEY = 13; @@ -77,7 +89,19 @@ export const AlertsList: React.FunctionComponent = () => { const [inputText, setInputText] = useState(); const [typesFilter, setTypesFilter] = useState([]); const [actionTypesFilter, setActionTypesFilter] = useState([]); + const [alertStatusesFilter, setAlertStatusesFilter] = useState([]); const [alertFlyoutVisible, setAlertFlyoutVisibility] = useState(false); + const [dissmissAlertErrors, setDissmissAlertErrors] = useState(false); + const [alertsStatusesTotal, setAlertsStatusesTotal] = useState>( + AlertExecutionStatusValues.reduce( + (prev: Record, status: string) => + ({ + ...prev, + [status]: 0, + } as Record), + {} + ) + ); const [alertTypesState, setAlertTypesState] = useState({ isLoading: false, isInitialized: false, @@ -92,13 +116,14 @@ export const AlertsList: React.FunctionComponent = () => { useEffect(() => { loadAlertsData(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [alertTypesState, page, searchText]); - - useEffect(() => { - loadAlertsData(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [JSON.stringify(typesFilter), JSON.stringify(actionTypesFilter)]); + }, [ + alertTypesState, + page, + searchText, + JSON.stringify(typesFilter), + JSON.stringify(actionTypesFilter), + JSON.stringify(alertStatusesFilter), + ]); useEffect(() => { (async () => { @@ -120,7 +145,6 @@ export const AlertsList: React.FunctionComponent = () => { setAlertTypesState({ ...alertTypesState, isLoading: false }); } })(); - // eslint-disable-next-line react-hooks/exhaustive-deps }, []); useEffect(() => { @@ -137,7 +161,6 @@ export const AlertsList: React.FunctionComponent = () => { }); } })(); - // eslint-disable-next-line react-hooks/exhaustive-deps }, []); async function loadAlertsData() { @@ -151,7 +174,9 @@ export const AlertsList: React.FunctionComponent = () => { searchText, typesFilter, actionTypesFilter, + alertStatusesFilter, }); + await loadAlertsTotalStatuses(); setAlertsState({ isLoading: false, data: alertsResponse.data, @@ -175,7 +200,52 @@ export const AlertsList: React.FunctionComponent = () => { } } + async function loadAlertsTotalStatuses() { + let alertsStatuses = {}; + try { + AlertExecutionStatusValues.forEach(async (status: string) => { + const alertsTotalResponse = await loadAlerts({ + http, + page: { index: 0, size: 0 }, + searchText, + typesFilter, + actionTypesFilter, + alertStatusesFilter: [status], + }); + setAlertsStatusesTotal({ ...alertsStatuses, [status]: alertsTotalResponse.total }); + alertsStatuses = { ...alertsStatuses, [status]: alertsTotalResponse.total }; + }); + } catch (e) { + toastNotifications.addDanger({ + title: i18n.translate( + 'xpack.triggersActionsUI.sections.alertsList.unableToLoadAlertsStatusesInfoMessage', + { + defaultMessage: 'Unable to load alert statuses info', + } + ), + }); + } + } + const alertsTableColumns = [ + { + field: 'executionStatus', + name: i18n.translate( + 'xpack.triggersActionsUI.sections.alertsList.alertsListTable.columns.statusTitle', + { defaultMessage: 'Status' } + ), + sortable: false, + truncateText: false, + 'data-test-subj': 'alertsTableCell-status', + render: (executionStatus: AlertExecutionStatus) => { + const healthColor = getHealthColor(executionStatus.status); + return ( + + {alertsStatusesTranslationsMapping[executionStatus.status]} + + ); + }, + }, { field: 'name', name: i18n.translate( @@ -280,24 +350,13 @@ export const AlertsList: React.FunctionComponent = () => { actionTypes={actionTypes} onChange={(ids: string[]) => setActionTypesFilter(ids)} />, + setAlertStatusesFilter(ids)} + />, ]; - if (authorizedToCreateAnyAlerts) { - toolsRight.push( - setAlertFlyoutVisibility(true)} - > - - - ); - } - const authorizedToModifySelectedAlerts = selectedIds.length ? filterAlertsById(alertsState.data, selectedIds).every((selectedAlert) => hasAllPrivilege(selectedAlert, alertTypesState.data.get(selectedAlert.alertTypeId)) @@ -326,6 +385,21 @@ export const AlertsList: React.FunctionComponent = () => {
)} + {authorizedToCreateAnyAlerts ? ( + + setAlertFlyoutVisibility(true)} + > + + + + ) : null} {
- + + {!dissmissAlertErrors && alertsStatusesTotal.error > 0 ? ( + + + + } + iconType="alert" + > + setAlertStatusesFilter(['error'])} + > + + + setDissmissAlertErrors(true)}> + + + + + + ) : null} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {/* Large to remain consistent with ActionsList table spacing */} @@ -402,7 +587,8 @@ export const AlertsList: React.FunctionComponent = () => { const isFilterApplied = !( isEmpty(searchText) && isEmpty(typesFilter) && - isEmpty(actionTypesFilter) + isEmpty(actionTypesFilter) && + isEmpty(alertStatusesFilter) ); return ( diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/translations.ts b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/translations.ts new file mode 100644 index 0000000000000..dbcf2d6854af5 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alerts_list/translations.ts @@ -0,0 +1,85 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const ALERT_STATUS_OK = i18n.translate( + 'xpack.triggersActionsUI.sections.alertsList.alertStatusOk', + { + defaultMessage: 'Ok', + } +); + +export const ALERT_STATUS_ACTIVE = i18n.translate( + 'xpack.triggersActionsUI.sections.alertsList.alertStatusActive', + { + defaultMessage: 'Active', + } +); + +export const ALERT_STATUS_ERROR = i18n.translate( + 'xpack.triggersActionsUI.sections.alertsList.alertStatusError', + { + defaultMessage: 'Error', + } +); + +export const ALERT_STATUS_PENDING = i18n.translate( + 'xpack.triggersActionsUI.sections.alertsList.alertStatusPending', + { + defaultMessage: 'Pending', + } +); + +export const ALERT_STATUS_UNKNOWN = i18n.translate( + 'xpack.triggersActionsUI.sections.alertsList.alertStatusUnknown', + { + defaultMessage: 'Unknown', + } +); + +export const alertsStatusesTranslationsMapping = { + ok: ALERT_STATUS_OK, + active: ALERT_STATUS_ACTIVE, + error: ALERT_STATUS_ERROR, + pending: ALERT_STATUS_PENDING, + unknown: ALERT_STATUS_UNKNOWN, +}; + +export const ALERT_ERROR_UNKNOWN_REASON = i18n.translate( + 'xpack.triggersActionsUI.sections.alertsList.alertErrorReasonUnknown', + { + defaultMessage: 'An error occurred for unknown reasons.', + } +); + +export const ALERT_ERROR_READING_REASON = i18n.translate( + 'xpack.triggersActionsUI.sections.alertsList.alertErrorReasonReading', + { + defaultMessage: 'An error occurred when reading the alert.', + } +); + +export const ALERT_ERROR_DECRYPTING_REASON = i18n.translate( + 'xpack.triggersActionsUI.sections.alertsList.alertErrorReasonDecrypting', + { + defaultMessage: 'An error occurred when decrypting the alert.', + } +); + +export const ALERT_ERROR_EXECUTION_REASON = i18n.translate( + 'xpack.triggersActionsUI.sections.alertsList.alertErrorReasonRunning', + { + defaultMessage: 'An error occurred when running the alert.', + } +); + +export const alertsErrorReasonTranslationsMapping = { + read: ALERT_ERROR_READING_REASON, + decrypt: ALERT_ERROR_DECRYPTING_REASON, + execute: ALERT_ERROR_EXECUTION_REASON, + unknown: ALERT_ERROR_UNKNOWN_REASON, +}; diff --git a/x-pack/plugins/triggers_actions_ui/public/types.ts b/x-pack/plugins/triggers_actions_ui/public/types.ts index c551746fdec0c..148facdee248d 100644 --- a/x-pack/plugins/triggers_actions_ui/public/types.ts +++ b/x-pack/plugins/triggers_actions_ui/public/types.ts @@ -147,7 +147,7 @@ export interface AlertType { export type SanitizedAlertType = Omit; -export type AlertWithoutId = Omit; +export type AlertUpdates = Omit; export interface AlertTableItem extends Alert { alertType: AlertType['name']; diff --git a/x-pack/test/functional/page_objects/copy_saved_objects_to_space_page.ts b/x-pack/test/functional/page_objects/copy_saved_objects_to_space_page.ts index 6b8680271635b..00a364bb7543e 100644 --- a/x-pack/test/functional/page_objects/copy_saved_objects_to_space_page.ts +++ b/x-pack/test/functional/page_objects/copy_saved_objects_to_space_page.ts @@ -37,9 +37,9 @@ export function CopySavedObjectsToSpacePageProvider({ if (!overwrite) { const radio = await testSubjects.find('cts-copyModeControl-overwriteRadioGroup'); // a radio button consists of a div tag that contains an input, a div, and a label - // we can't click the input directly, need to go up one level and click the parent div - const div = await radio.findByXpath("//div[input[@id='overwriteDisabled']]"); - await div.click(); + // we can't click the input directly, need to click the label + const label = await radio.findByCssSelector('label[for="overwriteDisabled"]'); + await label.click(); } await testSubjects.click(`cts-space-selector-row-${destinationSpaceId}`); }, diff --git a/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/data.json b/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/data.json index 4c0447c29c8f9..d9d5c6f9c5808 100644 --- a/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/data.json +++ b/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/data.json @@ -347,6 +347,23 @@ } } +{ + "type": "doc", + "value": { + "id": "sharedtype:all_spaces", + "index": ".kibana", + "source": { + "sharedtype": { + "title": "A shared saved-object in all spaces" + }, + "type": "sharedtype", + "namespaces": ["*"], + "updated_at": "2017-09-21T18:59:16.270Z" + }, + "type": "doc" + } +} + { "type": "doc", "value": { diff --git a/x-pack/test/saved_object_api_integration/common/lib/saved_object_test_cases.ts b/x-pack/test/saved_object_api_integration/common/lib/saved_object_test_cases.ts index 190b12e038b27..e8558acc2c1f7 100644 --- a/x-pack/test/saved_object_api_integration/common/lib/saved_object_test_cases.ts +++ b/x-pack/test/saved_object_api_integration/common/lib/saved_object_test_cases.ts @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { SPACES } from './spaces'; +import { SPACES, ALL_SPACES_ID } from './spaces'; import { TestCase } from './types'; const { @@ -32,6 +32,11 @@ export const SAVED_OBJECT_TEST_CASES: Record = Object.fr id: 'space2-isolatedtype-id', expectedNamespaces: [SPACE_2_ID], }), + MULTI_NAMESPACE_ALL_SPACES: Object.freeze({ + type: 'sharedtype', + id: 'all_spaces', + expectedNamespaces: [ALL_SPACES_ID], + }), MULTI_NAMESPACE_DEFAULT_AND_SPACE_1: Object.freeze({ type: 'sharedtype', id: 'default_and_space_1', diff --git a/x-pack/test/saved_object_api_integration/common/lib/saved_object_test_utils.ts b/x-pack/test/saved_object_api_integration/common/lib/saved_object_test_utils.ts index 9d4b5e80e9c3d..511d183145a30 100644 --- a/x-pack/test/saved_object_api_integration/common/lib/saved_object_test_utils.ts +++ b/x-pack/test/saved_object_api_integration/common/lib/saved_object_test_utils.ts @@ -6,7 +6,7 @@ import expect from '@kbn/expect'; import { SavedObjectsErrorHelpers } from '../../../../../src/core/server'; -import { SPACES } from './spaces'; +import { SPACES, ALL_SPACES_ID } from './spaces'; import { AUTHENTICATION } from './authentication'; import { TestCase, TestUser, ExpectResponseBody } from './types'; @@ -73,7 +73,10 @@ export const getTestTitle = ( }; export const isUserAuthorizedAtSpace = (user: TestUser | undefined, namespace: string) => - !user || user.authorizedAtSpaces.includes('*') || user.authorizedAtSpaces.includes(namespace); + !user || + (user.authorizedAtSpaces.length > 0 && namespace === ALL_SPACES_ID) || + user.authorizedAtSpaces.includes('*') || + user.authorizedAtSpaces.includes(namespace); export const getRedactedNamespaces = ( user: TestUser | undefined, diff --git a/x-pack/test/saved_object_api_integration/common/lib/spaces.ts b/x-pack/test/saved_object_api_integration/common/lib/spaces.ts index a9c552d4ccd78..7b21c2f0245fa 100644 --- a/x-pack/test/saved_object_api_integration/common/lib/spaces.ts +++ b/x-pack/test/saved_object_api_integration/common/lib/spaces.ts @@ -15,3 +15,5 @@ export const SPACES = { spaceId: 'default', }, }; + +export const ALL_SPACES_ID = '*'; diff --git a/x-pack/test/saved_object_api_integration/common/suites/bulk_create.ts b/x-pack/test/saved_object_api_integration/common/suites/bulk_create.ts index b1608946b8e62..e10c1905d8541 100644 --- a/x-pack/test/saved_object_api_integration/common/suites/bulk_create.ts +++ b/x-pack/test/saved_object_api_integration/common/suites/bulk_create.ts @@ -8,9 +8,8 @@ import expect from '@kbn/expect'; import { SuperTest } from 'supertest'; import { SavedObjectsErrorHelpers } from '../../../../../src/core/server'; import { SAVED_OBJECT_TEST_CASES as CASES } from '../lib/saved_object_test_cases'; -import { SPACES } from '../lib/spaces'; +import { SPACES, ALL_SPACES_ID } from '../lib/spaces'; import { - createRequest, expectResponses, getUrlPrefix, getTestTitle, @@ -18,29 +17,57 @@ import { } from '../lib/saved_object_test_utils'; import { ExpectResponseBody, TestCase, TestDefinition, TestSuite, TestUser } from '../lib/types'; +const { + DEFAULT: { spaceId: DEFAULT_SPACE_ID }, + SPACE_1: { spaceId: SPACE_1_ID }, + SPACE_2: { spaceId: SPACE_2_ID }, +} = SPACES; + export interface BulkCreateTestDefinition extends TestDefinition { request: Array<{ type: string; id: string }>; overwrite: boolean; } export type BulkCreateTestSuite = TestSuite; export interface BulkCreateTestCase extends TestCase { + initialNamespaces?: string[]; failure?: 400 | 409; // only used for permitted response case fail409Param?: string; } const NEW_ATTRIBUTE_KEY = 'title'; // all type mappings include this attribute, for simplicity's sake const NEW_ATTRIBUTE_VAL = `New attribute value ${Date.now()}`; +const EACH_SPACE = [DEFAULT_SPACE_ID, SPACE_1_ID, SPACE_2_ID]; const NEW_SINGLE_NAMESPACE_OBJ = Object.freeze({ type: 'dashboard', id: 'new-dashboard-id' }); const NEW_MULTI_NAMESPACE_OBJ = Object.freeze({ type: 'sharedtype', id: 'new-sharedtype-id' }); +const NEW_EACH_SPACE_OBJ = Object.freeze({ + type: 'sharedtype', + id: 'new-each-space-id', + expectedNamespaces: EACH_SPACE, // expected namespaces of resulting object + initialNamespaces: EACH_SPACE, // args passed to the bulkCreate method +}); +const NEW_ALL_SPACES_OBJ = Object.freeze({ + type: 'sharedtype', + id: 'new-all-spaces-id', + expectedNamespaces: [ALL_SPACES_ID], // expected namespaces of resulting object + initialNamespaces: [ALL_SPACES_ID], // args passed to the bulkCreate method +}); const NEW_NAMESPACE_AGNOSTIC_OBJ = Object.freeze({ type: 'globaltype', id: 'new-globaltype-id' }); export const TEST_CASES: Record = Object.freeze({ ...CASES, NEW_SINGLE_NAMESPACE_OBJ, NEW_MULTI_NAMESPACE_OBJ, + NEW_EACH_SPACE_OBJ, + NEW_ALL_SPACES_OBJ, NEW_NAMESPACE_AGNOSTIC_OBJ, }); +const createRequest = ({ type, id, initialNamespaces }: BulkCreateTestCase) => ({ + type, + id, + ...(initialNamespaces && { namespaces: initialNamespaces }), +}); + export function bulkCreateTestSuiteFactory(es: any, esArchiver: any, supertest: SuperTest) { const expectForbidden = expectResponses.forbiddenTypes('bulk_create'); const expectResponseBody = ( diff --git a/x-pack/test/saved_object_api_integration/common/suites/create.ts b/x-pack/test/saved_object_api_integration/common/suites/create.ts index 7e28d5ed9ed94..1b3902be37d72 100644 --- a/x-pack/test/saved_object_api_integration/common/suites/create.ts +++ b/x-pack/test/saved_object_api_integration/common/suites/create.ts @@ -7,9 +7,8 @@ import expect from '@kbn/expect'; import { SuperTest } from 'supertest'; import { SAVED_OBJECT_TEST_CASES as CASES } from '../lib/saved_object_test_cases'; -import { SPACES } from '../lib/spaces'; +import { SPACES, ALL_SPACES_ID } from '../lib/spaces'; import { - createRequest, expectResponses, getUrlPrefix, getTestTitle, @@ -17,30 +16,58 @@ import { } from '../lib/saved_object_test_utils'; import { ExpectResponseBody, TestCase, TestDefinition, TestSuite, TestUser } from '../lib/types'; +const { + DEFAULT: { spaceId: DEFAULT_SPACE_ID }, + SPACE_1: { spaceId: SPACE_1_ID }, + SPACE_2: { spaceId: SPACE_2_ID }, +} = SPACES; + export interface CreateTestDefinition extends TestDefinition { - request: { type: string; id: string }; + request: { type: string; id: string; initialNamespaces?: string[] }; overwrite: boolean; } export type CreateTestSuite = TestSuite; export interface CreateTestCase extends TestCase { + initialNamespaces?: string[]; failure?: 400 | 403 | 409; } const NEW_ATTRIBUTE_KEY = 'title'; // all type mappings include this attribute, for simplicity's sake const NEW_ATTRIBUTE_VAL = `New attribute value ${Date.now()}`; +const EACH_SPACE = [DEFAULT_SPACE_ID, SPACE_1_ID, SPACE_2_ID]; // ID intentionally left blank on NEW_SINGLE_NAMESPACE_OBJ to ensure we can create saved objects without specifying the ID // we could create six separate test cases to test every permutation, but there's no real value in doing so const NEW_SINGLE_NAMESPACE_OBJ = Object.freeze({ type: 'dashboard', id: '' }); const NEW_MULTI_NAMESPACE_OBJ = Object.freeze({ type: 'sharedtype', id: 'new-sharedtype-id' }); +const NEW_EACH_SPACE_OBJ = Object.freeze({ + type: 'sharedtype', + id: 'new-each-space-id', + expectedNamespaces: EACH_SPACE, // expected namespaces of resulting object + initialNamespaces: EACH_SPACE, // args passed to the bulkCreate method +}); +const NEW_ALL_SPACES_OBJ = Object.freeze({ + type: 'sharedtype', + id: 'new-all-spaces-id', + expectedNamespaces: [ALL_SPACES_ID], // expected namespaces of resulting object + initialNamespaces: [ALL_SPACES_ID], // args passed to the bulkCreate method +}); const NEW_NAMESPACE_AGNOSTIC_OBJ = Object.freeze({ type: 'globaltype', id: 'new-globaltype-id' }); export const TEST_CASES: Record = Object.freeze({ ...CASES, NEW_SINGLE_NAMESPACE_OBJ, NEW_MULTI_NAMESPACE_OBJ, + NEW_EACH_SPACE_OBJ, + NEW_ALL_SPACES_OBJ, NEW_NAMESPACE_AGNOSTIC_OBJ, }); +const createRequest = ({ type, id, initialNamespaces }: CreateTestCase) => ({ + type, + id, + initialNamespaces, +}); + export function createTestSuiteFactory(es: any, esArchiver: any, supertest: SuperTest) { const expectForbidden = expectResponses.forbiddenTypes('create'); const expectResponseBody = ( @@ -96,9 +123,12 @@ export function createTestSuiteFactory(es: any, esArchiver: any, supertest: Supe for (const test of tests) { it(`should return ${test.responseStatusCode} ${test.title}`, async () => { - const { type, id } = test.request; + const { type, id, initialNamespaces } = test.request; const path = `${type}${id ? `/${id}` : ''}`; - const requestBody = { attributes: { [NEW_ATTRIBUTE_KEY]: NEW_ATTRIBUTE_VAL } }; + const requestBody = { + attributes: { [NEW_ATTRIBUTE_KEY]: NEW_ATTRIBUTE_VAL }, + ...(initialNamespaces && { namespaces: initialNamespaces }), + }; const query = test.overwrite ? '?overwrite=true' : ''; await supertest .post(`${getUrlPrefix(spaceId)}/api/saved_objects/${path}${query}`) diff --git a/x-pack/test/saved_object_api_integration/common/suites/delete.ts b/x-pack/test/saved_object_api_integration/common/suites/delete.ts index 228e7977f99ac..859bb2b7e8fe2 100644 --- a/x-pack/test/saved_object_api_integration/common/suites/delete.ts +++ b/x-pack/test/saved_object_api_integration/common/suites/delete.ts @@ -8,20 +8,16 @@ import { SuperTest } from 'supertest'; import expect from '@kbn/expect/expect.js'; import { SAVED_OBJECT_TEST_CASES as CASES } from '../lib/saved_object_test_cases'; import { SPACES } from '../lib/spaces'; -import { - createRequest, - expectResponses, - getUrlPrefix, - getTestTitle, -} from '../lib/saved_object_test_utils'; +import { expectResponses, getUrlPrefix, getTestTitle } from '../lib/saved_object_test_utils'; import { ExpectResponseBody, TestCase, TestDefinition, TestSuite } from '../lib/types'; export interface DeleteTestDefinition extends TestDefinition { - request: { type: string; id: string }; + request: { type: string; id: string; force?: boolean }; } export type DeleteTestSuite = TestSuite; export interface DeleteTestCase extends TestCase { - failure?: 403 | 404; + force?: boolean; + failure?: 400 | 403 | 404; } const DOES_NOT_EXIST = Object.freeze({ type: 'dashboard', id: 'does-not-exist' }); @@ -30,6 +26,11 @@ export const TEST_CASES: Record = Object.freeze({ DOES_NOT_EXIST, }); +/** + * Test cases have additional properties that we don't want to send in HTTP Requests + */ +const createRequest = ({ type, id, force }: DeleteTestCase) => ({ type, id, force }); + export function deleteTestSuiteFactory(esArchiver: any, supertest: SuperTest) { const expectForbidden = expectResponses.forbiddenTypes('delete'); const expectResponseBody = (testCase: DeleteTestCase): ExpectResponseBody => async ( @@ -81,9 +82,10 @@ export function deleteTestSuiteFactory(esArchiver: any, supertest: SuperTest { - const { type, id } = test.request; + const { type, id, force } = test.request; await supertest .delete(`${getUrlPrefix(spaceId)}/api/saved_objects/${type}/${id}`) + .query({ ...(force && { force }) }) .auth(user?.username, user?.password) .expect(test.responseStatusCode) .then(test.responseBody); diff --git a/x-pack/test/saved_object_api_integration/common/suites/export.ts b/x-pack/test/saved_object_api_integration/common/suites/export.ts index 4eb967a952c60..a1addda1cdd1f 100644 --- a/x-pack/test/saved_object_api_integration/common/suites/export.ts +++ b/x-pack/test/saved_object_api_integration/common/suites/export.ts @@ -75,14 +75,17 @@ export const getTestCases = (spaceId?: string): { [key: string]: ExportTestCase multiNamespaceType: { title: 'multi-namespace type', type: 'sharedtype', - successResult: (spaceId === SPACE_1_ID - ? [CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, CASES.MULTI_NAMESPACE_ONLY_SPACE_1] - : spaceId === SPACE_2_ID - ? [CASES.MULTI_NAMESPACE_ONLY_SPACE_2] - : [CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1] - ) - .concat([CONFLICT_1_OBJ, CONFLICT_2A_OBJ, CONFLICT_2B_OBJ, CONFLICT_3_OBJ, CONFLICT_4A_OBJ]) - .flat(), + successResult: [ + CASES.MULTI_NAMESPACE_ALL_SPACES, + ...(spaceId === SPACE_1_ID + ? [CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, CASES.MULTI_NAMESPACE_ONLY_SPACE_1] + : spaceId === SPACE_2_ID + ? [CASES.MULTI_NAMESPACE_ONLY_SPACE_2] + : [CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1] + ) + .concat([CONFLICT_1_OBJ, CONFLICT_2A_OBJ, CONFLICT_2B_OBJ, CONFLICT_3_OBJ, CONFLICT_4A_OBJ]) + .flat(), + ], }, namespaceAgnosticObject: { title: 'namespace-agnostic object', diff --git a/x-pack/test/saved_object_api_integration/common/suites/find.ts b/x-pack/test/saved_object_api_integration/common/suites/find.ts index 381306f810122..c7243d8db25fe 100644 --- a/x-pack/test/saved_object_api_integration/common/suites/find.ts +++ b/x-pack/test/saved_object_api_integration/common/suites/find.ts @@ -8,7 +8,7 @@ import expect from '@kbn/expect'; import { SuperTest } from 'supertest'; import querystring from 'querystring'; import { SAVED_OBJECT_TEST_CASES, CONFLICT_TEST_CASES } from '../lib/saved_object_test_cases'; -import { SPACES } from '../lib/spaces'; +import { SPACES, ALL_SPACES_ID } from '../lib/spaces'; import { getUrlPrefix, isUserAuthorizedAtSpace, @@ -75,13 +75,16 @@ export const getTestCases = ( return TEST_CASES.filter((t) => { const hasOtherNamespaces = !t.expectedNamespaces || // namespace-agnostic types do not have an expectedNamespaces field - t.expectedNamespaces.some((ns) => ns !== (currentSpace ?? DEFAULT_SPACE_ID)); + t.expectedNamespaces.some( + (ns) => ns === ALL_SPACES_ID || ns !== (currentSpace ?? DEFAULT_SPACE_ID) + ); return hasOtherNamespaces && predicate(t); }); } return TEST_CASES.filter( (t) => (!t.expectedNamespaces || + t.expectedNamespaces.includes(ALL_SPACES_ID) || t.expectedNamespaces.includes(currentSpace ?? DEFAULT_SPACE_ID)) && predicate(t) ); diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_create.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_create.ts index 93ae439d01166..a34bb4b3e78e7 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_create.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_create.ts @@ -43,6 +43,7 @@ const createTestCases = (overwrite: boolean, spaceId: string) => { ...fail409(!overwrite && spaceId === SPACE_2_ID), expectedNamespaces, }, + { ...CASES.MULTI_NAMESPACE_ALL_SPACES, ...fail409(!overwrite) }, { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, ...fail409(!overwrite || (spaceId !== DEFAULT_SPACE_ID && spaceId !== SPACE_1_ID)), @@ -63,9 +64,10 @@ const createTestCases = (overwrite: boolean, spaceId: string) => { { ...CASES.NEW_MULTI_NAMESPACE_OBJ, expectedNamespaces }, CASES.NEW_NAMESPACE_AGNOSTIC_OBJ, ]; + const crossNamespace = [CASES.NEW_EACH_SPACE_OBJ, CASES.NEW_ALL_SPACES_OBJ]; const hiddenType = [{ ...CASES.HIDDEN, ...fail400() }]; const allTypes = normalTypes.concat(hiddenType); - return { normalTypes, hiddenType, allTypes }; + return { normalTypes, crossNamespace, hiddenType, allTypes }; }; export default function ({ getService }: FtrProviderContext) { @@ -79,22 +81,37 @@ export default function ({ getService }: FtrProviderContext) { supertest ); const createTests = (overwrite: boolean, spaceId: string, user: TestUser) => { - const { normalTypes, hiddenType, allTypes } = createTestCases(overwrite, spaceId); + const { normalTypes, crossNamespace, hiddenType, allTypes } = createTestCases( + overwrite, + spaceId + ); // use singleRequest to reduce execution time and/or test combined cases + const authorizedCommon = [ + createTestDefinitions(normalTypes, false, overwrite, { + spaceId, + user, + singleRequest: true, + }), + createTestDefinitions(hiddenType, true, overwrite, { spaceId, user }), + createTestDefinitions(allTypes, true, overwrite, { + spaceId, + user, + singleRequest: true, + responseBodyOverride: expectForbidden(['hiddentype']), + }), + ].flat(); return { unauthorized: createTestDefinitions(allTypes, true, overwrite, { spaceId, user }), - authorized: [ - createTestDefinitions(normalTypes, false, overwrite, { - spaceId, - user, - singleRequest: true, - }), - createTestDefinitions(hiddenType, true, overwrite, { spaceId, user }), - createTestDefinitions(allTypes, true, overwrite, { + authorizedAtSpace: [ + authorizedCommon, + createTestDefinitions(crossNamespace, true, overwrite, { spaceId, user }), + ].flat(), + authorizedEverywhere: [ + authorizedCommon, + createTestDefinitions(crossNamespace, false, overwrite, { spaceId, user, singleRequest: true, - responseBodyOverride: expectForbidden(['hiddentype']), }), ].flat(), superuser: createTestDefinitions(allTypes, false, overwrite, { @@ -124,10 +141,15 @@ export default function ({ getService }: FtrProviderContext) { const { unauthorized } = createTests(overwrite!, spaceId, user); _addTests(user, unauthorized); }); - [users.dualAll, users.allGlobally, users.allAtSpace].forEach((user) => { - const { authorized } = createTests(overwrite!, spaceId, user); - _addTests(user, authorized); + + const { authorizedAtSpace } = createTests(overwrite!, spaceId, users.allAtSpace); + _addTests(users.allAtSpace, authorizedAtSpace); + + [users.dualAll, users.allGlobally].forEach((user) => { + const { authorizedEverywhere } = createTests(overwrite!, spaceId, user); + _addTests(user, authorizedEverywhere); }); + const { superuser } = createTests(overwrite!, spaceId, users.superuser); _addTests(users.superuser, superuser); } diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_get.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_get.ts index 4a35bdd03e4dd..4878d9d81dbf6 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_get.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_get.ts @@ -28,6 +28,7 @@ const createTestCases = (spaceId: string) => { { ...CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, ...fail404(spaceId !== DEFAULT_SPACE_ID) }, { ...CASES.SINGLE_NAMESPACE_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, { ...CASES.SINGLE_NAMESPACE_SPACE_2, ...fail404(spaceId !== SPACE_2_ID) }, + CASES.MULTI_NAMESPACE_ALL_SPACES, { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, ...fail404(spaceId !== DEFAULT_SPACE_ID && spaceId !== SPACE_1_ID), diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_update.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_update.ts index 1e11d1fc61110..3f4341de6cfc5 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_update.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/bulk_update.ts @@ -28,6 +28,7 @@ const createTestCases = (spaceId: string) => { { ...CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, ...fail404(spaceId !== DEFAULT_SPACE_ID) }, { ...CASES.SINGLE_NAMESPACE_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, { ...CASES.SINGLE_NAMESPACE_SPACE_2, ...fail404(spaceId !== SPACE_2_ID) }, + CASES.MULTI_NAMESPACE_ALL_SPACES, { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, ...fail404(spaceId !== DEFAULT_SPACE_ID && spaceId !== SPACE_1_ID), @@ -44,6 +45,7 @@ const createTestCases = (spaceId: string) => { { ...CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, namespace: DEFAULT_SPACE_ID }, { ...CASES.SINGLE_NAMESPACE_SPACE_1, namespace: SPACE_1_ID }, { ...CASES.SINGLE_NAMESPACE_SPACE_2, namespace: SPACE_1_ID, ...fail404() }, // intentional 404 test case + { ...CASES.MULTI_NAMESPACE_ALL_SPACES, namespace: DEFAULT_SPACE_ID }, // any spaceId will work (not '*') { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, namespace: DEFAULT_SPACE_ID }, // SPACE_1_ID would also work { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, namespace: SPACE_2_ID, ...fail404() }, // intentional 404 test case { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, namespace: SPACE_2_ID }, diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/create.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/create.ts index 7353dafb5e1b5..37328c0ffc342 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/create.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/create.ts @@ -41,6 +41,7 @@ const createTestCases = (overwrite: boolean, spaceId: string) => { ...fail409(!overwrite && spaceId === SPACE_2_ID), expectedNamespaces, }, + { ...CASES.MULTI_NAMESPACE_ALL_SPACES, ...fail409(!overwrite) }, { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, ...fail409(!overwrite || (spaceId !== DEFAULT_SPACE_ID && spaceId !== SPACE_1_ID)), @@ -52,9 +53,10 @@ const createTestCases = (overwrite: boolean, spaceId: string) => { { ...CASES.NEW_MULTI_NAMESPACE_OBJ, expectedNamespaces }, CASES.NEW_NAMESPACE_AGNOSTIC_OBJ, ]; + const crossNamespace = [CASES.NEW_EACH_SPACE_OBJ, CASES.NEW_ALL_SPACES_OBJ]; const hiddenType = [{ ...CASES.HIDDEN, ...fail400() }]; - const allTypes = normalTypes.concat(hiddenType); - return { normalTypes, hiddenType, allTypes }; + const allTypes = normalTypes.concat(crossNamespace, hiddenType); + return { normalTypes, crossNamespace, hiddenType, allTypes }; }; export default function ({ getService }: FtrProviderContext) { @@ -64,11 +66,20 @@ export default function ({ getService }: FtrProviderContext) { const { addTests, createTestDefinitions } = createTestSuiteFactory(es, esArchiver, supertest); const createTests = (overwrite: boolean, spaceId: string, user: TestUser) => { - const { normalTypes, hiddenType, allTypes } = createTestCases(overwrite, spaceId); + const { normalTypes, crossNamespace, hiddenType, allTypes } = createTestCases( + overwrite, + spaceId + ); return { unauthorized: createTestDefinitions(allTypes, true, overwrite, { spaceId, user }), - authorized: [ + authorizedAtSpace: [ + createTestDefinitions(normalTypes, false, overwrite, { spaceId, user }), + createTestDefinitions(crossNamespace, true, overwrite, { spaceId, user }), + createTestDefinitions(hiddenType, true, overwrite, { spaceId, user }), + ].flat(), + authorizedEverywhere: [ createTestDefinitions(normalTypes, false, overwrite, { spaceId, user }), + createTestDefinitions(crossNamespace, false, overwrite, { spaceId, user }), createTestDefinitions(hiddenType, true, overwrite, { spaceId, user }), ].flat(), superuser: createTestDefinitions(allTypes, false, overwrite, { spaceId, user }), @@ -94,10 +105,15 @@ export default function ({ getService }: FtrProviderContext) { const { unauthorized } = createTests(overwrite!, spaceId, user); _addTests(user, unauthorized); }); - [users.dualAll, users.allGlobally, users.allAtSpace].forEach((user) => { - const { authorized } = createTests(overwrite!, spaceId, user); - _addTests(user, authorized); + + const { authorizedAtSpace } = createTests(overwrite!, spaceId, users.allAtSpace); + _addTests(users.allAtSpace, authorizedAtSpace); + + [users.dualAll, users.allGlobally].forEach((user) => { + const { authorizedEverywhere } = createTests(overwrite!, spaceId, user); + _addTests(user, authorizedEverywhere); }); + const { superuser } = createTests(overwrite!, spaceId, users.superuser); _addTests(users.superuser, superuser); } diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/delete.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/delete.ts index 62b229f831562..436f09e8d2ee0 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/delete.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/delete.ts @@ -19,7 +19,7 @@ const { SPACE_1: { spaceId: SPACE_1_ID }, SPACE_2: { spaceId: SPACE_2_ID }, } = SPACES; -const { fail404 } = testCaseFailures; +const { fail400, fail404 } = testCaseFailures; const createTestCases = (spaceId: string) => { // for each permitted (non-403) outcome, if failure !== undefined then we expect @@ -28,8 +28,18 @@ const createTestCases = (spaceId: string) => { { ...CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, ...fail404(spaceId !== DEFAULT_SPACE_ID) }, { ...CASES.SINGLE_NAMESPACE_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, { ...CASES.SINGLE_NAMESPACE_SPACE_2, ...fail404(spaceId !== SPACE_2_ID) }, + { ...CASES.MULTI_NAMESPACE_ALL_SPACES, ...fail400() }, + // try to delete this object again, this time using the `force` option + { ...CASES.MULTI_NAMESPACE_ALL_SPACES, force: true }, { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, + ...fail400(spaceId === DEFAULT_SPACE_ID || spaceId === SPACE_1_ID), + ...fail404(spaceId !== DEFAULT_SPACE_ID && spaceId !== SPACE_1_ID), + }, + // try to delete this object again, this time using the `force` option + { + ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, + force: true, ...fail404(spaceId !== DEFAULT_SPACE_ID && spaceId !== SPACE_1_ID), }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/get.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/get.ts index dabe174af4d4b..b554eb55b0adb 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/get.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/get.ts @@ -28,6 +28,7 @@ const createTestCases = (spaceId: string) => { { ...CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, ...fail404(spaceId !== DEFAULT_SPACE_ID) }, { ...CASES.SINGLE_NAMESPACE_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, { ...CASES.SINGLE_NAMESPACE_SPACE_2, ...fail404(spaceId !== SPACE_2_ID) }, + CASES.MULTI_NAMESPACE_ALL_SPACES, { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, ...fail404(spaceId !== DEFAULT_SPACE_ID && spaceId !== SPACE_1_ID), diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/import.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/import.ts index 0b531a3dccc1a..a319a73c6a98a 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/import.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/import.ts @@ -58,6 +58,7 @@ const createTestCases = (overwrite: boolean, spaceId: string) => { const group2 = [ // when overwrite=true, all of the objects in this group are created successfully, so we can check the created object attributes CASES.NEW_MULTI_NAMESPACE_OBJ, + { ...CASES.MULTI_NAMESPACE_ALL_SPACES, ...fail409(!overwrite) }, { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, ...fail409(!overwrite && (spaceId === DEFAULT_SPACE_ID || spaceId === SPACE_1_ID)), diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/resolve_import_errors.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/resolve_import_errors.ts index 792fe63e5932d..b0f4f13f268c9 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/resolve_import_errors.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/resolve_import_errors.ts @@ -55,6 +55,7 @@ const createTestCases = (overwrite: boolean, spaceId: string) => { const group1NonImportable = [{ ...CASES.HIDDEN, ...fail400() }]; const group1All = [...group1Importable, ...group1NonImportable]; const group2 = [ + { ...CASES.MULTI_NAMESPACE_ALL_SPACES, ...fail409(!overwrite) }, { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, ...fail409(!overwrite && (spaceId === DEFAULT_SPACE_ID || spaceId === SPACE_1_ID)), diff --git a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/update.ts b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/update.ts index fec6f2b7de715..a976ce08adb1f 100644 --- a/x-pack/test/saved_object_api_integration/security_and_spaces/apis/update.ts +++ b/x-pack/test/saved_object_api_integration/security_and_spaces/apis/update.ts @@ -28,6 +28,7 @@ const createTestCases = (spaceId: string) => { { ...CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, ...fail404(spaceId !== DEFAULT_SPACE_ID) }, { ...CASES.SINGLE_NAMESPACE_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, { ...CASES.SINGLE_NAMESPACE_SPACE_2, ...fail404(spaceId !== SPACE_2_ID) }, + CASES.MULTI_NAMESPACE_ALL_SPACES, { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, ...fail404(spaceId !== DEFAULT_SPACE_ID && spaceId !== SPACE_1_ID), diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/bulk_create.ts b/x-pack/test/saved_object_api_integration/security_only/apis/bulk_create.ts index cc2c5e2e7fc00..dc340f2c2aa7c 100644 --- a/x-pack/test/saved_object_api_integration/security_only/apis/bulk_create.ts +++ b/x-pack/test/saved_object_api_integration/security_only/apis/bulk_create.ts @@ -28,6 +28,7 @@ const createTestCases = (overwrite: boolean) => { { ...CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, ...fail409(!overwrite) }, { ...CASES.SINGLE_NAMESPACE_SPACE_1, expectedNamespaces }, { ...CASES.SINGLE_NAMESPACE_SPACE_2, expectedNamespaces }, + { ...CASES.MULTI_NAMESPACE_ALL_SPACES, ...fail409(!overwrite) }, { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, ...fail409(!overwrite) }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail409(), ...unresolvableConflict() }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail409(), ...unresolvableConflict() }, @@ -35,6 +36,8 @@ const createTestCases = (overwrite: boolean) => { { ...CASES.NEW_SINGLE_NAMESPACE_OBJ, expectedNamespaces }, { ...CASES.NEW_MULTI_NAMESPACE_OBJ, expectedNamespaces }, CASES.NEW_NAMESPACE_AGNOSTIC_OBJ, + CASES.NEW_EACH_SPACE_OBJ, + CASES.NEW_ALL_SPACES_OBJ, ]; const hiddenType = [{ ...CASES.HIDDEN, ...fail400() }]; const allTypes = normalTypes.concat(hiddenType); diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/bulk_get.ts b/x-pack/test/saved_object_api_integration/security_only/apis/bulk_get.ts index d305e08da1b32..96eddf1f8bd3c 100644 --- a/x-pack/test/saved_object_api_integration/security_only/apis/bulk_get.ts +++ b/x-pack/test/saved_object_api_integration/security_only/apis/bulk_get.ts @@ -22,6 +22,7 @@ const createTestCases = () => { CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, { ...CASES.SINGLE_NAMESPACE_SPACE_1, ...fail404() }, { ...CASES.SINGLE_NAMESPACE_SPACE_2, ...fail404() }, + CASES.MULTI_NAMESPACE_ALL_SPACES, CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail404() }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail404() }, diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/bulk_update.ts b/x-pack/test/saved_object_api_integration/security_only/apis/bulk_update.ts index 39ceb5a70d1b2..2a19c56f80ce6 100644 --- a/x-pack/test/saved_object_api_integration/security_only/apis/bulk_update.ts +++ b/x-pack/test/saved_object_api_integration/security_only/apis/bulk_update.ts @@ -28,6 +28,7 @@ const createTestCases = () => { CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, { ...CASES.SINGLE_NAMESPACE_SPACE_1, ...fail404() }, { ...CASES.SINGLE_NAMESPACE_SPACE_2, ...fail404() }, + CASES.MULTI_NAMESPACE_ALL_SPACES, CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail404() }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail404() }, @@ -42,6 +43,7 @@ const createTestCases = () => { { ...CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, namespace: DEFAULT_SPACE_ID }, { ...CASES.SINGLE_NAMESPACE_SPACE_1, namespace: SPACE_1_ID }, { ...CASES.SINGLE_NAMESPACE_SPACE_2, namespace: SPACE_1_ID, ...fail404() }, // intentional 404 test case + { ...CASES.MULTI_NAMESPACE_ALL_SPACES, namespace: DEFAULT_SPACE_ID }, // any spaceId will work (not '*') { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, namespace: DEFAULT_SPACE_ID }, // SPACE_1_ID would also work { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, namespace: SPACE_2_ID, ...fail404() }, // intentional 404 test case { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, namespace: SPACE_2_ID }, diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/create.ts b/x-pack/test/saved_object_api_integration/security_only/apis/create.ts index b7c6ecef979bd..f469ffc97fbe0 100644 --- a/x-pack/test/saved_object_api_integration/security_only/apis/create.ts +++ b/x-pack/test/saved_object_api_integration/security_only/apis/create.ts @@ -27,6 +27,7 @@ const createTestCases = (overwrite: boolean) => { { ...CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, ...fail409(!overwrite) }, { ...CASES.SINGLE_NAMESPACE_SPACE_1, expectedNamespaces }, { ...CASES.SINGLE_NAMESPACE_SPACE_2, expectedNamespaces }, + { ...CASES.MULTI_NAMESPACE_ALL_SPACES, ...fail409(!overwrite) }, { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, ...fail409(!overwrite) }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail409() }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail409() }, @@ -34,6 +35,8 @@ const createTestCases = (overwrite: boolean) => { { ...CASES.NEW_SINGLE_NAMESPACE_OBJ, expectedNamespaces }, { ...CASES.NEW_MULTI_NAMESPACE_OBJ, expectedNamespaces }, CASES.NEW_NAMESPACE_AGNOSTIC_OBJ, + CASES.NEW_EACH_SPACE_OBJ, + CASES.NEW_ALL_SPACES_OBJ, ]; const hiddenType = [{ ...CASES.HIDDEN, ...fail400() }]; const allTypes = normalTypes.concat(hiddenType); diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/delete.ts b/x-pack/test/saved_object_api_integration/security_only/apis/delete.ts index 4f379d5d1cbb9..4caf112a59b27 100644 --- a/x-pack/test/saved_object_api_integration/security_only/apis/delete.ts +++ b/x-pack/test/saved_object_api_integration/security_only/apis/delete.ts @@ -13,7 +13,7 @@ import { DeleteTestDefinition, } from '../../common/suites/delete'; -const { fail404 } = testCaseFailures; +const { fail400, fail404 } = testCaseFailures; const createTestCases = () => { // for each permitted (non-403) outcome, if failure !== undefined then we expect @@ -22,7 +22,12 @@ const createTestCases = () => { CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, { ...CASES.SINGLE_NAMESPACE_SPACE_1, ...fail404() }, { ...CASES.SINGLE_NAMESPACE_SPACE_2, ...fail404() }, - CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, + { ...CASES.MULTI_NAMESPACE_ALL_SPACES, ...fail400() }, + // try to delete this object again, this time using the `force` option + { ...CASES.MULTI_NAMESPACE_ALL_SPACES, force: true }, + { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, ...fail400() }, + // try to delete this object again, this time using the `force` option + { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, force: true }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail404() }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail404() }, CASES.NAMESPACE_AGNOSTIC, diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/get.ts b/x-pack/test/saved_object_api_integration/security_only/apis/get.ts index 0f105b939960f..5eed2839b6172 100644 --- a/x-pack/test/saved_object_api_integration/security_only/apis/get.ts +++ b/x-pack/test/saved_object_api_integration/security_only/apis/get.ts @@ -22,6 +22,7 @@ const createTestCases = () => { CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, { ...CASES.SINGLE_NAMESPACE_SPACE_1, ...fail404() }, { ...CASES.SINGLE_NAMESPACE_SPACE_2, ...fail404() }, + CASES.MULTI_NAMESPACE_ALL_SPACES, CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail404() }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail404() }, diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/import.ts b/x-pack/test/saved_object_api_integration/security_only/apis/import.ts index 34be3b7408432..df763dba6d18a 100644 --- a/x-pack/test/saved_object_api_integration/security_only/apis/import.ts +++ b/x-pack/test/saved_object_api_integration/security_only/apis/import.ts @@ -49,6 +49,7 @@ const createTestCases = (overwrite: boolean) => { const group2 = [ // when overwrite=true, all of the objects in this group are created successfully, so we can check the created object attributes CASES.NEW_MULTI_NAMESPACE_OBJ, + { ...CASES.MULTI_NAMESPACE_ALL_SPACES, ...fail409(!overwrite) }, { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, ...fail409(!overwrite) }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...destinationId() }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...destinationId() }, diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/resolve_import_errors.ts b/x-pack/test/saved_object_api_integration/security_only/apis/resolve_import_errors.ts index 91134dd14bd8a..22734e95da0b5 100644 --- a/x-pack/test/saved_object_api_integration/security_only/apis/resolve_import_errors.ts +++ b/x-pack/test/saved_object_api_integration/security_only/apis/resolve_import_errors.ts @@ -43,6 +43,7 @@ const createTestCases = (overwrite: boolean) => { const group1NonImportable = [{ ...CASES.HIDDEN, ...fail400() }]; const group1All = [...group1Importable, ...group1NonImportable]; const group2 = [ + { ...CASES.MULTI_NAMESPACE_ALL_SPACES, ...fail409(!overwrite) }, { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, ...fail409(!overwrite) }, { ...CASES.CONFLICT_1A_OBJ, ...newCopy() }, // "ambiguous source" conflict which results in a new destination ID and empty origin ID { ...CASES.CONFLICT_1B_OBJ, ...newCopy() }, // "ambiguous source" conflict which results in a new destination ID and empty origin ID diff --git a/x-pack/test/saved_object_api_integration/security_only/apis/update.ts b/x-pack/test/saved_object_api_integration/security_only/apis/update.ts index c1fd350101fd4..d6e22abf4af24 100644 --- a/x-pack/test/saved_object_api_integration/security_only/apis/update.ts +++ b/x-pack/test/saved_object_api_integration/security_only/apis/update.ts @@ -22,6 +22,7 @@ const createTestCases = () => { CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, { ...CASES.SINGLE_NAMESPACE_SPACE_1, ...fail404() }, { ...CASES.SINGLE_NAMESPACE_SPACE_2, ...fail404() }, + CASES.MULTI_NAMESPACE_ALL_SPACES, CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail404() }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, ...fail404() }, diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_create.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_create.ts index ef47b09eddbc8..e0ba683953066 100644 --- a/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_create.ts +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_create.ts @@ -4,7 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -import expect from '@kbn/expect'; import { SPACES } from '../../common/lib/spaces'; import { testCaseFailures, getTestScenarios } from '../../common/lib/saved_object_test_utils'; import { FtrProviderContext } from '../../common/ftr_provider_context'; @@ -39,6 +38,7 @@ const createTestCases = (overwrite: boolean, spaceId: string) => { ...fail409(!overwrite && spaceId === SPACE_2_ID), expectedNamespaces, }, + { ...CASES.MULTI_NAMESPACE_ALL_SPACES, ...fail409(!overwrite) }, { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, ...fail409(!overwrite || (spaceId !== DEFAULT_SPACE_ID && spaceId !== SPACE_1_ID)), @@ -59,6 +59,8 @@ const createTestCases = (overwrite: boolean, spaceId: string) => { { ...CASES.NEW_SINGLE_NAMESPACE_OBJ, expectedNamespaces }, { ...CASES.NEW_MULTI_NAMESPACE_OBJ, expectedNamespaces }, CASES.NEW_NAMESPACE_AGNOSTIC_OBJ, + CASES.NEW_EACH_SPACE_OBJ, + CASES.NEW_ALL_SPACES_OBJ, ]; }; @@ -73,21 +75,7 @@ export default function ({ getService }: FtrProviderContext) { return createTestDefinitions(testCases, false, overwrite, { spaceId, singleRequest: true, - }).concat( - ['namespace', 'namespaces'].map((key) => ({ - title: `(bad request) when ${key} is specified on the saved object`, - request: [{ type: 'isolatedtype', id: 'some-id', [key]: 'any-value' }] as any, - responseStatusCode: 400, - responseBody: async (response: Record) => { - expect(response.body).to.eql({ - statusCode: 400, - error: 'Bad Request', - message: `[request body.0.${key}]: definition for this key is missing`, - }); - }, - overwrite, - })) - ); + }); }; describe('_bulk_create', () => { diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_get.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_get.ts index 37bb2ec920c1e..7eef9a95f5238 100644 --- a/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_get.ts +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_get.ts @@ -22,6 +22,7 @@ const createTestCases = (spaceId: string) => [ { ...CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, ...fail404(spaceId !== DEFAULT_SPACE_ID) }, { ...CASES.SINGLE_NAMESPACE_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, { ...CASES.SINGLE_NAMESPACE_SPACE_2, ...fail404(spaceId !== SPACE_2_ID) }, + CASES.MULTI_NAMESPACE_ALL_SPACES, { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, ...fail404(spaceId !== DEFAULT_SPACE_ID && spaceId !== SPACE_1_ID), diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_update.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_update.ts index b51ec303fadf3..e789377b93fe1 100644 --- a/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_update.ts +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/bulk_update.ts @@ -23,6 +23,7 @@ const createTestCases = (spaceId: string) => { { ...CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, ...fail404(spaceId !== DEFAULT_SPACE_ID) }, { ...CASES.SINGLE_NAMESPACE_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, { ...CASES.SINGLE_NAMESPACE_SPACE_2, ...fail404(spaceId !== SPACE_2_ID) }, + CASES.MULTI_NAMESPACE_ALL_SPACES, { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, ...fail404(spaceId !== DEFAULT_SPACE_ID && spaceId !== SPACE_1_ID), @@ -39,6 +40,7 @@ const createTestCases = (spaceId: string) => { { ...CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, namespace: DEFAULT_SPACE_ID }, { ...CASES.SINGLE_NAMESPACE_SPACE_1, namespace: SPACE_1_ID }, { ...CASES.SINGLE_NAMESPACE_SPACE_2, namespace: SPACE_1_ID, ...fail404() }, // intentional 404 test case + { ...CASES.MULTI_NAMESPACE_ALL_SPACES, namespace: spaceId }, // any spaceId will work (not '*') { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, namespace: DEFAULT_SPACE_ID }, // SPACE_1_ID would also work { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, namespace: SPACE_2_ID, ...fail404() }, // intentional 404 test case { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_2, namespace: SPACE_2_ID }, diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/create.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/create.ts index 10e57b4db82dc..c5013a0f0ddd3 100644 --- a/x-pack/test/saved_object_api_integration/spaces_only/apis/create.ts +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/create.ts @@ -36,6 +36,7 @@ const createTestCases = (overwrite: boolean, spaceId: string) => { ...fail409(!overwrite && spaceId === SPACE_2_ID), expectedNamespaces, }, + { ...CASES.MULTI_NAMESPACE_ALL_SPACES, ...fail409(!overwrite) }, { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, ...fail409(!overwrite || (spaceId !== DEFAULT_SPACE_ID && spaceId !== SPACE_1_ID)), @@ -47,6 +48,8 @@ const createTestCases = (overwrite: boolean, spaceId: string) => { { ...CASES.NEW_SINGLE_NAMESPACE_OBJ, expectedNamespaces }, { ...CASES.NEW_MULTI_NAMESPACE_OBJ, expectedNamespaces }, CASES.NEW_NAMESPACE_AGNOSTIC_OBJ, + CASES.NEW_EACH_SPACE_OBJ, + CASES.NEW_ALL_SPACES_OBJ, ]; }; diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/delete.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/delete.ts index 82045a9e288ce..66309a4be4460 100644 --- a/x-pack/test/saved_object_api_integration/spaces_only/apis/delete.ts +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/delete.ts @@ -14,7 +14,7 @@ const { SPACE_1: { spaceId: SPACE_1_ID }, SPACE_2: { spaceId: SPACE_2_ID }, } = SPACES; -const { fail404 } = testCaseFailures; +const { fail400, fail404 } = testCaseFailures; const createTestCases = (spaceId: string) => [ // for each outcome, if failure !== undefined then we expect to receive @@ -22,8 +22,18 @@ const createTestCases = (spaceId: string) => [ { ...CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, ...fail404(spaceId !== DEFAULT_SPACE_ID) }, { ...CASES.SINGLE_NAMESPACE_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, { ...CASES.SINGLE_NAMESPACE_SPACE_2, ...fail404(spaceId !== SPACE_2_ID) }, + { ...CASES.MULTI_NAMESPACE_ALL_SPACES, ...fail400() }, + // try to delete this object again, this time using the `force` option + { ...CASES.MULTI_NAMESPACE_ALL_SPACES, force: true }, { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, + ...fail400(spaceId === DEFAULT_SPACE_ID || spaceId === SPACE_1_ID), + ...fail404(spaceId !== DEFAULT_SPACE_ID && spaceId !== SPACE_1_ID), + }, + // try to delete this object again, this time using the `force` option + { + ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, + force: true, ...fail404(spaceId !== DEFAULT_SPACE_ID && spaceId !== SPACE_1_ID), }, { ...CASES.MULTI_NAMESPACE_ONLY_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/get.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/get.ts index b0fed3e13b9af..a56216537a365 100644 --- a/x-pack/test/saved_object_api_integration/spaces_only/apis/get.ts +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/get.ts @@ -22,6 +22,7 @@ const createTestCases = (spaceId: string) => [ { ...CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, ...fail404(spaceId !== DEFAULT_SPACE_ID) }, { ...CASES.SINGLE_NAMESPACE_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, { ...CASES.SINGLE_NAMESPACE_SPACE_2, ...fail404(spaceId !== SPACE_2_ID) }, + CASES.MULTI_NAMESPACE_ALL_SPACES, { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, ...fail404(spaceId !== DEFAULT_SPACE_ID && spaceId !== SPACE_1_ID), diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/import.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/import.ts index a36249528540b..3009fa0bd75a4 100644 --- a/x-pack/test/saved_object_api_integration/spaces_only/apis/import.ts +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/import.ts @@ -44,6 +44,7 @@ const createTestCases = (overwrite: boolean, spaceId: string) => { }, { ...CASES.SINGLE_NAMESPACE_SPACE_1, ...fail409(!overwrite && spaceId === SPACE_1_ID) }, { ...CASES.SINGLE_NAMESPACE_SPACE_2, ...fail409(!overwrite && spaceId === SPACE_2_ID) }, + { ...CASES.MULTI_NAMESPACE_ALL_SPACES, ...fail409(!overwrite) }, { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, ...fail409(!overwrite && (spaceId === DEFAULT_SPACE_ID || spaceId === SPACE_1_ID)), diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/resolve_import_errors.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/resolve_import_errors.ts index 1431a61b1cbe0..721a6b2bf7108 100644 --- a/x-pack/test/saved_object_api_integration/spaces_only/apis/resolve_import_errors.ts +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/resolve_import_errors.ts @@ -48,6 +48,7 @@ const createTestCases = (overwrite: boolean, spaceId: string) => { : CASES.SINGLE_NAMESPACE_SPACE_2; return [ { ...singleNamespaceObject, ...fail409(!overwrite) }, + { ...CASES.MULTI_NAMESPACE_ALL_SPACES, ...fail409(!overwrite) }, { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, ...fail409(!overwrite && (spaceId === DEFAULT_SPACE_ID || spaceId === SPACE_1_ID)), diff --git a/x-pack/test/saved_object_api_integration/spaces_only/apis/update.ts b/x-pack/test/saved_object_api_integration/spaces_only/apis/update.ts index 31ef6fb25b2f2..7a004290249ca 100644 --- a/x-pack/test/saved_object_api_integration/spaces_only/apis/update.ts +++ b/x-pack/test/saved_object_api_integration/spaces_only/apis/update.ts @@ -22,6 +22,7 @@ const createTestCases = (spaceId: string) => [ { ...CASES.SINGLE_NAMESPACE_DEFAULT_SPACE, ...fail404(spaceId !== DEFAULT_SPACE_ID) }, { ...CASES.SINGLE_NAMESPACE_SPACE_1, ...fail404(spaceId !== SPACE_1_ID) }, { ...CASES.SINGLE_NAMESPACE_SPACE_2, ...fail404(spaceId !== SPACE_2_ID) }, + CASES.MULTI_NAMESPACE_ALL_SPACES, { ...CASES.MULTI_NAMESPACE_DEFAULT_AND_SPACE_1, ...fail404(spaceId !== DEFAULT_SPACE_ID && spaceId !== SPACE_1_ID), diff --git a/x-pack/test/spaces_api_integration/common/fixtures/es_archiver/saved_objects/spaces/data.json b/x-pack/test/spaces_api_integration/common/fixtures/es_archiver/saved_objects/spaces/data.json index 7e528c23c20a0..5ce6c0ce6b7c5 100644 --- a/x-pack/test/spaces_api_integration/common/fixtures/es_archiver/saved_objects/spaces/data.json +++ b/x-pack/test/spaces_api_integration/common/fixtures/es_archiver/saved_objects/spaces/data.json @@ -482,7 +482,7 @@ { "type": "doc", "value": { - "id": "sharedtype:all_spaces", + "id": "sharedtype:each_space", "index": ".kibana", "source": { "sharedtype": { @@ -496,6 +496,23 @@ } } +{ + "type": "doc", + "value": { + "id": "sharedtype:all_spaces", + "index": ".kibana", + "source": { + "sharedtype": { + "title": "A shared saved-object in all spaces" + }, + "type": "sharedtype", + "namespaces": ["*"], + "updated_at": "2017-09-21T18:59:16.270Z" + }, + "type": "doc" + } +} + { "type": "doc", "value": { diff --git a/x-pack/test/spaces_api_integration/common/lib/saved_object_test_cases.ts b/x-pack/test/spaces_api_integration/common/lib/saved_object_test_cases.ts index 3b0f5f8570aa3..9b8baa7f22a2b 100644 --- a/x-pack/test/spaces_api_integration/common/lib/saved_object_test_cases.ts +++ b/x-pack/test/spaces_api_integration/common/lib/saved_object_test_cases.ts @@ -29,9 +29,13 @@ export const MULTI_NAMESPACE_SAVED_OBJECT_TEST_CASES = Object.freeze({ id: 'space_1_and_space_2', existingNamespaces: ['space_1', 'space_2'], }), + EACH_SPACE: Object.freeze({ + id: 'each_space', + existingNamespaces: ['default', 'space_1', 'space_2'], // each individual space + }), ALL_SPACES: Object.freeze({ id: 'all_spaces', - existingNamespaces: ['default', 'space_1', 'space_2'], + existingNamespaces: ['*'], // all current and future spaces }), DOES_NOT_EXIST: Object.freeze({ id: 'does_not_exist', diff --git a/x-pack/test/spaces_api_integration/common/suites/copy_to_space.ts b/x-pack/test/spaces_api_integration/common/suites/copy_to_space.ts index 26c736034501f..ee7a2fb731657 100644 --- a/x-pack/test/spaces_api_integration/common/suites/copy_to_space.ts +++ b/x-pack/test/spaces_api_integration/common/suites/copy_to_space.ts @@ -423,7 +423,7 @@ export function copyToSpaceTestSuiteFactory( const type = 'sharedtype'; const v4 = new RegExp(/^[0-9A-F]{8}-[0-9A-F]{4}-4[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$/i); const noConflictId = `${spaceId}_only`; - const exactMatchId = 'all_spaces'; + const exactMatchId = 'each_space'; const inexactMatchId = `conflict_1_${spaceId}`; const ambiguousConflictId = `conflict_2_${spaceId}`; diff --git a/x-pack/test/spaces_api_integration/common/suites/resolve_copy_to_space_conflicts.ts b/x-pack/test/spaces_api_integration/common/suites/resolve_copy_to_space_conflicts.ts index cb9219b1ba2ed..eba7e2033eadf 100644 --- a/x-pack/test/spaces_api_integration/common/suites/resolve_copy_to_space_conflicts.ts +++ b/x-pack/test/spaces_api_integration/common/suites/resolve_copy_to_space_conflicts.ts @@ -310,7 +310,7 @@ export function resolveCopyToSpaceConflictsSuite( // a 403 error actually comes back as an HTTP 200 response const statusCode = outcome === 'noAccess' ? 404 : 200; const type = 'sharedtype'; - const exactMatchId = 'all_spaces'; + const exactMatchId = 'each_space'; const inexactMatchId = `conflict_1_${spaceId}`; const ambiguousConflictId = `conflict_2_${spaceId}`; diff --git a/x-pack/test/spaces_api_integration/common/suites/share_add.ts b/x-pack/test/spaces_api_integration/common/suites/share_add.ts index 219190cb28002..f2a3c69a91196 100644 --- a/x-pack/test/spaces_api_integration/common/suites/share_add.ts +++ b/x-pack/test/spaces_api_integration/common/suites/share_add.ts @@ -26,8 +26,6 @@ export interface ShareAddTestCase { id: string; namespaces: string[]; failure?: 400 | 403 | 404; - fail400Param?: string; - fail403Param?: string; } const TYPE = 'sharedtype'; @@ -39,22 +37,16 @@ const getTestTitle = ({ id, namespaces }: ShareAddTestCase) => `{id: ${id}, namespaces: [${namespaces.join(',')}]}`; export function shareAddTestSuiteFactory(esArchiver: any, supertest: SuperTest) { + const expectForbidden = expectResponses.forbiddenTypes('share_to_space'); const expectResponseBody = (testCase: ShareAddTestCase): ExpectResponseBody => async ( response: Record ) => { - const { id, failure, fail400Param, fail403Param } = testCase; + const { id, failure } = testCase; const object = response.body; if (failure === 403) { - await expectResponses.forbiddenTypes(fail403Param!)(TYPE)(response); - } else if (failure) { - let error: any; - if (failure === 400) { - error = SavedObjectsErrorHelpers.createBadRequestError( - `${id} already exists in the following namespace(s): ${fail400Param}` - ); - } else if (failure === 404) { - error = SavedObjectsErrorHelpers.createGenericNotFoundError(TYPE, id); - } + await expectForbidden(TYPE)(response); + } else if (failure === 404) { + const error = SavedObjectsErrorHelpers.createGenericNotFoundError(TYPE, id); expect(object.error).to.eql(error.output.payload.error); expect(object.statusCode).to.eql(error.output.payload.statusCode); } else { @@ -67,13 +59,12 @@ export function shareAddTestSuiteFactory(esArchiver: any, supertest: SuperTest { let cases = Array.isArray(testCases) ? testCases : [testCases]; if (forbidden) { // override the expected result in each test case - cases = cases.map((x) => ({ ...x, failure: 403, fail403Param: options?.fail403Param })); + cases = cases.map((x) => ({ ...x, failure: 403 })); } return cases.map((x) => ({ title: getTestTitle(x), diff --git a/x-pack/test/spaces_api_integration/common/suites/share_remove.ts b/x-pack/test/spaces_api_integration/common/suites/share_remove.ts index 0748aa797264c..d318609e11570 100644 --- a/x-pack/test/spaces_api_integration/common/suites/share_remove.ts +++ b/x-pack/test/spaces_api_integration/common/suites/share_remove.ts @@ -27,7 +27,6 @@ export interface ShareRemoveTestCase { id: string; namespaces: string[]; failure?: 400 | 403 | 404; - fail400Param?: string; } const TYPE = 'sharedtype'; @@ -37,23 +36,16 @@ const createRequest = ({ id, namespaces }: ShareRemoveTestCase) => ({ }); export function shareRemoveTestSuiteFactory(esArchiver: any, supertest: SuperTest) { - const expectForbidden = expectResponses.forbiddenTypes('delete'); + const expectForbidden = expectResponses.forbiddenTypes('share_to_space'); const expectResponseBody = (testCase: ShareRemoveTestCase): ExpectResponseBody => async ( response: Record ) => { - const { id, failure, fail400Param } = testCase; + const { id, failure } = testCase; const object = response.body; if (failure === 403) { await expectForbidden(TYPE)(response); - } else if (failure) { - let error: any; - if (failure === 400) { - error = SavedObjectsErrorHelpers.createBadRequestError( - `${id} doesn't exist in the following namespace(s): ${fail400Param}` - ); - } else if (failure === 404) { - error = SavedObjectsErrorHelpers.createGenericNotFoundError(TYPE, id); - } + } else if (failure === 404) { + const error = SavedObjectsErrorHelpers.createGenericNotFoundError(TYPE, id); expect(object.error).to.eql(error.output.payload.error); expect(object.statusCode).to.eql(error.output.payload.statusCode); } else { diff --git a/x-pack/test/spaces_api_integration/security_and_spaces/apis/share_add.ts b/x-pack/test/spaces_api_integration/security_and_spaces/apis/share_add.ts index ddd029c8d7d68..40f87cb90933f 100644 --- a/x-pack/test/spaces_api_integration/security_and_spaces/apis/share_add.ts +++ b/x-pack/test/spaces_api_integration/security_and_spaces/apis/share_add.ts @@ -12,7 +12,11 @@ import { import { TestUser } from '../../../saved_object_api_integration/common/lib/types'; import { MULTI_NAMESPACE_SAVED_OBJECT_TEST_CASES as CASES } from '../../common/lib/saved_object_test_cases'; import { TestInvoker } from '../../common/lib/types'; -import { shareAddTestSuiteFactory, ShareAddTestDefinition } from '../../common/suites/share_add'; +import { + shareAddTestSuiteFactory, + ShareAddTestDefinition, + ShareAddTestCase, +} from '../../common/suites/share_add'; const { DEFAULT: { spaceId: DEFAULT_SPACE_ID }, @@ -33,6 +37,8 @@ const createTestCases = (spaceId: string) => { { ...CASES.SPACE_1_AND_SPACE_2, namespaces, ...fail404(spaceId === DEFAULT_SPACE_ID) }, { ...CASES.ALL_SPACES, namespaces }, { ...CASES.DOES_NOT_EXIST, namespaces, ...fail404() }, + // Test case to check adding all spaces ("*") to a saved object + { ...CASES.EACH_SPACE, namespaces: ['*'] }, // Test cases to check adding multiple namespaces to different saved objects that exist in one space // These are non-exhaustive, they only check cases for adding two additional namespaces to a saved object // More permutations are covered in the corresponding spaces_only test suite @@ -57,13 +63,24 @@ const calculateSingleSpaceAuthZ = ( testCases: ReturnType, spaceId: string ) => { - const targetsOtherSpace = testCases.filter( - (x) => !x.namespaces.includes(spaceId) || x.namespaces.length > 1 - ); - const tmp = testCases.filter((x) => !targetsOtherSpace.includes(x)); // doesn't target other space - const doesntExistInThisSpace = tmp.filter((x) => !x.existingNamespaces.includes(spaceId)); - const existsInThisSpace = tmp.filter((x) => x.existingNamespaces.includes(spaceId)); - return { targetsOtherSpace, doesntExistInThisSpace, existsInThisSpace }; + const targetsAllSpaces: ShareAddTestCase[] = []; + const targetsOtherSpace: ShareAddTestCase[] = []; + const doesntExistInThisSpace: ShareAddTestCase[] = []; + const existsInThisSpace: ShareAddTestCase[] = []; + + for (const testCase of testCases) { + const { namespaces, existingNamespaces } = testCase; + if (namespaces.includes('*')) { + targetsAllSpaces.push(testCase); + } else if (!namespaces.includes(spaceId) || namespaces.length > 1) { + targetsOtherSpace.push(testCase); + } else if (!existingNamespaces.includes(spaceId)) { + doesntExistInThisSpace.push(testCase); + } else { + existsInThisSpace.push(testCase); + } + } + return { targetsAllSpaces, targetsOtherSpace, doesntExistInThisSpace, existsInThisSpace }; }; // eslint-disable-next-line import/no-default-export export default function ({ getService }: TestInvoker) { @@ -77,18 +94,20 @@ export default function ({ getService }: TestInvoker) { const otherSpaceId = spaceId === DEFAULT_SPACE_ID ? SPACE_1_ID : DEFAULT_SPACE_ID; const otherSpace = calculateSingleSpaceAuthZ(testCases, otherSpaceId); return { - unauthorized: createTestDefinitions(testCases, true, { fail403Param: 'create' }), + unauthorized: createTestDefinitions(testCases, true), authorizedInSpace: [ - createTestDefinitions(thisSpace.targetsOtherSpace, true, { fail403Param: 'create' }), + createTestDefinitions(thisSpace.targetsAllSpaces, true), + createTestDefinitions(thisSpace.targetsOtherSpace, true), createTestDefinitions(thisSpace.doesntExistInThisSpace, false), createTestDefinitions(thisSpace.existsInThisSpace, false), ].flat(), authorizedInOtherSpace: [ - createTestDefinitions(otherSpace.targetsOtherSpace, true, { fail403Param: 'create' }), - // If the preflight GET request fails, it will return a 404 error; users who are authorized to create saved objects in the target - // space(s) but are not authorized to update saved objects in this space will see a 403 error instead of 404. This is a safeguard to + createTestDefinitions(thisSpace.targetsAllSpaces, true), + createTestDefinitions(otherSpace.targetsOtherSpace, true), + // If the preflight GET request fails, it will return a 404 error; users who are authorized to share saved objects in the target + // space(s) but are not authorized to share saved objects in this space will see a 403 error instead of 404. This is a safeguard to // prevent potential information disclosure of the spaces that a given saved object may exist in. - createTestDefinitions(otherSpace.doesntExistInThisSpace, true, { fail403Param: 'update' }), + createTestDefinitions(otherSpace.doesntExistInThisSpace, true), createTestDefinitions(otherSpace.existsInThisSpace, false), ].flat(), authorized: createTestDefinitions(testCases, false), diff --git a/x-pack/test/spaces_api_integration/security_and_spaces/apis/share_remove.ts b/x-pack/test/spaces_api_integration/security_and_spaces/apis/share_remove.ts index 4b120a71213b7..34406d3258aa4 100644 --- a/x-pack/test/spaces_api_integration/security_and_spaces/apis/share_remove.ts +++ b/x-pack/test/spaces_api_integration/security_and_spaces/apis/share_remove.ts @@ -35,16 +35,20 @@ const createTestCases = (spaceId: string) => { { id: CASES.DEFAULT_AND_SPACE_1.id, namespaces, ...fail404(spaceId === SPACE_2_ID) }, { id: CASES.DEFAULT_AND_SPACE_2.id, namespaces, ...fail404(spaceId === SPACE_1_ID) }, { id: CASES.SPACE_1_AND_SPACE_2.id, namespaces, ...fail404(spaceId === DEFAULT_SPACE_ID) }, - { id: CASES.ALL_SPACES.id, namespaces }, + { id: CASES.EACH_SPACE.id, namespaces }, { id: CASES.DOES_NOT_EXIST.id, namespaces, ...fail404() }, ] as ShareRemoveTestCase[]; - // Test cases to check removing all three namespaces from different saved objects that exist in two spaces - // These are non-exhaustive, they only check some cases -- each object will result in a 404, either because - // it never existed in the target namespace, or it was removed in one of the test cases above - // More permutations are covered in the corresponding spaces_only test suite namespaces = [DEFAULT_SPACE_ID, SPACE_1_ID, SPACE_2_ID]; const multipleSpaces = [ + // Test case to check removing all spaces from a saved object that exists in all spaces; + // It fails the second time because the object no longer exists + { ...CASES.ALL_SPACES, namespaces: ['*'] }, + { ...CASES.ALL_SPACES, namespaces: ['*'], ...fail404() }, + // Test cases to check removing all three namespaces from different saved objects that exist in two spaces + // These are non-exhaustive, they only check some cases -- each object will result in a 404, either because + // it never existed in the target namespace, or it was removed in one of the test cases above + // More permutations are covered in the corresponding spaces_only test suite { id: CASES.DEFAULT_AND_SPACE_1.id, namespaces, ...fail404() }, { id: CASES.DEFAULT_AND_SPACE_2.id, namespaces, ...fail404() }, { id: CASES.SPACE_1_AND_SPACE_2.id, namespaces, ...fail404() }, diff --git a/x-pack/test/spaces_api_integration/spaces_only/apis/share_add.ts b/x-pack/test/spaces_api_integration/spaces_only/apis/share_add.ts index 25ba986a12fd8..8b8e449b3c323 100644 --- a/x-pack/test/spaces_api_integration/spaces_only/apis/share_add.ts +++ b/x-pack/test/spaces_api_integration/spaces_only/apis/share_add.ts @@ -33,6 +33,7 @@ const createSingleTestCases = (spaceId: string) => { { ...CASES.DEFAULT_AND_SPACE_1, namespaces, ...fail404(spaceId === SPACE_2_ID) }, { ...CASES.DEFAULT_AND_SPACE_2, namespaces, ...fail404(spaceId === SPACE_1_ID) }, { ...CASES.SPACE_1_AND_SPACE_2, namespaces, ...fail404(spaceId === DEFAULT_SPACE_ID) }, + { ...CASES.EACH_SPACE, namespaces }, { ...CASES.ALL_SPACES, namespaces }, { ...CASES.DOES_NOT_EXIST, namespaces, ...fail404() }, ]; @@ -42,14 +43,26 @@ const createSingleTestCases = (spaceId: string) => { * These are non-exhaustive, but they check different permutations of saved objects and spaces to add */ const createMultiTestCases = () => { - const allSpaces = [DEFAULT_SPACE_ID, SPACE_1_ID, SPACE_2_ID]; - let id = CASES.DEFAULT_ONLY.id; - const one = [{ id, namespaces: allSpaces }]; - id = CASES.DEFAULT_AND_SPACE_1.id; - const two = [{ id, namespaces: allSpaces }]; - id = CASES.ALL_SPACES.id; - const three = [{ id, namespaces: allSpaces }]; - return { one, two, three }; + const eachSpace = [DEFAULT_SPACE_ID, SPACE_1_ID, SPACE_2_ID]; + const allSpaces = ['*']; + // for each of the cases below, test adding each space and all spaces to the object + const one = [ + { id: CASES.DEFAULT_ONLY.id, namespaces: eachSpace }, + { id: CASES.DEFAULT_ONLY.id, namespaces: allSpaces }, + ]; + const two = [ + { id: CASES.DEFAULT_AND_SPACE_1.id, namespaces: eachSpace }, + { id: CASES.DEFAULT_AND_SPACE_1.id, namespaces: allSpaces }, + ]; + const three = [ + { id: CASES.EACH_SPACE.id, namespaces: eachSpace }, + { id: CASES.EACH_SPACE.id, namespaces: allSpaces }, + ]; + const four = [ + { id: CASES.ALL_SPACES.id, namespaces: eachSpace }, + { id: CASES.ALL_SPACES.id, namespaces: allSpaces }, + ]; + return { one, two, three, four }; }; // eslint-disable-next-line import/no-default-export @@ -68,6 +81,7 @@ export default function ({ getService }: TestInvoker) { one: createTestDefinitions(testCases.one, false), two: createTestDefinitions(testCases.two, false), three: createTestDefinitions(testCases.three, false), + four: createTestDefinitions(testCases.four, false), }; }; @@ -76,9 +90,10 @@ export default function ({ getService }: TestInvoker) { const tests = createSingleTests(spaceId); addTests(`targeting the ${spaceId} space`, { spaceId, tests }); }); - const { one, two, three } = createMultiTests(); + const { one, two, three, four } = createMultiTests(); addTests('for a saved object in the default space', { tests: one }); addTests('for a saved object in the default and space_1 spaces', { tests: two }); addTests('for a saved object in the default, space_1, and space_2 spaces', { tests: three }); + addTests('for a saved object in all spaces', { tests: four }); }); } diff --git a/x-pack/test/spaces_api_integration/spaces_only/apis/share_remove.ts b/x-pack/test/spaces_api_integration/spaces_only/apis/share_remove.ts index 2c4506b723533..bb0e6f858cbab 100644 --- a/x-pack/test/spaces_api_integration/spaces_only/apis/share_remove.ts +++ b/x-pack/test/spaces_api_integration/spaces_only/apis/share_remove.ts @@ -33,7 +33,7 @@ const createSingleTestCases = (spaceId: string) => { { ...CASES.DEFAULT_AND_SPACE_1, namespaces, ...fail404(spaceId === SPACE_2_ID) }, { ...CASES.DEFAULT_AND_SPACE_2, namespaces, ...fail404(spaceId === SPACE_1_ID) }, { ...CASES.SPACE_1_AND_SPACE_2, namespaces, ...fail404(spaceId === DEFAULT_SPACE_ID) }, - { ...CASES.ALL_SPACES, namespaces }, + { ...CASES.EACH_SPACE, namespaces }, { ...CASES.DOES_NOT_EXIST, namespaces, ...fail404() }, ]; }; @@ -56,7 +56,7 @@ const createMultiTestCases = () => { { id, namespaces: [DEFAULT_SPACE_ID], ...fail404() }, // this object's namespaces no longer contains DEFAULT_SPACE_ID { id, namespaces: [SPACE_1_ID], ...fail404() }, // this object's namespaces does contain SPACE_1_ID ]; - id = CASES.ALL_SPACES.id; + id = CASES.EACH_SPACE.id; const three = [ { id, namespaces: [DEFAULT_SPACE_ID, SPACE_1_ID, nonExistentSpaceId] }, // this saved object will not be found in the context of the current namespace ('default') @@ -64,7 +64,15 @@ const createMultiTestCases = () => { { id, namespaces: [SPACE_1_ID], ...fail404() }, // this object's namespaces no longer contains SPACE_1_ID { id, namespaces: [SPACE_2_ID], ...fail404() }, // this object's namespaces does contain SPACE_2_ID ]; - return { one, two, three }; + id = CASES.ALL_SPACES.id; + const four = [ + { id, namespaces: [DEFAULT_SPACE_ID, SPACE_1_ID, nonExistentSpaceId] }, + // this saved object will still be found in the context of the current namespace ('default') + { id, namespaces: ['*'] }, + // this object no longer exists + { id, namespaces: ['*'], ...fail404() }, + ]; + return { one, two, three, four }; }; // eslint-disable-next-line import/no-default-export @@ -83,6 +91,7 @@ export default function ({ getService }: TestInvoker) { one: createTestDefinitions(testCases.one, false), two: createTestDefinitions(testCases.two, false), three: createTestDefinitions(testCases.three, false), + four: createTestDefinitions(testCases.four, false), }; }; @@ -91,9 +100,10 @@ export default function ({ getService }: TestInvoker) { const tests = createSingleTests(spaceId); addTests(`targeting the ${spaceId} space`, { spaceId, tests }); }); - const { one, two, three } = createMultiTests(); + const { one, two, three, four } = createMultiTests(); addTests('for a saved object in the default space', { tests: one }); addTests('for a saved object in the default and space_1 spaces', { tests: two }); addTests('for a saved object in the default, space_1, and space_2 spaces', { tests: three }); + addTests('for a saved object in all spaces', { tests: four }); }); }