Implements Encrypted Saved Objects Model Version API (#166302)

Closes #161002
Closes #170073

## Summary

This PR implements a createModelVersion API in the Encrypted Saved
Objects plugin to support upward migrations for model version encrypted
saved objects.

Much like how the `createMigration` API provided a way to wrap migration
functions to support migration of encrypted saved objects prior to the
model version paradigm, the new `createModelVersion` API provides a way
to wrap a model version definition for the same purpose.

`createModelVersion` manipulates the changes defined for a model version
('unsafe_transform', 'data_backfill', 'data_removal'), merging them into
a single transform function in which the saved object document is
decrypted, transformed, and then encrypted again. The document is
decrypted with the `encrypted saved object type registration` provided
by the required `inputType` parameter. Similarly, the document is by
encrypted with the `encrypted saved object type registration` provided
by the required `outputType` parameter.

An example plugin (`examples/eso_model_version_example`) provides a
demonstration of how the createModelVersion API should be used. The UI
of the example plugin gives an idea of what the encrypted saved objects
look like before and after the model version changes are applied.

## Testing

### Manual Testing
- Modify the example plugin implementation in
`examples/eso_model_version_example` to include different changes or
additional model versions.

### Unit Tests
-
`x-pack/plugins/encrypted_saved_objects/server/create_model_version.test.ts`

### Functional Tests
-
`x-pack/test/encrypted_saved_objects_api_integration/tests/encrypted_saved_objects_api.ts`
-
`x-pack/test/encrypted_saved_objects_api_integration/tests/encrypted_saved_objects_decryption.ts`

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Jeramy Soucy 2023-12-07 16:01:29 -05:00 committed by GitHub
parent f6840055d4
commit 835d4aff4c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
37 changed files with 2216 additions and 50 deletions

1
.github/CODEOWNERS vendored
View file

@ -379,6 +379,7 @@ packages/kbn-eslint-plugin-eslint @elastic/kibana-operations
packages/kbn-eslint-plugin-i18n @elastic/obs-knowledge-team @elastic/kibana-operations
packages/kbn-eslint-plugin-imports @elastic/kibana-operations
packages/kbn-eslint-plugin-telemetry @elastic/obs-knowledge-team
examples/eso_model_version_example @elastic/kibana-security
x-pack/test/encrypted_saved_objects_api_integration/plugins/api_consumer_plugin @elastic/kibana-security
packages/kbn-event-annotation-common @elastic/kibana-visualizations
packages/kbn-event-annotation-components @elastic/kibana-visualizations

View file

@ -0,0 +1,16 @@
## Encrypted Saved Object Model Version Example
This plugin provides a simple use case demonstration of:
- How to organize versioned saved object and encryption registration definitions
- How to use the createModelVersion wrapper function of the Encrypted Saved Objects plugin
- How/when encrypted model versions are migrated and what to expect when they are queried
This is an example plugin to demonstrate implementation of an encrypted saved object with model versions using the new encryptedSavedObjectsPlugin.createModelVersion API.
A good place to start is by reviewing the definitions in `examples/eso_model_version_example/server/types`. This is where the interfaces and constants that for the example saved object are defined.
In `examples/eso_model_version_example/server/plugin.ts` the model versions are defined, which include typical changes you might see in a saved object over time only in this case the model version definitions are wrapped by the new createModelVersion API.
Lastly, use the plugin UI to get a sense for how the objects are migrated - you can query the raw documents and then decrypted the migrated objects.
To run this example, use the command `yarn start --run-examples`.

View file

@ -0,0 +1,9 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
export const PLUGIN_ID = 'esoModelVersionExample';

View file

@ -0,0 +1,13 @@
{
"type": "plugin",
"id": "@kbn/eso-model-version-example",
"owner": "@elastic/kibana-security",
"description": "ESO Model Version Example",
"plugin": {
"id": "esoModelVersionExample",
"server": true,
"browser": true,
"requiredBundles": ["kibanaReact"],
"requiredPlugins": ["developerExamples", "security", "spaces", "encryptedSavedObjects"]
}
}

View file

@ -0,0 +1,149 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import {
EuiAccordion,
EuiButton,
EuiCodeBlock,
EuiPageTemplate,
EuiSpacer,
EuiText,
EuiTextColor,
EuiTitle,
} from '@elastic/eui';
import React, { useState } from 'react';
export const MyPluginComponent: React.FC = () => {
const [generated, setGenerated] = useState('');
const [rawDocs, setRawDocs] = useState('');
const [objects, setObjects] = useState('');
const [decrypted, setDecrypted] = useState('');
const handler = async (
endpoint: string,
setter: (value: React.SetStateAction<string>) => void
) => {
const response = await fetch(endpoint);
const data = await response.json();
setter(JSON.stringify(data, null, 2));
};
return (
<EuiPageTemplate>
<EuiPageTemplate.Section grow={false} color="subdued" bottomBorder="extended">
<EuiTitle size="l">
<h1>Encrypted Saved Object Model Version Example</h1>
</EuiTitle>
<EuiText>
This is a demonstration to show the results of the implementation found in&nbsp;
<EuiTextColor color="accent">examples/eso_model_version_example</EuiTextColor>
</EuiText>
</EuiPageTemplate.Section>
<EuiPageTemplate.Section
grow={false}
color="subdued"
bottomBorder="extended"
title="Create Objects"
>
<EuiText>
1. This will create three objects - one for each model version definition (see&nbsp;
<EuiTextColor color="accent">
examples/eso_model_version_example/server/types
</EuiTextColor>
).
</EuiText>
<EuiButton
onClick={() => {
handler('/internal/eso_mv_example/generate', setGenerated);
}}
>
Create Objects
</EuiButton>
<EuiSpacer />
<EuiAccordion
id="createdObjectsAccordion"
buttonContent="Created Objects"
initialIsOpen={true}
>
<EuiCodeBlock language="json" fontSize="s" paddingSize="s" isCopyable>
{generated}
</EuiCodeBlock>
</EuiAccordion>
</EuiPageTemplate.Section>
<EuiPageTemplate.Section grow={false} color="subdued" bottomBorder="extended">
<EuiText>
2. This will read the raw documents of the objects with an Elasticsearch client. Note that
the&nbsp;
<EuiTextColor color="accent">typeMigrationVersion&nbsp;</EuiTextColor>
(10.n.0) will correspond to the model version (n).
</EuiText>
<EuiButton
onClick={() => {
handler('/internal/eso_mv_example/read_raw', setRawDocs);
}}
>
Read Raw Documents
</EuiButton>
<EuiSpacer />
<EuiAccordion
id="rawDocumentsAccordion"
buttonContent="Raw Object Documents"
initialIsOpen={true}
>
<EuiCodeBlock language="json" fontSize="s" paddingSize="s" isCopyable>
{rawDocs}
</EuiCodeBlock>
</EuiAccordion>
</EuiPageTemplate.Section>
<EuiPageTemplate.Section grow={false} color="subdued" bottomBorder="extended">
<EuiText>
3. This will read the saved objects with a Kibana saved object client. Note that the
objects have been migrated on read to the latest model version, and the encrypted fields
have been stripped.
</EuiText>
<EuiButton
onClick={() => {
handler('/internal/eso_mv_example/get_objects', setObjects);
}}
>
Read Saved Objects
</EuiButton>
<EuiSpacer />
<EuiAccordion
id="migratedObjectsAccordion"
buttonContent="Migrated Objects"
initialIsOpen={true}
>
<EuiCodeBlock language="json" fontSize="s" paddingSize="s" isCopyable>
{objects}
</EuiCodeBlock>
</EuiAccordion>
</EuiPageTemplate.Section>
<EuiPageTemplate.Section grow={false} color="subdued" bottomBorder="extended">
<EuiText>4. This will decrypt the saved objects.</EuiText>
<EuiButton
onClick={() => {
handler('/internal/eso_mv_example/get_decrypted', setDecrypted);
}}
>
Decrypt Secrets
</EuiButton>
<EuiSpacer />
<EuiAccordion
id="decryptedAccordion"
buttonContent="Decrypted Secrets"
initialIsOpen={true}
>
<EuiCodeBlock language="json" fontSize="s" paddingSize="s" isCopyable>
{decrypted}
</EuiCodeBlock>
</EuiAccordion>
</EuiPageTemplate.Section>
</EuiPageTemplate>
);
};

View file

@ -0,0 +1,59 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import ReactDOM from 'react-dom';
import { AppMountParameters, CoreSetup, CoreStart, Plugin } from '@kbn/core/public';
import { DeveloperExamplesSetup } from '@kbn/developer-examples-plugin/public';
import { SecurityPluginSetup, SecurityPluginStart } from '@kbn/security-plugin/public';
import { KibanaPageTemplate } from '@kbn/shared-ux-page-kibana-template';
import type { FeaturesPluginSetup } from '@kbn/features-plugin/public';
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
import { MyPluginComponent } from './app';
interface SetupDeps {
developerExamples: DeveloperExamplesSetup;
security: SecurityPluginSetup;
features: FeaturesPluginSetup;
}
interface StartDeps {
security: SecurityPluginStart;
}
export class EsoModelVersionExample implements Plugin<void, void, SetupDeps, StartDeps> {
public setup(coreSetup: CoreSetup<StartDeps>, deps: SetupDeps) {
coreSetup.application.register({
id: 'esoModelVersionExample',
title: 'ESO Model Version Example',
async mount({ element }: AppMountParameters) {
const [coreStart] = await coreSetup.getStartServices();
ReactDOM.render(
<KibanaPageTemplate>
<KibanaContextProvider services={{ ...coreStart, ...deps }}>
<MyPluginComponent />
</KibanaContextProvider>
</KibanaPageTemplate>,
element
);
return () => ReactDOM.unmountComponentAtNode(element);
},
});
deps.developerExamples.register({
appId: 'esoModelVersionExample',
title: 'ESO Model Version Example',
description: 'Example of encrypted saved object with model version implementation',
});
}
public start(core: CoreStart, deps: StartDeps) {
return {};
}
public stop() {}
}
export const plugin = () => new EsoModelVersionExample();

View file

@ -0,0 +1,11 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { PluginInitializer } from '@kbn/core/server';
import { EsoModelVersionExample } from './plugin';
export const plugin: PluginInitializer<void, void> = async () => new EsoModelVersionExample();

View file

@ -0,0 +1,423 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// This is an example plugin to demonstrate implementation of an encrypted saved object with model versions using //
// the new encryptedSavedObjectsPlugin.createModelVersion API. //
// //
// A good place to start is by reviewing the definitions in examples/eso_model_version_example/server/types. This //
// is where the interfaces and constants for the example saved object are defined. //
// //
// In this file (plugin.ts) the model versions are defined, which include typical changes you might see in a saved //
// object over time, only in this case the model version definitions are wrapped by the new createModelVersion API. //
// //
// Lastly, use the plugin UI to get a sense for how the objects are migrated - you can query the raw documents and //
// the decrypted migrated objects. //
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
import {
CoreSetup,
IRouter,
Plugin,
RequestHandlerContext,
SavedObjectsBulkResponse,
} from '@kbn/core/server';
import {
EncryptedSavedObjectsPluginSetup,
EncryptedSavedObjectsPluginStart,
} from '@kbn/encrypted-saved-objects-plugin/server';
import { schema } from '@kbn/config-schema';
import { SpacesPluginSetup } from '@kbn/spaces-plugin/server';
import { WriteResponseBase } from '@elastic/elasticsearch/lib/api/types';
import {
esoModelVersionExampleV1,
esoModelVersionExampleV2,
esoModelVersionExampleV3,
} from './types';
import { EsoModelVersionExampleTypeRegistration, EXAMPLE_SAVED_OBJECT_TYPE } from './types/latest';
const documentVersionConstants = [
esoModelVersionExampleV1.ESO_MV_RAW_DOC,
esoModelVersionExampleV2.ESO_MV_RAW_DOC,
esoModelVersionExampleV3.ESO_MV_RAW_DOC,
];
export interface EsoModelVersionExamplePluginSetup {
encryptedSavedObjects: EncryptedSavedObjectsPluginSetup;
spaces: SpacesPluginSetup;
}
export interface EsoModelVersionExamplePluginsStart {
encryptedSavedObjects: EncryptedSavedObjectsPluginStart;
}
export class EsoModelVersionExample implements Plugin<void, void> {
public setup(
core: CoreSetup<EsoModelVersionExamplePluginsStart>,
plugins: EsoModelVersionExamplePluginSetup
) {
// Register some endpoints for the example plugin
const router = core.http.createRouter();
this.registerGenerateEndpoint(router); // This will create three objects - one for each model version definition.
this.registerReadRawEndpoint(router); // This will read the objects' raw documents with an Elasticsearch client.
this.registerGetObjectsEndpoint(router); // This will read the migrated objects with an SO client.
this.registerGetDecryptedEndpoint(router, core, plugins); // This will decrypt the objects' secrets.
// register type as ESO using the latest definition
plugins.encryptedSavedObjects.registerType(EsoModelVersionExampleTypeRegistration);
// Register the SO with model versions
core.savedObjects.registerType({
name: EXAMPLE_SAVED_OBJECT_TYPE,
hidden: false,
namespaceType: 'multiple-isolated',
mappings: {
dynamic: false,
properties: {
name: {
type: 'text',
fields: {
keyword: {
type: 'keyword',
},
},
},
},
},
modelVersions: {
1: plugins.encryptedSavedObjects.createModelVersion({
modelVersion: {
// It is required to define at least one change to use the 'createModelVersion' wrapper, so we will define a no-op transform
changes: [
{
type: 'unsafe_transform',
transformFn: (document) => {
return { document };
},
},
],
schemas: {
forwardCompatibility: schema.object(
{
name: schema.string(),
toBeRemoved: schema.string(),
aadField1: schema.maybe(schema.object({ flag1: schema.maybe(schema.boolean()) })),
secrets: schema.any(),
},
// 'ignore' will strip any new unknown fields coming from new versions (a zero-downtime upgrade consideration)
// We want to do this unless we have a compelling reason not to, like if we know we want to add a new AAD field
// in the next version (see model version 2)
{ unknowns: 'ignore' }
),
create: schema.object({
name: schema.string(),
toBeRemoved: schema.string(),
aadField1: schema.maybe(schema.object({ flag1: schema.maybe(schema.boolean()) })),
secrets: schema.any(),
}),
},
},
inputType: esoModelVersionExampleV1.EsoModelVersionExampleTypeRegistration, // Pass in the type registration for the specific version
outputType: esoModelVersionExampleV1.EsoModelVersionExampleTypeRegistration, // In this case both input an output are V1
shouldTransformIfDecryptionFails: true,
}),
2: plugins.encryptedSavedObjects.createModelVersion({
modelVersion: {
changes: [
// Version 2 adds additional optional properties (or "sub-fields") to aadField1 and secrets, we're going to back fill them.
// Version 2 also adds an optional field aadExcludedField, which is excluded from AAD. This will be stripped out for
// older versions during zero-downtime upgrades due to the forwardCompatibility schema in model version 1.
{
type: 'data_backfill',
backfillFn: (doc) => {
const aadField1 = doc.attributes.aadField1;
const secrets = doc.attributes.secrets;
return {
attributes: {
aadField1: { ...aadField1, flag2: false },
secrets: {
...secrets,
b: "model version 2 adds property 'b' to the secrets attribute",
},
},
};
},
},
],
schemas: {
forwardCompatibility: schema.object(
{
name: schema.string(),
toBeRemoved: schema.string(),
aadField1: schema.maybe(
schema.object({
flag1: schema.maybe(schema.boolean()),
flag2: schema.maybe(schema.boolean()),
})
),
aadExcludedField: schema.maybe(schema.string()),
secrets: schema.any(),
},
// If we know that we will be adding a new AAD field in the next version, we will NOT strip new fields
// in the forward compatibility schema. This is a Zero-downtime upgrade consideration and ensures that
// old versions will use those fields when constructing AAD. The caveat is that we need to know ahead
// of time, and make sure the consuming code can handle having the additional attribute, even if it
// is not used yet.
{ unknowns: 'allow' }
),
create: schema.object({
name: schema.string(),
toBeRemoved: schema.string(),
aadField1: schema.maybe(
schema.object({
flag1: schema.maybe(schema.boolean()),
flag2: schema.maybe(schema.boolean()),
})
),
aadExcludedField: schema.maybe(schema.string()),
secrets: schema.any(),
}),
},
},
inputType: esoModelVersionExampleV1.EsoModelVersionExampleTypeRegistration, // In this case we are expecting to transform from a V1 object
outputType: esoModelVersionExampleV2.EsoModelVersionExampleTypeRegistration, // to a V2 object
shouldTransformIfDecryptionFails: true,
}),
3: plugins.encryptedSavedObjects.createModelVersion({
modelVersion: {
// Version 3 adds a new attribute aadField2 which is included in AAD, we're not going to back fill it.
// For zero-downtime this new attribute is ok, because the previous model version allows unknown fields and will not strip it.
// The previous version will include it by default when constructing AAD.
changes: [
{
type: 'data_removal',
removedAttributePaths: ['toBeRemoved'],
},
],
schemas: {
forwardCompatibility: schema.object(
{
name: schema.string(),
aadField1: schema.maybe(
schema.object({
flag1: schema.maybe(schema.boolean()),
flag2: schema.maybe(schema.boolean()),
})
),
aadField2: schema.maybe(
schema.object({
foo: schema.maybe(schema.string()),
bar: schema.maybe(schema.string()),
})
),
aadExcludedField: schema.maybe(schema.string()),
secrets: schema.any(),
},
// We will ignore any new fields of future versions again
{ unknowns: 'ignore' }
),
create: schema.object({
name: schema.string(),
aadField1: schema.maybe(
schema.object({
flag1: schema.maybe(schema.boolean()),
flag2: schema.maybe(schema.boolean()),
})
),
aadField2: schema.maybe(
schema.object({
foo: schema.maybe(schema.string()),
bar: schema.maybe(schema.string()),
})
),
aadExcludedField: schema.maybe(schema.string()),
secrets: schema.any(),
}),
},
},
inputType: esoModelVersionExampleV2.EsoModelVersionExampleTypeRegistration, // In this case we are expecting to transform from V2 to V3. This happens to be the latest
outputType: esoModelVersionExampleV3.EsoModelVersionExampleTypeRegistration, // version, but being explicit means we don't need to change this when we implement V4
shouldTransformIfDecryptionFails: true,
}),
},
});
}
start() {
return {};
}
// This will create three objects - one for each model version definition.
private registerGenerateEndpoint(router: IRouter<RequestHandlerContext>) {
router.get(
{
path: '/internal/eso_mv_example/generate',
validate: false,
},
async (context, request, response) => {
const { elasticsearch } = await context.core;
// Try to delete the documents in case they already exist
try {
await Promise.all(
documentVersionConstants.map(async (obj) => {
await elasticsearch.client.asInternalUser.delete({
id: obj.id,
index: obj.index,
});
})
);
} catch (error) {
// ignore errors - objects may not exist
}
// Save raw docs for all three versions, so we can decrypt them and inspect
try {
const objectsCreated = await Promise.all(
documentVersionConstants.map(async (obj) => {
const createdDoc: WriteResponseBase =
await elasticsearch.client.asInternalUser.create(obj);
const parts = createdDoc._id.split(':', 2);
return { type: parts[0], id: parts[1] };
})
);
return response.ok({
body: {
objectsCreated,
},
});
} catch (error) {
return response.ok({
body: {
error,
},
});
}
}
);
}
// This will read the objects' raw documents with an Elasticsearch client.
private registerReadRawEndpoint(router: IRouter<RequestHandlerContext>) {
router.get(
{
path: '/internal/eso_mv_example/read_raw',
validate: false,
},
async (context, request, response) => {
// Read the raw documents so we can display the model versions prior to migration transformations
const { elasticsearch } = await context.core;
try {
const rawDocuments = await Promise.all(
documentVersionConstants.map(async (obj) => {
return await elasticsearch.client.asInternalUser.get({
id: obj.id,
index: obj.index,
});
})
);
return response.ok({
body: {
rawDocuments,
},
});
} catch (error) {
return response.ok({
body: {
error,
},
});
}
}
);
}
// This will read the migrated objects with an SO client.
private registerGetObjectsEndpoint(router: IRouter<RequestHandlerContext>) {
router.get(
{
path: '/internal/eso_mv_example/get_objects',
validate: false,
},
async (context, request, response) => {
// Get the objects via the SO client so we can display how the objects are migrated via the MV definitions
const { savedObjects } = await context.core;
try {
const bulkGetObjects = documentVersionConstants.map((obj) => {
const parts = obj.id.split(':', 2);
return { type: parts[0], id: parts[1] };
});
const result: SavedObjectsBulkResponse = await savedObjects.client.bulkGet(
bulkGetObjects
);
return response.ok({
body: result,
});
} catch (error) {
return response.ok({
body: {
error,
},
});
}
}
);
}
// This will decrypt the objects' secrets.
private registerGetDecryptedEndpoint(
router: IRouter<RequestHandlerContext>,
core: CoreSetup<EsoModelVersionExamplePluginsStart>,
plugins: EsoModelVersionExamplePluginSetup
) {
router.get(
{
path: '/internal/eso_mv_example/get_decrypted',
validate: false,
},
async (context, request, response) => {
// Decrypt the objects as the internal user so we can display the secrets
const [, { encryptedSavedObjects }] = await core.getStartServices();
const spaceId = plugins.spaces.spacesService.getSpaceId(request);
const namespace = plugins.spaces.spacesService.spaceIdToNamespace(spaceId);
try {
const esoClient = encryptedSavedObjects.getClient({
includedHiddenTypes: [EXAMPLE_SAVED_OBJECT_TYPE],
});
const decrypted = await Promise.all(
documentVersionConstants.map(async (obj) => {
const parts = obj.id.split(':', 2);
const dooder = await esoClient.getDecryptedAsInternalUser(parts[0], parts[1], {
namespace,
});
return dooder;
})
);
return response.ok({
body: decrypted,
});
} catch (error) {
return response.ok({
body: {
error,
},
});
}
}
);
}
}

View file

@ -0,0 +1,63 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { EncryptedSavedObjectTypeRegistration } from '@kbn/encrypted-saved-objects-plugin/server';
export const EXAMPLE_SAVED_OBJECT_TYPE = 'eso_model_version_example';
export interface EsoModelVersionExampleOptions1 {
flag1?: boolean;
}
export interface EsoModelVersionExampleSecretData {
a: string;
}
// These are the attributes of V1 of our saved object.
export interface EsoModelVersionExample {
name: string; // Display name attribute. Not part of AAD
toBeRemoved: string; // An attribute that will be removed in a later model version.
aadField1?: EsoModelVersionExampleOptions1; // Optional attribute that is part of AAD.
secrets: EsoModelVersionExampleSecretData; // An encrypted attribute.
}
// This is the encryption definition for V1 of our saved object.
// It is important to keep this definition so it can be used with the new
// createModelVersion wrapper when newer model versions are defined.
export const EsoModelVersionExampleTypeRegistration: EncryptedSavedObjectTypeRegistration = {
type: EXAMPLE_SAVED_OBJECT_TYPE,
attributesToEncrypt: new Set(['secrets']),
attributesToExcludeFromAAD: new Set(['name', 'toBeRemoved']), // aadField1 is included in AAD, but not name or toBeRemoved
};
// This is just some static information used to generate a document
// for this specific model version. Otherwise, creating a saved object
// will always create the latest model version.
export const ESO_MV_RAW_DOC = {
index: '.kibana',
id: 'eso_model_version_example:9e2c00d0-7dc4-11ee-b7ba-ede5fa1a84d7',
document: {
eso_model_version_example: {
name: 'MV1 Test',
toBeRemoved: 'nope',
aadField1: {
flag1: false,
},
secrets:
'SItM+8gR71K5LSmy2dX7EmwZUcDiZWAaI667qFZ22Cn6PtncjMuCMI9586IVt0X69ROV/q81J1XBNp71JpC+hVBZQjis1M17iYerot53srZbG2uw5j8onBiTdr30EgoWx2YFca0+Plm23ukiSdpZH0FSSQJ3npjN5HFumzG9eseNzET3',
},
type: 'eso_model_version_example',
references: [],
managed: false,
namespaces: ['default'],
coreMigrationVersion: '8.8.0',
typeMigrationVersion: '10.1.0',
updated_at: '2023-11-07T23:23:11.581Z',
created_at: '2023-11-07T23:23:11.581Z',
},
};

View file

@ -0,0 +1,70 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { EncryptedSavedObjectTypeRegistration } from '@kbn/encrypted-saved-objects-plugin/server';
export const EXAMPLE_SAVED_OBJECT_TYPE = 'eso_model_version_example';
// V2 adds a new sub-field "flag2"
export interface EsoModelVersionExampleOptions1 {
flag1?: boolean;
flag2?: boolean;
}
// V2 adds a new encrypted sub-field "b"
export interface EsoModelVersionExampleSecretData {
a: string;
b?: string;
}
// These are the attributes of V2 of our saved object.
export interface EsoModelVersionExample {
name: string; // Display name attribute. Not part of AAD
toBeRemoved: string; // An attribute that will be removed in a later model version.
aadField1?: EsoModelVersionExampleOptions1; // Optional attribute that is part of AAD.
aadExcludedField?: string; // Optional attribute that is NOT part of AAD.
secrets: EsoModelVersionExampleSecretData; // An encrypted attribute.
}
// This is the encryption definition for V2 of our saved object.
// It is important to keep this definition so it can be used with the new
// createModelVersion wrapper when newer model versions are defined.
export const EsoModelVersionExampleTypeRegistration: EncryptedSavedObjectTypeRegistration = {
type: EXAMPLE_SAVED_OBJECT_TYPE,
attributesToEncrypt: new Set(['secrets']),
attributesToExcludeFromAAD: new Set(['name', 'toBeRemoved', 'aadExcludedField']), // aadField1 is included in AAD, but not name, toBeRemoved, or aadExcludedField
};
// This is just some static information used to generate a document
// for this specific model version. Otherwise, creating a saved object
// will always create the latest model version.
export const ESO_MV_RAW_DOC = {
index: '.kibana',
id: 'eso_model_version_example:52868e00-7dd5-11ee-bc21-35484912189c',
document: {
eso_model_version_example: {
name: 'MV2 Test',
toBeRemoved: 'nothing to see here',
aadField1: {
flag1: true,
flag2: true,
},
aadExcludedField: 'this will not be used in AAD',
secrets:
'uXBOQpvkI9+lAcfJ52yQAroKIIj+YBT9Ym3IpH1nmPBj2u51tZ07tnPQ3EtO379zHzGOMu+9Da3+bVmDbtsL0z/YrDad3f0o0XSnuEDvmPIVWqC0EwKguik+t63s5LrFvp4r+X3OmsG+jIISx/PXXgLl/8NiWa/urjp649lTGo/k4QvSHyQ4egeM1LjRihFSBFEZkQljF6SJLFocuDlQb8GHkVtgp0pKKfrZu0mI8Q==',
},
type: 'eso_model_version_example',
references: [],
managed: false,
namespaces: ['default'],
coreMigrationVersion: '8.8.0',
typeMigrationVersion: '10.2.0',
updated_at: '2023-11-08T01:22:46.112Z',
created_at: '2023-11-08T01:22:46.112Z',
},
};

View file

@ -0,0 +1,78 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { EncryptedSavedObjectTypeRegistration } from '@kbn/encrypted-saved-objects-plugin/server';
export const EXAMPLE_SAVED_OBJECT_TYPE = 'eso_model_version_example';
export interface EsoModelVersionExampleOptions1 {
flag1?: boolean;
flag2?: boolean;
}
// This is a new attribute added in V3
export interface EsoModelVersionExampleOptions2 {
foo?: string;
bar?: string;
}
export interface EsoModelVersionExampleSecretData {
a: string;
b?: string;
}
// These are the attributes of V3 of our saved object.
export interface EsoModelVersionExample {
name: string; // Display name attribute. Not part of AAD
// We have removed 'toBeRemoved'
aadField1?: EsoModelVersionExampleOptions1; // Optional attribute that is part of AAD.
aadField2?: EsoModelVersionExampleOptions2; // Optional attribute that is part of AAD.
aadExcludedField?: string; // Optional attribute that is NOT part of AAD.
secrets: EsoModelVersionExampleSecretData; // An encrypted attribute.
}
// This is the encryption definition for V3 of our saved object.
// It is important to keep this definition so it can be used with the new
// createModelVersion wrapper when newer model versions are defined.
export const EsoModelVersionExampleTypeRegistration: EncryptedSavedObjectTypeRegistration = {
type: EXAMPLE_SAVED_OBJECT_TYPE,
attributesToEncrypt: new Set(['secrets']),
attributesToExcludeFromAAD: new Set(['name', 'aadExcludedField']), // aadField1 and aadField2 are included in AAD, but not name, or aadExcludedField
};
// This is just some static information used to generate a document
// for this specific model version. Otherwise, creating a saved object
// will always create the latest model version.
export const ESO_MV_RAW_DOC = {
index: '.kibana',
id: 'eso_model_version_example:4b43a8b0-7dd7-11ee-8355-7d13444c2fd7',
document: {
eso_model_version_example: {
name: 'MV3 Test',
aadField1: {
flag1: false,
flag2: true,
},
aadField2: {
foo: 'bar',
bar: 'foo',
},
aadExcludedField: 'this is a field excluded from AAD',
secrets:
'YYtHdisdq44Mvd9VdUui62hM8OowEgkuWSfidWq11lG4aXYR61tf+G+BlbwO6rqKPbFWK238Vn1tP+zceeiCofDqEZkViinT1nGDGjArEEsmIUlDtj5IdaY6boMGRzUJ+37viUrISFXMVV9n2qVMp7IYb2BGkAb3hyh4+ZO9SPTbrKhkcpKgpLs3CEvmfsgeW/Tkxh+F65uK2RShkgLoPy62JI35XUz1paop+zSQ90yPL9ysoQ==',
},
type: 'eso_model_version_example',
references: [],
managed: false,
namespaces: ['default'],
coreMigrationVersion: '8.8.0',
typeMigrationVersion: '10.3.0',
updated_at: '2023-11-08T01:36:52.923Z',
created_at: '2023-11-08T01:36:52.923Z',
},
};

View file

@ -0,0 +1,11 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
export * as esoModelVersionExampleV1 from './example_type/v1';
export * as esoModelVersionExampleV2 from './example_type/v2';
export * as esoModelVersionExampleV3 from './example_type/v3';

View file

@ -0,0 +1,9 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
export * from './example_type/v3';

View file

@ -0,0 +1,26 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "target/types"
},
"include": [
"index.ts",
"common/**/*.ts",
"public/**/*.ts",
"public/**/*.tsx",
"server/**/*.ts",
"../../typings/**/*"
],
"exclude": ["target/**/*"],
"kbn_references": [
"@kbn/core",
"@kbn/kibana-react-plugin",
"@kbn/developer-examples-plugin",
"@kbn/security-plugin",
"@kbn/shared-ux-page-kibana-template",
"@kbn/features-plugin",
"@kbn/encrypted-saved-objects-plugin",
"@kbn/config-schema",
"@kbn/spaces-plugin"
]
}

