From 65186b3393b6c58c020226d8fbadf84e02c0e4a0 Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin Date: Thu, 14 May 2020 07:59:11 +0200 Subject: [PATCH] Allow encrypted saved-object properties to be accessed by end-users (#64941) Co-authored-by: kobelb --- .../encrypted_saved_objects/kibana.json | 1 + .../server/audit/audit_logger.test.ts | 69 ++- .../server/audit/audit_logger.ts | 27 +- ...ypted_saved_object_type_definition.test.ts | 113 ++++ .../encrypted_saved_object_type_definition.ts | 67 +++ .../encrypted_saved_objects_service.test.ts | 408 ++++++++++----- .../crypto/encrypted_saved_objects_service.ts | 165 ++++-- .../server/crypto/encryption_error.test.ts | 22 +- .../server/crypto/encryption_error.ts | 13 + .../server/crypto/index.mock.ts | 22 +- .../server/plugin.test.ts | 6 +- .../encrypted_saved_objects/server/plugin.ts | 11 +- ...ypted_saved_objects_client_wrapper.test.ts | 492 ++++++++++++++++-- .../encrypted_saved_objects_client_wrapper.ts | 132 +++-- .../server/saved_objects/index.test.ts | 140 +++++ .../server/saved_objects/index.ts | 31 +- .../api_consumer_plugin/server/index.ts | 59 ++- .../tests/encrypted_saved_objects_api.ts | 272 ++++++++-- 18 files changed, 1712 insertions(+), 338 deletions(-) create mode 100644 x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_object_type_definition.test.ts create mode 100644 x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_object_type_definition.ts create mode 100644 x-pack/plugins/encrypted_saved_objects/server/saved_objects/index.test.ts diff --git a/x-pack/plugins/encrypted_saved_objects/kibana.json b/x-pack/plugins/encrypted_saved_objects/kibana.json index 8bf214a4d7c3..74f797ba36a1 100644 --- a/x-pack/plugins/encrypted_saved_objects/kibana.json +++ b/x-pack/plugins/encrypted_saved_objects/kibana.json @@ -3,6 +3,7 @@ "version": "8.0.0", "kibanaVersion": "kibana", "configPath": ["xpack", "encryptedSavedObjects"], + "optionalPlugins": ["security"], "server": true, "ui": false } diff --git a/x-pack/plugins/encrypted_saved_objects/server/audit/audit_logger.test.ts b/x-pack/plugins/encrypted_saved_objects/server/audit/audit_logger.test.ts index 3553a3bc3477..760c4ef01b31 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/audit/audit_logger.test.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/audit/audit_logger.test.ts @@ -5,8 +5,9 @@ */ import { EncryptedSavedObjectsAuditLogger } from './audit_logger'; +import { mockAuthenticatedUser } from '../../../security/common/model/authenticated_user.mock'; -test('properly logs audit events', () => { +it('properly logs audit events', () => { const mockInternalAuditLogger = { log: jest.fn() }; const audit = new EncryptedSavedObjectsAuditLogger(() => mockInternalAuditLogger); @@ -19,6 +20,11 @@ test('properly logs audit events', () => { id: 'object-id-ns', namespace: 'object-ns', }); + audit.encryptAttributesSuccess( + ['one', 'two'], + { type: 'known-type-ns', id: 'object-id-ns', namespace: 'object-ns' }, + mockAuthenticatedUser() + ); audit.decryptAttributesSuccess(['three', 'four'], { type: 'known-type-1', @@ -29,6 +35,11 @@ test('properly logs audit events', () => { id: 'object-id-1-ns', namespace: 'object-ns', }); + audit.decryptAttributesSuccess( + ['three', 'four'], + { type: 'known-type-1-ns', id: 'object-id-1-ns', namespace: 'object-ns' }, + mockAuthenticatedUser() + ); audit.encryptAttributeFailure('five', { type: 'known-type-2', @@ -39,6 +50,11 @@ test('properly logs audit events', () => { id: 'object-id-2-ns', namespace: 'object-ns', }); + audit.encryptAttributeFailure( + 'five', + { type: 'known-type-2-ns', id: 'object-id-2-ns', namespace: 'object-ns' }, + mockAuthenticatedUser() + ); audit.decryptAttributeFailure('six', { type: 'known-type-3', @@ -49,8 +65,13 @@ test('properly logs audit events', () => { id: 'object-id-3-ns', namespace: 'object-ns', }); + audit.decryptAttributeFailure( + 'six', + { type: 'known-type-3-ns', id: 'object-id-3-ns', namespace: 'object-ns' }, + mockAuthenticatedUser() + ); - expect(mockInternalAuditLogger.log).toHaveBeenCalledTimes(8); + expect(mockInternalAuditLogger.log).toHaveBeenCalledTimes(12); expect(mockInternalAuditLogger.log).toHaveBeenCalledWith( 'encrypt_success', 'Successfully encrypted attributes "[one,two]" for saved object "[known-type,object-id]".', @@ -66,6 +87,17 @@ test('properly logs audit events', () => { attributesNames: ['one', 'two'], } ); + expect(mockInternalAuditLogger.log).toHaveBeenCalledWith( + 'encrypt_success', + 'Successfully encrypted attributes "[one,two]" for saved object "[object-ns,known-type-ns,object-id-ns]".', + { + id: 'object-id-ns', + type: 'known-type-ns', + namespace: 'object-ns', + attributesNames: ['one', 'two'], + username: 'user', + } + ); expect(mockInternalAuditLogger.log).toHaveBeenCalledWith( 'decrypt_success', @@ -82,6 +114,17 @@ test('properly logs audit events', () => { attributesNames: ['three', 'four'], } ); + expect(mockInternalAuditLogger.log).toHaveBeenCalledWith( + 'decrypt_success', + 'Successfully decrypted attributes "[three,four]" for saved object "[object-ns,known-type-1-ns,object-id-1-ns]".', + { + id: 'object-id-1-ns', + type: 'known-type-1-ns', + namespace: 'object-ns', + attributesNames: ['three', 'four'], + username: 'user', + } + ); expect(mockInternalAuditLogger.log).toHaveBeenCalledWith( 'encrypt_failure', @@ -93,6 +136,17 @@ test('properly logs audit events', () => { 'Failed to encrypt attribute "five" for saved object "[object-ns,known-type-2-ns,object-id-2-ns]".', { id: 'object-id-2-ns', type: 'known-type-2-ns', namespace: 'object-ns', attributeName: 'five' } ); + expect(mockInternalAuditLogger.log).toHaveBeenCalledWith( + 'encrypt_failure', + 'Failed to encrypt attribute "five" for saved object "[object-ns,known-type-2-ns,object-id-2-ns]".', + { + id: 'object-id-2-ns', + type: 'known-type-2-ns', + namespace: 'object-ns', + attributeName: 'five', + username: 'user', + } + ); expect(mockInternalAuditLogger.log).toHaveBeenCalledWith( 'decrypt_failure', @@ -104,4 +158,15 @@ test('properly logs audit events', () => { 'Failed to decrypt attribute "six" for saved object "[object-ns,known-type-3-ns,object-id-3-ns]".', { id: 'object-id-3-ns', type: 'known-type-3-ns', namespace: 'object-ns', attributeName: 'six' } ); + expect(mockInternalAuditLogger.log).toHaveBeenCalledWith( + 'decrypt_failure', + 'Failed to decrypt attribute "six" for saved object "[object-ns,known-type-3-ns,object-id-3-ns]".', + { + id: 'object-id-3-ns', + type: 'known-type-3-ns', + namespace: 'object-ns', + attributeName: 'six', + username: 'user', + } + ); }); diff --git a/x-pack/plugins/encrypted_saved_objects/server/audit/audit_logger.ts b/x-pack/plugins/encrypted_saved_objects/server/audit/audit_logger.ts index 9eb0a1e0872c..1a10dd343d43 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/audit/audit_logger.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/audit/audit_logger.ts @@ -6,6 +6,7 @@ import { SavedObjectDescriptor, descriptorToArray } from '../crypto'; import { LegacyAPI } from '../plugin'; +import { AuthenticatedUser } from '../../../security/common/model'; /** * Represents all audit events the plugin can log. @@ -13,49 +14,59 @@ import { LegacyAPI } from '../plugin'; export class EncryptedSavedObjectsAuditLogger { constructor(private readonly getAuditLogger: () => LegacyAPI['auditLogger']) {} - public encryptAttributeFailure(attributeName: string, descriptor: SavedObjectDescriptor) { + public encryptAttributeFailure( + attributeName: string, + descriptor: SavedObjectDescriptor, + user?: AuthenticatedUser + ) { this.getAuditLogger().log( 'encrypt_failure', `Failed to encrypt attribute "${attributeName}" for saved object "[${descriptorToArray( descriptor )}]".`, - { ...descriptor, attributeName } + { ...descriptor, attributeName, username: user?.username } ); } - public decryptAttributeFailure(attributeName: string, descriptor: SavedObjectDescriptor) { + public decryptAttributeFailure( + attributeName: string, + descriptor: SavedObjectDescriptor, + user?: AuthenticatedUser + ) { this.getAuditLogger().log( 'decrypt_failure', `Failed to decrypt attribute "${attributeName}" for saved object "[${descriptorToArray( descriptor )}]".`, - { ...descriptor, attributeName } + { ...descriptor, attributeName, username: user?.username } ); } public encryptAttributesSuccess( attributesNames: readonly string[], - descriptor: SavedObjectDescriptor + descriptor: SavedObjectDescriptor, + user?: AuthenticatedUser ) { this.getAuditLogger().log( 'encrypt_success', `Successfully encrypted attributes "[${attributesNames}]" for saved object "[${descriptorToArray( descriptor )}]".`, - { ...descriptor, attributesNames } + { ...descriptor, attributesNames, username: user?.username } ); } public decryptAttributesSuccess( attributesNames: readonly string[], - descriptor: SavedObjectDescriptor + descriptor: SavedObjectDescriptor, + user?: AuthenticatedUser ) { this.getAuditLogger().log( 'decrypt_success', `Successfully decrypted attributes "[${attributesNames}]" for saved object "[${descriptorToArray( descriptor )}]".`, - { ...descriptor, attributesNames } + { ...descriptor, attributesNames, username: user?.username } ); } } diff --git a/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_object_type_definition.test.ts b/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_object_type_definition.test.ts new file mode 100644 index 000000000000..a82a162a14e2 --- /dev/null +++ b/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_object_type_definition.test.ts @@ -0,0 +1,113 @@ +/* + * 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 { EncryptedSavedObjectTypeRegistration } from './encrypted_saved_objects_service'; +import { EncryptedSavedObjectAttributesDefinition } from './encrypted_saved_object_type_definition'; + +it('correctly determines attribute properties', () => { + const attributes = ['attr#1', 'attr#2', 'attr#3', 'attr#4']; + const cases: Array<[ + EncryptedSavedObjectTypeRegistration, + { + shouldBeEncrypted: boolean[]; + shouldBeExcludedFromAAD: boolean[]; + shouldBeStripped: boolean[]; + } + ]> = [ + [ + { + type: 'so-type', + attributesToEncrypt: new Set(['attr#1', 'attr#2', 'attr#3', 'attr#4']), + }, + { + shouldBeEncrypted: [true, true, true, true], + shouldBeExcludedFromAAD: [true, true, true, true], + shouldBeStripped: [true, true, true, true], + }, + ], + [ + { + type: 'so-type', + attributesToEncrypt: new Set(['attr#1', 'attr#2']), + }, + { + shouldBeEncrypted: [true, true, false, false], + shouldBeExcludedFromAAD: [true, true, false, false], + shouldBeStripped: [true, true, false, false], + }, + ], + [ + { + type: 'so-type', + attributesToEncrypt: new Set([{ key: 'attr#1' }, { key: 'attr#2' }]), + }, + { + shouldBeEncrypted: [true, true, false, false], + shouldBeExcludedFromAAD: [true, true, false, false], + shouldBeStripped: [true, true, false, false], + }, + ], + [ + { + type: 'so-type', + attributesToEncrypt: new Set(['attr#1', 'attr#2']), + attributesToExcludeFromAAD: new Set(['attr#3']), + }, + { + shouldBeEncrypted: [true, true, false, false], + shouldBeExcludedFromAAD: [true, true, true, false], + shouldBeStripped: [true, true, false, false], + }, + ], + [ + { + type: 'so-type', + attributesToEncrypt: new Set([ + 'attr#1', + 'attr#2', + { key: 'attr#4', dangerouslyExposeValue: true }, + ]), + attributesToExcludeFromAAD: new Set(['attr#3']), + }, + { + shouldBeEncrypted: [true, true, false, true], + shouldBeExcludedFromAAD: [true, true, true, true], + shouldBeStripped: [true, true, false, false], + }, + ], + [ + { + type: 'so-type', + attributesToEncrypt: new Set([ + { key: 'attr#1', dangerouslyExposeValue: true }, + 'attr#2', + { key: 'attr#4', dangerouslyExposeValue: true }, + ]), + attributesToExcludeFromAAD: new Set(['some-other-attribute']), + }, + { + shouldBeEncrypted: [true, true, false, true], + shouldBeExcludedFromAAD: [true, true, false, true], + shouldBeStripped: [false, true, false, false], + }, + ], + ]; + + for (const [typeRegistration, asserts] of cases) { + const typeDefinition = new EncryptedSavedObjectAttributesDefinition(typeRegistration); + for (const [attributeIndex, attributeName] of attributes.entries()) { + expect(typeDefinition.shouldBeEncrypted(attributeName)).toBe( + asserts.shouldBeEncrypted[attributeIndex] + ); + expect(typeDefinition.shouldBeStripped(attributeName)).toBe( + asserts.shouldBeStripped[attributeIndex] + ); + expect(typeDefinition.shouldBeExcludedFromAAD(attributeName)).toBe( + asserts.shouldBeExcludedFromAAD[attributeIndex] + ); + } + } +}); diff --git a/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_object_type_definition.ts b/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_object_type_definition.ts new file mode 100644 index 000000000000..849a2888b6e1 --- /dev/null +++ b/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_object_type_definition.ts @@ -0,0 +1,67 @@ +/* + * 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 { EncryptedSavedObjectTypeRegistration } from './encrypted_saved_objects_service'; + +/** + * Represents the definition of the attributes of the specific saved object that are supposed to be + * encrypted. The definition also dictates which attributes should be excluded from AAD and/or + * stripped from response. + */ +export class EncryptedSavedObjectAttributesDefinition { + public readonly attributesToEncrypt: ReadonlySet; + private readonly attributesToExcludeFromAAD: ReadonlySet | undefined; + private readonly attributesToStrip: ReadonlySet; + + constructor(typeRegistration: EncryptedSavedObjectTypeRegistration) { + const attributesToEncrypt = new Set(); + const attributesToStrip = new Set(); + for (const attribute of typeRegistration.attributesToEncrypt) { + if (typeof attribute === 'string') { + attributesToEncrypt.add(attribute); + attributesToStrip.add(attribute); + } else { + attributesToEncrypt.add(attribute.key); + if (!attribute.dangerouslyExposeValue) { + attributesToStrip.add(attribute.key); + } + } + } + + this.attributesToEncrypt = attributesToEncrypt; + this.attributesToStrip = attributesToStrip; + this.attributesToExcludeFromAAD = typeRegistration.attributesToExcludeFromAAD; + } + + /** + * Determines whether particular attribute should be encrypted. Full list of attributes that + * should be encrypted can be retrieved via `attributesToEncrypt` property. + * @param attributeName Name of the attribute. + */ + public shouldBeEncrypted(attributeName: string) { + return this.attributesToEncrypt.has(attributeName); + } + + /** + * Determines whether particular attribute should be excluded from AAD. + * @param attributeName Name of the attribute. + */ + public shouldBeExcludedFromAAD(attributeName: string) { + return ( + this.shouldBeEncrypted(attributeName) || + (this.attributesToExcludeFromAAD != null && + this.attributesToExcludeFromAAD.has(attributeName)) + ); + } + + /** + * Determines whether particular attribute should be stripped from the attribute list. + * @param attributeName Name of the attribute. + */ + public shouldBeStripped(attributeName: string) { + return this.attributesToStrip.has(attributeName); + } +} diff --git a/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.test.ts b/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.test.ts index be33238a26ee..6ece9d1be8ec 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.test.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.test.ts @@ -4,6 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ +import { mockAuthenticatedUser } from '../../../security/common/model/authenticated_user.mock'; + jest.mock('@elastic/node-crypto', () => jest.fn()); import { EncryptedSavedObjectsAuditLogger } from '../audit'; @@ -72,40 +74,26 @@ describe('#isRegistered', () => { }); }); -describe('#stripEncryptedAttributes', () => { - it('does not strip attributes from unknown types', () => { +describe('#stripOrDecryptAttributes', () => { + it('does not strip attributes from unknown types', async () => { const attributes = { attrOne: 'one', attrTwo: 'two', attrThree: 'three' }; - expect(service.stripEncryptedAttributes('unknown-type', attributes)).toEqual({ - attrOne: 'one', - attrTwo: 'two', - attrThree: 'three', - }); + await expect( + service.stripOrDecryptAttributes({ id: 'unknown-id', type: 'unknown-type' }, attributes) + ).resolves.toEqual({ attributes: { attrOne: 'one', attrTwo: 'two', attrThree: 'three' } }); }); - it('does not strip attributes from known, but not registered types', () => { - const attributes = { attrOne: 'one', attrTwo: 'two', attrThree: 'three' }; - - expect(service.stripEncryptedAttributes('known-type-1', attributes)).toEqual({ - attrOne: 'one', - attrTwo: 'two', - attrThree: 'three', - }); - }); - - it('does not strip any attributes if none of them are supposed to be encrypted', () => { + it('does not strip any attributes if none of them are supposed to be encrypted', async () => { const attributes = { attrOne: 'one', attrTwo: 'two', attrThree: 'three' }; service.registerType({ type: 'known-type-1', attributesToEncrypt: new Set(['attrFour']) }); - expect(service.stripEncryptedAttributes('known-type-1', attributes)).toEqual({ - attrOne: 'one', - attrTwo: 'two', - attrThree: 'three', - }); + await expect( + service.stripOrDecryptAttributes({ id: 'known-id', type: 'known-type-1' }, attributes) + ).resolves.toEqual({ attributes: { attrOne: 'one', attrTwo: 'two', attrThree: 'three' } }); }); - it('strips only attributes that are supposed to be encrypted', () => { + it('strips only attributes that are supposed to be encrypted', async () => { const attributes = { attrOne: 'one', attrTwo: 'two', attrThree: 'three' }; service.registerType({ @@ -113,8 +101,113 @@ describe('#stripEncryptedAttributes', () => { attributesToEncrypt: new Set(['attrOne', 'attrThree']), }); - expect(service.stripEncryptedAttributes('known-type-1', attributes)).toEqual({ - attrTwo: 'two', + await expect( + service.stripOrDecryptAttributes({ id: 'known-id', type: 'known-type-1' }, attributes) + ).resolves.toEqual({ attributes: { attrTwo: 'two' } }); + }); + + describe('with `dangerouslyExposeValue`', () => { + it('decrypts and exposes values with `dangerouslyExposeValue` set to `true`', async () => { + service.registerType({ + type: 'known-type-1', + attributesToEncrypt: new Set([ + 'attrOne', + { key: 'attrThree', dangerouslyExposeValue: true }, + ]), + }); + + const attributes = { attrOne: 'one', attrTwo: 'two', attrThree: 'three' }; + const encryptedAttributes = await service.encryptAttributes( + { type: 'known-type-1', id: 'object-id' }, + attributes + ); + + const mockUser = mockAuthenticatedUser(); + await expect( + service.stripOrDecryptAttributes( + { type: 'known-type-1', id: 'object-id' }, + encryptedAttributes, + undefined, + { user: mockUser } + ) + ).resolves.toEqual({ attributes: { attrTwo: 'two', attrThree: 'three' } }); + + expect(mockAuditLogger.decryptAttributesSuccess).toHaveBeenCalledTimes(1); + expect(mockAuditLogger.decryptAttributesSuccess).toHaveBeenCalledWith( + ['attrThree'], + { type: 'known-type-1', id: 'object-id' }, + mockUser + ); + }); + + it('exposes values with `dangerouslyExposeValue` set to `true` using original attributes if provided', async () => { + service.registerType({ + type: 'known-type-1', + attributesToEncrypt: new Set([ + 'attrOne', + { key: 'attrThree', dangerouslyExposeValue: true }, + ]), + }); + + const attributes = { attrOne: 'one', attrTwo: 'two', attrThree: 'three' }; + const encryptedAttributes = { + attrOne: 'fake-enc-one', + attrTwo: 'two', + attrThree: 'fake-enc-three', + }; + + await expect( + service.stripOrDecryptAttributes( + { type: 'known-type-1', id: 'object-id' }, + encryptedAttributes, + attributes + ) + ).resolves.toEqual({ attributes: { attrTwo: 'two', attrThree: 'three' } }); + + expect(mockAuditLogger.decryptAttributesSuccess).not.toHaveBeenCalled(); + expect(mockAuditLogger.decryptAttributeFailure).not.toHaveBeenCalled(); + }); + + it('strips attributes with `dangerouslyExposeValue` set to `true` if failed to decrypt', async () => { + service.registerType({ + type: 'known-type-1', + attributesToEncrypt: new Set([ + 'attrOne', + { key: 'attrThree', dangerouslyExposeValue: true }, + ]), + }); + + const attributes = { + attrZero: 'zero', + attrOne: 'one', + attrTwo: 'two', + attrThree: 'three', + attrFour: 'four', + }; + const encryptedAttributes = await service.encryptAttributes( + { type: 'known-type-1', id: 'object-id' }, + attributes + ); + + encryptedAttributes.attrThree = 'some-undecryptable-value'; + + const mockUser = mockAuthenticatedUser(); + const { attributes: decryptedAttributes, error } = await service.stripOrDecryptAttributes( + { type: 'known-type-1', id: 'object-id' }, + encryptedAttributes, + undefined, + { user: mockUser } + ); + + expect(decryptedAttributes).toEqual({ attrZero: 'zero', attrTwo: 'two', attrFour: 'four' }); + expect(error).toMatchInlineSnapshot(`[Error: Unable to decrypt attribute "attrThree"]`); + + expect(mockAuditLogger.decryptAttributesSuccess).not.toHaveBeenCalled(); + expect(mockAuditLogger.decryptAttributeFailure).toHaveBeenCalledWith( + 'attrThree', + { type: 'known-type-1', id: 'object-id' }, + mockUser + ); }); }); }); @@ -183,8 +276,11 @@ describe('#encryptAttributes', () => { attributesToEncrypt: new Set(['attrOne', 'attrThree', 'attrFour']), }); + const mockUser = mockAuthenticatedUser(); await expect( - service.encryptAttributes({ type: 'known-type-1', id: 'object-id' }, attributes) + service.encryptAttributes({ type: 'known-type-1', id: 'object-id' }, attributes, { + user: mockUser, + }) ).resolves.toEqual({ attrOne: '|one|["known-type-1","object-id",{"attrTwo":"two"}]|', attrTwo: 'two', @@ -194,7 +290,8 @@ describe('#encryptAttributes', () => { expect(mockAuditLogger.encryptAttributesSuccess).toHaveBeenCalledTimes(1); expect(mockAuditLogger.encryptAttributesSuccess).toHaveBeenCalledWith( ['attrOne', 'attrThree'], - { type: 'known-type-1', id: 'object-id' } + { type: 'known-type-1', id: 'object-id' }, + mockUser ); }); @@ -206,17 +303,21 @@ describe('#encryptAttributes', () => { attributesToEncrypt: new Set(['attrOne', 'attrThree']), }); + const mockUser = mockAuthenticatedUser(); await expect( - service.encryptAttributes({ type: 'known-type-1', id: 'object-id' }, attributes) + service.encryptAttributes({ type: 'known-type-1', id: 'object-id' }, attributes, { + user: mockUser, + }) ).resolves.toEqual({ attrTwo: 'two', attrThree: '|three|["known-type-1","object-id",{"attrTwo":"two"}]|', }); expect(mockAuditLogger.encryptAttributesSuccess).toHaveBeenCalledTimes(1); - expect(mockAuditLogger.encryptAttributesSuccess).toHaveBeenCalledWith(['attrThree'], { - type: 'known-type-1', - id: 'object-id', - }); + expect(mockAuditLogger.encryptAttributesSuccess).toHaveBeenCalledWith( + ['attrThree'], + { type: 'known-type-1', id: 'object-id' }, + mockUser + ); }); it('includes `namespace` into AAD if provided', async () => { @@ -227,21 +328,23 @@ describe('#encryptAttributes', () => { attributesToEncrypt: new Set(['attrOne', 'attrThree']), }); + const mockUser = mockAuthenticatedUser(); await expect( service.encryptAttributes( { type: 'known-type-1', id: 'object-id', namespace: 'object-ns' }, - attributes + attributes, + { user: mockUser } ) ).resolves.toEqual({ attrTwo: 'two', attrThree: '|three|["object-ns","known-type-1","object-id",{"attrTwo":"two"}]|', }); expect(mockAuditLogger.encryptAttributesSuccess).toHaveBeenCalledTimes(1); - expect(mockAuditLogger.encryptAttributesSuccess).toHaveBeenCalledWith(['attrThree'], { - type: 'known-type-1', - id: 'object-id', - namespace: 'object-ns', - }); + expect(mockAuditLogger.encryptAttributesSuccess).toHaveBeenCalledWith( + ['attrThree'], + { type: 'known-type-1', id: 'object-id', namespace: 'object-ns' }, + mockUser + ); }); it('does not include specified attributes to AAD', async () => { @@ -300,8 +403,11 @@ describe('#encryptAttributes', () => { .mockResolvedValueOnce('Successfully encrypted attrOne') .mockRejectedValueOnce(new Error('Something went wrong with attrThree...')); + const mockUser = mockAuthenticatedUser(); await expect( - service.encryptAttributes({ type: 'known-type-1', id: 'object-id' }, attributes) + service.encryptAttributes({ type: 'known-type-1', id: 'object-id' }, attributes, { + user: mockUser, + }) ).rejects.toThrowError(EncryptionError); expect(attributes).toEqual({ @@ -311,10 +417,11 @@ describe('#encryptAttributes', () => { }); expect(mockAuditLogger.encryptAttributesSuccess).not.toHaveBeenCalled(); expect(mockAuditLogger.encryptAttributeFailure).toHaveBeenCalledTimes(1); - expect(mockAuditLogger.encryptAttributeFailure).toHaveBeenCalledWith('attrThree', { - type: 'known-type-1', - id: 'object-id', - }); + expect(mockAuditLogger.encryptAttributeFailure).toHaveBeenCalledWith( + 'attrThree', + { type: 'known-type-1', id: 'object-id' }, + mockUser + ); }); }); @@ -379,8 +486,11 @@ describe('#decryptAttributes', () => { attrFour: null, }); + const mockUser = mockAuthenticatedUser(); await expect( - service.decryptAttributes({ type: 'known-type-1', id: 'object-id' }, encryptedAttributes) + service.decryptAttributes({ type: 'known-type-1', id: 'object-id' }, encryptedAttributes, { + user: mockUser, + }) ).resolves.toEqual({ attrOne: 'one', attrTwo: 'two', @@ -390,7 +500,8 @@ describe('#decryptAttributes', () => { expect(mockAuditLogger.decryptAttributesSuccess).toHaveBeenCalledTimes(1); expect(mockAuditLogger.decryptAttributesSuccess).toHaveBeenCalledWith( ['attrOne', 'attrThree'], - { type: 'known-type-1', id: 'object-id' } + { type: 'known-type-1', id: 'object-id' }, + mockUser ); }); @@ -411,17 +522,21 @@ describe('#decryptAttributes', () => { attrThree: expect.not.stringMatching(/^three$/), }); + const mockUser = mockAuthenticatedUser(); await expect( - service.decryptAttributes({ type: 'known-type-1', id: 'object-id' }, encryptedAttributes) + service.decryptAttributes({ type: 'known-type-1', id: 'object-id' }, encryptedAttributes, { + user: mockUser, + }) ).resolves.toEqual({ attrTwo: 'two', attrThree: 'three', }); expect(mockAuditLogger.decryptAttributesSuccess).toHaveBeenCalledTimes(1); - expect(mockAuditLogger.decryptAttributesSuccess).toHaveBeenCalledWith(['attrThree'], { - type: 'known-type-1', - id: 'object-id', - }); + expect(mockAuditLogger.decryptAttributesSuccess).toHaveBeenCalledWith( + ['attrThree'], + { type: 'known-type-1', id: 'object-id' }, + mockUser + ); }); it('decrypts if all attributes that contribute to AAD are present', async () => { @@ -445,17 +560,21 @@ describe('#decryptAttributes', () => { const attributesWithoutAttr = { attrTwo: 'two', attrThree: encryptedAttributes.attrThree }; + const mockUser = mockAuthenticatedUser(); await expect( - service.decryptAttributes({ type: 'known-type-1', id: 'object-id' }, attributesWithoutAttr) + service.decryptAttributes({ type: 'known-type-1', id: 'object-id' }, attributesWithoutAttr, { + user: mockUser, + }) ).resolves.toEqual({ attrTwo: 'two', attrThree: 'three', }); expect(mockAuditLogger.decryptAttributesSuccess).toHaveBeenCalledTimes(1); - expect(mockAuditLogger.decryptAttributesSuccess).toHaveBeenCalledWith(['attrThree'], { - type: 'known-type-1', - id: 'object-id', - }); + expect(mockAuditLogger.decryptAttributesSuccess).toHaveBeenCalledWith( + ['attrThree'], + { type: 'known-type-1', id: 'object-id' }, + mockUser + ); }); it('decrypts even if attributes in AAD are defined in a different order', async () => { @@ -482,10 +601,12 @@ describe('#decryptAttributes', () => { attrOne: 'one', }; + const mockUser = mockAuthenticatedUser(); await expect( service.decryptAttributes( { type: 'known-type-1', id: 'object-id' }, - attributesInDifferentOrder + attributesInDifferentOrder, + { user: mockUser } ) ).resolves.toEqual({ attrOne: 'one', @@ -493,10 +614,11 @@ describe('#decryptAttributes', () => { attrThree: 'three', }); expect(mockAuditLogger.decryptAttributesSuccess).toHaveBeenCalledTimes(1); - expect(mockAuditLogger.decryptAttributesSuccess).toHaveBeenCalledWith(['attrThree'], { - type: 'known-type-1', - id: 'object-id', - }); + expect(mockAuditLogger.decryptAttributesSuccess).toHaveBeenCalledWith( + ['attrThree'], + { type: 'known-type-1', id: 'object-id' }, + mockUser + ); }); it('decrypts if correct namespace is provided', async () => { @@ -517,10 +639,12 @@ describe('#decryptAttributes', () => { attrThree: expect.not.stringMatching(/^three$/), }); + const mockUser = mockAuthenticatedUser(); await expect( service.decryptAttributes( { type: 'known-type-1', id: 'object-id', namespace: 'object-ns' }, - encryptedAttributes + encryptedAttributes, + { user: mockUser } ) ).resolves.toEqual({ attrOne: 'one', @@ -528,11 +652,11 @@ describe('#decryptAttributes', () => { attrThree: 'three', }); expect(mockAuditLogger.decryptAttributesSuccess).toHaveBeenCalledTimes(1); - expect(mockAuditLogger.decryptAttributesSuccess).toHaveBeenCalledWith(['attrThree'], { - type: 'known-type-1', - id: 'object-id', - namespace: 'object-ns', - }); + expect(mockAuditLogger.decryptAttributesSuccess).toHaveBeenCalledWith( + ['attrThree'], + { type: 'known-type-1', id: 'object-id', namespace: 'object-ns' }, + mockUser + ); }); it('decrypts even if no attributes are included into AAD', async () => { @@ -551,8 +675,11 @@ describe('#decryptAttributes', () => { attrThree: expect.not.stringMatching(/^three$/), }); + const mockUser = mockAuthenticatedUser(); await expect( - service.decryptAttributes({ type: 'known-type-1', id: 'object-id' }, encryptedAttributes) + service.decryptAttributes({ type: 'known-type-1', id: 'object-id' }, encryptedAttributes, { + user: mockUser, + }) ).resolves.toEqual({ attrOne: 'one', attrThree: 'three', @@ -560,7 +687,8 @@ describe('#decryptAttributes', () => { expect(mockAuditLogger.decryptAttributesSuccess).toHaveBeenCalledTimes(1); expect(mockAuditLogger.decryptAttributesSuccess).toHaveBeenCalledWith( ['attrOne', 'attrThree'], - { type: 'known-type-1', id: 'object-id' } + { type: 'known-type-1', id: 'object-id' }, + mockUser ); }); @@ -592,8 +720,11 @@ describe('#decryptAttributes', () => { attrSix: expect.any(String), }); + const mockUser = mockAuthenticatedUser(); await expect( - service.decryptAttributes({ type: 'known-type-1', id: 'object-id' }, encryptedAttributes) + service.decryptAttributes({ type: 'known-type-1', id: 'object-id' }, encryptedAttributes, { + user: mockUser, + }) ).resolves.toEqual({ attrOne: 'one', attrTwo: 'two', @@ -605,7 +736,8 @@ describe('#decryptAttributes', () => { expect(mockAuditLogger.decryptAttributesSuccess).toHaveBeenCalledTimes(1); expect(mockAuditLogger.decryptAttributesSuccess).toHaveBeenCalledWith( ['attrOne', 'attrThree', 'attrFive', 'attrSix'], - { type: 'known-type-1', id: 'object-id' } + { type: 'known-type-1', id: 'object-id' }, + mockUser ); }); @@ -632,39 +764,53 @@ describe('#decryptAttributes', () => { it('fails to decrypt if not all attributes that contribute to AAD are present', async () => { const attributesWithoutAttr = { attrTwo: 'two', attrThree: encryptedAttributes.attrThree }; + const mockUser = mockAuthenticatedUser(); await expect( - service.decryptAttributes({ type: 'known-type-1', id: 'object-id' }, attributesWithoutAttr) + service.decryptAttributes( + { type: 'known-type-1', id: 'object-id' }, + attributesWithoutAttr, + { user: mockUser } + ) ).rejects.toThrowError(EncryptionError); expect(mockAuditLogger.decryptAttributesSuccess).not.toHaveBeenCalled(); - expect(mockAuditLogger.decryptAttributeFailure).toHaveBeenCalledWith('attrThree', { - type: 'known-type-1', - id: 'object-id', - }); + expect(mockAuditLogger.decryptAttributeFailure).toHaveBeenCalledWith( + 'attrThree', + { type: 'known-type-1', id: 'object-id' }, + mockUser + ); }); it('fails to decrypt if ID does not match', async () => { + const mockUser = mockAuthenticatedUser(); await expect( - service.decryptAttributes({ type: 'known-type-1', id: 'object-id*' }, encryptedAttributes) + service.decryptAttributes({ type: 'known-type-1', id: 'object-id*' }, encryptedAttributes, { + user: mockUser, + }) ).rejects.toThrowError(EncryptionError); expect(mockAuditLogger.decryptAttributesSuccess).not.toHaveBeenCalled(); - expect(mockAuditLogger.decryptAttributeFailure).toHaveBeenCalledWith('attrThree', { - type: 'known-type-1', - id: 'object-id*', - }); + expect(mockAuditLogger.decryptAttributeFailure).toHaveBeenCalledWith( + 'attrThree', + { type: 'known-type-1', id: 'object-id*' }, + mockUser + ); }); it('fails to decrypt if type does not match', async () => { + const mockUser = mockAuthenticatedUser(); await expect( - service.decryptAttributes({ type: 'known-type-2', id: 'object-id' }, encryptedAttributes) + service.decryptAttributes({ type: 'known-type-2', id: 'object-id' }, encryptedAttributes, { + user: mockUser, + }) ).rejects.toThrowError(EncryptionError); expect(mockAuditLogger.decryptAttributesSuccess).not.toHaveBeenCalled(); - expect(mockAuditLogger.decryptAttributeFailure).toHaveBeenCalledWith('attrThree', { - type: 'known-type-2', - id: 'object-id', - }); + expect(mockAuditLogger.decryptAttributeFailure).toHaveBeenCalledWith( + 'attrThree', + { type: 'known-type-2', id: 'object-id' }, + mockUser + ); }); it('fails to decrypt if namespace does not match', async () => { @@ -673,19 +819,21 @@ describe('#decryptAttributes', () => { { attrOne: 'one', attrTwo: 'two', attrThree: 'three' } ); + const mockUser = mockAuthenticatedUser(); await expect( service.decryptAttributes( { type: 'known-type-1', id: 'object-id', namespace: 'object-NS' }, - encryptedAttributes + encryptedAttributes, + { user: mockUser } ) ).rejects.toThrowError(EncryptionError); expect(mockAuditLogger.decryptAttributesSuccess).not.toHaveBeenCalled(); - expect(mockAuditLogger.decryptAttributeFailure).toHaveBeenCalledWith('attrThree', { - type: 'known-type-1', - id: 'object-id', - namespace: 'object-NS', - }); + expect(mockAuditLogger.decryptAttributeFailure).toHaveBeenCalledWith( + 'attrThree', + { type: 'known-type-1', id: 'object-id', namespace: 'object-NS' }, + mockUser + ); }); it('fails to decrypt if namespace is expected, but is not provided', async () => { @@ -694,71 +842,75 @@ describe('#decryptAttributes', () => { { attrOne: 'one', attrTwo: 'two', attrThree: 'three' } ); + const mockUser = mockAuthenticatedUser(); await expect( - service.decryptAttributes({ type: 'known-type-1', id: 'object-id' }, encryptedAttributes) + service.decryptAttributes({ type: 'known-type-1', id: 'object-id' }, encryptedAttributes, { + user: mockUser, + }) ).rejects.toThrowError(EncryptionError); expect(mockAuditLogger.decryptAttributesSuccess).not.toHaveBeenCalled(); - expect(mockAuditLogger.decryptAttributeFailure).toHaveBeenCalledWith('attrThree', { - type: 'known-type-1', - id: 'object-id', - }); + expect(mockAuditLogger.decryptAttributeFailure).toHaveBeenCalledWith( + 'attrThree', + { type: 'known-type-1', id: 'object-id' }, + mockUser + ); }); it('fails to decrypt if encrypted attribute is defined, but not a string', async () => { + const mockUser = mockAuthenticatedUser(); await expect( service.decryptAttributes( { type: 'known-type-1', id: 'object-id' }, - { - ...encryptedAttributes, - attrThree: 2, - } + { ...encryptedAttributes, attrThree: 2 }, + { user: mockUser } ) ).rejects.toThrowError( 'Encrypted "attrThree" attribute should be a string, but found number' ); expect(mockAuditLogger.decryptAttributesSuccess).not.toHaveBeenCalled(); - expect(mockAuditLogger.decryptAttributeFailure).toHaveBeenCalledWith('attrThree', { - type: 'known-type-1', - id: 'object-id', - }); + expect(mockAuditLogger.decryptAttributeFailure).toHaveBeenCalledWith( + 'attrThree', + { type: 'known-type-1', id: 'object-id' }, + mockUser + ); }); it('fails to decrypt if encrypted attribute is not correct', async () => { + const mockUser = mockAuthenticatedUser(); await expect( service.decryptAttributes( { type: 'known-type-1', id: 'object-id' }, - { - ...encryptedAttributes, - attrThree: 'some-unknown-string', - } + { ...encryptedAttributes, attrThree: 'some-unknown-string' }, + { user: mockUser } ) ).rejects.toThrowError(EncryptionError); expect(mockAuditLogger.decryptAttributesSuccess).not.toHaveBeenCalled(); - expect(mockAuditLogger.decryptAttributeFailure).toHaveBeenCalledWith('attrThree', { - type: 'known-type-1', - id: 'object-id', - }); + expect(mockAuditLogger.decryptAttributeFailure).toHaveBeenCalledWith( + 'attrThree', + { type: 'known-type-1', id: 'object-id' }, + mockUser + ); }); it('fails to decrypt if the AAD attribute has changed', async () => { + const mockUser = mockAuthenticatedUser(); await expect( service.decryptAttributes( { type: 'known-type-1', id: 'object-id' }, - { - ...encryptedAttributes, - attrOne: 'oNe', - } + { ...encryptedAttributes, attrOne: 'oNe' }, + { user: mockUser } ) ).rejects.toThrowError(EncryptionError); expect(mockAuditLogger.decryptAttributesSuccess).not.toHaveBeenCalled(); - expect(mockAuditLogger.decryptAttributeFailure).toHaveBeenCalledWith('attrThree', { - type: 'known-type-1', - id: 'object-id', - }); + expect(mockAuditLogger.decryptAttributeFailure).toHaveBeenCalledWith( + 'attrThree', + { type: 'known-type-1', id: 'object-id' }, + mockUser + ); }); it('fails if encrypted with another encryption key', async () => { @@ -773,15 +925,19 @@ describe('#decryptAttributes', () => { attributesToEncrypt: new Set(['attrThree']), }); + const mockUser = mockAuthenticatedUser(); await expect( - service.decryptAttributes({ type: 'known-type-1', id: 'object-id' }, encryptedAttributes) + service.decryptAttributes({ type: 'known-type-1', id: 'object-id' }, encryptedAttributes, { + user: mockUser, + }) ).rejects.toThrowError(EncryptionError); expect(mockAuditLogger.decryptAttributesSuccess).not.toHaveBeenCalled(); - expect(mockAuditLogger.decryptAttributeFailure).toHaveBeenCalledWith('attrThree', { - type: 'known-type-1', - id: 'object-id', - }); + expect(mockAuditLogger.decryptAttributeFailure).toHaveBeenCalledWith( + 'attrThree', + { type: 'known-type-1', id: 'object-id' }, + mockUser + ); }); }); }); diff --git a/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.ts b/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.ts index eea2b12354d9..5cf3e1c2d65a 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.ts @@ -8,8 +8,20 @@ import nodeCrypto, { Crypto } from '@elastic/node-crypto'; import stringify from 'json-stable-stringify'; import typeDetect from 'type-detect'; import { Logger } from 'src/core/server'; +import { AuthenticatedUser } from '../../../security/common/model'; import { EncryptedSavedObjectsAuditLogger } from '../audit'; -import { EncryptionError } from './encryption_error'; +import { EncryptionError, EncryptionErrorOperation } from './encryption_error'; +import { EncryptedSavedObjectAttributesDefinition } from './encrypted_saved_object_type_definition'; + +/** + * Describes the attributes to encrypt. By default, attribute values won't be exposed to end-users + * and can only be consumed by the internal Kibana server. If end-users should have access to the + * encrypted values use `dangerouslyExposeValue: true` + */ +export interface AttributeToEncrypt { + readonly key: string; + readonly dangerouslyExposeValue?: boolean; +} /** * Describes the registration entry for the saved object type that contain attributes that need to @@ -17,7 +29,7 @@ import { EncryptionError } from './encryption_error'; */ export interface EncryptedSavedObjectTypeRegistration { readonly type: string; - readonly attributesToEncrypt: ReadonlySet; + readonly attributesToEncrypt: ReadonlySet; readonly attributesToExcludeFromAAD?: ReadonlySet; } @@ -30,6 +42,16 @@ export interface SavedObjectDescriptor { readonly namespace?: string; } +/** + * Describes parameters that are common for all EncryptedSavedObjectsService public methods. + */ +interface CommonParameters { + /** + * User on behalf of the method is called if determined. + */ + user?: AuthenticatedUser; +} + /** * Utility function that gives array representation of the saved object descriptor respecting * optional `namespace` property. @@ -52,9 +74,12 @@ export class EncryptedSavedObjectsService { /** * Map of all registered saved object types where the `key` is saved object type and the `value` - * is the registration parameters (names of attributes that need to be encrypted etc.). + * is the definition (names of attributes that need to be encrypted etc.). */ - private readonly typeRegistrations: Map = new Map(); + private readonly typeDefinitions: Map< + string, + EncryptedSavedObjectAttributesDefinition + > = new Map(); /** * @param encryptionKey The key used to encrypt and decrypt saved objects attributes. @@ -81,11 +106,14 @@ export class EncryptedSavedObjectsService { throw new Error(`The "attributesToEncrypt" array for "${typeRegistration.type}" is empty.`); } - if (this.typeRegistrations.has(typeRegistration.type)) { + if (this.typeDefinitions.has(typeRegistration.type)) { throw new Error(`The "${typeRegistration.type}" saved object type is already registered.`); } - this.typeRegistrations.set(typeRegistration.type, typeRegistration); + this.typeDefinitions.set( + typeRegistration.type, + new EncryptedSavedObjectAttributesDefinition(typeRegistration) + ); } /** @@ -94,32 +122,75 @@ export class EncryptedSavedObjectsService { * @param type Saved object type. */ public isRegistered(type: string) { - return this.typeRegistrations.has(type); + return this.typeDefinitions.has(type); } /** - * Takes saved object attributes for the specified type and strips any of them that are supposed - * to be encrypted and returns that __NEW__ attributes dictionary back. - * @param type Type of the saved object to strip encrypted attributes from. - * @param attributes Dictionary of __ALL__ saved object attributes. + * Takes saved object attributes for the specified type and, depending on the type definition, + * either decrypts or strips encrypted attributes (e.g. in case AAD or encryption key has changed + * and decryption is no longer possible). + * @param descriptor Saved object descriptor (ID, type and optional namespace) + * @param attributes Object that includes a dictionary of __ALL__ saved object attributes stored + * in Elasticsearch. + * @param [originalAttributes] An optional dictionary of __ALL__ saved object original attributes + * that were used to create that saved object (i.e. values are NOT encrypted). + * @param [params] Parameters that control the way encrypted attributes are handled. */ - public stripEncryptedAttributes>( - type: string, - attributes: T - ): Record { - const typeRegistration = this.typeRegistrations.get(type); - if (typeRegistration === undefined) { - return attributes; + public async stripOrDecryptAttributes>( + descriptor: SavedObjectDescriptor, + attributes: T, + originalAttributes?: T, + params?: CommonParameters + ) { + const typeDefinition = this.typeDefinitions.get(descriptor.type); + if (typeDefinition === undefined) { + return { attributes }; } + let decryptedAttributes: T | null = null; + let decryptionError: Error | undefined; const clonedAttributes: Record = {}; for (const [attributeName, attributeValue] of Object.entries(attributes)) { - if (!typeRegistration.attributesToEncrypt.has(attributeName)) { + // We should strip encrypted attribute if definition explicitly mandates that or decryption + // failed. + if ( + typeDefinition.shouldBeStripped(attributeName) || + (!!decryptionError && typeDefinition.shouldBeEncrypted(attributeName)) + ) { + continue; + } + + // If attribute isn't supposed to be encrypted, just copy it to the resulting attribute set. + if (!typeDefinition.shouldBeEncrypted(attributeName)) { clonedAttributes[attributeName] = attributeValue; + } else if (originalAttributes) { + // If attribute should be decrypted, but we have original attributes used to create object + // we should get raw unencrypted value from there to avoid performance penalty. + clonedAttributes[attributeName] = originalAttributes[attributeName]; + } else { + // Otherwise just try to decrypt attribute. We decrypt all attributes at once, cache it and + // reuse for any other attributes. + if (decryptedAttributes === null) { + try { + decryptedAttributes = await this.decryptAttributes( + descriptor, + // Decrypt only attributes that are supposed to be exposed. + Object.fromEntries( + Object.entries(attributes).filter(([key]) => !typeDefinition.shouldBeStripped(key)) + ) as T, + { user: params?.user } + ); + } catch (err) { + decryptionError = err; + continue; + } + } + + clonedAttributes[attributeName] = decryptedAttributes[attributeName]; } } - return clonedAttributes; + return { attributes: clonedAttributes as T, error: decryptionError }; } /** @@ -128,20 +199,22 @@ export class EncryptedSavedObjectsService { * attributes were encrypted original attributes dictionary is returned. * @param descriptor Descriptor of the saved object to encrypt attributes for. * @param attributes Dictionary of __ALL__ saved object attributes. + * @param [params] Additional parameters. * @throws Will throw if encryption fails for whatever reason. */ public async encryptAttributes>( descriptor: SavedObjectDescriptor, - attributes: T + attributes: T, + params?: CommonParameters ): Promise { - const typeRegistration = this.typeRegistrations.get(descriptor.type); - if (typeRegistration === undefined) { + const typeDefinition = this.typeDefinitions.get(descriptor.type); + if (typeDefinition === undefined) { return attributes; } - const encryptionAAD = this.getAAD(typeRegistration, descriptor, attributes); + const encryptionAAD = this.getAAD(typeDefinition, descriptor, attributes); const encryptedAttributes: Record = {}; - for (const attributeName of typeRegistration.attributesToEncrypt) { + for (const attributeName of typeDefinition.attributesToEncrypt) { const attributeValue = attributes[attributeName]; if (attributeValue != null) { try { @@ -153,11 +226,12 @@ export class EncryptedSavedObjectsService { this.logger.error( `Failed to encrypt "${attributeName}" attribute: ${err.message || err}` ); - this.audit.encryptAttributeFailure(attributeName, descriptor); + this.audit.encryptAttributeFailure(attributeName, descriptor, params?.user); throw new EncryptionError( `Unable to encrypt attribute "${attributeName}"`, attributeName, + EncryptionErrorOperation.Encryption, err ); } @@ -167,12 +241,12 @@ export class EncryptedSavedObjectsService { // Normally we expect all registered to-be-encrypted attributes to be defined, but if it's // not the case we should collect and log them to make troubleshooting easier. const encryptedAttributesKeys = Object.keys(encryptedAttributes); - if (encryptedAttributesKeys.length !== typeRegistration.attributesToEncrypt.size) { + if (encryptedAttributesKeys.length !== typeDefinition.attributesToEncrypt.size) { this.logger.debug( `The following attributes of saved object "${descriptorToArray( descriptor )}" should have been encrypted: ${Array.from( - typeRegistration.attributesToEncrypt + typeDefinition.attributesToEncrypt )}, but found only: ${encryptedAttributesKeys}` ); } @@ -181,7 +255,7 @@ export class EncryptedSavedObjectsService { return attributes; } - this.audit.encryptAttributesSuccess(encryptedAttributesKeys, descriptor); + this.audit.encryptAttributesSuccess(encryptedAttributesKeys, descriptor, params?.user); return { ...attributes, @@ -195,28 +269,30 @@ export class EncryptedSavedObjectsService { * attributes were decrypted original attributes dictionary is returned. * @param descriptor Descriptor of the saved object to decrypt attributes for. * @param attributes Dictionary of __ALL__ saved object attributes. + * @param [params] Additional parameters. * @throws Will throw if decryption fails for whatever reason. * @throws Will throw if any of the attributes to decrypt is not a string. */ public async decryptAttributes>( descriptor: SavedObjectDescriptor, - attributes: T + attributes: T, + params?: CommonParameters ): Promise { - const typeRegistration = this.typeRegistrations.get(descriptor.type); - if (typeRegistration === undefined) { + const typeDefinition = this.typeDefinitions.get(descriptor.type); + if (typeDefinition === undefined) { return attributes; } - const encryptionAAD = this.getAAD(typeRegistration, descriptor, attributes); + const encryptionAAD = this.getAAD(typeDefinition, descriptor, attributes); const decryptedAttributes: Record = {}; - for (const attributeName of typeRegistration.attributesToEncrypt) { + for (const attributeName of typeDefinition.attributesToEncrypt) { const attributeValue = attributes[attributeName]; if (attributeValue == null) { continue; } if (typeof attributeValue !== 'string') { - this.audit.decryptAttributeFailure(attributeName, descriptor); + this.audit.decryptAttributeFailure(attributeName, descriptor, params?.user); throw new Error( `Encrypted "${attributeName}" attribute should be a string, but found ${typeDetect( attributeValue @@ -231,11 +307,12 @@ export class EncryptedSavedObjectsService { )) as string; } catch (err) { this.logger.error(`Failed to decrypt "${attributeName}" attribute: ${err.message || err}`); - this.audit.decryptAttributeFailure(attributeName, descriptor); + this.audit.decryptAttributeFailure(attributeName, descriptor, params?.user); throw new EncryptionError( `Unable to decrypt attribute "${attributeName}"`, attributeName, + EncryptionErrorOperation.Decryption, err ); } @@ -244,12 +321,12 @@ export class EncryptedSavedObjectsService { // Normally we expect all registered to-be-encrypted attributes to be defined, but if it's // not the case we should collect and log them to make troubleshooting easier. const decryptedAttributesKeys = Object.keys(decryptedAttributes); - if (decryptedAttributesKeys.length !== typeRegistration.attributesToEncrypt.size) { + if (decryptedAttributesKeys.length !== typeDefinition.attributesToEncrypt.size) { this.logger.debug( `The following attributes of saved object "${descriptorToArray( descriptor )}" should have been decrypted: ${Array.from( - typeRegistration.attributesToEncrypt + typeDefinition.attributesToEncrypt )}, but found only: ${decryptedAttributesKeys}` ); } @@ -258,7 +335,7 @@ export class EncryptedSavedObjectsService { return attributes; } - this.audit.decryptAttributesSuccess(decryptedAttributesKeys, descriptor); + this.audit.decryptAttributesSuccess(decryptedAttributesKeys, descriptor, params?.user); return { ...attributes, @@ -269,23 +346,19 @@ export class EncryptedSavedObjectsService { /** * Generates string representation of the Additional Authenticated Data based on the specified saved * object type and attributes. - * @param typeRegistration Saved object type registration parameters. + * @param typeDefinition Encrypted saved object type definition. * @param descriptor Descriptor of the saved object to get AAD for. * @param attributes All attributes of the saved object instance of the specified type. */ private getAAD( - typeRegistration: EncryptedSavedObjectTypeRegistration, + typeDefinition: EncryptedSavedObjectAttributesDefinition, descriptor: SavedObjectDescriptor, attributes: Record ) { // Collect all attributes (both keys and values) that should contribute to AAD. const attributesAAD: Record = {}; for (const [attributeKey, attributeValue] of Object.entries(attributes)) { - if ( - !typeRegistration.attributesToEncrypt.has(attributeKey) && - (typeRegistration.attributesToExcludeFromAAD == null || - !typeRegistration.attributesToExcludeFromAAD.has(attributeKey)) - ) { + if (!typeDefinition.shouldBeExcludedFromAAD(attributeKey)) { attributesAAD[attributeKey] = attributeValue; } } diff --git a/x-pack/plugins/encrypted_saved_objects/server/crypto/encryption_error.test.ts b/x-pack/plugins/encrypted_saved_objects/server/crypto/encryption_error.test.ts index 33b48044c864..816f65214679 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/crypto/encryption_error.test.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/crypto/encryption_error.test.ts @@ -4,18 +4,38 @@ * you may not use this file except in compliance with the Elastic License. */ -import { EncryptionError } from './encryption_error'; +import { EncryptionError, EncryptionErrorOperation } from './encryption_error'; test('#EncryptionError is correctly constructed', () => { const cause = new TypeError('Some weird error'); const encryptionError = new EncryptionError( 'Unable to encrypt attribute "someAttr"', 'someAttr', + EncryptionErrorOperation.Encryption, cause ); expect(encryptionError).toBeInstanceOf(EncryptionError); expect(encryptionError.message).toBe('Unable to encrypt attribute "someAttr"'); expect(encryptionError.attributeName).toBe('someAttr'); + expect(encryptionError.operation).toBe(EncryptionErrorOperation.Encryption); expect(encryptionError.cause).toBe(cause); + expect(JSON.stringify(encryptionError)).toMatchInlineSnapshot( + `"{\\"message\\":\\"Unable to encrypt attribute \\\\\\"someAttr\\\\\\"\\"}"` + ); + + const decryptionErrorWithoutCause = new EncryptionError( + 'Unable to decrypt attribute "someAttr"', + 'someAttr', + EncryptionErrorOperation.Decryption + ); + + expect(decryptionErrorWithoutCause).toBeInstanceOf(EncryptionError); + expect(decryptionErrorWithoutCause.message).toBe('Unable to decrypt attribute "someAttr"'); + expect(decryptionErrorWithoutCause.attributeName).toBe('someAttr'); + expect(decryptionErrorWithoutCause.operation).toBe(EncryptionErrorOperation.Decryption); + expect(decryptionErrorWithoutCause.cause).toBeUndefined(); + expect(JSON.stringify(decryptionErrorWithoutCause)).toMatchInlineSnapshot( + `"{\\"message\\":\\"Unable to decrypt attribute \\\\\\"someAttr\\\\\\"\\"}"` + ); }); diff --git a/x-pack/plugins/encrypted_saved_objects/server/crypto/encryption_error.ts b/x-pack/plugins/encrypted_saved_objects/server/crypto/encryption_error.ts index 432797e24d1c..aa14ebbb3b3f 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/crypto/encryption_error.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/crypto/encryption_error.ts @@ -4,10 +4,19 @@ * you may not use this file except in compliance with the Elastic License. */ +/** + * Defines operation (encryption or decryption) during which error occurred. + */ +export enum EncryptionErrorOperation { + Encryption, + Decryption, +} + export class EncryptionError extends Error { constructor( message: string, public readonly attributeName: string, + public readonly operation: EncryptionErrorOperation, public readonly cause?: Error ) { super(message); @@ -16,4 +25,8 @@ export class EncryptionError extends Error { // https://github.com/Microsoft/TypeScript/wiki/Breaking-Changes#extending-built-ins-like-error-array-and-map-may-no-longer-work Object.setPrototypeOf(this, EncryptionError.prototype); } + + toJSON() { + return { message: this.message }; + } } diff --git a/x-pack/plugins/encrypted_saved_objects/server/crypto/index.mock.ts b/x-pack/plugins/encrypted_saved_objects/server/crypto/index.mock.ts index 54a4203b8919..de0a089ab8c6 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/crypto/index.mock.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/crypto/index.mock.ts @@ -19,7 +19,7 @@ export const encryptedSavedObjectsServiceMock = { function processAttributes>( descriptor: Pick, attrs: T, - action: (attrs: T, attrName: string) => void + action: (attrs: T, attrName: string, shouldExpose: boolean) => void ) { const registration = registrations.find(r => r.type === descriptor.type); if (!registration) { @@ -27,9 +27,13 @@ export const encryptedSavedObjectsServiceMock = { } const clonedAttrs = { ...attrs }; - for (const attrName of registration.attributesToEncrypt) { + for (const attr of registration.attributesToEncrypt) { + const [attrName, shouldExpose] = + typeof attr === 'string' + ? [attr, false] + : [attr.key, attr.dangerouslyExposeValue === true]; if (attrName in clonedAttrs) { - action(clonedAttrs, attrName); + action(clonedAttrs, attrName, shouldExpose); } } return clonedAttrs; @@ -53,8 +57,16 @@ export const encryptedSavedObjectsServiceMock = { (clonedAttrs[attrName] = (clonedAttrs[attrName] as string).slice(1, -1)) ) ); - mock.stripEncryptedAttributes.mockImplementation((type, attrs) => - processAttributes({ type }, attrs, (clonedAttrs, attrName) => delete clonedAttrs[attrName]) + mock.stripOrDecryptAttributes.mockImplementation((descriptor, attrs) => + Promise.resolve({ + attributes: processAttributes(descriptor, attrs, (clonedAttrs, attrName, shouldExpose) => { + if (shouldExpose) { + clonedAttrs[attrName] = (clonedAttrs[attrName] as string).slice(1, -1); + } else { + delete clonedAttrs[attrName]; + } + }), + }) ); return mock; diff --git a/x-pack/plugins/encrypted_saved_objects/server/plugin.test.ts b/x-pack/plugins/encrypted_saved_objects/server/plugin.test.ts index 5228734e4a77..117adba5794d 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/plugin.test.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/plugin.test.ts @@ -7,12 +7,14 @@ import { Plugin } from './plugin'; import { coreMock } from 'src/core/server/mocks'; +import { securityMock } from '../../security/server/mocks'; describe('EncryptedSavedObjects Plugin', () => { describe('setup()', () => { it('exposes proper contract', async () => { const plugin = new Plugin(coreMock.createPluginInitializerContext()); - await expect(plugin.setup(coreMock.createSetup())).resolves.toMatchInlineSnapshot(` + await expect(plugin.setup(coreMock.createSetup(), { security: securityMock.createSetup() })) + .resolves.toMatchInlineSnapshot(` Object { "__legacyCompat": Object { "registerLegacyAPI": [Function], @@ -27,7 +29,7 @@ describe('EncryptedSavedObjects Plugin', () => { describe('start()', () => { it('exposes proper contract', async () => { const plugin = new Plugin(coreMock.createPluginInitializerContext()); - await plugin.setup(coreMock.createSetup()); + await plugin.setup(coreMock.createSetup(), { security: securityMock.createSetup() }); await expect(plugin.start()).toMatchInlineSnapshot(` Object { "getDecryptedAsInternalUser": [Function], diff --git a/x-pack/plugins/encrypted_saved_objects/server/plugin.ts b/x-pack/plugins/encrypted_saved_objects/server/plugin.ts index a0218c51c272..02212f271cf8 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/plugin.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/plugin.ts @@ -11,6 +11,7 @@ import { CoreSetup, } from 'src/core/server'; import { first } from 'rxjs/operators'; +import { SecurityPluginSetup } from '../../security/server'; import { createConfig$ } from './config'; import { EncryptedSavedObjectsService, @@ -20,6 +21,10 @@ import { import { EncryptedSavedObjectsAuditLogger } from './audit'; import { SavedObjectsSetup, setupSavedObjects } from './saved_objects'; +export interface PluginsSetup { + security?: SecurityPluginSetup; +} + export interface EncryptedSavedObjectsPluginSetup { registerType: (typeRegistration: EncryptedSavedObjectTypeRegistration) => void; __legacyCompat: { registerLegacyAPI: (legacyAPI: LegacyAPI) => void }; @@ -59,7 +64,10 @@ export class Plugin { this.logger = this.initializerContext.logger.get(); } - public async setup(core: CoreSetup): Promise { + public async setup( + core: CoreSetup, + deps: PluginsSetup + ): Promise { const { config, usingEphemeralEncryptionKey } = await createConfig$(this.initializerContext) .pipe(first()) .toPromise(); @@ -75,6 +83,7 @@ export class Plugin { this.savedObjectsSetup = setupSavedObjects({ service, savedObjects: core.savedObjects, + security: deps.security, getStartServices: core.getStartServices, }); diff --git a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.test.ts b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.test.ts index 2caf3a7055df..8a4e78288e41 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.test.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.test.ts @@ -4,15 +4,17 @@ * you may not use this file except in compliance with the Elastic License. */ -jest.mock('uuid', () => ({ v4: jest.fn().mockReturnValue('uuid-v4-id') })); - +import { EncryptionErrorOperation } from '../crypto/encryption_error'; import { SavedObjectsClientContract } from 'src/core/server'; -import { EncryptedSavedObjectsService } from '../crypto'; +import { EncryptedSavedObjectsService, EncryptionError } from '../crypto'; import { EncryptedSavedObjectsClientWrapper } from './encrypted_saved_objects_client_wrapper'; import { savedObjectsClientMock, savedObjectsTypeRegistryMock } from 'src/core/server/mocks'; +import { mockAuthenticatedUser } from '../../../security/common/model/authenticated_user.mock'; import { encryptedSavedObjectsServiceMock } from '../crypto/index.mock'; +jest.mock('uuid', () => ({ v4: jest.fn().mockReturnValue('uuid-v4-id') })); + let wrapper: EncryptedSavedObjectsClientWrapper; let mockBaseClient: jest.Mocked; let mockBaseTypeRegistry: ReturnType; @@ -23,7 +25,10 @@ beforeEach(() => { encryptedSavedObjectsServiceMockInstance = encryptedSavedObjectsServiceMock.create([ { type: 'known-type', - attributesToEncrypt: new Set(['attrSecret']), + attributesToEncrypt: new Set([ + 'attrSecret', + { key: 'attrNotSoSecret', dangerouslyExposeValue: true }, + ]), }, ]); @@ -31,6 +36,7 @@ beforeEach(() => { service: encryptedSavedObjectsServiceMockInstance, baseClient: mockBaseClient, baseTypeRegistry: mockBaseTypeRegistry, + getCurrentUser: () => mockAuthenticatedUser(), } as any); }); @@ -63,13 +69,23 @@ describe('#create', () => { expect(mockBaseClient.create).not.toHaveBeenCalled(); }); - it('generates ID, encrypts attributes and strips them from response', async () => { - const attributes = { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' }; + it('generates ID, encrypts attributes and strips them from response except for ones with `dangerouslyExposeValue` set to `true`', async () => { + const attributes = { + attrOne: 'one', + attrSecret: 'secret', + attrNotSoSecret: 'not-so-secret', + attrThree: 'three', + }; const options = { overwrite: true }; const mockedResponse = { id: 'uuid-v4-id', type: 'known-type', - attributes: { attrOne: 'one', attrSecret: '*secret*', attrThree: 'three' }, + attributes: { + attrOne: 'one', + attrSecret: '*secret*', + attrNotSoSecret: '*not-so-secret*', + attrThree: 'three', + }, references: [], }; @@ -77,19 +93,30 @@ describe('#create', () => { expect(await wrapper.create('known-type', attributes, options)).toEqual({ ...mockedResponse, - attributes: { attrOne: 'one', attrThree: 'three' }, + attributes: { attrOne: 'one', attrNotSoSecret: 'not-so-secret', attrThree: 'three' }, }); expect(encryptedSavedObjectsServiceMockInstance.encryptAttributes).toHaveBeenCalledTimes(1); expect(encryptedSavedObjectsServiceMockInstance.encryptAttributes).toHaveBeenCalledWith( { type: 'known-type', id: 'uuid-v4-id' }, - { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' } + { + attrOne: 'one', + attrSecret: 'secret', + attrNotSoSecret: 'not-so-secret', + attrThree: 'three', + }, + { user: mockAuthenticatedUser() } ); expect(mockBaseClient.create).toHaveBeenCalledTimes(1); expect(mockBaseClient.create).toHaveBeenCalledWith( 'known-type', - { attrOne: 'one', attrSecret: '*secret*', attrThree: 'three' }, + { + attrOne: 'one', + attrSecret: '*secret*', + attrNotSoSecret: '*not-so-secret*', + attrThree: 'three', + }, { id: 'uuid-v4-id', overwrite: true } ); }); @@ -119,7 +146,8 @@ describe('#create', () => { id: 'uuid-v4-id', namespace: expectNamespaceInDescriptor ? namespace : undefined, }, - { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' } + { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' }, + { user: mockAuthenticatedUser() } ); expect(mockBaseClient.create).toHaveBeenCalledTimes(1); @@ -221,14 +249,19 @@ describe('#bulkCreate', () => { expect(mockBaseClient.bulkCreate).not.toHaveBeenCalled(); }); - it('generates ID, encrypts attributes and strips them from response', async () => { - const attributes = { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' }; + it('generates ID, encrypts attributes and strips them from response except for ones with `dangerouslyExposeValue` set to `true`', async () => { + const attributes = { + attrOne: 'one', + attrSecret: 'secret', + attrNotSoSecret: 'not-so-secret', + attrThree: 'three', + }; const mockedResponse = { saved_objects: [ { id: 'uuid-v4-id', type: 'known-type', - attributes, + attributes: { ...attributes, attrSecret: '*secret*', attrNotSoSecret: '*not-so-secret*' }, references: [], }, { @@ -249,7 +282,10 @@ describe('#bulkCreate', () => { await expect(wrapper.bulkCreate(bulkCreateParams)).resolves.toEqual({ saved_objects: [ - { ...mockedResponse.saved_objects[0], attributes: { attrOne: 'one', attrThree: 'three' } }, + { + ...mockedResponse.saved_objects[0], + attributes: { attrOne: 'one', attrNotSoSecret: 'not-so-secret', attrThree: 'three' }, + }, mockedResponse.saved_objects[1], ], }); @@ -257,7 +293,13 @@ describe('#bulkCreate', () => { expect(encryptedSavedObjectsServiceMockInstance.encryptAttributes).toHaveBeenCalledTimes(1); expect(encryptedSavedObjectsServiceMockInstance.encryptAttributes).toHaveBeenCalledWith( { type: 'known-type', id: 'uuid-v4-id' }, - { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' } + { + attrOne: 'one', + attrSecret: 'secret', + attrNotSoSecret: 'not-so-secret', + attrThree: 'three', + }, + { user: mockAuthenticatedUser() } ); expect(mockBaseClient.bulkCreate).toHaveBeenCalledTimes(1); @@ -266,7 +308,12 @@ describe('#bulkCreate', () => { { ...bulkCreateParams[0], id: 'uuid-v4-id', - attributes: { attrOne: 'one', attrSecret: '*secret*', attrThree: 'three' }, + attributes: { + attrOne: 'one', + attrSecret: '*secret*', + attrNotSoSecret: '*not-so-secret*', + attrThree: 'three', + }, }, bulkCreateParams[1], ], @@ -301,7 +348,8 @@ describe('#bulkCreate', () => { id: 'uuid-v4-id', namespace: expectNamespaceInDescriptor ? namespace : undefined, }, - { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' } + { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' }, + { user: mockAuthenticatedUser() } ); expect(mockBaseClient.bulkCreate).toHaveBeenCalledTimes(1); @@ -377,7 +425,7 @@ describe('#bulkUpdate', () => { ); }); - it('encrypts attributes and strips them from response', async () => { + it('encrypts attributes and strips them from response except for ones with `dangerouslyExposeValue` set to `true`', async () => { const docs = [ { id: 'some-id', @@ -385,6 +433,7 @@ describe('#bulkUpdate', () => { attributes: { attrOne: 'one', attrSecret: 'secret', + attrNotSoSecret: 'not-so-secret', attrThree: 'three', }, }, @@ -394,13 +443,22 @@ describe('#bulkUpdate', () => { attributes: { attrOne: 'one 2', attrSecret: 'secret 2', + attrNotSoSecret: 'not-so-secret 2', attrThree: 'three 2', }, }, ]; const mockedResponse = { - saved_objects: docs.map(doc => ({ ...doc, references: undefined })), + saved_objects: docs.map(doc => ({ + ...doc, + attributes: { + ...doc.attributes, + attrSecret: `*${doc.attributes.attrSecret}*`, + attrNotSoSecret: `*${doc.attributes.attrNotSoSecret}*`, + }, + references: undefined, + })), }; mockBaseClient.bulkUpdate.mockResolvedValue(mockedResponse); @@ -417,6 +475,7 @@ describe('#bulkUpdate', () => { type: 'known-type', attributes: { attrOne: 'one', + attrNotSoSecret: 'not-so-secret', attrThree: 'three', }, }, @@ -425,6 +484,7 @@ describe('#bulkUpdate', () => { type: 'known-type', attributes: { attrOne: 'one 2', + attrNotSoSecret: 'not-so-secret 2', attrThree: 'three 2', }, }, @@ -434,11 +494,23 @@ describe('#bulkUpdate', () => { expect(encryptedSavedObjectsServiceMockInstance.encryptAttributes).toHaveBeenCalledTimes(2); expect(encryptedSavedObjectsServiceMockInstance.encryptAttributes).toHaveBeenCalledWith( { type: 'known-type', id: 'some-id' }, - { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' } + { + attrOne: 'one', + attrSecret: 'secret', + attrNotSoSecret: 'not-so-secret', + attrThree: 'three', + }, + { user: mockAuthenticatedUser() } ); expect(encryptedSavedObjectsServiceMockInstance.encryptAttributes).toHaveBeenCalledWith( { type: 'known-type', id: 'some-id-2' }, - { attrOne: 'one 2', attrSecret: 'secret 2', attrThree: 'three 2' } + { + attrOne: 'one 2', + attrSecret: 'secret 2', + attrNotSoSecret: 'not-so-secret 2', + attrThree: 'three 2', + }, + { user: mockAuthenticatedUser() } ); expect(mockBaseClient.bulkUpdate).toHaveBeenCalledTimes(1); @@ -450,6 +522,7 @@ describe('#bulkUpdate', () => { attributes: { attrOne: 'one', attrSecret: '*secret*', + attrNotSoSecret: '*not-so-secret*', attrThree: 'three', }, }, @@ -459,6 +532,7 @@ describe('#bulkUpdate', () => { attributes: { attrOne: 'one 2', attrSecret: '*secret 2*', + attrNotSoSecret: '*not-so-secret 2*', attrThree: 'three 2', }, }, @@ -509,7 +583,8 @@ describe('#bulkUpdate', () => { id: 'some-id', namespace: expectNamespaceInDescriptor ? namespace : undefined, }, - { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' } + { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' }, + { user: mockAuthenticatedUser() } ); expect(mockBaseClient.bulkUpdate).toHaveBeenCalledTimes(1); @@ -635,19 +710,29 @@ describe('#find', () => { expect(mockBaseClient.find).toHaveBeenCalledWith(options); }); - it('redirects request to underlying base client and strips encrypted attributes if type is registered', async () => { + it('redirects request to underlying base client and strips encrypted attributes except for ones with `dangerouslyExposeValue` set to `true` if type is registered', async () => { const mockedResponse = { saved_objects: [ { id: 'some-id', type: 'unknown-type', - attributes: { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' }, + attributes: { + attrOne: 'one', + attrSecret: 'secret', + attrNotSoSecret: 'not-so-secret', + attrThree: 'three', + }, references: [], }, { id: 'some-id-2', type: 'known-type', - attributes: { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' }, + attributes: { + attrOne: 'one', + attrSecret: '*secret*', + attrNotSoSecret: '*not-so-secret*', + attrThree: 'three', + }, references: [], }, ], @@ -664,16 +749,118 @@ describe('#find', () => { saved_objects: [ { ...mockedResponse.saved_objects[0], - attributes: { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' }, + attributes: { + attrOne: 'one', + attrSecret: 'secret', + attrNotSoSecret: 'not-so-secret', + attrThree: 'three', + }, }, { ...mockedResponse.saved_objects[1], - attributes: { attrOne: 'one', attrThree: 'three' }, + attributes: { attrOne: 'one', attrNotSoSecret: 'not-so-secret', attrThree: 'three' }, }, ], }); expect(mockBaseClient.find).toHaveBeenCalledTimes(1); expect(mockBaseClient.find).toHaveBeenCalledWith(options); + + expect(encryptedSavedObjectsServiceMockInstance.stripOrDecryptAttributes).toHaveBeenCalledTimes( + 1 + ); + expect(encryptedSavedObjectsServiceMockInstance.stripOrDecryptAttributes).toHaveBeenCalledWith( + { type: 'known-type', id: 'some-id-2' }, + { + attrOne: 'one', + attrSecret: '*secret*', + attrNotSoSecret: '*not-so-secret*', + attrThree: 'three', + }, + undefined, + { user: mockAuthenticatedUser() } + ); + }); + + it('includes both attributes and error if decryption fails.', async () => { + const mockedResponse = { + saved_objects: [ + { + id: 'some-id', + type: 'unknown-type', + attributes: { + attrOne: 'one', + attrSecret: 'secret', + attrNotSoSecret: 'not-so-secret', + attrThree: 'three', + }, + references: [], + }, + { + id: 'some-id-2', + type: 'known-type', + attributes: { + attrOne: 'one', + attrSecret: '*secret*', + attrNotSoSecret: '*not-so-secret*', + attrThree: 'three', + }, + references: [], + }, + ], + total: 2, + per_page: 2, + page: 1, + }; + + mockBaseClient.find.mockResolvedValue(mockedResponse); + + const decryptionError = new EncryptionError( + 'something failed', + 'attrNotSoSecret', + EncryptionErrorOperation.Decryption + ); + encryptedSavedObjectsServiceMockInstance.stripOrDecryptAttributes.mockResolvedValue({ + attributes: { attrOne: 'one', attrThree: 'three' }, + error: decryptionError, + }); + + const options = { type: ['unknown-type', 'known-type'], search: 'query' }; + await expect(wrapper.find(options)).resolves.toEqual({ + ...mockedResponse, + saved_objects: [ + { + ...mockedResponse.saved_objects[0], + attributes: { + attrOne: 'one', + attrSecret: 'secret', + attrNotSoSecret: 'not-so-secret', + attrThree: 'three', + }, + }, + { + ...mockedResponse.saved_objects[1], + attributes: { attrOne: 'one', attrThree: 'three' }, + error: decryptionError, + }, + ], + }); + expect(mockBaseClient.find).toHaveBeenCalledTimes(1); + expect(mockBaseClient.find).toHaveBeenCalledWith(options); + + expect(encryptedSavedObjectsServiceMockInstance.stripOrDecryptAttributes).toHaveBeenCalledTimes( + 1 + ); + expect(encryptedSavedObjectsServiceMockInstance.stripOrDecryptAttributes).toHaveBeenCalledWith( + { type: 'known-type', id: 'some-id-2' }, + { + attrOne: 'one', + attrSecret: '*secret*', + attrNotSoSecret: '*not-so-secret*', + attrThree: 'three', + }, + undefined, + { user: mockAuthenticatedUser() } + ); }); it('fails if base client fails', async () => { @@ -734,19 +921,29 @@ describe('#bulkGet', () => { expect(mockBaseClient.bulkGet).toHaveBeenCalledWith(bulkGetParams, options); }); - it('redirects request to underlying base client and strips encrypted attributes if type is registered', async () => { + it('redirects request to underlying base client and strips encrypted attributes except for ones with `dangerouslyExposeValue` set to `true` if type is registered', async () => { const mockedResponse = { saved_objects: [ { id: 'some-id', type: 'unknown-type', - attributes: { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' }, + attributes: { + attrOne: 'one', + attrSecret: 'secret', + attrNotSoSecret: 'not-so-secret', + attrThree: 'three', + }, references: [], }, { id: 'some-id-2', type: 'known-type', - attributes: { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' }, + attributes: { + attrOne: 'one', + attrSecret: '*secret*', + attrNotSoSecret: '*not-so-secret*', + attrThree: 'three', + }, references: [], }, ], @@ -768,16 +965,123 @@ describe('#bulkGet', () => { saved_objects: [ { ...mockedResponse.saved_objects[0], - attributes: { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' }, + attributes: { + attrOne: 'one', + attrSecret: 'secret', + attrNotSoSecret: 'not-so-secret', + attrThree: 'three', + }, }, { ...mockedResponse.saved_objects[1], - attributes: { attrOne: 'one', attrThree: 'three' }, + attributes: { attrOne: 'one', attrNotSoSecret: 'not-so-secret', attrThree: 'three' }, }, ], }); expect(mockBaseClient.bulkGet).toHaveBeenCalledTimes(1); expect(mockBaseClient.bulkGet).toHaveBeenCalledWith(bulkGetParams, options); + + expect(encryptedSavedObjectsServiceMockInstance.stripOrDecryptAttributes).toHaveBeenCalledTimes( + 1 + ); + expect(encryptedSavedObjectsServiceMockInstance.stripOrDecryptAttributes).toHaveBeenCalledWith( + { type: 'known-type', id: 'some-id-2', namespace: 'some-ns' }, + { + attrOne: 'one', + attrSecret: '*secret*', + attrNotSoSecret: '*not-so-secret*', + attrThree: 'three', + }, + undefined, + { user: mockAuthenticatedUser() } + ); + }); + + it('includes both attributes and error if decryption fails.', async () => { + const mockedResponse = { + saved_objects: [ + { + id: 'some-id', + type: 'unknown-type', + attributes: { + attrOne: 'one', + attrSecret: 'secret', + attrNotSoSecret: 'not-so-secret', + attrThree: 'three', + }, + references: [], + }, + { + id: 'some-id-2', + type: 'known-type', + attributes: { + attrOne: 'one', + attrSecret: '*secret*', + attrNotSoSecret: '*not-so-secret*', + attrThree: 'three', + }, + references: [], + }, + ], + total: 2, + per_page: 2, + page: 1, + }; + + mockBaseClient.bulkGet.mockResolvedValue(mockedResponse); + + const decryptionError = new EncryptionError( + 'something failed', + 'attrNotSoSecret', + EncryptionErrorOperation.Decryption + ); + encryptedSavedObjectsServiceMockInstance.stripOrDecryptAttributes.mockResolvedValue({ + attributes: { attrOne: 'one', attrThree: 'three' }, + error: decryptionError, + }); + + const bulkGetParams = [ + { type: 'unknown-type', id: 'some-id' }, + { type: 'known-type', id: 'some-id-2' }, + ]; + + const options = { namespace: 'some-ns' }; + await expect(wrapper.bulkGet(bulkGetParams, options)).resolves.toEqual({ + ...mockedResponse, + saved_objects: [ + { + ...mockedResponse.saved_objects[0], + attributes: { + attrOne: 'one', + attrSecret: 'secret', + attrNotSoSecret: 'not-so-secret', + attrThree: 'three', + }, + }, + { + ...mockedResponse.saved_objects[1], + attributes: { attrOne: 'one', attrThree: 'three' }, + error: decryptionError, + }, + ], + }); + expect(mockBaseClient.bulkGet).toHaveBeenCalledTimes(1); + expect(mockBaseClient.bulkGet).toHaveBeenCalledWith(bulkGetParams, options); + + expect(encryptedSavedObjectsServiceMockInstance.stripOrDecryptAttributes).toHaveBeenCalledTimes( + 1 + ); + expect(encryptedSavedObjectsServiceMockInstance.stripOrDecryptAttributes).toHaveBeenCalledWith( + { type: 'known-type', id: 'some-id-2', namespace: 'some-ns' }, + { + attrOne: 'one', + attrSecret: '*secret*', + attrNotSoSecret: '*not-so-secret*', + attrThree: 'three', + }, + undefined, + { user: mockAuthenticatedUser() } + ); }); it('fails if base client fails', async () => { @@ -814,11 +1118,7 @@ describe('#bulkGet', () => { const options = { namespace: 'some-ns' }; await expect(wrapper.bulkGet(bulkGetParams, options)).resolves.toEqual({ ...mockedResponse, - saved_objects: [ - { - ...mockedResponse.saved_objects[0], - }, - ], + saved_objects: [{ ...mockedResponse.saved_objects[0] }], }); expect(mockBaseClient.bulkGet).toHaveBeenCalledTimes(1); }); @@ -844,11 +1144,16 @@ describe('#get', () => { expect(mockBaseClient.get).toHaveBeenCalledWith('unknown-type', 'some-id', options); }); - it('redirects request to underlying base client and strips encrypted attributes if type is registered', async () => { + it('redirects request to underlying base client and strips encrypted attributes except for ones with `dangerouslyExposeValue` set to `true` if type is registered', async () => { const mockedResponse = { id: 'some-id', type: 'known-type', - attributes: { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' }, + attributes: { + attrOne: 'one', + attrSecret: '*secret*', + attrNotSoSecret: '*not-so-secret*', + attrThree: 'three', + }, references: [], }; @@ -857,10 +1162,75 @@ describe('#get', () => { const options = { namespace: 'some-ns' }; await expect(wrapper.get('known-type', 'some-id', options)).resolves.toEqual({ ...mockedResponse, - attributes: { attrOne: 'one', attrThree: 'three' }, + attributes: { attrOne: 'one', attrNotSoSecret: 'not-so-secret', attrThree: 'three' }, }); expect(mockBaseClient.get).toHaveBeenCalledTimes(1); expect(mockBaseClient.get).toHaveBeenCalledWith('known-type', 'some-id', options); + + expect(encryptedSavedObjectsServiceMockInstance.stripOrDecryptAttributes).toHaveBeenCalledTimes( + 1 + ); + expect(encryptedSavedObjectsServiceMockInstance.stripOrDecryptAttributes).toHaveBeenCalledWith( + { type: 'known-type', id: 'some-id', namespace: 'some-ns' }, + { + attrOne: 'one', + attrSecret: '*secret*', + attrNotSoSecret: '*not-so-secret*', + attrThree: 'three', + }, + undefined, + { user: mockAuthenticatedUser() } + ); + }); + + it('includes both attributes and error if decryption fails.', async () => { + const mockedResponse = { + id: 'some-id', + type: 'known-type', + attributes: { + attrOne: 'one', + attrSecret: '*secret*', + attrNotSoSecret: '*not-so-secret*', + attrThree: 'three', + }, + references: [], + }; + + mockBaseClient.get.mockResolvedValue(mockedResponse); + + const decryptionError = new EncryptionError( + 'something failed', + 'attrNotSoSecret', + EncryptionErrorOperation.Decryption + ); + encryptedSavedObjectsServiceMockInstance.stripOrDecryptAttributes.mockResolvedValue({ + attributes: { attrOne: 'one', attrThree: 'three' }, + error: decryptionError, + }); + + const options = { namespace: 'some-ns' }; + await expect(wrapper.get('known-type', 'some-id', options)).resolves.toEqual({ + ...mockedResponse, + attributes: { attrOne: 'one', attrThree: 'three' }, + error: decryptionError, + }); + expect(mockBaseClient.get).toHaveBeenCalledTimes(1); + expect(mockBaseClient.get).toHaveBeenCalledWith('known-type', 'some-id', options); + + expect(encryptedSavedObjectsServiceMockInstance.stripOrDecryptAttributes).toHaveBeenCalledTimes( + 1 + ); + expect(encryptedSavedObjectsServiceMockInstance.stripOrDecryptAttributes).toHaveBeenCalledWith( + { type: 'known-type', id: 'some-id', namespace: 'some-ns' }, + { + attrOne: 'one', + attrSecret: '*secret*', + attrNotSoSecret: '*not-so-secret*', + attrThree: 'three', + }, + undefined, + { user: mockAuthenticatedUser() } + ); }); it('fails if base client fails', async () => { @@ -895,29 +1265,54 @@ describe('#update', () => { ); }); - it('encrypts attributes and strips them from response', async () => { - const attributes = { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' }; + it('encrypts attributes and strips them from response except for ones with `dangerouslyExposeValue` set to `true`', async () => { + const attributes = { + attrOne: 'one', + attrSecret: 'secret', + attrNotSoSecret: 'not-so-secret', + attrThree: 'three', + }; const options = { version: 'some-version' }; - const mockedResponse = { id: 'some-id', type: 'known-type', attributes, references: [] }; + const mockedResponse = { + id: 'some-id', + type: 'known-type', + attributes: { + ...attributes, + attrSecret: `*${attributes.attrSecret}*`, + attrNotSoSecret: `*${attributes.attrNotSoSecret}*`, + }, + references: [], + }; mockBaseClient.update.mockResolvedValue(mockedResponse); await expect(wrapper.update('known-type', 'some-id', attributes, options)).resolves.toEqual({ ...mockedResponse, - attributes: { attrOne: 'one', attrThree: 'three' }, + attributes: { attrOne: 'one', attrNotSoSecret: 'not-so-secret', attrThree: 'three' }, }); expect(encryptedSavedObjectsServiceMockInstance.encryptAttributes).toHaveBeenCalledTimes(1); expect(encryptedSavedObjectsServiceMockInstance.encryptAttributes).toHaveBeenCalledWith( { type: 'known-type', id: 'some-id' }, - { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' } + { + attrOne: 'one', + attrSecret: 'secret', + attrNotSoSecret: 'not-so-secret', + attrThree: 'three', + }, + { user: mockAuthenticatedUser() } ); expect(mockBaseClient.update).toHaveBeenCalledTimes(1); expect(mockBaseClient.update).toHaveBeenCalledWith( 'known-type', 'some-id', - { attrOne: 'one', attrSecret: '*secret*', attrThree: 'three' }, + { + attrOne: 'one', + attrSecret: '*secret*', + attrNotSoSecret: '*not-so-secret*', + attrThree: 'three', + }, options ); }); @@ -942,7 +1337,8 @@ describe('#update', () => { id: 'some-id', namespace: expectNamespaceInDescriptor ? namespace : undefined, }, - { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' } + { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' }, + { user: mockAuthenticatedUser() } ); expect(mockBaseClient.update).toHaveBeenCalledTimes(1); diff --git a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.ts b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.ts index e8197536d29d..7ba8ef3eaad8 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.ts @@ -23,12 +23,14 @@ import { SavedObjectsDeleteFromNamespacesOptions, ISavedObjectTypeRegistry, } from 'src/core/server'; +import { AuthenticatedUser } from '../../../security/common/model'; import { EncryptedSavedObjectsService } from '../crypto'; interface EncryptedSavedObjectsClientOptions { baseClient: SavedObjectsClientContract; baseTypeRegistry: ISavedObjectTypeRegistry; service: Readonly; + getCurrentUser: () => AuthenticatedUser | undefined; } /** @@ -49,7 +51,7 @@ export class EncryptedSavedObjectsClientWrapper implements SavedObjectsClientCon private getDescriptorNamespace = (type: string, namespace?: string) => this.options.baseTypeRegistry.isSingleNamespace(type) ? namespace : undefined; - public async create( + public async create( type: string, attributes: T = {} as T, options: SavedObjectsCreateOptions = {} @@ -69,19 +71,22 @@ export class EncryptedSavedObjectsClientWrapper implements SavedObjectsClientCon const id = generateID(); const namespace = this.getDescriptorNamespace(type, options.namespace); - return this.stripEncryptedAttributesFromResponse( + return await this.handleEncryptedAttributesInResponse( await this.options.baseClient.create( type, - await this.options.service.encryptAttributes( + (await this.options.service.encryptAttributes( { type, id, namespace }, - attributes as Record - ), + attributes as Record, + { user: this.options.getCurrentUser() } + )) as T, { ...options, id } - ) - ) as SavedObject; + ), + attributes, + namespace + ); } - public async bulkCreate( + public async bulkCreate( objects: Array>, options?: SavedObjectsBaseOptions ) { @@ -110,19 +115,22 @@ export class EncryptedSavedObjectsClientWrapper implements SavedObjectsClientCon id, attributes: await this.options.service.encryptAttributes( { type: object.type, id, namespace }, - object.attributes as Record + object.attributes as Record, + { user: this.options.getCurrentUser() } ), } as SavedObjectsBulkCreateObject; }) ); - return this.stripEncryptedAttributesFromBulkResponse( - await this.options.baseClient.bulkCreate(encryptedObjects, options) + return await this.handleEncryptedAttributesInBulkResponse( + await this.options.baseClient.bulkCreate(encryptedObjects, options), + objects, + options?.namespace ); } - public async bulkUpdate( - objects: SavedObjectsBulkUpdateObject[], + public async bulkUpdate( + objects: Array>, options?: SavedObjectsBaseOptions ) { // We encrypt attributes for every object in parallel and that can potentially exhaust libuv or @@ -139,14 +147,17 @@ export class EncryptedSavedObjectsClientWrapper implements SavedObjectsClientCon ...object, attributes: await this.options.service.encryptAttributes( { type, id, namespace }, - attributes + attributes, + { user: this.options.getCurrentUser() } ), }; }) ); - return this.stripEncryptedAttributesFromBulkResponse( - await this.options.baseClient.bulkUpdate(encryptedObjects, options) + return await this.handleEncryptedAttributesInBulkResponse( + await this.options.baseClient.bulkUpdate(encryptedObjects, options), + objects, + options?.namespace ); } @@ -154,28 +165,34 @@ export class EncryptedSavedObjectsClientWrapper implements SavedObjectsClientCon return await this.options.baseClient.delete(type, id, options); } - public async find(options: SavedObjectsFindOptions) { - return this.stripEncryptedAttributesFromBulkResponse( - await this.options.baseClient.find(options) + public async find(options: SavedObjectsFindOptions) { + return await this.handleEncryptedAttributesInBulkResponse( + await this.options.baseClient.find(options), + undefined, + options.namespace ); } - public async bulkGet( + public async bulkGet( objects: SavedObjectsBulkGetObject[] = [], options?: SavedObjectsBaseOptions ) { - return this.stripEncryptedAttributesFromBulkResponse( - await this.options.baseClient.bulkGet(objects, options) + return await this.handleEncryptedAttributesInBulkResponse( + await this.options.baseClient.bulkGet(objects, options), + undefined, + options?.namespace ); } - public async get(type: string, id: string, options?: SavedObjectsBaseOptions) { - return this.stripEncryptedAttributesFromResponse( - await this.options.baseClient.get(type, id, options) + public async get(type: string, id: string, options?: SavedObjectsBaseOptions) { + return await this.handleEncryptedAttributesInResponse( + await this.options.baseClient.get(type, id, options), + undefined as unknown, + this.getDescriptorNamespace(type, options?.namespace) ); } - public async update( + public async update( type: string, id: string, attributes: Partial, @@ -185,13 +202,17 @@ export class EncryptedSavedObjectsClientWrapper implements SavedObjectsClientCon return await this.options.baseClient.update(type, id, attributes, options); } const namespace = this.getDescriptorNamespace(type, options?.namespace); - return this.stripEncryptedAttributesFromResponse( + return this.handleEncryptedAttributesInResponse( await this.options.baseClient.update( type, id, - await this.options.service.encryptAttributes({ type, id, namespace }, attributes), + await this.options.service.encryptAttributes({ type, id, namespace }, attributes, { + user: this.options.getCurrentUser(), + }), options - ) + ), + attributes, + namespace ); } @@ -217,15 +238,28 @@ export class EncryptedSavedObjectsClientWrapper implements SavedObjectsClientCon * Strips encrypted attributes from any non-bulk Saved Objects API response. If type isn't * registered, response is returned as is. * @param response Raw response returned by the underlying base client. + * @param [originalAttributes] Optional list of original attributes of the saved object. + * @param [namespace] Optional namespace that was used for the saved objects operation. */ - private stripEncryptedAttributesFromResponse( - response: T - ): T { - if (this.options.service.isRegistered(response.type) && response.attributes) { - response.attributes = this.options.service.stripEncryptedAttributes( - response.type, - response.attributes as Record + private async handleEncryptedAttributesInResponse< + T, + R extends SavedObjectsUpdateResponse | SavedObject + >(response: R, originalAttributes?: T, namespace?: string): Promise { + if (response.attributes && this.options.service.isRegistered(response.type)) { + // Error is returned when decryption fails, and in this case encrypted attributes will be + // stripped from the returned attributes collection. That will let consumer decide whether to + // fail or handle recovery gracefully. + const { attributes, error } = await this.options.service.stripOrDecryptAttributes( + { id: response.id, type: response.type, namespace }, + response.attributes as Record, + originalAttributes as Record, + { user: this.options.getCurrentUser() } ); + + response.attributes = attributes as T; + if (error) { + response.error = error as any; + } } return response; @@ -235,17 +269,23 @@ export class EncryptedSavedObjectsClientWrapper implements SavedObjectsClientCon * Strips encrypted attributes from any bulk Saved Objects API response. If type for any bulk * response portion isn't registered, it is returned as is. * @param response Raw response returned by the underlying base client. + * @param [objects] Optional list of saved objects with original attributes. + * @param [namespace] Optional namespace that was used for the saved objects operation. */ - private stripEncryptedAttributesFromBulkResponse< - T extends SavedObjectsBulkResponse | SavedObjectsFindResponse | SavedObjectsBulkUpdateResponse - >(response: T): T { - for (const savedObject of response.saved_objects) { - if (this.options.service.isRegistered(savedObject.type) && savedObject.attributes) { - savedObject.attributes = this.options.service.stripEncryptedAttributes( - savedObject.type, - savedObject.attributes as Record - ); - } + private async handleEncryptedAttributesInBulkResponse< + T, + R extends + | SavedObjectsBulkResponse + | SavedObjectsFindResponse + | SavedObjectsBulkUpdateResponse, + O extends Array> | Array> + >(response: R, objects?: O, namespace?: string) { + for (const [index, savedObject] of response.saved_objects.entries()) { + await this.handleEncryptedAttributesInResponse( + savedObject, + objects?.[index].attributes ?? undefined, + this.getDescriptorNamespace(savedObject.type, namespace) + ); } return response; diff --git a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/index.test.ts b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/index.test.ts new file mode 100644 index 000000000000..c11f6a2b2afa --- /dev/null +++ b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/index.test.ts @@ -0,0 +1,140 @@ +/* + * 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 { SavedObjectsSetup, setupSavedObjects } from '.'; + +import { + coreMock, + httpServerMock, + savedObjectsClientMock, + savedObjectsRepositoryMock, + savedObjectsTypeRegistryMock, +} from '../../../../../src/core/server/mocks'; +import { securityMock } from '../../../security/server/mocks'; +import { encryptedSavedObjectsServiceMock } from '../crypto/index.mock'; +import { EncryptedSavedObjectsClientWrapper } from './encrypted_saved_objects_client_wrapper'; +import { + ISavedObjectsRepository, + ISavedObjectTypeRegistry, + SavedObject, +} from '../../../../../src/core/server'; +import { EncryptedSavedObjectsService } from '../crypto'; + +describe('#setupSavedObjects', () => { + let setupContract: SavedObjectsSetup; + let coreSetupMock: ReturnType; + let mockSavedObjectsRepository: jest.Mocked; + let mockSavedObjectTypeRegistry: jest.Mocked; + let mockEncryptedSavedObjectsService: jest.Mocked; + beforeEach(() => { + const coreStartMock = coreMock.createStart(); + + mockSavedObjectsRepository = savedObjectsRepositoryMock.create(); + coreStartMock.savedObjects.createInternalRepository.mockReturnValue(mockSavedObjectsRepository); + + mockSavedObjectTypeRegistry = savedObjectsTypeRegistryMock.create(); + coreStartMock.savedObjects.getTypeRegistry.mockReturnValue(mockSavedObjectTypeRegistry); + + coreSetupMock = coreMock.createSetup(); + coreSetupMock.getStartServices.mockResolvedValue([coreStartMock, {}, {}]); + + mockEncryptedSavedObjectsService = encryptedSavedObjectsServiceMock.create([ + { type: 'known-type', attributesToEncrypt: new Set(['attrSecret']) }, + ]); + setupContract = setupSavedObjects({ + service: mockEncryptedSavedObjectsService, + savedObjects: coreSetupMock.savedObjects, + security: securityMock.createSetup(), + getStartServices: coreSetupMock.getStartServices, + }); + }); + + it('properly registers client wrapper factory', () => { + expect(coreSetupMock.savedObjects.addClientWrapper).toHaveBeenCalledTimes(1); + expect(coreSetupMock.savedObjects.addClientWrapper).toHaveBeenCalledWith( + Number.MAX_SAFE_INTEGER, + 'encryptedSavedObjects', + expect.any(Function) + ); + + const [[, , clientFactory]] = coreSetupMock.savedObjects.addClientWrapper.mock.calls; + expect( + clientFactory({ + client: savedObjectsClientMock.create(), + typeRegistry: savedObjectsTypeRegistryMock.create(), + request: httpServerMock.createKibanaRequest(), + }) + ).toBeInstanceOf(EncryptedSavedObjectsClientWrapper); + }); + + describe('#getDecryptedAsInternalUser', () => { + it('includes `namespace` for single-namespace saved objects', async () => { + const mockSavedObject: SavedObject = { + id: 'some-id', + type: 'known-type', + attributes: { attrOne: 'one', attrSecret: '*secret*' }, + references: [], + }; + mockSavedObjectsRepository.get.mockResolvedValue(mockSavedObject); + mockSavedObjectTypeRegistry.isSingleNamespace.mockReturnValue(true); + + await expect( + setupContract.getDecryptedAsInternalUser(mockSavedObject.type, mockSavedObject.id, { + namespace: 'some-ns', + }) + ).resolves.toEqual({ + ...mockSavedObject, + attributes: { attrOne: 'one', attrSecret: 'secret' }, + }); + + expect(mockEncryptedSavedObjectsService.decryptAttributes).toHaveBeenCalledTimes(1); + expect(mockEncryptedSavedObjectsService.decryptAttributes).toHaveBeenCalledWith( + { type: mockSavedObject.type, id: mockSavedObject.id, namespace: 'some-ns' }, + mockSavedObject.attributes + ); + + expect(mockSavedObjectsRepository.get).toHaveBeenCalledTimes(1); + expect(mockSavedObjectsRepository.get).toHaveBeenCalledWith( + mockSavedObject.type, + mockSavedObject.id, + { namespace: 'some-ns' } + ); + }); + + it('does not include `namespace` for multiple-namespace saved objects', async () => { + const mockSavedObject: SavedObject = { + id: 'some-id', + type: 'known-type', + attributes: { attrOne: 'one', attrSecret: '*secret*' }, + references: [], + }; + mockSavedObjectsRepository.get.mockResolvedValue(mockSavedObject); + mockSavedObjectTypeRegistry.isSingleNamespace.mockReturnValue(false); + + await expect( + setupContract.getDecryptedAsInternalUser(mockSavedObject.type, mockSavedObject.id, { + namespace: 'some-ns', + }) + ).resolves.toEqual({ + ...mockSavedObject, + attributes: { attrOne: 'one', attrSecret: 'secret' }, + }); + + expect(mockEncryptedSavedObjectsService.decryptAttributes).toHaveBeenCalledTimes(1); + expect(mockEncryptedSavedObjectsService.decryptAttributes).toHaveBeenCalledWith( + { type: mockSavedObject.type, id: mockSavedObject.id, namespace: undefined }, + mockSavedObject.attributes + ); + + expect(mockSavedObjectsRepository.get).toHaveBeenCalledTimes(1); + expect(mockSavedObjectsRepository.get).toHaveBeenCalledWith( + mockSavedObject.type, + mockSavedObject.id, + { namespace: 'some-ns' } + ); + }); + }); +}); diff --git a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/index.ts b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/index.ts index 10599ae3a179..9eca93ffd0b9 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/index.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/index.ts @@ -9,13 +9,17 @@ import { SavedObject, SavedObjectsBaseOptions, SavedObjectsServiceSetup, + ISavedObjectsRepository, + ISavedObjectTypeRegistry, } from 'src/core/server'; +import { SecurityPluginSetup } from '../../../security/server'; import { EncryptedSavedObjectsService } from '../crypto'; import { EncryptedSavedObjectsClientWrapper } from './encrypted_saved_objects_client_wrapper'; interface SetupSavedObjectsParams { service: PublicMethodsOf; savedObjects: SavedObjectsServiceSetup; + security?: SecurityPluginSetup; getStartServices: StartServicesAccessor; } @@ -30,6 +34,7 @@ export interface SavedObjectsSetup { export function setupSavedObjects({ service, savedObjects, + security, getStartServices, }: SetupSavedObjectsParams): SavedObjectsSetup { // Register custom saved object client that will encrypt, decrypt and strip saved object @@ -40,25 +45,39 @@ export function setupSavedObjects({ savedObjects.addClientWrapper( Number.MAX_SAFE_INTEGER, 'encryptedSavedObjects', - ({ client: baseClient, typeRegistry: baseTypeRegistry }) => - new EncryptedSavedObjectsClientWrapper({ baseClient, baseTypeRegistry, service }) + ({ client: baseClient, typeRegistry: baseTypeRegistry, request }) => + new EncryptedSavedObjectsClientWrapper({ + baseClient, + baseTypeRegistry, + service, + getCurrentUser: () => security?.authc.getCurrentUser(request) ?? undefined, + }) ); - const internalRepositoryPromise = getStartServices().then(([core]) => - core.savedObjects.createInternalRepository() + const internalRepositoryAndTypeRegistryPromise = getStartServices().then( + ([core]) => + [core.savedObjects.createInternalRepository(), core.savedObjects.getTypeRegistry()] as [ + ISavedObjectsRepository, + ISavedObjectTypeRegistry + ] ); + return { getDecryptedAsInternalUser: async ( type: string, id: string, options?: SavedObjectsBaseOptions ): Promise> => { - const internalRepository = await internalRepositoryPromise; + const [internalRepository, typeRegistry] = await internalRepositoryAndTypeRegistryPromise; const savedObject = await internalRepository.get(type, id, options); return { ...savedObject, attributes: (await service.decryptAttributes( - { type, id, namespace: options && options.namespace }, + { + type, + id, + namespace: typeRegistry.isSingleNamespace(type) ? options?.namespace : undefined, + }, savedObject.attributes as Record )) as T, }; diff --git a/x-pack/test/encrypted_saved_objects_api_integration/fixtures/api_consumer_plugin/server/index.ts b/x-pack/test/encrypted_saved_objects_api_integration/fixtures/api_consumer_plugin/server/index.ts index 838a1d1c9461..46bb7f802462 100644 --- a/x-pack/test/encrypted_saved_objects_api_integration/fixtures/api_consumer_plugin/server/index.ts +++ b/x-pack/test/encrypted_saved_objects_api_integration/fixtures/api_consumer_plugin/server/index.ts @@ -4,8 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ -import { CoreSetup, PluginInitializer } from '../../../../../../src/core/server'; -import { deepFreeze } from '../../../../../../src/core/server'; +import { + deepFreeze, + CoreSetup, + PluginInitializer, + SavedObjectsNamespaceType, +} from '../../../../../../src/core/server'; import { EncryptedSavedObjectsPluginSetup, EncryptedSavedObjectsPluginStart, @@ -13,6 +17,9 @@ import { import { SpacesPluginSetup } from '../../../../../plugins/spaces/server'; const SAVED_OBJECT_WITH_SECRET_TYPE = 'saved-object-with-secret'; +const SAVED_OBJECT_WITH_SECRET_AND_MULTIPLE_SPACES_TYPE = + 'saved-object-with-secret-and-multiple-spaces'; +const SAVED_OBJECT_WITHOUT_SECRET_TYPE = 'saved-object-without-secret'; interface PluginsSetup { encryptedSavedObjects: EncryptedSavedObjectsPluginSetup; @@ -26,28 +33,44 @@ interface PluginsStart { export const plugin: PluginInitializer = () => ({ setup(core: CoreSetup, deps) { + for (const [name, namespaceType] of [ + [SAVED_OBJECT_WITH_SECRET_TYPE, 'single'], + [SAVED_OBJECT_WITH_SECRET_AND_MULTIPLE_SPACES_TYPE, 'multiple'], + ] as Array<[string, SavedObjectsNamespaceType]>) { + core.savedObjects.registerType({ + name, + hidden: false, + namespaceType, + mappings: deepFreeze({ + properties: { + publicProperty: { type: 'keyword' }, + publicPropertyExcludedFromAAD: { type: 'keyword' }, + publicPropertyStoredEncrypted: { type: 'binary' }, + privateProperty: { type: 'binary' }, + }, + }), + }); + + deps.encryptedSavedObjects.registerType({ + type: name, + attributesToEncrypt: new Set([ + 'privateProperty', + { key: 'publicPropertyStoredEncrypted', dangerouslyExposeValue: true }, + ]), + attributesToExcludeFromAAD: new Set(['publicPropertyExcludedFromAAD']), + }); + } + core.savedObjects.registerType({ - name: SAVED_OBJECT_WITH_SECRET_TYPE, + name: SAVED_OBJECT_WITHOUT_SECRET_TYPE, hidden: false, namespaceType: 'single', - mappings: deepFreeze({ - properties: { - publicProperty: { type: 'keyword' }, - publicPropertyExcludedFromAAD: { type: 'keyword' }, - privateProperty: { type: 'binary' }, - }, - }), - }); - - deps.encryptedSavedObjects.registerType({ - type: SAVED_OBJECT_WITH_SECRET_TYPE, - attributesToEncrypt: new Set(['privateProperty']), - attributesToExcludeFromAAD: new Set(['publicPropertyExcludedFromAAD']), + mappings: deepFreeze({ properties: { publicProperty: { type: 'keyword' } } }), }); core.http.createRouter().get( { - path: '/api/saved_objects/get-decrypted-as-internal-user/{id}', + path: '/api/saved_objects/get-decrypted-as-internal-user/{type}/{id}', validate: { params: value => ({ value }) }, }, async (context, request, response) => { @@ -58,7 +81,7 @@ export const plugin: PluginInitializer = try { return response.ok({ body: await encryptedSavedObjects.getDecryptedAsInternalUser( - SAVED_OBJECT_WITH_SECRET_TYPE, + request.params.type, request.params.id, { namespace } ), diff --git a/x-pack/test/encrypted_saved_objects_api_integration/tests/encrypted_saved_objects_api.ts b/x-pack/test/encrypted_saved_objects_api_integration/tests/encrypted_saved_objects_api.ts index 7fe3d2891121..54b1f00616c9 100644 --- a/x-pack/test/encrypted_saved_objects_api_integration/tests/encrypted_saved_objects_api.ts +++ b/x-pack/test/encrypted_saved_objects_api_integration/tests/encrypted_saved_objects_api.ts @@ -14,13 +14,20 @@ export default function({ getService }: FtrProviderContext) { const supertest = getService('supertest'); const SAVED_OBJECT_WITH_SECRET_TYPE = 'saved-object-with-secret'; + const SAVED_OBJECT_WITH_SECRET_AND_MULTIPLE_SPACES_TYPE = + 'saved-object-with-secret-and-multiple-spaces'; + const SAVED_OBJECT_WITHOUT_SECRET_TYPE = 'saved-object-without-secret'; - function runTests(getURLAPIBaseURL: () => string, generateRawID: (id: string) => string) { - async function getRawSavedObjectAttributes(id: string) { + function runTests( + encryptedSavedObjectType: string, + getURLAPIBaseURL: () => string, + generateRawID: (id: string, type: string) => string + ) { + async function getRawSavedObjectAttributes({ id, type }: SavedObject) { const { - _source: { [SAVED_OBJECT_WITH_SECRET_TYPE]: savedObject }, + _source: { [type]: savedObject }, } = await es.get({ - id: generateRawID(id), + id: generateRawID(id, type), index: '.kibana', }); @@ -29,20 +36,22 @@ export default function({ getService }: FtrProviderContext) { let savedObjectOriginalAttributes: { publicProperty: string; - publicPropertyExcludedFromAAD: string; + publicPropertyStoredEncrypted: string; privateProperty: string; + publicPropertyExcludedFromAAD: string; }; let savedObject: SavedObject; beforeEach(async () => { savedObjectOriginalAttributes = { publicProperty: randomness.string(), - publicPropertyExcludedFromAAD: randomness.string(), + publicPropertyStoredEncrypted: randomness.string(), privateProperty: randomness.string(), + publicPropertyExcludedFromAAD: randomness.string(), }; const { body } = await supertest - .post(`${getURLAPIBaseURL()}${SAVED_OBJECT_WITH_SECRET_TYPE}`) + .post(`${getURLAPIBaseURL()}${encryptedSavedObjectType}`) .set('kbn-xsrf', 'xxx') .send({ attributes: savedObjectOriginalAttributes }) .expect(200); @@ -54,14 +63,19 @@ export default function({ getService }: FtrProviderContext) { expect(savedObject.attributes).to.eql({ publicProperty: savedObjectOriginalAttributes.publicProperty, publicPropertyExcludedFromAAD: savedObjectOriginalAttributes.publicPropertyExcludedFromAAD, + publicPropertyStoredEncrypted: savedObjectOriginalAttributes.publicPropertyStoredEncrypted, }); - const rawAttributes = await getRawSavedObjectAttributes(savedObject.id); + const rawAttributes = await getRawSavedObjectAttributes(savedObject); expect(rawAttributes.publicProperty).to.be(savedObjectOriginalAttributes.publicProperty); expect(rawAttributes.publicPropertyExcludedFromAAD).to.be( savedObjectOriginalAttributes.publicPropertyExcludedFromAAD ); + expect(rawAttributes.publicPropertyStoredEncrypted).to.not.be.empty(); + expect(rawAttributes.publicPropertyStoredEncrypted).to.not.be( + savedObjectOriginalAttributes.publicPropertyStoredEncrypted + ); expect(rawAttributes.privateProperty).to.not.be.empty(); expect(rawAttributes.privateProperty).to.not.be( savedObjectOriginalAttributes.privateProperty @@ -71,18 +85,20 @@ export default function({ getService }: FtrProviderContext) { it('#bulkCreate encrypts attributes and strips them from response', async () => { const bulkCreateParams = [ { - type: SAVED_OBJECT_WITH_SECRET_TYPE, + type: encryptedSavedObjectType, attributes: { publicProperty: randomness.string(), publicPropertyExcludedFromAAD: randomness.string(), + publicPropertyStoredEncrypted: randomness.string(), privateProperty: randomness.string(), }, }, { - type: SAVED_OBJECT_WITH_SECRET_TYPE, + type: encryptedSavedObjectType, attributes: { publicProperty: randomness.string(), publicPropertyExcludedFromAAD: randomness.string(), + publicPropertyStoredEncrypted: randomness.string(), privateProperty: randomness.string(), }, }, @@ -100,30 +116,120 @@ export default function({ getService }: FtrProviderContext) { for (let index = 0; index < savedObjects.length; index++) { const attributesFromResponse = savedObjects[index].attributes; const attributesFromRequest = bulkCreateParams[index].attributes; - const rawAttributes = await getRawSavedObjectAttributes(savedObjects[index].id); + const rawAttributes = await getRawSavedObjectAttributes(savedObjects[index]); expect(attributesFromResponse).to.eql({ publicProperty: attributesFromRequest.publicProperty, publicPropertyExcludedFromAAD: attributesFromRequest.publicPropertyExcludedFromAAD, + publicPropertyStoredEncrypted: attributesFromRequest.publicPropertyStoredEncrypted, }); expect(rawAttributes.publicProperty).to.be(attributesFromRequest.publicProperty); expect(rawAttributes.publicPropertyExcludedFromAAD).to.be( attributesFromRequest.publicPropertyExcludedFromAAD ); + expect(rawAttributes.publicPropertyStoredEncrypted).to.not.be.empty(); + expect(rawAttributes.publicPropertyStoredEncrypted).to.not.be( + attributesFromRequest.publicPropertyStoredEncrypted + ); expect(rawAttributes.privateProperty).to.not.be.empty(); expect(rawAttributes.privateProperty).to.not.be(attributesFromRequest.privateProperty); } }); + it('#bulkCreate with different types encrypts attributes and strips them from response when necessary', async () => { + const bulkCreateParams = [ + { + type: encryptedSavedObjectType, + attributes: { + publicProperty: randomness.string(), + publicPropertyExcludedFromAAD: randomness.string(), + publicPropertyStoredEncrypted: randomness.string(), + privateProperty: randomness.string(), + }, + }, + { + type: SAVED_OBJECT_WITHOUT_SECRET_TYPE, + attributes: { + publicProperty: randomness.string(), + }, + }, + ]; + + const { + body: { saved_objects: savedObjects }, + } = await supertest + .post(`${getURLAPIBaseURL()}_bulk_create`) + .set('kbn-xsrf', 'xxx') + .send(bulkCreateParams) + .expect(200); + + expect(savedObjects).to.have.length(bulkCreateParams.length); + for (let index = 0; index < savedObjects.length; index++) { + const attributesFromResponse = savedObjects[index].attributes; + const attributesFromRequest = bulkCreateParams[index].attributes; + + const type = savedObjects[index].type; + expect(type).to.be.eql(bulkCreateParams[index].type); + + const rawAttributes = await getRawSavedObjectAttributes(savedObjects[index]); + if (type === SAVED_OBJECT_WITHOUT_SECRET_TYPE) { + expect(attributesFromResponse).to.eql(attributesFromRequest); + expect(attributesFromRequest).to.eql(rawAttributes); + } else { + expect(attributesFromResponse).to.eql({ + publicProperty: attributesFromRequest.publicProperty, + publicPropertyExcludedFromAAD: attributesFromRequest.publicPropertyExcludedFromAAD, + publicPropertyStoredEncrypted: attributesFromRequest.publicPropertyStoredEncrypted, + }); + + expect(rawAttributes.publicProperty).to.be(attributesFromRequest.publicProperty); + expect(rawAttributes.publicPropertyExcludedFromAAD).to.be( + attributesFromRequest.publicPropertyExcludedFromAAD + ); + expect(rawAttributes.publicPropertyStoredEncrypted).to.not.be.empty(); + expect(rawAttributes.publicPropertyStoredEncrypted).to.not.be( + attributesFromRequest.publicPropertyStoredEncrypted + ); + expect(rawAttributes.privateProperty).to.not.be.empty(); + expect(rawAttributes.privateProperty).to.not.be(attributesFromRequest.privateProperty); + } + } + }); + it('#get strips encrypted attributes from response', async () => { const { body: response } = await supertest - .get(`${getURLAPIBaseURL()}${SAVED_OBJECT_WITH_SECRET_TYPE}/${savedObject.id}`) + .get(`${getURLAPIBaseURL()}${encryptedSavedObjectType}/${savedObject.id}`) .expect(200); expect(response.attributes).to.eql({ publicProperty: savedObjectOriginalAttributes.publicProperty, publicPropertyExcludedFromAAD: savedObjectOriginalAttributes.publicPropertyExcludedFromAAD, + publicPropertyStoredEncrypted: savedObjectOriginalAttributes.publicPropertyStoredEncrypted, + }); + expect(response.error).to.be(undefined); + }); + + it('#get strips all encrypted attributes from response if decryption fails', async () => { + // Update non-encrypted property that is included into AAD to make it impossible to decrypt + // encrypted attributes. + const updatedPublicProperty = randomness.string(); + await supertest + .put(`${getURLAPIBaseURL()}${encryptedSavedObjectType}/${savedObject.id}`) + .set('kbn-xsrf', 'xxx') + .send({ attributes: { publicProperty: updatedPublicProperty } }) + .expect(200); + + const { body: response } = await supertest + .get(`${getURLAPIBaseURL()}${encryptedSavedObjectType}/${savedObject.id}`) + .expect(200); + + expect(response.attributes).to.eql({ + publicProperty: updatedPublicProperty, + publicPropertyExcludedFromAAD: savedObjectOriginalAttributes.publicPropertyExcludedFromAAD, + }); + expect(response.error).to.eql({ + message: 'Unable to decrypt attribute "publicPropertyStoredEncrypted"', }); }); @@ -131,7 +237,7 @@ export default function({ getService }: FtrProviderContext) { const { body: { saved_objects: savedObjects }, } = await supertest - .get(`${getURLAPIBaseURL()}_find?type=${SAVED_OBJECT_WITH_SECRET_TYPE}`) + .get(`${getURLAPIBaseURL()}_find?type=${encryptedSavedObjectType}`) .expect(200); expect(savedObjects).to.have.length(1); @@ -139,6 +245,35 @@ export default function({ getService }: FtrProviderContext) { expect(savedObjects[0].attributes).to.eql({ publicProperty: savedObjectOriginalAttributes.publicProperty, publicPropertyExcludedFromAAD: savedObjectOriginalAttributes.publicPropertyExcludedFromAAD, + publicPropertyStoredEncrypted: savedObjectOriginalAttributes.publicPropertyStoredEncrypted, + }); + expect(savedObjects[0].error).to.be(undefined); + }); + + it('#find strips all encrypted attributes from response if decryption fails', async () => { + // Update non-encrypted property that is included into AAD to make it impossible to decrypt + // encrypted attributes. + const updatedPublicProperty = randomness.string(); + await supertest + .put(`${getURLAPIBaseURL()}${encryptedSavedObjectType}/${savedObject.id}`) + .set('kbn-xsrf', 'xxx') + .send({ attributes: { publicProperty: updatedPublicProperty } }) + .expect(200); + + const { + body: { saved_objects: savedObjects }, + } = await supertest + .get(`${getURLAPIBaseURL()}_find?type=${encryptedSavedObjectType}`) + .expect(200); + + expect(savedObjects).to.have.length(1); + expect(savedObjects[0].id).to.be(savedObject.id); + expect(savedObjects[0].attributes).to.eql({ + publicProperty: updatedPublicProperty, + publicPropertyExcludedFromAAD: savedObjectOriginalAttributes.publicPropertyExcludedFromAAD, + }); + expect(savedObjects[0].error).to.eql({ + message: 'Unable to decrypt attribute "publicPropertyStoredEncrypted"', }); }); @@ -156,6 +291,37 @@ export default function({ getService }: FtrProviderContext) { expect(savedObjects[0].attributes).to.eql({ publicProperty: savedObjectOriginalAttributes.publicProperty, publicPropertyExcludedFromAAD: savedObjectOriginalAttributes.publicPropertyExcludedFromAAD, + publicPropertyStoredEncrypted: savedObjectOriginalAttributes.publicPropertyStoredEncrypted, + }); + expect(savedObjects[0].error).to.be(undefined); + }); + + it('#bulkGet strips all encrypted attributes from response if decryption fails', async () => { + // Update non-encrypted property that is included into AAD to make it impossible to decrypt + // encrypted attributes. + const updatedPublicProperty = randomness.string(); + await supertest + .put(`${getURLAPIBaseURL()}${encryptedSavedObjectType}/${savedObject.id}`) + .set('kbn-xsrf', 'xxx') + .send({ attributes: { publicProperty: updatedPublicProperty } }) + .expect(200); + + const { + body: { saved_objects: savedObjects }, + } = await supertest + .post(`${getURLAPIBaseURL()}_bulk_get`) + .set('kbn-xsrf', 'xxx') + .send([{ type: savedObject.type, id: savedObject.id }]) + .expect(200); + + expect(savedObjects).to.have.length(1); + expect(savedObjects[0].id).to.be(savedObject.id); + expect(savedObjects[0].attributes).to.eql({ + publicProperty: updatedPublicProperty, + publicPropertyExcludedFromAAD: savedObjectOriginalAttributes.publicPropertyExcludedFromAAD, + }); + expect(savedObjects[0].error).to.eql({ + message: 'Unable to decrypt attribute "publicPropertyStoredEncrypted"', }); }); @@ -163,11 +329,12 @@ export default function({ getService }: FtrProviderContext) { const updatedAttributes = { publicProperty: randomness.string(), publicPropertyExcludedFromAAD: randomness.string(), + publicPropertyStoredEncrypted: randomness.string(), privateProperty: randomness.string(), }; const { body: response } = await supertest - .put(`${getURLAPIBaseURL()}${SAVED_OBJECT_WITH_SECRET_TYPE}/${savedObject.id}`) + .put(`${getURLAPIBaseURL()}${encryptedSavedObjectType}/${savedObject.id}`) .set('kbn-xsrf', 'xxx') .send({ attributes: updatedAttributes }) .expect(200); @@ -175,13 +342,18 @@ export default function({ getService }: FtrProviderContext) { expect(response.attributes).to.eql({ publicProperty: updatedAttributes.publicProperty, publicPropertyExcludedFromAAD: updatedAttributes.publicPropertyExcludedFromAAD, + publicPropertyStoredEncrypted: updatedAttributes.publicPropertyStoredEncrypted, }); - const rawAttributes = await getRawSavedObjectAttributes(savedObject.id); + const rawAttributes = await getRawSavedObjectAttributes(savedObject); expect(rawAttributes.publicProperty).to.be(updatedAttributes.publicProperty); expect(rawAttributes.publicPropertyExcludedFromAAD).to.be( updatedAttributes.publicPropertyExcludedFromAAD ); + expect(rawAttributes.publicPropertyStoredEncrypted).to.not.be.empty(); + expect(rawAttributes.publicPropertyStoredEncrypted).to.not.be( + updatedAttributes.publicPropertyStoredEncrypted + ); expect(rawAttributes.privateProperty).to.not.be.empty(); expect(rawAttributes.privateProperty).to.not.be(updatedAttributes.privateProperty); @@ -189,7 +361,11 @@ export default function({ getService }: FtrProviderContext) { it('#getDecryptedAsInternalUser decrypts and returns all attributes', async () => { const { body: decryptedResponse } = await supertest - .get(`${getURLAPIBaseURL()}get-decrypted-as-internal-user/${savedObject.id}`) + .get( + `${getURLAPIBaseURL()}get-decrypted-as-internal-user/${encryptedSavedObjectType}/${ + savedObject.id + }` + ) .expect(200); expect(decryptedResponse.attributes).to.eql(savedObjectOriginalAttributes); @@ -199,7 +375,7 @@ export default function({ getService }: FtrProviderContext) { const updatedAttributes = { publicPropertyExcludedFromAAD: randomness.string() }; const { body: response } = await supertest - .put(`${getURLAPIBaseURL()}${SAVED_OBJECT_WITH_SECRET_TYPE}/${savedObject.id}`) + .put(`${getURLAPIBaseURL()}${encryptedSavedObjectType}/${savedObject.id}`) .set('kbn-xsrf', 'xxx') .send({ attributes: updatedAttributes }) .expect(200); @@ -209,7 +385,11 @@ export default function({ getService }: FtrProviderContext) { }); const { body: decryptedResponse } = await supertest - .get(`${getURLAPIBaseURL()}get-decrypted-as-internal-user/${savedObject.id}`) + .get( + `${getURLAPIBaseURL()}get-decrypted-as-internal-user/${encryptedSavedObjectType}/${ + savedObject.id + }` + ) .expect(200); expect(decryptedResponse.attributes).to.eql({ @@ -222,7 +402,7 @@ export default function({ getService }: FtrProviderContext) { const updatedAttributes = { publicProperty: randomness.string() }; const { body: response } = await supertest - .put(`${getURLAPIBaseURL()}${SAVED_OBJECT_WITH_SECRET_TYPE}/${savedObject.id}`) + .put(`${getURLAPIBaseURL()}${encryptedSavedObjectType}/${savedObject.id}`) .set('kbn-xsrf', 'xxx') .send({ attributes: updatedAttributes }) .expect(200); @@ -233,7 +413,11 @@ export default function({ getService }: FtrProviderContext) { // Bad request means that we successfully detected "EncryptionError" (not unexpected one). await supertest - .get(`${getURLAPIBaseURL()}get-decrypted-as-internal-user/${savedObject.id}`) + .get( + `${getURLAPIBaseURL()}get-decrypted-as-internal-user/${encryptedSavedObjectType}/${ + savedObject.id + }` + ) .expect(400, { statusCode: 400, error: 'Bad Request', @@ -243,19 +427,38 @@ export default function({ getService }: FtrProviderContext) { } describe('encrypted saved objects API', () => { + function generateRawId(id: string, type: string, spaceId?: string) { + return `${ + spaceId && type !== SAVED_OBJECT_WITH_SECRET_AND_MULTIPLE_SPACES_TYPE + ? `${spaceId}:${type}` + : type + }:${id}`; + } + afterEach(async () => { await es.deleteByQuery({ index: '.kibana', - q: `type:${SAVED_OBJECT_WITH_SECRET_TYPE}`, + q: `type:${SAVED_OBJECT_WITH_SECRET_TYPE} OR type:${SAVED_OBJECT_WITH_SECRET_AND_MULTIPLE_SPACES_TYPE} OR type:${SAVED_OBJECT_WITHOUT_SECRET_TYPE}`, refresh: true, }); }); describe('within a default space', () => { - runTests( - () => '/api/saved_objects/', - id => `${SAVED_OBJECT_WITH_SECRET_TYPE}:${id}` - ); + describe('with `single` namespace saved object', () => { + runTests( + SAVED_OBJECT_WITH_SECRET_TYPE, + () => '/api/saved_objects/', + (id, type) => generateRawId(id, type) + ); + }); + + describe('with `multiple` namespace saved object', () => { + runTests( + SAVED_OBJECT_WITH_SECRET_AND_MULTIPLE_SPACES_TYPE, + () => '/api/saved_objects/', + (id, type) => generateRawId(id, type) + ); + }); }); describe('within a custom space', () => { @@ -276,10 +479,21 @@ export default function({ getService }: FtrProviderContext) { .expect(204); }); - runTests( - () => `/s/${SPACE_ID}/api/saved_objects/`, - id => `${SPACE_ID}:${SAVED_OBJECT_WITH_SECRET_TYPE}:${id}` - ); + describe('with `single` namespace saved object', () => { + runTests( + SAVED_OBJECT_WITH_SECRET_TYPE, + () => `/s/${SPACE_ID}/api/saved_objects/`, + (id, type) => generateRawId(id, type, SPACE_ID) + ); + }); + + describe('with `multiple` namespace saved object', () => { + runTests( + SAVED_OBJECT_WITH_SECRET_AND_MULTIPLE_SPACES_TYPE, + () => `/s/${SPACE_ID}/api/saved_objects/`, + (id, type) => generateRawId(id, type, SPACE_ID) + ); + }); }); }); }