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