mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
[7.x] Introduce Encrypted Saved Objects
plugin (#36045)
This commit is contained in:
parent
4466dab205
commit
4c26ccca65
24 changed files with 2910 additions and 7 deletions
|
@ -38,6 +38,7 @@ import { translations } from './plugins/translations';
|
|||
import { upgradeAssistant } from './plugins/upgrade_assistant';
|
||||
import { uptime } from './plugins/uptime';
|
||||
import { ossTelemetry } from './plugins/oss_telemetry';
|
||||
import { encryptedSavedObjects } from './plugins/encrypted_saved_objects';
|
||||
|
||||
module.exports = function (kibana) {
|
||||
return [
|
||||
|
@ -75,5 +76,6 @@ module.exports = function (kibana) {
|
|||
upgradeAssistant(kibana),
|
||||
uptime(kibana),
|
||||
ossTelemetry(kibana),
|
||||
encryptedSavedObjects(kibana),
|
||||
];
|
||||
};
|
||||
|
|
|
@ -168,7 +168,7 @@
|
|||
"@elastic/eui": "10.3.1",
|
||||
"@elastic/javascript-typescript-langserver": "^0.1.23",
|
||||
"@elastic/lsp-extension": "^0.1.1",
|
||||
"@elastic/node-crypto": "0.1.2",
|
||||
"@elastic/node-crypto": "^1.0.0",
|
||||
"@elastic/nodegit": "0.25.0-alpha.19",
|
||||
"@elastic/numeral": "2.3.3",
|
||||
"@kbn/babel-preset": "1.0.0",
|
||||
|
|
100
x-pack/plugins/encrypted_saved_objects/README.md
Normal file
100
x-pack/plugins/encrypted_saved_objects/README.md
Normal file
|
@ -0,0 +1,100 @@
|
|||
# Encrypted Saved Objects
|
||||
|
||||
## Overview
|
||||
|
||||
The purpose of this plugin is to provide a way to encrypt/decrypt attributes on the custom Saved Objects that works with
|
||||
security and spaces filtering as well as performing audit logging.
|
||||
|
||||
[RFC #2: Encrypted Saved Objects Attributes](../../../rfcs/text/0002_encrypted_attributes.md).
|
||||
|
||||
## Usage
|
||||
|
||||
Follow these steps to use `encrypted_saved_objects` in your plugin:
|
||||
|
||||
1. Declare `encrypted_saved_objects` as a dependency:
|
||||
|
||||
```typescript
|
||||
...
|
||||
new kibana.Plugin({
|
||||
...
|
||||
require: ['encrypted_saved_objects'],
|
||||
...
|
||||
});
|
||||
```
|
||||
|
||||
2. Add attributes to be encrypted in `mappings.json` file for the respective Saved Object type. These attributes should
|
||||
always have a `binary` type since they'll contain encrypted content as a `Base64` encoded string and should never be
|
||||
searchable or analyzed:
|
||||
|
||||
```json
|
||||
{
|
||||
"my-saved-object-type": {
|
||||
"properties": {
|
||||
"name": { "type": "keyword" },
|
||||
"mySecret": { "type": "binary" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. Register Saved Object type using the provided API:
|
||||
|
||||
```typescript
|
||||
server.plugins.encrypted_saved_objects.registerType({
|
||||
type: 'my-saved-object-type',
|
||||
attributesToEncrypt: new Set(['mySecret']),
|
||||
});
|
||||
```
|
||||
|
||||
4. For any Saved Object operation that does not require retrieval of decrypted content, use standard REST or
|
||||
programmatic Saved Object API, e.g.:
|
||||
|
||||
```typescript
|
||||
...
|
||||
async handler(request: Request) {
|
||||
return await server.savedObjects
|
||||
.getScopedSavedObjectsClient(request)
|
||||
.create('my-saved-object-type', { name: 'some name', mySecret: 'non encrypted secret' });
|
||||
}
|
||||
...
|
||||
```
|
||||
|
||||
5. To retrieve Saved Object with decrypted content use the dedicated `getDecryptedAsInternalUser` API method.
|
||||
|
||||
**Note:** As name suggests the method will retrieve the encrypted values and decrypt them on behalf of the internal Kibana
|
||||
user to make it possible to use this method even when user request context is not available (e.g. in background tasks).
|
||||
Hence this method should only be used wherever consumers would otherwise feel comfortable using `callWithInternalUser`
|
||||
and preferably only as a part of the Kibana server routines that are outside of the lifecycle of a HTTP request that a
|
||||
user has control over.
|
||||
|
||||
```typescript
|
||||
const savedObjectWithDecryptedContent = await server.plugins.encrypted_saved_objects.getDecryptedAsInternalUser(
|
||||
'my-saved-object-type',
|
||||
'saved-object-id'
|
||||
);
|
||||
```
|
||||
|
||||
`getDecryptedAsInternalUser` also accepts the 3rd optional `options` argument that has exactly the same type as `options`
|
||||
one would pass to `SavedObjectsClient.get`. These argument allows to specify `namespace` property that, for example, is
|
||||
required if Saved Object was created within a non-default space.
|
||||
|
||||
## Testing
|
||||
|
||||
### Unit tests
|
||||
|
||||
From `kibana-root-folder/x-pack`, run:
|
||||
```bash
|
||||
$ node scripts/jest.js
|
||||
```
|
||||
|
||||
### API Integration tests
|
||||
|
||||
In one shell, from `kibana-root-folder/x-pack`:
|
||||
```bash
|
||||
$ node scripts/functional_tests_server.js --config test/plugin_api_integration/config.js
|
||||
```
|
||||
|
||||
In another shell, from `kibana-root-folder/x-pack`:
|
||||
```bash
|
||||
$ node ../scripts/functional_test_runner.js --config test/plugin_api_integration/config.js --grep="{TEST_NAME}"
|
||||
```
|
55
x-pack/plugins/encrypted_saved_objects/index.ts
Normal file
55
x-pack/plugins/encrypted_saved_objects/index.ts
Normal file
|
@ -0,0 +1,55 @@
|
|||
/*
|
||||
* 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 { Root } from 'joi';
|
||||
import { Legacy, Server } from 'kibana';
|
||||
|
||||
// @ts-ignore
|
||||
import { AuditLogger } from '../../server/lib/audit_logger';
|
||||
|
||||
import { CONFIG_PREFIX, PLUGIN_ID, Plugin } from './server/plugin';
|
||||
|
||||
export const encryptedSavedObjects = (kibana: any) =>
|
||||
new kibana.Plugin({
|
||||
id: PLUGIN_ID,
|
||||
configPrefix: CONFIG_PREFIX,
|
||||
require: ['kibana', 'elasticsearch', 'xpack_main'],
|
||||
|
||||
config(Joi: Root) {
|
||||
return Joi.object({
|
||||
enabled: Joi.boolean().default(true),
|
||||
encryptionKey: Joi.string().min(32),
|
||||
}).default();
|
||||
},
|
||||
|
||||
async init(server: Legacy.Server) {
|
||||
const loggerFacade = {
|
||||
fatal: (errorOrMessage: string | Error) => server.log(['fatal', PLUGIN_ID], errorOrMessage),
|
||||
trace: (message: string) => server.log(['debug', PLUGIN_ID], message),
|
||||
error: (message: string) => server.log(['error', PLUGIN_ID], message),
|
||||
warn: (message: string) => server.log(['warning', PLUGIN_ID], message),
|
||||
debug: (message: string) => server.log(['debug', PLUGIN_ID], message),
|
||||
info: (message: string) => server.log(['info', PLUGIN_ID], message),
|
||||
} as Server.Logger;
|
||||
|
||||
const config = server.config();
|
||||
const encryptedSavedObjectsSetup = new Plugin(loggerFacade).setup(
|
||||
{
|
||||
config: {
|
||||
encryptionKey: config.get<string | undefined>(`${CONFIG_PREFIX}.encryptionKey`),
|
||||
},
|
||||
savedObjects: server.savedObjects,
|
||||
elasticsearch: server.plugins.elasticsearch,
|
||||
},
|
||||
{ audit: new AuditLogger(server, PLUGIN_ID, config, server.plugins.xpack_main.info) }
|
||||
);
|
||||
|
||||
// Re-expose plugin setup contract through legacy mechanism.
|
||||
for (const [setupMethodName, setupMethod] of Object.entries(encryptedSavedObjectsSetup)) {
|
||||
server.expose(setupMethodName, setupMethod);
|
||||
}
|
||||
},
|
||||
});
|
|
@ -0,0 +1,107 @@
|
|||
/*
|
||||
* 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 { EncryptedSavedObjectsAuditLogger } from './encrypted_saved_objects_audit_logger';
|
||||
|
||||
test('properly logs audit events', () => {
|
||||
const mockInternalAuditLogger = { log: jest.fn() };
|
||||
const audit = new EncryptedSavedObjectsAuditLogger(mockInternalAuditLogger);
|
||||
|
||||
audit.encryptAttributesSuccess(['one', 'two'], {
|
||||
type: 'known-type',
|
||||
id: 'object-id',
|
||||
});
|
||||
audit.encryptAttributesSuccess(['one', 'two'], {
|
||||
type: 'known-type-ns',
|
||||
id: 'object-id-ns',
|
||||
namespace: 'object-ns',
|
||||
});
|
||||
|
||||
audit.decryptAttributesSuccess(['three', 'four'], {
|
||||
type: 'known-type-1',
|
||||
id: 'object-id-1',
|
||||
});
|
||||
audit.decryptAttributesSuccess(['three', 'four'], {
|
||||
type: 'known-type-1-ns',
|
||||
id: 'object-id-1-ns',
|
||||
namespace: 'object-ns',
|
||||
});
|
||||
|
||||
audit.encryptAttributeFailure('five', {
|
||||
type: 'known-type-2',
|
||||
id: 'object-id-2',
|
||||
});
|
||||
audit.encryptAttributeFailure('five', {
|
||||
type: 'known-type-2-ns',
|
||||
id: 'object-id-2-ns',
|
||||
namespace: 'object-ns',
|
||||
});
|
||||
|
||||
audit.decryptAttributeFailure('six', {
|
||||
type: 'known-type-3',
|
||||
id: 'object-id-3',
|
||||
});
|
||||
audit.decryptAttributeFailure('six', {
|
||||
type: 'known-type-3-ns',
|
||||
id: 'object-id-3-ns',
|
||||
namespace: 'object-ns',
|
||||
});
|
||||
|
||||
expect(mockInternalAuditLogger.log).toHaveBeenCalledTimes(8);
|
||||
expect(mockInternalAuditLogger.log).toHaveBeenCalledWith(
|
||||
'encrypt_success',
|
||||
'Successfully encrypted attributes "[one,two]" for saved object "[known-type,object-id]".',
|
||||
{ id: 'object-id', type: 'known-type', 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'],
|
||||
}
|
||||
);
|
||||
|
||||
expect(mockInternalAuditLogger.log).toHaveBeenCalledWith(
|
||||
'decrypt_success',
|
||||
'Successfully decrypted attributes "[three,four]" for saved object "[known-type-1,object-id-1]".',
|
||||
{ id: 'object-id-1', type: 'known-type-1', 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'],
|
||||
}
|
||||
);
|
||||
|
||||
expect(mockInternalAuditLogger.log).toHaveBeenCalledWith(
|
||||
'encrypt_failure',
|
||||
'Failed to encrypt attribute "five" for saved object "[known-type-2,object-id-2]".',
|
||||
{ id: 'object-id-2', type: 'known-type-2', 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' }
|
||||
);
|
||||
|
||||
expect(mockInternalAuditLogger.log).toHaveBeenCalledWith(
|
||||
'decrypt_failure',
|
||||
'Failed to decrypt attribute "six" for saved object "[known-type-3,object-id-3]".',
|
||||
{ id: 'object-id-3', type: 'known-type-3', 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' }
|
||||
);
|
||||
});
|
|
@ -0,0 +1,60 @@
|
|||
/*
|
||||
* 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 { SavedObjectDescriptor, descriptorToArray } from './encrypted_saved_objects_service';
|
||||
|
||||
/**
|
||||
* Represents all audit events the plugin can log.
|
||||
*/
|
||||
export class EncryptedSavedObjectsAuditLogger {
|
||||
constructor(private readonly auditLogger: any) {}
|
||||
|
||||
public encryptAttributeFailure(attributeName: string, descriptor: SavedObjectDescriptor) {
|
||||
this.auditLogger.log(
|
||||
'encrypt_failure',
|
||||
`Failed to encrypt attribute "${attributeName}" for saved object "[${descriptorToArray(
|
||||
descriptor
|
||||
)}]".`,
|
||||
{ ...descriptor, attributeName }
|
||||
);
|
||||
}
|
||||
|
||||
public decryptAttributeFailure(attributeName: string, descriptor: SavedObjectDescriptor) {
|
||||
this.auditLogger.log(
|
||||
'decrypt_failure',
|
||||
`Failed to decrypt attribute "${attributeName}" for saved object "[${descriptorToArray(
|
||||
descriptor
|
||||
)}]".`,
|
||||
{ ...descriptor, attributeName }
|
||||
);
|
||||
}
|
||||
|
||||
public encryptAttributesSuccess(
|
||||
attributesNames: ReadonlyArray<string>,
|
||||
descriptor: SavedObjectDescriptor
|
||||
) {
|
||||
this.auditLogger.log(
|
||||
'encrypt_success',
|
||||
`Successfully encrypted attributes "[${attributesNames}]" for saved object "[${descriptorToArray(
|
||||
descriptor
|
||||
)}]".`,
|
||||
{ ...descriptor, attributesNames }
|
||||
);
|
||||
}
|
||||
|
||||
public decryptAttributesSuccess(
|
||||
attributesNames: ReadonlyArray<string>,
|
||||
descriptor: SavedObjectDescriptor
|
||||
) {
|
||||
this.auditLogger.log(
|
||||
'decrypt_success',
|
||||
`Successfully decrypted attributes "[${attributesNames}]" for saved object "[${descriptorToArray(
|
||||
descriptor
|
||||
)}]".`,
|
||||
{ ...descriptor, attributesNames }
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,711 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
jest.mock('uuid', () => ({ v4: jest.fn().mockReturnValue('uuid-v4-id') }));
|
||||
|
||||
import { EncryptedSavedObjectsClientWrapper } from './encrypted_saved_objects_client_wrapper';
|
||||
import { EncryptedSavedObjectsService } from './encrypted_saved_objects_service';
|
||||
import { createEncryptedSavedObjectsServiceMock } from './encrypted_saved_objects_service.mock';
|
||||
import { SavedObjectsClient } from 'src/legacy/server/saved_objects/service/saved_objects_client';
|
||||
|
||||
function createSavedObjectsClientMock(): jest.Mocked<SavedObjectsClient> {
|
||||
return {
|
||||
errors: {} as any,
|
||||
bulkCreate: jest.fn(),
|
||||
bulkGet: jest.fn(),
|
||||
create: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
find: jest.fn(),
|
||||
get: jest.fn(),
|
||||
update: jest.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
let wrapper: EncryptedSavedObjectsClientWrapper;
|
||||
let mockBaseClient: jest.Mocked<SavedObjectsClient>;
|
||||
let encryptedSavedObjectsServiceMock: jest.Mocked<EncryptedSavedObjectsService>;
|
||||
beforeEach(() => {
|
||||
mockBaseClient = createSavedObjectsClientMock();
|
||||
encryptedSavedObjectsServiceMock = createEncryptedSavedObjectsServiceMock([
|
||||
{
|
||||
type: 'known-type',
|
||||
attributesToEncrypt: new Set(['attrSecret']),
|
||||
},
|
||||
]);
|
||||
|
||||
wrapper = new EncryptedSavedObjectsClientWrapper({
|
||||
service: encryptedSavedObjectsServiceMock,
|
||||
baseClient: mockBaseClient,
|
||||
} as any);
|
||||
});
|
||||
|
||||
afterEach(() => jest.clearAllMocks());
|
||||
|
||||
describe('#create', () => {
|
||||
it('redirects request to underlying base client if type is not registered', async () => {
|
||||
const attributes = { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' };
|
||||
const options = { id: 'some-non-uuid-v4-id' };
|
||||
const mockedResponse = { id: options.id, type: 'unknown-type', attributes, references: [] };
|
||||
|
||||
mockBaseClient.create.mockResolvedValue(mockedResponse);
|
||||
|
||||
await expect(wrapper.create('unknown-type', attributes, options)).resolves.toEqual({
|
||||
...mockedResponse,
|
||||
id: options.id,
|
||||
attributes: { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' },
|
||||
});
|
||||
expect(mockBaseClient.create).toHaveBeenCalledTimes(1);
|
||||
expect(mockBaseClient.create).toHaveBeenCalledWith('unknown-type', attributes, options);
|
||||
});
|
||||
|
||||
it('fails if type is registered and ID is specified', async () => {
|
||||
const attributes = { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' };
|
||||
|
||||
await expect(wrapper.create('known-type', attributes, { id: 'some-id' })).rejects.toThrowError(
|
||||
'Predefined IDs are not allowed for saved objects with encrypted attributes.'
|
||||
);
|
||||
|
||||
expect(mockBaseClient.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('generates ID, encrypts attributes and strips them from response', async () => {
|
||||
const attributes = { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' };
|
||||
const options = { overwrite: true };
|
||||
const mockedResponse = {
|
||||
id: 'uuid-v4-id',
|
||||
type: 'known-type',
|
||||
attributes: { attrOne: 'one', attrSecret: '*secret*', attrThree: 'three' },
|
||||
references: [],
|
||||
};
|
||||
|
||||
mockBaseClient.create.mockResolvedValue(mockedResponse);
|
||||
|
||||
expect(await wrapper.create('known-type', attributes, options)).toEqual({
|
||||
...mockedResponse,
|
||||
attributes: { attrOne: 'one', attrThree: 'three' },
|
||||
});
|
||||
|
||||
expect(encryptedSavedObjectsServiceMock.encryptAttributes).toHaveBeenCalledTimes(1);
|
||||
expect(encryptedSavedObjectsServiceMock.encryptAttributes).toHaveBeenCalledWith(
|
||||
{ type: 'known-type', id: 'uuid-v4-id' },
|
||||
{ attrOne: 'one', attrSecret: 'secret', attrThree: 'three' }
|
||||
);
|
||||
|
||||
expect(mockBaseClient.create).toHaveBeenCalledTimes(1);
|
||||
expect(mockBaseClient.create).toHaveBeenCalledWith(
|
||||
'known-type',
|
||||
{ attrOne: 'one', attrSecret: '*secret*', attrThree: 'three' },
|
||||
{ id: 'uuid-v4-id', overwrite: true }
|
||||
);
|
||||
});
|
||||
|
||||
it('uses `namespace` to encrypt attributes if it is specified', async () => {
|
||||
const attributes = { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' };
|
||||
const options = { overwrite: true, namespace: 'some-namespace' };
|
||||
const mockedResponse = {
|
||||
id: 'uuid-v4-id',
|
||||
type: 'known-type',
|
||||
attributes: { attrOne: 'one', attrSecret: '*secret*', attrThree: 'three' },
|
||||
references: [],
|
||||
};
|
||||
|
||||
mockBaseClient.create.mockResolvedValue(mockedResponse);
|
||||
|
||||
expect(await wrapper.create('known-type', attributes, options)).toEqual({
|
||||
...mockedResponse,
|
||||
attributes: { attrOne: 'one', attrThree: 'three' },
|
||||
});
|
||||
|
||||
expect(encryptedSavedObjectsServiceMock.encryptAttributes).toHaveBeenCalledTimes(1);
|
||||
expect(encryptedSavedObjectsServiceMock.encryptAttributes).toHaveBeenCalledWith(
|
||||
{ type: 'known-type', id: 'uuid-v4-id', namespace: 'some-namespace' },
|
||||
{ attrOne: 'one', attrSecret: 'secret', attrThree: 'three' }
|
||||
);
|
||||
|
||||
expect(mockBaseClient.create).toHaveBeenCalledTimes(1);
|
||||
expect(mockBaseClient.create).toHaveBeenCalledWith(
|
||||
'known-type',
|
||||
{ attrOne: 'one', attrSecret: '*secret*', attrThree: 'three' },
|
||||
{ id: 'uuid-v4-id', overwrite: true, namespace: 'some-namespace' }
|
||||
);
|
||||
});
|
||||
|
||||
it('fails if base client fails', async () => {
|
||||
const failureReason = new Error('Something bad happened...');
|
||||
mockBaseClient.create.mockRejectedValue(failureReason);
|
||||
|
||||
await expect(
|
||||
wrapper.create('known-type', { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' })
|
||||
).rejects.toThrowError(failureReason);
|
||||
|
||||
expect(mockBaseClient.create).toHaveBeenCalledTimes(1);
|
||||
expect(mockBaseClient.create).toHaveBeenCalledWith(
|
||||
'known-type',
|
||||
{ attrOne: 'one', attrSecret: '*secret*', attrThree: 'three' },
|
||||
{ id: 'uuid-v4-id' }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#bulkCreate', () => {
|
||||
it('does not fail if ID is specified for not registered type', async () => {
|
||||
const attributes = { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' };
|
||||
const options = { namespace: 'some-namespace' };
|
||||
const mockedResponse = {
|
||||
saved_objects: [
|
||||
{
|
||||
id: 'uuid-v4-id',
|
||||
type: 'known-type',
|
||||
attributes,
|
||||
references: [],
|
||||
},
|
||||
{
|
||||
id: 'some-id',
|
||||
type: 'unknown-type',
|
||||
attributes,
|
||||
references: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
mockBaseClient.bulkCreate.mockResolvedValue(mockedResponse);
|
||||
|
||||
const bulkCreateParams = [
|
||||
{ type: 'known-type', attributes },
|
||||
{ id: 'some-id', type: 'unknown-type', attributes },
|
||||
];
|
||||
|
||||
await expect(wrapper.bulkCreate(bulkCreateParams, options)).resolves.toEqual({
|
||||
saved_objects: [
|
||||
{ ...mockedResponse.saved_objects[0], attributes: { attrOne: 'one', attrThree: 'three' } },
|
||||
mockedResponse.saved_objects[1],
|
||||
],
|
||||
});
|
||||
|
||||
expect(mockBaseClient.bulkCreate).toHaveBeenCalledTimes(1);
|
||||
expect(mockBaseClient.bulkCreate).toHaveBeenCalledWith(
|
||||
[
|
||||
{
|
||||
...bulkCreateParams[0],
|
||||
id: 'uuid-v4-id',
|
||||
attributes: { attrOne: 'one', attrSecret: '*secret*', attrThree: 'three' },
|
||||
},
|
||||
bulkCreateParams[1],
|
||||
],
|
||||
options
|
||||
);
|
||||
});
|
||||
|
||||
it('fails if ID is specified for registered type', async () => {
|
||||
const attributes = { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' };
|
||||
const options = { namespace: 'some-namespace' };
|
||||
|
||||
const bulkCreateParams = [
|
||||
{ id: 'some-id', type: 'known-type', attributes },
|
||||
{ type: 'unknown-type', attributes },
|
||||
];
|
||||
|
||||
await expect(wrapper.bulkCreate(bulkCreateParams, options)).rejects.toThrowError(
|
||||
'Predefined IDs are not allowed for saved objects with encrypted attributes.'
|
||||
);
|
||||
|
||||
expect(mockBaseClient.bulkCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('generates ID, encrypts attributes and strips them from response', async () => {
|
||||
const attributes = { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' };
|
||||
const mockedResponse = {
|
||||
saved_objects: [
|
||||
{
|
||||
id: 'uuid-v4-id',
|
||||
type: 'known-type',
|
||||
attributes,
|
||||
references: [],
|
||||
},
|
||||
{
|
||||
id: 'some-id',
|
||||
type: 'unknown-type',
|
||||
attributes,
|
||||
references: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
mockBaseClient.bulkCreate.mockResolvedValue(mockedResponse);
|
||||
|
||||
const bulkCreateParams = [
|
||||
{ type: 'known-type', attributes },
|
||||
{ type: 'unknown-type', attributes },
|
||||
];
|
||||
|
||||
await expect(wrapper.bulkCreate(bulkCreateParams)).resolves.toEqual({
|
||||
saved_objects: [
|
||||
{ ...mockedResponse.saved_objects[0], attributes: { attrOne: 'one', attrThree: 'three' } },
|
||||
mockedResponse.saved_objects[1],
|
||||
],
|
||||
});
|
||||
|
||||
expect(encryptedSavedObjectsServiceMock.encryptAttributes).toHaveBeenCalledTimes(1);
|
||||
expect(encryptedSavedObjectsServiceMock.encryptAttributes).toHaveBeenCalledWith(
|
||||
{ type: 'known-type', id: 'uuid-v4-id' },
|
||||
{ attrOne: 'one', attrSecret: 'secret', attrThree: 'three' }
|
||||
);
|
||||
|
||||
expect(mockBaseClient.bulkCreate).toHaveBeenCalledTimes(1);
|
||||
expect(mockBaseClient.bulkCreate).toHaveBeenCalledWith(
|
||||
[
|
||||
{
|
||||
...bulkCreateParams[0],
|
||||
id: 'uuid-v4-id',
|
||||
attributes: { attrOne: 'one', attrSecret: '*secret*', attrThree: 'three' },
|
||||
},
|
||||
bulkCreateParams[1],
|
||||
],
|
||||
undefined
|
||||
);
|
||||
});
|
||||
|
||||
it('uses `namespace` to encrypt attributes if it is specified', async () => {
|
||||
const attributes = { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' };
|
||||
const options = { namespace: 'some-namespace' };
|
||||
const mockedResponse = {
|
||||
saved_objects: [{ id: 'uuid-v4-id', type: 'known-type', attributes, references: [] }],
|
||||
};
|
||||
|
||||
mockBaseClient.bulkCreate.mockResolvedValue(mockedResponse);
|
||||
|
||||
const bulkCreateParams = [{ type: 'known-type', attributes }];
|
||||
await expect(wrapper.bulkCreate(bulkCreateParams, options)).resolves.toEqual({
|
||||
saved_objects: [
|
||||
{ ...mockedResponse.saved_objects[0], attributes: { attrOne: 'one', attrThree: 'three' } },
|
||||
],
|
||||
});
|
||||
|
||||
expect(encryptedSavedObjectsServiceMock.encryptAttributes).toHaveBeenCalledTimes(1);
|
||||
expect(encryptedSavedObjectsServiceMock.encryptAttributes).toHaveBeenCalledWith(
|
||||
{ type: 'known-type', id: 'uuid-v4-id', namespace: 'some-namespace' },
|
||||
{ attrOne: 'one', attrSecret: 'secret', attrThree: 'three' }
|
||||
);
|
||||
|
||||
expect(mockBaseClient.bulkCreate).toHaveBeenCalledTimes(1);
|
||||
expect(mockBaseClient.bulkCreate).toHaveBeenCalledWith(
|
||||
[
|
||||
{
|
||||
...bulkCreateParams[0],
|
||||
id: 'uuid-v4-id',
|
||||
attributes: { attrOne: 'one', attrSecret: '*secret*', attrThree: 'three' },
|
||||
},
|
||||
],
|
||||
options
|
||||
);
|
||||
});
|
||||
|
||||
it('fails if base client fails', async () => {
|
||||
const failureReason = new Error('Something bad happened...');
|
||||
mockBaseClient.bulkCreate.mockRejectedValue(failureReason);
|
||||
|
||||
await expect(
|
||||
wrapper.bulkCreate([
|
||||
{
|
||||
type: 'known-type',
|
||||
attributes: { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' },
|
||||
},
|
||||
])
|
||||
).rejects.toThrowError(failureReason);
|
||||
|
||||
expect(mockBaseClient.bulkCreate).toHaveBeenCalledTimes(1);
|
||||
expect(mockBaseClient.bulkCreate).toHaveBeenCalledWith(
|
||||
[
|
||||
{
|
||||
type: 'known-type',
|
||||
id: 'uuid-v4-id',
|
||||
attributes: { attrOne: 'one', attrSecret: '*secret*', attrThree: 'three' },
|
||||
},
|
||||
],
|
||||
undefined
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#delete', () => {
|
||||
it('redirects request to underlying base client if type is not registered', async () => {
|
||||
const options = { namespace: 'some-ns' };
|
||||
|
||||
await wrapper.delete('unknown-type', 'some-id', options);
|
||||
|
||||
expect(mockBaseClient.delete).toHaveBeenCalledTimes(1);
|
||||
expect(mockBaseClient.delete).toHaveBeenCalledWith('unknown-type', 'some-id', options);
|
||||
});
|
||||
|
||||
it('redirects request to underlying base client if type is registered', async () => {
|
||||
const options = { namespace: 'some-ns' };
|
||||
|
||||
await wrapper.delete('known-type', 'some-id', options);
|
||||
|
||||
expect(mockBaseClient.delete).toHaveBeenCalledTimes(1);
|
||||
expect(mockBaseClient.delete).toHaveBeenCalledWith('known-type', 'some-id', options);
|
||||
});
|
||||
|
||||
it('fails if base client fails', async () => {
|
||||
const failureReason = new Error('Something bad happened...');
|
||||
mockBaseClient.delete.mockRejectedValue(failureReason);
|
||||
|
||||
await expect(wrapper.delete('known-type', 'some-id')).rejects.toThrowError(failureReason);
|
||||
|
||||
expect(mockBaseClient.delete).toHaveBeenCalledTimes(1);
|
||||
expect(mockBaseClient.delete).toHaveBeenCalledWith('known-type', 'some-id', undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#find', () => {
|
||||
it('redirects request to underlying base client and does not alter response if type is not registered', async () => {
|
||||
const mockedResponse = {
|
||||
saved_objects: [
|
||||
{
|
||||
id: 'some-id',
|
||||
type: 'unknown-type',
|
||||
attributes: { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' },
|
||||
references: [],
|
||||
},
|
||||
{
|
||||
id: 'some-id-2',
|
||||
type: 'unknown-type',
|
||||
attributes: { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' },
|
||||
references: [],
|
||||
},
|
||||
],
|
||||
total: 2,
|
||||
per_page: 2,
|
||||
page: 1,
|
||||
};
|
||||
|
||||
mockBaseClient.find.mockResolvedValue(mockedResponse);
|
||||
|
||||
const options = { type: 'unknown-type', search: 'query' };
|
||||
await expect(wrapper.find(options)).resolves.toEqual({
|
||||
...mockedResponse,
|
||||
saved_objects: [
|
||||
{
|
||||
...mockedResponse.saved_objects[0],
|
||||
attributes: { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' },
|
||||
},
|
||||
{
|
||||
...mockedResponse.saved_objects[1],
|
||||
attributes: { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' },
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(mockBaseClient.find).toHaveBeenCalledTimes(1);
|
||||
expect(mockBaseClient.find).toHaveBeenCalledWith(options);
|
||||
});
|
||||
|
||||
it('redirects request to underlying base client and strips encrypted attributes if type is registered', async () => {
|
||||
const mockedResponse = {
|
||||
saved_objects: [
|
||||
{
|
||||
id: 'some-id',
|
||||
type: 'unknown-type',
|
||||
attributes: { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' },
|
||||
references: [],
|
||||
},
|
||||
{
|
||||
id: 'some-id-2',
|
||||
type: 'known-type',
|
||||
attributes: { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' },
|
||||
references: [],
|
||||
},
|
||||
],
|
||||
total: 2,
|
||||
per_page: 2,
|
||||
page: 1,
|
||||
};
|
||||
|
||||
mockBaseClient.find.mockResolvedValue(mockedResponse);
|
||||
|
||||
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', attrThree: 'three' },
|
||||
},
|
||||
{
|
||||
...mockedResponse.saved_objects[1],
|
||||
attributes: { attrOne: 'one', attrThree: 'three' },
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(mockBaseClient.find).toHaveBeenCalledTimes(1);
|
||||
expect(mockBaseClient.find).toHaveBeenCalledWith(options);
|
||||
});
|
||||
|
||||
it('fails if base client fails', async () => {
|
||||
const failureReason = new Error('Something bad happened...');
|
||||
mockBaseClient.find.mockRejectedValue(failureReason);
|
||||
|
||||
await expect(wrapper.find({ type: 'known-type' })).rejects.toThrowError(failureReason);
|
||||
|
||||
expect(mockBaseClient.find).toHaveBeenCalledTimes(1);
|
||||
expect(mockBaseClient.find).toHaveBeenCalledWith({ type: 'known-type' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('#bulkGet', () => {
|
||||
it('redirects request to underlying base client and does not alter response if type is not registered', async () => {
|
||||
const mockedResponse = {
|
||||
saved_objects: [
|
||||
{
|
||||
id: 'some-id',
|
||||
type: 'unknown-type',
|
||||
attributes: { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' },
|
||||
references: [],
|
||||
},
|
||||
{
|
||||
id: 'some-id-2',
|
||||
type: 'unknown-type',
|
||||
attributes: { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' },
|
||||
references: [],
|
||||
},
|
||||
],
|
||||
total: 2,
|
||||
per_page: 2,
|
||||
page: 1,
|
||||
};
|
||||
|
||||
mockBaseClient.bulkGet.mockResolvedValue(mockedResponse);
|
||||
|
||||
const bulkGetParams = [
|
||||
{ type: 'unknown-type', id: 'some-id' },
|
||||
{ type: 'unknown-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', attrThree: 'three' },
|
||||
},
|
||||
{
|
||||
...mockedResponse.saved_objects[1],
|
||||
attributes: { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' },
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(mockBaseClient.bulkGet).toHaveBeenCalledTimes(1);
|
||||
expect(mockBaseClient.bulkGet).toHaveBeenCalledWith(bulkGetParams, options);
|
||||
});
|
||||
|
||||
it('redirects request to underlying base client and strips encrypted attributes if type is registered', async () => {
|
||||
const mockedResponse = {
|
||||
saved_objects: [
|
||||
{
|
||||
id: 'some-id',
|
||||
type: 'unknown-type',
|
||||
attributes: { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' },
|
||||
references: [],
|
||||
},
|
||||
{
|
||||
id: 'some-id-2',
|
||||
type: 'known-type',
|
||||
attributes: { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' },
|
||||
references: [],
|
||||
},
|
||||
],
|
||||
total: 2,
|
||||
per_page: 2,
|
||||
page: 1,
|
||||
};
|
||||
|
||||
mockBaseClient.bulkGet.mockResolvedValue(mockedResponse);
|
||||
|
||||
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', attrThree: 'three' },
|
||||
},
|
||||
{
|
||||
...mockedResponse.saved_objects[1],
|
||||
attributes: { attrOne: 'one', attrThree: 'three' },
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(mockBaseClient.bulkGet).toHaveBeenCalledTimes(1);
|
||||
expect(mockBaseClient.bulkGet).toHaveBeenCalledWith(bulkGetParams, options);
|
||||
});
|
||||
|
||||
it('fails if base client fails', async () => {
|
||||
const failureReason = new Error('Something bad happened...');
|
||||
mockBaseClient.bulkGet.mockRejectedValue(failureReason);
|
||||
|
||||
await expect(wrapper.bulkGet([{ type: 'known-type', id: 'some-id' }])).rejects.toThrowError(
|
||||
failureReason
|
||||
);
|
||||
|
||||
expect(mockBaseClient.bulkGet).toHaveBeenCalledTimes(1);
|
||||
expect(mockBaseClient.bulkGet).toHaveBeenCalledWith(
|
||||
[{ type: 'known-type', id: 'some-id' }],
|
||||
undefined
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#get', () => {
|
||||
it('redirects request to underlying base client and does not alter response if type is not registered', async () => {
|
||||
const mockedResponse = {
|
||||
id: 'some-id',
|
||||
type: 'unknown-type',
|
||||
attributes: { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' },
|
||||
references: [],
|
||||
};
|
||||
|
||||
mockBaseClient.get.mockResolvedValue(mockedResponse);
|
||||
|
||||
const options = { namespace: 'some-ns' };
|
||||
await expect(wrapper.get('unknown-type', 'some-id', options)).resolves.toEqual({
|
||||
...mockedResponse,
|
||||
attributes: { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' },
|
||||
});
|
||||
expect(mockBaseClient.get).toHaveBeenCalledTimes(1);
|
||||
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 () => {
|
||||
const mockedResponse = {
|
||||
id: 'some-id',
|
||||
type: 'known-type',
|
||||
attributes: { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' },
|
||||
references: [],
|
||||
};
|
||||
|
||||
mockBaseClient.get.mockResolvedValue(mockedResponse);
|
||||
|
||||
const options = { namespace: 'some-ns' };
|
||||
await expect(wrapper.get('known-type', 'some-id', options)).resolves.toEqual({
|
||||
...mockedResponse,
|
||||
attributes: { attrOne: 'one', attrThree: 'three' },
|
||||
});
|
||||
expect(mockBaseClient.get).toHaveBeenCalledTimes(1);
|
||||
expect(mockBaseClient.get).toHaveBeenCalledWith('known-type', 'some-id', options);
|
||||
});
|
||||
|
||||
it('fails if base client fails', async () => {
|
||||
const failureReason = new Error('Something bad happened...');
|
||||
mockBaseClient.get.mockRejectedValue(failureReason);
|
||||
|
||||
await expect(wrapper.get('known-type', 'some-id')).rejects.toThrowError(failureReason);
|
||||
|
||||
expect(mockBaseClient.get).toHaveBeenCalledTimes(1);
|
||||
expect(mockBaseClient.get).toHaveBeenCalledWith('known-type', 'some-id', undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#update', () => {
|
||||
it('redirects request to underlying base client if type is not registered', async () => {
|
||||
const attributes = { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' };
|
||||
const options = { version: 'some-version' };
|
||||
const mockedResponse = { id: 'some-id', type: 'unknown-type', attributes, references: [] };
|
||||
|
||||
mockBaseClient.update.mockResolvedValue(mockedResponse);
|
||||
|
||||
await expect(wrapper.update('unknown-type', 'some-id', attributes, options)).resolves.toEqual({
|
||||
...mockedResponse,
|
||||
attributes: { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' },
|
||||
});
|
||||
expect(mockBaseClient.update).toHaveBeenCalledTimes(1);
|
||||
expect(mockBaseClient.update).toHaveBeenCalledWith(
|
||||
'unknown-type',
|
||||
'some-id',
|
||||
attributes,
|
||||
options
|
||||
);
|
||||
});
|
||||
|
||||
it('encrypts attributes and strips them from response', async () => {
|
||||
const attributes = { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' };
|
||||
const options = { version: 'some-version' };
|
||||
const mockedResponse = { id: 'some-id', type: 'known-type', attributes, references: [] };
|
||||
|
||||
mockBaseClient.update.mockResolvedValue(mockedResponse);
|
||||
|
||||
await expect(wrapper.update('known-type', 'some-id', attributes, options)).resolves.toEqual({
|
||||
...mockedResponse,
|
||||
attributes: { attrOne: 'one', attrThree: 'three' },
|
||||
});
|
||||
|
||||
expect(encryptedSavedObjectsServiceMock.encryptAttributes).toHaveBeenCalledTimes(1);
|
||||
expect(encryptedSavedObjectsServiceMock.encryptAttributes).toHaveBeenCalledWith(
|
||||
{ type: 'known-type', id: 'some-id' },
|
||||
{ attrOne: 'one', attrSecret: 'secret', attrThree: 'three' }
|
||||
);
|
||||
|
||||
expect(mockBaseClient.update).toHaveBeenCalledTimes(1);
|
||||
expect(mockBaseClient.update).toHaveBeenCalledWith(
|
||||
'known-type',
|
||||
'some-id',
|
||||
{ attrOne: 'one', attrSecret: '*secret*', attrThree: 'three' },
|
||||
options
|
||||
);
|
||||
});
|
||||
|
||||
it('uses `namespace` to encrypt attributes if it is specified', async () => {
|
||||
const attributes = { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' };
|
||||
const options = { version: 'some-version', namespace: 'some-namespace' };
|
||||
const mockedResponse = { id: 'some-id', type: 'known-type', attributes, references: [] };
|
||||
|
||||
mockBaseClient.update.mockResolvedValue(mockedResponse);
|
||||
|
||||
await expect(wrapper.update('known-type', 'some-id', attributes, options)).resolves.toEqual({
|
||||
...mockedResponse,
|
||||
attributes: { attrOne: 'one', attrThree: 'three' },
|
||||
});
|
||||
|
||||
expect(encryptedSavedObjectsServiceMock.encryptAttributes).toHaveBeenCalledTimes(1);
|
||||
expect(encryptedSavedObjectsServiceMock.encryptAttributes).toHaveBeenCalledWith(
|
||||
{ type: 'known-type', id: 'some-id', namespace: 'some-namespace' },
|
||||
{ attrOne: 'one', attrSecret: 'secret', attrThree: 'three' }
|
||||
);
|
||||
|
||||
expect(mockBaseClient.update).toHaveBeenCalledTimes(1);
|
||||
expect(mockBaseClient.update).toHaveBeenCalledWith(
|
||||
'known-type',
|
||||
'some-id',
|
||||
{ attrOne: 'one', attrSecret: '*secret*', attrThree: 'three' },
|
||||
options
|
||||
);
|
||||
});
|
||||
|
||||
it('fails if base client fails', async () => {
|
||||
const failureReason = new Error('Something bad happened...');
|
||||
mockBaseClient.update.mockRejectedValue(failureReason);
|
||||
|
||||
await expect(
|
||||
wrapper.update('known-type', 'some-id', {
|
||||
attrOne: 'one',
|
||||
attrSecret: 'secret',
|
||||
attrThree: 'three',
|
||||
})
|
||||
).rejects.toThrowError(failureReason);
|
||||
|
||||
expect(mockBaseClient.update).toHaveBeenCalledTimes(1);
|
||||
expect(mockBaseClient.update).toHaveBeenCalledWith(
|
||||
'known-type',
|
||||
'some-id',
|
||||
{ attrOne: 'one', attrSecret: '*secret*', attrThree: 'three' },
|
||||
undefined
|
||||
);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,194 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import uuid from 'uuid';
|
||||
import {
|
||||
BaseOptions,
|
||||
BulkCreateObject,
|
||||
BulkCreateResponse,
|
||||
BulkGetObjects,
|
||||
BulkGetResponse,
|
||||
CreateOptions,
|
||||
CreateResponse,
|
||||
FindOptions,
|
||||
FindResponse,
|
||||
GetResponse,
|
||||
SavedObjectAttributes,
|
||||
SavedObjectsClient,
|
||||
UpdateOptions,
|
||||
UpdateResponse,
|
||||
} from 'src/legacy/server/saved_objects/service/saved_objects_client';
|
||||
import { EncryptedSavedObjectsService } from './encrypted_saved_objects_service';
|
||||
|
||||
interface EncryptedSavedObjectsClientOptions {
|
||||
baseClient: SavedObjectsClient;
|
||||
service: Readonly<EncryptedSavedObjectsService>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates UUIDv4 ID for the any newly created saved object that is supposed to contain
|
||||
* encrypted attributes.
|
||||
*/
|
||||
function generateID() {
|
||||
return uuid.v4();
|
||||
}
|
||||
|
||||
export class EncryptedSavedObjectsClientWrapper implements SavedObjectsClient {
|
||||
constructor(
|
||||
private readonly options: EncryptedSavedObjectsClientOptions,
|
||||
public readonly errors: SavedObjectsClient['errors'] = options.baseClient.errors
|
||||
) {}
|
||||
|
||||
public async create<T extends SavedObjectAttributes>(
|
||||
type: string,
|
||||
attributes: T = {} as T,
|
||||
options: CreateOptions = {}
|
||||
) {
|
||||
if (!this.options.service.isRegistered(type)) {
|
||||
return await this.options.baseClient.create(type, attributes, options);
|
||||
}
|
||||
|
||||
// Saved objects with encrypted attributes should have IDs that are hard to guess especially
|
||||
// since IDs are part of the AAD used during encryption, that's why we control them within this
|
||||
// wrapper and don't allow consumers to specify their own IDs directly.
|
||||
if (options.id) {
|
||||
throw new Error(
|
||||
'Predefined IDs are not allowed for saved objects with encrypted attributes.'
|
||||
);
|
||||
}
|
||||
|
||||
const id = generateID();
|
||||
return this.stripEncryptedAttributesFromResponse(
|
||||
await this.options.baseClient.create(
|
||||
type,
|
||||
await this.options.service.encryptAttributes(
|
||||
{ type, id, namespace: options.namespace },
|
||||
attributes
|
||||
),
|
||||
{ ...options, id }
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
public async bulkCreate(objects: BulkCreateObject[], options?: BaseOptions) {
|
||||
// We encrypt attributes for every object in parallel and that can potentially exhaust libuv or
|
||||
// NodeJS thread pool. If it turns out to be a problem, we can consider switching to the
|
||||
// sequential processing.
|
||||
const encryptedObjects = await Promise.all(
|
||||
objects.map(async object => {
|
||||
if (!this.options.service.isRegistered(object.type)) {
|
||||
return object;
|
||||
}
|
||||
|
||||
// Saved objects with encrypted attributes should have IDs that are hard to guess especially
|
||||
// since IDs are part of the AAD used during encryption, that's why we control them within this
|
||||
// wrapper and don't allow consumers to specify their own IDs directly.
|
||||
if (object.id) {
|
||||
throw new Error(
|
||||
'Predefined IDs are not allowed for saved objects with encrypted attributes.'
|
||||
);
|
||||
}
|
||||
|
||||
const id = generateID();
|
||||
return {
|
||||
...object,
|
||||
id,
|
||||
attributes: await this.options.service.encryptAttributes(
|
||||
{ type: object.type, id, namespace: options && options.namespace },
|
||||
object.attributes
|
||||
),
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return this.stripEncryptedAttributesFromBulkResponse(
|
||||
await this.options.baseClient.bulkCreate(encryptedObjects, options)
|
||||
);
|
||||
}
|
||||
|
||||
public async delete(type: string, id: string, options?: BaseOptions) {
|
||||
return await this.options.baseClient.delete(type, id, options);
|
||||
}
|
||||
|
||||
public async find(options: FindOptions = {}) {
|
||||
return this.stripEncryptedAttributesFromBulkResponse(
|
||||
await this.options.baseClient.find(options)
|
||||
);
|
||||
}
|
||||
|
||||
public async bulkGet(objects: BulkGetObjects = [], options?: BaseOptions) {
|
||||
return this.stripEncryptedAttributesFromBulkResponse(
|
||||
await this.options.baseClient.bulkGet(objects, options)
|
||||
);
|
||||
}
|
||||
|
||||
public async get(type: string, id: string, options?: BaseOptions) {
|
||||
return this.stripEncryptedAttributesFromResponse(
|
||||
await this.options.baseClient.get(type, id, options)
|
||||
);
|
||||
}
|
||||
|
||||
public async update<T extends SavedObjectAttributes>(
|
||||
type: string,
|
||||
id: string,
|
||||
attributes: Partial<T>,
|
||||
options?: UpdateOptions
|
||||
) {
|
||||
if (!this.options.service.isRegistered(type)) {
|
||||
return await this.options.baseClient.update(type, id, attributes, options);
|
||||
}
|
||||
|
||||
return this.stripEncryptedAttributesFromResponse(
|
||||
await this.options.baseClient.update(
|
||||
type,
|
||||
id,
|
||||
await this.options.service.encryptAttributes(
|
||||
{ type, id, namespace: options && options.namespace },
|
||||
attributes
|
||||
),
|
||||
options
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
private stripEncryptedAttributesFromResponse<
|
||||
T extends UpdateResponse | CreateResponse | GetResponse
|
||||
>(response: T): T {
|
||||
if (this.options.service.isRegistered(response.type)) {
|
||||
response.attributes = this.options.service.stripEncryptedAttributes(
|
||||
response.type,
|
||||
response.attributes
|
||||
);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
private stripEncryptedAttributesFromBulkResponse<
|
||||
T extends BulkCreateResponse | BulkGetResponse | FindResponse
|
||||
>(response: T): T {
|
||||
for (const savedObject of response.saved_objects) {
|
||||
if (this.options.service.isRegistered(savedObject.type)) {
|
||||
savedObject.attributes = this.options.service.stripEncryptedAttributes(
|
||||
savedObject.type,
|
||||
savedObject.attributes
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,60 @@
|
|||
/*
|
||||
* 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 {
|
||||
EncryptedSavedObjectsService,
|
||||
EncryptedSavedObjectTypeRegistration,
|
||||
SavedObjectDescriptor,
|
||||
} from './encrypted_saved_objects_service';
|
||||
|
||||
export function createEncryptedSavedObjectsServiceMock(
|
||||
registrations: EncryptedSavedObjectTypeRegistration[] = []
|
||||
) {
|
||||
const mock: jest.Mocked<EncryptedSavedObjectsService> = new (jest.requireMock(
|
||||
'./encrypted_saved_objects_service'
|
||||
)).EncryptedSavedObjectsService();
|
||||
|
||||
function processAttributes<T extends Record<string, any>>(
|
||||
descriptor: Pick<SavedObjectDescriptor, 'type'>,
|
||||
attrs: T,
|
||||
action: (attrs: T, attrName: string) => void
|
||||
) {
|
||||
const registration = registrations.find(r => r.type === descriptor.type);
|
||||
if (!registration) {
|
||||
return attrs;
|
||||
}
|
||||
|
||||
const clonedAttrs = { ...attrs };
|
||||
for (const attrName of registration.attributesToEncrypt) {
|
||||
if (attrName in clonedAttrs) {
|
||||
action(clonedAttrs, attrName);
|
||||
}
|
||||
}
|
||||
return clonedAttrs;
|
||||
}
|
||||
|
||||
mock.isRegistered.mockImplementation(type => registrations.findIndex(r => r.type === type) >= 0);
|
||||
mock.encryptAttributes.mockImplementation(async (descriptor, attrs) =>
|
||||
processAttributes(
|
||||
descriptor,
|
||||
attrs,
|
||||
(clonedAttrs, attrName) => (clonedAttrs[attrName] = `*${clonedAttrs[attrName]}*`)
|
||||
)
|
||||
);
|
||||
mock.decryptAttributes.mockImplementation(async (descriptor, attrs) =>
|
||||
processAttributes(
|
||||
descriptor,
|
||||
attrs,
|
||||
(clonedAttrs, attrName) =>
|
||||
(clonedAttrs[attrName] = (clonedAttrs[attrName] as string).slice(1, -1))
|
||||
)
|
||||
);
|
||||
mock.stripEncryptedAttributes.mockImplementation((type, attrs) =>
|
||||
processAttributes({ type }, attrs, (clonedAttrs, attrName) => delete clonedAttrs[attrName])
|
||||
);
|
||||
|
||||
return mock;
|
||||
}
|
|
@ -0,0 +1,797 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
jest.mock('@elastic/node-crypto', () => jest.fn());
|
||||
|
||||
import { EncryptedSavedObjectsAuditLogger } from './encrypted_saved_objects_audit_logger';
|
||||
import { EncryptedSavedObjectsService } from './encrypted_saved_objects_service';
|
||||
import { EncryptionError } from './encryption_error';
|
||||
|
||||
let service: EncryptedSavedObjectsService;
|
||||
let mockAuditLogger: jest.Mocked<EncryptedSavedObjectsAuditLogger>;
|
||||
beforeEach(() => {
|
||||
mockAuditLogger = {
|
||||
encryptAttributesSuccess: jest.fn(),
|
||||
encryptAttributeFailure: jest.fn(),
|
||||
decryptAttributesSuccess: jest.fn(),
|
||||
decryptAttributeFailure: jest.fn(),
|
||||
} as any;
|
||||
|
||||
// Call actual `@elastic/node-crypto` by default, but allow to override implementation in tests.
|
||||
jest
|
||||
.requireMock('@elastic/node-crypto')
|
||||
.mockImplementation((...args: any[]) => jest.requireActual('@elastic/node-crypto')(...args));
|
||||
|
||||
service = new EncryptedSavedObjectsService(
|
||||
'encryption-key-abc',
|
||||
['known-type-1', 'known-type-2'],
|
||||
{ debug: jest.fn(), error: jest.fn() } as any,
|
||||
mockAuditLogger
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => jest.resetAllMocks());
|
||||
|
||||
it('correctly initializes crypto', () => {
|
||||
const mockNodeCrypto = jest.requireMock('@elastic/node-crypto');
|
||||
expect(mockNodeCrypto).toHaveBeenCalledTimes(1);
|
||||
expect(mockNodeCrypto).toHaveBeenCalledWith({ encryptionKey: 'encryption-key-abc' });
|
||||
});
|
||||
|
||||
describe('#registerType', () => {
|
||||
it('throws if `attributesToEncrypt` is empty', () => {
|
||||
expect(() =>
|
||||
service.registerType({ type: 'known-type-1', attributesToEncrypt: new Set() })
|
||||
).toThrowError('The "attributesToEncrypt" array for "known-type-1" is empty.');
|
||||
});
|
||||
|
||||
it('throws if `type` has been registered already', () => {
|
||||
service.registerType({ type: 'known-type-1', attributesToEncrypt: new Set(['attr']) });
|
||||
expect(() =>
|
||||
service.registerType({ type: 'known-type-1', attributesToEncrypt: new Set(['attr']) })
|
||||
).toThrowError('The "known-type-1" saved object type is already registered.');
|
||||
});
|
||||
|
||||
it('throws if `type` references to the unknown type', () => {
|
||||
expect(() =>
|
||||
service.registerType({ type: 'unknown-type', attributesToEncrypt: new Set(['attr']) })
|
||||
).toThrowError('The type "unknown-type" is not known saved object type.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('#isRegistered', () => {
|
||||
it('correctly determines whether the specified type is registered', () => {
|
||||
expect(service.isRegistered('known-type-1')).toBe(false);
|
||||
expect(service.isRegistered('known-type-2')).toBe(false);
|
||||
expect(service.isRegistered('unknown-type')).toBe(false);
|
||||
|
||||
service.registerType({ type: 'known-type-1', attributesToEncrypt: new Set(['attr-1']) });
|
||||
expect(service.isRegistered('known-type-1')).toBe(true);
|
||||
expect(service.isRegistered('known-type-2')).toBe(false);
|
||||
expect(service.isRegistered('unknown-type')).toBe(false);
|
||||
|
||||
service.registerType({ type: 'known-type-2', attributesToEncrypt: new Set(['attr-2']) });
|
||||
expect(service.isRegistered('known-type-1')).toBe(true);
|
||||
expect(service.isRegistered('known-type-2')).toBe(true);
|
||||
expect(service.isRegistered('unknown-type')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#stripEncryptedAttributes', () => {
|
||||
it('does not strip attributes from unknown types', () => {
|
||||
const attributes = { attrOne: 'one', attrTwo: 'two', attrThree: 'three' };
|
||||
|
||||
expect(service.stripEncryptedAttributes('unknown-type', attributes)).toEqual({
|
||||
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', () => {
|
||||
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',
|
||||
});
|
||||
});
|
||||
|
||||
it('strips only attributes that are supposed to be encrypted', () => {
|
||||
const attributes = { attrOne: 'one', attrTwo: 'two', attrThree: 'three' };
|
||||
|
||||
service.registerType({
|
||||
type: 'known-type-1',
|
||||
attributesToEncrypt: new Set(['attrOne', 'attrThree']),
|
||||
});
|
||||
|
||||
expect(service.stripEncryptedAttributes('known-type-1', attributes)).toEqual({
|
||||
attrTwo: 'two',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('#encryptAttributes', () => {
|
||||
let mockEncrypt: jest.Mock;
|
||||
beforeEach(() => {
|
||||
mockEncrypt = jest
|
||||
.fn()
|
||||
.mockImplementation(async (valueToEncrypt, aad) => `|${valueToEncrypt}|${aad}|`);
|
||||
jest.requireMock('@elastic/node-crypto').mockReturnValue({ encrypt: mockEncrypt });
|
||||
|
||||
service = new EncryptedSavedObjectsService(
|
||||
'encryption-key-abc',
|
||||
['known-type-1', 'known-type-2'],
|
||||
{ debug: jest.fn(), error: jest.fn() } as any,
|
||||
mockAuditLogger
|
||||
);
|
||||
});
|
||||
|
||||
it('does not encrypt attributes for unknown types', async () => {
|
||||
const attributes = { attrOne: 'one', attrTwo: 'two', attrThree: 'three' };
|
||||
|
||||
await expect(
|
||||
service.encryptAttributes({ type: 'unknown-type', id: 'object-id' }, attributes)
|
||||
).resolves.toEqual({
|
||||
attrOne: 'one',
|
||||
attrTwo: 'two',
|
||||
attrThree: 'three',
|
||||
});
|
||||
expect(mockAuditLogger.encryptAttributesSuccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not encrypt attributes for known, but not registered types', async () => {
|
||||
const attributes = { attrOne: 'one', attrTwo: 'two', attrThree: 'three' };
|
||||
|
||||
await expect(
|
||||
service.encryptAttributes({ type: 'known-type-1', id: 'object-id' }, attributes)
|
||||
).resolves.toEqual({
|
||||
attrOne: 'one',
|
||||
attrTwo: 'two',
|
||||
attrThree: 'three',
|
||||
});
|
||||
expect(mockAuditLogger.encryptAttributesSuccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not encrypt attributes that are not supposed to be encrypted', async () => {
|
||||
const attributes = { attrOne: 'one', attrTwo: 'two', attrThree: 'three' };
|
||||
|
||||
service.registerType({ type: 'known-type-1', attributesToEncrypt: new Set(['attrFour']) });
|
||||
|
||||
await expect(
|
||||
service.encryptAttributes({ type: 'known-type-1', id: 'object-id' }, attributes)
|
||||
).resolves.toEqual({
|
||||
attrOne: 'one',
|
||||
attrTwo: 'two',
|
||||
attrThree: 'three',
|
||||
});
|
||||
expect(mockAuditLogger.encryptAttributesSuccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('encrypts only attributes that are supposed to be encrypted', async () => {
|
||||
const attributes = { attrOne: 'one', attrTwo: 'two', attrThree: 'three', attrFour: null };
|
||||
|
||||
service.registerType({
|
||||
type: 'known-type-1',
|
||||
attributesToEncrypt: new Set(['attrOne', 'attrThree', 'attrFour']),
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.encryptAttributes({ type: 'known-type-1', id: 'object-id' }, attributes)
|
||||
).resolves.toEqual({
|
||||
attrOne: '|one|["known-type-1","object-id",{"attrTwo":"two"}]|',
|
||||
attrTwo: 'two',
|
||||
attrThree: '|three|["known-type-1","object-id",{"attrTwo":"two"}]|',
|
||||
attrFour: null,
|
||||
});
|
||||
expect(mockAuditLogger.encryptAttributesSuccess).toHaveBeenCalledTimes(1);
|
||||
expect(mockAuditLogger.encryptAttributesSuccess).toHaveBeenCalledWith(
|
||||
['attrOne', 'attrThree'],
|
||||
{ type: 'known-type-1', id: 'object-id' }
|
||||
);
|
||||
});
|
||||
|
||||
it('encrypts only attributes that are supposed to be encrypted even if not all provided', async () => {
|
||||
const attributes = { attrTwo: 'two', attrThree: 'three' };
|
||||
|
||||
service.registerType({
|
||||
type: 'known-type-1',
|
||||
attributesToEncrypt: new Set(['attrOne', 'attrThree']),
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.encryptAttributes({ type: 'known-type-1', id: 'object-id' }, attributes)
|
||||
).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',
|
||||
});
|
||||
});
|
||||
|
||||
it('includes `namespace` into AAD if provided', async () => {
|
||||
const attributes = { attrTwo: 'two', attrThree: 'three' };
|
||||
|
||||
service.registerType({
|
||||
type: 'known-type-1',
|
||||
attributesToEncrypt: new Set(['attrOne', 'attrThree']),
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.encryptAttributes(
|
||||
{ type: 'known-type-1', id: 'object-id', namespace: 'object-ns' },
|
||||
attributes
|
||||
)
|
||||
).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',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not include specified attributes to AAD', async () => {
|
||||
const knownType1attributes = { attrOne: 'one', attrTwo: 'two', attrThree: 'three' };
|
||||
service.registerType({
|
||||
type: 'known-type-1',
|
||||
attributesToEncrypt: new Set(['attrThree']),
|
||||
});
|
||||
|
||||
const knownType2attributes = { attrOne: 'one', attrTwo: 'two', attrThree: 'three' };
|
||||
service.registerType({
|
||||
type: 'known-type-2',
|
||||
attributesToEncrypt: new Set(['attrThree']),
|
||||
attributesToExcludeFromAAD: new Set(['attrTwo']),
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.encryptAttributes({ type: 'known-type-1', id: 'object-id-1' }, knownType1attributes)
|
||||
).resolves.toEqual({
|
||||
attrOne: 'one',
|
||||
attrTwo: 'two',
|
||||
attrThree: '|three|["known-type-1","object-id-1",{"attrOne":"one","attrTwo":"two"}]|',
|
||||
});
|
||||
await expect(
|
||||
service.encryptAttributes({ type: 'known-type-2', id: 'object-id-2' }, knownType2attributes)
|
||||
).resolves.toEqual({
|
||||
attrOne: 'one',
|
||||
attrTwo: 'two',
|
||||
attrThree: '|three|["known-type-2","object-id-2",{"attrOne":"one"}]|',
|
||||
});
|
||||
});
|
||||
|
||||
it('encrypts even if no attributes are included into AAD', async () => {
|
||||
const attributes = { attrOne: 'one', attrThree: 'three' };
|
||||
service.registerType({
|
||||
type: 'known-type-1',
|
||||
attributesToEncrypt: new Set(['attrOne', 'attrThree']),
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.encryptAttributes({ type: 'known-type-1', id: 'object-id-1' }, attributes)
|
||||
).resolves.toEqual({
|
||||
attrOne: '|one|["known-type-1","object-id-1",{}]|',
|
||||
attrThree: '|three|["known-type-1","object-id-1",{}]|',
|
||||
});
|
||||
});
|
||||
|
||||
it('fails if encryption of any attribute fails', async () => {
|
||||
const attributes = { attrOne: 'one', attrTwo: 'two', attrThree: 'three' };
|
||||
service.registerType({
|
||||
type: 'known-type-1',
|
||||
attributesToEncrypt: new Set(['attrOne', 'attrThree']),
|
||||
});
|
||||
|
||||
mockEncrypt
|
||||
.mockResolvedValueOnce('Successfully encrypted attrOne')
|
||||
.mockRejectedValueOnce(new Error('Something went wrong with attrThree...'));
|
||||
|
||||
await expect(
|
||||
service.encryptAttributes({ type: 'known-type-1', id: 'object-id' }, attributes)
|
||||
).rejects.toThrowError(EncryptionError);
|
||||
|
||||
expect(attributes).toEqual({
|
||||
attrOne: 'one',
|
||||
attrTwo: 'two',
|
||||
attrThree: 'three',
|
||||
});
|
||||
expect(mockAuditLogger.encryptAttributesSuccess).not.toHaveBeenCalled();
|
||||
expect(mockAuditLogger.encryptAttributeFailure).toHaveBeenCalledTimes(1);
|
||||
expect(mockAuditLogger.encryptAttributeFailure).toHaveBeenCalledWith('attrThree', {
|
||||
type: 'known-type-1',
|
||||
id: 'object-id',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('#decryptAttributes', () => {
|
||||
it('does not decrypt attributes for unknown types', async () => {
|
||||
const attributes = { attrOne: 'one', attrTwo: 'two', attrThree: 'three' };
|
||||
|
||||
await expect(
|
||||
service.decryptAttributes({ type: 'unknown-type', id: 'object-id' }, attributes)
|
||||
).resolves.toEqual({
|
||||
attrOne: 'one',
|
||||
attrTwo: 'two',
|
||||
attrThree: 'three',
|
||||
});
|
||||
expect(mockAuditLogger.decryptAttributesSuccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not decrypt attributes for known, but not registered types', async () => {
|
||||
const attributes = { attrOne: 'one', attrTwo: 'two', attrThree: 'three' };
|
||||
|
||||
await expect(
|
||||
service.decryptAttributes({ type: 'known-type-1', id: 'object-id' }, attributes)
|
||||
).resolves.toEqual({
|
||||
attrOne: 'one',
|
||||
attrTwo: 'two',
|
||||
attrThree: 'three',
|
||||
});
|
||||
expect(mockAuditLogger.decryptAttributesSuccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not decrypt attributes that are not supposed to be decrypted', async () => {
|
||||
const attributes = { attrOne: 'one', attrTwo: 'two', attrThree: 'three' };
|
||||
|
||||
service.registerType({ type: 'known-type-1', attributesToEncrypt: new Set(['attrFour']) });
|
||||
|
||||
await expect(
|
||||
service.decryptAttributes({ type: 'known-type-1', id: 'object-id' }, attributes)
|
||||
).resolves.toEqual({
|
||||
attrOne: 'one',
|
||||
attrTwo: 'two',
|
||||
attrThree: 'three',
|
||||
});
|
||||
expect(mockAuditLogger.decryptAttributesSuccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('decrypts only attributes that are supposed to be decrypted', async () => {
|
||||
const attributes = { attrOne: 'one', attrTwo: 'two', attrThree: 'three', attrFour: null };
|
||||
|
||||
service.registerType({
|
||||
type: 'known-type-1',
|
||||
attributesToEncrypt: new Set(['attrOne', 'attrThree', 'attrFour']),
|
||||
});
|
||||
|
||||
const encryptedAttributes = await service.encryptAttributes(
|
||||
{ type: 'known-type-1', id: 'object-id' },
|
||||
attributes
|
||||
);
|
||||
expect(encryptedAttributes).toEqual({
|
||||
attrOne: expect.not.stringMatching('one'),
|
||||
attrTwo: 'two',
|
||||
attrThree: expect.not.stringMatching('three'),
|
||||
attrFour: null,
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.decryptAttributes({ type: 'known-type-1', id: 'object-id' }, encryptedAttributes)
|
||||
).resolves.toEqual({
|
||||
attrOne: 'one',
|
||||
attrTwo: 'two',
|
||||
attrThree: 'three',
|
||||
attrFour: null,
|
||||
});
|
||||
expect(mockAuditLogger.decryptAttributesSuccess).toHaveBeenCalledTimes(1);
|
||||
expect(mockAuditLogger.decryptAttributesSuccess).toHaveBeenCalledWith(
|
||||
['attrOne', 'attrThree'],
|
||||
{ type: 'known-type-1', id: 'object-id' }
|
||||
);
|
||||
});
|
||||
|
||||
it('decrypts only attributes that are supposed to be encrypted even if not all provided', async () => {
|
||||
const attributes = { attrTwo: 'two', attrThree: 'three' };
|
||||
|
||||
service.registerType({
|
||||
type: 'known-type-1',
|
||||
attributesToEncrypt: new Set(['attrOne', 'attrThree']),
|
||||
});
|
||||
|
||||
const encryptedAttributes = await service.encryptAttributes(
|
||||
{ type: 'known-type-1', id: 'object-id' },
|
||||
attributes
|
||||
);
|
||||
expect(encryptedAttributes).toEqual({
|
||||
attrTwo: 'two',
|
||||
attrThree: expect.not.stringMatching('three'),
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.decryptAttributes({ type: 'known-type-1', id: 'object-id' }, encryptedAttributes)
|
||||
).resolves.toEqual({
|
||||
attrTwo: 'two',
|
||||
attrThree: 'three',
|
||||
});
|
||||
expect(mockAuditLogger.decryptAttributesSuccess).toHaveBeenCalledTimes(1);
|
||||
expect(mockAuditLogger.decryptAttributesSuccess).toHaveBeenCalledWith(['attrThree'], {
|
||||
type: 'known-type-1',
|
||||
id: 'object-id',
|
||||
});
|
||||
});
|
||||
|
||||
it('decrypts if all attributes that contribute to AAD are present', async () => {
|
||||
const attributes = { attrOne: 'one', attrTwo: 'two', attrThree: 'three' };
|
||||
|
||||
service.registerType({
|
||||
type: 'known-type-1',
|
||||
attributesToEncrypt: new Set(['attrThree']),
|
||||
attributesToExcludeFromAAD: new Set(['attrOne']),
|
||||
});
|
||||
|
||||
const encryptedAttributes = await service.encryptAttributes(
|
||||
{ type: 'known-type-1', id: 'object-id' },
|
||||
attributes
|
||||
);
|
||||
expect(encryptedAttributes).toEqual({
|
||||
attrOne: 'one',
|
||||
attrTwo: 'two',
|
||||
attrThree: expect.not.stringMatching('three'),
|
||||
});
|
||||
|
||||
const attributesWithoutAttr = { attrTwo: 'two', attrThree: encryptedAttributes.attrThree };
|
||||
|
||||
await expect(
|
||||
service.decryptAttributes({ type: 'known-type-1', id: 'object-id' }, attributesWithoutAttr)
|
||||
).resolves.toEqual({
|
||||
attrTwo: 'two',
|
||||
attrThree: 'three',
|
||||
});
|
||||
expect(mockAuditLogger.decryptAttributesSuccess).toHaveBeenCalledTimes(1);
|
||||
expect(mockAuditLogger.decryptAttributesSuccess).toHaveBeenCalledWith(['attrThree'], {
|
||||
type: 'known-type-1',
|
||||
id: 'object-id',
|
||||
});
|
||||
});
|
||||
|
||||
it('decrypts even if attributes in AAD are defined in a different order', async () => {
|
||||
const attributes = { attrOne: 'one', attrTwo: 'two', attrThree: 'three' };
|
||||
|
||||
service.registerType({
|
||||
type: 'known-type-1',
|
||||
attributesToEncrypt: new Set(['attrThree']),
|
||||
});
|
||||
|
||||
const encryptedAttributes = await service.encryptAttributes(
|
||||
{ type: 'known-type-1', id: 'object-id' },
|
||||
attributes
|
||||
);
|
||||
expect(encryptedAttributes).toEqual({
|
||||
attrOne: 'one',
|
||||
attrTwo: 'two',
|
||||
attrThree: expect.not.stringMatching('three'),
|
||||
});
|
||||
|
||||
const attributesInDifferentOrder = {
|
||||
attrThree: encryptedAttributes.attrThree,
|
||||
attrTwo: 'two',
|
||||
attrOne: 'one',
|
||||
};
|
||||
|
||||
await expect(
|
||||
service.decryptAttributes(
|
||||
{ type: 'known-type-1', id: 'object-id' },
|
||||
attributesInDifferentOrder
|
||||
)
|
||||
).resolves.toEqual({
|
||||
attrOne: 'one',
|
||||
attrTwo: 'two',
|
||||
attrThree: 'three',
|
||||
});
|
||||
expect(mockAuditLogger.decryptAttributesSuccess).toHaveBeenCalledTimes(1);
|
||||
expect(mockAuditLogger.decryptAttributesSuccess).toHaveBeenCalledWith(['attrThree'], {
|
||||
type: 'known-type-1',
|
||||
id: 'object-id',
|
||||
});
|
||||
});
|
||||
|
||||
it('decrypts if correct namespace is provided', async () => {
|
||||
const attributes = { attrOne: 'one', attrTwo: 'two', attrThree: 'three' };
|
||||
|
||||
service.registerType({
|
||||
type: 'known-type-1',
|
||||
attributesToEncrypt: new Set(['attrThree']),
|
||||
});
|
||||
|
||||
const encryptedAttributes = await service.encryptAttributes(
|
||||
{ type: 'known-type-1', id: 'object-id', namespace: 'object-ns' },
|
||||
attributes
|
||||
);
|
||||
expect(encryptedAttributes).toEqual({
|
||||
attrOne: 'one',
|
||||
attrTwo: 'two',
|
||||
attrThree: expect.not.stringMatching('three'),
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.decryptAttributes(
|
||||
{ type: 'known-type-1', id: 'object-id', namespace: 'object-ns' },
|
||||
encryptedAttributes
|
||||
)
|
||||
).resolves.toEqual({
|
||||
attrOne: 'one',
|
||||
attrTwo: 'two',
|
||||
attrThree: 'three',
|
||||
});
|
||||
expect(mockAuditLogger.decryptAttributesSuccess).toHaveBeenCalledTimes(1);
|
||||
expect(mockAuditLogger.decryptAttributesSuccess).toHaveBeenCalledWith(['attrThree'], {
|
||||
type: 'known-type-1',
|
||||
id: 'object-id',
|
||||
namespace: 'object-ns',
|
||||
});
|
||||
});
|
||||
|
||||
it('decrypts even if no attributes are included into AAD', async () => {
|
||||
const attributes = { attrOne: 'one', attrThree: 'three' };
|
||||
service.registerType({
|
||||
type: 'known-type-1',
|
||||
attributesToEncrypt: new Set(['attrOne', 'attrThree']),
|
||||
});
|
||||
|
||||
const encryptedAttributes = await service.encryptAttributes(
|
||||
{ type: 'known-type-1', id: 'object-id' },
|
||||
attributes
|
||||
);
|
||||
expect(encryptedAttributes).toEqual({
|
||||
attrOne: expect.not.stringMatching('one'),
|
||||
attrThree: expect.not.stringMatching('three'),
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.decryptAttributes({ type: 'known-type-1', id: 'object-id' }, encryptedAttributes)
|
||||
).resolves.toEqual({
|
||||
attrOne: 'one',
|
||||
attrThree: 'three',
|
||||
});
|
||||
expect(mockAuditLogger.decryptAttributesSuccess).toHaveBeenCalledTimes(1);
|
||||
expect(mockAuditLogger.decryptAttributesSuccess).toHaveBeenCalledWith(
|
||||
['attrOne', 'attrThree'],
|
||||
{ type: 'known-type-1', id: 'object-id' }
|
||||
);
|
||||
});
|
||||
|
||||
it('decrypts non-string attributes and restores their original type', async () => {
|
||||
const attributes = {
|
||||
attrOne: 'one',
|
||||
attrTwo: 'two',
|
||||
attrThree: 'three',
|
||||
attrFour: null,
|
||||
attrFive: { nested: 'five' },
|
||||
attrSix: 6,
|
||||
};
|
||||
|
||||
service.registerType({
|
||||
type: 'known-type-1',
|
||||
attributesToEncrypt: new Set(['attrOne', 'attrThree', 'attrFour', 'attrFive', 'attrSix']),
|
||||
});
|
||||
|
||||
const encryptedAttributes = await service.encryptAttributes(
|
||||
{ type: 'known-type-1', id: 'object-id' },
|
||||
attributes
|
||||
);
|
||||
expect(encryptedAttributes).toEqual({
|
||||
attrOne: expect.not.stringMatching('one'),
|
||||
attrTwo: 'two',
|
||||
attrThree: expect.not.stringMatching('three'),
|
||||
attrFour: null,
|
||||
attrFive: expect.any(String),
|
||||
attrSix: expect.any(String),
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.decryptAttributes({ type: 'known-type-1', id: 'object-id' }, encryptedAttributes)
|
||||
).resolves.toEqual({
|
||||
attrOne: 'one',
|
||||
attrTwo: 'two',
|
||||
attrThree: 'three',
|
||||
attrFour: null,
|
||||
attrFive: { nested: 'five' },
|
||||
attrSix: 6,
|
||||
});
|
||||
expect(mockAuditLogger.decryptAttributesSuccess).toHaveBeenCalledTimes(1);
|
||||
expect(mockAuditLogger.decryptAttributesSuccess).toHaveBeenCalledWith(
|
||||
['attrOne', 'attrThree', 'attrFive', 'attrSix'],
|
||||
{ type: 'known-type-1', id: 'object-id' }
|
||||
);
|
||||
});
|
||||
|
||||
describe('decryption failures', () => {
|
||||
let encryptedAttributes: Record<string, string>;
|
||||
beforeEach(async () => {
|
||||
const attributes = { attrOne: 'one', attrTwo: 'two', attrThree: 'three' };
|
||||
|
||||
service.registerType({
|
||||
type: 'known-type-1',
|
||||
attributesToEncrypt: new Set(['attrThree']),
|
||||
});
|
||||
|
||||
service.registerType({
|
||||
type: 'known-type-2',
|
||||
attributesToEncrypt: new Set(['attrThree']),
|
||||
});
|
||||
|
||||
encryptedAttributes = await service.encryptAttributes(
|
||||
{ type: 'known-type-1', id: 'object-id' },
|
||||
attributes
|
||||
);
|
||||
});
|
||||
|
||||
it('fails to decrypt if not all attributes that contribute to AAD are present', async () => {
|
||||
const attributesWithoutAttr = { attrTwo: 'two', attrThree: encryptedAttributes.attrThree };
|
||||
await expect(
|
||||
service.decryptAttributes({ type: 'known-type-1', id: 'object-id' }, attributesWithoutAttr)
|
||||
).rejects.toThrowError(EncryptionError);
|
||||
|
||||
expect(mockAuditLogger.decryptAttributesSuccess).not.toHaveBeenCalled();
|
||||
expect(mockAuditLogger.decryptAttributeFailure).toHaveBeenCalledWith('attrThree', {
|
||||
type: 'known-type-1',
|
||||
id: 'object-id',
|
||||
});
|
||||
});
|
||||
|
||||
it('fails to decrypt if ID does not match', async () => {
|
||||
await expect(
|
||||
service.decryptAttributes({ type: 'known-type-1', id: 'object-id*' }, encryptedAttributes)
|
||||
).rejects.toThrowError(EncryptionError);
|
||||
|
||||
expect(mockAuditLogger.decryptAttributesSuccess).not.toHaveBeenCalled();
|
||||
expect(mockAuditLogger.decryptAttributeFailure).toHaveBeenCalledWith('attrThree', {
|
||||
type: 'known-type-1',
|
||||
id: 'object-id*',
|
||||
});
|
||||
});
|
||||
|
||||
it('fails to decrypt if type does not match', async () => {
|
||||
await expect(
|
||||
service.decryptAttributes({ type: 'known-type-2', id: 'object-id' }, encryptedAttributes)
|
||||
).rejects.toThrowError(EncryptionError);
|
||||
|
||||
expect(mockAuditLogger.decryptAttributesSuccess).not.toHaveBeenCalled();
|
||||
expect(mockAuditLogger.decryptAttributeFailure).toHaveBeenCalledWith('attrThree', {
|
||||
type: 'known-type-2',
|
||||
id: 'object-id',
|
||||
});
|
||||
});
|
||||
|
||||
it('fails to decrypt if namespace does not match', async () => {
|
||||
encryptedAttributes = await service.encryptAttributes(
|
||||
{ type: 'known-type-1', id: 'object-id', namespace: 'object-ns' },
|
||||
{ attrOne: 'one', attrTwo: 'two', attrThree: 'three' }
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.decryptAttributes(
|
||||
{ type: 'known-type-1', id: 'object-id', namespace: 'object-NS' },
|
||||
encryptedAttributes
|
||||
)
|
||||
).rejects.toThrowError(EncryptionError);
|
||||
|
||||
expect(mockAuditLogger.decryptAttributesSuccess).not.toHaveBeenCalled();
|
||||
expect(mockAuditLogger.decryptAttributeFailure).toHaveBeenCalledWith('attrThree', {
|
||||
type: 'known-type-1',
|
||||
id: 'object-id',
|
||||
namespace: 'object-NS',
|
||||
});
|
||||
});
|
||||
|
||||
it('fails to decrypt if namespace is expected, but is not provided', async () => {
|
||||
encryptedAttributes = await service.encryptAttributes(
|
||||
{ type: 'known-type-1', id: 'object-id', namespace: 'object-ns' },
|
||||
{ attrOne: 'one', attrTwo: 'two', attrThree: 'three' }
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.decryptAttributes({ type: 'known-type-1', id: 'object-id' }, encryptedAttributes)
|
||||
).rejects.toThrowError(EncryptionError);
|
||||
|
||||
expect(mockAuditLogger.decryptAttributesSuccess).not.toHaveBeenCalled();
|
||||
expect(mockAuditLogger.decryptAttributeFailure).toHaveBeenCalledWith('attrThree', {
|
||||
type: 'known-type-1',
|
||||
id: 'object-id',
|
||||
});
|
||||
});
|
||||
|
||||
it('fails to decrypt if encrypted attribute is defined, but not a string', async () => {
|
||||
await expect(
|
||||
service.decryptAttributes(
|
||||
{ type: 'known-type-1', id: 'object-id' },
|
||||
{
|
||||
...encryptedAttributes,
|
||||
attrThree: 2,
|
||||
}
|
||||
)
|
||||
).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',
|
||||
});
|
||||
});
|
||||
|
||||
it('fails to decrypt if encrypted attribute is not correct', async () => {
|
||||
await expect(
|
||||
service.decryptAttributes(
|
||||
{ type: 'known-type-1', id: 'object-id' },
|
||||
{
|
||||
...encryptedAttributes,
|
||||
attrThree: 'some-unknown-string',
|
||||
}
|
||||
)
|
||||
).rejects.toThrowError(EncryptionError);
|
||||
|
||||
expect(mockAuditLogger.decryptAttributesSuccess).not.toHaveBeenCalled();
|
||||
expect(mockAuditLogger.decryptAttributeFailure).toHaveBeenCalledWith('attrThree', {
|
||||
type: 'known-type-1',
|
||||
id: 'object-id',
|
||||
});
|
||||
});
|
||||
|
||||
it('fails to decrypt if the AAD attribute has changed', async () => {
|
||||
await expect(
|
||||
service.decryptAttributes(
|
||||
{ type: 'known-type-1', id: 'object-id' },
|
||||
{
|
||||
...encryptedAttributes,
|
||||
attrOne: 'oNe',
|
||||
}
|
||||
)
|
||||
).rejects.toThrowError(EncryptionError);
|
||||
|
||||
expect(mockAuditLogger.decryptAttributesSuccess).not.toHaveBeenCalled();
|
||||
expect(mockAuditLogger.decryptAttributeFailure).toHaveBeenCalledWith('attrThree', {
|
||||
type: 'known-type-1',
|
||||
id: 'object-id',
|
||||
});
|
||||
});
|
||||
|
||||
it('fails if encrypted with another encryption key', async () => {
|
||||
service = new EncryptedSavedObjectsService(
|
||||
'encryption-key-abc*',
|
||||
['known-type-1'],
|
||||
{ debug: jest.fn(), error: jest.fn() } as any,
|
||||
mockAuditLogger
|
||||
);
|
||||
|
||||
service.registerType({
|
||||
type: 'known-type-1',
|
||||
attributesToEncrypt: new Set(['attrThree']),
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.decryptAttributes({ type: 'known-type-1', id: 'object-id' }, encryptedAttributes)
|
||||
).rejects.toThrowError(EncryptionError);
|
||||
|
||||
expect(mockAuditLogger.decryptAttributesSuccess).not.toHaveBeenCalled();
|
||||
expect(mockAuditLogger.decryptAttributeFailure).toHaveBeenCalledWith('attrThree', {
|
||||
type: 'known-type-1',
|
||||
id: 'object-id',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,311 @@
|
|||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
// @ts-ignore
|
||||
import nodeCrypto from '@elastic/node-crypto';
|
||||
import stringify from 'json-stable-stringify';
|
||||
import typeDetect from 'type-detect';
|
||||
import { Server } from 'kibana';
|
||||
import { EncryptedSavedObjectsAuditLogger } from './encrypted_saved_objects_audit_logger';
|
||||
import { EncryptionError } from './encryption_error';
|
||||
|
||||
/**
|
||||
* Describes the registration entry for the saved object type that contain attributes that need to
|
||||
* be encrypted.
|
||||
*/
|
||||
export interface EncryptedSavedObjectTypeRegistration {
|
||||
readonly type: string;
|
||||
readonly attributesToEncrypt: ReadonlySet<string>;
|
||||
readonly attributesToExcludeFromAAD?: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Uniquely identifies saved object.
|
||||
*/
|
||||
export interface SavedObjectDescriptor {
|
||||
readonly id: string;
|
||||
readonly type: string;
|
||||
readonly namespace?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility function that gives array representation of the saved object descriptor respecting
|
||||
* optional `namespace` property.
|
||||
* @param descriptor Saved Object descriptor to turn into array.
|
||||
*/
|
||||
export function descriptorToArray(descriptor: SavedObjectDescriptor) {
|
||||
return descriptor.namespace
|
||||
? [descriptor.namespace, descriptor.type, descriptor.id]
|
||||
: [descriptor.type, descriptor.id];
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the service that tracks all saved object types that might contain attributes that need
|
||||
* to be encrypted before they are stored and eventually decrypted when retrieved. The service
|
||||
* performs encryption only based on registered saved object types that are known to contain such
|
||||
* attributes.
|
||||
*/
|
||||
export class EncryptedSavedObjectsService {
|
||||
private readonly crypto: Readonly<{
|
||||
encrypt<T>(valueToEncrypt: T, aad?: string): Promise<string>;
|
||||
decrypt<T>(valueToDecrypt: string, aad?: string): Promise<T>;
|
||||
}>;
|
||||
|
||||
/**
|
||||
* 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.).
|
||||
*/
|
||||
private readonly typeRegistrations: Map<string, EncryptedSavedObjectTypeRegistration> = new Map();
|
||||
|
||||
/**
|
||||
* @param encryptionKey The key used to encrypt and decrypt saved objects attributes.
|
||||
* @param knownTypes The list of all known saved object types.
|
||||
* @param log Ordinary logger instance.
|
||||
* @param audit Audit logger instance.
|
||||
*/
|
||||
constructor(
|
||||
encryptionKey: string,
|
||||
private readonly knownTypes: ReadonlyArray<string>,
|
||||
private readonly log: Server.Logger,
|
||||
private readonly audit: EncryptedSavedObjectsAuditLogger
|
||||
) {
|
||||
this.crypto = nodeCrypto({ encryptionKey });
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers saved object type as the one that contains attributes that should be encrypted.
|
||||
* @param typeRegistration Saved object type registration parameters.
|
||||
* @throws Will throw if `attributesToEncrypt` is empty.
|
||||
* @throws Will throw if the type is already registered.
|
||||
* @throws Will throw if the type is not known saved object type.
|
||||
*/
|
||||
public registerType(typeRegistration: EncryptedSavedObjectTypeRegistration) {
|
||||
if (typeRegistration.attributesToEncrypt.size === 0) {
|
||||
throw new Error(`The "attributesToEncrypt" array for "${typeRegistration.type}" is empty.`);
|
||||
}
|
||||
|
||||
if (this.typeRegistrations.has(typeRegistration.type)) {
|
||||
throw new Error(`The "${typeRegistration.type}" saved object type is already registered.`);
|
||||
}
|
||||
|
||||
if (!this.knownTypes.includes(typeRegistration.type)) {
|
||||
throw new Error(`The type "${typeRegistration.type}" is not known saved object type.`);
|
||||
}
|
||||
|
||||
this.typeRegistrations.set(typeRegistration.type, typeRegistration);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether specified saved object type is registered as the one that contains attributes
|
||||
* that should be encrypted.
|
||||
* @param type Saved object type.
|
||||
*/
|
||||
public isRegistered(type: string) {
|
||||
return this.typeRegistrations.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.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
const clonedAttributes: Record<string, unknown> = {};
|
||||
for (const [attributeName, attributeValue] of Object.entries(attributes)) {
|
||||
if (!typeRegistration.attributesToEncrypt.has(attributeName)) {
|
||||
clonedAttributes[attributeName] = attributeValue;
|
||||
}
|
||||
}
|
||||
|
||||
return clonedAttributes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes saved object attributes for the specified type and encrypts all of them that are supposed
|
||||
* to be encrypted if any and returns that __NEW__ attributes dictionary back. If none of the
|
||||
* 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.
|
||||
* @throws Will throw if encryption fails for whatever reason.
|
||||
*/
|
||||
public async encryptAttributes<T extends Record<string, unknown>>(
|
||||
descriptor: SavedObjectDescriptor,
|
||||
attributes: T
|
||||
): Promise<T> {
|
||||
const typeRegistration = this.typeRegistrations.get(descriptor.type);
|
||||
if (typeRegistration === undefined) {
|
||||
return attributes;
|
||||
}
|
||||
|
||||
const encryptionAAD = this.getAAD(typeRegistration, descriptor, attributes);
|
||||
const encryptedAttributes: Record<string, string> = {};
|
||||
for (const attributeName of typeRegistration.attributesToEncrypt) {
|
||||
const attributeValue = attributes[attributeName];
|
||||
if (attributeValue != null) {
|
||||
try {
|
||||
encryptedAttributes[attributeName] = await this.crypto.encrypt(
|
||||
attributeValue,
|
||||
encryptionAAD
|
||||
);
|
||||
} catch (err) {
|
||||
this.log.error(`Failed to encrypt "${attributeName}" attribute: ${err.message || err}`);
|
||||
this.audit.encryptAttributeFailure(attributeName, descriptor);
|
||||
|
||||
throw new EncryptionError(
|
||||
`Unable to encrypt attribute "${attributeName}"`,
|
||||
attributeName,
|
||||
err
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
this.log.debug(
|
||||
`The following attributes of saved object "${descriptorToArray(
|
||||
descriptor
|
||||
)}" should have been encrypted: ${Array.from(
|
||||
typeRegistration.attributesToEncrypt
|
||||
)}, but found only: ${encryptedAttributesKeys}`
|
||||
);
|
||||
}
|
||||
|
||||
if (encryptedAttributesKeys.length === 0) {
|
||||
return attributes;
|
||||
}
|
||||
|
||||
this.audit.encryptAttributesSuccess(encryptedAttributesKeys, descriptor);
|
||||
|
||||
return {
|
||||
...attributes,
|
||||
...encryptedAttributes,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes saved object attributes for the specified type and decrypts all of them that are supposed
|
||||
* to be encrypted if any and returns that __NEW__ attributes dictionary back. If none of the
|
||||
* 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.
|
||||
* @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
|
||||
): Promise<T> {
|
||||
const typeRegistration = this.typeRegistrations.get(descriptor.type);
|
||||
if (typeRegistration === undefined) {
|
||||
return attributes;
|
||||
}
|
||||
|
||||
const encryptionAAD = this.getAAD(typeRegistration, descriptor, attributes);
|
||||
const decryptedAttributes: Record<string, string> = {};
|
||||
for (const attributeName of typeRegistration.attributesToEncrypt) {
|
||||
const attributeValue = attributes[attributeName];
|
||||
if (attributeValue == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof attributeValue !== 'string') {
|
||||
this.audit.decryptAttributeFailure(attributeName, descriptor);
|
||||
throw new Error(
|
||||
`Encrypted "${attributeName}" attribute should be a string, but found ${typeDetect(
|
||||
attributeValue
|
||||
)}`
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
decryptedAttributes[attributeName] = await this.crypto.decrypt(
|
||||
attributeValue,
|
||||
encryptionAAD
|
||||
);
|
||||
} catch (err) {
|
||||
this.log.error(`Failed to decrypt "${attributeName}" attribute: ${err.message || err}`);
|
||||
this.audit.decryptAttributeFailure(attributeName, descriptor);
|
||||
|
||||
throw new EncryptionError(
|
||||
`Unable to decrypt attribute "${attributeName}"`,
|
||||
attributeName,
|
||||
err
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
this.log.debug(
|
||||
`The following attributes of saved object "${descriptorToArray(
|
||||
descriptor
|
||||
)}" should have been decrypted: ${Array.from(
|
||||
typeRegistration.attributesToEncrypt
|
||||
)}, but found only: ${decryptedAttributesKeys}`
|
||||
);
|
||||
}
|
||||
|
||||
if (decryptedAttributesKeys.length === 0) {
|
||||
return attributes;
|
||||
}
|
||||
|
||||
this.audit.decryptAttributesSuccess(decryptedAttributesKeys, descriptor);
|
||||
|
||||
return {
|
||||
...attributes,
|
||||
...decryptedAttributes,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 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<T extends Record<string, unknown>>(
|
||||
typeRegistration: EncryptedSavedObjectTypeRegistration,
|
||||
descriptor: SavedObjectDescriptor,
|
||||
attributes: T
|
||||
) {
|
||||
// Collect all attributes (both keys and values) that should contribute to AAD.
|
||||
const attributesAAD = {} as T;
|
||||
for (const [attributeKey, attributeValue] of Object.entries(attributes)) {
|
||||
if (
|
||||
!typeRegistration.attributesToEncrypt.has(attributeKey) &&
|
||||
(typeRegistration.attributesToExcludeFromAAD == null ||
|
||||
!typeRegistration.attributesToExcludeFromAAD.has(attributeKey))
|
||||
) {
|
||||
attributesAAD[attributeKey] = attributeValue;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(attributesAAD).length) {
|
||||
this.log.debug(
|
||||
`The AAD for saved object "${descriptorToArray(
|
||||
descriptor
|
||||
)}" does not include any attributes.`
|
||||
);
|
||||
}
|
||||
|
||||
return stringify([...descriptorToArray(descriptor), attributesAAD]);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
/*
|
||||
* 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 { EncryptionError } 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',
|
||||
cause
|
||||
);
|
||||
|
||||
expect(encryptionError).toBeInstanceOf(EncryptionError);
|
||||
expect(encryptionError.message).toBe('Unable to encrypt attribute "someAttr"');
|
||||
expect(encryptionError.attributeName).toBe('someAttr');
|
||||
expect(encryptionError.cause).toBe(cause);
|
||||
});
|
|
@ -0,0 +1,19 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
export class EncryptionError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly attributeName: string,
|
||||
public readonly cause?: Error
|
||||
) {
|
||||
super(message);
|
||||
|
||||
// Set the prototype explicitly, see:
|
||||
// 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);
|
||||
}
|
||||
}
|
13
x-pack/plugins/encrypted_saved_objects/server/lib/index.ts
Normal file
13
x-pack/plugins/encrypted_saved_objects/server/lib/index.ts
Normal file
|
@ -0,0 +1,13 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
export {
|
||||
EncryptedSavedObjectsService,
|
||||
EncryptedSavedObjectTypeRegistration,
|
||||
} from './encrypted_saved_objects_service';
|
||||
export { EncryptionError } from './encryption_error';
|
||||
export { EncryptedSavedObjectsAuditLogger } from './encrypted_saved_objects_audit_logger';
|
||||
export { EncryptedSavedObjectsClientWrapper } from './encrypted_saved_objects_client_wrapper';
|
86
x-pack/plugins/encrypted_saved_objects/server/plugin.ts
Normal file
86
x-pack/plugins/encrypted_saved_objects/server/plugin.ts
Normal file
|
@ -0,0 +1,86 @@
|
|||
/*
|
||||
* 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 crypto from 'crypto';
|
||||
import { Legacy, Server } from 'kibana';
|
||||
import { SavedObjectsRepository } from 'src/legacy/server/saved_objects/service/lib';
|
||||
import { BaseOptions } from 'src/legacy/server/saved_objects/service/saved_objects_client';
|
||||
import {
|
||||
EncryptedSavedObjectsService,
|
||||
EncryptedSavedObjectTypeRegistration,
|
||||
EncryptionError,
|
||||
EncryptedSavedObjectsAuditLogger,
|
||||
EncryptedSavedObjectsClientWrapper,
|
||||
} from './lib';
|
||||
|
||||
export const PLUGIN_ID = 'encrypted_saved_objects';
|
||||
export const CONFIG_PREFIX = `xpack.${PLUGIN_ID}`;
|
||||
|
||||
interface CoreSetup {
|
||||
config: { encryptionKey?: string };
|
||||
elasticsearch: Legacy.Plugins.elasticsearch.Plugin;
|
||||
savedObjects: Legacy.SavedObjectsService;
|
||||
}
|
||||
|
||||
interface PluginsSetup {
|
||||
audit: unknown;
|
||||
}
|
||||
|
||||
export class Plugin {
|
||||
constructor(private readonly log: Server.Logger) {}
|
||||
|
||||
public setup(core: CoreSetup, plugins: PluginsSetup) {
|
||||
let encryptionKey = core.config.encryptionKey;
|
||||
if (encryptionKey == null) {
|
||||
this.log.warn(
|
||||
`Generating a random key for ${CONFIG_PREFIX}.encryptionKey. To be able ` +
|
||||
'to decrypt encrypted saved objects attributes after restart, please set ' +
|
||||
`${CONFIG_PREFIX}.encryptionKey in kibana.yml`
|
||||
);
|
||||
|
||||
encryptionKey = crypto.randomBytes(16).toString('hex');
|
||||
}
|
||||
|
||||
const service = Object.freeze(
|
||||
new EncryptedSavedObjectsService(
|
||||
encryptionKey,
|
||||
core.savedObjects.types,
|
||||
this.log,
|
||||
new EncryptedSavedObjectsAuditLogger(plugins.audit)
|
||||
)
|
||||
);
|
||||
|
||||
// Register custom saved object client that will encrypt, decrypt and strip saved object
|
||||
// attributes where appropriate for any saved object repository request. We choose max possible
|
||||
// priority for this wrapper to allow all other wrappers to set proper `namespace` for the Saved
|
||||
// Object (e.g. wrapper registered by the Spaces plugin) before we encrypt attributes since
|
||||
// `namespace` is included into AAD.
|
||||
core.savedObjects.addScopedSavedObjectsClientWrapperFactory(
|
||||
Number.MAX_SAFE_INTEGER,
|
||||
({ client: baseClient }) => new EncryptedSavedObjectsClientWrapper({ baseClient, service })
|
||||
);
|
||||
|
||||
const internalRepository: SavedObjectsRepository = core.savedObjects.getSavedObjectsRepository(
|
||||
core.elasticsearch.getCluster('admin').callWithInternalUser
|
||||
);
|
||||
|
||||
return {
|
||||
isEncryptionError: (error: Error) => error instanceof EncryptionError,
|
||||
registerType: (typeRegistration: EncryptedSavedObjectTypeRegistration) =>
|
||||
service.registerType(typeRegistration),
|
||||
getDecryptedAsInternalUser: async (type: string, id: string, options?: BaseOptions) => {
|
||||
const savedObject = await internalRepository.get(type, id, options);
|
||||
return {
|
||||
...savedObject,
|
||||
attributes: await service.decryptAttributes(
|
||||
{ type, id, namespace: options && options.namespace },
|
||||
savedObject.attributes
|
||||
),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
|
@ -198,7 +198,7 @@ export const security = (kibana) => new kibana.Plugin({
|
|||
return new savedObjects.SavedObjectsClient(callWithRequestRepository);
|
||||
});
|
||||
|
||||
savedObjects.addScopedSavedObjectsClientWrapperFactory(Number.MIN_VALUE, ({ client, request }) => {
|
||||
savedObjects.addScopedSavedObjectsClientWrapperFactory(Number.MIN_SAFE_INTEGER, ({ client, request }) => {
|
||||
if (authorization.mode.useRbacForRequest(request)) {
|
||||
return new SecureSavedObjectsClientWrapper({
|
||||
actions: authorization.actions,
|
||||
|
|
|
@ -169,7 +169,7 @@ export const spaces = (kibana: Record<string, any>) =>
|
|||
types,
|
||||
} = server.savedObjects as SavedObjectsService;
|
||||
addScopedSavedObjectsClientWrapperFactory(
|
||||
Number.MAX_VALUE,
|
||||
Number.MAX_SAFE_INTEGER - 1,
|
||||
spacesSavedObjectsClientWrapperFactory(spacesService, types)
|
||||
);
|
||||
|
||||
|
|
|
@ -18,6 +18,7 @@ export default async function ({ readConfigFile }) {
|
|||
return {
|
||||
testFiles: [
|
||||
require.resolve('./test_suites/task_manager'),
|
||||
require.resolve('./test_suites/encrypted_saved_objects'),
|
||||
],
|
||||
services: {
|
||||
retry: kibanaFunctionalConfig.get('services.retry'),
|
||||
|
|
|
@ -0,0 +1,48 @@
|
|||
/*
|
||||
* 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 { Request } from 'hapi';
|
||||
import { boomify, badRequest } from 'boom';
|
||||
import { Legacy } from 'kibana';
|
||||
|
||||
const SAVED_OBJECT_WITH_SECRET_TYPE = 'saved-object-with-secret';
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default function esoPlugin(kibana: any) {
|
||||
return new kibana.Plugin({
|
||||
id: 'eso',
|
||||
require: ['encrypted_saved_objects'],
|
||||
uiExports: { mappings: require('./mappings.json') },
|
||||
init(server: Legacy.Server) {
|
||||
server.route({
|
||||
method: 'GET',
|
||||
path: '/api/saved_objects/get-decrypted-as-internal-user/{id}',
|
||||
async handler(request: Request) {
|
||||
const namespace = server.plugins.spaces && server.plugins.spaces.getSpaceId(request);
|
||||
try {
|
||||
return await (server.plugins as any).encrypted_saved_objects.getDecryptedAsInternalUser(
|
||||
SAVED_OBJECT_WITH_SECRET_TYPE,
|
||||
request.params.id,
|
||||
{ namespace: namespace === 'default' ? undefined : namespace }
|
||||
);
|
||||
} catch (err) {
|
||||
if ((server.plugins as any).encrypted_saved_objects.isEncryptionError(err)) {
|
||||
return badRequest('Failed to encrypt attributes');
|
||||
}
|
||||
|
||||
return boomify(err);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
(server.plugins as any).encrypted_saved_objects.registerType({
|
||||
type: SAVED_OBJECT_WITH_SECRET_TYPE,
|
||||
attributesToEncrypt: new Set(['privateProperty']),
|
||||
attributesToExcludeFromAAD: new Set(['publicPropertyExcludedFromAAD']),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"saved-object-with-secret": {
|
||||
"properties": {
|
||||
"publicProperty": {
|
||||
"type": "keyword"
|
||||
},
|
||||
"publicPropertyExcludedFromAAD": {
|
||||
"type": "keyword"
|
||||
},
|
||||
"privateProperty": {
|
||||
"type": "binary"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"name": "eso",
|
||||
"version": "kibana"
|
||||
}
|
|
@ -0,0 +1,284 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License;
|
||||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import expect from '@kbn/expect';
|
||||
import { SavedObject } from 'src/legacy/server/saved_objects/service';
|
||||
import { KibanaFunctionalTestDefaultProviders } from '../../../types/providers';
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default function({ getService }: KibanaFunctionalTestDefaultProviders) {
|
||||
const es = getService('es');
|
||||
const chance = getService('chance');
|
||||
const supertest = getService('supertest');
|
||||
|
||||
const SAVED_OBJECT_WITH_SECRET_TYPE = 'saved-object-with-secret';
|
||||
|
||||
function runTests(getURLAPIBaseURL: () => string, generateRawID: (id: string) => string) {
|
||||
async function getRawSavedObjectAttributes(id: string) {
|
||||
const {
|
||||
_source: { [SAVED_OBJECT_WITH_SECRET_TYPE]: savedObject },
|
||||
} = await es.get({
|
||||
id: generateRawID(id),
|
||||
type: '_doc',
|
||||
index: '.kibana',
|
||||
});
|
||||
|
||||
return savedObject;
|
||||
}
|
||||
|
||||
let savedObjectOriginalAttributes: {
|
||||
publicProperty: string;
|
||||
publicPropertyExcludedFromAAD: string;
|
||||
privateProperty: string;
|
||||
};
|
||||
|
||||
let savedObject: SavedObject;
|
||||
beforeEach(async () => {
|
||||
savedObjectOriginalAttributes = {
|
||||
publicProperty: chance.string(),
|
||||
publicPropertyExcludedFromAAD: chance.string(),
|
||||
privateProperty: chance.string(),
|
||||
};
|
||||
|
||||
const { body } = await supertest
|
||||
.post(`${getURLAPIBaseURL()}${SAVED_OBJECT_WITH_SECRET_TYPE}`)
|
||||
.set('kbn-xsrf', 'xxx')
|
||||
.send({ attributes: savedObjectOriginalAttributes }, {})
|
||||
.expect(200);
|
||||
|
||||
savedObject = body;
|
||||
});
|
||||
|
||||
it('#create encrypts attributes and strips them from response', async () => {
|
||||
expect(savedObject.attributes).to.eql({
|
||||
publicProperty: savedObjectOriginalAttributes.publicProperty,
|
||||
publicPropertyExcludedFromAAD: savedObjectOriginalAttributes.publicPropertyExcludedFromAAD,
|
||||
});
|
||||
|
||||
const rawAttributes = await getRawSavedObjectAttributes(savedObject.id);
|
||||
expect(rawAttributes.publicProperty).to.be(savedObjectOriginalAttributes.publicProperty);
|
||||
expect(rawAttributes.publicPropertyExcludedFromAAD).to.be(
|
||||
savedObjectOriginalAttributes.publicPropertyExcludedFromAAD
|
||||
);
|
||||
|
||||
expect(rawAttributes.privateProperty).to.not.be.empty();
|
||||
expect(rawAttributes.privateProperty).to.not.be(
|
||||
savedObjectOriginalAttributes.privateProperty
|
||||
);
|
||||
});
|
||||
|
||||
it('#bulkCreate encrypts attributes and strips them from response', async () => {
|
||||
const bulkCreateParams = [
|
||||
{
|
||||
type: SAVED_OBJECT_WITH_SECRET_TYPE,
|
||||
attributes: {
|
||||
publicProperty: chance.string(),
|
||||
publicPropertyExcludedFromAAD: chance.string(),
|
||||
privateProperty: chance.string(),
|
||||
},
|
||||
},
|
||||
{
|
||||
type: SAVED_OBJECT_WITH_SECRET_TYPE,
|
||||
attributes: {
|
||||
publicProperty: chance.string(),
|
||||
publicPropertyExcludedFromAAD: chance.string(),
|
||||
privateProperty: chance.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 rawAttributes = await getRawSavedObjectAttributes(savedObjects[index].id);
|
||||
|
||||
expect(attributesFromResponse).to.eql({
|
||||
publicProperty: attributesFromRequest.publicProperty,
|
||||
publicPropertyExcludedFromAAD: attributesFromRequest.publicPropertyExcludedFromAAD,
|
||||
});
|
||||
|
||||
expect(rawAttributes.publicProperty).to.be(attributesFromRequest.publicProperty);
|
||||
expect(rawAttributes.publicPropertyExcludedFromAAD).to.be(
|
||||
attributesFromRequest.publicPropertyExcludedFromAAD
|
||||
);
|
||||
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}`)
|
||||
.expect(200);
|
||||
|
||||
expect(response.attributes).to.eql({
|
||||
publicProperty: savedObjectOriginalAttributes.publicProperty,
|
||||
publicPropertyExcludedFromAAD: savedObjectOriginalAttributes.publicPropertyExcludedFromAAD,
|
||||
});
|
||||
});
|
||||
|
||||
it('#find strips encrypted attributes from response', async () => {
|
||||
const {
|
||||
body: { saved_objects: savedObjects },
|
||||
} = await supertest
|
||||
.get(`${getURLAPIBaseURL()}_find?type=${SAVED_OBJECT_WITH_SECRET_TYPE}`)
|
||||
.expect(200);
|
||||
|
||||
expect(savedObjects).to.have.length(1);
|
||||
expect(savedObjects[0].id).to.be(savedObject.id);
|
||||
expect(savedObjects[0].attributes).to.eql({
|
||||
publicProperty: savedObjectOriginalAttributes.publicProperty,
|
||||
publicPropertyExcludedFromAAD: savedObjectOriginalAttributes.publicPropertyExcludedFromAAD,
|
||||
});
|
||||
});
|
||||
|
||||
it('#bulkGet strips encrypted attributes from response', async () => {
|
||||
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: savedObjectOriginalAttributes.publicProperty,
|
||||
publicPropertyExcludedFromAAD: savedObjectOriginalAttributes.publicPropertyExcludedFromAAD,
|
||||
});
|
||||
});
|
||||
|
||||
it('#update encrypts attributes and strips them from response', async () => {
|
||||
const updatedAttributes = {
|
||||
publicProperty: chance.string(),
|
||||
publicPropertyExcludedFromAAD: chance.string(),
|
||||
privateProperty: chance.string(),
|
||||
};
|
||||
|
||||
const { body: response } = await supertest
|
||||
.put(`${getURLAPIBaseURL()}${SAVED_OBJECT_WITH_SECRET_TYPE}/${savedObject.id}`)
|
||||
.set('kbn-xsrf', 'xxx')
|
||||
.send({ attributes: updatedAttributes }, {})
|
||||
.expect(200);
|
||||
|
||||
expect(response.attributes).to.eql({
|
||||
publicProperty: updatedAttributes.publicProperty,
|
||||
publicPropertyExcludedFromAAD: updatedAttributes.publicPropertyExcludedFromAAD,
|
||||
});
|
||||
|
||||
const rawAttributes = await getRawSavedObjectAttributes(savedObject.id);
|
||||
expect(rawAttributes.publicProperty).to.be(updatedAttributes.publicProperty);
|
||||
expect(rawAttributes.publicPropertyExcludedFromAAD).to.be(
|
||||
updatedAttributes.publicPropertyExcludedFromAAD
|
||||
);
|
||||
|
||||
expect(rawAttributes.privateProperty).to.not.be.empty();
|
||||
expect(rawAttributes.privateProperty).to.not.be(updatedAttributes.privateProperty);
|
||||
});
|
||||
|
||||
it('#getDecryptedAsInternalUser decrypts and returns all attributes', async () => {
|
||||
const { body: decryptedResponse } = await supertest
|
||||
.get(`${getURLAPIBaseURL()}get-decrypted-as-internal-user/${savedObject.id}`)
|
||||
.expect(200);
|
||||
|
||||
expect(decryptedResponse.attributes).to.eql(savedObjectOriginalAttributes);
|
||||
});
|
||||
|
||||
it('#getDecryptedAsInternalUser is able to decrypt if non-AAD attribute has changed', async () => {
|
||||
const updatedAttributes = { publicPropertyExcludedFromAAD: chance.string() };
|
||||
|
||||
const { body: response } = await supertest
|
||||
.put(`${getURLAPIBaseURL()}${SAVED_OBJECT_WITH_SECRET_TYPE}/${savedObject.id}`)
|
||||
.set('kbn-xsrf', 'xxx')
|
||||
.send({ attributes: updatedAttributes }, {})
|
||||
.expect(200);
|
||||
|
||||
expect(response.attributes).to.eql({
|
||||
publicPropertyExcludedFromAAD: updatedAttributes.publicPropertyExcludedFromAAD,
|
||||
});
|
||||
|
||||
const { body: decryptedResponse } = await supertest
|
||||
.get(`${getURLAPIBaseURL()}get-decrypted-as-internal-user/${savedObject.id}`)
|
||||
.expect(200);
|
||||
|
||||
expect(decryptedResponse.attributes).to.eql({
|
||||
...savedObjectOriginalAttributes,
|
||||
publicPropertyExcludedFromAAD: updatedAttributes.publicPropertyExcludedFromAAD,
|
||||
});
|
||||
});
|
||||
|
||||
it('#getDecryptedAsInternalUser fails to decrypt if AAD attribute has changed', async () => {
|
||||
const updatedAttributes = { publicProperty: chance.string() };
|
||||
|
||||
const { body: response } = await supertest
|
||||
.put(`${getURLAPIBaseURL()}${SAVED_OBJECT_WITH_SECRET_TYPE}/${savedObject.id}`)
|
||||
.set('kbn-xsrf', 'xxx')
|
||||
.send({ attributes: updatedAttributes }, {})
|
||||
.expect(200);
|
||||
|
||||
expect(response.attributes).to.eql({
|
||||
publicProperty: updatedAttributes.publicProperty,
|
||||
});
|
||||
|
||||
// Bad request means that we successfully detected "EncryptionError" (not unexpected one).
|
||||
await supertest
|
||||
.get(`${getURLAPIBaseURL()}get-decrypted-as-internal-user/${savedObject.id}`)
|
||||
.expect(400, {
|
||||
statusCode: 400,
|
||||
error: 'Bad Request',
|
||||
message: 'Failed to encrypt attributes',
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
describe('encrypted saved objects API', () => {
|
||||
afterEach(async () => {
|
||||
await es.deleteByQuery({
|
||||
index: '.kibana',
|
||||
q: `type:${SAVED_OBJECT_WITH_SECRET_TYPE}`,
|
||||
refresh: true,
|
||||
});
|
||||
});
|
||||
|
||||
describe('within a default space', () => {
|
||||
runTests(() => '/api/saved_objects/', id => `${SAVED_OBJECT_WITH_SECRET_TYPE}:${id}`);
|
||||
});
|
||||
|
||||
describe('within a custom space', () => {
|
||||
const SPACE_ID = 'eso';
|
||||
|
||||
before(async () => {
|
||||
await supertest
|
||||
.post('/api/spaces/space')
|
||||
.set('kbn-xsrf', 'xxx')
|
||||
.send({ id: SPACE_ID, name: SPACE_ID })
|
||||
.expect(200);
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await supertest
|
||||
.delete(`/api/spaces/space/${SPACE_ID}`)
|
||||
.set('kbn-xsrf', 'xxx')
|
||||
.expect(204);
|
||||
});
|
||||
|
||||
runTests(
|
||||
() => `/s/${SPACE_ID}/api/saved_objects/`,
|
||||
id => `${SPACE_ID}:${SAVED_OBJECT_WITH_SECRET_TYPE}:${id}`
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
|
@ -0,0 +1,15 @@
|
|||
/*
|
||||
* 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 { KibanaFunctionalTestDefaultProviders } from '../../../types/providers';
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default function({ loadTestFile }: KibanaFunctionalTestDefaultProviders) {
|
||||
describe('encrypted_saved_objects', function encryptedSavedObjectsSuite() {
|
||||
this.tags('ciGroup2');
|
||||
loadTestFile(require.resolve('./encrypted_saved_objects_api'));
|
||||
});
|
||||
}
|
|
@ -1438,10 +1438,10 @@
|
|||
through2 "^2.0.0"
|
||||
update-notifier "^0.5.0"
|
||||
|
||||
"@elastic/node-crypto@0.1.2":
|
||||
version "0.1.2"
|
||||
resolved "https://registry.yarnpkg.com/@elastic/node-crypto/-/node-crypto-0.1.2.tgz#c18ac282f635e88f041cc1555d806e492ca8f3b1"
|
||||
integrity sha1-wYrCgvY16I8EHMFVXYBuSSyo87E=
|
||||
"@elastic/node-crypto@^1.0.0":
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/@elastic/node-crypto/-/node-crypto-1.0.0.tgz#4d325df333fe1319556bb4d54214098ada1171d4"
|
||||
integrity sha512-bbjbEyILPRTRt0xnda18OttLtlkJBPuXx3CjISUSn9jhWqHoFMzfOaZ73D5jxZE2SaFZUrJYfPpqXP6qqPufAQ==
|
||||
|
||||
"@elastic/nodegit@0.25.0-alpha.19":
|
||||
version "0.25.0-alpha.19"
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue