Allow encrypted saved-object properties to be accessed by end-users (#64941)

Co-authored-by: kobelb <brandon.kobel@elastic.co>
This commit is contained in:
Aleh Zasypkin 2020-05-14 07:59:11 +02:00 committed by GitHub
parent 0fd5311a82
commit 65186b3393
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 1712 additions and 338 deletions

View file

@ -3,6 +3,7 @@
"version": "8.0.0", "version": "8.0.0",
"kibanaVersion": "kibana", "kibanaVersion": "kibana",
"configPath": ["xpack", "encryptedSavedObjects"], "configPath": ["xpack", "encryptedSavedObjects"],
"optionalPlugins": ["security"],
"server": true, "server": true,
"ui": false "ui": false
} }

View file

@ -5,8 +5,9 @@
*/ */
import { EncryptedSavedObjectsAuditLogger } from './audit_logger'; 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 mockInternalAuditLogger = { log: jest.fn() };
const audit = new EncryptedSavedObjectsAuditLogger(() => mockInternalAuditLogger); const audit = new EncryptedSavedObjectsAuditLogger(() => mockInternalAuditLogger);
@ -19,6 +20,11 @@ test('properly logs audit events', () => {
id: 'object-id-ns', id: 'object-id-ns',
namespace: 'object-ns', namespace: 'object-ns',
}); });
audit.encryptAttributesSuccess(
['one', 'two'],
{ type: 'known-type-ns', id: 'object-id-ns', namespace: 'object-ns' },
mockAuthenticatedUser()
);
audit.decryptAttributesSuccess(['three', 'four'], { audit.decryptAttributesSuccess(['three', 'four'], {
type: 'known-type-1', type: 'known-type-1',
@ -29,6 +35,11 @@ test('properly logs audit events', () => {
id: 'object-id-1-ns', id: 'object-id-1-ns',
namespace: 'object-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', { audit.encryptAttributeFailure('five', {
type: 'known-type-2', type: 'known-type-2',
@ -39,6 +50,11 @@ test('properly logs audit events', () => {
id: 'object-id-2-ns', id: 'object-id-2-ns',
namespace: 'object-ns', namespace: 'object-ns',
}); });
audit.encryptAttributeFailure(
'five',
{ type: 'known-type-2-ns', id: 'object-id-2-ns', namespace: 'object-ns' },
mockAuthenticatedUser()
);
audit.decryptAttributeFailure('six', { audit.decryptAttributeFailure('six', {
type: 'known-type-3', type: 'known-type-3',
@ -49,8 +65,13 @@ test('properly logs audit events', () => {
id: 'object-id-3-ns', id: 'object-id-3-ns',
namespace: 'object-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( expect(mockInternalAuditLogger.log).toHaveBeenCalledWith(
'encrypt_success', 'encrypt_success',
'Successfully encrypted attributes "[one,two]" for saved object "[known-type,object-id]".', 'Successfully encrypted attributes "[one,two]" for saved object "[known-type,object-id]".',
@ -66,6 +87,17 @@ test('properly logs audit events', () => {
attributesNames: ['one', 'two'], 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( expect(mockInternalAuditLogger.log).toHaveBeenCalledWith(
'decrypt_success', 'decrypt_success',
@ -82,6 +114,17 @@ test('properly logs audit events', () => {
attributesNames: ['three', 'four'], 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( expect(mockInternalAuditLogger.log).toHaveBeenCalledWith(
'encrypt_failure', '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]".', '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' } { 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( expect(mockInternalAuditLogger.log).toHaveBeenCalledWith(
'decrypt_failure', '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]".', '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' } { 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',
}
);
}); });

View file

@ -6,6 +6,7 @@
import { SavedObjectDescriptor, descriptorToArray } from '../crypto'; import { SavedObjectDescriptor, descriptorToArray } from '../crypto';
import { LegacyAPI } from '../plugin'; import { LegacyAPI } from '../plugin';
import { AuthenticatedUser } from '../../../security/common/model';
/** /**
* Represents all audit events the plugin can log. * Represents all audit events the plugin can log.
@ -13,49 +14,59 @@ import { LegacyAPI } from '../plugin';
export class EncryptedSavedObjectsAuditLogger { export class EncryptedSavedObjectsAuditLogger {
constructor(private readonly getAuditLogger: () => LegacyAPI['auditLogger']) {} constructor(private readonly getAuditLogger: () => LegacyAPI['auditLogger']) {}
public encryptAttributeFailure(attributeName: string, descriptor: SavedObjectDescriptor) { public encryptAttributeFailure(
attributeName: string,
descriptor: SavedObjectDescriptor,
user?: AuthenticatedUser
) {
this.getAuditLogger().log( this.getAuditLogger().log(
'encrypt_failure', 'encrypt_failure',
`Failed to encrypt attribute "${attributeName}" for saved object "[${descriptorToArray( `Failed to encrypt attribute "${attributeName}" for saved object "[${descriptorToArray(
descriptor 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( this.getAuditLogger().log(
'decrypt_failure', 'decrypt_failure',
`Failed to decrypt attribute "${attributeName}" for saved object "[${descriptorToArray( `Failed to decrypt attribute "${attributeName}" for saved object "[${descriptorToArray(
descriptor descriptor
)}]".`, )}]".`,
{ ...descriptor, attributeName } { ...descriptor, attributeName, username: user?.username }
); );
} }
public encryptAttributesSuccess( public encryptAttributesSuccess(
attributesNames: readonly string[], attributesNames: readonly string[],
descriptor: SavedObjectDescriptor descriptor: SavedObjectDescriptor,
user?: AuthenticatedUser
) { ) {
this.getAuditLogger().log( this.getAuditLogger().log(
'encrypt_success', 'encrypt_success',
`Successfully encrypted attributes "[${attributesNames}]" for saved object "[${descriptorToArray( `Successfully encrypted attributes "[${attributesNames}]" for saved object "[${descriptorToArray(
descriptor descriptor
)}]".`, )}]".`,
{ ...descriptor, attributesNames } { ...descriptor, attributesNames, username: user?.username }
); );
} }
public decryptAttributesSuccess( public decryptAttributesSuccess(
attributesNames: readonly string[], attributesNames: readonly string[],
descriptor: SavedObjectDescriptor descriptor: SavedObjectDescriptor,
user?: AuthenticatedUser
) { ) {
this.getAuditLogger().log( this.getAuditLogger().log(
'decrypt_success', 'decrypt_success',
`Successfully decrypted attributes "[${attributesNames}]" for saved object "[${descriptorToArray( `Successfully decrypted attributes "[${attributesNames}]" for saved object "[${descriptorToArray(
descriptor descriptor
)}]".`, )}]".`,
{ ...descriptor, attributesNames } { ...descriptor, attributesNames, username: user?.username }
); );
} }
} }

View file

@ -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]
);
}
}
});

View file

@ -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<string>;
private readonly attributesToExcludeFromAAD: ReadonlySet<string> | undefined;
private readonly attributesToStrip: ReadonlySet<string>;
constructor(typeRegistration: EncryptedSavedObjectTypeRegistration) {
const attributesToEncrypt = new Set<string>();
const attributesToStrip = new Set<string>();
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);
}
}

View file

@ -4,6 +4,8 @@
* you may not use this file except in compliance with the Elastic License. * 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()); jest.mock('@elastic/node-crypto', () => jest.fn());
import { EncryptedSavedObjectsAuditLogger } from '../audit'; import { EncryptedSavedObjectsAuditLogger } from '../audit';
@ -72,40 +74,26 @@ describe('#isRegistered', () => {
}); });
}); });
describe('#stripEncryptedAttributes', () => { describe('#stripOrDecryptAttributes', () => {
it('does not strip attributes from unknown types', () => { it('does not strip attributes from unknown types', async () => {
const attributes = { attrOne: 'one', attrTwo: 'two', attrThree: 'three' }; const attributes = { attrOne: 'one', attrTwo: 'two', attrThree: 'three' };
expect(service.stripEncryptedAttributes('unknown-type', attributes)).toEqual({ await expect(
attrOne: 'one', service.stripOrDecryptAttributes({ id: 'unknown-id', type: 'unknown-type' }, attributes)
attrTwo: 'two', ).resolves.toEqual({ attributes: { attrOne: 'one', attrTwo: 'two', attrThree: 'three' } });
attrThree: 'three',
});
}); });
it('does not strip attributes from known, but not registered types', () => { it('does not strip any attributes if none of them are supposed to be encrypted', async () => {
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', () => {
const attributes = { attrOne: 'one', attrTwo: 'two', attrThree: 'three' }; const attributes = { attrOne: 'one', attrTwo: 'two', attrThree: 'three' };
service.registerType({ type: 'known-type-1', attributesToEncrypt: new Set(['attrFour']) }); service.registerType({ type: 'known-type-1', attributesToEncrypt: new Set(['attrFour']) });
expect(service.stripEncryptedAttributes('known-type-1', attributes)).toEqual({ await expect(
attrOne: 'one', service.stripOrDecryptAttributes({ id: 'known-id', type: 'known-type-1' }, attributes)
attrTwo: 'two', ).resolves.toEqual({ attributes: { attrOne: 'one', attrTwo: 'two', attrThree: 'three' } });
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' }; const attributes = { attrOne: 'one', attrTwo: 'two', attrThree: 'three' };
service.registerType({ service.registerType({
@ -113,8 +101,113 @@ describe('#stripEncryptedAttributes', () => {
attributesToEncrypt: new Set(['attrOne', 'attrThree']), attributesToEncrypt: new Set(['attrOne', 'attrThree']),
}); });
expect(service.stripEncryptedAttributes('known-type-1', attributes)).toEqual({ await expect(
attrTwo: 'two', 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']), attributesToEncrypt: new Set(['attrOne', 'attrThree', 'attrFour']),
}); });
const mockUser = mockAuthenticatedUser();
await expect( 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({ ).resolves.toEqual({
attrOne: '|one|["known-type-1","object-id",{"attrTwo":"two"}]|', attrOne: '|one|["known-type-1","object-id",{"attrTwo":"two"}]|',
attrTwo: 'two', attrTwo: 'two',
@ -194,7 +290,8 @@ describe('#encryptAttributes', () => {
expect(mockAuditLogger.encryptAttributesSuccess).toHaveBeenCalledTimes(1); expect(mockAuditLogger.encryptAttributesSuccess).toHaveBeenCalledTimes(1);
expect(mockAuditLogger.encryptAttributesSuccess).toHaveBeenCalledWith( expect(mockAuditLogger.encryptAttributesSuccess).toHaveBeenCalledWith(
['attrOne', 'attrThree'], ['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']), attributesToEncrypt: new Set(['attrOne', 'attrThree']),
}); });
const mockUser = mockAuthenticatedUser();
await expect( 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({ ).resolves.toEqual({
attrTwo: 'two', attrTwo: 'two',
attrThree: '|three|["known-type-1","object-id",{"attrTwo":"two"}]|', attrThree: '|three|["known-type-1","object-id",{"attrTwo":"two"}]|',
}); });
expect(mockAuditLogger.encryptAttributesSuccess).toHaveBeenCalledTimes(1); expect(mockAuditLogger.encryptAttributesSuccess).toHaveBeenCalledTimes(1);
expect(mockAuditLogger.encryptAttributesSuccess).toHaveBeenCalledWith(['attrThree'], { expect(mockAuditLogger.encryptAttributesSuccess).toHaveBeenCalledWith(
type: 'known-type-1', ['attrThree'],
id: 'object-id', { type: 'known-type-1', id: 'object-id' },
}); mockUser
);
}); });
it('includes `namespace` into AAD if provided', async () => { it('includes `namespace` into AAD if provided', async () => {
@ -227,21 +328,23 @@ describe('#encryptAttributes', () => {
attributesToEncrypt: new Set(['attrOne', 'attrThree']), attributesToEncrypt: new Set(['attrOne', 'attrThree']),
}); });
const mockUser = mockAuthenticatedUser();
await expect( await expect(
service.encryptAttributes( service.encryptAttributes(
{ type: 'known-type-1', id: 'object-id', namespace: 'object-ns' }, { type: 'known-type-1', id: 'object-id', namespace: 'object-ns' },
attributes attributes,
{ user: mockUser }
) )
).resolves.toEqual({ ).resolves.toEqual({
attrTwo: 'two', attrTwo: 'two',
attrThree: '|three|["object-ns","known-type-1","object-id",{"attrTwo":"two"}]|', attrThree: '|three|["object-ns","known-type-1","object-id",{"attrTwo":"two"}]|',
}); });
expect(mockAuditLogger.encryptAttributesSuccess).toHaveBeenCalledTimes(1); expect(mockAuditLogger.encryptAttributesSuccess).toHaveBeenCalledTimes(1);
expect(mockAuditLogger.encryptAttributesSuccess).toHaveBeenCalledWith(['attrThree'], { expect(mockAuditLogger.encryptAttributesSuccess).toHaveBeenCalledWith(
type: 'known-type-1', ['attrThree'],
id: 'object-id', { type: 'known-type-1', id: 'object-id', namespace: 'object-ns' },
namespace: 'object-ns', mockUser
}); );
}); });
it('does not include specified attributes to AAD', async () => { it('does not include specified attributes to AAD', async () => {
@ -300,8 +403,11 @@ describe('#encryptAttributes', () => {
.mockResolvedValueOnce('Successfully encrypted attrOne') .mockResolvedValueOnce('Successfully encrypted attrOne')
.mockRejectedValueOnce(new Error('Something went wrong with attrThree...')); .mockRejectedValueOnce(new Error('Something went wrong with attrThree...'));
const mockUser = mockAuthenticatedUser();
await expect( 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); ).rejects.toThrowError(EncryptionError);
expect(attributes).toEqual({ expect(attributes).toEqual({
@ -311,10 +417,11 @@ describe('#encryptAttributes', () => {
}); });
expect(mockAuditLogger.encryptAttributesSuccess).not.toHaveBeenCalled(); expect(mockAuditLogger.encryptAttributesSuccess).not.toHaveBeenCalled();
expect(mockAuditLogger.encryptAttributeFailure).toHaveBeenCalledTimes(1); expect(mockAuditLogger.encryptAttributeFailure).toHaveBeenCalledTimes(1);
expect(mockAuditLogger.encryptAttributeFailure).toHaveBeenCalledWith('attrThree', { expect(mockAuditLogger.encryptAttributeFailure).toHaveBeenCalledWith(
type: 'known-type-1', 'attrThree',
id: 'object-id', { type: 'known-type-1', id: 'object-id' },
}); mockUser
);
}); });
}); });
@ -379,8 +486,11 @@ describe('#decryptAttributes', () => {
attrFour: null, attrFour: null,
}); });
const mockUser = mockAuthenticatedUser();
await expect( 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({ ).resolves.toEqual({
attrOne: 'one', attrOne: 'one',
attrTwo: 'two', attrTwo: 'two',
@ -390,7 +500,8 @@ describe('#decryptAttributes', () => {
expect(mockAuditLogger.decryptAttributesSuccess).toHaveBeenCalledTimes(1); expect(mockAuditLogger.decryptAttributesSuccess).toHaveBeenCalledTimes(1);
expect(mockAuditLogger.decryptAttributesSuccess).toHaveBeenCalledWith( expect(mockAuditLogger.decryptAttributesSuccess).toHaveBeenCalledWith(
['attrOne', 'attrThree'], ['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$/), attrThree: expect.not.stringMatching(/^three$/),
}); });
const mockUser = mockAuthenticatedUser();
await expect( 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({ ).resolves.toEqual({
attrTwo: 'two', attrTwo: 'two',
attrThree: 'three', attrThree: 'three',
}); });
expect(mockAuditLogger.decryptAttributesSuccess).toHaveBeenCalledTimes(1); expect(mockAuditLogger.decryptAttributesSuccess).toHaveBeenCalledTimes(1);
expect(mockAuditLogger.decryptAttributesSuccess).toHaveBeenCalledWith(['attrThree'], { expect(mockAuditLogger.decryptAttributesSuccess).toHaveBeenCalledWith(
type: 'known-type-1', ['attrThree'],
id: 'object-id', { type: 'known-type-1', id: 'object-id' },
}); mockUser
);
}); });
it('decrypts if all attributes that contribute to AAD are present', async () => { 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 attributesWithoutAttr = { attrTwo: 'two', attrThree: encryptedAttributes.attrThree };
const mockUser = mockAuthenticatedUser();
await expect( 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({ ).resolves.toEqual({
attrTwo: 'two', attrTwo: 'two',
attrThree: 'three', attrThree: 'three',
}); });
expect(mockAuditLogger.decryptAttributesSuccess).toHaveBeenCalledTimes(1); expect(mockAuditLogger.decryptAttributesSuccess).toHaveBeenCalledTimes(1);
expect(mockAuditLogger.decryptAttributesSuccess).toHaveBeenCalledWith(['attrThree'], { expect(mockAuditLogger.decryptAttributesSuccess).toHaveBeenCalledWith(
type: 'known-type-1', ['attrThree'],
id: 'object-id', { type: 'known-type-1', id: 'object-id' },
}); mockUser
);
}); });
it('decrypts even if attributes in AAD are defined in a different order', async () => { it('decrypts even if attributes in AAD are defined in a different order', async () => {
@ -482,10 +601,12 @@ describe('#decryptAttributes', () => {
attrOne: 'one', attrOne: 'one',
}; };
const mockUser = mockAuthenticatedUser();
await expect( await expect(
service.decryptAttributes( service.decryptAttributes(
{ type: 'known-type-1', id: 'object-id' }, { type: 'known-type-1', id: 'object-id' },
attributesInDifferentOrder attributesInDifferentOrder,
{ user: mockUser }
) )
).resolves.toEqual({ ).resolves.toEqual({
attrOne: 'one', attrOne: 'one',
@ -493,10 +614,11 @@ describe('#decryptAttributes', () => {
attrThree: 'three', attrThree: 'three',
}); });
expect(mockAuditLogger.decryptAttributesSuccess).toHaveBeenCalledTimes(1); expect(mockAuditLogger.decryptAttributesSuccess).toHaveBeenCalledTimes(1);
expect(mockAuditLogger.decryptAttributesSuccess).toHaveBeenCalledWith(['attrThree'], { expect(mockAuditLogger.decryptAttributesSuccess).toHaveBeenCalledWith(
type: 'known-type-1', ['attrThree'],
id: 'object-id', { type: 'known-type-1', id: 'object-id' },
}); mockUser
);
}); });
it('decrypts if correct namespace is provided', async () => { it('decrypts if correct namespace is provided', async () => {
@ -517,10 +639,12 @@ describe('#decryptAttributes', () => {
attrThree: expect.not.stringMatching(/^three$/), attrThree: expect.not.stringMatching(/^three$/),
}); });
const mockUser = mockAuthenticatedUser();
await expect( await expect(
service.decryptAttributes( service.decryptAttributes(
{ type: 'known-type-1', id: 'object-id', namespace: 'object-ns' }, { type: 'known-type-1', id: 'object-id', namespace: 'object-ns' },
encryptedAttributes encryptedAttributes,
{ user: mockUser }
) )
).resolves.toEqual({ ).resolves.toEqual({
attrOne: 'one', attrOne: 'one',
@ -528,11 +652,11 @@ describe('#decryptAttributes', () => {
attrThree: 'three', attrThree: 'three',
}); });
expect(mockAuditLogger.decryptAttributesSuccess).toHaveBeenCalledTimes(1); expect(mockAuditLogger.decryptAttributesSuccess).toHaveBeenCalledTimes(1);
expect(mockAuditLogger.decryptAttributesSuccess).toHaveBeenCalledWith(['attrThree'], { expect(mockAuditLogger.decryptAttributesSuccess).toHaveBeenCalledWith(
type: 'known-type-1', ['attrThree'],
id: 'object-id', { type: 'known-type-1', id: 'object-id', namespace: 'object-ns' },
namespace: 'object-ns', mockUser
}); );
}); });
it('decrypts even if no attributes are included into AAD', async () => { it('decrypts even if no attributes are included into AAD', async () => {
@ -551,8 +675,11 @@ describe('#decryptAttributes', () => {
attrThree: expect.not.stringMatching(/^three$/), attrThree: expect.not.stringMatching(/^three$/),
}); });
const mockUser = mockAuthenticatedUser();
await expect( 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({ ).resolves.toEqual({
attrOne: 'one', attrOne: 'one',
attrThree: 'three', attrThree: 'three',
@ -560,7 +687,8 @@ describe('#decryptAttributes', () => {
expect(mockAuditLogger.decryptAttributesSuccess).toHaveBeenCalledTimes(1); expect(mockAuditLogger.decryptAttributesSuccess).toHaveBeenCalledTimes(1);
expect(mockAuditLogger.decryptAttributesSuccess).toHaveBeenCalledWith( expect(mockAuditLogger.decryptAttributesSuccess).toHaveBeenCalledWith(
['attrOne', 'attrThree'], ['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), attrSix: expect.any(String),
}); });
const mockUser = mockAuthenticatedUser();
await expect( 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({ ).resolves.toEqual({
attrOne: 'one', attrOne: 'one',
attrTwo: 'two', attrTwo: 'two',
@ -605,7 +736,8 @@ describe('#decryptAttributes', () => {
expect(mockAuditLogger.decryptAttributesSuccess).toHaveBeenCalledTimes(1); expect(mockAuditLogger.decryptAttributesSuccess).toHaveBeenCalledTimes(1);
expect(mockAuditLogger.decryptAttributesSuccess).toHaveBeenCalledWith( expect(mockAuditLogger.decryptAttributesSuccess).toHaveBeenCalledWith(
['attrOne', 'attrThree', 'attrFive', 'attrSix'], ['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 () => { it('fails to decrypt if not all attributes that contribute to AAD are present', async () => {
const attributesWithoutAttr = { attrTwo: 'two', attrThree: encryptedAttributes.attrThree }; const attributesWithoutAttr = { attrTwo: 'two', attrThree: encryptedAttributes.attrThree };
const mockUser = mockAuthenticatedUser();
await expect( 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); ).rejects.toThrowError(EncryptionError);
expect(mockAuditLogger.decryptAttributesSuccess).not.toHaveBeenCalled(); expect(mockAuditLogger.decryptAttributesSuccess).not.toHaveBeenCalled();
expect(mockAuditLogger.decryptAttributeFailure).toHaveBeenCalledWith('attrThree', { expect(mockAuditLogger.decryptAttributeFailure).toHaveBeenCalledWith(
type: 'known-type-1', 'attrThree',
id: 'object-id', { type: 'known-type-1', id: 'object-id' },
}); mockUser
);
}); });
it('fails to decrypt if ID does not match', async () => { it('fails to decrypt if ID does not match', async () => {
const mockUser = mockAuthenticatedUser();
await expect( 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); ).rejects.toThrowError(EncryptionError);
expect(mockAuditLogger.decryptAttributesSuccess).not.toHaveBeenCalled(); expect(mockAuditLogger.decryptAttributesSuccess).not.toHaveBeenCalled();
expect(mockAuditLogger.decryptAttributeFailure).toHaveBeenCalledWith('attrThree', { expect(mockAuditLogger.decryptAttributeFailure).toHaveBeenCalledWith(
type: 'known-type-1', 'attrThree',
id: 'object-id*', { type: 'known-type-1', id: 'object-id*' },
}); mockUser
);
}); });
it('fails to decrypt if type does not match', async () => { it('fails to decrypt if type does not match', async () => {
const mockUser = mockAuthenticatedUser();
await expect( 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); ).rejects.toThrowError(EncryptionError);
expect(mockAuditLogger.decryptAttributesSuccess).not.toHaveBeenCalled(); expect(mockAuditLogger.decryptAttributesSuccess).not.toHaveBeenCalled();
expect(mockAuditLogger.decryptAttributeFailure).toHaveBeenCalledWith('attrThree', { expect(mockAuditLogger.decryptAttributeFailure).toHaveBeenCalledWith(
type: 'known-type-2', 'attrThree',
id: 'object-id', { type: 'known-type-2', id: 'object-id' },
}); mockUser
);
}); });
it('fails to decrypt if namespace does not match', async () => { it('fails to decrypt if namespace does not match', async () => {
@ -673,19 +819,21 @@ describe('#decryptAttributes', () => {
{ attrOne: 'one', attrTwo: 'two', attrThree: 'three' } { attrOne: 'one', attrTwo: 'two', attrThree: 'three' }
); );
const mockUser = mockAuthenticatedUser();
await expect( await expect(
service.decryptAttributes( service.decryptAttributes(
{ type: 'known-type-1', id: 'object-id', namespace: 'object-NS' }, { type: 'known-type-1', id: 'object-id', namespace: 'object-NS' },
encryptedAttributes encryptedAttributes,
{ user: mockUser }
) )
).rejects.toThrowError(EncryptionError); ).rejects.toThrowError(EncryptionError);
expect(mockAuditLogger.decryptAttributesSuccess).not.toHaveBeenCalled(); expect(mockAuditLogger.decryptAttributesSuccess).not.toHaveBeenCalled();
expect(mockAuditLogger.decryptAttributeFailure).toHaveBeenCalledWith('attrThree', { expect(mockAuditLogger.decryptAttributeFailure).toHaveBeenCalledWith(
type: 'known-type-1', 'attrThree',
id: 'object-id', { type: 'known-type-1', id: 'object-id', namespace: 'object-NS' },
namespace: 'object-NS', mockUser
}); );
}); });
it('fails to decrypt if namespace is expected, but is not provided', async () => { 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' } { attrOne: 'one', attrTwo: 'two', attrThree: 'three' }
); );
const mockUser = mockAuthenticatedUser();
await expect( 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); ).rejects.toThrowError(EncryptionError);
expect(mockAuditLogger.decryptAttributesSuccess).not.toHaveBeenCalled(); expect(mockAuditLogger.decryptAttributesSuccess).not.toHaveBeenCalled();
expect(mockAuditLogger.decryptAttributeFailure).toHaveBeenCalledWith('attrThree', { expect(mockAuditLogger.decryptAttributeFailure).toHaveBeenCalledWith(
type: 'known-type-1', 'attrThree',
id: 'object-id', { type: 'known-type-1', id: 'object-id' },
}); mockUser
);
}); });
it('fails to decrypt if encrypted attribute is defined, but not a string', async () => { it('fails to decrypt if encrypted attribute is defined, but not a string', async () => {
const mockUser = mockAuthenticatedUser();
await expect( await expect(
service.decryptAttributes( service.decryptAttributes(
{ type: 'known-type-1', id: 'object-id' }, { type: 'known-type-1', id: 'object-id' },
{ { ...encryptedAttributes, attrThree: 2 },
...encryptedAttributes, { user: mockUser }
attrThree: 2,
}
) )
).rejects.toThrowError( ).rejects.toThrowError(
'Encrypted "attrThree" attribute should be a string, but found number' 'Encrypted "attrThree" attribute should be a string, but found number'
); );
expect(mockAuditLogger.decryptAttributesSuccess).not.toHaveBeenCalled(); expect(mockAuditLogger.decryptAttributesSuccess).not.toHaveBeenCalled();
expect(mockAuditLogger.decryptAttributeFailure).toHaveBeenCalledWith('attrThree', { expect(mockAuditLogger.decryptAttributeFailure).toHaveBeenCalledWith(
type: 'known-type-1', 'attrThree',
id: 'object-id', { type: 'known-type-1', id: 'object-id' },
}); mockUser
);
}); });
it('fails to decrypt if encrypted attribute is not correct', async () => { it('fails to decrypt if encrypted attribute is not correct', async () => {
const mockUser = mockAuthenticatedUser();
await expect( await expect(
service.decryptAttributes( service.decryptAttributes(
{ type: 'known-type-1', id: 'object-id' }, { type: 'known-type-1', id: 'object-id' },
{ { ...encryptedAttributes, attrThree: 'some-unknown-string' },
...encryptedAttributes, { user: mockUser }
attrThree: 'some-unknown-string',
}
) )
).rejects.toThrowError(EncryptionError); ).rejects.toThrowError(EncryptionError);
expect(mockAuditLogger.decryptAttributesSuccess).not.toHaveBeenCalled(); expect(mockAuditLogger.decryptAttributesSuccess).not.toHaveBeenCalled();
expect(mockAuditLogger.decryptAttributeFailure).toHaveBeenCalledWith('attrThree', { expect(mockAuditLogger.decryptAttributeFailure).toHaveBeenCalledWith(
type: 'known-type-1', 'attrThree',
id: 'object-id', { type: 'known-type-1', id: 'object-id' },
}); mockUser
);
}); });
it('fails to decrypt if the AAD attribute has changed', async () => { it('fails to decrypt if the AAD attribute has changed', async () => {
const mockUser = mockAuthenticatedUser();
await expect( await expect(
service.decryptAttributes( service.decryptAttributes(
{ type: 'known-type-1', id: 'object-id' }, { type: 'known-type-1', id: 'object-id' },
{ { ...encryptedAttributes, attrOne: 'oNe' },
...encryptedAttributes, { user: mockUser }
attrOne: 'oNe',
}
) )
).rejects.toThrowError(EncryptionError); ).rejects.toThrowError(EncryptionError);
expect(mockAuditLogger.decryptAttributesSuccess).not.toHaveBeenCalled(); expect(mockAuditLogger.decryptAttributesSuccess).not.toHaveBeenCalled();
expect(mockAuditLogger.decryptAttributeFailure).toHaveBeenCalledWith('attrThree', { expect(mockAuditLogger.decryptAttributeFailure).toHaveBeenCalledWith(
type: 'known-type-1', 'attrThree',
id: 'object-id', { type: 'known-type-1', id: 'object-id' },
}); mockUser
);
}); });
it('fails if encrypted with another encryption key', async () => { it('fails if encrypted with another encryption key', async () => {
@ -773,15 +925,19 @@ describe('#decryptAttributes', () => {
attributesToEncrypt: new Set(['attrThree']), attributesToEncrypt: new Set(['attrThree']),
}); });
const mockUser = mockAuthenticatedUser();
await expect( 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); ).rejects.toThrowError(EncryptionError);
expect(mockAuditLogger.decryptAttributesSuccess).not.toHaveBeenCalled(); expect(mockAuditLogger.decryptAttributesSuccess).not.toHaveBeenCalled();
expect(mockAuditLogger.decryptAttributeFailure).toHaveBeenCalledWith('attrThree', { expect(mockAuditLogger.decryptAttributeFailure).toHaveBeenCalledWith(
type: 'known-type-1', 'attrThree',
id: 'object-id', { type: 'known-type-1', id: 'object-id' },
}); mockUser
);
}); });
}); });
}); });

View file

@ -8,8 +8,20 @@ import nodeCrypto, { Crypto } from '@elastic/node-crypto';
import stringify from 'json-stable-stringify'; import stringify from 'json-stable-stringify';
import typeDetect from 'type-detect'; import typeDetect from 'type-detect';
import { Logger } from 'src/core/server'; import { Logger } from 'src/core/server';
import { AuthenticatedUser } from '../../../security/common/model';
import { EncryptedSavedObjectsAuditLogger } from '../audit'; 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 * 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 { export interface EncryptedSavedObjectTypeRegistration {
readonly type: string; readonly type: string;
readonly attributesToEncrypt: ReadonlySet<string>; readonly attributesToEncrypt: ReadonlySet<string | AttributeToEncrypt>;
readonly attributesToExcludeFromAAD?: ReadonlySet<string>; readonly attributesToExcludeFromAAD?: ReadonlySet<string>;
} }
@ -30,6 +42,16 @@ export interface SavedObjectDescriptor {
readonly namespace?: string; 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 * Utility function that gives array representation of the saved object descriptor respecting
* optional `namespace` property. * 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` * 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<string, EncryptedSavedObjectTypeRegistration> = new Map(); private readonly typeDefinitions: Map<
string,
EncryptedSavedObjectAttributesDefinition
> = new Map();
/** /**
* @param encryptionKey The key used to encrypt and decrypt saved objects attributes. * @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.`); 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.`); 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. * @param type Saved object type.
*/ */
public isRegistered(type: string) { 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 * Takes saved object attributes for the specified type and, depending on the type definition,
* to be encrypted and returns that __NEW__ attributes dictionary back. * either decrypts or strips encrypted attributes (e.g. in case AAD or encryption key has changed
* @param type Type of the saved object to strip encrypted attributes from. * and decryption is no longer possible).
* @param attributes Dictionary of __ALL__ saved object attributes. * @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<T extends Record<string, unknown>>( public async stripOrDecryptAttributes<T extends Record<string, unknown>>(
type: string, descriptor: SavedObjectDescriptor,
attributes: T attributes: T,
): Record<string, unknown> { originalAttributes?: T,
const typeRegistration = this.typeRegistrations.get(type); params?: CommonParameters
if (typeRegistration === undefined) { ) {
return attributes; const typeDefinition = this.typeDefinitions.get(descriptor.type);
if (typeDefinition === undefined) {
return { attributes };
} }
let decryptedAttributes: T | null = null;
let decryptionError: Error | undefined;
const clonedAttributes: Record<string, unknown> = {}; const clonedAttributes: Record<string, unknown> = {};
for (const [attributeName, attributeValue] of Object.entries(attributes)) { 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; 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. * attributes were encrypted original attributes dictionary is returned.
* @param descriptor Descriptor of the saved object to encrypt attributes for. * @param descriptor Descriptor of the saved object to encrypt attributes for.
* @param attributes Dictionary of __ALL__ saved object attributes. * @param attributes Dictionary of __ALL__ saved object attributes.
* @param [params] Additional parameters.
* @throws Will throw if encryption fails for whatever reason. * @throws Will throw if encryption fails for whatever reason.
*/ */
public async encryptAttributes<T extends Record<string, unknown>>( public async encryptAttributes<T extends Record<string, unknown>>(
descriptor: SavedObjectDescriptor, descriptor: SavedObjectDescriptor,
attributes: T attributes: T,
params?: CommonParameters
): Promise<T> { ): Promise<T> {
const typeRegistration = this.typeRegistrations.get(descriptor.type); const typeDefinition = this.typeDefinitions.get(descriptor.type);
if (typeRegistration === undefined) { if (typeDefinition === undefined) {
return attributes; return attributes;
} }
const encryptionAAD = this.getAAD(typeRegistration, descriptor, attributes); const encryptionAAD = this.getAAD(typeDefinition, descriptor, attributes);
const encryptedAttributes: Record<string, string> = {}; const encryptedAttributes: Record<string, string> = {};
for (const attributeName of typeRegistration.attributesToEncrypt) { for (const attributeName of typeDefinition.attributesToEncrypt) {
const attributeValue = attributes[attributeName]; const attributeValue = attributes[attributeName];
if (attributeValue != null) { if (attributeValue != null) {
try { try {
@ -153,11 +226,12 @@ export class EncryptedSavedObjectsService {
this.logger.error( this.logger.error(
`Failed to encrypt "${attributeName}" attribute: ${err.message || err}` `Failed to encrypt "${attributeName}" attribute: ${err.message || err}`
); );
this.audit.encryptAttributeFailure(attributeName, descriptor); this.audit.encryptAttributeFailure(attributeName, descriptor, params?.user);
throw new EncryptionError( throw new EncryptionError(
`Unable to encrypt attribute "${attributeName}"`, `Unable to encrypt attribute "${attributeName}"`,
attributeName, attributeName,
EncryptionErrorOperation.Encryption,
err err
); );
} }
@ -167,12 +241,12 @@ export class EncryptedSavedObjectsService {
// Normally we expect all registered to-be-encrypted attributes to be defined, but if it's // 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. // not the case we should collect and log them to make troubleshooting easier.
const encryptedAttributesKeys = Object.keys(encryptedAttributes); const encryptedAttributesKeys = Object.keys(encryptedAttributes);
if (encryptedAttributesKeys.length !== typeRegistration.attributesToEncrypt.size) { if (encryptedAttributesKeys.length !== typeDefinition.attributesToEncrypt.size) {
this.logger.debug( this.logger.debug(
`The following attributes of saved object "${descriptorToArray( `The following attributes of saved object "${descriptorToArray(
descriptor descriptor
)}" should have been encrypted: ${Array.from( )}" should have been encrypted: ${Array.from(
typeRegistration.attributesToEncrypt typeDefinition.attributesToEncrypt
)}, but found only: ${encryptedAttributesKeys}` )}, but found only: ${encryptedAttributesKeys}`
); );
} }
@ -181,7 +255,7 @@ export class EncryptedSavedObjectsService {
return attributes; return attributes;
} }
this.audit.encryptAttributesSuccess(encryptedAttributesKeys, descriptor); this.audit.encryptAttributesSuccess(encryptedAttributesKeys, descriptor, params?.user);
return { return {
...attributes, ...attributes,
@ -195,28 +269,30 @@ export class EncryptedSavedObjectsService {
* attributes were decrypted original attributes dictionary is returned. * attributes were decrypted original attributes dictionary is returned.
* @param descriptor Descriptor of the saved object to decrypt attributes for. * @param descriptor Descriptor of the saved object to decrypt attributes for.
* @param attributes Dictionary of __ALL__ saved object attributes. * @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 decryption fails for whatever reason.
* @throws Will throw if any of the attributes to decrypt is not a string. * @throws Will throw if any of the attributes to decrypt is not a string.
*/ */
public async decryptAttributes<T extends Record<string, unknown>>( public async decryptAttributes<T extends Record<string, unknown>>(
descriptor: SavedObjectDescriptor, descriptor: SavedObjectDescriptor,
attributes: T attributes: T,
params?: CommonParameters
): Promise<T> { ): Promise<T> {
const typeRegistration = this.typeRegistrations.get(descriptor.type); const typeDefinition = this.typeDefinitions.get(descriptor.type);
if (typeRegistration === undefined) { if (typeDefinition === undefined) {
return attributes; return attributes;
} }
const encryptionAAD = this.getAAD(typeRegistration, descriptor, attributes); const encryptionAAD = this.getAAD(typeDefinition, descriptor, attributes);
const decryptedAttributes: Record<string, string> = {}; const decryptedAttributes: Record<string, string> = {};
for (const attributeName of typeRegistration.attributesToEncrypt) { for (const attributeName of typeDefinition.attributesToEncrypt) {
const attributeValue = attributes[attributeName]; const attributeValue = attributes[attributeName];
if (attributeValue == null) { if (attributeValue == null) {
continue; continue;
} }
if (typeof attributeValue !== 'string') { if (typeof attributeValue !== 'string') {
this.audit.decryptAttributeFailure(attributeName, descriptor); this.audit.decryptAttributeFailure(attributeName, descriptor, params?.user);
throw new Error( throw new Error(
`Encrypted "${attributeName}" attribute should be a string, but found ${typeDetect( `Encrypted "${attributeName}" attribute should be a string, but found ${typeDetect(
attributeValue attributeValue
@ -231,11 +307,12 @@ export class EncryptedSavedObjectsService {
)) as string; )) as string;
} catch (err) { } catch (err) {
this.logger.error(`Failed to decrypt "${attributeName}" attribute: ${err.message || 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( throw new EncryptionError(
`Unable to decrypt attribute "${attributeName}"`, `Unable to decrypt attribute "${attributeName}"`,
attributeName, attributeName,
EncryptionErrorOperation.Decryption,
err err
); );
} }
@ -244,12 +321,12 @@ export class EncryptedSavedObjectsService {
// Normally we expect all registered to-be-encrypted attributes to be defined, but if it's // 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. // not the case we should collect and log them to make troubleshooting easier.
const decryptedAttributesKeys = Object.keys(decryptedAttributes); const decryptedAttributesKeys = Object.keys(decryptedAttributes);
if (decryptedAttributesKeys.length !== typeRegistration.attributesToEncrypt.size) { if (decryptedAttributesKeys.length !== typeDefinition.attributesToEncrypt.size) {
this.logger.debug( this.logger.debug(
`The following attributes of saved object "${descriptorToArray( `The following attributes of saved object "${descriptorToArray(
descriptor descriptor
)}" should have been decrypted: ${Array.from( )}" should have been decrypted: ${Array.from(
typeRegistration.attributesToEncrypt typeDefinition.attributesToEncrypt
)}, but found only: ${decryptedAttributesKeys}` )}, but found only: ${decryptedAttributesKeys}`
); );
} }
@ -258,7 +335,7 @@ export class EncryptedSavedObjectsService {
return attributes; return attributes;
} }
this.audit.decryptAttributesSuccess(decryptedAttributesKeys, descriptor); this.audit.decryptAttributesSuccess(decryptedAttributesKeys, descriptor, params?.user);
return { return {
...attributes, ...attributes,
@ -269,23 +346,19 @@ export class EncryptedSavedObjectsService {
/** /**
* Generates string representation of the Additional Authenticated Data based on the specified saved * Generates string representation of the Additional Authenticated Data based on the specified saved
* object type and attributes. * 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 descriptor Descriptor of the saved object to get AAD for.
* @param attributes All attributes of the saved object instance of the specified type. * @param attributes All attributes of the saved object instance of the specified type.
*/ */
private getAAD( private getAAD(
typeRegistration: EncryptedSavedObjectTypeRegistration, typeDefinition: EncryptedSavedObjectAttributesDefinition,
descriptor: SavedObjectDescriptor, descriptor: SavedObjectDescriptor,
attributes: Record<string, unknown> attributes: Record<string, unknown>
) { ) {
// Collect all attributes (both keys and values) that should contribute to AAD. // Collect all attributes (both keys and values) that should contribute to AAD.
const attributesAAD: Record<string, unknown> = {}; const attributesAAD: Record<string, unknown> = {};
for (const [attributeKey, attributeValue] of Object.entries(attributes)) { for (const [attributeKey, attributeValue] of Object.entries(attributes)) {
if ( if (!typeDefinition.shouldBeExcludedFromAAD(attributeKey)) {
!typeRegistration.attributesToEncrypt.has(attributeKey) &&
(typeRegistration.attributesToExcludeFromAAD == null ||
!typeRegistration.attributesToExcludeFromAAD.has(attributeKey))
) {
attributesAAD[attributeKey] = attributeValue; attributesAAD[attributeKey] = attributeValue;
} }
} }

View file

@ -4,18 +4,38 @@
* you may not use this file except in compliance with the Elastic License. * 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', () => { test('#EncryptionError is correctly constructed', () => {
const cause = new TypeError('Some weird error'); const cause = new TypeError('Some weird error');
const encryptionError = new EncryptionError( const encryptionError = new EncryptionError(
'Unable to encrypt attribute "someAttr"', 'Unable to encrypt attribute "someAttr"',
'someAttr', 'someAttr',
EncryptionErrorOperation.Encryption,
cause cause
); );
expect(encryptionError).toBeInstanceOf(EncryptionError); expect(encryptionError).toBeInstanceOf(EncryptionError);
expect(encryptionError.message).toBe('Unable to encrypt attribute "someAttr"'); expect(encryptionError.message).toBe('Unable to encrypt attribute "someAttr"');
expect(encryptionError.attributeName).toBe('someAttr'); expect(encryptionError.attributeName).toBe('someAttr');
expect(encryptionError.operation).toBe(EncryptionErrorOperation.Encryption);
expect(encryptionError.cause).toBe(cause); 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\\\\\\"\\"}"`
);
}); });

View file

@ -4,10 +4,19 @@
* you may not use this file except in compliance with the Elastic License. * 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 { export class EncryptionError extends Error {
constructor( constructor(
message: string, message: string,
public readonly attributeName: string, public readonly attributeName: string,
public readonly operation: EncryptionErrorOperation,
public readonly cause?: Error public readonly cause?: Error
) { ) {
super(message); 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 // 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); Object.setPrototypeOf(this, EncryptionError.prototype);
} }
toJSON() {
return { message: this.message };
}
} }

View file

@ -19,7 +19,7 @@ export const encryptedSavedObjectsServiceMock = {
function processAttributes<T extends Record<string, any>>( function processAttributes<T extends Record<string, any>>(
descriptor: Pick<SavedObjectDescriptor, 'type'>, descriptor: Pick<SavedObjectDescriptor, 'type'>,
attrs: T, 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); const registration = registrations.find(r => r.type === descriptor.type);
if (!registration) { if (!registration) {
@ -27,9 +27,13 @@ export const encryptedSavedObjectsServiceMock = {
} }
const clonedAttrs = { ...attrs }; 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) { if (attrName in clonedAttrs) {
action(clonedAttrs, attrName); action(clonedAttrs, attrName, shouldExpose);
} }
} }
return clonedAttrs; return clonedAttrs;
@ -53,8 +57,16 @@ export const encryptedSavedObjectsServiceMock = {
(clonedAttrs[attrName] = (clonedAttrs[attrName] as string).slice(1, -1)) (clonedAttrs[attrName] = (clonedAttrs[attrName] as string).slice(1, -1))
) )
); );
mock.stripEncryptedAttributes.mockImplementation((type, attrs) => mock.stripOrDecryptAttributes.mockImplementation((descriptor, attrs) =>
processAttributes({ type }, attrs, (clonedAttrs, attrName) => delete clonedAttrs[attrName]) 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; return mock;

View file

@ -7,12 +7,14 @@
import { Plugin } from './plugin'; import { Plugin } from './plugin';
import { coreMock } from 'src/core/server/mocks'; import { coreMock } from 'src/core/server/mocks';
import { securityMock } from '../../security/server/mocks';
describe('EncryptedSavedObjects Plugin', () => { describe('EncryptedSavedObjects Plugin', () => {
describe('setup()', () => { describe('setup()', () => {
it('exposes proper contract', async () => { it('exposes proper contract', async () => {
const plugin = new Plugin(coreMock.createPluginInitializerContext()); 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 { Object {
"__legacyCompat": Object { "__legacyCompat": Object {
"registerLegacyAPI": [Function], "registerLegacyAPI": [Function],
@ -27,7 +29,7 @@ describe('EncryptedSavedObjects Plugin', () => {
describe('start()', () => { describe('start()', () => {
it('exposes proper contract', async () => { it('exposes proper contract', async () => {
const plugin = new Plugin(coreMock.createPluginInitializerContext()); const plugin = new Plugin(coreMock.createPluginInitializerContext());
await plugin.setup(coreMock.createSetup()); await plugin.setup(coreMock.createSetup(), { security: securityMock.createSetup() });
await expect(plugin.start()).toMatchInlineSnapshot(` await expect(plugin.start()).toMatchInlineSnapshot(`
Object { Object {
"getDecryptedAsInternalUser": [Function], "getDecryptedAsInternalUser": [Function],

View file

@ -11,6 +11,7 @@ import {
CoreSetup, CoreSetup,
} from 'src/core/server'; } from 'src/core/server';
import { first } from 'rxjs/operators'; import { first } from 'rxjs/operators';
import { SecurityPluginSetup } from '../../security/server';
import { createConfig$ } from './config'; import { createConfig$ } from './config';
import { import {
EncryptedSavedObjectsService, EncryptedSavedObjectsService,
@ -20,6 +21,10 @@ import {
import { EncryptedSavedObjectsAuditLogger } from './audit'; import { EncryptedSavedObjectsAuditLogger } from './audit';
import { SavedObjectsSetup, setupSavedObjects } from './saved_objects'; import { SavedObjectsSetup, setupSavedObjects } from './saved_objects';
export interface PluginsSetup {
security?: SecurityPluginSetup;
}
export interface EncryptedSavedObjectsPluginSetup { export interface EncryptedSavedObjectsPluginSetup {
registerType: (typeRegistration: EncryptedSavedObjectTypeRegistration) => void; registerType: (typeRegistration: EncryptedSavedObjectTypeRegistration) => void;
__legacyCompat: { registerLegacyAPI: (legacyAPI: LegacyAPI) => void }; __legacyCompat: { registerLegacyAPI: (legacyAPI: LegacyAPI) => void };
@ -59,7 +64,10 @@ export class Plugin {
this.logger = this.initializerContext.logger.get(); this.logger = this.initializerContext.logger.get();
} }
public async setup(core: CoreSetup): Promise<EncryptedSavedObjectsPluginSetup> { public async setup(
core: CoreSetup,
deps: PluginsSetup
): Promise<EncryptedSavedObjectsPluginSetup> {
const { config, usingEphemeralEncryptionKey } = await createConfig$(this.initializerContext) const { config, usingEphemeralEncryptionKey } = await createConfig$(this.initializerContext)
.pipe(first()) .pipe(first())
.toPromise(); .toPromise();
@ -75,6 +83,7 @@ export class Plugin {
this.savedObjectsSetup = setupSavedObjects({ this.savedObjectsSetup = setupSavedObjects({
service, service,
savedObjects: core.savedObjects, savedObjects: core.savedObjects,
security: deps.security,
getStartServices: core.getStartServices, getStartServices: core.getStartServices,
}); });

View file

@ -4,15 +4,17 @@
* you may not use this file except in compliance with the Elastic License. * 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 { SavedObjectsClientContract } from 'src/core/server';
import { EncryptedSavedObjectsService } from '../crypto'; import { EncryptedSavedObjectsService, EncryptionError } from '../crypto';
import { EncryptedSavedObjectsClientWrapper } from './encrypted_saved_objects_client_wrapper'; import { EncryptedSavedObjectsClientWrapper } from './encrypted_saved_objects_client_wrapper';
import { savedObjectsClientMock, savedObjectsTypeRegistryMock } from 'src/core/server/mocks'; import { savedObjectsClientMock, savedObjectsTypeRegistryMock } from 'src/core/server/mocks';
import { mockAuthenticatedUser } from '../../../security/common/model/authenticated_user.mock';
import { encryptedSavedObjectsServiceMock } from '../crypto/index.mock'; import { encryptedSavedObjectsServiceMock } from '../crypto/index.mock';
jest.mock('uuid', () => ({ v4: jest.fn().mockReturnValue('uuid-v4-id') }));
let wrapper: EncryptedSavedObjectsClientWrapper; let wrapper: EncryptedSavedObjectsClientWrapper;
let mockBaseClient: jest.Mocked<SavedObjectsClientContract>; let mockBaseClient: jest.Mocked<SavedObjectsClientContract>;
let mockBaseTypeRegistry: ReturnType<typeof savedObjectsTypeRegistryMock.create>; let mockBaseTypeRegistry: ReturnType<typeof savedObjectsTypeRegistryMock.create>;
@ -23,7 +25,10 @@ beforeEach(() => {
encryptedSavedObjectsServiceMockInstance = encryptedSavedObjectsServiceMock.create([ encryptedSavedObjectsServiceMockInstance = encryptedSavedObjectsServiceMock.create([
{ {
type: 'known-type', type: 'known-type',
attributesToEncrypt: new Set(['attrSecret']), attributesToEncrypt: new Set([
'attrSecret',
{ key: 'attrNotSoSecret', dangerouslyExposeValue: true },
]),
}, },
]); ]);
@ -31,6 +36,7 @@ beforeEach(() => {
service: encryptedSavedObjectsServiceMockInstance, service: encryptedSavedObjectsServiceMockInstance,
baseClient: mockBaseClient, baseClient: mockBaseClient,
baseTypeRegistry: mockBaseTypeRegistry, baseTypeRegistry: mockBaseTypeRegistry,
getCurrentUser: () => mockAuthenticatedUser(),
} as any); } as any);
}); });
@ -63,13 +69,23 @@ describe('#create', () => {
expect(mockBaseClient.create).not.toHaveBeenCalled(); expect(mockBaseClient.create).not.toHaveBeenCalled();
}); });
it('generates ID, encrypts attributes and strips them from response', async () => { 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', attrThree: 'three' }; const attributes = {
attrOne: 'one',
attrSecret: 'secret',
attrNotSoSecret: 'not-so-secret',
attrThree: 'three',
};
const options = { overwrite: true }; const options = { overwrite: true };
const mockedResponse = { const mockedResponse = {
id: 'uuid-v4-id', id: 'uuid-v4-id',
type: 'known-type', type: 'known-type',
attributes: { attrOne: 'one', attrSecret: '*secret*', attrThree: 'three' }, attributes: {
attrOne: 'one',
attrSecret: '*secret*',
attrNotSoSecret: '*not-so-secret*',
attrThree: 'three',
},
references: [], references: [],
}; };
@ -77,19 +93,30 @@ describe('#create', () => {
expect(await wrapper.create('known-type', attributes, options)).toEqual({ expect(await wrapper.create('known-type', attributes, options)).toEqual({
...mockedResponse, ...mockedResponse,
attributes: { attrOne: 'one', attrThree: 'three' }, attributes: { attrOne: 'one', attrNotSoSecret: 'not-so-secret', attrThree: 'three' },
}); });
expect(encryptedSavedObjectsServiceMockInstance.encryptAttributes).toHaveBeenCalledTimes(1); expect(encryptedSavedObjectsServiceMockInstance.encryptAttributes).toHaveBeenCalledTimes(1);
expect(encryptedSavedObjectsServiceMockInstance.encryptAttributes).toHaveBeenCalledWith( expect(encryptedSavedObjectsServiceMockInstance.encryptAttributes).toHaveBeenCalledWith(
{ type: 'known-type', id: 'uuid-v4-id' }, { 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).toHaveBeenCalledTimes(1);
expect(mockBaseClient.create).toHaveBeenCalledWith( expect(mockBaseClient.create).toHaveBeenCalledWith(
'known-type', 'known-type',
{ attrOne: 'one', attrSecret: '*secret*', attrThree: 'three' }, {
attrOne: 'one',
attrSecret: '*secret*',
attrNotSoSecret: '*not-so-secret*',
attrThree: 'three',
},
{ id: 'uuid-v4-id', overwrite: true } { id: 'uuid-v4-id', overwrite: true }
); );
}); });
@ -119,7 +146,8 @@ describe('#create', () => {
id: 'uuid-v4-id', id: 'uuid-v4-id',
namespace: expectNamespaceInDescriptor ? namespace : undefined, namespace: expectNamespaceInDescriptor ? namespace : undefined,
}, },
{ attrOne: 'one', attrSecret: 'secret', attrThree: 'three' } { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' },
{ user: mockAuthenticatedUser() }
); );
expect(mockBaseClient.create).toHaveBeenCalledTimes(1); expect(mockBaseClient.create).toHaveBeenCalledTimes(1);
@ -221,14 +249,19 @@ describe('#bulkCreate', () => {
expect(mockBaseClient.bulkCreate).not.toHaveBeenCalled(); expect(mockBaseClient.bulkCreate).not.toHaveBeenCalled();
}); });
it('generates ID, encrypts attributes and strips them from response', async () => { 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', attrThree: 'three' }; const attributes = {
attrOne: 'one',
attrSecret: 'secret',
attrNotSoSecret: 'not-so-secret',
attrThree: 'three',
};
const mockedResponse = { const mockedResponse = {
saved_objects: [ saved_objects: [
{ {
id: 'uuid-v4-id', id: 'uuid-v4-id',
type: 'known-type', type: 'known-type',
attributes, attributes: { ...attributes, attrSecret: '*secret*', attrNotSoSecret: '*not-so-secret*' },
references: [], references: [],
}, },
{ {
@ -249,7 +282,10 @@ describe('#bulkCreate', () => {
await expect(wrapper.bulkCreate(bulkCreateParams)).resolves.toEqual({ await expect(wrapper.bulkCreate(bulkCreateParams)).resolves.toEqual({
saved_objects: [ 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], mockedResponse.saved_objects[1],
], ],
}); });
@ -257,7 +293,13 @@ describe('#bulkCreate', () => {
expect(encryptedSavedObjectsServiceMockInstance.encryptAttributes).toHaveBeenCalledTimes(1); expect(encryptedSavedObjectsServiceMockInstance.encryptAttributes).toHaveBeenCalledTimes(1);
expect(encryptedSavedObjectsServiceMockInstance.encryptAttributes).toHaveBeenCalledWith( expect(encryptedSavedObjectsServiceMockInstance.encryptAttributes).toHaveBeenCalledWith(
{ type: 'known-type', id: 'uuid-v4-id' }, { 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); expect(mockBaseClient.bulkCreate).toHaveBeenCalledTimes(1);
@ -266,7 +308,12 @@ describe('#bulkCreate', () => {
{ {
...bulkCreateParams[0], ...bulkCreateParams[0],
id: 'uuid-v4-id', id: 'uuid-v4-id',
attributes: { attrOne: 'one', attrSecret: '*secret*', attrThree: 'three' }, attributes: {
attrOne: 'one',
attrSecret: '*secret*',
attrNotSoSecret: '*not-so-secret*',
attrThree: 'three',
},
}, },
bulkCreateParams[1], bulkCreateParams[1],
], ],
@ -301,7 +348,8 @@ describe('#bulkCreate', () => {
id: 'uuid-v4-id', id: 'uuid-v4-id',
namespace: expectNamespaceInDescriptor ? namespace : undefined, namespace: expectNamespaceInDescriptor ? namespace : undefined,
}, },
{ attrOne: 'one', attrSecret: 'secret', attrThree: 'three' } { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' },
{ user: mockAuthenticatedUser() }
); );
expect(mockBaseClient.bulkCreate).toHaveBeenCalledTimes(1); 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 = [ const docs = [
{ {
id: 'some-id', id: 'some-id',
@ -385,6 +433,7 @@ describe('#bulkUpdate', () => {
attributes: { attributes: {
attrOne: 'one', attrOne: 'one',
attrSecret: 'secret', attrSecret: 'secret',
attrNotSoSecret: 'not-so-secret',
attrThree: 'three', attrThree: 'three',
}, },
}, },
@ -394,13 +443,22 @@ describe('#bulkUpdate', () => {
attributes: { attributes: {
attrOne: 'one 2', attrOne: 'one 2',
attrSecret: 'secret 2', attrSecret: 'secret 2',
attrNotSoSecret: 'not-so-secret 2',
attrThree: 'three 2', attrThree: 'three 2',
}, },
}, },
]; ];
const mockedResponse = { 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); mockBaseClient.bulkUpdate.mockResolvedValue(mockedResponse);
@ -417,6 +475,7 @@ describe('#bulkUpdate', () => {
type: 'known-type', type: 'known-type',
attributes: { attributes: {
attrOne: 'one', attrOne: 'one',
attrNotSoSecret: 'not-so-secret',
attrThree: 'three', attrThree: 'three',
}, },
}, },
@ -425,6 +484,7 @@ describe('#bulkUpdate', () => {
type: 'known-type', type: 'known-type',
attributes: { attributes: {
attrOne: 'one 2', attrOne: 'one 2',
attrNotSoSecret: 'not-so-secret 2',
attrThree: 'three 2', attrThree: 'three 2',
}, },
}, },
@ -434,11 +494,23 @@ describe('#bulkUpdate', () => {
expect(encryptedSavedObjectsServiceMockInstance.encryptAttributes).toHaveBeenCalledTimes(2); expect(encryptedSavedObjectsServiceMockInstance.encryptAttributes).toHaveBeenCalledTimes(2);
expect(encryptedSavedObjectsServiceMockInstance.encryptAttributes).toHaveBeenCalledWith( expect(encryptedSavedObjectsServiceMockInstance.encryptAttributes).toHaveBeenCalledWith(
{ type: 'known-type', id: 'some-id' }, { 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( expect(encryptedSavedObjectsServiceMockInstance.encryptAttributes).toHaveBeenCalledWith(
{ type: 'known-type', id: 'some-id-2' }, { 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); expect(mockBaseClient.bulkUpdate).toHaveBeenCalledTimes(1);
@ -450,6 +522,7 @@ describe('#bulkUpdate', () => {
attributes: { attributes: {
attrOne: 'one', attrOne: 'one',
attrSecret: '*secret*', attrSecret: '*secret*',
attrNotSoSecret: '*not-so-secret*',
attrThree: 'three', attrThree: 'three',
}, },
}, },
@ -459,6 +532,7 @@ describe('#bulkUpdate', () => {
attributes: { attributes: {
attrOne: 'one 2', attrOne: 'one 2',
attrSecret: '*secret 2*', attrSecret: '*secret 2*',
attrNotSoSecret: '*not-so-secret 2*',
attrThree: 'three 2', attrThree: 'three 2',
}, },
}, },
@ -509,7 +583,8 @@ describe('#bulkUpdate', () => {
id: 'some-id', id: 'some-id',
namespace: expectNamespaceInDescriptor ? namespace : undefined, namespace: expectNamespaceInDescriptor ? namespace : undefined,
}, },
{ attrOne: 'one', attrSecret: 'secret', attrThree: 'three' } { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' },
{ user: mockAuthenticatedUser() }
); );
expect(mockBaseClient.bulkUpdate).toHaveBeenCalledTimes(1); expect(mockBaseClient.bulkUpdate).toHaveBeenCalledTimes(1);
@ -635,19 +710,29 @@ describe('#find', () => {
expect(mockBaseClient.find).toHaveBeenCalledWith(options); 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 = { const mockedResponse = {
saved_objects: [ saved_objects: [
{ {
id: 'some-id', id: 'some-id',
type: 'unknown-type', type: 'unknown-type',
attributes: { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' }, attributes: {
attrOne: 'one',
attrSecret: 'secret',
attrNotSoSecret: 'not-so-secret',
attrThree: 'three',
},
references: [], references: [],
}, },
{ {
id: 'some-id-2', id: 'some-id-2',
type: 'known-type', type: 'known-type',
attributes: { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' }, attributes: {
attrOne: 'one',
attrSecret: '*secret*',
attrNotSoSecret: '*not-so-secret*',
attrThree: 'three',
},
references: [], references: [],
}, },
], ],
@ -664,16 +749,118 @@ describe('#find', () => {
saved_objects: [ saved_objects: [
{ {
...mockedResponse.saved_objects[0], ...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], ...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).toHaveBeenCalledTimes(1);
expect(mockBaseClient.find).toHaveBeenCalledWith(options); 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 () => { it('fails if base client fails', async () => {
@ -734,19 +921,29 @@ describe('#bulkGet', () => {
expect(mockBaseClient.bulkGet).toHaveBeenCalledWith(bulkGetParams, options); 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 = { const mockedResponse = {
saved_objects: [ saved_objects: [
{ {
id: 'some-id', id: 'some-id',
type: 'unknown-type', type: 'unknown-type',
attributes: { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' }, attributes: {
attrOne: 'one',
attrSecret: 'secret',
attrNotSoSecret: 'not-so-secret',
attrThree: 'three',
},
references: [], references: [],
}, },
{ {
id: 'some-id-2', id: 'some-id-2',
type: 'known-type', type: 'known-type',
attributes: { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' }, attributes: {
attrOne: 'one',
attrSecret: '*secret*',
attrNotSoSecret: '*not-so-secret*',
attrThree: 'three',
},
references: [], references: [],
}, },
], ],
@ -768,16 +965,123 @@ describe('#bulkGet', () => {
saved_objects: [ saved_objects: [
{ {
...mockedResponse.saved_objects[0], ...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], ...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).toHaveBeenCalledTimes(1);
expect(mockBaseClient.bulkGet).toHaveBeenCalledWith(bulkGetParams, options); 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 () => { it('fails if base client fails', async () => {
@ -814,11 +1118,7 @@ describe('#bulkGet', () => {
const options = { namespace: 'some-ns' }; const options = { namespace: 'some-ns' };
await expect(wrapper.bulkGet(bulkGetParams, options)).resolves.toEqual({ await expect(wrapper.bulkGet(bulkGetParams, options)).resolves.toEqual({
...mockedResponse, ...mockedResponse,
saved_objects: [ saved_objects: [{ ...mockedResponse.saved_objects[0] }],
{
...mockedResponse.saved_objects[0],
},
],
}); });
expect(mockBaseClient.bulkGet).toHaveBeenCalledTimes(1); expect(mockBaseClient.bulkGet).toHaveBeenCalledTimes(1);
}); });
@ -844,11 +1144,16 @@ describe('#get', () => {
expect(mockBaseClient.get).toHaveBeenCalledWith('unknown-type', 'some-id', options); 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 = { const mockedResponse = {
id: 'some-id', id: 'some-id',
type: 'known-type', type: 'known-type',
attributes: { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' }, attributes: {
attrOne: 'one',
attrSecret: '*secret*',
attrNotSoSecret: '*not-so-secret*',
attrThree: 'three',
},
references: [], references: [],
}; };
@ -857,10 +1162,75 @@ describe('#get', () => {
const options = { namespace: 'some-ns' }; const options = { namespace: 'some-ns' };
await expect(wrapper.get('known-type', 'some-id', options)).resolves.toEqual({ await expect(wrapper.get('known-type', 'some-id', options)).resolves.toEqual({
...mockedResponse, ...mockedResponse,
attributes: { attrOne: 'one', attrThree: 'three' }, attributes: { attrOne: 'one', attrNotSoSecret: 'not-so-secret', attrThree: 'three' },
}); });
expect(mockBaseClient.get).toHaveBeenCalledTimes(1); expect(mockBaseClient.get).toHaveBeenCalledTimes(1);
expect(mockBaseClient.get).toHaveBeenCalledWith('known-type', 'some-id', options); 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 () => { it('fails if base client fails', async () => {
@ -895,29 +1265,54 @@ describe('#update', () => {
); );
}); });
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 attributes = { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' }; const attributes = {
attrOne: 'one',
attrSecret: 'secret',
attrNotSoSecret: 'not-so-secret',
attrThree: 'three',
};
const options = { version: 'some-version' }; 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); mockBaseClient.update.mockResolvedValue(mockedResponse);
await expect(wrapper.update('known-type', 'some-id', attributes, options)).resolves.toEqual({ await expect(wrapper.update('known-type', 'some-id', attributes, options)).resolves.toEqual({
...mockedResponse, ...mockedResponse,
attributes: { attrOne: 'one', attrThree: 'three' }, attributes: { attrOne: 'one', attrNotSoSecret: 'not-so-secret', attrThree: 'three' },
}); });
expect(encryptedSavedObjectsServiceMockInstance.encryptAttributes).toHaveBeenCalledTimes(1); expect(encryptedSavedObjectsServiceMockInstance.encryptAttributes).toHaveBeenCalledTimes(1);
expect(encryptedSavedObjectsServiceMockInstance.encryptAttributes).toHaveBeenCalledWith( expect(encryptedSavedObjectsServiceMockInstance.encryptAttributes).toHaveBeenCalledWith(
{ type: 'known-type', id: 'some-id' }, { 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).toHaveBeenCalledTimes(1);
expect(mockBaseClient.update).toHaveBeenCalledWith( expect(mockBaseClient.update).toHaveBeenCalledWith(
'known-type', 'known-type',
'some-id', 'some-id',
{ attrOne: 'one', attrSecret: '*secret*', attrThree: 'three' }, {
attrOne: 'one',
attrSecret: '*secret*',
attrNotSoSecret: '*not-so-secret*',
attrThree: 'three',
},
options options
); );
}); });
@ -942,7 +1337,8 @@ describe('#update', () => {
id: 'some-id', id: 'some-id',
namespace: expectNamespaceInDescriptor ? namespace : undefined, namespace: expectNamespaceInDescriptor ? namespace : undefined,
}, },
{ attrOne: 'one', attrSecret: 'secret', attrThree: 'three' } { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' },
{ user: mockAuthenticatedUser() }
); );
expect(mockBaseClient.update).toHaveBeenCalledTimes(1); expect(mockBaseClient.update).toHaveBeenCalledTimes(1);

View file

@ -23,12 +23,14 @@ import {
SavedObjectsDeleteFromNamespacesOptions, SavedObjectsDeleteFromNamespacesOptions,
ISavedObjectTypeRegistry, ISavedObjectTypeRegistry,
} from 'src/core/server'; } from 'src/core/server';
import { AuthenticatedUser } from '../../../security/common/model';
import { EncryptedSavedObjectsService } from '../crypto'; import { EncryptedSavedObjectsService } from '../crypto';
interface EncryptedSavedObjectsClientOptions { interface EncryptedSavedObjectsClientOptions {
baseClient: SavedObjectsClientContract; baseClient: SavedObjectsClientContract;
baseTypeRegistry: ISavedObjectTypeRegistry; baseTypeRegistry: ISavedObjectTypeRegistry;
service: Readonly<EncryptedSavedObjectsService>; service: Readonly<EncryptedSavedObjectsService>;
getCurrentUser: () => AuthenticatedUser | undefined;
} }
/** /**
@ -49,7 +51,7 @@ export class EncryptedSavedObjectsClientWrapper implements SavedObjectsClientCon
private getDescriptorNamespace = (type: string, namespace?: string) => private getDescriptorNamespace = (type: string, namespace?: string) =>
this.options.baseTypeRegistry.isSingleNamespace(type) ? namespace : undefined; this.options.baseTypeRegistry.isSingleNamespace(type) ? namespace : undefined;
public async create<T = unknown>( public async create<T>(
type: string, type: string,
attributes: T = {} as T, attributes: T = {} as T,
options: SavedObjectsCreateOptions = {} options: SavedObjectsCreateOptions = {}
@ -69,19 +71,22 @@ export class EncryptedSavedObjectsClientWrapper implements SavedObjectsClientCon
const id = generateID(); const id = generateID();
const namespace = this.getDescriptorNamespace(type, options.namespace); const namespace = this.getDescriptorNamespace(type, options.namespace);
return this.stripEncryptedAttributesFromResponse( return await this.handleEncryptedAttributesInResponse(
await this.options.baseClient.create( await this.options.baseClient.create(
type, type,
await this.options.service.encryptAttributes( (await this.options.service.encryptAttributes(
{ type, id, namespace }, { type, id, namespace },
attributes as Record<string, unknown> attributes as Record<string, unknown>,
), { user: this.options.getCurrentUser() }
)) as T,
{ ...options, id } { ...options, id }
) ),
) as SavedObject<T>; attributes,
namespace
);
} }
public async bulkCreate<T = unknown>( public async bulkCreate<T>(
objects: Array<SavedObjectsBulkCreateObject<T>>, objects: Array<SavedObjectsBulkCreateObject<T>>,
options?: SavedObjectsBaseOptions options?: SavedObjectsBaseOptions
) { ) {
@ -110,19 +115,22 @@ export class EncryptedSavedObjectsClientWrapper implements SavedObjectsClientCon
id, id,
attributes: await this.options.service.encryptAttributes( attributes: await this.options.service.encryptAttributes(
{ type: object.type, id, namespace }, { type: object.type, id, namespace },
object.attributes as Record<string, unknown> object.attributes as Record<string, unknown>,
{ user: this.options.getCurrentUser() }
), ),
} as SavedObjectsBulkCreateObject<T>; } as SavedObjectsBulkCreateObject<T>;
}) })
); );
return this.stripEncryptedAttributesFromBulkResponse( return await this.handleEncryptedAttributesInBulkResponse(
await this.options.baseClient.bulkCreate<T>(encryptedObjects, options) await this.options.baseClient.bulkCreate<T>(encryptedObjects, options),
objects,
options?.namespace
); );
} }
public async bulkUpdate( public async bulkUpdate<T>(
objects: SavedObjectsBulkUpdateObject[], objects: Array<SavedObjectsBulkUpdateObject<T>>,
options?: SavedObjectsBaseOptions options?: SavedObjectsBaseOptions
) { ) {
// We encrypt attributes for every object in parallel and that can potentially exhaust libuv or // 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, ...object,
attributes: await this.options.service.encryptAttributes( attributes: await this.options.service.encryptAttributes(
{ type, id, namespace }, { type, id, namespace },
attributes attributes,
{ user: this.options.getCurrentUser() }
), ),
}; };
}) })
); );
return this.stripEncryptedAttributesFromBulkResponse( return await this.handleEncryptedAttributesInBulkResponse(
await this.options.baseClient.bulkUpdate(encryptedObjects, options) 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); return await this.options.baseClient.delete(type, id, options);
} }
public async find<T = unknown>(options: SavedObjectsFindOptions) { public async find<T>(options: SavedObjectsFindOptions) {
return this.stripEncryptedAttributesFromBulkResponse( return await this.handleEncryptedAttributesInBulkResponse(
await this.options.baseClient.find<T>(options) await this.options.baseClient.find<T>(options),
undefined,
options.namespace
); );
} }
public async bulkGet<T = unknown>( public async bulkGet<T>(
objects: SavedObjectsBulkGetObject[] = [], objects: SavedObjectsBulkGetObject[] = [],
options?: SavedObjectsBaseOptions options?: SavedObjectsBaseOptions
) { ) {
return this.stripEncryptedAttributesFromBulkResponse( return await this.handleEncryptedAttributesInBulkResponse(
await this.options.baseClient.bulkGet<T>(objects, options) await this.options.baseClient.bulkGet<T>(objects, options),
undefined,
options?.namespace
); );
} }
public async get<T = unknown>(type: string, id: string, options?: SavedObjectsBaseOptions) { public async get<T>(type: string, id: string, options?: SavedObjectsBaseOptions) {
return this.stripEncryptedAttributesFromResponse( return await this.handleEncryptedAttributesInResponse(
await this.options.baseClient.get<T>(type, id, options) await this.options.baseClient.get<T>(type, id, options),
undefined as unknown,
this.getDescriptorNamespace(type, options?.namespace)
); );
} }
public async update<T = unknown>( public async update<T>(
type: string, type: string,
id: string, id: string,
attributes: Partial<T>, attributes: Partial<T>,
@ -185,13 +202,17 @@ export class EncryptedSavedObjectsClientWrapper implements SavedObjectsClientCon
return await this.options.baseClient.update(type, id, attributes, options); return await this.options.baseClient.update(type, id, attributes, options);
} }
const namespace = this.getDescriptorNamespace(type, options?.namespace); const namespace = this.getDescriptorNamespace(type, options?.namespace);
return this.stripEncryptedAttributesFromResponse( return this.handleEncryptedAttributesInResponse(
await this.options.baseClient.update( await this.options.baseClient.update(
type, type,
id, id,
await this.options.service.encryptAttributes({ type, id, namespace }, attributes), await this.options.service.encryptAttributes({ type, id, namespace }, attributes, {
user: this.options.getCurrentUser(),
}),
options 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 * Strips encrypted attributes from any non-bulk Saved Objects API response. If type isn't
* registered, response is returned as is. * registered, response is returned as is.
* @param response Raw response returned by the underlying base client. * @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<T extends SavedObjectsUpdateResponse | SavedObject>( private async handleEncryptedAttributesInResponse<
response: T T,
): T { R extends SavedObjectsUpdateResponse<T> | SavedObject<T>
if (this.options.service.isRegistered(response.type) && response.attributes) { >(response: R, originalAttributes?: T, namespace?: string): Promise<R> {
response.attributes = this.options.service.stripEncryptedAttributes( if (response.attributes && this.options.service.isRegistered(response.type)) {
response.type, // Error is returned when decryption fails, and in this case encrypted attributes will be
response.attributes as Record<string, unknown> // 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<string, unknown>,
originalAttributes as Record<string, unknown>,
{ user: this.options.getCurrentUser() }
); );
response.attributes = attributes as T;
if (error) {
response.error = error as any;
}
} }
return response; 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 * 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. * response portion isn't registered, it is returned as is.
* @param response Raw response returned by the underlying base client. * @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< private async handleEncryptedAttributesInBulkResponse<
T extends SavedObjectsBulkResponse | SavedObjectsFindResponse | SavedObjectsBulkUpdateResponse T,
>(response: T): T { R extends
for (const savedObject of response.saved_objects) { | SavedObjectsBulkResponse<T>
if (this.options.service.isRegistered(savedObject.type) && savedObject.attributes) { | SavedObjectsFindResponse<T>
savedObject.attributes = this.options.service.stripEncryptedAttributes( | SavedObjectsBulkUpdateResponse<T>,
savedObject.type, O extends Array<SavedObjectsBulkCreateObject<T>> | Array<SavedObjectsBulkUpdateObject<T>>
savedObject.attributes as Record<string, unknown> >(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; return response;

View file

@ -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<typeof coreMock.createSetup>;
let mockSavedObjectsRepository: jest.Mocked<ISavedObjectsRepository>;
let mockSavedObjectTypeRegistry: jest.Mocked<ISavedObjectTypeRegistry>;
let mockEncryptedSavedObjectsService: jest.Mocked<EncryptedSavedObjectsService>;
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' }
);
});
});
});

View file

@ -9,13 +9,17 @@ import {
SavedObject, SavedObject,
SavedObjectsBaseOptions, SavedObjectsBaseOptions,
SavedObjectsServiceSetup, SavedObjectsServiceSetup,
ISavedObjectsRepository,
ISavedObjectTypeRegistry,
} from 'src/core/server'; } from 'src/core/server';
import { SecurityPluginSetup } from '../../../security/server';
import { EncryptedSavedObjectsService } from '../crypto'; import { EncryptedSavedObjectsService } from '../crypto';
import { EncryptedSavedObjectsClientWrapper } from './encrypted_saved_objects_client_wrapper'; import { EncryptedSavedObjectsClientWrapper } from './encrypted_saved_objects_client_wrapper';
interface SetupSavedObjectsParams { interface SetupSavedObjectsParams {
service: PublicMethodsOf<EncryptedSavedObjectsService>; service: PublicMethodsOf<EncryptedSavedObjectsService>;
savedObjects: SavedObjectsServiceSetup; savedObjects: SavedObjectsServiceSetup;
security?: SecurityPluginSetup;
getStartServices: StartServicesAccessor; getStartServices: StartServicesAccessor;
} }
@ -30,6 +34,7 @@ export interface SavedObjectsSetup {
export function setupSavedObjects({ export function setupSavedObjects({
service, service,
savedObjects, savedObjects,
security,
getStartServices, getStartServices,
}: SetupSavedObjectsParams): SavedObjectsSetup { }: SetupSavedObjectsParams): SavedObjectsSetup {
// Register custom saved object client that will encrypt, decrypt and strip saved object // Register custom saved object client that will encrypt, decrypt and strip saved object
@ -40,25 +45,39 @@ export function setupSavedObjects({
savedObjects.addClientWrapper( savedObjects.addClientWrapper(
Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER,
'encryptedSavedObjects', 'encryptedSavedObjects',
({ client: baseClient, typeRegistry: baseTypeRegistry }) => ({ client: baseClient, typeRegistry: baseTypeRegistry, request }) =>
new EncryptedSavedObjectsClientWrapper({ baseClient, baseTypeRegistry, service }) new EncryptedSavedObjectsClientWrapper({
baseClient,
baseTypeRegistry,
service,
getCurrentUser: () => security?.authc.getCurrentUser(request) ?? undefined,
})
); );
const internalRepositoryPromise = getStartServices().then(([core]) => const internalRepositoryAndTypeRegistryPromise = getStartServices().then(
core.savedObjects.createInternalRepository() ([core]) =>
[core.savedObjects.createInternalRepository(), core.savedObjects.getTypeRegistry()] as [
ISavedObjectsRepository,
ISavedObjectTypeRegistry
]
); );
return { return {
getDecryptedAsInternalUser: async <T = unknown>( getDecryptedAsInternalUser: async <T = unknown>(
type: string, type: string,
id: string, id: string,
options?: SavedObjectsBaseOptions options?: SavedObjectsBaseOptions
): Promise<SavedObject<T>> => { ): Promise<SavedObject<T>> => {
const internalRepository = await internalRepositoryPromise; const [internalRepository, typeRegistry] = await internalRepositoryAndTypeRegistryPromise;
const savedObject = await internalRepository.get(type, id, options); const savedObject = await internalRepository.get(type, id, options);
return { return {
...savedObject, ...savedObject,
attributes: (await service.decryptAttributes( attributes: (await service.decryptAttributes(
{ type, id, namespace: options && options.namespace }, {
type,
id,
namespace: typeRegistry.isSingleNamespace(type) ? options?.namespace : undefined,
},
savedObject.attributes as Record<string, unknown> savedObject.attributes as Record<string, unknown>
)) as T, )) as T,
}; };

View file

@ -4,8 +4,12 @@
* you may not use this file except in compliance with the Elastic License. * you may not use this file except in compliance with the Elastic License.
*/ */
import { CoreSetup, PluginInitializer } from '../../../../../../src/core/server'; import {
import { deepFreeze } from '../../../../../../src/core/server'; deepFreeze,
CoreSetup,
PluginInitializer,
SavedObjectsNamespaceType,
} from '../../../../../../src/core/server';
import { import {
EncryptedSavedObjectsPluginSetup, EncryptedSavedObjectsPluginSetup,
EncryptedSavedObjectsPluginStart, EncryptedSavedObjectsPluginStart,
@ -13,6 +17,9 @@ import {
import { SpacesPluginSetup } from '../../../../../plugins/spaces/server'; import { SpacesPluginSetup } from '../../../../../plugins/spaces/server';
const SAVED_OBJECT_WITH_SECRET_TYPE = 'saved-object-with-secret'; 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 { interface PluginsSetup {
encryptedSavedObjects: EncryptedSavedObjectsPluginSetup; encryptedSavedObjects: EncryptedSavedObjectsPluginSetup;
@ -26,28 +33,44 @@ interface PluginsStart {
export const plugin: PluginInitializer<void, void, PluginsSetup, PluginsStart> = () => ({ export const plugin: PluginInitializer<void, void, PluginsSetup, PluginsStart> = () => ({
setup(core: CoreSetup<PluginsStart>, deps) { setup(core: CoreSetup<PluginsStart>, 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({ core.savedObjects.registerType({
name: SAVED_OBJECT_WITH_SECRET_TYPE, name: SAVED_OBJECT_WITHOUT_SECRET_TYPE,
hidden: false, hidden: false,
namespaceType: 'single', namespaceType: 'single',
mappings: deepFreeze({ mappings: deepFreeze({ properties: { publicProperty: { type: 'keyword' } } }),
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']),
}); });
core.http.createRouter().get( 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 }) }, validate: { params: value => ({ value }) },
}, },
async (context, request, response) => { async (context, request, response) => {
@ -58,7 +81,7 @@ export const plugin: PluginInitializer<void, void, PluginsSetup, PluginsStart> =
try { try {
return response.ok({ return response.ok({
body: await encryptedSavedObjects.getDecryptedAsInternalUser( body: await encryptedSavedObjects.getDecryptedAsInternalUser(
SAVED_OBJECT_WITH_SECRET_TYPE, request.params.type,
request.params.id, request.params.id,
{ namespace } { namespace }
), ),

View file

@ -14,13 +14,20 @@ export default function({ getService }: FtrProviderContext) {
const supertest = getService('supertest'); const supertest = getService('supertest');
const SAVED_OBJECT_WITH_SECRET_TYPE = 'saved-object-with-secret'; 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) { function runTests(
async function getRawSavedObjectAttributes(id: string) { encryptedSavedObjectType: string,
getURLAPIBaseURL: () => string,
generateRawID: (id: string, type: string) => string
) {
async function getRawSavedObjectAttributes({ id, type }: SavedObject) {
const { const {
_source: { [SAVED_OBJECT_WITH_SECRET_TYPE]: savedObject }, _source: { [type]: savedObject },
} = await es.get({ } = await es.get({
id: generateRawID(id), id: generateRawID(id, type),
index: '.kibana', index: '.kibana',
}); });
@ -29,20 +36,22 @@ export default function({ getService }: FtrProviderContext) {
let savedObjectOriginalAttributes: { let savedObjectOriginalAttributes: {
publicProperty: string; publicProperty: string;
publicPropertyExcludedFromAAD: string; publicPropertyStoredEncrypted: string;
privateProperty: string; privateProperty: string;
publicPropertyExcludedFromAAD: string;
}; };
let savedObject: SavedObject; let savedObject: SavedObject;
beforeEach(async () => { beforeEach(async () => {
savedObjectOriginalAttributes = { savedObjectOriginalAttributes = {
publicProperty: randomness.string(), publicProperty: randomness.string(),
publicPropertyExcludedFromAAD: randomness.string(), publicPropertyStoredEncrypted: randomness.string(),
privateProperty: randomness.string(), privateProperty: randomness.string(),
publicPropertyExcludedFromAAD: randomness.string(),
}; };
const { body } = await supertest const { body } = await supertest
.post(`${getURLAPIBaseURL()}${SAVED_OBJECT_WITH_SECRET_TYPE}`) .post(`${getURLAPIBaseURL()}${encryptedSavedObjectType}`)
.set('kbn-xsrf', 'xxx') .set('kbn-xsrf', 'xxx')
.send({ attributes: savedObjectOriginalAttributes }) .send({ attributes: savedObjectOriginalAttributes })
.expect(200); .expect(200);
@ -54,14 +63,19 @@ export default function({ getService }: FtrProviderContext) {
expect(savedObject.attributes).to.eql({ expect(savedObject.attributes).to.eql({
publicProperty: savedObjectOriginalAttributes.publicProperty, publicProperty: savedObjectOriginalAttributes.publicProperty,
publicPropertyExcludedFromAAD: savedObjectOriginalAttributes.publicPropertyExcludedFromAAD, 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.publicProperty).to.be(savedObjectOriginalAttributes.publicProperty);
expect(rawAttributes.publicPropertyExcludedFromAAD).to.be( expect(rawAttributes.publicPropertyExcludedFromAAD).to.be(
savedObjectOriginalAttributes.publicPropertyExcludedFromAAD 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.empty();
expect(rawAttributes.privateProperty).to.not.be( expect(rawAttributes.privateProperty).to.not.be(
savedObjectOriginalAttributes.privateProperty savedObjectOriginalAttributes.privateProperty
@ -71,18 +85,20 @@ export default function({ getService }: FtrProviderContext) {
it('#bulkCreate encrypts attributes and strips them from response', async () => { it('#bulkCreate encrypts attributes and strips them from response', async () => {
const bulkCreateParams = [ const bulkCreateParams = [
{ {
type: SAVED_OBJECT_WITH_SECRET_TYPE, type: encryptedSavedObjectType,
attributes: { attributes: {
publicProperty: randomness.string(), publicProperty: randomness.string(),
publicPropertyExcludedFromAAD: randomness.string(), publicPropertyExcludedFromAAD: randomness.string(),
publicPropertyStoredEncrypted: randomness.string(),
privateProperty: randomness.string(), privateProperty: randomness.string(),
}, },
}, },
{ {
type: SAVED_OBJECT_WITH_SECRET_TYPE, type: encryptedSavedObjectType,
attributes: { attributes: {
publicProperty: randomness.string(), publicProperty: randomness.string(),
publicPropertyExcludedFromAAD: randomness.string(), publicPropertyExcludedFromAAD: randomness.string(),
publicPropertyStoredEncrypted: randomness.string(),
privateProperty: randomness.string(), privateProperty: randomness.string(),
}, },
}, },
@ -100,30 +116,120 @@ export default function({ getService }: FtrProviderContext) {
for (let index = 0; index < savedObjects.length; index++) { for (let index = 0; index < savedObjects.length; index++) {
const attributesFromResponse = savedObjects[index].attributes; const attributesFromResponse = savedObjects[index].attributes;
const attributesFromRequest = bulkCreateParams[index].attributes; const attributesFromRequest = bulkCreateParams[index].attributes;
const rawAttributes = await getRawSavedObjectAttributes(savedObjects[index].id); const rawAttributes = await getRawSavedObjectAttributes(savedObjects[index]);
expect(attributesFromResponse).to.eql({ expect(attributesFromResponse).to.eql({
publicProperty: attributesFromRequest.publicProperty, publicProperty: attributesFromRequest.publicProperty,
publicPropertyExcludedFromAAD: attributesFromRequest.publicPropertyExcludedFromAAD, publicPropertyExcludedFromAAD: attributesFromRequest.publicPropertyExcludedFromAAD,
publicPropertyStoredEncrypted: attributesFromRequest.publicPropertyStoredEncrypted,
}); });
expect(rawAttributes.publicProperty).to.be(attributesFromRequest.publicProperty); expect(rawAttributes.publicProperty).to.be(attributesFromRequest.publicProperty);
expect(rawAttributes.publicPropertyExcludedFromAAD).to.be( expect(rawAttributes.publicPropertyExcludedFromAAD).to.be(
attributesFromRequest.publicPropertyExcludedFromAAD 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.empty();
expect(rawAttributes.privateProperty).to.not.be(attributesFromRequest.privateProperty); 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 () => { it('#get strips encrypted attributes from response', async () => {
const { body: response } = await supertest const { body: response } = await supertest
.get(`${getURLAPIBaseURL()}${SAVED_OBJECT_WITH_SECRET_TYPE}/${savedObject.id}`) .get(`${getURLAPIBaseURL()}${encryptedSavedObjectType}/${savedObject.id}`)
.expect(200); .expect(200);
expect(response.attributes).to.eql({ expect(response.attributes).to.eql({
publicProperty: savedObjectOriginalAttributes.publicProperty, publicProperty: savedObjectOriginalAttributes.publicProperty,
publicPropertyExcludedFromAAD: savedObjectOriginalAttributes.publicPropertyExcludedFromAAD, 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 { const {
body: { saved_objects: savedObjects }, body: { saved_objects: savedObjects },
} = await supertest } = await supertest
.get(`${getURLAPIBaseURL()}_find?type=${SAVED_OBJECT_WITH_SECRET_TYPE}`) .get(`${getURLAPIBaseURL()}_find?type=${encryptedSavedObjectType}`)
.expect(200); .expect(200);
expect(savedObjects).to.have.length(1); expect(savedObjects).to.have.length(1);
@ -139,6 +245,35 @@ export default function({ getService }: FtrProviderContext) {
expect(savedObjects[0].attributes).to.eql({ expect(savedObjects[0].attributes).to.eql({
publicProperty: savedObjectOriginalAttributes.publicProperty, publicProperty: savedObjectOriginalAttributes.publicProperty,
publicPropertyExcludedFromAAD: savedObjectOriginalAttributes.publicPropertyExcludedFromAAD, 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({ expect(savedObjects[0].attributes).to.eql({
publicProperty: savedObjectOriginalAttributes.publicProperty, publicProperty: savedObjectOriginalAttributes.publicProperty,
publicPropertyExcludedFromAAD: savedObjectOriginalAttributes.publicPropertyExcludedFromAAD, 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 = { const updatedAttributes = {
publicProperty: randomness.string(), publicProperty: randomness.string(),
publicPropertyExcludedFromAAD: randomness.string(), publicPropertyExcludedFromAAD: randomness.string(),
publicPropertyStoredEncrypted: randomness.string(),
privateProperty: randomness.string(), privateProperty: randomness.string(),
}; };
const { body: response } = await supertest const { body: response } = await supertest
.put(`${getURLAPIBaseURL()}${SAVED_OBJECT_WITH_SECRET_TYPE}/${savedObject.id}`) .put(`${getURLAPIBaseURL()}${encryptedSavedObjectType}/${savedObject.id}`)
.set('kbn-xsrf', 'xxx') .set('kbn-xsrf', 'xxx')
.send({ attributes: updatedAttributes }) .send({ attributes: updatedAttributes })
.expect(200); .expect(200);
@ -175,13 +342,18 @@ export default function({ getService }: FtrProviderContext) {
expect(response.attributes).to.eql({ expect(response.attributes).to.eql({
publicProperty: updatedAttributes.publicProperty, publicProperty: updatedAttributes.publicProperty,
publicPropertyExcludedFromAAD: updatedAttributes.publicPropertyExcludedFromAAD, 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.publicProperty).to.be(updatedAttributes.publicProperty);
expect(rawAttributes.publicPropertyExcludedFromAAD).to.be( expect(rawAttributes.publicPropertyExcludedFromAAD).to.be(
updatedAttributes.publicPropertyExcludedFromAAD 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.empty();
expect(rawAttributes.privateProperty).to.not.be(updatedAttributes.privateProperty); 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 () => { it('#getDecryptedAsInternalUser decrypts and returns all attributes', async () => {
const { body: decryptedResponse } = await supertest 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(200);
expect(decryptedResponse.attributes).to.eql(savedObjectOriginalAttributes); expect(decryptedResponse.attributes).to.eql(savedObjectOriginalAttributes);
@ -199,7 +375,7 @@ export default function({ getService }: FtrProviderContext) {
const updatedAttributes = { publicPropertyExcludedFromAAD: randomness.string() }; const updatedAttributes = { publicPropertyExcludedFromAAD: randomness.string() };
const { body: response } = await supertest const { body: response } = await supertest
.put(`${getURLAPIBaseURL()}${SAVED_OBJECT_WITH_SECRET_TYPE}/${savedObject.id}`) .put(`${getURLAPIBaseURL()}${encryptedSavedObjectType}/${savedObject.id}`)
.set('kbn-xsrf', 'xxx') .set('kbn-xsrf', 'xxx')
.send({ attributes: updatedAttributes }) .send({ attributes: updatedAttributes })
.expect(200); .expect(200);
@ -209,7 +385,11 @@ export default function({ getService }: FtrProviderContext) {
}); });
const { body: decryptedResponse } = await supertest 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(200);
expect(decryptedResponse.attributes).to.eql({ expect(decryptedResponse.attributes).to.eql({
@ -222,7 +402,7 @@ export default function({ getService }: FtrProviderContext) {
const updatedAttributes = { publicProperty: randomness.string() }; const updatedAttributes = { publicProperty: randomness.string() };
const { body: response } = await supertest const { body: response } = await supertest
.put(`${getURLAPIBaseURL()}${SAVED_OBJECT_WITH_SECRET_TYPE}/${savedObject.id}`) .put(`${getURLAPIBaseURL()}${encryptedSavedObjectType}/${savedObject.id}`)
.set('kbn-xsrf', 'xxx') .set('kbn-xsrf', 'xxx')
.send({ attributes: updatedAttributes }) .send({ attributes: updatedAttributes })
.expect(200); .expect(200);
@ -233,7 +413,11 @@ export default function({ getService }: FtrProviderContext) {
// Bad request means that we successfully detected "EncryptionError" (not unexpected one). // Bad request means that we successfully detected "EncryptionError" (not unexpected one).
await supertest await supertest
.get(`${getURLAPIBaseURL()}get-decrypted-as-internal-user/${savedObject.id}`) .get(
`${getURLAPIBaseURL()}get-decrypted-as-internal-user/${encryptedSavedObjectType}/${
savedObject.id
}`
)
.expect(400, { .expect(400, {
statusCode: 400, statusCode: 400,
error: 'Bad Request', error: 'Bad Request',
@ -243,19 +427,38 @@ export default function({ getService }: FtrProviderContext) {
} }
describe('encrypted saved objects API', () => { 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 () => { afterEach(async () => {
await es.deleteByQuery({ await es.deleteByQuery({
index: '.kibana', 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, refresh: true,
}); });
}); });
describe('within a default space', () => { describe('within a default space', () => {
runTests( describe('with `single` namespace saved object', () => {
() => '/api/saved_objects/', runTests(
id => `${SAVED_OBJECT_WITH_SECRET_TYPE}:${id}` 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', () => { describe('within a custom space', () => {
@ -276,10 +479,21 @@ export default function({ getService }: FtrProviderContext) {
.expect(204); .expect(204);
}); });
runTests( describe('with `single` namespace saved object', () => {
() => `/s/${SPACE_ID}/api/saved_objects/`, runTests(
id => `${SPACE_ID}:${SAVED_OBJECT_WITH_SECRET_TYPE}:${id}` 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)
);
});
}); });
}); });
} }