diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index dea2c12756b0891..f456b82c2b203a7 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -235,6 +235,7 @@ x-pack/plugins/telemetry_collection_xpack/schema/xpack_plugins.json @elastic/kib /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_acl/ @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.savedobject.acl.md b/docs/development/core/public/kibana-plugin-core-public.savedobject.acl.md new file mode 100644 index 000000000000000..48feb3b0ad000e2 --- /dev/null +++ b/docs/development/core/public/kibana-plugin-core-public.savedobject.acl.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-public](./kibana-plugin-core-public.md) > [SavedObject](./kibana-plugin-core-public.savedobject.md) > [acl](./kibana-plugin-core-public.savedobject.acl.md) + +## SavedObject.acl property + +Signature: + +```typescript +acl?: SavedObjectACL; +``` 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 9404927f94957e5..809a8fa0b05f56a 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 | | --- | --- | --- | +| [acl](./kibana-plugin-core-public.savedobject.acl.md) | SavedObjectACL | | | [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/server/kibana-plugin-core-server.md b/docs/development/core/server/kibana-plugin-core-server.md index 82f4a285409c951..6c7e43d44fb39b0 100644 --- a/docs/development/core/server/kibana-plugin-core-server.md +++ b/docs/development/core/server/kibana-plugin-core-server.md @@ -136,6 +136,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) | | +| [SavedObjectACL](./kibana-plugin-core-server.savedobjectacl.md) | The "Access Control List" 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) | @@ -151,6 +152,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. | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobject.acl.md b/docs/development/core/server/kibana-plugin-core-server.savedobject.acl.md new file mode 100644 index 000000000000000..c170c5c564b0bec --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobject.acl.md @@ -0,0 +1,11 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObject](./kibana-plugin-core-server.savedobject.md) > [acl](./kibana-plugin-core-server.savedobject.acl.md) + +## SavedObject.acl property + +Signature: + +```typescript +acl?: SavedObjectACL; +``` 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 07172487e6fde25..7d51f0941a812aa 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 | | --- | --- | --- | +| [acl](./kibana-plugin-core-server.savedobject.acl.md) | SavedObjectACL | | | [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.savedobjectacl.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectacl.md new file mode 100644 index 000000000000000..44dc8ebf0a59e56 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectacl.md @@ -0,0 +1,20 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectACL](./kibana-plugin-core-server.savedobjectacl.md) + +## SavedObjectACL interface + +The "Access Control List" describing which users should be authorized to access this SavedObject. + +Signature: + +```typescript +export interface SavedObjectACL +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [owner](./kibana-plugin-core-server.savedobjectacl.owner.md) | string | The owner of this SavedObject. | + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectacl.owner.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectacl.owner.md new file mode 100644 index 000000000000000..66deae37df82636 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectacl.owner.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectACL](./kibana-plugin-core-server.savedobjectacl.md) > [owner](./kibana-plugin-core-server.savedobjectacl.owner.md) + +## SavedObjectACL.owner property + +The owner of this SavedObject. + +Signature: + +```typescript +owner: string; +``` diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsbulkcreateobject.acl.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsbulkcreateobject.acl.md new file mode 100644 index 000000000000000..baa1ccae9b3b986 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsbulkcreateobject.acl.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsBulkCreateObject](./kibana-plugin-core-server.savedobjectsbulkcreateobject.md) > [acl](./kibana-plugin-core-server.savedobjectsbulkcreateobject.acl.md) + +## SavedObjectsBulkCreateObject.acl property + +The [acl](./kibana-plugin-core-server.savedobjectacl.md) to associate with this saved object. + +Signature: + +```typescript +acl?: SavedObjectACL; +``` 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 6fc01212a2e41a4..472f33f0c0ed7b5 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 | | --- | --- | --- | +| [acl](./kibana-plugin-core-server.savedobjectsbulkcreateobject.acl.md) | SavedObjectACL | The [acl](./kibana-plugin-core-server.savedobjectacl.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.savedobjectscheckconflictsoptions.acl.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectscheckconflictsoptions.acl.md new file mode 100644 index 000000000000000..b64de94645c23e2 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectscheckconflictsoptions.acl.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsCheckConflictsOptions](./kibana-plugin-core-server.savedobjectscheckconflictsoptions.md) > [acl](./kibana-plugin-core-server.savedobjectscheckconflictsoptions.acl.md) + +## SavedObjectsCheckConflictsOptions.acl property + +An [acl](./kibana-plugin-core-server.savedobjectacl.md) which should be compatible with conflicting objects. + +Signature: + +```typescript +acl?: SavedObjectACL; +``` 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 000000000000000..f4770a9352e4eb3 --- /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 | +| --- | --- | --- | +| [acl](./kibana-plugin-core-server.savedobjectscheckconflictsoptions.acl.md) | SavedObjectACL | An [acl](./kibana-plugin-core-server.savedobjectacl.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 5cffb0c498b0b53..dba5404171d1857 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.acl.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectscreateoptions.acl.md new file mode 100644 index 000000000000000..fe556b051688924 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectscreateoptions.acl.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsCreateOptions](./kibana-plugin-core-server.savedobjectscreateoptions.md) > [acl](./kibana-plugin-core-server.savedobjectscreateoptions.acl.md) + +## SavedObjectsCreateOptions.acl property + +The [acl](./kibana-plugin-core-server.savedobjectacl.md) to associate with this saved object. + +Signature: + +```typescript +acl?: SavedObjectACL; +``` 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 1805f389d4e7f3e..92190ee165d5e90 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 | | --- | --- | --- | +| [acl](./kibana-plugin-core-server.savedobjectscreateoptions.acl.md) | SavedObjectACL | The [acl](./kibana-plugin-core-server.savedobjectacl.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).Note: this can only be used for multi-namespace object types. | diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectserrorhelpers.createincompatibleaclerror.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectserrorhelpers.createincompatibleaclerror.md new file mode 100644 index 000000000000000..716ab34b37ae1f9 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectserrorhelpers.createincompatibleaclerror.md @@ -0,0 +1,23 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsErrorHelpers](./kibana-plugin-core-server.savedobjectserrorhelpers.md) > [createIncompatibleACLError](./kibana-plugin-core-server.savedobjectserrorhelpers.createincompatibleaclerror.md) + +## SavedObjectsErrorHelpers.createIncompatibleACLError() method + +Signature: + +```typescript +static createIncompatibleACLError(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 9b69012ed5f1234..69d24543be91a5b 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 | | +| [createIncompatibleACLError(type, id)](./kibana-plugin-core-server.savedobjectserrorhelpers.createincompatibleaclerror.md) | static | | | [createInvalidVersionError(versionInput)](./kibana-plugin-core-server.savedobjectserrorhelpers.createinvalidversionerror.md) | static | | | [createTooManyRequestsError(type, id)](./kibana-plugin-core-server.savedobjectserrorhelpers.createtoomanyrequestserror.md) | static | | | [createUnsupportedTypeError(type)](./kibana-plugin-core-server.savedobjectserrorhelpers.createunsupportedtypeerror.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 17daf3ab1f0423f..cda7685a62abb9f 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 6e44bd704d6a7d8..aee3e4b408167bb 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.classification.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectstype.classification.md new file mode 100644 index 000000000000000..a6d4eb38bf8380c --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectstype.classification.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsType](./kibana-plugin-core-server.savedobjectstype.md) > [classification](./kibana-plugin-core-server.savedobjectstype.classification.md) + +## SavedObjectsType.classification property + +The for the type. + +Signature: + +```typescript +classification?: SavedObjectsClassification; +``` 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 eacad53be39fe0c..13bed42028c2289 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 | | --- | --- | --- | +| [classification](./kibana-plugin-core-server.savedobjectstype.classification.md) | SavedObjectsClassification | 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 multi-namespace objects 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)Example of a single-namespace type in 7.10: ```ts diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjecttyperegistry.isconfidential.md b/docs/development/core/server/kibana-plugin-core-server.savedobjecttyperegistry.isconfidential.md new file mode 100644 index 000000000000000..55c49d63963dd6c --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjecttyperegistry.isconfidential.md @@ -0,0 +1,24 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectTypeRegistry](./kibana-plugin-core-server.savedobjecttyperegistry.md) > [isConfidential](./kibana-plugin-core-server.savedobjecttyperegistry.isconfidential.md) + +## SavedObjectTypeRegistry.isConfidential() method + +Returns `true` if the given type is marked as `confidential`, and `false` otherwise. + +Signature: + +```typescript +isConfidential(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 55ad7ca137de0ad..cde418afb45d407 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjecttyperegistry.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjecttyperegistry.md @@ -21,6 +21,7 @@ export declare class SavedObjectTypeRegistry | [getIndex(type)](./kibana-plugin-core-server.savedobjecttyperegistry.getindex.md) | | Returns the indexPattern property for given type, or undefined if the type is not registered. | | [getType(type)](./kibana-plugin-core-server.savedobjecttyperegistry.gettype.md) | | Return the [type](./kibana-plugin-core-server.savedobjectstype.md) definition for given type name. | | [getVisibleTypes()](./kibana-plugin-core-server.savedobjecttyperegistry.getvisibletypes.md) | | Returns all visible [types](./kibana-plugin-core-server.savedobjectstype.md).A visible type is a type that doesn't explicitly define hidden=true during registration. | +| [isConfidential(type)](./kibana-plugin-core-server.savedobjecttyperegistry.isconfidential.md) | | Returns true if the given type is marked as confidential, and false otherwise. | | [isHidden(type)](./kibana-plugin-core-server.savedobjecttyperegistry.ishidden.md) | | Returns the hidden property for given type, or false if the type is not registered. | | [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); resolves to false if the type is not registered | diff --git a/src/core/public/public.api.md b/src/core/public/public.api.md index 0097127924a5cd5..05fd6aa669b9cf4 100644 --- a/src/core/public/public.api.md +++ b/src/core/public/public.api.md @@ -37,6 +37,7 @@ import React from 'react'; import { RecursiveReadonly } from '@kbn/utility-types'; import { Request } from '@hapi/hapi'; import * as Rx from 'rxjs'; +import { SavedObjectACL as SavedObjectACL_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'; @@ -1038,6 +1039,10 @@ export type PublicUiSettingsParams = Omit; // // @public (undocumented) export interface SavedObject { + // Warning: (ae-forgotten-export) The symbol "SavedObjectACL" needs to be exported by the entry point index.d.ts + // + // (undocumented) + acl?: SavedObjectACL; attributes: T; coreMigrationVersion?: string; // (undocumented) diff --git a/src/core/server/index.ts b/src/core/server/index.ts index af6d511a58779fd..50d0d226364a7a8 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -245,6 +245,7 @@ export { } from './plugins'; export { + SavedObjectACL, SavedObjectsBulkCreateObject, SavedObjectsBulkGetObject, SavedObjectsBulkUpdateObject, @@ -252,6 +253,7 @@ export { SavedObjectsBulkResponse, SavedObjectsBulkUpdateResponse, SavedObjectsCheckConflictsObject, + SavedObjectsCheckConflictsOptions, SavedObjectsCheckConflictsResponse, SavedObjectsClient, SavedObjectsClientProviderOptions, 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 bd3e60fc1a14071..15655f806d66584 100644 --- a/src/core/server/saved_objects/export/saved_objects_exporter.ts +++ b/src/core/server/saved_objects/export/saved_objects_exporter.ts @@ -130,7 +130,7 @@ export class SavedObjectsExporter { // redact attributes that should not be exported const redactedObjects = exportedObjects.map>( - ({ namespaces, ...object }) => object + ({ namespaces, acl, ...object }) => object ); const exportDetails: SavedObjectsExportResultDetails = { 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 0446ba3f692a94b..a1e40d499a5de41 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 9cf400a65030fb6..eb40f6a3726a0f4 100644 --- a/src/core/server/saved_objects/index.ts +++ b/src/core/server/saved_objects/index.ts @@ -84,6 +84,7 @@ export { } from './migrations'; export { + SavedObjectACL, 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 9ee998118bde662..ab12890fe8079da 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", + "acl": "f759893589b96eeddcb456de15abb5f4", "bbb": "18c78c995965207ed3f6e7fc5c6e55fe", "coreMigrationVersion": "2f4316de49999235636386fe51dc06c1", "migrationVersion": "4a1746014a75ade3a714e1db5763276f", @@ -21,6 +22,15 @@ Object { "aaa": Object { "type": "text", }, + "acl": 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 { + "acl": "f759893589b96eeddcb456de15abb5f4", "coreMigrationVersion": "2f4316de49999235636386fe51dc06c1", "firstType": "635418ab953d81d93f1190b70a8d3f57", "migrationVersion": "4a1746014a75ade3a714e1db5763276f", @@ -83,6 +94,15 @@ Object { }, "dynamic": "strict", "properties": Object { + "acl": 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 83e7b1549bc9704..da530ebaa0d0568 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', }, + acl: { + 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 741f715ba6ebe66..d39c6f3f0302708 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 @@ -290,7 +290,7 @@ describe('DocumentMigrator', () => { id: 'me', type: 'user', attributes: { name: 'Tyler' }, - acl: 'anyone', + acl: { owner: 'anyone' }, migrationVersion: {}, } as SavedObjectUnsanitizedDoc); expect(actual).toEqual({ 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 a8abc75114a967e..d438b5b51089e3d 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 @@ -59,6 +59,7 @@ describe('IndexMigrator', () => { namespace: '2f4316de49999235636386fe51dc06c1', namespaces: '2f4316de49999235636386fe51dc06c1', originId: '2f4316de49999235636386fe51dc06c1', + acl: 'f759893589b96eeddcb456de15abb5f4', references: '7997cf5a56cc02bdc9c93361bde732b0', coreMigrationVersion: '2f4316de49999235636386fe51dc06c1', type: '2f4316de49999235636386fe51dc06c1', @@ -73,6 +74,15 @@ describe('IndexMigrator', () => { originId: { type: 'keyword' }, type: { type: 'keyword' }, updated_at: { type: 'date' }, + acl: { + dynamic: 'strict', + properties: { + owner: { + type: 'keyword', + }, + }, + type: 'object', + }, references: { type: 'nested', properties: { @@ -184,6 +194,7 @@ describe('IndexMigrator', () => { originId: '2f4316de49999235636386fe51dc06c1', references: '7997cf5a56cc02bdc9c93361bde732b0', coreMigrationVersion: '2f4316de49999235636386fe51dc06c1', + acl: 'f759893589b96eeddcb456de15abb5f4', type: '2f4316de49999235636386fe51dc06c1', updated_at: '00da57df13e94e9d98437d13ace4bfe0', }, @@ -197,6 +208,15 @@ describe('IndexMigrator', () => { originId: { type: 'keyword' }, type: { type: 'keyword' }, updated_at: { type: 'date' }, + acl: { + dynamic: 'strict', + properties: { + owner: { + type: 'keyword', + }, + }, + type: 'object', + }, references: { type: 'nested', properties: { @@ -245,6 +265,7 @@ describe('IndexMigrator', () => { namespace: '2f4316de49999235636386fe51dc06c1', namespaces: '2f4316de49999235636386fe51dc06c1', originId: '2f4316de49999235636386fe51dc06c1', + acl: 'f759893589b96eeddcb456de15abb5f4', references: '7997cf5a56cc02bdc9c93361bde732b0', coreMigrationVersion: '2f4316de49999235636386fe51dc06c1', type: '2f4316de49999235636386fe51dc06c1', @@ -260,6 +281,15 @@ describe('IndexMigrator', () => { originId: { type: 'keyword' }, type: { type: 'keyword' }, updated_at: { type: 'date' }, + acl: { + 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 32c2536ab029687..a59ec0dcf228cf2 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 { + "acl": "f759893589b96eeddcb456de15abb5f4", "amap": "510f1f0adb69830cf8a1c5ce2923ed82", "bmap": "510f1f0adb69830cf8a1c5ce2923ed82", "coreMigrationVersion": "2f4316de49999235636386fe51dc06c1", @@ -18,6 +19,15 @@ Object { }, "dynamic": "strict", "properties": Object { + "acl": 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 bd347c4824764ae..984acf6d81a0254 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 @@ -23,6 +23,7 @@ const createRegistryMock = (): jest.Mocked< isHidden: jest.fn(), getIndex: jest.fn(), isImportableAndExportable: jest.fn(), + isConfidential: jest.fn(), }; mock.getVisibleTypes.mockReturnValue([]); @@ -37,6 +38,7 @@ const createRegistryMock = (): jest.Mocked< ); mock.isMultiNamespace.mockImplementation((type: string) => type === 'shared'); mock.isImportableAndExportable.mockReturnValue(true); + mock.isConfidential.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 0186af6e7628ded..27c49f8edfd36b5 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 @@ -304,6 +304,22 @@ describe('SavedObjectTypeRegistry', () => { }); }); + describe('#isConfidential', () => { + it('returns correct value for the type', () => { + registry.registerType(createType({ name: 'typeA', classification: 'confidential' })); + registry.registerType(createType({ name: 'typeB', classification: 'public' })); + + expect(registry.isConfidential('typeA')).toEqual(true); + expect(registry.isConfidential('typeB')).toEqual(false); + }); + it('returns false when the type is not registered', () => { + registry.registerType(createType({ name: 'typeA', classification: 'confidential' })); + registry.registerType(createType({ name: 'typeB', classification: 'public' })); + + expect(registry.isConfidential('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 d2cee700bf66d46..adc54782e286e3c 100644 --- a/src/core/server/saved_objects/saved_objects_type_registry.ts +++ b/src/core/server/saved_objects/saved_objects_type_registry.ts @@ -116,6 +116,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 `confidential`, and `false` otherwise. + */ + public isConfidential(type: string) { + return this.types.get(type)?.classification === 'confidential'; + } } 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 b09fb1ab30c79ae..d767645a75260c3 100644 --- a/src/core/server/saved_objects/serialization/serializer.test.ts +++ b/src/core/server/saved_objects/serialization/serializer.test.ts @@ -132,6 +132,27 @@ describe('#rawToSavedObject', () => { expect(expected).toEqual(actual); }); + test('if specified it copies the _source.acl property to acl', () => { + const actual = singleNamespaceSerializer.rawToSavedObject({ + _id: 'foo:bar', + _source: { + type: 'foo', + acl: { owner: 'alice' }, + }, + }); + expect(actual).toHaveProperty('acl', { owner: 'alice' }); + }); + + test(`if _source.acl is unspecified it doesn't set acl`, () => { + const actual = singleNamespaceSerializer.rawToSavedObject({ + _id: 'foo:bar', + _source: { + type: 'foo', + }, + }); + expect(actual).not.toHaveProperty('acl'); + }); + 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 4e9c3b6be03cf34..591bd0b60b05cc8 100644 --- a/src/core/server/saved_objects/serialization/serializer.ts +++ b/src/core/server/saved_objects/serialization/serializer.ts @@ -68,6 +68,7 @@ export class SavedObjectsSerializer { migrationVersion, references, coreMigrationVersion, + acl, } = _source; const version = @@ -85,6 +86,7 @@ export class SavedObjectsSerializer { ...(includeNamespace && { namespace }), ...(includeNamespaces && { namespaces }), ...(originId && { originId }), + ...(acl && { acl }), attributes: _source[type], references: references || [], ...(migrationVersion && { migrationVersion }), @@ -113,6 +115,7 @@ export class SavedObjectsSerializer { version, references, coreMigrationVersion, + acl, } = savedObj; const source = { [type]: attributes, @@ -121,6 +124,7 @@ export class SavedObjectsSerializer { ...(namespace && this.registry.isSingleNamespace(type) && { namespace }), ...(namespaces && this.registry.isMultiNamespace(type) && { namespaces }), ...(originId && { originId }), + ...(acl && { acl }), ...(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 5de168a08f1dba8..d6a8ceabb25d018 100644 --- a/src/core/server/saved_objects/serialization/types.ts +++ b/src/core/server/saved_objects/serialization/types.ts @@ -6,6 +6,7 @@ * Public License, v 1. */ +import { SavedObjectACL } from 'src/core/types'; import { SavedObjectsMigrationVersion, SavedObjectReference } from '../types'; /** @@ -29,6 +30,7 @@ export interface SavedObjectsRawDocSource { updated_at?: string; references?: SavedObjectReference[]; originId?: string; + acl?: SavedObjectACL; [typeMapping: string]: any; } @@ -47,6 +49,7 @@ interface SavedObjectDoc { version?: string; updated_at?: string; originId?: string; + acl?: SavedObjectACL; } interface Referencable { 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 1ac83d6047fbb57..a13444959dadff9 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 ACL error', () => { + describe('createIncompatibleACLError', () => { + it('makes the error identifiable as a Conflict error', () => { + const error = SavedObjectsErrorHelpers.createIncompatibleACLError('type', 'id'); + expect(SavedObjectsErrorHelpers.isConflictError(error)).toBe(true); + }); + + it('adds boom properties', () => { + const error = SavedObjectsErrorHelpers.createIncompatibleACLError('type', 'id'); + expect(error).toHaveProperty('isBoom', true); + }); + + describe('error.output', () => { + it('prefixes message with reason', () => { + const error = SavedObjectsErrorHelpers.createIncompatibleACLError('type', 'id'); + expect(error.output.payload).toMatchInlineSnapshot(` + Object { + "error": "Conflict", + "message": "Saved object [type/id] conflict: incompatible ACL", + "statusCode": 409, + } + `); + }); + + it('sets statusCode to 409', () => { + const error = SavedObjectsErrorHelpers.createIncompatibleACLError('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 f216e72efbcf822..b1bd6b3a636e96e 100644 --- a/src/core/server/saved_objects/service/lib/errors.ts +++ b/src/core/server/saved_objects/service/lib/errors.ts @@ -150,6 +150,12 @@ export class SavedObjectsErrorHelpers { ); } + public static createIncompatibleACLError(type: string, id: string) { + return SavedObjectsErrorHelpers.decorateConflictError( + Boom.conflict(`Saved object [${type}/${id}] conflict: incompatible ACL`) + ); + } + 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 421570a481297e9..0db560cb571b6c3 100644 --- a/src/core/server/saved_objects/service/lib/filter_utils.ts +++ b/src/core/server/saved_objects/service/lib/filter_utils.ts @@ -54,7 +54,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( @@ -163,11 +164,13 @@ export const isSavedObjectAttr = (key: string | null | undefined, indexMapping: 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 (fieldDefined(indexMapping, attributeKey)) { + return true; + } } + return false; }; export const hasFilterKeyError = ( @@ -185,6 +188,9 @@ export const hasFilterKeyError = ( if (keySplit.length <= 1 || !types.includes(keySplit[0])) { return `This type ${keySplit[0]} is not allowed`; } + if (isSavedObjectAttr(key, indexMapping)) { + 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/repository.test.js b/src/core/server/saved_objects/service/lib/repository.test.js index 216e1c4bd2d3c9d..ea25d39d02563f9 100644 --- a/src/core/server/saved_objects/service/lib/repository.test.js +++ b/src/core/server/saved_objects/service/lib/repository.test.js @@ -73,6 +73,13 @@ describe('SavedObjectsRepository', () => { }, }, }, + 'confidential-type': { + properties: { + name: { + type: 'keyword', + }, + }, + }, [CUSTOM_INDEX_TYPE]: { properties: { type: 'keyword', @@ -109,16 +116,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', { classification: 'confidential' })); registry.registerType({ ...createType(CUSTOM_INDEX_TYPE), indexPattern: 'custom', @@ -149,7 +158,7 @@ describe('SavedObjectsRepository', () => { }); const getMockGetResponse = ( - { type, id, references, namespace: objectNamespace, originId }, + { type, id, references, namespace: objectNamespace, originId, acl }, namespace ) => { const namespaceId = objectNamespace === 'default' ? undefined : objectNamespace ?? namespace; @@ -164,6 +173,7 @@ describe('SavedObjectsRepository', () => { ...(registry.isSingleNamespace(type) && { namespace: namespaceId }), ...(registry.isMultiNamespace(type) && { namespaces: [namespaceId ?? 'default'] }), ...(originId && { originId }), + ...(acl && { acl }), type, [type]: { title: 'Testing' }, references, @@ -313,6 +323,18 @@ describe('SavedObjectsRepository', () => { ); }); + it(`does not accept an ACL`, async () => { + await addToNamespacesSuccess(type, id, [newNs1, newNs2], { + acl: { owner: 'alice' }, + }); + + const [updateCall] = client.update.mock.calls; + const [updatedObject] = updateCall; + + expect(updatedObject).toHaveProperty('body.doc.namespaces'); + expect(updatedObject).not.toHaveProperty('body.doc.acl'); + }); + it(`defaults to a refresh setting of wait_for`, async () => { await addToNamespacesSuccess(type, id, [newNs1, newNs2]); expect(client.update).toHaveBeenCalledWith( @@ -425,34 +447,48 @@ describe('SavedObjectsRepository', () => { attributes: { title: 'Test Two' }, references: [{ name: 'ref_0', type: 'test', id: '2' }], }; + const obj3 = { + type: 'confidential-type', + id: 'my-secret', + acl: { + 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, acl }) => ({ + create: { + _id: `${namespace ? `${namespace}:` : ''}${type}:${id}`, + _source: { + [type]: attributes, + type, + namespace, + ...(acl && { acl }), + ...(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.isConfidential(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) ); @@ -462,7 +498,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; }; @@ -490,12 +526,13 @@ describe('SavedObjectsRepository', () => { ); }; - const expectObjArgs = ({ type, attributes, references }, overrides) => [ + const expectObjArgs = ({ type, attributes, references, acl }, overrides) => [ expect.any(Object), expect.objectContaining({ [type]: attributes, references, type, + ...(acl ? { acl } : undefined), ...overrides, ...mockTimestampFields, }), @@ -512,12 +549,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_TYPE }]; + const objects = [obj1, { ...obj2, type: MULTI_NAMESPACE_TYPE }, obj3]; await bulkCreateSuccess(objects); expect(client.bulk).toHaveBeenCalledTimes(1); expect(client.mget).toHaveBeenCalledTimes(1); @@ -526,13 +563,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' }); }); @@ -550,6 +587,7 @@ describe('SavedObjectsRepository', () => { version: mockVersion, }, obj2, + obj3, ], { overwrite: true } ); @@ -560,7 +598,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 () => { @@ -569,18 +613,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 ACL 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('acl', { 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() @@ -588,9 +653,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() @@ -601,10 +673,18 @@ describe('SavedObjectsRepository', () => { const objects = [ { ...obj1, type: NAMESPACE_AGNOSTIC_TYPE }, { ...obj2, type: MULTI_NAMESPACE_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() @@ -613,11 +693,18 @@ 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_TYPE })); + const objects = [obj1, obj2, obj3].map((x) => ({ ...x, type: MULTI_NAMESPACE_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() @@ -636,6 +723,7 @@ describe('SavedObjectsRepository', () => { const objects = [ { ...obj1, type: MULTI_NAMESPACE_TYPE, initialNamespaces: [ns2] }, { ...obj2, type: MULTI_NAMESPACE_TYPE, initialNamespaces: [ns3] }, + { ...obj3, type: MULTI_NAMESPACE_TYPE, initialNamespaces: [ns3] }, ]; await bulkCreateSuccess(objects, { namespace, overwrite: true }); const body = [ @@ -643,6 +731,8 @@ describe('SavedObjectsRepository', () => { expect.objectContaining({ namespaces: [ns2] }), expect.any(Object), expect.objectContaining({ namespaces: [ns3] }), + expect.any(Object), + expect.objectContaining({ namespaces: [ns3] }), ]; expect(client.bulk).toHaveBeenCalledWith( expect.objectContaining({ body }), @@ -672,7 +762,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() @@ -680,25 +770,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 () => { @@ -706,6 +799,7 @@ describe('SavedObjectsRepository', () => { const objects = [ { ...obj1, type: NAMESPACE_AGNOSTIC_TYPE }, { ...obj2, type: MULTI_NAMESPACE_TYPE }, + { ...obj3, type: MULTI_NAMESPACE_TYPE }, ]; await bulkCreateSuccess(objects, { namespace }); expectClientCallArgsAction(objects, { method: 'create', getId }); @@ -858,26 +952,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 () => { @@ -891,17 +989,19 @@ describe('SavedObjectsRepository', () => { }); it(`adds namespaces to body when providing namespace for multi-namespace type`, async () => { - const objects = [obj1, obj2].map((obj) => ({ ...obj, type: MULTI_NAMESPACE_TYPE })); + const objects = [obj1, obj2, obj3].map((obj) => ({ ...obj, type: MULTI_NAMESPACE_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) => ({ ...obj, type: MULTI_NAMESPACE_TYPE })); + const objects = [obj1, obj2, obj3].map((obj) => ({ ...obj, type: MULTI_NAMESPACE_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 () => { @@ -1211,6 +1311,15 @@ describe('SavedObjectsRepository', () => { id: 'logstash-*', attributes: { title: 'Test Two' }, }; + const obj3 = { + type: 'confidential-type', + id: 'my-secret', + acl: { + 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'; @@ -1285,7 +1394,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(); }); @@ -1303,8 +1412,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() @@ -1321,6 +1430,27 @@ describe('SavedObjectsRepository', () => { ); }); + it(`does not allow the ACL 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); @@ -1863,6 +1993,16 @@ describe('SavedObjectsRepository', () => { expect(client.create.mock.calls[0][0].body.references).toEqual([]); }); + it(`accepts an ACL`, async () => { + const test = async (acl) => { + await createSuccess(type, attributes, { id, acl }); + expect(client.create.mock.calls[0][0].body.acl).toEqual(acl); + 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 }); @@ -2040,6 +2180,24 @@ describe('SavedObjectsRepository', () => { ).rejects.toThrowError(createBadRequestError('"options.namespace" cannot be "*"')); }); + it(`throws when options.overwrite=true and options.acl is incompatible`, async () => { + const type = 'confidential-type'; + const id = 'my-secret-doc'; + const response = getMockGetResponse({ type, id, acl: { owner: 'alice' } }); + client.get.mockResolvedValueOnce( + elasticsearchClientMock.createSuccessTransportRequestPromise(response) + ); + await expect( + savedObjectsRepository.create('confidential-type', attributes, { + overwrite: true, + id, + acl: { owner: 'not-alice' }, + }) + ).rejects.toThrowError(SavedObjectsErrorHelpers.createIncompatibleACLError(type, id)); + + expect(client.create).not.toHaveBeenCalled(); + }); + it(`throws when type is invalid`, async () => { await expect(savedObjectsRepository.create('unknownType', attributes)).rejects.toThrowError( createUnsupportedTypeError('unknownType') @@ -2597,7 +2755,7 @@ describe('SavedObjectsRepository', () => { const generateSearchResults = (namespace) => { return { hits: { - total: 4, + total: 5, hits: [ { _index: '.kibana', @@ -2647,6 +2805,23 @@ describe('SavedObjectsRepository', () => { }, }, }, + { + _index: '.kibana', + _id: `${namespace ? `${namespace}:` : ''}confidential-type:my-secret`, + _score: 3, + ...mockVersionProps, + _source: { + namespace, + type: 'confidential-type', + acl: { + owner: 'alice', + }, + ...mockTimestampFields, + 'confidential-type': { + name: 'stocks-*', + }, + }, + }, { _index: '.kibana', _id: `${NAMESPACE_AGNOSTIC_TYPE}:something`, @@ -2854,9 +3029,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, + acl: doc._source.acl, ...mockTimestampFields, version: mockVersion, score: doc._score, @@ -2881,9 +3057,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, + acl: doc._source.acl, ...mockTimestampFields, version: mockVersion, score: doc._score, @@ -3100,8 +3280,14 @@ describe('SavedObjectsRepository', () => { const id = 'logstash-*'; const namespace = 'foo-namespace'; const originId = 'some-origin-id'; + const acl = { owner: 'alice' }; - const getSuccess = async (type, id, options, includeOriginId) => { + const getSuccess = async ( + type, + id, + options = {}, + { includeOriginId = false, includeACL = false } = {} + ) => { const response = getMockGetResponse( { type, @@ -3109,6 +3295,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 }), + ...(includeACL && { acl }), }, options?.namespace ); @@ -3256,9 +3443,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 acl property if present in cluster call response`, async () => { + const result = await getSuccess(type, id, {}, { includeACL: true }); + expect(result).toMatchObject({ acl }); + }); }); }); @@ -4200,6 +4392,23 @@ describe('SavedObjectsRepository', () => { await test(null); }); + it(`doesn't accept an ACL`, async () => { + const aclTest = async (acl) => { + await updateSuccess(type, id, attributes, { acl }); + expect(client.update).toHaveBeenCalledWith( + expect.objectContaining({ + body: { doc: expect.not.objectContaining({ acl: expect.anything() }) }, + }), + expect.anything() + ); + client.update.mockClear(); + }; + await aclTest({ owner: 'alice' }); + await aclTest(123); + await aclTest(true); + await aclTest(null); + }); + it(`defaults to a refresh setting of wait_for`, async () => { await updateSuccess(type, id, { foo: 'bar' }); expect(client.update).toHaveBeenCalledWith( @@ -4276,7 +4485,9 @@ describe('SavedObjectsRepository', () => { it(`includes _source_includes when type is multi-namespace`, async () => { await updateSuccess(MULTI_NAMESPACE_TYPE, id, attributes); expect(client.update).toHaveBeenCalledWith( - expect.objectContaining({ _source_includes: ['namespace', 'namespaces', 'originId'] }), + expect.objectContaining({ + _source_includes: ['namespace', 'namespaces', 'originId', 'acl'], + }), expect.anything() ); }); @@ -4285,7 +4496,7 @@ describe('SavedObjectsRepository', () => { await updateSuccess(type, id, attributes); expect(client.update).toHaveBeenLastCalledWith( expect.objectContaining({ - _source_includes: ['namespace', 'namespaces', 'originId'], + _source_includes: ['namespace', 'namespaces', 'originId', 'acl'], }), 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 2993d4234bd2e87..813f003856f54e9 100644 --- a/src/core/server/saved_objects/service/lib/repository.ts +++ b/src/core/server/saved_objects/service/lib/repository.ts @@ -7,6 +7,7 @@ */ import { omit, isObject } from 'lodash'; +import { SavedObjectACL } from 'src/core/types'; import { ElasticsearchClient, DeleteDocumentResponse, @@ -48,6 +49,8 @@ import { SavedObjectsRemoveReferencesToOptions, SavedObjectsRemoveReferencesToResponse, SavedObjectsResolveResponse, + SavedObjectsBulkCreateOptions, + SavedObjectsCheckConflictsOptions, } from '../saved_objects_client'; import { SavedObject, @@ -243,6 +246,7 @@ export class SavedObjectsRepository { originId, initialNamespaces, version, + acl, } = options; const namespace = normalizeNamespace(options.namespace); @@ -262,6 +266,13 @@ export class SavedObjectsRepository { throw SavedObjectsErrorHelpers.createUnsupportedTypeError(type); } + if (overwrite && options.acl && this._registry.isConfidential(type)) { + const existingDoc = await this.preflightGetRawDoc(type, id, namespace); + if (existingDoc && !this.hasCompatibleACL(existingDoc, options.acl)) { + throw SavedObjectsErrorHelpers.createIncompatibleACLError(type, id); + } + } + const time = this._getCurrentTime(); let savedObjectNamespace; let savedObjectNamespaces: string[] | undefined; @@ -284,6 +295,7 @@ export class SavedObjectsRepository { type, ...(savedObjectNamespace && { namespace: savedObjectNamespace }), ...(savedObjectNamespaces && { namespaces: savedObjectNamespaces }), + ...(acl && { acl }), originId, attributes, migrationVersion, @@ -323,7 +335,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); @@ -355,6 +367,8 @@ export class SavedObjectsRepository { const method = object.id && overwrite ? 'index' : 'create'; const requiresNamespacesCheck = object.id && this._registry.isMultiNamespace(object.type); + const requiresACLCheck = object.id && overwrite && this._registry.isConfidential(object.type); + const requiresPreflightCheck = requiresNamespacesCheck || requiresACLCheck; if (object.id == null) { object.id = SavedObjectsUtils.generateId(); @@ -365,7 +379,7 @@ export class SavedObjectsRepository { value: { method, object, - ...(requiresNamespacesCheck && { esRequestIndex: bulkGetRequestIndexCounter++ }), + ...(requiresPreflightCheck && { esRequestIndex: bulkGetRequestIndexCounter++ }), }, }; }); @@ -376,7 +390,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', 'acl'], })); const bulkGetResponse = bulkGetDocs.length ? await this.client.mget( @@ -401,7 +415,7 @@ export class SavedObjectsRepository { let versionProperties; const { esRequestIndex, - object: { initialNamespaces, version, ...object }, + object: { initialNamespaces, version, acl, ...object }, method, } = expectedBulkGetResult.value; if (esRequestIndex !== undefined) { @@ -422,6 +436,20 @@ export class SavedObjectsRepository { }, }; } + if (docFound && !this.hasCompatibleACL(actualResult, acl)) { + const { id, type } = object; + return { + tag: 'Left' as 'Left', + error: { + id, + type, + error: { + ...errorContent(SavedObjectsErrorHelpers.createIncompatibleACLError(type, id)), + metadata: { isNotOverwritable: true }, + }, + }, + }; + } savedObjectNamespaces = initialNamespaces || getSavedObjectNamespaces(namespace, docFound && actualResult); versionProperties = getExpectedVersionProperties(version, actualResult); @@ -443,6 +471,7 @@ export class SavedObjectsRepository { type: object.type, attributes: object.attributes, migrationVersion: object.migrationVersion, + acl, ...(savedObjectNamespace && { namespace: savedObjectNamespace }), ...(savedObjectNamespaces && { namespaces: savedObjectNamespaces }), updated_at: time, @@ -509,7 +538,7 @@ export class SavedObjectsRepository { */ async checkConflicts( objects: SavedObjectsCheckConflictsObject[] = [], - options: SavedObjectsBaseOptions = {} + options: SavedObjectsCheckConflictsOptions = {} ): Promise { if (objects.length === 0) { return { errors: [] }; @@ -545,7 +574,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: ['type', 'namespaces'], + _source: ['type', 'namespaces', 'acl'], })); const bulkGetResponse = bulkGetDocs.length ? await this.client.mget( @@ -568,14 +597,17 @@ export class SavedObjectsRepository { const { type, id, esRequestIndex } = expectedResult.value; const doc = bulkGetResponse?.body.docs[esRequestIndex]; if (doc.found) { + const isNotOverwritable = + !this.rawDocExistsInNamespace(doc, namespace) || !this.hasCompatibleACL(doc, options.acl); + + const errorMetadata = isNotOverwritable ? { metadata: { isNotOverwritable } } : undefined; + errors.push({ id, type, error: { ...errorContent(SavedObjectsErrorHelpers.createConflictError(type, id)), - ...(!this.rawDocExistsInNamespace(doc, namespace) && { - metadata: { isNotOverwritable: true }, - }), + ...errorMetadata, }, }); } @@ -1127,7 +1159,7 @@ export class SavedObjectsRepository { body: { doc, }, - _source_includes: ['namespace', 'namespaces', 'originId'], + _source_includes: ['namespace', 'namespaces', 'originId', 'acl'], }, { ignore: [404] } ); @@ -1137,7 +1169,7 @@ export class SavedObjectsRepository { throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); } - const { originId } = body.get._source; + const { originId, acl } = body.get._source; let namespaces = []; if (!this._registry.isNamespaceAgnostic(type)) { namespaces = body.get._source.namespaces ?? [ @@ -1153,6 +1185,7 @@ export class SavedObjectsRepository { version: encodeHitVersion(body), namespaces, ...(originId && { originId }), + ...(acl && { acl }), references, attributes, }; @@ -1404,7 +1437,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', 'acl'], })); const bulkGetResponse = bulkGetDocs.length ? await this.client.mget( @@ -1514,7 +1547,7 @@ export class SavedObjectsRepository { )[0] as any; // eslint-disable-next-line @typescript-eslint/naming-convention - const { [type]: attributes, references, updated_at } = documentToSave; + const { [type]: attributes, references, updated_at, acl } = documentToSave; if (error) { return { id, @@ -1527,6 +1560,7 @@ export class SavedObjectsRepository { return { id, type, + acl, ...(namespaces && { namespaces }), ...(originId && { originId }), updated_at, @@ -1825,6 +1859,36 @@ export class SavedObjectsRepository { return existsInNamespace ?? false; } + private async preflightGetRawDoc(type: string, id: string, namespace?: string | undefined) { + const { body, statusCode } = await this.client.get>( + { + id: this._serializer.generateRawId(namespace, type, id), + index: this.getIndexForType(type), + _source_includes: ['namespace', 'namespaces', 'acl'], + }, + { + ignore: [404], + } + ); + + const indexFound = statusCode !== 404; + const docFound = indexFound && body.found === true; + if (docFound) { + return body; + } + return null; + } + + private hasCompatibleACL( + rawDoc: GetResponse, + acl: SavedObjectACL | undefined + ) { + if (!acl) { + return true; + } + return rawDoc._source.acl?.owner === acl.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. @@ -1900,7 +1964,7 @@ export class SavedObjectsRepository { id: string, doc: { _seq_no: number; _primary_term: number; _source: SavedObjectsRawDocSource } ): SavedObject { - const { originId, updated_at: updatedAt } = doc._source; + const { originId, updated_at: updatedAt, acl } = doc._source; let namespaces: string[] = []; if (!this._registry.isNamespaceAgnostic(type)) { @@ -1913,6 +1977,7 @@ export class SavedObjectsRepository { id, type, namespaces, + ...(acl && { acl }), ...(originId && { originId }), ...(updatedAt && { updated_at: updatedAt }), version: encodeHitVersion(doc), 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 d17f6b082096fbd..50326c0f59720af 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 @@ * Public License, v 1. */ +import { SavedObjectACL } from 'src/core/types'; import { ISavedObjectsRepository } from './lib'; import { SavedObject, @@ -56,8 +57,17 @@ export interface SavedObjectsCreateOptions extends SavedObjectsBaseOptions { * Note: this can only be used for multi-namespace object types. */ initialNamespaces?: string[]; + + /** The {@link SavedObjectACL | acl} to associate with this saved object. */ + acl?: SavedObjectACL; } +/** + * + * @public + */ +export type SavedObjectsBulkCreateOptions = Omit; + /** * * @public @@ -89,6 +99,9 @@ export interface SavedObjectsBulkCreateObject { * Note: this can only be used for multi-namespace object types. */ initialNamespaces?: string[]; + + /** The {@link SavedObjectACL | acl} to associate with this saved object. */ + acl?: SavedObjectACL; } /** @@ -146,6 +159,15 @@ export interface SavedObjectsFindResponse { page: number; } +/** + * + * @public + */ +export interface SavedObjectsCheckConflictsOptions extends SavedObjectsBaseOptions { + /** An {@link SavedObjectACL | acl} which should be compatible with conflicting objects. */ + acl?: SavedObjectACL; +} + /** * * @public @@ -359,7 +381,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 4f47579741a5a13..b30dd209d7b8da2 100644 --- a/src/core/server/saved_objects/types.ts +++ b/src/core/server/saved_objects/types.ts @@ -32,6 +32,7 @@ import { SavedObject } from '../../types'; type KueryNode = any; export { + SavedObjectACL, SavedObjectAttributes, SavedObjectAttribute, SavedObjectAttributeSingle, @@ -205,6 +206,13 @@ export type SavedObjectsClientContract = Pick { + // (undocumented) + acl?: SavedObjectACL; attributes: T; coreMigrationVersion?: string; // Warning: (ae-forgotten-export) The symbol "SavedObjectError" needs to be exported by the entry point index.d.ts @@ -2062,6 +2065,11 @@ export interface SavedObject { version?: string; } +// @public +export interface SavedObjectACL { + owner: string; +} + // @public export type SavedObjectAttribute = SavedObjectAttributeSingle | SavedObjectAttributeSingle[]; @@ -2130,6 +2138,7 @@ export interface SavedObjectsBaseOptions { // @public (undocumented) export interface SavedObjectsBulkCreateObject { + acl?: SavedObjectACL_2; // (undocumented) attributes: T; coreMigrationVersion?: string; @@ -2194,6 +2203,11 @@ export interface SavedObjectsCheckConflictsObject { type: string; } +// @public (undocumented) +export interface SavedObjectsCheckConflictsOptions extends SavedObjectsBaseOptions { + acl?: SavedObjectACL_2; +} + // @public (undocumented) export interface SavedObjectsCheckConflictsResponse { // (undocumented) @@ -2212,7 +2226,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; create(type: string, attributes: T, options?: SavedObjectsCreateOptions): Promise>; delete(type: string, id: string, options?: SavedObjectsDeleteOptions): Promise<{}>; deleteFromNamespaces(type: string, id: string, namespaces: string[], options?: SavedObjectsDeleteFromNamespacesOptions): Promise; @@ -2294,6 +2308,7 @@ export interface SavedObjectsCoreFieldMapping { // @public (undocumented) export interface SavedObjectsCreateOptions extends SavedObjectsBaseOptions { + acl?: SavedObjectACL_2; coreMigrationVersion?: string; id?: string; initialNamespaces?: string[]; @@ -2336,6 +2351,8 @@ export class SavedObjectsErrorHelpers { // (undocumented) static createGenericNotFoundError(type?: string | null, id?: string | null): DecoratedError; // (undocumented) + static createIncompatibleACLError(type: string, id: string): DecoratedError; + // (undocumented) static createInvalidVersionError(versionInput?: string): DecoratedError; // (undocumented) static createTooManyRequestsError(type: string, id: string): DecoratedError; @@ -2759,10 +2776,11 @@ export interface SavedObjectsRemoveReferencesToResponse extends SavedObjectsBase // @public (undocumented) export class SavedObjectsRepository { addToNamespaces(type: string, id: string, namespaces: string[], options?: SavedObjectsAddToNamespacesOptions): Promise; - bulkCreate(objects: Array>, options?: SavedObjectsCreateOptions): Promise>; + // Warning: (ae-forgotten-export) The symbol "SavedObjectsBulkCreateOptions" needs to be exported by the entry point index.d.ts + 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; create(type: string, attributes: T, options?: SavedObjectsCreateOptions): Promise>; // Warning: (ae-forgotten-export) The symbol "IKibanaMigrator" needs to be exported by the entry point index.d.ts // @@ -2842,6 +2860,9 @@ export interface SavedObjectStatusMeta { // @public (undocumented) export interface SavedObjectsType { + // Warning: (ae-forgotten-export) The symbol "SavedObjectsClassification" 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 "SavedObjectsClassification" + classification?: SavedObjectsClassification; convertToAliasScript?: string; convertToMultiNamespaceTypeVersion?: string; hidden: boolean; @@ -2905,6 +2926,7 @@ export class SavedObjectTypeRegistry { getIndex(type: string): string | undefined; getType(type: string): SavedObjectsType | undefined; getVisibleTypes(): SavedObjectsType[]; + isConfidential(type: string): boolean; isHidden(type: string): boolean; isImportableAndExportable(type: string): boolean; isMultiNamespace(type: string): boolean; diff --git a/src/core/server/types.ts b/src/core/server/types.ts index 74f9fb65db54df1..256b004459e8136 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 { PluginOpaqueId } from './plugins/types'; export type { + SavedObjectACL, SavedObjectsImportResponse, SavedObjectsImportSuccess, SavedObjectsImportConflictError, diff --git a/src/core/types/saved_objects.ts b/src/core/types/saved_objects.ts index c19f1febc97b104..a1b047c122fbca1 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 List" describing which users should be authorized to access this SavedObject. + * + * @public + */ +export interface SavedObjectACL { + /** 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 @@ -93,6 +103,7 @@ export interface SavedObject { * space. */ originId?: string; + acl?: SavedObjectACL; } export interface SavedObjectError { diff --git a/src/plugins/data/public/public.api.md b/src/plugins/data/public/public.api.md index 9e493f46b0781b2..3acc7f39f152deb 100644 --- a/src/plugins/data/public/public.api.md +++ b/src/plugins/data/public/public.api.md @@ -86,6 +86,7 @@ import { Required } from '@kbn/utility-types'; import * as Rx from 'rxjs'; import { SavedObject } from 'kibana/server'; import { SavedObject as SavedObject_2 } from 'src/core/server'; +import { SavedObjectACL as SavedObjectACL_2 } from 'src/core/types'; import { SavedObjectReference } 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 2f9b43121b45a4f..4991278f88d8f44 100644 --- a/src/plugins/embeddable/public/public.api.md +++ b/src/plugins/embeddable/public/public.api.md @@ -50,6 +50,7 @@ import React from 'react'; import { RecursiveReadonly } from '@kbn/utility-types'; import { Request } from '@hapi/hapi'; import * as Rx from 'rxjs'; +import { SavedObjectACL as SavedObjectACL_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/plugin.ts b/x-pack/plugins/security/server/plugin.ts index 8d8e4c096f37e5e..9635a91e22db991 100644 --- a/x-pack/plugins/security/server/plugin.ts +++ b/x-pack/plugins/security/server/plugin.ts @@ -272,6 +272,7 @@ export class Plugin { 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/index.ts b/x-pack/plugins/security/server/saved_objects/index.ts index 16c935e048930f3..2026d8b15a5c182 100644 --- a/x-pack/plugins/security/server/saved_objects/index.ts +++ b/x-pack/plugins/security/server/saved_objects/index.ts @@ -14,6 +14,7 @@ import { SecureSavedObjectsClientWrapper } from './secure_saved_objects_client_w import { AuthorizationServiceSetup } from '../authorization'; import { SecurityAuditLogger, AuditServiceSetup } from '../audit'; import { SpacesService } from '../plugin'; +import { AuthenticatedUser } from '../../common/model'; interface SetupSavedObjectsParams { legacyAuditLogger: SecurityAuditLogger; @@ -24,6 +25,7 @@ interface SetupSavedObjectsParams { >; savedObjects: CoreSetup['savedObjects']; getSpacesService(): SpacesService | undefined; + getCurrentUser(request: KibanaRequest): AuthenticatedUser | null; } export function setupSavedObjects({ @@ -32,6 +34,7 @@ export function setupSavedObjects({ authz, savedObjects, getSpacesService, + getCurrentUser, }: SetupSavedObjectsParams) { const getKibanaRequest = (request: KibanaRequest | LegacyRequest) => request instanceof KibanaRequest ? request : KibanaRequest.from(request); @@ -47,20 +50,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 5c421776d54f04d..b01288f0bb9cacb 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 @@ -7,7 +7,11 @@ import { SecureSavedObjectsClientWrapper } from './secure_saved_objects_client_wrapper'; import { Actions } from '../authorization'; import { securityAuditLoggerMock, auditServiceMock } from '../audit/index.mock'; -import { savedObjectsClientMock, httpServerMock } from '../../../../../src/core/server/mocks'; +import { + savedObjectsClientMock, + httpServerMock, + savedObjectsServiceMock, +} from '../../../../../src/core/server/mocks'; import { SavedObjectsClientContract } from 'kibana/server'; import { SavedObjectActions } from '../authorization/actions/saved_object'; import { AuditEvent, EventOutcome } from '../audit'; @@ -36,12 +40,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'), @@ -52,6 +58,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()), @@ -155,16 +163,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 ); @@ -184,9 +186,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'] @@ -345,7 +345,7 @@ describe('#addToNamespaces', () => { clientOpts.legacyAuditLogger.savedObjectsAuthorizationFailure ).toHaveBeenCalledWith( USERNAME, - 'addToNamespacesCreate', + 'addToNamespaces', [type], namespaces.sort(), [{ privilege, spaceId: newNs1 }], @@ -356,15 +356,12 @@ describe('#addToNamespaces', () => { test(`throws decorated ForbiddenError when unauthorized to update in current space`, async () => { clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockImplementationOnce( - getMockCheckPrivilegesSuccess // create - ); - clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockImplementation( - getMockCheckPrivilegesFailure // update + getMockCheckPrivilegesFailure ); - await expect(client.addToNamespaces(type, id, namespaces)).rejects.toThrowError( - clientOpts.forbiddenError - ); + await expect( + client.addToNamespaces(type, id, namespaces, { namespace: currentNs }) + ).rejects.toThrowError(clientOpts.forbiddenError); expect(clientOpts.errors.decorateForbiddenError).toHaveBeenCalledTimes(1); expect(clientOpts.legacyAuditLogger.savedObjectsAuthorizationFailure).toHaveBeenCalledTimes(1); @@ -372,13 +369,13 @@ describe('#addToNamespaces', () => { clientOpts.legacyAuditLogger.savedObjectsAuthorizationFailure ).toHaveBeenLastCalledWith( USERNAME, - 'addToNamespacesUpdate', + 'addToNamespaces', [type], - [currentNs], - [{ privilege, spaceId: currentNs }], - { id, type, namespaces, options: {} } + [...namespaces, currentNs].sort(), + [{ privilege, spaceId: namespaces[0] }], + { id, type, namespaces, options: { namespace: currentNs } } ); - expect(clientOpts.legacyAuditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledTimes(1); + expect(clientOpts.legacyAuditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledTimes(0); }); test(`returns result of baseClient.addToNamespaces when authorized`, async () => { @@ -389,51 +386,33 @@ describe('#addToNamespaces', () => { expect(result).toBe(apiCallReturnValue); expect(clientOpts.legacyAuditLogger.savedObjectsAuthorizationFailure).not.toHaveBeenCalled(); - expect(clientOpts.legacyAuditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledTimes(2); - expect(clientOpts.legacyAuditLogger.savedObjectsAuthorizationSuccess).toHaveBeenNthCalledWith( - 1, + expect(clientOpts.legacyAuditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledTimes(1); + expect(clientOpts.legacyAuditLogger.savedObjectsAuthorizationSuccess).toHaveBeenCalledWith( USERNAME, - 'addToNamespacesCreate', // action for privilege check is 'share_to_space', but auditAction is 'addToNamespacesCreate' + 'addToNamespaces', // action for privilege check is 'share_to_space', but auditAction is 'addToNamespaces' [type], namespaces.sort(), { type, id, namespaces, options: {} } ); - expect(clientOpts.legacyAuditLogger.savedObjectsAuthorizationSuccess).toHaveBeenNthCalledWith( - 2, - USERNAME, - 'addToNamespacesUpdate', // action for privilege check is 'share_to_space', but auditAction is 'addToNamespacesUpdate' - [type], - [currentNs], - { type, id, namespaces, options: {} } - ); }); test(`checks privileges for user, actions, and namespaces`, async () => { - clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockImplementationOnce( - getMockCheckPrivilegesSuccess // create - ); clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockImplementation( getMockCheckPrivilegesFailure // update ); - await expect(client.addToNamespaces(type, id, namespaces)).rejects.toThrow(); // test is simpler with error case + const options = { namespace: 'other-namespace' }; + await expect(client.addToNamespaces(type, id, namespaces, options)).rejects.toThrow(); // test is simpler with error case - expect(clientOpts.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledTimes(2); - expect(clientOpts.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenNthCalledWith( - 1, - [privilege], - namespaces - ); - expect(clientOpts.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenNthCalledWith( - 2, + expect(clientOpts.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledTimes(1); + expect(clientOpts.checkSavedObjectsPrivilegesAsCurrentUser).toHaveBeenCalledWith( [privilege], - undefined // default namespace + [...namespaces, options.namespace] ); }); test(`filters namespaces that the user doesn't have access to`, async () => { - // this operation is unique because it requires two privilege checks before it executes - await expectObjectNamespaceFiltering(client.addToNamespaces, { type, id, namespaces }, 2); + await expectObjectNamespaceFiltering(client.addToNamespaces, { type, id, namespaces }); }); test(`adds audit event when successful`, async () => { @@ -472,6 +451,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]; @@ -507,6 +487,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 }; @@ -601,6 +582,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]; @@ -634,6 +616,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 }; @@ -669,8 +652,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 }; 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 e53bb742e217987..4f4ca9ba920e426 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 @@ -4,13 +4,17 @@ * you may not use this file except in compliance with the Elastic License. */ import type { PublicMethodsOf } from '@kbn/utility-types'; +import { SavedObjectACL } from 'src/core/types'; import { + ISavedObjectTypeRegistry, + SavedObject, SavedObjectsAddToNamespacesOptions, SavedObjectsBaseOptions, SavedObjectsBulkCreateObject, SavedObjectsBulkGetObject, SavedObjectsBulkUpdateObject, SavedObjectsCheckConflictsObject, + SavedObjectsCheckConflictsOptions, SavedObjectsClientContract, SavedObjectsCreateOptions, SavedObjectsDeleteFromNamespacesOptions, @@ -20,6 +24,7 @@ import { SavedObjectsUtils, } from '../../../../../src/core/server'; import { ALL_SPACES_ID, UNKNOWN_SPACE } from '../../common/constants'; +import { AuthenticatedUser } from '../../common/model'; import { AuditLogger, EventOutcome, @@ -30,14 +35,17 @@ import { import { Actions, CheckSavedObjectsPrivileges } from '../authorization'; import { CheckPrivilegesResponse } from '../authorization/types'; import { SpacesService } from '../plugin'; +import { esKuery } from '../../../../../src/plugins/data/server'; interface SecureSavedObjectsClientWrapperOptions { actions: Actions; legacyAuditLogger: SecurityAuditLogger; auditLogger: AuditLogger; baseClient: SavedObjectsClientContract; + typeRegistry: ISavedObjectTypeRegistry; errors: SavedObjectsClientContract['errors']; checkSavedObjectsPrivilegesAsCurrentUser: CheckSavedObjectsPrivileges; + getCurrentUser(): AuthenticatedUser | null; getSpacesService(): SpacesService | undefined; } @@ -53,11 +61,15 @@ interface EnsureAuthorizedOptions { args?: Record; auditAction?: string; requireFullAuthorization?: boolean; + savedObject?: SavedObject | null; } interface EnsureAuthorizedResult { status: 'fully_authorized' | 'partially_authorized' | 'unauthorized'; typeMap: Map; + legacyAuditLogger: { + logAuthorized(): void; + }; } interface EnsureAuthorizedTypeResult { authorizedSpaces: string[]; @@ -69,8 +81,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({ @@ -78,17 +92,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( @@ -96,16 +114,34 @@ 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 || [])]; + this.ensureACLNotSpecified(options); + + const augmentedOptions = { + ...options, + id: options.id ?? SavedObjectsUtils.generateId(), + acl: this.createACL(type), + }; + + const namespaces = [augmentedOptions.namespace, ...(augmentedOptions.initialNamespaces || [])]; try { - const args = { type, attributes, options: optionsWithId }; - await this.ensureAuthorized(type, 'create', namespaces, { args }); + const action = 'create'; + const args = { type, attributes, options: augmentedOptions }; + const { legacyAuditLogger } = await this.ensureAuthorizedForAction(type, action, namespaces, { + args, + }); + + 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, }) ); @@ -115,27 +151,38 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra savedObjectEvent({ action: SavedObjectAction.CREATE, outcome: EventOutcome.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.ensureACLNotSpecified(options); const args = { objects, options }; const types = this.getUniqueObjectTypes(objects); - await this.ensureAuthorized(types, 'bulk_create', options.namespace, { - args, - auditAction: 'checkConflicts', - }); + const { legacyAuditLogger } = await this.ensureAuthorizedForAction( + 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, + acl: { + owner: this.getOwner(), + }, + }); } public async bulkCreate( @@ -145,21 +192,28 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra const objectsWithId = objects.map((obj) => ({ ...obj, id: obj.id ?? SavedObjectsUtils.generateId(), + acl: this.createACL(obj.type), })); + const namespaces = objectsWithId.reduce( (acc, { initialNamespaces = [] }) => acc.concat(initialNamespaces), [options.namespace] ); try { + const action = 'bulk_create'; const args = { objects: objectsWithId, options }; - await this.ensureAuthorized( + const { legacyAuditLogger } = await this.ensureAuthorizedForAction( this.getUniqueObjectTypes(objectsWithId), - 'bulk_create', + action, namespaces, { args, } ); + + 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( @@ -188,8 +242,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.ensureAuthorized(type, 'delete', options.namespace, { args }); + const { legacyAuditLogger } = await this.ensureAuthorizedForAction( + type, + action, + options.namespace, + { args } + ); + 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({ @@ -200,6 +263,7 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra ); throw error; } + this.auditLogger.log( savedObjectEvent({ action: SavedObjectAction.DELETE, @@ -223,11 +287,14 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra } const args = { options }; - const { status, typeMap } = await this.ensureAuthorized( + const { status, typeMap, legacyAuditLogger } = await this.ensureAuthorizedForAction( options.type, 'find', options.namespaces, - { args, requireFullAuthorization: false } + { + args, + requireFullAuthorization: false, + } ); if (status === 'unauthorized') { @@ -241,16 +308,59 @@ 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.isConfidential(type)) { + return [ + ...acc, + // note: this relies on an implementation detail of th SO services `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 accidently alter our authorization model. + + // This is equivilent to writing the following, if this syntax was allowed by the SO `filter` option: + // esKuery.nodeTypes.function.buildNode('and', [ + // esKuery.nodeTypes.function.buildNode('is', `acl.owner`, this.getOwner()), + // esKuery.nodeTypes.function.buildNode('is', `type`, type), + // ]) + esKuery.nodeTypes.function.buildNode('is', `${type}.acl.owner`, this.getOwner()), + ]; + } + return acc; + }, [] as any); + + 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 }) => @@ -269,16 +379,20 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra objects: SavedObjectsBulkGetObject[] = [], options: SavedObjectsBaseOptions = {} ) { + let legacyAuditLogger; + const action = 'bulk_get'; try { const args = { objects, options }; - await this.ensureAuthorized( - this.getUniqueObjectTypes(objects), - 'bulk_get', - options.namespace, - { - args, - } - ); + legacyAuditLogger = ( + await this.ensureAuthorizedForAction( + this.getUniqueObjectTypes(objects), + action, + options.namespace, + { + args, + } + ) + ).legacyAuditLogger; } catch (error) { objects.forEach(({ type, id }) => this.auditLogger.log( @@ -294,24 +408,45 @@ 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 (!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; try { const args = { type, id, options }; - await this.ensureAuthorized(type, 'get', options.namespace, { args }); + legacyAuditLogger = ( + await this.ensureAuthorizedForAction(type, 'get', options.namespace, { args }) + ).legacyAuditLogger; } catch (error) { this.auditLogger.log( savedObjectEvent({ @@ -325,12 +460,24 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra const savedObject = await this.baseClient.get(type, id, options); - this.auditLogger.log( - savedObjectEvent({ - action: SavedObjectAction.GET, - savedObject: { type, id }, - }) - ); + if (this.isAuthorizedForObject(savedObject)) { + legacyAuditLogger.logAuthorized(); + this.auditLogger.log( + savedObjectEvent({ + action: SavedObjectAction.GET, + savedObject: { type, id }, + }) + ); + } else { + this.auditLogger.log( + savedObjectEvent({ + action: SavedObjectAction.GET, + savedObject: { type, id }, + error: this.createForbiddenTypesError('get', [type]), + }) + ); + throw this.errors.createGenericNotFoundError(type, id); + } return await this.redactSavedObjectNamespaces(savedObject, [options.namespace]); } @@ -340,9 +487,20 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra id: string, options: SavedObjectsBaseOptions = {} ) { + this.ensureACLNotSpecified(options); + const action = 'get'; try { const args = { type, id, options }; - await this.ensureAuthorized(type, 'get', options.namespace, { args, auditAction: 'resolve' }); + const { legacyAuditLogger } = await this.ensureAuthorizedForAction( + type, + action, + options.namespace, + { + args, + auditAction: 'resolve', + } + ); + legacyAuditLogger.logAuthorized(); } catch (error) { this.auditLogger.log( savedObjectEvent({ @@ -356,6 +514,18 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra const resolveResult = await this.baseClient.resolve(type, id, options); + if (!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, @@ -377,9 +547,19 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra attributes: Partial, options: SavedObjectsUpdateOptions = {} ) { + this.ensureACLNotSpecified(options); + try { + const action = 'update'; const args = { type, id, attributes, options }; - await this.ensureAuthorized(type, 'update', options.namespace, { args }); + const { legacyAuditLogger } = await this.ensureAuthorizedForAction( + type, + action, + options.namespace, + { args } + ); + await this.ensureAuthorizedForObjects([{ type, id }], options.namespace, action); + legacyAuditLogger.logAuthorized(); } catch (error) { this.auditLogger.log( savedObjectEvent({ @@ -398,6 +578,7 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra }) ); + // const augmentedOptions = { ...options, acl: this.createACL(type) }; const savedObject = await this.baseClient.update(type, id, attributes, options); return await this.redactSavedObjectNamespaces(savedObject, [options.namespace]); } @@ -410,21 +591,25 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra ) { const { namespace } = options; try { + const action = 'share_to_space'; const args = { type, id, namespaces, options }; // To share an object, the user must have the "share_to_space" permission in each of the destination namespaces. - await this.ensureAuthorized(type, 'share_to_space', namespaces, { - args, - auditAction: 'addToNamespacesCreate', - }); - // To share an object, the user must also have the "share_to_space" permission in one or more of the source namespaces. Because the // `addToNamespaces` operation is scoped to the current namespace, we can just check if the user has the "share_to_space" permission in // the current namespace. If the user has permission, but the saved object doesn't exist in this namespace, the base client operation // will result in a 404 error. - await this.ensureAuthorized(type, 'share_to_space', namespace, { - args, - auditAction: 'addToNamespacesUpdate', - }); + const { legacyAuditLogger } = await this.ensureAuthorizedForAction( + type, + action, + [...namespaces, options.namespace], + { + args, + auditAction: 'addToNamespaces', + } + ); + + await this.ensureAuthorizedForObjects([{ type, id }], options.namespace, action); + legacyAuditLogger.logAuthorized(); } catch (error) { this.auditLogger.log( savedObjectEvent({ @@ -456,12 +641,15 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra options: SavedObjectsDeleteFromNamespacesOptions = {} ) { try { + const action = 'share_to_space'; const args = { type, id, namespaces, options }; // To un-share an object, the user must have the "share_to_space" permission in each of the target namespaces. - await this.ensureAuthorized(type, 'share_to_space', namespaces, { + const { legacyAuditLogger } = await this.ensureAuthorizedForAction(type, action, namespaces, { args, auditAction: 'deleteFromNamespaces', }); + await this.ensureAuthorizedForObjects([{ type, id }], options.namespace, action); + legacyAuditLogger.logAuthorized(); } catch (error) { this.auditLogger.log( savedObjectEvent({ @@ -496,11 +684,20 @@ 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.ensureAuthorized(this.getUniqueObjectTypes(objects), 'bulk_update', namespaces, { - args, - }); + const { legacyAuditLogger } = await this.ensureAuthorizedForAction( + this.getUniqueObjectTypes(objects), + action, + namespaces, + { + args, + } + ); + await this.ensureAuthorizedForObjects(objects, options.namespace, action); + legacyAuditLogger.logAuthorized(); } catch (error) { objects.forEach(({ type, id }) => this.auditLogger.log( @@ -513,6 +710,7 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra ); throw error; } + objects.forEach(({ type, id }) => this.auditLogger.log( savedObjectEvent({ @@ -533,11 +731,19 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra options: SavedObjectsRemoveReferencesToOptions = {} ) { try { + const action = 'delete'; const args = { type, id, options }; - await this.ensureAuthorized(type, 'delete', options.namespace, { - args, - auditAction: 'removeReferences', - }); + const { legacyAuditLogger } = await this.ensureAuthorizedForAction( + type, + action, + options.namespace, + { + args, + auditAction: 'removeReferences', + } + ); + await this.ensureAuthorizedForObjects([{ type, id }], options.namespace, action); + legacyAuditLogger.logAuthorized(); } catch (error) { this.auditLogger.log( savedObjectEvent({ @@ -560,6 +766,23 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra return await this.baseClient.removeReferencesTo(type, id, options); } + private async ensureAuthorizedForObjects( + objects: Array<{ type: string; id: string }>, + namespace: string | undefined, + action: string + ) { + const objectsToRetrieve = objects.filter((so) => this.typeRegistry.isConfidential(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 @@ -571,7 +794,50 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra } } - private async ensureAuthorized( + private createACL(type: string): SavedObjectACL | undefined { + if (!this.typeRegistry.isConfidential(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 ensureACLNotSpecified(options: Record) { + if (options?.acl != null) { + throw new Error(`The security plugin is responsible for setting saved object ACLs`); + } + } + + private isAuthorizedForObject({ type, acl, error, attributes }: SavedObject) { + if (!this.typeRegistry.isConfidential(type)) { + return true; + } + + if (error != null && attributes == null) { + // object not found + return true; + } + + if (!acl?.owner) { + throw this.errors.decorateGeneralError( + new Error(`Unable to verfify object ownership due to missing ACL`) + ); + } + + return acl?.owner === this.getOwner(); + } + + private async ensureAuthorizedForAction( typeOrTypes: string | string[], action: string, namespaceOrNamespaces: undefined | string | Array, @@ -628,30 +894,57 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra }; if (hasAllRequested) { - logAuthorizationSuccess(types, spaceIds); - return { typeMap, status: 'fully_authorized' }; + return { + typeMap, + status: 'fully_authorized', + 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', + 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', + 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); } } + private createForbiddenTypesError(action: string, targetTypes: string[]) { + const msg = `Unable to ${action} ${targetTypes.join(',')}`; + return this.errors.decorateForbiddenError(new Error(msg)); + } + + 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 getMissingPrivileges(privileges: CheckPrivilegesResponse['privileges']) { return privileges.kibana .filter(({ authorized }) => !authorized) diff --git a/x-pack/scripts/functional_tests.js b/x-pack/scripts/functional_tests.js index 90c75cd62d6cc3b..4e49e344edf2025 100644 --- a/x-pack/scripts/functional_tests.js +++ b/x-pack/scripts/functional_tests.js @@ -52,6 +52,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_acl/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_acl/common/lib/authentication.ts b/x-pack/test/saved_object_acl/common/lib/authentication.ts new file mode 100644 index 000000000000000..c71758eb6cf6569 --- /dev/null +++ b/x-pack/test/saved_object_acl/common/lib/authentication.ts @@ -0,0 +1,81 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export const ROLES = { + ALICE: { + name: 'alice', + privileges: { + kibana: [ + { + feature: { + 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', + }, + SUPERUSER: { + username: 'elastic', + password: 'changeme', + roles: [], + superuser: true, + description: 'superuser', + }, +}; diff --git a/x-pack/test/saved_object_acl/common/lib/create_users_and_roles.ts b/x-pack/test/saved_object_acl/common/lib/create_users_and_roles.ts new file mode 100644 index 000000000000000..01243f269d52d57 --- /dev/null +++ b/x-pack/test/saved_object_acl/common/lib/create_users_and_roles.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { 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_acl/common/lib/index.ts b/x-pack/test/saved_object_acl/common/lib/index.ts new file mode 100644 index 000000000000000..f4cfa7a52129c33 --- /dev/null +++ b/x-pack/test/saved_object_acl/common/lib/index.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { Role, User, ExpectedResponse } from './types'; +export { ROLES, USERS } from './authentication'; +export { createUsersAndRoles } from './create_users_and_roles'; +export { + assertSavedObjectExists, + assertSavedObjectMissing, + assertSavedObjectACL, +} from './saved_object_assertions'; diff --git a/x-pack/test/saved_object_acl/common/lib/saved_object_assertions.ts b/x-pack/test/saved_object_acl/common/lib/saved_object_assertions.ts new file mode 100644 index 000000000000000..307001fef3277d5 --- /dev/null +++ b/x-pack/test/saved_object_acl/common/lib/saved_object_assertions.ts @@ -0,0 +1,74 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Client } from '@elastic/elasticsearch'; +import expect from '@kbn/expect'; +import { SavedObjectACL } 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: Client, + 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 assertSavedObjectACL = async ( + es: Client, + savedObjectType: string, + savedObjectId: string, + spaceId: string, + acl: SavedObjectACL | undefined +) => { + const documentId = getDocumentId(savedObjectType, savedObjectId, spaceId); + + const resp = await es.get( + { + index: '.kibana', + id: documentId, + }, + { ignore: [404] } + ); + + expect(resp.statusCode).to.eql(200); + expect(resp.body._source.acl).to.eql(acl); +}; + +export const assertSavedObjectMissing = async ( + es: Client, + 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_acl/common/lib/types.ts b/x-pack/test/saved_object_acl/common/lib/types.ts new file mode 100644 index 000000000000000..368258f29615cf0 --- /dev/null +++ b/x-pack/test/saved_object_acl/common/lib/types.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export 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_acl/config.ts b/x-pack/test/saved_object_acl/config.ts new file mode 100644 index 000000000000000..ae10ab51758a8a4 --- /dev/null +++ b/x-pack/test/saved_object_acl/config.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import path from 'path'; +import { FtrConfigProviderContext } from '@kbn/test/types/ftr'; +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 ACL API Integration Tests - Security and Spaces integration', + }, + esArchiver: { + directory: path.resolve(__dirname, 'fixtures', 'es_archiver'), + }, + 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_acl/fixtures/confidential_plugin/kibana.json b/x-pack/test/saved_object_acl/fixtures/confidential_plugin/kibana.json new file mode 100644 index 000000000000000..40475a3cff78e37 --- /dev/null +++ b/x-pack/test/saved_object_acl/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_acl/fixtures/confidential_plugin/server/index.ts b/x-pack/test/saved_object_acl/fixtures/confidential_plugin/server/index.ts new file mode 100644 index 000000000000000..117f13457ff5712 --- /dev/null +++ b/x-pack/test/saved_object_acl/fixtures/confidential_plugin/server/index.ts @@ -0,0 +1,70 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import 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'; + +export const plugin: PluginInitializer = () => ({ + setup(core: CoreSetup<{}>, { features }) { + core.savedObjects.registerType({ + name: CONFIDENTIAL_SAVED_OBJECT_TYPE, + hidden: false, + namespaceType: 'single', + classification: 'confidential', + management: { + importableAndExportable: true, + }, + mappings: { + properties: { + name: { type: 'keyword' }, + }, + }, + }); + + core.savedObjects.registerType({ + name: CONFIDENTIAL_MULTI_NAMESPACE_SAVED_OBJECT_TYPE, + hidden: false, + namespaceType: 'multiple', + classification: 'confidential', + 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_acl/fixtures/es_archiver/confidential_objects/data.json b/x-pack/test/saved_object_acl/fixtures/es_archiver/confidential_objects/data.json new file mode 100644 index 000000000000000..6a147e6c02d0b28 --- /dev/null +++ b/x-pack/test/saved_object_acl/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": { + "acl": { + "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": { + "acl": { + "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": { + "acl": { + "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": { + "acl": { + "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": { + "acl": { + "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": { + "acl": { + "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": { + "acl": { + "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" + }, + "acl": { + "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_acl/fixtures/es_archiver/confidential_objects/mappings.json b/x-pack/test/saved_object_acl/fixtures/es_archiver/confidential_objects/mappings.json new file mode 100644 index 000000000000000..2eac8b521ca376a --- /dev/null +++ b/x-pack/test/saved_object_acl/fixtures/es_archiver/confidential_objects/mappings.json @@ -0,0 +1,228 @@ +{ + "type": "index", + "value": { + "aliases": {}, + "index": ".kibana", + "mappings": { + "dynamic": "strict", + "properties": { + "acl": { + "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_acl/security_and_spaces/apis/bulk_create.ts b/x-pack/test/saved_object_acl/security_and_spaces/apis/bulk_create.ts new file mode 100644 index 000000000000000..77a7fef7a43c627 --- /dev/null +++ b/x-pack/test/saved_object_acl/security_and_spaces/apis/bulk_create.ts @@ -0,0 +1,196 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import { SavedObjectsBulkResponse } from 'src/core/server'; +import { CONFIDENTIAL_SAVED_OBJECT_TYPE } from '../../fixtures/confidential_plugin/server'; +import { + USERS, + ExpectedResponse, + assertSavedObjectExists, + assertSavedObjectACL, + 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('confidential_objects'); + }); + + after(async () => { + await esArchiver.unload('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.acl).to.eql({ + owner: expected.owner, + }); + } else { + expect(object.acl).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 ACL', 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 ACL 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 assertSavedObjectACL(es, 'index-pattern', savedObjectId, 'default', undefined); + }); + + it('does not allow creating an object that overwrites an object that belongs to another user', async () => { + const { username, password } = USERS.SUPERUSER; + const { httpCode, expectResponse } = unauthorizedExpectedResponse; + + const savedObjectId = 'alice_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 })); + }); + }); +} diff --git a/x-pack/test/saved_object_acl/security_and_spaces/apis/bulk_get.ts b/x-pack/test/saved_object_acl/security_and_spaces/apis/bulk_get.ts new file mode 100644 index 000000000000000..7f28d5a9c016dbb --- /dev/null +++ b/x-pack/test/saved_object_acl/security_and_spaces/apis/bulk_get.ts @@ -0,0 +1,308 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@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('confidential_objects'); + }); + + after(async () => { + await esArchiver.unload('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 404 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 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: 'index-pattern', + id: 'index_pattern_1', + }, + ]) + .expect(httpCode) + .then(expectResponse({ savedObjectType: 'index-pattern' })); + }); + + 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, + }, + ]) + ); + }); + + it('does not allow superusers to access objects from other users', async () => { + const { username, password } = USERS.SUPERUSER; + 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, + }, + ]) + ); + }); + }); +} diff --git a/x-pack/test/saved_object_acl/security_and_spaces/apis/bulk_update.ts b/x-pack/test/saved_object_acl/security_and_spaces/apis/bulk_update.ts new file mode 100644 index 000000000000000..b9d686aa2d5e349 --- /dev/null +++ b/x-pack/test/saved_object_acl/security_and_spaces/apis/bulk_update.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; + * you may not use this file except in compliance with the Elastic License. + */ + +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, + assertSavedObjectACL, +} 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('confidential_objects'); + }); + + after(async () => { + await esArchiver.unload('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 ACL 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.acl).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 ACL', 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 assertSavedObjectACL(es, CONFIDENTIAL_SAVED_OBJECT_TYPE, savedObjectId, 'default', { + owner: username, + }); + }); + + it('does not attach an ACL 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 assertSavedObjectACL(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.SUPERUSER; + const { httpCode, expectResponse } = unauthorizedExpectedResponse; + + const savedObjectId = 'alice_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 })); + }); + }); +} diff --git a/x-pack/test/saved_object_acl/security_and_spaces/apis/create.ts b/x-pack/test/saved_object_acl/security_and_spaces/apis/create.ts new file mode 100644 index 000000000000000..1576b53e2a9619c --- /dev/null +++ b/x-pack/test/saved_object_acl/security_and_spaces/apis/create.ts @@ -0,0 +1,177 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import 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('confidential_objects'); + }); + + after(async () => { + await esArchiver.unload('confidential_objects'); + }); + + const authorizedExpectedResponse: ExpectedResponse< + [{ owner?: string; attributes?: Record; type: string }] + > = { + httpCode: 200, + expectResponse: ({ owner, type: expectedType, attributes: expectedAttributes }) => ({ + body, + }) => { + const { acl, type, attributes } = body; + const requiresACL = expectedType === CONFIDENTIAL_SAVED_OBJECT_TYPE; + + const expectedACL = requiresACL ? { owner } : undefined; + + expect({ acl, type, attributes }).to.eql({ + acl: expectedACL, + 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 ACL', 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 ACL 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.SUPERUSER; + const { httpCode, expectResponse } = unauthorizedExpectedResponse; + + 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: 'hack attempt' }, + }) + .expect(httpCode) + .then(expectResponse({ type: CONFIDENTIAL_SAVED_OBJECT_TYPE, id: savedObjectId })); + }); + }); +} diff --git a/x-pack/test/saved_object_acl/security_and_spaces/apis/delete.ts b/x-pack/test/saved_object_acl/security_and_spaces/apis/delete.ts new file mode 100644 index 000000000000000..8eeca48b5ad931e --- /dev/null +++ b/x-pack/test/saved_object_acl/security_and_spaces/apis/delete.ts @@ -0,0 +1,99 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import 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('confidential_objects'); + }); + + after(async () => { + await esArchiver.unload('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.SUPERUSER; + const { httpCode, expectResponse } = unauthorizedExpectedResponse; + + const savedObjectId = 'alice_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'); + }); + }); +} diff --git a/x-pack/test/saved_object_acl/security_and_spaces/apis/export.ts b/x-pack/test/saved_object_acl/security_and_spaces/apis/export.ts new file mode 100644 index 000000000000000..eaba8d701962973 --- /dev/null +++ b/x-pack/test/saved_object_acl/security_and_spaces/apis/export.ts @@ -0,0 +1,228 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import 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('confidential_objects'); + }); + + after(async () => { + await esArchiver.unload('confidential_objects'); + }); + + const authorizedExpectedResponse: ExpectedResponse< + [ + { + expectedResults: Array>; + missingRefCount?: number; + missingReferences?: SavedObjectsExportResultDetails['missingReferences']; + } + ] + > = { + httpCode: 200, + expectResponse: ({ expectedResults, missingRefCount = 0, missingReferences = [] }) => ( + response + ) => { + const ndjson: Array> = response.text.split('\n').map(JSON.parse); + const summary = ndjson.pop(); + expect(summary).to.eql({ + exportedCount: expectedResults.length, + missingRefCount, + missingReferences, + }); + + expect(ndjson.length).to.eql(expectedResults.length); + ndjson.forEach(({ type, id, acl }, index) => { + const expected = expectedResults[index]; + expect({ type, id }).to.eql({ type: expected.type, id: expected.id }); + expect(acl).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.SUPERUSER; + const { httpCode, expectResponse } = unauthorizedForObjectExpectedResponse; + + 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({ + savedObjectType: CONFIDENTIAL_SAVED_OBJECT_TYPE, + savedObjectId: 'alice_doc_1', + }) + ); + }); + + it('does not export other users confidential objects even when referenced from public objects', async () => { + const { username, password } = USERS.SUPERUSER; + const { httpCode, expectResponse } = authorizedExpectedResponse; + + await assertSavedObjectExists(es, CONFIDENTIAL_SAVED_OBJECT_TYPE, 'alice_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: 'alice_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: 'alice_doc_1', + }, + ], + }, + ], + missingRefCount: 1, + missingReferences: [ + { + type: CONFIDENTIAL_SAVED_OBJECT_TYPE, + id: 'alice_doc_1', + }, + ], + }) + ); + }); + + it('allows confidential objects to be exported, and removes the ACL', 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_acl/security_and_spaces/apis/find.ts b/x-pack/test/saved_object_acl/security_and_spaces/apis/find.ts new file mode 100644 index 000000000000000..e74b90b499a47fe --- /dev/null +++ b/x-pack/test/saved_object_acl/security_and_spaces/apis/find.ts @@ -0,0 +1,145 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@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); + expect(object.type).to.eql(type); + expect(object.namespaces).to.eql(namespaces); + expect(object.attributes).to.be.an(Object); + }); + }, + }; + + describe('GET /api/saved_objects/_find', () => { + before(async () => { + await esArchiver.load('confidential_objects'); + }); + + after(async () => { + await esArchiver.unload('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'; + + 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'], + }, + ]) + ); + }); + + it(`does not allow superusers to find confidential objects that belong to other users`, async () => { + const { username, password } = USERS.SUPERUSER; + 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([])); + }); + }); +} diff --git a/x-pack/test/saved_object_acl/security_and_spaces/apis/get.ts b/x-pack/test/saved_object_acl/security_and_spaces/apis/get.ts new file mode 100644 index 000000000000000..65de23fd38c5a56 --- /dev/null +++ b/x-pack/test/saved_object_acl/security_and_spaces/apis/get.ts @@ -0,0 +1,143 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@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('confidential_objects'); + }); + + after(async () => { + await esArchiver.unload('confidential_objects'); + }); + + const authorizedExpectedResponse: ExpectedResponse< + [{ owner: string; savedObjectId: string }] + > = { + httpCode: 200, + expectResponse: ({ savedObjectId, owner }) => ({ body }) => { + const { acl, id, type } = body; + expect({ acl, id, type }).to.eql({ + acl: { + 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 = { + httpCode: 403, + expectResponse: () => ({ body }) => { + expect(body).to.eql({ + statusCode: 403, + error: 'Forbidden', + message: `Unable to get ${CONFIDENTIAL_SAVED_OBJECT_TYPE}`, + }); + }, + }; + + 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 404 for confidential objects that belong to another user', async () => { + const { username, password } = USERS.ALICE; + const { httpCode, expectResponse } = notFoundExpectedResponse; + 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({ 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()); + }); + + 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 })); + }); + + it('does not allow superusers to access objects from other users', async () => { + const { username, password } = USERS.SUPERUSER; + const { httpCode, expectResponse } = notFoundExpectedResponse; + 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 })); + }); + }); +} diff --git a/x-pack/test/saved_object_acl/security_and_spaces/apis/import.ts b/x-pack/test/saved_object_acl/security_and_spaces/apis/import.ts new file mode 100644 index 000000000000000..9893b423c377700 --- /dev/null +++ b/x-pack/test/saved_object_acl/security_and_spaces/apis/import.ts @@ -0,0 +1,294 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@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, + assertSavedObjectACL, +} 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('confidential_objects'); + }); + + after(async () => { + await esArchiver.unload('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 ACL', 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 assertSavedObjectACL( + 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 assertSavedObjectACL( + es, + CONFIDENTIAL_SAVED_OBJECT_TYPE, + 'alice_imported_doc', + 'default', + { owner: username } + ); + }); + + it('does not attach an ACL 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 assertSavedObjectACL(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 assertSavedObjectACL(es, CONFIDENTIAL_SAVED_OBJECT_TYPE, savedObjectId, 'default', { + owner: USERS.ALICE.username, + }); + }); + }); +} diff --git a/x-pack/test/saved_object_acl/security_and_spaces/apis/index.ts b/x-pack/test/saved_object_acl/security_and_spaces/apis/index.ts new file mode 100644 index 000000000000000..418850e069b8dd0 --- /dev/null +++ b/x-pack/test/saved_object_acl/security_and_spaces/apis/index.ts @@ -0,0 +1,37 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +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 ACL - 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_acl/security_and_spaces/apis/resolve.ts b/x-pack/test/saved_object_acl/security_and_spaces/apis/resolve.ts new file mode 100644 index 000000000000000..0e5e1ed457702a1 --- /dev/null +++ b/x-pack/test/saved_object_acl/security_and_spaces/apis/resolve.ts @@ -0,0 +1,201 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import { SavedObjectsResolveResponse, SavedObjectACL } 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('confidential_objects'); + }); + + after(async () => { + await esArchiver.unload('confidential_objects'); + }); + + const authorizedExpectedResponse: ExpectedResponse< + [ + { + savedObject: { type: string; id: string; acl: SavedObjectACL }; + outcome: SavedObjectsResolveResponse['outcome']; + } + ] + > = { + httpCode: 200, + expectResponse: ({ savedObject, outcome }) => ({ body }) => { + expect(body.outcome).to.eql(outcome); + const { type, id, acl } = body.saved_object; + expect({ type, id, acl }).to.eql({ + type: savedObject.type, + id: savedObject.id, + acl: savedObject.acl, + }); + }, + }; + + 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, + acl: { 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', + acl: { owner: username }, + }, + outcome: 'aliasMatch', + }) + ); + }); + + it('does not allow superusers to access objects from other users (exact match)', async () => { + const { username, password } = USERS.SUPERUSER; + const { httpCode, expectResponse } = unauthorizedExpectedResponse; + 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({ type: CONFIDENTIAL_SAVED_OBJECT_TYPE, id: savedObjectId })); + }); + + it('does not allow superusers to access objects from other users (alias match)', async () => { + const { username, password } = USERS.SUPERUSER; + const { httpCode, expectResponse } = unauthorizedExpectedResponse; + 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({ + type: CONFIDENTIAL_MULTI_NAMESPACE_SAVED_OBJECT_TYPE, + id: 'alice_alias-match-newid', + }) + ); + }); + }); +} diff --git a/x-pack/test/saved_object_acl/security_and_spaces/apis/resolve_import_errors.ts b/x-pack/test/saved_object_acl/security_and_spaces/apis/resolve_import_errors.ts new file mode 100644 index 000000000000000..158d39e4ce01379 --- /dev/null +++ b/x-pack/test/saved_object_acl/security_and_spaces/apis/resolve_import_errors.ts @@ -0,0 +1,364 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@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, + assertSavedObjectACL, + 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('confidential_objects'); + }); + + after(async () => { + await esArchiver.unload('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 assertSavedObjectACL(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 assertSavedObjectACL(es, CONFIDENTIAL_SAVED_OBJECT_TYPE, savedObjectId, 'default', { + owner: username, + }); + await assertSavedObjectACL(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 assertSavedObjectACL(es, CONFIDENTIAL_SAVED_OBJECT_TYPE, savedObjectId, 'default', { + owner: USERS.ALICE.username, + }); + // new object should belong to the importer + await assertSavedObjectACL(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 assertSavedObjectACL(es, CONFIDENTIAL_SAVED_OBJECT_TYPE, savedObjectId, 'default', { + owner: USERS.ALICE.username, + }); + // targeted existing object should also have the old ACL + await assertSavedObjectACL(es, CONFIDENTIAL_SAVED_OBJECT_TYPE, newSavedObjectId, 'default', { + owner: USERS.CHARLIE.username, + }); + }); + }); +} diff --git a/x-pack/test/saved_object_acl/security_and_spaces/apis/update.ts b/x-pack/test/saved_object_acl/security_and_spaces/apis/update.ts new file mode 100644 index 000000000000000..adee88c8ebdc9aa --- /dev/null +++ b/x-pack/test/saved_object_acl/security_and_spaces/apis/update.ts @@ -0,0 +1,135 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@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('confidential_objects'); + }); + + after(async () => { + await esArchiver.unload('confidential_objects'); + }); + + const authorizedExpectedResponse: ExpectedResponse< + [{ owner?: string; attributes?: Record; type: string }] + > = { + httpCode: 200, + expectResponse: ({ owner, type: expectedType, attributes: expectedAttributes }) => ({ + body, + }) => { + const { acl, type, attributes, error } = body; + const requiresACL = expectedType === CONFIDENTIAL_SAVED_OBJECT_TYPE; + + const expectedACL = requiresACL ? { owner } : undefined; + + expect(error).to.eql(undefined); + + expect({ acl, type, attributes }).to.eql({ + acl: expectedACL, + 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 ACL', 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 ACL 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.SUPERUSER; + const { httpCode, expectResponse } = unauthorizedExpectedResponse; + + const savedObjectId = 'alice_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 })); + }); + }); +} diff --git a/x-pack/test/saved_object_acl/services.ts b/x-pack/test/saved_object_acl/services.ts new file mode 100644 index 000000000000000..b15aa81414b73b5 --- /dev/null +++ b/x-pack/test/saved_object_acl/services.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { GenericFtrProviderContext } from '@kbn/test/types/ftr'; +import { services as apiIntegrationServices } from '../api_integration/services'; + +export const services = { + ...apiIntegrationServices, +}; + +export type FtrProviderContext = GenericFtrProviderContext; diff --git a/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/mappings.json b/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/mappings.json index 561c2ecc56fa260..0053f41e502b84f 100644 --- a/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/mappings.json +++ b/x-pack/test/saved_object_api_integration/common/fixtures/es_archiver/saved_objects/spaces/mappings.json @@ -93,6 +93,19 @@ } } }, + "confidentialtype": { + "properties": { + "title": { + "fields": { + "keyword": { + "ignore_above": 2048, + "type": "keyword" + } + }, + "type": "text" + } + } + }, "graph-workspace": { "properties": { "description": {