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

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,113 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { EncryptedSavedObjectTypeRegistration } from './encrypted_saved_objects_service';
import { EncryptedSavedObjectAttributesDefinition } from './encrypted_saved_object_type_definition';
it('correctly determines attribute properties', () => {
const attributes = ['attr#1', 'attr#2', 'attr#3', 'attr#4'];
const cases: Array<[
EncryptedSavedObjectTypeRegistration,
{
shouldBeEncrypted: boolean[];
shouldBeExcludedFromAAD: boolean[];
shouldBeStripped: boolean[];
}
]> = [
[
{
type: 'so-type',
attributesToEncrypt: new Set(['attr#1', 'attr#2', 'attr#3', 'attr#4']),
},
{
shouldBeEncrypted: [true, true, true, true],
shouldBeExcludedFromAAD: [true, true, true, true],
shouldBeStripped: [true, true, true, true],
},
],
[
{
type: 'so-type',
attributesToEncrypt: new Set(['attr#1', 'attr#2']),
},
{
shouldBeEncrypted: [true, true, false, false],
shouldBeExcludedFromAAD: [true, true, false, false],
shouldBeStripped: [true, true, false, false],
},
],
[
{
type: 'so-type',
attributesToEncrypt: new Set([{ key: 'attr#1' }, { key: 'attr#2' }]),
},
{
shouldBeEncrypted: [true, true, false, false],
shouldBeExcludedFromAAD: [true, true, false, false],
shouldBeStripped: [true, true, false, false],
},
],
[
{
type: 'so-type',
attributesToEncrypt: new Set(['attr#1', 'attr#2']),
attributesToExcludeFromAAD: new Set(['attr#3']),
},
{
shouldBeEncrypted: [true, true, false, false],
shouldBeExcludedFromAAD: [true, true, true, false],
shouldBeStripped: [true, true, false, false],
},
],
[
{
type: 'so-type',
attributesToEncrypt: new Set([
'attr#1',
'attr#2',
{ key: 'attr#4', dangerouslyExposeValue: true },
]),
attributesToExcludeFromAAD: new Set(['attr#3']),
},
{
shouldBeEncrypted: [true, true, false, true],
shouldBeExcludedFromAAD: [true, true, true, true],
shouldBeStripped: [true, true, false, false],
},
],
[
{
type: 'so-type',
attributesToEncrypt: new Set([
{ key: 'attr#1', dangerouslyExposeValue: true },
'attr#2',
{ key: 'attr#4', dangerouslyExposeValue: true },
]),
attributesToExcludeFromAAD: new Set(['some-other-attribute']),
},
{
shouldBeEncrypted: [true, true, false, true],
shouldBeExcludedFromAAD: [true, true, false, true],
shouldBeStripped: [false, true, false, false],
},
],
];
for (const [typeRegistration, asserts] of cases) {
const typeDefinition = new EncryptedSavedObjectAttributesDefinition(typeRegistration);
for (const [attributeIndex, attributeName] of attributes.entries()) {
expect(typeDefinition.shouldBeEncrypted(attributeName)).toBe(
asserts.shouldBeEncrypted[attributeIndex]
);
expect(typeDefinition.shouldBeStripped(attributeName)).toBe(
asserts.shouldBeStripped[attributeIndex]
);
expect(typeDefinition.shouldBeExcludedFromAAD(attributeName)).toBe(
asserts.shouldBeExcludedFromAAD[attributeIndex]
);
}
}
});

View file

