[Encrypted Saved Objects] Adds support for migrations in ESO (#69513) (#69977)

Introduces migrations into Encrypted Saved Objects.

The two main changes here are:
1. The addition of a createMigration api on the EncryptedSavedObjectsPluginSetup.
2. A change in SavedObjects migration to ensure they don't block the event loop.
This commit is contained in:
Gidi Meir Morris 2020-06-25 20:01:04 +01:00 committed by GitHub
parent 03561745f9
commit 95a60c14f6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 4282 additions and 155 deletions

View file

@ -46,6 +46,7 @@ export { httpServiceMock } from './http/http_service.mock';
export { loggingSystemMock } from './logging/logging_system.mock';
export { savedObjectsRepositoryMock } from './saved_objects/service/lib/repository.mock';
export { savedObjectsServiceMock } from './saved_objects/saved_objects_service.mock';
export { migrationMocks } from './saved_objects/migrations/mocks';
export { typeRegistryMock as savedObjectsTypeRegistryMock } from './saved_objects/saved_objects_type_registry.mock';
export { uiSettingsServiceMock } from './ui_settings/ui_settings_service.mock';
export { metricsServiceMock } from './metrics/metrics_service.mock';

View file

@ -195,7 +195,7 @@ async function migrateSourceToDest(context: Context) {
await Index.write(
callCluster,
dest.indexName,
migrateRawDocs(serializer, documentMigrator.migrate, docs, log)
await migrateRawDocs(serializer, documentMigrator.migrate, docs, log)
);
}
}

View file

