Skip to content

Commit

Permalink
[Encrypted Saved Objects] Adds support for migrations in ESO (#69513)
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
gmmorris authored Jun 25, 2020
1 parent ef496ff commit 68cf857
Show file tree
Hide file tree
Showing 24 changed files with 4,281 additions and 154 deletions.
1 change: 1 addition & 0 deletions src/core/server/mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
[
Expand Down Expand Up @@ -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,
[
Expand Down
57 changes: 43 additions & 14 deletions src/core/server/saved_objects/migrations/core/migrate_raw_docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 '.';

Expand All @@ -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);
}
}
return processedDocs;
}

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;
});
/**
* 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));
});
});
}
2 changes: 1 addition & 1 deletion x-pack/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,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",
Expand Down
132 changes: 132 additions & 0 deletions x-pack/plugins/encrypted_saved_objects/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading

0 comments on commit 68cf857

Please sign in to comment.