diff --git a/docs/development/core/public/kibana-plugin-core-public.md b/docs/development/core/public/kibana-plugin-core-public.md index f341a7cd9315f..a13438ff48e0b 100644 --- a/docs/development/core/public/kibana-plugin-core-public.md +++ b/docs/development/core/public/kibana-plugin-core-public.md @@ -98,6 +98,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | [OverlayStart](./kibana-plugin-core-public.overlaystart.md) | | | [Plugin](./kibana-plugin-core-public.plugin.md) | The interface that should be returned by a PluginInitializer. | | [PluginInitializerContext](./kibana-plugin-core-public.plugininitializercontext.md) | The available core services passed to a PluginInitializer | +| [ResolvedSimpleSavedObject](./kibana-plugin-core-public.resolvedsimplesavedobject.md) | This interface is a very simple wrapper for SavedObjects resolved from the server with the [SavedObjectsClient](./kibana-plugin-core-public.savedobjectsclient.md). | | [SavedObject](./kibana-plugin-core-public.savedobject.md) | | | [SavedObjectAttributes](./kibana-plugin-core-public.savedobjectattributes.md) | The data for a Saved Object is stored as an object in the attributes property. | | [SavedObjectError](./kibana-plugin-core-public.savedobjecterror.md) | | @@ -126,6 +127,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | [SavedObjectsImportUnknownError](./kibana-plugin-core-public.savedobjectsimportunknownerror.md) | Represents a failure to import due to an unknown reason. | | [SavedObjectsImportUnsupportedTypeError](./kibana-plugin-core-public.savedobjectsimportunsupportedtypeerror.md) | Represents a failure to import due to having an unsupported saved object type. | | [SavedObjectsMigrationVersion](./kibana-plugin-core-public.savedobjectsmigrationversion.md) | 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. | +| [SavedObjectsResolveResponse](./kibana-plugin-core-public.savedobjectsresolveresponse.md) | | | [SavedObjectsStart](./kibana-plugin-core-public.savedobjectsstart.md) | | | [SavedObjectsUpdateOptions](./kibana-plugin-core-public.savedobjectsupdateoptions.md) | | | [ToastOptions](./kibana-plugin-core-public.toastoptions.md) | Options available for [IToasts](./kibana-plugin-core-public.itoasts.md) APIs. | diff --git a/docs/development/core/public/kibana-plugin-core-public.resolvedsimplesavedobject.aliastargetid.md b/docs/development/core/public/kibana-plugin-core-public.resolvedsimplesavedobject.aliastargetid.md new file mode 100644 index 0000000000000..415681b2bb0d3 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.resolvedsimplesavedobject.aliastargetid.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [ResolvedSimpleSavedObject](./kibana-plugin-core-public.resolvedsimplesavedobject.md) > [aliasTargetId](./kibana-plugin-core-public.resolvedsimplesavedobject.aliastargetid.md) + +## ResolvedSimpleSavedObject.aliasTargetId property + +The ID of the object that the legacy URL alias points to. This is only defined when the outcome is `'aliasMatch'` or `'conflict'`. + +Signature: + +```typescript +aliasTargetId?: SavedObjectsResolveResponse['aliasTargetId']; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.resolvedsimplesavedobject.md b/docs/development/core/public/kibana-plugin-core-public.resolvedsimplesavedobject.md new file mode 100644 index 0000000000000..43727d86296a4 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.resolvedsimplesavedobject.md @@ -0,0 +1,22 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [ResolvedSimpleSavedObject](./kibana-plugin-core-public.resolvedsimplesavedobject.md) + +## ResolvedSimpleSavedObject interface + +This interface is a very simple wrapper for SavedObjects resolved from the server with the [SavedObjectsClient](./kibana-plugin-core-public.savedobjectsclient.md). + +Signature: + +```typescript +export interface ResolvedSimpleSavedObject +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [aliasTargetId](./kibana-plugin-core-public.resolvedsimplesavedobject.aliastargetid.md) | SavedObjectsResolveResponse['aliasTargetId'] | The ID of the object that the legacy URL alias points to. This is only defined when the outcome is 'aliasMatch' or 'conflict'. | +| [outcome](./kibana-plugin-core-public.resolvedsimplesavedobject.outcome.md) | SavedObjectsResolveResponse['outcome'] | The outcome for a successful resolve call is one of the following values:\* 'exactMatch' -- One document exactly matched the given ID. \* 'aliasMatch' -- One document with a legacy URL alias matched the given ID; in this case the saved_object.id field is different than the given ID. \* 'conflict' -- Two documents matched the given ID, one was an exact match and another with a legacy URL alias; in this case the saved_object object is the exact match, and the saved_object.id field is the same as the given ID. | +| [savedObject](./kibana-plugin-core-public.resolvedsimplesavedobject.savedobject.md) | SimpleSavedObject<T> | The saved object that was found. | + diff --git a/docs/development/core/public/kibana-plugin-core-public.resolvedsimplesavedobject.outcome.md b/docs/development/core/public/kibana-plugin-core-public.resolvedsimplesavedobject.outcome.md new file mode 100644 index 0000000000000..ceeef7706cc0f --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.resolvedsimplesavedobject.outcome.md @@ -0,0 +1,15 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [ResolvedSimpleSavedObject](./kibana-plugin-core-public.resolvedsimplesavedobject.md) > [outcome](./kibana-plugin-core-public.resolvedsimplesavedobject.outcome.md) + +## ResolvedSimpleSavedObject.outcome property + +The outcome for a successful `resolve` call is one of the following values: + +\* `'exactMatch'` -- One document exactly matched the given ID. \* `'aliasMatch'` -- One document with a legacy URL alias matched the given ID; in this case the `saved_object.id` field is different than the given ID. \* `'conflict'` -- Two documents matched the given ID, one was an exact match and another with a legacy URL alias; in this case the `saved_object` object is the exact match, and the `saved_object.id` field is the same as the given ID. + +Signature: + +```typescript +outcome: SavedObjectsResolveResponse['outcome']; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.resolvedsimplesavedobject.savedobject.md b/docs/development/core/public/kibana-plugin-core-public.resolvedsimplesavedobject.savedobject.md new file mode 100644 index 0000000000000..c05e8801768c9 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.resolvedsimplesavedobject.savedobject.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [ResolvedSimpleSavedObject](./kibana-plugin-core-public.resolvedsimplesavedobject.md) > [savedObject](./kibana-plugin-core-public.resolvedsimplesavedobject.savedobject.md) + +## ResolvedSimpleSavedObject.savedObject property + +The saved object that was found. + +Signature: + +```typescript +savedObject: SimpleSavedObject; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobject.md b/docs/development/core/public/kibana-plugin-core-public.savedobject.md index 9404927f94957..26f472b741268 100644 --- a/docs/development/core/public/kibana-plugin-core-public.savedobject.md +++ b/docs/development/core/public/kibana-plugin-core-public.savedobject.md @@ -19,7 +19,7 @@ export interface SavedObject | [error](./kibana-plugin-core-public.savedobject.error.md) | SavedObjectError | | | [id](./kibana-plugin-core-public.savedobject.id.md) | string | The ID of this Saved Object, guaranteed to be unique for all objects of the same type | | [migrationVersion](./kibana-plugin-core-public.savedobject.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-public.savedobject.namespaces.md) | string[] | Namespace(s) that this saved object exists in. This attribute is only used for multi-namespace saved object types. | +| [namespaces](./kibana-plugin-core-public.savedobject.namespaces.md) | string[] | Space(s) that this saved object exists in. This attribute is not used for "global" saved object types which are registered with namespaceType: 'agnostic'. | | [originId](./kibana-plugin-core-public.savedobject.originid.md) | string | The ID of the saved object this originated from. This is set if this object's id was regenerated; that can happen during migration from a legacy single-namespace type, or during import. It is only set during migration or create operations. This is used during import to ensure that ID regeneration is deterministic, so saved objects will be overwritten if they are imported multiple times into a given space. | | [references](./kibana-plugin-core-public.savedobject.references.md) | SavedObjectReference[] | A reference to another saved object. | | [type](./kibana-plugin-core-public.savedobject.type.md) | string | The type of Saved Object. Each plugin can define it's own custom Saved Object types. | diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobject.namespaces.md b/docs/development/core/public/kibana-plugin-core-public.savedobject.namespaces.md index 257df45934506..3418b964ab2d7 100644 --- a/docs/development/core/public/kibana-plugin-core-public.savedobject.namespaces.md +++ b/docs/development/core/public/kibana-plugin-core-public.savedobject.namespaces.md @@ -4,7 +4,7 @@ ## SavedObject.namespaces property -Namespace(s) that this saved object exists in. This attribute is only used for multi-namespace saved object types. +Space(s) that this saved object exists in. This attribute is not used for "global" saved object types which are registered with `namespaceType: 'agnostic'`. Signature: 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 96bbeae346b2e..aacda031003c6 100644 --- a/docs/development/core/public/kibana-plugin-core-public.savedobjectsclient.md +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsclient.md @@ -26,6 +26,7 @@ The constructor for this class is marked as internal. Third-party code should no | [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, A = unknown>(options: SavedObjectsFindOptions) => Promise<SavedObjectsFindResponsePublic<T, unknown>> | Search for objects | | [get](./kibana-plugin-core-public.savedobjectsclient.get.md) | | <T = unknown>(type: string, id: string) => Promise<SimpleSavedObject<T>> | Fetches a single object | +| [resolve](./kibana-plugin-core-public.savedobjectsclient.resolve.md) | | <T = unknown>(type: string, id: string) => Promise<ResolvedSimpleSavedObject<T>> | Resolves a single object | ## Methods diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsclient.resolve.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsclient.resolve.md new file mode 100644 index 0000000000000..15fb1f3e9ac22 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsclient.resolve.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SavedObjectsClient](./kibana-plugin-core-public.savedobjectsclient.md) > [resolve](./kibana-plugin-core-public.savedobjectsclient.resolve.md) + +## SavedObjectsClient.resolve property + +Resolves a single object + +Signature: + +```typescript +resolve: (type: string, id: string) => Promise>; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsresolveresponse.aliastargetid.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsresolveresponse.aliastargetid.md new file mode 100644 index 0000000000000..02055da686880 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsresolveresponse.aliastargetid.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SavedObjectsResolveResponse](./kibana-plugin-core-public.savedobjectsresolveresponse.md) > [aliasTargetId](./kibana-plugin-core-public.savedobjectsresolveresponse.aliastargetid.md) + +## SavedObjectsResolveResponse.aliasTargetId property + +The ID of the object that the legacy URL alias points to. This is only defined when the outcome is `'aliasMatch'` or `'conflict'`. + +Signature: + +```typescript +aliasTargetId?: string; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsresolveresponse.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsresolveresponse.md new file mode 100644 index 0000000000000..4345f2949d48e --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsresolveresponse.md @@ -0,0 +1,21 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SavedObjectsResolveResponse](./kibana-plugin-core-public.savedobjectsresolveresponse.md) + +## SavedObjectsResolveResponse interface + + +Signature: + +```typescript +export interface SavedObjectsResolveResponse +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [aliasTargetId](./kibana-plugin-core-public.savedobjectsresolveresponse.aliastargetid.md) | string | The ID of the object that the legacy URL alias points to. This is only defined when the outcome is 'aliasMatch' or 'conflict'. | +| [outcome](./kibana-plugin-core-public.savedobjectsresolveresponse.outcome.md) | 'exactMatch' | 'aliasMatch' | 'conflict' | The outcome for a successful resolve call is one of the following values:\* 'exactMatch' -- One document exactly matched the given ID. \* 'aliasMatch' -- One document with a legacy URL alias matched the given ID; in this case the saved_object.id field is different than the given ID. \* 'conflict' -- Two documents matched the given ID, one was an exact match and another with a legacy URL alias; in this case the saved_object object is the exact match, and the saved_object.id field is the same as the given ID. | +| [saved\_object](./kibana-plugin-core-public.savedobjectsresolveresponse.saved_object.md) | SavedObject<T> | The saved object that was found. | + diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsresolveresponse.outcome.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsresolveresponse.outcome.md new file mode 100644 index 0000000000000..ff4367d804e5d --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsresolveresponse.outcome.md @@ -0,0 +1,15 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SavedObjectsResolveResponse](./kibana-plugin-core-public.savedobjectsresolveresponse.md) > [outcome](./kibana-plugin-core-public.savedobjectsresolveresponse.outcome.md) + +## SavedObjectsResolveResponse.outcome property + +The outcome for a successful `resolve` call is one of the following values: + +\* `'exactMatch'` -- One document exactly matched the given ID. \* `'aliasMatch'` -- One document with a legacy URL alias matched the given ID; in this case the `saved_object.id` field is different than the given ID. \* `'conflict'` -- Two documents matched the given ID, one was an exact match and another with a legacy URL alias; in this case the `saved_object` object is the exact match, and the `saved_object.id` field is the same as the given ID. + +Signature: + +```typescript +outcome: 'exactMatch' | 'aliasMatch' | 'conflict'; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectsresolveresponse.saved_object.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectsresolveresponse.saved_object.md new file mode 100644 index 0000000000000..d8a74d766d582 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectsresolveresponse.saved_object.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SavedObjectsResolveResponse](./kibana-plugin-core-public.savedobjectsresolveresponse.md) > [saved\_object](./kibana-plugin-core-public.savedobjectsresolveresponse.saved_object.md) + +## SavedObjectsResolveResponse.saved\_object property + +The saved object that was found. + +Signature: + +```typescript +saved_object: SavedObject; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.simplesavedobject._constructor_.md b/docs/development/core/public/kibana-plugin-core-public.simplesavedobject._constructor_.md index 8fb005421e870..c73a3a200cc24 100644 --- a/docs/development/core/public/kibana-plugin-core-public.simplesavedobject._constructor_.md +++ b/docs/development/core/public/kibana-plugin-core-public.simplesavedobject._constructor_.md @@ -9,7 +9,7 @@ Constructs a new instance of the `SimpleSavedObject` class Signature: ```typescript -constructor(client: SavedObjectsClientContract, { id, type, version, attributes, error, references, migrationVersion, coreMigrationVersion, }: SavedObjectType); +constructor(client: SavedObjectsClientContract, { id, type, version, attributes, error, references, migrationVersion, coreMigrationVersion, namespaces, }: SavedObjectType); ``` ## Parameters @@ -17,5 +17,5 @@ constructor(client: SavedObjectsClientContract, { id, type, version, attributes, | Parameter | Type | Description | | --- | --- | --- | | client | SavedObjectsClientContract | | -| { id, type, version, attributes, error, references, migrationVersion, coreMigrationVersion, } | SavedObjectType<T> | | +| { id, type, version, attributes, error, references, migrationVersion, coreMigrationVersion, namespaces, } | SavedObjectType<T> | | diff --git a/docs/development/core/public/kibana-plugin-core-public.simplesavedobject.md b/docs/development/core/public/kibana-plugin-core-public.simplesavedobject.md index 35264a3a4cf0c..e15a4d4ea6d09 100644 --- a/docs/development/core/public/kibana-plugin-core-public.simplesavedobject.md +++ b/docs/development/core/public/kibana-plugin-core-public.simplesavedobject.md @@ -18,7 +18,7 @@ export declare class SimpleSavedObject | Constructor | Modifiers | Description | | --- | --- | --- | -| [(constructor)(client, { id, type, version, attributes, error, references, migrationVersion, coreMigrationVersion, })](./kibana-plugin-core-public.simplesavedobject._constructor_.md) | | Constructs a new instance of the SimpleSavedObject class | +| [(constructor)(client, { id, type, version, attributes, error, references, migrationVersion, coreMigrationVersion, namespaces, })](./kibana-plugin-core-public.simplesavedobject._constructor_.md) | | Constructs a new instance of the SimpleSavedObject class | ## Properties @@ -30,6 +30,7 @@ export declare class SimpleSavedObject | [error](./kibana-plugin-core-public.simplesavedobject.error.md) | | SavedObjectType<T>['error'] | | | [id](./kibana-plugin-core-public.simplesavedobject.id.md) | | SavedObjectType<T>['id'] | | | [migrationVersion](./kibana-plugin-core-public.simplesavedobject.migrationversion.md) | | SavedObjectType<T>['migrationVersion'] | | +| [namespaces](./kibana-plugin-core-public.simplesavedobject.namespaces.md) | | SavedObjectType<T>['namespaces'] | Space(s) that this saved object exists in. This attribute is not used for "global" saved object types which are registered with namespaceType: 'agnostic'. | | [references](./kibana-plugin-core-public.simplesavedobject.references.md) | | SavedObjectType<T>['references'] | | | [type](./kibana-plugin-core-public.simplesavedobject.type.md) | | SavedObjectType<T>['type'] | | diff --git a/docs/development/core/public/kibana-plugin-core-public.simplesavedobject.namespaces.md b/docs/development/core/public/kibana-plugin-core-public.simplesavedobject.namespaces.md new file mode 100644 index 0000000000000..7fb0a4e3a717a --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.simplesavedobject.namespaces.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SimpleSavedObject](./kibana-plugin-core-public.simplesavedobject.md) > [namespaces](./kibana-plugin-core-public.simplesavedobject.namespaces.md) + +## SimpleSavedObject.namespaces property + +Space(s) that this saved object exists in. This attribute is not used for "global" saved object types which are registered with `namespaceType: 'agnostic'`. + +Signature: + +```typescript +namespaces: SavedObjectType['namespaces']; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobject.md b/docs/development/core/server/kibana-plugin-core-server.savedobject.md index 07172487e6fde..4c62b359b284d 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobject.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobject.md @@ -19,7 +19,7 @@ export interface SavedObject | [error](./kibana-plugin-core-server.savedobject.error.md) | SavedObjectError | | | [id](./kibana-plugin-core-server.savedobject.id.md) | string | The ID of this Saved Object, guaranteed to be unique for all objects of the same type | | [migrationVersion](./kibana-plugin-core-server.savedobject.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.savedobject.namespaces.md) | string[] | Namespace(s) that this saved object exists in. This attribute is only used for multi-namespace saved object types. | +| [namespaces](./kibana-plugin-core-server.savedobject.namespaces.md) | string[] | Space(s) that this saved object exists in. This attribute is not used for "global" saved object types which are registered with namespaceType: 'agnostic'. | | [originId](./kibana-plugin-core-server.savedobject.originid.md) | string | The ID of the saved object this originated from. This is set if this object's id was regenerated; that can happen during migration from a legacy single-namespace type, or during import. It is only set during migration or create operations. This is used during import to ensure that ID regeneration is deterministic, so saved objects will be overwritten if they are imported multiple times into a given space. | | [references](./kibana-plugin-core-server.savedobject.references.md) | SavedObjectReference[] | A reference to another saved object. | | [type](./kibana-plugin-core-server.savedobject.type.md) | string | The type of Saved Object. Each plugin can define it's own custom Saved Object types. | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobject.namespaces.md b/docs/development/core/server/kibana-plugin-core-server.savedobject.namespaces.md index 2a555db01df3b..3c2909486219b 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobject.namespaces.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobject.namespaces.md @@ -4,7 +4,7 @@ ## SavedObject.namespaces property -Namespace(s) that this saved object exists in. This attribute is only used for multi-namespace saved object types. +Space(s) that this saved object exists in. This attribute is not used for "global" saved object types which are registered with `namespaceType: 'agnostic'`. Signature: diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveresponse.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveresponse.md index ffcf15dbc80c7..8a2504ec7adcc 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveresponse.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveresponse.md @@ -17,5 +17,5 @@ export interface SavedObjectsResolveResponse | --- | --- | --- | | [aliasTargetId](./kibana-plugin-core-server.savedobjectsresolveresponse.aliastargetid.md) | string | The ID of the object that the legacy URL alias points to. This is only defined when the outcome is 'aliasMatch' or 'conflict'. | | [outcome](./kibana-plugin-core-server.savedobjectsresolveresponse.outcome.md) | 'exactMatch' | 'aliasMatch' | 'conflict' | The outcome for a successful resolve call is one of the following values:\* 'exactMatch' -- One document exactly matched the given ID. \* 'aliasMatch' -- One document with a legacy URL alias matched the given ID; in this case the saved_object.id field is different than the given ID. \* 'conflict' -- Two documents matched the given ID, one was an exact match and another with a legacy URL alias; in this case the saved_object object is the exact match, and the saved_object.id field is the same as the given ID. | -| [saved\_object](./kibana-plugin-core-server.savedobjectsresolveresponse.saved_object.md) | SavedObject<T> | | +| [saved\_object](./kibana-plugin-core-server.savedobjectsresolveresponse.saved_object.md) | SavedObject<T> | The saved object that was found. | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveresponse.saved_object.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveresponse.saved_object.md index c184312675f75..c7748a2f97025 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveresponse.saved_object.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsresolveresponse.saved_object.md @@ -4,6 +4,8 @@ ## SavedObjectsResolveResponse.saved\_object property +The saved object that was found. + Signature: ```typescript diff --git a/src/core/public/index.ts b/src/core/public/index.ts index 32737ff427ef3..9bf1a05abc34e 100644 --- a/src/core/public/index.ts +++ b/src/core/public/index.ts @@ -99,6 +99,7 @@ export type { } from './application'; export { SimpleSavedObject } from './saved_objects'; +export type { ResolvedSimpleSavedObject } from './saved_objects'; export type { SavedObjectsBatchResponse, SavedObjectsBulkCreateObject, @@ -107,6 +108,7 @@ export type { SavedObjectsBulkUpdateOptions, SavedObjectsCreateOptions, SavedObjectsFindResponsePublic, + SavedObjectsResolveResponse, SavedObjectsUpdateOptions, SavedObject, SavedObjectAttribute, diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index 32897f10425d6..8b87c21e22fa4 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -1118,6 +1118,13 @@ export type ResolveDeprecationResponse = { reason: string; }; +// @public +export interface ResolvedSimpleSavedObject { + aliasTargetId?: SavedObjectsResolveResponse['aliasTargetId']; + outcome: SavedObjectsResolveResponse['outcome']; + savedObject: SimpleSavedObject; +} + // Warning: (ae-missing-release-tag) "SavedObject" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -1247,6 +1254,7 @@ export class SavedObjectsClient { // 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>; + resolve: (type: string, id: string) => Promise>; update(type: string, id: string, attributes: T, { version, references, upsert }?: SavedObjectsUpdateOptions): Promise>; } @@ -1467,6 +1475,13 @@ export interface SavedObjectsMigrationVersion { // @public export type SavedObjectsNamespaceType = 'single' | 'multiple' | 'multiple-isolated' | 'agnostic'; +// @public (undocumented) +export interface SavedObjectsResolveResponse { + aliasTargetId?: string; + outcome: 'exactMatch' | 'aliasMatch' | 'conflict'; + saved_object: SavedObject; +} + // @public (undocumented) export interface SavedObjectsStart { // (undocumented) @@ -1504,7 +1519,7 @@ export class ScopedHistory implements History { - constructor(client: SavedObjectsClientContract, { id, type, version, attributes, error, references, migrationVersion, coreMigrationVersion, }: SavedObject); + constructor(client: SavedObjectsClientContract, { id, type, version, attributes, error, references, migrationVersion, coreMigrationVersion, namespaces, }: SavedObject); // (undocumented) attributes: T; // (undocumented) @@ -1521,6 +1536,7 @@ export class SimpleSavedObject { id: SavedObject['id']; // (undocumented) migrationVersion: SavedObject['migrationVersion']; + namespaces: SavedObject['namespaces']; // (undocumented) references: SavedObject['references']; // (undocumented) diff --git a/src/core/public/saved_objects/index.ts b/src/core/public/saved_objects/index.ts index cd75bc16f8362..bd22947b174b7 100644 --- a/src/core/public/saved_objects/index.ts +++ b/src/core/public/saved_objects/index.ts @@ -17,9 +17,11 @@ export type { SavedObjectsCreateOptions, SavedObjectsFindResponsePublic, SavedObjectsUpdateOptions, + SavedObjectsResolveResponse, SavedObjectsBulkUpdateOptions, } from './saved_objects_client'; export { SimpleSavedObject } from './simple_saved_object'; +export type { ResolvedSimpleSavedObject } from './types'; export type { SavedObjectsStart } from './saved_objects_service'; export type { SavedObjectsBaseOptions, 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 c2beef5b990c1..85441b9841eaf 100644 --- a/src/core/public/saved_objects/saved_objects_client.test.ts +++ b/src/core/public/saved_objects/saved_objects_client.test.ts @@ -6,6 +6,8 @@ * Side Public License, v 1. */ +import type { SavedObjectsResolveResponse } from 'src/core/server'; + import { SavedObjectsClient } from './saved_objects_client'; import { SimpleSavedObject } from './simple_saved_object'; import { httpServiceMock } from '../http/http_service.mock'; @@ -147,6 +149,62 @@ describe('SavedObjectsClient', () => { }); }); + describe('#resolve', () => { + beforeEach(() => { + beforeEach(() => { + http.fetch.mockResolvedValue({ + saved_object: doc, + outcome: 'conflict', + aliasTargetId: 'another-id', + } as SavedObjectsResolveResponse); + }); + }); + + test('rejects if `type` is undefined', async () => { + expect(savedObjectsClient.resolve(undefined as any, doc.id)).rejects.toMatchInlineSnapshot( + `[Error: requires type and id]` + ); + }); + + test('rejects if `id` is undefined', async () => { + expect(savedObjectsClient.resolve(doc.type, undefined as any)).rejects.toMatchInlineSnapshot( + `[Error: requires type and id]` + ); + }); + + test('makes HTTP call', () => { + savedObjectsClient.resolve(doc.type, doc.id); + expect(http.fetch.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "/api/saved_objects/resolve/config/AVwSwFxtcMV38qjDZoQg", + Object { + "body": undefined, + "method": undefined, + "query": undefined, + }, + ], + ] + `); + }); + + test('rejects when HTTP call fails', async () => { + http.fetch.mockRejectedValueOnce(new Error('Request failed')); + await expect(savedObjectsClient.resolve(doc.type, doc.id)).rejects.toMatchInlineSnapshot( + `[Error: Request failed]` + ); + }); + + test('resolves with ResolvedSimpleSavedObject instance', async () => { + const result = await savedObjectsClient.resolve(doc.type, doc.id); + expect(result.savedObject).toBeInstanceOf(SimpleSavedObject); + expect(result.savedObject.type).toBe(doc.type); + expect(result.savedObject.get('title')).toBe('Example title'); + expect(result.outcome).toBe('conflict'); + expect(result.aliasTargetId).toBe('another-id'); + }); + }); + describe('#delete', () => { beforeEach(() => { http.fetch.mockResolvedValue({}); diff --git a/src/core/public/saved_objects/saved_objects_client.ts b/src/core/public/saved_objects/saved_objects_client.ts index 36ec3e734bd96..838b7adebc897 100644 --- a/src/core/public/saved_objects/saved_objects_client.ts +++ b/src/core/public/saved_objects/saved_objects_client.ts @@ -16,11 +16,15 @@ import { SavedObjectsClientContract as SavedObjectsApi, SavedObjectsFindOptions as SavedObjectFindOptionsServer, SavedObjectsMigrationVersion, + SavedObjectsResolveResponse, } from '../../server'; import { SimpleSavedObject } from './simple_saved_object'; +import type { ResolvedSimpleSavedObject } from './types'; import { HttpFetchOptions, HttpSetup } from '../http'; +export type { SavedObjectsResolveResponse }; + type PromiseType> = T extends Promise ? U : never; type SavedObjectsFindOptions = Omit< @@ -421,6 +425,29 @@ export class SavedObjectsClient { return request; } + /** + * Resolves a single object + * + * @param {string} type + * @param {string} id + * @returns The resolve result for the saved object for the given type and id. + */ + public resolve = ( + type: string, + id: string + ): Promise> => { + if (!type || !id) { + return Promise.reject(new Error('requires type and id')); + } + + const path = `${this.getPath(['resolve'])}/${type}/${id}`; + const request: Promise> = this.savedObjectsFetch(path, {}); + return request.then(({ saved_object: object, outcome, aliasTargetId }) => { + const savedObject = new SimpleSavedObject(this, object); + return { savedObject, outcome, aliasTargetId }; + }); + }; + /** * Updates an object * diff --git a/src/core/public/saved_objects/saved_objects_service.mock.ts b/src/core/public/saved_objects/saved_objects_service.mock.ts index 625ea6b5dd2da..2ceef1c077c39 100644 --- a/src/core/public/saved_objects/saved_objects_service.mock.ts +++ b/src/core/public/saved_objects/saved_objects_service.mock.ts @@ -18,6 +18,7 @@ const createStartContractMock = () => { bulkGet: jest.fn(), find: jest.fn(), get: jest.fn(), + resolve: jest.fn(), update: jest.fn(), }, }; diff --git a/src/core/public/saved_objects/simple_saved_object.ts b/src/core/public/saved_objects/simple_saved_object.ts index b78890893c4ce..449d3d7943fca 100644 --- a/src/core/public/saved_objects/simple_saved_object.ts +++ b/src/core/public/saved_objects/simple_saved_object.ts @@ -30,6 +30,11 @@ export class SimpleSavedObject { public coreMigrationVersion: SavedObjectType['coreMigrationVersion']; public error: SavedObjectType['error']; public references: SavedObjectType['references']; + /** + * Space(s) that this saved object exists in. This attribute is not used for "global" saved object types which are registered with + * `namespaceType: 'agnostic'`. + */ + public namespaces: SavedObjectType['namespaces']; constructor( private client: SavedObjectsClientContract, @@ -42,6 +47,7 @@ export class SimpleSavedObject { references, migrationVersion, coreMigrationVersion, + namespaces, }: SavedObjectType ) { this.id = id; @@ -51,6 +57,7 @@ export class SimpleSavedObject { this._version = version; this.migrationVersion = migrationVersion; this.coreMigrationVersion = coreMigrationVersion; + this.namespaces = namespaces; if (error) { this.error = error; } diff --git a/src/core/public/saved_objects/types.ts b/src/core/public/saved_objects/types.ts new file mode 100644 index 0000000000000..ac3df16730125 --- /dev/null +++ b/src/core/public/saved_objects/types.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import type { SavedObjectsResolveResponse } from '../../server'; +import { SimpleSavedObject } from './simple_saved_object'; + +/** + * This interface is a very simple wrapper for SavedObjects resolved from the server + * with the {@link SavedObjectsClient}. + * + * @public + */ +export interface ResolvedSimpleSavedObject { + /** + * The saved object that was found. + */ + savedObject: SimpleSavedObject; + /** + * The outcome for a successful `resolve` call is one of the following values: + * + * * `'exactMatch'` -- One document exactly matched the given ID. + * * `'aliasMatch'` -- One document with a legacy URL alias matched the given ID; in this case the `saved_object.id` field is different + * than the given ID. + * * `'conflict'` -- Two documents matched the given ID, one was an exact match and another with a legacy URL alias; in this case the + * `saved_object` object is the exact match, and the `saved_object.id` field is the same as the given ID. + */ + outcome: SavedObjectsResolveResponse['outcome']; + /** + * The ID of the object that the legacy URL alias points to. This is only defined when the outcome is `'aliasMatch'` or `'conflict'`. + */ + aliasTargetId?: SavedObjectsResolveResponse['aliasTargetId']; +} diff --git a/src/core/server/core_usage_data/core_usage_data_service.mock.ts b/src/core/server/core_usage_data/core_usage_data_service.mock.ts index 5fa67fecb2a8a..a03f79096004b 100644 --- a/src/core/server/core_usage_data/core_usage_data_service.mock.ts +++ b/src/core/server/core_usage_data/core_usage_data_service.mock.ts @@ -139,6 +139,12 @@ const createStartContractMock = () => { storeSizeBytes: 1, }, ], + legacyUrlAliases: { + inactiveCount: 1, + activeCount: 1, + disabledCount: 1, + totalCount: 3, + }, }, }, }) diff --git a/src/core/server/core_usage_data/core_usage_data_service.test.ts b/src/core/server/core_usage_data/core_usage_data_service.test.ts index 95dd392016c17..2395d6d1c1725 100644 --- a/src/core/server/core_usage_data/core_usage_data_service.test.ts +++ b/src/core/server/core_usage_data/core_usage_data_service.test.ts @@ -183,6 +183,19 @@ describe('CoreUsageDataService', () => { }, ], } as any); + elasticsearch.client.asInternalUser.search.mockResolvedValueOnce({ + body: { + hits: { total: { value: 6 } }, + aggregations: { + aliases: { + buckets: { + active: { doc_count: 1 }, + disabled: { doc_count: 2 }, + }, + }, + }, + }, + } as any); const typeRegistry = savedObjectsServiceMock.createTypeRegistryMock(); typeRegistry.getAllTypes.mockReturnValue([ { name: 'type 1', indexPattern: '.kibana' }, @@ -329,6 +342,12 @@ describe('CoreUsageDataService', () => { "storeSizeBytes": 2000, }, ], + "legacyUrlAliases": Object { + "activeCount": 1, + "disabledCount": 2, + "inactiveCount": 3, + "totalCount": 6, + }, }, }, } diff --git a/src/core/server/core_usage_data/core_usage_data_service.ts b/src/core/server/core_usage_data/core_usage_data_service.ts index afe1b45175f86..7cf38dddc563e 100644 --- a/src/core/server/core_usage_data/core_usage_data_service.ts +++ b/src/core/server/core_usage_data/core_usage_data_service.ts @@ -13,6 +13,11 @@ import { hasConfigPathIntersection, ChangedDeprecatedPaths } from '@kbn/config'; import { CoreService } from 'src/core/types'; import { Logger, SavedObjectsServiceStart, SavedObjectTypeRegistry } from 'src/core/server'; +import { + AggregationsFiltersAggregate, + AggregationsFiltersBucketItem, + SearchTotalHits, +} from '@elastic/elasticsearch/api/types'; import { CoreContext } from '../core_context'; import { ElasticsearchConfigType } from '../elasticsearch/elasticsearch_config'; import { HttpConfigType, InternalHttpServiceSetup } from '../http'; @@ -29,6 +34,7 @@ import { isConfigured } from './is_configured'; import { ElasticsearchServiceStart } from '../elasticsearch'; import { KibanaConfigType } from '../kibana_config'; import { coreUsageStatsType } from './core_usage_stats'; +import { LEGACY_URL_ALIAS_TYPE } from '../saved_objects/object_types'; import { CORE_USAGE_STATS_TYPE } from './constants'; import { CoreUsageStatsClient } from './core_usage_stats_client'; import { MetricsServiceSetup, OpsMetrics } from '..'; @@ -98,11 +104,25 @@ export class CoreUsageDataService implements CoreService { - const indices = await Promise.all( + const [indices, legacyUrlAliases] = await Promise.all([ + this.getSavedObjectIndicesUsageData(savedObjects, elasticsearch), + this.getSavedObjectAliasUsageData(elasticsearch), + ]); + return { + indices, + legacyUrlAliases, + }; + } + + private async getSavedObjectIndicesUsageData( + savedObjects: SavedObjectsServiceStart, + elasticsearch: ElasticsearchServiceStart + ) { + return Promise.all( Array.from( savedObjects .getTypeRegistry() @@ -136,10 +156,44 @@ export class CoreUsageDataService implements CoreService; + const disabledCount = buckets.disabled.doc_count as number; + const activeCount = buckets.active.doc_count as number; + const inactiveCount = totalCount - disabledCount - activeCount; + + return { totalCount, disabledCount, activeCount, inactiveCount }; } private async getCoreUsageData( @@ -162,7 +216,7 @@ export class CoreUsageDataService implements CoreService { }, }); + /** Each time resolve is called, usage stats are incremented depending upon the outcome. */ + const expectIncrementCounter = (n, outcomeStatString) => { + expect(client.update).toHaveBeenNthCalledWith( + n, + expect.objectContaining({ + body: expect.objectContaining({ + upsert: expect.objectContaining({ + [CORE_USAGE_STATS_TYPE]: { + [outcomeStatString]: 1, + [REPOSITORY_RESOLVE_OUTCOME_STATS.TOTAL]: 1, + }, + }), + }), + }), + expect.anything() + ); + }; + describe('outcomes', () => { describe('error', () => { const expectNotFoundError = async (type, id, options) => { @@ -3302,9 +3321,10 @@ describe('SavedObjectsRepository', () => { ); await expectNotFoundError(type, id, options); - expect(client.update).not.toHaveBeenCalled(); + expect(client.update).toHaveBeenCalledTimes(1); // incremented stats expect(client.get).toHaveBeenCalledTimes(1); // retrieved actual target expect(client.mget).not.toHaveBeenCalled(); + expectIncrementCounter(1, REPOSITORY_RESOLVE_OUTCOME_STATS.NOT_FOUND); }); it('because actual object and alias object are both not found', async () => { @@ -3320,9 +3340,10 @@ describe('SavedObjectsRepository', () => { ); await expectNotFoundError(type, id, options); - expect(client.update).toHaveBeenCalledTimes(1); // retrieved alias object + expect(client.update).toHaveBeenCalledTimes(2); // retrieved alias object, then incremented stats expect(client.get).not.toHaveBeenCalled(); expect(client.mget).toHaveBeenCalledTimes(1); // retrieved actual target and alias target + expectIncrementCounter(2, REPOSITORY_RESOLVE_OUTCOME_STATS.NOT_FOUND); }); }); @@ -3335,9 +3356,10 @@ describe('SavedObjectsRepository', () => { ); const result = await savedObjectsRepository.resolve(type, id, options); - expect(client.update).not.toHaveBeenCalled(); + expect(client.update).toHaveBeenCalledTimes(1); // incremented stats expect(client.get).toHaveBeenCalledTimes(1); // retrieved actual target expect(client.mget).not.toHaveBeenCalled(); + expectIncrementCounter(1, REPOSITORY_RESOLVE_OUTCOME_STATS.EXACT_MATCH); expect(result).toEqual({ saved_object: expect.objectContaining({ type, id }), outcome: 'exactMatch', @@ -3354,9 +3376,10 @@ describe('SavedObjectsRepository', () => { ); const result = await savedObjectsRepository.resolve(type, id, options); - expect(client.update).toHaveBeenCalledTimes(1); // retrieved alias object + expect(client.update).toHaveBeenCalledTimes(2); // retrieved alias object, then incremented stats expect(client.get).toHaveBeenCalledTimes(1); // retrieved actual target expect(client.mget).not.toHaveBeenCalled(); + expectIncrementCounter(2, REPOSITORY_RESOLVE_OUTCOME_STATS.EXACT_MATCH); expect(result).toEqual({ saved_object: expect.objectContaining({ type, id }), outcome: 'exactMatch', @@ -3388,9 +3411,10 @@ describe('SavedObjectsRepository', () => { ); const result = await savedObjectsRepository.resolve(type, id, options); - expect(client.update).toHaveBeenCalledTimes(1); // retrieved alias object + expect(client.update).toHaveBeenCalledTimes(2); // retrieved alias object, then incremented stats expect(client.get).not.toHaveBeenCalled(); expect(client.mget).toHaveBeenCalledTimes(1); // retrieved actual target and alias target + expectIncrementCounter(2, REPOSITORY_RESOLVE_OUTCOME_STATS.EXACT_MATCH); expect(result).toEqual({ saved_object: expect.objectContaining({ type, id }), outcome: 'exactMatch', @@ -3429,9 +3453,10 @@ describe('SavedObjectsRepository', () => { ); const result = await savedObjectsRepository.resolve(type, id, options); - expect(client.update).toHaveBeenCalledTimes(1); // retrieved alias object + expect(client.update).toHaveBeenCalledTimes(2); // retrieved alias object, then incremented stats expect(client.get).not.toHaveBeenCalled(); expect(client.mget).toHaveBeenCalledTimes(1); // retrieved actual target and alias target + expectIncrementCounter(2, REPOSITORY_RESOLVE_OUTCOME_STATS.ALIAS_MATCH); expect(result).toEqual({ saved_object: expect.objectContaining({ type, id: aliasTargetId }), outcome: 'aliasMatch', @@ -3470,9 +3495,10 @@ describe('SavedObjectsRepository', () => { ); const result = await savedObjectsRepository.resolve(type, id, options); - expect(client.update).toHaveBeenCalledTimes(1); // retrieved alias object + expect(client.update).toHaveBeenCalledTimes(2); // retrieved alias object, then incremented stats expect(client.get).not.toHaveBeenCalled(); expect(client.mget).toHaveBeenCalledTimes(1); // retrieved actual target and alias target + expectIncrementCounter(2, REPOSITORY_RESOLVE_OUTCOME_STATS.CONFLICT); expect(result).toEqual({ saved_object: expect.objectContaining({ type, id }), outcome: 'conflict', diff --git a/src/core/server/saved_objects/service/lib/repository.ts b/src/core/server/saved_objects/service/lib/repository.ts index c9fa50da55df1..986467c917dd2 100644 --- a/src/core/server/saved_objects/service/lib/repository.ts +++ b/src/core/server/saved_objects/service/lib/repository.ts @@ -8,6 +8,11 @@ import { omit, isObject } from 'lodash'; import type { estypes } from '@elastic/elasticsearch'; +import { + CORE_USAGE_STATS_TYPE, + CORE_USAGE_STATS_ID, + REPOSITORY_RESOLVE_OUTCOME_STATS, +} from '../../../core_usage_data'; import type { ElasticsearchClient } from '../../../elasticsearch/'; import type { Logger } from '../../../logging'; import { getRootPropertiesObjects, IndexMapping } from '../../mappings'; @@ -1057,7 +1062,7 @@ export class SavedObjectsRepository { const time = this._getCurrentTime(); // retrieve the alias, and if it is not disabled, update it - const aliasResponse = await this.client.update<{ 'legacy-url-alias': LegacyUrlAlias }>( + const aliasResponse = await this.client.update<{ [LEGACY_URL_ALIAS_TYPE]: LegacyUrlAlias }>( { id: rawAliasId, index: this.getIndexForType(LEGACY_URL_ALIAS_TYPE), @@ -1128,21 +1133,25 @@ export class SavedObjectsRepository { // @ts-expect-error MultiGetHit._source is optional aliasMatchDoc.found && this.rawDocExistsInNamespace(aliasMatchDoc, namespace); + let result: SavedObjectsResolveResponse | null = null; + let outcomeStatString = REPOSITORY_RESOLVE_OUTCOME_STATS.NOT_FOUND; if (foundExactMatch && foundAliasMatch) { - return { + result = { // @ts-expect-error MultiGetHit._source is optional saved_object: getSavedObjectFromSource(this._registry, type, id, exactMatchDoc), outcome: 'conflict', aliasTargetId: legacyUrlAlias.targetId, }; + outcomeStatString = REPOSITORY_RESOLVE_OUTCOME_STATS.CONFLICT; } else if (foundExactMatch) { - return { + result = { // @ts-expect-error MultiGetHit._source is optional saved_object: getSavedObjectFromSource(this._registry, type, id, exactMatchDoc), outcome: 'exactMatch', }; + outcomeStatString = REPOSITORY_RESOLVE_OUTCOME_STATS.EXACT_MATCH; } else if (foundAliasMatch) { - return { + result = { saved_object: getSavedObjectFromSource( this._registry, type, @@ -1153,6 +1162,13 @@ export class SavedObjectsRepository { outcome: 'aliasMatch', aliasTargetId: legacyUrlAlias.targetId, }; + outcomeStatString = REPOSITORY_RESOLVE_OUTCOME_STATS.ALIAS_MATCH; + } + + await this.incrementResolveOutcomeStats(outcomeStatString); + + if (result !== null) { + return result; } throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); } @@ -1649,8 +1665,8 @@ export class SavedObjectsRepository { type: string, id: string, counterFields: Array, - options: SavedObjectsIncrementCounterOptions = {} - ): Promise> { + options?: SavedObjectsIncrementCounterOptions + ) { if (typeof type !== 'string') { throw new Error('"type" argument must be a string'); } @@ -1671,6 +1687,16 @@ export class SavedObjectsRepository { throw SavedObjectsErrorHelpers.createUnsupportedTypeError(type); } + return this.incrementCounterInternal(type, id, counterFields, options); + } + + /** @internal incrementCounter function that is used interally and bypasses validation checks. */ + private async incrementCounterInternal( + type: string, + id: string, + counterFields: Array, + options: SavedObjectsIncrementCounterOptions = {} + ): Promise> { const { migrationVersion, refresh = DEFAULT_REFRESH_SETTING, @@ -2064,8 +2090,25 @@ export class SavedObjectsRepository { id: string, options: SavedObjectsBaseOptions ): Promise> { - const object = await this.get(type, id, options); - return { saved_object: object, outcome: 'exactMatch' }; + try { + const object = await this.get(type, id, options); + await this.incrementResolveOutcomeStats(REPOSITORY_RESOLVE_OUTCOME_STATS.EXACT_MATCH); + return { saved_object: object, outcome: 'exactMatch' }; + } catch (err) { + if (SavedObjectsErrorHelpers.isNotFoundError(err)) { + await this.incrementResolveOutcomeStats(REPOSITORY_RESOLVE_OUTCOME_STATS.NOT_FOUND); + } + throw err; + } + } + + private async incrementResolveOutcomeStats(outcomeStatString: string) { + await this.incrementCounterInternal( + CORE_USAGE_STATS_TYPE, + CORE_USAGE_STATS_ID, + [outcomeStatString, REPOSITORY_RESOLVE_OUTCOME_STATS.TOTAL], + { refresh: false } + ).catch(() => {}); // if the call fails for some reason, intentionally swallow the error } private validateInitialNamespaces(type: string, initialNamespaces: string[] | undefined) { 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 1423050145695..abb86d8120a9b 100644 --- a/src/core/server/saved_objects/service/saved_objects_client.ts +++ b/src/core/server/saved_objects/service/saved_objects_client.ts @@ -311,6 +311,9 @@ export interface SavedObjectsUpdateResponse * @public */ export interface SavedObjectsResolveResponse { + /** + * The saved object that was found. + */ saved_object: SavedObject; /** * The outcome for a successful `resolve` call is one of the following values: diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index 3bc0b54635eb5..13ec594df9075 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -503,6 +503,12 @@ export interface CoreServicesUsageData { storeSizeBytes: number; primaryStoreSizeBytes: number; }[]; + legacyUrlAliases: { + activeCount: number; + inactiveCount: number; + disabledCount: number; + totalCount: number; + }; }; } @@ -765,6 +771,16 @@ export interface CoreUsageStats { 'apiCalls.savedObjectsUpdate.namespace.default.total'?: number; // (undocumented) 'apiCalls.savedObjectsUpdate.total'?: number; + // (undocumented) + 'savedObjectsRepository.resolvedOutcome.aliasMatch'?: number; + // (undocumented) + 'savedObjectsRepository.resolvedOutcome.conflict'?: number; + // (undocumented) + 'savedObjectsRepository.resolvedOutcome.exactMatch'?: number; + // (undocumented) + 'savedObjectsRepository.resolvedOutcome.notFound'?: number; + // (undocumented) + 'savedObjectsRepository.resolvedOutcome.total'?: number; } // @public (undocumented) @@ -2925,7 +2941,6 @@ export interface SavedObjectsResolveImportErrorsOptions { export interface SavedObjectsResolveResponse { aliasTargetId?: string; outcome: 'exactMatch' | 'aliasMatch' | 'conflict'; - // (undocumented) saved_object: SavedObject; } diff --git a/src/core/types/saved_objects.ts b/src/core/types/saved_objects.ts index 416b562b175b6..3a97c2fd6f010 100644 --- a/src/core/types/saved_objects.ts +++ b/src/core/types/saved_objects.ts @@ -84,7 +84,10 @@ export interface SavedObject { migrationVersion?: SavedObjectsMigrationVersion; /** A semver value that is used when upgrading objects between Kibana versions. */ coreMigrationVersion?: string; - /** Namespace(s) that this saved object exists in. This attribute is only used for multi-namespace saved object types. */ + /** + * Space(s) that this saved object exists in. This attribute is not used for "global" saved object types which are registered with + * `namespaceType: 'agnostic'`. + */ namespaces?: string[]; /** * The ID of the saved object this originated from. This is set if this object's `id` was regenerated; that can happen during migration diff --git a/src/plugins/kibana_usage_collection/server/collectors/core/core_usage_collector.ts b/src/plugins/kibana_usage_collection/server/collectors/core/core_usage_collector.ts index bf51e21bb9bf4..ffc4559876b2b 100644 --- a/src/plugins/kibana_usage_collection/server/collectors/core/core_usage_collector.ts +++ b/src/plugins/kibana_usage_collection/server/collectors/core/core_usage_collector.ts @@ -377,6 +377,34 @@ export function getCoreUsageCollector( }, }, }, + legacyUrlAliases: { + inactiveCount: { + type: 'long', + _meta: { + description: + 'Count of legacy URL aliases that are inactive; they are not disabled, but they have not been resolved.', + }, + }, + activeCount: { + type: 'long', + _meta: { + description: + 'Count of legacy URL aliases that are active; they are not disabled, and they have been resolved at least once.', + }, + }, + disabledCount: { + type: 'long', + _meta: { + description: 'Count of legacy URL aliases that are disabled.', + }, + }, + totalCount: { + type: 'long', + _meta: { + description: 'Total count of legacy URL aliases.', + }, + }, + }, }, }, // Saved Objects Client APIs @@ -914,6 +942,38 @@ export function getCoreUsageCollector( description: 'How many times this API has been called without all types selected.', }, }, + // Saved Objects Repository counters + 'savedObjectsRepository.resolvedOutcome.exactMatch': { + type: 'long', + _meta: { + description: 'How many times a saved object has resolved with an exact match outcome.', + }, + }, + 'savedObjectsRepository.resolvedOutcome.aliasMatch': { + type: 'long', + _meta: { + description: 'How many times a saved object has resolved with an alias match outcome.', + }, + }, + 'savedObjectsRepository.resolvedOutcome.conflict': { + type: 'long', + _meta: { + description: 'How many times a saved object has resolved with a conflict outcome.', + }, + }, + 'savedObjectsRepository.resolvedOutcome.notFound': { + type: 'long', + _meta: { + description: 'How many times a saved object has resolved with a not found outcome.', + }, + }, + 'savedObjectsRepository.resolvedOutcome.total': { + type: 'long', + _meta: { + description: + 'How many times a saved object has resolved with any of the four possible outcomes.', + }, + }, }, fetch() { return getCoreUsageDataService().getCoreUsageData(); diff --git a/src/plugins/saved_objects_management/public/lib/bulk_get_objects.ts b/src/plugins/saved_objects_management/public/lib/bulk_get_objects.ts new file mode 100644 index 0000000000000..1d29a39b5c7e4 --- /dev/null +++ b/src/plugins/saved_objects_management/public/lib/bulk_get_objects.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { HttpStart } from 'src/core/public'; +import { SavedObjectWithMetadata } from '../types'; + +export async function bulkGetObjects( + http: HttpStart, + objects: Array<{ type: string; id: string }> +): Promise { + return await http.post( + `/api/kibana/management/saved_objects/_bulk_get`, + { body: JSON.stringify(objects) } + ); +} diff --git a/src/plugins/saved_objects_management/public/lib/find_objects.ts b/src/plugins/saved_objects_management/public/lib/find_objects.ts index 6bbe8331d129d..9dafae0be303f 100644 --- a/src/plugins/saved_objects_management/public/lib/find_objects.ts +++ b/src/plugins/saved_objects_management/public/lib/find_objects.ts @@ -35,13 +35,3 @@ export async function findObjects( return keysToCamelCaseShallow(response) as SavedObjectsFindResponse; } - -export async function findObject( - http: HttpStart, - type: string, - id: string -): Promise { - return await http.get( - `/api/kibana/management/saved_objects/${encodeURIComponent(type)}/${encodeURIComponent(id)}` - ); -} diff --git a/src/plugins/saved_objects_management/public/lib/index.ts b/src/plugins/saved_objects_management/public/lib/index.ts index ecd070c4b3e87..df1485bedfc69 100644 --- a/src/plugins/saved_objects_management/public/lib/index.ts +++ b/src/plugins/saved_objects_management/public/lib/index.ts @@ -30,7 +30,8 @@ export { FailedImport, } from './process_import_response'; export { getDefaultTitle } from './get_default_title'; -export { findObjects, findObject } from './find_objects'; +export { findObjects } from './find_objects'; +export { bulkGetObjects } from './bulk_get_objects'; export { extractExportDetails, SavedObjectsExportResultDetails } from './extract_export_details'; export { createFieldList } from './create_field_list'; export { getAllowedTypes } from './get_allowed_types'; diff --git a/src/plugins/saved_objects_management/public/management_section/object_view/saved_object_view.tsx b/src/plugins/saved_objects_management/public/management_section/object_view/saved_object_view.tsx index 764c493be3ed4..3bf70de1abdad 100644 --- a/src/plugins/saved_objects_management/public/management_section/object_view/saved_object_view.tsx +++ b/src/plugins/saved_objects_management/public/management_section/object_view/saved_object_view.tsx @@ -19,7 +19,7 @@ import { } from '../../../../../core/public'; import { ISavedObjectsManagementServiceRegistry } from '../../services'; import { Header, NotFoundErrors, Intro, Form } from './components'; -import { canViewInApp, findObject } from '../../lib'; +import { canViewInApp, bulkGetObjects } from '../../lib'; import { SubmittedFormData } from '../types'; import { SavedObjectWithMetadata } from '../../types'; @@ -41,6 +41,11 @@ interface SavedObjectEditionState { object?: SavedObjectWithMetadata; } +const unableFindSavedObjectNotificationMessage = i18n.translate( + 'savedObjectsManagement.objectView.unableFindSavedObjectNotificationMessage', + { defaultMessage: 'Unable to find saved object' } +); + export class SavedObjectEdition extends Component< SavedObjectEditionProps, SavedObjectEditionState @@ -58,13 +63,26 @@ export class SavedObjectEdition extends Component< } componentDidMount() { - const { http, id } = this.props; + const { http, id, notifications } = this.props; const { type } = this.state; - findObject(http, type, id).then((object) => { - this.setState({ - object, + bulkGetObjects(http, [{ type, id }]) + .then(([object]) => { + if (object.error) { + const { message } = object.error; + notifications.toasts.addDanger({ + title: unableFindSavedObjectNotificationMessage, + text: message, + }); + } else { + this.setState({ object }); + } + }) + .catch((err) => { + notifications.toasts.addDanger({ + title: unableFindSavedObjectNotificationMessage, + text: err.message ?? 'Unknown error', + }); }); - }); } render() { diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/delete_confirm_modal.test.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/delete_confirm_modal.test.tsx index f33b5488aa760..1dcf15bc551d4 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/delete_confirm_modal.test.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/delete_confirm_modal.test.tsx @@ -7,15 +7,21 @@ */ import React from 'react'; +import { findTestSubject } from '@elastic/eui/lib/test'; import { mountWithIntl } from '@kbn/test/jest'; import { SavedObjectWithMetadata } from '../../../../common'; import { DeleteConfirmModal } from './delete_confirm_modal'; -const createObject = (): SavedObjectWithMetadata => ({ +interface CreateObjectOptions { + namespaces?: string[]; +} + +const createObject = ({ namespaces }: CreateObjectOptions = {}): SavedObjectWithMetadata => ({ id: 'foo', type: 'bar', attributes: {}, references: [], + namespaces, meta: {}, }); @@ -83,4 +89,45 @@ describe('DeleteConfirmModal', () => { expect(onConfirm).toHaveBeenCalledTimes(1); expect(onCancel).not.toHaveBeenCalled(); }); + + describe('shared objects warning', () => { + it('does not display a callout when no objects are shared', () => { + const objs = [ + createObject(), // if for some reason an object has no namespaces array, it does not count as shared + createObject({ namespaces: [] }), // if for some reason an object has an empty namespaces array, it does not count as shared + createObject({ namespaces: ['one-space'] }), // an object in a single space does not count as shared + ]; + const wrapper = mountWithIntl( + + ); + const callout = findTestSubject(wrapper, 'sharedObjectsWarning'); + expect(callout).toHaveLength(0); + }); + + it('displays a callout when one or more objects are shared', () => { + const objs = [ + createObject({ namespaces: ['one-space'] }), // an object in a single space does not count as shared + createObject({ namespaces: ['one-space', 'another-space'] }), // an object in two spaces counts as shared + createObject({ namespaces: ['*'] }), // an object in all spaces counts as shared + ]; + const wrapper = mountWithIntl( + + ); + const callout = findTestSubject(wrapper, 'sharedObjectsWarning'); + expect(callout).toHaveLength(1); + expect(callout.text()).toMatchInlineSnapshot( + `"2 of your saved objects are sharedShared objects are deleted from every space they are in."` + ); + }); + }); }); diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/delete_confirm_modal.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/delete_confirm_modal.tsx index d589d5a700801..7f1f3adc96d8b 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/delete_confirm_modal.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/delete_confirm_modal.tsx @@ -47,8 +47,17 @@ export const DeleteConfirmModal: FC = ({ return selectedObjects.filter((obj) => obj.meta.hiddenType); }, [selectedObjects]); const deletableObjects = useMemo(() => { - return selectedObjects.filter((obj) => !obj.meta.hiddenType); + return selectedObjects + .filter((obj) => !obj.meta.hiddenType) + .map(({ type, id, meta, namespaces = [] }) => { + const { title = '', icon = 'apps' } = meta; + const isShared = namespaces.length > 1 || namespaces.includes('*'); + return { type, id, icon, title, isShared }; + }); }, [selectedObjects]); + const sharedObjectsCount = useMemo(() => { + return deletableObjects.filter((obj) => obj.isShared).length; + }, [deletableObjects]); if (isDeleting) { return ( @@ -93,6 +102,30 @@ export const DeleteConfirmModal: FC = ({ )} + {sharedObjectsCount > 0 && ( + <> + + } + iconType="alert" + color="warning" + > +