@ -26,7 +26,7 @@ import { createSavedObjectsMigrationLoggerMock } from '../../migrations/mocks';
describe('migrateRawDocs', () => {
test('converts raw docs to saved objects', async () => {
const transform = jest.fn<any, any>((doc: any) => _.set(doc, 'attributes.name', 'HOI!'));
const result = migrateRawDocs(
const result = await migrateRawDocs(
new SavedObjectsSerializer(new SavedObjectTypeRegistry()),
transform,
[
@ -55,7 +55,7 @@ describe('migrateRawDocs', () => {
const transform = jest.fn<any, any>((doc: any) =>
_.set(_.cloneDeep(doc), 'attributes.name', 'TADA')
);
const result = migrateRawDocs(
const result = await migrateRawDocs(
new SavedObjectsSerializer(new SavedObjectTypeRegistry()),
transform,
[

View file

@ -21,7 +21,11 @@
* This file provides logic for migrating raw documents.
*/
import { SavedObjectsRawDoc, SavedObjectsSerializer } from '../../serialization';
import {
SavedObjectsRawDoc,
SavedObjectsSerializer,
SavedObjectUnsanitizedDoc,
} from '../../serialization';
import { TransformFn } from './document_migrator';
import { SavedObjectsMigrationLogger } from '.';
@ -33,26 +37,51 @@ import { SavedObjectsMigrationLogger } from '.';
* @param {SavedObjectsRawDoc[]} rawDocs
* @returns {SavedObjectsRawDoc[]}
*/
export function migrateRawDocs(
export async function migrateRawDocs(
serializer: SavedObjectsSerializer,
migrateDoc: TransformFn,
rawDocs: SavedObjectsRawDoc[],
log: SavedObjectsMigrationLogger
): SavedObjectsRawDoc[] {
return rawDocs.map((raw) => {
): Promise<SavedObjectsRawDoc[]> {
const migrateDocWithoutBlocking = transformNonBlocking(migrateDoc);
const processedDocs = [];
for (const raw of rawDocs) {
if (serializer.isRawSavedObject(raw)) {
const savedObject = serializer.rawToSavedObject(raw);
savedObject.migrationVersion = savedObject.migrationVersion || {};
return serializer.savedObjectToRaw({
references: [],
...migrateDoc(savedObject),
});
processedDocs.push(
serializer.savedObjectToRaw({
references: [],
...(await migrateDocWithoutBlocking(savedObject)),
})
);
} else {
log.error(
`Error: Unable to migrate the corrupt Saved Object document ${raw._id}. To prevent Kibana from performing a migration on every restart, please delete or fix this document by ensuring that the namespace and type in the document's id matches the values in the namespace and type fields.`,
{ rawDocument: raw }
);
processedDocs.push(raw);
}
log.error(
`Error: Unable to migrate the corrupt Saved Object document ${raw._id}. To prevent Kibana from performing a migration on every restart, please delete or fix this document by ensuring that the namespace and type in the document's id matches the values in the namespace and type fields.`,
{ rawDocument: raw }
);
return raw;
});
}
return processedDocs;
}
/**
* Migration transform functions are potentially CPU heavy e.g. doing decryption/encryption
* or (de)/serializing large JSON payloads.
* Executing all transforms for a batch in a synchronous loop can block the event-loop for a long time.
* To prevent this we use setImmediate to ensure that the event-loop can process other parallel
* work in between each transform.
*/
function transformNonBlocking(
transform: TransformFn
): (doc: SavedObjectUnsanitizedDoc) => Promise<SavedObjectUnsanitizedDoc> {
// promises aren't enough to unblock the event loop
return (doc: SavedObjectUnsanitizedDoc) =>
new Promise((resolve) => {
// set immediate is though
setImmediate(() => {
resolve(transform(doc));
});
});
}

View file

@ -199,7 +199,7 @@
"@elastic/eui": "24.1.0",
"@elastic/filesaver": "1.1.2",
"@elastic/maki": "6.3.0",
"@elastic/node-crypto": "1.1.1",
"@elastic/node-crypto": "1.2.1",
"@elastic/numeral": "^2.5.0",
"@kbn/babel-preset": "1.0.0",
"@kbn/config-schema": "1.0.0",

View file

@ -99,6 +99,138 @@ const savedObjectWithDecryptedContent = await esoClient.getDecryptedAsInternalU
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.
### Defining migrations
EncryptedSavedObjects rely on standard SavedObject migrations, but due to the additional complexity introduced by the need to decrypt and reencrypt the migrated document, there are some caveats to how we support this.
The good news is, most of this complexity is abstracted away by the plugin and all you need to do is leverage our api.
The `EncryptedSavedObjects` Plugin _SetupContract_ exposes an `createMigration` api which facilitates defining a migration for your EncryptedSavedObject type.
The `createMigration` function takes four arguments:
|Argument|Description|Type|
|---|---|---|
|isMigrationNeededPredicate|A predicate which is called for each document, prior to being decrypted, which confirms whether a document requires migration or not. This predicate is important as the decryption step is costly and we would rather not decrypt and re-encrypt a document if we can avoid it.|function|
|migration|A migration function which will migrate each decrypted document from the old shape to the new one.|function|
|inputType|Optional. An `EncryptedSavedObjectTypeRegistration` which describes the ESOType of the input (the document prior to migration). If this type isn't provided, we'll assume the input doc follows the registered type. |object|
|migratedType| Optional. An `EncryptedSavedObjectTypeRegistration` which describes the ESOType of the output (the document after migration). If this type isn't provided, we'll assume the migrated doc follows the registered type.|object|
### Example: Migrating a Value
```typescript
encryptedSavedObjects.registerType({
type: 'alert',
attributesToEncrypt: new Set(['apiKey']),
attributesToExcludeFromAAD: new Set(['mutedInstanceIds', 'updatedBy']),
});
const migration790 = encryptedSavedObjects.createMigration<RawAlert, RawAlert>(
function shouldBeMigrated(doc): doc is SavedObjectUnsanitizedDoc<RawAlert> {
return doc.consumer === 'alerting' || doc.consumer === undefined;
},
(doc: SavedObjectUnsanitizedDoc<RawAlert>): SavedObjectUnsanitizedDoc<RawAlert> => {
const {
attributes: { consumer },
} = doc;
return {
...doc,
attributes: {
...doc.attributes,
consumer: consumer === 'alerting' || !consumer ? 'alerts' : consumer,
},
};
}
);
```
In the above example you can see thwe following:
1. In `shouldBeMigrated` we limit the migrated alerts to those whose `consumer` field equals `alerting` or is undefined.
2. In the migration function we then migrate the value of `consumer` to the value we want (`alerts` or `unknown`, depending on the current value). In this function we can assume that only documents with a `consumer` of `alerting` or `undefined` will be passed in, but it's still safest not to, and so we use the current `consumer` as the default when needed.
3. Note that we haven't passed in any type definitions. This is because we can rely on the registered type, as the migration is changing a value and not the shape of the object.
As we said above, an EncryptedSavedObject migration is a normal SavedObjects migration, and so we can plug it into the underlying SavedObject just like any other kind of migration:
```typescript
savedObjects.registerType({
name: 'alert',
hidden: true,
namespaceType: 'single',
migrations: {
// apply this migration in 7.9.0
'7.9.0': migration790,
},
mappings: {
//...
},
});
```
### Example: Migating a Type
If your migration needs to change the type by, for example, removing an encrypted field, you will have to specify the legacy type for the input.
```typescript
encryptedSavedObjects.registerType({
type: 'alert',
attributesToEncrypt: new Set(['apiKey']),
attributesToExcludeFromAAD: new Set(['mutedInstanceIds', 'updatedBy']),
});
const migration790 = encryptedSavedObjects.createMigration<RawAlert, RawAlert>(
function shouldBeMigrated(doc): doc is SavedObjectUnsanitizedDoc<RawAlert> {
return doc.consumer === 'alerting' || doc.consumer === undefined;
},
(doc: SavedObjectUnsanitizedDoc<RawAlert>): SavedObjectUnsanitizedDoc<RawAlert> => {
const {
attributes: { legacyEncryptedField, ...attributes },
} = doc;
return {
...doc,
attributes: {
...attributes
},
};
},
{
type: 'alert',
attributesToEncrypt: new Set(['apiKey', 'legacyEncryptedField']),
attributesToExcludeFromAAD: new Set(['mutedInstanceIds', 'updatedBy']),
}
);
```
As you can see in this example we provide a legacy type which describes the _input_ which needs to be decrypted.
The migration function will default to using the registered type to encrypt the migrated document after the migration is applied.
If you need to migrate between two legacy types, you can specify both types at once:
```typescript
encryptedSavedObjects.registerType({
type: 'alert',
attributesToEncrypt: new Set(['apiKey']),
attributesToExcludeFromAAD: new Set(['mutedInstanceIds', 'updatedBy']),
});
const migration780 = encryptedSavedObjects.createMigration<RawAlert, RawAlert>(
function shouldBeMigrated(doc): doc is SavedObjectUnsanitizedDoc<RawAlert> {
// ...
},
(doc: SavedObjectUnsanitizedDoc<RawAlert>): SavedObjectUnsanitizedDoc<RawAlert> => {
// ...
},
// legacy input type
{
type: 'alert',
attributesToEncrypt: new Set(['apiKey', 'legacyEncryptedField']),
attributesToExcludeFromAAD: new Set(['mutedInstanceIds', 'updatedBy']),
},
// legacy migration type
{
type: 'alert',
attributesToEncrypt: new Set(['apiKey', 'legacyEncryptedField']),
attributesToExcludeFromAAD: new Set(['mutedInstanceIds', 'updatedBy', 'legacyEncryptedField']),
}
);
```
## Testing
### Unit tests

View file

@ -0,0 +1,296 @@
/*
* 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 { SavedObjectUnsanitizedDoc } from 'kibana/server';
import { migrationMocks } from 'src/core/server/mocks';
import { encryptedSavedObjectsServiceMock } from './crypto/index.mock';
import { getCreateMigration } from './create_migration';
afterEach(() => {
jest.clearAllMocks();
});
describe('createMigration()', () => {
const { log } = migrationMocks.createContext();
const inputType = { type: 'known-type-1', attributesToEncrypt: new Set(['firstAttr']) };
const migrationType = {
type: 'known-type-1',
attributesToEncrypt: new Set(['firstAttr', 'secondAttr']),
};
interface InputType {
firstAttr: string;
nonEncryptedAttr?: string;
}
interface MigrationType {
firstAttr: string;
encryptedAttr?: string;
}
const encryptionSavedObjectService = encryptedSavedObjectsServiceMock.create();
it('throws if the types arent compatible', async () => {
const migrationCreator = getCreateMigration(encryptionSavedObjectService, () =>
encryptedSavedObjectsServiceMock.create()
);
expect(() =>
migrationCreator(
function (doc): doc is SavedObjectUnsanitizedDoc {
return true;
},
(doc) => doc,
{
type: 'known-type-1',
attributesToEncrypt: new Set(),
},
{
type: 'known-type-2',
attributesToEncrypt: new Set(),
}
)
).toThrowErrorMatchingInlineSnapshot(
`"An Invalid Encrypted Saved Objects migration is trying to migrate across types (\\"known-type-1\\" => \\"known-type-2\\"), which isn't permitted"`
);
});
describe('migration of an existing type', () => {
it('uses the type in the current service for both input and migration types when none are specified', async () => {
const instantiateServiceWithLegacyType = jest.fn(() =>
encryptedSavedObjectsServiceMock.create()
);
const migrationCreator = getCreateMigration(
encryptionSavedObjectService,
instantiateServiceWithLegacyType
);
const noopMigration = migrationCreator<InputType, MigrationType>(
function (doc): doc is SavedObjectUnsanitizedDoc<InputType> {
return true;
},
(doc) => doc
);
const attributes = {
firstAttr: 'first_attr',
};
encryptionSavedObjectService.decryptAttributesSync.mockReturnValueOnce(attributes);
encryptionSavedObjectService.encryptAttributesSync.mockReturnValueOnce(attributes);
noopMigration(
{
id: '123',
type: 'known-type-1',
namespace: 'namespace',
attributes,
},
{ log }
);
expect(encryptionSavedObjectService.decryptAttributesSync).toHaveBeenCalledWith(
{
id: '123',
type: 'known-type-1',
namespace: 'namespace',
},
attributes
);
expect(encryptionSavedObjectService.encryptAttributesSync).toHaveBeenCalledWith(
{
id: '123',
type: 'known-type-1',
namespace: 'namespace',
},
attributes
);
});
});
describe('migration of a single legacy type', () => {
it('uses the input type as the mirgation type when omitted', async () => {
const serviceWithLegacyType = encryptedSavedObjectsServiceMock.create();
const instantiateServiceWithLegacyType = jest.fn(() => serviceWithLegacyType);
const migrationCreator = getCreateMigration(
encryptionSavedObjectService,
instantiateServiceWithLegacyType
);
const noopMigration = migrationCreator<InputType, MigrationType>(
function (doc): doc is SavedObjectUnsanitizedDoc<InputType> {
return true;
},
(doc) => doc,
inputType
);
const attributes = {
firstAttr: 'first_attr',
};
serviceWithLegacyType.decryptAttributesSync.mockReturnValueOnce(attributes);
encryptionSavedObjectService.encryptAttributesSync.mockReturnValueOnce(attributes);
noopMigration(
{
id: '123',
type: 'known-type-1',
namespace: 'namespace',
attributes,
},
{ log }
);
expect(serviceWithLegacyType.decryptAttributesSync).toHaveBeenCalledWith(
{
id: '123',
type: 'known-type-1',
namespace: 'namespace',
},
attributes
);
expect(encryptionSavedObjectService.encryptAttributesSync).toHaveBeenCalledWith(
{
id: '123',
type: 'known-type-1',
namespace: 'namespace',
},
attributes
);
});
});
describe('migration across two legacy types', () => {
const serviceWithInputLegacyType = encryptedSavedObjectsServiceMock.create();
const serviceWithMigrationLegacyType = encryptedSavedObjectsServiceMock.create();
const instantiateServiceWithLegacyType = jest.fn();
function createMigration() {
instantiateServiceWithLegacyType
.mockImplementationOnce(() => serviceWithInputLegacyType)
.mockImplementationOnce(() => serviceWithMigrationLegacyType);
const migrationCreator = getCreateMigration(
encryptionSavedObjectService,
instantiateServiceWithLegacyType
);
return migrationCreator<InputType, MigrationType>(
function (doc): doc is SavedObjectUnsanitizedDoc<InputType> {
// migrate doc that have the second field
return (
typeof (doc as SavedObjectUnsanitizedDoc<InputType>).attributes.nonEncryptedAttr ===
'string'
);
},
({ attributes: { firstAttr, nonEncryptedAttr }, ...doc }) => ({
attributes: {
// modify an encrypted field
firstAttr: `~~${firstAttr}~~`,
// encrypt a non encrypted field if it's there
...(nonEncryptedAttr ? { encryptedAttr: `${nonEncryptedAttr}` } : {}),
},
...doc,
}),
inputType,
migrationType
);
}
it('doesnt decrypt saved objects that dont need to be migrated', async () => {
const migration = createMigration();
expect(instantiateServiceWithLegacyType).toHaveBeenCalledWith(inputType);
expect(instantiateServiceWithLegacyType).toHaveBeenCalledWith(migrationType);
expect(
migration(
{
id: '123',
type: 'known-type-1',
namespace: 'namespace',
attributes: {
firstAttr: '#####',
},
},
{ log }
)
).toMatchObject({
id: '123',
type: 'known-type-1',
namespace: 'namespace',
attributes: {
firstAttr: '#####',
},
});
expect(serviceWithInputLegacyType.decryptAttributesSync).not.toHaveBeenCalled();
expect(serviceWithMigrationLegacyType.encryptAttributesSync).not.toHaveBeenCalled();
});
it('decrypt, migrates and reencrypts saved objects that need to be migrated', async () => {
const migration = createMigration();
expect(instantiateServiceWithLegacyType).toHaveBeenCalledWith(inputType);
expect(instantiateServiceWithLegacyType).toHaveBeenCalledWith(migrationType);
serviceWithInputLegacyType.decryptAttributesSync.mockReturnValueOnce({
firstAttr: 'first_attr',
nonEncryptedAttr: 'non encrypted',
});
serviceWithMigrationLegacyType.encryptAttributesSync.mockReturnValueOnce({
firstAttr: `#####`,
encryptedAttr: `#####`,
});
expect(
migration(
{
id: '123',
type: 'known-type-1',
namespace: 'namespace',
attributes: {
firstAttr: '#####',
nonEncryptedAttr: 'non encrypted',
},
},
{ log }
)
).toMatchObject({
id: '123',
type: 'known-type-1',
namespace: 'namespace',
attributes: {
firstAttr: '#####',
encryptedAttr: `#####`,
},
});
expect(serviceWithInputLegacyType.decryptAttributesSync).toHaveBeenCalledWith(
{
id: '123',
type: 'known-type-1',
namespace: 'namespace',
},
{
firstAttr: '#####',
nonEncryptedAttr: 'non encrypted',
}
);
expect(serviceWithMigrationLegacyType.encryptAttributesSync).toHaveBeenCalledWith(
{
id: '123',
type: 'known-type-1',
namespace: 'namespace',
},
{
firstAttr: `~~first_attr~~`,
encryptedAttr: 'non encrypted',
}
);
});
});
});

View file

@ -0,0 +1,91 @@
/*
* 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 {
SavedObjectUnsanitizedDoc,
SavedObjectMigrationFn,
SavedObjectMigrationContext,
} from 'src/core/server';
import { EncryptedSavedObjectTypeRegistration, EncryptedSavedObjectsService } from './crypto';
type SavedObjectOptionalMigrationFn<InputAttributes, MigratedAttributes> = (
doc: SavedObjectUnsanitizedDoc<InputAttributes> | SavedObjectUnsanitizedDoc<MigratedAttributes>,
context: SavedObjectMigrationContext
) => SavedObjectUnsanitizedDoc<MigratedAttributes>;
type IsMigrationNeededPredicate<InputAttributes, MigratedAttributes> = (
encryptedDoc:
| SavedObjectUnsanitizedDoc<InputAttributes>
| SavedObjectUnsanitizedDoc<MigratedAttributes>
) => encryptedDoc is SavedObjectUnsanitizedDoc<InputAttributes>;
export type CreateEncryptedSavedObjectsMigrationFn = <
InputAttributes = unknown,
MigratedAttributes = InputAttributes
>(
isMigrationNeededPredicate: IsMigrationNeededPredicate<InputAttributes, MigratedAttributes>,
migration: SavedObjectMigrationFn<InputAttributes, MigratedAttributes>,
inputType?: EncryptedSavedObjectTypeRegistration,
migratedType?: EncryptedSavedObjectTypeRegistration
) => SavedObjectOptionalMigrationFn<InputAttributes, MigratedAttributes>;
export const getCreateMigration = (
encryptedSavedObjectsService: Readonly<EncryptedSavedObjectsService>,
instantiateServiceWithLegacyType: (
typeRegistration: EncryptedSavedObjectTypeRegistration
) => EncryptedSavedObjectsService
): CreateEncryptedSavedObjectsMigrationFn => (
isMigrationNeededPredicate,
migration,
inputType,
migratedType
) => {
if (inputType && migratedType && inputType.type !== migratedType.type) {
throw new Error(
`An Invalid Encrypted Saved Objects migration is trying to migrate across types ("${inputType.type}" => "${migratedType.type}"), which isn't permitted`
);
}
const inputService = inputType
? instantiateServiceWithLegacyType(inputType)
: encryptedSavedObjectsService;
const migratedService = migratedType
? instantiateServiceWithLegacyType(migratedType)
: encryptedSavedObjectsService;
return (encryptedDoc, context) => {
if (!isMigrationNeededPredicate(encryptedDoc)) {
return encryptedDoc;
}
const descriptor = {
id: encryptedDoc.id!,
type: encryptedDoc.type,
namespace: encryptedDoc.namespace,
};
// decrypt the attributes using the input type definition
// then migrate the document
// then encrypt the attributes using the migration type definition
return mapAttributes(
migration(
mapAttributes(encryptedDoc, (inputAttributes) =>
inputService.decryptAttributesSync<any>(descriptor, inputAttributes)
),
context
),
(migratedAttributes) =>
migratedService.encryptAttributesSync<any>(descriptor, migratedAttributes)
);
};
};
function mapAttributes<T>(obj: SavedObjectUnsanitizedDoc<T>, mapper: (attributes: T) => T) {
return Object.assign(obj, {
attributes: mapper(obj.attributes),
});
}

View file

@ -0,0 +1,84 @@
/*
* 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';
function createEncryptedSavedObjectsServiceMock() {
return ({
isRegistered: jest.fn(),
stripOrDecryptAttributes: jest.fn(),
encryptAttributes: jest.fn(),
decryptAttributes: jest.fn(),
encryptAttributesSync: jest.fn(),
decryptAttributesSync: jest.fn(),
} as unknown) as jest.Mocked<EncryptedSavedObjectsService>;
}
export const encryptedSavedObjectsServiceMock = {
create: createEncryptedSavedObjectsServiceMock,
createWithTypes(registrations: EncryptedSavedObjectTypeRegistration[] = []) {
const mock = createEncryptedSavedObjectsServiceMock();
function processAttributes<T extends Record<string, any>>(
descriptor: Pick<SavedObjectDescriptor, 'type'>,
attrs: T,
action: (attrs: T, attrName: string, shouldExpose: boolean) => void
) {
const registration = registrations.find((r) => r.type === descriptor.type);
if (!registration) {
return attrs;
}
const clonedAttrs = { ...attrs };
for (const attr of registration.attributesToEncrypt) {
const [attrName, shouldExpose] =
typeof attr === 'string'
? [attr, false]
: [attr.key, attr.dangerouslyExposeValue === true];
if (attrName in clonedAttrs) {
action(clonedAttrs, attrName, shouldExpose);
}
}
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.stripOrDecryptAttributes.mockImplementation((descriptor, attrs) =>
Promise.resolve({
attributes: processAttributes(descriptor, attrs, (clonedAttrs, attrName, shouldExpose) => {
if (shouldExpose) {
clonedAttrs[attrName] = (clonedAttrs[attrName] as string).slice(1, -1);
} else {
delete clonedAttrs[attrName];
}
}),
})
);
return mock;
},
};

View file

@ -4,10 +4,9 @@
* you may not use this file except in compliance with the Elastic License.
*/
import nodeCrypto, { Crypto } from '@elastic/node-crypto';
import { mockAuthenticatedUser } from '../../../security/common/model/authenticated_user.mock';
jest.mock('@elastic/node-crypto', () => jest.fn());
import { EncryptedSavedObjectsAuditLogger } from '../audit';
import { EncryptedSavedObjectsService } from './encrypted_saved_objects_service';
import { EncryptionError } from './encryption_error';
@ -15,19 +14,37 @@ import { EncryptionError } from './encryption_error';
import { loggingSystemMock } from 'src/core/server/mocks';
import { encryptedSavedObjectsAuditLoggerMock } from '../audit/index.mock';
const crypto = nodeCrypto({ encryptionKey: 'encryption-key-abc' });
const mockNodeCrypto: jest.Mocked<Crypto> = {
encrypt: jest.fn(),
decrypt: jest.fn(),
encryptSync: jest.fn(),
decryptSync: jest.fn(),
};
let service: EncryptedSavedObjectsService;
let mockAuditLogger: jest.Mocked<EncryptedSavedObjectsAuditLogger>;
beforeEach(() => {
// Call actual `@elastic/node-crypto` by default, but allow to override implementation in tests.
mockNodeCrypto.encrypt.mockImplementation(async (input: any, aad?: string) =>
crypto.encrypt(input, aad)
);
mockNodeCrypto.decrypt.mockImplementation(
async (encryptedOutput: string | Buffer, aad?: string) => crypto.decrypt(encryptedOutput, aad)
);
mockNodeCrypto.encryptSync.mockImplementation((input: any, aad?: string) =>
crypto.encryptSync(input, aad)
);
mockNodeCrypto.decryptSync.mockImplementation((encryptedOutput: string | Buffer, aad?: string) =>
crypto.decryptSync(encryptedOutput, aad)
);
mockAuditLogger = encryptedSavedObjectsAuditLoggerMock.create();
// Call actual `@elastic/node-crypto` by default, but allow to override implementation in tests.
jest.requireMock('@elastic/node-crypto').mockImplementation((...args: any[]) => {
const { default: nodeCrypto } = jest.requireActual('@elastic/node-crypto');
return nodeCrypto(...args);
});
service = new EncryptedSavedObjectsService(
'encryption-key-abc',
mockNodeCrypto,
loggingSystemMock.create().get(),
mockAuditLogger
);
@ -35,12 +52,6 @@ beforeEach(() => {
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(() =>
@ -213,15 +224,13 @@ describe('#stripOrDecryptAttributes', () => {
});
describe('#encryptAttributes', () => {
let mockEncrypt: jest.Mock;
beforeEach(() => {
mockEncrypt = jest
.fn()
.mockImplementation(async (valueToEncrypt, aad) => `|${valueToEncrypt}|${aad}|`);
jest.requireMock('@elastic/node-crypto').mockReturnValue({ encrypt: mockEncrypt });
mockNodeCrypto.encrypt.mockImplementation(
async (valueToEncrypt, aad) => `|${valueToEncrypt}|${aad}|`
);
service = new EncryptedSavedObjectsService(
'encryption-key-abc',
mockNodeCrypto,
loggingSystemMock.create().get(),
mockAuditLogger
);
@ -399,7 +408,7 @@ describe('#encryptAttributes', () => {
attributesToEncrypt: new Set(['attrOne', 'attrThree']),
});
mockEncrypt
mockNodeCrypto.encrypt
.mockResolvedValueOnce('Successfully encrypted attrOne')
.mockRejectedValueOnce(new Error('Something went wrong with attrThree...'));
@ -915,7 +924,7 @@ describe('#decryptAttributes', () => {
it('fails if encrypted with another encryption key', async () => {
service = new EncryptedSavedObjectsService(
'encryption-key-abc*',
nodeCrypto({ encryptionKey: 'encryption-key-abc*' }),
loggingSystemMock.create().get(),
mockAuditLogger
);
@ -941,3 +950,532 @@ describe('#decryptAttributes', () => {
});
});
});
describe('#encryptAttributesSync', () => {
beforeEach(() => {
mockNodeCrypto.encryptSync.mockImplementation(
(valueToEncrypt, aad) => `|${valueToEncrypt}|${aad}|`
);
service = new EncryptedSavedObjectsService(
mockNodeCrypto,
loggingSystemMock.create().get(),
mockAuditLogger
);
});
it('does not encrypt attributes that are not supposed to be encrypted', () => {
const attributes = { attrOne: 'one', attrTwo: 'two', attrThree: 'three' };
service.registerType({
type: 'known-type-1',
attributesToEncrypt: new Set(['attrFour']),
});
expect(
service.encryptAttributesSync({ type: 'known-type-1', id: 'object-id' }, attributes)
).toEqual({
attrOne: 'one',
attrTwo: 'two',
attrThree: 'three',
});
});
it('encrypts only attributes that are supposed to be encrypted', () => {
const attributes = { attrOne: 'one', attrTwo: 'two', attrThree: 'three', attrFour: null };
service.registerType({
type: 'known-type-1',
attributesToEncrypt: new Set(['attrOne', 'attrThree', 'attrFour']),
});
expect(
service.encryptAttributesSync({ type: 'known-type-1', id: 'object-id' }, attributes)
).toEqual({
attrOne: '|one|["known-type-1","object-id",{"attrTwo":"two"}]|',
attrTwo: 'two',
attrThree: '|three|["known-type-1","object-id",{"attrTwo":"two"}]|',
attrFour: null,
});
});
it('encrypts only attributes that are supposed to be encrypted even if not all provided', () => {
const attributes = { attrTwo: 'two', attrThree: 'three' };
service.registerType({
type: 'known-type-1',
attributesToEncrypt: new Set(['attrOne', 'attrThree']),
});
expect(
service.encryptAttributesSync({ type: 'known-type-1', id: 'object-id' }, attributes)
).toEqual({
attrTwo: 'two',
attrThree: '|three|["known-type-1","object-id",{"attrTwo":"two"}]|',
});
});
it('includes `namespace` into AAD if provided', () => {
const attributes = { attrTwo: 'two', attrThree: 'three' };
service.registerType({
type: 'known-type-1',
attributesToEncrypt: new Set(['attrOne', 'attrThree']),
});
expect(
service.encryptAttributesSync(
{ type: 'known-type-1', id: 'object-id', namespace: 'object-ns' },
attributes
)
).toEqual({
attrTwo: 'two',
attrThree: '|three|["object-ns","known-type-1","object-id",{"attrTwo":"two"}]|',
});
});
it('does not include specified attributes to AAD', () => {
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']),
});
expect(
service.encryptAttributesSync(
{ type: 'known-type-1', id: 'object-id-1' },
knownType1attributes
)
).toEqual({
attrOne: 'one',
attrTwo: 'two',
attrThree: '|three|["known-type-1","object-id-1",{"attrOne":"one","attrTwo":"two"}]|',
});
expect(
service.encryptAttributesSync(
{ type: 'known-type-2', id: 'object-id-2' },
knownType2attributes
)
).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', () => {
const attributes = { attrOne: 'one', attrThree: 'three' };
service.registerType({
type: 'known-type-1',
attributesToEncrypt: new Set(['attrOne', 'attrThree']),
});
expect(
service.encryptAttributesSync({ type: 'known-type-1', id: 'object-id-1' }, attributes)
).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', () => {
const attributes = { attrOne: 'one', attrTwo: 'two', attrThree: 'three' };
service.registerType({
type: 'known-type-1',
attributesToEncrypt: new Set(['attrOne', 'attrThree']),
});
mockNodeCrypto.encryptSync
.mockImplementationOnce(() => 'Successfully encrypted attrOne')
.mockImplementationOnce(() => {
throw new Error('Something went wrong with attrThree...');
});
expect(() =>
service.encryptAttributesSync({ type: 'known-type-1', id: 'object-id' }, attributes)
).toThrowError(EncryptionError);
expect(attributes).toEqual({
attrOne: 'one',
attrTwo: 'two',
attrThree: 'three',
});
});
});
describe('#decryptAttributesSync', () => {
it('does not decrypt attributes that are not supposed to be decrypted', () => {
const attributes = { attrOne: 'one', attrTwo: 'two', attrThree: 'three' };
service.registerType({
type: 'known-type-1',
attributesToEncrypt: new Set(['attrFour']),
});
expect(
service.decryptAttributesSync({ type: 'known-type-1', id: 'object-id' }, attributes)
).toEqual({
attrOne: 'one',
attrTwo: 'two',
attrThree: 'three',
});
});
it('decrypts only attributes that are supposed to be decrypted', () => {
const attributes = { attrOne: 'one', attrTwo: 'two', attrThree: 'three', attrFour: null };
service.registerType({
type: 'known-type-1',
attributesToEncrypt: new Set(['attrOne', 'attrThree', 'attrFour']),
});
const encryptedAttributes = service.encryptAttributesSync(
{ 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,
});
expect(
service.decryptAttributesSync({ type: 'known-type-1', id: 'object-id' }, encryptedAttributes)
).toEqual({
attrOne: 'one',
attrTwo: 'two',
attrThree: 'three',
attrFour: null,
});
});
it('decrypts only attributes that are supposed to be encrypted even if not all provided', () => {
const attributes = { attrTwo: 'two', attrThree: 'three' };
service.registerType({
type: 'known-type-1',
attributesToEncrypt: new Set(['attrOne', 'attrThree']),
});
const encryptedAttributes = service.encryptAttributesSync(
{ type: 'known-type-1', id: 'object-id' },
attributes
);
expect(encryptedAttributes).toEqual({
attrTwo: 'two',
attrThree: expect.not.stringMatching(/^three$/),
});
expect(
service.decryptAttributesSync({ type: 'known-type-1', id: 'object-id' }, encryptedAttributes)
).toEqual({
attrTwo: 'two',
attrThree: 'three',
});
});
it('decrypts if all attributes that contribute to AAD are present', () => {
const attributes = { attrOne: 'one', attrTwo: 'two', attrThree: 'three' };
service.registerType({
type: 'known-type-1',
attributesToEncrypt: new Set(['attrThree']),
attributesToExcludeFromAAD: new Set(['attrOne']),
});
const encryptedAttributes = service.encryptAttributesSync(
{ 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 };
expect(
service.decryptAttributesSync(
{ type: 'known-type-1', id: 'object-id' },
attributesWithoutAttr
)
).toEqual({
attrTwo: 'two',
attrThree: 'three',
});
});
it('decrypts even if attributes in AAD are defined in a different order', () => {
const attributes = { attrOne: 'one', attrTwo: 'two', attrThree: 'three' };
service.registerType({
type: 'known-type-1',
attributesToEncrypt: new Set(['attrThree']),
});
const encryptedAttributes = service.encryptAttributesSync(
{ 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',
};
expect(
service.decryptAttributesSync(
{ type: 'known-type-1', id: 'object-id' },
attributesInDifferentOrder
)
).toEqual({
attrOne: 'one',
attrTwo: 'two',
attrThree: 'three',
});
});
it('decrypts if correct namespace is provided', () => {
const attributes = { attrOne: 'one', attrTwo: 'two', attrThree: 'three' };
service.registerType({
type: 'known-type-1',
attributesToEncrypt: new Set(['attrThree']),
});
const encryptedAttributes = service.encryptAttributesSync(
{ type: 'known-type-1', id: 'object-id', namespace: 'object-ns' },
attributes
);
expect(encryptedAttributes).toEqual({
attrOne: 'one',
attrTwo: 'two',
attrThree: expect.not.stringMatching(/^three$/),
});
expect(
service.decryptAttributesSync(
{ type: 'known-type-1', id: 'object-id', namespace: 'object-ns' },
encryptedAttributes
)
).toEqual({
attrOne: 'one',
attrTwo: 'two',
attrThree: 'three',
});
});
it('decrypts even if no attributes are included into AAD', () => {
const attributes = { attrOne: 'one', attrThree: 'three' };
service.registerType({
type: 'known-type-1',
attributesToEncrypt: new Set(['attrOne', 'attrThree']),
});
const encryptedAttributes = service.encryptAttributesSync(
{ type: 'known-type-1', id: 'object-id' },
attributes
);
expect(encryptedAttributes).toEqual({
attrOne: expect.not.stringMatching(/^one$/),
attrThree: expect.not.stringMatching(/^three$/),
});
expect(
service.decryptAttributesSync({ type: 'known-type-1', id: 'object-id' }, encryptedAttributes)
).toEqual({
attrOne: 'one',
attrThree: 'three',
});
});
it('decrypts non-string attributes and restores their original type', () => {
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 = service.encryptAttributesSync(
{ 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),
});
expect(
service.decryptAttributesSync({ type: 'known-type-1', id: 'object-id' }, encryptedAttributes)
).toEqual({
attrOne: 'one',
attrTwo: 'two',
attrThree: 'three',
attrFour: null,
attrFive: { nested: 'five' },
attrSix: 6,
});
});
describe('decryption failures', () => {
let encryptedAttributes: Record<string, string>;
const type1 = {
type: 'known-type-1',
attributesToEncrypt: new Set(['attrThree']),
};
const type2 = {
type: 'known-type-2',
attributesToEncrypt: new Set(['attrThree']),
};
beforeEach(() => {
service.registerType(type1);
service.registerType(type2);
const attributes = { attrOne: 'one', attrTwo: 'two', attrThree: 'three' };
encryptedAttributes = service.encryptAttributesSync(
{ type: 'known-type-1', id: 'object-id' },
attributes
);
});
it('fails to decrypt if not all attributes that contribute to AAD are present', () => {
const attributesWithoutAttr = { attrTwo: 'two', attrThree: encryptedAttributes.attrThree };
expect(() =>
service.decryptAttributesSync(
{ type: 'known-type-1', id: 'object-id' },
attributesWithoutAttr
)
).toThrowError(EncryptionError);
});
it('fails to decrypt if ID does not match', () => {
expect(() =>
service.decryptAttributesSync(
{ type: 'known-type-1', id: 'object-id*' },
encryptedAttributes
)
).toThrowError(EncryptionError);
});
it('fails to decrypt if type does not match', () => {
expect(() =>
service.decryptAttributesSync(
{ type: 'known-type-2', id: 'object-id' },
encryptedAttributes
)
).toThrowError(EncryptionError);
});
it('fails to decrypt if namespace does not match', () => {
encryptedAttributes = service.encryptAttributesSync(
{ type: 'known-type-1', id: 'object-id', namespace: 'object-ns' },
{ attrOne: 'one', attrTwo: 'two', attrThree: 'three' }
);
expect(() =>
service.decryptAttributesSync(
{ type: 'known-type-1', id: 'object-id', namespace: 'object-NS' },
encryptedAttributes
)
).toThrowError(EncryptionError);
});
it('fails to decrypt if namespace is expected, but is not provided', () => {
encryptedAttributes = service.encryptAttributesSync(
{ type: 'known-type-1', id: 'object-id', namespace: 'object-ns' },
{ attrOne: 'one', attrTwo: 'two', attrThree: 'three' }
);
expect(() =>
service.decryptAttributesSync(
{ type: 'known-type-1', id: 'object-id' },
encryptedAttributes
)
).toThrowError(EncryptionError);
});
it('fails to decrypt if encrypted attribute is defined, but not a string', () => {
expect(() =>
service.decryptAttributesSync(
{ type: 'known-type-1', id: 'object-id' },
{
...encryptedAttributes,
attrThree: 2,
}
)
).toThrowError('Encrypted "attrThree" attribute should be a string, but found number');
});
it('fails to decrypt if encrypted attribute is not correct', () => {
expect(() =>
service.decryptAttributesSync(
{ type: 'known-type-1', id: 'object-id' },
{
...encryptedAttributes,
attrThree: 'some-unknown-string',
}
)
).toThrowError(EncryptionError);
});
it('fails to decrypt if the AAD attribute has changed', () => {
expect(() =>
service.decryptAttributesSync(
{ type: 'known-type-1', id: 'object-id' },
{
...encryptedAttributes,
attrOne: 'oNe',
}
)
).toThrowError(EncryptionError);
});
it('fails if encrypted with another encryption key', () => {
service = new EncryptedSavedObjectsService(
nodeCrypto({ encryptionKey: 'encryption-key-abc*' }),
loggingSystemMock.create().get(),
mockAuditLogger
);
service.registerType({
type: 'known-type-1',
attributesToEncrypt: new Set(['attrThree']),
});
expect(() =>
service.decryptAttributesSync(
{ type: 'known-type-1', id: 'object-id' },
encryptedAttributes
)
).toThrowError(EncryptionError);
});
});
});

View file

@ -4,9 +4,9 @@
* you may not use this file except in compliance with the Elastic License.
*/
import nodeCrypto, { Crypto } from '@elastic/node-crypto';
import stringify from 'json-stable-stringify';
import { Crypto, EncryptOutput } from '@elastic/node-crypto';
import typeDetect from 'type-detect';
import stringify from 'json-stable-stringify';
import { Logger } from 'src/core/server';
import { AuthenticatedUser } from '../../../security/common/model';
import { EncryptedSavedObjectsAuditLogger } from '../audit';
@ -70,8 +70,6 @@ export function descriptorToArray(descriptor: SavedObjectDescriptor) {
* attributes.
*/
export class EncryptedSavedObjectsService {
private readonly crypto: Readonly<Crypto>;
/**
* Map of all registered saved object types where the `key` is saved object type and the `value`
* is the definition (names of attributes that need to be encrypted etc.).
@ -82,17 +80,15 @@ export class EncryptedSavedObjectsService {
> = new Map();
/**
* @param encryptionKey The key used to encrypt and decrypt saved objects attributes.
* @param crypto nodeCrypto instance.
* @param logger Ordinary logger instance.
* @param audit Audit logger instance.
*/
constructor(
encryptionKey: string,
private readonly crypto: Readonly<Crypto>,
private readonly logger: Logger,
private readonly audit: EncryptedSavedObjectsAuditLogger
) {
this.crypto = nodeCrypto({ encryptionKey });
}
) {}
/**
* Registers saved object type as the one that contains attributes that should be encrypted.
@ -193,20 +189,11 @@ export class EncryptedSavedObjectsService {
return { attributes: clonedAttributes as T, error: decryptionError };
}
/**
* 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.
* @param [params] Additional parameters.
* @throws Will throw if encryption fails for whatever reason.
*/
public async encryptAttributes<T extends Record<string, unknown>>(
private *attributesToEncryptIterator<T extends Record<string, unknown>>(
descriptor: SavedObjectDescriptor,
attributes: T,
params?: CommonParameters
): Promise<T> {
): Iterator<[unknown, string], T, string> {
const typeDefinition = this.typeDefinitions.get(descriptor.type);
if (typeDefinition === undefined) {
return attributes;
@ -218,10 +205,7 @@ export class EncryptedSavedObjectsService {
const attributeValue = attributes[attributeName];
if (attributeValue != null) {
try {
encryptedAttributes[attributeName] = await this.crypto.encrypt(
attributeValue,
encryptionAAD
);
encryptedAttributes[attributeName] = (yield [attributeValue, encryptionAAD])!;
} catch (err) {
this.logger.error(
`Failed to encrypt "${attributeName}" attribute: ${err.message || err}`
@ -263,6 +247,64 @@ export class EncryptedSavedObjectsService {
};
}
/**
* 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.
* @param [params] Additional parameters.
* @throws Will throw if encryption fails for whatever reason.
*/
public async encryptAttributes<T extends Record<string, unknown>>(
descriptor: SavedObjectDescriptor,
attributes: T,
params?: CommonParameters
): Promise<T> {
const iterator = this.attributesToEncryptIterator<T>(descriptor, attributes, params);
let iteratorResult = iterator.next();
while (!iteratorResult.done) {
const [attributeValue, encryptionAAD] = iteratorResult.value;
try {
iteratorResult = iterator.next(await this.crypto.encrypt(attributeValue, encryptionAAD));
} catch (err) {
iterator.throw!(err);
}
}
return iteratorResult.value;
}
/**
* 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.
* @param [params] Additional parameters.
* @throws Will throw if encryption fails for whatever reason.
*/
public encryptAttributesSync<T extends Record<string, unknown>>(
descriptor: SavedObjectDescriptor,
attributes: T,
params?: CommonParameters
): T {
const iterator = this.attributesToEncryptIterator<T>(descriptor, attributes, params);
let iteratorResult = iterator.next();
while (!iteratorResult.done) {
const [attributeValue, encryptionAAD] = iteratorResult.value;
try {
iteratorResult = iterator.next(this.crypto.encryptSync(attributeValue, encryptionAAD));
} catch (err) {
iterator.throw!(err);
}
}
return iteratorResult.value;
}
/**
* 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
@ -278,13 +320,65 @@ export class EncryptedSavedObjectsService {
attributes: T,
params?: CommonParameters
): Promise<T> {
const iterator = this.attributesToDecryptIterator<T>(descriptor, attributes, params);
let iteratorResult = iterator.next();
while (!iteratorResult.done) {
const [attributeValue, encryptionAAD] = iteratorResult.value;
try {
iteratorResult = iterator.next(
(await this.crypto.decrypt(attributeValue, encryptionAAD)) as string
);
} catch (err) {
iterator.throw!(err);
}
}
return iteratorResult.value;
}
/**
* 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.
* @param [params] Additional parameters.
* @throws Will throw if decryption fails for whatever reason.
* @throws Will throw if any of the attributes to decrypt is not a string.
*/
public decryptAttributesSync<T extends Record<string, unknown>>(
descriptor: SavedObjectDescriptor,
attributes: T,
params?: CommonParameters
): T {
const iterator = this.attributesToDecryptIterator<T>(descriptor, attributes, params);
let iteratorResult = iterator.next();
while (!iteratorResult.done) {
const [attributeValue, encryptionAAD] = iteratorResult.value;
try {
iteratorResult = iterator.next(this.crypto.decryptSync(attributeValue, encryptionAAD));
} catch (err) {
iterator.throw!(err);
}
}
return iteratorResult.value;
}
private *attributesToDecryptIterator<T extends Record<string, unknown>>(
descriptor: SavedObjectDescriptor,
attributes: T,
params?: CommonParameters
): Iterator<[string, string], T, EncryptOutput> {
const typeDefinition = this.typeDefinitions.get(descriptor.type);
if (typeDefinition === undefined) {
return attributes;
}
const encryptionAAD = this.getAAD(typeDefinition, descriptor, attributes);
const decryptedAttributes: Record<string, string> = {};
const decryptedAttributes: Record<string, EncryptOutput> = {};
for (const attributeName of typeDefinition.attributesToEncrypt) {
const attributeValue = attributes[attributeName];
if (attributeValue == null) {
@ -301,10 +395,7 @@ export class EncryptedSavedObjectsService {
}
try {
decryptedAttributes[attributeName] = (await this.crypto.decrypt(
attributeValue,
encryptionAAD
)) as string;
decryptedAttributes[attributeName] = (yield [attributeValue, encryptionAAD])!;
} catch (err) {
this.logger.error(`Failed to decrypt "${attributeName}" attribute: ${err.message || err}`);
this.audit.decryptAttributeFailure(attributeName, descriptor, params?.user);

View file

@ -4,71 +4,4 @@
* you may not use this file except in compliance with the Elastic License.
*/
import {
EncryptedSavedObjectsService,
EncryptedSavedObjectTypeRegistration,
SavedObjectDescriptor,
} from '.';
export const encryptedSavedObjectsServiceMock = {
create(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, shouldExpose: boolean) => void
) {
const registration = registrations.find((r) => r.type === descriptor.type);
if (!registration) {
return attrs;
}
const clonedAttrs = { ...attrs };
for (const attr of registration.attributesToEncrypt) {
const [attrName, shouldExpose] =
typeof attr === 'string'
? [attr, false]
: [attr.key, attr.dangerouslyExposeValue === true];
if (attrName in clonedAttrs) {
action(clonedAttrs, attrName, shouldExpose);
}
}
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.stripOrDecryptAttributes.mockImplementation((descriptor, attrs) =>
Promise.resolve({
attributes: processAttributes(descriptor, attrs, (clonedAttrs, attrName, shouldExpose) => {
if (shouldExpose) {
clonedAttrs[attrName] = (clonedAttrs[attrName] as string).slice(1, -1);
} else {
delete clonedAttrs[attrName];
}
}),
})
);
return mock;
},
};
export { encryptedSavedObjectsServiceMock } from './encrypted_saved_objects_service.mocks';

View file

@ -11,3 +11,4 @@ export {
SavedObjectDescriptor,
} from './encrypted_saved_objects_service';
export { EncryptionError } from './encryption_error';
export { EncryptedSavedObjectAttributesDefinition } from './encrypted_saved_object_type_definition';

View file

@ -12,6 +12,7 @@ function createEncryptedSavedObjectsSetupMock() {
registerType: jest.fn(),
__legacyCompat: { registerLegacyAPI: jest.fn() },
usingEphemeralEncryptionKey: true,
createMigration: jest.fn(),
} as jest.Mocked<EncryptedSavedObjectsPluginSetup>;
}

View file

@ -16,6 +16,7 @@ describe('EncryptedSavedObjects Plugin', () => {
await expect(plugin.setup(coreMock.createSetup(), { security: securityMock.createSetup() }))
.resolves.toMatchInlineSnapshot(`
Object {
"createMigration": [Function],
"registerType": [Function],
"usingEphemeralEncryptionKey": true,
}

View file

@ -4,6 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import nodeCrypto from '@elastic/node-crypto';
import { Logger, PluginInitializerContext, CoreSetup } from 'src/core/server';
import { first } from 'rxjs/operators';
import { SecurityPluginSetup } from '../../security/server';
@ -15,6 +16,7 @@ import {
} from './crypto';
import { EncryptedSavedObjectsAuditLogger } from './audit';
import { setupSavedObjects, ClientInstanciator } from './saved_objects';
import { getCreateMigration, CreateEncryptedSavedObjectsMigrationFn } from './create_migration';
export interface PluginsSetup {
security?: SecurityPluginSetup;
@ -23,6 +25,7 @@ export interface PluginsSetup {
export interface EncryptedSavedObjectsPluginSetup {
registerType: (typeRegistration: EncryptedSavedObjectTypeRegistration) => void;
usingEphemeralEncryptionKey: boolean;
createMigration: CreateEncryptedSavedObjectsMigrationFn;
}
export interface EncryptedSavedObjectsPluginStart {
@ -45,18 +48,18 @@ export class Plugin {
core: CoreSetup,
deps: PluginsSetup
): Promise<EncryptedSavedObjectsPluginSetup> {
const { config, usingEphemeralEncryptionKey } = await createConfig$(this.initializerContext)
.pipe(first())
.toPromise();
const {
config: { encryptionKey },
usingEphemeralEncryptionKey,
} = await createConfig$(this.initializerContext).pipe(first()).toPromise();
const crypto = nodeCrypto({ encryptionKey });
const auditLogger = new EncryptedSavedObjectsAuditLogger(
deps.security?.audit.getLogger('encryptedSavedObjects')
);
const service = Object.freeze(
new EncryptedSavedObjectsService(
config.encryptionKey,
this.logger,
new EncryptedSavedObjectsAuditLogger(
deps.security?.audit.getLogger('encryptedSavedObjects')
)
)
new EncryptedSavedObjectsService(crypto, this.logger, auditLogger)
);
this.savedObjectsSetup = setupSavedObjects({
@ -70,6 +73,18 @@ export class Plugin {
registerType: (typeRegistration: EncryptedSavedObjectTypeRegistration) =>
service.registerType(typeRegistration),
usingEphemeralEncryptionKey,
createMigration: getCreateMigration(
service,
(typeRegistration: EncryptedSavedObjectTypeRegistration) => {
const serviceForMigration = new EncryptedSavedObjectsService(
crypto,
this.logger,
auditLogger
);
serviceForMigration.registerType(typeRegistration);
return serviceForMigration;
}
),
};
}

View file

@ -22,7 +22,7 @@ let encryptedSavedObjectsServiceMockInstance: jest.Mocked<EncryptedSavedObjectsS
beforeEach(() => {
mockBaseClient = savedObjectsClientMock.create();
mockBaseTypeRegistry = savedObjectsTypeRegistryMock.create();
encryptedSavedObjectsServiceMockInstance = encryptedSavedObjectsServiceMock.create([
encryptedSavedObjectsServiceMockInstance = encryptedSavedObjectsServiceMock.createWithTypes([
{
type: 'known-type',
attributesToEncrypt: new Set([

View file

@ -42,7 +42,7 @@ describe('#setupSavedObjects', () => {
coreSetupMock = coreMock.createSetup();
coreSetupMock.getStartServices.mockResolvedValue([coreStartMock, {}, {}]);
mockEncryptedSavedObjectsService = encryptedSavedObjectsServiceMock.create([
mockEncryptedSavedObjectsService = encryptedSavedObjectsServiceMock.createWithTypes([
{ type: 'known-type', attributesToEncrypt: new Set(['attrSecret']) },
]);
setupContract = setupSavedObjects({

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { resolve } from 'path';
import path from 'path';
import { FtrConfigProviderContext } from '@kbn/test/types/ftr';
import { services } from './services';
@ -18,12 +18,16 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) {
junit: {
reportName: 'X-Pack Encrypted Saved Objects API Integration Tests',
},
esArchiver: {
directory: path.join(__dirname, 'fixtures', 'es_archiver'),
},
esTestCluster: xPackAPITestsConfig.get('esTestCluster'),
kbnTestServer: {
...xPackAPITestsConfig.get('kbnTestServer'),
serverArgs: [
...xPackAPITestsConfig.get('kbnTestServer.serverArgs'),
`--plugin-path=${resolve(__dirname, './fixtures/api_consumer_plugin')}`,
'--xpack.encryptedSavedObjects.encryptionKey="wuGNaIhoMpk5sO4UBxgr3NyW1sFcLgIf"',
`--plugin-path=${path.resolve(__dirname, './fixtures/api_consumer_plugin')}`,
],
},
};

View file

@ -9,6 +9,7 @@ import {
CoreSetup,
PluginInitializer,
SavedObjectsNamespaceType,
SavedObjectUnsanitizedDoc,
} from '../../../../../../src/core/server';
import {
EncryptedSavedObjectsPluginSetup,
@ -23,6 +24,17 @@ const SAVED_OBJECT_WITH_SECRET_AND_MULTIPLE_SPACES_TYPE =
'saved-object-with-secret-and-multiple-spaces';
const SAVED_OBJECT_WITHOUT_SECRET_TYPE = 'saved-object-without-secret';
const SAVED_OBJECT_WITH_MIGRATION_TYPE = 'saved-object-with-migration';
interface MigratedTypePre790 {
nonEncryptedAttribute: string;
encryptedAttribute: string;
}
interface MigratedType {
nonEncryptedAttribute: string;
encryptedAttribute: string;
additionalEncryptedAttribute: string;
}
export interface PluginsSetup {
encryptedSavedObjects: EncryptedSavedObjectsPluginSetup;
spaces: SpacesPluginSetup;
@ -34,7 +46,7 @@ export interface PluginsStart {
}
export const plugin: PluginInitializer<void, void, PluginsSetup, PluginsStart> = () => ({
setup(core: CoreSetup<PluginsStart>, deps) {
setup(core: CoreSetup<PluginsStart>, deps: PluginsSetup) {
for (const [name, namespaceType, hidden] of [
[SAVED_OBJECT_WITH_SECRET_TYPE, 'single', false],
[HIDDEN_SAVED_OBJECT_WITH_SECRET_TYPE, 'single', true],
@ -71,6 +83,8 @@ export const plugin: PluginInitializer<void, void, PluginsSetup, PluginsStart> =
mappings: deepFreeze({ properties: { publicProperty: { type: 'keyword' } } }),
});
defineTypeWithMigration(core, deps);
const router = core.http.createRouter();
router.get(
{
@ -103,3 +117,83 @@ export const plugin: PluginInitializer<void, void, PluginsSetup, PluginsStart> =
start() {},
stop() {},
});
function defineTypeWithMigration(core: CoreSetup<PluginsStart>, deps: PluginsSetup) {
const typePriorTo790 = {
type: SAVED_OBJECT_WITH_MIGRATION_TYPE,
attributesToEncrypt: new Set(['encryptedAttribute']),
};
// current type is registered
deps.encryptedSavedObjects.registerType({
type: SAVED_OBJECT_WITH_MIGRATION_TYPE,
attributesToEncrypt: new Set(['encryptedAttribute', 'additionalEncryptedAttribute']),
});
core.savedObjects.registerType({
name: SAVED_OBJECT_WITH_MIGRATION_TYPE,
hidden: false,
namespaceType: 'single',
mappings: {
properties: {
nonEncryptedAttribute: {
type: 'keyword',
},
encryptedAttribute: {
type: 'binary',
},
additionalEncryptedAttribute: {
type: 'keyword',
},
},
},
migrations: {
// in this version we migrated a non encrypted field and type didnt change
'7.8.0': deps.encryptedSavedObjects.createMigration<MigratedTypePre790, MigratedTypePre790>(
function shouldBeMigrated(doc): doc is SavedObjectUnsanitizedDoc<MigratedTypePre790> {
return true;
},
(
doc: SavedObjectUnsanitizedDoc<MigratedTypePre790>
): SavedObjectUnsanitizedDoc<MigratedTypePre790> => {
const {
attributes: { nonEncryptedAttribute },
} = doc;
return {
...doc,
attributes: {
...doc.attributes,
nonEncryptedAttribute: `${nonEncryptedAttribute}-migrated`,
},
};
},
// type hasn't changed as the field we're updating is not an encrypted one
typePriorTo790,
typePriorTo790
),
// in this version we encrypted an existing non encrypted field
'7.9.0': deps.encryptedSavedObjects.createMigration<MigratedTypePre790, MigratedType>(
function shouldBeMigrated(doc): doc is SavedObjectUnsanitizedDoc<MigratedTypePre790> {
return true;
},
(
doc: SavedObjectUnsanitizedDoc<MigratedTypePre790>
): SavedObjectUnsanitizedDoc<MigratedType> => {
const {
attributes: { nonEncryptedAttribute },
} = doc;
return {
...doc,
attributes: {
...doc.attributes,
nonEncryptedAttribute,
// clone and modify the non encrypted field
additionalEncryptedAttribute: `${nonEncryptedAttribute}-encrypted`,
},
};
},
typePriorTo790
),
},
});
}

View file

@ -0,0 +1,370 @@
{
"type": "doc",
"value": {
"id": "config:8.0.0",
"index": ".kibana_1",
"source": {
"config": {
"buildNum": 9007199254740991
},
"migrationVersion": {
"config": "7.9.0"
},
"references": [
],
"type": "config",
"updated_at": "2020-06-17T15:03:14.532Z"
}
}
}
{
"type": "doc",
"value": {
"id": "space:default",
"index": ".kibana_1",
"source": {
"migrationVersion": {
"space": "6.6.0"
},
"references": [
],
"space": {
"_reserved": true,
"color": "#00bfb3",
"description": "This is your default space!",
"disabledFeatures": [
],
"name": "Default"
},
"type": "space",
"updated_at": "2020-06-17T15:03:27.426Z"
}
}
}
{
"type": "doc",
"value": {
"id": "apm-telemetry:apm-telemetry",
"index": ".kibana_1",
"source": {
"apm-telemetry": {
"agents": {
},
"cardinality": {
"transaction": {
"name": {
"all_agents": {
"1d": 0
},
"rum": {
"1d": 0
}
}
},
"user_agent": {
"original": {
"all_agents": {
"1d": 0
},
"rum": {
"1d": 0
}
}
}
},
"counts": {
"agent_configuration": {
"all": 0
},
"error": {
"1d": 0,
"all": 0
},
"max_error_groups_per_service": {
"1d": 0
},
"max_transaction_groups_per_service": {
"1d": 0
},
"metric": {
"1d": 0,
"all": 0
},
"onboarding": {
"1d": 0,
"all": 0
},
"services": {
"1d": 0
},
"sourcemap": {
"1d": 0,
"all": 0
},
"span": {
"1d": 0,
"all": 0
},
"traces": {
"1d": 0
},
"transaction": {
"1d": 0,
"all": 0
}
},
"has_any_services": false,
"indices": {
"all": {
"total": {
"docs": {
"count": 0
},
"store": {
"size_in_bytes": 416
}
}
},
"shards": {
"total": 2
}
},
"integrations": {
"ml": {
"all_jobs_count": 0
}
},
"services_per_agent": {
"dotnet": 0,
"go": 0,
"java": 0,
"js-base": 0,
"nodejs": 0,
"python": 0,
"ruby": 0,
"rum-js": 0
},
"tasks": {
"agent_configuration": {
"took": {
"ms": 21
}
},
"agents": {
"took": {
"ms": 65
}
},
"cardinality": {
"took": {
"ms": 80
}
},
"groupings": {
"took": {
"ms": 25
}
},
"indices_stats": {
"took": {
"ms": 65
}
},
"integrations": {
"took": {
"ms": 108
}
},
"processor_events": {
"took": {
"ms": 113
}
},
"services": {
"took": {
"ms": 98
}
},
"versions": {
"took": {
"ms": 6
}
}
}
},
"references": [
],
"type": "apm-telemetry",
"updated_at": "2020-06-17T15:03:47.184Z"
}
}
}
{
"type": "doc",
"value": {
"id": "saved-object-with-migration:74f3e6d7-b7bb-477d-ac28-92ee22728e6e",
"index": ".kibana_1",
"source": {
"saved-object-with-migration": {
"encryptedAttribute": "JuDwwSjflpKmPKUIfjgo04E0DW9iyhp8C94hwvflgkS0SUUPt+862FQ1eja4VEfEG7HVUt7xxj+BWeZv9vrf4olxgbr4/f5RrT8BVic0EOVS9nhspiDVEv12mV0uDWGtdneB/UWyaZg+0Qr0tPrwceSl8BS///U=",
"nonEncryptedAttribute": "elastic"
},
"migrationVersion": {
"saved-object-with-migration": "7.7.0"
},
"references": [
],
"type": "saved-object-with-migration",
"updated_at": "2020-06-17T15:35:39.839Z"
}
}
}
{
"type": "doc",
"value": {
"id": "application_usage_transactional:5f01fd40-b0b0-11ea-9510-fdf248d5f2a4",
"index": ".kibana_1",
"source": {
"application_usage_transactional": {
"appId": "management",
"minutesOnScreen": 1.60245,
"numberOfClicks": 6,
"timestamp": "2020-06-17T15:36:54.292Z"
},
"references": [
],
"type": "application_usage_transactional",
"updated_at": "2020-06-17T15:36:54.292Z"
}
}
}
{
"type": "doc",
"value": {
"id": "application_usage_transactional:4ca5ac00-b0b0-11ea-9510-fdf248d5f2a4",
"index": ".kibana_1",
"source": {
"application_usage_transactional": {
"appId": "home",
"minutesOnScreen": 0.4106666666666667,
"numberOfClicks": 3,
"timestamp": "2020-06-17T15:36:23.487Z"
},
"references": [
],
"type": "application_usage_transactional",
"updated_at": "2020-06-17T15:36:23.488Z"
}
}
}
{
"type": "doc",
"value": {
"id": "ui-metric:kibana-user_agent:Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/81.0.4044.138 Safari/537.36",
"index": ".kibana_1",
"source": {
"references": [
],
"type": "ui-metric",
"ui-metric": {
"count": 1
},
"updated_at": "2020-06-17T15:36:23.487Z"
}
}
}
{
"type": "doc",
"value": {
"id": "ui-metric:Kibana_home:sampleDataDecline",
"index": ".kibana_1",
"source": {
"type": "ui-metric",
"ui-metric": {
"count": 1
},
"updated_at": "2020-06-17T15:36:23.488Z"
}
}
}
{
"type": "doc",
"value": {
"id": "ui-metric:Kibana_home:welcomeScreenMount",
"index": ".kibana_1",
"source": {
"type": "ui-metric",
"ui-metric": {
"count": 1
},
"updated_at": "2020-06-17T15:36:23.488Z"
}
}
}
{
"type": "doc",
"value": {
"id": "telemetry:telemetry",
"index": ".kibana_1",
"source": {
"references": [
],
"telemetry": {
"lastReported": 1592408310031,
"reportFailureCount": 0,
"userHasSeenNotice": true
},
"type": "telemetry",
"updated_at": "2020-06-17T15:38:30.031Z"
}
}
}
{
"type": "doc",
"value": {
"id": "maps-telemetry:maps-telemetry",
"index": ".kibana_1",
"source": {
"maps-telemetry": {
"attributesPerMap": {
"dataSourcesCount": {
"avg": 0,
"max": 0,
"min": 0
},
"emsVectorLayersCount": {
},
"layerTypesCount": {
},
"layersCount": {
"avg": 0,
"max": 0,
"min": 0
}
},
"indexPatternsWithGeoFieldCount": 0,
"indexPatternsWithGeoPointFieldCount": 0,
"indexPatternsWithGeoShapeFieldCount": 0,
"mapsTotalCount": 0,
"settings": {
"showMapVisualizationTypes": false
},
"timeCaptured": "2020-06-17T16:29:27.563Z"
},
"references": [
],
"type": "maps-telemetry",
"updated_at": "2020-06-17T16:29:27.563Z"
}
}
}

View file

@ -12,6 +12,7 @@ export default function ({ getService }: FtrProviderContext) {
const es = getService('legacyEs');
const randomness = getService('randomness');
const supertest = getService('supertest');
const esArchiver = getService('esArchiver');
const SAVED_OBJECT_WITH_SECRET_TYPE = 'saved-object-with-secret';
const HIDDEN_SAVED_OBJECT_WITH_SECRET_TYPE = 'hidden-saved-object-with-secret';
@ -502,5 +503,32 @@ export default function ({ getService }: FtrProviderContext) {
);
});
});
describe('migrations', () => {
before(async () => {
await esArchiver.load('encrypted_saved_objects');
});
after(async () => {
await esArchiver.unload('encrypted_saved_objects');
});
it('migrates unencrypted fields on saved objects', async () => {
const { body: decryptedResponse } = await supertest
.get(
`/api/saved_objects/get-decrypted-as-internal-user/saved-object-with-migration/74f3e6d7-b7bb-477d-ac28-92ee22728e6e`
)
.expect(200);
expect(decryptedResponse.attributes).to.eql({
// ensures the encrypted field can still be decrypted after the migration
encryptedAttribute: 'this is my secret api key',
// ensures the non-encrypted field has been migrated in 7.8.0
nonEncryptedAttribute: 'elastic-migrated',
// ensures the non-encrypted field has been migrated into a new encrypted field in 7.9.0
additionalEncryptedAttribute: 'elastic-migrated-encrypted',
});
});
});
});
}

View file

@ -2361,6 +2361,11 @@
resolved "https://registry.yarnpkg.com/@elastic/node-crypto/-/node-crypto-1.1.1.tgz#619b70322c9cce4a7ee5fbf8f678b1baa7f06095"
integrity sha512-F6tIk8Txdqjg8Siv60iAvXzO9ZdQI87K3sS/fh5xd2XaWK+T5ZfqeTvsT7srwG6fr6uCBfuQEJV1KBBl+JpLZA==
"@elastic/node-crypto@1.2.1":
version "1.2.1"
resolved "https://registry.yarnpkg.com/@elastic/node-crypto/-/node-crypto-1.2.1.tgz#dfd9218f9b5729fa519762e6a6968aaf61b86eb0"
integrity sha512-RlZg+poLA2SwZZUM5RMJDJiKojlSB1mJkumIvLgXvvTCcCliC6rM0lUaNecV9pbQLIHrGlX2BrbwiuPWhv0czQ==
"@elastic/numeral@^2.5.0":
version "2.5.0"
resolved "https://registry.yarnpkg.com/@elastic/numeral/-/numeral-2.5.0.tgz#8da714827fc278f17546601fdfe55f5c920e2bc5"