View file

@ -416,6 +416,7 @@
"@kbn/es-query": "link:packages/kbn-es-query",
"@kbn/es-types": "link:packages/kbn-es-types",
"@kbn/es-ui-shared-plugin": "link:src/plugins/es_ui_shared",
"@kbn/eso-model-version-example": "link:examples/eso_model_version_example",
"@kbn/eso-plugin": "link:x-pack/test/encrypted_saved_objects_api_integration/plugins/api_consumer_plugin",
"@kbn/event-annotation-common": "link:packages/kbn-event-annotation-common",
"@kbn/event-annotation-components": "link:packages/kbn-event-annotation-components",

View file

@ -73,6 +73,7 @@ export type {
SavedObjectsRawDoc,
SavedObjectSanitizedDoc,
SavedObjectsRawDocParseOptions,
SavedObjectDoc,
SavedObjectUnsanitizedDoc,
} from './src/serialization';
export type { ISavedObjectTypeRegistry } from './src/type_registry';

View file

@ -99,7 +99,7 @@ export interface SavedObjectsRawDocSource {
*
* @public
*/
interface SavedObjectDoc<T = unknown> {
export interface SavedObjectDoc<T = unknown> {
attributes: T;
id: string;
type: string;

View file

@ -752,6 +752,8 @@
"@kbn/eslint-plugin-imports/*": ["packages/kbn-eslint-plugin-imports/*"],
"@kbn/eslint-plugin-telemetry": ["packages/kbn-eslint-plugin-telemetry"],
"@kbn/eslint-plugin-telemetry/*": ["packages/kbn-eslint-plugin-telemetry/*"],
"@kbn/eso-model-version-example": ["examples/eso_model_version_example"],
"@kbn/eso-model-version-example/*": ["examples/eso_model_version_example/*"],
"@kbn/eso-plugin": ["x-pack/test/encrypted_saved_objects_api_integration/plugins/api_consumer_plugin"],
"@kbn/eso-plugin/*": ["x-pack/test/encrypted_saved_objects_api_integration/plugins/api_consumer_plugin/*"],
"@kbn/event-annotation-common": ["packages/kbn-event-annotation-common"],

View file

@ -16,6 +16,7 @@ import type {
import { EncryptionError } from './crypto';
import type { EncryptedSavedObjectsService, EncryptedSavedObjectTypeRegistration } from './crypto';
import { normalizeNamespace } from './saved_objects';
import { mapAttributes } from './saved_objects/map_attributes';
type SavedObjectOptionalMigrationFn<InputAttributes, MigratedAttributes> = (
doc: SavedObjectUnsanitizedDoc<InputAttributes> | SavedObjectUnsanitizedDoc<MigratedAttributes>,
@ -157,9 +158,3 @@ export const getCreateMigration =
});
};
};
function mapAttributes<T>(obj: SavedObjectUnsanitizedDoc<T>, mapper: (attributes: T) => T) {
return Object.assign(obj, {
attributes: mapper(obj.attributes),
});
}

View file

@ -0,0 +1,756 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { logger } from 'elastic-apm-node';
import type {
SavedObjectModelTransformationContext,
SavedObjectsModelUnsafeTransformChange,
} from '@kbn/core-saved-objects-server';
import { getCreateEsoModelVersion } from './create_model_version';
import type { EncryptedSavedObjectTypeRegistration } from './crypto';
import { EncryptionError, EncryptionErrorOperation } from './crypto';
import { encryptedSavedObjectsServiceMock } from './crypto/index.mock';
describe('create ESO model version', () => {
afterEach(() => {
jest.clearAllMocks();
});
const inputType: EncryptedSavedObjectTypeRegistration = {
type: 'known-type-1',
attributesToEncrypt: new Set(['firstAttr']),
};
const outputType: EncryptedSavedObjectTypeRegistration = {
type: 'known-type-1',
attributesToEncrypt: new Set(['firstAttr', 'secondAttr']),
};
const context: SavedObjectModelTransformationContext = {
log: logger,
modelVersion: 1,
namespaceType: 'single',
};
const encryptionSavedObjectService = encryptedSavedObjectsServiceMock.create();
it('throws if the types are not compatible', () => {
const mvCreator = getCreateEsoModelVersion(encryptionSavedObjectService, () =>
encryptedSavedObjectsServiceMock.create()
);
expect(() =>
mvCreator({
modelVersion: {
changes: [
{
type: 'unsafe_transform',
transformFn: (document) => {
return { document };
},
},
],
},
inputType: {
type: 'known-type-1',
attributesToEncrypt: new Set(),
},
outputType: {
type: 'known-type-2',
attributesToEncrypt: new Set(),
},
})
).toThrowErrorMatchingInlineSnapshot(
`"An invalid Encrypted Saved Objects Model Version transformation is trying to transform across types (\\"known-type-1\\" => \\"known-type-2\\"), which isn't permitted"`
);
});
it('throws if there are no changes defined', () => {
const mvCreator = getCreateEsoModelVersion(encryptionSavedObjectService, () =>
encryptedSavedObjectsServiceMock.create()
);
expect(() =>
mvCreator({
modelVersion: {
changes: [],
},
inputType,
outputType,
})
).toThrowErrorMatchingInlineSnapshot(
`"No Model Version changes defined. At least one change is required to create an Encrypted Saved Objects Model Version."`
);
});
it('merges all applicable transforms', () => {
const instantiateServiceWithLegacyType = jest.fn(() => encryptionSavedObjectService);
const mvCreator = getCreateEsoModelVersion(
encryptionSavedObjectService,
instantiateServiceWithLegacyType
);
const esoModelVersion = mvCreator({
modelVersion: {
// changes include at least one of each supported transform type in an interleaved order
// (we're not concerned with mapping changes here)
changes: [
{
type: 'unsafe_transform',
transformFn: (document) => {
document.attributes.three = '3';
return { document };
},
},
{
type: 'data_removal',
removedAttributePaths: ['firstAttr'],
},
{
type: 'unsafe_transform',
transformFn: (document) => {
document.attributes.two = '2';
return { document: { ...document, new_prop_1: 'new prop 1' } };
},
},
{
type: 'data_backfill',
backfillFn: () => {
return { attributes: { one: '1' } };
},
},
{
type: 'unsafe_transform',
transformFn: (document) => {
document.attributes.four = '4';
return { document: { ...document, new_prop_2: 'new prop 2' } };
},
},
],
},
inputType,
outputType,
});
const initialAttributes = {
firstAttr: 'first_attr',
};
const expectedAttributes = {
one: '1',
two: '2',
three: '3',
four: '4',
};
encryptionSavedObjectService.decryptAttributesSync.mockReturnValueOnce(initialAttributes);
encryptionSavedObjectService.encryptAttributesSync.mockReturnValueOnce(expectedAttributes);
// There should be only one change now
expect(esoModelVersion.changes.length === 1);
// It should be a single unsafe transform
const unsafeTransforms = esoModelVersion.changes.filter(
(change) => change.type === 'unsafe_transform'
) as SavedObjectsModelUnsafeTransformChange[];
expect(unsafeTransforms.length === 1);
const result = unsafeTransforms[0].transformFn(
{
id: '123',
type: 'known-type-1',
namespace: 'namespace',
attributes: initialAttributes,
},
context
);
// This is the major part of the test. Did the encrypt function get called with
// the attributes updated by all of the transform functions.
expect(encryptionSavedObjectService.encryptAttributesSync).toBeCalledTimes(1);
expect(encryptionSavedObjectService.encryptAttributesSync).toHaveBeenCalledWith(
{
id: '123',
type: 'known-type-1',
namespace: 'namespace',
},
expectedAttributes
);
expect(result).toEqual({
document: {
id: '123',
type: 'known-type-1',
namespace: 'namespace',
new_prop_1: 'new prop 1', // added by unsafe transform
new_prop_2: 'new prop 2', // added by unsafe transform
attributes: expectedAttributes,
},
});
});
it('throws error on decryption failure if shouldTransformIfDecryptionFails is false', () => {
const instantiateServiceWithLegacyType = jest.fn(() => encryptionSavedObjectService);
const mvCreator = getCreateEsoModelVersion(
encryptionSavedObjectService,
instantiateServiceWithLegacyType
);
const esoModelVersion = mvCreator({
modelVersion: {
changes: [
{
type: 'unsafe_transform',
transformFn: (document) => {
return { document };
},
},
],
},
inputType,
outputType,
shouldTransformIfDecryptionFails: false,
});
const attributes = {
firstAttr: 'first_attr',
};
encryptionSavedObjectService.decryptAttributesSync.mockImplementationOnce(() => {
throw new Error('decryption failed!');
});
const unsafeTransforms = esoModelVersion.changes.filter(
(change) => change.type === 'unsafe_transform'
) as SavedObjectsModelUnsafeTransformChange[];
expect(unsafeTransforms.length === 1);
expect(() => {
unsafeTransforms[0].transformFn(
{
id: '123',
type: 'known-type-1',
namespace: 'namespace',
attributes,
},
context
);
}).toThrowError(`decryption failed!`);
expect(encryptionSavedObjectService.decryptAttributesSync).toHaveBeenCalledWith(
{
id: '123',
type: 'known-type-1',
namespace: 'namespace',
},
attributes,
{ isTypeBeingConverted: false }
);
expect(encryptionSavedObjectService.encryptAttributesSync).not.toHaveBeenCalled();
});
it('throws error on decryption failure if shouldTransformIfDecryptionFails is true but error is not encryption error', () => {
const instantiateServiceWithLegacyType = jest.fn(() => encryptionSavedObjectService);
const mvCreator = getCreateEsoModelVersion(
encryptionSavedObjectService,
instantiateServiceWithLegacyType
);
const esoModelVersion = mvCreator({
modelVersion: {
changes: [
{
type: 'unsafe_transform',
transformFn: (document) => {
return { document };
},
},
],
},
inputType,
outputType,
shouldTransformIfDecryptionFails: true,
});
const attributes = {
firstAttr: 'first_attr',
};
encryptionSavedObjectService.decryptAttributesSync.mockImplementationOnce(() => {
throw new Error('decryption failed!');
});
const unsafeTransforms = esoModelVersion.changes.filter(
(change) => change.type === 'unsafe_transform'
) as SavedObjectsModelUnsafeTransformChange[];
expect(unsafeTransforms.length === 1);
expect(() => {
unsafeTransforms[0].transformFn(
{
id: '123',
type: 'known-type-1',
namespace: 'namespace',
attributes,
},
context
);
}).toThrowError(`decryption failed!`);
expect(encryptionSavedObjectService.decryptAttributesSync).toHaveBeenCalledWith(
{
id: '123',
type: 'known-type-1',
namespace: 'namespace',
},
attributes,
{ isTypeBeingConverted: false }
);
expect(encryptionSavedObjectService.stripOrDecryptAttributesSync).not.toHaveBeenCalled();
expect(encryptionSavedObjectService.encryptAttributesSync).not.toHaveBeenCalled();
});
it('executes transformation on decryption failure if shouldTransformIfDecryptionFails is true and error is encryption error', () => {
const instantiateServiceWithLegacyType = jest.fn(() => encryptionSavedObjectService);
const mvCreator = getCreateEsoModelVersion(
encryptionSavedObjectService,
instantiateServiceWithLegacyType
);
const esoModelVersion = mvCreator({
modelVersion: {
changes: [
{
type: 'unsafe_transform',
transformFn: (document) => {
return { document };
},
},
],
},
inputType,
outputType,
shouldTransformIfDecryptionFails: true,
});
const attributes = {
firstAttr: 'first_attr',
attrToStrip: 'secret',
};
const strippedAttributes = {
firstAttr: 'first_attr',
};
encryptionSavedObjectService.decryptAttributesSync.mockImplementationOnce(() => {
throw new EncryptionError(
`Unable to decrypt attribute "'attribute'"`,
'attribute',
EncryptionErrorOperation.Decryption,
new Error('decryption failed')
);
});
encryptionSavedObjectService.stripOrDecryptAttributesSync.mockReturnValueOnce({
attributes: strippedAttributes,
});
const unsafeTransforms = esoModelVersion.changes.filter(
(change) => change.type === 'unsafe_transform'
) as SavedObjectsModelUnsafeTransformChange[];
expect(unsafeTransforms.length === 1);
unsafeTransforms[0].transformFn(
{
id: '123',
type: 'known-type-1',
namespace: 'namespace',
attributes,
},
context
);
expect(encryptionSavedObjectService.stripOrDecryptAttributesSync).toHaveBeenCalledWith(
{
id: '123',
type: 'known-type-1',
namespace: 'namespace',
},
attributes,
{ isTypeBeingConverted: false }
);
expect(encryptionSavedObjectService.encryptAttributesSync).toHaveBeenCalledWith(
{
id: '123',
type: 'known-type-1',
namespace: 'namespace',
},
strippedAttributes
);
});
it('throws error on transform failure', () => {
const instantiateServiceWithLegacyType = jest.fn(() => encryptionSavedObjectService);
const mvCreator = getCreateEsoModelVersion(
encryptionSavedObjectService,
instantiateServiceWithLegacyType
);
const esoModelVersion = mvCreator({
modelVersion: {
changes: [
{
type: 'unsafe_transform',
transformFn: (document) => {
throw new Error('transform failed!');
},
},
],
},
inputType,
outputType,
});
const attributes = {
firstAttr: 'first_attr',
};
encryptionSavedObjectService.decryptAttributesSync.mockReturnValueOnce(attributes);
const unsafeTransforms = esoModelVersion.changes.filter(
(change) => change.type === 'unsafe_transform'
) as SavedObjectsModelUnsafeTransformChange[];
expect(unsafeTransforms.length === 1);
expect(() => {
unsafeTransforms[0].transformFn(
{
id: '123',
type: 'known-type-1',
namespace: 'namespace',
attributes,
},
context
);
}).toThrowError(`transform failed!`);
expect(encryptionSavedObjectService.decryptAttributesSync).toHaveBeenCalledWith(
{
id: '123',
type: 'known-type-1',
namespace: 'namespace',
},
attributes,
{ isTypeBeingConverted: false }
);
expect(encryptionSavedObjectService.encryptAttributesSync).not.toHaveBeenCalled();
});
it('throws error on transform failure even if shouldMigrateIfDecryptionFails is true', () => {
const instantiateServiceWithLegacyType = jest.fn(() => encryptionSavedObjectService);
const mvCreator = getCreateEsoModelVersion(
encryptionSavedObjectService,
instantiateServiceWithLegacyType
);
const esoModelVersion = mvCreator({
modelVersion: {
changes: [
{
type: 'unsafe_transform',
transformFn: (document) => {
throw new Error('transform failed!');
},
},
],
},
inputType,
outputType,
shouldTransformIfDecryptionFails: true,
});
const attributes = {
firstAttr: 'first_attr',
};
encryptionSavedObjectService.decryptAttributesSync.mockReturnValueOnce(attributes);
const unsafeTransforms = esoModelVersion.changes.filter(
(change) => change.type === 'unsafe_transform'
) as SavedObjectsModelUnsafeTransformChange[];
expect(unsafeTransforms.length === 1);
expect(() => {
unsafeTransforms[0].transformFn(
{
id: '123',
type: 'known-type-1',
namespace: 'namespace',
attributes,
},
context
);
}).toThrowError(`transform failed!`);
expect(encryptionSavedObjectService.decryptAttributesSync).toHaveBeenCalledWith(
{
id: '123',
type: 'known-type-1',
namespace: 'namespace',
},
attributes,
{ isTypeBeingConverted: false }
);
expect(encryptionSavedObjectService.encryptAttributesSync).not.toHaveBeenCalled();
});
it('throws error on encryption failure', () => {
const instantiateServiceWithLegacyType = jest.fn(() => encryptionSavedObjectService);
const mvCreator = getCreateEsoModelVersion(
encryptionSavedObjectService,
instantiateServiceWithLegacyType
);
const esoModelVersion = mvCreator({
modelVersion: {
changes: [
{
type: 'unsafe_transform',
transformFn: (document) => {
return { document };
},
},
],
},
inputType,
outputType,
});
const attributes = {
firstAttr: 'first_attr',
};
encryptionSavedObjectService.decryptAttributesSync.mockReturnValueOnce(attributes);
encryptionSavedObjectService.encryptAttributesSync.mockImplementationOnce(() => {
throw new Error('encryption failed!');
});
const unsafeTransforms = esoModelVersion.changes.filter(
(change) => change.type === 'unsafe_transform'
) as SavedObjectsModelUnsafeTransformChange[];
expect(unsafeTransforms.length === 1);
expect(() => {
unsafeTransforms[0].transformFn(
{
id: '123',
type: 'known-type-1',
namespace: 'namespace',
attributes,
},
context
);
}).toThrowError(`encryption failed!`);
expect(encryptionSavedObjectService.decryptAttributesSync).toHaveBeenCalledWith(
{
id: '123',
type: 'known-type-1',
namespace: 'namespace',
},
attributes,
{ isTypeBeingConverted: false }
);
expect(encryptionSavedObjectService.encryptAttributesSync).toHaveBeenCalledWith(
{
id: '123',
type: 'known-type-1',
namespace: 'namespace',
},
attributes
);
});
it('throws error on encryption failure even if shouldMigrateIfDecryptionFails is true', () => {
const instantiateServiceWithLegacyType = jest.fn(() => encryptionSavedObjectService);
const mvCreator = getCreateEsoModelVersion(
encryptionSavedObjectService,
instantiateServiceWithLegacyType
);
const esoModelVersion = mvCreator({
modelVersion: {
changes: [
{
type: 'unsafe_transform',
transformFn: (document) => {
return { document };
},
},
],
},
inputType,
outputType,
shouldTransformIfDecryptionFails: true,
});
const attributes = {
firstAttr: 'first_attr',
};
encryptionSavedObjectService.decryptAttributesSync.mockReturnValueOnce(attributes);
encryptionSavedObjectService.encryptAttributesSync.mockImplementationOnce(() => {
throw new Error('encryption failed!');
});
const unsafeTransforms = esoModelVersion.changes.filter(
(change) => change.type === 'unsafe_transform'
) as SavedObjectsModelUnsafeTransformChange[];
expect(unsafeTransforms.length === 1);
expect(() => {
unsafeTransforms[0].transformFn(
{
id: '123',
type: 'known-type-1',
namespace: 'namespace',
attributes,
},
context
);
}).toThrowError(`encryption failed!`);
expect(encryptionSavedObjectService.decryptAttributesSync).toHaveBeenCalledWith(
{
id: '123',
type: 'known-type-1',
namespace: 'namespace',
},
attributes,
{ isTypeBeingConverted: false }
);
expect(encryptionSavedObjectService.encryptAttributesSync).toHaveBeenCalledWith(
{
id: '123',
type: 'known-type-1',
namespace: 'namespace',
},
attributes
);
});
it('decrypts with input type, and encrypts with output type', () => {
const serviceWithInputLegacyType = encryptedSavedObjectsServiceMock.create();
const serviceWithOutputLegacyType = encryptedSavedObjectsServiceMock.create();
const instantiateServiceWithLegacyType = jest.fn();
function createEsoMv() {
instantiateServiceWithLegacyType
.mockImplementationOnce(() => serviceWithInputLegacyType)
.mockImplementationOnce(() => serviceWithOutputLegacyType);
const mvCreator = getCreateEsoModelVersion(
encryptionSavedObjectService,
instantiateServiceWithLegacyType
);
return mvCreator({
modelVersion: {
changes: [
{
type: 'unsafe_transform',
transformFn: (document) => {
// modify an encrypted field
document.attributes.firstAttr = `~~${document.attributes.firstAttr}~~`;
// encrypt a non encrypted field if it's there
if (document.attributes.nonEncryptedAttr) {
document.attributes.encryptedAttr = document.attributes.nonEncryptedAttr;
delete document.attributes.nonEncryptedAttr;
}
return { document };
},
},
],
},
inputType,
outputType,
});
}
const esoMv = createEsoMv();
expect(instantiateServiceWithLegacyType).toHaveBeenCalledWith(inputType);
expect(instantiateServiceWithLegacyType).toHaveBeenCalledWith(outputType);
serviceWithInputLegacyType.decryptAttributesSync.mockReturnValueOnce({
firstAttr: 'first_attr',
nonEncryptedAttr: 'non encrypted',
});
serviceWithOutputLegacyType.encryptAttributesSync.mockReturnValueOnce({
firstAttr: `#####`,
encryptedAttr: `#####`,
});
const unsafeTransforms = esoMv.changes.filter(
(change) => change.type === 'unsafe_transform'
) as SavedObjectsModelUnsafeTransformChange[];
expect(unsafeTransforms.length === 1);
const result = unsafeTransforms[0].transformFn(
{
id: '123',
type: 'known-type-1',
namespace: 'namespace',
attributes: {
firstAttr: '#####',
nonEncryptedAttr: 'non encrypted',
},
},
context
);
expect(result).toMatchObject({
document: {
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',
},
{ isTypeBeingConverted: false }
);
expect(serviceWithOutputLegacyType.encryptAttributesSync).toHaveBeenCalledWith(
{
id: '123',
type: 'known-type-1',
namespace: 'namespace',
},
{
firstAttr: `~~first_attr~~`,
encryptedAttr: 'non encrypted',
}
);
});
});

View file

@ -0,0 +1,135 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { buildModelVersionTransformFn } from '@kbn/core-saved-objects-base-server-internal';
import type {
SavedObjectModelTransformationFn,
SavedObjectsModelChange,
SavedObjectsModelVersion,
} from '@kbn/core-saved-objects-server';
import { EncryptionError } from './crypto';
import type { EncryptedSavedObjectsService, EncryptedSavedObjectTypeRegistration } from './crypto';
import { mapAttributes } from './saved_objects/map_attributes';
export interface CreateEsoModelVersionFnOpts {
modelVersion: SavedObjectsModelVersion;
shouldTransformIfDecryptionFails?: boolean;
inputType: EncryptedSavedObjectTypeRegistration;
outputType: EncryptedSavedObjectTypeRegistration;
}
// This function is designed to wrap a Model Version implementation of an Encrypted Saved Object (a Saved Object
// who's type is registered with the Encrypted Saved Object Plugin). The purpose of this wrapper is to ensure that
// version changes to the ESO what would require re-encryption (e.g.changes to encrypted fields or fields excluded
// from AAD) are performed correctly. Prior to Model Versions, the CreateEncryptedSavedObjectsMigrationFn handled
// wrapping migration functions for the same purpose.
//
// For Model Versions, 'data_backfill', 'data_removal', and 'unsafe_transform' changes are leveraged to implement
// any changes to the object as usual. This function returns a Model Version where the changes are merged into a
// single 'unsafe_transform' transform where the document being transformed is first decrypted via the inputType
// EncryptedSavedObjectTypeRegistration, then transformed based on the changes defined in the input Model Version,
// and finally encrypted via the outputType EncryptedSavedObjectTypeRegistration.The implementation for this can
// be found in getCreateEsoModelVersion below.
export type CreateEsoModelVersionFn = (
opts: CreateEsoModelVersionFnOpts
) => SavedObjectsModelVersion;
export const getCreateEsoModelVersion =
(
encryptedSavedObjectsService: Readonly<EncryptedSavedObjectsService>,
instantiateServiceWithLegacyType: (
typeRegistration: EncryptedSavedObjectTypeRegistration
) => EncryptedSavedObjectsService
): CreateEsoModelVersionFn =>
({ modelVersion, shouldTransformIfDecryptionFails, inputType, outputType }) => {
// If there are no changes, then there is no reason to create an Encrypted Saved Objects Model Version
// Throw an error to notify the developer
const incomingChanges = modelVersion.changes;
if (incomingChanges.length === 0) {
throw new Error(
`No Model Version changes defined. At least one change is required to create an Encrypted Saved Objects Model Version.`
);
}
if (inputType.type !== outputType.type) {
throw new Error(
`An invalid Encrypted Saved Objects Model Version transformation is trying to transform across types ("${inputType.type}" => "${outputType.type}"), which isn't permitted`
);
}
const inputService = instantiateServiceWithLegacyType(inputType);
const outputService =
inputType !== outputType ? instantiateServiceWithLegacyType(outputType) : inputService;
const transformFn = createMergedTransformFn(
inputService,
outputService,
shouldTransformIfDecryptionFails,
incomingChanges
);
return { ...modelVersion, changes: [{ type: 'unsafe_transform', transformFn }] };
};
function createMergedTransformFn(
inputService: Readonly<EncryptedSavedObjectsService>,
outputService: Readonly<EncryptedSavedObjectsService>,
shouldTransformIfDecryptionFails: boolean | undefined,
modelChanges: SavedObjectsModelChange[]
): SavedObjectModelTransformationFn {
// This merges the functions from all 'data_backfill', 'data_removal', and 'unsafe_transform' changes
const mergedTransformFn = buildModelVersionTransformFn(modelChanges);
return (document, context) => {
const { type, id, originId } = document;
const descriptorNamespace = context.namespaceType === 'single' ? document.namespace : undefined;
const encryptionDescriptor = { id, type, namespace: descriptorNamespace };
const decryptionParams = {
// Note about isTypeBeingConverted: false
// "Converting to multi-namespace clashes with the ZDT requirement for serverless"
// See deprecation in packages/core/saved-objects/core-saved-objects-server/src/migration.ts SavedObjectMigrationContext
isTypeBeingConverted: false,
originId,
};
const documentToTransform = mapAttributes(document, (inputAttributes) => {
try {
return inputService.decryptAttributesSync<any>(
encryptionDescriptor,
inputAttributes,
decryptionParams
);
} catch (err) {
if (!shouldTransformIfDecryptionFails || !(err instanceof EncryptionError)) {
throw err;
}
context.log.warn(
`Decryption failed for encrypted Saved Object "${document.id}" of type "${document.type}" with error: ${err.message}. Encrypted attributes have been stripped from the original document and model version transformation will be applied but this may cause errors later on.`
);
return inputService.stripOrDecryptAttributesSync<any>(
encryptionDescriptor,
inputAttributes,
decryptionParams
).attributes;
}
});
// call merged transforms
const result = mergedTransformFn(documentToTransform, context);
// encrypt
const transformedDoc = mapAttributes(result.document, (transformedAttributes) => {
return outputService.encryptAttributesSync<any>(encryptionDescriptor, transformedAttributes);
});
// return encrypted doc
return { ...result, document: transformedDoc };
};
}

View file

@ -202,7 +202,9 @@ describe('#stripOrDecryptAttributes', () => {
);
expect(decryptedAttributes).toEqual({ attrZero: 'zero', attrTwo: 'two', attrFour: 'four' });
expect(error).toMatchInlineSnapshot(`[Error: Unable to decrypt attribute "attrThree"]`);
expect(error).toMatchInlineSnapshot(
`[Error: Unable to decrypt attribute "attrThree" of saved object "known-type-1,object-id"]`
);
});
});
@ -257,7 +259,9 @@ describe('#stripOrDecryptAttributes', () => {
const encryptionError = error as EncryptionError;
expect(encryptionError.attributeName).toBe('attrThree');
expect(encryptionError.message).toBe('Unable to decrypt attribute "attrThree"');
expect(encryptionError.message).toBe(
'Unable to decrypt attribute "attrThree" of saved object "known-type-1,object-id"'
);
expect(encryptionError.cause).toEqual(
new Error('Decryption is disabled because of missing decryption keys.')
);
@ -381,7 +385,9 @@ describe('#stripOrDecryptAttributesSync', () => {
);
expect(decryptedAttributes).toEqual({ attrZero: 'zero', attrTwo: 'two', attrFour: 'four' });
expect(error).toMatchInlineSnapshot(`[Error: Unable to decrypt attribute "attrThree"]`);
expect(error).toMatchInlineSnapshot(
`[Error: Unable to decrypt attribute "attrThree" of saved object "known-type-1,object-id"]`
);
});
});
@ -436,7 +442,9 @@ describe('#stripOrDecryptAttributesSync', () => {
const encryptionError = error as EncryptionError;
expect(encryptionError.attributeName).toBe('attrThree');
expect(encryptionError.message).toBe('Unable to decrypt attribute "attrThree"');
expect(encryptionError.message).toBe(
'Unable to decrypt attribute "attrThree" of saved object "known-type-1,object-id"'
);
expect(encryptionError.cause).toEqual(
new Error('Decryption is disabled because of missing decryption keys.')
);

View file

@ -286,11 +286,15 @@ export class EncryptedSavedObjectsService {
encryptedAttributes[attributeName] = (yield [attributeValue, encryptionAAD])!;
} catch (err) {
this.options.logger.error(
`Failed to encrypt "${attributeName}" attribute: ${err.message || err}`
`Failed to encrypt "${attributeName}" attribute of saved object "${descriptorToArray(
descriptor
)}": ${err.message || err}`
);
throw new EncryptionError(
`Unable to encrypt attribute "${attributeName}"`,
`Unable to encrypt attribute "${attributeName}" of saved object "${descriptorToArray(
descriptor
)}"`,
attributeName,
EncryptionErrorOperation.Encryption,
err
@ -544,11 +548,15 @@ export class EncryptedSavedObjectsService {
decryptedAttributes[attributeName] = (yield [attributeValue, encryptionAADs])!;
} catch (err) {
this.options.logger.error(
`Failed to decrypt "${attributeName}" attribute: ${err.message || err}`
`Failed to decrypt attribute "${attributeName}" of saved object "${descriptorToArray(
descriptor
)}": ${err.message || err}`
);
throw new EncryptionError(
`Unable to decrypt attribute "${attributeName}"`,
`Unable to decrypt attribute "${attributeName}" of saved object "${descriptorToArray(
descriptor
)}"`,
attributeName,
EncryptionErrorOperation.Decryption,
err

View file

@ -19,6 +19,7 @@ function createEncryptedSavedObjectsSetupMock(
__legacyCompat: { registerLegacyAPI: jest.fn() },
canEncrypt,
createMigration: jest.fn(),
createModelVersion: jest.fn(),
} as jest.Mocked<EncryptedSavedObjectsPluginSetup>;
}

View file

@ -22,6 +22,7 @@ describe('EncryptedSavedObjects Plugin', () => {
Object {
"canEncrypt": false,
"createMigration": [Function],
"createModelVersion": [Function],
"registerType": [Function],
}
`);
@ -39,6 +40,7 @@ describe('EncryptedSavedObjects Plugin', () => {
Object {
"canEncrypt": true,
"createMigration": [Function],
"createModelVersion": [Function],
"registerType": [Function],
}
`);

View file

@ -12,8 +12,11 @@ import type { CoreSetup, Logger, Plugin, PluginInitializerContext } from '@kbn/c
import type { SecurityPluginSetup } from '@kbn/security-plugin/server';
import type { ConfigType } from './config';
import type { CreateEncryptedSavedObjectsMigrationFn } from './create_migration';
import { getCreateMigration } from './create_migration';
import {
type CreateEncryptedSavedObjectsMigrationFn,
getCreateMigration,
} from './create_migration';
import { type CreateEsoModelVersionFn, getCreateEsoModelVersion } from './create_model_version';
import type { EncryptedSavedObjectTypeRegistration } from './crypto';
import {
EncryptedSavedObjectsService,
@ -35,6 +38,7 @@ export interface EncryptedSavedObjectsPluginSetup {
canEncrypt: boolean;
registerType: (typeRegistration: EncryptedSavedObjectTypeRegistration) => void;
createMigration: CreateEncryptedSavedObjectsMigrationFn;
createModelVersion: CreateEsoModelVersionFn;
}
export interface EncryptedSavedObjectsPluginStart {
@ -129,6 +133,18 @@ export class EncryptedSavedObjectsPlugin
return serviceForMigration;
}
),
createModelVersion: getCreateEsoModelVersion(
service,
(typeRegistration: EncryptedSavedObjectTypeRegistration) => {
const serviceForMigration = new EncryptedSavedObjectsService({
primaryCrypto,
decryptionOnlyCryptos,
logger: this.logger,
});
serviceForMigration.registerType(typeRegistration);
return serviceForMigration;
}
),
};
}

View file

@ -0,0 +1,14 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { SavedObjectUnsanitizedDoc } from '@kbn/core/server';
export function mapAttributes<T>(obj: SavedObjectUnsanitizedDoc<T>, mapper: (attributes: T) => T) {
return Object.assign(obj, {
attributes: mapper(obj.attributes),
});
}

View file

@ -10,6 +10,7 @@
"@kbn/core",
"@kbn/utility-types",
"@kbn/core-saved-objects-server",
"@kbn/core-saved-objects-base-server-internal",
],
"exclude": [
"target/**/*",

View file

@ -55,7 +55,7 @@ export default function executionStatusAlertTests({ getService }: FtrProviderCon
executionStatus = await waitForStatus(alertId, new Set(['error']));
expect(executionStatus.error).to.be.ok();
expect(executionStatus.error.reason).to.be(RuleExecutionStatusErrorReasons.Decrypt);
expect(executionStatus.error.message).to.be('Unable to decrypt attribute "apiKey"');
expect(executionStatus.error.message).to.contain('Unable to decrypt attribute "apiKey"');
});
});

View file

@ -6,6 +6,10 @@
*/
import expect from '@kbn/expect';
import {
descriptorToArray,
SavedObjectDescriptor,
} from '@kbn/encrypted-saved-objects-plugin/server/crypto';
import { Spaces } from '../../../scenarios';
import { getUrlPrefix, getTestRuleData, ObjectRemover, getEventLog } from '../../../../common/lib';
import { FtrProviderContext } from '../../../../common/ftr_provider_context';
@ -73,12 +77,20 @@ export default function eventLogTests({ getService }: FtrProviderContext) {
const event = events[1];
expect(event).to.be.ok();
const expectedDescriptor: SavedObjectDescriptor = {
id: alertId,
type: 'alert',
// alerts types are multinamespace, so the namespace in the descriptor should not get set
};
validateEvent(event, {
spaceId,
savedObjects: [{ type: 'alert', id: alertId, rel: 'primary', type_id: 'test.noop' }],
outcome: 'failure',
message: `test.noop:${alertId}: execution failed`,
errorMessage: 'Unable to decrypt attribute "apiKey"',
errorMessage: `Unable to decrypt attribute "apiKey" of saved object "${descriptorToArray(
expectedDescriptor
)}"`,
status: 'error',
reason: 'decrypt',
shouldHaveTask: true,

View file

@ -88,3 +88,25 @@
}
}
}
{
"type": "doc",
"value": {
"id": "saved-object-mv:e35debe0-6c54-11ee-88d4-47e62f05d6ef",
"index": ".kibana",
"source": {
"saved-object-mv": {
"encryptedAttribute": "y4ZzV15mzNhVVTtFSk9Q/XsDqUJVi8B3i82OusVGFTOsm8VntzCq5j/QUTRdjulZUQq6SW1olrZwl2lrpvJuAJ9ItRMJHNwPVVo0ia9FgvnIjNZ4KUpj/Vrp3jtMNVhFEcKN7+zzxY9BDm2EYXGSiSVEDmSYTFM=",
"nonEncryptedAttribute": "elastic"
},
"typeMigrationVersion": "8.8.0",
"modelVersion": 0,
"references": [],
"namespaces": [
"default"
],
"type": "saved-object-mv",
"updated_at": "2020-06-17T15:35:39.839Z"
}
}
}

View file

@ -0,0 +1,64 @@
{
"type": "doc",
"value": {
"id": "config:8.0.0",
"index": ".kibana",
"source": {
"config": {
"buildNum": 9007199254740991
},
"references": [],
"type": "config",
"updated_at": "2020-06-17T15:03:14.532Z",
"namespaces": [
"default"
],
"coreMigrationVersion": "8.8.0",
"typeMigrationVersion": "7.9.0"
}
}
}
{
"type": "doc",
"value": {
"id": "saved-object-mv:e35debe0-6c54-11ee-88d4-47e62f05d6ef",
"index": ".kibana",
"source": {
"saved-object-mv": {
"encryptedAttribute": "x4ZzV15mzNhVVTtFSk9Q/XsDqUJVi8B3i82OusVGFTOsm8VntzCq5j/QUTRdjulZUQq6SW1olrZwl2lrpvJuAJ9ItRMJHNwPVVo0ia9FgvnIjNZ4KUpj/Vrp3jtMNVhFEcKN7+zzxY9BDm2EYXGSiSVEDmSYTFM=",
"nonEncryptedAttribute": "elastic"
},
"typeMigrationVersion": "8.8.0",
"modelVersion": 0,
"references": [],
"namespaces": [
"default"
],
"type": "saved-object-mv",
"updated_at": "2020-06-17T15:35:39.839Z"
}
}
}
{
"type": "doc",
"value": {
"id": "saved-object-mv:fd176460-6c56-11ee-b81b-d9ea3824cff5",
"index": ".kibana",
"source": {
"saved-object-mv": {
"encryptedAttribute": "FyAHi/Go7w/6/Ip2OZEnQOm7GbYIvFEIZa3xWXq4kfhskCQWQRXXeEI5gu7xWPjSADL4pxbkHiWIfLrJXH7i5fsrL/05zi74AQ8hYeMAQPno+tcDa22Azb/YDiaFo/6hGIoLb5r00pp2mSdsItIAVSiFg1c56gM=",
"nonEncryptedAttribute": "elastic"
},
"typeMigrationVersion": "8.8.0",
"modelVersion": 0,
"references": [],
"namespaces": [
"custom-space"
],
"type": "saved-object-mv",
"updated_at": "2020-06-17T15:35:39.839Z"
}
}
}

View file

@ -34,7 +34,7 @@ export function registerHiddenSORoutes(
});
} catch (err) {
if (encryptedSavedObjects.isEncryptionError(err)) {
return response.badRequest({ body: 'Failed to encrypt attributes' });
return response.badRequest({ body: 'Failed to decrypt attributes' });
}
return response.customError({ body: err, statusCode: 500 });

View file

@ -28,6 +28,9 @@ const SAVED_OBJECT_WITH_SECRET_AND_MULTIPLE_SPACES_TYPE =
const SAVED_OBJECT_WITHOUT_SECRET_TYPE = 'saved-object-without-secret';
const SAVED_OBJECT_WITH_MIGRATION_TYPE = 'saved-object-with-migration';
const SAVED_OBJECT_MV_TYPE = 'saved-object-mv';
interface MigratedTypePre790 {
nonEncryptedAttribute: string;
encryptedAttribute: string;
@ -88,6 +91,8 @@ export const plugin: PluginInitializer<void, void, PluginsSetup, PluginsStart> =
defineTypeWithMigration(core, deps);
defineModelVersionWithMigration(core, deps);
const router = core.http.createRouter();
router.get(
{
@ -107,7 +112,7 @@ export const plugin: PluginInitializer<void, void, PluginsSetup, PluginsStart> =
});
} catch (err) {
if (encryptedSavedObjects.isEncryptionError(err)) {
return response.badRequest({ body: 'Failed to encrypt attributes' });
return response.badRequest({ body: 'Failed to decrypt attributes' });
}
return response.customError({ body: err, statusCode: 500 });
@ -251,3 +256,75 @@ function defineTypeWithMigration(core: CoreSetup<PluginsStart>, deps: PluginsSet
},
});
}
function defineModelVersionWithMigration(core: CoreSetup<PluginsStart>, deps: PluginsSetup) {
const typePriorTo810 = {
type: SAVED_OBJECT_MV_TYPE,
attributesToEncrypt: new Set(['encryptedAttribute']),
};
const latestType = {
type: SAVED_OBJECT_MV_TYPE,
attributesToEncrypt: new Set(['encryptedAttribute', 'additionalEncryptedAttribute']),
};
deps.encryptedSavedObjects.registerType(latestType);
core.savedObjects.registerType({
name: SAVED_OBJECT_MV_TYPE,
hidden: false,
management: { importableAndExportable: true },
namespaceType: 'multiple-isolated',
switchToModelVersionAt: '8.10.0',
mappings: {
properties: {
nonEncryptedAttribute: {
type: 'keyword',
},
encryptedAttribute: {
type: 'binary',
},
additionalEncryptedAttribute: {
type: 'keyword',
},
},
},
modelVersions: {
'1': deps.encryptedSavedObjects.createModelVersion({
modelVersion: {
changes: [
{
type: 'unsafe_transform',
transformFn: (document) => {
const {
attributes: { nonEncryptedAttribute },
} = document;
document.attributes.nonEncryptedAttribute = `${nonEncryptedAttribute}-migrated`;
return { document };
},
},
],
},
inputType: typePriorTo810,
outputType: typePriorTo810,
shouldTransformIfDecryptionFails: true,
}),
'2': deps.encryptedSavedObjects.createModelVersion({
modelVersion: {
changes: [
{
type: 'unsafe_transform',
transformFn: (document) => {
// clone and modify the non encrypted field
document.attributes.additionalEncryptedAttribute = `${document.attributes.nonEncryptedAttribute}-encrypted`;
return { document };
},
},
],
},
inputType: typePriorTo810,
outputType: latestType,
shouldTransformIfDecryptionFails: true,
}),
},
});
}