@ -0,0 +1,67 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { EncryptedSavedObjectTypeRegistration } from './encrypted_saved_objects_service';
/**
* Represents the definition of the attributes of the specific saved object that are supposed to be
* encrypted. The definition also dictates which attributes should be excluded from AAD and/or
* stripped from response.
*/
export class EncryptedSavedObjectAttributesDefinition {
public readonly attributesToEncrypt: ReadonlySet<string>;
private readonly attributesToExcludeFromAAD: ReadonlySet<string> | undefined;
private readonly attributesToStrip: ReadonlySet<string>;
constructor(typeRegistration: EncryptedSavedObjectTypeRegistration) {
const attributesToEncrypt = new Set<string>();
const attributesToStrip = new Set<string>();
for (const attribute of typeRegistration.attributesToEncrypt) {
if (typeof attribute === 'string') {
attributesToEncrypt.add(attribute);
attributesToStrip.add(attribute);
} else {
attributesToEncrypt.add(attribute.key);
if (!attribute.dangerouslyExposeValue) {
attributesToStrip.add(attribute.key);
}
}
}
this.attributesToEncrypt = attributesToEncrypt;
this.attributesToStrip = attributesToStrip;
this.attributesToExcludeFromAAD = typeRegistration.attributesToExcludeFromAAD;
}
/**
* Determines whether particular attribute should be encrypted. Full list of attributes that
* should be encrypted can be retrieved via `attributesToEncrypt` property.
* @param attributeName Name of the attribute.
*/
public shouldBeEncrypted(attributeName: string) {
return this.attributesToEncrypt.has(attributeName);
}
/**
* Determines whether particular attribute should be excluded from AAD.
* @param attributeName Name of the attribute.
*/
public shouldBeExcludedFromAAD(attributeName: string) {
return (
this.shouldBeEncrypted(attributeName) ||
(this.attributesToExcludeFromAAD != null &&
this.attributesToExcludeFromAAD.has(attributeName))
);
}
/**
* Determines whether particular attribute should be stripped from the attribute list.
* @param attributeName Name of the attribute.
*/
public shouldBeStripped(attributeName: string) {
return this.attributesToStrip.has(attributeName);
}
}

View file

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

View file

@ -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;
}
}
clonedAttributes[attributeName] = decryptedAttributes[attributeName];
}
}
return clonedAttributes;
return { attributes: clonedAttributes as T, error: decryptionError };
}
/**
@ -128,20 +199,22 @@ export class EncryptedSavedObjectsService {
* attributes were encrypted original attributes dictionary is returned.
* @param descriptor Descriptor of the saved object to encrypt attributes for.
* @param attributes Dictionary of __ALL__ saved object attributes.
* @param [params] Additional parameters.
* @throws Will throw if encryption fails for whatever reason.
*/
public async encryptAttributes<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;
}
}

View file

@ -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\\\\\\"\\"}"`
);
});

View file

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

View file

@ -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;

View file

@ -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],

View file

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

View file

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

View file

