From 79c45cb511d5542ea4d8632a94b847c5a6369306 Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin Date: Mon, 20 Jul 2020 12:30:50 +0200 Subject: [PATCH 1/5] Add support for the encryption key rotation to the encrypted saved objects plugin. --- .../encrypted_saved_objects/server/config.ts | 37 ++- .../crypto/encrypted_saved_objects_service.ts | 108 ++++++-- .../crypto/encryption_key_rotation_service.ts | 252 ++++++++++++++++++ .../server/crypto/index.ts | 1 + .../encrypted_saved_objects/server/plugin.ts | 39 ++- .../server/routes/index.ts | 27 ++ .../server/routes/key_rotation.ts | 55 ++++ 7 files changed, 480 insertions(+), 39 deletions(-) create mode 100644 x-pack/plugins/encrypted_saved_objects/server/crypto/encryption_key_rotation_service.ts create mode 100644 x-pack/plugins/encrypted_saved_objects/server/routes/index.ts create mode 100644 x-pack/plugins/encrypted_saved_objects/server/routes/key_rotation.ts diff --git a/x-pack/plugins/encrypted_saved_objects/server/config.ts b/x-pack/plugins/encrypted_saved_objects/server/config.ts index 9c751a9c67f523..7fb7265b89297d 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/config.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/config.ts @@ -7,17 +7,33 @@ import crypto from 'crypto'; import { map } from 'rxjs/operators'; import { schema, TypeOf } from '@kbn/config-schema'; +import { UnwrapObservable } from '@kbn/utility-types'; import { PluginInitializerContext } from 'src/core/server'; -export const ConfigSchema = schema.object({ - enabled: schema.boolean({ defaultValue: true }), - encryptionKey: schema.conditional( - schema.contextRef('dist'), - true, - schema.maybe(schema.string({ minLength: 32 })), - schema.string({ minLength: 32, defaultValue: 'a'.repeat(32) }) - ), -}); +export type ConfigType = UnwrapObservable>; + +export const ConfigSchema = schema.object( + { + enabled: schema.boolean({ defaultValue: true }), + encryptionKey: schema.conditional( + schema.contextRef('dist'), + true, + schema.maybe(schema.string({ minLength: 32 })), + schema.string({ minLength: 32, defaultValue: 'a'.repeat(32) }) + ), + keyRotation: schema.object({ + decryptionOnlyKeys: schema.arrayOf(schema.string({ minLength: 32 }), { defaultValue: [] }), + }), + }, + { + validate(value) { + const decryptionOnlyKeys = value.keyRotation?.decryptionOnlyKeys ?? []; + if (value.encryptionKey && decryptionOnlyKeys.includes(value.encryptionKey)) { + return '`keyRotation.decryptionOnlyKeys` cannot contain primary encryption key specified in `encryptionKey`.'; + } + }, + } +); export function createConfig$(context: PluginInitializerContext) { return context.config.create>().pipe( @@ -37,7 +53,8 @@ export function createConfig$(context: PluginInitializerContext) { } return { - config: { ...config, encryptionKey }, + ...config, + encryptionKey, usingEphemeralEncryptionKey, }; }) diff --git a/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.ts b/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.ts index 82d6bb9be15f67..a0201327af495b 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.ts @@ -52,6 +52,16 @@ interface CommonParameters { user?: AuthenticatedUser; } +/** + * Describes parameters for the decrypt methods. + */ +interface DecryptParameters extends CommonParameters { + /** + * Indicates whether decryption should only be performed using secondary decryption-only keys. + */ + useDecryptionOnlyKeys?: boolean; +} + /** * Utility function that gives array representation of the saved object descriptor respecting * optional `namespace` property. @@ -80,15 +90,21 @@ export class EncryptedSavedObjectsService { > = new Map(); /** - * @param crypto nodeCrypto instance. + * @param cryptos nodeCrypto instances used for encryption and/or decryption. The first crypto + * in the list is considered as primary and it's the only one that is used for both encryption and + * decryption, the rest are decryption-only cryptos. * @param logger Ordinary logger instance. * @param audit Audit logger instance. */ constructor( - private readonly crypto: Readonly, + private readonly cryptos: Readonly, private readonly logger: Logger, private readonly audit: EncryptedSavedObjectsAuditLogger - ) {} + ) { + if (cryptos.length === 0 || !cryptos[0]) { + throw new Error('The list of cryptos should include at least one item.'); + } + } /** * Registers saved object type as the one that contains attributes that should be encrypted. @@ -136,7 +152,7 @@ export class EncryptedSavedObjectsService { descriptor: SavedObjectDescriptor, attributes: T, originalAttributes?: T, - params?: CommonParameters + params?: DecryptParameters ) { const typeDefinition = this.typeDefinitions.get(descriptor.type); if (typeDefinition === undefined) { @@ -174,7 +190,7 @@ export class EncryptedSavedObjectsService { Object.fromEntries( Object.entries(attributes).filter(([key]) => !typeDefinition.shouldBeStripped(key)) ) as T, - { user: params?.user } + params ); } catch (err) { decryptionError = err; @@ -264,13 +280,14 @@ export class EncryptedSavedObjectsService { attributes: T, params?: CommonParameters ): Promise { + const encrypter = this.getEncrypter(); const iterator = this.attributesToEncryptIterator(descriptor, attributes, params); let iteratorResult = iterator.next(); while (!iteratorResult.done) { const [attributeValue, encryptionAAD] = iteratorResult.value; try { - iteratorResult = iterator.next(await this.crypto.encrypt(attributeValue, encryptionAAD)); + iteratorResult = iterator.next(await encrypter.encrypt(attributeValue, encryptionAAD)); } catch (err) { iterator.throw!(err); } @@ -293,13 +310,14 @@ export class EncryptedSavedObjectsService { attributes: T, params?: CommonParameters ): T { + const encrypter = this.getEncrypter(); const iterator = this.attributesToEncryptIterator(descriptor, attributes, params); let iteratorResult = iterator.next(); while (!iteratorResult.done) { const [attributeValue, encryptionAAD] = iteratorResult.value; try { - iteratorResult = iterator.next(this.crypto.encryptSync(attributeValue, encryptionAAD)); + iteratorResult = iterator.next(encrypter.encryptSync(attributeValue, encryptionAAD)); } catch (err) { iterator.throw!(err); } @@ -321,19 +339,31 @@ export class EncryptedSavedObjectsService { public async decryptAttributes>( descriptor: SavedObjectDescriptor, attributes: T, - params?: CommonParameters + params?: DecryptParameters ): Promise { + const decrypters = this.getDecrypters(params?.useDecryptionOnlyKeys); const iterator = this.attributesToDecryptIterator(descriptor, attributes, params); let iteratorResult = iterator.next(); while (!iteratorResult.done) { const [attributeValue, encryptionAAD] = iteratorResult.value; - try { - iteratorResult = iterator.next( - (await this.crypto.decrypt(attributeValue, encryptionAAD)) as string - ); - } catch (err) { - iterator.throw!(err); + + let decryptionError; + for (const decrypter of decrypters) { + try { + iteratorResult = iterator.next(await decrypter.decrypt(attributeValue, encryptionAAD)); + decryptionError = undefined; + break; + } catch (err) { + // Remember the error thrown when we tried to decrypt with the primary key. + if (!decryptionError) { + decryptionError = err; + } + } + } + + if (decryptionError) { + iterator.throw!(decryptionError); } } @@ -353,17 +383,31 @@ export class EncryptedSavedObjectsService { public decryptAttributesSync>( descriptor: SavedObjectDescriptor, attributes: T, - params?: CommonParameters + params?: DecryptParameters ): T { + const decrypters = this.getDecrypters(params?.useDecryptionOnlyKeys); const iterator = this.attributesToDecryptIterator(descriptor, attributes, params); let iteratorResult = iterator.next(); while (!iteratorResult.done) { const [attributeValue, encryptionAAD] = iteratorResult.value; - try { - iteratorResult = iterator.next(this.crypto.decryptSync(attributeValue, encryptionAAD)); - } catch (err) { - iterator.throw!(err); + + let decryptionError; + for (const decrypter of decrypters) { + try { + iteratorResult = iterator.next(decrypter.decryptSync(attributeValue, encryptionAAD)); + decryptionError = undefined; + break; + } catch (err) { + // Remember the error thrown when we tried to decrypt with the primary key. + if (!decryptionError) { + decryptionError = err; + } + } + } + + if (decryptionError) { + iterator.throw!(decryptionError); } } @@ -468,4 +512,30 @@ export class EncryptedSavedObjectsService { return stringify([...descriptorToArray(descriptor), attributesAAD]); } + + /** + * Returns NodeCrypto instance used for encryption (the primary crypto). + */ + private getEncrypter() { + return this.cryptos[0]; + } + + /** + * Returns list of NodeCrypto instances used for decryption. + * @param useDecryptionOnlyKeys Specifies whether returned decrypters should include only those + * that are using decryption only keys (the secondary cryptos). + */ + private getDecrypters(useDecryptionOnlyKeys?: boolean) { + if (!useDecryptionOnlyKeys) { + return this.cryptos; + } + + if (this.cryptos.length === 1) { + throw new Error( + `"useDecryptionOnlyKeys" cannot be set when decryption only keys aren't configured.` + ); + } + + return this.cryptos.slice(1); + } } diff --git a/x-pack/plugins/encrypted_saved_objects/server/crypto/encryption_key_rotation_service.ts b/x-pack/plugins/encrypted_saved_objects/server/crypto/encryption_key_rotation_service.ts new file mode 100644 index 00000000000000..cdf9acd81b2e95 --- /dev/null +++ b/x-pack/plugins/encrypted_saved_objects/server/crypto/encryption_key_rotation_service.ts @@ -0,0 +1,252 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + ISavedObjectTypeRegistry, + KibanaRequest, + Logger, + SavedObject, + SavedObjectsBulkUpdateObject, + StartServicesAccessor, +} from 'src/core/server'; +import { AuthenticatedUser, SecurityPluginSetup } from '../../../security/server'; +import { getDescriptorNamespace } from '../saved_objects/get_descriptor_namespace'; +import { EncryptedSavedObjectsService } from './encrypted_saved_objects_service'; + +interface EncryptionKeyRotationServiceOptions { + logger: Logger; + service: PublicMethodsOf; + getStartServices: StartServicesAccessor; + security?: SecurityPluginSetup; +} + +interface EncryptionKeyRotationParams { + /** + * The maximum number of the objects we fetch and process in one iteration. + */ + batchSize: number; + + /** + * Optionally allows to limit key rotation to only specified Saved Object type. + */ + type?: string; +} + +interface EncryptionKeyRotationResult { + /** + * The total number of the Saved Objects encrypted by the Encrypted Saved Objects plugin. + */ + total: number; + + /** + * The number of the Saved Objects that were still encrypted with one of the secondary encryption + * keys and were successfully re-encrypted with the primary key. + */ + successful: number; + + /** + * The number of the Saved Objects that were still encrypted with one of the secondary encryption + * keys that we failed to re-encrypt with the primary key. + */ + failed: number; +} + +/** + * Service that deals with encryption key rotation matters. + */ +export class EncryptionKeyRotationService { + constructor(private readonly options: EncryptionKeyRotationServiceOptions) {} + + public async rotate( + request: KibanaRequest, + { batchSize, type }: EncryptionKeyRotationParams + ): Promise { + const [{ savedObjects }] = await this.options.getStartServices(); + const typeRegistry = savedObjects.getTypeRegistry(); + + // We need to retrieve all SavedObject types which have encrypted attributes, specifically + // collecting those that are hidden as they are ignored by the Saved Objects client by default. + this.options.logger.debug('Retrieving Saved Object types that require encryption.'); + const registeredSavedObjectTypes = []; + const registeredHiddenSavedObjectTypes = []; + for (const knownType of typeRegistry.getAllTypes()) { + if (this.options.service.isRegistered(knownType.name) && (!type || knownType.name === type)) { + registeredSavedObjectTypes.push(knownType.name); + + if (knownType.hidden) { + registeredHiddenSavedObjectTypes.push(knownType.name); + } + } + } + + const result = { total: 0, successful: 0, failed: 0 }; + if (registeredSavedObjectTypes.length === 0) { + this.options.logger.debug( + type + ? `Saved Object type "${type}" is not registered, encryption key rotation is not needed.` + : 'There are no registered Saved Object types that can have encrypted attributes, encryption key rotation is not needed.' + ); + return result; + } + + this.options.logger.debug( + `Saved Objects with the following types [${registeredSavedObjectTypes}] will be processed.` + ); + + // We need two separate Saved Objects clients for the retrieval and update. For retrieval we + // don't want to have Encrypted Saved Objects wrapper so that it doesn't strip encrypted + // attributes. But for the update we want to have it so that it automatically re-encrypts + // attributes with the new primary encryption key. + const user = this.options.security?.authc.getCurrentUser(request) ?? undefined; + const retrieveClient = savedObjects.getScopedClient(request, { + includedHiddenTypes: registeredHiddenSavedObjectTypes, + excludedWrappers: ['encryptedSavedObjects'], + }); + const updateClient = savedObjects.getScopedClient(request, { + includedHiddenTypes: registeredHiddenSavedObjectTypes, + }); + + // Keeps track of object IDs that have been processed already. + const processedObjectIDs = new Set(); + + // Until we get scroll/search_after support in Saved Objects client we have to retrieve as much objects as allowed + // by the `batchSize` parameter. Instead of using paging functionality (size/from or page/perPage parameters) that + // has certain performance issues and is also limited by the maximum result window setting on .kibana index + // (10,000 by default) we always fetch the first page of the results sorted by the `updated_at` field. This way we + // can prioritize "old" objects that have a higher chance to have been encrypted with the old encryption keys, since + // all newly created or updated objects are always encrypted with the current primary key. Re-encryption of the + // "old" objects with the primary key implicitly bumps up their `updated_at` field so that these objects won't be + // included into the first page of the results during next iteration. Additionally we track IDs of all processed + // objects so that eventually we can detect that first page consists of only objects encrypted with the current + // primary key and stop iterating. + // + // LIMITATION: if we have a lot of "old" objects encrypted with the _unknown_ encryption key it may either + // significantly slow down rotation or prevent it from happening completely since such objects will be included into + // every batch we fetch and if their number is equal to or greater than `batchSize` we won't be able to process any + // object. Another and more complex case when we can be hit by this limitation is when users have multiple Kibana + // instances configured with different primary encryption keys, these time even "new" objects may require rotation, + // but they may be included into 2+ page of the results. We can potentially detect such cases and issue a warning, + // but it's not an easy task: if we detect a case when none of the objects from the very first batch cannot be + // decrypted with the decryption only keys we'll need to check how many of them can be decrypted at all using all + // available keys including the current primary one. + // + // Also theoretically if `batchSize` is less than `index.max_result_window` we could try to rely on the paging + // functionality and switch to the second page, but the issue here is that objects can be deleted in the meantime + // so that unprocessed objects may get into the first page and we'll miss them. We can of course oscillate between + // the first and the second pages or do multiple rotation passes, but it'd complicate code significantly. + let batch = 0; + while (true) { + this.options.logger.debug(`Fetching ${batchSize} objects (batch #${batch}).`); + const savedObjectsToDecrypt = await retrieveClient.find({ + type: registeredSavedObjectTypes, + perPage: batchSize, + namespaces: ['*'], + sortField: 'updated_at', + sortOrder: 'asc', + }); + + // We use `total` only from the first batch just as an approximate indicator for the consumer since total number + // can change from batch to batch, but it won't affect the actual processing logic. + if (batch === 0) { + this.options.logger.debug(`Found ${savedObjectsToDecrypt.total} objects.`); + result.total = savedObjectsToDecrypt.total; + } + + this.options.logger.debug( + `Decrypting ${savedObjectsToDecrypt.saved_objects.length} objects (batch #${batch}).` + ); + const savedObjectsToEncrypt = await this.getSavedObjectsToReEncrypt( + savedObjectsToDecrypt.saved_objects, + processedObjectIDs, + typeRegistry, + user + ); + if (savedObjectsToEncrypt.length === 0) { + break; + } + + this.options.logger.debug( + `Re-encrypting ${savedObjectsToEncrypt.length} objects (batch #${batch}).` + ); + try { + const succeeded = ( + await updateClient.bulkUpdate(savedObjectsToEncrypt) + ).saved_objects.filter((savedObject) => !savedObject.error).length; + + this.options.logger.debug( + `Successfully re-encrypted ${succeeded} out of ${savedObjectsToEncrypt.length} objects (batch #${batch}).` + ); + + result.successful += succeeded; + result.failed += savedObjectsToEncrypt.length - succeeded; + } catch (err) { + this.options.logger.error( + `Failed to re-encrypt saved objects (batch #${batch}): ${err.message}` + ); + result.failed += savedObjectsToEncrypt.length; + } + + if (savedObjectsToDecrypt.total <= batchSize) { + break; + } + + batch++; + } + + this.options.logger.debug( + `Encryption key rotation is completed. ${result.successful} objects out ouf ${result.total} were successfully re-encrypted with the primary encryption key and ${result.failed} objects failed.` + ); + + return result; + } + + /** + * Takes a list of Saved Objects and tries to decrypt their attributes with the secondary encryption + * keys, silently skipping those that cannot be decrypted. The objects that were decrypted with the + * decryption-only keys will be returned and grouped by the namespace. + * @param savedObjects Saved Objects to decrypt attributes for. + * @param processedObjectIDs Set of Saved Object IDs that were already processed. + * @param typeRegistry Saved Objects type registry. + * @param user The user that initiated decryption. + */ + private async getSavedObjectsToReEncrypt( + savedObjects: SavedObject[], + processedObjectIDs: Set, + typeRegistry: ISavedObjectTypeRegistry, + user?: AuthenticatedUser + ) { + const decryptedSavedObjects: SavedObjectsBulkUpdateObject[] = []; + for (const savedObject of savedObjects) { + // We shouldn't process objects that we already processed during previous iterations. + if (processedObjectIDs.has(savedObject.id)) { + continue; + } else { + processedObjectIDs.add(savedObject.id); + } + + const namespace = savedObject.namespaces?.[0]; + let decryptedAttributes; + try { + decryptedAttributes = await this.options.service.decryptAttributes( + { + type: savedObject.type, + id: savedObject.id, + namespace: getDescriptorNamespace(typeRegistry, savedObject.type, namespace), + }, + savedObject.attributes as Record, + { useDecryptionOnlyKeys: true, user } + ); + } catch { + // Just skip object if we couldn't decrypt it with the decryption-only keys. + continue; + } + + decryptedSavedObjects.push({ ...savedObject, attributes: decryptedAttributes, namespace }); + } + + return decryptedSavedObjects; + } +} diff --git a/x-pack/plugins/encrypted_saved_objects/server/crypto/index.ts b/x-pack/plugins/encrypted_saved_objects/server/crypto/index.ts index 75445bd24eba84..ff5e5fdc010596 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/crypto/index.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/crypto/index.ts @@ -12,3 +12,4 @@ export { } from './encrypted_saved_objects_service'; export { EncryptionError } from './encryption_error'; export { EncryptedSavedObjectAttributesDefinition } from './encrypted_saved_object_type_definition'; +export { EncryptionKeyRotationService } from './encryption_key_rotation_service'; diff --git a/x-pack/plugins/encrypted_saved_objects/server/plugin.ts b/x-pack/plugins/encrypted_saved_objects/server/plugin.ts index 69777798ddf192..2f3237d23d02e2 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/plugin.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/plugin.ts @@ -13,10 +13,12 @@ import { EncryptedSavedObjectsService, EncryptedSavedObjectTypeRegistration, EncryptionError, + EncryptionKeyRotationService, } from './crypto'; import { EncryptedSavedObjectsAuditLogger } from './audit'; import { setupSavedObjects, ClientInstanciator } from './saved_objects'; import { getCreateMigration, CreateEncryptedSavedObjectsMigrationFn } from './create_migration'; +import { defineRoutes } from './routes'; export interface PluginsSetup { security?: SecurityPluginSetup; @@ -48,18 +50,21 @@ export class Plugin { core: CoreSetup, deps: PluginsSetup ): Promise { - const { - config: { encryptionKey }, - usingEphemeralEncryptionKey, - } = await createConfig$(this.initializerContext).pipe(first()).toPromise(); - - const crypto = nodeCrypto({ encryptionKey }); - + const config = await createConfig$(this.initializerContext).pipe(first()).toPromise(); const auditLogger = new EncryptedSavedObjectsAuditLogger( deps.security?.audit.getLogger('encryptedSavedObjects') ); + + // This list of cryptos consists of the primary crypto that is used for both encryption and + // decryption, and the optional secondary cryptos that are used for decryption only. + const cryptos = [ + nodeCrypto({ encryptionKey: config.encryptionKey }), + ...config.keyRotation.decryptionOnlyKeys.map((decryptionKey) => + nodeCrypto({ encryptionKey: decryptionKey }) + ), + ]; const service = Object.freeze( - new EncryptedSavedObjectsService(crypto, this.logger, auditLogger) + new EncryptedSavedObjectsService(cryptos, this.logger, auditLogger) ); this.savedObjectsSetup = setupSavedObjects({ @@ -69,15 +74,29 @@ export class Plugin { getStartServices: core.getStartServices, }); + defineRoutes({ + router: core.http.createRouter(), + logger: this.initializerContext.logger.get('routes'), + encryptionKeyRotationService: Object.freeze( + new EncryptionKeyRotationService({ + logger: this.logger.get('key-rotation-service'), + service, + getStartServices: core.getStartServices, + security: deps.security, + }) + ), + config, + }); + return { registerType: (typeRegistration: EncryptedSavedObjectTypeRegistration) => service.registerType(typeRegistration), - usingEphemeralEncryptionKey, + usingEphemeralEncryptionKey: config.usingEphemeralEncryptionKey, createMigration: getCreateMigration( service, (typeRegistration: EncryptedSavedObjectTypeRegistration) => { const serviceForMigration = new EncryptedSavedObjectsService( - crypto, + cryptos, this.logger, auditLogger ); diff --git a/x-pack/plugins/encrypted_saved_objects/server/routes/index.ts b/x-pack/plugins/encrypted_saved_objects/server/routes/index.ts new file mode 100644 index 00000000000000..a4135b136094c5 --- /dev/null +++ b/x-pack/plugins/encrypted_saved_objects/server/routes/index.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { IRouter, Logger } from '../../../../../src/core/server'; +import { ConfigType } from '../config'; +import { EncryptionKeyRotationService } from '../crypto'; + +import { defineKeyRotationRoutes } from './key_rotation'; + +/** + * Describes parameters used to define HTTP routes. + */ +export interface RouteDefinitionParams { + router: IRouter; + logger: Logger; + config: ConfigType; + encryptionKeyRotationService: PublicMethodsOf; +} + +export function defineRoutes(params: RouteDefinitionParams) { + if (params.config.keyRotation.decryptionOnlyKeys.length > 0) { + defineKeyRotationRoutes(params); + } +} diff --git a/x-pack/plugins/encrypted_saved_objects/server/routes/key_rotation.ts b/x-pack/plugins/encrypted_saved_objects/server/routes/key_rotation.ts new file mode 100644 index 00000000000000..92429254e133e2 --- /dev/null +++ b/x-pack/plugins/encrypted_saved_objects/server/routes/key_rotation.ts @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { schema } from '@kbn/config-schema'; +import { RouteDefinitionParams } from '.'; + +/** + * The default maximum value of from + size for searches to .kibana index. Since we cannot use scroll + * or search_after functionality with the .kibana index we limit maximum batch size with this value. + */ +const DEFAULT_MAX_RESULT_WINDOW = 10000; + +/** + * Defines routes that are used for encryption key rotation. + */ +export function defineKeyRotationRoutes({ + encryptionKeyRotationService, + router, + logger, +}: RouteDefinitionParams) { + router.post( + { + path: '/api/encrypted_saved_objects/rotate_key', + validate: { + query: schema.object({ + batchSize: schema.number({ + min: 1, + max: DEFAULT_MAX_RESULT_WINDOW, + defaultValue: DEFAULT_MAX_RESULT_WINDOW, + }), + type: schema.maybe(schema.string()), + }), + }, + options: { + tags: ['access:rotateEncryptionKey'], + }, + }, + async (context, request, response) => { + try { + return response.ok({ + body: await encryptionKeyRotationService.rotate(request, { + batchSize: request.query.batchSize, + type: request.query.type, + }), + }); + } catch (err) { + logger.error(err); + return response.internalError(); + } + } + ); +} From 1e53e6165f2fac3e6139bd4c09b6688425b442d9 Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin Date: Thu, 1 Oct 2020 07:38:00 +0200 Subject: [PATCH 2/5] Review#1: allow new setting in docker config, always register rotation route, prevent multiple concurrent rotation operation, limit max number of batches during rotation and more, add tests. --- docs/setup/production.asciidoc | 1 + .../resources/bin/kibana-docker | 1 + .../server/config.test.ts | 108 +++- .../encrypted_saved_objects/server/config.ts | 44 +- .../encrypted_saved_objects_service.test.ts | 387 ++++++++++++-- .../crypto/encrypted_saved_objects_service.ts | 106 ++-- .../encryption_key_rotation_service.mocks.ts | 15 + .../encryption_key_rotation_service.test.ts | 502 ++++++++++++++++++ .../crypto/encryption_key_rotation_service.ts | 34 +- .../server/crypto/index.mock.ts | 1 + .../server/plugin.test.ts | 9 +- .../encrypted_saved_objects/server/plugin.ts | 44 +- .../server/routes/index.mock.ts | 19 + .../server/routes/index.ts | 4 +- .../server/routes/key_rotation.test.ts | 172 ++++++ .../server/routes/key_rotation.ts | 22 +- .../get_descriptor_namespace.test.ts | 11 + .../saved_objects/get_descriptor_namespace.ts | 8 +- 18 files changed, 1321 insertions(+), 167 deletions(-) create mode 100644 x-pack/plugins/encrypted_saved_objects/server/crypto/encryption_key_rotation_service.mocks.ts create mode 100644 x-pack/plugins/encrypted_saved_objects/server/crypto/encryption_key_rotation_service.test.ts create mode 100644 x-pack/plugins/encrypted_saved_objects/server/routes/index.mock.ts create mode 100644 x-pack/plugins/encrypted_saved_objects/server/routes/key_rotation.test.ts diff --git a/docs/setup/production.asciidoc b/docs/setup/production.asciidoc index 3075220e3a47c5..e097704e05d406 100644 --- a/docs/setup/production.asciidoc +++ b/docs/setup/production.asciidoc @@ -135,6 +135,7 @@ Settings that must be the same: xpack.security.encryptionKey //decrypting session information xpack.reporting.encryptionKey //decrypting reports xpack.encryptedSavedObjects.encryptionKey // decrypting saved objects +xpack.encryptedSavedObjects.keyRotation.decryptionOnlyKeys // saved objects encryption key rotation, if any -------- Separate configuration files can be used from the command line by using the `-c` flag: diff --git a/src/dev/build/tasks/os_packages/docker_generator/resources/bin/kibana-docker b/src/dev/build/tasks/os_packages/docker_generator/resources/bin/kibana-docker index 2770f288b6af8f..959e1f8dc3e729 100755 --- a/src/dev/build/tasks/os_packages/docker_generator/resources/bin/kibana-docker +++ b/src/dev/build/tasks/os_packages/docker_generator/resources/bin/kibana-docker @@ -159,6 +159,7 @@ kibana_vars=( xpack.code.security.gitHostWhitelist xpack.code.security.gitProtocolWhitelist xpack.encryptedSavedObjects.encryptionKey + xpack.encryptedSavedObjects.keyRotation.decryptionOnlyKeys xpack.graph.enabled xpack.graph.canEditDrillDownUrls xpack.graph.savePolicy diff --git a/x-pack/plugins/encrypted_saved_objects/server/config.test.ts b/x-pack/plugins/encrypted_saved_objects/server/config.test.ts index 3f8074eb15c0c3..cbe987830717fc 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/config.test.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/config.test.ts @@ -6,9 +6,8 @@ jest.mock('crypto', () => ({ randomBytes: jest.fn() })); -import { first } from 'rxjs/operators'; -import { loggingSystemMock, coreMock } from 'src/core/server/mocks'; -import { createConfig$, ConfigSchema } from './config'; +import { loggingSystemMock } from 'src/core/server/mocks'; +import { createConfig, ConfigSchema } from './config'; describe('config schema', () => { it('generates proper defaults', () => { @@ -16,6 +15,9 @@ describe('config schema', () => { Object { "enabled": true, "encryptionKey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "keyRotation": Object { + "decryptionOnlyKeys": Array [], + }, } `); @@ -23,12 +25,41 @@ describe('config schema', () => { Object { "enabled": true, "encryptionKey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "keyRotation": Object { + "decryptionOnlyKeys": Array [], + }, } `); expect(ConfigSchema.validate({}, { dist: true })).toMatchInlineSnapshot(` Object { "enabled": true, + "keyRotation": Object { + "decryptionOnlyKeys": Array [], + }, + } + `); + }); + + it('properly validates config', () => { + expect( + ConfigSchema.validate( + { + encryptionKey: 'a'.repeat(32), + keyRotation: { decryptionOnlyKeys: ['b'.repeat(32), 'c'.repeat(32)] }, + }, + { dist: true } + ) + ).toMatchInlineSnapshot(` + Object { + "enabled": true, + "encryptionKey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "keyRotation": Object { + "decryptionOnlyKeys": Array [ + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "cccccccccccccccccccccccccccccccc", + ], + }, } `); }); @@ -46,21 +77,65 @@ describe('config schema', () => { `"[encryptionKey]: value has length [3] but it must have a minimum length of [32]."` ); }); + + it('should throw error if any of the xpack.encryptedSavedObjects.keyRotation.decryptionOnlyKeys is less than 32 characters', () => { + expect(() => + ConfigSchema.validate({ + keyRotation: { decryptionOnlyKeys: ['a'.repeat(32), 'b'.repeat(31)] }, + }) + ).toThrowErrorMatchingInlineSnapshot( + `"[keyRotation.decryptionOnlyKeys.1]: value has length [31] but it must have a minimum length of [32]."` + ); + + expect(() => + ConfigSchema.validate( + { keyRotation: { decryptionOnlyKeys: ['a'.repeat(32), 'b'.repeat(31)] } }, + { dist: true } + ) + ).toThrowErrorMatchingInlineSnapshot( + `"[keyRotation.decryptionOnlyKeys.1]: value has length [31] but it must have a minimum length of [32]."` + ); + }); + + it('should throw error if any of the xpack.encryptedSavedObjects.keyRotation.decryptionOnlyKeys is equal to xpack.encryptedSavedObjects.encryptionKey', () => { + expect(() => + ConfigSchema.validate({ + encryptionKey: 'a'.repeat(32), + keyRotation: { decryptionOnlyKeys: ['a'.repeat(32)] }, + }) + ).toThrowErrorMatchingInlineSnapshot( + `"\`keyRotation.decryptionOnlyKeys\` cannot contain primary encryption key specified in \`encryptionKey\`."` + ); + + expect(() => + ConfigSchema.validate( + { + encryptionKey: 'a'.repeat(32), + keyRotation: { decryptionOnlyKeys: ['a'.repeat(32)] }, + }, + { dist: true } + ) + ).toThrowErrorMatchingInlineSnapshot( + `"\`keyRotation.decryptionOnlyKeys\` cannot contain primary encryption key specified in \`encryptionKey\`."` + ); + }); }); -describe('createConfig$()', () => { - it('should log a warning, set xpack.encryptedSavedObjects.encryptionKey and usingEphemeralEncryptionKey=true when encryptionKey is not set', async () => { +describe('createConfig()', () => { + it('should log a warning, set xpack.encryptedSavedObjects.encryptionKey and usingEphemeralEncryptionKey=true when encryptionKey is not set', () => { const mockRandomBytes = jest.requireMock('crypto').randomBytes; mockRandomBytes.mockReturnValue('ab'.repeat(16)); - const contextMock = coreMock.createPluginInitializerContext({}); - const config = await createConfig$(contextMock).pipe(first()).toPromise(); + const logger = loggingSystemMock.create().get(); + const config = createConfig(ConfigSchema.validate({}, { dist: true }), logger); expect(config).toEqual({ - config: { encryptionKey: 'ab'.repeat(16) }, + enabled: true, + encryptionKey: 'ab'.repeat(16), + keyRotation: { decryptionOnlyKeys: [] }, usingEphemeralEncryptionKey: true, }); - expect(loggingSystemMock.collect(contextMock.logger).warn).toMatchInlineSnapshot(` + expect(loggingSystemMock.collect(logger).warn).toMatchInlineSnapshot(` Array [ Array [ "Generating a random key for xpack.encryptedSavedObjects.encryptionKey. To be able to decrypt encrypted saved objects attributes after restart, please set xpack.encryptedSavedObjects.encryptionKey in kibana.yml", @@ -70,15 +145,18 @@ describe('createConfig$()', () => { }); it('should not log a warning and set usingEphemeralEncryptionKey=false when encryptionKey is set', async () => { - const contextMock = coreMock.createPluginInitializerContext({ - encryptionKey: 'supersecret', - }); - const config = await createConfig$(contextMock).pipe(first()).toPromise(); + const logger = loggingSystemMock.create().get(); + const config = createConfig( + ConfigSchema.validate({ encryptionKey: 'supersecret'.repeat(3) }, { dist: true }), + logger + ); expect(config).toEqual({ - config: { encryptionKey: 'supersecret' }, + enabled: true, + encryptionKey: 'supersecret'.repeat(3), + keyRotation: { decryptionOnlyKeys: [] }, usingEphemeralEncryptionKey: false, }); - expect(loggingSystemMock.collect(contextMock.logger).warn).toEqual([]); + expect(loggingSystemMock.collect(logger).warn).toEqual([]); }); }); diff --git a/x-pack/plugins/encrypted_saved_objects/server/config.ts b/x-pack/plugins/encrypted_saved_objects/server/config.ts index 7fb7265b89297d..f06c6fa1823ba6 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/config.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/config.ts @@ -5,12 +5,10 @@ */ import crypto from 'crypto'; -import { map } from 'rxjs/operators'; import { schema, TypeOf } from '@kbn/config-schema'; -import { UnwrapObservable } from '@kbn/utility-types'; -import { PluginInitializerContext } from 'src/core/server'; +import { Logger } from 'src/core/server'; -export type ConfigType = UnwrapObservable>; +export type ConfigType = ReturnType; export const ConfigSchema = schema.object( { @@ -35,28 +33,22 @@ export const ConfigSchema = schema.object( } ); -export function createConfig$(context: PluginInitializerContext) { - return context.config.create>().pipe( - map((config) => { - const logger = context.logger.get('config'); +export function createConfig(config: TypeOf, logger: Logger) { + let encryptionKey = config.encryptionKey; + const usingEphemeralEncryptionKey = encryptionKey === undefined; + if (encryptionKey === undefined) { + logger.warn( + 'Generating a random key for xpack.encryptedSavedObjects.encryptionKey. ' + + 'To be able to decrypt encrypted saved objects attributes after restart, ' + + 'please set xpack.encryptedSavedObjects.encryptionKey in kibana.yml' + ); - let encryptionKey = config.encryptionKey; - const usingEphemeralEncryptionKey = encryptionKey === undefined; - if (encryptionKey === undefined) { - logger.warn( - 'Generating a random key for xpack.encryptedSavedObjects.encryptionKey. ' + - 'To be able to decrypt encrypted saved objects attributes after restart, ' + - 'please set xpack.encryptedSavedObjects.encryptionKey in kibana.yml' - ); - - encryptionKey = crypto.randomBytes(16).toString('hex'); - } + encryptionKey = crypto.randomBytes(16).toString('hex'); + } - return { - ...config, - encryptionKey, - usingEphemeralEncryptionKey, - }; - }) - ); + return { + ...config, + encryptionKey, + usingEphemeralEncryptionKey, + }; } diff --git a/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.test.ts b/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.test.ts index 42d2e2ffd15163..88d57072697fe6 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.test.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.test.ts @@ -14,40 +14,44 @@ import { EncryptionError } from './encryption_error'; import { loggingSystemMock } from 'src/core/server/mocks'; import { encryptedSavedObjectsAuditLoggerMock } from '../audit/index.mock'; -const crypto = nodeCrypto({ encryptionKey: 'encryption-key-abc' }); +function createNodeCryptMock(encryptionKey: string) { + const crypto = nodeCrypto({ encryptionKey }); + const nodeCryptoMock: jest.Mocked = { + encrypt: jest.fn(), + decrypt: jest.fn(), + encryptSync: jest.fn(), + decryptSync: jest.fn(), + }; -const mockNodeCrypto: jest.Mocked = { - encrypt: jest.fn(), - decrypt: jest.fn(), - encryptSync: jest.fn(), - decryptSync: jest.fn(), -}; - -let service: EncryptedSavedObjectsService; -let mockAuditLogger: jest.Mocked; - -beforeEach(() => { // Call actual `@elastic/node-crypto` by default, but allow to override implementation in tests. - mockNodeCrypto.encrypt.mockImplementation(async (input: any, aad?: string) => + nodeCryptoMock.encrypt.mockImplementation(async (input: any, aad?: string) => crypto.encrypt(input, aad) ); - mockNodeCrypto.decrypt.mockImplementation( + nodeCryptoMock.decrypt.mockImplementation( async (encryptedOutput: string | Buffer, aad?: string) => crypto.decrypt(encryptedOutput, aad) ); - mockNodeCrypto.encryptSync.mockImplementation((input: any, aad?: string) => + nodeCryptoMock.encryptSync.mockImplementation((input: any, aad?: string) => crypto.encryptSync(input, aad) ); - mockNodeCrypto.decryptSync.mockImplementation((encryptedOutput: string | Buffer, aad?: string) => + nodeCryptoMock.decryptSync.mockImplementation((encryptedOutput: string | Buffer, aad?: string) => crypto.decryptSync(encryptedOutput, aad) ); + return nodeCryptoMock; +} + +let mockNodeCrypto: jest.Mocked; +let service: EncryptedSavedObjectsService; +let mockAuditLogger: jest.Mocked; +beforeEach(() => { + mockNodeCrypto = createNodeCryptMock('encryption-key-abc'); mockAuditLogger = encryptedSavedObjectsAuditLoggerMock.create(); - service = new EncryptedSavedObjectsService( - mockNodeCrypto, - loggingSystemMock.create().get(), - mockAuditLogger - ); + service = new EncryptedSavedObjectsService({ + primaryCrypto: mockNodeCrypto, + logger: loggingSystemMock.create().get(), + audit: mockAuditLogger, + }); }); afterEach(() => jest.resetAllMocks()); @@ -229,11 +233,11 @@ describe('#encryptAttributes', () => { async (valueToEncrypt, aad) => `|${valueToEncrypt}|${aad}|` ); - service = new EncryptedSavedObjectsService( - mockNodeCrypto, - loggingSystemMock.create().get(), - mockAuditLogger - ); + service = new EncryptedSavedObjectsService({ + primaryCrypto: mockNodeCrypto, + logger: loggingSystemMock.create().get(), + audit: mockAuditLogger, + }); }); it('does not encrypt attributes for unknown types', async () => { @@ -304,6 +308,34 @@ describe('#encryptAttributes', () => { ); }); + it('encrypts only using primary crypto', async () => { + const attributes = { attrOne: 'one', attrTwo: 'two', attrThree: 'three', attrFour: null }; + + const decryptionOnlyCrypto = createNodeCryptMock('some-key'); + service = new EncryptedSavedObjectsService({ + primaryCrypto: mockNodeCrypto, + decryptionOnlyCryptos: [decryptionOnlyCrypto], + logger: loggingSystemMock.create().get(), + audit: mockAuditLogger, + }); + service.registerType({ + type: 'known-type-1', + attributesToEncrypt: new Set(['attrOne', 'attrThree', 'attrFour']), + }); + + await expect( + service.encryptAttributes({ type: 'known-type-1', id: 'object-id' }, attributes) + ).resolves.toEqual({ + attrOne: '|one|["known-type-1","object-id",{"attrTwo":"two"}]|', + attrTwo: 'two', + attrThree: '|three|["known-type-1","object-id",{"attrTwo":"two"}]|', + attrFour: null, + }); + + expect(decryptionOnlyCrypto.encrypt).not.toHaveBeenCalled(); + expect(decryptionOnlyCrypto.encryptSync).not.toHaveBeenCalled(); + }); + it('encrypts only attributes that are supposed to be encrypted even if not all provided', async () => { const attributes = { attrTwo: 'two', attrThree: 'three' }; @@ -923,11 +955,11 @@ describe('#decryptAttributes', () => { }); it('fails if encrypted with another encryption key', async () => { - service = new EncryptedSavedObjectsService( - nodeCrypto({ encryptionKey: 'encryption-key-abc*' }), - loggingSystemMock.create().get(), - mockAuditLogger - ); + service = new EncryptedSavedObjectsService({ + primaryCrypto: nodeCrypto({ encryptionKey: 'encryption-key-abc*' }), + logger: loggingSystemMock.create().get(), + audit: mockAuditLogger, + }); service.registerType({ type: 'known-type-1', @@ -949,6 +981,123 @@ describe('#decryptAttributes', () => { ); }); }); + + describe('with decryption only keys', () => { + function getService(primaryCrypto: Crypto, decryptionOnlyCryptos?: Readonly) { + const esoService = new EncryptedSavedObjectsService({ + primaryCrypto, + decryptionOnlyCryptos, + logger: loggingSystemMock.create().get(), + audit: mockAuditLogger, + }); + + esoService.registerType({ + type: 'known-type-1', + attributesToEncrypt: new Set(['attrOne', 'attrThree', 'attrFour']), + }); + + return esoService; + } + + const attributes = { attrOne: 'one', attrTwo: 'two', attrThree: 'three', attrFour: null }; + + let decryptionOnlyCryptoOne: jest.Mocked; + let decryptionOnlyCryptoTwo: jest.Mocked; + beforeEach(() => { + decryptionOnlyCryptoOne = createNodeCryptMock('old-key-one'); + decryptionOnlyCryptoTwo = createNodeCryptMock('old-key-two'); + + service = getService(mockNodeCrypto, [decryptionOnlyCryptoOne, decryptionOnlyCryptoTwo]); + }); + + it('does not use decryption only keys if we can decrypt using primary key', async () => { + const encryptedAttributes = await service.encryptAttributes( + { type: 'known-type-1', id: 'object-id' }, + attributes + ); + + await expect( + service.decryptAttributes({ type: 'known-type-1', id: 'object-id' }, encryptedAttributes) + ).resolves.toEqual({ attrOne: 'one', attrTwo: 'two', attrThree: 'three', attrFour: null }); + expect(mockAuditLogger.decryptAttributesSuccess).toHaveBeenCalledTimes(1); + expect(mockAuditLogger.decryptAttributesSuccess).toHaveBeenCalledWith( + ['attrOne', 'attrThree'], + { type: 'known-type-1', id: 'object-id' }, + undefined + ); + + expect(decryptionOnlyCryptoOne.decrypt).not.toHaveBeenCalled(); + expect(decryptionOnlyCryptoTwo.decrypt).not.toHaveBeenCalled(); + }); + + it('uses decryption only keys if cannot decrypt using primary key', async () => { + const encryptedAttributes = await getService(decryptionOnlyCryptoOne).encryptAttributes( + { type: 'known-type-1', id: 'object-id' }, + attributes + ); + + await expect( + service.decryptAttributes({ type: 'known-type-1', id: 'object-id' }, encryptedAttributes) + ).resolves.toEqual({ attrOne: 'one', attrTwo: 'two', attrThree: 'three', attrFour: null }); + expect(mockAuditLogger.decryptAttributesSuccess).toHaveBeenCalledTimes(1); + expect(mockAuditLogger.decryptAttributesSuccess).toHaveBeenCalledWith( + ['attrOne', 'attrThree'], + { type: 'known-type-1', id: 'object-id' }, + undefined + ); + + // One call per attributes, we have 2 of them. + expect(mockNodeCrypto.decrypt).toHaveBeenCalledTimes(2); + expect(decryptionOnlyCryptoOne.decrypt).toHaveBeenCalledTimes(2); + expect(decryptionOnlyCryptoTwo.decrypt).not.toHaveBeenCalled(); + }); + + it('uses all available decryption only keys if needed', async () => { + const encryptedAttributes = await getService(decryptionOnlyCryptoTwo).encryptAttributes( + { type: 'known-type-1', id: 'object-id' }, + attributes + ); + + await expect( + service.decryptAttributes({ type: 'known-type-1', id: 'object-id' }, encryptedAttributes) + ).resolves.toEqual({ attrOne: 'one', attrTwo: 'two', attrThree: 'three', attrFour: null }); + expect(mockAuditLogger.decryptAttributesSuccess).toHaveBeenCalledTimes(1); + expect(mockAuditLogger.decryptAttributesSuccess).toHaveBeenCalledWith( + ['attrOne', 'attrThree'], + { type: 'known-type-1', id: 'object-id' }, + undefined + ); + + // One call per attributes, we have 2 of them. + expect(mockNodeCrypto.decrypt).toHaveBeenCalledTimes(2); + expect(decryptionOnlyCryptoOne.decrypt).toHaveBeenCalledTimes(2); + expect(decryptionOnlyCryptoTwo.decrypt).toHaveBeenCalledTimes(2); + }); + + it('does not use primary encryption key if `omitPrimaryEncryptionKey` is specified', async () => { + const encryptedAttributes = await getService(decryptionOnlyCryptoOne).encryptAttributes( + { type: 'known-type-1', id: 'object-id' }, + attributes + ); + + await expect( + service.decryptAttributes({ type: 'known-type-1', id: 'object-id' }, encryptedAttributes, { + omitPrimaryEncryptionKey: true, + }) + ).resolves.toEqual({ attrOne: 'one', attrTwo: 'two', attrThree: 'three', attrFour: null }); + expect(mockAuditLogger.decryptAttributesSuccess).toHaveBeenCalledTimes(1); + expect(mockAuditLogger.decryptAttributesSuccess).toHaveBeenCalledWith( + ['attrOne', 'attrThree'], + { type: 'known-type-1', id: 'object-id' }, + undefined + ); + + // One call per attributes, we have 2 of them. + expect(mockNodeCrypto.decrypt).not.toHaveBeenCalled(); + expect(decryptionOnlyCryptoOne.decrypt).toHaveBeenCalledTimes(2); + expect(decryptionOnlyCryptoTwo.decrypt).not.toHaveBeenCalled(); + }); + }); }); describe('#encryptAttributesSync', () => { @@ -957,11 +1106,11 @@ describe('#encryptAttributesSync', () => { (valueToEncrypt, aad) => `|${valueToEncrypt}|${aad}|` ); - service = new EncryptedSavedObjectsService( - mockNodeCrypto, - loggingSystemMock.create().get(), - mockAuditLogger - ); + service = new EncryptedSavedObjectsService({ + primaryCrypto: mockNodeCrypto, + logger: loggingSystemMock.create().get(), + audit: mockAuditLogger, + }); }); it('does not encrypt attributes that are not supposed to be encrypted', () => { @@ -996,6 +1145,34 @@ describe('#encryptAttributesSync', () => { }); }); + it('encrypts only using primary crypto', async () => { + const attributes = { attrOne: 'one', attrTwo: 'two', attrThree: 'three', attrFour: null }; + + const decryptionOnlyCrypto = createNodeCryptMock('some-key'); + service = new EncryptedSavedObjectsService({ + primaryCrypto: mockNodeCrypto, + decryptionOnlyCryptos: [decryptionOnlyCrypto], + logger: loggingSystemMock.create().get(), + audit: mockAuditLogger, + }); + service.registerType({ + type: 'known-type-1', + attributesToEncrypt: new Set(['attrOne', 'attrThree', 'attrFour']), + }); + + expect( + service.encryptAttributesSync({ type: 'known-type-1', id: 'object-id' }, attributes) + ).toEqual({ + attrOne: '|one|["known-type-1","object-id",{"attrTwo":"two"}]|', + attrTwo: 'two', + attrThree: '|three|["known-type-1","object-id",{"attrTwo":"two"}]|', + attrFour: null, + }); + + expect(decryptionOnlyCrypto.encrypt).not.toHaveBeenCalled(); + expect(decryptionOnlyCrypto.encryptSync).not.toHaveBeenCalled(); + }); + it('encrypts only attributes that are supposed to be encrypted even if not all provided', () => { const attributes = { attrTwo: 'two', attrThree: 'three' }; @@ -1459,11 +1636,11 @@ describe('#decryptAttributesSync', () => { }); it('fails if encrypted with another encryption key', () => { - service = new EncryptedSavedObjectsService( - nodeCrypto({ encryptionKey: 'encryption-key-abc*' }), - loggingSystemMock.create().get(), - mockAuditLogger - ); + service = new EncryptedSavedObjectsService({ + primaryCrypto: nodeCrypto({ encryptionKey: 'encryption-key-abc*' }), + logger: loggingSystemMock.create().get(), + audit: mockAuditLogger, + }); service.registerType({ type: 'known-type-1', @@ -1478,4 +1655,132 @@ describe('#decryptAttributesSync', () => { ).toThrowError(EncryptionError); }); }); + + describe('with decryption only keys', () => { + function getService(primaryCrypto: Crypto, decryptionOnlyCryptos?: Readonly) { + const esoService = new EncryptedSavedObjectsService({ + primaryCrypto, + decryptionOnlyCryptos, + logger: loggingSystemMock.create().get(), + audit: mockAuditLogger, + }); + + esoService.registerType({ + type: 'known-type-1', + attributesToEncrypt: new Set(['attrOne', 'attrThree', 'attrFour']), + }); + + return esoService; + } + + const attributes = { attrOne: 'one', attrTwo: 'two', attrThree: 'three', attrFour: null }; + + let decryptionOnlyCryptoOne: jest.Mocked; + let decryptionOnlyCryptoTwo: jest.Mocked; + beforeEach(() => { + decryptionOnlyCryptoOne = createNodeCryptMock('old-key-one'); + decryptionOnlyCryptoTwo = createNodeCryptMock('old-key-two'); + + service = getService(mockNodeCrypto, [decryptionOnlyCryptoOne, decryptionOnlyCryptoTwo]); + }); + + it('does not use decryption only keys if we can decrypt using primary key', () => { + const encryptedAttributes = service.encryptAttributesSync( + { type: 'known-type-1', id: 'object-id' }, + attributes + ); + + expect( + service.decryptAttributesSync( + { type: 'known-type-1', id: 'object-id' }, + encryptedAttributes + ) + ).toEqual({ attrOne: 'one', attrTwo: 'two', attrThree: 'three', attrFour: null }); + expect(mockAuditLogger.decryptAttributesSuccess).toHaveBeenCalledTimes(1); + expect(mockAuditLogger.decryptAttributesSuccess).toHaveBeenCalledWith( + ['attrOne', 'attrThree'], + { type: 'known-type-1', id: 'object-id' }, + undefined + ); + + expect(decryptionOnlyCryptoOne.decryptSync).not.toHaveBeenCalled(); + expect(decryptionOnlyCryptoTwo.decryptSync).not.toHaveBeenCalled(); + }); + + it('uses decryption only keys if cannot decrypt using primary key', () => { + const encryptedAttributes = getService(decryptionOnlyCryptoOne).encryptAttributesSync( + { type: 'known-type-1', id: 'object-id' }, + attributes + ); + + expect( + service.decryptAttributesSync( + { type: 'known-type-1', id: 'object-id' }, + encryptedAttributes + ) + ).toEqual({ attrOne: 'one', attrTwo: 'two', attrThree: 'three', attrFour: null }); + expect(mockAuditLogger.decryptAttributesSuccess).toHaveBeenCalledTimes(1); + expect(mockAuditLogger.decryptAttributesSuccess).toHaveBeenCalledWith( + ['attrOne', 'attrThree'], + { type: 'known-type-1', id: 'object-id' }, + undefined + ); + + // One call per attributes, we have 2 of them. + expect(mockNodeCrypto.decryptSync).toHaveBeenCalledTimes(2); + expect(decryptionOnlyCryptoOne.decryptSync).toHaveBeenCalledTimes(2); + expect(decryptionOnlyCryptoTwo.decryptSync).not.toHaveBeenCalled(); + }); + + it('uses all available decryption only keys if needed', () => { + const encryptedAttributes = getService(decryptionOnlyCryptoTwo).encryptAttributesSync( + { type: 'known-type-1', id: 'object-id' }, + attributes + ); + + expect( + service.decryptAttributesSync( + { type: 'known-type-1', id: 'object-id' }, + encryptedAttributes + ) + ).toEqual({ attrOne: 'one', attrTwo: 'two', attrThree: 'three', attrFour: null }); + expect(mockAuditLogger.decryptAttributesSuccess).toHaveBeenCalledTimes(1); + expect(mockAuditLogger.decryptAttributesSuccess).toHaveBeenCalledWith( + ['attrOne', 'attrThree'], + { type: 'known-type-1', id: 'object-id' }, + undefined + ); + + // One call per attributes, we have 2 of them. + expect(mockNodeCrypto.decryptSync).toHaveBeenCalledTimes(2); + expect(decryptionOnlyCryptoOne.decryptSync).toHaveBeenCalledTimes(2); + expect(decryptionOnlyCryptoTwo.decryptSync).toHaveBeenCalledTimes(2); + }); + + it('does not use primary encryption key if `omitPrimaryEncryptionKey` is specified', () => { + const encryptedAttributes = getService(decryptionOnlyCryptoOne).encryptAttributesSync( + { type: 'known-type-1', id: 'object-id' }, + attributes + ); + + expect( + service.decryptAttributesSync( + { type: 'known-type-1', id: 'object-id' }, + encryptedAttributes, + { omitPrimaryEncryptionKey: true } + ) + ).toEqual({ attrOne: 'one', attrTwo: 'two', attrThree: 'three', attrFour: null }); + expect(mockAuditLogger.decryptAttributesSuccess).toHaveBeenCalledTimes(1); + expect(mockAuditLogger.decryptAttributesSuccess).toHaveBeenCalledWith( + ['attrOne', 'attrThree'], + { type: 'known-type-1', id: 'object-id' }, + undefined + ); + + // One call per attributes, we have 2 of them. + expect(mockNodeCrypto.decryptSync).not.toHaveBeenCalled(); + expect(decryptionOnlyCryptoOne.decryptSync).toHaveBeenCalledTimes(2); + expect(decryptionOnlyCryptoTwo.decryptSync).not.toHaveBeenCalled(); + }); + }); }); diff --git a/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.ts b/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.ts index a0201327af495b..1f1093a179538c 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.ts @@ -59,7 +59,29 @@ interface DecryptParameters extends CommonParameters { /** * Indicates whether decryption should only be performed using secondary decryption-only keys. */ - useDecryptionOnlyKeys?: boolean; + omitPrimaryEncryptionKey?: boolean; +} + +interface EncryptedSavedObjectsServiceOptions { + /** + * Service logger instance. + */ + logger: Logger; + + /** + * Audit logger instance. + */ + audit: EncryptedSavedObjectsAuditLogger; + + /** + * NodeCrypto instance used for both encryption and decryption. + */ + primaryCrypto: Crypto; + + /** + * NodeCrypto instances used ONLY for decryption (i.e. rotated encryption keys). + */ + decryptionOnlyCryptos?: Readonly; } /** @@ -89,22 +111,7 @@ export class EncryptedSavedObjectsService { EncryptedSavedObjectAttributesDefinition > = new Map(); - /** - * @param cryptos nodeCrypto instances used for encryption and/or decryption. The first crypto - * in the list is considered as primary and it's the only one that is used for both encryption and - * decryption, the rest are decryption-only cryptos. - * @param logger Ordinary logger instance. - * @param audit Audit logger instance. - */ - constructor( - private readonly cryptos: Readonly, - private readonly logger: Logger, - private readonly audit: EncryptedSavedObjectsAuditLogger - ) { - if (cryptos.length === 0 || !cryptos[0]) { - throw new Error('The list of cryptos should include at least one item.'); - } - } + constructor(private readonly options: EncryptedSavedObjectsServiceOptions) {} /** * Registers saved object type as the one that contains attributes that should be encrypted. @@ -226,10 +233,10 @@ export class EncryptedSavedObjectsService { try { encryptedAttributes[attributeName] = (yield [attributeValue, encryptionAAD])!; } catch (err) { - this.logger.error( + this.options.logger.error( `Failed to encrypt "${attributeName}" attribute: ${err.message || err}` ); - this.audit.encryptAttributeFailure(attributeName, descriptor, params?.user); + this.options.audit.encryptAttributeFailure(attributeName, descriptor, params?.user); throw new EncryptionError( `Unable to encrypt attribute "${attributeName}"`, @@ -245,7 +252,7 @@ export class EncryptedSavedObjectsService { // not the case we should collect and log them to make troubleshooting easier. const encryptedAttributesKeys = Object.keys(encryptedAttributes); if (encryptedAttributesKeys.length !== typeDefinition.attributesToEncrypt.size) { - this.logger.debug( + this.options.logger.debug( `The following attributes of saved object "${descriptorToArray( descriptor )}" should have been encrypted: ${Array.from( @@ -258,7 +265,7 @@ export class EncryptedSavedObjectsService { return attributes; } - this.audit.encryptAttributesSuccess(encryptedAttributesKeys, descriptor, params?.user); + this.options.audit.encryptAttributesSuccess(encryptedAttributesKeys, descriptor, params?.user); return { ...attributes, @@ -280,14 +287,15 @@ export class EncryptedSavedObjectsService { attributes: T, params?: CommonParameters ): Promise { - const encrypter = this.getEncrypter(); const iterator = this.attributesToEncryptIterator(descriptor, attributes, params); let iteratorResult = iterator.next(); while (!iteratorResult.done) { const [attributeValue, encryptionAAD] = iteratorResult.value; try { - iteratorResult = iterator.next(await encrypter.encrypt(attributeValue, encryptionAAD)); + iteratorResult = iterator.next( + await this.options.primaryCrypto.encrypt(attributeValue, encryptionAAD) + ); } catch (err) { iterator.throw!(err); } @@ -310,14 +318,15 @@ export class EncryptedSavedObjectsService { attributes: T, params?: CommonParameters ): T { - const encrypter = this.getEncrypter(); const iterator = this.attributesToEncryptIterator(descriptor, attributes, params); let iteratorResult = iterator.next(); while (!iteratorResult.done) { const [attributeValue, encryptionAAD] = iteratorResult.value; try { - iteratorResult = iterator.next(encrypter.encryptSync(attributeValue, encryptionAAD)); + iteratorResult = iterator.next( + this.options.primaryCrypto.encryptSync(attributeValue, encryptionAAD) + ); } catch (err) { iterator.throw!(err); } @@ -341,7 +350,7 @@ export class EncryptedSavedObjectsService { attributes: T, params?: DecryptParameters ): Promise { - const decrypters = this.getDecrypters(params?.useDecryptionOnlyKeys); + const decrypters = this.getDecrypters(params?.omitPrimaryEncryptionKey); const iterator = this.attributesToDecryptIterator(descriptor, attributes, params); let iteratorResult = iterator.next(); @@ -385,7 +394,7 @@ export class EncryptedSavedObjectsService { attributes: T, params?: DecryptParameters ): T { - const decrypters = this.getDecrypters(params?.useDecryptionOnlyKeys); + const decrypters = this.getDecrypters(params?.omitPrimaryEncryptionKey); const iterator = this.attributesToDecryptIterator(descriptor, attributes, params); let iteratorResult = iterator.next(); @@ -432,7 +441,7 @@ export class EncryptedSavedObjectsService { } if (typeof attributeValue !== 'string') { - this.audit.decryptAttributeFailure(attributeName, descriptor, params?.user); + this.options.audit.decryptAttributeFailure(attributeName, descriptor, params?.user); throw new Error( `Encrypted "${attributeName}" attribute should be a string, but found ${typeDetect( attributeValue @@ -445,8 +454,10 @@ export class EncryptedSavedObjectsService { try { decryptedAttributes[attributeName] = (yield [attributeValue, encryptionAAD])!; } catch (err) { - this.logger.error(`Failed to decrypt "${attributeName}" attribute: ${err.message || err}`); - this.audit.decryptAttributeFailure(attributeName, descriptor, params?.user); + this.options.logger.error( + `Failed to decrypt "${attributeName}" attribute: ${err.message || err}` + ); + this.options.audit.decryptAttributeFailure(attributeName, descriptor, params?.user); throw new EncryptionError( `Unable to decrypt attribute "${attributeName}"`, @@ -461,7 +472,7 @@ export class EncryptedSavedObjectsService { // not the case we should collect and log them to make troubleshooting easier. const decryptedAttributesKeys = Object.keys(decryptedAttributes); if (decryptedAttributesKeys.length !== typeDefinition.attributesToEncrypt.size) { - this.logger.debug( + this.options.logger.debug( `The following attributes of saved object "${descriptorToArray( descriptor )}" should have been decrypted: ${Array.from( @@ -474,7 +485,7 @@ export class EncryptedSavedObjectsService { return attributes; } - this.audit.decryptAttributesSuccess(decryptedAttributesKeys, descriptor, params?.user); + this.options.audit.decryptAttributesSuccess(decryptedAttributesKeys, descriptor, params?.user); return { ...attributes, @@ -503,7 +514,7 @@ export class EncryptedSavedObjectsService { } if (Object.keys(attributesAAD).length === 0) { - this.logger.debug( + this.options.logger.debug( `The AAD for saved object "${descriptorToArray( descriptor )}" does not include any attributes.` @@ -513,29 +524,22 @@ export class EncryptedSavedObjectsService { return stringify([...descriptorToArray(descriptor), attributesAAD]); } - /** - * Returns NodeCrypto instance used for encryption (the primary crypto). - */ - private getEncrypter() { - return this.cryptos[0]; - } - /** * Returns list of NodeCrypto instances used for decryption. - * @param useDecryptionOnlyKeys Specifies whether returned decrypters should include only those - * that are using decryption only keys (the secondary cryptos). + * @param omitPrimaryEncryptionKey Specifies whether returned decrypters shouldn't include primary + * encryption/decryption crypto. */ - private getDecrypters(useDecryptionOnlyKeys?: boolean) { - if (!useDecryptionOnlyKeys) { - return this.cryptos; - } + private getDecrypters(omitPrimaryEncryptionKey?: boolean) { + if (omitPrimaryEncryptionKey) { + if (!this.options.decryptionOnlyCryptos || this.options.decryptionOnlyCryptos.length === 0) { + throw new Error( + `"omitPrimaryEncryptionKey" cannot be set when secondary keys aren't configured.` + ); + } - if (this.cryptos.length === 1) { - throw new Error( - `"useDecryptionOnlyKeys" cannot be set when decryption only keys aren't configured.` - ); + return this.options.decryptionOnlyCryptos; } - return this.cryptos.slice(1); + return [this.options.primaryCrypto, ...(this.options.decryptionOnlyCryptos ?? [])]; } } diff --git a/x-pack/plugins/encrypted_saved_objects/server/crypto/encryption_key_rotation_service.mocks.ts b/x-pack/plugins/encrypted_saved_objects/server/crypto/encryption_key_rotation_service.mocks.ts new file mode 100644 index 00000000000000..2d14577f915676 --- /dev/null +++ b/x-pack/plugins/encrypted_saved_objects/server/crypto/encryption_key_rotation_service.mocks.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { EncryptionKeyRotationService } from './encryption_key_rotation_service'; + +function createEncryptionKeyRotationServiceMock() { + return ({ rotate: jest.fn() } as unknown) as jest.Mocked; +} + +export const encryptionKeyRotationServiceMock = { + create: createEncryptionKeyRotationServiceMock, +}; diff --git a/x-pack/plugins/encrypted_saved_objects/server/crypto/encryption_key_rotation_service.test.ts b/x-pack/plugins/encrypted_saved_objects/server/crypto/encryption_key_rotation_service.test.ts new file mode 100644 index 00000000000000..8607b81e7205ec --- /dev/null +++ b/x-pack/plugins/encrypted_saved_objects/server/crypto/encryption_key_rotation_service.test.ts @@ -0,0 +1,502 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + SavedObject, + SavedObjectsClientContract, + SavedObjectsServiceStart, +} from '../../../../../src/core/server'; +import { EncryptionError, EncryptionErrorOperation } from './encryption_error'; +import { EncryptionKeyRotationService } from './encryption_key_rotation_service'; +import { EncryptedSavedObjectsService } from './encrypted_saved_objects_service'; + +import { + coreMock, + httpServerMock, + loggingSystemMock, + savedObjectsClientMock, + savedObjectsTypeRegistryMock, +} from '../../../../../src/core/server/mocks'; +import { encryptedSavedObjectsServiceMock } from './index.mock'; + +function getMockSavedObject(savedObject?: Partial>) { + const id = savedObject?.id ?? `id-1`; + return { + id, + type: `type-${id}`, + references: [], + attributes: { attr: `attr-${id}` }, + score: 0, + ...savedObject, + }; +} + +let mockEncryptionService: jest.Mocked; +let mockRetrieveClient: jest.Mocked; +let mockUpdateClient: jest.Mocked; +let mockSavedObjects: jest.Mocked; +let service: EncryptionKeyRotationService; +beforeEach(() => { + mockEncryptionService = encryptedSavedObjectsServiceMock.create(); + mockEncryptionService.isRegistered.mockImplementation( + (type) => type !== 'type-id-3' && type !== 'type-id-6' + ); + mockEncryptionService.decryptAttributes.mockImplementation(async (descriptor, { attr }) => ({ + attr: `decrypted-${attr}`, + })); + + const coreSetupMock = coreMock.createSetup(); + const coreStartMock = coreMock.createStart(); + coreSetupMock.getStartServices.mockResolvedValue([coreStartMock, {}, {}]); + + mockSavedObjects = coreStartMock.savedObjects; + const typeRegistryMock = savedObjectsTypeRegistryMock.create(); + typeRegistryMock.getAllTypes.mockReturnValue([ + { name: 'type-id-1', namespaceType: 'single', mappings: { properties: {} }, hidden: false }, + { name: 'type-id-2', namespaceType: 'single', mappings: { properties: {} }, hidden: true }, + { name: 'type-id-3', namespaceType: 'single', mappings: { properties: {} }, hidden: false }, + { name: 'type-id-4', namespaceType: 'multiple', mappings: { properties: {} }, hidden: true }, + { name: 'type-id-5', namespaceType: 'single', mappings: { properties: {} }, hidden: false }, + { name: 'type-id-6', namespaceType: 'single', mappings: { properties: {} }, hidden: true }, + ]); + typeRegistryMock.isSingleNamespace.mockImplementation((type) => type !== 'type-id-4'); + mockSavedObjects.getTypeRegistry.mockReturnValue(typeRegistryMock); + + mockRetrieveClient = savedObjectsClientMock.create(); + mockRetrieveClient.find.mockResolvedValue({ total: 0, saved_objects: [], per_page: 0, page: 0 }); + mockUpdateClient = savedObjectsClientMock.create(); + mockSavedObjects.getScopedClient.mockImplementation((request, params) => + params?.excludedWrappers?.[0] === 'encryptedSavedObjects' + ? mockRetrieveClient + : mockUpdateClient + ); + + service = new EncryptionKeyRotationService({ + logger: loggingSystemMock.create().get(), + service: mockEncryptionService, + getStartServices: coreSetupMock.getStartServices, + }); +}); + +it('correctly setups Saved Objects clients', async () => { + const mockRequest = httpServerMock.createKibanaRequest(); + await service.rotate(mockRequest, { batchSize: 10000 }); + + expect(mockSavedObjects.getScopedClient).toHaveBeenCalledTimes(2); + expect(mockSavedObjects.getScopedClient).toHaveBeenCalledWith(mockRequest, { + includedHiddenTypes: ['type-id-2', 'type-id-4'], + excludedWrappers: ['encryptedSavedObjects'], + }); + expect(mockSavedObjects.getScopedClient).toHaveBeenCalledWith(mockRequest, { + includedHiddenTypes: ['type-id-2', 'type-id-4'], + }); +}); + +it('bails out if specified type is not registered', async () => { + mockEncryptionService.isRegistered.mockImplementation((type) => type !== 'type-unknown'); + + await expect( + service.rotate(httpServerMock.createKibanaRequest(), { + batchSize: 10000, + type: 'type-unknown', + }) + ).resolves.toEqual({ + total: 0, + successful: 0, + failed: 0, + }); + + expect(mockSavedObjects.getScopedClient).not.toHaveBeenCalled(); +}); + +it('does not perform rotation if there are no Saved Objects to process', async () => { + await expect( + service.rotate(httpServerMock.createKibanaRequest(), { batchSize: 12345 }) + ).resolves.toEqual({ + total: 0, + successful: 0, + failed: 0, + }); + + expect(mockRetrieveClient.find).toHaveBeenCalledTimes(1); + expect(mockRetrieveClient.find).toHaveBeenCalledWith({ + type: ['type-id-1', 'type-id-2', 'type-id-4', 'type-id-5'], + perPage: 12345, + namespaces: ['*'], + sortField: 'updated_at', + sortOrder: 'asc', + }); + + await expect( + service.rotate(httpServerMock.createKibanaRequest(), { batchSize: 54321, type: 'type-id-2' }) + ).resolves.toEqual({ + total: 0, + successful: 0, + failed: 0, + }); + + expect(mockRetrieveClient.find).toHaveBeenCalledTimes(2); + expect(mockRetrieveClient.find).toHaveBeenCalledWith({ + type: ['type-id-2'], + perPage: 54321, + namespaces: ['*'], + sortField: 'updated_at', + sortOrder: 'asc', + }); + + expect(mockEncryptionService.decryptAttributes).not.toHaveBeenCalled(); + expect(mockUpdateClient.bulkUpdate).not.toHaveBeenCalled(); +}); + +it('throws if Saved Object attributes cannot be decrypted because of unknown reason', async () => { + mockRetrieveClient.find.mockResolvedValue({ + total: 2, + saved_objects: [getMockSavedObject({ id: 'id-1' }), getMockSavedObject({ id: 'id-2' })], + per_page: 2, + page: 0, + }); + + const decryptionFailure = new Error('Oh no!'); + mockEncryptionService.decryptAttributes.mockRejectedValue(decryptionFailure); + + await expect( + service.rotate(httpServerMock.createKibanaRequest(), { batchSize: 12345 }) + ).rejects.toBe(decryptionFailure); + + expect(mockUpdateClient.bulkUpdate).not.toHaveBeenCalled(); +}); + +it('does not perform rotation if Saved Object attributes cannot be decrypted', async () => { + mockRetrieveClient.find.mockResolvedValue({ + total: 2, + saved_objects: [getMockSavedObject({ id: 'id-1' }), getMockSavedObject({ id: 'id-2' })], + per_page: 2, + page: 0, + }); + + mockEncryptionService.decryptAttributes.mockRejectedValue( + new EncryptionError('some-message', 'attr', EncryptionErrorOperation.Decryption) + ); + + await expect( + service.rotate(httpServerMock.createKibanaRequest(), { batchSize: 12345 }) + ).resolves.toEqual({ + total: 2, + successful: 0, + failed: 0, + }); + + expect(mockEncryptionService.decryptAttributes).toHaveBeenCalledTimes(2); + expect(mockUpdateClient.bulkUpdate).not.toHaveBeenCalled(); +}); + +it('properly rotates encryption key', async () => { + const savedObjects = [ + getMockSavedObject({ id: 'id-1' }), + getMockSavedObject({ id: 'id-2', namespaces: ['ns-1'] }), + getMockSavedObject({ id: 'id-4', namespaces: ['ns-2', 'ns-3'] }), + ]; + mockRetrieveClient.find.mockResolvedValue({ + total: 3, + saved_objects: savedObjects, + per_page: 3, + page: 0, + }); + mockUpdateClient.bulkUpdate.mockResolvedValue({ + saved_objects: savedObjects.map((object) => ({ ...object, attributes: {} })), + }); + + await expect( + service.rotate(httpServerMock.createKibanaRequest(), { batchSize: 12345 }) + ).resolves.toEqual({ + total: 3, + successful: 3, + failed: 0, + }); + + expect(mockEncryptionService.decryptAttributes).toHaveBeenCalledTimes(3); + expect(mockEncryptionService.decryptAttributes).toHaveBeenCalledWith( + { type: 'type-id-1', id: 'id-1' }, + { attr: 'attr-id-1' }, + { omitPrimaryEncryptionKey: true } + ); + expect(mockEncryptionService.decryptAttributes).toHaveBeenCalledWith( + { type: 'type-id-2', id: 'id-2', namespace: 'ns-1' }, + { attr: 'attr-id-2' }, + { omitPrimaryEncryptionKey: true } + ); + expect(mockEncryptionService.decryptAttributes).toHaveBeenCalledWith( + { type: 'type-id-4', id: 'id-4' }, + { attr: 'attr-id-4' }, + { omitPrimaryEncryptionKey: true } + ); + + expect(mockUpdateClient.bulkUpdate).toHaveBeenCalledTimes(1); + expect(mockUpdateClient.bulkUpdate).toHaveBeenCalledWith([ + { ...savedObjects[0], attributes: { attr: 'decrypted-attr-id-1' } }, + { ...savedObjects[1], namespace: 'ns-1', attributes: { attr: 'decrypted-attr-id-2' } }, + { ...savedObjects[2], namespace: 'ns-2', attributes: { attr: 'decrypted-attr-id-4' } }, + ]); +}); + +it('skips objects that cannot be decrypted', async () => { + const savedObjects = [ + getMockSavedObject({ id: 'id-1' }), + getMockSavedObject({ id: 'id-2', namespaces: ['ns-1'] }), + getMockSavedObject({ id: 'id-4', namespaces: ['ns-2', 'ns-3'] }), + ]; + mockRetrieveClient.find.mockResolvedValue({ + total: 3, + saved_objects: savedObjects, + per_page: 3, + page: 0, + }); + mockUpdateClient.bulkUpdate.mockResolvedValue({ + saved_objects: [ + { ...savedObjects[0], attributes: {} }, + { ...savedObjects[2], attributes: {} }, + ], + }); + + mockEncryptionService.decryptAttributes.mockImplementation(async ({ type }, { attr }) => { + if (type === 'type-id-2') { + throw new EncryptionError('some-message', 'attr', EncryptionErrorOperation.Decryption); + } + + return { attr: `decrypted-${attr}` }; + }); + + await expect( + service.rotate(httpServerMock.createKibanaRequest(), { batchSize: 12345 }) + ).resolves.toEqual({ + total: 3, + successful: 2, + failed: 0, + }); + + expect(mockEncryptionService.decryptAttributes).toHaveBeenCalledTimes(3); + expect(mockUpdateClient.bulkUpdate).toHaveBeenCalledTimes(1); + expect(mockUpdateClient.bulkUpdate).toHaveBeenCalledWith([ + { ...savedObjects[0], attributes: { attr: 'decrypted-attr-id-1' } }, + { ...savedObjects[2], namespace: 'ns-2', attributes: { attr: 'decrypted-attr-id-4' } }, + ]); +}); + +it('marks object that we could not update as failed', async () => { + const savedObjects = [ + getMockSavedObject({ id: 'id-1' }), + getMockSavedObject({ id: 'id-2', namespaces: ['ns-1'] }), + getMockSavedObject({ id: 'id-4', namespaces: ['ns-2', 'ns-3'] }), + ]; + mockRetrieveClient.find.mockResolvedValue({ + total: 3, + saved_objects: savedObjects, + per_page: 3, + page: 0, + }); + mockUpdateClient.bulkUpdate.mockResolvedValue({ + saved_objects: [{ ...savedObjects[0], attributes: {} }, { error: new Error('Oh no!') } as any], + }); + + mockEncryptionService.decryptAttributes.mockImplementation(async ({ type }, { attr }) => { + if (type === 'type-id-2') { + throw new EncryptionError('some-message', 'attr', EncryptionErrorOperation.Decryption); + } + + return { attr: `decrypted-${attr}` }; + }); + + await expect( + service.rotate(httpServerMock.createKibanaRequest(), { batchSize: 12345 }) + ).resolves.toEqual({ + total: 3, + successful: 1, + failed: 1, + }); + + expect(mockEncryptionService.decryptAttributes).toHaveBeenCalledTimes(3); + expect(mockUpdateClient.bulkUpdate).toHaveBeenCalledTimes(1); + expect(mockUpdateClient.bulkUpdate).toHaveBeenCalledWith([ + { ...savedObjects[0], attributes: { attr: 'decrypted-attr-id-1' } }, + { ...savedObjects[2], namespace: 'ns-2', attributes: { attr: 'decrypted-attr-id-4' } }, + ]); +}); + +it('iterates until number of returned results less than batch size', async () => { + const savedObjectsBatch0 = [ + getMockSavedObject({ id: 'id-1', type: 'type-id-1' }), + getMockSavedObject({ id: 'id-2', type: 'type-id-1' }), + getMockSavedObject({ id: 'id-3', type: 'type-id-1' }), + ]; + + const savedObjectsBatch1 = [ + getMockSavedObject({ id: 'id-4', type: 'type-id-1' }), + getMockSavedObject({ id: 'id-5', type: 'type-id-1' }), + ]; + + // During first request we had 100 objects in total. + mockRetrieveClient.find.mockResolvedValueOnce({ + total: 100, + saved_objects: savedObjectsBatch0, + per_page: 3, + page: 0, + }); + mockUpdateClient.bulkUpdate.mockResolvedValueOnce({ + saved_objects: [ + { ...savedObjectsBatch0[0], attributes: {} }, + { ...savedObjectsBatch0[1], attributes: {} }, + { ...savedObjectsBatch0[2], attributes: {} }, + ], + }); + + // But when we fetch data for the second time we have just two objects left (e.g. they were removed). + mockRetrieveClient.find.mockResolvedValueOnce({ + total: 2, + saved_objects: savedObjectsBatch1, + per_page: 2, + page: 0, + }); + mockUpdateClient.bulkUpdate.mockResolvedValueOnce({ + saved_objects: [ + { ...savedObjectsBatch1[0], attributes: {} }, + { ...savedObjectsBatch1[1], attributes: {} }, + ], + }); + + await expect( + service.rotate(httpServerMock.createKibanaRequest(), { batchSize: 3 }) + ).resolves.toEqual({ + total: 100, + successful: 5, + failed: 0, + }); + + expect(mockRetrieveClient.find).toHaveBeenCalledTimes(2); + expect(mockEncryptionService.decryptAttributes).toHaveBeenCalledTimes(5); + expect(mockUpdateClient.bulkUpdate).toHaveBeenCalledTimes(2); + expect(mockUpdateClient.bulkUpdate).toHaveBeenCalledWith([ + { ...savedObjectsBatch0[0], attributes: { attr: 'decrypted-attr-id-1' } }, + { ...savedObjectsBatch0[1], attributes: { attr: 'decrypted-attr-id-2' } }, + { ...savedObjectsBatch0[2], attributes: { attr: 'decrypted-attr-id-3' } }, + ]); + expect(mockUpdateClient.bulkUpdate).toHaveBeenCalledWith([ + { ...savedObjectsBatch1[0], attributes: { attr: 'decrypted-attr-id-4' } }, + { ...savedObjectsBatch1[1], attributes: { attr: 'decrypted-attr-id-5' } }, + ]); +}); + +it('iterates until no new objects are returned', async () => { + const savedObjectBatches = [ + [ + getMockSavedObject({ id: 'id-1', type: 'type-id-1' }), + getMockSavedObject({ id: 'id-2', type: 'type-id-1' }), + getMockSavedObject({ id: 'id-3', type: 'type-id-1' }), + ], + [ + getMockSavedObject({ id: 'id-4', type: 'type-id-1' }), + getMockSavedObject({ id: 'id-5', type: 'type-id-1' }), + getMockSavedObject({ id: 'id-6', type: 'type-id-1' }), + ], + [ + getMockSavedObject({ id: 'id-7', type: 'type-id-1' }), + getMockSavedObject({ id: 'id-8', type: 'type-id-1' }), + getMockSavedObject({ id: 'id-9', type: 'type-id-1' }), + ], + [ + getMockSavedObject({ id: 'id-1', type: 'type-id-1' }), + getMockSavedObject({ id: 'id-2', type: 'type-id-1' }), + getMockSavedObject({ id: 'id-3', type: 'type-id-1' }), + ], + ]; + + for (const batch of savedObjectBatches) { + mockRetrieveClient.find.mockResolvedValueOnce({ + total: 100, + saved_objects: batch, + per_page: 3, + page: 0, + }); + mockUpdateClient.bulkUpdate.mockResolvedValueOnce({ + saved_objects: batch.map((object) => ({ ...object, attributes: {} })), + }); + } + + await expect( + service.rotate(httpServerMock.createKibanaRequest(), { batchSize: 3 }) + ).resolves.toEqual({ + total: 100, + successful: 9, + failed: 0, + }); + + expect(mockRetrieveClient.find).toHaveBeenCalledTimes(4); + // We don't decrypt\update same object twice, so neither object from the last batch is decrypted or updated. + expect(mockEncryptionService.decryptAttributes).toHaveBeenCalledTimes(9); + expect(mockUpdateClient.bulkUpdate).toHaveBeenCalledTimes(3); + for (const batch of savedObjectBatches.slice(0, 3)) { + expect(mockUpdateClient.bulkUpdate).toHaveBeenCalledWith( + batch.map((object) => ({ + ...object, + attributes: { attr: `decrypted-${object.attributes.attr}` }, + })) + ); + } +}); + +it('iterates until max number of batches is reached', async () => { + // Simulate the scenario when we're getting more records then was indicated by the `total` field + // returned with the first batch, and every such batch includes documents we haven't processed yet. + const savedObjectBatches = [ + [ + getMockSavedObject({ id: 'id-1', type: 'type-id-1' }), + getMockSavedObject({ id: 'id-2', type: 'type-id-1' }), + ], + [ + getMockSavedObject({ id: 'id-3', type: 'type-id-1' }), + getMockSavedObject({ id: 'id-4', type: 'type-id-1' }), + ], + [ + getMockSavedObject({ id: 'id-5', type: 'type-id-1' }), + getMockSavedObject({ id: 'id-6', type: 'type-id-1' }), + ], + [ + getMockSavedObject({ id: 'id-7', type: 'type-id-1' }), + getMockSavedObject({ id: 'id-8', type: 'type-id-1' }), + ], + ]; + + for (const batch of savedObjectBatches) { + mockRetrieveClient.find.mockResolvedValueOnce({ + total: 3, + saved_objects: batch, + per_page: 2, + page: 0, + }); + mockUpdateClient.bulkUpdate.mockResolvedValueOnce({ + saved_objects: batch.map((object) => ({ ...object, attributes: {} })), + }); + } + + await expect( + service.rotate(httpServerMock.createKibanaRequest(), { batchSize: 2 }) + ).resolves.toEqual({ + total: 3, + successful: 6, + failed: 0, + }); + + expect(mockRetrieveClient.find).toHaveBeenCalledTimes(3); + expect(mockEncryptionService.decryptAttributes).toHaveBeenCalledTimes(6); + expect(mockUpdateClient.bulkUpdate).toHaveBeenCalledTimes(3); + for (const batch of savedObjectBatches.slice(0, 3)) { + expect(mockUpdateClient.bulkUpdate).toHaveBeenCalledWith( + batch.map((object) => ({ + ...object, + attributes: { attr: `decrypted-${object.attributes.attr}` }, + })) + ); + } +}); diff --git a/x-pack/plugins/encrypted_saved_objects/server/crypto/encryption_key_rotation_service.ts b/x-pack/plugins/encrypted_saved_objects/server/crypto/encryption_key_rotation_service.ts index cdf9acd81b2e95..d95a1176d6ac59 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/crypto/encryption_key_rotation_service.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/crypto/encryption_key_rotation_service.ts @@ -15,6 +15,7 @@ import { import { AuthenticatedUser, SecurityPluginSetup } from '../../../security/server'; import { getDescriptorNamespace } from '../saved_objects/get_descriptor_namespace'; import { EncryptedSavedObjectsService } from './encrypted_saved_objects_service'; +import { EncryptionError } from './encryption_error'; interface EncryptionKeyRotationServiceOptions { logger: Logger; @@ -138,6 +139,7 @@ export class EncryptionKeyRotationService { // so that unprocessed objects may get into the first page and we'll miss them. We can of course oscillate between // the first and the second pages or do multiple rotation passes, but it'd complicate code significantly. let batch = 0; + let maxBatches = 0; while (true) { this.options.logger.debug(`Fetching ${batchSize} objects (batch #${batch}).`); const savedObjectsToDecrypt = await retrieveClient.find({ @@ -153,6 +155,11 @@ export class EncryptionKeyRotationService { if (batch === 0) { this.options.logger.debug(`Found ${savedObjectsToDecrypt.total} objects.`); result.total = savedObjectsToDecrypt.total; + // Since we process live data there is a theoretical chance that we may be getting new + // objects in every batch effectively making this loop infinite. To prevent this we want to + // limit a number of batches we process during single rotation request giving enough room + // for the Saved Objects occasionally created during rotation. + maxBatches = Math.ceil((savedObjectsToDecrypt.total * 2) / batchSize); } this.options.logger.debug( @@ -189,11 +196,9 @@ export class EncryptionKeyRotationService { result.failed += savedObjectsToEncrypt.length; } - if (savedObjectsToDecrypt.total <= batchSize) { + if (savedObjectsToDecrypt.total <= batchSize || ++batch >= maxBatches) { break; } - - batch++; } this.options.logger.debug( @@ -227,24 +232,35 @@ export class EncryptionKeyRotationService { processedObjectIDs.add(savedObject.id); } - const namespace = savedObject.namespaces?.[0]; let decryptedAttributes; try { decryptedAttributes = await this.options.service.decryptAttributes( { type: savedObject.type, id: savedObject.id, - namespace: getDescriptorNamespace(typeRegistry, savedObject.type, namespace), + namespace: getDescriptorNamespace( + typeRegistry, + savedObject.type, + savedObject.namespaces + ), }, savedObject.attributes as Record, - { useDecryptionOnlyKeys: true, user } + { omitPrimaryEncryptionKey: true, user } ); - } catch { - // Just skip object if we couldn't decrypt it with the decryption-only keys. + } catch (err) { + if (!(err instanceof EncryptionError)) { + throw err; + } + continue; } - decryptedSavedObjects.push({ ...savedObject, attributes: decryptedAttributes, namespace }); + decryptedSavedObjects.push({ + ...savedObject, + attributes: decryptedAttributes, + // `bulkUpdate` expects objects with a single `namespace`. + namespace: savedObject.namespaces?.[0], + }); } return decryptedSavedObjects; diff --git a/x-pack/plugins/encrypted_saved_objects/server/crypto/index.mock.ts b/x-pack/plugins/encrypted_saved_objects/server/crypto/index.mock.ts index 3e4983deca6255..4410cbac7beb90 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/crypto/index.mock.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/crypto/index.mock.ts @@ -5,3 +5,4 @@ */ export { encryptedSavedObjectsServiceMock } from './encrypted_saved_objects_service.mocks'; +export { encryptionKeyRotationServiceMock } from './encryption_key_rotation_service.mocks'; diff --git a/x-pack/plugins/encrypted_saved_objects/server/plugin.test.ts b/x-pack/plugins/encrypted_saved_objects/server/plugin.test.ts index 57108954f2568d..8d8f1a51f68021 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/plugin.test.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/plugin.test.ts @@ -5,6 +5,7 @@ */ import { Plugin } from './plugin'; +import { ConfigSchema } from './config'; import { coreMock } from 'src/core/server/mocks'; import { securityMock } from '../../security/server/mocks'; @@ -12,7 +13,9 @@ import { securityMock } from '../../security/server/mocks'; describe('EncryptedSavedObjects Plugin', () => { describe('setup()', () => { it('exposes proper contract', async () => { - const plugin = new Plugin(coreMock.createPluginInitializerContext()); + const plugin = new Plugin( + coreMock.createPluginInitializerContext(ConfigSchema.validate({}, { dist: true })) + ); await expect(plugin.setup(coreMock.createSetup(), { security: securityMock.createSetup() })) .resolves.toMatchInlineSnapshot(` Object { @@ -26,7 +29,9 @@ describe('EncryptedSavedObjects Plugin', () => { describe('start()', () => { it('exposes proper contract', async () => { - const plugin = new Plugin(coreMock.createPluginInitializerContext()); + const plugin = new Plugin( + coreMock.createPluginInitializerContext(ConfigSchema.validate({}, { dist: true })) + ); await plugin.setup(coreMock.createSetup(), { security: securityMock.createSetup() }); const startContract = plugin.start(); diff --git a/x-pack/plugins/encrypted_saved_objects/server/plugin.ts b/x-pack/plugins/encrypted_saved_objects/server/plugin.ts index 2f3237d23d02e2..6e3724fa3fe588 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/plugin.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/plugin.ts @@ -4,11 +4,12 @@ * you may not use this file except in compliance with the Elastic License. */ +import { first, map } from 'rxjs/operators'; import nodeCrypto from '@elastic/node-crypto'; import { Logger, PluginInitializerContext, CoreSetup } from 'src/core/server'; -import { first } from 'rxjs/operators'; +import { TypeOf } from '@kbn/config-schema'; import { SecurityPluginSetup } from '../../security/server'; -import { createConfig$ } from './config'; +import { createConfig, ConfigSchema } from './config'; import { EncryptedSavedObjectsService, EncryptedSavedObjectTypeRegistration, @@ -50,21 +51,29 @@ export class Plugin { core: CoreSetup, deps: PluginsSetup ): Promise { - const config = await createConfig$(this.initializerContext).pipe(first()).toPromise(); + const config = await this.initializerContext.config + .create>() + .pipe( + map((rawConfig) => createConfig(rawConfig, this.initializerContext.logger.get('config'))) + ) + .pipe(first()) + .toPromise(); const auditLogger = new EncryptedSavedObjectsAuditLogger( deps.security?.audit.getLogger('encryptedSavedObjects') ); - // This list of cryptos consists of the primary crypto that is used for both encryption and - // decryption, and the optional secondary cryptos that are used for decryption only. - const cryptos = [ - nodeCrypto({ encryptionKey: config.encryptionKey }), - ...config.keyRotation.decryptionOnlyKeys.map((decryptionKey) => - nodeCrypto({ encryptionKey: decryptionKey }) - ), - ]; + const primaryCrypto = nodeCrypto({ encryptionKey: config.encryptionKey }); + const decryptionOnlyCryptos = config.keyRotation.decryptionOnlyKeys.map((decryptionKey) => + nodeCrypto({ encryptionKey: decryptionKey }) + ); + const service = Object.freeze( - new EncryptedSavedObjectsService(cryptos, this.logger, auditLogger) + new EncryptedSavedObjectsService({ + primaryCrypto, + decryptionOnlyCryptos, + logger: this.logger, + audit: auditLogger, + }) ); this.savedObjectsSetup = setupSavedObjects({ @@ -95,11 +104,12 @@ export class Plugin { createMigration: getCreateMigration( service, (typeRegistration: EncryptedSavedObjectTypeRegistration) => { - const serviceForMigration = new EncryptedSavedObjectsService( - cryptos, - this.logger, - auditLogger - ); + const serviceForMigration = new EncryptedSavedObjectsService({ + primaryCrypto, + decryptionOnlyCryptos, + logger: this.logger, + audit: auditLogger, + }); serviceForMigration.registerType(typeRegistration); return serviceForMigration; } diff --git a/x-pack/plugins/encrypted_saved_objects/server/routes/index.mock.ts b/x-pack/plugins/encrypted_saved_objects/server/routes/index.mock.ts new file mode 100644 index 00000000000000..b3d54c7f1ecac9 --- /dev/null +++ b/x-pack/plugins/encrypted_saved_objects/server/routes/index.mock.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { ConfigSchema, createConfig } from '../config'; + +import { httpServiceMock, loggingSystemMock } from '../../../../../src/core/server/mocks'; +import { encryptionKeyRotationServiceMock } from '../crypto/index.mock'; + +export const routeDefinitionParamsMock = { + create: (config: Record = {}) => ({ + router: httpServiceMock.createRouter(), + logger: loggingSystemMock.create().get(), + config: createConfig(ConfigSchema.validate(config), loggingSystemMock.create().get()), + encryptionKeyRotationService: encryptionKeyRotationServiceMock.create(), + }), +}; diff --git a/x-pack/plugins/encrypted_saved_objects/server/routes/index.ts b/x-pack/plugins/encrypted_saved_objects/server/routes/index.ts index a4135b136094c5..72af8060de8270 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/routes/index.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/routes/index.ts @@ -21,7 +21,5 @@ export interface RouteDefinitionParams { } export function defineRoutes(params: RouteDefinitionParams) { - if (params.config.keyRotation.decryptionOnlyKeys.length > 0) { - defineKeyRotationRoutes(params); - } + defineKeyRotationRoutes(params); } diff --git a/x-pack/plugins/encrypted_saved_objects/server/routes/key_rotation.test.ts b/x-pack/plugins/encrypted_saved_objects/server/routes/key_rotation.test.ts new file mode 100644 index 00000000000000..68320b08f080a9 --- /dev/null +++ b/x-pack/plugins/encrypted_saved_objects/server/routes/key_rotation.test.ts @@ -0,0 +1,172 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { Type } from '@kbn/config-schema'; +import { + IRouter, + kibanaResponseFactory, + RequestHandler, + RequestHandlerContext, + RouteConfig, +} from '../../../../../src/core/server'; +import { defineKeyRotationRoutes } from './key_rotation'; + +import { httpServerMock } from '../../../../../src/core/server/mocks'; +import { routeDefinitionParamsMock } from './index.mock'; +import { EncryptionKeyRotationService } from '../crypto'; + +describe('Key rotation routes', () => { + let router: jest.Mocked; + let mockContext: RequestHandlerContext; + let mockEncryptionKeyRotationService: jest.Mocked; + beforeEach(() => { + const routeParamsMock = routeDefinitionParamsMock.create({ + keyRotation: { decryptionOnlyKeys: ['b'.repeat(32)] }, + }); + router = routeParamsMock.router; + mockEncryptionKeyRotationService = routeParamsMock.encryptionKeyRotationService; + + mockContext = ({} as unknown) as RequestHandlerContext; + + defineKeyRotationRoutes(routeParamsMock); + }); + + describe('rotate', () => { + let routeHandler: RequestHandler; + let routeConfig: RouteConfig; + beforeEach(() => { + const [rotateRouteConfig, rotateRouteHandler] = router.post.mock.calls.find( + ([{ path }]) => path === '/api/encrypted_saved_objects/rotate_key' + )!; + + routeConfig = rotateRouteConfig; + routeHandler = rotateRouteHandler; + }); + + it('correctly defines route.', () => { + expect(routeConfig.options).toEqual({ tags: ['access:rotateEncryptionKey'] }); + expect(routeConfig.validate).toEqual({ + body: undefined, + query: expect.any(Type), + params: undefined, + }); + + const queryValidator = (routeConfig.validate as any).query as Type; + expect( + queryValidator.validate({ + batchSize: 100, + type: 'some-type', + }) + ).toEqual({ + batchSize: 100, + type: 'some-type', + }); + expect(queryValidator.validate({ batchSize: 1 })).toEqual({ batchSize: 1 }); + expect(queryValidator.validate({ batchSize: 10000 })).toEqual({ batchSize: 10000 }); + expect(queryValidator.validate({})).toEqual({ batchSize: 10000 }); + + expect(() => queryValidator.validate({ batchSize: 0 })).toThrowErrorMatchingInlineSnapshot( + `"[batchSize]: Value must be equal to or greater than [1]."` + ); + expect(() => + queryValidator.validate({ batchSize: 10001 }) + ).toThrowErrorMatchingInlineSnapshot( + `"[batchSize]: Value must be equal to or lower than [10000]."` + ); + + expect(() => queryValidator.validate({ type: 100 })).toThrowErrorMatchingInlineSnapshot( + `"[type]: expected value of type [string] but got [number]"` + ); + }); + + it('returns 400 if decryption only keys are not specified.', async () => { + const routeParamsMock = routeDefinitionParamsMock.create(); + defineKeyRotationRoutes(routeParamsMock); + const [, rotateRouteHandler] = routeParamsMock.router.post.mock.calls.find( + ([{ path }]) => path === '/api/encrypted_saved_objects/rotate_key' + )!; + + await expect( + rotateRouteHandler(mockContext, httpServerMock.createKibanaRequest(), kibanaResponseFactory) + ).resolves.toEqual({ + status: 400, + payload: + 'Kibana is not configured to support encryption key rotation. Update `kibana.yml` to include `xpack.encryptedSavedObjects.keyRotation.decryptionOnlyKeys` to rotate your encryption keys.', + options: { + body: + 'Kibana is not configured to support encryption key rotation. Update `kibana.yml` to include `xpack.encryptedSavedObjects.keyRotation.decryptionOnlyKeys` to rotate your encryption keys.', + }, + }); + }); + + it('returns 500 if `rotate` throws unhandled exception.', async () => { + const unhandledException = new Error('Something went wrong.'); + mockEncryptionKeyRotationService.rotate.mockRejectedValue(unhandledException); + + const mockRequest = httpServerMock.createKibanaRequest({ query: { batchSize: 1234 } }); + const response = await routeHandler(mockContext, mockRequest, kibanaResponseFactory); + + expect(response.status).toBe(500); + expect(response.payload).toEqual(unhandledException); + expect(mockEncryptionKeyRotationService.rotate).toHaveBeenCalledWith(mockRequest, { + batchSize: 1234, + }); + }); + + it('returns whatever `rotate` returns.', async () => { + const mockRequest = httpServerMock.createKibanaRequest({ query: { batchSize: 1234 } }); + mockEncryptionKeyRotationService.rotate.mockResolvedValue({ + total: 3, + successful: 6, + failed: 0, + }); + + await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual({ + status: 200, + payload: { total: 3, successful: 6, failed: 0 }, + options: { body: { total: 3, successful: 6, failed: 0 } }, + }); + }); + + it('returns 429 if called while rotation is in progress.', async () => { + const mockRequest = httpServerMock.createKibanaRequest({ query: { batchSize: 1234 } }); + mockEncryptionKeyRotationService.rotate.mockResolvedValue({ + total: 3, + successful: 6, + failed: 0, + }); + + // Run rotation, but don't wait until it's complete. + const firstRequestPromise = routeHandler(mockContext, mockRequest, kibanaResponseFactory); + + // Try to run rotation once again. + await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual({ + status: 429, + payload: + 'Encryption key rotation is in progress already. Please wait until it is completed and try again.', + options: { + statusCode: 429, + body: + 'Encryption key rotation is in progress already. Please wait until it is completed and try again.', + }, + }); + + // Initial request properly resolves. + await expect(firstRequestPromise).resolves.toEqual({ + status: 200, + payload: { total: 3, successful: 6, failed: 0 }, + options: { body: { total: 3, successful: 6, failed: 0 } }, + }); + + // And consequent requests resolve properly too. + await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual({ + status: 200, + payload: { total: 3, successful: 6, failed: 0 }, + options: { body: { total: 3, successful: 6, failed: 0 } }, + }); + }); + }); +}); diff --git a/x-pack/plugins/encrypted_saved_objects/server/routes/key_rotation.ts b/x-pack/plugins/encrypted_saved_objects/server/routes/key_rotation.ts index 92429254e133e2..32147b903b20eb 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/routes/key_rotation.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/routes/key_rotation.ts @@ -20,7 +20,9 @@ export function defineKeyRotationRoutes({ encryptionKeyRotationService, router, logger, + config, }: RouteDefinitionParams) { + let rotationInProgress = false; router.post( { path: '/api/encrypted_saved_objects/rotate_key', @@ -39,6 +41,22 @@ export function defineKeyRotationRoutes({ }, }, async (context, request, response) => { + if (config.keyRotation.decryptionOnlyKeys.length === 0) { + return response.badRequest({ + body: + 'Kibana is not configured to support encryption key rotation. Update `kibana.yml` to include `xpack.encryptedSavedObjects.keyRotation.decryptionOnlyKeys` to rotate your encryption keys.', + }); + } + + if (rotationInProgress) { + return response.customError({ + body: + 'Encryption key rotation is in progress already. Please wait until it is completed and try again.', + statusCode: 429, + }); + } + + rotationInProgress = true; try { return response.ok({ body: await encryptionKeyRotationService.rotate(request, { @@ -48,7 +66,9 @@ export function defineKeyRotationRoutes({ }); } catch (err) { logger.error(err); - return response.internalError(); + return response.customError({ body: err, statusCode: 500 }); + } finally { + rotationInProgress = false; } } ); diff --git a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/get_descriptor_namespace.test.ts b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/get_descriptor_namespace.test.ts index 7ba90a5a76ab32..33ea1d8c3acec8 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/get_descriptor_namespace.test.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/get_descriptor_namespace.test.ts @@ -66,5 +66,16 @@ describe('getDescriptorNamespace', () => { 'foo-namespace' ); }); + + it('returns the provided namespace if it is in array format', () => { + const mockBaseTypeRegistry = savedObjectsTypeRegistryMock.create(); + mockBaseTypeRegistry.isSingleNamespace.mockReturnValue(true); + mockBaseTypeRegistry.isMultiNamespace.mockReturnValue(false); + mockBaseTypeRegistry.isNamespaceAgnostic.mockReturnValue(false); + + expect(getDescriptorNamespace(mockBaseTypeRegistry, 'singletype', ['foo-namespace'])).toEqual( + 'foo-namespace' + ); + }); }); }); diff --git a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/get_descriptor_namespace.ts b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/get_descriptor_namespace.ts index 7201f13fb930b6..7c237b82cbb151 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/get_descriptor_namespace.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/get_descriptor_namespace.ts @@ -9,9 +9,13 @@ import { ISavedObjectTypeRegistry, SavedObjectsUtils } from '../../../../../src/ export const getDescriptorNamespace = ( typeRegistry: ISavedObjectTypeRegistry, type: string, - namespace?: string + namespace?: string | string[] ) => { - const descriptorNamespace = typeRegistry.isSingleNamespace(type) ? namespace : undefined; + const descriptorNamespace = typeRegistry.isSingleNamespace(type) + ? Array.isArray(namespace) + ? namespace[0] + : namespace + : undefined; return normalizeNamespace(descriptorNamespace); }; From 1808db334a32aadc6936bf0d0c97e5ba59b23d72 Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin Date: Thu, 1 Oct 2020 11:58:35 +0200 Subject: [PATCH 3/5] Add integration tests. --- .../config.ts | 4 + .../es_archiver/key_rotation/data.json | 186 ++ .../es_archiver/key_rotation/mappings.json | 2417 +++++++++++++++++ .../tests/encrypted_saved_objects_api.ts | 121 +- 4 files changed, 2721 insertions(+), 7 deletions(-) create mode 100644 x-pack/test/encrypted_saved_objects_api_integration/fixtures/es_archiver/key_rotation/data.json create mode 100644 x-pack/test/encrypted_saved_objects_api_integration/fixtures/es_archiver/key_rotation/mappings.json diff --git a/x-pack/test/encrypted_saved_objects_api_integration/config.ts b/x-pack/test/encrypted_saved_objects_api_integration/config.ts index f061a38b72ce6d..8f1aff337a3de7 100644 --- a/x-pack/test/encrypted_saved_objects_api_integration/config.ts +++ b/x-pack/test/encrypted_saved_objects_api_integration/config.ts @@ -27,6 +27,10 @@ export default async function ({ readConfigFile }: FtrConfigProviderContext) { serverArgs: [ ...xPackAPITestsConfig.get('kbnTestServer.serverArgs'), '--xpack.encryptedSavedObjects.encryptionKey="wuGNaIhoMpk5sO4UBxgr3NyW1sFcLgIf"', + `--xpack.encryptedSavedObjects.keyRotation.decryptionOnlyKeys=${JSON.stringify([ + 'a'.repeat(32), + 'b'.repeat(32), + ])}`, `--plugin-path=${path.resolve(__dirname, './fixtures/api_consumer_plugin')}`, ], }, diff --git a/x-pack/test/encrypted_saved_objects_api_integration/fixtures/es_archiver/key_rotation/data.json b/x-pack/test/encrypted_saved_objects_api_integration/fixtures/es_archiver/key_rotation/data.json new file mode 100644 index 00000000000000..a98bf5ca8bd1bd --- /dev/null +++ b/x-pack/test/encrypted_saved_objects_api_integration/fixtures/es_archiver/key_rotation/data.json @@ -0,0 +1,186 @@ +{ + "type": "doc", + "value": { + "id": "config:8.0.0", + "index": ".kibana_1", + "source": { + "config": { + "buildNum": 9007199254740991 + }, + "migrationVersion": { + "config": "7.9.0" + }, + "references": [ + ], + "type": "config", + "updated_at": "2020-06-17T15:03:14.532Z" + } + } +} + +{ + "type": "doc", + "value": { + "id": "space:default", + "index": ".kibana_1", + "source": { + "migrationVersion": { + "space": "6.6.0" + }, + "references": [ + ], + "space": { + "_reserved": true, + "color": "#00bfb3", + "description": "This is your default space!", + "disabledFeatures": [ + ], + "name": "Default" + }, + "type": "space", + "updated_at": "2020-06-17T15:03:27.426Z" + } + } +} + +{ + "type": "doc", + "value": { + "id" : "space:a", + "index" : ".kibana_1", + "source" : { + "space" : { + "name" : "a", + "disabledFeatures" : [ ] + }, + "type" : "space", + "references" : [ ], + "migrationVersion" : { + "space" : "6.6.0" + }, + "updated_at" : "2020-10-01T06:13:23.484Z" + } + } +} + +{ + "type": "doc", + "value": { + "id": "saved-object-with-secret:cd9272b2-6a15-4295-bb7b-15f6347e267b", + "index": ".kibana_1", + "source": { + "saved-object-with-secret": { + "publicProperty": "some-public-property-0", + "publicPropertyExcludedFromAAD": "some-public-property-excluded-from-aad-0", + "publicPropertyStoredEncrypted": "DNrREm+906YK6Dt3P9NzBmZ+pSYmNYd7dyxw5Q97lIquROGRgqC84EW2/B2eVNfUhYYYcFlvtDrdZQXYmTmKNfZ5z8/hSwhRIXEhGPYiBplm1TiOQNp/P31FLp4l26YKt/ygVjQY/QFVWhDoA5sd+58TtzhxTtzx2FythwYgW6jJNQ==", + "privateProperty": "BdPvHwNJGgrXHQkSwKPJAtAvBMgfbapmSkthDjFeuoT7kJVfVujxvIZ47ZrUTfXOj1S67yx8R14A0B1VMdk6mITEeQpnIKgRbtqrevqzU522oTKdRZ/D99DL3ZEymmXMKqzafXwNny4pBZY4IMJsP4TRiMr2" + }, + "type": "saved-object-with-secret", + "references": [], + "updated_at": "2020-10-01T06:13:47.268Z" + } + } +} + +{ + "type": "doc", + "value": { + "id" : "saved-object-with-secret-and-multiple-spaces:6b388d9b-6d8f-4e20-ba38-d9477370729d", + "index" : ".kibana_1", + "source" : { + "saved-object-with-secret-and-multiple-spaces" : { + "publicProperty" : "some-public-property-0", + "publicPropertyExcludedFromAAD" : "some-public-property-excluded-from-aad-0", + "publicPropertyStoredEncrypted" : "iA3mnPhS4k1I9SOhguDGb52dq/VsFNlNepxdpAdjXUeHyxV9tXkdX98wDRwSMRgD5esGcdaA8A/QdbAP/pSUnGwMXNAAwmyVrDlC/vAPrZStm/nNVhI6ibl+ItjBEGmV0sueZhU49K8uctrrnctWcSk4u0ZFijQbMvN53Obgeg7p9g==", + "privateProperty" : "oAvCdNvgs4B/BJT5Rc6pNM1uSZ9EeMbjQPK10jAFWQE+hheZHpbXjTqaYxStZC+L/kr5ahRtWyWmOguissMLanx8vhfUWMvpZTxR8VG5rTgnXJ/XNwyRWsBCiIzpRmQHyBQgZJ84jeh/kaRskM1atv+Rm8Sc" + }, + "type" : "saved-object-with-secret-and-multiple-spaces", + "references" : [ ], + "namespaces" : [ + "default", + "a" + ], + "updated_at" : "2020-10-01T06:14:33.842Z" + } + } +} + +{ + "type": "doc", + "value": { + "id" : "hidden-saved-object-with-secret:8d90daef-f992-443c-ab78-053c6a7b0e9c", + "index" : ".kibana_1", + "source" : { + "hidden-saved-object-with-secret" : { + "publicProperty" : "some-public-property-0", + "publicPropertyExcludedFromAAD" : "some-public-property-excluded-from-aad-0", + "publicPropertyStoredEncrypted" : "raJcA4T4lOKuNMFOQC26dp6ToA7iBhUURh7ep4E6ZWGBbot/dpAvjccVBqYcLNCk7Qub2MKY2w7B4dhibJoVaBks8bl5EesKcxFIQ2llU+t8rtKk+x08kB5pOl2IPk4jXkNgkv/zZvwKMM+ot2lE4JG8IKbmuJmrbjmKbGLglIs2mg==", + "privateProperty" : "rYnHH52YWJWPv1jJ++oQAzVK2UUVJr6LSn+IU1LszAzhE3+pC7uAnHnW5KzOqaKtf71Gtc+yxwqu0w8Pnh1/aZqE7GVNVbRYAKDoJS0rpG/0SQXH8Inwl0Q+pTle6UJUvfS3PnwBVBkHGCtIiX4Oo+jXZIku" + }, + "type" : "hidden-saved-object-with-secret", + "references" : [ ], + "updated_at" : "2020-10-01T06:26:55.367Z" + } + } +} + +{ + "type": "doc", + "value": { + "id" : "saved-object-with-secret:4336f782-450f-4142-aecd-b46ed092af52", + "index" : ".kibana_1", + "source" : { + "saved-object-with-secret" : { + "publicProperty" : "some-public-property-0", + "publicPropertyExcludedFromAAD" : "some-public-property-excluded-from-aad-0", + "publicPropertyStoredEncrypted" : "ZzRNcMUBDo59TxZxhfvsIx5fNdwHKUXuqBzRJTuGCeiHzTETxX5/uM/L8oUfKRW4BqslRrqnbKMoew6v3dmd+8q5M1OGikX+hDLF06enuZTJi2OD8rcvvBds716hVxoE6aZvWQ4oybO06617wCPwaI2JUV6peA4/4Tz4wnony3TbCA==", + "privateProperty" : "NhxZiOueUmizr9QOxnJqgUIkQvM7NbARU9cGl7J6aoB5BZPQRGASHekFHfyy2PAiQX7bAVOo/C4XYxSgAmgzs6sSK4SqMKljZzRkh1IUss2hPZoEGq3yuyGlJusSPRMTuIgnzbvdSmSqlDoew6or+nRXOD55" + }, + "type" : "saved-object-with-secret", + "references" : [ ], + "updated_at" : "2020-10-01T06:31:36.578Z" + } + } +} + +{ + "type": "doc", + "value": { + "id" : "saved-object-with-secret-and-multiple-spaces:2ee1bede-400d-4767-b3f0-09a7064bec14", + "index" : ".kibana_1", + "source" : { + "saved-object-with-secret-and-multiple-spaces" : { + "publicProperty" : "some-public-property-0", + "publicPropertyExcludedFromAAD" : "some-public-property-excluded-from-aad-0", + "publicPropertyStoredEncrypted" : "eRXGrN833HNDpyw4aglw9RVyaVnOZo4BK6sAIWjbH49kITuGlmWBNPTBXyXR59VqgEtxeKJFcoCndkYMlYTc/NRNtoOBfLIIYoyfrPNMLwJb7MShRL/z4ykq/be+lU5mi6Uylnt3XXJSVv6pfQJYdTdZUMULQVUhSzn0DSj3i9k2qg==", + "privateProperty" : "ro8iLGcH4i4/XqadjfuKBET7+fdso8+IXMSWyyq0F+OS1t7oH7MsRuTS2xnraN4eXMF0cfj+jJPSKD7o5oD9539H4GdXXwBg6cEWMy1bTff/g8ixSS9BcuEG0JFX+Id10CoUVq0oY0O7acf1b/yRnpB7C9yi" + }, + "type" : "saved-object-with-secret-and-multiple-spaces", + "references" : [ ], + "namespaces" : [ + "default", + "a" + ], + "updated_at" : "2020-10-01T06:31:53.192Z" + } + } +} + +{ + "type": "doc", + "value": { + "id" : "hidden-saved-object-with-secret:506038a1-ec71-42b5-bce2-99661b29c62b", + "index" : ".kibana_1", + "source" : { + "hidden-saved-object-with-secret" : { + "publicProperty" : "some-public-property-0", + "publicPropertyExcludedFromAAD" : "some-public-property-excluded-from-aad-0", + "publicPropertyStoredEncrypted" : "nkYREB/Uqtc1btk1ieaoFvtU/sZPlHVuCICkDkBrIzkK/K6kaaDAV1AephRH9m3xpyQkhQjdE0PO7OvR0vxjIl4D+huteM0W8Iezzov0MT4r9xfhFodLamsAPAMwRlUmxSnTq6T0LiiNdRIF+LdSc0tb4Qi6mZuY7B8cI5t+uklRcQ==", + "privateProperty" : "vHce4ivOenygNl50dpuXAuBmKGoGMFoDOCvIEQXj02sYrzW8RjbV9xvrZo9PQNMlJOZeedseqJcjRO54J1fJ6MkQ0KmMtkRaMJSWLdvO23OstTmgOyaHQ6z6pg4REdtTG56vEotq6NzBE0w2PPIo/aNW5rl+" + }, + "type" : "hidden-saved-object-with-secret", + "references" : [ ], + "updated_at" : "2020-10-01T06:32:01.476Z" + } + } +} diff --git a/x-pack/test/encrypted_saved_objects_api_integration/fixtures/es_archiver/key_rotation/mappings.json b/x-pack/test/encrypted_saved_objects_api_integration/fixtures/es_archiver/key_rotation/mappings.json new file mode 100644 index 00000000000000..e24ab1c7b63f2f --- /dev/null +++ b/x-pack/test/encrypted_saved_objects_api_integration/fixtures/es_archiver/key_rotation/mappings.json @@ -0,0 +1,2417 @@ +{ + "type": "index", + "value": { + "aliases": { + ".kibana": { + } + }, + "index": ".kibana_1", + "mappings": { + "dynamic": "strict", + "_meta": { + "migrationMappingPropertyHashes": { + "ml-telemetry": "257fd1d4b4fdbb9cb4b8a3b27da201e9", + "visualization": "44d6bd48a1a653bcb60ea01614b9e3c9", + "endpoint:user-artifact": "4a11183eee21e6fbad864f7a30b39ad0", + "references": "7997cf5a56cc02bdc9c93361bde732b0", + "graph-workspace": "cd7ba1330e6682e9cc00b78850874be1", + "epm-packages": "386dc9996a3b74607de64c2ab2171582", + "type": "2f4316de49999235636386fe51dc06c1", + "space": "c5ca8acafa0beaa4d08d014a97b6bc6b", + "infrastructure-ui-source": "3d1b76c39bfb2cc8296b024d73854724", + "ingest_manager_settings": "02a03095f0e05b7a538fa801b88a217f", + "saved-object-with-migration": "7b67d1471e47144478618055293b45d3", + "application_usage_totals": "3d1b76c39bfb2cc8296b024d73854724", + "saved-object-with-secret-and-multiple-spaces": "bdf31541e7d2f348b5e21a7769c022ba", + "action": "6e96ac5e648f57523879661ea72525b7", + "dashboard": "74eb4b909f81222fa1ddeaba2881a37e", + "metrics-explorer-view": "3d1b76c39bfb2cc8296b024d73854724", + "siem-detection-engine-rule-actions": "6569b288c169539db10cb262bf79de18", + "query": "11aaeb7f5f7fa5bb43f25e18ce26e7d9", + "file-upload-telemetry": "0ed4d3e1983d1217a30982630897092e", + "application_usage_transactional": "3d1b76c39bfb2cc8296b024d73854724", + "action_task_params": "a9d49f184ee89641044be0ca2950fa3a", + "fleet-agent-events": "e20a508b6e805189356be381dbfac8db", + "saved-object-with-secret": "bdf31541e7d2f348b5e21a7769c022ba", + "apm-indices": "9bb9b2bf1fa636ed8619cbab5ce6a1dd", + "inventory-view": "3d1b76c39bfb2cc8296b024d73854724", + "upgrade-assistant-reindex-operation": "215107c281839ea9b3ad5f6419819763", + "cases-comments": "c2061fb929f585df57425102fa928b4b", + "canvas-workpad-template": "ae2673f678281e2c055d764b153e9715", + "fleet-enrollment-api-keys": "a69ef7ae661dab31561d6c6f052ef2a7", + "canvas-element": "7390014e1091044523666d97247392fc", + "ingest-outputs": "8aa988c376e65443fefc26f1075e93a3", + "telemetry": "36a616f7026dfa617d6655df850fe16d", + "upgrade-assistant-telemetry": "56702cec857e0a9dacfb696655b4ff7b", + "lens-ui-telemetry": "509bfa5978586998e05f9e303c07a327", + "namespaces": "2f4316de49999235636386fe51dc06c1", + "application_usage_daily": "43b8830d5d0df85a6823d290885fc9fd", + "siem-ui-timeline-note": "8874706eedc49059d4cf0f5094559084", + "saved-object-without-secret": "f9ee0ffffd41159d7b9942dbfd22ce6d", + "lens": "52346cfec69ff7b47d5f0c12361a2797", + "exception-list-agnostic": "497afa2f881a675d72d58e20057f3d8b", + "sample-data-telemetry": "7d3cfeb915303c9641c59681967ffeb4", + "fleet-agent-actions": "9511b565b1cc6441a42033db3d5de8e9", + "exception-list": "497afa2f881a675d72d58e20057f3d8b", + "monitoring-telemetry": "2669d5ec15e82391cf58df4294ee9c68", + "app_search_telemetry": "3d1b76c39bfb2cc8296b024d73854724", + "search": "7f9e077078cab612f6a58e3bfdedb71a", + "originId": "2f4316de49999235636386fe51dc06c1", + "updated_at": "00da57df13e94e9d98437d13ace4bfe0", + "cases-configure": "42711cbb311976c0687853f4c1354572", + "search-telemetry": "3d1b76c39bfb2cc8296b024d73854724", + "alert": "3685ae5bd1c7ad4a73a2c37b1819e89e", + "canvas-workpad": "b0a1706d356228dbdcb4a17e6b9eb231", + "siem-detection-engine-rule-status": "ae783f41c6937db6b7a2ef5c93a9e9b0", + "ingest-package-policies": "f74dfe498e1849267cda41580b2be110", + "map": "4a05b35c3a3a58fbc72dd0202dc3487f", + "uptime-dynamic-settings": "3d1b76c39bfb2cc8296b024d73854724", + "cases": "32aa96a6d3855ddda53010ae2048ac22", + "apm-telemetry": "3d1b76c39bfb2cc8296b024d73854724", + "siem-ui-timeline": "d12c5474364d737d17252acf1dc4585c", + "kql-telemetry": "d12a98a6f19a2d273696597547e064ee", + "ui-metric": "0d409297dc5ebe1e3a1da691c6ee32e3", + "url": "c7f66a0df8b1b52f17c28c4adb111105", + "enterprise_search_telemetry": "3d1b76c39bfb2cc8296b024d73854724", + "hidden-saved-object-with-secret": "bdf31541e7d2f348b5e21a7769c022ba", + "endpoint:user-artifact-manifest": "4b9c0e7cfaf86d82a7ee9ed68065e50d", + "migrationVersion": "4a1746014a75ade3a714e1db5763276f", + "index-pattern": "45915a1ad866812242df474eb0479052", + "fleet-agents": "cb661e8ede2b640c42c8e5ef99db0683", + "maps-telemetry": "5ef305b18111b77789afefbd36b66171", + "namespace": "2f4316de49999235636386fe51dc06c1", + "cases-user-actions": "32277330ec6b721abe3b846cfd939a71", + "timelion-sheet": "9a2a2748877c7a7b582fef201ab1d4cf", + "siem-ui-timeline-pinned-event": "20638091112f0e14f0e443d512301c29", + "config": "c63748b75f39d0c54de12d12c1ccbc20", + "ingest-agent-policies": "8b0733cce189659593659dad8db426f0", + "tsvb-validation-telemetry": "3a37ef6c8700ae6fc97d5c7da00e9215", + "workplace_search_telemetry": "3d1b76c39bfb2cc8296b024d73854724" + } + }, + "properties": { + "action": { + "properties": { + "actionTypeId": { + "type": "keyword" + }, + "config": { + "type": "object", + "enabled": false + }, + "name": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword" + } + } + }, + "secrets": { + "type": "binary" + } + } + }, + "action_task_params": { + "properties": { + "actionId": { + "type": "keyword" + }, + "apiKey": { + "type": "binary" + }, + "params": { + "type": "object", + "enabled": false + } + } + }, + "alert": { + "properties": { + "actions": { + "type": "nested", + "properties": { + "actionRef": { + "type": "keyword" + }, + "actionTypeId": { + "type": "keyword" + }, + "group": { + "type": "keyword" + }, + "params": { + "type": "object", + "enabled": false + } + } + }, + "alertTypeId": { + "type": "keyword" + }, + "apiKey": { + "type": "binary" + }, + "apiKeyOwner": { + "type": "keyword" + }, + "consumer": { + "type": "keyword" + }, + "createdAt": { + "type": "date" + }, + "createdBy": { + "type": "keyword" + }, + "enabled": { + "type": "boolean" + }, + "meta": { + "properties": { + "versionApiKeyLastmodified": { + "type": "keyword" + } + } + }, + "muteAll": { + "type": "boolean" + }, + "mutedInstanceIds": { + "type": "keyword" + }, + "name": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword" + } + } + }, + "params": { + "type": "object", + "enabled": false + }, + "schedule": { + "properties": { + "interval": { + "type": "keyword" + } + } + }, + "scheduledTaskId": { + "type": "keyword" + }, + "tags": { + "type": "keyword" + }, + "throttle": { + "type": "keyword" + }, + "updatedBy": { + "type": "keyword" + } + } + }, + "apm-indices": { + "properties": { + "apm_oss": { + "properties": { + "errorIndices": { + "type": "keyword" + }, + "metricsIndices": { + "type": "keyword" + }, + "onboardingIndices": { + "type": "keyword" + }, + "sourcemapIndices": { + "type": "keyword" + }, + "spanIndices": { + "type": "keyword" + }, + "transactionIndices": { + "type": "keyword" + } + } + } + } + }, + "apm-telemetry": { + "type": "object", + "dynamic": "false" + }, + "app_search_telemetry": { + "type": "object", + "dynamic": "false" + }, + "application_usage_daily": { + "dynamic": "false", + "properties": { + "timestamp": { + "type": "date" + } + } + }, + "application_usage_totals": { + "type": "object", + "dynamic": "false" + }, + "application_usage_transactional": { + "type": "object", + "dynamic": "false" + }, + "canvas-element": { + "dynamic": "false", + "properties": { + "@created": { + "type": "date" + }, + "@timestamp": { + "type": "date" + }, + "content": { + "type": "text" + }, + "help": { + "type": "text" + }, + "image": { + "type": "text" + }, + "name": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword" + } + } + } + } + }, + "canvas-workpad": { + "dynamic": "false", + "properties": { + "@created": { + "type": "date" + }, + "@timestamp": { + "type": "date" + }, + "name": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword" + } + } + } + } + }, + "canvas-workpad-template": { + "dynamic": "false", + "properties": { + "help": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword" + } + } + }, + "name": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword" + } + } + }, + "tags": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword" + } + } + }, + "template_key": { + "type": "keyword" + } + } + }, + "cases": { + "properties": { + "closed_at": { + "type": "date" + }, + "closed_by": { + "properties": { + "email": { + "type": "keyword" + }, + "full_name": { + "type": "keyword" + }, + "username": { + "type": "keyword" + } + } + }, + "connector_id": { + "type": "keyword" + }, + "created_at": { + "type": "date" + }, + "created_by": { + "properties": { + "email": { + "type": "keyword" + }, + "full_name": { + "type": "keyword" + }, + "username": { + "type": "keyword" + } + } + }, + "description": { + "type": "text" + }, + "external_service": { + "properties": { + "connector_id": { + "type": "keyword" + }, + "connector_name": { + "type": "keyword" + }, + "external_id": { + "type": "keyword" + }, + "external_title": { + "type": "text" + }, + "external_url": { + "type": "text" + }, + "pushed_at": { + "type": "date" + }, + "pushed_by": { + "properties": { + "email": { + "type": "keyword" + }, + "full_name": { + "type": "keyword" + }, + "username": { + "type": "keyword" + } + } + } + } + }, + "status": { + "type": "keyword" + }, + "tags": { + "type": "keyword" + }, + "title": { + "type": "keyword" + }, + "updated_at": { + "type": "date" + }, + "updated_by": { + "properties": { + "email": { + "type": "keyword" + }, + "full_name": { + "type": "keyword" + }, + "username": { + "type": "keyword" + } + } + } + } + }, + "cases-comments": { + "properties": { + "comment": { + "type": "text" + }, + "created_at": { + "type": "date" + }, + "created_by": { + "properties": { + "email": { + "type": "keyword" + }, + "full_name": { + "type": "keyword" + }, + "username": { + "type": "keyword" + } + } + }, + "pushed_at": { + "type": "date" + }, + "pushed_by": { + "properties": { + "email": { + "type": "keyword" + }, + "full_name": { + "type": "keyword" + }, + "username": { + "type": "keyword" + } + } + }, + "updated_at": { + "type": "date" + }, + "updated_by": { + "properties": { + "email": { + "type": "keyword" + }, + "full_name": { + "type": "keyword" + }, + "username": { + "type": "keyword" + } + } + } + } + }, + "cases-configure": { + "properties": { + "closure_type": { + "type": "keyword" + }, + "connector_id": { + "type": "keyword" + }, + "connector_name": { + "type": "keyword" + }, + "created_at": { + "type": "date" + }, + "created_by": { + "properties": { + "email": { + "type": "keyword" + }, + "full_name": { + "type": "keyword" + }, + "username": { + "type": "keyword" + } + } + }, + "updated_at": { + "type": "date" + }, + "updated_by": { + "properties": { + "email": { + "type": "keyword" + }, + "full_name": { + "type": "keyword" + }, + "username": { + "type": "keyword" + } + } + } + } + }, + "cases-user-actions": { + "properties": { + "action": { + "type": "keyword" + }, + "action_at": { + "type": "date" + }, + "action_by": { + "properties": { + "email": { + "type": "keyword" + }, + "full_name": { + "type": "keyword" + }, + "username": { + "type": "keyword" + } + } + }, + "action_field": { + "type": "keyword" + }, + "new_value": { + "type": "text" + }, + "old_value": { + "type": "text" + } + } + }, + "config": { + "dynamic": "false", + "properties": { + "buildNum": { + "type": "keyword" + } + } + }, + "dashboard": { + "properties": { + "description": { + "type": "text" + }, + "hits": { + "type": "integer", + "index": false, + "doc_values": false + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text", + "index": false + } + } + }, + "optionsJSON": { + "type": "text", + "index": false + }, + "panelsJSON": { + "type": "text", + "index": false + }, + "refreshInterval": { + "properties": { + "display": { + "type": "keyword", + "index": false, + "doc_values": false + }, + "pause": { + "type": "boolean", + "doc_values": false, + "index": false + }, + "section": { + "type": "integer", + "index": false, + "doc_values": false + }, + "value": { + "type": "integer", + "index": false, + "doc_values": false + } + } + }, + "timeFrom": { + "type": "keyword", + "index": false, + "doc_values": false + }, + "timeRestore": { + "type": "boolean", + "doc_values": false, + "index": false + }, + "timeTo": { + "type": "keyword", + "index": false, + "doc_values": false + }, + "title": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "endpoint:user-artifact": { + "properties": { + "body": { + "type": "binary" + }, + "compressionAlgorithm": { + "type": "keyword", + "index": false + }, + "created": { + "type": "date", + "index": false + }, + "decodedSha256": { + "type": "keyword", + "index": false + }, + "decodedSize": { + "type": "long", + "index": false + }, + "encodedSha256": { + "type": "keyword" + }, + "encodedSize": { + "type": "long", + "index": false + }, + "encryptionAlgorithm": { + "type": "keyword", + "index": false + }, + "identifier": { + "type": "keyword" + } + } + }, + "endpoint:user-artifact-manifest": { + "properties": { + "created": { + "type": "date", + "index": false + }, + "ids": { + "type": "keyword", + "index": false + }, + "schemaVersion": { + "type": "keyword" + }, + "semanticVersion": { + "type": "keyword", + "index": false + } + } + }, + "enterprise_search_telemetry": { + "type": "object", + "dynamic": "false" + }, + "epm-packages": { + "properties": { + "es_index_patterns": { + "type": "object", + "enabled": false + }, + "install_started_at": { + "type": "date" + }, + "install_status": { + "type": "keyword" + }, + "install_version": { + "type": "keyword" + }, + "installed_es": { + "type": "nested", + "properties": { + "id": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + } + }, + "installed_kibana": { + "type": "nested", + "properties": { + "id": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + } + }, + "internal": { + "type": "boolean" + }, + "name": { + "type": "keyword" + }, + "removable": { + "type": "boolean" + }, + "version": { + "type": "keyword" + } + } + }, + "exception-list": { + "properties": { + "_tags": { + "type": "keyword" + }, + "comments": { + "properties": { + "comment": { + "type": "keyword" + }, + "created_at": { + "type": "keyword" + }, + "created_by": { + "type": "keyword" + }, + "id": { + "type": "keyword" + }, + "updated_at": { + "type": "keyword" + }, + "updated_by": { + "type": "keyword" + } + } + }, + "created_at": { + "type": "keyword" + }, + "created_by": { + "type": "keyword" + }, + "description": { + "type": "keyword" + }, + "entries": { + "properties": { + "entries": { + "properties": { + "field": { + "type": "keyword" + }, + "operator": { + "type": "keyword" + }, + "type": { + "type": "keyword" + }, + "value": { + "type": "keyword", + "fields": { + "text": { + "type": "text" + } + } + } + } + }, + "field": { + "type": "keyword" + }, + "list": { + "properties": { + "id": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + } + }, + "operator": { + "type": "keyword" + }, + "type": { + "type": "keyword" + }, + "value": { + "type": "keyword", + "fields": { + "text": { + "type": "text" + } + } + } + } + }, + "immutable": { + "type": "boolean" + }, + "item_id": { + "type": "keyword" + }, + "list_id": { + "type": "keyword" + }, + "list_type": { + "type": "keyword" + }, + "meta": { + "type": "keyword" + }, + "name": { + "type": "keyword" + }, + "tags": { + "type": "keyword" + }, + "tie_breaker_id": { + "type": "keyword" + }, + "type": { + "type": "keyword" + }, + "updated_by": { + "type": "keyword" + }, + "version": { + "type": "keyword" + } + } + }, + "exception-list-agnostic": { + "properties": { + "_tags": { + "type": "keyword" + }, + "comments": { + "properties": { + "comment": { + "type": "keyword" + }, + "created_at": { + "type": "keyword" + }, + "created_by": { + "type": "keyword" + }, + "id": { + "type": "keyword" + }, + "updated_at": { + "type": "keyword" + }, + "updated_by": { + "type": "keyword" + } + } + }, + "created_at": { + "type": "keyword" + }, + "created_by": { + "type": "keyword" + }, + "description": { + "type": "keyword" + }, + "entries": { + "properties": { + "entries": { + "properties": { + "field": { + "type": "keyword" + }, + "operator": { + "type": "keyword" + }, + "type": { + "type": "keyword" + }, + "value": { + "type": "keyword", + "fields": { + "text": { + "type": "text" + } + } + } + } + }, + "field": { + "type": "keyword" + }, + "list": { + "properties": { + "id": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + } + }, + "operator": { + "type": "keyword" + }, + "type": { + "type": "keyword" + }, + "value": { + "type": "keyword", + "fields": { + "text": { + "type": "text" + } + } + } + } + }, + "immutable": { + "type": "boolean" + }, + "item_id": { + "type": "keyword" + }, + "list_id": { + "type": "keyword" + }, + "list_type": { + "type": "keyword" + }, + "meta": { + "type": "keyword" + }, + "name": { + "type": "keyword" + }, + "tags": { + "type": "keyword" + }, + "tie_breaker_id": { + "type": "keyword" + }, + "type": { + "type": "keyword" + }, + "updated_by": { + "type": "keyword" + }, + "version": { + "type": "keyword" + } + } + }, + "file-upload-telemetry": { + "properties": { + "filesUploadedTotalCount": { + "type": "long" + } + } + }, + "fleet-agent-actions": { + "properties": { + "ack_data": { + "type": "text" + }, + "agent_id": { + "type": "keyword" + }, + "created_at": { + "type": "date" + }, + "data": { + "type": "binary" + }, + "policy_id": { + "type": "keyword" + }, + "policy_revision": { + "type": "integer" + }, + "sent_at": { + "type": "date" + }, + "type": { + "type": "keyword" + } + } + }, + "fleet-agent-events": { + "properties": { + "action_id": { + "type": "keyword" + }, + "agent_id": { + "type": "keyword" + }, + "data": { + "type": "text" + }, + "message": { + "type": "text" + }, + "payload": { + "type": "text" + }, + "policy_id": { + "type": "keyword" + }, + "stream_id": { + "type": "keyword" + }, + "subtype": { + "type": "keyword" + }, + "timestamp": { + "type": "date" + }, + "type": { + "type": "keyword" + } + } + }, + "fleet-agents": { + "properties": { + "access_api_key_id": { + "type": "keyword" + }, + "active": { + "type": "boolean" + }, + "current_error_events": { + "type": "text", + "index": false + }, + "default_api_key": { + "type": "binary" + }, + "default_api_key_id": { + "type": "keyword" + }, + "enrolled_at": { + "type": "date" + }, + "last_checkin": { + "type": "date" + }, + "last_checkin_status": { + "type": "keyword" + }, + "last_updated": { + "type": "date" + }, + "local_metadata": { + "type": "flattened" + }, + "packages": { + "type": "keyword" + }, + "policy_id": { + "type": "keyword" + }, + "policy_revision": { + "type": "integer" + }, + "shared_id": { + "type": "keyword" + }, + "type": { + "type": "keyword" + }, + "unenrolled_at": { + "type": "date" + }, + "unenrollment_started_at": { + "type": "date" + }, + "updated_at": { + "type": "date" + }, + "upgrade_started_at": { + "type": "date" + }, + "upgraded_at": { + "type": "date" + }, + "user_provided_metadata": { + "type": "flattened" + }, + "version": { + "type": "keyword" + } + } + }, + "fleet-enrollment-api-keys": { + "properties": { + "active": { + "type": "boolean" + }, + "api_key": { + "type": "binary" + }, + "api_key_id": { + "type": "keyword" + }, + "created_at": { + "type": "date" + }, + "expire_at": { + "type": "date" + }, + "name": { + "type": "keyword" + }, + "policy_id": { + "type": "keyword" + }, + "type": { + "type": "keyword" + }, + "updated_at": { + "type": "date" + } + } + }, + "graph-workspace": { + "properties": { + "description": { + "type": "text" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "numLinks": { + "type": "integer" + }, + "numVertices": { + "type": "integer" + }, + "title": { + "type": "text" + }, + "version": { + "type": "integer" + }, + "wsState": { + "type": "text" + } + } + }, + "hidden-saved-object-with-secret": { + "properties": { + "privateProperty": { + "type": "binary" + }, + "publicProperty": { + "type": "keyword" + }, + "publicPropertyExcludedFromAAD": { + "type": "keyword" + }, + "publicPropertyStoredEncrypted": { + "type": "binary" + } + } + }, + "index-pattern": { + "dynamic": "false", + "properties": { + "title": { + "type": "text" + }, + "type": { + "type": "keyword" + } + } + }, + "infrastructure-ui-source": { + "type": "object", + "dynamic": "false" + }, + "ingest-agent-policies": { + "properties": { + "description": { + "type": "text" + }, + "is_default": { + "type": "boolean" + }, + "monitoring_enabled": { + "type": "keyword", + "index": false + }, + "name": { + "type": "keyword" + }, + "namespace": { + "type": "keyword" + }, + "package_policies": { + "type": "keyword" + }, + "revision": { + "type": "integer" + }, + "status": { + "type": "keyword" + }, + "updated_at": { + "type": "date" + }, + "updated_by": { + "type": "keyword" + } + } + }, + "ingest-outputs": { + "properties": { + "ca_sha256": { + "type": "keyword", + "index": false + }, + "config": { + "type": "flattened" + }, + "fleet_enroll_password": { + "type": "binary" + }, + "fleet_enroll_username": { + "type": "binary" + }, + "hosts": { + "type": "keyword" + }, + "is_default": { + "type": "boolean" + }, + "name": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + } + }, + "ingest-package-policies": { + "properties": { + "created_at": { + "type": "date" + }, + "created_by": { + "type": "keyword" + }, + "description": { + "type": "text" + }, + "enabled": { + "type": "boolean" + }, + "inputs": { + "type": "nested", + "enabled": false, + "properties": { + "config": { + "type": "flattened" + }, + "enabled": { + "type": "boolean" + }, + "streams": { + "type": "nested", + "properties": { + "compiled_stream": { + "type": "flattened" + }, + "config": { + "type": "flattened" + }, + "data_stream": { + "properties": { + "dataset": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + } + }, + "enabled": { + "type": "boolean" + }, + "id": { + "type": "keyword" + }, + "vars": { + "type": "flattened" + } + } + }, + "type": { + "type": "keyword" + }, + "vars": { + "type": "flattened" + } + } + }, + "name": { + "type": "keyword" + }, + "namespace": { + "type": "keyword" + }, + "output_id": { + "type": "keyword" + }, + "package": { + "properties": { + "name": { + "type": "keyword" + }, + "title": { + "type": "keyword" + }, + "version": { + "type": "keyword" + } + } + }, + "policy_id": { + "type": "keyword" + }, + "revision": { + "type": "integer" + }, + "updated_at": { + "type": "date" + }, + "updated_by": { + "type": "keyword" + } + } + }, + "ingest_manager_settings": { + "properties": { + "agent_auto_upgrade": { + "type": "keyword" + }, + "has_seen_add_data_notice": { + "type": "boolean", + "index": false + }, + "kibana_ca_sha256": { + "type": "keyword" + }, + "kibana_urls": { + "type": "keyword" + }, + "package_auto_upgrade": { + "type": "keyword" + } + } + }, + "inventory-view": { + "type": "object", + "dynamic": "false" + }, + "kql-telemetry": { + "properties": { + "optInCount": { + "type": "long" + }, + "optOutCount": { + "type": "long" + } + } + }, + "lens": { + "properties": { + "description": { + "type": "text" + }, + "expression": { + "type": "keyword", + "index": false, + "doc_values": false + }, + "state": { + "type": "flattened" + }, + "title": { + "type": "text" + }, + "visualizationType": { + "type": "keyword" + } + } + }, + "lens-ui-telemetry": { + "properties": { + "count": { + "type": "integer" + }, + "date": { + "type": "date" + }, + "name": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + } + }, + "map": { + "properties": { + "description": { + "type": "text" + }, + "layerListJSON": { + "type": "text" + }, + "mapStateJSON": { + "type": "text" + }, + "title": { + "type": "text" + }, + "uiStateJSON": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "maps-telemetry": { + "type": "object", + "enabled": false + }, + "metrics-explorer-view": { + "type": "object", + "dynamic": "false" + }, + "migrationVersion": { + "dynamic": "true", + "properties": { + "config": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "space": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + } + } + }, + "ml-telemetry": { + "properties": { + "file_data_visualizer": { + "properties": { + "index_creation_count": { + "type": "long" + } + } + } + } + }, + "monitoring-telemetry": { + "properties": { + "reportedClusterUuids": { + "type": "keyword" + } + } + }, + "namespace": { + "type": "keyword" + }, + "namespaces": { + "type": "keyword" + }, + "originId": { + "type": "keyword" + }, + "query": { + "properties": { + "description": { + "type": "text" + }, + "filters": { + "type": "object", + "enabled": false + }, + "query": { + "properties": { + "language": { + "type": "keyword" + }, + "query": { + "type": "keyword", + "index": false + } + } + }, + "timefilter": { + "type": "object", + "enabled": false + }, + "title": { + "type": "text" + } + } + }, + "references": { + "type": "nested", + "properties": { + "id": { + "type": "keyword" + }, + "name": { + "type": "keyword" + }, + "type": { + "type": "keyword" + } + } + }, + "sample-data-telemetry": { + "properties": { + "installCount": { + "type": "long" + }, + "unInstallCount": { + "type": "long" + } + } + }, + "saved-object-with-migration": { + "properties": { + "additionalEncryptedAttribute": { + "type": "keyword" + }, + "encryptedAttribute": { + "type": "binary" + }, + "nonEncryptedAttribute": { + "type": "keyword" + } + } + }, + "saved-object-with-secret": { + "properties": { + "privateProperty": { + "type": "binary" + }, + "publicProperty": { + "type": "keyword" + }, + "publicPropertyExcludedFromAAD": { + "type": "keyword" + }, + "publicPropertyStoredEncrypted": { + "type": "binary" + } + } + }, + "saved-object-with-secret-and-multiple-spaces": { + "properties": { + "privateProperty": { + "type": "binary" + }, + "publicProperty": { + "type": "keyword" + }, + "publicPropertyExcludedFromAAD": { + "type": "keyword" + }, + "publicPropertyStoredEncrypted": { + "type": "binary" + } + } + }, + "saved-object-without-secret": { + "properties": { + "publicProperty": { + "type": "keyword" + } + } + }, + "search": { + "properties": { + "columns": { + "type": "keyword", + "index": false, + "doc_values": false + }, + "description": { + "type": "text" + }, + "hits": { + "type": "integer", + "index": false, + "doc_values": false + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text", + "index": false + } + } + }, + "sort": { + "type": "keyword", + "index": false, + "doc_values": false + }, + "title": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "search-telemetry": { + "type": "object", + "dynamic": "false" + }, + "siem-detection-engine-rule-actions": { + "properties": { + "actions": { + "properties": { + "action_type_id": { + "type": "keyword" + }, + "group": { + "type": "keyword" + }, + "id": { + "type": "keyword" + }, + "params": { + "type": "object", + "enabled": false + } + } + }, + "alertThrottle": { + "type": "keyword" + }, + "ruleAlertId": { + "type": "keyword" + }, + "ruleThrottle": { + "type": "keyword" + } + } + }, + "siem-detection-engine-rule-status": { + "properties": { + "alertId": { + "type": "keyword" + }, + "bulkCreateTimeDurations": { + "type": "float" + }, + "gap": { + "type": "text" + }, + "lastFailureAt": { + "type": "date" + }, + "lastFailureMessage": { + "type": "text" + }, + "lastLookBackDate": { + "type": "date" + }, + "lastSuccessAt": { + "type": "date" + }, + "lastSuccessMessage": { + "type": "text" + }, + "searchAfterTimeDurations": { + "type": "float" + }, + "status": { + "type": "keyword" + }, + "statusDate": { + "type": "date" + } + } + }, + "siem-ui-timeline": { + "properties": { + "columns": { + "properties": { + "aggregatable": { + "type": "boolean" + }, + "category": { + "type": "keyword" + }, + "columnHeaderType": { + "type": "keyword" + }, + "description": { + "type": "text" + }, + "example": { + "type": "text" + }, + "id": { + "type": "keyword" + }, + "indexes": { + "type": "keyword" + }, + "name": { + "type": "text" + }, + "placeholder": { + "type": "text" + }, + "searchable": { + "type": "boolean" + }, + "type": { + "type": "keyword" + } + } + }, + "created": { + "type": "date" + }, + "createdBy": { + "type": "text" + }, + "dataProviders": { + "properties": { + "and": { + "properties": { + "enabled": { + "type": "boolean" + }, + "excluded": { + "type": "boolean" + }, + "id": { + "type": "keyword" + }, + "kqlQuery": { + "type": "text" + }, + "name": { + "type": "text" + }, + "queryMatch": { + "properties": { + "displayField": { + "type": "text" + }, + "displayValue": { + "type": "text" + }, + "field": { + "type": "text" + }, + "operator": { + "type": "text" + }, + "value": { + "type": "text" + } + } + }, + "type": { + "type": "text" + } + } + }, + "enabled": { + "type": "boolean" + }, + "excluded": { + "type": "boolean" + }, + "id": { + "type": "keyword" + }, + "kqlQuery": { + "type": "text" + }, + "name": { + "type": "text" + }, + "queryMatch": { + "properties": { + "displayField": { + "type": "text" + }, + "displayValue": { + "type": "text" + }, + "field": { + "type": "text" + }, + "operator": { + "type": "text" + }, + "value": { + "type": "text" + } + } + }, + "type": { + "type": "text" + } + } + }, + "dateRange": { + "properties": { + "end": { + "type": "date" + }, + "start": { + "type": "date" + } + } + }, + "description": { + "type": "text" + }, + "eventType": { + "type": "keyword" + }, + "excludedRowRendererIds": { + "type": "text" + }, + "favorite": { + "properties": { + "favoriteDate": { + "type": "date" + }, + "fullName": { + "type": "text" + }, + "keySearch": { + "type": "text" + }, + "userName": { + "type": "text" + } + } + }, + "filters": { + "properties": { + "exists": { + "type": "text" + }, + "match_all": { + "type": "text" + }, + "meta": { + "properties": { + "alias": { + "type": "text" + }, + "controlledBy": { + "type": "text" + }, + "disabled": { + "type": "boolean" + }, + "field": { + "type": "text" + }, + "formattedValue": { + "type": "text" + }, + "index": { + "type": "keyword" + }, + "key": { + "type": "keyword" + }, + "negate": { + "type": "boolean" + }, + "params": { + "type": "text" + }, + "type": { + "type": "keyword" + }, + "value": { + "type": "text" + } + } + }, + "missing": { + "type": "text" + }, + "query": { + "type": "text" + }, + "range": { + "type": "text" + }, + "script": { + "type": "text" + } + } + }, + "indexNames": { + "type": "text" + }, + "kqlMode": { + "type": "keyword" + }, + "kqlQuery": { + "properties": { + "filterQuery": { + "properties": { + "kuery": { + "properties": { + "expression": { + "type": "text" + }, + "kind": { + "type": "keyword" + } + } + }, + "serializedQuery": { + "type": "text" + } + } + } + } + }, + "savedQueryId": { + "type": "keyword" + }, + "sort": { + "properties": { + "columnId": { + "type": "keyword" + }, + "sortDirection": { + "type": "keyword" + } + } + }, + "status": { + "type": "keyword" + }, + "templateTimelineId": { + "type": "text" + }, + "templateTimelineVersion": { + "type": "integer" + }, + "timelineType": { + "type": "keyword" + }, + "title": { + "type": "text" + }, + "updated": { + "type": "date" + }, + "updatedBy": { + "type": "text" + } + } + }, + "siem-ui-timeline-note": { + "properties": { + "created": { + "type": "date" + }, + "createdBy": { + "type": "text" + }, + "eventId": { + "type": "keyword" + }, + "note": { + "type": "text" + }, + "timelineId": { + "type": "keyword" + }, + "updated": { + "type": "date" + }, + "updatedBy": { + "type": "text" + } + } + }, + "siem-ui-timeline-pinned-event": { + "properties": { + "created": { + "type": "date" + }, + "createdBy": { + "type": "text" + }, + "eventId": { + "type": "keyword" + }, + "timelineId": { + "type": "keyword" + }, + "updated": { + "type": "date" + }, + "updatedBy": { + "type": "text" + } + } + }, + "space": { + "properties": { + "_reserved": { + "type": "boolean" + }, + "color": { + "type": "keyword" + }, + "description": { + "type": "text" + }, + "disabledFeatures": { + "type": "keyword" + }, + "imageUrl": { + "type": "text", + "index": false + }, + "initials": { + "type": "keyword" + }, + "name": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 2048 + } + } + } + } + }, + "telemetry": { + "properties": { + "allowChangingOptInStatus": { + "type": "boolean" + }, + "enabled": { + "type": "boolean" + }, + "lastReported": { + "type": "date" + }, + "lastVersionChecked": { + "type": "keyword" + }, + "reportFailureCount": { + "type": "integer" + }, + "reportFailureVersion": { + "type": "keyword" + }, + "sendUsageFrom": { + "type": "keyword" + }, + "userHasSeenNotice": { + "type": "boolean" + } + } + }, + "timelion-sheet": { + "properties": { + "description": { + "type": "text" + }, + "hits": { + "type": "integer" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text" + } + } + }, + "timelion_chart_height": { + "type": "integer" + }, + "timelion_columns": { + "type": "integer" + }, + "timelion_interval": { + "type": "keyword" + }, + "timelion_other_interval": { + "type": "keyword" + }, + "timelion_rows": { + "type": "integer" + }, + "timelion_sheet": { + "type": "text" + }, + "title": { + "type": "text" + }, + "version": { + "type": "integer" + } + } + }, + "tsvb-validation-telemetry": { + "properties": { + "failedRequests": { + "type": "long" + } + } + }, + "type": { + "type": "keyword" + }, + "ui-metric": { + "properties": { + "count": { + "type": "integer" + } + } + }, + "updated_at": { + "type": "date" + }, + "upgrade-assistant-reindex-operation": { + "properties": { + "errorMessage": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "indexName": { + "type": "keyword" + }, + "lastCompletedStep": { + "type": "long" + }, + "locked": { + "type": "date" + }, + "newIndexName": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "reindexOptions": { + "properties": { + "openAndClose": { + "type": "boolean" + }, + "queueSettings": { + "properties": { + "queuedAt": { + "type": "long" + }, + "startedAt": { + "type": "long" + } + } + } + } + }, + "reindexTaskId": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 256 + } + } + }, + "reindexTaskPercComplete": { + "type": "float" + }, + "runningReindexCount": { + "type": "integer" + }, + "status": { + "type": "integer" + } + } + }, + "upgrade-assistant-telemetry": { + "properties": { + "features": { + "properties": { + "deprecation_logging": { + "properties": { + "enabled": { + "type": "boolean", + "null_value": true + } + } + } + } + }, + "ui_open": { + "properties": { + "cluster": { + "type": "long", + "null_value": 0 + }, + "indices": { + "type": "long", + "null_value": 0 + }, + "overview": { + "type": "long", + "null_value": 0 + } + } + }, + "ui_reindex": { + "properties": { + "close": { + "type": "long", + "null_value": 0 + }, + "open": { + "type": "long", + "null_value": 0 + }, + "start": { + "type": "long", + "null_value": 0 + }, + "stop": { + "type": "long", + "null_value": 0 + } + } + } + } + }, + "uptime-dynamic-settings": { + "type": "object", + "dynamic": "false" + }, + "url": { + "properties": { + "accessCount": { + "type": "long" + }, + "accessDate": { + "type": "date" + }, + "createDate": { + "type": "date" + }, + "url": { + "type": "text", + "fields": { + "keyword": { + "type": "keyword", + "ignore_above": 2048 + } + } + } + } + }, + "visualization": { + "properties": { + "description": { + "type": "text" + }, + "kibanaSavedObjectMeta": { + "properties": { + "searchSourceJSON": { + "type": "text", + "index": false + } + } + }, + "savedSearchRefName": { + "type": "keyword", + "index": false, + "doc_values": false + }, + "title": { + "type": "text" + }, + "uiStateJSON": { + "type": "text", + "index": false + }, + "version": { + "type": "integer" + }, + "visState": { + "type": "text", + "index": false + } + } + }, + "workplace_search_telemetry": { + "type": "object", + "dynamic": "false" + } + } + }, + "settings": { + "index": { + "auto_expand_replicas": "0-1", + "number_of_replicas": "1", + "number_of_shards": "1" + } + } + } +} diff --git a/x-pack/test/encrypted_saved_objects_api_integration/tests/encrypted_saved_objects_api.ts b/x-pack/test/encrypted_saved_objects_api_integration/tests/encrypted_saved_objects_api.ts index 8bdc1715bf487b..2494cb36bd52e3 100644 --- a/x-pack/test/encrypted_saved_objects_api_integration/tests/encrypted_saved_objects_api.ts +++ b/x-pack/test/encrypted_saved_objects_api_integration/tests/encrypted_saved_objects_api.ts @@ -437,15 +437,15 @@ export default function ({ getService }: FtrProviderContext) { }:${id}`; } - afterEach(async () => { - await es.deleteByQuery({ - index: '.kibana', - q: `type:${SAVED_OBJECT_WITH_SECRET_TYPE} OR type:${HIDDEN_SAVED_OBJECT_WITH_SECRET_TYPE} OR type:${SAVED_OBJECT_WITH_SECRET_AND_MULTIPLE_SPACES_TYPE} OR type:${SAVED_OBJECT_WITHOUT_SECRET_TYPE}`, - refresh: true, + describe('within a default space', () => { + afterEach(async () => { + await es.deleteByQuery({ + index: '.kibana', + q: `type:${SAVED_OBJECT_WITH_SECRET_TYPE} OR type:${HIDDEN_SAVED_OBJECT_WITH_SECRET_TYPE} OR type:${SAVED_OBJECT_WITH_SECRET_AND_MULTIPLE_SPACES_TYPE} OR type:${SAVED_OBJECT_WITHOUT_SECRET_TYPE}`, + refresh: true, + }); }); - }); - describe('within a default space', () => { describe('with `single` namespace saved object', () => { runTests( SAVED_OBJECT_WITH_SECRET_TYPE, @@ -486,6 +486,14 @@ export default function ({ getService }: FtrProviderContext) { await supertest.delete(`/api/spaces/space/${SPACE_ID}`).set('kbn-xsrf', 'xxx').expect(204); }); + afterEach(async () => { + await es.deleteByQuery({ + index: '.kibana', + q: `type:${SAVED_OBJECT_WITH_SECRET_TYPE} OR type:${HIDDEN_SAVED_OBJECT_WITH_SECRET_TYPE} OR type:${SAVED_OBJECT_WITH_SECRET_AND_MULTIPLE_SPACES_TYPE} OR type:${SAVED_OBJECT_WITHOUT_SECRET_TYPE}`, + refresh: true, + }); + }); + describe('with `single` namespace saved object', () => { runTests( SAVED_OBJECT_WITH_SECRET_TYPE, @@ -529,5 +537,104 @@ export default function ({ getService }: FtrProviderContext) { }); }); }); + + describe('key rotation', () => { + const savedObjectsEncryptedWithLegacyKeys: Array<[string, string, string[], boolean]> = [ + [SAVED_OBJECT_WITH_SECRET_TYPE, 'cd9272b2-6a15-4295-bb7b-15f6347e267b', ['default'], false], + [ + SAVED_OBJECT_WITH_SECRET_AND_MULTIPLE_SPACES_TYPE, + '6b388d9b-6d8f-4e20-ba38-d9477370729d', + ['a', 'default'], + false, + ], + [ + HIDDEN_SAVED_OBJECT_WITH_SECRET_TYPE, + '8d90daef-f992-443c-ab78-053c6a7b0e9c', + ['default'], + true, + ], + [SAVED_OBJECT_WITH_SECRET_TYPE, '4336f782-450f-4142-aecd-b46ed092af52', ['default'], false], + [ + SAVED_OBJECT_WITH_SECRET_AND_MULTIPLE_SPACES_TYPE, + '2ee1bede-400d-4767-b3f0-09a7064bec14', + ['a', 'default'], + false, + ], + [ + HIDDEN_SAVED_OBJECT_WITH_SECRET_TYPE, + '506038a1-ec71-42b5-bce2-99661b29c62b', + ['default'], + true, + ], + ]; + + before(async () => { + await esArchiver.load('key_rotation'); + }); + + after(async () => { + await esArchiver.unload('key_rotation'); + }); + + it('#get can properly retrieve objects encrypted with the legacy keys', async () => { + // Hidden objects cannot be retrieved with standard Saved Objects APIs. + for (const [type, id, namespaces, hidden] of savedObjectsEncryptedWithLegacyKeys.filter( + ([, , , hiddenSavedObject]) => !hiddenSavedObject + )) { + const url = hidden + ? `/api/saved_objects/${type}/${id}` + : `/api/saved_objects/${type}/${id}`; + const { body: decryptedResponse } = await supertest.get(url).expect(200); + + expect(decryptedResponse.namespaces.sort()).to.eql(namespaces); + expect(decryptedResponse.attributes).to.eql({ + publicProperty: 'some-public-property-0', + publicPropertyExcludedFromAAD: 'some-public-property-excluded-from-aad-0', + publicPropertyStoredEncrypted: 'some-public-but-encrypted-property-0', + }); + } + }); + + it('#get-decrypted-as-internal-user can properly retrieve objects encrypted with the legacy keys', async () => { + for (const [type, id, namespaces, hidden] of savedObjectsEncryptedWithLegacyKeys) { + const url = hidden + ? `/api/hidden_saved_objects/get-decrypted-as-internal-user/${type}/${id}` + : `/api/saved_objects/get-decrypted-as-internal-user/${type}/${id}`; + const { body: decryptedResponse } = await supertest.get(url).expect(200); + + expect(decryptedResponse.namespaces.sort()).to.eql(namespaces); + expect(decryptedResponse.attributes).to.eql({ + privateProperty: 'some-private-property-0', + publicProperty: 'some-public-property-0', + publicPropertyExcludedFromAAD: 'some-public-property-excluded-from-aad-0', + publicPropertyStoredEncrypted: 'some-public-but-encrypted-property-0', + }); + } + }); + + // Since this test re-encrypts objects it should always go last in this suite. + it('saved objects can be properly re-encrypted', async () => { + await supertest + .post('/api/encrypted_saved_objects/rotate_key') + .set('kbn-xsrf', 'xxx') + .send() + .expect(200, { total: 6, successful: 6, failed: 0 }); + + for (const [type, id, namespaces, hidden] of savedObjectsEncryptedWithLegacyKeys) { + const url = hidden + ? `/api/hidden_saved_objects/get-decrypted-as-internal-user/${type}/${id}` + : `/api/saved_objects/get-decrypted-as-internal-user/${type}/${id}`; + const { body: decryptedResponse } = await supertest.get(url).expect(200); + + expect(decryptedResponse.namespaces.sort()).to.eql(namespaces); + expect(decryptedResponse.attributes).to.eql({ + privateProperty: 'some-private-property-0', + publicProperty: 'some-public-property-0', + publicPropertyExcludedFromAAD: 'some-public-property-excluded-from-aad-0', + publicPropertyStoredEncrypted: 'some-public-but-encrypted-property-0', + }); + } + }); + }); }); } From 726f6ba99ced36adc0edcf285dc75d2bfcd5419f Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin Date: Fri, 2 Oct 2020 18:36:58 +0200 Subject: [PATCH 4/5] Review#2: add test to verify that non-superuser cannot rotate key, use proper naming convention for the rotate route URL and more. --- .../crypto/encryption_key_rotation_service.ts | 6 ++-- .../server/routes/key_rotation.test.ts | 6 ++-- .../server/routes/key_rotation.ts | 2 +- .../services.ts | 8 ++++- .../tests/encrypted_saved_objects_api.ts | 32 +++++++++++++++---- 5 files changed, 39 insertions(+), 15 deletions(-) diff --git a/x-pack/plugins/encrypted_saved_objects/server/crypto/encryption_key_rotation_service.ts b/x-pack/plugins/encrypted_saved_objects/server/crypto/encryption_key_rotation_service.ts index d95a1176d6ac59..fb1b6db45e762d 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/crypto/encryption_key_rotation_service.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/crypto/encryption_key_rotation_service.ts @@ -85,7 +85,7 @@ export class EncryptionKeyRotationService { const result = { total: 0, successful: 0, failed: 0 }; if (registeredSavedObjectTypes.length === 0) { - this.options.logger.debug( + this.options.logger.info( type ? `Saved Object type "${type}" is not registered, encryption key rotation is not needed.` : 'There are no registered Saved Object types that can have encrypted attributes, encryption key rotation is not needed.' @@ -93,7 +93,7 @@ export class EncryptionKeyRotationService { return result; } - this.options.logger.debug( + this.options.logger.info( `Saved Objects with the following types [${registeredSavedObjectTypes}] will be processed.` ); @@ -201,7 +201,7 @@ export class EncryptionKeyRotationService { } } - this.options.logger.debug( + this.options.logger.info( `Encryption key rotation is completed. ${result.successful} objects out ouf ${result.total} were successfully re-encrypted with the primary encryption key and ${result.failed} objects failed.` ); diff --git a/x-pack/plugins/encrypted_saved_objects/server/routes/key_rotation.test.ts b/x-pack/plugins/encrypted_saved_objects/server/routes/key_rotation.test.ts index 68320b08f080a9..ced4dda48fcd2a 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/routes/key_rotation.test.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/routes/key_rotation.test.ts @@ -39,7 +39,7 @@ describe('Key rotation routes', () => { let routeConfig: RouteConfig; beforeEach(() => { const [rotateRouteConfig, rotateRouteHandler] = router.post.mock.calls.find( - ([{ path }]) => path === '/api/encrypted_saved_objects/rotate_key' + ([{ path }]) => path === '/api/encrypted_saved_objects/_rotate_key' )!; routeConfig = rotateRouteConfig; @@ -86,7 +86,7 @@ describe('Key rotation routes', () => { const routeParamsMock = routeDefinitionParamsMock.create(); defineKeyRotationRoutes(routeParamsMock); const [, rotateRouteHandler] = routeParamsMock.router.post.mock.calls.find( - ([{ path }]) => path === '/api/encrypted_saved_objects/rotate_key' + ([{ path }]) => path === '/api/encrypted_saved_objects/_rotate_key' )!; await expect( @@ -161,7 +161,7 @@ describe('Key rotation routes', () => { options: { body: { total: 3, successful: 6, failed: 0 } }, }); - // And consequent requests resolve properly too. + // And subsequent requests resolve properly too. await expect(routeHandler(mockContext, mockRequest, kibanaResponseFactory)).resolves.toEqual({ status: 200, payload: { total: 3, successful: 6, failed: 0 }, diff --git a/x-pack/plugins/encrypted_saved_objects/server/routes/key_rotation.ts b/x-pack/plugins/encrypted_saved_objects/server/routes/key_rotation.ts index 32147b903b20eb..48b29387106ee3 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/routes/key_rotation.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/routes/key_rotation.ts @@ -25,7 +25,7 @@ export function defineKeyRotationRoutes({ let rotationInProgress = false; router.post( { - path: '/api/encrypted_saved_objects/rotate_key', + path: '/api/encrypted_saved_objects/_rotate_key', validate: { query: schema.object({ batchSize: schema.number({ diff --git a/x-pack/test/encrypted_saved_objects_api_integration/services.ts b/x-pack/test/encrypted_saved_objects_api_integration/services.ts index b7398349cce5da..a8d8048462693d 100644 --- a/x-pack/test/encrypted_saved_objects_api_integration/services.ts +++ b/x-pack/test/encrypted_saved_objects_api_integration/services.ts @@ -4,4 +4,10 @@ * you may not use this file except in compliance with the Elastic License. */ -export { services } from '../api_integration/services'; +import { services as commonServices } from '../common/services'; +import { services as apiIntegrationServices } from '../api_integration/services'; + +export const services = { + ...commonServices, + supertestWithoutAuth: apiIntegrationServices.supertestWithoutAuth, +}; diff --git a/x-pack/test/encrypted_saved_objects_api_integration/tests/encrypted_saved_objects_api.ts b/x-pack/test/encrypted_saved_objects_api_integration/tests/encrypted_saved_objects_api.ts index 2494cb36bd52e3..54c74554c9bdbc 100644 --- a/x-pack/test/encrypted_saved_objects_api_integration/tests/encrypted_saved_objects_api.ts +++ b/x-pack/test/encrypted_saved_objects_api_integration/tests/encrypted_saved_objects_api.ts @@ -13,6 +13,7 @@ export default function ({ getService }: FtrProviderContext) { const randomness = getService('randomness'); const supertest = getService('supertest'); const esArchiver = getService('esArchiver'); + const security = getService('security'); const SAVED_OBJECT_WITH_SECRET_TYPE = 'saved-object-with-secret'; const HIDDEN_SAVED_OBJECT_WITH_SECRET_TYPE = 'hidden-saved-object-with-secret'; @@ -539,6 +540,7 @@ export default function ({ getService }: FtrProviderContext) { }); describe('key rotation', () => { + const supertestWithoutAuth = getService('supertestWithoutAuth'); const savedObjectsEncryptedWithLegacyKeys: Array<[string, string, string[], boolean]> = [ [SAVED_OBJECT_WITH_SECRET_TYPE, 'cd9272b2-6a15-4295-bb7b-15f6347e267b', ['default'], false], [ @@ -568,23 +570,30 @@ export default function ({ getService }: FtrProviderContext) { ], ]; + const KIBANA_ADMIN_USERNAME = 'admin'; + const KIBANA_ADMIN_PASSWORD = 'changeme'; before(async () => { + await security.user.create(KIBANA_ADMIN_USERNAME, { + password: KIBANA_ADMIN_PASSWORD, + roles: ['kibana_admin'], + full_name: 'a kibana admin', + }); await esArchiver.load('key_rotation'); }); after(async () => { await esArchiver.unload('key_rotation'); + await security.user.delete('admin'); }); it('#get can properly retrieve objects encrypted with the legacy keys', async () => { // Hidden objects cannot be retrieved with standard Saved Objects APIs. - for (const [type, id, namespaces, hidden] of savedObjectsEncryptedWithLegacyKeys.filter( + for (const [type, id, namespaces] of savedObjectsEncryptedWithLegacyKeys.filter( ([, , , hiddenSavedObject]) => !hiddenSavedObject )) { - const url = hidden - ? `/api/saved_objects/${type}/${id}` - : `/api/saved_objects/${type}/${id}`; - const { body: decryptedResponse } = await supertest.get(url).expect(200); + const { body: decryptedResponse } = await supertest + .get(`/api/saved_objects/${type}/${id}`) + .expect(200); expect(decryptedResponse.namespaces.sort()).to.eql(namespaces); expect(decryptedResponse.attributes).to.eql({ @@ -612,10 +621,19 @@ export default function ({ getService }: FtrProviderContext) { } }); + it('non-super user cannot rotate encryption key', async () => { + await supertestWithoutAuth + .post('/api/encrypted_saved_objects/_rotate_key') + .set('kbn-xsrf', 'xxx') + .auth(KIBANA_ADMIN_USERNAME, KIBANA_ADMIN_PASSWORD) + .send() + .expect(404); + }); + // Since this test re-encrypts objects it should always go last in this suite. - it('saved objects can be properly re-encrypted', async () => { + it('encryption key can be properly rotated by the superuser', async () => { await supertest - .post('/api/encrypted_saved_objects/rotate_key') + .post('/api/encrypted_saved_objects/_rotate_key') .set('kbn-xsrf', 'xxx') .send() .expect(200, { total: 6, successful: 6, failed: 0 }); From 0fb46157dc649399d590e6db6b294df6b4dc6b6f Mon Sep 17 00:00:00 2001 From: Aleh Zasypkin Date: Fri, 2 Oct 2020 19:24:49 +0200 Subject: [PATCH 5/5] Workaround weird type issue. --- .../tests/encrypted_saved_objects_api.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/x-pack/test/encrypted_saved_objects_api_integration/tests/encrypted_saved_objects_api.ts b/x-pack/test/encrypted_saved_objects_api_integration/tests/encrypted_saved_objects_api.ts index 54c74554c9bdbc..637bb7c2022419 100644 --- a/x-pack/test/encrypted_saved_objects_api_integration/tests/encrypted_saved_objects_api.ts +++ b/x-pack/test/encrypted_saved_objects_api_integration/tests/encrypted_saved_objects_api.ts @@ -29,10 +29,10 @@ export default function ({ getService }: FtrProviderContext) { async function getRawSavedObjectAttributes({ id, type }: SavedObject) { const { _source: { [type]: savedObject }, - } = await es.get({ + } = await es.get>({ id: generateRawID(id, type), index: '.kibana', - }); + } as any); return savedObject; }