View file

@ -8,6 +8,10 @@
import expect from '@kbn/expect';
import type { SavedObject } from '@kbn/core/server';
import { MAIN_SAVED_OBJECT_INDEX } from '@kbn/core-saved-objects-server';
import {
descriptorToArray,
SavedObjectDescriptor,
} from '@kbn/encrypted-saved-objects-plugin/server/crypto';
import type { FtrProviderContext } from '../ftr_provider_context';
export default function ({ getService }: FtrProviderContext) {
@ -26,7 +30,8 @@ export default function ({ getService }: FtrProviderContext) {
function runTests(
encryptedSavedObjectType: string,
getURLAPIBaseURL: () => string,
generateRawID: (id: string, type: string) => string
generateRawID: (id: string, type: string) => string,
expectedDescriptorNamespace?: string
) {
async function getRawSavedObjectAttributes({ id, type }: SavedObject) {
const { _source } = await es.get<Record<string, any>>({
@ -43,6 +48,8 @@ export default function ({ getService }: FtrProviderContext) {
publicPropertyExcludedFromAAD: string;
};
let expectedDescriptor: SavedObjectDescriptor;
let savedObject: SavedObject;
beforeEach(async () => {
savedObjectOriginalAttributes = {
@ -59,6 +66,11 @@ export default function ({ getService }: FtrProviderContext) {
.expect(200);
savedObject = body;
expectedDescriptor = {
namespace: expectedDescriptorNamespace,
type: encryptedSavedObjectType,
id: savedObject.id,
};
});
it('#create encrypts attributes and strips them from response', async () => {
@ -231,7 +243,9 @@ export default function ({ getService }: FtrProviderContext) {
publicPropertyExcludedFromAAD: savedObjectOriginalAttributes.publicPropertyExcludedFromAAD,
});
expect(response.error).to.eql({
message: 'Unable to decrypt attribute "publicPropertyStoredEncrypted"',
message: `Unable to decrypt attribute "publicPropertyStoredEncrypted" of saved object "${descriptorToArray(
expectedDescriptor
)}"`,
});
});
@ -275,7 +289,9 @@ export default function ({ getService }: FtrProviderContext) {
publicPropertyExcludedFromAAD: savedObjectOriginalAttributes.publicPropertyExcludedFromAAD,
});
expect(savedObjects[0].error).to.eql({
message: 'Unable to decrypt attribute "publicPropertyStoredEncrypted"',
message: `Unable to decrypt attribute "publicPropertyStoredEncrypted" of saved object "${descriptorToArray(
expectedDescriptor
)}"`,
});
});
@ -323,7 +339,9 @@ export default function ({ getService }: FtrProviderContext) {
publicPropertyExcludedFromAAD: savedObjectOriginalAttributes.publicPropertyExcludedFromAAD,
});
expect(savedObjects[0].error).to.eql({
message: 'Unable to decrypt attribute "publicPropertyStoredEncrypted"',
message: `Unable to decrypt attribute "publicPropertyStoredEncrypted" of saved object "${descriptorToArray(
expectedDescriptor
)}"`,
});
});
@ -423,7 +441,7 @@ export default function ({ getService }: FtrProviderContext) {
.expect(400, {
statusCode: 400,
error: 'Bad Request',
message: 'Failed to encrypt attributes',
message: 'Failed to decrypt attributes',
});
});
@ -455,7 +473,9 @@ export default function ({ getService }: FtrProviderContext) {
);
expect(decryptedResponse.saved_objects[0].error.message).to.be(
'Unable to decrypt attribute "privateProperty"'
`Unable to decrypt attribute "privateProperty" of saved object "${descriptorToArray(
expectedDescriptor
)}"`
);
expect(decryptedResponse.saved_objects[0].attributes).to.eql({
@ -561,7 +581,8 @@ export default function ({ getService }: FtrProviderContext) {
runTests(
SAVED_OBJECT_WITH_SECRET_TYPE,
() => `/s/${SPACE_ID}/api/saved_objects/`,
(id, type) => generateRawId(id, type, SPACE_ID)
(id, type) => generateRawId(id, type, SPACE_ID),
SPACE_ID
);
});
@ -575,45 +596,59 @@ export default function ({ getService }: FtrProviderContext) {
});
describe('migrations', () => {
before(async () => {
// we are injecting unknown types in this archive, so we need to relax the mappings restrictions
await es.indices.putMapping({ index: MAIN_SAVED_OBJECT_INDEX, dynamic: true });
await esArchiver.load(
'x-pack/test/encrypted_saved_objects_api_integration/fixtures/es_archiver/encrypted_saved_objects'
);
});
after(async () => {
await esArchiver.unload(
'x-pack/test/encrypted_saved_objects_api_integration/fixtures/es_archiver/encrypted_saved_objects'
);
});
function getGetApiUrl({ objectId, spaceId }: { objectId: string; spaceId?: string }) {
function getGetApiUrl({
type,
objectId,
spaceId,
}: {
type: string;
objectId: string;
spaceId?: string;
}) {
const spacePrefix = spaceId ? `/s/${spaceId}` : '';
return `${spacePrefix}/api/saved_objects/get-decrypted-as-internal-user/saved-object-with-migration/${objectId}`;
return `${spacePrefix}/api/saved_objects/get-decrypted-as-internal-user/${type}/${objectId}`;
}
// For brevity, each encrypted saved object has the same decrypted attributes after migrations/conversion.
// An assertion based on this ensures all encrypted fields can still be decrypted after migrations/conversion have been applied.
const expectedDecryptedAttributes = {
encryptedAttribute: 'this is my secret api key',
nonEncryptedAttribute: 'elastic-migrated', // this field was migrated in 7.8.0
additionalEncryptedAttribute: 'elastic-migrated-encrypted', // this field was added in 7.9.0
nonEncryptedAttribute: 'elastic-migrated', // this field was migrated in 7.8.0 or model version 1
additionalEncryptedAttribute: 'elastic-migrated-encrypted', // this field was added in 7.9.0 or model version 2
};
// In these test cases, we simulate a scenario where some existing objects that are migrated when Kibana starts up. Note that when a
// document migration is triggered, the saved object "convert" transform is also applied by the Core migration algorithm.
describe('handles index migration correctly', () => {
before(async () => {
// we are injecting unknown types in this archive, so we need to relax the mappings restrictions
await es.indices.putMapping({ index: MAIN_SAVED_OBJECT_INDEX, dynamic: true });
await esArchiver.load(
'x-pack/test/encrypted_saved_objects_api_integration/fixtures/es_archiver/encrypted_saved_objects'
);
});
after(async () => {
await esArchiver.unload(
'x-pack/test/encrypted_saved_objects_api_integration/fixtures/es_archiver/encrypted_saved_objects'
);
});
describe('in the default space', () => {
it('for a saved object that needs to be migrated before it is converted', async () => {
const getApiUrl = getGetApiUrl({ objectId: '74f3e6d7-b7bb-477d-ac28-92ee22728e6e' });
const getApiUrl = getGetApiUrl({
type: 'saved-object-with-migration',
objectId: '74f3e6d7-b7bb-477d-ac28-92ee22728e6e',
});
const { body: decryptedResponse } = await supertest.get(getApiUrl).expect(200);
expect(decryptedResponse.attributes).to.eql(expectedDecryptedAttributes);
});
it('for a saved object that does not need to be migrated before it is converted', async () => {
const getApiUrl = getGetApiUrl({ objectId: '362828f0-eef2-11eb-9073-11359682300a' });
const getApiUrl = getGetApiUrl({
type: 'saved-object-with-migration',
objectId: '362828f0-eef2-11eb-9073-11359682300a',
});
const { body: decryptedResponse } = await supertest.get(getApiUrl).expect(200);
expect(decryptedResponse.attributes).to.eql(expectedDecryptedAttributes);
});
@ -624,6 +659,7 @@ export default function ({ getService }: FtrProviderContext) {
it('for a saved object that needs to be migrated before it is converted', async () => {
const getApiUrl = getGetApiUrl({
type: 'saved-object-with-migration',
objectId: 'a98e22f8-530e-5d69-baf7-97526796f3a6', // This ID is not found in the data.json file, it is dynamically generated when the object is converted; the original ID is a67c6950-eed8-11eb-9a62-032b4e4049d1
spaceId,
});
@ -633,6 +669,7 @@ export default function ({ getService }: FtrProviderContext) {
it('for a saved object that does not need to be migrated before it is converted', async () => {
const getApiUrl = getGetApiUrl({
type: 'saved-object-with-migration',
objectId: '41395c74-da7a-5679-9535-412d550a6cf7', // This ID is not found in the data.json file, it is dynamically generated when the object is converted; the original ID is 36448a90-eef2-11eb-9073-11359682300a
spaceId,
});
@ -646,6 +683,20 @@ export default function ({ getService }: FtrProviderContext) {
// `migrationVersion` field is included below. Note that when a document migration is triggered, the saved object "convert" transform
// is *not* applied by the Core migration algorithm.
describe('handles document migration correctly', () => {
before(async () => {
// we are injecting unknown types in this archive, so we need to relax the mappings restrictions
await es.indices.putMapping({ index: MAIN_SAVED_OBJECT_INDEX, dynamic: true });
await esArchiver.load(
'x-pack/test/encrypted_saved_objects_api_integration/fixtures/es_archiver/encrypted_saved_objects'
);
});
after(async () => {
await esArchiver.unload(
'x-pack/test/encrypted_saved_objects_api_integration/fixtures/es_archiver/encrypted_saved_objects'
);
});
function getCreateApiUrl({ spaceId }: { spaceId?: string } = {}) {
const spacePrefix = spaceId ? `/s/${spaceId}` : '';
return `${spacePrefix}/api/saved_objects/saved-object-with-migration`;
@ -668,7 +719,7 @@ export default function ({ getService }: FtrProviderContext) {
.expect(200);
const { id: objectId } = savedObject;
const getApiUrl = getGetApiUrl({ objectId });
const getApiUrl = getGetApiUrl({ type: 'saved-object-with-migration', objectId });
const { body: decryptedResponse } = await supertest.get(getApiUrl).expect(200);
expect(decryptedResponse.attributes).to.eql(expectedDecryptedAttributes);
});
@ -683,7 +734,48 @@ export default function ({ getService }: FtrProviderContext) {
.expect(200);
const { id: objectId } = savedObject;
const getApiUrl = getGetApiUrl({ objectId, spaceId });
const getApiUrl = getGetApiUrl({
type: 'saved-object-with-migration',
objectId,
spaceId,
});
const { body: decryptedResponse } = await supertest.get(getApiUrl).expect(200);
expect(decryptedResponse.attributes).to.eql(expectedDecryptedAttributes);
});
});
// In these test cases, we simulate a scenario where some existing model version objects need to be migrated. This happens because
// they have an outdated model version number. This also means that the encryptedSavedObjects.createModelVersion wrapper is used to
// facilitate the migration (see x-pack/test/encrypted_saved_objects_api_integration/plugins/api_consumer_plugin/server/index.ts)
describe('handles model version transforms correctly', () => {
before(async () => {
await es.indices.putMapping({ index: MAIN_SAVED_OBJECT_INDEX, dynamic: true });
await esArchiver.load(
'x-pack/test/encrypted_saved_objects_api_integration/fixtures/es_archiver/encrypted_saved_objects_model_version'
);
});
after(async () => {
await esArchiver.unload(
'x-pack/test/encrypted_saved_objects_api_integration/fixtures/es_archiver/encrypted_saved_objects_model_version'
);
});
it('in the default space', async () => {
const getApiUrl = getGetApiUrl({
type: 'saved-object-mv',
objectId: 'e35debe0-6c54-11ee-88d4-47e62f05d6ef',
});
const { body: decryptedResponse } = await supertest.get(getApiUrl).expect(200);
expect(decryptedResponse.attributes).to.eql(expectedDecryptedAttributes);
});
it('in a custom space', async () => {
const getApiUrl = getGetApiUrl({
type: 'saved-object-mv',
objectId: 'fd176460-6c56-11ee-b81b-d9ea3824cff5',
spaceId: 'custom-space',
});
const { body: decryptedResponse } = await supertest.get(getApiUrl).expect(200);
expect(decryptedResponse.attributes).to.eql(expectedDecryptedAttributes);
});

View file

@ -5,12 +5,14 @@
* 2.0.
*/
import { MAIN_SAVED_OBJECT_INDEX } from '@kbn/core-saved-objects-server';
import expect from '@kbn/expect';
import { FtrProviderContext } from '../ftr_provider_context';
export default function ({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
const esArchiver = getService('esArchiver');
const es = getService('es');
describe('encrypted saved objects decryption', () => {
// This test uses esArchiver to load alert and action saved objects that have been created with a different encryption key
@ -21,6 +23,7 @@ export default function ({ getService }: FtrProviderContext) {
describe('migrations', () => {
before(async () => {
await es.indices.putMapping({ index: MAIN_SAVED_OBJECT_INDEX, dynamic: true });
await esArchiver.load(
'x-pack/test/encrypted_saved_objects_api_integration/fixtures/es_archiver/encrypted_saved_objects_different_key'
);
@ -60,6 +63,20 @@ export default function ({ getService }: FtrProviderContext) {
expect(migratedRule.secrets).to.be(undefined);
expect(migratedConnector.is_missing_secrets).to.eql(false);
});
// This validates the shouldTransformIfDecryptionFails flag
// (see x-pack/test/encrypted_saved_objects_api_integration/plugins/api_consumer_plugin/server/index.ts)
it('performs model version transforms even if decryption fails', async () => {
const { body: decryptedResponse } = await supertest
.get(
`/api/saved_objects/get-decrypted-as-internal-user/saved-object-mv/e35debe0-6c54-11ee-88d4-47e62f05d6ef`
)
.expect(200); // operation will throw if flag is set to false
expect(decryptedResponse.attributes).to.eql({
nonEncryptedAttribute: 'elastic-migrated',
additionalEncryptedAttribute: 'elastic-migrated-encrypted',
});
});
});
});
}

View file

@ -4439,6 +4439,10 @@
version "0.0.0"
uid ""
"@kbn/eso-model-version-example@link:examples/eso_model_version_example":
version "0.0.0"
uid ""
"@kbn/eso-plugin@link:x-pack/test/encrypted_saved_objects_api_integration/plugins/api_consumer_plugin":
version "0.0.0"
uid ""