@ -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,17 +269,23 @@ export class EncryptedSavedObjectsClientWrapper implements SavedObjectsClientCon
* Strips encrypted attributes from any bulk Saved Objects API response. If type for any bulk
* response portion isn't registered, it is returned as is.
* @param response Raw response returned by the underlying base client.
* @param [objects] Optional list of saved objects with original attributes.
* @param [namespace] Optional namespace that was used for the saved objects operation.
*/
private stripEncryptedAttributesFromBulkResponse<
T extends SavedObjectsBulkResponse | SavedObjectsFindResponse | SavedObjectsBulkUpdateResponse
>(response: T): T {
for (const savedObject of response.saved_objects) {
if (this.options.service.isRegistered(savedObject.type) && savedObject.attributes) {
savedObject.attributes = this.options.service.stripEncryptedAttributes(
savedObject.type,
savedObject.attributes as Record<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;

View file

@ -0,0 +1,140 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { SavedObjectsSetup, setupSavedObjects } from '.';
import {
coreMock,
httpServerMock,
savedObjectsClientMock,
savedObjectsRepositoryMock,
savedObjectsTypeRegistryMock,
} from '../../../../../src/core/server/mocks';
import { securityMock } from '../../../security/server/mocks';
import { encryptedSavedObjectsServiceMock } from '../crypto/index.mock';
import { EncryptedSavedObjectsClientWrapper } from './encrypted_saved_objects_client_wrapper';
import {
ISavedObjectsRepository,
ISavedObjectTypeRegistry,
SavedObject,
} from '../../../../../src/core/server';
import { EncryptedSavedObjectsService } from '../crypto';
describe('#setupSavedObjects', () => {
let setupContract: SavedObjectsSetup;
let coreSetupMock: ReturnType<typeof coreMock.createSetup>;
let mockSavedObjectsRepository: jest.Mocked<ISavedObjectsRepository>;
let mockSavedObjectTypeRegistry: jest.Mocked<ISavedObjectTypeRegistry>;
let mockEncryptedSavedObjectsService: jest.Mocked<EncryptedSavedObjectsService>;
beforeEach(() => {
const coreStartMock = coreMock.createStart();
mockSavedObjectsRepository = savedObjectsRepositoryMock.create();
coreStartMock.savedObjects.createInternalRepository.mockReturnValue(mockSavedObjectsRepository);
mockSavedObjectTypeRegistry = savedObjectsTypeRegistryMock.create();
coreStartMock.savedObjects.getTypeRegistry.mockReturnValue(mockSavedObjectTypeRegistry);
coreSetupMock = coreMock.createSetup();
coreSetupMock.getStartServices.mockResolvedValue([coreStartMock, {}, {}]);
mockEncryptedSavedObjectsService = encryptedSavedObjectsServiceMock.create([
{ type: 'known-type', attributesToEncrypt: new Set(['attrSecret']) },
]);
setupContract = setupSavedObjects({
service: mockEncryptedSavedObjectsService,
savedObjects: coreSetupMock.savedObjects,
security: securityMock.createSetup(),
getStartServices: coreSetupMock.getStartServices,
});
});
it('properly registers client wrapper factory', () => {
expect(coreSetupMock.savedObjects.addClientWrapper).toHaveBeenCalledTimes(1);
expect(coreSetupMock.savedObjects.addClientWrapper).toHaveBeenCalledWith(
Number.MAX_SAFE_INTEGER,
'encryptedSavedObjects',
expect.any(Function)
);
const [[, , clientFactory]] = coreSetupMock.savedObjects.addClientWrapper.mock.calls;
expect(
clientFactory({
client: savedObjectsClientMock.create(),
typeRegistry: savedObjectsTypeRegistryMock.create(),
request: httpServerMock.createKibanaRequest(),
})
).toBeInstanceOf(EncryptedSavedObjectsClientWrapper);
});
describe('#getDecryptedAsInternalUser', () => {
it('includes `namespace` for single-namespace saved objects', async () => {
const mockSavedObject: SavedObject = {
id: 'some-id',
type: 'known-type',
attributes: { attrOne: 'one', attrSecret: '*secret*' },
references: [],
};
mockSavedObjectsRepository.get.mockResolvedValue(mockSavedObject);
mockSavedObjectTypeRegistry.isSingleNamespace.mockReturnValue(true);
await expect(
setupContract.getDecryptedAsInternalUser(mockSavedObject.type, mockSavedObject.id, {
namespace: 'some-ns',
})
).resolves.toEqual({
...mockSavedObject,
attributes: { attrOne: 'one', attrSecret: 'secret' },
});
expect(mockEncryptedSavedObjectsService.decryptAttributes).toHaveBeenCalledTimes(1);
expect(mockEncryptedSavedObjectsService.decryptAttributes).toHaveBeenCalledWith(
{ type: mockSavedObject.type, id: mockSavedObject.id, namespace: 'some-ns' },
mockSavedObject.attributes
);
expect(mockSavedObjectsRepository.get).toHaveBeenCalledTimes(1);
expect(mockSavedObjectsRepository.get).toHaveBeenCalledWith(
mockSavedObject.type,
mockSavedObject.id,
{ namespace: 'some-ns' }
);
});
it('does not include `namespace` for multiple-namespace saved objects', async () => {
const mockSavedObject: SavedObject = {
id: 'some-id',
type: 'known-type',
attributes: { attrOne: 'one', attrSecret: '*secret*' },
references: [],
};
mockSavedObjectsRepository.get.mockResolvedValue(mockSavedObject);
mockSavedObjectTypeRegistry.isSingleNamespace.mockReturnValue(false);
await expect(
setupContract.getDecryptedAsInternalUser(mockSavedObject.type, mockSavedObject.id, {
namespace: 'some-ns',
})
).resolves.toEqual({
...mockSavedObject,
attributes: { attrOne: 'one', attrSecret: 'secret' },
});
expect(mockEncryptedSavedObjectsService.decryptAttributes).toHaveBeenCalledTimes(1);
expect(mockEncryptedSavedObjectsService.decryptAttributes).toHaveBeenCalledWith(
{ type: mockSavedObject.type, id: mockSavedObject.id, namespace: undefined },
mockSavedObject.attributes
);
expect(mockSavedObjectsRepository.get).toHaveBeenCalledTimes(1);
expect(mockSavedObjectsRepository.get).toHaveBeenCalledWith(
mockSavedObject.type,
mockSavedObject.id,
{ namespace: 'some-ns' }
);
});
});
});

View file

@ -9,13 +9,17 @@ import {
SavedObject,
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,
};

View file

@ -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,
hidden: false,
namespaceType,
mappings: deepFreeze({
properties: {
publicProperty: { type: 'keyword' },
publicPropertyExcludedFromAAD: { type: 'keyword' },
publicPropertyStoredEncrypted: { type: 'binary' },
privateProperty: { type: 'binary' },
},
}),
});
deps.encryptedSavedObjects.registerType({
type: name,
attributesToEncrypt: new Set([
'privateProperty',
{ key: 'publicPropertyStoredEncrypted', dangerouslyExposeValue: true },
]),
attributesToExcludeFromAAD: new Set(['publicPropertyExcludedFromAAD']),
});
}
core.savedObjects.registerType({
name: SAVED_OBJECT_WITH_SECRET_TYPE,
name: SAVED_OBJECT_WITHOUT_SECRET_TYPE,
hidden: false,
namespaceType: 'single',
mappings: deepFreeze({
properties: {
publicProperty: { type: 'keyword' },
publicPropertyExcludedFromAAD: { type: 'keyword' },
privateProperty: { type: 'binary' },
},
}),
});
deps.encryptedSavedObjects.registerType({
type: SAVED_OBJECT_WITH_SECRET_TYPE,
attributesToEncrypt: new Set(['privateProperty']),
attributesToExcludeFromAAD: new Set(['publicPropertyExcludedFromAAD']),
mappings: deepFreeze({ properties: { publicProperty: { type: 'keyword' } } }),
});
core.http.createRouter().get(
{
path: '/api/saved_objects/get-decrypted-as-internal-user/{id}',
path: '/api/saved_objects/get-decrypted-as-internal-user/{type}/{id}',
validate: { params: value => ({ value }) },
},
async (context, request, response) => {
@ -58,7 +81,7 @@ export const plugin: PluginInitializer<void, void, PluginsSetup, PluginsStart> =
try {
return response.ok({
body: await encryptedSavedObjects.getDecryptedAsInternalUser(
SAVED_OBJECT_WITH_SECRET_TYPE,
request.params.type,
request.params.id,
{ namespace }
),

View file

@ -14,13 +14,20 @@ export default function({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
const SAVED_OBJECT_WITH_SECRET_TYPE = 'saved-object-with-secret';
const SAVED_OBJECT_WITH_SECRET_AND_MULTIPLE_SPACES_TYPE =
'saved-object-with-secret-and-multiple-spaces';
const SAVED_OBJECT_WITHOUT_SECRET_TYPE = 'saved-object-without-secret';
function runTests(getURLAPIBaseURL: () => string, generateRawID: (id: string) => string) {
async function getRawSavedObjectAttributes(id: string) {
function runTests(
encryptedSavedObjectType: string,
getURLAPIBaseURL: () => string,
generateRawID: (id: string, type: string) => string
) {
async function getRawSavedObjectAttributes({ id, type }: SavedObject) {
const {
_source: { [SAVED_OBJECT_WITH_SECRET_TYPE]: savedObject },
_source: { [type]: savedObject },
} = await es.get({
id: generateRawID(id),
id: generateRawID(id, type),
index: '.kibana',
});
@ -29,20 +36,22 @@ export default function({ getService }: FtrProviderContext) {
let savedObjectOriginalAttributes: {
publicProperty: string;
publicPropertyExcludedFromAAD: string;
publicPropertyStoredEncrypted: string;
privateProperty: string;
publicPropertyExcludedFromAAD: string;
};
let savedObject: SavedObject;
beforeEach(async () => {
savedObjectOriginalAttributes = {
publicProperty: randomness.string(),
publicPropertyExcludedFromAAD: randomness.string(),
publicPropertyStoredEncrypted: randomness.string(),
privateProperty: randomness.string(),
publicPropertyExcludedFromAAD: randomness.string(),
};
const { body } = await supertest
.post(`${getURLAPIBaseURL()}${SAVED_OBJECT_WITH_SECRET_TYPE}`)
.post(`${getURLAPIBaseURL()}${encryptedSavedObjectType}`)
.set('kbn-xsrf', 'xxx')
.send({ attributes: savedObjectOriginalAttributes })
.expect(200);
@ -54,14 +63,19 @@ export default function({ getService }: FtrProviderContext) {
expect(savedObject.attributes).to.eql({
publicProperty: savedObjectOriginalAttributes.publicProperty,
publicPropertyExcludedFromAAD: savedObjectOriginalAttributes.publicPropertyExcludedFromAAD,
publicPropertyStoredEncrypted: savedObjectOriginalAttributes.publicPropertyStoredEncrypted,
});
const rawAttributes = await getRawSavedObjectAttributes(savedObject.id);
const rawAttributes = await getRawSavedObjectAttributes(savedObject);
expect(rawAttributes.publicProperty).to.be(savedObjectOriginalAttributes.publicProperty);
expect(rawAttributes.publicPropertyExcludedFromAAD).to.be(
savedObjectOriginalAttributes.publicPropertyExcludedFromAAD
);
expect(rawAttributes.publicPropertyStoredEncrypted).to.not.be.empty();
expect(rawAttributes.publicPropertyStoredEncrypted).to.not.be(
savedObjectOriginalAttributes.publicPropertyStoredEncrypted
);
expect(rawAttributes.privateProperty).to.not.be.empty();
expect(rawAttributes.privateProperty).to.not.be(
savedObjectOriginalAttributes.privateProperty
@ -71,18 +85,20 @@ export default function({ getService }: FtrProviderContext) {
it('#bulkCreate encrypts attributes and strips them from response', async () => {
const bulkCreateParams = [
{
type: SAVED_OBJECT_WITH_SECRET_TYPE,
type: encryptedSavedObjectType,
attributes: {
publicProperty: randomness.string(),
publicPropertyExcludedFromAAD: randomness.string(),
publicPropertyStoredEncrypted: randomness.string(),
privateProperty: randomness.string(),
},
},
{
type: SAVED_OBJECT_WITH_SECRET_TYPE,
type: encryptedSavedObjectType,
attributes: {
publicProperty: randomness.string(),
publicPropertyExcludedFromAAD: randomness.string(),
publicPropertyStoredEncrypted: randomness.string(),
privateProperty: randomness.string(),
},
},
@ -100,30 +116,120 @@ export default function({ getService }: FtrProviderContext) {
for (let index = 0; index < savedObjects.length; index++) {
const attributesFromResponse = savedObjects[index].attributes;
const attributesFromRequest = bulkCreateParams[index].attributes;
const rawAttributes = await getRawSavedObjectAttributes(savedObjects[index].id);
const rawAttributes = await getRawSavedObjectAttributes(savedObjects[index]);
expect(attributesFromResponse).to.eql({
publicProperty: attributesFromRequest.publicProperty,
publicPropertyExcludedFromAAD: attributesFromRequest.publicPropertyExcludedFromAAD,
publicPropertyStoredEncrypted: attributesFromRequest.publicPropertyStoredEncrypted,
});
expect(rawAttributes.publicProperty).to.be(attributesFromRequest.publicProperty);
expect(rawAttributes.publicPropertyExcludedFromAAD).to.be(
attributesFromRequest.publicPropertyExcludedFromAAD
);
expect(rawAttributes.publicPropertyStoredEncrypted).to.not.be.empty();
expect(rawAttributes.publicPropertyStoredEncrypted).to.not.be(
attributesFromRequest.publicPropertyStoredEncrypted
);
expect(rawAttributes.privateProperty).to.not.be.empty();
expect(rawAttributes.privateProperty).to.not.be(attributesFromRequest.privateProperty);
}
});
it('#bulkCreate with different types encrypts attributes and strips them from response when necessary', async () => {
const bulkCreateParams = [
{
type: encryptedSavedObjectType,
attributes: {
publicProperty: randomness.string(),
publicPropertyExcludedFromAAD: randomness.string(),
publicPropertyStoredEncrypted: randomness.string(),
privateProperty: randomness.string(),
},
},
{
type: SAVED_OBJECT_WITHOUT_SECRET_TYPE,
attributes: {
publicProperty: randomness.string(),
},
},
];
const {
body: { saved_objects: savedObjects },
} = await supertest
.post(`${getURLAPIBaseURL()}_bulk_create`)
.set('kbn-xsrf', 'xxx')
.send(bulkCreateParams)
.expect(200);
expect(savedObjects).to.have.length(bulkCreateParams.length);
for (let index = 0; index < savedObjects.length; index++) {
const attributesFromResponse = savedObjects[index].attributes;
const attributesFromRequest = bulkCreateParams[index].attributes;
const type = savedObjects[index].type;
expect(type).to.be.eql(bulkCreateParams[index].type);
const rawAttributes = await getRawSavedObjectAttributes(savedObjects[index]);
if (type === SAVED_OBJECT_WITHOUT_SECRET_TYPE) {
expect(attributesFromResponse).to.eql(attributesFromRequest);
expect(attributesFromRequest).to.eql(rawAttributes);
} else {
expect(attributesFromResponse).to.eql({
publicProperty: attributesFromRequest.publicProperty,
publicPropertyExcludedFromAAD: attributesFromRequest.publicPropertyExcludedFromAAD,
publicPropertyStoredEncrypted: attributesFromRequest.publicPropertyStoredEncrypted,
});
expect(rawAttributes.publicProperty).to.be(attributesFromRequest.publicProperty);
expect(rawAttributes.publicPropertyExcludedFromAAD).to.be(
attributesFromRequest.publicPropertyExcludedFromAAD
);
expect(rawAttributes.publicPropertyStoredEncrypted).to.not.be.empty();
expect(rawAttributes.publicPropertyStoredEncrypted).to.not.be(
attributesFromRequest.publicPropertyStoredEncrypted
);
expect(rawAttributes.privateProperty).to.not.be.empty();
expect(rawAttributes.privateProperty).to.not.be(attributesFromRequest.privateProperty);
}
}
});
it('#get strips encrypted attributes from response', async () => {
const { body: response } = await supertest
.get(`${getURLAPIBaseURL()}${SAVED_OBJECT_WITH_SECRET_TYPE}/${savedObject.id}`)
.get(`${getURLAPIBaseURL()}${encryptedSavedObjectType}/${savedObject.id}`)
.expect(200);
expect(response.attributes).to.eql({
publicProperty: savedObjectOriginalAttributes.publicProperty,
publicPropertyExcludedFromAAD: savedObjectOriginalAttributes.publicPropertyExcludedFromAAD,
publicPropertyStoredEncrypted: savedObjectOriginalAttributes.publicPropertyStoredEncrypted,
});
expect(response.error).to.be(undefined);
});
it('#get strips all encrypted attributes from response if decryption fails', async () => {
// Update non-encrypted property that is included into AAD to make it impossible to decrypt
// encrypted attributes.
const updatedPublicProperty = randomness.string();
await supertest
.put(`${getURLAPIBaseURL()}${encryptedSavedObjectType}/${savedObject.id}`)
.set('kbn-xsrf', 'xxx')
.send({ attributes: { publicProperty: updatedPublicProperty } })
.expect(200);
const { body: response } = await supertest
.get(`${getURLAPIBaseURL()}${encryptedSavedObjectType}/${savedObject.id}`)
.expect(200);
expect(response.attributes).to.eql({
publicProperty: updatedPublicProperty,
publicPropertyExcludedFromAAD: savedObjectOriginalAttributes.publicPropertyExcludedFromAAD,
});
expect(response.error).to.eql({
message: 'Unable to decrypt attribute "publicPropertyStoredEncrypted"',
});
});
@ -131,7 +237,7 @@ export default function({ getService }: FtrProviderContext) {
const {
body: { saved_objects: savedObjects },
} = await supertest
.get(`${getURLAPIBaseURL()}_find?type=${SAVED_OBJECT_WITH_SECRET_TYPE}`)
.get(`${getURLAPIBaseURL()}_find?type=${encryptedSavedObjectType}`)
.expect(200);
expect(savedObjects).to.have.length(1);
@ -139,6 +245,35 @@ export default function({ getService }: FtrProviderContext) {
expect(savedObjects[0].attributes).to.eql({
publicProperty: savedObjectOriginalAttributes.publicProperty,
publicPropertyExcludedFromAAD: savedObjectOriginalAttributes.publicPropertyExcludedFromAAD,
publicPropertyStoredEncrypted: savedObjectOriginalAttributes.publicPropertyStoredEncrypted,
});
expect(savedObjects[0].error).to.be(undefined);
});
it('#find strips all encrypted attributes from response if decryption fails', async () => {
// Update non-encrypted property that is included into AAD to make it impossible to decrypt
// encrypted attributes.
const updatedPublicProperty = randomness.string();
await supertest
.put(`${getURLAPIBaseURL()}${encryptedSavedObjectType}/${savedObject.id}`)
.set('kbn-xsrf', 'xxx')
.send({ attributes: { publicProperty: updatedPublicProperty } })
.expect(200);
const {
body: { saved_objects: savedObjects },
} = await supertest
.get(`${getURLAPIBaseURL()}_find?type=${encryptedSavedObjectType}`)
.expect(200);
expect(savedObjects).to.have.length(1);
expect(savedObjects[0].id).to.be(savedObject.id);
expect(savedObjects[0].attributes).to.eql({
publicProperty: updatedPublicProperty,
publicPropertyExcludedFromAAD: savedObjectOriginalAttributes.publicPropertyExcludedFromAAD,
});
expect(savedObjects[0].error).to.eql({
message: 'Unable to decrypt attribute "publicPropertyStoredEncrypted"',
});
});
@ -156,6 +291,37 @@ export default function({ getService }: FtrProviderContext) {
expect(savedObjects[0].attributes).to.eql({
publicProperty: savedObjectOriginalAttributes.publicProperty,
publicPropertyExcludedFromAAD: savedObjectOriginalAttributes.publicPropertyExcludedFromAAD,
publicPropertyStoredEncrypted: savedObjectOriginalAttributes.publicPropertyStoredEncrypted,
});
expect(savedObjects[0].error).to.be(undefined);
});
it('#bulkGet strips all encrypted attributes from response if decryption fails', async () => {
// Update non-encrypted property that is included into AAD to make it impossible to decrypt
// encrypted attributes.
const updatedPublicProperty = randomness.string();
await supertest
.put(`${getURLAPIBaseURL()}${encryptedSavedObjectType}/${savedObject.id}`)
.set('kbn-xsrf', 'xxx')
.send({ attributes: { publicProperty: updatedPublicProperty } })
.expect(200);
const {
body: { saved_objects: savedObjects },
} = await supertest
.post(`${getURLAPIBaseURL()}_bulk_get`)
.set('kbn-xsrf', 'xxx')
.send([{ type: savedObject.type, id: savedObject.id }])
.expect(200);
expect(savedObjects).to.have.length(1);
expect(savedObjects[0].id).to.be(savedObject.id);
expect(savedObjects[0].attributes).to.eql({
publicProperty: updatedPublicProperty,
publicPropertyExcludedFromAAD: savedObjectOriginalAttributes.publicPropertyExcludedFromAAD,
});
expect(savedObjects[0].error).to.eql({
message: 'Unable to decrypt attribute "publicPropertyStoredEncrypted"',
});
});
@ -163,11 +329,12 @@ export default function({ getService }: FtrProviderContext) {
const updatedAttributes = {
publicProperty: randomness.string(),
publicPropertyExcludedFromAAD: randomness.string(),
publicPropertyStoredEncrypted: randomness.string(),
privateProperty: randomness.string(),
};
const { body: response } = await supertest
.put(`${getURLAPIBaseURL()}${SAVED_OBJECT_WITH_SECRET_TYPE}/${savedObject.id}`)
.put(`${getURLAPIBaseURL()}${encryptedSavedObjectType}/${savedObject.id}`)
.set('kbn-xsrf', 'xxx')
.send({ attributes: updatedAttributes })
.expect(200);
@ -175,13 +342,18 @@ export default function({ getService }: FtrProviderContext) {
expect(response.attributes).to.eql({
publicProperty: updatedAttributes.publicProperty,
publicPropertyExcludedFromAAD: updatedAttributes.publicPropertyExcludedFromAAD,
publicPropertyStoredEncrypted: updatedAttributes.publicPropertyStoredEncrypted,
});
const rawAttributes = await getRawSavedObjectAttributes(savedObject.id);
const rawAttributes = await getRawSavedObjectAttributes(savedObject);
expect(rawAttributes.publicProperty).to.be(updatedAttributes.publicProperty);
expect(rawAttributes.publicPropertyExcludedFromAAD).to.be(
updatedAttributes.publicPropertyExcludedFromAAD
);
expect(rawAttributes.publicPropertyStoredEncrypted).to.not.be.empty();
expect(rawAttributes.publicPropertyStoredEncrypted).to.not.be(
updatedAttributes.publicPropertyStoredEncrypted
);
expect(rawAttributes.privateProperty).to.not.be.empty();
expect(rawAttributes.privateProperty).to.not.be(updatedAttributes.privateProperty);
@ -189,7 +361,11 @@ export default function({ getService }: FtrProviderContext) {
it('#getDecryptedAsInternalUser decrypts and returns all attributes', async () => {
const { body: decryptedResponse } = await supertest
.get(`${getURLAPIBaseURL()}get-decrypted-as-internal-user/${savedObject.id}`)
.get(
`${getURLAPIBaseURL()}get-decrypted-as-internal-user/${encryptedSavedObjectType}/${
savedObject.id
}`
)
.expect(200);
expect(decryptedResponse.attributes).to.eql(savedObjectOriginalAttributes);
@ -199,7 +375,7 @@ export default function({ getService }: FtrProviderContext) {
const updatedAttributes = { publicPropertyExcludedFromAAD: randomness.string() };
const { body: response } = await supertest
.put(`${getURLAPIBaseURL()}${SAVED_OBJECT_WITH_SECRET_TYPE}/${savedObject.id}`)
.put(`${getURLAPIBaseURL()}${encryptedSavedObjectType}/${savedObject.id}`)
.set('kbn-xsrf', 'xxx')
.send({ attributes: updatedAttributes })
.expect(200);
@ -209,7 +385,11 @@ export default function({ getService }: FtrProviderContext) {
});
const { body: decryptedResponse } = await supertest
.get(`${getURLAPIBaseURL()}get-decrypted-as-internal-user/${savedObject.id}`)
.get(
`${getURLAPIBaseURL()}get-decrypted-as-internal-user/${encryptedSavedObjectType}/${
savedObject.id
}`
)
.expect(200);
expect(decryptedResponse.attributes).to.eql({
@ -222,7 +402,7 @@ export default function({ getService }: FtrProviderContext) {
const updatedAttributes = { publicProperty: randomness.string() };
const { body: response } = await supertest
.put(`${getURLAPIBaseURL()}${SAVED_OBJECT_WITH_SECRET_TYPE}/${savedObject.id}`)
.put(`${getURLAPIBaseURL()}${encryptedSavedObjectType}/${savedObject.id}`)
.set('kbn-xsrf', 'xxx')
.send({ attributes: updatedAttributes })
.expect(200);
@ -233,7 +413,11 @@ export default function({ getService }: FtrProviderContext) {
// Bad request means that we successfully detected "EncryptionError" (not unexpected one).
await supertest
.get(`${getURLAPIBaseURL()}get-decrypted-as-internal-user/${savedObject.id}`)
.get(
`${getURLAPIBaseURL()}get-decrypted-as-internal-user/${encryptedSavedObjectType}/${
savedObject.id
}`
)
.expect(400, {
statusCode: 400,
error: 'Bad Request',
@ -243,19 +427,38 @@ export default function({ getService }: FtrProviderContext) {
}
describe('encrypted saved objects API', () => {
function generateRawId(id: string, type: string, spaceId?: string) {
return `${
spaceId && type !== SAVED_OBJECT_WITH_SECRET_AND_MULTIPLE_SPACES_TYPE
? `${spaceId}:${type}`
: type
}:${id}`;
}
afterEach(async () => {
await es.deleteByQuery({
index: '.kibana',
q: `type:${SAVED_OBJECT_WITH_SECRET_TYPE}`,
q: `type:${SAVED_OBJECT_WITH_SECRET_TYPE} OR type:${SAVED_OBJECT_WITH_SECRET_AND_MULTIPLE_SPACES_TYPE} OR type:${SAVED_OBJECT_WITHOUT_SECRET_TYPE}`,
refresh: true,
});
});
describe('within a default space', () => {
runTests(
() => '/api/saved_objects/',
id => `${SAVED_OBJECT_WITH_SECRET_TYPE}:${id}`
);
describe('with `single` namespace saved object', () => {
runTests(
SAVED_OBJECT_WITH_SECRET_TYPE,
() => '/api/saved_objects/',
(id, type) => generateRawId(id, type)
);
});
describe('with `multiple` namespace saved object', () => {
runTests(
SAVED_OBJECT_WITH_SECRET_AND_MULTIPLE_SPACES_TYPE,
() => '/api/saved_objects/',
(id, type) => generateRawId(id, type)
);
});
});
describe('within a custom space', () => {
@ -276,10 +479,21 @@ export default function({ getService }: FtrProviderContext) {
.expect(204);
});
runTests(
() => `/s/${SPACE_ID}/api/saved_objects/`,
id => `${SPACE_ID}:${SAVED_OBJECT_WITH_SECRET_TYPE}:${id}`
);
describe('with `single` namespace saved object', () => {
runTests(
SAVED_OBJECT_WITH_SECRET_TYPE,
() => `/s/${SPACE_ID}/api/saved_objects/`,
(id, type) => generateRawId(id, type, SPACE_ID)
);
});
describe('with `multiple` namespace saved object', () => {
runTests(
SAVED_OBJECT_WITH_SECRET_AND_MULTIPLE_SPACES_TYPE,
() => `/s/${SPACE_ID}/api/saved_objects/`,
(id, type) => generateRawId(id, type, SPACE_ID)
);
});
});
});
}