+ +

+
+ + + )}

= ({ { defaultMessage: 'Type' } ), width: '50px', - render: (type, object) => ( + render: (type, { icon }) => ( - + ), }, @@ -124,7 +157,7 @@ export const DeleteConfirmModal: FC = ({ ), }, { - field: 'meta.title', + field: 'title', name: i18n.translate( 'savedObjectsManagement.objectsTable.deleteSavedObjectsConfirmModal.titleColumnName', { defaultMessage: 'Title' } @@ -157,7 +190,8 @@ export const DeleteConfirmModal: FC = ({ > diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/table.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/table.tsx index ad61b0b692ea7..4e4bd51c4bb84 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/table.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/table.tsx @@ -48,7 +48,7 @@ export interface TableProps { filterOptions: any[]; capabilities: ApplicationStart['capabilities']; onDelete: () => void; - onActionRefresh: (object: SavedObjectWithMetadata) => void; + onActionRefresh: (objects: Array<{ type: string; id: string }>) => void; onExport: (includeReferencesDeep: boolean) => void; goInspectObject: (obj: SavedObjectWithMetadata) => void; pageIndex: number; @@ -277,10 +277,9 @@ export class Table extends PureComponent { this.setState({ activeAction: undefined, }); - const { refreshOnFinish = () => false } = action; - if (refreshOnFinish()) { - onActionRefresh(object); - } + const { refreshOnFinish = () => [] } = action; + const objectsToRefresh = refreshOnFinish(); + onActionRefresh(objectsToRefresh); }); if (action.euiAction.onClick) { 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 42c1220ef5540..5ea433f91d1a6 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 @@ -30,7 +30,7 @@ import { fetchExportObjects, fetchExportByTypeAndSearch, findObjects, - findObject, + bulkGetObjects, extractExportDetails, SavedObjectsExportResultDetails, getTagFindReferences, @@ -96,6 +96,14 @@ export interface SavedObjectsTableState { isIncludeReferencesDeepChecked: boolean; } +const unableFindSavedObjectsNotificationMessage = i18n.translate( + 'savedObjectsManagement.objectsTable.unableFindSavedObjectsNotificationMessage', + { defaultMessage: 'Unable find saved objects' } +); +const unableFindSavedObjectNotificationMessage = i18n.translate( + 'savedObjectsManagement.objectsTable.unableFindSavedObjectNotificationMessage', + { defaultMessage: 'Unable to find saved object' } +); export class SavedObjectsTable extends Component { private _isMounted = false; @@ -129,13 +137,14 @@ export class SavedObjectsTable extends Component { @@ -188,15 +197,15 @@ export class SavedObjectsTable extends Component { - this.setState({ isSearching: true }, this.debouncedFetchObjects); + fetchAllSavedObjects = () => { + this.setState({ isSearching: true }, this.debouncedFindObjects); }; - fetchSavedObject = (type: string, id: string) => { - this.setState({ isSearching: true }, () => this.debouncedFetchObject(type, id)); + fetchSavedObjects = (objects: Array<{ type: string; id: string }>) => { + this.setState({ isSearching: true }, () => this.debouncedBulkGetObjects(objects)); }; - debouncedFetchObjects = debounce(async () => { + debouncedFindObjects = debounce(async () => { const { activeQuery: query, page, perPage } = this.state; const { notifications, http, allowedTypes, taggingApi } = this.props; const { queryText, visibleTypes, selectedTags } = parseQuery(query); @@ -240,27 +249,45 @@ export class SavedObjectsTable extends Component { + debouncedBulkGetObjects = debounce(async (objects: Array<{ type: string; id: string }>) => { const { notifications, http } = this.props; try { - const resp = await findObject(http, type, id); + const resp = await bulkGetObjects(http, objects); if (!this._isMounted) { return; } + const { map: fetchedObjectsMap, errors: objectErrors } = resp.reduce( + ({ map, errors }, obj) => { + if (obj.error) { + errors.push(obj.error.message); + } else { + map.set(getObjectKey(obj), obj); + } + return { map, errors }; + }, + { map: new Map(), errors: [] as string[] } + ); + + if (objectErrors.length) { + notifications.toasts.addDanger({ + title: unableFindSavedObjectNotificationMessage, + text: objectErrors.join(', '), + }); + } + this.setState(({ savedObjects, filteredItemCount }) => { - const refreshedSavedObjects = savedObjects.map((object) => - object.type === type && object.id === id ? resp : object - ); + // modify the existing objects array, replacing any existing objects with the newly fetched ones + const refreshedSavedObjects = savedObjects.map((obj) => { + const fetchedObject = fetchedObjectsMap.get(getObjectKey(obj)); + return fetchedObject ?? obj; + }); return { savedObjects: refreshedSavedObjects, filteredItemCount, @@ -274,21 +301,25 @@ export class SavedObjectsTable extends Component { - await Promise.all([this.fetchSavedObjects(), this.fetchCounts()]); + refreshAllObjects = async () => { + await Promise.all([this.fetchAllSavedObjects(), this.fetchCounts()]); }; - refreshObject = async ({ type, id }: SavedObjectWithMetadata) => { - await this.fetchSavedObject(type, id); + refreshObjects = async (objects: Array<{ type: string; id: string }>) => { + const currentObjectsSet = this.state.savedObjects.reduce( + (acc, obj) => acc.add(getObjectKey(obj)), + new Set() + ); + const objectsToFetch = objects.filter((obj) => currentObjectsSet.has(getObjectKey(obj))); + if (objectsToFetch.length) { + this.fetchSavedObjects(objectsToFetch); + } }; onSelectionChanged = (selection: SavedObjectWithMetadata[]) => { @@ -305,7 +336,7 @@ export class SavedObjectsTable extends Component { - this.fetchSavedObjects(); + this.fetchAllSavedObjects(); this.fetchCounts(); } ); @@ -320,7 +351,7 @@ export class SavedObjectsTable extends Component { this.hideImportFlyout(); - this.fetchSavedObjects(); + this.fetchAllSavedObjects(); this.fetchCounts(); }; @@ -480,7 +511,7 @@ export class SavedObjectsTable extends Component this.setState({ isShowingExportAllOptionsModal: true })} onImport={this.showImportFlyout} - onRefresh={this.refreshObjects} + onRefresh={this.refreshAllObjects} filteredCount={filteredItemCount} /> @@ -645,7 +676,7 @@ export class SavedObjectsTable extends Component void; render?: (item: SavedObjectsManagementRecord) => any; }; - public refreshOnFinish?: () => boolean; + public refreshOnFinish?: () => Array<{ type: string; id: string }>; private callbacks: Function[] = []; diff --git a/src/plugins/saved_objects_management/server/routes/get.ts b/src/plugins/saved_objects_management/server/routes/bulk_get.ts similarity index 52% rename from src/plugins/saved_objects_management/server/routes/get.ts rename to src/plugins/saved_objects_management/server/routes/bulk_get.ts index 5a48f2f2affa7..c277653b6f350 100644 --- a/src/plugins/saved_objects_management/server/routes/get.ts +++ b/src/plugins/saved_objects_management/server/routes/bulk_get.ts @@ -11,34 +11,42 @@ import { IRouter } from 'src/core/server'; import { injectMetaAttributes } from '../lib'; import { ISavedObjectsManagement } from '../services'; -export const registerGetRoute = ( +export const registerBulkGetRoute = ( router: IRouter, managementServicePromise: Promise ) => { - router.get( + router.post( { - path: '/api/kibana/management/saved_objects/{type}/{id}', + path: '/api/kibana/management/saved_objects/_bulk_get', validate: { - params: schema.object({ - type: schema.string(), - id: schema.string(), - }), + body: schema.arrayOf( + schema.object({ + type: schema.string(), + id: schema.string(), + }) + ), }, }, router.handleLegacyErrors(async (context, req, res) => { - const { type, id } = req.params; const managementService = await managementServicePromise; const { getClient, typeRegistry } = context.core.savedObjects; - const includedHiddenTypes = [type].filter( - (entry) => typeRegistry.isHidden(entry) && typeRegistry.isImportableAndExportable(entry) + + const objects = req.body; + const uniqueTypes = objects.reduce((acc, { type }) => acc.add(type), new Set()); + const includedHiddenTypes = Array.from(uniqueTypes).filter( + (type) => typeRegistry.isHidden(type) && typeRegistry.isImportableAndExportable(type) ); const client = getClient({ includedHiddenTypes }); - const findResponse = await client.get(type, id); - - const enhancedSavedObject = injectMetaAttributes(findResponse, managementService); + const response = await client.bulkGet(objects); + const enhancedObjects = response.saved_objects.map((obj) => { + if (!obj.error) { + return injectMetaAttributes(obj, managementService); + } + return obj; + }); - return res.ok({ body: enhancedSavedObject }); + return res.ok({ body: enhancedObjects }); }) ); }; diff --git a/src/plugins/saved_objects_management/server/routes/index.test.ts b/src/plugins/saved_objects_management/server/routes/index.test.ts index 7fbc54cbaf556..3ec6afe1c0bbc 100644 --- a/src/plugins/saved_objects_management/server/routes/index.test.ts +++ b/src/plugins/saved_objects_management/server/routes/index.test.ts @@ -23,8 +23,8 @@ describe('registerRoutes', () => { }); expect(httpSetup.createRouter).toHaveBeenCalledTimes(1); - expect(router.get).toHaveBeenCalledTimes(4); - expect(router.post).toHaveBeenCalledTimes(2); + expect(router.get).toHaveBeenCalledTimes(3); + expect(router.post).toHaveBeenCalledTimes(3); expect(router.get).toHaveBeenCalledWith( expect.objectContaining({ @@ -32,9 +32,9 @@ describe('registerRoutes', () => { }), expect.any(Function) ); - expect(router.get).toHaveBeenCalledWith( + expect(router.post).toHaveBeenCalledWith( expect.objectContaining({ - path: '/api/kibana/management/saved_objects/{type}/{id}', + path: '/api/kibana/management/saved_objects/_bulk_get', }), expect.any(Function) ); diff --git a/src/plugins/saved_objects_management/server/routes/index.ts b/src/plugins/saved_objects_management/server/routes/index.ts index 44453fccf88ed..b5b461575604d 100644 --- a/src/plugins/saved_objects_management/server/routes/index.ts +++ b/src/plugins/saved_objects_management/server/routes/index.ts @@ -9,7 +9,7 @@ import { HttpServiceSetup } from 'src/core/server'; import { ISavedObjectsManagement } from '../services'; import { registerFindRoute } from './find'; -import { registerGetRoute } from './get'; +import { registerBulkGetRoute } from './bulk_get'; import { registerScrollForCountRoute } from './scroll_count'; import { registerScrollForExportRoute } from './scroll_export'; import { registerRelationshipsRoute } from './relationships'; @@ -23,7 +23,7 @@ interface RegisterRouteOptions { export function registerRoutes({ http, managementServicePromise }: RegisterRouteOptions) { const router = http.createRouter(); registerFindRoute(router, managementServicePromise); - registerGetRoute(router, managementServicePromise); + registerBulkGetRoute(router, managementServicePromise); registerScrollForCountRoute(router); registerScrollForExportRoute(router); registerRelationshipsRoute(router, managementServicePromise); diff --git a/src/plugins/spaces_oss/public/api.ts b/src/plugins/spaces_oss/public/api.ts index ddee9c0528ba1..b1b6a16958dbd 100644 --- a/src/plugins/spaces_oss/public/api.ts +++ b/src/plugins/spaces_oss/public/api.ts @@ -168,15 +168,19 @@ export interface ShareToSpaceFlyoutProps { */ behaviorContext?: 'within-space' | 'outside-space'; /** - * Optional handler that is called when the user has saved changes and there are spaces to be added to and/or removed from the object. If - * this is not defined, a default handler will be used that calls `/api/spaces/_update_objects_spaces` and displays a toast indicating - * what occurred. + * Optional handler that is called when the user has saved changes and there are spaces to be added to and/or removed from the object and + * its relatives. If this is not defined, a default handler will be used that calls `/api/spaces/_update_objects_spaces` and displays a + * toast indicating what occurred. */ - changeSpacesHandler?: (spacesToAdd: string[], spacesToRemove: string[]) => Promise; + changeSpacesHandler?: ( + objects: Array<{ type: string; id: string }>, + spacesToAdd: string[], + spacesToRemove: string[] + ) => Promise; /** - * Optional callback when the target object is updated. + * Optional callback when the target object and its relatives are updated. */ - onUpdate?: () => void; + onUpdate?: (updatedObjects: Array<{ type: string; id: string }>) => void; /** * Optional callback when the flyout is closed. */ @@ -288,4 +292,11 @@ export interface SpaceAvatarProps { * Default value is true. */ announceSpaceName?: boolean; + + /** + * Whether or not to render the avatar in a disabled state. + * + * Default value is false. + */ + isDisabled?: boolean; } diff --git a/src/plugins/telemetry/schema/oss_plugins.json b/src/plugins/telemetry/schema/oss_plugins.json index f8edfdcc5c364..56fc7697a4e07 100644 --- a/src/plugins/telemetry/schema/oss_plugins.json +++ b/src/plugins/telemetry/schema/oss_plugins.json @@ -7188,6 +7188,34 @@ } } } + }, + "legacyUrlAliases": { + "properties": { + "inactiveCount": { + "type": "long", + "_meta": { + "description": "Count of legacy URL aliases that are inactive; they are not disabled, but they have not been resolved." + } + }, + "activeCount": { + "type": "long", + "_meta": { + "description": "Count of legacy URL aliases that are active; they are not disabled, and they have been resolved at least once." + } + }, + "disabledCount": { + "type": "long", + "_meta": { + "description": "Count of legacy URL aliases that are disabled." + } + }, + "totalCount": { + "type": "long", + "_meta": { + "description": "Total count of legacy URL aliases." + } + } + } } } } @@ -7744,6 +7772,36 @@ "_meta": { "description": "How many times this API has been called without all types selected." } + }, + "savedObjectsRepository.resolvedOutcome.exactMatch": { + "type": "long", + "_meta": { + "description": "How many times a saved object has resolved with an exact match outcome." + } + }, + "savedObjectsRepository.resolvedOutcome.aliasMatch": { + "type": "long", + "_meta": { + "description": "How many times a saved object has resolved with an alias match outcome." + } + }, + "savedObjectsRepository.resolvedOutcome.conflict": { + "type": "long", + "_meta": { + "description": "How many times a saved object has resolved with a conflict outcome." + } + }, + "savedObjectsRepository.resolvedOutcome.notFound": { + "type": "long", + "_meta": { + "description": "How many times a saved object has resolved with a not found outcome." + } + }, + "savedObjectsRepository.resolvedOutcome.total": { + "type": "long", + "_meta": { + "description": "How many times a saved object has resolved with any of the four possible outcomes." + } } } }, diff --git a/test/api_integration/apis/saved_objects_management/bulk_get.ts b/test/api_integration/apis/saved_objects_management/bulk_get.ts new file mode 100644 index 0000000000000..35fa03a41770c --- /dev/null +++ b/test/api_integration/apis/saved_objects_management/bulk_get.ts @@ -0,0 +1,83 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import expect from '@kbn/expect'; +import type { Response } from 'supertest'; +import type { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const kibanaServer = getService('kibanaServer'); + + describe('_bulk_get', () => { + const URL = '/api/kibana/management/saved_objects/_bulk_get'; + const validObject = { type: 'visualization', id: 'dd7caf20-9efd-11e7-acb3-3dab96693fab' }; + const invalidObject = { type: 'wigwags', id: 'foo' }; + + before(() => + kibanaServer.importExport.load( + 'test/api_integration/fixtures/kbn_archiver/saved_objects/basic.json' + ) + ); + after(() => + kibanaServer.importExport.unload( + 'test/api_integration/fixtures/kbn_archiver/saved_objects/basic.json' + ) + ); + + function expectSuccess(index: number, { body }: Response) { + const { type, id, meta, error } = body[index]; + expect(type).to.eql(validObject.type); + expect(id).to.eql(validObject.id); + expect(meta).to.not.equal(undefined); + expect(error).to.equal(undefined); + } + + function expectBadRequest(index: number, { body }: Response) { + const { type, id, error } = body[index]; + expect(type).to.eql(invalidObject.type); + expect(id).to.eql(invalidObject.id); + expect(error).to.eql({ + message: `Unsupported saved object type: '${invalidObject.type}': Bad Request`, + statusCode: 400, + error: 'Bad Request', + }); + } + + it('should return 200 for object that exists and inject metadata', async () => + await supertest + .post(URL) + .send([validObject]) + .expect(200) + .then((response: Response) => { + expect(response.body).to.have.length(1); + expectSuccess(0, response); + })); + + it('should return error for invalid object type', async () => + await supertest + .post(URL) + .send([invalidObject]) + .expect(200) + .then((response: Response) => { + expect(response.body).to.have.length(1); + expectBadRequest(0, response); + })); + + it('should return mix of successes and errors', async () => + await supertest + .post(URL) + .send([validObject, invalidObject]) + .expect(200) + .then((response: Response) => { + expect(response.body).to.have.length(2); + expectSuccess(0, response); + expectBadRequest(1, response); + })); + }); +} diff --git a/test/api_integration/apis/saved_objects_management/get.ts b/test/api_integration/apis/saved_objects_management/get.ts deleted file mode 100644 index 3b49a28ca4022..0000000000000 --- a/test/api_integration/apis/saved_objects_management/get.ts +++ /dev/null @@ -1,47 +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 - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import expect from '@kbn/expect'; -import { Response } from 'supertest'; -import { FtrProviderContext } from '../../ftr_provider_context'; - -export default function ({ getService }: FtrProviderContext) { - const supertest = getService('supertest'); - const kibanaServer = getService('kibanaServer'); - - describe('get', () => { - const existingObject = 'visualization/dd7caf20-9efd-11e7-acb3-3dab96693fab'; - const nonexistentObject = 'wigwags/foo'; - - before(async () => { - await kibanaServer.importExport.load( - 'test/api_integration/fixtures/kbn_archiver/saved_objects/basic.json' - ); - }); - after(async () => { - await kibanaServer.importExport.unload( - 'test/api_integration/fixtures/kbn_archiver/saved_objects/basic.json' - ); - }); - - it('should return 200 for object that exists and inject metadata', async () => - await supertest - .get(`/api/kibana/management/saved_objects/${existingObject}`) - .expect(200) - .then((resp: Response) => { - const { body } = resp; - const { type, id, meta } = body; - expect(type).to.eql('visualization'); - expect(id).to.eql('dd7caf20-9efd-11e7-acb3-3dab96693fab'); - expect(meta).to.not.equal(undefined); - })); - - it('should return 404 for object that does not exist', async () => - await supertest.get(`/api/kibana/management/saved_objects/${nonexistentObject}`).expect(404)); - }); -} diff --git a/test/api_integration/apis/saved_objects_management/index.ts b/test/api_integration/apis/saved_objects_management/index.ts index 3af5699ca0458..208ded1d50706 100644 --- a/test/api_integration/apis/saved_objects_management/index.ts +++ b/test/api_integration/apis/saved_objects_management/index.ts @@ -11,7 +11,7 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ loadTestFile }: FtrProviderContext) { describe('saved objects management apis', () => { loadTestFile(require.resolve('./find')); - loadTestFile(require.resolve('./get')); + loadTestFile(require.resolve('./bulk_get')); loadTestFile(require.resolve('./relationships')); loadTestFile(require.resolve('./scroll_count')); }); diff --git a/test/plugin_functional/test_suites/saved_objects_management/bulk_get.ts b/test/plugin_functional/test_suites/saved_objects_management/bulk_get.ts new file mode 100644 index 0000000000000..b792df4244e60 --- /dev/null +++ b/test/plugin_functional/test_suites/saved_objects_management/bulk_get.ts @@ -0,0 +1,93 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import expect from '@kbn/expect'; +import type { Response } from 'supertest'; +import type { PluginFunctionalProviderContext } from '../../services'; + +export default function ({ getService }: PluginFunctionalProviderContext) { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + + describe('_bulk_get', () => { + describe('saved objects with hidden type', () => { + before(() => + esArchiver.load( + 'test/functional/fixtures/es_archiver/saved_objects_management/hidden_saved_objects' + ) + ); + after(() => + esArchiver.unload( + 'test/functional/fixtures/es_archiver/saved_objects_management/hidden_saved_objects' + ) + ); + const URL = '/api/kibana/management/saved_objects/_bulk_get'; + const hiddenTypeExportableImportable = { + type: 'test-hidden-importable-exportable', + id: 'ff3733a0-9fty-11e7-ahb3-3dcb94193fab', + }; + const hiddenTypeNonExportableImportable = { + type: 'test-hidden-non-importable-exportable', + id: 'op3767a1-9rcg-53u7-jkb3-3dnb74193awc', + }; + + function expectSuccess(index: number, { body }: Response) { + const { type, id, meta, error } = body[index]; + expect(type).to.eql(hiddenTypeExportableImportable.type); + expect(id).to.eql(hiddenTypeExportableImportable.id); + expect(meta).to.not.equal(undefined); + expect(error).to.equal(undefined); + } + + function expectBadRequest(index: number, { body }: Response) { + const { type, id, error } = body[index]; + expect(type).to.eql(hiddenTypeNonExportableImportable.type); + expect(id).to.eql(hiddenTypeNonExportableImportable.id); + expect(error).to.eql({ + message: `Unsupported saved object type: '${hiddenTypeNonExportableImportable.type}': Bad Request`, + statusCode: 400, + error: 'Bad Request', + }); + } + + it('should return 200 for hidden types that are importableAndExportable', async () => + await supertest + .post(URL) + .send([hiddenTypeExportableImportable]) + .set('kbn-xsrf', 'true') + .expect(200) + .then((response: Response) => { + expect(response.body).to.have.length(1); + expectSuccess(0, response); + })); + + it('should return error for hidden types that are not importableAndExportable', async () => + await supertest + .post(URL) + .send([hiddenTypeNonExportableImportable]) + .set('kbn-xsrf', 'true') + .expect(200) + .then((response: Response) => { + expect(response.body).to.have.length(1); + expectBadRequest(0, response); + })); + + it('should return mix of successes and errors', async () => + await supertest + .post(URL) + .send([hiddenTypeExportableImportable, hiddenTypeNonExportableImportable]) + .set('kbn-xsrf', 'true') + .expect(200) + .then((response: Response) => { + expect(response.body).to.have.length(2); + expectSuccess(0, response); + expectBadRequest(1, response); + })); + }); + }); +} diff --git a/test/plugin_functional/test_suites/saved_objects_management/get.ts b/test/plugin_functional/test_suites/saved_objects_management/get.ts deleted file mode 100644 index 6a69aa9146f3e..0000000000000 --- a/test/plugin_functional/test_suites/saved_objects_management/get.ts +++ /dev/null @@ -1,53 +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 - * 2.0 and the Server Side Public License, v 1; you may not use this file except - * in compliance with, at your election, the Elastic License 2.0 or the Server - * Side Public License, v 1. - */ - -import expect from '@kbn/expect'; -import { PluginFunctionalProviderContext } from '../../services'; - -export default function ({ getService }: PluginFunctionalProviderContext) { - const supertest = getService('supertest'); - const esArchiver = getService('esArchiver'); - - describe('get', () => { - describe('saved objects with hidden type', () => { - before(() => - esArchiver.load( - 'test/functional/fixtures/es_archiver/saved_objects_management/hidden_saved_objects' - ) - ); - after(() => - esArchiver.unload( - 'test/functional/fixtures/es_archiver/saved_objects_management/hidden_saved_objects' - ) - ); - const hiddenTypeExportableImportable = - 'test-hidden-importable-exportable/ff3733a0-9fty-11e7-ahb3-3dcb94193fab'; - const hiddenTypeNonExportableImportable = - 'test-hidden-non-importable-exportable/op3767a1-9rcg-53u7-jkb3-3dnb74193awc'; - - it('should return 200 for hidden types that are importableAndExportable', async () => - await supertest - .get(`/api/kibana/management/saved_objects/${hiddenTypeExportableImportable}`) - .set('kbn-xsrf', 'true') - .expect(200) - .then((resp) => { - const { body } = resp; - const { type, id, meta } = body; - expect(type).to.eql('test-hidden-importable-exportable'); - expect(id).to.eql('ff3733a0-9fty-11e7-ahb3-3dcb94193fab'); - expect(meta).to.not.equal(undefined); - })); - - it('should return 404 for hidden types that are not importableAndExportable', async () => - await supertest - .get(`/api/kibana/management/saved_objects/${hiddenTypeNonExportableImportable}`) - .set('kbn-xsrf', 'true') - .expect(404)); - }); - }); -} diff --git a/test/plugin_functional/test_suites/saved_objects_management/index.ts b/test/plugin_functional/test_suites/saved_objects_management/index.ts index edaa819e5ea58..03ac96b9a11f6 100644 --- a/test/plugin_functional/test_suites/saved_objects_management/index.ts +++ b/test/plugin_functional/test_suites/saved_objects_management/index.ts @@ -12,7 +12,7 @@ export default function ({ loadTestFile }: PluginFunctionalProviderContext) { describe('Saved Objects Management', function () { loadTestFile(require.resolve('./find')); loadTestFile(require.resolve('./scroll_count')); - loadTestFile(require.resolve('./get')); + loadTestFile(require.resolve('./bulk_get')); loadTestFile(require.resolve('./export_transform')); loadTestFile(require.resolve('./import_warnings')); loadTestFile(require.resolve('./hidden_types')); diff --git a/x-pack/plugins/ml/public/application/components/job_spaces_list/job_spaces_list.tsx b/x-pack/plugins/ml/public/application/components/job_spaces_list/job_spaces_list.tsx index 85d1301fee957..a405f0486430c 100644 --- a/x-pack/plugins/ml/public/application/components/job_spaces_list/job_spaces_list.tsx +++ b/x-pack/plugins/ml/public/application/components/job_spaces_list/job_spaces_list.tsx @@ -37,7 +37,11 @@ export const JobSpacesList: FC = ({ spacesApi, spaceIds, jobId, jobType, const [showFlyout, setShowFlyout] = useState(false); - async function changeSpacesHandler(spacesToAdd: string[], spacesToMaybeRemove: string[]) { + async function changeSpacesHandler( + _objects: Array<{ type: string; id: string }>, // this is ignored because ML jobs do not have references + spacesToAdd: string[], + spacesToMaybeRemove: string[] + ) { // If the user is adding the job to all current and future spaces, don't remove it from any specified spaces const spacesToRemove = spacesToAdd.includes(ALL_SPACES_ID) ? [] : spacesToMaybeRemove; diff --git a/x-pack/plugins/security/server/authorization/authorization_service.tsx b/x-pack/plugins/security/server/authorization/authorization_service.tsx index 0777c231ecd89..72f2c9843daec 100644 --- a/x-pack/plugins/security/server/authorization/authorization_service.tsx +++ b/x-pack/plugins/security/server/authorization/authorization_service.tsx @@ -94,6 +94,7 @@ export interface AuthorizationServiceSetup { actions: Actions; checkPrivilegesWithRequest: CheckPrivilegesWithRequest; checkPrivilegesDynamicallyWithRequest: CheckPrivilegesDynamicallyWithRequest; + checkSavedObjectsPrivilegesWithRequest: CheckSavedObjectsPrivilegesWithRequest; mode: AuthorizationMode; } diff --git a/x-pack/plugins/security/server/mocks.ts b/x-pack/plugins/security/server/mocks.ts index c30fcd8b69604..f1f858a40a465 100644 --- a/x-pack/plugins/security/server/mocks.ts +++ b/x-pack/plugins/security/server/mocks.ts @@ -23,6 +23,7 @@ function createSetupMock() { actions: mockAuthz.actions, checkPrivilegesWithRequest: mockAuthz.checkPrivilegesWithRequest, checkPrivilegesDynamicallyWithRequest: mockAuthz.checkPrivilegesDynamicallyWithRequest, + checkSavedObjectsPrivilegesWithRequest: mockAuthz.checkSavedObjectsPrivilegesWithRequest, mode: mockAuthz.mode, }, registerSpacesService: jest.fn(), @@ -42,6 +43,7 @@ function createStartMock() { actions: mockAuthz.actions, checkPrivilegesWithRequest: mockAuthz.checkPrivilegesWithRequest, checkPrivilegesDynamicallyWithRequest: mockAuthz.checkPrivilegesDynamicallyWithRequest, + checkSavedObjectsPrivilegesWithRequest: mockAuthz.checkSavedObjectsPrivilegesWithRequest, mode: mockAuthz.mode, }, }; diff --git a/x-pack/plugins/security/server/plugin.test.ts b/x-pack/plugins/security/server/plugin.test.ts index 574e37fdd1841..2d17e75527c6f 100644 --- a/x-pack/plugins/security/server/plugin.test.ts +++ b/x-pack/plugins/security/server/plugin.test.ts @@ -101,6 +101,7 @@ describe('Security Plugin', () => { }, "checkPrivilegesDynamicallyWithRequest": [Function], "checkPrivilegesWithRequest": [Function], + "checkSavedObjectsPrivilegesWithRequest": [Function], "mode": Object { "useRbacForRequest": [Function], }, @@ -171,6 +172,7 @@ describe('Security Plugin', () => { }, "checkPrivilegesDynamicallyWithRequest": [Function], "checkPrivilegesWithRequest": [Function], + "checkSavedObjectsPrivilegesWithRequest": [Function], "mode": Object { "useRbacForRequest": [Function], }, diff --git a/x-pack/plugins/security/server/plugin.ts b/x-pack/plugins/security/server/plugin.ts index d0403c0f170ea..1873ca42324c0 100644 --- a/x-pack/plugins/security/server/plugin.ts +++ b/x-pack/plugins/security/server/plugin.ts @@ -328,6 +328,8 @@ export class SecurityPlugin checkPrivilegesWithRequest: this.authorizationSetup.checkPrivilegesWithRequest, checkPrivilegesDynamicallyWithRequest: this.authorizationSetup .checkPrivilegesDynamicallyWithRequest, + checkSavedObjectsPrivilegesWithRequest: this.authorizationSetup + .checkSavedObjectsPrivilegesWithRequest, mode: this.authorizationSetup.mode, }, @@ -386,6 +388,8 @@ export class SecurityPlugin checkPrivilegesWithRequest: this.authorizationSetup!.checkPrivilegesWithRequest, checkPrivilegesDynamicallyWithRequest: this.authorizationSetup! .checkPrivilegesDynamicallyWithRequest, + checkSavedObjectsPrivilegesWithRequest: this.authorizationSetup! + .checkSavedObjectsPrivilegesWithRequest, mode: this.authorizationSetup!.mode, }, }); diff --git a/x-pack/plugins/security/server/saved_objects/ensure_authorized.test.ts b/x-pack/plugins/security/server/saved_objects/ensure_authorized.test.ts index 531b547a1f275..b9ab7cef7f15b 100644 --- a/x-pack/plugins/security/server/saved_objects/ensure_authorized.test.ts +++ b/x-pack/plugins/security/server/saved_objects/ensure_authorized.test.ts @@ -11,7 +11,11 @@ import type { CheckSavedObjectsPrivileges } from '../authorization'; import { Actions } from '../authorization'; import type { CheckPrivilegesResponse } from '../authorization/types'; import type { EnsureAuthorizedResult } from './ensure_authorized'; -import { ensureAuthorized, getEnsureAuthorizedActionResult } from './ensure_authorized'; +import { + ensureAuthorized, + getEnsureAuthorizedActionResult, + isAuthorizedForObjectInAllSpaces, +} from './ensure_authorized'; describe('ensureAuthorized', () => { function setupDependencies() { @@ -224,3 +228,46 @@ describe('getEnsureAuthorizedActionResult', () => { expect(result).toEqual({ authorizedSpaces: [] }); }); }); + +describe('isAuthorizedForObjectInAllSpaces', () => { + const typeActionMap: EnsureAuthorizedResult<'action'>['typeActionMap'] = new Map([ + ['type-1', { action: { authorizedSpaces: [], isGloballyAuthorized: true } }], + ['type-2', { action: { authorizedSpaces: ['space-1', 'space-2'] } }], + ['type-3', { action: { authorizedSpaces: [] } }], + // type-4 is not present in the results + ]); + + test('returns true if the user is authorized for the type in the given spaces', () => { + const type1Result = isAuthorizedForObjectInAllSpaces('type-1', 'action', typeActionMap, [ + 'space-1', + 'space-2', + 'space-3', + ]); + expect(type1Result).toBe(true); + + const type2Result = isAuthorizedForObjectInAllSpaces('type-2', 'action', typeActionMap, [ + 'space-1', + 'space-2', + ]); + expect(type2Result).toBe(true); + }); + + test('returns false if the user is not authorized for the type in the given spaces', () => { + const type2Result = isAuthorizedForObjectInAllSpaces('type-2', 'action', typeActionMap, [ + 'space-1', + 'space-2', + 'space-3', // the user is not authorized for this type and action in space-3 + ]); + expect(type2Result).toBe(false); + + const type3Result = isAuthorizedForObjectInAllSpaces('type-3', 'action', typeActionMap, [ + 'space-1', // the user is not authorized for this type and action in any space + ]); + expect(type3Result).toBe(false); + + const type4Result = isAuthorizedForObjectInAllSpaces('type-4', 'action', typeActionMap, [ + 'space-1', // the user is not authorized for this type and action in any space + ]); + expect(type4Result).toBe(false); + }); +}); diff --git a/x-pack/plugins/security/server/saved_objects/ensure_authorized.ts b/x-pack/plugins/security/server/saved_objects/ensure_authorized.ts index 0ce7b5f78f13b..c3457e75f9644 100644 --- a/x-pack/plugins/security/server/saved_objects/ensure_authorized.ts +++ b/x-pack/plugins/security/server/saved_objects/ensure_authorized.ts @@ -135,6 +135,29 @@ export function getEnsureAuthorizedActionResult( return record[action] ?? { authorizedSpaces: [] }; } +/** + * Helper function that, given an `EnsureAuthorizedResult`, ensures that the user is authorized to perform a given action for the given + * object type in the given spaces. + * + * @param {string} objectType the object type to check. + * @param {T} action the action to check. + * @param {EnsureAuthorizedResult['typeActionMap']} typeActionMap the typeActionMap from an EnsureAuthorizedResult. + * @param {string[]} spacesToAuthorizeFor the spaces to check. + */ +export function isAuthorizedForObjectInAllSpaces( + objectType: string, + action: T, + typeActionMap: EnsureAuthorizedResult['typeActionMap'], + spacesToAuthorizeFor: string[] +) { + const actionResult = getEnsureAuthorizedActionResult(objectType, action, typeActionMap); + const { authorizedSpaces, isGloballyAuthorized } = actionResult; + const authorizedSpacesSet = new Set(authorizedSpaces); + return ( + isGloballyAuthorized || spacesToAuthorizeFor.every((space) => authorizedSpacesSet.has(space)) + ); +} + async function checkPrivileges( deps: EnsureAuthorizedDependencies, actions: string | string[], diff --git a/x-pack/plugins/security/server/saved_objects/index.ts b/x-pack/plugins/security/server/saved_objects/index.ts index 364f639e9e9a3..b291fa86bbf56 100644 --- a/x-pack/plugins/security/server/saved_objects/index.ts +++ b/x-pack/plugins/security/server/saved_objects/index.ts @@ -24,6 +24,19 @@ interface SetupSavedObjectsParams { getSpacesService(): SpacesService | undefined; } +export type { + EnsureAuthorizedDependencies, + EnsureAuthorizedOptions, + EnsureAuthorizedResult, + EnsureAuthorizedActionResult, +} from './ensure_authorized'; + +export { + ensureAuthorized, + getEnsureAuthorizedActionResult, + isAuthorizedForObjectInAllSpaces, +} from './ensure_authorized'; + export function setupSavedObjects({ legacyAuditLogger, audit, 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 ef3dcac4c064b..a3bd215211983 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 @@ -41,7 +41,11 @@ import type { EnsureAuthorizedOptions, EnsureAuthorizedResult, } from './ensure_authorized'; -import { ensureAuthorized, getEnsureAuthorizedActionResult } from './ensure_authorized'; +import { + ensureAuthorized, + getEnsureAuthorizedActionResult, + isAuthorizedForObjectInAllSpaces, +} from './ensure_authorized'; interface SecureSavedObjectsClientWrapperOptions { actions: Actions; @@ -1071,20 +1075,6 @@ function namespaceComparator(a: string, b: string) { return A > B ? 1 : A < B ? -1 : 0; } -function isAuthorizedForObjectInAllSpaces( - objectType: string, - action: T, - typeActionMap: EnsureAuthorizedResult['typeActionMap'], - spacesToAuthorizeFor: string[] -) { - const actionResult = getEnsureAuthorizedActionResult(objectType, action, typeActionMap); - const { authorizedSpaces, isGloballyAuthorized } = actionResult; - const authorizedSpacesSet = new Set(authorizedSpaces); - return ( - isGloballyAuthorized || spacesToAuthorizeFor.every((space) => authorizedSpacesSet.has(space)) - ); -} - function getRedactedSpaces( objectType: string, action: T, diff --git a/x-pack/plugins/security/server/spaces/secure_spaces_client_wrapper.test.mocks.ts b/x-pack/plugins/security/server/spaces/secure_spaces_client_wrapper.test.mocks.ts new file mode 100644 index 0000000000000..02bd9971f28b8 --- /dev/null +++ b/x-pack/plugins/security/server/spaces/secure_spaces_client_wrapper.test.mocks.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { ensureAuthorized } from '../saved_objects'; + +export const mockEnsureAuthorized = jest.fn() as jest.MockedFunction; + +jest.mock('../saved_objects', () => { + return { + ...jest.requireActual('../saved_objects'), + ensureAuthorized: mockEnsureAuthorized, + }; +}); diff --git a/x-pack/plugins/security/server/spaces/secure_spaces_client_wrapper.test.ts b/x-pack/plugins/security/server/spaces/secure_spaces_client_wrapper.test.ts index bc7cb727edd80..20a524251bd4a 100644 --- a/x-pack/plugins/security/server/spaces/secure_spaces_client_wrapper.test.ts +++ b/x-pack/plugins/security/server/spaces/secure_spaces_client_wrapper.test.ts @@ -5,15 +5,17 @@ * 2.0. */ +import { mockEnsureAuthorized } from './secure_spaces_client_wrapper.test.mocks'; + import { deepFreeze } from '@kbn/std'; -import type { EcsEventOutcome } from 'src/core/server'; +import type { EcsEventOutcome, SavedObjectsClientContract } from 'src/core/server'; import { SavedObjectsErrorHelpers } from 'src/core/server'; import { httpServerMock } from 'src/core/server/mocks'; -import type { GetAllSpacesPurpose, Space } from '../../../spaces/server'; +import type { GetAllSpacesPurpose, LegacyUrlAliasTarget, Space } from '../../../spaces/server'; import { spacesClientMock } from '../../../spaces/server/mocks'; import type { AuditEvent, AuditLogger } from '../audit'; -import { SpaceAuditAction } from '../audit'; +import { SavedObjectAction, SpaceAuditAction } from '../audit'; import { auditServiceMock } from '../audit/index.mock'; import type { AuthorizationServiceSetup, @@ -22,7 +24,11 @@ import type { import { authorizationMock } from '../authorization/index.mock'; import type { CheckPrivilegesResponse } from '../authorization/types'; import type { LegacySpacesAuditLogger } from './legacy_audit_logger'; -import { SecureSpacesClientWrapper } from './secure_spaces_client_wrapper'; +import { + getAliasId, + LEGACY_URL_ALIAS_TYPE, + SecureSpacesClientWrapper, +} from './secure_spaces_client_wrapper'; interface Opts { securityEnabled?: boolean; @@ -71,12 +77,20 @@ const setup = ({ securityEnabled = false }: Opts = {}) => { const auditLogger = auditServiceMock.create().asScoped(httpServerMock.createKibanaRequest()); const request = httpServerMock.createKibanaRequest(); + + const forbiddenError = new Error('Mock ForbiddenError'); + const errors = ({ + decorateForbiddenError: jest.fn().mockReturnValue(forbiddenError), + // other errors exist but are not needed for these test cases + } as unknown) as jest.Mocked; + const wrapper = new SecureSpacesClientWrapper( baseClient, request, authorization, auditLogger, - legacyAuditLogger + legacyAuditLogger, + errors ); return { authorization, @@ -85,6 +99,7 @@ const setup = ({ securityEnabled = false }: Opts = {}) => { baseClient, auditLogger, legacyAuditLogger, + forbiddenError, }; }; @@ -160,6 +175,10 @@ const expectAuditEvent = ( ); }; +beforeEach(() => { + mockEnsureAuthorized.mockReset(); +}); + describe('SecureSpacesClientWrapper', () => { describe('#getAll', () => { const savedObjects = [ @@ -747,4 +766,99 @@ describe('SecureSpacesClientWrapper', () => { }); }); }); + + describe('#disableLegacyUrlAliases', () => { + const alias1 = { targetSpace: 'space-1', targetType: 'type-1', sourceId: 'id' }; + const alias2 = { targetSpace: 'space-2', targetType: 'type-2', sourceId: 'id' }; + + function expectAuditEvents( + auditLogger: AuditLogger, + aliases: LegacyUrlAliasTarget[], + action: EcsEventOutcome + ) { + aliases.forEach((alias) => { + expectAuditEvent(auditLogger, SavedObjectAction.UPDATE, action, { + type: LEGACY_URL_ALIAS_TYPE, + id: getAliasId(alias), + }); + }); + } + + function expectAuthorizationCheck(targetTypes: string[], targetSpaces: string[]) { + expect(mockEnsureAuthorized).toHaveBeenCalledTimes(1); + expect(mockEnsureAuthorized).toHaveBeenCalledWith( + expect.any(Object), // dependencies + targetTypes, // unique types of the alias targets + ['bulk_update'], // actions + targetSpaces, // unique spaces of the alias targets + { requireFullAuthorization: false } + ); + } + + describe('when security is not enabled', () => { + const securityEnabled = false; + + it('delegates to base client without checking authorization', async () => { + const { wrapper, baseClient, auditLogger } = setup({ securityEnabled }); + const aliases = [alias1]; + await wrapper.disableLegacyUrlAliases(aliases); + + expect(mockEnsureAuthorized).not.toHaveBeenCalled(); + expectAuditEvents(auditLogger, aliases, 'unknown'); + expect(baseClient.disableLegacyUrlAliases).toHaveBeenCalledTimes(1); + expect(baseClient.disableLegacyUrlAliases).toHaveBeenCalledWith(aliases); + }); + }); + + describe('when security is enabled', () => { + const securityEnabled = true; + + it('re-throws the error if the authorization check fails', async () => { + const error = new Error('Oh no!'); + mockEnsureAuthorized.mockRejectedValue(error); + const { wrapper, baseClient, auditLogger } = setup({ securityEnabled }); + const aliases = [alias1, alias2]; + await expect(() => wrapper.disableLegacyUrlAliases(aliases)).rejects.toThrow(error); + + expectAuthorizationCheck(['type-1', 'type-2'], ['space-1', 'space-2']); + expectAuditEvents(auditLogger, aliases, 'failure'); + expect(baseClient.disableLegacyUrlAliases).not.toHaveBeenCalled(); + }); + + it('throws a forbidden error when unauthorized', async () => { + mockEnsureAuthorized.mockResolvedValue({ + status: 'partially_authorized', + typeActionMap: new Map() + .set('type-1', { bulk_update: { authorizedSpaces: ['space-1'] } }) + .set('type-2', { bulk_update: { authorizedSpaces: ['space-1'] } }), // the user is not authorized to bulkUpdate type-2 in space-2, so this will throw a forbidden error + }); + const { wrapper, baseClient, auditLogger, forbiddenError } = setup({ securityEnabled }); + const aliases = [alias1, alias2]; + await expect(() => wrapper.disableLegacyUrlAliases(aliases)).rejects.toThrow( + forbiddenError + ); + + expectAuthorizationCheck(['type-1', 'type-2'], ['space-1', 'space-2']); + expectAuditEvents(auditLogger, aliases, 'failure'); + expect(baseClient.disableLegacyUrlAliases).not.toHaveBeenCalled(); + }); + + it('updates the legacy URL aliases when authorized', async () => { + mockEnsureAuthorized.mockResolvedValue({ + status: 'partially_authorized', + typeActionMap: new Map() + .set('type-1', { bulk_update: { authorizedSpaces: ['space-1'] } }) + .set('type-2', { bulk_update: { authorizedSpaces: ['space-2'] } }), + }); + const { wrapper, baseClient, auditLogger } = setup({ securityEnabled }); + const aliases = [alias1, alias2]; + await wrapper.disableLegacyUrlAliases(aliases); + + expectAuthorizationCheck(['type-1', 'type-2'], ['space-1', 'space-2']); + expectAuditEvents(auditLogger, aliases, 'unknown'); + expect(baseClient.disableLegacyUrlAliases).toHaveBeenCalledTimes(1); + expect(baseClient.disableLegacyUrlAliases).toHaveBeenCalledWith(aliases); + }); + }); + }); }); diff --git a/x-pack/plugins/security/server/spaces/secure_spaces_client_wrapper.ts b/x-pack/plugins/security/server/spaces/secure_spaces_client_wrapper.ts index ab882570ac630..f3d66ac0381eb 100644 --- a/x-pack/plugins/security/server/spaces/secure_spaces_client_wrapper.ts +++ b/x-pack/plugins/security/server/spaces/secure_spaces_client_wrapper.ts @@ -7,19 +7,22 @@ import Boom from '@hapi/boom'; -import type { KibanaRequest } from 'src/core/server'; +import type { KibanaRequest, SavedObjectsClientContract } from 'src/core/server'; import type { GetAllSpacesOptions, GetAllSpacesPurpose, GetSpaceResult, ISpacesClient, + LegacyUrlAliasTarget, Space, } from '../../../spaces/server'; import type { AuditLogger } from '../audit'; -import { SpaceAuditAction, spaceAuditEvent } from '../audit'; +import { SavedObjectAction, savedObjectEvent, SpaceAuditAction, spaceAuditEvent } from '../audit'; import type { AuthorizationServiceSetup } from '../authorization'; import type { SecurityPluginSetup } from '../plugin'; +import type { EnsureAuthorizedDependencies, EnsureAuthorizedOptions } from '../saved_objects'; +import { ensureAuthorized, isAuthorizedForObjectInAllSpaces } from '../saved_objects'; import type { LegacySpacesAuditLogger } from './legacy_audit_logger'; const PURPOSE_PRIVILEGE_MAP: Record< @@ -38,6 +41,9 @@ const PURPOSE_PRIVILEGE_MAP: Record< ], }; +/** @internal */ +export const LEGACY_URL_ALIAS_TYPE = 'legacy-url-alias'; + export class SecureSpacesClientWrapper implements ISpacesClient { private readonly useRbac = this.authorization.mode.useRbacForRequest(this.request); @@ -46,7 +52,8 @@ export class SecureSpacesClientWrapper implements ISpacesClient { private readonly request: KibanaRequest, private readonly authorization: AuthorizationServiceSetup, private readonly auditLogger: AuditLogger, - private readonly legacyAuditLogger: LegacySpacesAuditLogger + private readonly legacyAuditLogger: LegacySpacesAuditLogger, + private readonly errors: SavedObjectsClientContract['errors'] ) {} public async getAll({ @@ -277,6 +284,85 @@ export class SecureSpacesClientWrapper implements ISpacesClient { return this.spacesClient.delete(id); } + public async disableLegacyUrlAliases(aliases: LegacyUrlAliasTarget[]) { + if (this.useRbac) { + try { + const [uniqueSpaces, uniqueTypes, typesAndSpacesMap] = aliases.reduce( + ([spaces, types, typesAndSpaces], { targetSpace, targetType }) => { + const spacesForType = typesAndSpaces.get(targetType) ?? new Set(); + return [ + spaces.add(targetSpace), + types.add(targetType), + typesAndSpaces.set(targetType, spacesForType.add(targetSpace)), + ]; + }, + [new Set(), new Set(), new Map>()] + ); + + const action = 'bulk_update'; + const { typeActionMap } = await this.ensureAuthorizedForSavedObjects( + Array.from(uniqueTypes), + [action], + Array.from(uniqueSpaces), + { requireFullAuthorization: false } + ); + const unauthorizedTypes = new Set(); + for (const type of uniqueTypes) { + const spaces = Array.from(typesAndSpacesMap.get(type)!); + if (!isAuthorizedForObjectInAllSpaces(type, action, typeActionMap, spaces)) { + unauthorizedTypes.add(type); + } + } + if (unauthorizedTypes.size > 0) { + const targetTypes = Array.from(unauthorizedTypes).sort().join(','); + const msg = `Unable to disable aliases for ${targetTypes}`; + throw this.errors.decorateForbiddenError(new Error(msg)); + } + } catch (error) { + aliases.forEach((alias) => { + const id = getAliasId(alias); + this.auditLogger.log( + savedObjectEvent({ + action: SavedObjectAction.UPDATE, + savedObject: { type: LEGACY_URL_ALIAS_TYPE, id }, + error, + }) + ); + }); + throw error; + } + } + + aliases.forEach((alias) => { + const id = getAliasId(alias); + this.auditLogger.log( + savedObjectEvent({ + action: SavedObjectAction.UPDATE, + outcome: 'unknown', + savedObject: { type: LEGACY_URL_ALIAS_TYPE, id }, + }) + ); + }); + + return this.spacesClient.disableLegacyUrlAliases(aliases); + } + + private async ensureAuthorizedForSavedObjects( + types: string[], + actions: T[], + namespaces: string[], + options?: EnsureAuthorizedOptions + ) { + const ensureAuthorizedDependencies: EnsureAuthorizedDependencies = { + actions: this.authorization.actions, + errors: this.errors, + checkSavedObjectsPrivilegesAsCurrentUser: this.authorization.checkSavedObjectsPrivilegesWithRequest( + this.request + ), + }; + return ensureAuthorized(ensureAuthorizedDependencies, types, actions, namespaces, options); + } + private async ensureAuthorizedGlobally(action: string, method: string, forbiddenMessage: string) { const checkPrivileges = this.authorization.checkPrivilegesWithRequest(this.request); const { username, hasAllRequested } = await checkPrivileges.globally({ kibana: action }); @@ -312,3 +398,8 @@ export class SecureSpacesClientWrapper implements ISpacesClient { return value !== null; } } + +/** @internal This is only exported for testing purposes. */ +export function getAliasId({ targetSpace, targetType, sourceId }: LegacyUrlAliasTarget) { + return `${targetSpace}:${targetType}:${sourceId}`; +} diff --git a/x-pack/plugins/security/server/spaces/setup_spaces_client.ts b/x-pack/plugins/security/server/spaces/setup_spaces_client.ts index 2344d3e8c69d6..b518a1cff2c0f 100644 --- a/x-pack/plugins/security/server/spaces/setup_spaces_client.ts +++ b/x-pack/plugins/security/server/spaces/setup_spaces_client.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { SavedObjectsClient } from '../../../../../src/core/server'; import type { SpacesPluginSetup } from '../../../spaces/server'; import type { AuditServiceSetup } from '../audit'; import type { AuthorizationServiceSetup } from '../authorization'; @@ -39,7 +40,8 @@ export const setupSpacesClient = ({ audit, authz, spaces }: Deps) => { request, authz, audit.asScoped(request), - spacesAuditLogger + spacesAuditLogger, + SavedObjectsClient.errors ) ); }; diff --git a/x-pack/plugins/spaces/common/index.ts b/x-pack/plugins/spaces/common/index.ts index 9935d8055ec30..003a0c068a166 100644 --- a/x-pack/plugins/spaces/common/index.ts +++ b/x-pack/plugins/spaces/common/index.ts @@ -8,4 +8,9 @@ export { isReservedSpace } from './is_reserved_space'; export { MAX_SPACE_INITIALS, SPACE_SEARCH_COUNT_THRESHOLD, ENTER_SPACE_PATH } from './constants'; export { addSpaceIdToPath, getSpaceIdFromPath } from './lib/spaces_url_parser'; -export type { GetAllSpacesOptions, GetAllSpacesPurpose, GetSpaceResult } from './types'; +export type { + GetAllSpacesOptions, + GetAllSpacesPurpose, + GetSpaceResult, + LegacyUrlAliasTarget, +} from './types'; diff --git a/x-pack/plugins/spaces/common/types.ts b/x-pack/plugins/spaces/common/types.ts index 866d29bf64d5b..55bd1c137f8cf 100644 --- a/x-pack/plugins/spaces/common/types.ts +++ b/x-pack/plugins/spaces/common/types.ts @@ -50,3 +50,21 @@ export interface GetSpaceResult extends Space { */ authorizedPurposes?: Record; } + +/** + * Client interface for interacting with legacy URL aliases. + */ +export interface LegacyUrlAliasTarget { + /** + * The namespace that the object existed in when it was converted. + */ + targetSpace: string; + /** + * The type of the object when it was converted. + */ + targetType: string; + /** + * The original ID of the object, before it was converted. + */ + sourceId: string; +} diff --git a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/selectable_spaces_control.tsx b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/selectable_spaces_control.tsx index e1ecc06935791..2f96646844a35 100644 --- a/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/selectable_spaces_control.tsx +++ b/x-pack/plugins/spaces/public/copy_saved_objects_to_space/components/selectable_spaces_control.tsx @@ -14,6 +14,7 @@ import React, { lazy, Suspense } from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import type { Space } from 'src/plugins/spaces_oss/common'; +import { SPACE_SEARCH_COUNT_THRESHOLD } from '../../../common'; import { getSpaceAvatarComponent } from '../../space_avatar'; // No need to wrap LazySpaceAvatar in an error boundary, because it is one of the first chunks loaded when opening Kibana. @@ -83,7 +84,7 @@ export const SelectableSpacesControl = (props: Props) => { className: 'spcCopyToSpace__spacesList', 'data-test-subj': 'cts-form-space-selector', }} - searchable={options.length > 6} + searchable={options.length > SPACE_SEARCH_COUNT_THRESHOLD} > {(list, search) => { return ( diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/alias_table.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/alias_table.tsx new file mode 100644 index 0000000000000..2a2f2470e0199 --- /dev/null +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/alias_table.tsx @@ -0,0 +1,108 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { EuiTableComputedColumnType, Pagination } from '@elastic/eui'; +import { + EuiCallOut, + EuiFlexItem, + EuiInMemoryTable, + EuiLoadingSpinner, + EuiSpacer, +} from '@elastic/eui'; +import type { FunctionComponent } from 'react'; +import React, { lazy, Suspense, useMemo, useState } from 'react'; + +import { FormattedMessage } from '@kbn/i18n/react'; + +import { getSpaceAvatarComponent } from '../../space_avatar'; +import type { ShareToSpaceTarget } from '../../types'; +import type { InternalLegacyUrlAliasTarget } from './types'; + +// No need to wrap LazySpaceAvatar in an error boundary, because it is one of the first chunks loaded when opening Kibana. +const LazySpaceAvatar = lazy(() => + getSpaceAvatarComponent().then((component) => ({ default: component })) +); + +interface Props { + spaces: ShareToSpaceTarget[]; + aliasesToDisable: InternalLegacyUrlAliasTarget[]; +} + +export const AliasTable: FunctionComponent = ({ spaces, aliasesToDisable }) => { + const [pageIndex, setPageIndex] = useState(0); + const [pageSize, setPageSize] = useState(5); + + const spacesMap = useMemo( + () => + spaces.reduce( + (acc, space) => acc.set(space.id, space), + new Map() + ), + [spaces] + ); + const filteredAliasesToDisable = useMemo( + () => aliasesToDisable.filter(({ spaceExists }) => spaceExists), + [aliasesToDisable] + ); + const aliasesToDisableCount = filteredAliasesToDisable.length; + const pagination: Pagination = { + pageIndex, + pageSize, + totalItemCount: aliasesToDisableCount, + pageSizeOptions: [5, 10, 15, 20], + }; + + return ( + <> + + } + color="warning" + > + + + + + + + }> + { + const space = spacesMap.get(targetSpace)!; // it's safe to use ! here because we filtered only for aliases that are in spaces that exist + return ; // the whole table is wrapped in a Suspense + }, + sortable: ({ targetSpace }) => targetSpace, + } as EuiTableComputedColumnType, + ]} + sorting={true} + pagination={pagination} + onTableChange={({ page: { index, size } }) => { + setPageIndex(index); + setPageSize(size); + }} + tableLayout="auto" + /> + + + + ); +}; diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/relatives_footer.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/relatives_footer.tsx new file mode 100644 index 0000000000000..ea3f29724e0d5 --- /dev/null +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/relatives_footer.tsx @@ -0,0 +1,46 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiHorizontalRule, EuiText } from '@elastic/eui'; +import React, { useMemo } from 'react'; + +import { FormattedMessage } from '@kbn/i18n/react'; +import type { SavedObjectReferenceWithContext } from 'src/core/public'; +import type { ShareToSpaceSavedObjectTarget } from 'src/plugins/spaces_oss/public'; + +interface Props { + savedObjectTarget: ShareToSpaceSavedObjectTarget; + referenceGraph: SavedObjectReferenceWithContext[]; + isDisabled: boolean; +} + +export const RelativesFooter = (props: Props) => { + const { savedObjectTarget, referenceGraph, isDisabled } = props; + + const relativesCount = useMemo(() => { + const { type, id } = savedObjectTarget; + return referenceGraph.filter( + (x) => (x.type !== type || x.id !== id) && x.spaces.length > 0 && !x.isMissing + ).length; + }, [savedObjectTarget, referenceGraph]); + + if (relativesCount > 0) { + return ( + <> + + + + + + ); + } + return null; +}; 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 876c8d027b2b4..fad819d35e18a 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 @@ -17,7 +17,6 @@ import { EuiLink, EuiLoadingSpinner, EuiSelectable, - EuiSpacer, EuiText, } from '@elastic/eui'; import React, { lazy, Suspense } from 'react'; @@ -25,6 +24,7 @@ import React, { lazy, Suspense } from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; +import { SPACE_SEARCH_COUNT_THRESHOLD } from '../../../common'; import { ALL_SPACES_ID, UNKNOWN_SPACE } from '../../../common/constants'; import { DocumentationLinksService } from '../../lib'; import { getSpaceAvatarComponent } from '../../space_avatar'; @@ -106,10 +106,14 @@ export const SelectableSpacesControl = (props: Props) => { .sort(createSpacesComparator(activeSpaceId)) .map((space) => { const checked = selectedSpaceIds.includes(space.id); - const additionalProps = getAdditionalProps(space, activeSpaceId, checked); + const { isAvatarDisabled, ...additionalProps } = getAdditionalProps( + space, + activeSpaceId, + checked + ); return { label: space.name, - prepend: , // wrapped in a Suspense below + prepend: , // wrapped in a Suspense below checked: checked ? 'on' : undefined, ['data-space-id']: space.id, ['data-test-subj']: `sts-space-selector-row-${space.id}`, @@ -140,8 +144,7 @@ export const SelectableSpacesControl = (props: Props) => { docLinks! ).getKibanaPrivilegesDocUrl(); return ( - <> - + { }} /> - + ); }; const getNoSpacesAvailable = () => { if (enableCreateNewSpaceLink && spaces.length < 2) { - return ; + return ( + + + + ); } return null; }; @@ -188,46 +195,52 @@ export const SelectableSpacesControl = (props: Props) => { ); const hiddenSpaces = hiddenCount ? {hiddenSpacesLabel} : null; return ( - - - {selectedSpacesLabel} - - {hiddenSpaces} - - } - fullWidth - > - <> - }> - 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={options.length > 6} - > - {(list, search) => { - return ( - <> - {search} - {list} - - ); - }} - - + <> + + + {selectedSpacesLabel} + + {hiddenSpaces} + + } + fullWidth + > + <> + + + + + }> + updateSelectedSpaces(newOptions as SpaceOption[])} + listProps={{ + bordered: true, + rowHeight: ROW_HEIGHT, + className: 'spcShareToSpace__spacesList', + 'data-test-subj': 'sts-form-space-selector', + }} + height="full" + searchable={options.length > SPACE_SEARCH_COUNT_THRESHOLD} + > + {(list, search) => { + return ( + <> + {search} + {list} + + ); + }} + + + {getUnknownSpacesLabel()} {getNoSpacesAvailable()} - - + + ); }; @@ -260,8 +273,10 @@ function getAdditionalProps( if (space.isFeatureDisabled) { return { append: APPEND_FEATURE_IS_DISABLED, + isAvatarDisabled: true, }; } + return {}; } /** 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 deleted file mode 100644 index 3baa21f68d4f3..0000000000000 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_mode_control.scss +++ /dev/null @@ -1,3 +0,0 @@ -.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 index df8d72f7a59de..7151f72583d6a 100644 --- 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 @@ -5,11 +5,9 @@ * 2.0. */ -import './share_mode_control.scss'; - import { + EuiButtonGroup, EuiCallOut, - EuiCheckableCard, EuiFlexGroup, EuiFlexItem, EuiIconTip, @@ -40,36 +38,27 @@ interface Props { enableSpaceAgnosticBehavior: boolean; } -function createLabel({ - title, - text, - disabled, - tooltip, -}: { - title: string; - text: string; - disabled: boolean; - tooltip?: string; -}) { - return ( - <> - - - {title} - - {tooltip && ( - - - - )} - - - - {text} - - - ); -} +const buttonGroupLegend = i18n.translate( + 'xpack.spaces.shareToSpace.shareModeControl.buttonGroupLegend', + { defaultMessage: 'Choose how this is shared' } +); + +const shareToAllSpacesId = 'shareToAllSpacesId'; +const shareToAllSpacesButtonLabel = i18n.translate( + 'xpack.spaces.shareToSpace.shareModeControl.shareToAllSpaces.buttonLabel', + { defaultMessage: 'All spaces' } +); + +const shareToExplicitSpacesId = 'shareToExplicitSpacesId'; +const shareToExplicitSpacesButtonLabel = i18n.translate( + 'xpack.spaces.shareToSpace.shareModeControl.shareToExplicitSpaces.buttonLabel', + { defaultMessage: 'Select spaces' } +); + +const cannotChangeTooltip = i18n.translate( + 'xpack.spaces.shareToSpace.shareModeControl.shareToAllSpaces.cannotChangeTooltip', + { defaultMessage: 'You need additional privileges to change this option.' } +); export const ShareModeControl = (props: Props) => { const { @@ -90,50 +79,9 @@ export const ShareModeControl = (props: Props) => { const { selectedSpaceIds } = shareOptions; const isGlobalControlChecked = selectedSpaceIds.includes(ALL_SPACES_ID); - const shareToAllSpaces = { - id: 'shareToAllSpaces', - title: i18n.translate('xpack.spaces.shareToSpace.shareModeControl.shareToAllSpaces.title', { - defaultMessage: 'All spaces', - }), - text: i18n.translate('xpack.spaces.shareToSpace.shareModeControl.shareToAllSpaces.text', { - defaultMessage: 'Make {objectNoun} available in all current and future spaces.', - values: { objectNoun }, - }), - ...(!canShareToAllSpaces && { - tooltip: isGlobalControlChecked - ? i18n.translate( - 'xpack.spaces.shareToSpace.shareModeControl.shareToAllSpaces.cannotUncheckTooltip', - { defaultMessage: 'You need additional privileges to change this option.' } - ) - : i18n.translate( - 'xpack.spaces.shareToSpace.shareModeControl.shareToAllSpaces.cannotCheckTooltip', - { defaultMessage: 'You need additional privileges to use this option.' } - ), - }), - disabled: !canShareToAllSpaces, - }; - const shareToExplicitSpaces = { - id: 'shareToExplicitSpaces', - title: i18n.translate( - 'xpack.spaces.shareToSpace.shareModeControl.shareToExplicitSpaces.title', - { defaultMessage: 'Select spaces' } - ), - text: i18n.translate('xpack.spaces.shareToSpace.shareModeControl.shareToExplicitSpaces.text', { - defaultMessage: 'Make {objectNoun} available in selected spaces only.', - values: { objectNoun }, - }), - disabled: !canShareToAllSpaces && isGlobalControlChecked, - }; - - const toggleShareOption = (allSpaces: boolean) => { - const updatedSpaceIds = allSpaces - ? [ALL_SPACES_ID, ...selectedSpaceIds] - : selectedSpaceIds.filter((id) => id !== ALL_SPACES_ID); - onChange(updatedSpaceIds); - }; const getPrivilegeWarning = () => { - if (!shareToExplicitSpaces.disabled) { + if (canShareToAllSpaces || !isGlobalControlChecked) { return null; } @@ -180,13 +128,66 @@ export const ShareModeControl = (props: Props) => { <> {getPrivilegeWarning()} - toggleShareOption(false)} - disabled={shareToExplicitSpaces.disabled} - > + { + const updatedSpaceIds = + optionId === shareToAllSpacesId + ? [ALL_SPACES_ID, ...selectedSpaceIds] + : selectedSpaceIds.filter((id) => id !== ALL_SPACES_ID); + onChange(updatedSpaceIds); + }} + legend={buttonGroupLegend} + color="secondary" + isFullWidth={true} + isDisabled={!canShareToAllSpaces} + /> + + + + + + + + {isGlobalControlChecked + ? i18n.translate( + 'xpack.spaces.shareToSpace.shareModeControl.shareToAllSpaces.text', + { + defaultMessage: + 'Make {objectNoun} available in all current and future spaces.', + values: { objectNoun }, + } + ) + : i18n.translate( + 'xpack.spaces.shareToSpace.shareModeControl.shareToExplicitSpaces.text', + { + defaultMessage: 'Make {objectNoun} available in selected spaces only.', + values: { objectNoun }, + } + )} + + + {!canShareToAllSpaces && ( + + + + )} + + + + + + { enableCreateNewSpaceLink={enableCreateNewSpaceLink} enableSpaceAgnosticBehavior={enableSpaceAgnosticBehavior} /> - - - toggleShareOption(true)} - disabled={shareToAllSpaces.disabled} - /> + ); }; diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.scss b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.scss new file mode 100644 index 0000000000000..5f5014c4a82f3 --- /dev/null +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.scss @@ -0,0 +1,3 @@ +.spcShareToSpace__flyoutBodyWrapper { + padding: $euiSizeL; +} diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.test.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.test.tsx index 4ec90b7e3826b..f02cae7674058 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.test.tsx +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.test.tsx @@ -5,20 +5,14 @@ * 2.0. */ -import type { EuiCheckableCardProps } from '@elastic/eui'; -import { - EuiCallOut, - EuiCheckableCard, - EuiIconTip, - EuiLoadingSpinner, - EuiSelectable, -} from '@elastic/eui'; +import { EuiCallOut, EuiIconTip, EuiLoadingSpinner, EuiSelectable } from '@elastic/eui'; import Boom from '@hapi/boom'; import { act } from '@testing-library/react'; import type { ReactWrapper } from 'enzyme'; import React from 'react'; import { findTestSubject, mountWithIntl, nextTick } from '@kbn/test/jest'; +import type { SavedObjectReferenceWithContext } from 'src/core/public'; import { coreMock } from 'src/core/public/mocks'; import type { Space } from 'src/plugins/spaces_oss/common'; @@ -26,7 +20,9 @@ import { ALL_SPACES_ID } from '../../../common/constants'; import { CopyToSpaceFlyoutInternal } from '../../copy_saved_objects_to_space/components/copy_to_space_flyout_internal'; import { getSpacesContextProviderWrapper } from '../../spaces_context'; import { spacesManagerMock } from '../../spaces_manager/mocks'; +import { AliasTable } from './alias_table'; import { NoSpacesAvailable } from './no_spaces_available'; +import { RelativesFooter } from './relatives_footer'; import { SelectableSpacesControl } from './selectable_spaces_control'; import { ShareModeControl } from './share_mode_control'; import { getShareToSpaceFlyoutComponent } from './share_to_space_flyout'; @@ -41,6 +37,7 @@ interface SetupOpts { enableCreateNewSpaceLink?: boolean; behaviorContext?: 'within-space' | 'outside-space'; mockFeatureId?: string; // optional feature ID to use for the SpacesContext + additionalShareableReferences?: SavedObjectReferenceWithContext[]; } const setup = async (opts: SetupOpts = {}) => { @@ -94,6 +91,19 @@ const setup = async (opts: SetupOpts = {}) => { title: 'foo', }; + mockSpacesManager.getShareableReferences.mockResolvedValue({ + objects: [ + { + // this is the result for the saved object target; by default, it has no references + type: savedObjectToShare.type, + id: savedObjectToShare.id, + spaces: savedObjectToShare.namespaces, + inboundReferences: [], + }, + ...(opts.additionalShareableReferences ?? []), + ], + }); + const { getStartServices } = coreMock.createSetup(); const startServices = coreMock.createStart(); startServices.application.capabilities = { @@ -138,6 +148,25 @@ const setup = async (opts: SetupOpts = {}) => { return { wrapper, onClose, mockSpacesManager, mockToastNotifications, savedObjectToShare }; }; +function changeSpaceSelection(wrapper: ReactWrapper, selectedSpaces: string[]) { + // Using props callback instead of simulating clicks, because EuiSelectable uses a virtualized list, which isn't easily testable via test + // subjects + const spaceSelector = wrapper.find(SelectableSpacesControl); + act(() => { + spaceSelector.props().onChange(selectedSpaces); + }); + wrapper.update(); +} + +async function clickButton(wrapper: ReactWrapper, button: 'continue' | 'save' | 'copy') { + const buttonNode = findTestSubject(wrapper, `sts-${button}-button`); + await act(async () => { + buttonNode.simulate('click'); + await nextTick(); + wrapper.update(); + }); +} + describe('ShareToSpaceFlyout', () => { it('waits for spaces to load', async () => { const { wrapper } = await setup({ returnBeforeSpacesLoad: true }); @@ -212,12 +241,7 @@ describe('ShareToSpaceFlyout', () => { expect(wrapper.find(EuiLoadingSpinner)).toHaveLength(0); expect(wrapper.find(NoSpacesAvailable)).toHaveLength(0); - const copyButton = findTestSubject(wrapper, 'sts-copy-link'); // this link is only present in the warning callout - - await act(async () => { - copyButton.simulate('click'); - await nextTick(); - }); + await clickButton(wrapper, 'copy'); // this link is only present in the warning callout wrapper.update(); expect(wrapper.find(CopyToSpaceFlyoutInternal)).toHaveLength(1); @@ -288,20 +312,8 @@ describe('ShareToSpaceFlyout', () => { expect(wrapper.find(EuiLoadingSpinner)).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 - const spaceSelector = wrapper.find(SelectableSpacesControl); - act(() => { - spaceSelector.props().onChange(['space-2', 'space-3']); - }); - - const startButton = findTestSubject(wrapper, 'sts-initiate-button'); - - await act(async () => { - startButton.simulate('click'); - await nextTick(); - wrapper.update(); - }); + changeSpaceSelection(wrapper, ['space-2', 'space-3']); + await clickButton(wrapper, 'save'); expect(mockSpacesManager.updateSavedObjectsSpaces).toHaveBeenCalled(); expect(mockToastNotifications.addError).toHaveBeenCalled(); @@ -320,21 +332,8 @@ describe('ShareToSpaceFlyout', () => { expect(wrapper.find(EuiLoadingSpinner)).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 - const spaceSelector = wrapper.find(SelectableSpacesControl); - - act(() => { - spaceSelector.props().onChange(['space-1', 'space-2', 'space-3']); - }); - - const startButton = findTestSubject(wrapper, 'sts-initiate-button'); - - await act(async () => { - startButton.simulate('click'); - await nextTick(); - wrapper.update(); - }); + changeSpaceSelection(wrapper, ['space-1', 'space-2', 'space-3']); + await clickButton(wrapper, 'save'); const { type, id } = savedObjectToShare; expect(mockSpacesManager.updateSavedObjectsSpaces).toHaveBeenCalledWith( @@ -361,21 +360,8 @@ describe('ShareToSpaceFlyout', () => { expect(wrapper.find(EuiLoadingSpinner)).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 - const spaceSelector = wrapper.find(SelectableSpacesControl); - - act(() => { - spaceSelector.props().onChange([]); - }); - - const startButton = findTestSubject(wrapper, 'sts-initiate-button'); - - await act(async () => { - startButton.simulate('click'); - await nextTick(); - wrapper.update(); - }); + changeSpaceSelection(wrapper, []); + await clickButton(wrapper, 'save'); const { type, id } = savedObjectToShare; expect(mockSpacesManager.updateSavedObjectsSpaces).toHaveBeenCalledWith( @@ -402,21 +388,8 @@ describe('ShareToSpaceFlyout', () => { expect(wrapper.find(EuiLoadingSpinner)).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 - const spaceSelector = wrapper.find(SelectableSpacesControl); - - act(() => { - spaceSelector.props().onChange(['space-2', 'space-3']); - }); - - const startButton = findTestSubject(wrapper, 'sts-initiate-button'); - - await act(async () => { - startButton.simulate('click'); - await nextTick(); - wrapper.update(); - }); + changeSpaceSelection(wrapper, ['space-2', 'space-3']); + await clickButton(wrapper, 'save'); const { type, id } = savedObjectToShare; expect(mockSpacesManager.updateSavedObjectsSpaces).toHaveBeenCalledWith( @@ -430,25 +403,13 @@ describe('ShareToSpaceFlyout', () => { expect(onClose).toHaveBeenCalledTimes(1); }); - describe('correctly renders checkable cards', () => { - function getCheckableCardProps( - wrapper: ReactWrapper> - ) { - const iconTip = wrapper.find(EuiIconTip); + describe('correctly renders share mode control', () => { + function getDescriptionAndWarning(wrapper: ReactWrapper) { + const descriptionNode = findTestSubject(wrapper, 'share-mode-control-description'); + const iconTipNode = wrapper.find(ShareModeControl).find(EuiIconTip); return { - checked: wrapper.prop('checked'), - disabled: wrapper.prop('disabled'), - ...(iconTip.length > 0 && { tooltip: iconTip.prop('content') as string }), - }; - } - function getCheckableCards(wrapper: ReactWrapper) { - return { - explicitSpacesCard: getCheckableCardProps( - wrapper.find('#shareToExplicitSpaces').find(EuiCheckableCard) - ), - allSpacesCard: getCheckableCardProps( - wrapper.find('#shareToAllSpaces').find(EuiCheckableCard) - ), + description: descriptionNode.text(), + isPrivilegeTooltipDisplayed: iconTipNode.length > 0, }; } @@ -458,27 +419,23 @@ describe('ShareToSpaceFlyout', () => { it('and the object is not shared to all spaces', async () => { const namespaces = ['my-active-space']; const { wrapper } = await setup({ canShareToAllSpaces, namespaces }); - const shareModeControl = wrapper.find(ShareModeControl); - const checkableCards = getCheckableCards(shareModeControl); + const { description, isPrivilegeTooltipDisplayed } = getDescriptionAndWarning(wrapper); - expect(checkableCards).toEqual({ - explicitSpacesCard: { checked: true, disabled: false }, - allSpacesCard: { checked: false, disabled: false }, - }); - expect(shareModeControl.find(EuiCallOut)).toHaveLength(0); // "Additional privileges required" callout + expect(description).toMatchInlineSnapshot( + `"Make object available in selected spaces only."` + ); + expect(isPrivilegeTooltipDisplayed).toBe(false); }); it('and the object is shared to all spaces', async () => { const namespaces = [ALL_SPACES_ID]; const { wrapper } = await setup({ canShareToAllSpaces, namespaces }); - const shareModeControl = wrapper.find(ShareModeControl); - const checkableCards = getCheckableCards(shareModeControl); + const { description, isPrivilegeTooltipDisplayed } = getDescriptionAndWarning(wrapper); - expect(checkableCards).toEqual({ - explicitSpacesCard: { checked: false, disabled: false }, - allSpacesCard: { checked: true, disabled: false }, - }); - expect(shareModeControl.find(EuiCallOut)).toHaveLength(0); // "Additional privileges required" callout + expect(description).toMatchInlineSnapshot( + `"Make object available in all current and future spaces."` + ); + expect(isPrivilegeTooltipDisplayed).toBe(false); }); }); @@ -488,35 +445,23 @@ describe('ShareToSpaceFlyout', () => { it('and the object is not shared to all spaces', async () => { const namespaces = ['my-active-space']; const { wrapper } = await setup({ canShareToAllSpaces, namespaces }); - const shareModeControl = wrapper.find(ShareModeControl); - const checkableCards = getCheckableCards(shareModeControl); - - expect(checkableCards).toEqual({ - explicitSpacesCard: { checked: true, disabled: false }, - allSpacesCard: { - checked: false, - disabled: true, - tooltip: 'You need additional privileges to use this option.', - }, - }); - expect(shareModeControl.find(EuiCallOut)).toHaveLength(0); // "Additional privileges required" callout + const { description, isPrivilegeTooltipDisplayed } = getDescriptionAndWarning(wrapper); + + expect(description).toMatchInlineSnapshot( + `"Make object available in selected spaces only."` + ); + expect(isPrivilegeTooltipDisplayed).toBe(true); }); it('and the object is shared to all spaces', async () => { const namespaces = [ALL_SPACES_ID]; const { wrapper } = await setup({ canShareToAllSpaces, namespaces }); - const shareModeControl = wrapper.find(ShareModeControl); - const checkableCards = getCheckableCards(shareModeControl); + const { description, isPrivilegeTooltipDisplayed } = getDescriptionAndWarning(wrapper); - expect(checkableCards).toEqual({ - explicitSpacesCard: { checked: false, disabled: true }, - allSpacesCard: { - checked: true, - disabled: true, - tooltip: 'You need additional privileges to change this option.', - }, - }); - expect(shareModeControl.find(EuiCallOut)).toHaveLength(1); // "Additional privileges required" callout + expect(description).toMatchInlineSnapshot( + `"Make object available in all current and future spaces."` + ); + expect(isPrivilegeTooltipDisplayed).toBe(true); }); }); }); @@ -714,4 +659,152 @@ describe('ShareToSpaceFlyout', () => { }); }); }); + + describe('alias list', () => { + it('shows only aliases for spaces that exist', async () => { + const namespaces = ['my-active-space']; // the saved object's current namespaces + const { wrapper } = await setup({ + namespaces, + additionalShareableReferences: [ + // it doesn't matter if aliases are for the saved object target or for references; this is easier to mock + { + type: 'foo', + id: '1', + spaces: namespaces, + inboundReferences: [], + spacesWithMatchingAliases: ['space-1', 'some-space-that-does-not-exist'], // space-1 exists, it is mocked at the top + }, + ], + }); + + changeSpaceSelection(wrapper, ['*']); + await clickButton(wrapper, 'continue'); + + const aliasTable = wrapper.find(AliasTable); + expect(aliasTable.prop('aliasesToDisable')).toEqual([ + { targetType: 'foo', sourceId: '1', targetSpace: 'space-1', spaceExists: true }, + { + // this alias is present, and it will be disabled, but it is not displayed in the table below due to the 'spaceExists' field + targetType: 'foo', + sourceId: '1', + targetSpace: 'some-space-that-does-not-exist', + spaceExists: false, + }, + ]); + expect(aliasTable.find(EuiCallOut).text()).toMatchInlineSnapshot( + `"Legacy URL conflict1 legacy URL will be disabled."` + ); + }); + + it('shows only aliases for selected spaces', async () => { + const namespaces = ['my-active-space']; // the saved object's current namespaces + const { wrapper } = await setup({ + namespaces, + additionalShareableReferences: [ + // it doesn't matter if aliases are for the saved object target or for references; this is easier to mock + { + type: 'foo', + id: '1', + spaces: namespaces, + inboundReferences: [], + spacesWithMatchingAliases: ['space-1', 'space-2'], // space-1 and space-2 both exist, they are mocked at the top + }, + ], + }); + + changeSpaceSelection(wrapper, ['space-1']); + await clickButton(wrapper, 'continue'); + + const aliasTable = wrapper.find(AliasTable); + expect(aliasTable.prop('aliasesToDisable')).toEqual([ + { targetType: 'foo', sourceId: '1', targetSpace: 'space-1', spaceExists: true }, + // even though an alias exists for space-2, it will not be disabled, because we aren't sharing to that space + ]); + expect(aliasTable.find(EuiCallOut).text()).toMatchInlineSnapshot( + `"Legacy URL conflict1 legacy URL will be disabled."` + ); + }); + }); + + describe('footer', () => { + it('does not show a description of relatives (references) if there are none', async () => { + const namespaces = ['my-active-space']; // the saved object's current namespaces + const { wrapper } = await setup({ namespaces }); + + const relativesControl = wrapper.find(RelativesFooter); + expect(relativesControl.isEmptyRender()).toBe(true); + }); + + it('shows a description of filtered relatives (references)', async () => { + const namespaces = ['my-active-space']; // the saved object's current namespaces + const { wrapper } = await setup({ + namespaces, + additionalShareableReferences: [ + // the saved object target is already included in the mock results by default; it will not be counted + { type: 'foo', id: '1', spaces: [], inboundReferences: [] }, // this will not be counted because spaces is empty (it may not be a shareable type) + { type: 'foo', id: '2', spaces: namespaces, inboundReferences: [], isMissing: true }, // this will not be counted because isMissing === true + { type: 'foo', id: '3', spaces: namespaces, inboundReferences: [] }, // this will be counted + ], + }); + + const relativesControl = wrapper.find(RelativesFooter); + expect(relativesControl.isEmptyRender()).toBe(false); + expect(relativesControl.text()).toMatchInlineSnapshot(`"1 related object will also change."`); + }); + + function expectButton(wrapper: ReactWrapper, button: 'save' | 'continue') { + const saveButton = findTestSubject(wrapper, 'sts-save-button'); + const continueButton = findTestSubject(wrapper, 'sts-continue-button'); + expect(saveButton).toHaveLength(button === 'save' ? 1 : 0); + expect(continueButton).toHaveLength(button === 'continue' ? 1 : 0); + } + + it('shows a save button if there are no legacy URL aliases to disable', async () => { + const namespaces = ['my-active-space']; // the saved object's current namespaces + const { wrapper } = await setup({ namespaces }); + + changeSpaceSelection(wrapper, ['*']); + expectButton(wrapper, 'save'); + }); + + it('shows a save button if there are legacy URL aliases to disable, but none for existing spaces', async () => { + const namespaces = ['my-active-space']; // the saved object's current namespaces + const { wrapper } = await setup({ + namespaces, + additionalShareableReferences: [ + // it doesn't matter if aliases are for the saved object target or for references; this is easier to mock + { + type: 'foo', + id: '1', + spaces: namespaces, + inboundReferences: [], + spacesWithMatchingAliases: ['some-space-that-does-not-exist'], + }, + ], + }); + + changeSpaceSelection(wrapper, ['*']); + expectButton(wrapper, 'save'); + }); + + it('shows a continue button if there are legacy URL aliases to disable for existing spaces', async () => { + const namespaces = ['my-active-space']; // the saved object's current namespaces + const { wrapper } = await setup({ + namespaces, + additionalShareableReferences: [ + // it doesn't matter if aliases are for the saved object target or for references; this is easier to mock + { + type: 'foo', + id: '1', + spaces: namespaces, + inboundReferences: [], + spacesWithMatchingAliases: ['space-1', 'some-space-that-does-not-exist'], // space-1 exists, it is mocked at the top + }, + ], + }); + + changeSpaceSelection(wrapper, ['*']); + expectButton(wrapper, 'continue'); + }); + }); }); diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.tsx b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.tsx index d8fc0f299d8e6..712adeb26bccb 100644 --- a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.tsx +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/share_to_space_flyout_internal.tsx @@ -5,18 +5,19 @@ * 2.0. */ +import './share_to_space_flyout_internal.scss'; + import { EuiButton, EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiFlyout, - EuiFlyoutBody, EuiFlyoutFooter, EuiFlyoutHeader, - EuiHorizontalRule, EuiIcon, EuiLoadingSpinner, + EuiSpacer, EuiText, EuiTitle, } from '@elastic/eui'; @@ -24,7 +25,7 @@ import React, { lazy, Suspense, useEffect, useMemo, useState } from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; -import type { ToastsStart } from 'src/core/public'; +import type { SavedObjectReferenceWithContext, ToastsStart } from 'src/core/public'; import type { ShareToSpaceFlyoutProps, ShareToSpaceSavedObjectTarget, @@ -36,8 +37,11 @@ import { useSpaces } from '../../spaces_context'; import type { SpacesManager } from '../../spaces_manager'; import type { ShareToSpaceTarget } from '../../types'; import type { ShareOptions } from '../types'; +import { AliasTable } from './alias_table'; import { DEFAULT_OBJECT_NOUN } from './constants'; +import { RelativesFooter } from './relatives_footer'; import { ShareToSpaceForm } from './share_to_space_form'; +import type { InternalLegacyUrlAliasTarget } from './types'; // No need to wrap LazyCopyToSpaceFlyout in an error boundary, because the ShareToSpaceFlyoutInternal component itself is only ever used in // a lazy-loaded fashion with an error boundary. @@ -66,45 +70,53 @@ function createDefaultChangeSpacesHandler( spacesManager: SpacesManager, toastNotifications: ToastsStart ) { - return async (spacesToAdd: string[], spacesToRemove: string[]) => { - const { type, id, title } = object; - const objects = [{ type, id }]; + return async ( + objects: Array<{ type: string; id: string }>, + spacesToAdd: string[], + spacesToRemove: string[] + ) => { + const { title } = object; + const objectsToUpdate = objects.map(({ type, id }) => ({ type, id })); // only use 'type' and 'id' fields + const relativesCount = objects.length - 1; const toastTitle = i18n.translate('xpack.spaces.shareToSpace.shareSuccessTitle', { values: { objectNoun: object.noun }, defaultMessage: 'Updated {objectNoun}', description: `Object noun can be plural or singular, examples: "Updated objects", "Updated job"`, }); - await spacesManager.updateSavedObjectsSpaces(objects, spacesToAdd, spacesToRemove); + await spacesManager.updateSavedObjectsSpaces(objectsToUpdate, spacesToAdd, spacesToRemove); const isSharedToAllSpaces = spacesToAdd.includes(ALL_SPACES_ID); let toastText: string; if (spacesToAdd.length > 0 && spacesToRemove.length > 0 && !isSharedToAllSpaces) { toastText = i18n.translate('xpack.spaces.shareToSpace.shareSuccessAddRemoveText', { - defaultMessage: `'{object}' was added to {spacesTargetAdd} and removed from {spacesTargetRemove}.`, // TODO: update to include # of references and/or # of tags + defaultMessage: `'{object}' {relativesCount, plural, =0 {was} =1 {and {relativesCount} related object were} other {and {relativesCount} related objects were}} added to {spacesTargetAdd} and removed from {spacesTargetRemove}.`, values: { object: title, + relativesCount, spacesTargetAdd: getSpacesTargetString(spacesToAdd), spacesTargetRemove: getSpacesTargetString(spacesToRemove), }, - description: `Uses output of xpack.spaces.shareToSpace.spacesTarget or xpack.spaces.shareToSpace.allSpacesTarget as 'spacesTarget...' inputs. Example strings: "'Finance dashboard' was added to 1 space and removed from 2 spaces.", "'Finance dashboard' was added to 3 spaces and removed from all spaces."`, + description: `Uses output of xpack.spaces.shareToSpace.spacesTarget or xpack.spaces.shareToSpace.allSpacesTarget as 'spacesTarget...' inputs. Example strings: "'Finance dashboard' was added to 1 space and removed from 2 spaces.", "'Finance dashboard' and 2 related objects were added to 3 spaces and removed from all spaces."`, }); } else if (spacesToAdd.length > 0) { toastText = i18n.translate('xpack.spaces.shareToSpace.shareSuccessAddText', { - defaultMessage: `'{object}' was added to {spacesTarget}.`, // TODO: update to include # of references and/or # of tags + defaultMessage: `'{object}' {relativesCount, plural, =0 {was} =1 {and {relativesCount} related object were} other {and {relativesCount} related objects were}} added to {spacesTarget}.`, values: { object: title, + relativesCount, spacesTarget: getSpacesTargetString(spacesToAdd), }, - description: `Uses output of xpack.spaces.shareToSpace.spacesTarget or xpack.spaces.shareToSpace.allSpacesTarget as 'spacesTarget' input. Example strings: "'Finance dashboard' was added to 1 space.", "'Finance dashboard' was added to all spaces."`, + description: `Uses output of xpack.spaces.shareToSpace.spacesTarget or xpack.spaces.shareToSpace.allSpacesTarget as 'spacesTarget' input. Example strings: "'Finance dashboard' was added to 1 space.", "'Finance dashboard' and 2 related objects were added to all spaces."`, }); } else { toastText = i18n.translate('xpack.spaces.shareToSpace.shareSuccessRemoveText', { - defaultMessage: `'{object}' was removed from {spacesTarget}.`, // TODO: update to include # of references and/or # of tags + defaultMessage: `'{object}' {relativesCount, plural, =0 {was} =1 {and {relativesCount} related object were} other {and {relativesCount} related objects were}} removed from {spacesTarget}.`, values: { object: title, + relativesCount, spacesTarget: getSpacesTargetString(spacesToRemove), }, - description: `Uses output of xpack.spaces.shareToSpace.spacesTarget or xpack.spaces.shareToSpace.allSpacesTarget as 'spacesTarget' input. Example strings: "'Finance dashboard' was removed from 1 space.", "'Finance dashboard' was removed from all spaces."`, + description: `Uses output of xpack.spaces.shareToSpace.spacesTarget or xpack.spaces.shareToSpace.allSpacesTarget as 'spacesTarget' input. Example strings: "'Finance dashboard' was removed from 1 space.", "'Finance dashboard' and 2 related objects were removed from all spaces."`, }); } toastNotifications.addSuccess({ title: toastTitle, text: toastText }); @@ -131,7 +143,7 @@ export const ShareToSpaceFlyoutInternal = (props: ShareToSpaceFlyoutProps) => { const { flyoutIcon, flyoutTitle = i18n.translate('xpack.spaces.shareToSpace.flyoutTitle', { - defaultMessage: 'Edit spaces for {objectNoun}', + defaultMessage: 'Assign {objectNoun} to spaces', values: { objectNoun: savedObjectTarget.noun }, }), enableCreateCopyCallout = false, @@ -154,13 +166,15 @@ export const ShareToSpaceFlyoutInternal = (props: ShareToSpaceFlyoutProps) => { const [canShareToAllSpaces, setCanShareToAllSpaces] = useState(false); const [showMakeCopy, setShowMakeCopy] = useState(false); - const [{ isLoading, spaces }, setSpacesState] = useState<{ + const [{ isLoading, spaces, referenceGraph, aliasTargets }, setSpacesState] = useState<{ isLoading: boolean; spaces: ShareToSpaceTarget[]; - }>({ isLoading: true, spaces: [] }); + referenceGraph: SavedObjectReferenceWithContext[]; + aliasTargets: InternalLegacyUrlAliasTarget[]; + }>({ isLoading: true, spaces: [], referenceGraph: [], aliasTargets: [] }); useEffect(() => { const { type, id } = savedObjectTarget; - const getShareableReferences = spacesManager.getShareableReferences([{ type, id }]); // NOTE: not used yet, this is just included so you can see the request/response in Dev Tools + const getShareableReferences = spacesManager.getShareableReferences([{ type, id }]); const getPermissions = spacesManager.getShareSavedObjectPermissions(type); Promise.all([shareToSpacesDataPromise, getShareableReferences, getPermissions]) .then(([shareToSpacesData, shareableReferences, permissions]) => { @@ -176,6 +190,20 @@ export const ShareToSpaceFlyoutInternal = (props: ShareToSpaceFlyoutProps) => { setSpacesState({ isLoading: false, spaces: [...shareToSpacesData.spacesMap].map(([, spaceTarget]) => spaceTarget), + referenceGraph: shareableReferences.objects, + aliasTargets: shareableReferences.objects.reduce( + (acc, x) => { + for (const space of x.spacesWithMatchingAliases ?? []) { + if (space !== '?') { + const spaceExists = shareToSpacesData.spacesMap.has(space); + // If the user does not have privileges to view all spaces, they will be redacted; we cannot attempt to disable aliases for redacted spaces. + acc.push({ targetSpace: space, targetType: x.type, sourceId: x.id, spaceExists }); + } + } + return acc; + }, + [] + ), }); }) .catch((e) => { @@ -195,7 +223,12 @@ export const ShareToSpaceFlyoutInternal = (props: ShareToSpaceFlyoutProps) => { const getSelectionChanges = () => { if (!spaces.length) { - return { isSelectionChanged: false, spacesToAdd: [], spacesToRemove: [] }; + return { + isSelectionChanged: false, + spacesToAdd: [], + spacesToRemove: [], + aliasesToDisable: [], + }; } const activeSpaceId = !enableSpaceAgnosticBehavior && spaces.find((space) => space.isActiveSpace)!.id; @@ -231,21 +264,36 @@ export const ShareToSpaceFlyoutInternal = (props: ShareToSpaceFlyoutProps) => { : isUnsharedFromAllSpaces ? [...activeSpaceArray, ...selectedSpacesToAdd] : selectedSpacesToAdd; + const spacesToAddSet = new Set(spacesToAdd); const spacesToRemove = isUnsharedFromAllSpaces || !isSharedToAllSpaces ? selectedSpacesToRemove : [...activeSpaceArray, ...initialSelection]; - return { isSelectionChanged, spacesToAdd, spacesToRemove }; + const aliasesToDisable = isSharedToAllSpaces + ? aliasTargets + : aliasTargets.filter(({ targetSpace }) => spacesToAddSet.has(targetSpace)); + return { isSelectionChanged, spacesToAdd, spacesToRemove, aliasesToDisable }; }; - const { isSelectionChanged, spacesToAdd, spacesToRemove } = getSelectionChanges(); + const { + isSelectionChanged, + spacesToAdd, + spacesToRemove, + aliasesToDisable, + } = getSelectionChanges(); + const [showAliasesToDisable, setShowAliasesToDisable] = useState(false); const [shareInProgress, setShareInProgress] = useState(false); async function startShare() { setShareInProgress(true); try { - await changeSpacesHandler(spacesToAdd, spacesToRemove); - onUpdate(); + if (aliasesToDisable.length) { + const aliases = aliasesToDisable.map(({ spaceExists, ...alias }) => alias); // only use 'targetSpace', 'targetType', and 'sourceId' fields + await spacesManager.disableLegacyUrlAliases(aliases); + } + await changeSpacesHandler(referenceGraph, spacesToAdd, spacesToRemove); + const updatedObjects = referenceGraph.map(({ type, id }) => ({ type, id })); // only use 'type' and 'id' fields + onUpdate(updatedObjects); onClose(); } catch (e) { setShareInProgress(false); @@ -264,27 +312,86 @@ export const ShareToSpaceFlyoutInternal = (props: ShareToSpaceFlyoutProps) => { return ; } - // If the object has not been shared yet (e.g., it currently exists in exactly one space), and there is at least one space that we could - // share this object to, we want to display a callout to the user that explains the ramifications of shared objects. They might actually - // want to make a copy instead, so this callout contains a link that opens the Copy flyout. - const showCreateCopyCallout = - enableCreateCopyCallout && - spaces.length > 1 && - savedObjectTarget.namespaces.length === 1 && - !arraysAreEqual(savedObjectTarget.namespaces, [ALL_SPACES_ID]); - // Step 2: Share has not been initiated yet; User must fill out form to continue. + if (!showAliasesToDisable) { + // If the object has not been shared yet (e.g., it currently exists in exactly one space), and there is at least one space that we could + // share this object to, we want to display a callout to the user that explains the ramifications of shared objects. They might actually + // want to make a copy instead, so this callout contains a link that opens the Copy flyout. + const showCreateCopyCallout = + enableCreateCopyCallout && + spaces.length > 1 && + savedObjectTarget.namespaces.length === 1 && + !arraysAreEqual(savedObjectTarget.namespaces, [ALL_SPACES_ID]); + // Step 2: Share has not been initiated yet; User must fill out form to continue. + return ( + setShowMakeCopy(true)} + enableCreateNewSpaceLink={enableCreateNewSpaceLink} + enableSpaceAgnosticBehavior={enableSpaceAgnosticBehavior} + /> + ); + } + + return ; + }; + + const getFlyoutFooter = () => { + const filteredAliasesToDisable = aliasesToDisable.filter(({ spaceExists }) => spaceExists); + const showContinueButton = filteredAliasesToDisable.length && !showAliasesToDisable; return ( - setShowMakeCopy(true)} - enableCreateNewSpaceLink={enableCreateNewSpaceLink} - enableSpaceAgnosticBehavior={enableSpaceAgnosticBehavior} - /> + <> + + + + onClose()} + data-test-subj="sts-cancel-button" + disabled={shareInProgress} + > + + + + + {showContinueButton ? ( + setShowAliasesToDisable(true)} + data-test-subj="sts-continue-button" + disabled={isStartShareButtonDisabled} + > + + + ) : ( + startShare()} + data-test-subj="sts-save-button" + disabled={isStartShareButtonDisabled} + > + + + )} + + + ); }; @@ -317,54 +424,33 @@ export const ShareToSpaceFlyoutInternal = (props: ShareToSpaceFlyoutProps) => { - - - {savedObjectTarget.icon && ( - - + + + + + {savedObjectTarget.icon && ( + + + + )} + + +

{savedObjectTarget.title}

+ - )} - - -

{savedObjectTarget.title}

-
-
- + + - + {getFlyoutBody()} - + - - - - onClose()} - data-test-subj="sts-cancel-button" - disabled={shareInProgress} - > - - - - - startShare()} - data-test-subj="sts-initiate-button" - disabled={isStartShareButtonDisabled} - > - - - - - + {getFlyoutFooter()} ); }; 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 65ed0139d0dd8..7f8c659805c45 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 @@ -61,7 +61,7 @@ export const ShareToSpaceForm = (props: Props) => { defaultMessage="Your changes appear in each space you select. {makeACopyLink} if you don't want to synchronize your changes." values={{ makeACopyLink: ( - makeCopy()}> + makeCopy()}> { ) : null; return ( -
+ <> {createCopyCallout} { enableCreateNewSpaceLink={enableCreateNewSpaceLink} enableSpaceAgnosticBehavior={enableSpaceAgnosticBehavior} /> -
+ ); }; diff --git a/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/types.ts b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/types.ts new file mode 100644 index 0000000000000..ba39dd2499f4c --- /dev/null +++ b/x-pack/plugins/spaces/public/share_saved_objects_to_space/components/types.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { LegacyUrlAliasTarget } from '../../../common'; + +export interface InternalLegacyUrlAliasTarget extends LegacyUrlAliasTarget { + /** + * We could potentially have an alias for a space that does not exist; in that case, we may need disable it, but we don't want to show it + * in the UI. + */ + spaceExists: boolean; +} 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 9a0d171342a80..90dda8ad0b013 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 @@ -44,13 +44,13 @@ export class ShareToSpaceSavedObjectsManagementAction extends SavedObjectsManage return namespaceType === 'multiple' && !hiddenType && hasCapability; }, onClick: (object: SavedObjectsManagementRecord) => { - this.isDataChanged = false; + this.objectsToRefresh = []; this.start(object); }, }; - public refreshOnFinish = () => this.isDataChanged; + public refreshOnFinish = () => this.objectsToRefresh; - private isDataChanged: boolean = false; + private objectsToRefresh: Array<{ type: string; id: string }> = []; constructor(private readonly spacesApiUi: SpacesApiUi) { super(); @@ -70,7 +70,8 @@ export class ShareToSpaceSavedObjectsManagementAction extends SavedObjectsManage icon: this.record.meta.icon, }, flyoutIcon: 'share', - onUpdate: () => (this.isDataChanged = true), + onUpdate: (updatedObjects: Array<{ type: string; id: string }>) => + (this.objectsToRefresh = [...updatedObjects]), onClose: this.onClose, enableCreateCopyCallout: true, enableCreateNewSpaceLink: true, diff --git a/x-pack/plugins/spaces/public/space_avatar/space_avatar_internal.tsx b/x-pack/plugins/spaces/public/space_avatar/space_avatar_internal.tsx index 9a3a112110bbf..91b4dbf8a964e 100644 --- a/x-pack/plugins/spaces/public/space_avatar/space_avatar_internal.tsx +++ b/x-pack/plugins/spaces/public/space_avatar/space_avatar_internal.tsx @@ -20,6 +20,12 @@ interface Props { size?: 's' | 'm' | 'l' | 'xl'; className?: string; announceSpaceName?: boolean; + /** + * This property is passed to the underlying `EuiAvatar` component. If enabled, the SpaceAvatar will have a grayed out appearance. For + * example, this can be useful when rendering a list of spaces for a specific feature, if the feature is disabled in one of those spaces. + * Default: false. + */ + isDisabled?: boolean; } export const SpaceAvatarInternal: FC = (props: Props) => { diff --git a/x-pack/plugins/spaces/public/space_list/space_list_internal.tsx b/x-pack/plugins/spaces/public/space_list/space_list_internal.tsx index 1a512fb2d31f4..ac7e6446f2ccd 100644 --- a/x-pack/plugins/spaces/public/space_list/space_list_internal.tsx +++ b/x-pack/plugins/spaces/public/space_list/space_list_internal.tsx @@ -142,11 +142,10 @@ export const SpaceListInternal = ({ }> {displayedSpaces.map((space) => { - // color may be undefined, which is intentional; SpacesAvatar calls the getSpaceColor function before rendering - const color = space.isFeatureDisabled ? 'hollow' : space.color; + const isDisabled = space.isFeatureDisabled; return ( - + ); })} 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 39c06a2bc874d..5282163f93b15 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() { createSpace: jest.fn().mockResolvedValue(undefined), updateSpace: jest.fn().mockResolvedValue(undefined), deleteSpace: jest.fn().mockResolvedValue(undefined), + disableLegacyUrlAliases: jest.fn().mockResolvedValue(undefined), copySavedObjects: jest.fn().mockResolvedValue(undefined), getShareableReferences: jest.fn().mockResolvedValue(undefined), updateSavedObjectsSpaces: jest.fn().mockResolvedValue(undefined), 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 ff18b2965e8b9..d09177a915d99 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 @@ -154,4 +154,33 @@ describe('SpacesManager', () => { ); }); }); + + describe('#getShareableReferences', () => { + it('retrieves the shareable references, filters out references that are tags, and returns the result', async () => { + const obj1 = { type: 'not-a-tag', id: '1' }; // requested object + const obj2 = { type: 'tag', id: '2' }; // requested object + const obj3 = { type: 'tag', id: '3' }; // referenced object + const obj4 = { type: 'not-a-tag', id: '4' }; // referenced object + + const coreStart = coreMock.createStart(); + coreStart.http.post.mockResolvedValue({ objects: [obj1, obj2, obj3, obj4] }); // A realistic response would include additional fields besides 'type' and 'id', but they are not needed for this test case + const spacesManager = new SpacesManager(coreStart.http); + + const requestObjects = [obj1, obj2]; + const result = await spacesManager.getShareableReferences(requestObjects); + expect(coreStart.http.post).toHaveBeenCalledTimes(1); + expect(coreStart.http.post).toHaveBeenLastCalledWith( + '/api/spaces/_get_shareable_references', + { body: JSON.stringify({ objects: requestObjects }) } + ); + expect(result).toEqual({ + objects: [ + obj1, // obj1 is not a tag + obj2, // obj2 is a tag, but it was included in the request, so it is not excluded from the response + // obj3 is a tag, but it was not included in the request, so it is excluded from the response + obj4, // obj4 is not a tag + ], + }); + }); + }); }); 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 a7201def5ed40..845373bf22299 100644 --- a/x-pack/plugins/spaces/public/spaces_manager/spaces_manager.ts +++ b/x-pack/plugins/spaces/public/spaces_manager/spaces_manager.ts @@ -15,7 +15,7 @@ import type { } from 'src/core/public'; import type { Space } from 'src/plugins/spaces_oss/common'; -import type { GetAllSpacesOptions, GetSpaceResult } from '../../common'; +import type { GetAllSpacesOptions, GetSpaceResult, LegacyUrlAliasTarget } from '../../common'; import type { CopySavedObjectsToSpaceResponse } from '../copy_saved_objects_to_space/types'; interface SavedObjectTarget { @@ -23,6 +23,8 @@ interface SavedObjectTarget { id: string; } +const TAG_TYPE = 'tag'; + export class SpacesManager { private activeSpace$: BehaviorSubject = new BehaviorSubject(null); @@ -90,6 +92,12 @@ export class SpacesManager { await this.http.delete(`/api/spaces/space/${encodeURIComponent(space.id)}`); } + public async disableLegacyUrlAliases(aliases: LegacyUrlAliasTarget[]) { + await this.http.post('/api/spaces/_disable_legacy_url_aliases', { + body: JSON.stringify({ aliases }), + }); + } + public async copySavedObjects( objects: SavedObjectTarget[], spaces: string[], @@ -142,9 +150,21 @@ export class SpacesManager { public async getShareableReferences( objects: SavedObjectTarget[] ): Promise { - return this.http.post(`/api/spaces/_get_shareable_references`, { - body: JSON.stringify({ objects }), - }); + const response = await this.http.post( + `/api/spaces/_get_shareable_references`, + { body: JSON.stringify({ objects }) } + ); + + // We should exclude any child-reference tags because we don't yet support reconciling/merging duplicate tags. In other words: tags can + // be shared directly, but if a tag is only included as a reference of a requested object, it should not be shared. + const requestedObjectsSet = objects.reduce( + (acc, { type, id }) => acc.add(`${type}:${id}`), + new Set() + ); + const filteredObjects = response.objects.filter( + ({ type, id }) => type !== TAG_TYPE || requestedObjectsSet.has(`${type}:${id}`) + ); + return { objects: filteredObjects }; } public async updateSavedObjectsSpaces( diff --git a/x-pack/plugins/spaces/public/suspense_error_boundary/suspense_error_boundary.tsx b/x-pack/plugins/spaces/public/suspense_error_boundary/suspense_error_boundary.tsx index e90920f3f1e25..e2a1ce69deee8 100644 --- a/x-pack/plugins/spaces/public/suspense_error_boundary/suspense_error_boundary.tsx +++ b/x-pack/plugins/spaces/public/suspense_error_boundary/suspense_error_boundary.tsx @@ -14,6 +14,12 @@ import type { NotificationsStart } from 'src/core/public'; interface Props { notifications: NotificationsStart; + /** + * Whether or not to show a loading spinner while waiting for the child components to load. + * + * Default is true. + */ + showLoadingSpinner?: boolean; } interface State { @@ -44,11 +50,12 @@ export class SuspenseErrorBoundary extends Component, S } render() { - const { children, notifications } = this.props; + const { children, notifications, showLoadingSpinner = true } = this.props; const { error } = this.state; if (!notifications || error) { return null; } - return }>{children}; + const fallback = showLoadingSpinner ? : null; + return {children}; } } diff --git a/x-pack/plugins/spaces/public/ui_api/components.tsx b/x-pack/plugins/spaces/public/ui_api/components.tsx index b564e96be4c41..a277e3a1dd119 100644 --- a/x-pack/plugins/spaces/public/ui_api/components.tsx +++ b/x-pack/plugins/spaces/public/ui_api/components.tsx @@ -34,9 +34,15 @@ export const getComponents = ({ /** * Returns a function that creates a lazy-loading version of a component. */ - function wrapLazy(fn: () => Promise>) { + function wrapLazy(fn: () => Promise>, options: { showLoadingSpinner?: boolean } = {}) { + const { showLoadingSpinner } = options; return (props: JSX.IntrinsicAttributes & PropsWithRef>) => ( - + ); } @@ -44,7 +50,7 @@ export const getComponents = ({ getSpacesContextProvider: wrapLazy(() => getSpacesContextProviderWrapper({ spacesManager, getStartServices }) ), - getShareToSpaceFlyout: wrapLazy(getShareToSpaceFlyoutComponent), + getShareToSpaceFlyout: wrapLazy(getShareToSpaceFlyoutComponent, { showLoadingSpinner: false }), getSpaceList: wrapLazy(getSpaceListComponent), getLegacyUrlConflict: wrapLazy(() => getLegacyUrlConflict({ getStartServices })), getSpaceAvatar: wrapLazy(getSpaceAvatarComponent), diff --git a/x-pack/plugins/spaces/public/ui_api/lazy_wrapper.tsx b/x-pack/plugins/spaces/public/ui_api/lazy_wrapper.tsx index 9c3336cfca01d..384eee6c439c9 100644 --- a/x-pack/plugins/spaces/public/ui_api/lazy_wrapper.tsx +++ b/x-pack/plugins/spaces/public/ui_api/lazy_wrapper.tsx @@ -17,12 +17,14 @@ import { SuspenseErrorBoundary } from '../suspense_error_boundary'; interface InternalProps { fn: () => Promise>; getStartServices: StartServicesAccessor; + showLoadingSpinner?: boolean; props: JSX.IntrinsicAttributes & PropsWithRef>; } export const LazyWrapper: (props: InternalProps) => ReactElement | null = ({ fn, getStartServices, + showLoadingSpinner, props, }) => { const { value: startServices = [{ notifications: undefined }] } = useAsync(getStartServices); @@ -35,7 +37,7 @@ export const LazyWrapper: (props: InternalProps) => ReactElement | null = } return ( - + ); diff --git a/x-pack/plugins/spaces/server/index.ts b/x-pack/plugins/spaces/server/index.ts index c779e92a3ae9b..4765b06f5a02a 100644 --- a/x-pack/plugins/spaces/server/index.ts +++ b/x-pack/plugins/spaces/server/index.ts @@ -23,7 +23,12 @@ export { SpacesPluginSetup, SpacesPluginStart } from './plugin'; export { SpacesServiceSetup, SpacesServiceStart } from './spaces_service'; export { ISpacesClient, SpacesClientRepositoryFactory, SpacesClientWrapper } from './spaces_client'; -export { GetAllSpacesOptions, GetAllSpacesPurpose, GetSpaceResult } from '../common'; +export { + GetAllSpacesOptions, + GetAllSpacesPurpose, + GetSpaceResult, + LegacyUrlAliasTarget, +} from '../common'; // re-export types from oss definition export type { Space } from 'src/plugins/spaces_oss/common'; diff --git a/x-pack/plugins/spaces/server/routes/api/__fixtures__/create_mock_so_repository.ts b/x-pack/plugins/spaces/server/routes/api/__fixtures__/create_mock_so_repository.ts index 13398925490fa..acc13be6802c2 100644 --- a/x-pack/plugins/spaces/server/routes/api/__fixtures__/create_mock_so_repository.ts +++ b/x-pack/plugins/spaces/server/routes/api/__fixtures__/create_mock_so_repository.ts @@ -35,6 +35,7 @@ export const createMockSavedObjectsRepository = (spaces: any[] = []) => { } return {}; }), + bulkUpdate: jest.fn(), delete: jest.fn((type: string, id: string) => { return {}; }), diff --git a/x-pack/plugins/spaces/server/routes/api/external/disable_legacy_url_aliases.test.ts b/x-pack/plugins/spaces/server/routes/api/external/disable_legacy_url_aliases.test.ts new file mode 100644 index 0000000000000..e475b6b21569a --- /dev/null +++ b/x-pack/plugins/spaces/server/routes/api/external/disable_legacy_url_aliases.test.ts @@ -0,0 +1,143 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as Rx from 'rxjs'; + +import type { RouteValidatorConfig } from 'src/core/server'; +import { kibanaResponseFactory } from 'src/core/server'; +import { + coreMock, + httpServerMock, + httpServiceMock, + loggingSystemMock, +} from 'src/core/server/mocks'; + +import { spacesConfig } from '../../../lib/__fixtures__'; +import { SpacesClientService } from '../../../spaces_client'; +import { SpacesService } from '../../../spaces_service'; +import { usageStatsClientMock } from '../../../usage_stats/usage_stats_client.mock'; +import { usageStatsServiceMock } from '../../../usage_stats/usage_stats_service.mock'; +import { + createMockSavedObjectsRepository, + createSpaces, + mockRouteContext, + mockRouteContextWithInvalidLicense, +} from '../__fixtures__'; +import { initDisableLegacyUrlAliasesApi } from './disable_legacy_url_aliases'; + +describe('_disable_legacy_url_aliases', () => { + const spacesSavedObjects = createSpaces(); + + const setup = async () => { + const httpService = httpServiceMock.createSetupContract(); + const router = httpService.createRouter(); + + const coreStart = coreMock.createStart(); + + const savedObjectsRepositoryMock = createMockSavedObjectsRepository(spacesSavedObjects); + + const log = loggingSystemMock.create().get('spaces'); + + const clientService = new SpacesClientService(jest.fn()); + clientService + .setup({ config$: Rx.of(spacesConfig) }) + .setClientRepositoryFactory(() => savedObjectsRepositoryMock); + + const service = new SpacesService(); + service.setup({ + basePath: httpService.basePath, + }); + + const usageStatsClient = usageStatsClientMock.create(); + const usageStatsServicePromise = Promise.resolve( + usageStatsServiceMock.createSetupContract(usageStatsClient) + ); + + const clientServiceStart = clientService.start(coreStart); + + const spacesServiceStart = service.start({ + basePath: coreStart.http.basePath, + spacesClientService: clientServiceStart, + }); + + initDisableLegacyUrlAliasesApi({ + externalRouter: router, + getStartServices: async () => [coreStart, {}, {}], + log, + getSpacesService: () => spacesServiceStart, + usageStatsServicePromise, + }); + + const [routeDefinition, routeHandler] = router.post.mock.calls[0]; + + return { + routeValidation: routeDefinition.validate as RouteValidatorConfig<{}, {}, {}>, + routeHandler, + savedObjectsRepositoryMock, + usageStatsClient, + }; + }; + + it('records usageStats data', async () => { + const payload = { + aliases: [{ targetSpace: 'space-1', targetType: 'type-1', sourceId: 'id-1' }], + }; + + const { routeHandler, usageStatsClient } = await setup(); + + const request = httpServerMock.createKibanaRequest({ + body: payload, + method: 'post', + }); + + await routeHandler(mockRouteContext, request, kibanaResponseFactory); + + expect(usageStatsClient.incrementDisableLegacyUrlAliases).toHaveBeenCalled(); + }); + + it('should disable the provided aliases', async () => { + const payload = { + aliases: [{ targetSpace: 'space-1', targetType: 'type-1', sourceId: 'id-1' }], + }; + + const { routeHandler, savedObjectsRepositoryMock } = await setup(); + + const request = httpServerMock.createKibanaRequest({ + body: payload, + method: 'post', + }); + + const response = await routeHandler(mockRouteContext, request, kibanaResponseFactory); + + const { status } = response; + + expect(status).toEqual(204); + expect(savedObjectsRepositoryMock.bulkUpdate).toHaveBeenCalledTimes(1); + expect(savedObjectsRepositoryMock.bulkUpdate).toHaveBeenCalledWith([ + { type: 'legacy-url-alias', id: 'space-1:type-1:id-1', attributes: { disabled: true } }, + ]); + }); + + it(`returns http/403 when the license is invalid`, async () => { + const { routeHandler } = await setup(); + + const request = httpServerMock.createKibanaRequest({ + method: 'post', + }); + + const response = await routeHandler( + mockRouteContextWithInvalidLicense, + request, + kibanaResponseFactory + ); + + expect(response.status).toEqual(403); + expect(response.payload).toEqual({ + message: 'License is invalid for spaces', + }); + }); +}); diff --git a/x-pack/plugins/spaces/server/routes/api/external/disable_legacy_url_aliases.ts b/x-pack/plugins/spaces/server/routes/api/external/disable_legacy_url_aliases.ts new file mode 100644 index 0000000000000..a14744ddc5eeb --- /dev/null +++ b/x-pack/plugins/spaces/server/routes/api/external/disable_legacy_url_aliases.ts @@ -0,0 +1,50 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { schema } from '@kbn/config-schema'; + +import { wrapError } from '../../../lib/errors'; +import { createLicensedRouteHandler } from '../../lib'; +import type { ExternalRouteDeps } from './'; + +export function initDisableLegacyUrlAliasesApi(deps: ExternalRouteDeps) { + const { externalRouter, getSpacesService, usageStatsServicePromise } = deps; + const usageStatsClientPromise = usageStatsServicePromise.then(({ getClient }) => getClient()); + + externalRouter.post( + { + path: '/api/spaces/_disable_legacy_url_aliases', + validate: { + body: schema.object({ + aliases: schema.arrayOf( + schema.object({ + targetSpace: schema.string(), + targetType: schema.string(), + sourceId: schema.string(), + }) + ), + }), + }, + }, + createLicensedRouteHandler(async (_context, request, response) => { + const spacesClient = getSpacesService().createSpacesClient(request); + + const { aliases } = request.body; + + usageStatsClientPromise.then((usageStatsClient) => + usageStatsClient.incrementDisableLegacyUrlAliases() + ); + + try { + await spacesClient.disableLegacyUrlAliases(aliases); + return response.noContent(); + } catch (error) { + return response.customError(wrapError(error)); + } + }) + ); +} 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 9cebd8d0f9352..c42e57dea736d 100644 --- a/x-pack/plugins/spaces/server/routes/api/external/index.ts +++ b/x-pack/plugins/spaces/server/routes/api/external/index.ts @@ -12,6 +12,7 @@ import type { SpacesRouter } from '../../../types'; import type { UsageStatsServiceSetup } from '../../../usage_stats'; import { initCopyToSpacesApi } from './copy_to_space'; import { initDeleteSpacesApi } from './delete'; +import { initDisableLegacyUrlAliasesApi } from './disable_legacy_url_aliases'; import { initGetSpaceApi } from './get'; import { initGetAllSpacesApi } from './get_all'; import { initGetShareableReferencesApi } from './get_shareable_references'; @@ -36,4 +37,5 @@ export function initExternalSpacesApi(deps: ExternalRouteDeps) { initCopyToSpacesApi(deps); initUpdateObjectsSpacesApi(deps); initGetShareableReferencesApi(deps); + initDisableLegacyUrlAliasesApi(deps); } diff --git a/x-pack/plugins/spaces/server/spaces_client/spaces_client.mock.ts b/x-pack/plugins/spaces/server/spaces_client/spaces_client.mock.ts index f03e26ea8e714..d893d2b089f89 100644 --- a/x-pack/plugins/spaces/server/spaces_client/spaces_client.mock.ts +++ b/x-pack/plugins/spaces/server/spaces_client/spaces_client.mock.ts @@ -30,6 +30,7 @@ const createSpacesClientMock = () => create: jest.fn().mockImplementation((space: Space) => Promise.resolve(space)), update: jest.fn().mockImplementation((space: Space) => Promise.resolve(space)), delete: jest.fn(), + disableLegacyUrlAliases: jest.fn(), } as unknown) as jest.Mocked); export const spacesClientMock = { diff --git a/x-pack/plugins/spaces/server/spaces_client/spaces_client.test.ts b/x-pack/plugins/spaces/server/spaces_client/spaces_client.test.ts index 1d5d3851ce9c5..ae9fc254c0934 100644 --- a/x-pack/plugins/spaces/server/spaces_client/spaces_client.test.ts +++ b/x-pack/plugins/spaces/server/spaces_client/spaces_client.test.ts @@ -341,4 +341,25 @@ describe('#delete', () => { expect(mockCallWithRequestRepository.delete).toHaveBeenCalledWith('space', id); expect(mockCallWithRequestRepository.deleteByNamespace).toHaveBeenCalledWith(id); }); + + describe('#disableLegacyUrlAliases', () => { + test(`updates legacy URL aliases using callWithRequestRepository`, async () => { + const mockDebugLogger = createMockDebugLogger(); + const mockConfig = createMockConfig(); + const mockCallWithRequestRepository = savedObjectsRepositoryMock.create(); + + const client = new SpacesClient(mockDebugLogger, mockConfig, mockCallWithRequestRepository); + const aliases = [ + { targetSpace: 'space1', targetType: 'foo', sourceId: '123' }, + { targetSpace: 'space2', targetType: 'bar', sourceId: '456' }, + ]; + await client.disableLegacyUrlAliases(aliases); + + expect(mockCallWithRequestRepository.bulkUpdate).toHaveBeenCalledTimes(1); + expect(mockCallWithRequestRepository.bulkUpdate).toHaveBeenCalledWith([ + { type: 'legacy-url-alias', id: 'space1:foo:123', attributes: { disabled: true } }, + { type: 'legacy-url-alias', id: 'space2:bar:456', attributes: { disabled: true } }, + ]); + }); + }); }); diff --git a/x-pack/plugins/spaces/server/spaces_client/spaces_client.ts b/x-pack/plugins/spaces/server/spaces_client/spaces_client.ts index 02aa4d0b976c0..824d6e28b9923 100644 --- a/x-pack/plugins/spaces/server/spaces_client/spaces_client.ts +++ b/x-pack/plugins/spaces/server/spaces_client/spaces_client.ts @@ -11,7 +11,12 @@ import { omit } from 'lodash'; import type { ISavedObjectsRepository, SavedObject } from 'src/core/server'; import type { Space } from 'src/plugins/spaces_oss/common'; -import type { GetAllSpacesOptions, GetAllSpacesPurpose, GetSpaceResult } from '../../common'; +import type { + GetAllSpacesOptions, + GetAllSpacesPurpose, + GetSpaceResult, + LegacyUrlAliasTarget, +} from '../../common'; import { isReservedSpace } from '../../common'; import type { ConfigType } from '../config'; @@ -22,6 +27,7 @@ const SUPPORTED_GET_SPACE_PURPOSES: GetAllSpacesPurpose[] = [ 'shareSavedObjectsIntoSpace', ]; const DEFAULT_PURPOSE = 'any'; +const LEGACY_URL_ALIAS_TYPE = 'legacy-url-alias'; /** * Client interface for interacting with spaces. @@ -57,6 +63,12 @@ export interface ISpacesClient { * @param id the id of the space to delete. */ delete(id: string): Promise; + + /** + * Disables the specified legacy URL aliases. + * @param aliases the aliases to disable. + */ + disableLegacyUrlAliases(aliases: LegacyUrlAliasTarget[]): Promise; } /** @@ -135,6 +147,15 @@ export class SpacesClient implements ISpacesClient { await this.repository.delete('space', id); } + public async disableLegacyUrlAliases(aliases: LegacyUrlAliasTarget[]) { + const attributes = { disabled: true }; + const objectsToUpdate = aliases.map(({ targetSpace, targetType, sourceId }) => { + const id = `${targetSpace}:${targetType}:${sourceId}`; + return { type: LEGACY_URL_ALIAS_TYPE, id, attributes }; + }); + await this.repository.bulkUpdate(objectsToUpdate); + } + private transformSavedObjectToSpace(savedObject: SavedObject) { return { id: savedObject.id, diff --git a/x-pack/plugins/spaces/server/usage_collection/spaces_usage_collector.test.ts b/x-pack/plugins/spaces/server/usage_collection/spaces_usage_collector.test.ts index 19228614dc614..655463ed64a30 100644 --- a/x-pack/plugins/spaces/server/usage_collection/spaces_usage_collector.test.ts +++ b/x-pack/plugins/spaces/server/usage_collection/spaces_usage_collector.test.ts @@ -40,6 +40,7 @@ const MOCK_USAGE_STATS: UsageStats = { 'apiCalls.resolveCopySavedObjectsErrors.kibanaRequest.no': 0, 'apiCalls.resolveCopySavedObjectsErrors.createNewCopiesEnabled.yes': 6, 'apiCalls.resolveCopySavedObjectsErrors.createNewCopiesEnabled.no': 7, + 'apiCalls.disableLegacyUrlAliases.total': 17, }; function setup({ diff --git a/x-pack/plugins/spaces/server/usage_collection/spaces_usage_collector.ts b/x-pack/plugins/spaces/server/usage_collection/spaces_usage_collector.ts index 93892378717d5..b5c0972031a8f 100644 --- a/x-pack/plugins/spaces/server/usage_collection/spaces_usage_collector.ts +++ b/x-pack/plugins/spaces/server/usage_collection/spaces_usage_collector.ts @@ -425,6 +425,12 @@ export function getSpacesUsageCollector( 'The number of times the "Resolve Copy Saved Objects Errors" API has been called with "createNewCopies" set to false.', }, }, + 'apiCalls.disableLegacyUrlAliases.total': { + type: 'long', + _meta: { + description: 'The number of times the "Disable Legacy URL Aliases" API has been called.', + }, + }, }, fetch: async ({ esClient }: CollectorFetchContext) => { const { licensing, kibanaIndexConfig$, features, usageStatsServicePromise } = deps; diff --git a/x-pack/plugins/spaces/server/usage_stats/types.ts b/x-pack/plugins/spaces/server/usage_stats/types.ts index c0ea2c98ac4b8..81fe720cba745 100644 --- a/x-pack/plugins/spaces/server/usage_stats/types.ts +++ b/x-pack/plugins/spaces/server/usage_stats/types.ts @@ -18,4 +18,5 @@ export interface UsageStats { 'apiCalls.resolveCopySavedObjectsErrors.kibanaRequest.no'?: number; 'apiCalls.resolveCopySavedObjectsErrors.createNewCopiesEnabled.yes'?: number; 'apiCalls.resolveCopySavedObjectsErrors.createNewCopiesEnabled.no'?: number; + 'apiCalls.disableLegacyUrlAliases.total'?: number; } diff --git a/x-pack/plugins/spaces/server/usage_stats/usage_stats_client.mock.ts b/x-pack/plugins/spaces/server/usage_stats/usage_stats_client.mock.ts index 32380b1a21048..c9f73451c7553 100644 --- a/x-pack/plugins/spaces/server/usage_stats/usage_stats_client.mock.ts +++ b/x-pack/plugins/spaces/server/usage_stats/usage_stats_client.mock.ts @@ -12,6 +12,7 @@ const createUsageStatsClientMock = () => getUsageStats: jest.fn().mockResolvedValue({}), incrementCopySavedObjects: jest.fn().mockResolvedValue(null), incrementResolveCopySavedObjectsErrors: jest.fn().mockResolvedValue(null), + incrementDisableLegacyUrlAliases: jest.fn().mockResolvedValue(null), } as unknown) as jest.Mocked); export const usageStatsClientMock = { diff --git a/x-pack/plugins/spaces/server/usage_stats/usage_stats_client.test.ts b/x-pack/plugins/spaces/server/usage_stats/usage_stats_client.test.ts index 6a56cb68d2a16..c5b63a4d007b9 100644 --- a/x-pack/plugins/spaces/server/usage_stats/usage_stats_client.test.ts +++ b/x-pack/plugins/spaces/server/usage_stats/usage_stats_client.test.ts @@ -14,6 +14,7 @@ import type { } from './usage_stats_client'; import { COPY_STATS_PREFIX, + DISABLE_LEGACY_URL_ALIASES_STATS_PREFIX, RESOLVE_COPY_STATS_PREFIX, UsageStatsClient, } from './usage_stats_client'; @@ -50,6 +51,7 @@ describe('UsageStatsClient', () => { `${RESOLVE_COPY_STATS_PREFIX}.kibanaRequest.no`, `${RESOLVE_COPY_STATS_PREFIX}.createNewCopiesEnabled.yes`, `${RESOLVE_COPY_STATS_PREFIX}.createNewCopiesEnabled.no`, + `${DISABLE_LEGACY_URL_ALIASES_STATS_PREFIX}.total`, ], { initialize: true } ); @@ -131,7 +133,7 @@ describe('UsageStatsClient', () => { }); describe('#incrementResolveCopySavedObjectsErrors', () => { - it('does not throw an error if repository create operation fails', async () => { + it('does not throw an error if repository incrementCounter operation fails', async () => { const { usageStatsClient, repositoryMock } = setup(); repositoryMock.incrementCounter.mockRejectedValue(new Error('Oh no!')); @@ -182,4 +184,27 @@ describe('UsageStatsClient', () => { ); }); }); + + describe('#incrementDisableLegacyUrlAliases', () => { + it('does not throw an error if repository incrementCounter operation fails', async () => { + const { usageStatsClient, repositoryMock } = setup(); + repositoryMock.incrementCounter.mockRejectedValue(new Error('Oh no!')); + + await expect(usageStatsClient.incrementDisableLegacyUrlAliases()).resolves.toBeUndefined(); + expect(repositoryMock.incrementCounter).toHaveBeenCalledTimes(1); + }); + + it('uses the appropriate counter fields', async () => { + const { usageStatsClient, repositoryMock } = setup(); + + await usageStatsClient.incrementDisableLegacyUrlAliases(); + expect(repositoryMock.incrementCounter).toHaveBeenCalledTimes(1); + expect(repositoryMock.incrementCounter).toHaveBeenCalledWith( + SPACES_USAGE_STATS_TYPE, + SPACES_USAGE_STATS_ID, + [`${DISABLE_LEGACY_URL_ALIASES_STATS_PREFIX}.total`], + incrementOptions + ); + }); + }); }); diff --git a/x-pack/plugins/spaces/server/usage_stats/usage_stats_client.ts b/x-pack/plugins/spaces/server/usage_stats/usage_stats_client.ts index 5093dd42b5bfa..5dcf106d6cfb4 100644 --- a/x-pack/plugins/spaces/server/usage_stats/usage_stats_client.ts +++ b/x-pack/plugins/spaces/server/usage_stats/usage_stats_client.ts @@ -21,6 +21,7 @@ export type IncrementResolveCopySavedObjectsErrorsOptions = BaseIncrementOptions export const COPY_STATS_PREFIX = 'apiCalls.copySavedObjects'; export const RESOLVE_COPY_STATS_PREFIX = 'apiCalls.resolveCopySavedObjectsErrors'; +export const DISABLE_LEGACY_URL_ALIASES_STATS_PREFIX = 'apiCalls.disableLegacyUrlAliases'; const ALL_COUNTER_FIELDS = [ `${COPY_STATS_PREFIX}.total`, `${COPY_STATS_PREFIX}.kibanaRequest.yes`, @@ -34,6 +35,7 @@ const ALL_COUNTER_FIELDS = [ `${RESOLVE_COPY_STATS_PREFIX}.kibanaRequest.no`, `${RESOLVE_COPY_STATS_PREFIX}.createNewCopiesEnabled.yes`, `${RESOLVE_COPY_STATS_PREFIX}.createNewCopiesEnabled.no`, + `${DISABLE_LEGACY_URL_ALIASES_STATS_PREFIX}.total`, ]; export class UsageStatsClient { constructor( @@ -87,6 +89,11 @@ export class UsageStatsClient { await this.updateUsageStats(counterFieldNames, RESOLVE_COPY_STATS_PREFIX); } + public async incrementDisableLegacyUrlAliases() { + const counterFieldNames = ['total']; + await this.updateUsageStats(counterFieldNames, DISABLE_LEGACY_URL_ALIASES_STATS_PREFIX); + } + private async updateUsageStats(counterFieldNames: string[], prefix: string) { const options = { refresh: false }; try { diff --git a/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json b/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json index 39852ebaeb46b..bab4244139df0 100644 --- a/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json +++ b/x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json @@ -5763,6 +5763,12 @@ "_meta": { "description": "The number of times the \"Resolve Copy Saved Objects Errors\" API has been called with \"createNewCopies\" set to false." } + }, + "apiCalls.disableLegacyUrlAliases.total": { + "type": "long", + "_meta": { + "description": "The number of times the \"Disable Legacy URL Aliases\" API has been called." + } } } }, diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index 1b44200566405..cd2fafb0abbc0 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -3412,7 +3412,6 @@ "savedObjectsManagement.objects.savedObjectsDescription": "保存された検索、ビジュアライゼーション、ダッシュボードのインポート、エクスポート、管理を行います。", "savedObjectsManagement.objects.savedObjectsTitle": "保存されたオブジェクト", "savedObjectsManagement.objectsTable.deleteSavedObjectsConfirmModal.cancelButtonLabel": "キャンセル", - "savedObjectsManagement.objectsTable.deleteSavedObjectsConfirmModal.deleteButtonLabel": "削除", "savedObjectsManagement.objectsTable.deleteSavedObjectsConfirmModal.idColumnName": "Id", "savedObjectsManagement.objectsTable.deleteSavedObjectsConfirmModal.titleColumnName": "タイトル", "savedObjectsManagement.objectsTable.deleteSavedObjectsConfirmModal.typeColumnName": "型", @@ -22106,7 +22105,6 @@ "xpack.spaces.shareToSpace.columnTitle": "共有されているスペース", "xpack.spaces.shareToSpace.currentSpaceBadge": "現在", "xpack.spaces.shareToSpace.featureIsDisabledTooltip": "この機能はこのスペースでは無効です。", - "xpack.spaces.shareToSpace.flyoutTitle": "{objectNoun}のスペースを編集", "xpack.spaces.shareToSpace.legacyUrlConflictBody": "現在、{objectNoun} [id={currentObjectId}]を表示しています。このページのレガシーURLは別の{objectNoun} [id={otherObjectId}]を示しています。", "xpack.spaces.shareToSpace.legacyUrlConflictDismissButton": "閉じる", "xpack.spaces.shareToSpace.legacyUrlConflictLinkButton": "他の{objectNoun}に移動", @@ -22125,14 +22123,7 @@ "xpack.spaces.shareToSpace.shareModeControl.hiddenCountLabel": "+{hiddenCount}個が非表示", "xpack.spaces.shareToSpace.shareModeControl.selectedCountLabel": "{selectedCount}個が選択済み", "xpack.spaces.shareToSpace.shareModeControl.selectSpacesLabel": "スペースを選択", - "xpack.spaces.shareToSpace.shareModeControl.shareToAllSpaces.cannotCheckTooltip": "このオプションを使用するには、追加権限が必要です。", - "xpack.spaces.shareToSpace.shareModeControl.shareToAllSpaces.cannotUncheckTooltip": "このオプションを変更するには、追加権限が必要です。", - "xpack.spaces.shareToSpace.shareModeControl.shareToAllSpaces.text": "現在と将来のすべてのスペースで{objectNoun}を使用可能にします。", - "xpack.spaces.shareToSpace.shareModeControl.shareToAllSpaces.title": "すべてのスペース", - "xpack.spaces.shareToSpace.shareModeControl.shareToExplicitSpaces.text": "選択したスペースでのみ{objectNoun}を使用可能にします。", - "xpack.spaces.shareToSpace.shareModeControl.shareToExplicitSpaces.title": "スペースを選択", "xpack.spaces.shareToSpace.shareSuccessTitle": "{objectNoun}を更新しました", - "xpack.spaces.shareToSpace.shareToSpacesButton": "保存して閉じる", "xpack.spaces.shareToSpace.shareWarningBody": "変更は選択した各スペースに表示されます。変更を同期しない場合は、{makeACopyLink}。", "xpack.spaces.shareToSpace.shareWarningLink": "コピーを作成", "xpack.spaces.shareToSpace.shareWarningTitle": "変更はスペース全体で同期されます", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 9636aa9ba5282..2522abd40f07f 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -3436,7 +3436,6 @@ "savedObjectsManagement.objects.savedObjectsDescription": "导入、导出和管理您的已保存搜索、可视化和仪表板。", "savedObjectsManagement.objects.savedObjectsTitle": "已保存对象", "savedObjectsManagement.objectsTable.deleteSavedObjectsConfirmModal.cancelButtonLabel": "取消", - "savedObjectsManagement.objectsTable.deleteSavedObjectsConfirmModal.deleteButtonLabel": "删除", "savedObjectsManagement.objectsTable.deleteSavedObjectsConfirmModal.idColumnName": "ID", "savedObjectsManagement.objectsTable.deleteSavedObjectsConfirmModal.titleColumnName": "标题", "savedObjectsManagement.objectsTable.deleteSavedObjectsConfirmModal.typeColumnName": "类型", @@ -22460,7 +22459,6 @@ "xpack.spaces.shareToSpace.columnTitle": "共享工作区", "xpack.spaces.shareToSpace.currentSpaceBadge": "当前", "xpack.spaces.shareToSpace.featureIsDisabledTooltip": "此功能在此工作区中已禁用。", - "xpack.spaces.shareToSpace.flyoutTitle": "编辑 {objectNoun} 的工作区", "xpack.spaces.shareToSpace.legacyUrlConflictBody": "当前您正在查看 {objectNoun} [id={currentObjectId}]。此页面的旧 URL 显示不同的 {objectNoun} [id={otherObjectId}]。", "xpack.spaces.shareToSpace.legacyUrlConflictDismissButton": "关闭", "xpack.spaces.shareToSpace.legacyUrlConflictLinkButton": "前往其他 {objectNoun}", @@ -22479,14 +22477,9 @@ "xpack.spaces.shareToSpace.shareModeControl.hiddenCountLabel": "+{hiddenCount} 个已隐藏", "xpack.spaces.shareToSpace.shareModeControl.selectedCountLabel": "{selectedCount} 个已选择", "xpack.spaces.shareToSpace.shareModeControl.selectSpacesLabel": "选择工作区", - "xpack.spaces.shareToSpace.shareModeControl.shareToAllSpaces.cannotCheckTooltip": "您还需要其他权限,才能使用此选项。", - "xpack.spaces.shareToSpace.shareModeControl.shareToAllSpaces.cannotUncheckTooltip": "您还需要其他权限,才能更改此选项。", "xpack.spaces.shareToSpace.shareModeControl.shareToAllSpaces.text": "使 {objectNoun} 在当前和将来的所有工作区中都可用。", - "xpack.spaces.shareToSpace.shareModeControl.shareToAllSpaces.title": "所有工作区", "xpack.spaces.shareToSpace.shareModeControl.shareToExplicitSpaces.text": "仅使 {objectNoun} 在选定工作区中可用。", - "xpack.spaces.shareToSpace.shareModeControl.shareToExplicitSpaces.title": "选择工作区", "xpack.spaces.shareToSpace.shareSuccessTitle": "已更新 {objectNoun}", - "xpack.spaces.shareToSpace.shareToSpacesButton": "保存并关闭", "xpack.spaces.shareToSpace.shareWarningBody": "您的更改显示在您选择的每个工作区中。如果不想同步您的更改,{makeACopyLink}。", "xpack.spaces.shareToSpace.shareWarningLink": "创建副本", "xpack.spaces.shareToSpace.shareWarningTitle": "更改已在工作区之间同步", diff --git a/x-pack/test/spaces_api_integration/common/suites/disable_legacy_url_aliases.ts b/x-pack/test/spaces_api_integration/common/suites/disable_legacy_url_aliases.ts new file mode 100644 index 0000000000000..fdf827a931054 --- /dev/null +++ b/x-pack/test/spaces_api_integration/common/suites/disable_legacy_url_aliases.ts @@ -0,0 +1,128 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { SuperTest } from 'supertest'; +import type { KibanaClient } from '@elastic/elasticsearch/api/kibana'; +import { LegacyUrlAlias } from 'src/core/server/saved_objects/object_types'; +import { SPACES } from '../lib/spaces'; +import { getUrlPrefix } from '../../../saved_object_api_integration/common/lib/saved_object_test_utils'; +import { + ExpectResponseBody, + TestDefinition, + TestSuite, +} from '../../../saved_object_api_integration/common/lib/types'; + +export interface DisableLegacyUrlAliasesTestDefinition extends TestDefinition { + request: { + aliases: Array<{ targetSpace: string; targetType: string; sourceId: string }>; + }; +} +export type DisableLegacyUrlAliasesTestSuite = TestSuite; +export interface DisableLegacyUrlAliasesTestCase { + targetSpace: string; + targetType: string; + sourceId: string; +} + +const LEGACY_URL_ALIAS_TYPE = 'legacy-url-alias'; +interface RawLegacyUrlAlias { + [LEGACY_URL_ALIAS_TYPE]: LegacyUrlAlias; +} + +export const TEST_CASE_TARGET_TYPE = 'sharedtype'; +export const TEST_CASE_SOURCE_ID = 'default_only'; // two aliases exist for default_only: one in space_1, and one in space_2 +const createRequest = (alias: DisableLegacyUrlAliasesTestCase) => ({ + aliases: [alias], +}); +const getTestTitle = ({ targetSpace, targetType, sourceId }: DisableLegacyUrlAliasesTestCase) => { + return `for alias '${targetSpace}:${targetType}:${sourceId}'`; +}; + +export function disableLegacyUrlAliasesTestSuiteFactory( + es: KibanaClient, + esArchiver: any, + supertest: SuperTest +) { + const expectResponseBody = ( + testCase: DisableLegacyUrlAliasesTestCase, + statusCode: 204 | 403 + ): ExpectResponseBody => async (response: Record) => { + if (statusCode === 403) { + expect(response.body).to.eql({ + statusCode: 403, + error: 'Forbidden', + message: `Unable to disable aliases for ${testCase.targetType}`, + }); + } + const { targetSpace, targetType, sourceId } = testCase; + const esResponse = await es.get({ + index: '.kibana', + id: `${LEGACY_URL_ALIAS_TYPE}:${targetSpace}:${targetType}:${sourceId}`, + }); + const doc = esResponse.body._source!; + expect(doc).not.to.be(undefined); + expect(doc[LEGACY_URL_ALIAS_TYPE].disabled).to.be(statusCode === 204 ? true : undefined); + }; + const createTestDefinitions = ( + testCases: DisableLegacyUrlAliasesTestCase | DisableLegacyUrlAliasesTestCase[], + forbidden: boolean, + options: { + responseBodyOverride?: ExpectResponseBody; + } = {} + ): DisableLegacyUrlAliasesTestDefinition[] => { + const cases = Array.isArray(testCases) ? testCases : [testCases]; + const responseStatusCode = forbidden ? 403 : 204; + return cases.map((x) => ({ + title: getTestTitle(x), + responseStatusCode, + request: createRequest(x), + responseBody: options?.responseBodyOverride || expectResponseBody(x, responseStatusCode), + })); + }; + + const makeDisableLegacyUrlAliasesTest = (describeFn: Mocha.SuiteFunction) => ( + description: string, + definition: DisableLegacyUrlAliasesTestSuite + ) => { + const { user, spaceId = SPACES.DEFAULT.spaceId, tests } = definition; + + describeFn(description, () => { + before(() => + esArchiver.load( + 'x-pack/test/spaces_api_integration/common/fixtures/es_archiver/saved_objects/spaces' + ) + ); + after(() => + esArchiver.unload( + 'x-pack/test/spaces_api_integration/common/fixtures/es_archiver/saved_objects/spaces' + ) + ); + + for (const test of tests) { + it(`should return ${test.responseStatusCode} ${test.title}`, async () => { + const requestBody = test.request; + await supertest + .post(`${getUrlPrefix(spaceId)}/api/spaces/_disable_legacy_url_aliases`) + .auth(user?.username, user?.password) + .send(requestBody) + .expect(test.responseStatusCode) + .then(test.responseBody); + }); + } + }); + }; + + const addTests = makeDisableLegacyUrlAliasesTest(describe); + // @ts-ignore + addTests.only = makeDisableLegacyUrlAliasesTest(describe.only); + + return { + addTests, + createTestDefinitions, + }; +} diff --git a/x-pack/test/spaces_api_integration/security_and_spaces/apis/disable_legacy_url_aliases.ts b/x-pack/test/spaces_api_integration/security_and_spaces/apis/disable_legacy_url_aliases.ts new file mode 100644 index 0000000000000..c89bc519468ad --- /dev/null +++ b/x-pack/test/spaces_api_integration/security_and_spaces/apis/disable_legacy_url_aliases.ts @@ -0,0 +1,80 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { SPACES } from '../../common/lib/spaces'; +import { getTestScenarios } from '../../../saved_object_api_integration/common/lib/saved_object_test_utils'; +import { TestUser } from '../../../saved_object_api_integration/common/lib/types'; +import { + disableLegacyUrlAliasesTestSuiteFactory, + DisableLegacyUrlAliasesTestCase, + TEST_CASE_TARGET_TYPE, + TEST_CASE_SOURCE_ID, + DisableLegacyUrlAliasesTestDefinition, +} from '../../common/suites/disable_legacy_url_aliases'; +import { FtrProviderContext } from '../../common/ftr_provider_context'; + +const { + SPACE_1: { spaceId: SPACE_1_ID }, + SPACE_2: { spaceId: SPACE_2_ID }, +} = SPACES; + +const createTestCases = (...spaceIds: string[]): DisableLegacyUrlAliasesTestCase[] => { + return spaceIds.map((targetSpace) => ({ + targetSpace, + targetType: TEST_CASE_TARGET_TYPE, + sourceId: TEST_CASE_SOURCE_ID, + })); +}; + +// eslint-disable-next-line import/no-default-export +export default function ({ getService }: FtrProviderContext) { + const supertest = getService('supertestWithoutAuth'); + const esArchiver = getService('esArchiver'); + const es = getService('es'); + + const { addTests, createTestDefinitions } = disableLegacyUrlAliasesTestSuiteFactory( + es, + esArchiver, + supertest + ); + + describe('_disable_legacy_url_aliases', () => { + const _addTests = (user: TestUser, tests: DisableLegacyUrlAliasesTestDefinition[]) => { + addTests(`${user.description}`, { user, tests }); + }; + getTestScenarios().security.forEach(({ users }) => { + // We are intentionally using "security" test scenarios here, *not* "securityAndSpaces", because of how these tests are structured. + + [ + users.noAccess, + users.legacyAll, + users.dualRead, + users.readGlobally, + users.allAtDefaultSpace, + users.readAtDefaultSpace, + users.readAtSpace1, + ].forEach((user) => { + const unauthorized = createTestDefinitions(createTestCases(SPACE_1_ID, SPACE_2_ID), true); + _addTests(user, unauthorized); + }); + + const authorizedSpace1 = [ + ...createTestDefinitions(createTestCases(SPACE_1_ID), false), + ...createTestDefinitions(createTestCases(SPACE_2_ID), true), + ]; + _addTests(users.allAtSpace1, authorizedSpace1); + + [users.dualAll, users.allGlobally, users.superuser].forEach((user) => { + const authorizedGlobally = createTestDefinitions( + createTestCases(SPACE_1_ID, SPACE_2_ID), + false + ); + _addTests(user, authorizedGlobally); + }); + }); + }); +} diff --git a/x-pack/test/spaces_api_integration/security_and_spaces/apis/index.ts b/x-pack/test/spaces_api_integration/security_and_spaces/apis/index.ts index 4bb4d10eaabf8..a86fef0d758fc 100644 --- a/x-pack/test/spaces_api_integration/security_and_spaces/apis/index.ts +++ b/x-pack/test/spaces_api_integration/security_and_spaces/apis/index.ts @@ -29,5 +29,6 @@ export default function ({ loadTestFile, getService }: FtrProviderContext) { loadTestFile(require.resolve('./get')); loadTestFile(require.resolve('./update')); loadTestFile(require.resolve('./update_objects_spaces')); + loadTestFile(require.resolve('./disable_legacy_url_aliases')); }); } diff --git a/x-pack/test/spaces_api_integration/spaces_only/apis/disable_legacy_url_aliases.ts b/x-pack/test/spaces_api_integration/spaces_only/apis/disable_legacy_url_aliases.ts new file mode 100644 index 0000000000000..4cb73a7849b43 --- /dev/null +++ b/x-pack/test/spaces_api_integration/spaces_only/apis/disable_legacy_url_aliases.ts @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { SPACES } from '../../common/lib/spaces'; +import { + disableLegacyUrlAliasesTestSuiteFactory, + DisableLegacyUrlAliasesTestCase, + TEST_CASE_TARGET_TYPE, + TEST_CASE_SOURCE_ID, +} from '../../common/suites/disable_legacy_url_aliases'; +import { FtrProviderContext } from '../../common/ftr_provider_context'; + +const { + SPACE_1: { spaceId: SPACE_1_ID }, + SPACE_2: { spaceId: SPACE_2_ID }, +} = SPACES; + +const createTestCases = (...spaceIds: string[]): DisableLegacyUrlAliasesTestCase[] => { + return spaceIds.map((targetSpace) => ({ + targetSpace, + targetType: TEST_CASE_TARGET_TYPE, + sourceId: TEST_CASE_SOURCE_ID, + })); +}; + +// eslint-disable-next-line import/no-default-export +export default function ({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + const esArchiver = getService('esArchiver'); + const es = getService('es'); + + const { addTests, createTestDefinitions } = disableLegacyUrlAliasesTestSuiteFactory( + es, + esArchiver, + supertest + ); + + const testCases = createTestCases(SPACE_1_ID, SPACE_2_ID); + const tests = createTestDefinitions(testCases, false); + addTests(`_disable_legacy_url_aliases`, { tests }); +} diff --git a/x-pack/test/spaces_api_integration/spaces_only/apis/index.ts b/x-pack/test/spaces_api_integration/spaces_only/apis/index.ts index 489e2c2d22ffa..f64336b2b4908 100644 --- a/x-pack/test/spaces_api_integration/spaces_only/apis/index.ts +++ b/x-pack/test/spaces_api_integration/spaces_only/apis/index.ts @@ -21,5 +21,6 @@ export default function spacesOnlyTestSuite({ loadTestFile }: FtrProviderContext loadTestFile(require.resolve('./get')); loadTestFile(require.resolve('./update')); loadTestFile(require.resolve('./update_objects_spaces')); + loadTestFile(require.resolve('./disable_legacy_url_aliases')); }); }