diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 0744112650c23f..1d5dfa8dd57c72 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -261,6 +261,7 @@ /x-pack/test/ui_capabilities/ @elastic/kibana-security /x-pack/test/encrypted_saved_objects_api_integration/ @elastic/kibana-security /x-pack/test/functional/apps/security/ @elastic/kibana-security +/x-pack/test/saved_object_access_control/ @elastic/kibana-security /x-pack/test/security_api_integration/ @elastic/kibana-security /x-pack/test/security_functional/ @elastic/kibana-security /x-pack/test/spaces_api_integration/ @elastic/kibana-security diff --git a/docs/development/core/public/kibana-plugin-core-public.md b/docs/development/core/public/kibana-plugin-core-public.md index d743508e046ea8..f1024f3f2359e9 100644 --- a/docs/development/core/public/kibana-plugin-core-public.md +++ b/docs/development/core/public/kibana-plugin-core-public.md @@ -103,6 +103,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | [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) | | +| [SavedObjectAccessControl](./kibana-plugin-core-public.savedobjectaccesscontrol.md) | The "Access Control" describing which users should be authorized to access this SavedObject. | | [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) | | | [SavedObjectReference](./kibana-plugin-core-public.savedobjectreference.md) | A reference to another saved object. | diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobject.accesscontrol.md b/docs/development/core/public/kibana-plugin-core-public.savedobject.accesscontrol.md new file mode 100644 index 00000000000000..c33a64486020fa --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.savedobject.accesscontrol.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SavedObject](./kibana-plugin-core-public.savedobject.md) > [accessControl](./kibana-plugin-core-public.savedobject.accesscontrol.md) + +## SavedObject.accessControl property + +Signature: + +```typescript +accessControl?: SavedObjectAccessControl; +``` 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 26f472b741268a..ef043b9cb18f9e 100644 --- a/docs/development/core/public/kibana-plugin-core-public.savedobject.md +++ b/docs/development/core/public/kibana-plugin-core-public.savedobject.md @@ -14,6 +14,7 @@ export interface SavedObject | Property | Type | Description | | --- | --- | --- | +| [accessControl](./kibana-plugin-core-public.savedobject.accesscontrol.md) | SavedObjectAccessControl | | | [attributes](./kibana-plugin-core-public.savedobject.attributes.md) | T | The data for a Saved Object is stored as an object in the attributes property. | | [coreMigrationVersion](./kibana-plugin-core-public.savedobject.coremigrationversion.md) | string | A semver value that is used when upgrading objects between Kibana versions. | | [error](./kibana-plugin-core-public.savedobject.error.md) | SavedObjectError | | diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectaccesscontrol.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectaccesscontrol.md new file mode 100644 index 00000000000000..ad1b391f54389a --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectaccesscontrol.md @@ -0,0 +1,20 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SavedObjectAccessControl](./kibana-plugin-core-public.savedobjectaccesscontrol.md) + +## SavedObjectAccessControl interface + +The "Access Control" describing which users should be authorized to access this SavedObject. + +Signature: + +```typescript +export interface SavedObjectAccessControl +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [owner](./kibana-plugin-core-public.savedobjectaccesscontrol.owner.md) | string | The owner of this SavedObject. | + diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectaccesscontrol.owner.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectaccesscontrol.owner.md new file mode 100644 index 00000000000000..6781589367d876 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectaccesscontrol.owner.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SavedObjectAccessControl](./kibana-plugin-core-public.savedobjectaccesscontrol.md) > [owner](./kibana-plugin-core-public.savedobjectaccesscontrol.owner.md) + +## SavedObjectAccessControl.owner property + +The owner of this SavedObject. + +Signature: + +```typescript +owner: string; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectreferencewithcontext.accesscontrol.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectreferencewithcontext.accesscontrol.md new file mode 100644 index 00000000000000..5abba34423fc87 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectreferencewithcontext.accesscontrol.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SavedObjectReferenceWithContext](./kibana-plugin-core-public.savedobjectreferencewithcontext.md) > [accessControl](./kibana-plugin-core-public.savedobjectreferencewithcontext.accesscontrol.md) + +## SavedObjectReferenceWithContext.accessControl property + +The access control of the referenced object + +Signature: + +```typescript +accessControl?: SavedObjectAccessControl; +``` diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectreferencewithcontext.inboundreferences.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectreferencewithcontext.inboundreferences.md index 722b11f0c7ba9f..c75fbe49d6f869 100644 --- a/docs/development/core/public/kibana-plugin-core-public.savedobjectreferencewithcontext.inboundreferences.md +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectreferencewithcontext.inboundreferences.md @@ -12,6 +12,7 @@ References to this object; note that this does not contain \_all inbound referen inboundReferences: Array<{ type: string; id: string; + accessControl?: SavedObjectAccessControl; name: string; }>; ``` diff --git a/docs/development/core/public/kibana-plugin-core-public.savedobjectreferencewithcontext.md b/docs/development/core/public/kibana-plugin-core-public.savedobjectreferencewithcontext.md index a79fa96695e36b..7d4b9bb31d31c6 100644 --- a/docs/development/core/public/kibana-plugin-core-public.savedobjectreferencewithcontext.md +++ b/docs/development/core/public/kibana-plugin-core-public.savedobjectreferencewithcontext.md @@ -16,8 +16,9 @@ export interface SavedObjectReferenceWithContext | Property | Type | Description | | --- | --- | --- | +| [accessControl](./kibana-plugin-core-public.savedobjectreferencewithcontext.accesscontrol.md) | SavedObjectAccessControl | The access control of the referenced object | | [id](./kibana-plugin-core-public.savedobjectreferencewithcontext.id.md) | string | The ID of the referenced object | -| [inboundReferences](./kibana-plugin-core-public.savedobjectreferencewithcontext.inboundreferences.md) | Array<{
type: string;
id: string;
name: string;
}> | References to this object; note that this does not contain \_all inbound references everywhere for this object\_, it only contains inbound references for the scope of this operation | +| [inboundReferences](./kibana-plugin-core-public.savedobjectreferencewithcontext.inboundreferences.md) | Array<{
type: string;
id: string;
accessControl?: SavedObjectAccessControl;
name: string;
}> | References to this object; note that this does not contain \_all inbound references everywhere for this object\_, it only contains inbound references for the scope of this operation | | [isMissing](./kibana-plugin-core-public.savedobjectreferencewithcontext.ismissing.md) | boolean | Whether or not this object or reference is missing | | [spaces](./kibana-plugin-core-public.savedobjectreferencewithcontext.spaces.md) | string[] | The space(s) that the referenced object exists in | | [spacesWithMatchingAliases](./kibana-plugin-core-public.savedobjectreferencewithcontext.spaceswithmatchingaliases.md) | string[] | The space(s) that legacy URL aliases matching this type/id exist in | diff --git a/docs/development/core/server/kibana-plugin-core-server.md b/docs/development/core/server/kibana-plugin-core-server.md index a26f8bd7b15944..46e38e8ce622b8 100644 --- a/docs/development/core/server/kibana-plugin-core-server.md +++ b/docs/development/core/server/kibana-plugin-core-server.md @@ -149,6 +149,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | [RouteValidatorConfig](./kibana-plugin-core-server.routevalidatorconfig.md) | The configuration object to the RouteValidator class. Set params, query and/or body to specify the validation logic to follow for that property. | | [RouteValidatorOptions](./kibana-plugin-core-server.routevalidatoroptions.md) | Additional options for the RouteValidator class to modify its default behaviour. | | [SavedObject](./kibana-plugin-core-server.savedobject.md) | | +| [SavedObjectAccessControl](./kibana-plugin-core-server.savedobjectaccesscontrol.md) | The "Access Control" describing which users should be authorized to access this SavedObject. | | [SavedObjectAttributes](./kibana-plugin-core-server.savedobjectattributes.md) | The data for a Saved Object is stored as an object in the attributes property. | | [SavedObjectExportBaseOptions](./kibana-plugin-core-server.savedobjectexportbaseoptions.md) | | | [SavedObjectMigrationContext](./kibana-plugin-core-server.savedobjectmigrationcontext.md) | Migration context provided when invoking a [migration handler](./kibana-plugin-core-server.savedobjectmigrationfn.md) | @@ -163,6 +164,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | [SavedObjectsBulkUpdateOptions](./kibana-plugin-core-server.savedobjectsbulkupdateoptions.md) | | | [SavedObjectsBulkUpdateResponse](./kibana-plugin-core-server.savedobjectsbulkupdateresponse.md) | | | [SavedObjectsCheckConflictsObject](./kibana-plugin-core-server.savedobjectscheckconflictsobject.md) | | +| [SavedObjectsCheckConflictsOptions](./kibana-plugin-core-server.savedobjectscheckconflictsoptions.md) | | | [SavedObjectsCheckConflictsResponse](./kibana-plugin-core-server.savedobjectscheckconflictsresponse.md) | | | [SavedObjectsClientProviderOptions](./kibana-plugin-core-server.savedobjectsclientprovideroptions.md) | Options to control the creation of the Saved Objects Client. | | [SavedObjectsClientWrapperOptions](./kibana-plugin-core-server.savedobjectsclientwrapperoptions.md) | Options passed to each SavedObjectsClientWrapperFactory to aid in creating the wrapper instance. | @@ -318,6 +320,7 @@ The plugin integrates with the core system via lifecycle events: `setup` | [SavedObjectAttributeSingle](./kibana-plugin-core-server.savedobjectattributesingle.md) | Don't use this type, it's simply a helper type for [SavedObjectAttribute](./kibana-plugin-core-server.savedobjectattribute.md) | | [SavedObjectMigrationFn](./kibana-plugin-core-server.savedobjectmigrationfn.md) | A migration function for a [saved object type](./kibana-plugin-core-server.savedobjectstype.md) used to migrate it to a given version | | [SavedObjectSanitizedDoc](./kibana-plugin-core-server.savedobjectsanitizeddoc.md) | Describes Saved Object documents that have passed through the migration framework and are guaranteed to have a references root property. | +| [SavedObjectsBulkCreateOptions](./kibana-plugin-core-server.savedobjectsbulkcreateoptions.md) | | | [SavedObjectsClientContract](./kibana-plugin-core-server.savedobjectsclientcontract.md) | Saved Objects is Kibana's data persisentence mechanism allowing plugins to use Elasticsearch for storing plugin state.\#\# SavedObjectsClient errorsSince the SavedObjectsClient has its hands in everything we are a little paranoid about the way we present errors back to to application code. Ideally, all errors will be either:1. Caused by bad implementation (ie. undefined is not a function) and as such unpredictable 2. An error that has been classified and decorated appropriately by the decorators in [SavedObjectsErrorHelpers](./kibana-plugin-core-server.savedobjectserrorhelpers.md)Type 1 errors are inevitable, but since all expected/handle-able errors should be Type 2 the isXYZError() helpers exposed at SavedObjectsErrorHelpers should be used to understand and manage error responses from the SavedObjectsClient.Type 2 errors are decorated versions of the source error, so if the elasticsearch client threw an error it will be decorated based on its type. That means that rather than looking for error.body.error.type or doing substring checks on error.body.error.reason, just use the helpers to understand the meaning of the error:\`\`\`js if (SavedObjectsErrorHelpers.isNotFoundError(error)) { // handle 404 }if (SavedObjectsErrorHelpers.isNotAuthorizedError(error)) { // 401 handling should be automatic, but in case you wanted to know }// always rethrow the error unless you handle it throw error; \`\`\`\#\#\# 404s from missing indexFrom the perspective of application code and APIs the SavedObjectsClient is a black box that persists objects. One of the internal details that users have no control over is that we use an elasticsearch index for persistance and that index might be missing.At the time of writing we are in the process of transitioning away from the operating assumption that the SavedObjects index is always available. Part of this transition is handling errors resulting from an index missing. These used to trigger a 500 error in most cases, and in others cause 404s with different error messages.From my (Spencer) perspective, a 404 from the SavedObjectsApi is a 404; The object the request/call was targeting could not be found. This is why \#14141 takes special care to ensure that 404 errors are generic and don't distinguish between index missing or document missing.See [SavedObjectsClient](./kibana-plugin-core-server.savedobjectsclient.md) See [SavedObjectsErrorHelpers](./kibana-plugin-core-server.savedobjectserrorhelpers.md) | | [SavedObjectsClientFactory](./kibana-plugin-core-server.savedobjectsclientfactory.md) | Describes the factory used to create instances of the Saved Objects Client. | | [SavedObjectsClientFactoryProvider](./kibana-plugin-core-server.savedobjectsclientfactoryprovider.md) | Provider to invoke to retrieve a [SavedObjectsClientFactory](./kibana-plugin-core-server.savedobjectsclientfactory.md). | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobject.accesscontrol.md b/docs/development/core/server/kibana-plugin-core-server.savedobject.accesscontrol.md new file mode 100644 index 00000000000000..cd55e571cb3eb5 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobject.accesscontrol.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObject](./kibana-plugin-core-server.savedobject.md) > [accessControl](./kibana-plugin-core-server.savedobject.accesscontrol.md) + +## SavedObject.accessControl property + +Signature: + +```typescript +accessControl?: SavedObjectAccessControl; +``` 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 4c62b359b284dd..93ca1ee078b5ca 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobject.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobject.md @@ -14,6 +14,7 @@ export interface SavedObject | Property | Type | Description | | --- | --- | --- | +| [accessControl](./kibana-plugin-core-server.savedobject.accesscontrol.md) | SavedObjectAccessControl | | | [attributes](./kibana-plugin-core-server.savedobject.attributes.md) | T | The data for a Saved Object is stored as an object in the attributes property. | | [coreMigrationVersion](./kibana-plugin-core-server.savedobject.coremigrationversion.md) | string | A semver value that is used when upgrading objects between Kibana versions. | | [error](./kibana-plugin-core-server.savedobject.error.md) | SavedObjectError | | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectaccesscontrol.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectaccesscontrol.md new file mode 100644 index 00000000000000..01d19205f520c6 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectaccesscontrol.md @@ -0,0 +1,20 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectAccessControl](./kibana-plugin-core-server.savedobjectaccesscontrol.md) + +## SavedObjectAccessControl interface + +The "Access Control" describing which users should be authorized to access this SavedObject. + +Signature: + +```typescript +export interface SavedObjectAccessControl +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [owner](./kibana-plugin-core-server.savedobjectaccesscontrol.owner.md) | string | The owner of this SavedObject. | + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectaccesscontrol.owner.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectaccesscontrol.owner.md new file mode 100644 index 00000000000000..201c74fca5a261 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectaccesscontrol.owner.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectAccessControl](./kibana-plugin-core-server.savedobjectaccesscontrol.md) > [owner](./kibana-plugin-core-server.savedobjectaccesscontrol.owner.md) + +## SavedObjectAccessControl.owner property + +The owner of this SavedObject. + +Signature: + +```typescript +owner: string; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectreferencewithcontext.accesscontrol.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectreferencewithcontext.accesscontrol.md new file mode 100644 index 00000000000000..891744d945f8ac --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectreferencewithcontext.accesscontrol.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectReferenceWithContext](./kibana-plugin-core-server.savedobjectreferencewithcontext.md) > [accessControl](./kibana-plugin-core-server.savedobjectreferencewithcontext.accesscontrol.md) + +## SavedObjectReferenceWithContext.accessControl property + +The access control of the referenced object + +Signature: + +```typescript +accessControl?: SavedObjectAccessControl; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectreferencewithcontext.inboundreferences.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectreferencewithcontext.inboundreferences.md index 058c27032d0653..598b10869f4d38 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectreferencewithcontext.inboundreferences.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectreferencewithcontext.inboundreferences.md @@ -12,6 +12,7 @@ References to this object; note that this does not contain \_all inbound referen inboundReferences: Array<{ type: string; id: string; + accessControl?: SavedObjectAccessControl; name: string; }>; ``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectreferencewithcontext.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectreferencewithcontext.md index 1f8b33c6e94e8d..67f70c2c3176c8 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectreferencewithcontext.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectreferencewithcontext.md @@ -16,8 +16,9 @@ export interface SavedObjectReferenceWithContext | Property | Type | Description | | --- | --- | --- | +| [accessControl](./kibana-plugin-core-server.savedobjectreferencewithcontext.accesscontrol.md) | SavedObjectAccessControl | The access control of the referenced object | | [id](./kibana-plugin-core-server.savedobjectreferencewithcontext.id.md) | string | The ID of the referenced object | -| [inboundReferences](./kibana-plugin-core-server.savedobjectreferencewithcontext.inboundreferences.md) | Array<{
type: string;
id: string;
name: string;
}> | References to this object; note that this does not contain \_all inbound references everywhere for this object\_, it only contains inbound references for the scope of this operation | +| [inboundReferences](./kibana-plugin-core-server.savedobjectreferencewithcontext.inboundreferences.md) | Array<{
type: string;
id: string;
accessControl?: SavedObjectAccessControl;
name: string;
}> | References to this object; note that this does not contain \_all inbound references everywhere for this object\_, it only contains inbound references for the scope of this operation | | [isMissing](./kibana-plugin-core-server.savedobjectreferencewithcontext.ismissing.md) | boolean | Whether or not this object or reference is missing | | [spaces](./kibana-plugin-core-server.savedobjectreferencewithcontext.spaces.md) | string[] | The space(s) that the referenced object exists in | | [spacesWithMatchingAliases](./kibana-plugin-core-server.savedobjectreferencewithcontext.spaceswithmatchingaliases.md) | string[] | The space(s) that legacy URL aliases matching this type/id exist in | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsbulkcreateobject.accesscontrol.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsbulkcreateobject.accesscontrol.md new file mode 100644 index 00000000000000..45df2a0c31a645 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsbulkcreateobject.accesscontrol.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsBulkCreateObject](./kibana-plugin-core-server.savedobjectsbulkcreateobject.md) > [accessControl](./kibana-plugin-core-server.savedobjectsbulkcreateobject.accesscontrol.md) + +## SavedObjectsBulkCreateObject.accessControl property + +The [accessControl](./kibana-plugin-core-server.savedobjectaccesscontrol.md) to associate with this saved object. + +Signature: + +```typescript +accessControl?: SavedObjectAccessControl; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsbulkcreateobject.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsbulkcreateobject.md index 463c3fe81b7029..61311af30d7283 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsbulkcreateobject.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsbulkcreateobject.md @@ -15,6 +15,7 @@ export interface SavedObjectsBulkCreateObject | Property | Type | Description | | --- | --- | --- | +| [accessControl](./kibana-plugin-core-server.savedobjectsbulkcreateobject.accesscontrol.md) | SavedObjectAccessControl | The [accessControl](./kibana-plugin-core-server.savedobjectaccesscontrol.md) to associate with this saved object. | | [attributes](./kibana-plugin-core-server.savedobjectsbulkcreateobject.attributes.md) | T | | | [coreMigrationVersion](./kibana-plugin-core-server.savedobjectsbulkcreateobject.coremigrationversion.md) | string | A semver value that is used when upgrading objects between Kibana versions. If undefined, this will be automatically set to the current Kibana version when the object is created. If this is set to a non-semver value, or it is set to a semver value greater than the current Kibana version, it will result in an error. | | [id](./kibana-plugin-core-server.savedobjectsbulkcreateobject.id.md) | string | | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsbulkcreateoptions.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsbulkcreateoptions.md new file mode 100644 index 00000000000000..5a63f7351742c0 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsbulkcreateoptions.md @@ -0,0 +1,12 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsBulkCreateOptions](./kibana-plugin-core-server.savedobjectsbulkcreateoptions.md) + +## SavedObjectsBulkCreateOptions type + + +Signature: + +```typescript +export declare type SavedObjectsBulkCreateOptions = Omit; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectscheckconflictsoptions.accesscontrol.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectscheckconflictsoptions.accesscontrol.md new file mode 100644 index 00000000000000..02af4f19a74af6 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectscheckconflictsoptions.accesscontrol.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsCheckConflictsOptions](./kibana-plugin-core-server.savedobjectscheckconflictsoptions.md) > [accessControl](./kibana-plugin-core-server.savedobjectscheckconflictsoptions.accesscontrol.md) + +## SavedObjectsCheckConflictsOptions.accessControl property + +An [accessControl](./kibana-plugin-core-server.savedobjectaccesscontrol.md) which should be compatible with conflicting objects. + +Signature: + +```typescript +accessControl?: SavedObjectAccessControl; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectscheckconflictsoptions.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectscheckconflictsoptions.md new file mode 100644 index 00000000000000..74601b23b5e0ef --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectscheckconflictsoptions.md @@ -0,0 +1,19 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsCheckConflictsOptions](./kibana-plugin-core-server.savedobjectscheckconflictsoptions.md) + +## SavedObjectsCheckConflictsOptions interface + + +Signature: + +```typescript +export interface SavedObjectsCheckConflictsOptions extends SavedObjectsBaseOptions +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [accessControl](./kibana-plugin-core-server.savedobjectscheckconflictsoptions.accesscontrol.md) | SavedObjectAccessControl | An [accessControl](./kibana-plugin-core-server.savedobjectaccesscontrol.md) which should be compatible with conflicting objects. | + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.checkconflicts.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.checkconflicts.md index 5cffb0c498b0b5..dba5404171d185 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.checkconflicts.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsclient.checkconflicts.md @@ -9,7 +9,7 @@ Check what conflicts will result when creating a given array of saved objects. T Signature: ```typescript -checkConflicts(objects?: SavedObjectsCheckConflictsObject[], options?: SavedObjectsBaseOptions): Promise; +checkConflicts(objects?: SavedObjectsCheckConflictsObject[], options?: SavedObjectsCheckConflictsOptions): Promise; ``` ## Parameters @@ -17,7 +17,7 @@ checkConflicts(objects?: SavedObjectsCheckConflictsObject[], options?: SavedObje | Parameter | Type | Description | | --- | --- | --- | | objects | SavedObjectsCheckConflictsObject[] | | -| options | SavedObjectsBaseOptions | | +| options | SavedObjectsCheckConflictsOptions | | Returns: diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectscreateoptions.accesscontrol.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectscreateoptions.accesscontrol.md new file mode 100644 index 00000000000000..33130326c797d8 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectscreateoptions.accesscontrol.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsCreateOptions](./kibana-plugin-core-server.savedobjectscreateoptions.md) > [accessControl](./kibana-plugin-core-server.savedobjectscreateoptions.accesscontrol.md) + +## SavedObjectsCreateOptions.accessControl property + +The [accessControl](./kibana-plugin-core-server.savedobjectaccesscontrol.md) to associate with this saved object. + +Signature: + +```typescript +accessControl?: SavedObjectAccessControl; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectscreateoptions.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectscreateoptions.md index 7eaa9c51f5c82c..209b9760158072 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectscreateoptions.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectscreateoptions.md @@ -15,6 +15,7 @@ export interface SavedObjectsCreateOptions extends SavedObjectsBaseOptions | Property | Type | Description | | --- | --- | --- | +| [accessControl](./kibana-plugin-core-server.savedobjectscreateoptions.accesscontrol.md) | SavedObjectAccessControl | The [accessControl](./kibana-plugin-core-server.savedobjectaccesscontrol.md) to associate with this saved object. | | [coreMigrationVersion](./kibana-plugin-core-server.savedobjectscreateoptions.coremigrationversion.md) | string | A semver value that is used when upgrading objects between Kibana versions. If undefined, this will be automatically set to the current Kibana version when the object is created. If this is set to a non-semver value, or it is set to a semver value greater than the current Kibana version, it will result in an error. | | [id](./kibana-plugin-core-server.savedobjectscreateoptions.id.md) | string | (not recommended) Specify an id for the document | | [initialNamespaces](./kibana-plugin-core-server.savedobjectscreateoptions.initialnamespaces.md) | string[] | Optional initial namespaces for the object to be created in. If this is defined, it will supersede the namespace ID that is in [SavedObjectsCreateOptions](./kibana-plugin-core-server.savedobjectscreateoptions.md).\* For shareable object types (registered with namespaceType: 'multiple'): this option can be used to specify one or more spaces, including the "All spaces" identifier ('*'). \* For isolated object types (registered with namespaceType: 'single' or namespaceType: 'multiple-isolated'): this option can only be used to specify a single space, and the "All spaces" identifier ('*') is not allowed. \* For global object types (registered with namespaceType: 'agnostic'): this option cannot be used. | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectserrorhelpers.createincompatibleaccesscontrolerror.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectserrorhelpers.createincompatibleaccesscontrolerror.md new file mode 100644 index 00000000000000..740021e4fbd79c --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectserrorhelpers.createincompatibleaccesscontrolerror.md @@ -0,0 +1,23 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsErrorHelpers](./kibana-plugin-core-server.savedobjectserrorhelpers.md) > [createIncompatibleAccessControlError](./kibana-plugin-core-server.savedobjectserrorhelpers.createincompatibleaccesscontrolerror.md) + +## SavedObjectsErrorHelpers.createIncompatibleAccessControlError() method + +Signature: + +```typescript +static createIncompatibleAccessControlError(type: string, id: string): DecoratedError; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| type | string | | +| id | string | | + +Returns: + +`DecoratedError` + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectserrorhelpers.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectserrorhelpers.md index 2dc78f2df3a833..e70a8fdbeb1b1f 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectserrorhelpers.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectserrorhelpers.md @@ -18,6 +18,7 @@ export declare class SavedObjectsErrorHelpers | [createBadRequestError(reason)](./kibana-plugin-core-server.savedobjectserrorhelpers.createbadrequesterror.md) | static | | | [createConflictError(type, id, reason)](./kibana-plugin-core-server.savedobjectserrorhelpers.createconflicterror.md) | static | | | [createGenericNotFoundError(type, id)](./kibana-plugin-core-server.savedobjectserrorhelpers.creategenericnotfounderror.md) | static | | +| [createIncompatibleAccessControlError(type, id)](./kibana-plugin-core-server.savedobjectserrorhelpers.createincompatibleaccesscontrolerror.md) | static | | | [createIndexAliasNotFoundError(alias)](./kibana-plugin-core-server.savedobjectserrorhelpers.createindexaliasnotfounderror.md) | static | | | [createInvalidVersionError(versionInput)](./kibana-plugin-core-server.savedobjectserrorhelpers.createinvalidversionerror.md) | static | | | [createTooManyRequestsError(type, id)](./kibana-plugin-core-server.savedobjectserrorhelpers.createtoomanyrequestserror.md) | static | | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.bulkcreate.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.bulkcreate.md index 17daf3ab1f0423..cda7685a62abb9 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.bulkcreate.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.bulkcreate.md @@ -9,7 +9,7 @@ Creates multiple documents at once Signature: ```typescript -bulkCreate(objects: Array>, options?: SavedObjectsCreateOptions): Promise>; +bulkCreate(objects: Array>, options?: SavedObjectsBulkCreateOptions): Promise>; ``` ## Parameters @@ -17,7 +17,7 @@ bulkCreate(objects: Array>, options | Parameter | Type | Description | | --- | --- | --- | | objects | Array<SavedObjectsBulkCreateObject<T>> | | -| options | SavedObjectsCreateOptions | | +| options | SavedObjectsBulkCreateOptions | | Returns: diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.checkconflicts.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.checkconflicts.md index 6e44bd704d6a7d..aee3e4b408167b 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.checkconflicts.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsrepository.checkconflicts.md @@ -9,7 +9,7 @@ Check what conflicts will result when creating a given array of saved objects. T Signature: ```typescript -checkConflicts(objects?: SavedObjectsCheckConflictsObject[], options?: SavedObjectsBaseOptions): Promise; +checkConflicts(objects?: SavedObjectsCheckConflictsObject[], options?: SavedObjectsCheckConflictsOptions): Promise; ``` ## Parameters @@ -17,7 +17,7 @@ checkConflicts(objects?: SavedObjectsCheckConflictsObject[], options?: SavedObje | Parameter | Type | Description | | --- | --- | --- | | objects | SavedObjectsCheckConflictsObject[] | | -| options | SavedObjectsBaseOptions | | +| options | SavedObjectsCheckConflictsOptions | | Returns: diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectstype.accessclassification.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectstype.accessclassification.md new file mode 100644 index 00000000000000..afa38594095f57 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectstype.accessclassification.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsType](./kibana-plugin-core-server.savedobjectstype.md) > [accessClassification](./kibana-plugin-core-server.savedobjectstype.accessclassification.md) + +## SavedObjectsType.accessClassification property + +The for the type. + +Signature: + +```typescript +accessClassification?: SavedObjectsAccessClassification; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectstype.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectstype.md index c3aba5261561ff..729e1e9e23e1a3 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectstype.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectstype.md @@ -18,6 +18,7 @@ This is only internal for now, and will only be public when we expose the regist | Property | Type | Description | | --- | --- | --- | +| [accessClassification](./kibana-plugin-core-server.savedobjectstype.accessclassification.md) | SavedObjectsAccessClassification | The for the type. | | [convertToAliasScript](./kibana-plugin-core-server.savedobjectstype.converttoaliasscript.md) | string | If defined, will be used to convert the type to an alias. | | [convertToMultiNamespaceTypeVersion](./kibana-plugin-core-server.savedobjectstype.converttomultinamespacetypeversion.md) | string | If defined, objects of this type will be converted to a 'multiple' or 'multiple-isolated' namespace type when migrating to this version.Requirements:1. This string value must be a valid semver version 2. This type must have previously specified [\`namespaceType: 'single'\`](./kibana-plugin-core-server.savedobjectsnamespacetype.md) 3. This type must also specify [\`namespaceType: 'multiple'\`](./kibana-plugin-core-server.savedobjectsnamespacetype.md) \*or\* [\`namespaceType: 'multiple-isolated'\`](./kibana-plugin-core-server.savedobjectsnamespacetype.md)Example of a single-namespace type in 7.12: ```ts diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjecttyperegistry.isprivate.md b/docs/development/core/server/kibana-plugin-core-server.savedobjecttyperegistry.isprivate.md new file mode 100644 index 00000000000000..322a3bdbedd1b4 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjecttyperegistry.isprivate.md @@ -0,0 +1,24 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectTypeRegistry](./kibana-plugin-core-server.savedobjecttyperegistry.md) > [isPrivate](./kibana-plugin-core-server.savedobjecttyperegistry.isprivate.md) + +## SavedObjectTypeRegistry.isPrivate() method + +Returns `true` if the given type is marked as `private`, and `false` otherwise. + +Signature: + +```typescript +isPrivate(type: string): boolean; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| type | string | | + +Returns: + +`boolean` + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjecttyperegistry.md b/docs/development/core/server/kibana-plugin-core-server.savedobjecttyperegistry.md index 0f2de8c8ef9b33..308cde47659db7 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjecttyperegistry.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjecttyperegistry.md @@ -25,6 +25,7 @@ export declare class SavedObjectTypeRegistry | [isImportableAndExportable(type)](./kibana-plugin-core-server.savedobjecttyperegistry.isimportableandexportable.md) | | Returns the management.importableAndExportable property for given type, or false if the type is not registered or does not define a management section. | | [isMultiNamespace(type)](./kibana-plugin-core-server.savedobjecttyperegistry.ismultinamespace.md) | | Returns whether the type is multi-namespace (shareable \*or\* isolated); resolves to false if the type is not registered | | [isNamespaceAgnostic(type)](./kibana-plugin-core-server.savedobjecttyperegistry.isnamespaceagnostic.md) | | Returns whether the type is namespace-agnostic (global); resolves to false if the type is not registered | +| [isPrivate(type)](./kibana-plugin-core-server.savedobjecttyperegistry.isprivate.md) | | Returns true if the given type is marked as private, and false otherwise. | | [isShareable(type)](./kibana-plugin-core-server.savedobjecttyperegistry.isshareable.md) | | Returns whether the type is multi-namespace (shareable); resolves to false if the type is not registered | | [isSingleNamespace(type)](./kibana-plugin-core-server.savedobjecttyperegistry.issinglenamespace.md) | | Returns whether the type is single-namespace (isolated); resolves to true if the type is not registered | | [registerType(type)](./kibana-plugin-core-server.savedobjecttyperegistry.registertype.md) | | Register a [type](./kibana-plugin-core-server.savedobjectstype.md) inside the registry. A type can only be registered once. subsequent calls with the same type name will throw an error. | diff --git a/src/core/public/index.ts b/src/core/public/index.ts index 40304d27580ca8..c955fa7c15825b 100644 --- a/src/core/public/index.ts +++ b/src/core/public/index.ts @@ -102,6 +102,7 @@ export type { export { SimpleSavedObject } from './saved_objects'; export type { ResolvedSimpleSavedObject } from './saved_objects'; export type { + SavedObjectAccessControl, SavedObjectsBatchResponse, SavedObjectsBulkCreateObject, SavedObjectsBulkCreateOptions, diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index 673cb2e7ab5577..13d93c39a39605 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -38,6 +38,7 @@ import React from 'react'; import { RecursiveReadonly } from '@kbn/utility-types'; import { Request } from '@hapi/hapi'; import * as Rx from 'rxjs'; +import { SavedObjectAccessControl as SavedObjectAccessControl_2 } from 'src/core/types'; import { SchemaTypeError } from '@kbn/config-schema'; import { TransportRequestOptions } from '@elastic/elasticsearch/lib/Transport'; import { TransportRequestParams } from '@elastic/elasticsearch/lib/Transport'; @@ -1169,6 +1170,8 @@ export interface ResolvedSimpleSavedObject { // // @public (undocumented) export interface SavedObject { + // (undocumented) + accessControl?: SavedObjectAccessControl; attributes: T; coreMigrationVersion?: string; // (undocumented) @@ -1183,6 +1186,11 @@ export interface SavedObject { version?: string; } +// @public +export interface SavedObjectAccessControl { + owner: string; +} + // @public export type SavedObjectAttribute = SavedObjectAttributeSingle | SavedObjectAttributeSingle[]; @@ -1221,10 +1229,12 @@ export interface SavedObjectReference { // @public export interface SavedObjectReferenceWithContext { + accessControl?: SavedObjectAccessControl; id: string; inboundReferences: Array<{ type: string; id: string; + accessControl?: SavedObjectAccessControl; name: string; }>; isMissing?: boolean; diff --git a/src/core/public/saved_objects/index.ts b/src/core/public/saved_objects/index.ts index bd22947b174b79..333a1b4a76a42f 100644 --- a/src/core/public/saved_objects/index.ts +++ b/src/core/public/saved_objects/index.ts @@ -24,6 +24,7 @@ export { SimpleSavedObject } from './simple_saved_object'; export type { ResolvedSimpleSavedObject } from './types'; export type { SavedObjectsStart } from './saved_objects_service'; export type { + SavedObjectAccessControl, SavedObjectsBaseOptions, SavedObjectsFindOptions, SavedObjectsFindOptionsReference, diff --git a/src/core/server/index.ts b/src/core/server/index.ts index c77a3a967364c5..b255f14404ca6f 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -293,13 +293,16 @@ export { } from './saved_objects'; export type { + SavedObjectAccessControl, SavedObjectsBulkCreateObject, + SavedObjectsBulkCreateOptions, SavedObjectsBulkGetObject, SavedObjectsBulkUpdateObject, SavedObjectsBulkUpdateOptions, SavedObjectsBulkResponse, SavedObjectsBulkUpdateResponse, SavedObjectsCheckConflictsObject, + SavedObjectsCheckConflictsOptions, SavedObjectsCheckConflictsResponse, SavedObjectsClientProviderOptions, SavedObjectsClientWrapperFactory, diff --git a/src/core/server/saved_objects/export/saved_objects_exporter.ts b/src/core/server/saved_objects/export/saved_objects_exporter.ts index 211dcdc4ee62d1..154d43913bf40e 100644 --- a/src/core/server/saved_objects/export/saved_objects_exporter.ts +++ b/src/core/server/saved_objects/export/saved_objects_exporter.ts @@ -129,7 +129,9 @@ export class SavedObjectsExporter { // redact attributes that should not be exported const redactedObjects = includeNamespaces ? exportedObjects - : exportedObjects.map>(({ namespaces, ...object }) => object); + : exportedObjects.map>( + ({ namespaces, accessControl, ...object }) => object + ); const exportDetails: SavedObjectsExportResultDetails = { exportedCount: exportedObjects.length, diff --git a/src/core/server/saved_objects/import/lib/check_conflicts.test.ts b/src/core/server/saved_objects/import/lib/check_conflicts.test.ts index 3370dda05f68bd..65789a89da2387 100644 --- a/src/core/server/saved_objects/import/lib/check_conflicts.test.ts +++ b/src/core/server/saved_objects/import/lib/check_conflicts.test.ts @@ -102,7 +102,9 @@ describe('#checkConflicts', () => { it('returns expected result', async () => { const namespace = 'foo-namespace'; const params = setupParams({ objects, namespace }); - socCheckConflicts.mockResolvedValue({ errors: [obj2Error, obj3Error, obj4Error] }); + socCheckConflicts.mockResolvedValue({ + errors: [obj2Error, obj3Error, obj4Error], + }); const checkConflictsResult = await checkConflicts(params); expect(checkConflictsResult).toEqual({ @@ -129,7 +131,9 @@ describe('#checkConflicts', () => { it('does not return errors for regular conflicts when ignoreRegularConflicts=true', async () => { const namespace = 'foo-namespace'; const params = setupParams({ objects, namespace, ignoreRegularConflicts: true }); - socCheckConflicts.mockResolvedValue({ errors: [obj2Error, obj3Error, obj4Error] }); + socCheckConflicts.mockResolvedValue({ + errors: [obj2Error, obj3Error, obj4Error], + }); const checkConflictsResult = await checkConflicts(params); expect(checkConflictsResult).toEqual( @@ -197,7 +201,9 @@ describe('#checkConflicts', () => { it('adds `omitOriginId` field to `importIdMap` entries when createNewCopies=true', async () => { const namespace = 'foo-namespace'; const params = setupParams({ objects, namespace, createNewCopies: true }); - socCheckConflicts.mockResolvedValue({ errors: [obj2Error, obj3Error, obj4Error] }); + socCheckConflicts.mockResolvedValue({ + errors: [obj2Error, obj3Error, obj4Error], + }); const checkConflictsResult = await checkConflicts(params); expect(checkConflictsResult).toEqual( diff --git a/src/core/server/saved_objects/index.ts b/src/core/server/saved_objects/index.ts index 5f853d49219dc9..8b0cbd93724a57 100644 --- a/src/core/server/saved_objects/index.ts +++ b/src/core/server/saved_objects/index.ts @@ -86,6 +86,7 @@ export type { } from './migrations'; export type { + SavedObjectAccessControl, SavedObjectsNamespaceType, SavedObjectStatusMeta, SavedObjectsType, diff --git a/src/core/server/saved_objects/migrations/core/__snapshots__/build_active_mappings.test.ts.snap b/src/core/server/saved_objects/migrations/core/__snapshots__/build_active_mappings.test.ts.snap index 9ee998118bde66..616cd7e1f7a264 100644 --- a/src/core/server/saved_objects/migrations/core/__snapshots__/build_active_mappings.test.ts.snap +++ b/src/core/server/saved_objects/migrations/core/__snapshots__/build_active_mappings.test.ts.snap @@ -5,6 +5,7 @@ Object { "_meta": Object { "migrationMappingPropertyHashes": Object { "aaa": "625b32086eb1d1203564cf85062dd22e", + "accessControl": "f759893589b96eeddcb456de15abb5f4", "bbb": "18c78c995965207ed3f6e7fc5c6e55fe", "coreMigrationVersion": "2f4316de49999235636386fe51dc06c1", "migrationVersion": "4a1746014a75ade3a714e1db5763276f", @@ -21,6 +22,15 @@ Object { "aaa": Object { "type": "text", }, + "accessControl": Object { + "dynamic": "strict", + "properties": Object { + "owner": Object { + "type": "keyword", + }, + }, + "type": "object", + }, "bbb": Object { "type": "long", }, @@ -68,6 +78,7 @@ exports[`buildActiveMappings handles the \`dynamic\` property of types 1`] = ` Object { "_meta": Object { "migrationMappingPropertyHashes": Object { + "accessControl": "f759893589b96eeddcb456de15abb5f4", "coreMigrationVersion": "2f4316de49999235636386fe51dc06c1", "firstType": "635418ab953d81d93f1190b70a8d3f57", "migrationVersion": "4a1746014a75ade3a714e1db5763276f", @@ -83,6 +94,15 @@ Object { }, "dynamic": "strict", "properties": Object { + "accessControl": Object { + "dynamic": "strict", + "properties": Object { + "owner": Object { + "type": "keyword", + }, + }, + "type": "object", + }, "coreMigrationVersion": Object { "type": "keyword", }, diff --git a/src/core/server/saved_objects/migrations/core/build_active_mappings.ts b/src/core/server/saved_objects/migrations/core/build_active_mappings.ts index d3ac00fc0354ae..fe3dff0c64c048 100644 --- a/src/core/server/saved_objects/migrations/core/build_active_mappings.ts +++ b/src/core/server/saved_objects/migrations/core/build_active_mappings.ts @@ -127,6 +127,15 @@ function defaultMapping(): IndexMapping { type: { type: 'keyword', }, + accessControl: { + type: 'object', + dynamic: 'strict', + properties: { + owner: { + type: 'keyword', + }, + }, + }, namespace: { type: 'keyword', }, diff --git a/src/core/server/saved_objects/migrations/core/document_migrator.test.ts b/src/core/server/saved_objects/migrations/core/document_migrator.test.ts index 87b8ee08090641..5cec306ad38f97 100644 --- a/src/core/server/saved_objects/migrations/core/document_migrator.test.ts +++ b/src/core/server/saved_objects/migrations/core/document_migrator.test.ts @@ -280,9 +280,9 @@ describe('DocumentMigrator', () => { const migrator = new DocumentMigrator({ ...testOpts(), typeRegistry: createRegistry({ - name: 'acl', + name: 'accessControl', migrations: { - '2.3.5': setAttr('acl', 'admins-only, sucka!'), + '2.3.5': setAttr('accessControl', 'admins-only, sucka!'), }, }), }); @@ -291,15 +291,15 @@ describe('DocumentMigrator', () => { id: 'me', type: 'user', attributes: { name: 'Tyler' }, - acl: 'anyone', + accessControl: { owner: 'anyone' }, migrationVersion: {}, } as SavedObjectUnsanitizedDoc); expect(actual).toEqual({ id: 'me', type: 'user', attributes: { name: 'Tyler' }, - migrationVersion: { acl: '2.3.5' }, - acl: 'admins-only, sucka!', + migrationVersion: { accessControl: '2.3.5' }, + accessControl: 'admins-only, sucka!', coreMigrationVersion: kibanaVersion, }); }); diff --git a/src/core/server/saved_objects/migrations/core/index_migrator.test.ts b/src/core/server/saved_objects/migrations/core/index_migrator.test.ts index 64d4fa3609e90f..4b1e47fb0b3f55 100644 --- a/src/core/server/saved_objects/migrations/core/index_migrator.test.ts +++ b/src/core/server/saved_objects/migrations/core/index_migrator.test.ts @@ -61,6 +61,7 @@ describe('IndexMigrator', () => { namespace: '2f4316de49999235636386fe51dc06c1', namespaces: '2f4316de49999235636386fe51dc06c1', originId: '2f4316de49999235636386fe51dc06c1', + accessControl: 'f759893589b96eeddcb456de15abb5f4', references: '7997cf5a56cc02bdc9c93361bde732b0', coreMigrationVersion: '2f4316de49999235636386fe51dc06c1', type: '2f4316de49999235636386fe51dc06c1', @@ -75,6 +76,15 @@ describe('IndexMigrator', () => { originId: { type: 'keyword' }, type: { type: 'keyword' }, updated_at: { type: 'date' }, + accessControl: { + dynamic: 'strict', + properties: { + owner: { + type: 'keyword', + }, + }, + type: 'object', + }, references: { type: 'nested', properties: { @@ -186,6 +196,7 @@ describe('IndexMigrator', () => { originId: '2f4316de49999235636386fe51dc06c1', references: '7997cf5a56cc02bdc9c93361bde732b0', coreMigrationVersion: '2f4316de49999235636386fe51dc06c1', + accessControl: 'f759893589b96eeddcb456de15abb5f4', type: '2f4316de49999235636386fe51dc06c1', updated_at: '00da57df13e94e9d98437d13ace4bfe0', }, @@ -199,6 +210,15 @@ describe('IndexMigrator', () => { originId: { type: 'keyword' }, type: { type: 'keyword' }, updated_at: { type: 'date' }, + accessControl: { + dynamic: 'strict', + properties: { + owner: { + type: 'keyword', + }, + }, + type: 'object', + }, references: { type: 'nested', properties: { @@ -247,6 +267,7 @@ describe('IndexMigrator', () => { namespace: '2f4316de49999235636386fe51dc06c1', namespaces: '2f4316de49999235636386fe51dc06c1', originId: '2f4316de49999235636386fe51dc06c1', + accessControl: 'f759893589b96eeddcb456de15abb5f4', references: '7997cf5a56cc02bdc9c93361bde732b0', coreMigrationVersion: '2f4316de49999235636386fe51dc06c1', type: '2f4316de49999235636386fe51dc06c1', @@ -262,6 +283,15 @@ describe('IndexMigrator', () => { originId: { type: 'keyword' }, type: { type: 'keyword' }, updated_at: { type: 'date' }, + accessControl: { + dynamic: 'strict', + properties: { + owner: { + type: 'keyword', + }, + }, + type: 'object', + }, references: { type: 'nested', properties: { diff --git a/src/core/server/saved_objects/migrations/kibana/__snapshots__/kibana_migrator.test.ts.snap b/src/core/server/saved_objects/migrations/kibana/__snapshots__/kibana_migrator.test.ts.snap index 32c2536ab02968..ac2cb37be4cb39 100644 --- a/src/core/server/saved_objects/migrations/kibana/__snapshots__/kibana_migrator.test.ts.snap +++ b/src/core/server/saved_objects/migrations/kibana/__snapshots__/kibana_migrator.test.ts.snap @@ -4,6 +4,7 @@ exports[`KibanaMigrator getActiveMappings returns full index mappings w/ core pr Object { "_meta": Object { "migrationMappingPropertyHashes": Object { + "accessControl": "f759893589b96eeddcb456de15abb5f4", "amap": "510f1f0adb69830cf8a1c5ce2923ed82", "bmap": "510f1f0adb69830cf8a1c5ce2923ed82", "coreMigrationVersion": "2f4316de49999235636386fe51dc06c1", @@ -18,6 +19,15 @@ Object { }, "dynamic": "strict", "properties": Object { + "accessControl": Object { + "dynamic": "strict", + "properties": Object { + "owner": Object { + "type": "keyword", + }, + }, + "type": "object", + }, "amap": Object { "properties": Object { "field": Object { diff --git a/src/core/server/saved_objects/saved_objects_type_registry.mock.ts b/src/core/server/saved_objects/saved_objects_type_registry.mock.ts index d53a53d745c0c8..ea4e1f024339d0 100644 --- a/src/core/server/saved_objects/saved_objects_type_registry.mock.ts +++ b/src/core/server/saved_objects/saved_objects_type_registry.mock.ts @@ -24,6 +24,7 @@ const createRegistryMock = (): jest.Mocked< isHidden: jest.fn(), getIndex: jest.fn(), isImportableAndExportable: jest.fn(), + isPrivate: jest.fn(), }; mock.getVisibleTypes.mockReturnValue([]); @@ -39,6 +40,7 @@ const createRegistryMock = (): jest.Mocked< mock.isMultiNamespace.mockImplementation((type: string) => type === 'shared'); mock.isShareable.mockImplementation((type: string) => type === 'shared'); mock.isImportableAndExportable.mockReturnValue(true); + mock.isPrivate.mockReturnValue(false); return mock; }; diff --git a/src/core/server/saved_objects/saved_objects_type_registry.test.ts b/src/core/server/saved_objects/saved_objects_type_registry.test.ts index 872b61706c526a..8c2d24051553c8 100644 --- a/src/core/server/saved_objects/saved_objects_type_registry.test.ts +++ b/src/core/server/saved_objects/saved_objects_type_registry.test.ts @@ -330,6 +330,22 @@ describe('SavedObjectTypeRegistry', () => { }); }); + describe('#isPrivate', () => { + it('returns correct value for the type', () => { + registry.registerType(createType({ name: 'typeA', accessClassification: 'private' })); + registry.registerType(createType({ name: 'typeB', accessClassification: 'public' })); + + expect(registry.isPrivate('typeA')).toEqual(true); + expect(registry.isPrivate('typeB')).toEqual(false); + }); + it('returns false when the type is not registered', () => { + registry.registerType(createType({ name: 'typeA', accessClassification: 'private' })); + registry.registerType(createType({ name: 'typeB', accessClassification: 'public' })); + + expect(registry.isPrivate('unknownType')).toEqual(false); + }); + }); + describe('#getIndex', () => { it('returns correct value for the type', () => { registry.registerType(createType({ name: 'typeA', indexPattern: '.custom-index' })); diff --git a/src/core/server/saved_objects/saved_objects_type_registry.ts b/src/core/server/saved_objects/saved_objects_type_registry.ts index ba5960c59239d7..47073ed3616578 100644 --- a/src/core/server/saved_objects/saved_objects_type_registry.ts +++ b/src/core/server/saved_objects/saved_objects_type_registry.ts @@ -125,6 +125,13 @@ export class SavedObjectTypeRegistry { public isImportableAndExportable(type: string) { return this.types.get(type)?.management?.importableAndExportable ?? false; } + + /** + * Returns `true` if the given type is marked as `private`, and `false` otherwise. + */ + public isPrivate(type: string) { + return this.types.get(type)?.accessClassification === 'private' ?? false; + } } const validateType = ({ name, management }: SavedObjectsType) => { diff --git a/src/core/server/saved_objects/serialization/serializer.test.ts b/src/core/server/saved_objects/serialization/serializer.test.ts index 3fdeb4aa088e12..924f9512eaaa43 100644 --- a/src/core/server/saved_objects/serialization/serializer.test.ts +++ b/src/core/server/saved_objects/serialization/serializer.test.ts @@ -75,13 +75,13 @@ describe('#rawToSavedObject', () => { type: 'foo', migrationVersion: { hello: '1.2.3', - acl: '33.3.5', + accessControl: '33.3.5', }, }, }); expect(actual).toHaveProperty('migrationVersion', { hello: '1.2.3', - acl: '33.3.5', + accessControl: '33.3.5', }); }); @@ -109,7 +109,7 @@ describe('#rawToSavedObject', () => { }, migrationVersion: { hello: '1.2.3', - acl: '33.3.5', + accessControl: '33.3.5', }, updated_at: now, }, @@ -124,7 +124,7 @@ describe('#rawToSavedObject', () => { }, migrationVersion: { hello: '1.2.3', - acl: '33.3.5', + accessControl: '33.3.5', }, updated_at: now, references: [], @@ -132,6 +132,27 @@ describe('#rawToSavedObject', () => { expect(expected).toEqual(actual); }); + test('if specified it copies the _source.accessControl property to accessControl', () => { + const actual = singleNamespaceSerializer.rawToSavedObject({ + _id: 'foo:bar', + _source: { + type: 'foo', + accessControl: { owner: 'alice' }, + }, + }); + expect(actual).toHaveProperty('accessControl', { owner: 'alice' }); + }); + + test(`if _source.accessControl is unspecified it doesn't set accessControl`, () => { + const actual = singleNamespaceSerializer.rawToSavedObject({ + _id: 'foo:bar', + _source: { + type: 'foo', + }, + }); + expect(actual).not.toHaveProperty('accessControl'); + }); + test('if specified it copies the _source.coreMigrationVersion property to coreMigrationVersion', () => { const actual = singleNamespaceSerializer.rawToSavedObject({ _id: 'foo:bar', diff --git a/src/core/server/saved_objects/serialization/serializer.ts b/src/core/server/saved_objects/serialization/serializer.ts index 9c91abcfe79c56..8278e63a0f2248 100644 --- a/src/core/server/saved_objects/serialization/serializer.ts +++ b/src/core/server/saved_objects/serialization/serializer.ts @@ -91,6 +91,7 @@ export class SavedObjectsSerializer { migrationVersion, references, coreMigrationVersion, + accessControl, } = _source; const version = @@ -108,6 +109,7 @@ export class SavedObjectsSerializer { ...(includeNamespace && { namespace }), ...(includeNamespaces && { namespaces }), ...(originId && { originId }), + ...(accessControl && { accessControl }), attributes: _source[type], references: references || [], ...(migrationVersion && { migrationVersion }), @@ -136,6 +138,7 @@ export class SavedObjectsSerializer { version, references, coreMigrationVersion, + accessControl, } = savedObj; const source = { [type]: attributes, @@ -144,6 +147,7 @@ export class SavedObjectsSerializer { ...(namespace && this.registry.isSingleNamespace(type) && { namespace }), ...(namespaces && this.registry.isMultiNamespace(type) && { namespaces }), ...(originId && { originId }), + ...(accessControl && { accessControl }), ...(migrationVersion && { migrationVersion }), ...(coreMigrationVersion && { coreMigrationVersion }), ...(updated_at && { updated_at }), diff --git a/src/core/server/saved_objects/serialization/types.ts b/src/core/server/saved_objects/serialization/types.ts index 3956e2133e5a86..f2f788126a7033 100644 --- a/src/core/server/saved_objects/serialization/types.ts +++ b/src/core/server/saved_objects/serialization/types.ts @@ -6,6 +6,7 @@ * Side Public License, v 1. */ +import { SavedObjectAccessControl } from 'src/core/types'; import { SavedObjectsMigrationVersion, SavedObjectReference } from '../types'; /** @@ -29,6 +30,7 @@ export interface SavedObjectsRawDocSource { updated_at?: string; references?: SavedObjectReference[]; originId?: string; + accessControl?: SavedObjectAccessControl; [typeMapping: string]: any; } @@ -47,6 +49,7 @@ interface SavedObjectDoc { version?: string; updated_at?: string; originId?: string; + accessControl?: SavedObjectAccessControl; } interface Referencable { diff --git a/src/core/server/saved_objects/service/lib/collect_multi_namespace_references.ts b/src/core/server/saved_objects/service/lib/collect_multi_namespace_references.ts index 43923695f6548e..25d5065c4744c8 100644 --- a/src/core/server/saved_objects/service/lib/collect_multi_namespace_references.ts +++ b/src/core/server/saved_objects/service/lib/collect_multi_namespace_references.ts @@ -12,7 +12,7 @@ import { esKuery } from '../../es_query'; import { LegacyUrlAlias, LEGACY_URL_ALIAS_TYPE } from '../../object_types'; import type { ISavedObjectTypeRegistry } from '../../saved_objects_type_registry'; import type { SavedObjectsSerializer } from '../../serialization'; -import type { SavedObject, SavedObjectsBaseOptions } from '../../types'; +import type { SavedObject, SavedObjectsBaseOptions, SavedObjectAccessControl } from '../../types'; import { getRootFields } from './included_fields'; import { getSavedObjectFromSource, rawDocExistsInNamespace } from './internal_utils'; import type { @@ -68,6 +68,8 @@ export interface SavedObjectReferenceWithContext { type: string; /** The ID of the referenced object */ id: string; + /** The access control of the referenced object */ + accessControl?: SavedObjectAccessControl; /** The space(s) that the referenced object exists in */ spaces: string[]; /** @@ -79,6 +81,8 @@ export interface SavedObjectReferenceWithContext { type: string; /** The ID of the object that has the inbound reference */ id: string; + /** The access control specification of the object that has the inbound reference */ + accessControl?: SavedObjectAccessControl; /** The name of the inbound reference */ name: string; }>; @@ -138,7 +142,14 @@ export async function collectMultiNamespaceReferences( const { type, id } = parseKey(referenceKey); const object = objectMap.get(referenceKey); const spaces = object?.namespaces ?? []; - return { type, id, spaces, inboundReferences, ...(object === null && { isMissing: true }) }; + return { + type, + id, + accessControl: object?.accessControl, + spaces, + inboundReferences, + ...(object === null && { isMissing: true }), + }; }); const aliasesMap = await checkLegacyUrlAliases(createPointInTimeFinder, objectsWithContext); diff --git a/src/core/server/saved_objects/service/lib/errors.test.ts b/src/core/server/saved_objects/service/lib/errors.test.ts index a366dce626ec22..a783be6cd7e6f7 100644 --- a/src/core/server/saved_objects/service/lib/errors.test.ts +++ b/src/core/server/saved_objects/service/lib/errors.test.ts @@ -263,6 +263,38 @@ describe('savedObjectsClient/errorTypes', () => { }); }); + describe('Incompatible accessControl error', () => { + describe('createIncompatibleAccessControlError', () => { + it('makes the error identifiable as a Conflict error', () => { + const error = SavedObjectsErrorHelpers.createIncompatibleAccessControlError('type', 'id'); + expect(SavedObjectsErrorHelpers.isConflictError(error)).toBe(true); + }); + + it('adds boom properties', () => { + const error = SavedObjectsErrorHelpers.createIncompatibleAccessControlError('type', 'id'); + expect(error).toHaveProperty('isBoom', true); + }); + + describe('error.output', () => { + it('prefixes message with reason', () => { + const error = SavedObjectsErrorHelpers.createIncompatibleAccessControlError('type', 'id'); + expect(error.output.payload).toMatchInlineSnapshot(` + Object { + "error": "Conflict", + "message": "Saved object [type/id] conflict: incompatible accessControl", + "statusCode": 409, + } + `); + }); + + it('sets statusCode to 409', () => { + const error = SavedObjectsErrorHelpers.createIncompatibleAccessControlError('type', 'id'); + expect(error.output).toHaveProperty('statusCode', 409); + }); + }); + }); + }); + describe('TooManyRequests error', () => { describe('decorateTooManyRequestsError', () => { it('returns original object', () => { diff --git a/src/core/server/saved_objects/service/lib/errors.ts b/src/core/server/saved_objects/service/lib/errors.ts index 581145c7c09d1b..b90a78351d1e4d 100644 --- a/src/core/server/saved_objects/service/lib/errors.ts +++ b/src/core/server/saved_objects/service/lib/errors.ts @@ -163,6 +163,12 @@ export class SavedObjectsErrorHelpers { ); } + public static createIncompatibleAccessControlError(type: string, id: string) { + return SavedObjectsErrorHelpers.decorateConflictError( + Boom.conflict(`Saved object [${type}/${id}] conflict: incompatible accessControl`) + ); + } + public static isConflictError(error: Error | DecoratedError) { return isSavedObjectsClientError(error) && error[code] === CODE_CONFLICT; } diff --git a/src/core/server/saved_objects/service/lib/filter_utils.ts b/src/core/server/saved_objects/service/lib/filter_utils.ts index a41a25a27b70dd..c0dd1fb19deda3 100644 --- a/src/core/server/saved_objects/service/lib/filter_utils.ts +++ b/src/core/server/saved_objects/service/lib/filter_utils.ts @@ -55,7 +55,8 @@ export const validateConvertFilterToKueryNode = ( const existingKueryNode: KueryNode = path.length === 0 ? filterKueryNode : get(filterKueryNode, path); if (item.isSavedObjectAttr) { - existingKueryNode.arguments[0].value = existingKueryNode.arguments[0].value.split('.')[1]; + const [, ...kueryNodeParts] = existingKueryNode.arguments[0].value.split('.'); + existingKueryNode.arguments[0].value = kueryNodeParts.join('.'); const itemType = allowedTypes.filter((t) => t === item.type); if (itemType.length === 1) { set( @@ -147,7 +148,8 @@ export const validateFilterKueryNode = ({ ), isSavedObjectAttr: isSavedObjectAttr( nestedKeys != null ? `${nestedKeys}.${ast.value}` : ast.value, - indexMapping + indexMapping, + types ), key: nestedKeys != null ? `${nestedKeys}.${ast.value}` : ast.value, type: getType(nestedKeys != null ? `${nestedKeys}.${ast.value}` : ast.value), @@ -168,15 +170,21 @@ const getType = (key: string | undefined | null) => * @param key * @param indexMapping */ -export const isSavedObjectAttr = (key: string | null | undefined, indexMapping: IndexMapping) => { +export const isSavedObjectAttr = ( + key: string | null | undefined, + indexMapping: IndexMapping, + types: string[] +) => { const keySplit = key != null ? key.split('.') : []; if (keySplit.length === 1 && fieldDefined(indexMapping, keySplit[0])) { return true; - } else if (keySplit.length === 2 && fieldDefined(indexMapping, keySplit[1])) { - return true; - } else { - return false; + } else if (keySplit.length >= 2) { + const attributeKey = `${keySplit.slice(1, keySplit.length).join('.')}`; + if (!types.includes(keySplit[1]) && fieldDefined(indexMapping, attributeKey)) { + return true; + } } + return false; }; export const hasFilterKeyError = ( @@ -194,6 +202,9 @@ export const hasFilterKeyError = ( if (keySplit.length <= 1 || !types.includes(keySplit[0])) { return `This type ${keySplit[0]} is not allowed`; } + if (isSavedObjectAttr(key, indexMapping, types)) { + return null; + } if ( (keySplit.length === 2 && fieldDefined(indexMapping, key)) || (keySplit.length > 2 && keySplit[1] !== 'attributes') diff --git a/src/core/server/saved_objects/service/lib/internal_utils.test.ts b/src/core/server/saved_objects/service/lib/internal_utils.test.ts index d1fd067990f07b..da1cea68101c3f 100644 --- a/src/core/server/saved_objects/service/lib/internal_utils.test.ts +++ b/src/core/server/saved_objects/service/lib/internal_utils.test.ts @@ -91,6 +91,7 @@ describe('#getSavedObjectFromSource', () => { const references = [{ type: 'ref-type', id: 'ref-id', name: 'ref-name' }]; const migrationVersion = { foo: 'migrationVersion' }; const coreMigrationVersion = 'coreMigrationVersion'; + const accessControl = { owner: 'alice' }; const originId = 'originId'; // eslint-disable-next-line @typescript-eslint/naming-convention const updated_at = 'updatedAt'; @@ -110,6 +111,7 @@ describe('#getSavedObjectFromSource', () => { migrationVersion, coreMigrationVersion, originId, + accessControl, updated_at, ...namespaceAttrs, }, @@ -127,6 +129,7 @@ describe('#getSavedObjectFromSource', () => { id, migrationVersion, namespaces: expect.anything(), // see specific test cases below + accessControl, originId, references, type, diff --git a/src/core/server/saved_objects/service/lib/internal_utils.ts b/src/core/server/saved_objects/service/lib/internal_utils.ts index feaaea15649c76..426433c530a072 100644 --- a/src/core/server/saved_objects/service/lib/internal_utils.ts +++ b/src/core/server/saved_objects/service/lib/internal_utils.ts @@ -87,7 +87,7 @@ export function getSavedObjectFromSource( id: string, doc: { _seq_no?: number; _primary_term?: number; _source: SavedObjectsRawDocSource } ): SavedObject { - const { originId, updated_at: updatedAt } = doc._source; + const { originId, updated_at: updatedAt, accessControl } = doc._source; let namespaces: string[] = []; if (!registry.isNamespaceAgnostic(type)) { @@ -100,6 +100,7 @@ export function getSavedObjectFromSource( id, type, namespaces, + ...(accessControl && { accessControl }), ...(originId && { originId }), ...(updatedAt && { updated_at: updatedAt }), version: encodeHitVersion(doc), diff --git a/src/core/server/saved_objects/service/lib/repository.test.js b/src/core/server/saved_objects/service/lib/repository.test.js index 474721ff3610a5..31b7a17695520e 100644 --- a/src/core/server/saved_objects/service/lib/repository.test.js +++ b/src/core/server/saved_objects/service/lib/repository.test.js @@ -105,6 +105,13 @@ describe('SavedObjectsRepository', () => { }, }, }, + 'confidential-type': { + properties: { + name: { + type: 'keyword', + }, + }, + }, [CUSTOM_INDEX_TYPE]: { properties: { type: 'keyword', @@ -148,16 +155,18 @@ describe('SavedObjectsRepository', () => { }, }; - const createType = (type) => ({ + const createType = (type, options = {}) => ({ name: type, mappings: { properties: mappings.properties[type].properties }, migrations: { '1.1.1': (doc) => doc }, + ...options, }); const registry = new SavedObjectTypeRegistry(); registry.registerType(createType('config')); registry.registerType(createType('index-pattern')); registry.registerType(createType('dashboard')); + registry.registerType(createType('confidential-type', { accessClassification: 'confidential' })); registry.registerType({ ...createType(CUSTOM_INDEX_TYPE), indexPattern: 'custom', @@ -192,7 +201,7 @@ describe('SavedObjectsRepository', () => { }); const getMockGetResponse = ( - { type, id, references, namespace: objectNamespace, originId }, + { type, id, references, namespace: objectNamespace, originId, accessControl }, namespace ) => { const namespaceId = objectNamespace === 'default' ? undefined : objectNamespace ?? namespace; @@ -207,6 +216,7 @@ describe('SavedObjectsRepository', () => { ...(registry.isSingleNamespace(type) && { namespace: namespaceId }), ...(registry.isMultiNamespace(type) && { namespaces: [namespaceId ?? 'default'] }), ...(originId && { originId }), + ...(accessControl && { accessControl }), type, [type]: { title: 'Testing' }, references, @@ -314,34 +324,48 @@ describe('SavedObjectsRepository', () => { attributes: { title: 'Test Two' }, references: [{ name: 'ref_0', type: 'test', id: '2' }], }; + const obj3 = { + type: 'confidential-type', + id: 'my-secret', + accessControl: { + owner: 'alice', + }, + attributes: { title: 'Test Three' }, + references: [{ name: 'ref_0', type: 'test', id: '3' }], + }; const namespace = 'foo-namespace'; const getMockBulkCreateResponse = (objects, namespace) => { return { - items: objects.map(({ type, id, originId, attributes, references, migrationVersion }) => ({ - create: { - _id: `${namespace ? `${namespace}:` : ''}${type}:${id}`, - _source: { - [type]: attributes, - type, - namespace, - ...(originId && { originId }), - references, - ...mockTimestampFields, - migrationVersion: migrationVersion || { [type]: '1.1.1' }, + items: objects.map( + ({ type, id, originId, attributes, references, migrationVersion, accessControl }) => ({ + create: { + _id: `${namespace ? `${namespace}:` : ''}${type}:${id}`, + _source: { + [type]: attributes, + type, + namespace, + ...(accessControl && { accessControl }), + ...(originId && { originId }), + references, + ...mockTimestampFields, + migrationVersion: migrationVersion || { [type]: '1.1.1' }, + }, + ...mockVersionProps, }, - ...mockVersionProps, - }, - })), + }) + ), }; }; const bulkCreateSuccess = async (objects, options) => { - const multiNamespaceObjects = objects.filter( - ({ type, id }) => registry.isMultiNamespace(type) && id + const preflightObjects = objects.filter( + ({ type, id }) => + id && + (registry.isMultiNamespace(type) || (options?.overwrite && registry.isPrivate(type))) ); - if (multiNamespaceObjects?.length) { - const response = getMockMgetResponse(multiNamespaceObjects, options?.namespace); + if (preflightObjects.length) { + const response = getMockMgetResponse(preflightObjects, options?.namespace); client.mget.mockResolvedValue( elasticsearchClientMock.createSuccessTransportRequestPromise(response) ); @@ -351,7 +375,7 @@ describe('SavedObjectsRepository', () => { elasticsearchClientMock.createSuccessTransportRequestPromise(response) ); const result = await savedObjectsRepository.bulkCreate(objects, options); - expect(client.mget).toHaveBeenCalledTimes(multiNamespaceObjects?.length ? 1 : 0); + expect(client.mget).toHaveBeenCalledTimes(preflightObjects.length ? 1 : 0); return result; }; @@ -379,12 +403,13 @@ describe('SavedObjectsRepository', () => { ); }; - const expectObjArgs = ({ type, attributes, references }, overrides) => [ + const expectObjArgs = ({ type, attributes, references, accessControl }, overrides) => [ expect.any(Object), expect.objectContaining({ [type]: attributes, references, type, + ...(accessControl ? { accessControl } : undefined), ...overrides, ...mockTimestampFields, }), @@ -401,12 +426,12 @@ describe('SavedObjectsRepository', () => { describe('client calls', () => { it(`should use the ES bulk action by default`, async () => { - await bulkCreateSuccess([obj1, obj2]); + await bulkCreateSuccess([obj1, obj2, obj3]); expect(client.bulk).toHaveBeenCalledTimes(1); }); it(`should use the ES mget action before bulk action for any types that are multi-namespace, when id is defined`, async () => { - const objects = [obj1, { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE }]; + const objects = [obj1, { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE }, obj3]; await bulkCreateSuccess(objects); expect(client.bulk).toHaveBeenCalledTimes(1); expect(client.mget).toHaveBeenCalledTimes(1); @@ -417,13 +442,13 @@ describe('SavedObjectsRepository', () => { }); it(`should use the ES create method if ID is undefined and overwrite=true`, async () => { - const objects = [obj1, obj2].map((obj) => ({ ...obj, id: undefined })); + const objects = [obj1, obj2, obj3].map((obj) => ({ ...obj, id: undefined })); await bulkCreateSuccess(objects, { overwrite: true }); expectClientCallArgsAction(objects, { method: 'create' }); }); it(`should use the ES create method if ID is undefined and overwrite=false`, async () => { - const objects = [obj1, obj2].map((obj) => ({ ...obj, id: undefined })); + const objects = [obj1, obj2, obj3].map((obj) => ({ ...obj, id: undefined })); await bulkCreateSuccess(objects); expectClientCallArgsAction(objects, { method: 'create' }); }); @@ -441,6 +466,10 @@ describe('SavedObjectsRepository', () => { version: mockVersion, }, obj2, + { + ...obj3, + version: mockVersion, + }, ], { overwrite: true } ); @@ -451,7 +480,13 @@ describe('SavedObjectsRepository', () => { if_primary_term: mockVersionProps._primary_term, }; - expectClientCallArgsAction([obj1WithSeq, obj2], { method: 'index' }); + const obj3WithSeq = { + ...obj3, + if_seq_no: mockVersionProps._seq_no, + if_primary_term: mockVersionProps._primary_term, + }; + + expectClientCallArgsAction([obj1WithSeq, obj2, obj3WithSeq], { method: 'index' }); }); it(`should use the ES create method if ID is defined and overwrite=false`, async () => { @@ -460,18 +495,39 @@ describe('SavedObjectsRepository', () => { }); it(`formats the ES request`, async () => { - await bulkCreateSuccess([obj1, obj2]); - const body = [...expectObjArgs(obj1), ...expectObjArgs(obj2)]; + await bulkCreateSuccess([obj1, obj2, obj3]); + const body = [...expectObjArgs(obj1), ...expectObjArgs(obj2), ...expectObjArgs(obj3)]; + expect(client.bulk).toHaveBeenCalledWith( + expect.objectContaining({ body }), + expect.anything() + ); + }); + + it(`allows an access control specification to be specified`, async () => { + await bulkCreateSuccess([obj3]); + const body = [...expectObjArgs(obj3)]; expect(client.bulk).toHaveBeenCalledWith( expect.objectContaining({ body }), expect.anything() ); + const [call] = client.bulk.mock.calls; + const [payload] = call; + const [, object] = payload.body; + expect(object).toHaveProperty('type', 'confidential-type'); + expect(object).toHaveProperty('accessControl', { owner: 'alice' }); }); it(`adds namespace to request body for any types that are single-namespace`, async () => { - await bulkCreateSuccess([obj1, obj2], { namespace }); + await bulkCreateSuccess([obj1, obj2, obj3], { namespace }); const expected = expect.objectContaining({ namespace }); - const body = [expect.any(Object), expected, expect.any(Object), expected]; + const body = [ + expect.any(Object), + expected, + expect.any(Object), + expected, + expect.any(Object), + expected, + ]; expect(client.bulk).toHaveBeenCalledWith( expect.objectContaining({ body }), expect.anything() @@ -479,9 +535,16 @@ describe('SavedObjectsRepository', () => { }); it(`normalizes options.namespace from 'default' to undefined`, async () => { - await bulkCreateSuccess([obj1, obj2], { namespace: 'default' }); + await bulkCreateSuccess([obj1, obj2, obj3], { namespace: 'default' }); const expected = expect.not.objectContaining({ namespace: 'default' }); - const body = [expect.any(Object), expected, expect.any(Object), expected]; + const body = [ + expect.any(Object), + expected, + expect.any(Object), + expected, + expect.any(Object), + expected, + ]; expect(client.bulk).toHaveBeenCalledWith( expect.objectContaining({ body }), expect.anything() @@ -492,10 +555,18 @@ describe('SavedObjectsRepository', () => { const objects = [ { ...obj1, type: NAMESPACE_AGNOSTIC_TYPE }, { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE }, + { ...obj3, type: MULTI_NAMESPACE_TYPE }, ]; await bulkCreateSuccess(objects, { namespace }); const expected = expect.not.objectContaining({ namespace: expect.anything() }); - const body = [expect.any(Object), expected, expect.any(Object), expected]; + const body = [ + expect.any(Object), + expected, + expect.any(Object), + expected, + expect.any(Object), + expected, + ]; expect(client.bulk).toHaveBeenCalledWith( expect.objectContaining({ body }), expect.anything() @@ -504,11 +575,21 @@ describe('SavedObjectsRepository', () => { it(`adds namespaces to request body for any types that are multi-namespace`, async () => { const test = async (namespace) => { - const objects = [obj1, obj2].map((x) => ({ ...x, type: MULTI_NAMESPACE_ISOLATED_TYPE })); + const objects = [obj1, obj2, obj3].map((x) => ({ + ...x, + type: MULTI_NAMESPACE_ISOLATED_TYPE, + })); const namespaces = [namespace ?? 'default']; await bulkCreateSuccess(objects, { namespace, overwrite: true }); const expected = expect.objectContaining({ namespaces }); - const body = [expect.any(Object), expected, expect.any(Object), expected]; + const body = [ + expect.any(Object), + expected, + expect.any(Object), + expected, + expect.any(Object), + expected, + ]; expect(client.bulk).toHaveBeenCalledWith( expect.objectContaining({ body }), expect.anything() @@ -570,7 +651,7 @@ describe('SavedObjectsRepository', () => { }); it(`defaults to a refresh setting of wait_for`, async () => { - await bulkCreateSuccess([obj1, obj2]); + await bulkCreateSuccess([obj1, obj2, obj3]); expect(client.bulk).toHaveBeenCalledWith( expect.objectContaining({ refresh: 'wait_for' }), expect.anything() @@ -578,25 +659,28 @@ describe('SavedObjectsRepository', () => { }); it(`should use default index`, async () => { - await bulkCreateSuccess([obj1, obj2]); - expectClientCallArgsAction([obj1, obj2], { method: 'create', _index: '.kibana-test' }); + await bulkCreateSuccess([obj1, obj2, obj3]); + expectClientCallArgsAction([obj1, obj2, obj3], { + method: 'create', + _index: '.kibana-test', + }); }); it(`should use custom index`, async () => { - await bulkCreateSuccess([obj1, obj2].map((x) => ({ ...x, type: CUSTOM_INDEX_TYPE }))); - expectClientCallArgsAction([obj1, obj2], { method: 'create', _index: 'custom' }); + await bulkCreateSuccess([obj1, obj2, obj3].map((x) => ({ ...x, type: CUSTOM_INDEX_TYPE }))); + expectClientCallArgsAction([obj1, obj2, obj3], { method: 'create', _index: 'custom' }); }); it(`prepends namespace to the id when providing namespace for single-namespace type`, async () => { const getId = (type, id) => `${namespace}:${type}:${id}`; // test that the raw document ID equals this (e.g., has a namespace prefix) - await bulkCreateSuccess([obj1, obj2], { namespace }); - expectClientCallArgsAction([obj1, obj2], { method: 'create', getId }); + await bulkCreateSuccess([obj1, obj2, obj3], { namespace }); + expectClientCallArgsAction([obj1, obj2, obj3], { method: 'create', getId }); }); it(`doesn't prepend namespace to the id when providing no namespace for single-namespace type`, async () => { const getId = (type, id) => `${type}:${id}`; // test that the raw document ID equals this (e.g., does not have a namespace prefix) - await bulkCreateSuccess([obj1, obj2]); - expectClientCallArgsAction([obj1, obj2], { method: 'create', getId }); + await bulkCreateSuccess([obj1, obj2, obj3]); + expectClientCallArgsAction([obj1, obj2, obj3], { method: 'create', getId }); }); it(`doesn't prepend namespace to the id when not using single-namespace type`, async () => { @@ -604,6 +688,7 @@ describe('SavedObjectsRepository', () => { const objects = [ { ...obj1, type: NAMESPACE_AGNOSTIC_TYPE }, { ...obj2, type: MULTI_NAMESPACE_ISOLATED_TYPE }, + { ...obj3, type: MULTI_NAMESPACE_ISOLATED_TYPE }, ]; await bulkCreateSuccess(objects, { namespace }); expectClientCallArgsAction(objects, { method: 'create', getId }); @@ -762,26 +847,30 @@ describe('SavedObjectsRepository', () => { describe('migration', () => { it(`migrates the docs and serializes the migrated docs`, async () => { migrator.migrateDocument.mockImplementation(mockMigrateDocument); - await bulkCreateSuccess([obj1, obj2]); - const docs = [obj1, obj2].map((x) => ({ ...x, ...mockTimestampFields })); + await bulkCreateSuccess([obj1, obj2, obj3], { overwrite: true }); + const docs = [obj1, obj2, obj3].map((x) => ({ ...x, ...mockTimestampFields })); expectMigrationArgs(docs[0], true, 1); expectMigrationArgs(docs[1], true, 2); + expectMigrationArgs(docs[2], true, 3); const migratedDocs = docs.map((x) => migrator.migrateDocument(x)); expect(serializer.savedObjectToRaw).toHaveBeenNthCalledWith(1, migratedDocs[0]); expect(serializer.savedObjectToRaw).toHaveBeenNthCalledWith(2, migratedDocs[1]); + expect(serializer.savedObjectToRaw).toHaveBeenNthCalledWith(3, migratedDocs[2]); }); it(`adds namespace to body when providing namespace for single-namespace type`, async () => { - await bulkCreateSuccess([obj1, obj2], { namespace }); + await bulkCreateSuccess([obj1, obj2, obj3], { namespace }); expectMigrationArgs({ namespace }, true, 1); expectMigrationArgs({ namespace }, true, 2); + expectMigrationArgs({ namespace }, true, 3); }); it(`doesn't add namespace to body when providing no namespace for single-namespace type`, async () => { - await bulkCreateSuccess([obj1, obj2]); + await bulkCreateSuccess([obj1, obj2, obj3]); expectMigrationArgs({ namespace: expect.anything() }, false, 1); expectMigrationArgs({ namespace: expect.anything() }, false, 2); + expectMigrationArgs({ namespace: expect.anything() }, false, 3); }); it(`doesn't add namespace to body when not using single-namespace type`, async () => { @@ -795,23 +884,25 @@ describe('SavedObjectsRepository', () => { }); it(`adds namespaces to body when providing namespace for multi-namespace type`, async () => { - const objects = [obj1, obj2].map((obj) => ({ + const objects = [obj1, obj2, obj3].map((obj) => ({ ...obj, type: MULTI_NAMESPACE_ISOLATED_TYPE, })); await bulkCreateSuccess(objects, { namespace }); expectMigrationArgs({ namespaces: [namespace] }, true, 1); expectMigrationArgs({ namespaces: [namespace] }, true, 2); + expectMigrationArgs({ namespaces: [namespace] }, true, 3); }); it(`adds default namespaces to body when providing no namespace for multi-namespace type`, async () => { - const objects = [obj1, obj2].map((obj) => ({ + const objects = [obj1, obj2, obj3].map((obj) => ({ ...obj, type: MULTI_NAMESPACE_ISOLATED_TYPE, })); await bulkCreateSuccess(objects); expectMigrationArgs({ namespaces: ['default'] }, true, 1); expectMigrationArgs({ namespaces: ['default'] }, true, 2); + expectMigrationArgs({ namespaces: ['default'] }, true, 3); }); it(`doesn't add namespaces to body when not using multi-namespace type`, async () => { @@ -1121,6 +1212,15 @@ describe('SavedObjectsRepository', () => { id: 'logstash-*', attributes: { title: 'Test Two' }, }; + const obj3 = { + type: 'confidential-type', + id: 'my-secret', + accessControl: { + owner: 'alice', + }, + attributes: { title: 'Test Two' }, + references: [], + }; const references = [{ name: 'ref_0', type: 'test', id: '1' }]; const originId = 'some-origin-id'; const namespace = 'foo-namespace'; @@ -1195,7 +1295,7 @@ describe('SavedObjectsRepository', () => { describe('client calls', () => { it(`should use the ES bulk action by default`, async () => { - await bulkUpdateSuccess([obj1, obj2]); + await bulkUpdateSuccess([obj1, obj2, obj3]); expect(client.bulk).toHaveBeenCalled(); }); @@ -1215,8 +1315,8 @@ describe('SavedObjectsRepository', () => { }); it(`formats the ES request`, async () => { - await bulkUpdateSuccess([obj1, obj2]); - const body = [...expectObjArgs(obj1), ...expectObjArgs(obj2)]; + await bulkUpdateSuccess([obj1, obj2, obj3]); + const body = [...expectObjArgs(obj1), ...expectObjArgs(obj2), ...expectObjArgs(obj3)]; expect(client.bulk).toHaveBeenCalledWith( expect.objectContaining({ body }), expect.anything() @@ -1233,6 +1333,27 @@ describe('SavedObjectsRepository', () => { ); }); + it(`does not allow the access control specification to be updated`, async () => { + await bulkUpdateSuccess([obj3]); + const body = [...expectObjArgs(obj3)]; + expect(client.bulk).toHaveBeenCalledWith( + expect.objectContaining({ body }), + expect.anything() + ); + const [call] = client.bulk.mock.calls; + const [payload] = call; + const [, object] = payload.body; + expect(object.doc).toMatchInlineSnapshot(` + Object { + "confidential-type": Object { + "title": "Test Two", + }, + "references": Array [], + "updated_at": "2017-08-14T15:49:14.886Z", + } + `); + }); + it(`doesnt call Elasticsearch if there are no valid objects to update`, async () => { const objects = [obj1, obj2].map((x) => ({ ...x, type: 'unknownType' })); await savedObjectsRepository.bulkUpdate(objects); @@ -1765,6 +1886,16 @@ describe('SavedObjectsRepository', () => { expect(client.create.mock.calls[0][0].body.references).toEqual([]); }); + it(`accepts an access control specification`, async () => { + const test = async (accessControl) => { + await createSuccess(type, attributes, { id, accessControl }); + expect(client.create.mock.calls[0][0].body.accessControl).toEqual(accessControl); + client.create.mockClear(); + }; + await test({ owner: 'alice' }); + await test(undefined); + }); + it(`accepts custom references array`, async () => { const test = async (references) => { await createSuccess(type, attributes, { id, references }); @@ -2550,7 +2681,7 @@ describe('SavedObjectsRepository', () => { const generateSearchResults = (namespace) => { return { hits: { - total: 4, + total: 5, hits: [ { _index: '.kibana', @@ -2600,6 +2731,23 @@ describe('SavedObjectsRepository', () => { }, }, }, + { + _index: '.kibana', + _id: `${namespace ? `${namespace}:` : ''}confidential-type:my-secret`, + _score: 3, + ...mockVersionProps, + _source: { + namespace, + type: 'confidential-type', + accessControl: { + owner: 'alice', + }, + ...mockTimestampFields, + 'confidential-type': { + name: 'stocks-*', + }, + }, + }, { _index: '.kibana', _id: `${NAMESPACE_AGNOSTIC_TYPE}:something`, @@ -2816,9 +2964,10 @@ describe('SavedObjectsRepository', () => { noNamespaceSearchResults.hits.hits.forEach((doc, i) => { expect(response.saved_objects[i]).toEqual({ - id: doc._id.replace(/(index-pattern|config|globalType)\:/, ''), + id: doc._id.replace(/(index-pattern|config|globalType|confidential-type)\:/, ''), type: doc._source.type, originId: doc._source.originId, + accessControl: doc._source.accessControl, ...mockTimestampFields, version: mockVersion, score: doc._score, @@ -2843,9 +2992,13 @@ describe('SavedObjectsRepository', () => { namespacedSearchResults.hits.hits.forEach((doc, i) => { expect(response.saved_objects[i]).toEqual({ - id: doc._id.replace(/(foo-namespace\:)?(index-pattern|config|globalType)\:/, ''), + id: doc._id.replace( + /(foo-namespace\:)?(index-pattern|config|globalType|confidential-type)\:/, + '' + ), type: doc._source.type, originId: doc._source.originId, + accessControl: doc._source.accessControl, ...mockTimestampFields, version: mockVersion, score: doc._score, @@ -3088,8 +3241,14 @@ describe('SavedObjectsRepository', () => { const id = 'logstash-*'; const namespace = 'foo-namespace'; const originId = 'some-origin-id'; + const accessControl = { owner: 'alice' }; - const getSuccess = async (type, id, options, includeOriginId) => { + const getSuccess = async ( + type, + id, + options = {}, + { includeOriginId = false, includeAccessControl = false } = {} + ) => { const response = getMockGetResponse( { type, @@ -3097,6 +3256,7 @@ describe('SavedObjectsRepository', () => { // "includeOriginId" is not an option for the operation; however, if the existing saved object contains an originId attribute, the // operation will return it in the result. This flag is just used for test purposes to modify the mock cluster call response. ...(includeOriginId && { originId }), + ...(includeAccessControl && { accessControl }), }, options?.namespace ); @@ -3246,9 +3406,14 @@ describe('SavedObjectsRepository', () => { }); it(`includes originId property if present in cluster call response`, async () => { - const result = await getSuccess(type, id, {}, true); + const result = await getSuccess(type, id, {}, { includeOriginId: true }); expect(result).toMatchObject({ originId }); }); + + it(`includes accessControl property if present in cluster call response`, async () => { + const result = await getSuccess(type, id, {}, { includeAccessControl: true }); + expect(result).toMatchObject({ accessControl }); + }); }); }); @@ -3940,6 +4105,23 @@ describe('SavedObjectsRepository', () => { await test(null); }); + it(`doesn't accept an access control specification`, async () => { + const accessControlTest = async (accessControl) => { + await updateSuccess(type, id, attributes, { accessControl }); + expect(client.update).toHaveBeenCalledWith( + expect.objectContaining({ + body: { doc: expect.not.objectContaining({ accessControl: expect.anything() }) }, + }), + expect.anything() + ); + client.update.mockClear(); + }; + await accessControlTest({ owner: 'alice' }); + await accessControlTest(123); + await accessControlTest(true); + await accessControlTest(null); + }); + it(`defaults to a refresh setting of wait_for`, async () => { await updateSuccess(type, id, { foo: 'bar' }); expect(client.update).toHaveBeenCalledWith( @@ -4018,7 +4200,9 @@ describe('SavedObjectsRepository', () => { it(`includes _source_includes when type is multi-namespace`, async () => { await updateSuccess(MULTI_NAMESPACE_ISOLATED_TYPE, id, attributes); expect(client.update).toHaveBeenCalledWith( - expect.objectContaining({ _source_includes: ['namespace', 'namespaces', 'originId'] }), + expect.objectContaining({ + _source_includes: ['namespace', 'namespaces', 'originId', 'accessControl'], + }), expect.anything() ); }); @@ -4027,7 +4211,7 @@ describe('SavedObjectsRepository', () => { await updateSuccess(type, id, attributes); expect(client.update).toHaveBeenLastCalledWith( expect.objectContaining({ - _source_includes: ['namespace', 'namespaces', 'originId'], + _source_includes: ['namespace', 'namespaces', 'originId', 'accessControl'], }), expect.anything() ); diff --git a/src/core/server/saved_objects/service/lib/repository.ts b/src/core/server/saved_objects/service/lib/repository.ts index 986467c917dd25..97f1fb5cc5fe85 100644 --- a/src/core/server/saved_objects/service/lib/repository.ts +++ b/src/core/server/saved_objects/service/lib/repository.ts @@ -8,6 +8,7 @@ import { omit, isObject } from 'lodash'; import type { estypes } from '@elastic/elasticsearch'; +import { SavedObjectAccessControl } from 'src/core/types'; import { CORE_USAGE_STATS_TYPE, CORE_USAGE_STATS_ID, @@ -56,6 +57,8 @@ import { SavedObjectsRemoveReferencesToOptions, SavedObjectsRemoveReferencesToResponse, SavedObjectsResolveResponse, + SavedObjectsBulkCreateOptions, + SavedObjectsCheckConflictsOptions, } from '../saved_objects_client'; import { SavedObject, @@ -285,6 +288,7 @@ export class SavedObjectsRepository { originId, initialNamespaces, version, + accessControl, } = options; const namespace = normalizeNamespace(options.namespace); @@ -316,6 +320,7 @@ export class SavedObjectsRepository { type, ...(savedObjectNamespace && { namespace: savedObjectNamespace }), ...(savedObjectNamespaces && { namespaces: savedObjectNamespaces }), + ...(accessControl && { accessControl }), originId, attributes, migrationVersion, @@ -356,7 +361,7 @@ export class SavedObjectsRepository { */ async bulkCreate( objects: Array>, - options: SavedObjectsCreateOptions = {} + options: SavedObjectsBulkCreateOptions = {} ): Promise> { const { overwrite = false, refresh = DEFAULT_REFRESH_SETTING } = options; const namespace = normalizeNamespace(options.namespace); @@ -385,6 +390,9 @@ export class SavedObjectsRepository { const method = id && overwrite ? 'index' : 'create'; const requiresNamespacesCheck = id && this._registry.isMultiNamespace(type); + const requiresAccessControlCheck = false; + // object.id && overwrite && this._registry.isPrivate(object.type); + const requiresPreflightCheck = requiresNamespacesCheck || requiresAccessControlCheck; if (id == null) { object.id = SavedObjectsUtils.generateId(); @@ -395,7 +403,7 @@ export class SavedObjectsRepository { value: { method, object, - ...(requiresNamespacesCheck && { esRequestIndex: bulkGetRequestIndexCounter++ }), + ...(requiresPreflightCheck && { esRequestIndex: bulkGetRequestIndexCounter++ }), }, }; }); @@ -406,7 +414,7 @@ export class SavedObjectsRepository { .map(({ value: { object: { type, id } } }) => ({ _id: this._serializer.generateRawId(namespace, type, id), _index: this.getIndexForType(type), - _source: ['type', 'namespaces'], + _source: ['type', 'namespaces', 'accessControl'], })); const bulkGetResponse = bulkGetDocs.length ? await this.client.mget( @@ -431,7 +439,7 @@ export class SavedObjectsRepository { let versionProperties; const { esRequestIndex, - object: { initialNamespaces, version, ...object }, + object: { initialNamespaces, version, accessControl, ...object }, method, } = expectedBulkGetResult.value; if (esRequestIndex !== undefined) { @@ -453,6 +461,22 @@ export class SavedObjectsRepository { }, }; } + if (docFound && !this.hasCompatibleAccessControl(actualResult, accessControl)) { + const { id, type } = object; + return { + tag: 'Left' as 'Left', + error: { + id, + type, + error: { + ...errorContent( + SavedObjectsErrorHelpers.createIncompatibleAccessControlError(type, id) + ), + metadata: { isNotOverwritable: true }, + }, + }, + }; + } savedObjectNamespaces = initialNamespaces || // @ts-expect-error MultiGetHit._source is optional @@ -477,6 +501,7 @@ export class SavedObjectsRepository { type: object.type, attributes: object.attributes, migrationVersion: object.migrationVersion, + accessControl, ...(savedObjectNamespace && { namespace: savedObjectNamespace }), ...(savedObjectNamespaces && { namespaces: savedObjectNamespaces }), updated_at: time, @@ -539,7 +564,7 @@ export class SavedObjectsRepository { */ async checkConflicts( objects: SavedObjectsCheckConflictsObject[] = [], - options: SavedObjectsBaseOptions = {} + options: SavedObjectsCheckConflictsOptions = {} ): Promise { if (objects.length === 0) { return { errors: [] }; @@ -575,7 +600,7 @@ export class SavedObjectsRepository { const bulkGetDocs = expectedBulkGetResults.filter(isRight).map(({ value: { type, id } }) => ({ _id: this._serializer.generateRawId(namespace, type, id), _index: this.getIndexForType(type), - _source: { includes: ['type', 'namespaces'] }, + _source: { includes: ['type', 'namespaces', 'accessControl'] }, })); const bulkGetResponse = bulkGetDocs.length ? await this.client.mget( @@ -598,15 +623,19 @@ export class SavedObjectsRepository { const { type, id, esRequestIndex } = expectedResult.value; const doc = bulkGetResponse?.body.docs[esRequestIndex]; if (doc?.found) { + const isNotOverwritable = + // @ts-expect-error MultiGetHit._source is optional + !this.rawDocExistsInNamespace(doc, namespace) || + !this.hasCompatibleAccessControl(doc, options.accessControl); + + const errorMetadata = isNotOverwritable ? { metadata: { isNotOverwritable } } : undefined; + errors.push({ id, type, error: { ...errorContent(SavedObjectsErrorHelpers.createConflictError(type, id)), - // @ts-expect-error MultiGetHit._source is optional - ...(!this.rawDocExistsInNamespace(doc!, namespace) && { - metadata: { isNotOverwritable: true }, - }), + ...errorMetadata, }, }); } @@ -1245,7 +1274,7 @@ export class SavedObjectsRepository { doc, ...(rawUpsert && { upsert: rawUpsert._source }), }, - _source_includes: ['namespace', 'namespaces', 'originId'], + _source_includes: ['namespace', 'namespaces', 'originId', 'accessControl'], require_alias: true, }) .catch((err) => { @@ -1256,7 +1285,7 @@ export class SavedObjectsRepository { throw err; }); - const { originId } = body.get?._source ?? {}; + const { originId, accessControl } = body.get?._source ?? {}; let namespaces: string[] = []; if (!this._registry.isNamespaceAgnostic(type)) { namespaces = body.get?._source.namespaces ?? [ @@ -1271,6 +1300,7 @@ export class SavedObjectsRepository { version: encodeHitVersion(body), namespaces, ...(originId && { originId }), + ...(accessControl && { accessControl }), references, attributes, }; @@ -1406,7 +1436,7 @@ export class SavedObjectsRepository { .map(({ value: { type, id, objectNamespace } }) => ({ _id: this._serializer.generateRawId(getNamespaceId(objectNamespace), type, id), _index: this.getIndexForType(type), - _source: ['type', 'namespaces'], + _source: ['type', 'namespaces', 'accessControl'], })); const bulkGetResponse = bulkGetDocs.length ? await this.client.mget( @@ -1526,12 +1556,13 @@ export class SavedObjectsRepository { const { _seq_no: seqNo, _primary_term: primaryTerm, get } = rawResponse; // eslint-disable-next-line @typescript-eslint/naming-convention - const { [type]: attributes, references, updated_at } = documentToSave; + const { [type]: attributes, references, updated_at, accessControl } = documentToSave; const { originId } = get._source; return { id, type, + accessControl, ...(namespaces && { namespaces }), ...(originId && { originId }), updated_at, @@ -2013,6 +2044,19 @@ export class SavedObjectsRepository { return rawDocExistsInNamespace(this._registry, raw, namespace); } + private hasCompatibleAccessControl( + rawDoc: + | estypes.GetResponse + | estypes.MgetHit + | undefined, + accessControl: SavedObjectAccessControl | undefined + ) { + if (!rawDoc?._source?.accessControl) { + return true; + } + return rawDoc._source?.accessControl?.owner === accessControl?.owner; + } + /** * Pre-flight check to get a multi-namespace saved object's included namespaces. This ensures that, if the saved object exists, it * includes the target namespace. diff --git a/src/core/server/saved_objects/service/lib/search_dsl/query_params.test.ts b/src/core/server/saved_objects/service/lib/search_dsl/query_params.test.ts index 4df268dafa185d..5dcceb3a3908bb 100644 --- a/src/core/server/saved_objects/service/lib/search_dsl/query_params.test.ts +++ b/src/core/server/saved_objects/service/lib/search_dsl/query_params.test.ts @@ -601,7 +601,7 @@ describe('#getQueryParams', () => { expect( mppClauses.map((clause: any) => Object.keys(clause.match_phrase_prefix)[0]) - ).toEqual(['saved.title', 'pending.title', 'saved.desc', 'pending.desc']); + ).toEqual(['pending.title', 'saved.title', 'pending.desc', 'saved.desc']); }); it('uses all registered types when `type` is not provided', () => { diff --git a/src/core/server/saved_objects/service/lib/search_dsl/query_params.ts b/src/core/server/saved_objects/service/lib/search_dsl/query_params.ts index e5b9108795a259..9b5d3473f7a488 100644 --- a/src/core/server/saved_objects/service/lib/search_dsl/query_params.ts +++ b/src/core/server/saved_objects/service/lib/search_dsl/query_params.ts @@ -17,11 +17,23 @@ import { ALL_NAMESPACES_STRING, DEFAULT_NAMESPACE_STRING } from '../utils'; * Gets the types based on the type. Uses mappings to support * null type (all types), a single type string or an array */ -function getTypes(registry: ISavedObjectTypeRegistry, type?: string | string[]) { - if (!type) { - return registry.getAllTypes().map((registeredType) => registeredType.name); +function getTypes( + registry: ISavedObjectTypeRegistry, + typeToNamespacesMap?: Map, + allowedTypes?: string | string[] +) { + const allTypes = typeToNamespacesMap + ? Array.from(typeToNamespacesMap.keys()) + : registry.getAllTypes().map((registeredType) => registeredType.name); + + if (Array.isArray(allowedTypes)) { + return allTypes.filter((name) => allowedTypes.includes(name)); + } + if (typeof allowedTypes === 'string') { + return allTypes.filter((name) => name === allowedTypes); } - return Array.isArray(type) ? type : [type]; + // otherwise, no restrictions + return allTypes; } /** @@ -204,10 +216,7 @@ export function getQueryParams({ hasReferenceOperator, kueryNode, }: QueryParams) { - const types = getTypes( - registry, - typeToNamespacesMap ? Array.from(typeToNamespacesMap.keys()) : type - ); + const types = getTypes(registry, typeToNamespacesMap, type); if (hasReference && !Array.isArray(hasReference)) { hasReference = [hasReference]; 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 abb86d8120a9ba..ed8518b739d07d 100644 --- a/src/core/server/saved_objects/service/saved_objects_client.ts +++ b/src/core/server/saved_objects/service/saved_objects_client.ts @@ -6,6 +6,7 @@ * Side Public License, v 1. */ +import type { SavedObjectAccessControl } from 'src/core/types'; import type { ISavedObjectsRepository, ISavedObjectsPointInTimeFinder, @@ -70,8 +71,17 @@ export interface SavedObjectsCreateOptions extends SavedObjectsBaseOptions { * * For global object types (registered with `namespaceType: 'agnostic'`): this option cannot be used. */ initialNamespaces?: string[]; + + /** The {@link SavedObjectAccessControl | accessControl} to associate with this saved object. */ + accessControl?: SavedObjectAccessControl; } +/** + * + * @public + */ +export type SavedObjectsBulkCreateOptions = Omit; + /** * * @public @@ -107,6 +117,9 @@ export interface SavedObjectsBulkCreateObject { * * For global object types (registered with `namespaceType: 'agnostic'`): this option cannot be used. */ initialNamespaces?: string[]; + + /** The {@link SavedObjectAccessControl | accessControl} to associate with this saved object. */ + accessControl?: SavedObjectAccessControl; } /** @@ -195,6 +208,15 @@ export interface SavedObjectsFindResponse { pit_id?: string; } +/** + * + * @public + */ +export interface SavedObjectsCheckConflictsOptions extends SavedObjectsBaseOptions { + /** An {@link SavedObjectAccessControl | accessControl} which should be compatible with conflicting objects. */ + accessControl?: SavedObjectAccessControl; +} + /** * * @public @@ -423,7 +445,7 @@ export class SavedObjectsClient { */ async checkConflicts( objects: SavedObjectsCheckConflictsObject[] = [], - options: SavedObjectsBaseOptions = {} + options: SavedObjectsCheckConflictsOptions = {} ): Promise { return await this._repository.checkConflicts(objects, options); } diff --git a/src/core/server/saved_objects/types.ts b/src/core/server/saved_objects/types.ts index 1bb214de701e28..2a0704c2f2e8af 100644 --- a/src/core/server/saved_objects/types.ts +++ b/src/core/server/saved_objects/types.ts @@ -33,6 +33,7 @@ import { SavedObject } from '../../types'; type KueryNode = any; export type { + SavedObjectAccessControl, SavedObjectAttributes, SavedObjectAttribute, SavedObjectAttributeSingle, @@ -248,6 +249,13 @@ export type SavedObjectsClientContract = Pick { * The {@link SavedObjectsNamespaceType | namespace type} for the type. */ namespaceType: SavedObjectsNamespaceType; + + /** + * The {@link SavedObjectsAccessClassification | access classification} for the type. + */ + accessClassification?: SavedObjectsAccessClassification; /** * If defined, the type instances will be stored in the given index instead of the default one. */ diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index 52548c760e30bb..7ccd467890462b 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -141,6 +141,7 @@ import { Request } from '@hapi/hapi'; import { RequestHandlerContext as RequestHandlerContext_2 } from 'src/core/server'; import { ResponseObject } from '@hapi/hapi'; import { ResponseToolkit } from '@hapi/hapi'; +import { SavedObjectAccessControl as SavedObjectAccessControl_2 } from 'src/core/types'; import { SchemaTypeError } from '@kbn/config-schema'; import { ScrollParams } from 'elasticsearch'; import { SearchParams } from 'elasticsearch'; @@ -2226,6 +2227,8 @@ export type SafeRouteMethod = 'get' | 'options'; // // @public (undocumented) export interface SavedObject { + // (undocumented) + accessControl?: SavedObjectAccessControl; attributes: T; coreMigrationVersion?: string; // Warning: (ae-forgotten-export) The symbol "SavedObjectError" needs to be exported by the entry point index.d.ts @@ -2242,6 +2245,11 @@ export interface SavedObject { version?: string; } +// @public +export interface SavedObjectAccessControl { + owner: string; +} + // @public export type SavedObjectAttribute = SavedObjectAttributeSingle | SavedObjectAttributeSingle[]; @@ -2291,10 +2299,12 @@ export interface SavedObjectReference { // @public export interface SavedObjectReferenceWithContext { + accessControl?: SavedObjectAccessControl; id: string; inboundReferences: Array<{ type: string; id: string; + accessControl?: SavedObjectAccessControl; name: string; }>; isMissing?: boolean; @@ -2316,6 +2326,7 @@ export interface SavedObjectsBaseOptions { // @public (undocumented) export interface SavedObjectsBulkCreateObject { + accessControl?: SavedObjectAccessControl_2; // (undocumented) attributes: T; coreMigrationVersion?: string; @@ -2332,6 +2343,9 @@ export interface SavedObjectsBulkCreateObject { version?: string; } +// @public (undocumented) +export type SavedObjectsBulkCreateOptions = Omit; + // @public (undocumented) export interface SavedObjectsBulkGetObject { fields?: string[]; @@ -2380,6 +2394,11 @@ export interface SavedObjectsCheckConflictsObject { type: string; } +// @public (undocumented) +export interface SavedObjectsCheckConflictsOptions extends SavedObjectsBaseOptions { + accessControl?: SavedObjectAccessControl_2; +} + // @public (undocumented) export interface SavedObjectsCheckConflictsResponse { // (undocumented) @@ -2397,7 +2416,7 @@ export class SavedObjectsClient { bulkCreate(objects: Array>, options?: SavedObjectsCreateOptions): Promise>; bulkGet(objects?: SavedObjectsBulkGetObject[], options?: SavedObjectsBaseOptions): Promise>; bulkUpdate(objects: Array>, options?: SavedObjectsBulkUpdateOptions): Promise>; - checkConflicts(objects?: SavedObjectsCheckConflictsObject[], options?: SavedObjectsBaseOptions): Promise; + checkConflicts(objects?: SavedObjectsCheckConflictsObject[], options?: SavedObjectsCheckConflictsOptions): Promise; closePointInTime(id: string, options?: SavedObjectsClosePointInTimeOptions): Promise; collectMultiNamespaceReferences(objects: SavedObjectsCollectMultiNamespaceReferencesObject[], options?: SavedObjectsCollectMultiNamespaceReferencesOptions): Promise; create(type: string, attributes: T, options?: SavedObjectsCreateOptions): Promise>; @@ -2479,6 +2498,7 @@ export interface SavedObjectsCollectMultiNamespaceReferencesResponse { // @public (undocumented) export interface SavedObjectsCreateOptions extends SavedObjectsBaseOptions { + accessControl?: SavedObjectAccessControl_2; coreMigrationVersion?: string; id?: string; initialNamespaces?: string[]; @@ -2520,6 +2540,8 @@ export class SavedObjectsErrorHelpers { // (undocumented) static createGenericNotFoundError(type?: string | null, id?: string | null): DecoratedError; // (undocumented) + static createIncompatibleAccessControlError(type: string, id: string): DecoratedError; + // (undocumented) static createIndexAliasNotFoundError(alias: string): DecoratedError; // (undocumented) static createInvalidVersionError(versionInput?: string): DecoratedError; @@ -2989,10 +3011,10 @@ export interface SavedObjectsRemoveReferencesToResponse extends SavedObjectsBase // @public (undocumented) export class SavedObjectsRepository { - bulkCreate(objects: Array>, options?: SavedObjectsCreateOptions): Promise>; + bulkCreate(objects: Array>, options?: SavedObjectsBulkCreateOptions): Promise>; bulkGet(objects?: SavedObjectsBulkGetObject[], options?: SavedObjectsBaseOptions): Promise>; bulkUpdate(objects: Array>, options?: SavedObjectsBulkUpdateOptions): Promise>; - checkConflicts(objects?: SavedObjectsCheckConflictsObject[], options?: SavedObjectsBaseOptions): Promise; + checkConflicts(objects?: SavedObjectsCheckConflictsObject[], options?: SavedObjectsCheckConflictsOptions): Promise; closePointInTime(id: string, options?: SavedObjectsClosePointInTimeOptions): Promise; collectMultiNamespaceReferences(objects: SavedObjectsCollectMultiNamespaceReferencesObject[], options?: SavedObjectsCollectMultiNamespaceReferencesOptions): Promise; create(type: string, attributes: T, options?: SavedObjectsCreateOptions): Promise>; @@ -3076,6 +3098,9 @@ export interface SavedObjectStatusMeta { // @public (undocumented) export interface SavedObjectsType { + // Warning: (ae-forgotten-export) The symbol "SavedObjectsAccessClassification" needs to be exported by the entry point index.d.ts + // Warning: (ae-unresolved-link) The @link reference could not be resolved: The package "kibana" does not have an export "SavedObjectsAccessClassification" + accessClassification?: SavedObjectsAccessClassification; convertToAliasScript?: string; convertToMultiNamespaceTypeVersion?: string; hidden: boolean; @@ -3175,6 +3200,7 @@ export class SavedObjectTypeRegistry { isImportableAndExportable(type: string): boolean; isMultiNamespace(type: string): boolean; isNamespaceAgnostic(type: string): boolean; + isPrivate(type: string): boolean; isShareable(type: string): boolean; isSingleNamespace(type: string): boolean; registerType(type: SavedObjectsType): void; diff --git a/src/core/server/types.ts b/src/core/server/types.ts index 77b5378f9477fa..8d84f054b80d4f 100644 --- a/src/core/server/types.ts +++ b/src/core/server/types.ts @@ -9,6 +9,7 @@ /** This module is intended for consumption by public to avoid import issues with server-side code */ export type { PluginOpaqueId } from './plugins/types'; export type { + SavedObjectAccessControl, SavedObjectsImportResponse, SavedObjectsImportSuccess, SavedObjectsImportConflictError, diff --git a/src/core/types/saved_objects.ts b/src/core/types/saved_objects.ts index 3a97c2fd6f010b..0c98a41f15f333 100644 --- a/src/core/types/saved_objects.ts +++ b/src/core/types/saved_objects.ts @@ -47,6 +47,16 @@ export interface SavedObjectReference { id: string; } +/** + * The "Access Control" describing which users should be authorized to access this SavedObject. + * + * @public + */ +export interface SavedObjectAccessControl { + /** The owner of this SavedObject. */ + owner: string; +} + /** * Information about the migrations that have been applied to this SavedObject. * When Kibana starts up, KibanaMigrator detects outdated documents and @@ -96,6 +106,7 @@ export interface SavedObject { * space. */ originId?: string; + accessControl?: SavedObjectAccessControl; } export interface SavedObjectError { diff --git a/src/plugins/data/public/public.api.md b/src/plugins/data/public/public.api.md index 781d073b34978a..f56adca0b093e7 100644 --- a/src/plugins/data/public/public.api.md +++ b/src/plugins/data/public/public.api.md @@ -100,6 +100,7 @@ import { Required } from '@kbn/utility-types'; import * as Rx from 'rxjs'; import { SavedObject } from 'src/core/server'; import { SavedObject as SavedObject_2 } from 'kibana/server'; +import { SavedObjectAccessControl as SavedObjectAccessControl_2 } from 'src/core/types'; import { SavedObjectReference as SavedObjectReference_2 } from 'src/core/types'; import { SavedObjectsClientContract } from 'src/core/public'; import { SavedObjectsFindOptions } from 'kibana/public'; diff --git a/src/plugins/embeddable/public/public.api.md b/src/plugins/embeddable/public/public.api.md index 54fe2a7b0bec26..3ea47b7e65eb0b 100644 --- a/src/plugins/embeddable/public/public.api.md +++ b/src/plugins/embeddable/public/public.api.md @@ -49,6 +49,7 @@ import React from 'react'; import { RecursiveReadonly } from '@kbn/utility-types'; import { Request } from '@hapi/hapi'; import * as Rx from 'rxjs'; +import { SavedObjectAccessControl as SavedObjectAccessControl_2 } from 'src/core/types'; import { SavedObjectAttributes } from 'kibana/server'; import { SavedObjectAttributes as SavedObjectAttributes_2 } from 'src/core/public'; import { SavedObjectAttributes as SavedObjectAttributes_3 } from 'kibana/public'; diff --git a/x-pack/plugins/security/server/authorization/actions/saved_object.test.ts b/x-pack/plugins/security/server/authorization/actions/saved_object.test.ts index ceed327b53707f..293c9a792cbfdd 100644 --- a/x-pack/plugins/security/server/authorization/actions/saved_object.test.ts +++ b/x-pack/plugins/security/server/authorization/actions/saved_object.test.ts @@ -35,3 +35,10 @@ describe('#get', () => { ); }); }); + +describe(`#manage`, () => { + test('returns `saved_object:${version}:manage`', () => { + const spaceActions = new SavedObjectActions(version); + expect(spaceActions.manage).toBe('saved_object:1.0.0-zeta1:manage'); + }); +}); diff --git a/x-pack/plugins/security/server/authorization/actions/saved_object.ts b/x-pack/plugins/security/server/authorization/actions/saved_object.ts index 6d1a23e6f42fec..c2fd1b7e93b567 100644 --- a/x-pack/plugins/security/server/authorization/actions/saved_object.ts +++ b/x-pack/plugins/security/server/authorization/actions/saved_object.ts @@ -25,4 +25,8 @@ export class SavedObjectActions { return `${this.prefix}${type}/${operation}`; } + + public get manage(): string { + return `${this.prefix}manage`; + } } diff --git a/x-pack/plugins/security/server/authorization/privileges/privileges.test.ts b/x-pack/plugins/security/server/authorization/privileges/privileges.test.ts index 5264e74861be13..86149fa9aebce5 100644 --- a/x-pack/plugins/security/server/authorization/privileges/privileges.test.ts +++ b/x-pack/plugins/security/server/authorization/privileges/privileges.test.ts @@ -199,10 +199,11 @@ describe('features', () => { }); // the `global` and `space` privileges behave very similarly, with the one exception being that -// "global all" includes the ability to manage spaces. The following tests both groups at once... +// "global all" includes the ability to manage spaces and saved objects. The following tests both groups at once... [ { group: 'global', + expectManageSavedObjects: true, expectManageSpaces: true, expectGetFeatures: true, expectEnterpriseSearch: true, @@ -210,6 +211,7 @@ describe('features', () => { }, { group: 'space', + expectManageSavedObjects: false, expectManageSpaces: false, expectGetFeatures: false, expectEnterpriseSearch: false, @@ -218,10 +220,11 @@ describe('features', () => { ].forEach( ({ group, + expectManageSavedObjects, expectManageSpaces, expectGetFeatures, - expectEnterpriseSearch, expectDecryptedTelemetry, + expectEnterpriseSearch, }) => { describe(`${group}`, () => { test('actions defined in any feature privilege are included in `all`', () => { @@ -272,6 +275,7 @@ describe('features', () => { actions.version, ...(expectDecryptedTelemetry ? [actions.api.get('decryptedTelemetry')] : []), ...(expectGetFeatures ? [actions.api.get('features')] : []), + ...(expectManageSavedObjects ? [actions.savedObject.manage] : []), ...(expectManageSpaces ? [ actions.space.manage, @@ -469,7 +473,7 @@ describe('features', () => { all: ['ignore-me-1', 'ignore-me-2'], read: ['ignore-me-1', 'ignore-me-2'], }, - ui: ['ignore-me-1'], + ui: [], }, }, ], @@ -488,6 +492,7 @@ describe('features', () => { actions.version, ...(expectDecryptedTelemetry ? [actions.api.get('decryptedTelemetry')] : []), ...(expectGetFeatures ? [actions.api.get('features')] : []), + ...(expectManageSavedObjects ? [actions.savedObject.manage] : []), ...(expectManageSpaces ? [ actions.space.manage, @@ -554,6 +559,7 @@ describe('features', () => { actions.version, ...(expectDecryptedTelemetry ? [actions.api.get('decryptedTelemetry')] : []), ...(expectGetFeatures ? [actions.api.get('features')] : []), + ...(expectManageSavedObjects ? [actions.savedObject.manage] : []), ...(expectManageSpaces ? [ actions.space.manage, @@ -621,6 +627,7 @@ describe('features', () => { actions.version, ...(expectDecryptedTelemetry ? [actions.api.get('decryptedTelemetry')] : []), ...(expectGetFeatures ? [actions.api.get('features')] : []), + ...(expectManageSavedObjects ? [actions.savedObject.manage] : []), ...(expectManageSpaces ? [ actions.space.manage, @@ -889,6 +896,7 @@ describe('subFeatures', () => { actions.version, actions.api.get('decryptedTelemetry'), actions.api.get('features'), + actions.savedObject.manage, actions.space.manage, actions.ui.get('spaces', 'manage'), actions.ui.get('management', 'kibana', 'spaces'), @@ -1055,6 +1063,7 @@ describe('subFeatures', () => { actions.version, actions.api.get('decryptedTelemetry'), actions.api.get('features'), + actions.savedObject.manage, actions.space.manage, actions.ui.get('spaces', 'manage'), actions.ui.get('management', 'kibana', 'spaces'), @@ -1288,6 +1297,7 @@ describe('subFeatures', () => { actions.version, actions.api.get('decryptedTelemetry'), actions.api.get('features'), + actions.savedObject.manage, actions.space.manage, actions.ui.get('spaces', 'manage'), actions.ui.get('management', 'kibana', 'spaces'), @@ -1427,6 +1437,7 @@ describe('subFeatures', () => { actions.version, actions.api.get('decryptedTelemetry'), actions.api.get('features'), + actions.savedObject.manage, actions.space.manage, actions.ui.get('spaces', 'manage'), actions.ui.get('management', 'kibana', 'spaces'), @@ -1609,6 +1620,7 @@ describe('subFeatures', () => { actions.version, actions.api.get('decryptedTelemetry'), actions.api.get('features'), + actions.savedObject.manage, actions.space.manage, actions.ui.get('spaces', 'manage'), actions.ui.get('management', 'kibana', 'spaces'), @@ -1737,6 +1749,7 @@ describe('subFeatures', () => { actions.version, actions.api.get('decryptedTelemetry'), actions.api.get('features'), + actions.savedObject.manage, actions.space.manage, actions.ui.get('spaces', 'manage'), actions.ui.get('management', 'kibana', 'spaces'), @@ -1968,6 +1981,7 @@ describe('subFeatures', () => { actions.version, actions.api.get('decryptedTelemetry'), actions.api.get('features'), + actions.savedObject.manage, actions.space.manage, actions.ui.get('spaces', 'manage'), actions.ui.get('management', 'kibana', 'spaces'), @@ -2233,6 +2247,7 @@ describe('subFeatures', () => { actions.version, actions.api.get('decryptedTelemetry'), actions.api.get('features'), + actions.savedObject.manage, actions.space.manage, actions.ui.get('spaces', 'manage'), actions.ui.get('management', 'kibana', 'spaces'), diff --git a/x-pack/plugins/security/server/authorization/privileges/privileges.ts b/x-pack/plugins/security/server/authorization/privileges/privileges.ts index c38a5c9a44f57d..22b6f264680fe3 100644 --- a/x-pack/plugins/security/server/authorization/privileges/privileges.ts +++ b/x-pack/plugins/security/server/authorization/privileges/privileges.ts @@ -106,6 +106,7 @@ export function privilegesFactory( actions.version, actions.api.get('decryptedTelemetry'), actions.api.get('features'), + actions.savedObject.manage, actions.space.manage, actions.ui.get('spaces', 'manage'), actions.ui.get('management', 'kibana', 'spaces'), diff --git a/x-pack/plugins/security/server/config.ts b/x-pack/plugins/security/server/config.ts index 6ce161a8988109..296907f137144c 100644 --- a/x-pack/plugins/security/server/config.ts +++ b/x-pack/plugins/security/server/config.ts @@ -197,6 +197,9 @@ const providersConfigSchema = schema.object( } ); +// xpack.security.session.idleTimeout: '1h' +// xpack.security.session.lifespan: '3d' + export const ConfigSchema = schema.object({ enabled: schema.boolean({ defaultValue: true }), loginAssistanceMessage: schema.string({ defaultValue: '' }), diff --git a/x-pack/plugins/security/server/plugin.ts b/x-pack/plugins/security/server/plugin.ts index 1873ca42324c03..5ca02e0f5bb2b0 100644 --- a/x-pack/plugins/security/server/plugin.ts +++ b/x-pack/plugins/security/server/plugin.ts @@ -298,6 +298,7 @@ export class SecurityPlugin authz: this.authorizationSetup, savedObjects: core.savedObjects, getSpacesService: () => spaces?.spacesService, + getCurrentUser: (request: KibanaRequest) => this.getAuthentication().getCurrentUser(request), }); defineRoutes({ 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 b9ab7cef7f15ba..f484e37fdb1cab 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 @@ -62,6 +62,7 @@ describe('ensureAuthorized', () => { await ensureAuthorized(deps, types, actions, namespaces); expect(deps.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith( [ + 'saved_object:some-version:manage', 'mock-saved_object:a/foo', 'mock-saved_object:a/bar', 'mock-saved_object:b/foo', @@ -93,6 +94,8 @@ describe('ensureAuthorized', () => { ['b', { foo: { authorizedSpaces: ['x', 'y'] }, bar: { authorizedSpaces: ['x', 'y'] } }], ['c', { foo: { authorizedSpaces: ['x', 'y'] }, bar: { authorizedSpaces: ['x', 'y'] } }], ]), + canSpecifyAccessControl: true, + requiresObjectAuthorization: false, }; test('with default options', async () => { @@ -163,6 +166,8 @@ describe('ensureAuthorized', () => { ['b', { foo: { authorizedSpaces: ['x', 'y'] } }], ['c', { foo: { authorizedSpaces: ['x'] }, bar: { authorizedSpaces: ['x'] } }], ]), + canSpecifyAccessControl: false, + requiresObjectAuthorization: true, }); }); }); @@ -208,7 +213,12 @@ describe('ensureAuthorized', () => { deps.checkSavedObjectsPrivilegesAsCurrentUser.mockResolvedValue(resolvedPrivileges); const options = { requireFullAuthorization: false }; const result = await ensureAuthorized(deps, types, actions, namespaces, options); - expect(result).toEqual({ status: 'unauthorized', typeActionMap: new Map() }); + expect(result).toEqual({ + status: 'unauthorized', + typeActionMap: new Map(), + canSpecifyAccessControl: false, + requiresObjectAuthorization: true, + }); }); }); }); 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 c3457e75f9644a..6cf38795f5c285 100644 --- a/x-pack/plugins/security/server/saved_objects/ensure_authorized.ts +++ b/x-pack/plugins/security/server/saved_objects/ensure_authorized.ts @@ -24,6 +24,8 @@ export interface EnsureAuthorizedOptions { export interface EnsureAuthorizedResult { status: 'fully_authorized' | 'partially_authorized' | 'unauthorized'; typeActionMap: Map>; + requiresObjectAuthorization: boolean; + canSpecifyAccessControl: boolean; } export interface EnsureAuthorizedActionResult { @@ -53,10 +55,13 @@ export async function ensureAuthorized( actions.map((action) => [deps.actions.savedObject.get(type, action), { type, action }]) ) ); - const privilegeActions = Array.from(privilegeActionsMap.keys()); + const privilegeActions = [ + deps.actions.savedObject.manage, + ...Array.from(privilegeActionsMap.keys()), + ]; const { hasAllRequested, privileges } = await checkPrivileges(deps, privilegeActions, spaceIds); - const missingPrivileges = getMissingPrivileges(privileges); + const missingPrivileges = getMissingPrivileges(privileges, [deps.actions.savedObject.manage]); const typeActionMap = privileges.kibana.reduce< Map> >((acc, { resource, privilege }) => { @@ -64,7 +69,8 @@ export async function ensureAuthorized( (resource && missingPrivileges.get(resource)?.has(privilege)) || (!resource && missingPrivileges.get(undefined)?.has(privilege)); - if (missingPrivilegesAtResource) { + const isManageSavedObjectsPrivilege = privilege === deps.actions.savedObject.manage; + if (missingPrivilegesAtResource || isManageSavedObjectsPrivilege) { return acc; } const { type, action } = privilegeActionsMap.get(privilege)!; // always defined @@ -89,16 +95,36 @@ export async function ensureAuthorized( }); }, new Map()); - if (hasAllRequested) { - return { typeActionMap, status: 'fully_authorized' }; + const isFullyAuthorized = missingPrivileges.size === 0; + // `hasAllRequested` comes from the ES privilege check above, which has been augmented to include the "manage" saved object privilege. + const requiresObjectAuthorization = hasAllRequested === false; + const canSpecifyAccessControl = hasAllRequested === true; + + if (isFullyAuthorized) { + return { + typeActionMap, + status: 'fully_authorized', + requiresObjectAuthorization, + canSpecifyAccessControl, + }; } if (!requireFullAuthorization) { const isPartiallyAuthorized = typeActionMap.size > 0; if (isPartiallyAuthorized) { - return { typeActionMap, status: 'partially_authorized' }; + return { + typeActionMap, + status: 'partially_authorized', + requiresObjectAuthorization, + canSpecifyAccessControl, + }; } else { - return { typeActionMap, status: 'unauthorized' }; + return { + typeActionMap, + status: 'unauthorized', + requiresObjectAuthorization, + canSpecifyAccessControl, + }; } } @@ -170,10 +196,13 @@ async function checkPrivileges( } } -function getMissingPrivileges(privileges: CheckPrivilegesResponse['privileges']) { +function getMissingPrivileges( + privileges: CheckPrivilegesResponse['privileges'], + ignorePrivileges: string[] = [] +) { return privileges.kibana.reduce>>( (acc, { resource, privilege, authorized }) => { - if (!authorized) { + if (!authorized && !ignorePrivileges.includes(privilege)) { if (resource) { acc.set(resource, (acc.get(resource) || new Set()).add(privilege)); } diff --git a/x-pack/plugins/security/server/saved_objects/index.ts b/x-pack/plugins/security/server/saved_objects/index.ts index b291fa86bbf563..ca12765a2dfaee 100644 --- a/x-pack/plugins/security/server/saved_objects/index.ts +++ b/x-pack/plugins/security/server/saved_objects/index.ts @@ -8,6 +8,7 @@ import type { CoreSetup, LegacyRequest } from 'src/core/server'; import { KibanaRequest, SavedObjectsClient } from '../../../../../src/core/server'; +import type { AuthenticatedUser } from '../../common/model'; import type { AuditServiceSetup, SecurityAuditLogger } from '../audit'; import type { AuthorizationServiceSetupInternal } from '../authorization'; import type { SpacesService } from '../plugin'; @@ -22,6 +23,7 @@ interface SetupSavedObjectsParams { >; savedObjects: CoreSetup['savedObjects']; getSpacesService(): SpacesService | undefined; + getCurrentUser(request: KibanaRequest): AuthenticatedUser | null; } export type { @@ -43,6 +45,7 @@ export function setupSavedObjects({ authz, savedObjects, getSpacesService, + getCurrentUser, }: SetupSavedObjectsParams) { const getKibanaRequest = (request: KibanaRequest | LegacyRequest) => request instanceof KibanaRequest ? request : KibanaRequest.from(request); @@ -58,20 +61,26 @@ export function setupSavedObjects({ } ); - savedObjects.addClientWrapper(Number.MAX_SAFE_INTEGER - 1, 'security', ({ client, request }) => { - const kibanaRequest = getKibanaRequest(request); - return authz.mode.useRbacForRequest(kibanaRequest) - ? new SecureSavedObjectsClientWrapper({ - actions: authz.actions, - legacyAuditLogger, - auditLogger: audit.asScoped(kibanaRequest), - baseClient: client, - checkSavedObjectsPrivilegesAsCurrentUser: authz.checkSavedObjectsPrivilegesWithRequest( - kibanaRequest - ), - errors: SavedObjectsClient.errors, - getSpacesService, - }) - : client; - }); + savedObjects.addClientWrapper( + Number.MAX_SAFE_INTEGER - 1, + 'security', + ({ client, request, typeRegistry }) => { + const kibanaRequest = getKibanaRequest(request); + return authz.mode.useRbacForRequest(kibanaRequest) + ? new SecureSavedObjectsClientWrapper({ + actions: authz.actions, + legacyAuditLogger, + auditLogger: audit.asScoped(kibanaRequest), + baseClient: client, + checkSavedObjectsPrivilegesAsCurrentUser: authz.checkSavedObjectsPrivilegesWithRequest( + kibanaRequest + ), + typeRegistry, + errors: SavedObjectsClient.errors, + getSpacesService, + getCurrentUser: () => getCurrentUser(kibanaRequest), + }) + : client; + } + ); } diff --git a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts index e5a2340aba3f0c..345db9f1f9c26f 100644 --- a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts +++ b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts @@ -14,7 +14,11 @@ import type { SavedObjectsClientContract, SavedObjectsUpdateObjectsSpacesResponseObject, } from 'src/core/server'; -import { httpServerMock, savedObjectsClientMock } from 'src/core/server/mocks'; +import { + httpServerMock, + savedObjectsClientMock, + savedObjectsServiceMock, +} from 'src/core/server/mocks'; import type { AuditEvent } from '../audit'; import { auditServiceMock, securityAuditLoggerMock } from '../audit/index.mock'; @@ -47,12 +51,14 @@ const createSecureSavedObjectsClientWrapperOptions = () => { const forbiddenError = new Error('Mock ForbiddenError'); const generalError = new Error('Mock GeneralError'); + const notFoundError = new Error('Mock NotFoundError'); const errors = ({ decorateForbiddenError: jest.fn().mockReturnValue(forbiddenError), decorateGeneralError: jest.fn().mockReturnValue(generalError), createBadRequestError: jest.fn().mockImplementation((message) => new Error(message)), - isNotFoundError: jest.fn().mockReturnValue(false), + createGenericNotFoundError: jest.fn().mockImplementation(() => notFoundError), + isNotFoundError: jest.fn().mockImplementation((e) => e.message === notFoundError.message), } as unknown) as jest.Mocked; const getSpacesService = jest.fn().mockReturnValue({ namespaceToSpaceId: (namespace?: string) => (namespace ? namespace : 'default'), @@ -63,6 +69,8 @@ const createSecureSavedObjectsClientWrapperOptions = () => { baseClient: savedObjectsClientMock.create(), checkSavedObjectsPrivilegesAsCurrentUser: jest.fn(), errors, + getCurrentUser: jest.fn().mockReturnValue({ username: 'foo' }), + typeRegistry: savedObjectsServiceMock.createStartContract().getTypeRegistry(), getSpacesService, legacyAuditLogger: securityAuditLoggerMock.create(), auditLogger: auditServiceMock.create().asScoped(httpServerMock.createKibanaRequest()), @@ -107,7 +115,9 @@ const expectForbiddenError = async (fn: Function, args: Record, act const ACTION = getCalls[0][1]; const types = getCalls.map((x) => x[0]); - const missing = [{ spaceId, privilege: actions[0] }]; // if there was more than one type, only the first type was unauthorized + + expect(actions[0]).toEqual(clientOpts.actions.savedObject.manage); + const missing = [{ spaceId, privilege: actions[1] }]; // if there was more than one type, only the first type was unauthorized const spaceIds = [spaceId]; expect(clientOpts.errors.decorateForbiddenError).toHaveBeenCalledTimes(1); @@ -157,7 +167,7 @@ const expectPrivilegeCheck = async ( const getResults = (clientOpts.actions.savedObject.get as jest.MockedFunction< SavedObjectActions['get'] >).mock.results; - const actions = getResults.map((x) => x.value); + const actions = [clientOpts.actions.savedObject.manage, ...getResults.map((x) => x.value)]; expect(clientOpts.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledTimes(1); expect(clientOpts.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith( @@ -166,16 +176,10 @@ const expectPrivilegeCheck = async ( ); }; -const expectObjectNamespaceFiltering = async ( - fn: Function, - args: Record, - privilegeChecks = 1 -) => { - for (let i = 0; i < privilegeChecks; i++) { - clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockImplementationOnce( - getMockCheckPrivilegesSuccess // privilege check for authorization - ); - } +const expectObjectNamespaceFiltering = async (fn: Function, args: Record) => { + clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockImplementationOnce( + getMockCheckPrivilegesSuccess // privilege check for authorization + ); clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockImplementation( getMockCheckPrivilegesFailure // privilege check for namespace filtering ); @@ -193,9 +197,7 @@ const expectObjectNamespaceFiltering = async ( // we will never redact the "All Spaces" ID expect(result).toEqual(expect.objectContaining({ namespaces: ['*', authorizedNamespace, '?'] })); - expect(clientOpts.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledTimes( - privilegeChecks + 1 - ); + expect(clientOpts.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledTimes(2); expect(clientOpts.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenLastCalledWith( 'login:', ['some-other-namespace'] @@ -301,11 +303,11 @@ function getMockCheckPrivilegesFailure(actions: string | string[], namespaces?: username: USERNAME, privileges: { kibana: _namespaces - .map((resource, idxa) => - _actions.map((action, idxb) => ({ + .map((resource, resourceIdx) => + _actions.map((action, actionIdx) => ({ resource, privilege: action, - authorized: idxa > 0 || idxb > 0, + authorized: resourceIdx > 0 || actionIdx > 1, })) ) .flat(), @@ -347,6 +349,7 @@ describe('#bulkCreate', () => { test(`returns result of baseClient.bulkCreate when authorized`, async () => { const apiCallReturnValue = { saved_objects: [], foo: 'bar' }; + clientOpts.baseClient.bulkGet.mockReturnValue(apiCallReturnValue as any); clientOpts.baseClient.bulkCreate.mockReturnValue(apiCallReturnValue as any); const objects = [obj1, obj2]; @@ -382,6 +385,7 @@ describe('#bulkCreate', () => { test(`adds audit event when successful`, async () => { const apiCallReturnValue = { saved_objects: [], foo: 'bar' }; + clientOpts.baseClient.bulkGet.mockReturnValue(apiCallReturnValue as any); clientOpts.baseClient.bulkCreate.mockReturnValue(apiCallReturnValue as any); const objects = [obj1, obj2]; const options = { namespace }; @@ -476,6 +480,7 @@ describe('#bulkUpdate', () => { test(`returns result of baseClient.bulkUpdate when authorized`, async () => { const apiCallReturnValue = { saved_objects: [], foo: 'bar' }; + clientOpts.baseClient.bulkGet.mockReturnValue(apiCallReturnValue as any); clientOpts.baseClient.bulkUpdate.mockReturnValue(apiCallReturnValue as any); const objects = [obj1, obj2]; @@ -509,6 +514,7 @@ describe('#bulkUpdate', () => { test(`adds audit event when successful`, async () => { const apiCallReturnValue = { saved_objects: [], foo: 'bar' }; + clientOpts.baseClient.bulkGet.mockReturnValue(apiCallReturnValue as any); clientOpts.baseClient.bulkUpdate.mockReturnValue(apiCallReturnValue as any); const objects = [obj1, obj2]; const options = { namespace }; @@ -544,8 +550,8 @@ describe('#checkConflicts', () => { }); test(`returns result of baseClient.create when authorized`, async () => { - const apiCallReturnValue = Symbol(); - clientOpts.baseClient.checkConflicts.mockResolvedValue(apiCallReturnValue as any); + const apiCallReturnValue = Object.freeze({ errors: [] }); + clientOpts.baseClient.checkConflicts.mockResolvedValue(apiCallReturnValue); const objects = [obj1, obj2]; const options = { namespace }; @@ -1199,6 +1205,8 @@ describe('#collectMultiNamespaceReferences', () => { .set('a', { bulk_get: { isGloballyAuthorized: true, authorizedSpaces: [] } }) .set('b', { bulk_get: { authorizedSpaces: [spaceX, spaceY] } }) .set('c', { bulk_get: { authorizedSpaces: [spaceY] } }), + requiresObjectAuthorization: true, + canSpecifyAccessControl: false, }); const options = { namespace: spaceX }; // spaceX is the current space await expect(() => @@ -1230,6 +1238,8 @@ describe('#collectMultiNamespaceReferences', () => { bulk_get: { authorizedSpaces: [spaceX, spaceY] }, share_to_space: { authorizedSpaces: [spaceY] }, }), + requiresObjectAuthorization: true, + canSpecifyAccessControl: false, }); const options = { namespace: spaceX, purpose: 'updateObjectsSpaces' as const }; // spaceX is the current space await expect(() => @@ -1263,6 +1273,8 @@ describe('#collectMultiNamespaceReferences', () => { typeActionMap: new Map().set('a', { bulk_get: { isGloballyAuthorized: true, authorizedSpaces: [] }, }), + requiresObjectAuthorization: true, + canSpecifyAccessControl: false, }); // When the loop gets to obj2, it will determine that the user is authorized for the object but *not* for the graph. However, it will // also determine that there is *no* valid inbound reference tying this object back to what was requested. In this case, throw an @@ -1289,6 +1301,8 @@ describe('#collectMultiNamespaceReferences', () => { typeActionMap: new Map().set('a', { bulk_get: { isGloballyAuthorized: true, authorizedSpaces: [] }, // success case for the simplest test }), + requiresObjectAuthorization: false, + canSpecifyAccessControl: true, }); }); @@ -1368,6 +1382,8 @@ describe('#collectMultiNamespaceReferences', () => { .set('b', { bulk_get: { authorizedSpaces: [spaceX] } }) .set('c', { bulk_get: { authorizedSpaces: [spaceX] } }), // the user is not authorized to read type 'd' + requiresObjectAuthorization: true, + canSpecifyAccessControl: false, }); const options = { namespace: spaceX }; // spaceX is the current space @@ -1425,6 +1441,8 @@ describe('#collectMultiNamespaceReferences', () => { // Even though the user can only share type 'c' in spaceX, we won't redact spaceY because the user has read privileges there }), // the user is not authorized to read or share type 'd' + requiresObjectAuthorization: true, + canSpecifyAccessControl: false, }); const options = { namespace: spaceX, purpose: 'updateObjectsSpaces' as const }; // spaceX is the current space @@ -1512,6 +1530,8 @@ describe('#updateObjectsSpaces', () => { bulk_get: { authorizedSpaces: [spaceA, spaceB, spaceC] }, share_to_space: { authorizedSpaces: [spaceA, spaceB] }, }), + requiresObjectAuthorization: true, + canSpecifyAccessControl: false, }); const objects = [obj1, obj2, obj3]; @@ -1553,6 +1573,8 @@ describe('#updateObjectsSpaces', () => { bulk_get: { authorizedSpaces: [spaceA, spaceB, spaceC] }, share_to_space: { authorizedSpaces: [spaceA, spaceB, spaceC] }, }), + requiresObjectAuthorization: true, + canSpecifyAccessControl: false, }); clientOpts.baseClient.updateObjectsSpaces.mockRejectedValue(new Error('Oh no!')); @@ -1597,6 +1619,8 @@ describe('#updateObjectsSpaces', () => { bulk_get: { authorizedSpaces: [spaceA, spaceB, spaceC] }, // the user is not authorized to bulkGet type 'z' in spaceD, so it will be redacted from the results share_to_space: { authorizedSpaces: [spaceA, spaceB, spaceC] }, }), + requiresObjectAuthorization: true, + canSpecifyAccessControl: false, }); clientOpts.baseClient.updateObjectsSpaces.mockResolvedValue({ objects: [ @@ -1657,6 +1681,8 @@ describe('#updateObjectsSpaces', () => { bulk_get: { isGloballyAuthorized: true, authorizedSpaces: [] }, share_to_space: { isGloballyAuthorized: true, authorizedSpaces: [] }, }), + requiresObjectAuthorization: false, + canSpecifyAccessControl: true, }); clientOpts.baseClient.updateObjectsSpaces.mockResolvedValue({ objects: [ @@ -1690,6 +1716,8 @@ describe('#updateObjectsSpaces', () => { bulk_get: { isGloballyAuthorized: true, authorizedSpaces: [] }, share_to_space: { isGloballyAuthorized: true, authorizedSpaces: [] }, }), + requiresObjectAuthorization: false, + canSpecifyAccessControl: true, }); clientOpts.baseClient.updateObjectsSpaces.mockResolvedValue({ objects: [ 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 a3bd2152119830..aa267a68c93565 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 @@ -7,12 +7,17 @@ import type { PublicMethodsOf } from '@kbn/utility-types'; import type { + ISavedObjectTypeRegistry, + SavedObject, + SavedObjectAccessControl, SavedObjectReferenceWithContext, SavedObjectsBaseOptions, SavedObjectsBulkCreateObject, + SavedObjectsBulkCreateOptions, SavedObjectsBulkGetObject, SavedObjectsBulkUpdateObject, SavedObjectsCheckConflictsObject, + SavedObjectsCheckConflictsOptions, SavedObjectsClientContract, SavedObjectsClosePointInTimeOptions, SavedObjectsCollectMultiNamespaceReferencesObject, @@ -29,7 +34,9 @@ import type { SavedObjectsUpdateOptions, } from 'src/core/server'; +import type { AuthenticatedUser } from '..'; import { SavedObjectsUtils } from '../../../../../src/core/server'; +import { esKuery } from '../../../../../src/plugins/data/server'; import { ALL_SPACES_ID, UNKNOWN_SPACE } from '../../common/constants'; import type { AuditLogger, SecurityAuditLogger } from '../audit'; import { SavedObjectAction, savedObjectEvent } from '../audit'; @@ -52,8 +59,10 @@ interface SecureSavedObjectsClientWrapperOptions { legacyAuditLogger: SecurityAuditLogger; auditLogger: AuditLogger; baseClient: SavedObjectsClientContract; + typeRegistry: ISavedObjectTypeRegistry; errors: SavedObjectsClientContract['errors']; checkSavedObjectsPrivilegesAsCurrentUser: CheckSavedObjectsPrivileges; + getCurrentUser(): AuthenticatedUser | null; getSpacesService(): SpacesService | undefined; } @@ -69,11 +78,17 @@ interface LegacyEnsureAuthorizedOptions { args?: Record; auditAction?: string; requireFullAuthorization?: boolean; + savedObject?: SavedObject | null; } interface LegacyEnsureAuthorizedResult { status: 'fully_authorized' | 'partially_authorized' | 'unauthorized'; typeMap: Map; + requiresObjectAuthorization: boolean; + canSpecifyAccessControl: boolean; + legacyAuditLogger: { + logAuthorized(): void; + }; } interface LegacyEnsureAuthorizedTypeResult { authorizedSpaces: string[]; @@ -84,8 +99,10 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra private readonly legacyAuditLogger: PublicMethodsOf; private readonly auditLogger: AuditLogger; private readonly baseClient: SavedObjectsClientContract; + private readonly typeRegistry: ISavedObjectTypeRegistry; private readonly checkSavedObjectsPrivilegesAsCurrentUser: CheckSavedObjectsPrivileges; private getSpacesService: () => SpacesService | undefined; + private getCurrentUser: () => AuthenticatedUser | null; public readonly errors: SavedObjectsClientContract['errors']; constructor({ @@ -93,17 +110,21 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra legacyAuditLogger, auditLogger, baseClient, + typeRegistry, checkSavedObjectsPrivilegesAsCurrentUser, errors, getSpacesService, + getCurrentUser, }: SecureSavedObjectsClientWrapperOptions) { this.errors = errors; this.actions = actions; this.legacyAuditLogger = legacyAuditLogger; this.auditLogger = auditLogger; this.baseClient = baseClient; + this.typeRegistry = typeRegistry; this.checkSavedObjectsPrivilegesAsCurrentUser = checkSavedObjectsPrivilegesAsCurrentUser; this.getSpacesService = getSpacesService; + this.getCurrentUser = getCurrentUser; } public async create( @@ -111,16 +132,42 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra attributes: T = {} as T, options: SavedObjectsCreateOptions = {} ) { - const optionsWithId = { ...options, id: options.id ?? SavedObjectsUtils.generateId() }; - const namespaces = [optionsWithId.namespace, ...(optionsWithId.initialNamespaces || [])]; + const augmentedOptions = { + ...options, + id: options.id ?? SavedObjectsUtils.generateId(), + accessControl: options.accessControl ?? this.createAccessControl(type), + }; + + const namespaces = [augmentedOptions.namespace, ...(augmentedOptions.initialNamespaces || [])]; try { - const args = { type, attributes, options: optionsWithId }; - await this.legacyEnsureAuthorized(type, 'create', namespaces, { args }); + const action = 'create'; + const args = { type, attributes, options: augmentedOptions }; + const { + legacyAuditLogger, + requiresObjectAuthorization, + canSpecifyAccessControl, + } = await this.legacyEnsureAuthorized(type, action, namespaces, { + args, + }); + if (!canSpecifyAccessControl) { + this.ensureAccessControlNotSpecified(options); + } + // FIXME: this check differs from bulk_create -- one of them is incorrect (probably this one?) + const needsPreflightCheck = requiresObjectAuthorization && options.overwrite === true; + if (needsPreflightCheck) { + await this.ensureAuthorizedForObjects( + [{ type, id: augmentedOptions.id }], + augmentedOptions.namespace, + action + ); + } + // Need to wait until we've successfully authorized individual object access before declaring this "authorized" + legacyAuditLogger.logAuthorized(); } catch (error) { this.auditLogger.log( savedObjectEvent({ action: SavedObjectAction.CREATE, - savedObject: { type, id: optionsWithId.id }, + savedObject: { type, id: augmentedOptions.id }, error, }) ); @@ -130,51 +177,84 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra savedObjectEvent({ action: SavedObjectAction.CREATE, outcome: 'unknown', - savedObject: { type, id: optionsWithId.id }, + savedObject: { type, id: augmentedOptions.id }, }) ); - const savedObject = await this.baseClient.create(type, attributes, optionsWithId); + const savedObject = await this.baseClient.create(type, attributes, augmentedOptions); return await this.redactSavedObjectNamespaces(savedObject, namespaces); } public async checkConflicts( objects: SavedObjectsCheckConflictsObject[] = [], - options: SavedObjectsBaseOptions = {} + options: SavedObjectsCheckConflictsOptions = {} ) { + this.ensureAccessControlNotSpecified(options); const args = { objects, options }; const types = this.getUniqueObjectTypes(objects); - await this.legacyEnsureAuthorized(types, 'bulk_create', options.namespace, { - args, - auditAction: 'checkConflicts', - }); + const { legacyAuditLogger } = await this.legacyEnsureAuthorized( + types, + 'bulk_create', + options.namespace, + { + args, + auditAction: 'checkConflicts', + } + ); + legacyAuditLogger.logAuthorized(); - const response = await this.baseClient.checkConflicts(objects, options); - return response; + return this.baseClient.checkConflicts(objects, { + ...options, + accessControl: { + owner: this.getOwner(), + }, + }); } public async bulkCreate( objects: Array>, - options: SavedObjectsBaseOptions = {} + options: SavedObjectsBulkCreateOptions = {} ) { - const objectsWithId = objects.map((obj) => ({ - ...obj, - id: obj.id ?? SavedObjectsUtils.generateId(), - })); + let hasAccessControlSpecified = false; + const objectsWithId: Array & { id: string }> = []; + objects.forEach((obj) => { + objectsWithId.push({ + ...obj, + id: obj.id ?? SavedObjectsUtils.generateId(), + accessControl: obj.accessControl ?? this.createAccessControl(obj.type), + }); + hasAccessControlSpecified = hasAccessControlSpecified || obj.accessControl != null; + }); + const namespaces = objectsWithId.reduce( (acc, { initialNamespaces = [] }) => acc.concat(initialNamespaces), [options.namespace] ); try { + const action = 'bulk_create'; const args = { objects: objectsWithId, options }; - await this.legacyEnsureAuthorized( + const { + legacyAuditLogger, + requiresObjectAuthorization, + canSpecifyAccessControl, + } = await this.legacyEnsureAuthorized( this.getUniqueObjectTypes(objectsWithId), - 'bulk_create', + action, namespaces, { args, } ); + + if (!canSpecifyAccessControl && hasAccessControlSpecified) { + throw this.errors.decorateForbiddenError(new Error('ACL cannot be specified')); + } + const requiresPreflightCheck = requiresObjectAuthorization; + if (requiresPreflightCheck) { + await this.ensureAuthorizedForObjects(objectsWithId, options.namespace, action); + } + // Need to wait until we've successfully authorized individual object access before declaring this "authorized" + legacyAuditLogger.logAuthorized(); } catch (error) { objectsWithId.forEach(({ type, id }) => this.auditLogger.log( @@ -203,8 +283,17 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra public async delete(type: string, id: string, options: SavedObjectsBaseOptions = {}) { try { + const action = 'delete'; const args = { type, id, options }; - await this.legacyEnsureAuthorized(type, 'delete', options.namespace, { args }); + const { + legacyAuditLogger, + requiresObjectAuthorization, + } = await this.legacyEnsureAuthorized(type, action, options.namespace, { args }); + if (requiresObjectAuthorization) { + await this.ensureAuthorizedForObjects([{ type, id }], options.namespace, action); + } + // Need to wait until we've successfully authorized individual object access before declaring this "authorized" + legacyAuditLogger.logAuthorized(); } catch (error) { this.auditLogger.log( savedObjectEvent({ @@ -215,6 +304,7 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra ); throw error; } + this.auditLogger.log( savedObjectEvent({ action: SavedObjectAction.DELETE, @@ -243,12 +333,15 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra } const args = { options }; - const { status, typeMap } = await this.legacyEnsureAuthorized( - options.type, - 'find', - options.namespaces, - { args, requireFullAuthorization: false } - ); + const { + status, + typeMap, + legacyAuditLogger, + requiresObjectAuthorization, + } = await this.legacyEnsureAuthorized(options.type, 'find', options.namespaces, { + args, + requireFullAuthorization: false, + }); if (status === 'unauthorized') { // return empty response @@ -261,16 +354,63 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra return SavedObjectsUtils.createEmptyFindResponse(options); } + legacyAuditLogger.logAuthorized(); + const typeToNamespacesMap = Array.from(typeMap).reduce>( (acc, [type, { authorizedSpaces, isGloballyAuthorized }]) => isGloballyAuthorized ? acc.set(type, options.namespaces) : acc.set(type, authorizedSpaces), new Map() ); + const filterClauses = Array.from(typeMap.keys()).reduce((acc, type) => { + if (this.typeRegistry.isPrivate(type) && requiresObjectAuthorization) { + return [ + ...acc, + // note: this relies on specific behavior of the SO service's `filter_utils`, + // which automatically wraps this in an `and` node to ensure the type is accounted for. + // we have added additional safeguards there, and functional tests will ensure that changes + // to this logic will not accidentally alter our authorization model. + + // This is equivalent to writing the following, if this syntax was allowed by the SO `filter` option: + // esKuery.nodeTypes.function.buildNode('and', [ + // esKuery.nodeTypes.function.buildNode('is', `accessControl.owner`, this.getOwner()), + // esKuery.nodeTypes.function.buildNode('is', `type`, type), + // ]) + esKuery.nodeTypes.function.buildNode( + 'is', + `${type}.accessControl.owner`, + this.getOwner() + ), + ]; + } + return acc; + }, [] as unknown[]); + + const confidentialObjectsFilter = + filterClauses.length > 0 ? esKuery.nodeTypes.function.buildNode('or', filterClauses) : null; + + let filter; + if (options.filter && confidentialObjectsFilter) { + const existingFilter = + typeof options.filter === 'string' + ? esKuery.fromKueryExpression(options.filter) + : options.filter; + + filter = esKuery.nodeTypes.function.buildNode('and', [ + existingFilter, + confidentialObjectsFilter, + ]); + } else if (confidentialObjectsFilter) { + filter = confidentialObjectsFilter; + } else { + filter = options.filter; + } + const response = await this.baseClient.find({ ...options, typeToNamespacesMap: undefined, // if the user is fully authorized, use `undefined` as the typeToNamespacesMap to prevent privilege escalation ...(status === 'partially_authorized' && { typeToNamespacesMap, type: '', namespaces: [] }), // the repository requires that `type` and `namespaces` must be empty if `typeToNamespacesMap` is defined + filter, }); response.saved_objects.forEach(({ type, id }) => @@ -289,16 +429,19 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra objects: SavedObjectsBulkGetObject[] = [], options: SavedObjectsBaseOptions = {} ) { + let legacyAuditLogger; + let requiresObjectAuthorization: boolean; + const action = 'bulk_get'; try { const args = { objects, options }; - await this.legacyEnsureAuthorized( + ({ legacyAuditLogger, requiresObjectAuthorization } = await this.legacyEnsureAuthorized( this.getUniqueObjectTypes(objects), - 'bulk_get', + action, options.namespace, { args, } - ); + )); } catch (error) { objects.forEach(({ type, id }) => this.auditLogger.log( @@ -314,24 +457,50 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra const response = await this.baseClient.bulkGet(objects, options); - response.saved_objects.forEach(({ error, type, id }) => { - if (!error) { + const savedObjects = response.saved_objects.map((object) => { + if (requiresObjectAuthorization && !this.isAuthorizedForObject(object)) { + const error = this.createForbiddenObjectError(action, object); this.auditLogger.log( savedObjectEvent({ action: SavedObjectAction.GET, - savedObject: { type, id }, + savedObject: { type: object.type, id: object.id }, + error, }) ); + return ({ + type: object.type, + id: object.id, + error: error.output.payload, + } as unknown) as SavedObject; } + this.auditLogger.log( + savedObjectEvent({ + action: SavedObjectAction.GET, + savedObject: { type: object.type, id: object.id }, + }) + ); + return object; }); - return await this.redactSavedObjectsNamespaces(response, [options.namespace]); + legacyAuditLogger.logAuthorized(); + + return this.redactSavedObjectsNamespaces({ ...response, saved_objects: savedObjects }, [ + options.namespace, + ]); } public async get(type: string, id: string, options: SavedObjectsBaseOptions = {}) { + let legacyAuditLogger; + let requiresObjectAuthorization: boolean; + const action = 'get'; try { const args = { type, id, options }; - await this.legacyEnsureAuthorized(type, 'get', options.namespace, { args }); + ({ legacyAuditLogger, requiresObjectAuthorization } = await this.legacyEnsureAuthorized( + type, + action, + options.namespace, + { args } + )); } catch (error) { this.auditLogger.log( savedObjectEvent({ @@ -345,6 +514,19 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra const savedObject = await this.baseClient.get(type, id, options); + if (requiresObjectAuthorization && !this.isAuthorizedForObject(savedObject)) { + const error = this.createForbiddenObjectError(action, savedObject); + this.auditLogger.log( + savedObjectEvent({ + action: SavedObjectAction.GET, + savedObject: { type, id }, + error, + }) + ); + throw error; + } + + legacyAuditLogger.logAuthorized(); this.auditLogger.log( savedObjectEvent({ action: SavedObjectAction.GET, @@ -360,12 +542,22 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra id: string, options: SavedObjectsBaseOptions = {} ) { + this.ensureAccessControlNotSpecified(options); + const action = 'get'; + let legacyAuditLogger; + let requiresObjectAuthorization: boolean; try { const args = { type, id, options }; - await this.legacyEnsureAuthorized(type, 'get', options.namespace, { - args, - auditAction: 'resolve', - }); + ({ legacyAuditLogger, requiresObjectAuthorization } = await this.legacyEnsureAuthorized( + type, + action, + options.namespace, + { + args, + auditAction: 'resolve', + } + )); + legacyAuditLogger.logAuthorized(); } catch (error) { this.auditLogger.log( savedObjectEvent({ @@ -379,6 +571,18 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra const resolveResult = await this.baseClient.resolve(type, id, options); + if (requiresObjectAuthorization && !this.isAuthorizedForObject(resolveResult.saved_object)) { + const error = this.createForbiddenObjectError(action, resolveResult.saved_object); + this.auditLogger.log( + savedObjectEvent({ + action: SavedObjectAction.RESOLVE, + savedObject: resolveResult.saved_object, + error, + }) + ); + throw error; + } + this.auditLogger.log( savedObjectEvent({ action: SavedObjectAction.RESOLVE, @@ -400,9 +604,18 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra attributes: Partial, options: SavedObjectsUpdateOptions = {} ) { + this.ensureAccessControlNotSpecified(options); try { + const action = 'update'; const args = { type, id, attributes, options }; - await this.legacyEnsureAuthorized(type, 'update', options.namespace, { args }); + const { + legacyAuditLogger, + requiresObjectAuthorization, + } = await this.legacyEnsureAuthorized(type, action, options.namespace, { args }); + if (requiresObjectAuthorization) { + await this.ensureAuthorizedForObjects([{ type, id }], options.namespace, action); + } + legacyAuditLogger.logAuthorized(); } catch (error) { this.auditLogger.log( savedObjectEvent({ @@ -421,6 +634,7 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra }) ); + // const augmentedOptions = { ...options, accessControl: this.createACL(type) }; const savedObject = await this.baseClient.update(type, id, attributes, options); return await this.redactSavedObjectNamespaces(savedObject, [options.namespace]); } @@ -435,16 +649,22 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra .filter(({ namespace }) => namespace !== undefined) .map(({ namespace }) => namespace!); const namespaces = [options?.namespace, ...objectNamespaces]; + try { + const action = 'bulk_update'; const args = { objects, options }; - await this.legacyEnsureAuthorized( + const { legacyAuditLogger, requiresObjectAuthorization } = await this.legacyEnsureAuthorized( this.getUniqueObjectTypes(objects), - 'bulk_update', + action, namespaces, { args, } ); + if (requiresObjectAuthorization) { + await this.ensureAuthorizedForObjects(objects, options.namespace, action); + } + legacyAuditLogger.logAuthorized(); } catch (error) { objects.forEach(({ type, id }) => this.auditLogger.log( @@ -457,6 +677,7 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra ); throw error; } + objects.forEach(({ type, id }) => this.auditLogger.log( savedObjectEvent({ @@ -477,11 +698,21 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra options: SavedObjectsRemoveReferencesToOptions = {} ) { try { + const action = 'delete'; const args = { type, id, options }; - await this.legacyEnsureAuthorized(type, 'delete', options.namespace, { - args, - auditAction: 'removeReferences', - }); + const { legacyAuditLogger, requiresObjectAuthorization } = await this.legacyEnsureAuthorized( + type, + action, + options.namespace, + { + args, + auditAction: 'removeReferences', + } + ); + if (requiresObjectAuthorization) { + await this.ensureAuthorizedForObjects([{ type, id }], options.namespace, action); + } + legacyAuditLogger.logAuthorized(); } catch (error) { this.auditLogger.log( savedObjectEvent({ @@ -510,12 +741,18 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra ) { try { const args = { type, options }; - await this.legacyEnsureAuthorized(type, 'open_point_in_time', options?.namespace, { - args, - // Partial authorization is acceptable in this case because this method is only designed - // to be used with `find`, which already allows for partial authorization. - requireFullAuthorization: false, - }); + const { legacyAuditLogger } = await this.legacyEnsureAuthorized( + type, + 'open_point_in_time', + options?.namespace, + { + args, + // Partial authorization is acceptable in this case because this method is only designed + // to be used with `find`, which already allows for partial authorization. + requireFullAuthorization: false, + } + ); + legacyAuditLogger.logAuthorized(); } catch (error) { this.auditLogger.log( savedObjectEvent({ @@ -585,18 +822,36 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra ) ); - const { typeActionMap } = await this.ensureAuthorized( + const { typeActionMap, requiresObjectAuthorization } = await this.ensureAuthorized( uniqueTypes, options.purpose === 'updateObjectsSpaces' ? ['bulk_get', 'share_to_space'] : ['bulk_get'], uniqueSpaces, { requireFullAuthorization: false } ); + const requestedObjectsSet = objects.reduce( + (acc, { type, id }) => acc.add(`${type}:${id}`), + new Set() + ); + const retrievedObjectsMap = response.objects.reduce( + (acc, object) => acc.set(`${object.type}:${object.id}`, object), + new Map() + ); + // The user must be authorized to access every requested object in the current space. // Note: non-multi-namespace object types will have an empty spaces array. const authAction = options.purpose === 'updateObjectsSpaces' ? 'share_to_space' : 'bulk_get'; try { - this.ensureAuthorizedInAllSpaces(objects, authAction, typeActionMap, [currentSpaceId]); + const requestedObjects = objects.map( + ({ type, id }) => retrievedObjectsMap.get(`${type}:${id}`)! + ); + this.ensureAuthorizedInAllSpaces( + requestedObjects, + authAction, + typeActionMap, + [currentSpaceId], + requiresObjectAuthorization + ); } catch (error) { objects.forEach(({ type, id }) => this.auditLogger.log( @@ -613,14 +868,6 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra // The user is authorized to access all of the requested objects in the space(s) that they exist in. // Now: 1. omit any result objects that the user has no access to, 2. for the rest, redact any space(s) that the user is not authorized // for, and 3. create audit records for any objects that will be returned to the user. - const requestedObjectsSet = objects.reduce( - (acc, { type, id }) => acc.add(`${type}:${id}`), - new Set() - ); - const retrievedObjectsSet = response.objects.reduce( - (acc, { type, id }) => acc.add(`${type}:${id}`), - new Set() - ); const traversedObjects = new Set(); const filteredObjectsMap = new Map(); const getIsAuthorizedForInboundReference = (inbound: { type: string; id: string }) => { @@ -634,12 +881,9 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra const objKey = `${type}:${id}`; traversedObjects.add(objKey); // Is the user authorized to access this object in all required space(s)? - const isAuthorizedForObject = isAuthorizedForObjectInAllSpaces( - type, - authAction, - typeActionMap, - [currentSpaceId] - ); + const isAuthorizedForObject = + (!requiresObjectAuthorization || this.isAuthorizedForObject(obj)) && + isAuthorizedForObjectInAllSpaces(type, authAction, typeActionMap, [currentSpaceId]); // Redact the inbound references so we don't leak any info about other objects that the user is not authorized to access const redactedInboundReferences = inboundReferences.filter((inbound) => { if (inbound.type === type && inbound.id === id) { @@ -670,7 +914,7 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra const hasUntraversedInboundReferences = inboundReferences.some( (ref) => !traversedObjects.has(`${ref.type}:${ref.id}`) && - retrievedObjectsSet.has(`${ref.type}:${ref.id}`) + retrievedObjectsMap.has(`${ref.type}:${ref.id}`) ); if (hasUntraversedInboundReferences) { @@ -682,7 +926,7 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra const missingInboundReference = inboundReferences.find( (ref) => !traversedObjects.has(`${ref.type}:${ref.id}`) && - !retrievedObjectsSet.has(`${ref.type}:${ref.id}`) + !retrievedObjectsMap.has(`${ref.type}:${ref.id}`) ); if (missingInboundReference) { @@ -734,8 +978,14 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra const allSpacesSet = new Set([currentSpaceId, ...spacesToAdd, ...spacesToRemove]); const bulkGetResponse = await this.baseClient.bulkGet(objects, { namespace }); - const objectsToUpdate = objects.map(({ type, id }, i) => { - const { namespaces: spaces = [], version } = bulkGetResponse.saved_objects[i]; + const objectsToUpdate: Array> = objects.map(({ type, id }, i) => { + const { + namespaces: spaces = [], + version, + attributes, + accessControl, + references, + } = bulkGetResponse.saved_objects[i]; // If 'namespaces' is undefined, the object was not found (or it is namespace-agnostic). // Either way, we will pass in an empty 'spaces' array to the base client, which will cause it to skip this object. for (const space of spaces) { @@ -744,11 +994,11 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra allSpacesSet.add(space); } } - return { type, id, spaces, version }; + return { type, id, spaces, version, attributes, accessControl, references }; }); const uniqueTypes = this.getUniqueObjectTypes(objects); - const { typeActionMap } = await this.ensureAuthorized( + const { typeActionMap, requiresObjectAuthorization } = await this.ensureAuthorized( uniqueTypes, ['bulk_get', 'share_to_space'], Array.from(allSpacesSet), @@ -760,7 +1010,13 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra try { // The user must be authorized to share every requested object in each of: the current space, spacesToAdd, and spacesToRemove. const spaces = this.getUniqueSpaces(currentSpaceId, ...spacesToAdd, ...spacesToRemove); - this.ensureAuthorizedInAllSpaces(objects, 'share_to_space', typeActionMap, spaces); + this.ensureAuthorizedInAllSpaces( + objectsToUpdate, + 'share_to_space', + typeActionMap, + spaces, + requiresObjectAuthorization + ); } catch (error) { objects.forEach(({ type, id }) => this.auditLogger.log( @@ -803,6 +1059,23 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra return { objects: redactedObjects }; } + private async ensureAuthorizedForObjects( + objects: Array<{ type: string; id: string }>, + namespace: string | undefined, + action: string + ) { + const objectsToRetrieve = objects.filter((so) => this.typeRegistry.isPrivate(so.type)); + if (objectsToRetrieve.length === 0) { + return; + } + const confidentialObjects = await this.baseClient.bulkGet(objectsToRetrieve, { namespace }); + confidentialObjects.saved_objects.forEach((object) => { + if (!this.isAuthorizedForObject(object)) { + throw this.createForbiddenObjectError(action, object); + } + }); + } + private async checkPrivileges( actions: string | string[], namespaceOrNamespaces?: string | Array @@ -814,6 +1087,58 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra } } + private createAccessControl(type: string): SavedObjectAccessControl | undefined { + if (!this.typeRegistry.isPrivate(type)) { + return; + } + return { + owner: this.getOwner(), + }; + } + + private getOwner() { + // FIXME: `username` is not a valid owner + const { username } = this.getCurrentUser() ?? {}; + if (!username) { + throw this.errors.decorateGeneralError(new Error(`Unable to retrieve owner`)); + } + return username; + } + + private ensureAccessControlNotSpecified(options: Record) { + if (options?.accessControl != null) { + throw this.errors.createBadRequestError( + `Setting an accessControl is not permitted for this operation.` + ); + } + } + + private isAuthorizedForObject(object: SavedObject | SavedObjectReferenceWithContext) { + if (!this.typeRegistry.isPrivate(object.type)) { + return true; + } + + if (this.isSavedObjectReference(object)) { + if (object.isMissing) { + return true; + } + } else { + // type is SavedObject + if (object.error != null && object.attributes == null) { + // object not found + return true; + } + } + + if (!object.accessControl?.owner) { + throw this.errors.decorateGeneralError( + new Error(`Unable to verify object ownership due to missing access control declaration`) + ); + } + + return object.accessControl?.owner === this.getOwner(); + } + private async legacyEnsureAuthorized( typeOrTypes: string | string[], action: string, @@ -825,15 +1150,19 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra const actionsToTypesMap = new Map( types.map((type) => [this.actions.savedObject.get(type, action), type]) ); - const actions = Array.from(actionsToTypesMap.keys()); + const actions = [this.actions.savedObject.manage, ...Array.from(actionsToTypesMap.keys())]; const result = await this.checkPrivileges(actions, namespaceOrNamespaces); const { hasAllRequested, username, privileges } = result; + const spaceIds = uniq( privileges.kibana.map(({ resource }) => resource).filter((x) => x !== undefined) ).sort() as string[]; - const missingPrivileges = this.getMissingPrivileges(privileges); + const missingPrivileges = this.getMissingPrivileges(privileges, [ + this.actions.savedObject.manage, + ]); + const typeMap = privileges.kibana.reduce>( (acc, { resource, privilege, authorized }) => { if (!authorized) { @@ -870,28 +1199,55 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra ); }; - if (hasAllRequested) { - logAuthorizationSuccess(types, spaceIds); - return { typeMap, status: 'fully_authorized' }; + const isFullyAuthorized = missingPrivileges.length === 0; + // `hasAllRequested` comes from the ES privilege check above, which has been augmented to include the "manage" saved object privilege. + const requiresObjectAuthorization = hasAllRequested === false; + const canSpecifyAccessControl = hasAllRequested === true; + if (isFullyAuthorized) { + return { + typeMap, + status: 'fully_authorized', + requiresObjectAuthorization, + canSpecifyAccessControl, + legacyAuditLogger: { + logAuthorized: () => logAuthorizationSuccess(types, spaceIds), + }, + }; } else if (!requireFullAuthorization) { const isPartiallyAuthorized = privileges.kibana.some(({ authorized }) => authorized); if (isPartiallyAuthorized) { - for (const [type, { isGloballyAuthorized, authorizedSpaces }] of typeMap.entries()) { - // generate an individual audit record for each authorized type - logAuthorizationSuccess([type], isGloballyAuthorized ? spaceIds : authorizedSpaces); - } - return { typeMap, status: 'partially_authorized' }; + return { + typeMap, + status: 'partially_authorized', + requiresObjectAuthorization, + canSpecifyAccessControl, + legacyAuditLogger: { + logAuthorized: () => { + for (const [type, { isGloballyAuthorized, authorizedSpaces }] of typeMap.entries()) { + // generate an individual audit record for each authorized type + logAuthorizationSuccess([type], isGloballyAuthorized ? spaceIds : authorizedSpaces); + } + }, + }, + }; } else { logAuthorizationFailure(); - return { typeMap, status: 'unauthorized' }; + return { + typeMap, + status: 'unauthorized', + requiresObjectAuthorization, + canSpecifyAccessControl, + legacyAuditLogger: { + logAuthorized: () => {}, + }, + }; } } else { logAuthorizationFailure(); const targetTypes = uniq( - missingPrivileges.map(({ privilege }) => actionsToTypesMap.get(privilege)).sort() - ).join(','); - const msg = `Unable to ${action} ${targetTypes}`; - throw this.errors.decorateForbiddenError(new Error(msg)); + missingPrivileges.map(({ privilege }) => actionsToTypesMap.get(privilege)!).sort() + ); + throw this.createForbiddenTypesError(action, targetTypes); } } @@ -915,12 +1271,13 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra * array of objects are authorized in the required space(s). */ private ensureAuthorizedInAllSpaces( - objects: Array<{ type: string }>, + objects: Array> | SavedObjectReferenceWithContext[], action: T, typeActionMap: EnsureAuthorizedResult['typeActionMap'], - spaces: string[] + spaces: string[], + requiresObjectAuthorization: boolean ) { - const uniqueTypes = uniq(objects.map(({ type }) => type)); + const uniqueTypes = uniq((objects as Array<{ type: string }>).map(({ type }) => type)); const unauthorizedTypes = new Set(); for (const type of uniqueTypes) { if (!isAuthorizedForObjectInAllSpaces(type, action, typeActionMap, spaces)) { @@ -928,15 +1285,39 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra } } if (unauthorizedTypes.size > 0) { - const targetTypes = Array.from(unauthorizedTypes).sort().join(','); - const msg = `Unable to ${action} ${targetTypes}`; - throw this.errors.decorateForbiddenError(new Error(msg)); + throw this.createForbiddenTypesError(action, Array.from(unauthorizedTypes)); } + if (requiresObjectAuthorization) { + objects.forEach((object: SavedObject | SavedObjectReferenceWithContext) => { + if (!this.isAuthorizedForObject(object)) { + throw this.createForbiddenObjectError(action, object); + } + }); + } + } + + private createForbiddenTypesError(action: string, targetTypes: string[]) { + const msg = `Unable to ${action} ${targetTypes.sort().join(',')}`; + return this.errors.decorateForbiddenError(new Error(msg)); } - private getMissingPrivileges(privileges: CheckPrivilegesResponse['privileges']) { + private createForbiddenObjectError(action: string, object: { type: string; id: string }) { + const msg = `Unable to ${action} ${object.type}:${object.id}`; + return this.errors.decorateForbiddenError(new Error(msg)); + } + + private isSavedObjectReference( + object: SavedObject | SavedObjectReferenceWithContext + ): object is SavedObjectReferenceWithContext { + return Array.isArray((object as SavedObjectReferenceWithContext).inboundReferences); + } + + private getMissingPrivileges( + privileges: CheckPrivilegesResponse['privileges'], + ignorePrivileges: string[] = [] + ) { return privileges.kibana - .filter(({ authorized }) => !authorized) + .filter(({ authorized, privilege }) => !authorized && !ignorePrivileges.includes(privilege)) .map(({ resource, privilege }) => ({ spaceId: resource, privilege })); } 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 20a524251bd4a8..71f12d9b3caa67 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 @@ -831,6 +831,8 @@ describe('SecureSpacesClientWrapper', () => { 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 + requiresObjectAuthorization: true, + canSpecifyAccessControl: false, }); const { wrapper, baseClient, auditLogger, forbiddenError } = setup({ securityEnabled }); const aliases = [alias1, alias2]; @@ -849,6 +851,8 @@ describe('SecureSpacesClientWrapper', () => { typeActionMap: new Map() .set('type-1', { bulk_update: { authorizedSpaces: ['space-1'] } }) .set('type-2', { bulk_update: { authorizedSpaces: ['space-2'] } }), + requiresObjectAuthorization: true, + canSpecifyAccessControl: false, }); const { wrapper, baseClient, auditLogger } = setup({ securityEnabled }); const aliases = [alias1, alias2]; diff --git a/x-pack/scripts/functional_tests.js b/x-pack/scripts/functional_tests.js index 9e527835231b49..5d6f67eb05b07d 100644 --- a/x-pack/scripts/functional_tests.js +++ b/x-pack/scripts/functional_tests.js @@ -68,6 +68,7 @@ const onlyNotInCoverageTests = [ require.resolve('../test/spaces_api_integration/spaces_only/config.ts'), require.resolve('../test/spaces_api_integration/security_and_spaces/config_trial.ts'), require.resolve('../test/spaces_api_integration/security_and_spaces/config_basic.ts'), + require.resolve('../test/saved_object_access_control/config.ts'), require.resolve('../test/saved_object_api_integration/security_and_spaces/config_trial.ts'), require.resolve('../test/saved_object_api_integration/security_and_spaces/config_basic.ts'), require.resolve('../test/saved_object_api_integration/security_only/config_trial.ts'), diff --git a/x-pack/test/saved_object_access_control/common/lib/authentication.ts b/x-pack/test/saved_object_access_control/common/lib/authentication.ts new file mode 100644 index 00000000000000..c7bb3a0bb49d8a --- /dev/null +++ b/x-pack/test/saved_object_access_control/common/lib/authentication.ts @@ -0,0 +1,90 @@ +/* + * 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. + */ + +export const ROLES = { + ALICE: { + name: 'alice', + privileges: { + kibana: [ + { + feature: { + discover: ['all'], + testConfidentialPlugin: ['all'], + }, + spaces: ['default'], + }, + { + feature: { + testConfidentialPlugin: ['read'], + }, + spaces: ['space_1'], + }, + ], + }, + }, + BOB: { + name: 'bob', + privileges: { + kibana: [ + { + feature: { + testConfidentialPlugin: ['all'], + }, + spaces: ['default'], + }, + ], + }, + }, + CHARLIE: { + name: 'charlie', + privileges: { + kibana: [ + { + feature: { + discover: ['all'], + }, + spaces: ['default'], + }, + ], + }, + }, +}; + +export const USERS = { + ALICE: { + username: 'alice', + password: 'password', + roles: [ROLES.ALICE.name], + description: 'Alice', + }, + BOB: { + username: 'bob', + password: 'password', + roles: [ROLES.BOB.name], + description: 'Bob', + }, + CHARLIE: { + username: 'charlie', + password: 'password', + roles: [ROLES.CHARLIE.name], + description: 'A user without access to the confidential saved object type', + }, + KIBANA_ADMIN: { + username: 'kibana_admin_user', + password: 'changeme', + roles: ['kibana_admin'], + superuser: false, + description: 'Kibana Administrator', + }, + SUPERUSER: { + username: 'elastic', + password: 'changeme', + roles: [], + superuser: true, + description: 'superuser', + }, +}; diff --git a/x-pack/test/saved_object_access_control/common/lib/create_users_and_roles.ts b/x-pack/test/saved_object_access_control/common/lib/create_users_and_roles.ts new file mode 100644 index 00000000000000..2987d561f77628 --- /dev/null +++ b/x-pack/test/saved_object_access_control/common/lib/create_users_and_roles.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FtrProviderContext as CommonFtrProviderContext } from '../../../common/ftr_provider_context'; +import { USERS, ROLES } from './authentication'; +import { User, Role } from './types'; + +export const createUsersAndRoles = async (getService: CommonFtrProviderContext['getService']) => { + const security = getService('security'); + + const createRole = async ({ name, privileges }: Role) => { + return await security.role.create(name, privileges); + }; + + const createUser = async ({ username, password, roles, superuser }: User) => { + // no need to create superuser + if (superuser) { + return; + } + + return await security.user.create(username, { + password, + roles, + full_name: username.replace('_', ' '), + email: `${username}@elastic.co`, + }); + }; + + for (const role of Object.values(ROLES)) { + await createRole(role); + } + + for (const user of Object.values(USERS)) { + await createUser(user); + } +}; diff --git a/x-pack/test/saved_object_access_control/common/lib/index.ts b/x-pack/test/saved_object_access_control/common/lib/index.ts new file mode 100644 index 00000000000000..bb81c23f8cbc1d --- /dev/null +++ b/x-pack/test/saved_object_access_control/common/lib/index.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { Role, User, ExpectedResponse } from './types'; +export { ROLES, USERS } from './authentication'; +export { createUsersAndRoles } from './create_users_and_roles'; +export { + assertSavedObjectExists, + assertSavedObjectMissing, + assertSavedObjectAccessControl, +} from './saved_object_assertions'; diff --git a/x-pack/test/saved_object_access_control/common/lib/saved_object_assertions.ts b/x-pack/test/saved_object_access_control/common/lib/saved_object_assertions.ts new file mode 100644 index 00000000000000..80f4d03b965e57 --- /dev/null +++ b/x-pack/test/saved_object_access_control/common/lib/saved_object_assertions.ts @@ -0,0 +1,75 @@ +/* + * 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 { KibanaClient } from '@elastic/elasticsearch/api/kibana'; +import expect from '@kbn/expect'; +import { SavedObjectAccessControl } from 'src/core/types'; + +const getDocumentId = (savedObjectType: string, savedObjectId: string, spaceId: string) => { + return spaceId === 'default' + ? `${savedObjectType}:${savedObjectId}` + : `${spaceId}:${savedObjectType}:${savedObjectId}`; +}; + +export const assertSavedObjectExists = async ( + es: KibanaClient, + savedObjectType: string, + savedObjectId: string, + spaceId: string = 'default' +) => { + const documentId = getDocumentId(savedObjectType, savedObjectId, spaceId); + + const resp = await es.get( + { + index: '.kibana', + id: documentId, + }, + { ignore: [404] } + ); + + expect(resp.statusCode).to.eql(200); +}; + +export const assertSavedObjectAccessControl = async ( + es: KibanaClient, + savedObjectType: string, + savedObjectId: string, + spaceId: string, + accessControl: SavedObjectAccessControl | undefined +) => { + const documentId = getDocumentId(savedObjectType, savedObjectId, spaceId); + + const resp = await es.get<{ accessControl: Record }>( + { + index: '.kibana', + id: documentId, + }, + { ignore: [404] } + ); + + expect(resp.statusCode).to.eql(200); + expect(resp.body?._source?.accessControl).to.eql(accessControl); +}; + +export const assertSavedObjectMissing = async ( + es: KibanaClient, + savedObjectType: string, + savedObjectId: string, + spaceId: string = 'default' +) => { + const documentId = getDocumentId(savedObjectType, savedObjectId, spaceId); + + const resp = await es.get( + { + index: '.kibana', + id: documentId, + }, + { ignore: [404] } + ); + + expect(resp.statusCode).to.eql(404); +}; diff --git a/x-pack/test/saved_object_access_control/common/lib/types.ts b/x-pack/test/saved_object_access_control/common/lib/types.ts new file mode 100644 index 00000000000000..4d4ec8b8b89549 --- /dev/null +++ b/x-pack/test/saved_object_access_control/common/lib/types.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export interface User { + username: string; + password: string; + roles: string[]; + superuser?: boolean; + description?: string; +} + +export interface Role { + name: string; + privileges: any; +} + +export interface ExpectedResponse { + httpCode: number; + expectResponse: (...args: T) => (body: Record) => void | Promise; +} diff --git a/x-pack/test/saved_object_access_control/config.ts b/x-pack/test/saved_object_access_control/config.ts new file mode 100644 index 00000000000000..02dafc67481c6c --- /dev/null +++ b/x-pack/test/saved_object_access_control/config.ts @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import path from 'path'; +import { FtrConfigProviderContext } from '@kbn/test'; +import { services } from './services'; + +export default async function ({ readConfigFile }: FtrConfigProviderContext) { + const apiIntegrationConfig = await readConfigFile( + require.resolve('../api_integration/config.ts') + ); + + return { + testFiles: [require.resolve('./security_and_spaces/apis')], + servers: apiIntegrationConfig.get('servers'), + services, + junit: { + reportName: + 'X-Pack Saved Object Access Control API Integration Tests - Security and Spaces integration', + }, + esTestCluster: { + ...apiIntegrationConfig.get('esTestCluster'), + license: 'trial', + }, + kbnTestServer: { + ...apiIntegrationConfig.get('kbnTestServer'), + serverArgs: [ + ...apiIntegrationConfig.get('kbnTestServer.serverArgs'), + '--server.xsrf.disableProtection=true', + `--plugin-path=${path.resolve(__dirname, 'fixtures', 'confidential_plugin')}`, + ], + }, + }; +} diff --git a/x-pack/test/saved_object_access_control/fixtures/confidential_plugin/kibana.json b/x-pack/test/saved_object_access_control/fixtures/confidential_plugin/kibana.json new file mode 100644 index 00000000000000..40475a3cff78e3 --- /dev/null +++ b/x-pack/test/saved_object_access_control/fixtures/confidential_plugin/kibana.json @@ -0,0 +1,8 @@ +{ + "id": "confidentialPlugin", + "version": "8.0.0", + "kibanaVersion": "kibana", + "requiredPlugins": ["features"], + "server": true, + "ui": false +} diff --git a/x-pack/test/saved_object_access_control/fixtures/confidential_plugin/server/index.ts b/x-pack/test/saved_object_access_control/fixtures/confidential_plugin/server/index.ts new file mode 100644 index 00000000000000..0bbe8228dcf7c4 --- /dev/null +++ b/x-pack/test/saved_object_access_control/fixtures/confidential_plugin/server/index.ts @@ -0,0 +1,75 @@ +/* + * 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 { PluginInitializer, CoreSetup } from 'src/core/server'; +import type { PluginSetupContract as FeaturesPluginSetup } from '../../../../../plugins/features/server'; + +export const CONFIDENTIAL_SAVED_OBJECT_TYPE = 'confidential'; +export const CONFIDENTIAL_MULTI_NAMESPACE_SAVED_OBJECT_TYPE = 'confidential_multinamespace'; + +interface PluginSetupDeps { + features: FeaturesPluginSetup; +} + +export const plugin: PluginInitializer = () => ({ + setup(core: CoreSetup<{}>, { features }: PluginSetupDeps) { + core.savedObjects.registerType({ + name: CONFIDENTIAL_SAVED_OBJECT_TYPE, + hidden: false, + namespaceType: 'single', + accessClassification: 'private', + management: { + importableAndExportable: true, + }, + mappings: { + properties: { + name: { type: 'keyword' }, + }, + }, + }); + + core.savedObjects.registerType({ + name: CONFIDENTIAL_MULTI_NAMESPACE_SAVED_OBJECT_TYPE, + hidden: false, + namespaceType: 'multiple', + accessClassification: 'private', + management: { + importableAndExportable: true, + }, + mappings: { + properties: { + name: { type: 'keyword' }, + }, + }, + }); + + features.registerKibanaFeature({ + id: 'testConfidentialPlugin', + name: 'Test Confidential Plugin', + category: { id: 'test', label: 'test' }, + app: [], + privileges: { + all: { + savedObject: { + all: [CONFIDENTIAL_SAVED_OBJECT_TYPE, CONFIDENTIAL_MULTI_NAMESPACE_SAVED_OBJECT_TYPE], + read: [], + }, + ui: [], + }, + read: { + savedObject: { + all: [], + read: [CONFIDENTIAL_SAVED_OBJECT_TYPE, CONFIDENTIAL_MULTI_NAMESPACE_SAVED_OBJECT_TYPE], + }, + ui: [], + }, + }, + }); + }, + start() {}, + stop() {}, +}); diff --git a/x-pack/test/saved_object_access_control/fixtures/es_archiver/confidential_objects/data.json b/x-pack/test/saved_object_access_control/fixtures/es_archiver/confidential_objects/data.json new file mode 100644 index 00000000000000..53dcf4afadcda7 --- /dev/null +++ b/x-pack/test/saved_object_access_control/fixtures/es_archiver/confidential_objects/data.json @@ -0,0 +1,242 @@ +{ + "type": "doc", + "value": { + "id": "space:default", + "index": ".kibana", + "source": { + "space": { + "_reserved": true, + "description": "This is the default space", + "name": "Default Space" + }, + "type": "space", + "updated_at": "2017-09-21T18:49:16.270Z" + }, + "type": "doc" + } +} + +{ + "type": "doc", + "value": { + "id": "space:space_1", + "index": ".kibana", + "source": { + "space": { + "description": "This is the first test space", + "name": "Space 1" + }, + "type": "space", + "updated_at": "2017-09-21T18:49:16.270Z" + }, + "type": "doc" + } +} + +{ + "type": "doc", + "value": { + "id": "space:space_2", + "index": ".kibana", + "source": { + "space": { + "description": "This is the second test space", + "name": "Space 2" + }, + "type": "space", + "updated_at": "2017-09-21T18:49:16.270Z" + }, + "type": "doc" + } +} + +{ + "type": "doc", + "value": { + "id": "confidential:alice_doc_1", + "index": ".kibana", + "source": { + "accessControl": { + "owner": "alice" + }, + "confidential": { + "name": "Alice's first confidential object in the default space" + }, + "type": "confidential", + "updated_at": "2017-09-21T18:49:16.270Z" + }, + "type": "doc" + } +} + +{ + "type": "doc", + "value": { + "id": "index-pattern:index_pattern_1", + "index": ".kibana", + "source": { + "index-pattern": { + "title": "First index pattern" + }, + "type": "index-pattern", + "updated_at": "2017-09-21T18:49:16.270Z" + }, + "type": "doc" + } +} + +{ + "type": "doc", + "value": { + "id": "space_1:confidential:alice_doc_1", + "index": ".kibana", + "source": { + "accessControl": { + "owner": "alice" + }, + "confidential": { + "name": "Alice's first confidential object in the space_1 space" + }, + "type": "confidential", + "namespace": "space_1", + "updated_at": "2017-09-21T18:49:16.270Z" + }, + "type": "doc" + } +} + +{ + "type": "doc", + "value": { + "id": "space_2:confidential:alice_doc_1", + "index": ".kibana", + "source": { + "accessControl": { + "owner": "alice" + }, + "confidential": { + "name": "Alice's first confidential object in the space_2 space" + }, + "type": "confidential", + "namespace": "space_2", + "updated_at": "2017-09-21T18:49:16.270Z" + }, + "type": "doc" + } +} + +{ + "type": "doc", + "value": { + "id": "space_1:confidential:alice_space_1_doc", + "index": ".kibana", + "source": { + "accessControl": { + "owner": "alice" + }, + "confidential": { + "name": "Alice's second confidential object in the space_1 space. This does not exist in the default space." + }, + "type": "confidential", + "namespace": "space_1", + "updated_at": "2017-09-21T18:49:16.270Z" + }, + "type": "doc" + } +} + +{ + "type": "doc", + "value": { + "id": "confidential:bob_doc_1", + "index": ".kibana", + "source": { + "accessControl": { + "owner": "bob" + }, + "confidential": { + "name": "Bob's first confidential object in the default space" + }, + "type": "confidential", + "updated_at": "2017-09-21T18:49:16.270Z" + }, + "type": "doc" + } +} + +{ + "type": "doc", + "value": { + "id": "space_1:confidential:bob_doc_1", + "index": ".kibana", + "source": { + "accessControl": { + "owner": "bob" + }, + "confidential": { + "name": "Bob's first confidential object in the space_1 space" + }, + "type": "confidential", + "namespace": "space_1", + "updated_at": "2017-09-21T18:49:16.270Z" + }, + "type": "doc" + } +} + +{ + "type": "doc", + "value": { + "id": "confidential:charlie_doc_1", + "index": ".kibana", + "source": { + "accessControl": { + "owner": "charlie" + }, + "confidential": { + "name": "Charlie's first confidential object in the default space. He cannot access this, despite being the owner." + }, + "type": "confidential", + "updated_at": "2017-09-21T18:49:16.270Z" + }, + "type": "doc" + } +} + +{ + "type": "doc", + "value": { + "index": ".kibana", + "id": "confidential_multinamespace:alice_alias-match-newid", + "source": { + "type": "confidential_multinamespace", + "updated_at": "2017-09-21T18:51:23.794Z", + "confidential_multinamespace": { + "name": "Resolve outcome aliasMatch" + }, + "accessControl": { + "owner": "alice" + }, + "namespaces": [ + "space_1" + ] + } + } +} + +{ + "type": "doc", + "value": { + "index": ".kibana", + "id": "legacy-url-alias:space_1:confidential_multinamespace:alice_alias-match", + "source": { + "type": "legacy-url-alias", + "updated_at": "2017-09-21T18:51:23.794Z", + "legacy-url-alias": { + "targetNamespace": "space_1", + "targetType": "confidential_multinamespace", + "targetId": "alice_alias-match-newid" + } + } + } +} \ No newline at end of file diff --git a/x-pack/test/saved_object_access_control/fixtures/es_archiver/confidential_objects/mappings.json b/x-pack/test/saved_object_access_control/fixtures/es_archiver/confidential_objects/mappings.json new file mode 100644 index 00000000000000..9bc7a392701c38 --- /dev/null +++ b/x-pack/test/saved_object_access_control/fixtures/es_archiver/confidential_objects/mappings.json @@ -0,0 +1,228 @@ +{ + "type": "index", + "value": { + "aliases": {}, + "index": ".kibana", + "mappings": { + "dynamic": "strict", + "properties": { + "accessControl": { + "dynamic": "strict", + "properties": { + "owner": { + "type": "keyword" + } + } + }, + "config": { + "dynamic": "true", + "properties": { + "buildNum": { + "type": "keyword" + }, + "defaultIndex": { + "fields": { + "keyword": { + "ignore_above": 256, + "type": "keyword" + } + }, + "type": "text" + } + } + }, + "confidential": { + "dynamic": "strict", + "properties": { + "name": { + "type": "text" + } + } + }, + "confidential_multinamespace": { + "dynamic": "strict", + "properties": { + "name": { + "type": "text" + } + } + }, + "legacy-url-alias": { + "properties": { + "targetNamespace": { + "type": "keyword" + }, + "targetType": { + "type": "keyword" + }, + "targetId": { + "type": "keyword" + }, + "lastResolved": { + "type": "date" + }, + "resolveCounter": { + "type": "integer" + }, + "disabled": { + "type": "boolean" + } + } + }, + "dashboard": { + "dynamic": "strict", + "properties": { + "description": { + "type": "text" + }, + "hits": { + "type": "integer" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "optionsJSON": { + "type": "text" + }, + "panelsJSON": { + "type": "text" + }, + "refreshInterval": { + "properties": { + "display": { + "type": "keyword" + }, + "pause": { + "type": "boolean" + }, + "section": { + "type": "integer" + }, + "value": { + "type": "integer" + } + } + }, + "timeFrom": { + "type": "keyword" + }, + "timeRestore": { + "type": "boolean" + }, + "timeTo": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "uiStateJSON": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "index-pattern": { + "properties": { + "fieldFormatMap": { + "type": "text" + }, + "fields": { + "type": "text" + }, + "intervalName": { + "type": "keyword" + }, + "notExpandable": { + "type": "boolean" + }, + "sourceFilters": { + "type": "text" + }, + "timeFieldName": { + "type": "keyword" + }, + "title": { + "type": "text" + } + } + }, + "namespace": { + "type": "keyword" + }, + "namespaces": { + "type": "keyword" + }, + "originId": { + "type": "keyword" + }, + "server": { + "properties": { + "uuid": { + "type": "keyword" + } + } + }, + "tag": { + "properties": { + "name": { + "type": "text" + }, + "description": { + "type": "text" + }, + "color": { + "type": "text" + } + } + }, + "space": { + "properties": { + "_reserved": { + "type": "boolean" + }, + "color": { + "type": "keyword" + }, + "description": { + "type": "text" + }, + "disabledFeatures": { + "type": "keyword" + }, + "initials": { + "type": "keyword" + }, + "name": { + "fields": { + "keyword": { + "ignore_above": 2048, + "type": "keyword" + } + }, + "type": "text" + } + } + }, + "type": { + "type": "keyword" + }, + "updated_at": { + "type": "date" + } + } + }, + "settings": { + "index": { + "auto_expand_replicas": "0-1", + "number_of_replicas": "0", + "number_of_shards": "1" + } + } + } +} diff --git a/x-pack/test/saved_object_access_control/security_and_spaces/apis/bulk_create.ts b/x-pack/test/saved_object_access_control/security_and_spaces/apis/bulk_create.ts new file mode 100644 index 00000000000000..53eb7ad34af65a --- /dev/null +++ b/x-pack/test/saved_object_access_control/security_and_spaces/apis/bulk_create.ts @@ -0,0 +1,263 @@ +/* + * 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 { SavedObjectsBulkResponse } from 'src/core/server'; +import { CONFIDENTIAL_SAVED_OBJECT_TYPE } from '../../fixtures/confidential_plugin/server'; +import { + USERS, + ExpectedResponse, + assertSavedObjectExists, + assertSavedObjectAccessControl, + assertSavedObjectMissing, +} from '../../common/lib'; +import { FtrProviderContext } from '../../services'; + +// eslint-disable-next-line import/no-default-export +export default function ({ getService }: FtrProviderContext) { + const es = getService('es'); + const esArchiver = getService('esArchiver'); + const supertest = getService('supertestWithoutAuth'); + + describe('POST /api/saved_objects/_bulk_create', () => { + before(async () => { + await esArchiver.load( + 'x-pack/test/saved_object_access_control/fixtures/es_archiver/confidential_objects' + ); + }); + + after(async () => { + await esArchiver.unload( + 'x-pack/test/saved_object_access_control/fixtures/es_archiver/confidential_objects' + ); + }); + + const authorizedExpectedResponse: ExpectedResponse< + [ + Array<{ + owner?: string; + attributes?: Record; + type: string; + id?: string; + namespaces?: string[]; + }> + ] + > = { + httpCode: 200, + expectResponse: (opts) => ({ body }) => { + const { saved_objects: savedObjects } = body as SavedObjectsBulkResponse; + + expect(opts.length).to.eql(savedObjects.length); + savedObjects.forEach((object, index) => { + const expected = opts[index]; + if (expected.id) { + expect(object.id).to.eql(expected.id); + } + expect(object.type).to.eql(expected.type); + + if (expected.owner) { + expect(object.accessControl).to.eql({ + owner: expected.owner, + }); + } else { + expect(object.accessControl).to.eql(undefined); + } + + expect(object.namespaces).to.eql(expected.namespaces); + + expect(object.error).to.eql(undefined); + }); + }, + }; + + const unauthorizedExpectedResponse: ExpectedResponse<[{ type: string; id?: string }]> = { + httpCode: 403, + expectResponse: ({ type, id }) => ({ body }) => { + expect(body).to.eql({ + statusCode: 403, + error: 'Forbidden', + message: `Unable to bulk_create ${type}${id ? ':' + id : ''}`, + }); + }, + }; + + it('returns 403 for users who cannot create confidential objects of this type', async () => { + const { username, password } = USERS.CHARLIE; + const { httpCode, expectResponse } = unauthorizedExpectedResponse; + + await supertest + .post(`/api/saved_objects/_bulk_create`) + .auth(username, password) + .send([ + { + type: CONFIDENTIAL_SAVED_OBJECT_TYPE, + id: 'charlie_doc_1', + attributes: { + name: 'new name', + }, + }, + ]) + .expect(httpCode) + .then(expectResponse({ type: CONFIDENTIAL_SAVED_OBJECT_TYPE })); + }); + + it('allows confidential objects to be created, and attaches an appropriate accessControl', async () => { + const { username, password } = USERS.ALICE; + const { httpCode, expectResponse } = authorizedExpectedResponse; + + const savedObjectId = 'alice_new_doc_1'; + + await assertSavedObjectMissing(es, CONFIDENTIAL_SAVED_OBJECT_TYPE, savedObjectId); + + const name = 'bulk create test'; + + await supertest + .post(`/api/saved_objects/_bulk_create`) + .auth(username, password) + .send([ + { + type: CONFIDENTIAL_SAVED_OBJECT_TYPE, + id: savedObjectId, + attributes: { + name, + }, + }, + ]) + .expect(httpCode) + .then( + expectResponse([ + { + attributes: { name }, + type: CONFIDENTIAL_SAVED_OBJECT_TYPE, + id: savedObjectId, + namespaces: ['default'], + owner: username, + }, + ]) + ); + }); + + it('does not attach an accessControl for public objects', async () => { + const { username, password } = USERS.SUPERUSER; + const { httpCode, expectResponse } = authorizedExpectedResponse; + + const savedObjectId = 'new_index_pattern'; + + await supertest + .post(`/api/saved_objects/_bulk_create`) + .auth(username, password) + .send([ + { + id: savedObjectId, + type: 'index-pattern', + attributes: { + title: 'title', + }, + }, + ]) + .expect(httpCode) + .then( + expectResponse([ + { + id: savedObjectId, + type: 'index-pattern', + attributes: { + title: 'title', + }, + namespaces: ['default'], + }, + ]) + ); + + await assertSavedObjectAccessControl( + es, + 'index-pattern', + savedObjectId, + 'default', + undefined + ); + }); + + it('does not allow creating an object that overwrites an object belonging to another user', async () => { + const { username, password } = USERS.ALICE; + const { httpCode, expectResponse } = unauthorizedExpectedResponse; + + const savedObjectId = 'bob_doc_1'; + + await assertSavedObjectExists(es, CONFIDENTIAL_SAVED_OBJECT_TYPE, savedObjectId); + + await supertest + .post(`/api/saved_objects/_bulk_create`) + .auth(username, password) + .send([ + { + type: CONFIDENTIAL_SAVED_OBJECT_TYPE, + id: savedObjectId, + attributes: { name: 'hack attempt' }, + }, + ]) + .expect(httpCode) + .then(expectResponse({ type: CONFIDENTIAL_SAVED_OBJECT_TYPE, id: savedObjectId })); + }); + + it('does not allow overwriting an object belonging to another user', async () => { + const { username, password } = USERS.ALICE; + const { httpCode, expectResponse } = unauthorizedExpectedResponse; + + const savedObjectId = 'bob_doc_1'; + + await assertSavedObjectExists(es, CONFIDENTIAL_SAVED_OBJECT_TYPE, savedObjectId); + + await supertest + .post(`/api/saved_objects/_bulk_create?overwrite=true`) + .auth(username, password) + .send([ + { + type: CONFIDENTIAL_SAVED_OBJECT_TYPE, + id: savedObjectId, + attributes: { name: 'hack attempt' }, + }, + ]) + .expect(httpCode) + .then(expectResponse({ type: CONFIDENTIAL_SAVED_OBJECT_TYPE, id: savedObjectId })); + }); + + [USERS.KIBANA_ADMIN, USERS.SUPERUSER].forEach((user) => { + it(`allows ${user.description} to overwrite objects that belong to other users`, async () => { + const { username, password } = user; + const { httpCode, expectResponse } = authorizedExpectedResponse; + + const savedObjectId = 'alice_doc_1'; + + await assertSavedObjectExists(es, CONFIDENTIAL_SAVED_OBJECT_TYPE, savedObjectId); + + await supertest + .post(`/api/saved_objects/_bulk_create?overwrite=true`) + .auth(username, password) + .send([ + { + type: CONFIDENTIAL_SAVED_OBJECT_TYPE, + id: savedObjectId, + attributes: { name: 'update attempt' }, + }, + ]) + .expect(httpCode) + .then( + expectResponse([ + { + id: savedObjectId, + type: CONFIDENTIAL_SAVED_OBJECT_TYPE, + attributes: { name: 'update attempt' }, + owner: username, + namespaces: ['default'], + }, + ]) + ); + }); + }); + }); +} diff --git a/x-pack/test/saved_object_access_control/security_and_spaces/apis/bulk_get.ts b/x-pack/test/saved_object_access_control/security_and_spaces/apis/bulk_get.ts new file mode 100644 index 00000000000000..baf75a799b28fd --- /dev/null +++ b/x-pack/test/saved_object_access_control/security_and_spaces/apis/bulk_get.ts @@ -0,0 +1,314 @@ +/* + * 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 type { SavedObjectsBulkResponse } from 'src/core/server'; +import { CONFIDENTIAL_SAVED_OBJECT_TYPE } from '../../fixtures/confidential_plugin/server'; +import type { FtrProviderContext } from '../../services'; +import { USERS, ExpectedResponse, assertSavedObjectExists } from '../../common/lib'; + +// eslint-disable-next-line import/no-default-export +export default function ({ getService }: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const supertest = getService('supertestWithoutAuth'); + const es = getService('es'); + + describe('POST /api/saved_objects/_bulk_get', () => { + before(async () => { + await esArchiver.load( + 'x-pack/test/saved_object_access_control/fixtures/es_archiver/confidential_objects' + ); + }); + + after(async () => { + await esArchiver.unload( + 'x-pack/test/saved_object_access_control/fixtures/es_archiver/confidential_objects' + ); + }); + + type BulkGetResponseOpts = Array<{ type: string; id: string; statusCode: number }>; + + const authorizedExpectedResponse: ExpectedResponse<[BulkGetResponseOpts]> = { + httpCode: 200, + expectResponse: (opts: BulkGetResponseOpts) => ({ body }) => { + const expectedPayload = opts.map(({ type, id, statusCode }) => { + if (statusCode === 403) { + return { + error: { + error: 'Forbidden', + message: `Unable to bulk_get ${type}:${id}`, + statusCode: 403, + }, + id, + type, + }; + } + if (statusCode === 404) { + return { + error: { + error: 'Not Found', + message: `Saved object [${type}/${id}] not found`, + statusCode: 404, + }, + id, + type, + }; + } + if (statusCode === 200) { + return { + id, + type, + }; + } + throw new Error(`Unexpected status code: ${statusCode}`); + }); + + const { saved_objects: savedObjects } = body as SavedObjectsBulkResponse; + expect(savedObjects.length).to.eql(expectedPayload.length); + savedObjects.forEach((object, index) => { + const { id, type, error } = expectedPayload[index]; + expect(object.id).to.eql(id); + expect(object.type).to.eql(type); + expect(object.error).to.eql(error); + if (error) { + expect(object.attributes).to.eql(undefined); + } else { + expect(object.attributes).to.be.an(Object); + } + }); + }, + }; + + const unauthorizedExpectedResponse: ExpectedResponse<[{ savedObjectType: string }]> = { + httpCode: 403, + expectResponse: ({ savedObjectType }) => ({ body }) => { + expect(body).to.eql({ + statusCode: 403, + error: 'Forbidden', + message: `Unable to bulk_get ${savedObjectType}`, + }); + }, + }; + + it('returns 404 for confidential objects that do not exist', async () => { + const { username, password } = USERS.ALICE; + const { httpCode, expectResponse } = authorizedExpectedResponse; + const savedObjectId = 'not_found_object'; + await supertest + .post(`/api/saved_objects/_bulk_get`) + .auth(username, password) + .send([ + { + type: CONFIDENTIAL_SAVED_OBJECT_TYPE, + id: savedObjectId, + }, + ]) + .expect(httpCode) + .then( + expectResponse([ + { + type: CONFIDENTIAL_SAVED_OBJECT_TYPE, + id: savedObjectId, + statusCode: 404, + }, + ]) + ); + }); + + it('returns 403 for confidential objects that belong to another user', async () => { + const { username, password } = USERS.ALICE; + const { httpCode, expectResponse } = authorizedExpectedResponse; + const savedObjectId = 'bob_doc_1'; + + await assertSavedObjectExists(es, CONFIDENTIAL_SAVED_OBJECT_TYPE, savedObjectId); + + await supertest + .post(`/api/saved_objects/_bulk_get`) + .auth(username, password) + .send([ + { + type: CONFIDENTIAL_SAVED_OBJECT_TYPE, + id: savedObjectId, + }, + ]) + .expect(httpCode) + .then( + expectResponse([ + { + type: CONFIDENTIAL_SAVED_OBJECT_TYPE, + id: savedObjectId, + statusCode: 403, + }, + ]) + ); + }); + + it('returns 404 for confidential objects that exist in another space', async () => { + const { username, password } = USERS.ALICE; + const { httpCode, expectResponse } = authorizedExpectedResponse; + const savedObjectId = 'alice_space_1_doc'; + + await assertSavedObjectExists(es, CONFIDENTIAL_SAVED_OBJECT_TYPE, savedObjectId, 'space_1'); + + await supertest + .post(`/api/saved_objects/_bulk_get`) + .auth(username, password) + .send([ + { + type: CONFIDENTIAL_SAVED_OBJECT_TYPE, + id: savedObjectId, + }, + ]) + .expect(httpCode) + .then( + expectResponse([ + { + type: CONFIDENTIAL_SAVED_OBJECT_TYPE, + id: savedObjectId, + statusCode: 404, + }, + ]) + ); + }); + + it('returns 403 for users who cannot access confidential objects of this type', async () => { + const { username, password } = USERS.CHARLIE; + const { httpCode, expectResponse } = unauthorizedExpectedResponse; + const savedObjectId = 'charlie_doc_1'; + + await assertSavedObjectExists(es, CONFIDENTIAL_SAVED_OBJECT_TYPE, savedObjectId); + + await supertest + .post(`/api/saved_objects/_bulk_get`) + .auth(username, password) + .send([ + { + type: CONFIDENTIAL_SAVED_OBJECT_TYPE, + id: savedObjectId, + }, + ]) + .expect(httpCode) + .then(expectResponse({ savedObjectType: CONFIDENTIAL_SAVED_OBJECT_TYPE })); + }); + + it('returns 403 if user is not authorized for all requested types', async () => { + const { username, password } = USERS.ALICE; + const { httpCode, expectResponse } = unauthorizedExpectedResponse; + + await assertSavedObjectExists(es, CONFIDENTIAL_SAVED_OBJECT_TYPE, 'alice_doc_1'); + + await supertest + .post(`/api/saved_objects/_bulk_get`) + .auth(username, password) + .send([ + { + type: CONFIDENTIAL_SAVED_OBJECT_TYPE, + id: 'alice_doc_1', + }, + { + type: 'dashboard', + id: 'dashboard_1', + }, + ]) + .expect(httpCode) + .then(expectResponse({ savedObjectType: 'dashboard' })); + }); + + it('returns 200 for confidential objects that belong to the current user', async () => { + const { username, password } = USERS.ALICE; + const { httpCode, expectResponse } = authorizedExpectedResponse; + const savedObjectId = 'alice_doc_1'; + await supertest + .post(`/api/saved_objects/_bulk_get`) + .auth(username, password) + .send([ + { + type: CONFIDENTIAL_SAVED_OBJECT_TYPE, + id: savedObjectId, + }, + ]) + .expect(httpCode) + .then( + expectResponse([ + { + type: CONFIDENTIAL_SAVED_OBJECT_TYPE, + id: savedObjectId, + statusCode: 200, + }, + ]) + ); + }); + + it('returns only the objects the user is authorized for', async () => { + const { username, password } = USERS.ALICE; + const { httpCode, expectResponse } = authorizedExpectedResponse; + + await assertSavedObjectExists(es, CONFIDENTIAL_SAVED_OBJECT_TYPE, 'alice_doc_1'); + await assertSavedObjectExists(es, CONFIDENTIAL_SAVED_OBJECT_TYPE, 'bob_doc_1'); + await assertSavedObjectExists(es, 'index-pattern', 'index_pattern_1'); + + await supertest + .post(`/api/saved_objects/_bulk_get`) + .auth(username, password) + .send([ + { + type: CONFIDENTIAL_SAVED_OBJECT_TYPE, + id: 'alice_doc_1', + }, + { + type: CONFIDENTIAL_SAVED_OBJECT_TYPE, + id: 'bob_doc_1', + }, + ]) + .expect(httpCode) + .then( + expectResponse([ + { + type: CONFIDENTIAL_SAVED_OBJECT_TYPE, + id: 'alice_doc_1', + statusCode: 200, + }, + { + type: CONFIDENTIAL_SAVED_OBJECT_TYPE, + id: 'bob_doc_1', + statusCode: 403, + }, + ]) + ); + }); + + [USERS.KIBANA_ADMIN, USERS.SUPERUSER].forEach((user) => { + it(`allows ${user.description} to access objects from other users`, async () => { + const { username, password } = user; + const { httpCode, expectResponse } = authorizedExpectedResponse; + const savedObjectId = 'bob_doc_1'; + + await assertSavedObjectExists(es, CONFIDENTIAL_SAVED_OBJECT_TYPE, savedObjectId); + + await supertest + .post(`/api/saved_objects/_bulk_get`) + .auth(username, password) + .send([ + { + type: CONFIDENTIAL_SAVED_OBJECT_TYPE, + id: savedObjectId, + }, + ]) + .expect(httpCode) + .then( + expectResponse([ + { + type: CONFIDENTIAL_SAVED_OBJECT_TYPE, + id: savedObjectId, + statusCode: 200, + }, + ]) + ); + }); + }); + }); +} diff --git a/x-pack/test/saved_object_access_control/security_and_spaces/apis/bulk_update.ts b/x-pack/test/saved_object_access_control/security_and_spaces/apis/bulk_update.ts new file mode 100644 index 00000000000000..0d2e21d59fbdad --- /dev/null +++ b/x-pack/test/saved_object_access_control/security_and_spaces/apis/bulk_update.ts @@ -0,0 +1,254 @@ +/* + * 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 { SavedObjectsBulkResponse } from 'src/core/server'; +import { CONFIDENTIAL_SAVED_OBJECT_TYPE } from '../../fixtures/confidential_plugin/server'; +import { + USERS, + ExpectedResponse, + assertSavedObjectExists, + assertSavedObjectAccessControl, +} from '../../common/lib'; +import { FtrProviderContext } from '../../services'; + +// eslint-disable-next-line import/no-default-export +export default function ({ getService }: FtrProviderContext) { + const es = getService('es'); + const esArchiver = getService('esArchiver'); + const supertest = getService('supertestWithoutAuth'); + + describe('PUT /api/saved_objects/_bulk_update', () => { + before(async () => { + await esArchiver.load( + 'x-pack/test/saved_object_access_control/fixtures/es_archiver/confidential_objects' + ); + }); + + after(async () => { + await esArchiver.unload( + 'x-pack/test/saved_object_access_control/fixtures/es_archiver/confidential_objects' + ); + }); + + const authorizedExpectedResponse: ExpectedResponse< + [ + Array<{ + owner?: string; + attributes?: Record; + type: string; + id?: string; + namespaces?: string[]; + }> + ] + > = { + httpCode: 200, + expectResponse: (opts) => ({ body }) => { + const { saved_objects: savedObjects } = body as SavedObjectsBulkResponse; + + expect(opts.length).to.eql(savedObjects.length); + savedObjects.forEach((object, index) => { + const expected = opts[index]; + if (expected.id) { + expect(object.id).to.eql(expected.id); + } + expect(object.type).to.eql(expected.type); + + // Since the access control is not updated as part of this operation, it will not be returned + // in the response. Validation must be done as an extra step. + expect(object.accessControl).to.eql(undefined); + + expect(object.namespaces).to.eql(expected.namespaces); + + expect(object.error).to.eql(undefined); + }); + }, + }; + + const unauthorizedExpectedResponse: ExpectedResponse<[{ type: string; id?: string }]> = { + httpCode: 403, + expectResponse: ({ type, id }) => ({ body }) => { + expect(body).to.eql({ + statusCode: 403, + error: 'Forbidden', + message: `Unable to bulk_update ${type}${id ? ':' + id : ''}`, + }); + }, + }; + + it('returns 403 for users who cannot update confidential objects of this type', async () => { + const { username, password } = USERS.CHARLIE; + const { httpCode, expectResponse } = unauthorizedExpectedResponse; + + await supertest + .put(`/api/saved_objects/_bulk_update`) + .auth(username, password) + .send([ + { + type: CONFIDENTIAL_SAVED_OBJECT_TYPE, + id: 'charlie_doc_1', + attributes: { + name: 'new name', + }, + }, + ]) + .expect(httpCode) + .then(expectResponse({ type: CONFIDENTIAL_SAVED_OBJECT_TYPE })); + }); + + it('allows confidential objects to be updated by their owner, and maintains an appropriate accessControl', async () => { + const { username, password } = USERS.ALICE; + const { httpCode, expectResponse } = authorizedExpectedResponse; + + const savedObjectId = 'alice_doc_1'; + + await assertSavedObjectExists(es, CONFIDENTIAL_SAVED_OBJECT_TYPE, savedObjectId); + + const name = 'bulk updated test'; + + await supertest + .put(`/api/saved_objects/_bulk_update`) + .auth(username, password) + .send([ + { + type: CONFIDENTIAL_SAVED_OBJECT_TYPE, + id: savedObjectId, + attributes: { + name, + }, + }, + ]) + .expect(httpCode) + .then( + expectResponse([ + { + attributes: { name }, + type: CONFIDENTIAL_SAVED_OBJECT_TYPE, + id: savedObjectId, + namespaces: ['default'], + }, + ]) + ); + + await assertSavedObjectAccessControl( + es, + CONFIDENTIAL_SAVED_OBJECT_TYPE, + savedObjectId, + 'default', + { + owner: username, + } + ); + }); + + it('does not attach an accessControl for public objects', async () => { + const { username, password } = USERS.SUPERUSER; + const { httpCode, expectResponse } = authorizedExpectedResponse; + + const savedObjectId = 'index_pattern_1'; + + await supertest + .put(`/api/saved_objects/_bulk_update`) + .auth(username, password) + .send([ + { + id: savedObjectId, + type: 'index-pattern', + attributes: { + title: 'updated title', + }, + }, + ]) + .expect(httpCode) + .then( + expectResponse([ + { + id: savedObjectId, + type: 'index-pattern', + attributes: { + title: 'updated title', + }, + namespaces: ['default'], + }, + ]) + ); + + await assertSavedObjectAccessControl( + es, + 'index-pattern', + savedObjectId, + 'default', + undefined + ); + }); + + it('does not allow updating an object that does not belong to the current user', async () => { + const { username, password } = USERS.ALICE; + const { httpCode, expectResponse } = unauthorizedExpectedResponse; + + const savedObjectId = 'bob_doc_1'; + + await assertSavedObjectExists(es, CONFIDENTIAL_SAVED_OBJECT_TYPE, savedObjectId); + + await supertest + .put(`/api/saved_objects/_bulk_update`) + .auth(username, password) + .send([ + { + type: CONFIDENTIAL_SAVED_OBJECT_TYPE, + id: savedObjectId, + attributes: { name: 'hack attempt' }, + }, + ]) + .expect(httpCode) + .then(expectResponse({ type: CONFIDENTIAL_SAVED_OBJECT_TYPE, id: savedObjectId })); + }); + + [USERS.KIBANA_ADMIN, USERS.SUPERUSER].forEach((user) => { + it(`allows ${user.description} to update objects that belong to other users, while maintaining the original access control`, async () => { + const { username, password } = user; + const { httpCode, expectResponse } = authorizedExpectedResponse; + + const savedObjectId = 'bob_doc_1'; + + await assertSavedObjectExists(es, CONFIDENTIAL_SAVED_OBJECT_TYPE, savedObjectId); + + await supertest + .put(`/api/saved_objects/_bulk_update`) + .auth(username, password) + .send([ + { + type: CONFIDENTIAL_SAVED_OBJECT_TYPE, + id: savedObjectId, + attributes: { name: 'update attempt' }, + }, + ]) + .expect(httpCode) + .then( + expectResponse([ + { + attributes: { name: 'update attempt' }, + type: CONFIDENTIAL_SAVED_OBJECT_TYPE, + id: savedObjectId, + namespaces: ['default'], + }, + ]) + ); + + await assertSavedObjectAccessControl( + es, + CONFIDENTIAL_SAVED_OBJECT_TYPE, + savedObjectId, + 'default', + { + owner: USERS.BOB.username, + } + ); + }); + }); + }); +} diff --git a/x-pack/test/saved_object_access_control/security_and_spaces/apis/create.ts b/x-pack/test/saved_object_access_control/security_and_spaces/apis/create.ts new file mode 100644 index 00000000000000..a779bf31722681 --- /dev/null +++ b/x-pack/test/saved_object_access_control/security_and_spaces/apis/create.ts @@ -0,0 +1,210 @@ +/* + * 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 { CONFIDENTIAL_SAVED_OBJECT_TYPE } from '../../fixtures/confidential_plugin/server'; +import { USERS, ExpectedResponse, assertSavedObjectExists } from '../../common/lib'; +import { FtrProviderContext } from '../../services'; + +// eslint-disable-next-line import/no-default-export +export default function ({ getService }: FtrProviderContext) { + const es = getService('es'); + const esArchiver = getService('esArchiver'); + const supertest = getService('supertestWithoutAuth'); + + describe('POST /api/saved_objects/{type}/{id}', () => { + before(async () => { + await esArchiver.load( + 'x-pack/test/saved_object_access_control/fixtures/es_archiver/confidential_objects' + ); + }); + + after(async () => { + await esArchiver.unload( + 'x-pack/test/saved_object_access_control/fixtures/es_archiver/confidential_objects' + ); + }); + + const authorizedExpectedResponse: ExpectedResponse< + [{ owner?: string; attributes?: Record; type: string }] + > = { + httpCode: 200, + expectResponse: ({ owner, type: expectedType, attributes: expectedAttributes }) => ({ + body, + }) => { + const { accessControl, type, attributes } = body; + const requiresAccessControl = expectedType === CONFIDENTIAL_SAVED_OBJECT_TYPE; + + const expectedAccessControl = requiresAccessControl ? { owner } : undefined; + + expect({ accessControl, type, attributes }).to.eql({ + accessControl: expectedAccessControl, + type: expectedType, + attributes: expectedAttributes, + }); + }, + }; + + const unauthorizedExpectedResponse: ExpectedResponse<[{ type: string; id?: string }]> = { + httpCode: 403, + expectResponse: ({ type, id }) => ({ body }) => { + expect(body).to.eql({ + statusCode: 403, + error: 'Forbidden', + message: `Unable to create ${type}${id ? `:${id}` : ''}`, + }); + }, + }; + + it('returns 403 for users who cannot create confidential objects of this type', async () => { + const { username, password } = USERS.CHARLIE; + const { httpCode, expectResponse } = unauthorizedExpectedResponse; + + await supertest + .post(`/api/saved_objects/${CONFIDENTIAL_SAVED_OBJECT_TYPE}`) + .auth(username, password) + .send({ + attributes: { name: 'test ' }, + }) + .expect(httpCode) + .then(expectResponse({ type: CONFIDENTIAL_SAVED_OBJECT_TYPE })); + }); + + it('allows confidential objects to be created, and attaches an appropriate accessControl', async () => { + const { username, password } = USERS.ALICE; + const { httpCode, expectResponse } = authorizedExpectedResponse; + + const name = 'test'; + + await supertest + .post(`/api/saved_objects/${CONFIDENTIAL_SAVED_OBJECT_TYPE}`) + .auth(username, password) + .send({ + attributes: { name }, + }) + .expect(httpCode) + .then( + expectResponse({ + attributes: { name }, + type: CONFIDENTIAL_SAVED_OBJECT_TYPE, + owner: username, + }) + ); + }); + + it('allows confidential objects to be overwritten by the same owner', async () => { + const { username, password } = USERS.ALICE; + const { httpCode, expectResponse } = authorizedExpectedResponse; + + const name = 'test'; + + // Create the object + await supertest + .post(`/api/saved_objects/${CONFIDENTIAL_SAVED_OBJECT_TYPE}/alice_test_object`) + .auth(username, password) + .send({ + attributes: { name }, + }) + .expect(httpCode) + .then( + expectResponse({ + attributes: { name }, + type: CONFIDENTIAL_SAVED_OBJECT_TYPE, + owner: username, + }) + ); + + await assertSavedObjectExists(es, CONFIDENTIAL_SAVED_OBJECT_TYPE, 'alice_test_object'); + + // And attempt to overwrite + const updatedName = 'updated test'; + await supertest + .post( + `/api/saved_objects/${CONFIDENTIAL_SAVED_OBJECT_TYPE}/alice_test_object?overwrite=true` + ) + .auth(username, password) + .send({ + attributes: { name: updatedName }, + }) + .expect(httpCode) + .then( + expectResponse({ + attributes: { name: updatedName }, + type: CONFIDENTIAL_SAVED_OBJECT_TYPE, + owner: username, + }) + ); + }); + + it('does not attach an accessControl for public objects', async () => { + const { username, password } = USERS.SUPERUSER; + const { httpCode, expectResponse } = authorizedExpectedResponse; + + await supertest + .post(`/api/saved_objects/index-pattern`) + .auth(username, password) + .send({ + attributes: { title: 'some index pattern' }, + }) + .expect(httpCode) + .then( + expectResponse({ + type: 'index-pattern', + attributes: { title: 'some index pattern' }, + }) + ); + }); + + it('does not allow overwriting an object that does not belong to the current user', async () => { + const { username, password } = USERS.ALICE; + const { httpCode, expectResponse } = unauthorizedExpectedResponse; + + const savedObjectId = 'bob_doc_1'; + + await assertSavedObjectExists(es, CONFIDENTIAL_SAVED_OBJECT_TYPE, savedObjectId); + + await supertest + .post( + `/api/saved_objects/${CONFIDENTIAL_SAVED_OBJECT_TYPE}/${savedObjectId}?overwrite=true` + ) + .auth(username, password) + .send({ + attributes: { name: 'hack attempt' }, + }) + .expect(httpCode) + .then(expectResponse({ type: CONFIDENTIAL_SAVED_OBJECT_TYPE, id: savedObjectId })); + }); + + [USERS.KIBANA_ADMIN, USERS.SUPERUSER].forEach((user) => { + it(`allows ${user.description} to overwrite objects that belong to other users`, async () => { + const { username, password } = user; + const { httpCode, expectResponse } = authorizedExpectedResponse; + + const savedObjectId = 'alice_test_object'; + + await assertSavedObjectExists(es, CONFIDENTIAL_SAVED_OBJECT_TYPE, savedObjectId); + + await supertest + .post( + `/api/saved_objects/${CONFIDENTIAL_SAVED_OBJECT_TYPE}/${savedObjectId}?overwrite=true` + ) + .auth(username, password) + .send({ + attributes: { name: 'update attempt' }, + }) + .expect(httpCode) + .then( + expectResponse({ + type: CONFIDENTIAL_SAVED_OBJECT_TYPE, + owner: username, + attributes: { name: 'update attempt' }, + }) + ); + }); + }); + }); +} diff --git a/x-pack/test/saved_object_access_control/security_and_spaces/apis/delete.ts b/x-pack/test/saved_object_access_control/security_and_spaces/apis/delete.ts new file mode 100644 index 00000000000000..2488193bbf01e0 --- /dev/null +++ b/x-pack/test/saved_object_access_control/security_and_spaces/apis/delete.ts @@ -0,0 +1,125 @@ +/* + * 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 { CONFIDENTIAL_SAVED_OBJECT_TYPE } from '../../fixtures/confidential_plugin/server'; +import { + USERS, + ExpectedResponse, + assertSavedObjectExists, + assertSavedObjectMissing, +} from '../../common/lib'; +import { FtrProviderContext } from '../../services'; + +// eslint-disable-next-line import/no-default-export +export default function ({ getService }: FtrProviderContext) { + const es = getService('es'); + const esArchiver = getService('esArchiver'); + const supertest = getService('supertestWithoutAuth'); + + describe('DELETE /api/saved_objects/{type}/{id}', () => { + before(async () => { + await esArchiver.load( + 'x-pack/test/saved_object_access_control/fixtures/es_archiver/confidential_objects' + ); + }); + + after(async () => { + await esArchiver.unload( + 'x-pack/test/saved_object_access_control/fixtures/es_archiver/confidential_objects' + ); + }); + + const authorizedExpectedResponse: ExpectedResponse = { + httpCode: 200, + expectResponse: () => ({ body }) => { + expect(body).to.eql({}); + }, + }; + + const unauthorizedExpectedResponse: ExpectedResponse<[{ type: string; id?: string }]> = { + httpCode: 403, + expectResponse: ({ type, id }) => ({ body }) => { + expect(body).to.eql({ + statusCode: 403, + error: 'Forbidden', + message: `Unable to delete ${type}${id ? ':' + id : ''}`, + }); + }, + }; + + it('returns 403 for users who cannot delete confidential objects of this type', async () => { + const { username, password } = USERS.CHARLIE; + const { httpCode, expectResponse } = unauthorizedExpectedResponse; + + await supertest + .delete(`/api/saved_objects/${CONFIDENTIAL_SAVED_OBJECT_TYPE}/charlie_doc_1`) + .auth(username, password) + .send({ + attributes: { name: 'updated' }, + }) + .expect(httpCode) + .then(expectResponse({ type: CONFIDENTIAL_SAVED_OBJECT_TYPE })); + }); + + it('does not allow deleting an object that does not belong to the current user', async () => { + const { username, password } = USERS.ALICE; + const { httpCode, expectResponse } = unauthorizedExpectedResponse; + + const savedObjectId = 'bob_doc_1'; + + await assertSavedObjectExists(es, CONFIDENTIAL_SAVED_OBJECT_TYPE, savedObjectId); + + await supertest + .delete(`/api/saved_objects/${CONFIDENTIAL_SAVED_OBJECT_TYPE}/${savedObjectId}`) + .auth(username, password) + .send({ + attributes: { name: 'hack attempt' }, + }) + .expect(httpCode) + .then(expectResponse({ type: CONFIDENTIAL_SAVED_OBJECT_TYPE, id: savedObjectId })); + + await assertSavedObjectExists(es, CONFIDENTIAL_SAVED_OBJECT_TYPE, savedObjectId); + }); + + it('allows confidential objects to be deleted by their owner', async () => { + const { username, password } = USERS.ALICE; + const { httpCode, expectResponse } = authorizedExpectedResponse; + + await assertSavedObjectExists(es, CONFIDENTIAL_SAVED_OBJECT_TYPE, 'alice_doc_1'); + + await supertest + .delete(`/api/saved_objects/${CONFIDENTIAL_SAVED_OBJECT_TYPE}/alice_doc_1`) + .auth(username, password) + .expect(httpCode) + .then(expectResponse()); + + await assertSavedObjectMissing(es, CONFIDENTIAL_SAVED_OBJECT_TYPE, 'alice_doc_1'); + }); + + [USERS.KIBANA_ADMIN, USERS.SUPERUSER].forEach((user) => { + it(`allows ${user.description} to delete objects belonging to other users`, async () => { + await esArchiver.load( + 'x-pack/test/saved_object_access_control/fixtures/es_archiver/confidential_objects' + ); + + const { username, password } = user; + const { httpCode, expectResponse } = authorizedExpectedResponse; + + await assertSavedObjectExists(es, CONFIDENTIAL_SAVED_OBJECT_TYPE, 'alice_doc_1'); + + await supertest + .delete(`/api/saved_objects/${CONFIDENTIAL_SAVED_OBJECT_TYPE}/alice_doc_1`) + .auth(username, password) + .expect(httpCode) + .then(expectResponse()); + + await assertSavedObjectMissing(es, CONFIDENTIAL_SAVED_OBJECT_TYPE, 'alice_doc_1'); + }); + }); + }); +} diff --git a/x-pack/test/saved_object_access_control/security_and_spaces/apis/export.ts b/x-pack/test/saved_object_access_control/security_and_spaces/apis/export.ts new file mode 100644 index 00000000000000..45b4bd40866b1d --- /dev/null +++ b/x-pack/test/saved_object_access_control/security_and_spaces/apis/export.ts @@ -0,0 +1,241 @@ +/* + * 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 { SavedObject, SavedObjectsExportResultDetails } from 'src/core/server'; +import { CONFIDENTIAL_SAVED_OBJECT_TYPE } from '../../fixtures/confidential_plugin/server'; +import { USERS, ExpectedResponse, assertSavedObjectExists } from '../../common/lib'; +import { FtrProviderContext } from '../../services'; + +// eslint-disable-next-line import/no-default-export +export default function ({ getService }: FtrProviderContext) { + const es = getService('es'); + const esArchiver = getService('esArchiver'); + const supertest = getService('supertestWithoutAuth'); + + describe('POST /api/saved_objects/_export', () => { + before(async () => { + await esArchiver.load( + 'x-pack/test/saved_object_access_control/fixtures/es_archiver/confidential_objects' + ); + }); + + after(async () => { + await esArchiver.unload( + 'x-pack/test/saved_object_access_control/fixtures/es_archiver/confidential_objects' + ); + }); + + const authorizedExpectedResponse: ExpectedResponse< + [ + { + excludedObjects?: Array>; + excludedObjectsCount?: number; + expectedResults: Array>; + missingRefCount?: number; + missingReferences?: SavedObjectsExportResultDetails['missingReferences']; + } + ] + > = { + httpCode: 200, + expectResponse: ({ + excludedObjects = [], + excludedObjectsCount = 0, + expectedResults, + missingRefCount = 0, + missingReferences = [], + }) => (response) => { + const ndjson: Array> = response.text.split('\n').map(JSON.parse); + const summary = ndjson.pop(); + expect(summary).to.eql({ + excludedObjects, + excludedObjectsCount, + exportedCount: expectedResults.length, + missingRefCount, + missingReferences, + }); + + expect(ndjson.length).to.eql(expectedResults.length); + ndjson.forEach(({ type, id, accessControl }, index) => { + const expected = expectedResults[index]; + expect({ type, id }).to.eql({ type: expected.type, id: expected.id }); + expect(accessControl).to.eql(undefined); + }); + }, + }; + + const unauthorizedForObjectExpectedResponse: ExpectedResponse< + [ + { + savedObjectType: string; + savedObjectId: string; + } + ] + > = { + httpCode: 400, + expectResponse: ({ savedObjectType, savedObjectId }) => ({ body }) => { + expect(body).to.eql({ + error: 'Bad Request', + message: 'Error fetching objects to export', + statusCode: 400, + attributes: { + objects: [ + { + error: { + error: 'Forbidden', + message: `Unable to bulk_get ${savedObjectType}:${savedObjectId}`, + statusCode: 403, + }, + id: savedObjectId, + type: savedObjectType, + }, + ], + }, + }); + }, + }; + + it('does not export confidential objects when user is not authorized for the type', async () => { + const { username, password } = USERS.CHARLIE; + const { httpCode, expectResponse } = authorizedExpectedResponse; + + await supertest + .post(`/api/saved_objects/_export`) + .auth(username, password) + .send({ + type: [CONFIDENTIAL_SAVED_OBJECT_TYPE], + }) + .expect(httpCode) + .then( + expectResponse({ + expectedResults: [], + }) + ); + }); + + it('does not export confidential objects when user is not authorized for the instance', async () => { + const { username, password } = USERS.ALICE; + const { httpCode, expectResponse } = unauthorizedForObjectExpectedResponse; + + await assertSavedObjectExists(es, CONFIDENTIAL_SAVED_OBJECT_TYPE, 'bob_doc_1'); + + await supertest + .post(`/api/saved_objects/_export`) + .auth(username, password) + .send({ + objects: [ + { + type: CONFIDENTIAL_SAVED_OBJECT_TYPE, + id: 'bob_doc_1', + }, + ], + }) + .expect(httpCode) + .then( + expectResponse({ + savedObjectType: CONFIDENTIAL_SAVED_OBJECT_TYPE, + savedObjectId: 'bob_doc_1', + }) + ); + }); + + it('does not export other users confidential objects even when referenced from public objects', async () => { + const { username, password } = USERS.ALICE; + const { httpCode, expectResponse } = authorizedExpectedResponse; + + await assertSavedObjectExists(es, CONFIDENTIAL_SAVED_OBJECT_TYPE, 'bob_doc_1'); + + await supertest + .post(`/api/saved_objects/index-pattern/sneaky-index-pattern`) + .auth(username, password) + .send({ + attributes: { + title: 'sneaky', + }, + references: [ + { + name: `Somebody else's confidential saved object`, + type: CONFIDENTIAL_SAVED_OBJECT_TYPE, + id: 'bob_doc_1', + }, + ], + }) + .expect(200); + + await supertest + .post(`/api/saved_objects/_export`) + .auth(username, password) + .send({ + objects: [ + { + type: 'index-pattern', + id: 'sneaky-index-pattern', + }, + ], + includeReferencesDeep: true, + }) + .expect(httpCode) + .then( + expectResponse({ + expectedResults: [ + { + id: 'sneaky-index-pattern', + type: 'index-pattern', + attributes: { title: 'sneaky' }, + references: [ + { + name: `Somebody else's confidential saved object`, + type: CONFIDENTIAL_SAVED_OBJECT_TYPE, + id: 'bob_doc_1', + }, + ], + }, + ], + missingRefCount: 1, + missingReferences: [ + { + type: CONFIDENTIAL_SAVED_OBJECT_TYPE, + id: 'bob_doc_1', + }, + ], + }) + ); + }); + + it('allows confidential objects to be exported, and removes the accessControl', async () => { + const { username, password } = USERS.ALICE; + const { httpCode, expectResponse } = authorizedExpectedResponse; + + await assertSavedObjectExists(es, CONFIDENTIAL_SAVED_OBJECT_TYPE, 'alice_doc_1'); + + await supertest + .post(`/api/saved_objects/_export`) + .auth(username, password) + .send({ + objects: [ + { + type: CONFIDENTIAL_SAVED_OBJECT_TYPE, + id: 'alice_doc_1', + }, + ], + }) + .expect(httpCode) + .then( + expectResponse({ + expectedResults: [ + { + type: CONFIDENTIAL_SAVED_OBJECT_TYPE, + id: 'alice_doc_1', + attributes: {}, + references: [], + }, + ], + }) + ); + }); + }); +} diff --git a/x-pack/test/saved_object_access_control/security_and_spaces/apis/find.ts b/x-pack/test/saved_object_access_control/security_and_spaces/apis/find.ts new file mode 100644 index 00000000000000..be2d7d0343681c --- /dev/null +++ b/x-pack/test/saved_object_access_control/security_and_spaces/apis/find.ts @@ -0,0 +1,194 @@ +/* + * 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 { SavedObjectsFindResponse } from 'src/core/server'; +import { CONFIDENTIAL_SAVED_OBJECT_TYPE } from '../../fixtures/confidential_plugin/server'; +import { USERS, ExpectedResponse, assertSavedObjectExists } from '../../common/lib'; +import { FtrProviderContext } from '../../services'; + +// eslint-disable-next-line import/no-default-export +export default function ({ getService }: FtrProviderContext) { + const es = getService('es'); + const esArchiver = getService('esArchiver'); + const supertest = getService('supertestWithoutAuth'); + + type FindResponseOpts = Array<{ type: string; id: string; namespaces?: string[] }>; + + const authorizedExpectedResponse: ExpectedResponse<[FindResponseOpts]> = { + httpCode: 200, + expectResponse: (opts: FindResponseOpts) => ({ body }) => { + const expectedPayload = opts.map(({ type, id, namespaces }) => { + return { + id, + type, + namespaces, + }; + }); + + const { saved_objects: savedObjects } = body as SavedObjectsFindResponse; + expect(savedObjects.length).to.eql(expectedPayload.length); + savedObjects.forEach((object, index) => { + const { id, type, namespaces } = expectedPayload[index]; + expect(object.id).to.eql(id, JSON.stringify({ index, object })); + expect(object.type).to.eql(type, JSON.stringify({ index, object })); + expect(object.namespaces).to.eql(namespaces, JSON.stringify({ index, object })); + expect(object.attributes).to.be.an(Object); + }); + }, + }; + + describe('GET /api/saved_objects/_find', () => { + before(async () => { + await esArchiver.load( + 'x-pack/test/saved_object_access_control/fixtures/es_archiver/confidential_objects' + ); + }); + + after(async () => { + await esArchiver.unload( + 'x-pack/test/saved_object_access_control/fixtures/es_archiver/confidential_objects' + ); + }); + + it(`returns no objects when searching for unauthorized types`, async () => { + const { username, password } = USERS.CHARLIE; + const { httpCode, expectResponse } = authorizedExpectedResponse; + const savedObjectId = 'charlie_doc_1'; + + await assertSavedObjectExists(es, CONFIDENTIAL_SAVED_OBJECT_TYPE, savedObjectId); + + await supertest + .get(`/api/saved_objects/_find?type=${CONFIDENTIAL_SAVED_OBJECT_TYPE}`) + .auth(username, password) + .expect(httpCode) + .then(expectResponse([])); + }); + + it(`returns the owners confidential objects`, async () => { + const { username, password } = USERS.ALICE; + const { httpCode, expectResponse } = authorizedExpectedResponse; + const savedObjectId = 'alice_doc_1'; + + await assertSavedObjectExists(es, CONFIDENTIAL_SAVED_OBJECT_TYPE, savedObjectId); + + await supertest + .get(`/api/saved_objects/_find?type=${CONFIDENTIAL_SAVED_OBJECT_TYPE}`) + .auth(username, password) + .expect(httpCode) + .then( + expectResponse([ + { + type: CONFIDENTIAL_SAVED_OBJECT_TYPE, + id: savedObjectId, + namespaces: ['default'], + }, + ]) + ); + }); + + it(`returns the owners confidential objects when searching across spaces, omitting spaces the user isn't authorized for`, async () => { + const { username, password } = USERS.ALICE; + const { httpCode, expectResponse } = authorizedExpectedResponse; + const savedObjectId = 'alice_doc_1'; + + // User is not authorized for this object. Ensure it exists so we can verify it's not returned for the right reason. + await assertSavedObjectExists(es, CONFIDENTIAL_SAVED_OBJECT_TYPE, 'charlie_doc_1'); + + await assertSavedObjectExists(es, CONFIDENTIAL_SAVED_OBJECT_TYPE, savedObjectId); + await assertSavedObjectExists(es, CONFIDENTIAL_SAVED_OBJECT_TYPE, savedObjectId, 'space_1'); + await assertSavedObjectExists( + es, + CONFIDENTIAL_SAVED_OBJECT_TYPE, + 'alice_space_1_doc', + 'space_1' + ); + + // this document exists, but Alice is not authorized to query within the `space_2` space + await assertSavedObjectExists(es, CONFIDENTIAL_SAVED_OBJECT_TYPE, savedObjectId, 'space_2'); + + await supertest + .get(`/api/saved_objects/_find?type=${CONFIDENTIAL_SAVED_OBJECT_TYPE}&namespaces=*`) + .auth(username, password) + .expect(httpCode) + .then( + expectResponse([ + { + type: CONFIDENTIAL_SAVED_OBJECT_TYPE, + id: savedObjectId, + namespaces: ['default'], + }, + { + type: CONFIDENTIAL_SAVED_OBJECT_TYPE, + id: savedObjectId, + namespaces: ['space_1'], + }, + { + type: CONFIDENTIAL_SAVED_OBJECT_TYPE, + id: 'alice_space_1_doc', + namespaces: ['space_1'], + }, + ]) + ); + }); + + [USERS.KIBANA_ADMIN, USERS.SUPERUSER].forEach((user) => { + it(`allows ${user.description} to find confidential objects that belong to other users`, async () => { + const { username, password } = user; + const { httpCode, expectResponse } = authorizedExpectedResponse; + const savedObjectId = 'charlie_doc_1'; + + await assertSavedObjectExists(es, CONFIDENTIAL_SAVED_OBJECT_TYPE, savedObjectId); + + await supertest + .get(`/api/saved_objects/_find?type=${CONFIDENTIAL_SAVED_OBJECT_TYPE}&namespaces=*`) + .auth(username, password) + .expect(httpCode) + .then( + expectResponse([ + { + type: CONFIDENTIAL_SAVED_OBJECT_TYPE, + id: 'alice_doc_1', + namespaces: ['default'], + }, + { + type: CONFIDENTIAL_SAVED_OBJECT_TYPE, + id: 'alice_doc_1', + namespaces: ['space_1'], + }, + { + type: CONFIDENTIAL_SAVED_OBJECT_TYPE, + id: 'alice_doc_1', + namespaces: ['space_2'], + }, + + { + type: CONFIDENTIAL_SAVED_OBJECT_TYPE, + id: 'alice_space_1_doc', + namespaces: ['space_1'], + }, + { + type: CONFIDENTIAL_SAVED_OBJECT_TYPE, + id: 'bob_doc_1', + namespaces: ['default'], + }, + { + type: CONFIDENTIAL_SAVED_OBJECT_TYPE, + id: 'bob_doc_1', + namespaces: ['space_1'], + }, + { + type: CONFIDENTIAL_SAVED_OBJECT_TYPE, + id: 'charlie_doc_1', + namespaces: ['default'], + }, + ]) + ); + }); + }); + }); +} diff --git a/x-pack/test/saved_object_access_control/security_and_spaces/apis/get.ts b/x-pack/test/saved_object_access_control/security_and_spaces/apis/get.ts new file mode 100644 index 00000000000000..b47bb6dc91316b --- /dev/null +++ b/x-pack/test/saved_object_access_control/security_and_spaces/apis/get.ts @@ -0,0 +1,150 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { CONFIDENTIAL_SAVED_OBJECT_TYPE } from '../../fixtures/confidential_plugin/server'; +import { USERS, ExpectedResponse, assertSavedObjectExists } from '../../common/lib'; +import { FtrProviderContext } from '../../services'; + +// eslint-disable-next-line import/no-default-export +export default function ({ getService }: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const supertest = getService('supertestWithoutAuth'); + const es = getService('es'); + + describe('GET /api/saved_objects/{type}/{id}', () => { + before(async () => { + await esArchiver.load( + 'x-pack/test/saved_object_access_control/fixtures/es_archiver/confidential_objects' + ); + }); + + after(async () => { + await esArchiver.unload( + 'x-pack/test/saved_object_access_control/fixtures/es_archiver/confidential_objects' + ); + }); + + const authorizedExpectedResponse: ExpectedResponse< + [{ owner: string; savedObjectId: string }] + > = { + httpCode: 200, + expectResponse: ({ savedObjectId, owner }) => ({ body }) => { + const { accessControl, id, type } = body; + expect({ accessControl, id, type }).to.eql({ + accessControl: { + owner, + }, + id: savedObjectId, + type: CONFIDENTIAL_SAVED_OBJECT_TYPE, + }); + }, + }; + + const notFoundExpectedResponse: ExpectedResponse<[{ savedObjectId: string }]> = { + httpCode: 404, + expectResponse: ({ savedObjectId }) => ({ body }) => { + expect(body).to.eql({ + statusCode: 404, + error: 'Not Found', + message: `Saved object [${CONFIDENTIAL_SAVED_OBJECT_TYPE}/${savedObjectId}] not found`, + }); + }, + }; + + const unauthorizedExpectedResponse: ExpectedResponse<[{ type: string; id?: string }]> = { + httpCode: 403, + expectResponse: ({ type, id }) => ({ body }) => { + expect(body).to.eql({ + statusCode: 403, + error: 'Forbidden', + message: `Unable to get ${type}${id ? ':' + id : ''}`, + }); + }, + }; + + it('returns 404 for confidential objects that do not exist', async () => { + const { username, password } = USERS.ALICE; + const { httpCode, expectResponse } = notFoundExpectedResponse; + const savedObjectId = 'not_found_object'; + await supertest + .get(`/api/saved_objects/${CONFIDENTIAL_SAVED_OBJECT_TYPE}/${savedObjectId}`) + .auth(username, password) + .expect(httpCode) + .then(expectResponse({ savedObjectId })); + }); + + it('returns 403 for confidential objects that belong to another user', async () => { + const { username, password } = USERS.ALICE; + const { httpCode, expectResponse } = unauthorizedExpectedResponse; + const savedObjectId = 'bob_doc_1'; + + await assertSavedObjectExists(es, CONFIDENTIAL_SAVED_OBJECT_TYPE, savedObjectId); + + await supertest + .get(`/api/saved_objects/${CONFIDENTIAL_SAVED_OBJECT_TYPE}/${savedObjectId}`) + .auth(username, password) + .expect(httpCode) + .then(expectResponse({ type: CONFIDENTIAL_SAVED_OBJECT_TYPE, id: savedObjectId })); + }); + + it('returns 404 for confidential objects that exist in another space', async () => { + const { username, password } = USERS.ALICE; + const { httpCode, expectResponse } = notFoundExpectedResponse; + const savedObjectId = 'alice_space_1_doc'; + + await assertSavedObjectExists(es, CONFIDENTIAL_SAVED_OBJECT_TYPE, savedObjectId, 'space_1'); + + await supertest + .get(`/api/saved_objects/${CONFIDENTIAL_SAVED_OBJECT_TYPE}/${savedObjectId}`) + .auth(username, password) + .expect(httpCode) + .then(expectResponse({ savedObjectId })); + }); + + it('returns 403 for users who cannot access confidential objects of this type', async () => { + const { username, password } = USERS.CHARLIE; + const { httpCode, expectResponse } = unauthorizedExpectedResponse; + const savedObjectId = 'charlie_doc_1'; + + await assertSavedObjectExists(es, CONFIDENTIAL_SAVED_OBJECT_TYPE, savedObjectId); + + await supertest + .get(`/api/saved_objects/${CONFIDENTIAL_SAVED_OBJECT_TYPE}/${savedObjectId}`) + .auth(username, password) + .expect(httpCode) + .then(expectResponse({ type: CONFIDENTIAL_SAVED_OBJECT_TYPE })); + }); + + it('returns 200 for confidential objects that belong to the current user', async () => { + const { username, password } = USERS.ALICE; + const { httpCode, expectResponse } = authorizedExpectedResponse; + const savedObjectId = 'alice_doc_1'; + await supertest + .get(`/api/saved_objects/${CONFIDENTIAL_SAVED_OBJECT_TYPE}/${savedObjectId}`) + .auth(username, password) + .expect(httpCode) + .then(expectResponse({ savedObjectId, owner: username })); + }); + + [USERS.KIBANA_ADMIN, USERS.SUPERUSER].forEach((user) => { + it(`allows ${user.description} to access objects from other users`, async () => { + const { username, password } = user; + const { httpCode, expectResponse } = authorizedExpectedResponse; + const savedObjectId = 'alice_doc_1'; + + await assertSavedObjectExists(es, CONFIDENTIAL_SAVED_OBJECT_TYPE, savedObjectId); + + await supertest + .get(`/api/saved_objects/${CONFIDENTIAL_SAVED_OBJECT_TYPE}/${savedObjectId}`) + .auth(username, password) + .expect(httpCode) + .then(expectResponse({ savedObjectId, owner: USERS.ALICE.username })); + }); + }); + }); +} diff --git a/x-pack/test/saved_object_access_control/security_and_spaces/apis/import.ts b/x-pack/test/saved_object_access_control/security_and_spaces/apis/import.ts new file mode 100644 index 00000000000000..6fe17b840e8a31 --- /dev/null +++ b/x-pack/test/saved_object_access_control/security_and_spaces/apis/import.ts @@ -0,0 +1,311 @@ +/* + * 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 { + SavedObject, + SavedObjectsImportFailure, + SavedObjectsImportResponse, + SavedObjectsImportSuccess, +} from 'src/core/server'; +import { CONFIDENTIAL_SAVED_OBJECT_TYPE } from '../../fixtures/confidential_plugin/server'; +import { + USERS, + ExpectedResponse, + assertSavedObjectExists, + assertSavedObjectAccessControl, +} from '../../common/lib'; +import { FtrProviderContext } from '../../services'; + +// eslint-disable-next-line import/no-default-export +export default function ({ getService }: FtrProviderContext) { + const es = getService('es'); + const esArchiver = getService('esArchiver'); + const supertest = getService('supertestWithoutAuth'); + + describe('POST /api/saved_objects/_import', () => { + before(async () => { + await esArchiver.load( + 'x-pack/test/saved_object_access_control/fixtures/es_archiver/confidential_objects' + ); + }); + + after(async () => { + await esArchiver.unload( + 'x-pack/test/saved_object_access_control/fixtures/es_archiver/confidential_objects' + ); + }); + + const authorizedExpectedResponse: ExpectedResponse< + [ + { + success: boolean; + successCount: number; + errors?: SavedObjectsImportFailure[]; + successResults?: Array< + Omit & { expectDestinationId?: boolean } + >; + } + ] + > = { + httpCode: 200, + expectResponse: ({ success, successCount, errors, successResults }) => ({ body }) => { + expect(body.success).to.eql(success, JSON.stringify(body)); + expect(body.successCount).to.eql(successCount); + if (errors) { + expect(body.errors).to.eql(errors); + } + if (successResults) { + expect(body.successResults.length).to.eql(successResults.length); + (body as SavedObjectsImportResponse).successResults!.forEach((result, index) => { + const expected = successResults[index]; + expect(result.id).to.eql(expected.id); + expect(result.type).to.eql(expected.type); + expect(result.overwrite).to.eql(expected.overwrite); + if (expected.expectDestinationId) { + expect(typeof result.destinationId).to.eql('string'); + } else { + expect(result.destinationId).to.eql(undefined); + } + }); + } + }, + }; + + const unauthorizedExpectedResponse: ExpectedResponse<[{ type: string; id?: string }]> = { + httpCode: 403, + expectResponse: ({ type, id }) => ({ body }) => { + expect(body).to.eql({ + statusCode: 403, + error: 'Forbidden', + message: `Unable to bulk_create ${type}${id ? ':' + id : ''}`, + }); + }, + }; + + const createPayload = (objectsToImport: Array>) => { + return Buffer.from(objectsToImport.map((obj) => JSON.stringify(obj)).join('\n'), 'utf8'); + }; + + it('returns 403 for users who cannot import confidential objects of this type', async () => { + const { username, password } = USERS.CHARLIE; + const { httpCode, expectResponse } = unauthorizedExpectedResponse; + + const objectsToImport = [ + { + type: CONFIDENTIAL_SAVED_OBJECT_TYPE, + id: 'charlie_imported_doc', + attributes: { + name: 'my imported object', + }, + references: [], + }, + ]; + + await supertest + .post(`/api/saved_objects/_import`) + .auth(username, password) + .attach('file', createPayload(objectsToImport), 'export.ndjson') + .expect(httpCode) + .then( + expectResponse({ + type: CONFIDENTIAL_SAVED_OBJECT_TYPE, + }) + ); + }); + + it('allows confidential objects to be imported, and attaches an appropriate accessControl', async () => { + const { username, password } = USERS.ALICE; + const { httpCode, expectResponse } = authorizedExpectedResponse; + + const objectsToImport = [ + { + type: CONFIDENTIAL_SAVED_OBJECT_TYPE, + id: 'alice_imported_doc', + attributes: { + name: 'my imported object', + }, + references: [], + }, + ]; + + await supertest + .post(`/api/saved_objects/_import`) + .auth(username, password) + .attach('file', createPayload(objectsToImport), 'export.ndjson') + .expect(httpCode) + .then( + expectResponse({ + success: true, + successCount: 1, + successResults: [ + { + type: CONFIDENTIAL_SAVED_OBJECT_TYPE, + id: 'alice_imported_doc', + meta: {}, + }, + ], + }) + ); + + await assertSavedObjectAccessControl( + es, + CONFIDENTIAL_SAVED_OBJECT_TYPE, + 'alice_imported_doc', + 'default', + { owner: username } + ); + }); + + it('allows confidential objects to be overwritten by the same owner', async () => { + const { username, password } = USERS.ALICE; + const { httpCode, expectResponse } = authorizedExpectedResponse; + + const savedObjectId = 'alice_imported_doc'; + + await assertSavedObjectExists(es, CONFIDENTIAL_SAVED_OBJECT_TYPE, savedObjectId); + + const objectsToImport = [ + { + type: CONFIDENTIAL_SAVED_OBJECT_TYPE, + id: savedObjectId, + originId: savedObjectId, + attributes: { + name: 'my UPDATED imported object', + }, + references: [], + }, + ]; + + await supertest + .post(`/api/saved_objects/_import?overwrite=true`) + .auth(username, password) + .attach('file', createPayload(objectsToImport), 'export.ndjson') + .expect(httpCode) + .then( + expectResponse({ + success: true, + successCount: 1, + successResults: [ + { + type: CONFIDENTIAL_SAVED_OBJECT_TYPE, + id: 'alice_imported_doc', + overwrite: true, + meta: {}, + }, + ], + }) + ); + + await assertSavedObjectAccessControl( + es, + CONFIDENTIAL_SAVED_OBJECT_TYPE, + 'alice_imported_doc', + 'default', + { owner: username } + ); + }); + + it('does not attach an accessControl for public objects', async () => { + const { username, password } = USERS.SUPERUSER; + const { httpCode, expectResponse } = authorizedExpectedResponse; + + const savedObjectId = 'my_index_pattern'; + + const objectsToImport = [ + { + type: 'index-pattern', + id: savedObjectId, + attributes: { + name: 'my index pattern', + }, + references: [], + }, + ]; + + await supertest + .post(`/api/saved_objects/_import`) + .auth(username, password) + .attach('file', createPayload(objectsToImport), 'export.ndjson') + .expect(httpCode) + .then( + expectResponse({ + success: true, + successCount: 1, + successResults: [ + { + type: 'index-pattern', + id: savedObjectId, + meta: { + icon: 'indexPatternApp', + }, + }, + ], + }) + ); + + await assertSavedObjectAccessControl( + es, + 'index-pattern', + savedObjectId, + 'default', + undefined + ); + }); + + it('does not allow overwriting an object that collides with another users confidential object', async () => { + const { username, password } = USERS.SUPERUSER; + const { httpCode, expectResponse } = authorizedExpectedResponse; + + const savedObjectId = 'alice_doc_1'; + + await assertSavedObjectExists(es, CONFIDENTIAL_SAVED_OBJECT_TYPE, savedObjectId); + + const objectsToImport = [ + { + type: CONFIDENTIAL_SAVED_OBJECT_TYPE, + id: savedObjectId, + attributes: { + name: 'my CHANGED saved object', + }, + references: [], + }, + ]; + + await supertest + .post(`/api/saved_objects/_import`) + .auth(username, password) + .attach('file', createPayload(objectsToImport), 'export.ndjson') + .expect(httpCode) + .then( + expectResponse({ + success: true, + successCount: 1, + successResults: [ + // Unresolvable conflicts such as this result in a new object with a new ID. + { + type: CONFIDENTIAL_SAVED_OBJECT_TYPE, + id: savedObjectId, + meta: {}, + expectDestinationId: true, + }, + ], + }) + ); + + await assertSavedObjectAccessControl( + es, + CONFIDENTIAL_SAVED_OBJECT_TYPE, + savedObjectId, + 'default', + { + owner: USERS.ALICE.username, + } + ); + }); + }); +} diff --git a/x-pack/test/saved_object_access_control/security_and_spaces/apis/index.ts b/x-pack/test/saved_object_access_control/security_and_spaces/apis/index.ts new file mode 100644 index 00000000000000..f1d241eddba834 --- /dev/null +++ b/x-pack/test/saved_object_access_control/security_and_spaces/apis/index.ts @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FtrProviderContext } from '../../services'; +import { createUsersAndRoles } from '../../common/lib'; + +// eslint-disable-next-line import/no-default-export +export default function ({ getService, loadTestFile }: FtrProviderContext) { + describe('saved objects accessControl - security and spaces integration', function () { + this.tags('ciGroup10'); + + before(async () => { + await createUsersAndRoles(getService); + }); + + loadTestFile(require.resolve('./get')); + loadTestFile(require.resolve('./bulk_get')); + loadTestFile(require.resolve('./resolve')); + + loadTestFile(require.resolve('./create')); + loadTestFile(require.resolve('./bulk_create')); + + loadTestFile(require.resolve('./update')); + loadTestFile(require.resolve('./bulk_update')); + + loadTestFile(require.resolve('./delete')); + + loadTestFile(require.resolve('./find')); + + loadTestFile(require.resolve('./import')); + loadTestFile(require.resolve('./export')); + loadTestFile(require.resolve('./resolve_import_errors')); + }); +} diff --git a/x-pack/test/saved_object_access_control/security_and_spaces/apis/resolve.ts b/x-pack/test/saved_object_access_control/security_and_spaces/apis/resolve.ts new file mode 100644 index 00000000000000..776e6f11b82324 --- /dev/null +++ b/x-pack/test/saved_object_access_control/security_and_spaces/apis/resolve.ts @@ -0,0 +1,219 @@ +/* + * 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 { SavedObjectsResolveResponse, SavedObjectAccessControl } from 'kibana/server'; +import { + CONFIDENTIAL_SAVED_OBJECT_TYPE, + CONFIDENTIAL_MULTI_NAMESPACE_SAVED_OBJECT_TYPE, +} from '../../fixtures/confidential_plugin/server'; +import { USERS, ExpectedResponse, assertSavedObjectExists } from '../../common/lib'; +import { FtrProviderContext } from '../../services'; + +// eslint-disable-next-line import/no-default-export +export default function ({ getService }: FtrProviderContext) { + const esArchiver = getService('esArchiver'); + const supertest = getService('supertestWithoutAuth'); + const es = getService('es'); + + describe('GET /api/saved_objects/resolve/{type}/{id}', () => { + before(async () => { + await esArchiver.load( + 'x-pack/test/saved_object_access_control/fixtures/es_archiver/confidential_objects' + ); + }); + + after(async () => { + await esArchiver.unload( + 'x-pack/test/saved_object_access_control/fixtures/es_archiver/confidential_objects' + ); + }); + + const authorizedExpectedResponse: ExpectedResponse< + [ + { + savedObject: { type: string; id: string; accessControl: SavedObjectAccessControl }; + outcome: SavedObjectsResolveResponse['outcome']; + } + ] + > = { + httpCode: 200, + expectResponse: ({ savedObject, outcome }) => ({ body }) => { + expect(body.outcome).to.eql(outcome); + const { type, id, accessControl } = body.saved_object; + expect({ type, id, accessControl }).to.eql({ + type: savedObject.type, + id: savedObject.id, + accessControl: savedObject.accessControl, + }); + }, + }; + + const notFoundExpectedResponse: ExpectedResponse<[{ savedObjectId: string }]> = { + httpCode: 404, + expectResponse: ({ savedObjectId }) => ({ body }) => { + expect(body).to.eql({ + statusCode: 404, + error: 'Not Found', + message: `Saved object [${CONFIDENTIAL_SAVED_OBJECT_TYPE}/${savedObjectId}] not found`, + }); + }, + }; + + const unauthorizedExpectedResponse: ExpectedResponse<[{ type: string; id?: string }]> = { + httpCode: 403, + expectResponse: ({ type, id }) => ({ body }) => { + expect(body).to.eql({ + statusCode: 403, + error: 'Forbidden', + message: `Unable to get ${type}${id ? `:${id}` : ''}`, + }); + }, + }; + + it('returns 404 for confidential objects that do not exist', async () => { + const { username, password } = USERS.ALICE; + const { httpCode, expectResponse } = notFoundExpectedResponse; + const savedObjectId = 'not_found_object'; + await supertest + .get(`/api/saved_objects/resolve/${CONFIDENTIAL_SAVED_OBJECT_TYPE}/${savedObjectId}`) + .auth(username, password) + .expect(httpCode) + .then(expectResponse({ savedObjectId })); + }); + + it('returns 403 for confidential objects that belong to another user', async () => { + const { username, password } = USERS.ALICE; + const { httpCode, expectResponse } = unauthorizedExpectedResponse; + const savedObjectId = 'bob_doc_1'; + + await assertSavedObjectExists(es, CONFIDENTIAL_SAVED_OBJECT_TYPE, savedObjectId); + + await supertest + .get(`/api/saved_objects/resolve/${CONFIDENTIAL_SAVED_OBJECT_TYPE}/${savedObjectId}`) + .auth(username, password) + .expect(httpCode) + .then(expectResponse({ type: CONFIDENTIAL_SAVED_OBJECT_TYPE, id: savedObjectId })); + }); + + it('returns 404 for confidential objects that exist in another space', async () => { + const { username, password } = USERS.ALICE; + const { httpCode, expectResponse } = notFoundExpectedResponse; + const savedObjectId = 'alice_space_1_doc'; + + await assertSavedObjectExists(es, CONFIDENTIAL_SAVED_OBJECT_TYPE, savedObjectId, 'space_1'); + + await supertest + .get(`/api/saved_objects/resolve/${CONFIDENTIAL_SAVED_OBJECT_TYPE}/${savedObjectId}`) + .auth(username, password) + .expect(httpCode) + .then(expectResponse({ savedObjectId })); + }); + + it('returns 403 for users who cannot access confidential objects of this type', async () => { + const { username, password } = USERS.CHARLIE; + const { httpCode, expectResponse } = unauthorizedExpectedResponse; + const savedObjectId = 'charlie_doc_1'; + + await assertSavedObjectExists(es, CONFIDENTIAL_SAVED_OBJECT_TYPE, savedObjectId); + + await supertest + .get(`/api/saved_objects/resolve/${CONFIDENTIAL_SAVED_OBJECT_TYPE}/${savedObjectId}`) + .auth(username, password) + .expect(httpCode) + .then(expectResponse({ type: CONFIDENTIAL_SAVED_OBJECT_TYPE })); + }); + + it('returns 200 for confidential objects that belong to the current user (exact match)', async () => { + const { username, password } = USERS.ALICE; + const { httpCode, expectResponse } = authorizedExpectedResponse; + const savedObjectId = 'alice_doc_1'; + await supertest + .get(`/api/saved_objects/resolve/${CONFIDENTIAL_SAVED_OBJECT_TYPE}/${savedObjectId}`) + .auth(username, password) + .expect(httpCode) + .then( + expectResponse({ + savedObject: { + type: CONFIDENTIAL_SAVED_OBJECT_TYPE, + id: savedObjectId, + accessControl: { owner: username }, + }, + outcome: 'exactMatch', + }) + ); + }); + + it('returns 200 for confidential objects that belong to the current user (alias match)', async () => { + const { username, password } = USERS.ALICE; + const { httpCode, expectResponse } = authorizedExpectedResponse; + const savedObjectId = 'alice_alias-match'; + await supertest + .get( + `/s/space_1/api/saved_objects/resolve/${CONFIDENTIAL_MULTI_NAMESPACE_SAVED_OBJECT_TYPE}/${savedObjectId}` + ) + .auth(username, password) + .expect(httpCode) + .then( + expectResponse({ + savedObject: { + type: CONFIDENTIAL_MULTI_NAMESPACE_SAVED_OBJECT_TYPE, + id: 'alice_alias-match-newid', + accessControl: { owner: username }, + }, + outcome: 'aliasMatch', + }) + ); + }); + + it('allows superusers to access objects from other users (exact match)', async () => { + const { username, password } = USERS.SUPERUSER; + const { httpCode, expectResponse } = authorizedExpectedResponse; + const savedObjectId = 'alice_doc_1'; + + await assertSavedObjectExists(es, CONFIDENTIAL_SAVED_OBJECT_TYPE, savedObjectId); + + await supertest + .get(`/api/saved_objects/resolve/${CONFIDENTIAL_SAVED_OBJECT_TYPE}/${savedObjectId}`) + .auth(username, password) + .expect(httpCode) + .then( + expectResponse({ + savedObject: { + type: CONFIDENTIAL_SAVED_OBJECT_TYPE, + id: savedObjectId, + accessControl: { owner: USERS.ALICE.username }, + }, + outcome: 'exactMatch', + }) + ); + }); + + it('allows superusers to access objects from other users (alias match)', async () => { + const { username, password } = USERS.SUPERUSER; + const { httpCode, expectResponse } = authorizedExpectedResponse; + const savedObjectId = 'alice_alias-match'; + + await supertest + .get( + `/s/space_1/api/saved_objects/resolve/${CONFIDENTIAL_MULTI_NAMESPACE_SAVED_OBJECT_TYPE}/${savedObjectId}` + ) + .auth(username, password) + .expect(httpCode) + .then( + expectResponse({ + savedObject: { + type: CONFIDENTIAL_MULTI_NAMESPACE_SAVED_OBJECT_TYPE, + id: 'alice_alias-match-newid', + accessControl: { owner: USERS.ALICE.username }, + }, + outcome: 'aliasMatch', + }) + ); + }); + }); +} diff --git a/x-pack/test/saved_object_access_control/security_and_spaces/apis/resolve_import_errors.ts b/x-pack/test/saved_object_access_control/security_and_spaces/apis/resolve_import_errors.ts new file mode 100644 index 00000000000000..2d03b2d2568bd4 --- /dev/null +++ b/x-pack/test/saved_object_access_control/security_and_spaces/apis/resolve_import_errors.ts @@ -0,0 +1,411 @@ +/* + * 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 { + SavedObject, + SavedObjectsImportFailure, + SavedObjectsImportResponse, + SavedObjectsImportSuccess, +} from 'src/core/server'; +import { CONFIDENTIAL_SAVED_OBJECT_TYPE } from '../../fixtures/confidential_plugin/server'; +import { + USERS, + ExpectedResponse, + assertSavedObjectExists, + assertSavedObjectAccessControl, + assertSavedObjectMissing, +} from '../../common/lib'; +import { FtrProviderContext } from '../../services'; + +// eslint-disable-next-line import/no-default-export +export default function ({ getService }: FtrProviderContext) { + const es = getService('es'); + const esArchiver = getService('esArchiver'); + const supertest = getService('supertestWithoutAuth'); + + describe('POST /api/saved_objects/_resolve_import_errors', () => { + before(async () => { + await esArchiver.load( + 'x-pack/test/saved_object_access_control/fixtures/es_archiver/confidential_objects' + ); + }); + + after(async () => { + await esArchiver.unload( + 'x-pack/test/saved_object_access_control/fixtures/es_archiver/confidential_objects' + ); + }); + + const authorizedExpectedResponse: ExpectedResponse< + [ + { + success: boolean; + successCount: number; + errors?: SavedObjectsImportFailure[]; + successResults?: Array< + Omit & { expectDestinationId?: boolean } + >; + } + ] + > = { + httpCode: 200, + expectResponse: ({ success, successCount, errors, successResults }) => ({ body }) => { + expect(body.success).to.eql(success); + expect(body.successCount).to.eql(successCount); + if (errors) { + expect(body.errors).to.eql(errors); + } + if (successResults) { + expect(body.successResults.length).to.eql(successResults.length); + (body as SavedObjectsImportResponse).successResults!.forEach((result, index) => { + const expected = successResults[index]; + expect(result.id).to.eql(expected.id); + expect(result.type).to.eql(expected.type); + expect(result.overwrite).to.eql(expected.overwrite); + if (expected.expectDestinationId) { + expect(typeof result.destinationId).to.eql('string'); + } else { + expect(result.destinationId).to.eql(undefined); + } + }); + } + }, + }; + + const unauthorizedExpectedResponse: ExpectedResponse<[{ type: string; id?: string }]> = { + httpCode: 403, + expectResponse: ({ type, id }) => ({ body }) => { + expect(body).to.eql({ + statusCode: 403, + error: 'Forbidden', + message: `Unable to bulk_create ${type}${id ? ':' + id : ''}`, + }); + }, + }; + + const createPayload = (objectsToImport: Array>) => { + return Buffer.from(objectsToImport.map((obj) => JSON.stringify(obj)).join('\n'), 'utf8'); + }; + + it('returns 403 for users who cannot import confidential objects of this type', async () => { + const { username, password } = USERS.CHARLIE; + const { httpCode, expectResponse } = unauthorizedExpectedResponse; + + const savedObjectId = `charlie_doc_1`; + + await assertSavedObjectExists(es, CONFIDENTIAL_SAVED_OBJECT_TYPE, savedObjectId); + + const objectsToImport = [ + { + type: CONFIDENTIAL_SAVED_OBJECT_TYPE, + id: savedObjectId, + attributes: { + name: 'my imported object', + }, + references: [], + }, + ]; + + const retries = [ + { + type: CONFIDENTIAL_SAVED_OBJECT_TYPE, + id: savedObjectId, + overwrite: true, + }, + ]; + + await supertest + .post(`/api/saved_objects/_resolve_import_errors`) + .auth(username, password) + .field('retries', JSON.stringify(retries)) + .attach('file', createPayload(objectsToImport), 'export.ndjson') + .expect(httpCode) + .then( + expectResponse({ + type: CONFIDENTIAL_SAVED_OBJECT_TYPE, + }) + ); + }); + + it(`allows confidential object conflicts to be resolved via overwrite by the conflict's owner`, async () => { + const { username, password } = USERS.ALICE; + const { httpCode, expectResponse } = authorizedExpectedResponse; + + const savedObjectId = 'alice_doc_1'; + + await assertSavedObjectExists(es, CONFIDENTIAL_SAVED_OBJECT_TYPE, savedObjectId); + + const objectsToImport = [ + { + type: CONFIDENTIAL_SAVED_OBJECT_TYPE, + id: savedObjectId, + attributes: { + name: 'my imported object', + }, + references: [], + }, + ]; + + const retries = [ + { + type: CONFIDENTIAL_SAVED_OBJECT_TYPE, + id: savedObjectId, + overwrite: true, + }, + ]; + + await supertest + .post(`/api/saved_objects/_resolve_import_errors`) + .auth(username, password) + .field('retries', JSON.stringify(retries)) + .attach('file', createPayload(objectsToImport), 'export.ndjson') + .expect(httpCode) + .then( + expectResponse({ + success: true, + successCount: 1, + successResults: [ + { + type: CONFIDENTIAL_SAVED_OBJECT_TYPE, + id: savedObjectId, + overwrite: true, + meta: {}, + }, + ], + }) + ); + + await assertSavedObjectAccessControl( + es, + CONFIDENTIAL_SAVED_OBJECT_TYPE, + savedObjectId, + 'default', + { + owner: username, + } + ); + }); + + it(`allows confidential object conflicts to be resolved via "new copy" by the conflict's owner`, async () => { + const { username, password } = USERS.ALICE; + const { httpCode, expectResponse } = authorizedExpectedResponse; + + const savedObjectId = 'alice_doc_1'; + const newSavedObjectId = 'new_copy_alice_doc_1'; + + await assertSavedObjectExists(es, CONFIDENTIAL_SAVED_OBJECT_TYPE, savedObjectId); + await assertSavedObjectMissing(es, CONFIDENTIAL_SAVED_OBJECT_TYPE, newSavedObjectId); + + const objectsToImport = [ + { + type: CONFIDENTIAL_SAVED_OBJECT_TYPE, + id: savedObjectId, + attributes: { + name: 'my imported object', + }, + references: [], + }, + ]; + + const retries = [ + { + type: CONFIDENTIAL_SAVED_OBJECT_TYPE, + id: savedObjectId, + overwrite: false, + destinationId: newSavedObjectId, + }, + ]; + + await supertest + .post(`/api/saved_objects/_resolve_import_errors?createNewCopies=true`) + .auth(username, password) + .field('retries', JSON.stringify(retries)) + .attach('file', createPayload(objectsToImport), 'export.ndjson') + .expect(httpCode) + .then( + expectResponse({ + success: true, + successCount: 1, + successResults: [ + { + type: CONFIDENTIAL_SAVED_OBJECT_TYPE, + id: savedObjectId, + expectDestinationId: true, + meta: {}, + }, + ], + }) + ); + + await assertSavedObjectAccessControl( + es, + CONFIDENTIAL_SAVED_OBJECT_TYPE, + savedObjectId, + 'default', + { + owner: username, + } + ); + await assertSavedObjectAccessControl( + es, + CONFIDENTIAL_SAVED_OBJECT_TYPE, + newSavedObjectId, + 'default', + { + owner: username, + } + ); + }); + + it(`allows confidential object conflicts to be resolved via "new copy" by other users`, async () => { + const { username, password } = USERS.SUPERUSER; + const { httpCode, expectResponse } = authorizedExpectedResponse; + + const savedObjectId = 'alice_doc_1'; + const newSavedObjectId = 'new_copy_superuser_doc_1'; + + await assertSavedObjectExists(es, CONFIDENTIAL_SAVED_OBJECT_TYPE, savedObjectId); + await assertSavedObjectMissing(es, CONFIDENTIAL_SAVED_OBJECT_TYPE, newSavedObjectId); + + const objectsToImport = [ + { + type: CONFIDENTIAL_SAVED_OBJECT_TYPE, + id: savedObjectId, + attributes: { + name: 'my imported object', + }, + references: [], + }, + ]; + + const retries = [ + { + type: CONFIDENTIAL_SAVED_OBJECT_TYPE, + id: savedObjectId, + overwrite: false, + destinationId: newSavedObjectId, + }, + ]; + + await supertest + .post(`/api/saved_objects/_resolve_import_errors?createNewCopies=true`) + .auth(username, password) + .field('retries', JSON.stringify(retries)) + .attach('file', createPayload(objectsToImport), 'export.ndjson') + .expect(httpCode) + .then( + expectResponse({ + success: true, + successCount: 1, + successResults: [ + { + type: CONFIDENTIAL_SAVED_OBJECT_TYPE, + id: savedObjectId, + expectDestinationId: true, + meta: {}, + }, + ], + }) + ); + + // existing object should have the old ACL + await assertSavedObjectAccessControl( + es, + CONFIDENTIAL_SAVED_OBJECT_TYPE, + savedObjectId, + 'default', + { + owner: USERS.ALICE.username, + } + ); + // new object should belong to the importer + await assertSavedObjectAccessControl( + es, + CONFIDENTIAL_SAVED_OBJECT_TYPE, + newSavedObjectId, + 'default', + { + owner: username, + } + ); + }); + + it(`does not allow the destinationId to overwrite a confidential object that belongs to another user`, async () => { + const { username, password } = USERS.SUPERUSER; + const { httpCode, expectResponse } = authorizedExpectedResponse; + + const savedObjectId = 'alice_doc_1'; + const newSavedObjectId = 'charlie_doc_1'; + + await assertSavedObjectExists(es, CONFIDENTIAL_SAVED_OBJECT_TYPE, savedObjectId); + await assertSavedObjectExists(es, CONFIDENTIAL_SAVED_OBJECT_TYPE, newSavedObjectId); + + const objectsToImport = [ + { + type: CONFIDENTIAL_SAVED_OBJECT_TYPE, + id: savedObjectId, + attributes: { + name: 'my imported object', + }, + references: [], + }, + ]; + + const retries = [ + { + type: CONFIDENTIAL_SAVED_OBJECT_TYPE, + id: savedObjectId, + overwrite: false, + destinationId: newSavedObjectId, + }, + ]; + + await supertest + .post(`/api/saved_objects/_resolve_import_errors?createNewCopies=true`) + .auth(username, password) + .field('retries', JSON.stringify(retries)) + .attach('file', createPayload(objectsToImport), 'export.ndjson') + .expect(httpCode) + .then( + expectResponse({ + success: true, + successCount: 1, + successResults: [ + { + type: CONFIDENTIAL_SAVED_OBJECT_TYPE, + id: savedObjectId, + meta: {}, + expectDestinationId: true, + }, + ], + }) + ); + + // existing object should have the old ACL + await assertSavedObjectAccessControl( + es, + CONFIDENTIAL_SAVED_OBJECT_TYPE, + savedObjectId, + 'default', + { + owner: USERS.ALICE.username, + } + ); + // targeted existing object should also have the old ACL + await assertSavedObjectAccessControl( + es, + CONFIDENTIAL_SAVED_OBJECT_TYPE, + newSavedObjectId, + 'default', + { + owner: USERS.CHARLIE.username, + } + ); + }); + }); +} diff --git a/x-pack/test/saved_object_access_control/security_and_spaces/apis/update.ts b/x-pack/test/saved_object_access_control/security_and_spaces/apis/update.ts new file mode 100644 index 00000000000000..ac13cf063a875b --- /dev/null +++ b/x-pack/test/saved_object_access_control/security_and_spaces/apis/update.ts @@ -0,0 +1,166 @@ +/* + * 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 { CONFIDENTIAL_SAVED_OBJECT_TYPE } from '../../fixtures/confidential_plugin/server'; +import { USERS, ExpectedResponse, assertSavedObjectExists } from '../../common/lib'; +import { FtrProviderContext } from '../../services'; + +// eslint-disable-next-line import/no-default-export +export default function ({ getService }: FtrProviderContext) { + const es = getService('es'); + const esArchiver = getService('esArchiver'); + const supertest = getService('supertestWithoutAuth'); + + describe('PUT /api/saved_objects/{type}/{id}', () => { + before(async () => { + await esArchiver.load( + 'x-pack/test/saved_object_access_control/fixtures/es_archiver/confidential_objects' + ); + }); + + after(async () => { + await esArchiver.unload( + 'x-pack/test/saved_object_access_control/fixtures/es_archiver/confidential_objects' + ); + }); + + const authorizedExpectedResponse: ExpectedResponse< + [{ owner?: string; attributes?: Record; type: string }] + > = { + httpCode: 200, + expectResponse: ({ owner, type: expectedType, attributes: expectedAttributes }) => ({ + body, + }) => { + const { accessControl, type, attributes, error } = body; + const requiresAccessControl = expectedType === CONFIDENTIAL_SAVED_OBJECT_TYPE; + + const expectedAccessControl = requiresAccessControl ? { owner } : undefined; + + expect(error).to.eql(undefined); + + expect({ accessControl, type, attributes }).to.eql({ + accessControl: expectedAccessControl, + type: expectedType, + attributes: expectedAttributes, + }); + }, + }; + + const unauthorizedExpectedResponse: ExpectedResponse<[{ type: string; id?: string }]> = { + httpCode: 403, + expectResponse: ({ type, id }) => ({ body }) => { + expect(body).to.eql({ + statusCode: 403, + error: 'Forbidden', + message: `Unable to update ${type}${id ? ':' + id : ''}`, + }); + }, + }; + + it('returns 403 for users who cannot update confidential objects of this type', async () => { + const { username, password } = USERS.CHARLIE; + const { httpCode, expectResponse } = unauthorizedExpectedResponse; + + await supertest + .put(`/api/saved_objects/${CONFIDENTIAL_SAVED_OBJECT_TYPE}/charlie_doc_1`) + .auth(username, password) + .send({ + attributes: { name: 'updated' }, + }) + .expect(httpCode) + .then(expectResponse({ type: CONFIDENTIAL_SAVED_OBJECT_TYPE })); + }); + + it('allows confidential objects to be updated by their owner, and maintains an appropriate accessControl', async () => { + const { username, password } = USERS.ALICE; + const { httpCode, expectResponse } = authorizedExpectedResponse; + + await assertSavedObjectExists(es, CONFIDENTIAL_SAVED_OBJECT_TYPE, 'alice_doc_1'); + + const name = 'updated test'; + + await supertest + .put(`/api/saved_objects/${CONFIDENTIAL_SAVED_OBJECT_TYPE}/alice_doc_1`) + .auth(username, password) + .send({ + attributes: { name }, + }) + .expect(httpCode) + .then( + expectResponse({ + attributes: { name }, + type: CONFIDENTIAL_SAVED_OBJECT_TYPE, + owner: username, + }) + ); + }); + + it('does not attach an accessControl for public objects', async () => { + const { username, password } = USERS.SUPERUSER; + const { httpCode, expectResponse } = authorizedExpectedResponse; + + await supertest + .put(`/api/saved_objects/index-pattern/index_pattern_1`) + .auth(username, password) + .send({ + attributes: { title: 'some index pattern' }, + }) + .expect(httpCode) + .then( + expectResponse({ + type: 'index-pattern', + attributes: { title: 'some index pattern' }, + }) + ); + }); + + it('does not allow updating an object that does not belong to the current user', async () => { + const { username, password } = USERS.ALICE; + const { httpCode, expectResponse } = unauthorizedExpectedResponse; + + const savedObjectId = 'bob_doc_1'; + + await assertSavedObjectExists(es, CONFIDENTIAL_SAVED_OBJECT_TYPE, savedObjectId); + + await supertest + .put(`/api/saved_objects/${CONFIDENTIAL_SAVED_OBJECT_TYPE}/${savedObjectId}`) + .auth(username, password) + .send({ + attributes: { name: 'hack attempt' }, + }) + .expect(httpCode) + .then(expectResponse({ type: CONFIDENTIAL_SAVED_OBJECT_TYPE, id: savedObjectId })); + }); + + [USERS.KIBANA_ADMIN, USERS.SUPERUSER].forEach((user) => { + it(`allows ${user.description} to update objects that belong to other users, while maintaining the original access control`, async () => { + const { username, password } = user; + const { httpCode, expectResponse } = authorizedExpectedResponse; + + const savedObjectId = 'bob_doc_1'; + + await assertSavedObjectExists(es, CONFIDENTIAL_SAVED_OBJECT_TYPE, savedObjectId); + + await supertest + .put(`/api/saved_objects/${CONFIDENTIAL_SAVED_OBJECT_TYPE}/${savedObjectId}`) + .auth(username, password) + .send({ + attributes: { name: 'update' }, + }) + .expect(httpCode) + .then( + expectResponse({ + attributes: { name: 'update' }, + type: CONFIDENTIAL_SAVED_OBJECT_TYPE, + owner: USERS.BOB.username, + }) + ); + }); + }); + }); +} diff --git a/x-pack/test/saved_object_access_control/services.ts b/x-pack/test/saved_object_access_control/services.ts new file mode 100644 index 00000000000000..743e9992caac96 --- /dev/null +++ b/x-pack/test/saved_object_access_control/services.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { GenericFtrProviderContext } from '@kbn/test'; +import { services as apiIntegrationServices } from '../api_integration/services'; + +export const services = { + ...apiIntegrationServices, +}; + +export type FtrProviderContext = GenericFtrProviderContext;