[7.x] Introduce Encrypted Saved Objects plugin (#36045)

This commit is contained in:
Aleh Zasypkin 2019-05-03 19:21:10 +02:00 committed by GitHub
parent 4466dab205
commit 4c26ccca65
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 2910 additions and 7 deletions

View file

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

View file

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

View 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}"
```

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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';

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,15 @@
{
"saved-object-with-secret": {
"properties": {
"publicProperty": {
"type": "keyword"
},
"publicPropertyExcludedFromAAD": {
"type": "keyword"
},
"privateProperty": {
"type": "binary"
}
}
}
}

View file

@ -0,0 +1,4 @@
{
"name": "eso",
"version": "kibana"
}

View file

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

View file

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

View file

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