diff --git a/docs/setup/production.asciidoc b/docs/setup/production.asciidoc index 3075220e3a47c..e097704e05d40 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 2770f288b6af8..959e1f8dc3e72 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/src/plugins/home/public/application/components/app_navigation_handler.ts b/src/plugins/home/public/application/components/app_navigation_handler.ts index 91407ffcaf226..b6230bc9f1e38 100644 --- a/src/plugins/home/public/application/components/app_navigation_handler.ts +++ b/src/plugins/home/public/application/components/app_navigation_handler.ts @@ -24,6 +24,7 @@ export const createAppNavigationHandler = (targetUrl: string) => (event: MouseEv if (event.altKey || event.metaKey || event.ctrlKey) { return; } + const { application, addBasePath } = getServices(); event.preventDefault(); - getServices().application.navigateToUrl(targetUrl); + application.navigateToUrl(addBasePath(targetUrl)); }; 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 3f8074eb15c0c..cbe987830717f 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 9c751a9c67f52..f06c6fa1823ba 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/config.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/config.ts @@ -5,41 +5,50 @@ */ import crypto from 'crypto'; -import { map } from 'rxjs/operators'; import { schema, TypeOf } from '@kbn/config-schema'; -import { PluginInitializerContext } from 'src/core/server'; +import { Logger } 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 = ReturnType; -export function createConfig$(context: PluginInitializerContext) { - return context.config.create>().pipe( - map((config) => { - const logger = context.logger.get('config'); +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`.'; + } + }, + } +); - 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' - ); +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' + ); - encryptionKey = crypto.randomBytes(16).toString('hex'); - } + encryptionKey = crypto.randomBytes(16).toString('hex'); + } - return { - config: { ...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 42d2e2ffd1516..88d57072697fe 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 82d6bb9be15f6..1f1093a179538 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,38 @@ 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. + */ + 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; +} + /** * Utility function that gives array representation of the saved object descriptor respecting * optional `namespace` property. @@ -79,16 +111,7 @@ export class EncryptedSavedObjectsService { EncryptedSavedObjectAttributesDefinition > = new Map(); - /** - * @param crypto nodeCrypto instance. - * @param logger Ordinary logger instance. - * @param audit Audit logger instance. - */ - constructor( - private readonly crypto: Readonly, - private readonly logger: Logger, - private readonly audit: EncryptedSavedObjectsAuditLogger - ) {} + constructor(private readonly options: EncryptedSavedObjectsServiceOptions) {} /** * Registers saved object type as the one that contains attributes that should be encrypted. @@ -136,7 +159,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 +197,7 @@ export class EncryptedSavedObjectsService { Object.fromEntries( Object.entries(attributes).filter(([key]) => !typeDefinition.shouldBeStripped(key)) ) as T, - { user: params?.user } + params ); } catch (err) { decryptionError = err; @@ -210,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}"`, @@ -229,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( @@ -242,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, @@ -270,7 +293,9 @@ export class EncryptedSavedObjectsService { while (!iteratorResult.done) { const [attributeValue, encryptionAAD] = iteratorResult.value; try { - iteratorResult = iterator.next(await this.crypto.encrypt(attributeValue, encryptionAAD)); + iteratorResult = iterator.next( + await this.options.primaryCrypto.encrypt(attributeValue, encryptionAAD) + ); } catch (err) { iterator.throw!(err); } @@ -299,7 +324,9 @@ export class EncryptedSavedObjectsService { while (!iteratorResult.done) { const [attributeValue, encryptionAAD] = iteratorResult.value; try { - iteratorResult = iterator.next(this.crypto.encryptSync(attributeValue, encryptionAAD)); + iteratorResult = iterator.next( + this.options.primaryCrypto.encryptSync(attributeValue, encryptionAAD) + ); } catch (err) { iterator.throw!(err); } @@ -321,19 +348,31 @@ export class EncryptedSavedObjectsService { public async decryptAttributes>( descriptor: SavedObjectDescriptor, attributes: T, - params?: CommonParameters + params?: DecryptParameters ): Promise { + const decrypters = this.getDecrypters(params?.omitPrimaryEncryptionKey); 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 +392,31 @@ export class EncryptedSavedObjectsService { public decryptAttributesSync>( descriptor: SavedObjectDescriptor, attributes: T, - params?: CommonParameters + params?: DecryptParameters ): T { + const decrypters = this.getDecrypters(params?.omitPrimaryEncryptionKey); 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); } } @@ -388,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 @@ -401,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}"`, @@ -417,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( @@ -430,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, @@ -459,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.` @@ -468,4 +523,23 @@ export class EncryptedSavedObjectsService { return stringify([...descriptorToArray(descriptor), attributesAAD]); } + + /** + * Returns list of NodeCrypto instances used for decryption. + * @param omitPrimaryEncryptionKey Specifies whether returned decrypters shouldn't include primary + * encryption/decryption crypto. + */ + 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.` + ); + } + + return this.options.decryptionOnlyCryptos; + } + + 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 0000000000000..2d14577f91567 --- /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 0000000000000..8607b81e7205e --- /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 new file mode 100644 index 0000000000000..fb1b6db45e762 --- /dev/null +++ b/x-pack/plugins/encrypted_saved_objects/server/crypto/encryption_key_rotation_service.ts @@ -0,0 +1,268 @@ +/* + * 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'; +import { EncryptionError } from './encryption_error'; + +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.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.' + ); + return result; + } + + this.options.logger.info( + `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; + let maxBatches = 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; + // 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( + `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 || ++batch >= maxBatches) { + break; + } + } + + 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.` + ); + + 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); + } + + let decryptedAttributes; + try { + decryptedAttributes = await this.options.service.decryptAttributes( + { + type: savedObject.type, + id: savedObject.id, + namespace: getDescriptorNamespace( + typeRegistry, + savedObject.type, + savedObject.namespaces + ), + }, + savedObject.attributes as Record, + { omitPrimaryEncryptionKey: true, user } + ); + } catch (err) { + if (!(err instanceof EncryptionError)) { + throw err; + } + + continue; + } + + 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 3e4983deca625..4410cbac7beb9 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/crypto/index.ts b/x-pack/plugins/encrypted_saved_objects/server/crypto/index.ts index 75445bd24eba8..ff5e5fdc01059 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.test.ts b/x-pack/plugins/encrypted_saved_objects/server/plugin.test.ts index 57108954f2568..8d8f1a51f6802 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 69777798ddf19..6e3724fa3fe58 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/plugin.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/plugin.ts @@ -4,19 +4,22 @@ * 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, 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 +51,29 @@ 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 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') ); + + const primaryCrypto = nodeCrypto({ encryptionKey: config.encryptionKey }); + const decryptionOnlyCryptos = config.keyRotation.decryptionOnlyKeys.map((decryptionKey) => + nodeCrypto({ encryptionKey: decryptionKey }) + ); + const service = Object.freeze( - new EncryptedSavedObjectsService(crypto, this.logger, auditLogger) + new EncryptedSavedObjectsService({ + primaryCrypto, + decryptionOnlyCryptos, + logger: this.logger, + audit: auditLogger, + }) ); this.savedObjectsSetup = setupSavedObjects({ @@ -69,18 +83,33 @@ 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, - 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 0000000000000..b3d54c7f1ecac --- /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 new file mode 100644 index 0000000000000..72af8060de827 --- /dev/null +++ b/x-pack/plugins/encrypted_saved_objects/server/routes/index.ts @@ -0,0 +1,25 @@ +/* + * 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) { + 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 0000000000000..ced4dda48fcd2 --- /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 subsequent 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 new file mode 100644 index 0000000000000..48b29387106ee --- /dev/null +++ b/x-pack/plugins/encrypted_saved_objects/server/routes/key_rotation.ts @@ -0,0 +1,75 @@ +/* + * 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, + config, +}: RouteDefinitionParams) { + let rotationInProgress = false; + 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) => { + 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, { + batchSize: request.query.batchSize, + type: request.query.type, + }), + }); + } catch (err) { + logger.error(err); + 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 7ba90a5a76ab3..33ea1d8c3acec 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 7201f13fb930b..7c237b82cbb15 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); }; diff --git a/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/template/template.test.ts b/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/template/template.test.ts index 99e568bf771f8..cc1aa79c7491c 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/template/template.test.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/template/template.test.ts @@ -212,6 +212,37 @@ test('tests processing keyword field with multi fields with analyzed text field' expect(mappings).toEqual(keywordWithAnalyzedMultiFieldsMapping); }); +test('tests processing keyword field with multi fields with normalized keyword field', () => { + const keywordWithNormalizedMultiFieldsLiteralYml = ` + - name: keywordWithNormalizedMultiField + type: keyword + multi_fields: + - name: normalized + type: keyword + normalizer: lowercase + `; + + const keywordWithNormalizedMultiFieldsMapping = { + properties: { + keywordWithNormalizedMultiField: { + ignore_above: 1024, + type: 'keyword', + fields: { + normalized: { + type: 'keyword', + ignore_above: 1024, + normalizer: 'lowercase', + }, + }, + }, + }, + }; + const fields: Field[] = safeLoad(keywordWithNormalizedMultiFieldsLiteralYml); + const processedFields = processFields(fields); + const mappings = generateMappings(processedFields); + expect(mappings).toEqual(keywordWithNormalizedMultiFieldsMapping); +}); + test('tests processing object field with no other attributes', () => { const objectFieldLiteralYml = ` - name: objectField diff --git a/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/template/template.ts b/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/template/template.ts index 00c2e873ba129..e0fea59107c26 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/template/template.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/elasticsearch/template/template.ts @@ -189,6 +189,9 @@ function generateKeywordMapping(field: Field): IndexTemplateMapping { if (field.ignore_above) { mapping.ignore_above = field.ignore_above; } + if (field.normalizer) { + mapping.normalizer = field.normalizer; + } return mapping; } diff --git a/x-pack/plugins/ingest_manager/server/services/epm/fields/field.ts b/x-pack/plugins/ingest_manager/server/services/epm/fields/field.ts index a44e5e4221f9f..5913302e77ba6 100644 --- a/x-pack/plugins/ingest_manager/server/services/epm/fields/field.ts +++ b/x-pack/plugins/ingest_manager/server/services/epm/fields/field.ts @@ -20,6 +20,7 @@ export interface Field { index?: boolean; required?: boolean; multi_fields?: Fields; + normalizer?: string; doc_values?: boolean; copy_to?: string; analyzer?: string; diff --git a/x-pack/plugins/lens/public/async_services.ts b/x-pack/plugins/lens/public/async_services.ts index 5a88b47c0e894..09b9233197d2f 100644 --- a/x-pack/plugins/lens/public/async_services.ts +++ b/x-pack/plugins/lens/public/async_services.ts @@ -21,4 +21,5 @@ export * from './xy_visualization/xy_visualization'; export * from './indexpattern_datasource/indexpattern'; export * from './editor_frame_service/editor_frame'; +export * from './editor_frame_service/embeddable'; export * from './app_plugin/mounter'; diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/index.ts b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/index.ts index 41558caafc64c..04d4bc9c25de5 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/index.ts +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/index.ts @@ -5,3 +5,5 @@ */ export * from './editor_frame'; +export * from './state_helpers'; +export * from './state_management'; diff --git a/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.test.tsx index d48f9ed713caf..151f85e817c70 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.test.tsx @@ -26,7 +26,6 @@ import { VIS_EVENT_TO_TRIGGER } from '../../../../../../src/plugins/visualizatio import { coreMock, httpServiceMock } from '../../../../../../src/core/public/mocks'; import { IBasePath } from '../../../../../../src/core/public'; import { AttributeService } from '../../../../../../src/plugins/dashboard/public'; -import { Ast } from '@kbn/interpreter/common'; import { LensAttributeService } from '../../lens_attribute_service'; jest.mock('../../../../../../src/plugins/inspector/public/', () => ({ @@ -103,8 +102,14 @@ describe('embeddable', () => { indexPatternService: {} as IndexPatternsContract, editable: true, getTrigger, - documentToExpression: () => Promise.resolve({} as Ast), - toExpressionString: () => 'my | expression', + documentToExpression: () => + Promise.resolve({ + type: 'expression', + chain: [ + { type: 'function', function: 'my', arguments: {} }, + { type: 'function', function: 'expression', arguments: {} }, + ], + }), }, {} as LensEmbeddableInput ); @@ -112,7 +117,8 @@ describe('embeddable', () => { embeddable.render(mountpoint); expect(expressionRenderer).toHaveBeenCalledTimes(1); - expect(expressionRenderer.mock.calls[0][0]!.expression).toEqual('my | expression'); + expect(expressionRenderer.mock.calls[0][0]!.expression).toEqual(`my +| expression`); }); it('should re-render if new input is pushed', async () => { @@ -129,8 +135,14 @@ describe('embeddable', () => { indexPatternService: {} as IndexPatternsContract, editable: true, getTrigger, - documentToExpression: () => Promise.resolve({} as Ast), - toExpressionString: () => 'my | expression', + documentToExpression: () => + Promise.resolve({ + type: 'expression', + chain: [ + { type: 'function', function: 'my', arguments: {} }, + { type: 'function', function: 'expression', arguments: {} }, + ], + }), }, { id: '123' } as LensEmbeddableInput ); @@ -162,8 +174,14 @@ describe('embeddable', () => { indexPatternService: {} as IndexPatternsContract, editable: true, getTrigger, - documentToExpression: () => Promise.resolve({} as Ast), - toExpressionString: () => 'my | expression', + documentToExpression: () => + Promise.resolve({ + type: 'expression', + chain: [ + { type: 'function', function: 'my', arguments: {} }, + { type: 'function', function: 'expression', arguments: {} }, + ], + }), }, input ); @@ -208,8 +226,14 @@ describe('embeddable', () => { indexPatternService: {} as IndexPatternsContract, editable: true, getTrigger, - documentToExpression: () => Promise.resolve({} as Ast), - toExpressionString: () => 'my | expression', + documentToExpression: () => + Promise.resolve({ + type: 'expression', + chain: [ + { type: 'function', function: 'my', arguments: {} }, + { type: 'function', function: 'expression', arguments: {} }, + ], + }), }, input ); @@ -237,8 +261,14 @@ describe('embeddable', () => { indexPatternService: {} as IndexPatternsContract, editable: true, getTrigger, - documentToExpression: () => Promise.resolve({} as Ast), - toExpressionString: () => 'my | expression', + documentToExpression: () => + Promise.resolve({ + type: 'expression', + chain: [ + { type: 'function', function: 'my', arguments: {} }, + { type: 'function', function: 'expression', arguments: {} }, + ], + }), }, { id: '123' } as LensEmbeddableInput ); @@ -270,8 +300,14 @@ describe('embeddable', () => { indexPatternService: {} as IndexPatternsContract, editable: true, getTrigger, - documentToExpression: () => Promise.resolve({} as Ast), - toExpressionString: () => 'my | expression', + documentToExpression: () => + Promise.resolve({ + type: 'expression', + chain: [ + { type: 'function', function: 'my', arguments: {} }, + { type: 'function', function: 'expression', arguments: {} }, + ], + }), }, { id: '123', timeRange, query, filters } as LensEmbeddableInput ); @@ -311,8 +347,14 @@ describe('embeddable', () => { indexPatternService: {} as IndexPatternsContract, editable: true, getTrigger, - documentToExpression: () => Promise.resolve({} as Ast), - toExpressionString: () => 'my | expression', + documentToExpression: () => + Promise.resolve({ + type: 'expression', + chain: [ + { type: 'function', function: 'my', arguments: {} }, + { type: 'function', function: 'expression', arguments: {} }, + ], + }), }, { id: '123', timeRange, query, filters } as LensEmbeddableInput ); diff --git a/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.tsx b/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.tsx index 16b19ca0af849..1297c1da6e1b6 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable.tsx @@ -18,7 +18,7 @@ import { import { ExecutionContextSearch } from 'src/plugins/expressions'; import { Subscription } from 'rxjs'; -import { Ast } from '@kbn/interpreter/common'; +import { toExpression, Ast } from '@kbn/interpreter/common'; import { ExpressionRendererEvent, ReactExpressionRendererType, @@ -59,7 +59,6 @@ export interface LensEmbeddableOutput extends EmbeddableOutput { export interface LensEmbeddableDeps { attributeService: LensAttributeService; documentToExpression: (doc: Document) => Promise; - toExpressionString: (astObj: Ast, type?: string) => string; editable: boolean; indexPatternService: IndexPatternsContract; expressionRenderer: ReactExpressionRendererType; @@ -135,7 +134,7 @@ export class Embeddable savedObjectId: (input as LensByReferenceInput)?.savedObjectId, }; const expression = await this.deps.documentToExpression(this.savedVis); - this.expression = expression ? this.deps.toExpressionString(expression) : null; + this.expression = expression ? toExpression(expression) : null; await this.initializeOutput(); this.isInitialized = true; if (this.domNode) { diff --git a/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable_factory.ts b/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable_factory.ts index 8771d1ebaddb1..35d120e5c4f45 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable_factory.ts +++ b/x-pack/plugins/lens/public/editor_frame_service/embeddable/embeddable_factory.ts @@ -7,7 +7,7 @@ import { Capabilities, HttpSetup } from 'kibana/public'; import { i18n } from '@kbn/i18n'; import { RecursiveReadonly } from '@kbn/utility-types'; -import { toExpression, Ast } from '@kbn/interpreter/target/common'; +import { Ast } from '@kbn/interpreter/target/common'; import { IndexPatternsContract, TimefilterContract, @@ -17,7 +17,7 @@ import { EmbeddableFactoryDefinition, IContainer, } from '../../../../../../src/plugins/embeddable/public'; -import { Embeddable, LensByReferenceInput, LensEmbeddableInput } from './embeddable'; +import { LensByReferenceInput, LensEmbeddableInput } from './embeddable'; import { DOC_TYPE } from '../../persistence'; import { UiActionsStart } from '../../../../../../src/plugins/ui_actions/public'; import { Document } from '../../persistence/saved_object_store'; @@ -83,6 +83,8 @@ export class EmbeddableFactory implements EmbeddableFactoryDefinition { indexPatternService, } = await this.getStartServices(); + const { Embeddable } = await import('../../async_services'); + return new Embeddable( { attributeService, @@ -93,7 +95,6 @@ export class EmbeddableFactory implements EmbeddableFactoryDefinition { basePath: coreHttp.basePath, getTrigger: uiActions?.getTrigger, documentToExpression, - toExpressionString: toExpression, }, input, parent diff --git a/x-pack/plugins/lens/public/editor_frame_service/embeddable/index.ts b/x-pack/plugins/lens/public/editor_frame_service/embeddable/index.ts new file mode 100644 index 0000000000000..460341365094e --- /dev/null +++ b/x-pack/plugins/lens/public/editor_frame_service/embeddable/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export * from './embeddable'; diff --git a/x-pack/plugins/lens/public/editor_frame_service/service.tsx b/x-pack/plugins/lens/public/editor_frame_service/service.tsx index 8892217f5d51d..e6d7f78f5ad07 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/service.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/service.tsx @@ -25,10 +25,8 @@ import { Document } from '../persistence/saved_object_store'; import { mergeTables } from './merge_tables'; import { formatColumn } from './format_column'; import { EmbeddableFactory, LensEmbeddableStartServices } from './embeddable/embeddable_factory'; -import { getActiveDatasourceIdFromDoc } from './editor_frame/state_management'; import { UiActionsStart } from '../../../../../src/plugins/ui_actions/public'; import { DashboardStart } from '../../../../../src/plugins/dashboard/public'; -import { persistedStateToExpression } from './editor_frame/state_helpers'; import { LensAttributeService } from '../lens_attribute_service'; export interface EditorFrameSetupPlugins { @@ -77,6 +75,8 @@ export class EditorFrameService { collectAsyncDefinitions(this.visualizations), ]); + const { persistedStateToExpression } = await import('../async_services'); + return await persistedStateToExpression(resolvedDatasources, resolvedVisualizations, doc); } @@ -133,7 +133,7 @@ export class EditorFrameService { const firstDatasourceId = Object.keys(resolvedDatasources)[0]; const firstVisualizationId = Object.keys(resolvedVisualizations)[0]; - const { EditorFrame } = await import('../async_services'); + const { EditorFrame, getActiveDatasourceIdFromDoc } = await import('../async_services'); render( diff --git a/x-pack/plugins/lists/README.md b/x-pack/plugins/lists/README.md index dac6e8bb78fa5..02be757303417 100644 --- a/x-pack/plugins/lists/README.md +++ b/x-pack/plugins/lists/README.md @@ -113,12 +113,6 @@ You should see the new exception list created like so: ```sh { - "_tags": [ - "endpoint", - "process", - "malware", - "os:linux" - ], "created_at": "2020-05-28T19:16:31.052Z", "created_by": "yo", "description": "This is a sample endpoint type exception", @@ -141,12 +135,6 @@ And you can attach exception list items like so: ```ts { - "_tags": [ - "endpoint", - "process", - "malware", - "os:linux" - ], "comments": [], "created_at": "2020-05-28T19:17:21.099Z", "created_by": "yo", @@ -173,6 +161,7 @@ And you can attach exception list items like so: "list_id": "endpoint_list", "name": "Sample Endpoint Exception List", "namespace_type": "single", + "os_types": ["linux"], "tags": [ "user added string for a tag", "malware" @@ -222,12 +211,6 @@ or for finding exception lists: { "data": [ { - "_tags": [ - "endpoint", - "process", - "malware", - "os:linux" - ], "created_at": "2020-05-28T19:16:31.052Z", "created_by": "yo", "description": "This is a sample endpoint type exception", @@ -235,6 +218,7 @@ or for finding exception lists: "list_id": "endpoint_list", "name": "Sample Endpoint Exception List", "namespace_type": "single", + "os_types": ["linux"], "tags": [ "user added string for a tag", "malware" diff --git a/x-pack/plugins/lists/common/constants.mock.ts b/x-pack/plugins/lists/common/constants.mock.ts index 46ed524ff33e3..c712af83dd9b1 100644 --- a/x-pack/plugins/lists/common/constants.mock.ts +++ b/x-pack/plugins/lists/common/constants.mock.ts @@ -5,6 +5,7 @@ */ import moment from 'moment'; +import { OsTypeArray } from './schemas/common'; import { EntriesArray } from './schemas/types'; import { EndpointEntriesArray } from './schemas/types/endpoint'; export const DATE_NOW = '2020-04-20T15:25:31.830Z'; @@ -68,7 +69,7 @@ export const ENDPOINT_ENTRIES: EndpointEntriesArray = [ { field: 'some.not.nested.field', operator: 'included', type: 'match', value: 'some value' }, ]; export const ITEM_TYPE = 'simple'; -export const _TAGS = []; +export const OS_TYPES: OsTypeArray = ['windows']; export const TAGS = []; export const COMMENTS = []; export const FILTER = 'name:Nicolas Bourbaki'; diff --git a/x-pack/plugins/lists/common/schemas/common/schemas.test.ts b/x-pack/plugins/lists/common/schemas/common/schemas.test.ts index ec3871b673888..04bdf037c556e 100644 --- a/x-pack/plugins/lists/common/schemas/common/schemas.test.ts +++ b/x-pack/plugins/lists/common/schemas/common/schemas.test.ts @@ -27,6 +27,8 @@ import { esDataTypeUnion, exceptionListType, operator, + osType, + osTypeArrayOrUndefined, type, } from './schemas'; @@ -379,4 +381,35 @@ describe('Common schemas', () => { expect(message.schema).toEqual({}); }); }); + + describe('osType', () => { + test('it will validate a correct osType', () => { + const payload = 'windows'; + const decoded = osType.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it will fail to validate an incorrect osType', () => { + const payload = 'foo'; + const decoded = osType.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "foo" supplied to ""linux" | "macos" | "windows""', + ]); + expect(message.schema).toEqual({}); + }); + + test('it will default to an empty array when osTypeArrayOrUndefined is used', () => { + const payload = undefined; + const decoded = osTypeArrayOrUndefined.decode(payload); + const checked = exactCheck(payload, decoded); + const message = pipe(checked, foldLeftRight); + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual([]); + }); + }); }); diff --git a/x-pack/plugins/lists/common/schemas/common/schemas.ts b/x-pack/plugins/lists/common/schemas/common/schemas.ts index 37da5fbcd1a1b..7497b81fbe91c 100644 --- a/x-pack/plugins/lists/common/schemas/common/schemas.ts +++ b/x-pack/plugins/lists/common/schemas/common/schemas.ts @@ -9,7 +9,7 @@ import * as t from 'io-ts'; import { DefaultNamespace } from '../types/default_namespace'; -import { DefaultStringArray, NonEmptyString } from '../../shared_imports'; +import { DefaultArray, DefaultStringArray, NonEmptyString } from '../../shared_imports'; export const name = t.string; export type Name = t.TypeOf; @@ -211,11 +211,6 @@ export type Tags = t.TypeOf; export const tagsOrUndefined = t.union([tags, t.undefined]); export type TagsOrUndefined = t.TypeOf; -export const _tags = DefaultStringArray; -export type _Tags = t.TypeOf; -export const _tagsOrUndefined = t.union([_tags, t.undefined]); -export type _TagsOrUndefined = t.TypeOf; - export const exceptionListType = t.keyof({ detection: null, endpoint: null }); export const exceptionListTypeOrUndefined = t.union([exceptionListType, t.undefined]); export type ExceptionListType = t.TypeOf; @@ -317,3 +312,16 @@ export type Immutable = t.TypeOf; export const immutableOrUndefined = t.union([immutable, t.undefined]); export type ImmutableOrUndefined = t.TypeOf; + +export const osType = t.keyof({ + linux: null, + macos: null, + windows: null, +}); +export type OsType = t.TypeOf; + +export const osTypeArray = DefaultArray(osType); +export type OsTypeArray = t.TypeOf; + +export const osTypeArrayOrUndefined = t.union([osTypeArray, t.undefined]); +export type OsTypeArrayOrUndefined = t.OutputOf; diff --git a/x-pack/plugins/lists/common/schemas/request/create_endpoint_list_item_schema.mock.ts b/x-pack/plugins/lists/common/schemas/request/create_endpoint_list_item_schema.mock.ts index 529e173618f15..f292b7c5bc945 100644 --- a/x-pack/plugins/lists/common/schemas/request/create_endpoint_list_item_schema.mock.ts +++ b/x-pack/plugins/lists/common/schemas/request/create_endpoint_list_item_schema.mock.ts @@ -11,20 +11,20 @@ import { ITEM_TYPE, META, NAME, + OS_TYPES, TAGS, - _TAGS, } from '../../constants.mock'; import { CreateEndpointListItemSchema } from './create_endpoint_list_item_schema'; export const getCreateEndpointListItemSchemaMock = (): CreateEndpointListItemSchema => ({ - _tags: _TAGS, comments: COMMENTS, description: DESCRIPTION, entries: ENDPOINT_ENTRIES, item_id: undefined, meta: META, name: NAME, + os_types: OS_TYPES, tags: TAGS, type: ITEM_TYPE, }); diff --git a/x-pack/plugins/lists/common/schemas/request/create_endpoint_list_item_schema.test.ts b/x-pack/plugins/lists/common/schemas/request/create_endpoint_list_item_schema.test.ts index 624de2fb30d17..afb0454a79667 100644 --- a/x-pack/plugins/lists/common/schemas/request/create_endpoint_list_item_schema.test.ts +++ b/x-pack/plugins/lists/common/schemas/request/create_endpoint_list_item_schema.test.ts @@ -174,19 +174,6 @@ describe('create_endpoint_list_item_schema', () => { expect(message.schema).toEqual(outputPayload); }); - test('it should pass validation when supplied an undefined for "_tags" but return an array and generate a correct body not counting the auto generated uuid', () => { - const inputPayload = getCreateEndpointListItemSchemaMock(); - const outputPayload = getCreateEndpointListItemSchemaMock(); - delete inputPayload._tags; - outputPayload._tags = []; - const decoded = createEndpointListItemSchema.decode(inputPayload); - const checked = exactCheck(inputPayload, decoded); - const message = pipe(checked, foldLeftRight); - delete (message.schema as CreateEndpointListItemSchema).item_id; - expect(getPaths(left(message.errors))).toEqual([]); - expect(message.schema).toEqual(outputPayload); - }); - test('it should pass validation when supplied an undefined for "item_id" and auto generate a uuid', () => { const inputPayload = getCreateEndpointListItemSchemaMock(); delete inputPayload.item_id; diff --git a/x-pack/plugins/lists/common/schemas/request/create_endpoint_list_item_schema.ts b/x-pack/plugins/lists/common/schemas/request/create_endpoint_list_item_schema.ts index d1fc167f5a92b..611d9a83befc7 100644 --- a/x-pack/plugins/lists/common/schemas/request/create_endpoint_list_item_schema.ts +++ b/x-pack/plugins/lists/common/schemas/request/create_endpoint_list_item_schema.ts @@ -8,13 +8,13 @@ import * as t from 'io-ts'; import { ItemId, + OsTypeArray, Tags, - _Tags, - _tags, description, exceptionListItemType, meta, name, + osTypeArrayOrUndefined, tags, } from '../common/schemas'; import { RequiredKeepUndefined } from '../../types'; @@ -34,10 +34,10 @@ export const createEndpointListItemSchema = t.intersection([ ), t.exact( t.partial({ - _tags, // defaults to empty array if not set during decode comments: DefaultCreateCommentsArray, // defaults to empty array if not set during decode item_id: DefaultUuid, // defaults to GUID (uuid v4) if not set during decode meta, // defaults to undefined if not set during decode + os_types: osTypeArrayOrUndefined, // defaults to empty array if not set during decode tags, // defaults to empty array if not set during decode }) ), @@ -48,11 +48,11 @@ export type CreateEndpointListItemSchema = t.OutputOf>, - '_tags' | 'tags' | 'item_id' | 'entries' | 'comments' + 'tags' | 'item_id' | 'entries' | 'comments' | 'os_types' > & { - _tags: _Tags; comments: CreateCommentsArray; tags: Tags; item_id: ItemId; entries: EntriesArray; + os_types: OsTypeArray; }; diff --git a/x-pack/plugins/lists/common/schemas/request/create_exception_list_item_schema.mock.ts b/x-pack/plugins/lists/common/schemas/request/create_exception_list_item_schema.mock.ts index da22e33dc7b52..9a55e88a7a8fa 100644 --- a/x-pack/plugins/lists/common/schemas/request/create_exception_list_item_schema.mock.ts +++ b/x-pack/plugins/lists/common/schemas/request/create_exception_list_item_schema.mock.ts @@ -14,14 +14,13 @@ import { META, NAME, NAMESPACE_TYPE, + OS_TYPES, TAGS, - _TAGS, } from '../../constants.mock'; import { CreateExceptionListItemSchema } from './create_exception_list_item_schema'; export const getCreateExceptionListItemSchemaMock = (): CreateExceptionListItemSchema => ({ - _tags: _TAGS, comments: COMMENTS, description: DESCRIPTION, entries: ENTRIES, @@ -30,6 +29,7 @@ export const getCreateExceptionListItemSchemaMock = (): CreateExceptionListItemS meta: META, name: NAME, namespace_type: NAMESPACE_TYPE, + os_types: OS_TYPES, tags: TAGS, type: ITEM_TYPE, }); @@ -43,6 +43,7 @@ export const getCreateExceptionListItemMinimalSchemaMock = (): CreateExceptionLi item_id: ITEM_ID, list_id: LIST_ID, name: NAME, + os_types: OS_TYPES, type: ITEM_TYPE, }); @@ -54,5 +55,6 @@ export const getCreateExceptionListItemMinimalSchemaMockWithoutId = (): CreateEx entries: ENTRIES, list_id: LIST_ID, name: NAME, + os_types: OS_TYPES, type: ITEM_TYPE, }); diff --git a/x-pack/plugins/lists/common/schemas/request/create_exception_list_item_schema.test.ts b/x-pack/plugins/lists/common/schemas/request/create_exception_list_item_schema.test.ts index 4a4c3972dc1e3..e83b2e3010785 100644 --- a/x-pack/plugins/lists/common/schemas/request/create_exception_list_item_schema.test.ts +++ b/x-pack/plugins/lists/common/schemas/request/create_exception_list_item_schema.test.ts @@ -176,19 +176,6 @@ describe('create_exception_list_item_schema', () => { expect(message.schema).toEqual(outputPayload); }); - test('it should pass validation when supplied an undefined for "_tags" but return an array and generate a correct body not counting the auto generated uuid', () => { - const inputPayload = getCreateExceptionListItemSchemaMock(); - const outputPayload = getCreateExceptionListItemSchemaMock(); - delete inputPayload._tags; - outputPayload._tags = []; - const decoded = createExceptionListItemSchema.decode(inputPayload); - const checked = exactCheck(inputPayload, decoded); - const message = pipe(checked, foldLeftRight); - delete (message.schema as CreateExceptionListItemSchema).item_id; - expect(getPaths(left(message.errors))).toEqual([]); - expect(message.schema).toEqual(outputPayload); - }); - test('it should pass validation when supplied an undefined for "item_id" and auto generate a uuid', () => { const inputPayload = getCreateExceptionListItemSchemaMock(); delete inputPayload.item_id; diff --git a/x-pack/plugins/lists/common/schemas/request/create_exception_list_item_schema.ts b/x-pack/plugins/lists/common/schemas/request/create_exception_list_item_schema.ts index fd3390721d41e..642a6c549e7fa 100644 --- a/x-pack/plugins/lists/common/schemas/request/create_exception_list_item_schema.ts +++ b/x-pack/plugins/lists/common/schemas/request/create_exception_list_item_schema.ts @@ -8,15 +8,15 @@ import * as t from 'io-ts'; import { ItemId, + OsTypeArray, Tags, - _Tags, - _tags, description, exceptionListItemType, list_id, meta, name, namespace_type, + osTypeArrayOrUndefined, tags, } from '../common/schemas'; import { RequiredKeepUndefined } from '../../types'; @@ -41,11 +41,11 @@ export const createExceptionListItemSchema = t.intersection([ ), t.exact( t.partial({ - _tags, // defaults to empty array if not set during decode comments: DefaultCreateCommentsArray, // defaults to empty array if not set during decode item_id: DefaultUuid, // defaults to GUID (uuid v4) if not set during decode meta, // defaults to undefined if not set during decode namespace_type, // defaults to 'single' if not set during decode + os_types: osTypeArrayOrUndefined, // defaults to empty array if not set during decode tags, // defaults to empty array if not set during decode }) ), @@ -56,12 +56,12 @@ export type CreateExceptionListItemSchema = t.OutputOf>, - '_tags' | 'tags' | 'item_id' | 'entries' | 'namespace_type' | 'comments' + 'tags' | 'item_id' | 'entries' | 'namespace_type' | 'comments' > & { - _tags: _Tags; comments: CreateCommentsArray; tags: Tags; item_id: ItemId; entries: EntriesArray; namespace_type: NamespaceType; + os_types: OsTypeArray; }; diff --git a/x-pack/plugins/lists/common/schemas/request/create_exception_list_schema.mock.ts b/x-pack/plugins/lists/common/schemas/request/create_exception_list_schema.mock.ts index f8431fcce1bf7..3150cb9975f21 100644 --- a/x-pack/plugins/lists/common/schemas/request/create_exception_list_schema.mock.ts +++ b/x-pack/plugins/lists/common/schemas/request/create_exception_list_schema.mock.ts @@ -17,12 +17,12 @@ import { import { CreateExceptionListSchema } from './create_exception_list_schema'; export const getCreateExceptionListSchemaMock = (): CreateExceptionListSchema => ({ - _tags: [], description: DESCRIPTION, list_id: undefined, meta: META, name: NAME, namespace_type: NAMESPACE_TYPE, + os_types: [], tags: [], type: ENDPOINT_TYPE, version: VERSION, diff --git a/x-pack/plugins/lists/common/schemas/request/create_exception_list_schema.test.ts b/x-pack/plugins/lists/common/schemas/request/create_exception_list_schema.test.ts index c9e2aa37a132b..6bcd3bc15a975 100644 --- a/x-pack/plugins/lists/common/schemas/request/create_exception_list_schema.test.ts +++ b/x-pack/plugins/lists/common/schemas/request/create_exception_list_schema.test.ts @@ -50,19 +50,6 @@ describe('create_exception_list_schema', () => { expect(message.schema).toEqual(outputPayload); }); - test('it should accept an undefined for "_tags" but return an array and generate a correct body not counting the uuid', () => { - const inputPayload = getCreateExceptionListSchemaMock(); - const outputPayload = getCreateExceptionListSchemaMock(); - delete inputPayload._tags; - outputPayload._tags = []; - const decoded = createExceptionListSchema.decode(inputPayload); - const checked = exactCheck(inputPayload, decoded); - const message = pipe(checked, foldLeftRight); - delete (message.schema as CreateExceptionListSchema).list_id; - expect(getPaths(left(message.errors))).toEqual([]); - expect(message.schema).toEqual(outputPayload); - }); - test('it should accept an undefined for "list_id" and auto generate a uuid', () => { const inputPayload = getCreateExceptionListSchemaMock(); delete inputPayload.list_id; diff --git a/x-pack/plugins/lists/common/schemas/request/create_exception_list_schema.ts b/x-pack/plugins/lists/common/schemas/request/create_exception_list_schema.ts index ffec974602714..4eae11081454c 100644 --- a/x-pack/plugins/lists/common/schemas/request/create_exception_list_schema.ts +++ b/x-pack/plugins/lists/common/schemas/request/create_exception_list_schema.ts @@ -8,14 +8,14 @@ import * as t from 'io-ts'; import { ListId, + OsTypeArray, Tags, - _Tags, - _tags, description, exceptionListType, meta, name, namespace_type, + osTypeArrayOrUndefined, tags, } from '../common/schemas'; import { RequiredKeepUndefined } from '../../types'; @@ -36,10 +36,10 @@ export const createExceptionListSchema = t.intersection([ ), t.exact( t.partial({ - _tags, // defaults to empty array if not set during decode list_id: DefaultUuid, // defaults to a GUID (UUID v4) string if not set during decode meta, // defaults to undefined if not set during decode namespace_type, // defaults to 'single' if not set during decode + os_types: osTypeArrayOrUndefined, // defaults to empty array if not set during decode tags, // defaults to empty array if not set during decode version: DefaultVersionNumber, // defaults to numerical 1 if not set during decode }) @@ -51,11 +51,11 @@ export type CreateExceptionListSchema = t.OutputOf>, - '_tags' | 'tags' | 'list_id' | 'namespace_type' + 'tags' | 'list_id' | 'namespace_type' | 'os_types' > & { - _tags: _Tags; tags: Tags; list_id: ListId; namespace_type: NamespaceType; + os_types: OsTypeArray; version: DefaultVersionNumberDecoded; }; diff --git a/x-pack/plugins/lists/common/schemas/request/update_endpoint_list_item_schema.mock.ts b/x-pack/plugins/lists/common/schemas/request/update_endpoint_list_item_schema.mock.ts index 0847389dac922..8c999332e8893 100644 --- a/x-pack/plugins/lists/common/schemas/request/update_endpoint_list_item_schema.mock.ts +++ b/x-pack/plugins/lists/common/schemas/request/update_endpoint_list_item_schema.mock.ts @@ -13,14 +13,13 @@ import { LIST_ITEM_ID, META, NAME, + OS_TYPES, TAGS, - _TAGS, } from '../../constants.mock'; import { UpdateEndpointListItemSchema } from './update_endpoint_list_item_schema'; export const getUpdateEndpointListItemSchemaMock = (): UpdateEndpointListItemSchema => ({ - _tags: _TAGS, _version: undefined, comments: COMMENTS, description: DESCRIPTION, @@ -29,6 +28,7 @@ export const getUpdateEndpointListItemSchemaMock = (): UpdateEndpointListItemSch item_id: LIST_ITEM_ID, meta: META, name: NAME, + os_types: OS_TYPES, tags: TAGS, type: ITEM_TYPE, }); diff --git a/x-pack/plugins/lists/common/schemas/request/update_endpoint_list_item_schema.test.ts b/x-pack/plugins/lists/common/schemas/request/update_endpoint_list_item_schema.test.ts index 671e38ceb5266..c7be8b78d54a6 100644 --- a/x-pack/plugins/lists/common/schemas/request/update_endpoint_list_item_schema.test.ts +++ b/x-pack/plugins/lists/common/schemas/request/update_endpoint_list_item_schema.test.ts @@ -127,18 +127,6 @@ describe('update_endpoint_list_item_schema', () => { expect(message.schema).toEqual(outputPayload); }); - test('it should accept an undefined for "_tags" but return an array', () => { - const inputPayload = getUpdateEndpointListItemSchemaMock(); - const outputPayload = getUpdateEndpointListItemSchemaMock(); - delete inputPayload._tags; - outputPayload._tags = []; - const decoded = updateEndpointListItemSchema.decode(inputPayload); - const checked = exactCheck(inputPayload, decoded); - const message = pipe(checked, foldLeftRight); - expect(getPaths(left(message.errors))).toEqual([]); - expect(message.schema).toEqual(outputPayload); - }); - test('it should not allow an extra key to be sent in', () => { const payload: UpdateEndpointListItemSchema & { extraKey?: string; diff --git a/x-pack/plugins/lists/common/schemas/request/update_endpoint_list_item_schema.ts b/x-pack/plugins/lists/common/schemas/request/update_endpoint_list_item_schema.ts index 6ce5ad7858b78..f6ced91cd4010 100644 --- a/x-pack/plugins/lists/common/schemas/request/update_endpoint_list_item_schema.ts +++ b/x-pack/plugins/lists/common/schemas/request/update_endpoint_list_item_schema.ts @@ -7,15 +7,15 @@ import * as t from 'io-ts'; import { + OsTypeArray, Tags, - _Tags, - _tags, _version, description, exceptionListItemType, id, meta, name, + osTypeArrayOrUndefined, tags, } from '../common/schemas'; import { RequiredKeepUndefined } from '../../types'; @@ -37,12 +37,12 @@ export const updateEndpointListItemSchema = t.intersection([ ), t.exact( t.partial({ - _tags, // defaults to empty array if not set during decode _version, // defaults to undefined if not set during decode comments: DefaultUpdateCommentsArray, // defaults to empty array if not set during decode id, // defaults to undefined if not set during decode item_id: t.union([t.string, t.undefined]), meta, // defaults to undefined if not set during decode + os_types: osTypeArrayOrUndefined, // defaults to empty array if not set during decode tags, // defaults to empty array if not set during decode }) ), @@ -53,10 +53,10 @@ export type UpdateEndpointListItemSchema = t.OutputOf>, - '_tags' | 'tags' | 'entries' | 'comments' + 'tags' | 'entries' | 'comments' > & { - _tags: _Tags; comments: UpdateCommentsArray; tags: Tags; entries: EntriesArray; + os_types: OsTypeArray; }; diff --git a/x-pack/plugins/lists/common/schemas/request/update_exception_list_item_schema.mock.ts b/x-pack/plugins/lists/common/schemas/request/update_exception_list_item_schema.mock.ts index 4673c0fe7629d..e65b37b48545e 100644 --- a/x-pack/plugins/lists/common/schemas/request/update_exception_list_item_schema.mock.ts +++ b/x-pack/plugins/lists/common/schemas/request/update_exception_list_item_schema.mock.ts @@ -15,14 +15,13 @@ import { META, NAME, NAMESPACE_TYPE, + OS_TYPES, TAGS, - _TAGS, } from '../../constants.mock'; import { UpdateExceptionListItemSchema } from './update_exception_list_item_schema'; export const getUpdateExceptionListItemSchemaMock = (): UpdateExceptionListItemSchema => ({ - _tags: _TAGS, _version: undefined, comments: COMMENTS, description: DESCRIPTION, @@ -32,6 +31,7 @@ export const getUpdateExceptionListItemSchemaMock = (): UpdateExceptionListItemS meta: META, name: NAME, namespace_type: NAMESPACE_TYPE, + os_types: ['linux'], tags: TAGS, type: ITEM_TYPE, }); @@ -45,5 +45,6 @@ export const getUpdateMinimalExceptionListItemSchemaMock = (): UpdateExceptionLi entries: ENTRIES, item_id: ITEM_ID, name: NAME, + os_types: OS_TYPES, type: ITEM_TYPE, }); diff --git a/x-pack/plugins/lists/common/schemas/request/update_exception_list_item_schema.test.ts b/x-pack/plugins/lists/common/schemas/request/update_exception_list_item_schema.test.ts index da320a4983de3..387c29ad2d190 100644 --- a/x-pack/plugins/lists/common/schemas/request/update_exception_list_item_schema.test.ts +++ b/x-pack/plugins/lists/common/schemas/request/update_exception_list_item_schema.test.ts @@ -139,18 +139,6 @@ describe('update_exception_list_item_schema', () => { expect(message.schema).toEqual(outputPayload); }); - test('it should accept an undefined for "_tags" but return an array', () => { - const inputPayload = getUpdateExceptionListItemSchemaMock(); - const outputPayload = getUpdateExceptionListItemSchemaMock(); - delete inputPayload._tags; - outputPayload._tags = []; - const decoded = updateExceptionListItemSchema.decode(inputPayload); - const checked = exactCheck(inputPayload, decoded); - const message = pipe(checked, foldLeftRight); - expect(getPaths(left(message.errors))).toEqual([]); - expect(message.schema).toEqual(outputPayload); - }); - test('it should accept an undefined for "item_id" and generate a correct body not counting the uuid', () => { const inputPayload = getUpdateExceptionListItemSchemaMock(); delete inputPayload.item_id; diff --git a/x-pack/plugins/lists/common/schemas/request/update_exception_list_item_schema.ts b/x-pack/plugins/lists/common/schemas/request/update_exception_list_item_schema.ts index 659dde0b5b533..14cac2bb93fe0 100644 --- a/x-pack/plugins/lists/common/schemas/request/update_exception_list_item_schema.ts +++ b/x-pack/plugins/lists/common/schemas/request/update_exception_list_item_schema.ts @@ -7,9 +7,8 @@ import * as t from 'io-ts'; import { + OsTypeArray, Tags, - _Tags, - _tags, _version, description, exceptionListItemType, @@ -17,6 +16,7 @@ import { meta, name, namespace_type, + osTypeArrayOrUndefined, tags, } from '../common/schemas'; import { RequiredKeepUndefined } from '../../types'; @@ -39,13 +39,13 @@ export const updateExceptionListItemSchema = t.intersection([ ), t.exact( t.partial({ - _tags, // defaults to empty array if not set during decode _version, // defaults to undefined if not set during decode comments: DefaultUpdateCommentsArray, // defaults to empty array if not set during decode id, // defaults to undefined if not set during decode item_id: t.union([t.string, t.undefined]), meta, // defaults to undefined if not set during decode namespace_type, // defaults to 'single' if not set during decode + os_types: osTypeArrayOrUndefined, // defaults to empty array if not set during decode tags, // defaults to empty array if not set during decode }) ), @@ -56,11 +56,11 @@ export type UpdateExceptionListItemSchema = t.OutputOf>, - '_tags' | 'tags' | 'entries' | 'namespace_type' | 'comments' + 'tags' | 'entries' | 'namespace_type' | 'comments' | 'os_types' > & { - _tags: _Tags; comments: UpdateCommentsArray; tags: Tags; entries: EntriesArray; namespace_type: NamespaceType; + os_types: OsTypeArray; }; diff --git a/x-pack/plugins/lists/common/schemas/request/update_exception_list_schema.mock.ts b/x-pack/plugins/lists/common/schemas/request/update_exception_list_schema.mock.ts index b7dc2d9e0c948..fdefa6fe9b2c5 100644 --- a/x-pack/plugins/lists/common/schemas/request/update_exception_list_schema.mock.ts +++ b/x-pack/plugins/lists/common/schemas/request/update_exception_list_schema.mock.ts @@ -4,12 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import { DESCRIPTION, ID, LIST_ID, META, NAME, NAMESPACE_TYPE, _TAGS } from '../../constants.mock'; +import { DESCRIPTION, ID, LIST_ID, META, NAME, NAMESPACE_TYPE } from '../../constants.mock'; import { UpdateExceptionListSchema } from './update_exception_list_schema'; export const getUpdateExceptionListSchemaMock = (): UpdateExceptionListSchema => ({ - _tags: _TAGS, _version: undefined, description: DESCRIPTION, id: ID, @@ -17,6 +16,7 @@ export const getUpdateExceptionListSchemaMock = (): UpdateExceptionListSchema => meta: META, name: NAME, namespace_type: NAMESPACE_TYPE, + os_types: [], tags: ['malware'], type: 'endpoint', }); diff --git a/x-pack/plugins/lists/common/schemas/request/update_exception_list_schema.test.ts b/x-pack/plugins/lists/common/schemas/request/update_exception_list_schema.test.ts index 32f114ae34d8e..4afd1aa442aa7 100644 --- a/x-pack/plugins/lists/common/schemas/request/update_exception_list_schema.test.ts +++ b/x-pack/plugins/lists/common/schemas/request/update_exception_list_schema.test.ts @@ -100,18 +100,6 @@ describe('update_exception_list_schema', () => { expect(message.schema).toEqual(outputPayload); }); - test('it should accept an undefined for "_tags" but return an array', () => { - const inputPayload = getUpdateExceptionListSchemaMock(); - const outputPayload = getUpdateExceptionListSchemaMock(); - delete inputPayload._tags; - outputPayload._tags = []; - const decoded = updateExceptionListSchema.decode(inputPayload); - const checked = exactCheck(inputPayload, decoded); - const message = pipe(checked, foldLeftRight); - expect(getPaths(left(message.errors))).toEqual([]); - expect(message.schema).toEqual(outputPayload); - }); - test('it should accept an undefined for "list_id" and generate a correct body not counting the uuid', () => { const inputPayload = getUpdateExceptionListSchemaMock(); delete inputPayload.list_id; diff --git a/x-pack/plugins/lists/common/schemas/request/update_exception_list_schema.ts b/x-pack/plugins/lists/common/schemas/request/update_exception_list_schema.ts index 54e0bbafe4981..37ba21bcfc424 100644 --- a/x-pack/plugins/lists/common/schemas/request/update_exception_list_schema.ts +++ b/x-pack/plugins/lists/common/schemas/request/update_exception_list_schema.ts @@ -7,9 +7,8 @@ import * as t from 'io-ts'; import { + OsTypeArray, Tags, - _Tags, - _tags, _version, description, exceptionListType, @@ -18,6 +17,7 @@ import { meta, name, namespace_type, + osTypeArrayOrUndefined, tags, version, } from '../common/schemas'; @@ -34,12 +34,12 @@ export const updateExceptionListSchema = t.intersection([ ), t.exact( t.partial({ - _tags, // defaults to empty array if not set during decode _version, // defaults to undefined if not set during decode id, // defaults to undefined if not set during decode list_id, // defaults to undefined if not set during decode meta, // defaults to undefined if not set during decode namespace_type, // defaults to 'single' if not set during decode + os_types: osTypeArrayOrUndefined, // defaults to empty array if not set during decode tags, // defaults to empty array if not set during decode version, // defaults to undefined if not set during decode }) @@ -51,9 +51,9 @@ export type UpdateExceptionListSchema = t.OutputOf>, - '_tags | tags | namespace_type' + 'tags | namespace_type' | 'os_types' > & { - _tags: _Tags; tags: Tags; namespace_type: NamespaceType; + os_types: OsTypeArray; }; diff --git a/x-pack/plugins/lists/common/schemas/response/create_endpoint_list_schema.test.ts b/x-pack/plugins/lists/common/schemas/response/create_endpoint_list_schema.test.ts index 380a1e3a4cfd5..ebae189ca7d06 100644 --- a/x-pack/plugins/lists/common/schemas/response/create_endpoint_list_schema.test.ts +++ b/x-pack/plugins/lists/common/schemas/response/create_endpoint_list_schema.test.ts @@ -42,7 +42,7 @@ describe('create_endpoint_list_schema', () => { const message = pipe(checked, foldLeftRight); expect(getPaths(left(message.errors))).toEqual([ - 'invalid keys "_tags,["endpoint","process","malware","os:linux"],_version,created_at,created_by,description,id,immutable,meta,{},name,namespace_type,tags,["user added string for a tag","malware"],tie_breaker_id,type,updated_at,updated_by,version"', + 'invalid keys "_version,created_at,created_by,description,id,immutable,meta,{},name,namespace_type,os_types,["linux"],tags,["user added string for a tag","malware"],tie_breaker_id,type,updated_at,updated_by,version"', ]); expect(message.schema).toEqual({}); }); diff --git a/x-pack/plugins/lists/common/schemas/response/exception_list_item_schema.mock.ts b/x-pack/plugins/lists/common/schemas/response/exception_list_item_schema.mock.ts index 1a8f21a5232f8..c2a751c03ee13 100644 --- a/x-pack/plugins/lists/common/schemas/response/exception_list_item_schema.mock.ts +++ b/x-pack/plugins/lists/common/schemas/response/exception_list_item_schema.mock.ts @@ -15,6 +15,7 @@ import { META, NAME, NAMESPACE_TYPE, + OS_TYPES, TIE_BREAKER, USER, } from '../../constants.mock'; @@ -22,7 +23,6 @@ import { import { ExceptionListItemSchema } from './exception_list_item_schema'; export const getExceptionListItemSchemaMock = (): ExceptionListItemSchema => ({ - _tags: ['endpoint', 'process', 'malware', 'os:linux'], _version: undefined, comments: COMMENTS, created_at: DATE_NOW, @@ -35,6 +35,7 @@ export const getExceptionListItemSchemaMock = (): ExceptionListItemSchema => ({ meta: META, name: NAME, namespace_type: NAMESPACE_TYPE, + os_types: ['linux'], tags: ['user added string for a tag', 'malware'], tie_breaker_id: TIE_BREAKER, type: ITEM_TYPE, @@ -49,7 +50,6 @@ export const getExceptionListItemSchemaMock = (): ExceptionListItemSchema => ({ export const getExceptionListItemResponseMockWithoutAutoGeneratedValues = (): Partial< ExceptionListItemSchema > => ({ - _tags: [], comments: [], created_by: ELASTIC_USER, description: DESCRIPTION, @@ -58,6 +58,7 @@ export const getExceptionListItemResponseMockWithoutAutoGeneratedValues = (): Pa list_id: LIST_ID, name: NAME, namespace_type: 'single', + os_types: OS_TYPES, tags: [], type: ITEM_TYPE, updated_by: ELASTIC_USER, diff --git a/x-pack/plugins/lists/common/schemas/response/exception_list_item_schema.ts b/x-pack/plugins/lists/common/schemas/response/exception_list_item_schema.ts index 65a1a26eaa622..f5ee12e098d17 100644 --- a/x-pack/plugins/lists/common/schemas/response/exception_list_item_schema.ts +++ b/x-pack/plugins/lists/common/schemas/response/exception_list_item_schema.ts @@ -7,7 +7,6 @@ import * as t from 'io-ts'; import { - _tags, _versionOrUndefined, created_at, created_by, @@ -19,6 +18,7 @@ import { metaOrUndefined, name, namespace_type, + osTypeArray, tags, tie_breaker_id, updated_at, @@ -28,7 +28,6 @@ import { commentsArray, entriesArray } from '../types'; export const exceptionListItemSchema = t.exact( t.type({ - _tags, _version: _versionOrUndefined, comments: commentsArray, created_at, @@ -41,6 +40,7 @@ export const exceptionListItemSchema = t.exact( meta: metaOrUndefined, name, namespace_type, + os_types: osTypeArray, tags, tie_breaker_id, type: exceptionListItemType, diff --git a/x-pack/plugins/lists/common/schemas/response/exception_list_schema.mock.ts b/x-pack/plugins/lists/common/schemas/response/exception_list_schema.mock.ts index 6df051e83b97c..7371a9d16fd4d 100644 --- a/x-pack/plugins/lists/common/schemas/response/exception_list_schema.mock.ts +++ b/x-pack/plugins/lists/common/schemas/response/exception_list_schema.mock.ts @@ -28,7 +28,6 @@ import { import { ExceptionListSchema } from './exception_list_schema'; export const getExceptionListSchemaMock = (): ExceptionListSchema => ({ - _tags: ['endpoint', 'process', 'malware', 'os:linux'], _version: _VERSION, created_at: DATE_NOW, created_by: USER, @@ -39,6 +38,7 @@ export const getExceptionListSchemaMock = (): ExceptionListSchema => ({ meta: META, name: 'Sample Endpoint Exception List', namespace_type: 'agnostic', + os_types: ['linux'], tags: ['user added string for a tag', 'malware'], tie_breaker_id: TIE_BREAKER, type: ENDPOINT_TYPE, @@ -63,13 +63,13 @@ export const getTrustedAppsListSchemaMock = (): ExceptionListSchema => { export const getExceptionResponseMockWithoutAutoGeneratedValues = (): Partial< ExceptionListSchema > => ({ - _tags: [], created_by: ELASTIC_USER, description: DESCRIPTION, immutable: IMMUTABLE, list_id: LIST_ID, name: NAME, namespace_type: 'single', + os_types: [], tags: [], type: ENDPOINT_TYPE, updated_by: ELASTIC_USER, diff --git a/x-pack/plugins/lists/common/schemas/response/exception_list_schema.ts b/x-pack/plugins/lists/common/schemas/response/exception_list_schema.ts index 6597cb20508ca..ec03467c64e5c 100644 --- a/x-pack/plugins/lists/common/schemas/response/exception_list_schema.ts +++ b/x-pack/plugins/lists/common/schemas/response/exception_list_schema.ts @@ -7,7 +7,6 @@ import * as t from 'io-ts'; import { - _tags, _versionOrUndefined, created_at, created_by, @@ -19,6 +18,7 @@ import { metaOrUndefined, name, namespace_type, + osTypeArray, tags, tie_breaker_id, updated_at, @@ -28,7 +28,6 @@ import { export const exceptionListSchema = t.exact( t.type({ - _tags, _version: _versionOrUndefined, created_at, created_by, @@ -39,6 +38,7 @@ export const exceptionListSchema = t.exact( meta: metaOrUndefined, name, namespace_type, + os_types: osTypeArray, tags, tie_breaker_id, type: exceptionListType, diff --git a/x-pack/plugins/lists/common/schemas/saved_objects/exceptions_list_so_schema.ts b/x-pack/plugins/lists/common/schemas/saved_objects/exceptions_list_so_schema.ts index f4db77f4ee057..16c43e4611edb 100644 --- a/x-pack/plugins/lists/common/schemas/saved_objects/exceptions_list_so_schema.ts +++ b/x-pack/plugins/lists/common/schemas/saved_objects/exceptions_list_so_schema.ts @@ -8,7 +8,6 @@ import * as t from 'io-ts'; import { commentsArrayOrUndefined, entriesArrayOrUndefined } from '../types'; import { - _tags, created_at, created_by, description, @@ -20,6 +19,7 @@ import { list_type, metaOrUndefined, name, + osTypeArray, tags, tie_breaker_id, updated_by, @@ -31,7 +31,6 @@ import { */ export const exceptionListSoSchema = t.exact( t.type({ - _tags, comments: commentsArrayOrUndefined, created_at, created_by, @@ -43,6 +42,7 @@ export const exceptionListSoSchema = t.exact( list_type, meta: metaOrUndefined, name, + os_types: osTypeArray, tags, tie_breaker_id, type: t.union([exceptionListType, exceptionListItemType]), diff --git a/x-pack/plugins/lists/common/shared_exports.ts b/x-pack/plugins/lists/common/shared_exports.ts index 361837bdef229..ec9358c2cb503 100644 --- a/x-pack/plugins/lists/common/shared_exports.ts +++ b/x-pack/plugins/lists/common/shared_exports.ts @@ -41,6 +41,8 @@ export { namespaceType, ExceptionListType, Type, + osTypeArray, + OsTypeArray, } from './schemas'; export { ENDPOINT_LIST_ID } from './constants'; diff --git a/x-pack/plugins/lists/common/shared_imports.ts b/x-pack/plugins/lists/common/shared_imports.ts index e5302b5cd5d88..9fe37465519ea 100644 --- a/x-pack/plugins/lists/common/shared_imports.ts +++ b/x-pack/plugins/lists/common/shared_imports.ts @@ -6,6 +6,7 @@ export { NonEmptyString, + DefaultArray, DefaultUuid, DefaultStringArray, DefaultVersionNumber, diff --git a/x-pack/plugins/lists/server/routes/create_endpoint_list_item_route.ts b/x-pack/plugins/lists/server/routes/create_endpoint_list_item_route.ts index 7fd07ed5fb8cd..cce4038ff48d6 100644 --- a/x-pack/plugins/lists/server/routes/create_endpoint_list_item_route.ts +++ b/x-pack/plugins/lists/server/routes/create_endpoint_list_item_route.ts @@ -37,13 +37,13 @@ export const createEndpointListItemRoute = (router: IRouter): void => { try { const { name, - _tags, tags, meta, comments, description, entries, item_id: itemId, + os_types: osTypes, type, } = request.body; const exceptionLists = getExceptionListClient(context); @@ -58,13 +58,13 @@ export const createEndpointListItemRoute = (router: IRouter): void => { }); } else { const createdList = await exceptionLists.createEndpointListItem({ - _tags, comments, description, entries, itemId, meta, name, + osTypes, tags, type, }); diff --git a/x-pack/plugins/lists/server/routes/create_exception_list_item_route.ts b/x-pack/plugins/lists/server/routes/create_exception_list_item_route.ts index e51e113239f20..afcb0f99c8a35 100644 --- a/x-pack/plugins/lists/server/routes/create_exception_list_item_route.ts +++ b/x-pack/plugins/lists/server/routes/create_exception_list_item_route.ts @@ -39,7 +39,6 @@ export const createExceptionListItemRoute = (router: IRouter): void => { const { namespace_type: namespaceType, name, - _tags, tags, meta, comments, @@ -47,6 +46,7 @@ export const createExceptionListItemRoute = (router: IRouter): void => { entries, item_id: itemId, list_id: listId, + os_types: osTypes, type, } = request.body; const exceptionLists = getExceptionListClient(context); @@ -87,7 +87,6 @@ export const createExceptionListItemRoute = (router: IRouter): void => { } } const createdList = await exceptionLists.createExceptionListItem({ - _tags, comments, description, entries, @@ -96,6 +95,7 @@ export const createExceptionListItemRoute = (router: IRouter): void => { meta, name, namespaceType, + osTypes, tags, type, }); diff --git a/x-pack/plugins/lists/server/routes/create_exception_list_route.ts b/x-pack/plugins/lists/server/routes/create_exception_list_route.ts index 08db0825e07bd..fd2ba6340009c 100644 --- a/x-pack/plugins/lists/server/routes/create_exception_list_route.ts +++ b/x-pack/plugins/lists/server/routes/create_exception_list_route.ts @@ -36,7 +36,6 @@ export const createExceptionListRoute = (router: IRouter): void => { try { const { name, - _tags, tags, meta, namespace_type: namespaceType, @@ -58,7 +57,6 @@ export const createExceptionListRoute = (router: IRouter): void => { }); } else { const createdList = await exceptionLists.createExceptionList({ - _tags, description, immutable: false, listId, diff --git a/x-pack/plugins/lists/server/routes/update_endpoint_list_item_route.ts b/x-pack/plugins/lists/server/routes/update_endpoint_list_item_route.ts index e0d6a0ffffa6b..8312f2fc87b98 100644 --- a/x-pack/plugins/lists/server/routes/update_endpoint_list_item_route.ts +++ b/x-pack/plugins/lists/server/routes/update_endpoint_list_item_route.ts @@ -38,9 +38,9 @@ export const updateEndpointListItemRoute = (router: IRouter): void => { description, id, name, + os_types: osTypes, meta, type, - _tags, _version, comments, entries, @@ -49,7 +49,6 @@ export const updateEndpointListItemRoute = (router: IRouter): void => { } = request.body; const exceptionLists = getExceptionListClient(context); const exceptionListItem = await exceptionLists.updateEndpointListItem({ - _tags, _version, comments, description, @@ -58,6 +57,7 @@ export const updateEndpointListItemRoute = (router: IRouter): void => { itemId, meta, name, + osTypes, tags, type, }); diff --git a/x-pack/plugins/lists/server/routes/update_exception_list_item_route.ts b/x-pack/plugins/lists/server/routes/update_exception_list_item_route.ts index 745ad0735a174..9ad563724b860 100644 --- a/x-pack/plugins/lists/server/routes/update_exception_list_item_route.ts +++ b/x-pack/plugins/lists/server/routes/update_exception_list_item_route.ts @@ -46,12 +46,12 @@ export const updateExceptionListItemRoute = (router: IRouter): void => { name, meta, type, - _tags, _version, comments, entries, item_id: itemId, namespace_type: namespaceType, + os_types: osTypes, tags, } = request.body; if (id == null && itemId == null) { @@ -62,7 +62,6 @@ export const updateExceptionListItemRoute = (router: IRouter): void => { } else { const exceptionLists = getExceptionListClient(context); const exceptionListItem = await exceptionLists.updateExceptionListItem({ - _tags, _version, comments, description, @@ -72,6 +71,7 @@ export const updateExceptionListItemRoute = (router: IRouter): void => { meta, name, namespaceType, + osTypes, tags, type, }); diff --git a/x-pack/plugins/lists/server/routes/update_exception_list_route.ts b/x-pack/plugins/lists/server/routes/update_exception_list_route.ts index 1903d0f601d1d..47008e3b78fae 100644 --- a/x-pack/plugins/lists/server/routes/update_exception_list_route.ts +++ b/x-pack/plugins/lists/server/routes/update_exception_list_route.ts @@ -35,7 +35,6 @@ export const updateExceptionListRoute = (router: IRouter): void => { const siemResponse = buildSiemResponse(response); try { const { - _tags, _version, tags, name, @@ -44,6 +43,7 @@ export const updateExceptionListRoute = (router: IRouter): void => { list_id: listId, meta, namespace_type: namespaceType, + os_types: osTypes, type, version, } = request.body; @@ -55,7 +55,6 @@ export const updateExceptionListRoute = (router: IRouter): void => { }); } else { const list = await exceptionLists.updateExceptionList({ - _tags, _version, description, id, @@ -63,6 +62,7 @@ export const updateExceptionListRoute = (router: IRouter): void => { meta, name, namespaceType, + osTypes, tags, type, version, diff --git a/x-pack/plugins/lists/server/saved_objects/exception_list.ts b/x-pack/plugins/lists/server/saved_objects/exception_list.ts index f9e408833e069..b3fd2c0eced98 100644 --- a/x-pack/plugins/lists/server/saved_objects/exception_list.ts +++ b/x-pack/plugins/lists/server/saved_objects/exception_list.ts @@ -6,6 +6,8 @@ import { SavedObjectsType } from 'kibana/server'; +import { migrations } from './migrations'; + export const exceptionListSavedObjectType = 'exception-list'; export const exceptionListAgnosticSavedObjectType = 'exception-list-agnostic'; export type SavedObjectType = 'exception-list' | 'exception-list-agnostic'; @@ -149,6 +151,9 @@ export const exceptionListItemMapping: SavedObjectsType['mappings'] = { item_id: { type: 'keyword', }, + os_types: { + type: 'keyword', + }, }, }; @@ -163,6 +168,7 @@ const combinedMappings: SavedObjectsType['mappings'] = { export const exceptionListType: SavedObjectsType = { hidden: false, mappings: combinedMappings, + migrations, name: exceptionListSavedObjectType, namespaceType: 'single', }; @@ -170,6 +176,7 @@ export const exceptionListType: SavedObjectsType = { export const exceptionListAgnosticType: SavedObjectsType = { hidden: false, mappings: combinedMappings, + migrations, name: exceptionListAgnosticSavedObjectType, namespaceType: 'agnostic', }; diff --git a/x-pack/plugins/lists/server/saved_objects/migrations.test.ts b/x-pack/plugins/lists/server/saved_objects/migrations.test.ts new file mode 100644 index 0000000000000..cd7ef0f37d505 --- /dev/null +++ b/x-pack/plugins/lists/server/saved_objects/migrations.test.ts @@ -0,0 +1,132 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { SavedObjectUnsanitizedDoc } from 'kibana/server'; + +import { ENDPOINT_LIST_ID } from '../../common/constants'; + +import { OldExceptionListSoSchema, migrations } from './migrations'; + +describe('7.10.0 lists migrations', () => { + const migration = migrations['7.10.0']; + + test('properly converts .text fields to .caseless', () => { + const doc = { + attributes: { + entries: [ + { + field: 'file.path.text', + operator: 'included', + type: 'match', + value: 'C:\\Windows\\explorer.exe', + }, + { + field: 'host.os.name', + operator: 'included', + type: 'match', + value: 'my-host', + }, + { + entries: [ + { + field: 'process.command_line.text', + operator: 'included', + type: 'match', + value: '/usr/bin/bash', + }, + { + field: 'process.parent.command_line.text', + operator: 'included', + type: 'match', + value: '/usr/bin/bash', + }, + ], + field: 'nested.field', + type: 'nested', + }, + ], + list_id: ENDPOINT_LIST_ID, + }, + id: 'abcd', + migrationVersion: {}, + references: [], + type: 'so-type', + updated_at: '2020-06-09T20:18:20.349Z', + }; + expect( + migration((doc as unknown) as SavedObjectUnsanitizedDoc) + ).toEqual({ + attributes: { + entries: [ + { + field: 'file.path.caseless', + operator: 'included', + type: 'match', + value: 'C:\\Windows\\explorer.exe', + }, + { + field: 'host.os.name', + operator: 'included', + type: 'match', + value: 'my-host', + }, + { + entries: [ + { + field: 'process.command_line.caseless', + operator: 'included', + type: 'match', + value: '/usr/bin/bash', + }, + { + field: 'process.parent.command_line.caseless', + operator: 'included', + type: 'match', + value: '/usr/bin/bash', + }, + ], + field: 'nested.field', + type: 'nested', + }, + ], + list_id: ENDPOINT_LIST_ID, + }, + id: 'abcd', + migrationVersion: {}, + references: [], + type: 'so-type', + updated_at: '2020-06-09T20:18:20.349Z', + }); + }); + + test('properly copies os tags to os_types', () => { + const doc = { + attributes: { + _tags: ['1234', 'os:windows'], + comments: [], + }, + id: 'abcd', + migrationVersion: {}, + references: [], + type: 'so-type', + updated_at: '2020-06-09T20:18:20.349Z', + }; + expect( + migration((doc as unknown) as SavedObjectUnsanitizedDoc) + ).toEqual({ + attributes: { + _tags: ['1234', 'os:windows'], + comments: [], + os_types: ['windows'], + }, + id: 'abcd', + migrationVersion: {}, + references: [], + type: 'so-type', + updated_at: '2020-06-09T20:18:20.349Z', + }); + }); +}); diff --git a/x-pack/plugins/lists/server/saved_objects/migrations.ts b/x-pack/plugins/lists/server/saved_objects/migrations.ts new file mode 100644 index 0000000000000..2e9792cd8eb3c --- /dev/null +++ b/x-pack/plugins/lists/server/saved_objects/migrations.ts @@ -0,0 +1,66 @@ +/* + * 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 * as t from 'io-ts'; +import { SavedObjectSanitizedDoc, SavedObjectUnsanitizedDoc } from 'kibana/server'; + +import { ENDPOINT_LIST_ID, ENDPOINT_TRUSTED_APPS_LIST_ID } from '../../common/constants'; +import { + EntriesArray, + ExceptionListSoSchema, + NonEmptyNestedEntriesArray, + OsTypeArray, + entriesNested, + entry, +} from '../../common/schemas'; + +const entryType = t.union([entry, entriesNested]); +type EntryType = t.TypeOf; + +const migrateEntry = (entryToMigrate: EntryType): EntryType => { + const newEntry = entryToMigrate; + if (entriesNested.is(entryToMigrate) && entriesNested.is(newEntry)) { + newEntry.entries = entryToMigrate.entries.map((nestedEntry) => + migrateEntry(nestedEntry) + ) as NonEmptyNestedEntriesArray; + } + newEntry.field = entryToMigrate.field.replace('.text', '.caseless'); + return newEntry; +}; + +const reduceOsTypes = (acc: string[], tag: string): string[] => { + if (tag.startsWith('os:')) { + // TODO: check OS against type + return [...acc, tag.replace('os:', '')]; + } + return [...acc]; +}; + +export type OldExceptionListSoSchema = ExceptionListSoSchema & { + _tags: string[]; +}; + +export const migrations = { + '7.10.0': ( + doc: SavedObjectUnsanitizedDoc + ): SavedObjectSanitizedDoc => ({ + ...doc, + ...{ + attributes: { + ...doc.attributes, + ...(doc.attributes.entries && + [ENDPOINT_LIST_ID, ENDPOINT_TRUSTED_APPS_LIST_ID].includes(doc.attributes.list_id) && { + entries: (doc.attributes.entries as EntriesArray).map(migrateEntry), + }), + ...(doc.attributes._tags && + doc.attributes._tags.reduce(reduceOsTypes, []).length > 0 && { + os_types: doc.attributes._tags.reduce(reduceOsTypes, []) as OsTypeArray, + }), + }, + }, + references: doc.references || [], + }), +}; diff --git a/x-pack/plugins/lists/server/scripts/exception_lists/new/endpoint_list_item.json b/x-pack/plugins/lists/server/scripts/exception_lists/new/endpoint_list_item.json index 6999441d21941..5e7dee83776bf 100644 --- a/x-pack/plugins/lists/server/scripts/exception_lists/new/endpoint_list_item.json +++ b/x-pack/plugins/lists/server/scripts/exception_lists/new/endpoint_list_item.json @@ -1,10 +1,10 @@ { "item_id": "simple_list_item", - "_tags": ["endpoint", "process", "malware", "os:linux"], "tags": ["user added string for a tag", "malware"], "type": "simple", "description": "This is a sample endpoint type exception", "name": "Sample Endpoint Exception List", + "os_types": ["linux"], "entries": [ { "field": "actingProcess.file.signer", diff --git a/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list.json b/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list.json index 19027ac189a47..73271514269da 100644 --- a/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list.json +++ b/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list.json @@ -1,6 +1,5 @@ { "list_id": "simple_list", - "_tags": ["endpoint", "process", "malware", "os:linux"], "tags": ["user added string for a tag", "malware"], "type": "detection", "description": "This is a sample endpoint type exception", diff --git a/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list_agnostic.json b/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list_agnostic.json index 4121b13880660..9987f5d46af1b 100644 --- a/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list_agnostic.json +++ b/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list_agnostic.json @@ -1,6 +1,5 @@ { "list_id": "endpoint_list", - "_tags": ["endpoint", "process", "malware", "os:linux"], "tags": ["user added string for a tag", "malware"], "type": "endpoint", "description": "This is a sample agnostic endpoint type exception", diff --git a/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list_detection.json b/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list_detection.json index 306195f4226e3..986c368bd2de3 100644 --- a/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list_detection.json +++ b/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list_detection.json @@ -1,6 +1,5 @@ { "list_id": "detection_list", - "_tags": ["detection"], "tags": ["detection", "sample_tag"], "type": "detection", "description": "This is a sample detection type exception list", diff --git a/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list_item.json b/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list_item.json index eede855aab199..e7eed0a56cb6d 100644 --- a/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list_item.json +++ b/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list_item.json @@ -1,11 +1,11 @@ { "list_id": "simple_list", "item_id": "simple_list_item", - "_tags": ["endpoint", "process", "malware", "os:linux"], "tags": ["user added string for a tag", "malware"], "type": "simple", "description": "This is a sample endpoint type exception", "name": "Sample Endpoint Exception List", + "os_types": ["linux"], "entries": [ { "field": "actingProcess.file.signer", diff --git a/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list_item_agnostic.json b/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list_item_agnostic.json index 9cda9c12d6b30..d57fb19955e34 100644 --- a/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list_item_agnostic.json +++ b/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list_item_agnostic.json @@ -1,12 +1,12 @@ { "list_id": "endpoint_list", "item_id": "endpoint_list_item", - "_tags": ["endpoint", "process", "malware", "os:linux"], "tags": ["user added string for a tag", "malware"], "type": "simple", "description": "This is a sample agnostic endpoint type exception", "name": "Sample Endpoint Exception List", "namespace_type": "agnostic", + "os_types": ["linux"], "entries": [ { "field": "actingProcess.file.signer", diff --git a/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list_item_auto_id.json b/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list_item_auto_id.json index f1281e2ea0560..9cc73577818c5 100644 --- a/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list_item_auto_id.json +++ b/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list_item_auto_id.json @@ -1,10 +1,10 @@ { "list_id": "simple_list", - "_tags": ["endpoint", "process", "malware", "os:linux"], "tags": ["user added string for a tag", "malware"], "type": "simple", "description": "This is a sample endpoint type exception that has no item_id so it creates a new id each time", "name": "Sample Endpoint Exception List", + "os_types": ["linux"], "comments": [], "entries": [ { diff --git a/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list_item_detection_auto_id.json b/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list_item_detection_auto_id.json index 833f6c023c5d9..e65f818c1df85 100644 --- a/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list_item_detection_auto_id.json +++ b/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list_item_detection_auto_id.json @@ -1,6 +1,5 @@ { "list_id": "detection_list", - "_tags": ["detection"], "tags": ["test_tag", "detection", "no_more_bad_guys"], "type": "simple", "description": "This is a sample detection type exception that has no item_id so it creates a new id each time", diff --git a/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list_item_with_bad_ip_list.json b/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list_item_with_bad_ip_list.json index bab435487ec25..9a5f6e888e6e4 100644 --- a/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list_item_with_bad_ip_list.json +++ b/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list_item_with_bad_ip_list.json @@ -1,11 +1,11 @@ { "list_id": "endpoint_list", "item_id": "endpoint_list_item_good_rock01", - "_tags": ["endpoint", "process", "malware", "os:windows"], "tags": ["user added string for a tag", "malware"], "type": "simple", "description": "Don't signal when agent.name is rock01 and source.ip is in the goodguys.txt list", "name": "Filter out good guys ip and agent.name rock01", + "os_types": ["windows"], "comments": [], "entries": [ { diff --git a/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list_item_with_list.json b/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list_item_with_list.json index e0d401eff9269..d0756b990aad0 100644 --- a/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list_item_with_list.json +++ b/x-pack/plugins/lists/server/scripts/exception_lists/new/exception_list_item_with_list.json @@ -1,11 +1,11 @@ { "list_id": "endpoint_list", "item_id": "endpoint_list_item_lg_val_list", - "_tags": ["endpoint", "process", "malware", "os:windows"], "tags": ["user added string for a tag", "malware"], "type": "simple", "description": "This is a sample exception list item with a large value list included", "name": "Sample Endpoint Exception List Item with large value list", + "os_types": ["windows"], "comments": [], "entries": [ { diff --git a/x-pack/plugins/lists/server/scripts/exception_lists/new/trusted_app_list_item_agnostic.json b/x-pack/plugins/lists/server/scripts/exception_lists/new/trusted_app_list_item_agnostic.json index 9f0c306a408f0..293ca14d323f7 100644 --- a/x-pack/plugins/lists/server/scripts/exception_lists/new/trusted_app_list_item_agnostic.json +++ b/x-pack/plugins/lists/server/scripts/exception_lists/new/trusted_app_list_item_agnostic.json @@ -1,12 +1,12 @@ { "list_id": "endpoint_trusted_apps", "item_id": "endpoint_trusted_apps_item", - "_tags": ["endpoint", "os:linux", "os:windows", "os:macos", "trusted-app"], "tags": ["user added string for a tag", "malware"], "type": "simple", "description": "This is a sample agnostic endpoint trusted app entry", "name": "Sample Endpoint Trusted App Entry", "namespace_type": "agnostic", + "os_types": ["linux", "windows", "macos"], "entries": [ { "field": "actingProcess.file.signer", diff --git a/x-pack/plugins/lists/server/scripts/exception_lists/updates/simple_update.json b/x-pack/plugins/lists/server/scripts/exception_lists/updates/simple_update.json index 8d07b29d7b428..15a6f495b7a8f 100644 --- a/x-pack/plugins/lists/server/scripts/exception_lists/updates/simple_update.json +++ b/x-pack/plugins/lists/server/scripts/exception_lists/updates/simple_update.json @@ -1,8 +1,8 @@ { "list_id": "simple_list", - "_tags": ["endpoint", "process", "malware", "os:linux"], "tags": ["user added string for a tag", "malware"], "type": "endpoint", + "os_types": ["linux"], "description": "Different description", "name": "Sample Endpoint Exception List" } diff --git a/x-pack/plugins/lists/server/scripts/exception_lists/updates/simple_update_agnostic.json b/x-pack/plugins/lists/server/scripts/exception_lists/updates/simple_update_agnostic.json index 90d5e0846e53a..fe29ca80c632e 100644 --- a/x-pack/plugins/lists/server/scripts/exception_lists/updates/simple_update_agnostic.json +++ b/x-pack/plugins/lists/server/scripts/exception_lists/updates/simple_update_agnostic.json @@ -1,11 +1,11 @@ { "item_id": "endpoint_list_item", - "_tags": ["endpoint", "process", "malware", "os:windows"], "tags": ["user added string for a tag", "malware"], "type": "simple", "description": "This is a sample agnostic change here this list", "name": "Sample Endpoint Exception List update change", "namespace_type": "agnostic", + "os_types": ["windows"], "entries": [ { "field": "event.category", diff --git a/x-pack/plugins/lists/server/scripts/exception_lists/updates/simple_update_item.json b/x-pack/plugins/lists/server/scripts/exception_lists/updates/simple_update_item.json index 81db909277595..d55f121253406 100644 --- a/x-pack/plugins/lists/server/scripts/exception_lists/updates/simple_update_item.json +++ b/x-pack/plugins/lists/server/scripts/exception_lists/updates/simple_update_item.json @@ -1,5 +1,4 @@ { - "_tags": ["detection"], "comments": [], "description": "Test comments - exception list item", "entries": [ diff --git a/x-pack/plugins/lists/server/services/exception_lists/create_endpoint_list.ts b/x-pack/plugins/lists/server/services/exception_lists/create_endpoint_list.ts index 2e9bb1325632e..fb2b637657bb6 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/create_endpoint_list.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/create_endpoint_list.ts @@ -35,7 +35,6 @@ export const createEndpointList = async ({ const savedObject = await savedObjectsClient.create( savedObjectType, { - _tags: [], comments: undefined, created_at: dateNow, created_by: user, @@ -47,6 +46,7 @@ export const createEndpointList = async ({ list_type: 'list', meta: undefined, name: ENDPOINT_LIST_NAME, + os_types: [], tags: [], tie_breaker_id: tieBreaker ?? uuid.v4(), type: 'endpoint', diff --git a/x-pack/plugins/lists/server/services/exception_lists/create_endpoint_trusted_apps_list.ts b/x-pack/plugins/lists/server/services/exception_lists/create_endpoint_trusted_apps_list.ts index c782cdd302666..d9eedb0af4e77 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/create_endpoint_trusted_apps_list.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/create_endpoint_trusted_apps_list.ts @@ -43,7 +43,6 @@ export const createEndpointTrustedAppsList = async ({ const savedObject = await savedObjectsClient.create( savedObjectType, { - _tags: [], comments: undefined, created_at: dateNow, created_by: user, @@ -55,6 +54,7 @@ export const createEndpointTrustedAppsList = async ({ list_type: 'list', meta: undefined, name: ENDPOINT_TRUSTED_APPS_LIST_NAME, + os_types: [], tags: [], tie_breaker_id: tieBreaker ?? uuid.v4(), type: 'endpoint', diff --git a/x-pack/plugins/lists/server/services/exception_lists/create_exception_list.ts b/x-pack/plugins/lists/server/services/exception_lists/create_exception_list.ts index c8d709ca340ad..91a0506ad06e3 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/create_exception_list.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/create_exception_list.ts @@ -19,13 +19,11 @@ import { NamespaceType, Tags, Version, - _Tags, } from '../../../common/schemas'; import { getSavedObjectType, transformSavedObjectToExceptionList } from './utils'; interface CreateExceptionListOptions { - _tags: _Tags; listId: ListId; savedObjectsClient: SavedObjectsClientContract; namespaceType: NamespaceType; @@ -41,7 +39,6 @@ interface CreateExceptionListOptions { } export const createExceptionList = async ({ - _tags, listId, immutable, savedObjectsClient, @@ -58,7 +55,6 @@ export const createExceptionList = async ({ const savedObjectType = getSavedObjectType({ namespaceType }); const dateNow = new Date().toISOString(); const savedObject = await savedObjectsClient.create(savedObjectType, { - _tags, comments: undefined, created_at: dateNow, created_by: user, @@ -70,6 +66,7 @@ export const createExceptionList = async ({ list_type: 'list', meta, name, + os_types: [], tags, tie_breaker_id: tieBreaker ?? uuid.v4(), type, diff --git a/x-pack/plugins/lists/server/services/exception_lists/create_exception_list_item.ts b/x-pack/plugins/lists/server/services/exception_lists/create_exception_list_item.ts index 47c21735b45f4..9f331362cdd44 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/create_exception_list_item.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/create_exception_list_item.ts @@ -19,8 +19,8 @@ import { MetaOrUndefined, Name, NamespaceType, + OsTypeArray, Tags, - _Tags, } from '../../../common/schemas'; import { @@ -30,7 +30,6 @@ import { } from './utils'; interface CreateExceptionListItemOptions { - _tags: _Tags; comments: CreateCommentsArray; listId: ListId; itemId: ItemId; @@ -44,10 +43,10 @@ interface CreateExceptionListItemOptions { tags: Tags; tieBreaker?: string; type: ExceptionListItemType; + osTypes: OsTypeArray; } export const createExceptionListItem = async ({ - _tags, comments, entries, itemId, @@ -55,6 +54,7 @@ export const createExceptionListItem = async ({ savedObjectsClient, namespaceType, name, + osTypes, description, meta, user, @@ -69,7 +69,6 @@ export const createExceptionListItem = async ({ user, }); const savedObject = await savedObjectsClient.create(savedObjectType, { - _tags, comments: transformedComments, created_at: dateNow, created_by: user, @@ -81,6 +80,7 @@ export const createExceptionListItem = async ({ list_type: 'item', meta, name, + os_types: osTypes as OsTypeArray, tags, tie_breaker_id: tieBreaker ?? uuid.v4(), type, diff --git a/x-pack/plugins/lists/server/services/exception_lists/exception_list_client.ts b/x-pack/plugins/lists/server/services/exception_lists/exception_list_client.ts index 747458175e3b8..9747c58d1cd0f 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/exception_list_client.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/exception_list_client.ts @@ -109,20 +109,19 @@ export class ExceptionListClient { * being there and existing before the item is inserted into the agnostic endpoint list. */ public createEndpointListItem = async ({ - _tags, comments, description, entries, itemId, meta, name, + osTypes, tags, type, }: CreateEndpointListItemOptions): Promise => { const { savedObjectsClient, user } = this; await this.createEndpointList(); return createExceptionListItem({ - _tags, comments, description, entries, @@ -131,6 +130,7 @@ export class ExceptionListClient { meta, name, namespaceType: 'agnostic', + osTypes, savedObjectsClient, tags, type, @@ -145,7 +145,6 @@ export class ExceptionListClient { * return of null but at least the list exists again. */ public updateEndpointListItem = async ({ - _tags, _version, comments, description, @@ -154,13 +153,13 @@ export class ExceptionListClient { itemId, meta, name, + osTypes, tags, type, }: UpdateEndpointListItemOptions): Promise => { const { savedObjectsClient, user } = this; await this.createEndpointList(); return updateExceptionListItem({ - _tags, _version, comments, description, @@ -170,6 +169,7 @@ export class ExceptionListClient { meta, name, namespaceType: 'agnostic', + osTypes, savedObjectsClient, tags, type, @@ -189,7 +189,6 @@ export class ExceptionListClient { }; public createExceptionList = async ({ - _tags, description, immutable, listId, @@ -202,7 +201,6 @@ export class ExceptionListClient { }: CreateExceptionListOptions): Promise => { const { savedObjectsClient, user } = this; return createExceptionList({ - _tags, description, immutable, listId, @@ -218,7 +216,6 @@ export class ExceptionListClient { }; public updateExceptionList = async ({ - _tags, _version, id, description, @@ -226,13 +223,13 @@ export class ExceptionListClient { meta, name, namespaceType, + osTypes, tags, type, version, }: UpdateExceptionListOptions): Promise => { const { savedObjectsClient, user } = this; return updateExceptionList({ - _tags, _version, description, id, @@ -240,6 +237,7 @@ export class ExceptionListClient { meta, name, namespaceType, + osTypes, savedObjectsClient, tags, type, @@ -263,7 +261,6 @@ export class ExceptionListClient { }; public createExceptionListItem = async ({ - _tags, comments, description, entries, @@ -272,12 +269,12 @@ export class ExceptionListClient { meta, name, namespaceType, + osTypes, tags, type, }: CreateExceptionListItemOptions): Promise => { const { savedObjectsClient, user } = this; return createExceptionListItem({ - _tags, comments, description, entries, @@ -286,6 +283,7 @@ export class ExceptionListClient { meta, name, namespaceType, + osTypes, savedObjectsClient, tags, type, @@ -294,7 +292,6 @@ export class ExceptionListClient { }; public updateExceptionListItem = async ({ - _tags, _version, comments, description, @@ -304,12 +301,12 @@ export class ExceptionListClient { meta, name, namespaceType, + osTypes, tags, type, }: UpdateExceptionListItemOptions): Promise => { const { savedObjectsClient, user } = this; return updateExceptionListItem({ - _tags, _version, comments, description, @@ -319,6 +316,7 @@ export class ExceptionListClient { meta, name, namespaceType, + osTypes, savedObjectsClient, tags, type, diff --git a/x-pack/plugins/lists/server/services/exception_lists/exception_list_client_types.ts b/x-pack/plugins/lists/server/services/exception_lists/exception_list_client_types.ts index 963716b55ea77..1fef2da5d975e 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/exception_list_client_types.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/exception_list_client_types.ts @@ -30,6 +30,7 @@ import { Name, NameOrUndefined, NamespaceType, + OsTypeArray, PageOrUndefined, PerPageOrUndefined, SortFieldOrUndefined, @@ -39,8 +40,6 @@ import { UpdateCommentsArray, Version, VersionOrUndefined, - _Tags, - _TagsOrUndefined, _VersionOrUndefined, } from '../../../common/schemas'; @@ -56,7 +55,6 @@ export interface GetExceptionListOptions { } export interface CreateExceptionListOptions { - _tags: _Tags; listId: ListId; namespaceType: NamespaceType; name: Name; @@ -69,12 +67,12 @@ export interface CreateExceptionListOptions { } export interface UpdateExceptionListOptions { - _tags: _TagsOrUndefined; _version: _VersionOrUndefined; id: IdOrUndefined; listId: ListIdOrUndefined; namespaceType: NamespaceType; name: NameOrUndefined; + osTypes: OsTypeArray; description: DescriptionOrUndefined; meta: MetaOrUndefined; tags: TagsOrUndefined; @@ -116,13 +114,13 @@ export interface GetEndpointListItemOptions { } export interface CreateExceptionListItemOptions { - _tags: _Tags; comments: CreateCommentsArray; entries: EntriesArray; itemId: ItemId; listId: ListId; namespaceType: NamespaceType; name: Name; + osTypes: OsTypeArray; description: Description; meta: MetaOrUndefined; tags: Tags; @@ -130,19 +128,18 @@ export interface CreateExceptionListItemOptions { } export interface CreateEndpointListItemOptions { - _tags: _Tags; comments: CreateCommentsArray; entries: EntriesArray; itemId: ItemId; name: Name; description: Description; meta: MetaOrUndefined; + osTypes: OsTypeArray; tags: Tags; type: ExceptionListItemType; } export interface UpdateExceptionListItemOptions { - _tags: _TagsOrUndefined; _version: _VersionOrUndefined; comments: UpdateCommentsArray; entries: EntriesArray; @@ -150,6 +147,7 @@ export interface UpdateExceptionListItemOptions { itemId: ItemIdOrUndefined; namespaceType: NamespaceType; name: NameOrUndefined; + osTypes: OsTypeArray; description: DescriptionOrUndefined; meta: MetaOrUndefined; tags: TagsOrUndefined; @@ -157,13 +155,13 @@ export interface UpdateExceptionListItemOptions { } export interface UpdateEndpointListItemOptions { - _tags: _TagsOrUndefined; _version: _VersionOrUndefined; comments: UpdateCommentsArray; entries: EntriesArray; id: IdOrUndefined; itemId: ItemIdOrUndefined; name: NameOrUndefined; + osTypes: OsTypeArray; description: DescriptionOrUndefined; meta: MetaOrUndefined; tags: TagsOrUndefined; diff --git a/x-pack/plugins/lists/server/services/exception_lists/update_exception_list.ts b/x-pack/plugins/lists/server/services/exception_lists/update_exception_list.ts index c26ff1bca4484..a9a666672d7bb 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/update_exception_list.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/update_exception_list.ts @@ -16,9 +16,9 @@ import { MetaOrUndefined, NameOrUndefined, NamespaceType, + OsTypeArray, TagsOrUndefined, VersionOrUndefined, - _TagsOrUndefined, _VersionOrUndefined, } from '../../../common/schemas'; @@ -27,12 +27,12 @@ import { getExceptionList } from './get_exception_list'; interface UpdateExceptionListOptions { id: IdOrUndefined; - _tags: _TagsOrUndefined; _version: _VersionOrUndefined; name: NameOrUndefined; description: DescriptionOrUndefined; savedObjectsClient: SavedObjectsClientContract; namespaceType: NamespaceType; + osTypes: OsTypeArray; listId: ListIdOrUndefined; meta: MetaOrUndefined; user: string; @@ -43,7 +43,6 @@ interface UpdateExceptionListOptions { } export const updateExceptionList = async ({ - _tags, _version, id, savedObjectsClient, @@ -67,7 +66,6 @@ export const updateExceptionList = async ({ savedObjectType, exceptionList.id, { - _tags, description, meta, name, diff --git a/x-pack/plugins/lists/server/services/exception_lists/update_exception_list_item.ts b/x-pack/plugins/lists/server/services/exception_lists/update_exception_list_item.ts index ccb74e8796705..9c3399b7509a5 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/update_exception_list_item.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/update_exception_list_item.ts @@ -17,9 +17,9 @@ import { MetaOrUndefined, NameOrUndefined, NamespaceType, + OsTypeArray, TagsOrUndefined, UpdateCommentsArrayOrUndefined, - _TagsOrUndefined, _VersionOrUndefined, } from '../../../common/schemas'; @@ -33,13 +33,13 @@ import { getExceptionListItem } from './get_exception_list_item'; interface UpdateExceptionListItemOptions { id: IdOrUndefined; comments: UpdateCommentsArrayOrUndefined; - _tags: _TagsOrUndefined; _version: _VersionOrUndefined; name: NameOrUndefined; description: DescriptionOrUndefined; entries: EntriesArray; savedObjectsClient: SavedObjectsClientContract; namespaceType: NamespaceType; + osTypes: OsTypeArray; itemId: ItemIdOrUndefined; meta: MetaOrUndefined; user: string; @@ -49,7 +49,6 @@ interface UpdateExceptionListItemOptions { } export const updateExceptionListItem = async ({ - _tags, _version, comments, entries, @@ -57,6 +56,7 @@ export const updateExceptionListItem = async ({ savedObjectsClient, namespaceType, name, + osTypes, description, itemId, meta, @@ -83,12 +83,12 @@ export const updateExceptionListItem = async ({ savedObjectType, exceptionListItem.id, { - _tags, comments: transformedComments, description, entries, meta, name, + os_types: osTypes, tags, type, updated_by: user, diff --git a/x-pack/plugins/lists/server/services/exception_lists/utils.ts b/x-pack/plugins/lists/server/services/exception_lists/utils.ts index 2989a09b0ce00..6a7bd249bf62a 100644 --- a/x-pack/plugins/lists/server/services/exception_lists/utils.ts +++ b/x-pack/plugins/lists/server/services/exception_lists/utils.ts @@ -71,7 +71,6 @@ export const transformSavedObjectToExceptionList = ({ version: _version, attributes: { /* eslint-disable @typescript-eslint/naming-convention */ - _tags, created_at, created_by, description, @@ -79,6 +78,7 @@ export const transformSavedObjectToExceptionList = ({ list_id, meta, name, + os_types, tags, tie_breaker_id, type, @@ -93,7 +93,6 @@ export const transformSavedObjectToExceptionList = ({ // TODO: Change this to do a decode and throw if the saved object is not as expected. // TODO: Do a throw if after the decode this is not the correct "list_type: list" return { - _tags, _version, created_at, created_by, @@ -104,6 +103,7 @@ export const transformSavedObjectToExceptionList = ({ meta, name, namespace_type: getExceptionListType({ savedObjectType: savedObject.type }), + os_types, tags, tie_breaker_id, type: exceptionListType.is(type) ? type : 'detection', @@ -124,11 +124,11 @@ export const transformSavedObjectUpdateToExceptionList = ({ const { version: _version, attributes: { - _tags, description, immutable, meta, name, + os_types: osTypes, tags, type, updated_by: updatedBy, @@ -141,7 +141,6 @@ export const transformSavedObjectUpdateToExceptionList = ({ // TODO: Change this to do a decode and throw if the saved object is not as expected. // TODO: Do a throw if after the decode this is not the correct "list_type: list" return { - _tags: _tags ?? exceptionList._tags, _version, created_at: exceptionList.created_at, created_by: exceptionList.created_by, @@ -152,6 +151,7 @@ export const transformSavedObjectUpdateToExceptionList = ({ meta: meta ?? exceptionList.meta, name: name ?? exceptionList.name, namespace_type: getExceptionListType({ savedObjectType: savedObject.type }), + os_types: osTypes ?? exceptionList.os_types, tags: tags ?? exceptionList.tags, tie_breaker_id: exceptionList.tie_breaker_id, type: exceptionListType.is(type) ? type : exceptionList.type, @@ -171,7 +171,6 @@ export const transformSavedObjectToExceptionListItem = ({ version: _version, attributes: { /* eslint-disable @typescript-eslint/naming-convention */ - _tags, comments, created_at, created_by, @@ -181,6 +180,7 @@ export const transformSavedObjectToExceptionListItem = ({ list_id, meta, name, + os_types, tags, tie_breaker_id, type, @@ -194,7 +194,6 @@ export const transformSavedObjectToExceptionListItem = ({ // TODO: Do a throw if after the decode this is not the correct "list_type: item" // TODO: Do a throw if item_id or entries is not defined. return { - _tags, _version, comments: comments ?? [], created_at, @@ -207,6 +206,7 @@ export const transformSavedObjectToExceptionListItem = ({ meta, name, namespace_type: getExceptionListType({ savedObjectType: savedObject.type }), + os_types, tags, tie_breaker_id, type: exceptionListItemType.is(type) ? type : 'simple', @@ -226,12 +226,12 @@ export const transformSavedObjectUpdateToExceptionListItem = ({ const { version: _version, attributes: { - _tags, comments, description, entries, meta, name, + os_types: osTypes, tags, type, updated_by: updatedBy, @@ -245,7 +245,6 @@ export const transformSavedObjectUpdateToExceptionListItem = ({ // TODO: Update exception list and item types (perhaps separating out) so as to avoid // defaulting return { - _tags: _tags ?? exceptionListItem._tags, _version, comments: comments ?? exceptionListItem.comments, created_at: exceptionListItem.created_at, @@ -258,6 +257,7 @@ export const transformSavedObjectUpdateToExceptionListItem = ({ meta: meta ?? exceptionListItem.meta, name: name ?? exceptionListItem.name, namespace_type: getExceptionListType({ savedObjectType: savedObject.type }), + os_types: osTypes ?? exceptionListItem.os_types, tags: tags ?? exceptionListItem.tags, tie_breaker_id: exceptionListItem.tie_breaker_id, type: exceptionListItemType.is(type) ? type : exceptionListItem.type, diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_array.test.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_array.test.ts new file mode 100644 index 0000000000000..6e23f31e8a994 --- /dev/null +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_array.test.ts @@ -0,0 +1,80 @@ +/* + * 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 * as t from 'io-ts'; + +import { DefaultArray } from './default_array'; +import { pipe } from 'fp-ts/lib/pipeable'; +import { left } from 'fp-ts/lib/Either'; +import { foldLeftRight, getPaths } from '../../../test_utils'; + +const testSchema = t.keyof({ + valid: true, + also_valid: true, +}); +type TestSchema = t.TypeOf; + +const defaultArraySchema = DefaultArray(testSchema); + +describe('default_array', () => { + test('it should validate an empty array', () => { + const payload: string[] = []; + const decoded = defaultArraySchema.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should validate an array of testSchema', () => { + const payload: TestSchema[] = ['valid']; + const decoded = defaultArraySchema.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should validate an array of valid testSchema strings', () => { + const payload = ['valid', 'also_valid']; + const decoded = defaultArraySchema.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual(payload); + }); + + test('it should not validate an array with a number', () => { + const payload = ['valid', 123]; + const decoded = defaultArraySchema.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "123" supplied to "DefaultArray"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should not validate an array with an invalid string', () => { + const payload = ['valid', 'invalid']; + const decoded = defaultArraySchema.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([ + 'Invalid value "invalid" supplied to "DefaultArray"', + ]); + expect(message.schema).toEqual({}); + }); + + test('it should return a default array entry', () => { + const payload = null; + const decoded = defaultArraySchema.decode(payload); + const message = pipe(decoded, foldLeftRight); + + expect(getPaths(left(message.errors))).toEqual([]); + expect(message.schema).toEqual([]); + }); +}); diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_array.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_array.ts new file mode 100644 index 0000000000000..8388eb315b8f4 --- /dev/null +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/default_array.ts @@ -0,0 +1,25 @@ +/* + * 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 * as t from 'io-ts'; +import { Either } from 'fp-ts/lib/Either'; + +/** + * Types the DefaultArray as: + * - If undefined, then a default array will be set + * - If an array is sent in, then the array will be validated to ensure all elements are type C + */ +export const DefaultArray = (codec: C) => { + const arrType = t.array(codec); + type ArrType = t.TypeOf; + return new t.Type( + 'DefaultArray', + arrType.is, + (input, context): Either => + input == null ? t.success([]) : arrType.validate(input, context), + t.identity + ); +}; diff --git a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/index.ts b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/index.ts index 28a66d2948a92..e76dd3fca3740 100644 --- a/x-pack/plugins/security_solution/common/detection_engine/schemas/types/index.ts +++ b/x-pack/plugins/security_solution/common/detection_engine/schemas/types/index.ts @@ -5,6 +5,7 @@ */ export * from './default_actions_array'; +export * from './default_array'; export * from './default_boolean_false'; export * from './default_boolean_true'; export * from './default_empty_string'; diff --git a/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.test.ts b/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.test.ts index ef1d9a99b0aeb..352c628f9fa23 100644 --- a/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.test.ts +++ b/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.test.ts @@ -76,7 +76,7 @@ describe('When invoking Trusted Apps Schema', () => { os: 'windows', entries: [ { - field: 'process.executable.text', + field: 'process.executable.caseless', type: 'match', operator: 'included', value: 'c:/programs files/Anti-Virus', @@ -204,7 +204,7 @@ describe('When invoking Trusted Apps Schema', () => { field: 'process.hash.*', value: 'A4370C0CF81686C0B696FA6261c9d3e0d810ae704ab8301839dffd5d5112f476', }, - { field: 'process.executable.text', value: '/tmp/dir1' }, + { field: 'process.executable.caseless', value: '/tmp/dir1' }, ].forEach((partialEntry) => { const bodyMsg3 = { ...getCreateTrustedAppItem(), diff --git a/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.ts b/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.ts index 25456115b3713..b4e837c472915 100644 --- a/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.ts +++ b/x-pack/plugins/security_solution/common/endpoint/schema/trusted_apps.ts @@ -35,7 +35,7 @@ export const PostTrustedAppCreateRequestSchema = { schema.object({ field: schema.oneOf([ schema.literal('process.hash.*'), - schema.literal('process.executable.text'), + schema.literal('process.executable.caseless'), ]), type: schema.literal('match'), operator: schema.literal('included'), diff --git a/x-pack/plugins/security_solution/common/endpoint/types/trusted_apps.ts b/x-pack/plugins/security_solution/common/endpoint/types/trusted_apps.ts index 75e0347b10078..3568136dd0e7b 100644 --- a/x-pack/plugins/security_solution/common/endpoint/types/trusted_apps.ts +++ b/x-pack/plugins/security_solution/common/endpoint/types/trusted_apps.ts @@ -33,7 +33,7 @@ export interface PostTrustedAppCreateResponse { } export interface MacosLinuxConditionEntry { - field: 'process.hash.*' | 'process.executable.text'; + field: 'process.hash.*' | 'process.executable.caseless'; type: 'match'; operator: 'included'; value: string; diff --git a/x-pack/plugins/security_solution/common/shared_exports.ts b/x-pack/plugins/security_solution/common/shared_exports.ts index bd1086a3f21e9..6269c3cee999c 100644 --- a/x-pack/plugins/security_solution/common/shared_exports.ts +++ b/x-pack/plugins/security_solution/common/shared_exports.ts @@ -5,6 +5,7 @@ */ export { NonEmptyString } from './detection_engine/schemas/types/non_empty_string'; +export { DefaultArray } from './detection_engine/schemas/types/default_array'; export { DefaultUuid } from './detection_engine/schemas/types/default_uuid'; export { DefaultStringArray } from './detection_engine/schemas/types/default_string_array'; export { diff --git a/x-pack/plugins/security_solution/common/shared_imports.ts b/x-pack/plugins/security_solution/common/shared_imports.ts index 564254b6a7596..bfe77d2f9e626 100644 --- a/x-pack/plugins/security_solution/common/shared_imports.ts +++ b/x-pack/plugins/security_solution/common/shared_imports.ts @@ -42,4 +42,6 @@ export { ExceptionListType, Type, ENDPOINT_LIST_ID, + osTypeArray, + OsTypeArray, } from '../../lists/common'; diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.test.tsx index ef2a5770eee8d..037462839c72d 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.test.tsx @@ -257,7 +257,7 @@ describe('When the add exception modal is opened', () => { indexPatterns: { ...stubIndexPattern, fields: [ - { name: 'file.path.text', type: 'string' }, + { name: 'file.path.caseless', type: 'string' }, { name: 'subject_name', type: 'string' }, { name: 'trusted', type: 'string' }, { name: 'file.hash.sha256', type: 'string' }, diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx index dee1db6482067..ad5bc98243467 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/add_exception_modal/index.tsx @@ -30,6 +30,7 @@ import * as i18nCommon from '../../../translations'; import * as i18n from './translations'; import * as sharedI18n from '../translations'; import { Ecs } from '../../../../../common/ecs'; +import { osTypeArray, OsTypeArray } from '../../../../../common/shared_imports'; import { useAppToasts } from '../../../hooks/use_app_toasts'; import { useKibana } from '../../../lib/kibana'; import { ExceptionBuilderComponent } from '../builder'; @@ -211,12 +212,7 @@ export const AddExceptionModal = memo(function AddExceptionModal({ const initialExceptionItems = useMemo((): ExceptionsBuilderExceptionItem[] => { if (exceptionListType === 'endpoint' && alertData != null && ruleExceptionList) { - return defaultEndpointExceptionItems( - exceptionListType, - ruleExceptionList.list_id, - ruleName, - alertData - ); + return defaultEndpointExceptionItems(ruleExceptionList.list_id, ruleName, alertData); } else { return []; } @@ -265,11 +261,11 @@ export const AddExceptionModal = memo(function AddExceptionModal({ [setShouldBulkCloseAlert] ); - const retrieveAlertOsTypes = useCallback((): string[] => { - const osDefaults = ['windows', 'macos']; + const retrieveAlertOsTypes = useCallback((): OsTypeArray => { + const osDefaults: OsTypeArray = ['windows', 'macos']; if (alertData != null) { const osTypes = alertData.host && alertData.host.os && alertData.host.os.family; - if (osTypes != null && osTypes.length > 0) { + if (osTypeArray.is(osTypes) && osTypes != null && osTypes.length > 0) { return osTypes; } return osDefaults; @@ -316,13 +312,14 @@ export const AddExceptionModal = memo(function AddExceptionModal({ [fetchOrCreateListError, exceptionItemsToAdd] ); + const addExceptionMessage = + exceptionListType === 'endpoint' ? i18n.ADD_ENDPOINT_EXCEPTION : i18n.ADD_EXCEPTION; + return ( - - {exceptionListType === 'endpoint' ? i18n.ADD_ENDPOINT_EXCEPTION : i18n.ADD_EXCEPTION} - + {addExceptionMessage} {ruleName} @@ -429,7 +426,7 @@ export const AddExceptionModal = memo(function AddExceptionModal({ isDisabled={isSubmitButtonDisabled} fill > - {i18n.ADD_EXCEPTION} + {addExceptionMessage} )} diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/helpers.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/helpers.test.tsx index 9bfd04cc19d72..2ee0fe88f73f7 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/helpers.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/helpers.test.tsx @@ -90,9 +90,9 @@ const getMockNestedParentBuilderEntry = (): FormattedBuilderEntry => ({ const mockEndpointFields = [ { - name: 'file.path.text', + name: 'file.path.caseless', type: 'string', - esTypes: ['text'], + esTypes: ['keyword'], count: 0, scripted: false, searchable: true, @@ -303,8 +303,8 @@ describe('Exception builder helpers', () => { { aggregatable: false, count: 0, - esTypes: ['text'], - name: 'file.path.text', + esTypes: ['keyword'], + name: 'file.path.caseless', readFromDocValues: false, scripted: false, searchable: true, diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/index.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/index.tsx index 165f3314c2f15..5904e0034a51c 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/builder/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/builder/index.tsx @@ -234,13 +234,12 @@ export const ExceptionBuilderComponent = ({ // empty `entries` array. Thought about appending an entry item to one, but that // would then be arbitrary, decided to just create a new exception list item const newException = getNewExceptionItem({ - listType, listId, namespaceType: listNamespaceType, ruleName, }); setUpdateExceptions([...exceptions, { ...newException }]); - }, [setUpdateExceptions, exceptions, listType, listId, listNamespaceType, ruleName]); + }, [setUpdateExceptions, exceptions, listId, listNamespaceType, ruleName]); // The builder can have existing exception items, or new exception items that have yet // to be created (and thus lack an id), this was creating some React bugs with relying diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.tsx index 128686428598c..08f7e3af90d0c 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/edit_exception_modal/index.tsx @@ -40,7 +40,6 @@ import { AddExceptionComments } from '../add_exception_comments'; import { enrichExistingExceptionItemWithComments, enrichExceptionItemsWithOS, - getOperatingSystems, entryHasListType, entryHasNonEcsType, lowercaseHashValues, @@ -228,8 +227,7 @@ export const EditExceptionModal = memo(function EditExceptionModal({ }, ]; if (exceptionListType === 'endpoint') { - const osTypes = exceptionItem._tags ? getOperatingSystems(exceptionItem._tags) : []; - enriched = lowercaseHashValues(enrichExceptionItemsWithOS(enriched, osTypes)); + enriched = lowercaseHashValues(enrichExceptionItemsWithOS(enriched, exceptionItem.os_types)); } return enriched; }, [exceptionItemsToAdd, exceptionItem, comment, exceptionListType]); diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/exceptionable_fields.json b/x-pack/plugins/security_solution/public/common/components/exceptions/exceptionable_fields.json index 037e340ee7fa2..2ea200466445b 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/exceptionable_fields.json +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/exceptionable_fields.json @@ -6,33 +6,33 @@ "Target.process.Ext.code_signature.valid", "Target.process.Ext.services", "Target.process.Ext.user", - "Target.process.command_line.text", - "Target.process.executable.text", + "Target.process.command_line.caseless", + "Target.process.executable.caseless", "Target.process.hash.md5", "Target.process.hash.sha1", "Target.process.hash.sha256", "Target.process.hash.sha512", - "Target.process.name.text", + "Target.process.name.caseless", "Target.process.parent.Ext.code_signature.status", "Target.process.parent.Ext.code_signature.subject_name", "Target.process.parent.Ext.code_signature.trusted", "Target.process.parent.Ext.code_signature.valid", - "Target.process.parent.command_line.text", - "Target.process.parent.executable.text", + "Target.process.parent.command_line.caseless", + "Target.process.parent.executable.caseless", "Target.process.parent.hash.md5", "Target.process.parent.hash.sha1", "Target.process.parent.hash.sha256", "Target.process.parent.hash.sha512", - "Target.process.parent.name.text", + "Target.process.parent.name.caseless", "Target.process.parent.pgid", - "Target.process.parent.working_directory.text", + "Target.process.parent.working_directory.caseless", "Target.process.pe.company", "Target.process.pe.description", "Target.process.pe.file_version", "Target.process.pe.original_file_name", "Target.process.pe.product", "Target.process.pgid", - "Target.process.working_directory.text", + "Target.process.working_directory.caseless", "agent.id", "agent.type", "agent.version", @@ -66,14 +66,14 @@ "file.mode", "file.name", "file.owner", - "file.path.text", + "file.path.caseless", "file.pe.company", "file.pe.description", "file.pe.file_version", "file.pe.original_file_name", "file.pe.product", "file.size", - "file.target_path.text", + "file.target_path.caseless", "file.type", "file.uid", "group.Ext.real.id", @@ -84,9 +84,9 @@ "host.id", "host.os.Ext.variant", "host.os.family", - "host.os.full.text", + "host.os.full.caseless", "host.os.kernel", - "host.os.name.text", + "host.os.name.caseless", "host.os.platform", "host.os.version", "host.type", @@ -96,33 +96,33 @@ "process.Ext.code_signature.valid", "process.Ext.services", "process.Ext.user", - "process.command_line.text", - "process.executable.text", + "process.command_line.caseless", + "process.executable.caseless", "process.hash.md5", "process.hash.sha1", "process.hash.sha256", "process.hash.sha512", - "process.name.text", + "process.name.caseless", "process.parent.Ext.code_signature.status", "process.parent.Ext.code_signature.subject_name", "process.parent.Ext.code_signature.trusted", "process.parent.Ext.code_signature.valid", - "process.parent.command_line.text", - "process.parent.executable.text", + "process.parent.command_line.caseless", + "process.parent.executable.caseless", "process.parent.hash.md5", "process.parent.hash.sha1", "process.parent.hash.sha256", "process.parent.hash.sha512", - "process.parent.name.text", + "process.parent.name.caseless", "process.parent.pgid", - "process.parent.working_directory.text", + "process.parent.working_directory.caseless", "process.pe.company", "process.pe.description", "process.pe.file_version", "process.pe.original_file_name", "process.pe.product", "process.pgid", - "process.working_directory.text", + "process.working_directory.caseless", "rule.uuid", "user.domain", "user.email", diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.test.tsx index 26fb460aee382..c89bde6d04dd3 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.test.tsx @@ -10,8 +10,6 @@ import moment from 'moment-timezone'; import { getOperatorType, getExceptionOperatorSelect, - getOperatingSystems, - getTagsInclude, getFormattedComments, filterExceptionItems, getNewExceptionItem, @@ -52,6 +50,7 @@ import { CreateExceptionListItemSchema, ExceptionListItemSchema, EntriesArray, + OsTypeArray, } from '../../../../../lists/common/schemas'; import { IIndexPattern } from 'src/plugins/data/common'; @@ -186,76 +185,18 @@ describe('Exception helpers', () => { }); }); - describe('#getOperatingSystems', () => { - test('it returns null if no operating system tag specified', () => { - const result = getOperatingSystems(['some tag', 'some other tag']); - - expect(result).toEqual([]); - }); - - test('it returns null if operating system tag malformed', () => { - const result = getOperatingSystems(['some tag', 'jibberos:mac,windows', 'some other tag']); - - expect(result).toEqual([]); - }); - - test('it returns operating systems if space included in os tag', () => { - const result = getOperatingSystems(['some tag', 'os: macos', 'some other tag']); - expect(result).toEqual(['macos']); - }); - - test('it returns operating systems if multiple os tags specified', () => { - const result = getOperatingSystems(['some tag', 'os: macos', 'some other tag', 'os:windows']); - expect(result).toEqual(['macos', 'windows']); - }); - }); - describe('#formatOperatingSystems', () => { test('it returns null if no operating system tag specified', () => { - const result = formatOperatingSystems(getOperatingSystems(['some tag', 'some other tag'])); - - expect(result).toEqual(''); - }); - - test('it returns null if operating system tag malformed', () => { - const result = formatOperatingSystems( - getOperatingSystems(['some tag', 'jibberos:mac,windows', 'some other tag']) - ); - + const result = formatOperatingSystems(['some os', 'some other os']); expect(result).toEqual(''); }); - test('it returns formatted operating systems if space included in os tag', () => { - const result = formatOperatingSystems( - getOperatingSystems(['some tag', 'os: macos', 'some other tag']) - ); - - expect(result).toEqual('macOS'); - }); - - test('it returns formatted operating systems if multiple os tags specified', () => { - const result = formatOperatingSystems( - getOperatingSystems(['some tag', 'os: macos', 'some other tag', 'os:windows']) - ); - + test('it returns formatted operating systems if multiple specified', () => { + const result = formatOperatingSystems(['some tag', 'macos', 'some other tag', 'windows']); expect(result).toEqual('macOS, Windows'); }); }); - describe('#getTagsInclude', () => { - test('it returns a tuple of "false" and "null" if no matches found', () => { - const result = getTagsInclude({ tags: ['some', 'tags', 'here'], regex: /(no match)/ }); - - expect(result).toEqual([false, null]); - }); - - test('it returns a tuple of "true" and matching string if matches found', () => { - const result = getTagsInclude({ tags: ['some', 'tags', 'here'], regex: /(some)/ }); - - expect(result).toEqual([true, 'some']); - }); - }); - describe('#getFormattedComments', () => { test('it returns formatted comment object with username and timestamp', () => { const payload = getCommentsArrayMock(); @@ -384,7 +325,6 @@ describe('Exception helpers', () => { test('it removes `temporaryId` from items', () => { const { meta, ...rest } = getNewExceptionItem({ - listType: 'detection', listId: '123', namespaceType: 'single', ruleName: 'rule name', @@ -400,7 +340,6 @@ describe('Exception helpers', () => { const payload = getExceptionListItemSchemaMock(); const result = formatExceptionItemForUpdate(payload); const expected = { - _tags: ['endpoint', 'process', 'malware', 'os:linux'], comments: [], description: 'some description', entries: ENTRIES, @@ -409,6 +348,7 @@ describe('Exception helpers', () => { meta: {}, name: 'some name', namespace_type: 'single', + os_types: ['linux'], tags: ['user added string for a tag', 'malware'], type: 'simple', }; @@ -489,14 +429,14 @@ describe('Exception helpers', () => { }); describe('#enrichExceptionItemsWithOS', () => { - test('it should add an os tag to an exception item', () => { + test('it should add an os to an exception item', () => { const payload = [getExceptionListItemSchemaMock()]; - const osTypes = ['windows']; + const osTypes: OsTypeArray = ['windows']; const result = enrichExceptionItemsWithOS(payload, osTypes); const expected = [ { ...getExceptionListItemSchemaMock(), - _tags: [...getExceptionListItemSchemaMock()._tags, 'os:windows'], + os_types: ['windows'], }, ]; expect(result).toEqual(expected); @@ -504,36 +444,16 @@ describe('Exception helpers', () => { test('it should add multiple os tags to all exception items', () => { const payload = [getExceptionListItemSchemaMock(), getExceptionListItemSchemaMock()]; - const osTypes = ['windows', 'macos']; - const result = enrichExceptionItemsWithOS(payload, osTypes); - const expected = [ - { - ...getExceptionListItemSchemaMock(), - _tags: [...getExceptionListItemSchemaMock()._tags, 'os:windows', 'os:macos'], - }, - { - ...getExceptionListItemSchemaMock(), - _tags: [...getExceptionListItemSchemaMock()._tags, 'os:windows', 'os:macos'], - }, - ]; - expect(result).toEqual(expected); - }); - - test('it should add os tag to all exception items without duplication', () => { - const payload = [ - { ...getExceptionListItemSchemaMock(), _tags: ['os:linux', 'os:windows'] }, - { ...getExceptionListItemSchemaMock(), _tags: ['os:linux'] }, - ]; - const osTypes = ['windows']; + const osTypes: OsTypeArray = ['windows', 'macos']; const result = enrichExceptionItemsWithOS(payload, osTypes); const expected = [ { ...getExceptionListItemSchemaMock(), - _tags: ['os:linux', 'os:windows'], + os_types: ['windows', 'macos'], }, { ...getExceptionListItemSchemaMock(), - _tags: ['os:linux', 'os:windows'], + os_types: ['windows', 'macos'], }, ]; expect(result).toEqual(expected); @@ -715,7 +635,6 @@ describe('Exception helpers', () => { describe('getPrepopulatedItem', () => { test('it returns prepopulated items', () => { const prepopulatedItem = getPrepopulatedItem({ - listType: 'endpoint', listId: 'some_id', ruleName: 'my rule', codeSignature: { subjectName: '', trusted: '' }, @@ -733,7 +652,7 @@ describe('Exception helpers', () => { field: 'file.Ext.code_signature', type: 'nested', }, - { field: 'file.path.text', operator: 'included', type: 'match', value: '' }, + { field: 'file.path.caseless', operator: 'included', type: 'match', value: '' }, { field: 'file.hash.sha256', operator: 'included', type: 'match', value: '' }, { field: 'event.code', operator: 'included', type: 'match', value: '' }, ]); @@ -741,7 +660,6 @@ describe('Exception helpers', () => { test('it returns prepopulated items with values', () => { const prepopulatedItem = getPrepopulatedItem({ - listType: 'endpoint', listId: 'some_id', ruleName: 'my rule', codeSignature: { subjectName: 'someSubjectName', trusted: 'false' }, @@ -764,7 +682,12 @@ describe('Exception helpers', () => { field: 'file.Ext.code_signature', type: 'nested', }, - { field: 'file.path.text', operator: 'included', type: 'match', value: 'some-file-path' }, + { + field: 'file.path.caseless', + operator: 'included', + type: 'match', + value: 'some-file-path', + }, { field: 'file.hash.sha256', operator: 'included', type: 'match', value: 'some-hash' }, { field: 'event.code', operator: 'included', type: 'match', value: 'some-event-code' }, ]); @@ -847,7 +770,7 @@ describe('Exception helpers', () => { describe('defaultEndpointExceptionItems', () => { test('it should return pre-populated items', () => { - const defaultItems = defaultEndpointExceptionItems('endpoint', 'list_id', 'my_rule', { + const defaultItems = defaultEndpointExceptionItems('list_id', 'my_rule', { _id: '123', file: { Ext: { @@ -881,7 +804,7 @@ describe('Exception helpers', () => { type: 'nested', }, { - field: 'file.path.text', + field: 'file.path.caseless', operator: 'included', type: 'match', value: 'some file path', @@ -904,7 +827,7 @@ describe('Exception helpers', () => { type: 'nested', }, { - field: 'file.path.text', + field: 'file.path.caseless', operator: 'included', type: 'match', value: 'some file path', diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx index d4acfa39f995d..684f3390ae41a 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/helpers.tsx @@ -6,7 +6,7 @@ import React from 'react'; import { EuiText, EuiCommentProps, EuiAvatar } from '@elastic/eui'; -import { capitalize, union } from 'lodash'; +import { capitalize } from 'lodash'; import moment from 'moment'; import uuid from 'uuid'; @@ -33,8 +33,8 @@ import { createExceptionListItemSchema, exceptionListItemSchema, UpdateExceptionListItemSchema, - ExceptionListType, EntryNested, + OsTypeArray, } from '../../../shared_imports'; import { IIndexPattern } from '../../../../../../../src/plugins/data/common'; import { validate } from '../../../../common/validate'; @@ -98,20 +98,12 @@ export const getEntryValue = (item: BuilderEntry): string | string[] | undefined } }; -/** - * Retrieves the values of tags marked as os - * - * @param tags an ExceptionItem's tags - */ -export const getOperatingSystems = (tags: string[]): string[] => { - return tags.filter((tag) => tag.startsWith('os:')).map((os) => os.substring(3).trim()); -}; - /** * Formats os value array to a displayable string */ export const formatOperatingSystems = (osTypes: string[]): string => { return osTypes + .filter((os) => ['linux', 'macos', 'windows'].includes(os)) .map((os) => { if (os === 'macos') { return 'macOS'; @@ -121,21 +113,6 @@ export const formatOperatingSystems = (osTypes: string[]): string => { .join(', '); }; -/** - * Returns all tags that match a given regex - */ -export const getTagsInclude = ({ - tags, - regex, -}: { - tags: string[]; - regex: RegExp; -}): [boolean, string | null] => { - const matches: string[] | null = tags.join(';').match(regex); - const match = matches != null ? matches[1] : null; - return [matches != null, match]; -}; - /** * Formats ExceptionItem.comments into EuiCommentList format * @@ -158,18 +135,15 @@ export const getFormattedComments = (comments: CommentsArray): EuiCommentProps[] })); export const getNewExceptionItem = ({ - listType, listId, namespaceType, ruleName, }: { - listType: ExceptionListType; listId: string; namespaceType: NamespaceType; ruleName: string; }): CreateExceptionListItemBuilderSchema => { return { - _tags: [listType], comments: [], description: `${ruleName} - exception list item`, entries: [ @@ -326,14 +300,12 @@ export const enrichExistingExceptionItemWithComments = ( */ export const enrichExceptionItemsWithOS = ( exceptionItems: Array, - osTypes: string[] + osTypes: OsTypeArray ): Array => { - const osTags = osTypes.map((os) => `os:${os}`); return exceptionItems.map((item: ExceptionListItemSchema | CreateExceptionListItemSchema) => { - const newTags = item._tags ? union(item._tags, osTags) : [...osTags]; return { ...item, - _tags: newTags, + os_types: osTypes, }; }); }; @@ -419,7 +391,6 @@ export const getCodeSignatureValue = ( * Returns the default values from the alert data to autofill new endpoint exceptions */ export const getPrepopulatedItem = ({ - listType, listId, ruleName, codeSignature, @@ -428,7 +399,6 @@ export const getPrepopulatedItem = ({ eventCode, listNamespace = 'agnostic', }: { - listType: ExceptionListType; listId: string; listNamespace?: NamespaceType; ruleName: string; @@ -438,7 +408,7 @@ export const getPrepopulatedItem = ({ eventCode: string; }): ExceptionsBuilderExceptionItem => { return { - ...getNewExceptionItem({ listType, listId, namespaceType: listNamespace, ruleName }), + ...getNewExceptionItem({ listId, namespaceType: listNamespace, ruleName }), entries: [ { field: 'file.Ext.code_signature', @@ -459,7 +429,7 @@ export const getPrepopulatedItem = ({ ], }, { - field: 'file.path.text', + field: 'file.path.caseless', operator: 'included', type: 'match', value: filePath ?? '', @@ -514,7 +484,6 @@ export const entryHasNonEcsType = ( * Returns the default values from the alert data to autofill new endpoint exceptions */ export const defaultEndpointExceptionItems = ( - listType: ExceptionListType, listId: string, ruleName: string, alertEcsData: Ecs @@ -523,7 +492,6 @@ export const defaultEndpointExceptionItems = ( return getCodeSignatureValue(alertEcsData).map((codeSignature) => getPrepopulatedItem({ - listType, listId, ruleName, filePath: file && file.path ? file.path[0] : '', diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/use_fetch_or_create_rule_exception_list.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/use_fetch_or_create_rule_exception_list.tsx index 944631d4e9fb5..38cf5722fa894 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/use_fetch_or_create_rule_exception_list.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/use_fetch_or_create_rule_exception_list.tsx @@ -82,7 +82,6 @@ export const useFetchOrCreateRuleExceptionList = ({ type: exceptionListType, namespace_type: 'single', list_id: undefined, - _tags: undefined, tags: undefined, meta: undefined, }; diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/index.stories.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/index.stories.tsx index 39f34ae8a3cf3..a1fa0884b6b0c 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/index.stories.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/exception_item/index.stories.tsx @@ -43,7 +43,6 @@ storiesOf('Components/ExceptionItem', module) }) .add('with description', () => { const payload = getExceptionListItemSchemaMock(); - payload._tags = []; payload.comments = []; payload.entries = [ { @@ -66,7 +65,6 @@ storiesOf('Components/ExceptionItem', module) }) .add('with comments', () => { const payload = getExceptionListItemSchemaMock(); - payload._tags = []; payload.description = ''; payload.comments = getCommentsArrayMock(); payload.entries = [ @@ -90,7 +88,6 @@ storiesOf('Components/ExceptionItem', module) }) .add('with nested entries', () => { const payload = getExceptionListItemSchemaMock(); - payload._tags = []; payload.description = ''; payload.comments = []; diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/helpers.test.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/helpers.test.tsx index 5f6e54b0d3cff..dbd4c805aa950 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/helpers.test.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/helpers.test.tsx @@ -175,10 +175,13 @@ describe('Exception viewer helpers', () => { test('it returns formatted description list with a description if one specified', () => { const payload = getExceptionListItemSchemaMock(); - payload._tags = []; payload.description = 'Im a description'; const result = getDescriptionListContent(payload); const expected: DescriptionListItem[] = [ + { + description: 'Linux', + title: 'OS', + }, { description: 'April 20th 2020 @ 15:25:31', title: 'Date created', @@ -198,10 +201,13 @@ describe('Exception viewer helpers', () => { test('it returns just user and date created if no other fields specified', () => { const payload = getExceptionListItemSchemaMock(); - payload._tags = []; payload.description = ''; const result = getDescriptionListContent(payload); const expected: DescriptionListItem[] = [ + { + description: 'Linux', + title: 'OS', + }, { description: 'April 20th 2020 @ 15:25:31', title: 'Date created', diff --git a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/helpers.tsx b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/helpers.tsx index 86b0512410e6f..edc3d20b03e5a 100644 --- a/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/helpers.tsx +++ b/x-pack/plugins/security_solution/public/common/components/exceptions/viewer/helpers.tsx @@ -6,12 +6,7 @@ import moment from 'moment'; import { entriesNested, ExceptionListItemSchema } from '../../../../lists_plugin_deps'; -import { - getEntryValue, - getExceptionOperatorSelect, - formatOperatingSystems, - getOperatingSystems, -} from '../helpers'; +import { getEntryValue, getExceptionOperatorSelect, formatOperatingSystems } from '../helpers'; import { FormattedEntry, BuilderEntry, DescriptionListItem } from '../types'; import * as i18n from '../translations'; @@ -80,7 +75,7 @@ export const getDescriptionListContent = ( const details = [ { title: i18n.OPERATING_SYSTEM, - value: formatOperatingSystems(getOperatingSystems(exceptionItem._tags ?? [])), + value: formatOperatingSystems(exceptionItem.os_types), }, { title: i18n.DATE_CREATED, diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/index.test.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/index.test.tsx index 165aa5a30b0f0..aea75b5b3b796 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/index.test.tsx @@ -14,6 +14,14 @@ jest.mock('../../../../common/lib/kibana', () => ({ services: { application: { getUrlForApp: jest.fn(), + capabilities: { + siem: { + crud: true, + }, + actions: { + read: true, + }, + }, }, triggers_actions_ui: { actionTypeRegistry: jest.fn(), diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/index.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/index.tsx index e6f1c25bf9dac..77168e62492c6 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/index.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/index.tsx @@ -11,6 +11,7 @@ import { EuiFlexItem, EuiButton, EuiSpacer, + EuiText, } from '@elastic/eui'; import { findIndex } from 'lodash/fp'; import React, { FC, memo, useCallback, useEffect, useMemo } from 'react'; @@ -141,37 +142,61 @@ const StepRuleActionsComponent: FC = ({ [isLoading, throttleOptions] ); - return isReadOnlyView ? ( - - - + if (isReadOnlyView) { + return ( + + + + ); + } + + const displayActionsOptions = + throttle !== stepActionsDefaultValue.throttle ? ( + <> + + + + ) : ( + + ); + + // only display the actions dropdown if the user has "read" privileges for actions + const displayActionsDropDown = application.capabilities.actions.show ? ( + <> + + {displayActionsOptions} + + + ) : ( + <> + {I18n.NO_ACTIONS_READ_PERMISSIONS} + + + + + + ); + + return ( <>
- - - {throttle !== stepActionsDefaultValue.throttle ? ( - <> - - - - ) : ( - - )} - - - + {displayActionsDropDown}
diff --git a/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/translations.tsx b/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/translations.tsx index a276cb17e95f5..b2677b761aabf 100644 --- a/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/translations.tsx +++ b/x-pack/plugins/security_solution/public/detections/components/rules/step_rule_actions/translations.tsx @@ -28,6 +28,14 @@ export const NO_CONNECTOR_SELECTED = i18n.translate( } ); +export const NO_ACTIONS_READ_PERMISSIONS = i18n.translate( + 'xpack.securitySolution.detectionEngine.createRule.stepRuleActions.noReadActionsPrivileges', + { + defaultMessage: + 'Cannot create rule actions. You do not have "Read" permissions for the "Actions" plugin.', + } +); + export const INVALID_MUSTACHE_TEMPLATE = (paramKey: string) => i18n.translate( 'xpack.securitySolution.detectionEngine.createRule.stepRuleActions.invalidMustacheTemplateErrorMessage', diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/columns.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/columns.tsx index 3666e16dcbaf8..c0786f5234157 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/columns.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/columns.tsx @@ -7,7 +7,6 @@ /* eslint-disable react/display-name */ import { - EuiBadge, EuiBasicTableColumn, EuiTableActionsColumnType, EuiText, @@ -24,7 +23,6 @@ import { getEmptyTagValue } from '../../../../../common/components/empty_value'; import { FormattedDate } from '../../../../../common/components/formatted_date'; import { getRuleDetailsUrl } from '../../../../../common/components/link_to/redirect_to_detection_engine'; import { ActionToaster } from '../../../../../common/components/toasters'; -import { TruncatableText } from '../../../../../common/components/truncatable_text'; import { getStatusColor } from '../../../../components/rules/rule_status/helpers'; import { RuleSwitch } from '../../../../components/rules/rule_switch'; import { SeverityBadge } from '../../../../components/rules/severity_badge'; @@ -39,6 +37,7 @@ import { Action } from './reducer'; import { LocalizedDateTooltip } from '../../../../../common/components/localized_date_tooltip'; import * as detectionI18n from '../../translations'; import { LinkAnchor } from '../../../../../common/components/links'; +import { TagsDisplay } from './tag_display'; export const getActions = ( dispatch: React.Dispatch, @@ -207,22 +206,19 @@ export const getColumns = ({ ); }, truncateText: true, - width: '10%', + width: '8%', }, { field: 'tags', name: i18n.COLUMN_TAGS, - render: (value: Rule['tags']) => ( - - {value.map((tag, i) => ( - - {tag} - - ))} - - ), + render: (value: Rule['tags']) => { + if (value.length > 0) { + return ; + } + return getEmptyTagValue(); + }, truncateText: true, - width: '14%', + width: '20%', }, { align: 'center', diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/helpers.test.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/helpers.test.tsx index 062d7967bf301..e06aea2fb2785 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/helpers.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/helpers.test.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { bucketRulesResponse, showRulesTable } from './helpers'; +import { bucketRulesResponse, caseInsensitiveSort, showRulesTable } from './helpers'; import { mockRule, mockRuleError } from './__mocks__/mock'; import uuid from 'uuid'; import { Rule, RuleError } from '../../../../containers/detection_engine/rules'; @@ -86,4 +86,15 @@ describe('AllRulesTable Helpers', () => { expect(result).toBeTruthy(); }); }); + + describe('caseInsensitiveSort', () => { + describe('when an array of differently cased tags is passed', () => { + const unsortedTags = ['atest', 'Ctest', 'Btest', 'ctest', 'btest', 'Atest']; + const result = caseInsensitiveSort(unsortedTags); + it('returns an alphabetically sorted array with no regard for casing', () => { + const expected = ['atest', 'Atest', 'Btest', 'btest', 'Ctest', 'ctest']; + expect(result).toEqual(expected); + }); + }); + }); }); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/helpers.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/helpers.ts index 0ebeb84d57468..c54c9ed9b8ef9 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/helpers.ts +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/helpers.ts @@ -33,3 +33,7 @@ export const showRulesTable = ({ }) => (rulesCustomInstalled != null && rulesCustomInstalled > 0) || (rulesInstalled != null && rulesInstalled > 0); + +export const caseInsensitiveSort = (tags: string[]): string[] => { + return tags.sort((a: string, b: string) => a.toLowerCase().localeCompare(b.toLowerCase())); // Case insensitive +}; diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/index.test.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/index.test.tsx index 13c6985a30c2b..f8ed57aa7537e 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/index.test.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/index.test.tsx @@ -5,7 +5,8 @@ */ import React from 'react'; -import { shallow, mount } from 'enzyme'; +import { shallow, mount, ReactWrapper } from 'enzyme'; +import { act } from 'react-dom/test-utils'; import '../../../../../common/mock/match_media'; import '../../../../../common/mock/formatted_relative'; @@ -179,27 +180,34 @@ describe('AllRules', () => { expect(wrapper.find('[title="All rules"]')).toHaveLength(1); }); - it('renders rules tab', async () => { - const wrapper = mount( - - - - ); + describe('rules tab', () => { + let wrapper: ReactWrapper; + beforeEach(() => { + wrapper = mount( + + + + ); + }); - await waitFor(() => { - expect(wrapper.exists('[data-test-subj="monitoring-table"]')).toBeFalsy(); - expect(wrapper.exists('[data-test-subj="rules-table"]')).toBeTruthy(); + it('renders correctly', async () => { + await act(async () => { + await waitFor(() => { + expect(wrapper.exists('[data-test-subj="monitoring-table"]')).toBeFalsy(); + expect(wrapper.exists('[data-test-subj="rules-table"]')).toBeTruthy(); + }); + }); }); }); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_table_filters/tags_filter_popover.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_table_filters/tags_filter_popover.tsx index 4fe0bc8f835df..9748dde91d18b 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_table_filters/tags_filter_popover.tsx +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/rules_table_filters/tags_filter_popover.tsx @@ -27,6 +27,7 @@ import { import styled from 'styled-components'; import * as i18n from '../../translations'; import { toggleSelectedGroup } from '../../../../../../common/components/ml_popover/jobs_table/filters/toggle_selected_group'; +import { caseInsensitiveSort } from '../helpers'; interface TagsFilterPopoverProps { selectedTags: string[]; @@ -36,9 +37,19 @@ interface TagsFilterPopoverProps { isLoading: boolean; // TO DO reimplement? } +const PopoverContentWrapper = styled.div` + width: 275px; +`; + const ScrollableDiv = styled.div` max-height: 250px; - overflow: auto; + overflow-y: auto; +`; + +const TagOverflowContainer = styled.span` + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; `; /** @@ -52,9 +63,7 @@ const TagsFilterPopoverComponent = ({ selectedTags, onSelectedTagsChanged, }: TagsFilterPopoverProps) => { - const sortedTags = useMemo(() => { - return tags.sort((a: string, b: string) => a.toLowerCase().localeCompare(b.toLowerCase())); // Case insensitive - }, [tags]); + const sortedTags = useMemo(() => caseInsensitiveSort(tags), [tags]); const [isTagPopoverOpen, setIsTagPopoverOpen] = useState(false); const [searchInput, setSearchInput] = useState(''); const [filterTags, setFilterTags] = useState(sortedTags); @@ -65,8 +74,9 @@ const TagsFilterPopoverComponent = ({ checked={selectedTags.includes(tag) ? 'on' : undefined} key={`${index}-${tag}`} onClick={() => toggleSelectedGroup(tag, selectedTags, onSelectedTagsChanged)} + title={tag} > - {`${tag}`} + {tag} )); }, [onSelectedTagsChanged, selectedTags, filterTags]); @@ -101,25 +111,27 @@ const TagsFilterPopoverComponent = ({ panelPaddingSize="none" repositionOnScroll > - - - - {tagsComponent} - {filterTags.length === 0 && ( - - - - {i18n.NO_TAGS_AVAILABLE} - - - - )} + + + + + {tagsComponent} + {filterTags.length === 0 && ( + + + + {i18n.NO_TAGS_AVAILABLE} + + + + )} + ); }; diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/tag_display.test.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/tag_display.test.tsx new file mode 100644 index 0000000000000..3e60bb2d3168b --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/tag_display.test.tsx @@ -0,0 +1,47 @@ +/* + * 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 React from 'react'; +import { mount, ReactWrapper } from 'enzyme'; + +import { TagsDisplay } from './tag_display'; +import { TestProviders } from '../../../../../common/mock'; +import { waitFor } from '@testing-library/react'; + +const mockTags = ['Elastic', 'Endpoint', 'Data Protection', 'ML', 'Continuous Monitoring']; + +describe('When tag display loads', () => { + let wrapper: ReactWrapper; + beforeEach(() => { + wrapper = mount( + + + + ); + }); + it('visibly renders 3 initial tags', () => { + for (let i = 0; i < 3; i++) { + expect(wrapper.exists(`[data-test-subj="rules-table-column-tags-${i}"]`)).toBeTruthy(); + } + }); + describe("when the 'see all' button is clicked", () => { + beforeEach(() => { + const seeAllButton = wrapper.find('[data-test-subj="tags-display-popover-button"] button'); + seeAllButton.simulate('click'); + }); + it('renders all the tags in the popover', async () => { + await waitFor(() => { + wrapper.update(); + expect(wrapper.exists('[data-test-subj="tags-display-popover"]')).toBeTruthy(); + for (let i = 0; i < mockTags.length; i++) { + expect( + wrapper.exists(`[data-test-subj="rules-table-column-popover-tags-${i}"]`) + ).toBeTruthy(); + } + }); + }); + }); +}); diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/tag_display.tsx b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/tag_display.tsx new file mode 100644 index 0000000000000..9503f5b884133 --- /dev/null +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/all/tag_display.tsx @@ -0,0 +1,104 @@ +/* + * 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 React, { useMemo, useState } from 'react'; +import { EuiPopover, EuiBadgeGroup, EuiBadge, EuiButtonEmpty } from '@elastic/eui'; +import styled from 'styled-components'; +import * as i18n from '../translations'; +import { caseInsensitiveSort } from './helpers'; + +interface TagsDisplayProps { + tags: string[]; +} + +const TagWrapper = styled(EuiBadgeGroup)` + width: 100%; +`; + +const TagPopoverWrapper = styled(EuiBadgeGroup)` + max-height: 200px; + max-width: 600px; + overflow: auto; +`; + +const TagPopoverButton = styled(EuiButtonEmpty)` + font-size: ${({ theme }) => theme.eui.euiFontSizeXS} + font-weight: 500; + height: 20px; +`; + +/** + * @param tags to display for filtering + */ +const TagsDisplayComponent = ({ tags }: TagsDisplayProps) => { + const [isTagPopoverOpen, setIsTagPopoverOpen] = useState(false); + const sortedTags = useMemo(() => caseInsensitiveSort(tags), [tags]); + + return ( + <> + {sortedTags.length <= 3 ? ( + + {sortedTags.map((tag: string, i: number) => ( + + {tag} + + ))} + + ) : ( + + {sortedTags.slice(0, 3).map((tag: string, i: number) => ( + + {tag} + + ))} + + setIsTagPopoverOpen(!isTagPopoverOpen)} + > + {i18n.COLUMN_SEE_ALL_POPOVER} + + } + isOpen={isTagPopoverOpen} + closePopover={() => setIsTagPopoverOpen(!isTagPopoverOpen)} + repositionOnScroll + > + + {sortedTags.map((tag: string, i: number) => ( + + {tag} + + ))} + + + + )} + + ); +}; + +export const TagsDisplay = React.memo(TagsDisplayComponent); + +TagsDisplay.displayName = 'TagsDisplay'; diff --git a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/translations.ts b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/translations.ts index 09503fcf1ef0f..f2f3265ead1ba 100644 --- a/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/translations.ts +++ b/x-pack/plugins/security_solution/public/detections/pages/detection_engine/rules/translations.ts @@ -315,6 +315,13 @@ export const COLUMN_TAGS = i18n.translate( } ); +export const COLUMN_SEE_ALL_POPOVER = i18n.translate( + 'xpack.securitySolution.detectionEngine.rules.allRules.columns.tagsPopoverTitle', + { + defaultMessage: 'See all', + } +); + export const COLUMN_ACTIVATE = i18n.translate( 'xpack.securitySolution.detectionEngine.rules.allRules.columns.activateTitle', { diff --git a/x-pack/plugins/security_solution/public/management/common/routing.test.ts b/x-pack/plugins/security_solution/public/management/common/routing.test.ts index 7082ab0ce5c4f..47f2f729446bd 100644 --- a/x-pack/plugins/security_solution/public/management/common/routing.test.ts +++ b/x-pack/plugins/security_solution/public/management/common/routing.test.ts @@ -6,6 +6,7 @@ import { extractTrustedAppsListPageLocation, getTrustedAppsListPath } from './routing'; import { MANAGEMENT_DEFAULT_PAGE, MANAGEMENT_DEFAULT_PAGE_SIZE } from './constants'; +import { TrustedAppsListPageLocation } from '../pages/trusted_apps/state'; describe('routing', () => { describe('extractListPaginationParams()', () => { @@ -56,6 +57,42 @@ describe('routing', () => { it('extracts proper page size when single valid value provided', () => { expect(extractTrustedAppsListPageLocation({ page_size: '20' }).page_size).toBe(20); }); + + it('extracts proper "show" when single valid value provided', () => { + expect(extractTrustedAppsListPageLocation({ show: 'create' }).show).toBe('create'); + }); + + it('extracts only last "show" when multiple values provided', () => { + expect(extractTrustedAppsListPageLocation({ show: ['invalid', 'create'] }).show).toBe( + 'create' + ); + }); + + it('extracts default "show" when no value provided', () => { + expect(extractTrustedAppsListPageLocation({}).show).toBeUndefined(); + }); + + it('extracts default "show" when single invalid value provided', () => { + expect(extractTrustedAppsListPageLocation({ show: 'invalid' }).show).toBeUndefined(); + }); + + it('extracts proper view type when single valid value provided', () => { + expect(extractTrustedAppsListPageLocation({ view_type: 'list' }).view_type).toBe('list'); + }); + + it('extracts only last view type when multiple values provided', () => { + expect(extractTrustedAppsListPageLocation({ view_type: ['grid', 'list'] }).view_type).toBe( + 'list' + ); + }); + + it('extracts default view type when no value provided', () => { + expect(extractTrustedAppsListPageLocation({}).view_type).toBe('grid'); + }); + + it('extracts default view type when single invalid value provided', () => { + expect(extractTrustedAppsListPageLocation({ view_type: 'invalid' }).view_type).toBe('grid'); + }); }); describe('getTrustedAppsListPath()', () => { @@ -67,17 +104,32 @@ describe('routing', () => { expect(getTrustedAppsListPath({})).toEqual('/trusted_apps'); }); - it('builds proper path when no page index provided', () => { + it('builds proper path when only page size provided', () => { expect(getTrustedAppsListPath({ page_size: 20 })).toEqual('/trusted_apps?page_size=20'); }); - it('builds proper path when no page size provided', () => { + it('builds proper path when only page index provided', () => { expect(getTrustedAppsListPath({ page_index: 2 })).toEqual('/trusted_apps?page_index=2'); }); - it('builds proper path when both page index and size provided', () => { - expect(getTrustedAppsListPath({ page_index: 2, page_size: 20 })).toEqual( - '/trusted_apps?page_index=2&page_size=20' + it('builds proper path when only "show" provided', () => { + expect(getTrustedAppsListPath({ show: 'create' })).toEqual('/trusted_apps?show=create'); + }); + + it('builds proper path when only view type provided', () => { + expect(getTrustedAppsListPath({ view_type: 'list' })).toEqual('/trusted_apps?view_type=list'); + }); + + it('builds proper path when all params provided', () => { + const location: TrustedAppsListPageLocation = { + page_index: 2, + page_size: 20, + show: 'create', + view_type: 'list', + }; + + expect(getTrustedAppsListPath(location)).toEqual( + '/trusted_apps?page_index=2&page_size=20&view_type=list&show=create' ); }); @@ -85,24 +137,52 @@ describe('routing', () => { const path = getTrustedAppsListPath({ page_index: MANAGEMENT_DEFAULT_PAGE, page_size: 20, + show: 'create', + view_type: 'list', }); - expect(path).toEqual('/trusted_apps?page_size=20'); + expect(path).toEqual('/trusted_apps?page_size=20&view_type=list&show=create'); }); it('builds proper path when page size is equal to default', () => { const path = getTrustedAppsListPath({ page_index: 2, page_size: MANAGEMENT_DEFAULT_PAGE_SIZE, + show: 'create', + view_type: 'list', + }); + + expect(path).toEqual('/trusted_apps?page_index=2&view_type=list&show=create'); + }); + + it('builds proper path when "show" is equal to default', () => { + const path = getTrustedAppsListPath({ + page_index: 2, + page_size: 20, + show: undefined, + view_type: 'list', + }); + + expect(path).toEqual('/trusted_apps?page_index=2&page_size=20&view_type=list'); + }); + + it('builds proper path when view type is equal to default', () => { + const path = getTrustedAppsListPath({ + page_index: 2, + page_size: 20, + show: 'create', + view_type: 'grid', }); - expect(path).toEqual('/trusted_apps?page_index=2'); + expect(path).toEqual('/trusted_apps?page_index=2&page_size=20&show=create'); }); - it('builds proper path when both page index and size are equal to default', () => { + it('builds proper path when params are equal to default', () => { const path = getTrustedAppsListPath({ page_index: MANAGEMENT_DEFAULT_PAGE, page_size: MANAGEMENT_DEFAULT_PAGE_SIZE, + show: undefined, + view_type: 'grid', }); expect(path).toEqual('/trusted_apps'); diff --git a/x-pack/plugins/security_solution/public/management/common/routing.ts b/x-pack/plugins/security_solution/public/management/common/routing.ts index 9acf4a1613c0b..b3162a8708a5f 100644 --- a/x-pack/plugins/security_solution/public/management/common/routing.ts +++ b/x-pack/plugins/security_solution/public/management/common/routing.ts @@ -105,6 +105,7 @@ const normalizeTrustedAppsPageLocation = ( ...(!isDefaultOrMissing(location.page_size, MANAGEMENT_DEFAULT_PAGE_SIZE) ? { page_size: location.page_size } : {}), + ...(!isDefaultOrMissing(location.view_type, 'grid') ? { view_type: location.view_type } : {}), ...(!isDefaultOrMissing(location.show, undefined) ? { show: location.show } : {}), }; } else { @@ -144,13 +145,16 @@ export const extractTrustedAppsListPageLocation = ( query: querystring.ParsedUrlQuery ): TrustedAppsListPageLocation => ({ ...extractListPaginationParams(query), + view_type: extractFirstParamValue(query, 'view_type') === 'list' ? 'list' : 'grid', show: extractFirstParamValue(query, 'show') === 'create' ? 'create' : undefined, }); -export const getTrustedAppsListPath = (params?: Partial): string => { +export const getTrustedAppsListPath = (location?: Partial): string => { const path = generatePath(MANAGEMENT_ROUTING_TRUSTED_APPS_PATH, { tabName: AdministrationSubTab.trustedApps, }); - return `${path}${appendSearch(querystring.stringify(normalizeTrustedAppsPageLocation(params)))}`; + return `${path}${appendSearch( + querystring.stringify(normalizeTrustedAppsPageLocation(location)) + )}`; }; diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/state/trusted_apps_list_page_state.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/state/trusted_apps_list_page_state.ts index a98ec03a006f5..e58de80c354a8 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/state/trusted_apps_list_page_state.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/state/trusted_apps_list_page_state.ts @@ -38,9 +38,12 @@ export interface TrustedAppCreateFailure { data: ServerApiError; } +export type ViewType = 'list' | 'grid'; + export interface TrustedAppsListPageLocation { page_index: number; page_size: number; + view_type: ViewType; show?: 'create'; } diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/middleware.test.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/middleware.test.ts index 2143b5135c575..e19731f28d9b9 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/middleware.test.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/middleware.test.ts @@ -75,7 +75,7 @@ describe('middleware', () => { describe('refreshing list resource state', () => { it('refreshes the list when location changes and data gets outdated', async () => { const pagination = { index: 2, size: 50 }; - const location = { page_index: 2, page_size: 50, show: undefined }; + const location = { page_index: 2, page_size: 50, show: undefined, view_type: 'grid' }; const service = createTrustedAppsServiceMock(); const { store, spyMiddleware } = createStoreSetup(service); @@ -110,7 +110,7 @@ describe('middleware', () => { it('does not refresh the list when location changes and data does not get outdated', async () => { const pagination = { index: 2, size: 50 }; - const location = { page_index: 2, page_size: 50, show: undefined }; + const location = { page_index: 2, page_size: 50, show: undefined, view_type: 'grid' }; const service = createTrustedAppsServiceMock(); const { store, spyMiddleware } = createStoreSetup(service); @@ -136,7 +136,7 @@ describe('middleware', () => { it('refreshes the list when data gets outdated with and outdate action', async () => { const newNow = 222222; const pagination = { index: 0, size: 10 }; - const location = { page_index: 0, page_size: 10, show: undefined }; + const location = { page_index: 0, page_size: 10, show: undefined, view_type: 'grid' }; const service = createTrustedAppsServiceMock(); const { store, spyMiddleware } = createStoreSetup(service); @@ -196,7 +196,7 @@ describe('middleware', () => { freshDataTimestamp: initialNow, }, active: true, - location: { page_index: 2, page_size: 50, show: undefined }, + location: { page_index: 2, page_size: 50, show: undefined, view_type: 'grid' }, }); const infiniteLoopTest = async () => { @@ -212,7 +212,7 @@ describe('middleware', () => { const entry = createSampleTrustedApp(3); const notFoundError = createServerApiError('Not Found'); const pagination = { index: 0, size: 10 }; - const location = { page_index: 0, page_size: 10, show: undefined }; + const location = { page_index: 0, page_size: 10, show: undefined, view_type: 'grid' }; const getTrustedAppsListResponse = createGetTrustedListAppsResponse(pagination, 500); const listView = createLoadedListViewWithPagination(initialNow, pagination, 500); const listViewNew = createLoadedListViewWithPagination(newNow, pagination, 500); diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/reducer.test.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/reducer.test.ts index 94fcdb39bb169..1c1d609fb16e8 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/reducer.test.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/reducer.test.ts @@ -24,31 +24,34 @@ const initialState = initialTrustedAppsPageState(); describe('reducer', () => { describe('UserChangedUrl', () => { - it('makes page state active and extracts pagination parameters', () => { + it('makes page state active and extracts all parameters', () => { const result = trustedAppsPageReducer( initialState, - createUserChangedUrlAction('/trusted_apps', '?page_index=5&page_size=50') + createUserChangedUrlAction( + '/trusted_apps', + '?page_index=5&page_size=50&show=create&view_type=list' + ) ); expect(result).toStrictEqual({ ...initialState, - location: { page_index: 5, page_size: 50, show: undefined }, + location: { page_index: 5, page_size: 50, show: 'create', view_type: 'list' }, active: true, }); }); - it('extracts default pagination parameters when none provided', () => { + it('extracts default pagination parameters when invalid provided', () => { const result = trustedAppsPageReducer( - { ...initialState, location: { page_index: 5, page_size: 50 } }, - createUserChangedUrlAction('/trusted_apps', '?page_index=b&page_size=60') + { ...initialState, location: { page_index: 5, page_size: 50, view_type: 'grid' } }, + createUserChangedUrlAction('/trusted_apps', '?page_index=b&page_size=60&show=a&view_type=c') ); expect(result).toStrictEqual({ ...initialState, active: true }); }); - it('extracts default pagination parameters when invalid provided', () => { + it('extracts default pagination parameters when none provided', () => { const result = trustedAppsPageReducer( - { ...initialState, location: { page_index: 5, page_size: 50 } }, + { ...initialState, location: { page_index: 5, page_size: 50, view_type: 'grid' } }, createUserChangedUrlAction('/trusted_apps') ); diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/reducer.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/reducer.ts index f4056f02a4140..efabbf20e629e 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/reducer.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/reducer.ts @@ -152,6 +152,7 @@ export const initialTrustedAppsPageState = (): TrustedAppsListPageState => ({ page_index: MANAGEMENT_DEFAULT_PAGE, page_size: MANAGEMENT_DEFAULT_PAGE_SIZE, show: undefined, + view_type: 'grid', }, active: false, }); diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/selectors.test.ts b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/selectors.test.ts index 01fe3e5bf202e..8706333cb7bf6 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/selectors.test.ts +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/store/selectors.test.ts @@ -4,7 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import { AsyncResourceState, TrustedAppsListPageState } from '../state'; +import { + AsyncResourceState, + TrustedAppsListPageLocation, + TrustedAppsListPageState, +} from '../state'; import { initialTrustedAppsPageState } from './reducer'; import { getListResourceState, @@ -84,7 +88,11 @@ describe('selectors', () => { it('returns false when current loaded data is up to date', () => { const listView = createLoadedListViewWithPagination(initialNow); - const location = { page_index: 0, page_size: 10 }; + const location: TrustedAppsListPageLocation = { + page_index: 0, + page_size: 10, + view_type: 'grid', + }; expect(needsRefreshOfListData({ ...initialState, listView, active: true, location })).toBe( false @@ -166,17 +174,25 @@ describe('selectors', () => { describe('getListCurrentPageIndex()', () => { it('returns page index', () => { - const state = { ...initialState, location: { page_index: 3, page_size: 10 } }; + const location: TrustedAppsListPageLocation = { + page_index: 3, + page_size: 10, + view_type: 'grid', + }; - expect(getCurrentLocationPageIndex(state)).toBe(3); + expect(getCurrentLocationPageIndex({ ...initialState, location })).toBe(3); }); }); describe('getListCurrentPageSize()', () => { it('returns page size', () => { - const state = { ...initialState, location: { page_index: 0, page_size: 20 } }; + const location: TrustedAppsListPageLocation = { + page_index: 0, + page_size: 20, + view_type: 'grid', + }; - expect(getCurrentLocationPageSize(state)).toBe(20); + expect(getCurrentLocationPageSize({ ...initialState, location })).toBe(20); }); }); diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/create_trusted_app_form.test.tsx b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/create_trusted_app_form.test.tsx index dd4f389f04705..211fc9ec3371e 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/create_trusted_app_form.test.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/create_trusted_app_form.test.tsx @@ -76,7 +76,7 @@ describe('When showing the Trusted App Create Form', () => { dataTestSub: string = dataTestSubjForForm ): HTMLButtonElement => { return renderResult.getByTestId( - `${dataTestSub}-conditionsBuilder-AndButton` + `${dataTestSub}-conditionsBuilder-group1-AndButton` ) as HTMLButtonElement; }; const getConditionBuilderAndConnectorBadge = ( diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/logical_condition/components/condition_entry.tsx b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/logical_condition/components/condition_entry.tsx index 7d30e81898cf2..55d0622c3f9a9 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/logical_condition/components/condition_entry.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/logical_condition/components/condition_entry.tsx @@ -83,7 +83,7 @@ export const ConditionEntry = memo( 'xpack.securitySolution.trustedapps.logicalConditionBuilder.entry.field.path', { defaultMessage: 'Path' } ), - value: 'process.executable.text', + value: 'process.executable.caseless', }, ]; }, []); @@ -130,6 +130,7 @@ export const ConditionEntry = memo( alignItems="center" direction="row" data-test-subj={dataTestSubj} + responsive={false} > ( { + return `calc(${theme.eui.euiButtonHeightSmall} + (${theme.eui.paddingSizes.s} * 2) + 3px);`; + }}; + } + + .group-entries { + margin-bottom: ${({ theme }) => theme.eui.paddingSizes.s}; + + & > * { + margin-bottom: ${({ theme }) => theme.eui.paddingSizes.s}; + + &:last-child { + margin-bottom: 0; + } + } + } + + .and-button { + min-width: 95px; + } `; export interface ConditionGroupProps { @@ -20,12 +45,23 @@ export interface ConditionGroupProps { entries: TrustedApp['entries']; onEntryRemove: ConditionEntryProps['onRemove']; onEntryChange: ConditionEntryProps['onChange']; + onAndClicked: () => void; + isAndDisabled?: boolean; /** called when any of the entries is visited (triggered via `onBlur` DOM event) */ onVisited?: ConditionEntryProps['onVisited']; 'data-test-subj'?: string; } export const ConditionGroup = memo( - ({ os, entries, onEntryRemove, onEntryChange, onVisited, 'data-test-subj': dataTestSubj }) => { + ({ + os, + entries, + onEntryRemove, + onEntryChange, + onAndClicked, + isAndDisabled, + onVisited, + 'data-test-subj': dataTestSubj, + }) => { const getTestId = useCallback( (suffix: string): string | undefined => { if (dataTestSubj) { @@ -35,28 +71,53 @@ export const ConditionGroup = memo( [dataTestSubj] ); return ( - + {entries.length > 1 && ( - - - + + + + + )} - - {(entries as (NewTrustedApp & { os: 'windows' })['entries']).map((entry, index) => ( - - ))} + +
+ {(entries as (NewTrustedApp & { os: 'windows' })['entries']).map((entry, index) => ( + + ))} +
+
+ + + + +
-
+ ); } ); diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/logical_condition/logical_condition_builder.tsx b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/logical_condition/logical_condition_builder.tsx index 84bfe1c1d2dc9..3ae2b13326b23 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/logical_condition/logical_condition_builder.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/logical_condition/logical_condition_builder.tsx @@ -5,17 +5,11 @@ */ import React, { memo, useCallback } from 'react'; -import { EuiButton, CommonProps, EuiText, EuiSpacer, EuiPanel } from '@elastic/eui'; +import { CommonProps, EuiText, EuiPanel } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; import { ConditionGroup, ConditionGroupProps } from './components/condition_group'; -const BUTTON_MIN_WIDTH = Object.freeze({ minWidth: '95px' }); - -export type LogicalConditionBuilderProps = CommonProps & - ConditionGroupProps & { - onAndClicked: () => void; - isAndDisabled?: boolean; - }; +export type LogicalConditionBuilderProps = CommonProps & ConditionGroupProps; export const LogicalConditionBuilder = memo( ({ entries, @@ -47,26 +41,13 @@ export const LogicalConditionBuilder = memo( entries={entries} onEntryRemove={onEntryRemove} onEntryChange={onEntryChange} + onAndClicked={onAndClicked} + isAndDisabled={isAndDisabled} onVisited={onVisited} data-test-subj={getTestId('group1')} /> )} - - - - ); } diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/trusted_app_card/index.stories.tsx b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/trusted_app_card/index.stories.tsx index 4b64030a702c5..1959e040d1860 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/trusted_app_card/index.stories.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/components/trusted_app_card/index.stories.tsx @@ -30,7 +30,7 @@ storiesOf('TrustedApps|TrustedAppCard', module) trustedApp.created_at = '2020-09-17T14:52:33.899Z'; trustedApp.entries = [ { - field: 'process.executable.text', + field: 'process.executable.caseless', operator: 'included', type: 'match', value: '/some/path/on/file/system', @@ -44,7 +44,7 @@ storiesOf('TrustedApps|TrustedAppCard', module) trustedApp.created_at = '2020-09-17T14:52:33.899Z'; trustedApp.entries = [ { - field: 'process.executable.text', + field: 'process.executable.caseless', operator: 'included', type: 'match', value: '/some/path/on/file/system', diff --git a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.tsx b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.tsx index d63cda5b513dc..c3add8cd5a9df 100644 --- a/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.tsx +++ b/x-pack/plugins/security_solution/public/management/pages/trusted_apps/view/trusted_apps_page.tsx @@ -84,7 +84,7 @@ export const TrustedAppsPage = memo(() => { {location.show === 'create' && ( )} diff --git a/x-pack/plugins/security_solution/scripts/endpoint/trusted_apps/index.ts b/x-pack/plugins/security_solution/scripts/endpoint/trusted_apps/index.ts index 3bd27259ad80c..03f0bf94a4264 100644 --- a/x-pack/plugins/security_solution/scripts/endpoint/trusted_apps/index.ts +++ b/x-pack/plugins/security_solution/scripts/endpoint/trusted_apps/index.ts @@ -67,7 +67,7 @@ const generateTrustedAppEntry: (options?: GenerateTrustedAppEntryOptions) => obj return { list_id: ENDPOINT_TRUSTED_APPS_LIST_ID, item_id: `generator_endpoint_trusted_apps_${generateUUID()}`, - _tags: ['endpoint', `os:${os}`], + os_types: [os], tags: ['user added string for a tag', 'malware'], type: 'simple', description: 'This is a sample agnostic endpoint trusted app entry', diff --git a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.test.ts b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.test.ts index a10ba9d6be38c..c1b97f2adfeab 100644 --- a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.test.ts @@ -62,7 +62,7 @@ describe('buildEventTypeSignal', () => { test('it should convert simple fields', async () => { const testEntries: EntriesArray = [ - { field: 'server.domain', operator: 'included', type: 'match', value: 'DOMAIN' }, + { field: 'host.os.full', operator: 'included', type: 'match', value: 'windows' }, { field: 'server.ip', operator: 'included', type: 'match', value: '192.168.1.1' }, { field: 'host.hostname', operator: 'included', type: 'match', value: 'estc' }, ]; @@ -71,10 +71,10 @@ describe('buildEventTypeSignal', () => { type: 'simple', entries: [ { - field: 'server.domain', + field: 'host.os.full', operator: 'included', type: 'exact_cased', - value: 'DOMAIN', + value: 'windows', }, { field: 'server.ip', @@ -108,10 +108,10 @@ describe('buildEventTypeSignal', () => { test('it should convert fields case sensitive', async () => { const testEntries: EntriesArray = [ - { field: 'server.domain.text', operator: 'included', type: 'match', value: 'DOMAIN' }, + { field: 'host.os.full.caseless', operator: 'included', type: 'match', value: 'windows' }, { field: 'server.ip', operator: 'included', type: 'match', value: '192.168.1.1' }, { - field: 'host.hostname.text', + field: 'host.hostname.caseless', operator: 'included', type: 'match_any', value: ['estc', 'kibana'], @@ -122,10 +122,10 @@ describe('buildEventTypeSignal', () => { type: 'simple', entries: [ { - field: 'server.domain', + field: 'host.os.full', operator: 'included', type: 'exact_caseless', - value: 'DOMAIN', + value: 'windows', }, { field: 'server.ip', @@ -159,12 +159,12 @@ describe('buildEventTypeSignal', () => { test('it should deduplicate exception entries', async () => { const testEntries: EntriesArray = [ - { field: 'server.domain.text', operator: 'included', type: 'match', value: 'DOMAIN' }, - { field: 'server.domain.text', operator: 'included', type: 'match', value: 'DOMAIN' }, - { field: 'server.domain.text', operator: 'included', type: 'match', value: 'DOMAIN' }, + { field: 'host.os.full.caseless', operator: 'included', type: 'match', value: 'windows' }, + { field: 'host.os.full.caseless', operator: 'included', type: 'match', value: 'windows' }, + { field: 'host.os.full.caseless', operator: 'included', type: 'match', value: 'windows' }, { field: 'server.ip', operator: 'included', type: 'match', value: '192.168.1.1' }, { - field: 'host.hostname.text', + field: 'host.hostname', operator: 'included', type: 'match_any', value: ['estc', 'kibana'], @@ -175,10 +175,10 @@ describe('buildEventTypeSignal', () => { type: 'simple', entries: [ { - field: 'server.domain', + field: 'host.os.full', operator: 'included', type: 'exact_caseless', - value: 'DOMAIN', + value: 'windows', }, { field: 'server.ip', @@ -189,7 +189,7 @@ describe('buildEventTypeSignal', () => { { field: 'host.hostname', operator: 'included', - type: 'exact_caseless_any', + type: 'exact_cased_any', value: ['estc', 'kibana'], }, ], @@ -264,7 +264,7 @@ describe('buildEventTypeSignal', () => { test('it should deduplicate exception items', async () => { const testEntries: EntriesArray = [ - { field: 'server.domain.text', operator: 'included', type: 'match', value: 'DOMAIN' }, + { field: 'host.os.full.caseless', operator: 'included', type: 'match', value: 'windows' }, { field: 'server.ip', operator: 'included', type: 'match', value: '192.168.1.1' }, ]; @@ -272,10 +272,10 @@ describe('buildEventTypeSignal', () => { type: 'simple', entries: [ { - field: 'server.domain', + field: 'host.os.full', operator: 'included', type: 'exact_caseless', - value: 'DOMAIN', + value: 'windows', }, { field: 'server.ip', @@ -308,9 +308,9 @@ describe('buildEventTypeSignal', () => { test('it should ignore unsupported entries', async () => { // Lists and exists are not supported by the Endpoint const testEntries: EntriesArray = [ - { field: 'server.domain', operator: 'included', type: 'match', value: 'DOMAIN' }, + { field: 'host.os.full', operator: 'included', type: 'match', value: 'windows' }, { - field: 'server.domain', + field: 'host.os.full', operator: 'included', type: 'list', list: { @@ -325,10 +325,10 @@ describe('buildEventTypeSignal', () => { type: 'simple', entries: [ { - field: 'server.domain', + field: 'host.os.full', operator: 'included', type: 'exact_cased', - value: 'DOMAIN', + value: 'windows', }, ], }; diff --git a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.ts b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.ts index 731b083f3293c..d0fd38c4f1af1 100644 --- a/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.ts +++ b/x-pack/plugins/security_solution/server/endpoint/lib/artifacts/lists.ts @@ -88,7 +88,7 @@ export async function getFullEndpointExceptionList( const response = await eClient.findExceptionListItem({ listId, namespaceType: 'agnostic', - filter: `exception-list-agnostic.attributes._tags:\"os:${os}\"`, + filter: `exception-list-agnostic.attributes.os_types:\"${os}\"`, perPage: 100, page, sortField: 'created_at', @@ -141,16 +141,16 @@ export function translateToEndpointExceptions( function getMatcherFunction(field: string, matchAny?: boolean): TranslatedEntryMatcher { return matchAny - ? field.endsWith('.text') + ? field.endsWith('.caseless') ? 'exact_caseless_any' : 'exact_cased_any' - : field.endsWith('.text') + : field.endsWith('.caseless') ? 'exact_caseless' : 'exact_cased'; } function normalizeFieldName(field: string): string { - return field.endsWith('.text') ? field.substring(0, field.length - 5) : field; + return field.endsWith('.caseless') ? field.substring(0, field.lastIndexOf('.')) : field; } function translateItem( diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/trusted_apps.test.ts b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/trusted_apps.test.ts index 9e9a35ea35318..0fc469fa62a80 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/trusted_apps.test.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/trusted_apps.test.ts @@ -133,7 +133,6 @@ describe('when invoking endpoint trusted apps route handlers', () => { const emptyResponse: FoundExceptionListItemSchema = { data: [ { - _tags: ['os:windows'], _version: undefined, comments: [], created_at: '2020-09-21T19:43:48.240Z', @@ -165,6 +164,7 @@ describe('when invoking endpoint trusted apps route handlers', () => { meta: undefined, name: 'test', namespace_type: 'agnostic', + os_types: ['windows'], tags: [], tie_breaker_id: '1', type: 'simple', @@ -240,7 +240,7 @@ describe('when invoking endpoint trusted apps route handlers', () => { os: 'windows', entries: [ { - field: 'process.executable.text', + field: 'process.executable.caseless', type: 'match', operator: 'included', value: 'c:/programs files/Anti-Virus', @@ -267,6 +267,7 @@ describe('when invoking endpoint trusted apps route handlers', () => { return ({ ...getExceptionListItemSchemaMock(), ...newExceptionItem, + os_types: newExceptionItem.osTypes, } as unknown) as ExceptionListItemSchema; }); }); @@ -288,12 +289,11 @@ describe('when invoking endpoint trusted apps route handlers', () => { const request = createPostRequest(); await routeHandler(context, request, response); expect(exceptionsListClient.createExceptionListItem.mock.calls[0][0]).toEqual({ - _tags: ['os:windows'], comments: [], description: 'this one is ok', entries: [ { - field: 'process.executable.text', + field: 'process.executable.caseless', operator: 'included', type: 'match', value: 'c:/programs files/Anti-Virus', @@ -304,6 +304,7 @@ describe('when invoking endpoint trusted apps route handlers', () => { meta: undefined, name: 'Some Anti-Virus App', namespaceType: 'agnostic', + osTypes: ['windows'], tags: [], type: 'simple', }); @@ -320,7 +321,7 @@ describe('when invoking endpoint trusted apps route handlers', () => { description: 'this one is ok', entries: [ { - field: 'process.executable.text', + field: 'process.executable.caseless', operator: 'included', type: 'match', value: 'c:/programs files/Anti-Virus', @@ -357,7 +358,7 @@ describe('when invoking endpoint trusted apps route handlers', () => { it('should trim condition entry values', async () => { const newTrustedApp = createNewTrustedAppBody(); newTrustedApp.entries.push({ - field: 'process.executable.text', + field: 'process.executable.caseless', value: '\n some value \r\n ', operator: 'included', type: 'match', @@ -366,13 +367,13 @@ describe('when invoking endpoint trusted apps route handlers', () => { await routeHandler(context, request, response); expect(exceptionsListClient.createExceptionListItem.mock.calls[0][0].entries).toEqual([ { - field: 'process.executable.text', + field: 'process.executable.caseless', operator: 'included', type: 'match', value: 'c:/programs files/Anti-Virus', }, { - field: 'process.executable.text', + field: 'process.executable.caseless', value: 'some value', operator: 'included', type: 'match', @@ -392,7 +393,7 @@ describe('when invoking endpoint trusted apps route handlers', () => { await routeHandler(context, request, response); expect(exceptionsListClient.createExceptionListItem.mock.calls[0][0].entries).toEqual([ { - field: 'process.executable.text', + field: 'process.executable.caseless', operator: 'included', type: 'match', value: 'c:/programs files/Anti-Virus', diff --git a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/utils.ts b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/utils.ts index 2b8129ab950c6..322d9a65162c0 100644 --- a/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/utils.ts +++ b/x-pack/plugins/security_solution/server/endpoint/routes/trusted_apps/utils.ts @@ -20,8 +20,8 @@ export const exceptionItemToTrustedAppItem = ( exceptionListItem: ExceptionListItemSchema ): TrustedApp => { // eslint-disable-next-line @typescript-eslint/naming-convention - const { entries, description, created_by, created_at, name, _tags, id } = exceptionListItem; - const os = osFromTagsList(_tags); + const { entries, description, created_by, created_at, name, os_types, id } = exceptionListItem; + const os = os_types.length ? os_types[0] : 'unknown'; return { entries: entries.map((entry) => { if (entry.field.startsWith('process.hash')) { @@ -41,19 +41,6 @@ export const exceptionItemToTrustedAppItem = ( } as TrustedApp; }; -/** - * Retrieves the OS entry from a list of tags (property returned with ExcptionListItem). - * For Trusted Apps each entry must have at MOST 1 OS. - * */ -const osFromTagsList = (tags: string[]): TrustedApp['os'] | 'unknown' => { - for (const tag of tags) { - if (tag.startsWith('os:')) { - return tag.substr(3) as TrustedApp['os']; - } - } - return 'unknown'; -}; - export const newTrustedAppItemToExceptionItem = ({ os, entries, @@ -61,7 +48,6 @@ export const newTrustedAppItemToExceptionItem = ({ description = '', }: NewTrustedApp): NewExceptionItem => { return { - _tags: tagsListFromOs(os), comments: [], description, // @ts-ignore @@ -83,15 +69,12 @@ export const newTrustedAppItemToExceptionItem = ({ meta: undefined, name: name.trim(), namespaceType: 'agnostic', + osTypes: [os], tags: [], type: 'simple', }; }; -const tagsListFromOs = (os: NewTrustedApp['os']): NewExceptionItem['_tags'] => { - return [`os:${os}`]; -}; - const hashType = (hash: string): 'md5' | 'sha256' | 'sha1' | undefined => { switch (hash.length) { case 32: diff --git a/x-pack/plugins/uptime/common/runtime_types/ping/ping.ts b/x-pack/plugins/uptime/common/runtime_types/ping/ping.ts index f954f8ba30849..775078a7e5df1 100644 --- a/x-pack/plugins/uptime/common/runtime_types/ping/ping.ts +++ b/x-pack/plugins/uptime/common/runtime_types/ping/ping.ts @@ -180,6 +180,48 @@ export const PingType = t.intersection([ down: t.number, up: t.number, }), + synthetics: t.partial({ + index: t.number, + journey: t.type({ + id: t.string, + name: t.string, + }), + error: t.partial({ + message: t.string, + name: t.string, + stack: t.string, + }), + package_version: t.string, + step: t.type({ + index: t.number, + name: t.string, + }), + type: t.string, + // ui-related field + screenshotLoading: t.boolean, + // ui-related field + screenshotExists: t.boolean, + blob: t.string, + blob_mime: t.string, + payload: t.partial({ + duration: t.number, + index: t.number, + is_navigation_request: t.boolean, + message: t.string, + method: t.string, + name: t.string, + params: t.partial({ + homepage: t.string, + }), + source: t.string, + start: t.number, + status: t.string, + ts: t.number, + type: t.string, + url: t.string, + end: t.number, + }), + }), tags: t.array(t.string), tcp: t.partial({ rtt: t.partial({ @@ -202,6 +244,13 @@ export const PingType = t.intersection([ }), ]); +export const SyntheticsJourneyApiResponseType = t.type({ + checkGroup: t.string, + steps: t.array(PingType), +}); + +export type SyntheticsJourneyApiResponse = t.TypeOf; + export type Ping = t.TypeOf; // Convenience function for tests etc that makes an empty ping diff --git a/x-pack/plugins/uptime/public/apps/uptime_app.tsx b/x-pack/plugins/uptime/public/apps/uptime_app.tsx index 4b58ba104314f..a5b8bc859ad94 100644 --- a/x-pack/plugins/uptime/public/apps/uptime_app.tsx +++ b/x-pack/plugins/uptime/public/apps/uptime_app.tsx @@ -40,6 +40,7 @@ export interface UptimeAppColors { range: string; mean: string; warning: string; + lightestShade: string; } export interface UptimeAppProps { diff --git a/x-pack/plugins/uptime/public/components/monitor/ping_list/__tests__/expanded_row.test.tsx b/x-pack/plugins/uptime/public/components/monitor/ping_list/__tests__/expanded_row.test.tsx index 2c1434cfd64bd..85d1362ed3766 100644 --- a/x-pack/plugins/uptime/public/components/monitor/ping_list/__tests__/expanded_row.test.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/ping_list/__tests__/expanded_row.test.tsx @@ -17,6 +17,7 @@ describe('PingListExpandedRow', () => { docId: 'fdeio12', timestamp: '19290310', monitor: { + check_group: 'check_group_id', duration: { us: 12345, }, @@ -87,4 +88,25 @@ describe('PingListExpandedRow', () => { expect(docLinkComponent).toHaveLength(1); }); + + it('renders a synthetics expanded row for synth monitor', () => { + ping.monitor.type = 'browser'; + expect(shallowWithIntl()).toMatchInlineSnapshot(` + + + + + + + + + `); + }); }); diff --git a/x-pack/plugins/uptime/public/components/monitor/ping_list/__tests__/ping_list.test.tsx b/x-pack/plugins/uptime/public/components/monitor/ping_list/__tests__/ping_list.test.tsx index cb8413ba08a81..8de4b89b7880d 100644 --- a/x-pack/plugins/uptime/public/components/monitor/ping_list/__tests__/ping_list.test.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/ping_list/__tests__/ping_list.test.tsx @@ -6,7 +6,7 @@ import React from 'react'; import { shallowWithIntl } from 'test_utils/enzyme_helpers'; -import { PingListComponent, toggleDetails } from '../ping_list'; +import { PingListComponent, rowShouldExpand, toggleDetails } from '../ping_list'; import { Ping, PingsResponse } from '../../../../../common/runtime_types'; import { ExpandedRowMap } from '../../../overview/monitor_list/types'; @@ -182,6 +182,7 @@ describe('PingList component', () => { to: 'now', }} getPings={jest.fn()} + pruneJourneysCallback={jest.fn()} lastRefresh={123} loading={false} locations={[]} @@ -273,5 +274,14 @@ describe('PingList component', () => { /> `); }); + + describe('rowShouldExpand', () => { + // TODO: expand for all cases + it('returns true for browser monitors', () => { + const ping = pings[0]; + ping.monitor.type = 'browser'; + expect(rowShouldExpand(ping)).toBe(true); + }); + }); }); }); diff --git a/x-pack/plugins/uptime/public/components/monitor/ping_list/expanded_row.tsx b/x-pack/plugins/uptime/public/components/monitor/ping_list/expanded_row.tsx index 201803ff3a951..6af38eca6b0e9 100644 --- a/x-pack/plugins/uptime/public/components/monitor/ping_list/expanded_row.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/ping_list/expanded_row.tsx @@ -3,6 +3,7 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ + // @ts-ignore formatNumber import { formatNumber } from '@elastic/eui/lib/services/format'; import { @@ -19,6 +20,7 @@ import { i18n } from '@kbn/i18n'; import { Ping, HttpResponseBody } from '../../../../common/runtime_types'; import { DocLinkForBody } from './doc_link_body'; import { PingRedirects } from './ping_redirects'; +import { BrowserExpandedRow } from '../synthetics/browser_expanded_row'; interface Props { ping: Ping; @@ -53,6 +55,24 @@ const BodyExcerpt = ({ content }: { content: string }) => export const PingListExpandedRowComponent = ({ ping }: Props) => { const listItems = []; + if (ping.monitor.type === 'browser') { + return ( + + + + + + + + + ); + } + // Show the error block if (ping.error) { listItems.push({ diff --git a/x-pack/plugins/uptime/public/components/monitor/ping_list/index.tsx b/x-pack/plugins/uptime/public/components/monitor/ping_list/index.tsx index 7fc19bbc9622b..da82d025f478b 100644 --- a/x-pack/plugins/uptime/public/components/monitor/ping_list/index.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/ping_list/index.tsx @@ -5,4 +5,4 @@ */ export { PingListComponent } from './ping_list'; -export { PingList } from './ping_list_container'; +export { PingList } from './ping_list'; diff --git a/x-pack/plugins/uptime/public/components/monitor/ping_list/ping_list.tsx b/x-pack/plugins/uptime/public/components/monitor/ping_list/ping_list.tsx index 09782c1b76edb..590b2f787bac4 100644 --- a/x-pack/plugins/uptime/public/components/monitor/ping_list/ping_list.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/ping_list/ping_list.tsx @@ -21,14 +21,63 @@ import { import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import moment from 'moment'; -import React, { useState, useEffect } from 'react'; +import React, { useCallback, useContext, useState, useEffect } from 'react'; import styled from 'styled-components'; +import { useDispatch, useSelector } from 'react-redux'; import { Ping, GetPingsParams, DateRange } from '../../../../common/runtime_types'; import { convertMicrosecondsToMilliseconds as microsToMillis } from '../../../lib/helper'; import { LocationName } from './location_name'; import { Pagination } from '../../overview/monitor_list'; import { PingListExpandedRowComponent } from './expanded_row'; -import { PingListProps } from './ping_list_container'; +// import { PingListProps } from './ping_list_container'; +import { pruneJourneyState } from '../../../state/actions/journey'; +import { selectPingList } from '../../../state/selectors'; +import { UptimeRefreshContext, UptimeSettingsContext } from '../../../contexts'; +import { getPings as getPingsAction } from '../../../state/actions'; + +export interface PingListProps { + monitorId: string; +} + +export const PingList = (props: PingListProps) => { + const { + error, + loading, + pingList: { locations, pings, total }, + } = useSelector(selectPingList); + + const { lastRefresh } = useContext(UptimeRefreshContext); + + const { dateRangeStart: drs, dateRangeEnd: dre } = useContext(UptimeSettingsContext); + + const dispatch = useDispatch(); + const getPingsCallback = useCallback( + (params: GetPingsParams) => dispatch(getPingsAction(params)), + [dispatch] + ); + const pruneJourneysCallback = useCallback( + (checkGroups: string[]) => dispatch(pruneJourneyState(checkGroups)), + [dispatch] + ); + + return ( + + ); +}; export const AllLocationOption = { 'data-test-subj': 'xpack.uptime.pingList.locationOptions.all', @@ -63,6 +112,7 @@ interface Props extends PingListProps { dateRange: DateRange; error?: Error; getPings: (props: GetPingsParams) => void; + pruneJourneysCallback: (checkGroups: string[]) => void; lastRefresh: number; loading: boolean; locations: string[]; @@ -96,6 +146,13 @@ const statusOptions = [ }, ]; +export function rowShouldExpand(item: Ping) { + const errorPresent = !!item.error; + const httpBodyPresent = item.http?.response?.body?.bytes ?? 0 > 0; + const isBrowserMonitor = item.monitor.type === 'browser'; + return errorPresent || httpBodyPresent || isBrowserMonitor; +} + export const PingListComponent = (props: Props) => { const [selectedLocation, setSelectedLocation] = useState(''); const [status, setStatus] = useState(''); @@ -105,6 +162,7 @@ export const PingListComponent = (props: Props) => { dateRange: { from, to }, error, getPings, + pruneJourneysCallback, lastRefresh, loading, locations, @@ -129,6 +187,27 @@ export const PingListComponent = (props: Props) => { const [expandedRows, setExpandedRows] = useState>({}); + const expandedIdsToRemove = JSON.stringify( + Object.keys(expandedRows).filter((e) => !pings.some(({ docId }) => docId === e)) + ); + useEffect(() => { + const parsed = JSON.parse(expandedIdsToRemove); + if (parsed.length) { + parsed.forEach((docId: string) => { + delete expandedRows[docId]; + }); + setExpandedRows(expandedRows); + } + }, [expandedIdsToRemove, expandedRows]); + + const expandedCheckGroups = pings + .filter((p: Ping) => Object.keys(expandedRows).some((f) => p.docId === f)) + .map(({ monitor: { check_group: cg } }) => cg); + const expandedCheckGroupsStr = JSON.stringify(expandedCheckGroups); + useEffect(() => { + pruneJourneysCallback(JSON.parse(expandedCheckGroupsStr)); + }, [pruneJourneysCallback, expandedCheckGroupsStr]); + const locationOptions = !locations ? [AllLocationOption] : [AllLocationOption].concat( @@ -239,7 +318,7 @@ export const PingListComponent = (props: Props) => { toggleDetails(item, expandedRows, setExpandedRows)} - disabled={!item.error && !(item.http?.response?.body?.bytes ?? 0 > 0)} + disabled={!rowShouldExpand(item)} aria-label={ expandedRows[item.docId] ? i18n.translate('xpack.uptime.pingList.collapseRow', { diff --git a/x-pack/plugins/uptime/public/components/monitor/ping_list/ping_list_container.tsx b/x-pack/plugins/uptime/public/components/monitor/ping_list/ping_list_container.tsx deleted file mode 100644 index 4d4bb175be9a8..0000000000000 --- a/x-pack/plugins/uptime/public/components/monitor/ping_list/ping_list_container.tsx +++ /dev/null @@ -1,51 +0,0 @@ -/* - * 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 { useSelector, useDispatch } from 'react-redux'; -import React, { useContext, useCallback } from 'react'; -import { selectPingList } from '../../../state/selectors'; -import { getPings } from '../../../state/actions'; -import { GetPingsParams } from '../../../../common/runtime_types'; -import { UptimeRefreshContext, UptimeSettingsContext } from '../../../contexts'; -import { PingListComponent } from './index'; - -export interface PingListProps { - monitorId: string; -} - -export const PingList = (props: PingListProps) => { - const { - error, - loading, - pingList: { locations, pings, total }, - } = useSelector(selectPingList); - - const { lastRefresh } = useContext(UptimeRefreshContext); - - const { dateRangeStart: drs, dateRangeEnd: dre } = useContext(UptimeSettingsContext); - - const dispatch = useDispatch(); - const getPingsCallback = useCallback((params: GetPingsParams) => dispatch(getPings(params)), [ - dispatch, - ]); - - return ( - - ); -}; diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/__tests__/browser_expanded_row.test.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/__tests__/browser_expanded_row.test.tsx new file mode 100644 index 0000000000000..191632d6ab713 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/__tests__/browser_expanded_row.test.tsx @@ -0,0 +1,181 @@ +/* + * 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 { shallowWithIntl } from 'test_utils/enzyme_helpers'; +import React from 'react'; +import { BrowserExpandedRowComponent } from '../browser_expanded_row'; +import { Ping } from '../../../../../common/runtime_types'; + +describe('BrowserExpandedRowComponent', () => { + let defStep: Ping; + beforeEach(() => { + defStep = { + docId: 'doc-id', + timestamp: '123', + monitor: { + duration: { + us: 100, + }, + id: 'mon-id', + status: 'up', + type: 'browser', + }, + }; + }); + + it('returns empty step state when no journey', () => { + expect(shallowWithIntl()).toMatchInlineSnapshot( + `` + ); + }); + + it('returns empty step state when journey has no steps', () => { + expect( + shallowWithIntl( + + ) + ).toMatchInlineSnapshot(``); + }); + + it('displays loading spinner when loading', () => { + expect( + shallowWithIntl( + + ) + ).toMatchInlineSnapshot(` +
+ +
+ `); + }); + + it('renders executed journey when step/end is present', () => { + expect( + shallowWithIntl( + + ) + ).toMatchInlineSnapshot(` + + `); + }); + + it('renders console output step list when only console steps are present', () => { + expect( + shallowWithIntl( + + ) + ).toMatchInlineSnapshot(` + + `); + }); + + it('renders null when only unsupported steps are present', () => { + expect( + shallowWithIntl( + + ) + ).toMatchInlineSnapshot(`""`); + }); +}); diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/__tests__/executed_step.test.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/__tests__/executed_step.test.tsx new file mode 100644 index 0000000000000..e3a8430cfa888 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/__tests__/executed_step.test.tsx @@ -0,0 +1,119 @@ +/* + * 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 { shallowWithIntl, mountWithIntl } from 'test_utils/enzyme_helpers'; +import React from 'react'; +import { ExecutedStep } from '../executed_step'; +import { Ping } from '../../../../../common/runtime_types'; + +describe('ExecutedStep', () => { + let step: Ping; + + beforeEach(() => { + step = { + docId: 'docID', + monitor: { + duration: { + us: 123, + }, + id: 'id', + status: 'up', + type: 'browser', + }, + synthetics: { + step: { + index: 4, + name: 'STEP_NAME', + }, + }, + timestamp: 'timestamp', + }; + }); + + it('renders correct step heading', () => { + expect(mountWithIntl().find('EuiText')) + .toMatchInlineSnapshot(` + +
+ + + 4. STEP_NAME + + +
+
+ `); + }); + + it('supplies status badge correct status', () => { + step.synthetics = { + payload: { status: 'THE_STATUS' }, + }; + expect(shallowWithIntl().find('StatusBadge')) + .toMatchInlineSnapshot(` + + `); + }); + + it('renders accordions for step, error message, and error stack script', () => { + step.synthetics = { + error: { + message: 'There was an error executing the step.', + stack: 'some.stack.trace.string', + }, + payload: { + source: 'const someVar = "the var"', + }, + step: { + index: 3, + name: 'STEP_NAME', + }, + }; + + expect(shallowWithIntl().find('CodeBlockAccordion')) + .toMatchInlineSnapshot(` + Array [ + + const someVar = "the var" + , + + There was an error executing the step. + , + + some.stack.trace.string + , + ] + `); + }); +}); diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/__tests__/status_badge.test.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/__tests__/status_badge.test.tsx new file mode 100644 index 0000000000000..1171c24ad899c --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/__tests__/status_badge.test.tsx @@ -0,0 +1,41 @@ +/* + * 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 { shallowWithIntl } from 'test_utils/enzyme_helpers'; +import React from 'react'; +import { StatusBadge } from '../status_badge'; + +describe('StatusBadge', () => { + it('displays success message', () => { + expect(shallowWithIntl()).toMatchInlineSnapshot(` + + Succeeded + + `); + }); + + it('displays failed message', () => { + expect(shallowWithIntl()).toMatchInlineSnapshot(` + + Failed + + `); + }); + + it('displays skipped message', () => { + expect(shallowWithIntl()).toMatchInlineSnapshot(` + + Skipped + + `); + }); +}); diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/__tests__/step_screenshot_display.test.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/__tests__/step_screenshot_display.test.tsx new file mode 100644 index 0000000000000..16db430dbd73a --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/__tests__/step_screenshot_display.test.tsx @@ -0,0 +1,193 @@ +/* + * 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 { shallowWithIntl, mountWithIntl } from 'test_utils/enzyme_helpers'; +import React from 'react'; +import * as reactUse from 'react-use'; +import { StepScreenshotDisplay } from '../step_screenshot_display'; + +describe('StepScreenshotDisplayProps', () => { + // @ts-ignore missing fields don't matter in this test, the component in question only relies on `isIntersecting` + jest.spyOn(reactUse, 'useIntersection').mockImplementation(() => ({ + isIntersecting: true, + })); + + it('displays screenshot thumbnail when present', () => { + const wrapper = mountWithIntl( + + ); + + wrapper.update(); + + expect(wrapper.find('img')).toMatchInlineSnapshot(`null`); + + expect(wrapper.find('EuiPopover')).toMatchInlineSnapshot(` + + } + closePopover={[Function]} + display="inlineBlock" + hasArrow={true} + isOpen={false} + ownFocus={false} + panelPaddingSize="m" + > + +
+
+ +
+
+
+
+ `); + }); + + it('uses alternative text when step name not available', () => { + const wrapper = mountWithIntl( + + ); + + wrapper.update(); + + expect(wrapper.find('EuiPopover')).toMatchInlineSnapshot(` + + } + closePopover={[Function]} + display="inlineBlock" + hasArrow={true} + isOpen={false} + ownFocus={false} + panelPaddingSize="m" + > + +
+
+ +
+
+
+
+ `); + }); + + it('displays No Image message when screenshot does not exist', () => { + expect( + shallowWithIntl( + + ).find('EuiText') + ).toMatchInlineSnapshot(` + + + + + + `); + }); +}); diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/browser_expanded_row.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/browser_expanded_row.tsx new file mode 100644 index 0000000000000..2546c5fb9a5d8 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/browser_expanded_row.tsx @@ -0,0 +1,64 @@ +/* + * 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 { EuiLoadingSpinner } from '@elastic/eui'; +import React, { useEffect, FC } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { Ping } from '../../../../common/runtime_types'; +import { getJourneySteps } from '../../../state/actions/journey'; +import { JourneyState } from '../../../state/reducers/journey'; +import { journeySelector } from '../../../state/selectors'; +import { EmptyStepState } from './empty_journey'; +import { ExecutedJourney } from './executed_journey'; +import { ConsoleOutputEventList } from './console_output_event_list'; + +interface BrowserExpandedRowProps { + checkGroup?: string; +} + +export const BrowserExpandedRow: React.FC = ({ checkGroup }) => { + const dispatch = useDispatch(); + useEffect(() => { + if (checkGroup) { + dispatch(getJourneySteps({ checkGroup })); + } + }, [dispatch, checkGroup]); + + const journeys = useSelector(journeySelector); + const journey = journeys[checkGroup ?? '']; + + return ; +}; + +type ComponentProps = BrowserExpandedRowProps & { + journey?: JourneyState; +}; + +const stepEnd = (step: Ping) => step.synthetics?.type === 'step/end'; +const stepConsole = (step: Ping) => + ['stderr', 'cmd/status'].indexOf(step.synthetics?.type ?? '') !== -1; + +export const BrowserExpandedRowComponent: FC = ({ checkGroup, journey }) => { + if (!!journey && journey.loading) { + return ( +
+ +
+ ); + } + + if (!journey || journey.steps.length === 0) { + return ; + } + + if (journey.steps.some(stepEnd)) return ; + + if (journey.steps.some(stepConsole)) return ; + + // TODO: should not happen, this means that the journey has no step/end and no console logs, but some other steps; filmstrip, screenshot, etc. + // we should probably create an error prompt letting the user know this step is not supported yet + return null; +}; diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/code_block_accordion.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/code_block_accordion.tsx new file mode 100644 index 0000000000000..9f6559f0c2709 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/code_block_accordion.tsx @@ -0,0 +1,35 @@ +/* + * 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 { EuiAccordion, EuiCodeBlock } from '@elastic/eui'; +import React, { FC } from 'react'; + +interface Props { + buttonContent: string; + id?: string; + language: 'html' | 'javascript'; + overflowHeight: number; +} + +/** + * Utility for showing `EuiAccordions` with code blocks which we use frequently in synthetics to display + * stack traces, long error messages, and synthetics journey code. + */ +export const CodeBlockAccordion: FC = ({ + buttonContent, + children, + id, + language, + overflowHeight, +}) => { + return children && id ? ( + + + {children} + + + ) : null; +}; diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/console_event.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/console_event.tsx new file mode 100644 index 0000000000000..0f2930db96b54 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/console_event.tsx @@ -0,0 +1,37 @@ +/* + * 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 { EuiFlexItem, EuiFlexGroup } from '@elastic/eui'; +import React, { useContext, FC } from 'react'; +import { Ping } from '../../../../common/runtime_types'; +import { UptimeThemeContext } from '../../../contexts'; + +interface Props { + event: Ping; +} + +export const ConsoleEvent: FC = ({ event }) => { + const { + colors: { danger }, + } = useContext(UptimeThemeContext); + + let typeColor: string | undefined; + if (event.synthetics?.type === 'stderr') { + typeColor = danger; + } else { + typeColor = undefined; + } + + return ( + + {event.timestamp} + + {event.synthetics?.type} + + {event.synthetics?.payload?.message} + + ); +}; diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/console_output_event_list.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/console_output_event_list.tsx new file mode 100644 index 0000000000000..9159c61532f15 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/console_output_event_list.tsx @@ -0,0 +1,41 @@ +/* + * 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 { EuiCodeBlock, EuiSpacer, EuiTitle } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import React, { FC } from 'react'; +import { JourneyState } from '../../../state/reducers/journey'; +import { ConsoleEvent } from './console_event'; + +interface Props { + journey: JourneyState; +} + +export const ConsoleOutputEventList: FC = ({ journey }) => ( +
+ +

+ +

+
+ +

+ +

+ + + {journey.steps.map((consoleEvent) => ( + + ))} + +
+); diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/empty_journey.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/empty_journey.tsx new file mode 100644 index 0000000000000..b6fead2bbbe09 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/empty_journey.tsx @@ -0,0 +1,52 @@ +/* + * 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 { EuiEmptyPrompt } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import React, { FC } from 'react'; + +interface EmptyStepStateProps { + checkGroup?: string; +} + +export const EmptyStepState: FC = ({ checkGroup }) => ( + + + + } + body={ + <> +

+ +

+ {!!checkGroup && ( +

+ {checkGroup} }} + /> +

+ )} +

+ +

+ + } + /> +); diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/executed_journey.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/executed_journey.tsx new file mode 100644 index 0000000000000..2c37f95428d0e --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/executed_journey.tsx @@ -0,0 +1,80 @@ +/* + * 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 { EuiFlexGroup, EuiSpacer, EuiText } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import React, { FC } from 'react'; +import { Ping } from '../../../../common/runtime_types'; +import { JourneyState } from '../../../state/reducers/journey'; +import { ExecutedStep } from './executed_step'; + +interface StepStatusCount { + failed: number; + skipped: number; + succeeded: number; +} + +function statusMessage(count: StepStatusCount) { + const total = count.succeeded + count.failed + count.skipped; + if (count.failed + count.skipped === total) { + return i18n.translate('xpack.uptime.synthetics.journey.allFailedMessage', { + defaultMessage: '{total} Steps - all failed or skipped', + values: { total }, + }); + } else if (count.succeeded === total) { + return i18n.translate('xpack.uptime.synthetics.journey.allSucceededMessage', { + defaultMessage: '{total} Steps - all succeeded', + values: { total }, + }); + } + return i18n.translate('xpack.uptime.synthetics.journey.partialSuccessMessage', { + defaultMessage: '{total} Steps - {succeeded} succeeded', + values: { succeeded: count.succeeded, total }, + }); +} + +function reduceStepStatus(prev: StepStatusCount, cur: Ping): StepStatusCount { + if (cur.synthetics?.payload?.status === 'succeeded') { + prev.succeeded += 1; + return prev; + } else if (cur.synthetics?.payload?.status === 'skipped') { + prev.skipped += 1; + return prev; + } + prev.failed += 1; + return prev; +} + +interface ExecutedJourneyProps { + journey: JourneyState; +} + +export const ExecutedJourney: FC = ({ journey }) => ( +
+ +

+ +

+

+ {statusMessage( + journey.steps.reduce(reduceStepStatus, { failed: 0, skipped: 0, succeeded: 0 }) + )} +

+
+ + + {journey.steps + .filter((step) => step.synthetics?.type === 'step/end') + .map((step, index) => ( + + ))} + +
+); diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/executed_step.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/executed_step.tsx new file mode 100644 index 0000000000000..3c26ba12eea65 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/executed_step.tsx @@ -0,0 +1,92 @@ +/* + * 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 { EuiFlexItem, EuiFlexGroup, EuiSpacer, EuiText } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; +import React, { FC } from 'react'; +import { i18n } from '@kbn/i18n'; +import { CodeBlockAccordion } from './code_block_accordion'; +import { StepScreenshotDisplay } from './step_screenshot_display'; +import { StatusBadge } from './status_badge'; +import { Ping } from '../../../../common/runtime_types'; + +const CODE_BLOCK_OVERFLOW_HEIGHT = 360; + +interface ExecutedStepProps { + step: Ping; + index: number; +} + +export const ExecutedStep: FC = ({ step, index }) => ( + <> +
+
+ + + + + +
+ +
+ +
+ +
+ + + + + + + {step.synthetics?.payload?.source} + + + {step.synthetics?.error?.message} + + + {step.synthetics?.error?.stack} + + + +
+
+ + +); diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/status_badge.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/status_badge.tsx new file mode 100644 index 0000000000000..f8fbeccaabf42 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/status_badge.tsx @@ -0,0 +1,52 @@ +/* + * 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 { EuiBadge } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React, { useContext, FC } from 'react'; +import { UptimeAppColors } from '../../../apps/uptime_app'; +import { UptimeThemeContext } from '../../../contexts'; + +interface StatusBadgeProps { + status?: string; +} + +export function colorFromStatus(color: UptimeAppColors, status?: string) { + switch (status) { + case 'succeeded': + return color.success; + case 'failed': + return color.danger; + default: + return 'default'; + } +} + +export function textFromStatus(status?: string) { + switch (status) { + case 'succeeded': + return i18n.translate('xpack.uptime.synthetics.statusBadge.succeededMessage', { + defaultMessage: 'Succeeded', + }); + case 'failed': + return i18n.translate('xpack.uptime.synthetics.statusBadge.failedMessage', { + defaultMessage: 'Failed', + }); + case 'skipped': + return i18n.translate('xpack.uptime.synthetics.statusBadge.skippedMessage', { + defaultMessage: 'Skipped', + }); + default: + return null; + } +} + +export const StatusBadge: FC = ({ status }) => { + const theme = useContext(UptimeThemeContext); + return ( + {textFromStatus(status)} + ); +}; diff --git a/x-pack/plugins/uptime/public/components/monitor/synthetics/step_screenshot_display.tsx b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_screenshot_display.tsx new file mode 100644 index 0000000000000..2e8ad4bd0c9a8 --- /dev/null +++ b/x-pack/plugins/uptime/public/components/monitor/synthetics/step_screenshot_display.tsx @@ -0,0 +1,175 @@ +/* + * 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 { + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiOverlayMask, + EuiPopover, + EuiText, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import React, { useContext, useEffect, useRef, useState, FC } from 'react'; +import { useIntersection } from 'react-use'; +import { UptimeThemeContext } from '../../../contexts'; + +interface StepScreenshotDisplayProps { + screenshotExists?: boolean; + checkGroup?: string; + stepIndex?: number; + stepName?: string; +} + +const THUMBNAIL_WIDTH = 320; +const THUMBNAIL_HEIGHT = 180; +const POPOVER_IMG_WIDTH = 640; +const POPOVER_IMG_HEIGHT = 360; + +export const StepScreenshotDisplay: FC = ({ + checkGroup, + screenshotExists, + stepIndex, + stepName, +}) => { + const containerRef = useRef(null); + const { + colors: { lightestShade: pageBackground }, + } = useContext(UptimeThemeContext); + + const [isImagePopoverOpen, setIsImagePopoverOpen] = useState(false); + const [isOverlayOpen, setIsOverlayOpen] = useState(false); + + const intersection = useIntersection(containerRef, { + root: null, + rootMargin: '0px', + threshold: 1, + }); + + const [hasIntersected, setHasIntersected] = useState(false); + const isIntersecting = intersection?.isIntersecting; + useEffect(() => { + if (hasIntersected === false && isIntersecting === true) { + setHasIntersected(true); + } + }, [hasIntersected, isIntersecting, setHasIntersected]); + + let content: JSX.Element | null = null; + const imgSrc = `/api/uptime/journey/screenshot/${checkGroup}/${stepIndex}`; + if (hasIntersected && screenshotExists) { + content = ( + <> + {isOverlayOpen && ( + setIsOverlayOpen(false)}> + setIsOverlayOpen(false)} + /> + + )} + setIsOverlayOpen(true)} + onMouseEnter={() => setIsImagePopoverOpen(true)} + onMouseLeave={() => setIsImagePopoverOpen(false)} + /> + } + closePopover={() => setIsImagePopoverOpen(false)} + isOpen={isImagePopoverOpen} + > + { + + + ); + } else if (screenshotExists === false) { + content = ( + + + + + + + + + + + + + ); + } + return ( +
+ {content} +
+ ); +}; diff --git a/x-pack/plugins/uptime/public/contexts/uptime_theme_context.tsx b/x-pack/plugins/uptime/public/contexts/uptime_theme_context.tsx index 51e8bcaed986f..f0a702b9c0b75 100644 --- a/x-pack/plugins/uptime/public/contexts/uptime_theme_context.tsx +++ b/x-pack/plugins/uptime/public/contexts/uptime_theme_context.tsx @@ -31,6 +31,7 @@ const defaultContext: UptimeThemeContextValues = { success: euiLightVars.euiColorSuccess, warning: euiLightVars.euiColorWarning, gray: euiLightVars.euiColorLightShade, + lightestShade: euiLightVars.euiColorLightestShade, }, chartTheme: { baseTheme: LIGHT_THEME, @@ -54,6 +55,7 @@ export const UptimeThemeContextProvider: React.FC = ({ darkMo range: euiDarkVars.euiFocusBackgroundColor, success: euiDarkVars.euiColorSuccess, warning: euiDarkVars.euiColorWarning, + lightestShade: euiDarkVars.euiColorLightestShade, }; } else { colors = { @@ -63,6 +65,7 @@ export const UptimeThemeContextProvider: React.FC = ({ darkMo range: euiLightVars.euiFocusBackgroundColor, success: euiLightVars.euiColorSuccess, warning: euiLightVars.euiColorWarning, + lightestShade: euiLightVars.euiColorLightestShade, }; } const value = useMemo(() => { diff --git a/x-pack/plugins/uptime/public/state/actions/journey.ts b/x-pack/plugins/uptime/public/state/actions/journey.ts new file mode 100644 index 0000000000000..0d35559d97fc3 --- /dev/null +++ b/x-pack/plugins/uptime/public/state/actions/journey.ts @@ -0,0 +1,24 @@ +/* + * 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 { createAction } from 'redux-actions'; +import { SyntheticsJourneyApiResponse } from '../../../common/runtime_types'; + +export interface FetchJourneyStepsParams { + checkGroup: string; +} + +export interface GetJourneyFailPayload { + checkGroup: string; + error: Error; +} + +export const getJourneySteps = createAction('GET_JOURNEY_STEPS'); +export const getJourneyStepsSuccess = createAction( + 'GET_JOURNEY_STEPS_SUCCESS' +); +export const getJourneyStepsFail = createAction('GET_JOURNEY_STEPS_FAIL'); +export const pruneJourneyState = createAction('PRUNE_JOURNEY_STATE'); diff --git a/x-pack/plugins/uptime/public/state/api/journey.ts b/x-pack/plugins/uptime/public/state/api/journey.ts new file mode 100644 index 0000000000000..e9ea9d8744bc8 --- /dev/null +++ b/x-pack/plugins/uptime/public/state/api/journey.ts @@ -0,0 +1,22 @@ +/* + * 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 { apiService } from './utils'; +import { FetchJourneyStepsParams } from '../actions/journey'; +import { + SyntheticsJourneyApiResponse, + SyntheticsJourneyApiResponseType, +} from '../../../common/runtime_types'; + +export async function fetchJourneySteps( + params: FetchJourneyStepsParams +): Promise { + return (await apiService.get( + `/api/uptime/journey/${params.checkGroup}`, + undefined, + SyntheticsJourneyApiResponseType + )) as SyntheticsJourneyApiResponse; +} diff --git a/x-pack/plugins/uptime/public/state/effects/index.ts b/x-pack/plugins/uptime/public/state/effects/index.ts index a2a5dd58dcf4d..4951f2102c8a7 100644 --- a/x-pack/plugins/uptime/public/state/effects/index.ts +++ b/x-pack/plugins/uptime/public/state/effects/index.ts @@ -18,6 +18,7 @@ import { fetchMLJobEffect } from './ml_anomaly'; import { fetchIndexStatusEffect } from './index_status'; import { fetchCertificatesEffect } from '../certificates/certificates'; import { fetchAlertsEffect } from '../alerts/alerts'; +import { fetchJourneyStepsEffect } from './journey'; export function* rootEffect() { yield fork(fetchMonitorDetailsEffect); @@ -35,4 +36,5 @@ export function* rootEffect() { yield fork(fetchIndexStatusEffect); yield fork(fetchCertificatesEffect); yield fork(fetchAlertsEffect); + yield fork(fetchJourneyStepsEffect); } diff --git a/x-pack/plugins/uptime/public/state/effects/journey.ts b/x-pack/plugins/uptime/public/state/effects/journey.ts new file mode 100644 index 0000000000000..2ba125535dea4 --- /dev/null +++ b/x-pack/plugins/uptime/public/state/effects/journey.ts @@ -0,0 +1,26 @@ +/* + * 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 { Action } from 'redux-actions'; +import { call, put, takeLatest } from 'redux-saga/effects'; +import { + getJourneySteps, + getJourneyStepsSuccess, + getJourneyStepsFail, + FetchJourneyStepsParams, +} from '../actions/journey'; +import { fetchJourneySteps } from '../api/journey'; + +export function* fetchJourneyStepsEffect() { + yield takeLatest(getJourneySteps, function* (action: Action) { + try { + const response = yield call(fetchJourneySteps, action.payload); + yield put(getJourneyStepsSuccess(response)); + } catch (e) { + yield put(getJourneyStepsFail({ checkGroup: action.payload.checkGroup, error: e })); + } + }); +} diff --git a/x-pack/plugins/uptime/public/state/reducers/index.ts b/x-pack/plugins/uptime/public/state/reducers/index.ts index a1aa8b9fbaf5b..c0bab124d5f9d 100644 --- a/x-pack/plugins/uptime/public/state/reducers/index.ts +++ b/x-pack/plugins/uptime/public/state/reducers/index.ts @@ -21,6 +21,7 @@ import { mlJobsReducer } from './ml_anomaly'; import { certificatesReducer } from '../certificates/certificates'; import { selectedFiltersReducer } from './selected_filters'; import { alertsReducer } from '../alerts/alerts'; +import { journeyReducer } from './journey'; export const rootReducer = combineReducers({ monitor: monitorReducer, @@ -39,4 +40,5 @@ export const rootReducer = combineReducers({ certificates: certificatesReducer, selectedFilters: selectedFiltersReducer, alerts: alertsReducer, + journeys: journeyReducer, }); diff --git a/x-pack/plugins/uptime/public/state/reducers/journey.ts b/x-pack/plugins/uptime/public/state/reducers/journey.ts new file mode 100644 index 0000000000000..e1c3dc808f1bf --- /dev/null +++ b/x-pack/plugins/uptime/public/state/reducers/journey.ts @@ -0,0 +1,98 @@ +/* + * 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 { handleActions, Action } from 'redux-actions'; +import { Ping, SyntheticsJourneyApiResponse } from '../../../common/runtime_types'; +import { pruneJourneyState } from '../actions/journey'; +import { + FetchJourneyStepsParams, + GetJourneyFailPayload, + getJourneySteps, + getJourneyStepsFail, + getJourneyStepsSuccess, +} from '../actions/journey'; + +export interface JourneyState { + checkGroup: string; + steps: Ping[]; + loading: boolean; + error?: Error; +} + +interface JourneyKVP { + [checkGroup: string]: JourneyState; +} + +const initialState: JourneyKVP = {}; + +type Payload = FetchJourneyStepsParams & + SyntheticsJourneyApiResponse & + GetJourneyFailPayload & + string[]; + +export const journeyReducer = handleActions( + { + [String(getJourneySteps)]: ( + state: JourneyKVP, + { payload: { checkGroup } }: Action + ) => ({ + ...state, + // add an empty entry while fetching the check group, + // or update the previously-loaded entry to a new loading state + [checkGroup]: state[checkGroup] + ? { + ...state[checkGroup], + loading: true, + } + : { + checkGroup, + steps: [], + loading: true, + }, + }), + + [String(getJourneyStepsSuccess)]: ( + state: JourneyKVP, + { payload: { checkGroup, steps } }: Action + ) => ({ + ...state, + [checkGroup]: { + loading: false, + checkGroup, + steps, + }, + }), + + [String(getJourneyStepsFail)]: ( + state: JourneyKVP, + { payload: { checkGroup, error } }: Action + ) => ({ + ...state, + [checkGroup]: state[checkGroup] + ? { + ...state[checkGroup], + loading: false, + error, + } + : { + checkGroup, + loading: false, + steps: [], + error, + }, + }), + + [String(pruneJourneyState)]: (state: JourneyKVP, action: Action) => + action.payload.reduce( + (prev, cur) => ({ + ...prev, + [cur]: state[cur], + }), + {} + ), + }, + initialState +); diff --git a/x-pack/plugins/uptime/public/state/selectors/__tests__/index.test.ts b/x-pack/plugins/uptime/public/state/selectors/__tests__/index.test.ts index 1a8929990ab55..a59e0be5cdf3f 100644 --- a/x-pack/plugins/uptime/public/state/selectors/__tests__/index.test.ts +++ b/x-pack/plugins/uptime/public/state/selectors/__tests__/index.test.ts @@ -117,6 +117,7 @@ describe('state selectors', () => { newAlert: { data: null, loading: false }, anomalyAlertDeletion: { data: null, loading: false }, }, + journeys: {}, }; it('selects base path from state', () => { diff --git a/x-pack/plugins/uptime/public/state/selectors/index.ts b/x-pack/plugins/uptime/public/state/selectors/index.ts index 6b3b46cc15137..6bfe67468aae5 100644 --- a/x-pack/plugins/uptime/public/state/selectors/index.ts +++ b/x-pack/plugins/uptime/public/state/selectors/index.ts @@ -94,3 +94,5 @@ export const searchTextSelector = ({ ui: { searchText } }: AppState) => searchTe export const selectedFiltersSelector = ({ selectedFilters }: AppState) => selectedFilters; export const monitorIdSelector = ({ ui: { monitorId } }: AppState) => monitorId; + +export const journeySelector = ({ journeys }: AppState) => journeys; diff --git a/x-pack/plugins/uptime/server/lib/requests/__tests__/get_pings.test.ts b/x-pack/plugins/uptime/server/lib/requests/__tests__/get_pings.test.ts index a52bf86499396..cb84cc2eb05b6 100644 --- a/x-pack/plugins/uptime/server/lib/requests/__tests__/get_pings.test.ts +++ b/x-pack/plugins/uptime/server/lib/requests/__tests__/get_pings.test.ts @@ -144,6 +144,30 @@ describe('getAll', () => { }, }, ], + "must_not": Array [ + Object { + "bool": Object { + "filter": Array [ + Object { + "term": Object { + "monitor.type": "browser", + }, + }, + Object { + "bool": Object { + "must_not": Array [ + Object { + "exists": Object { + "field": "summary", + }, + }, + ], + }, + }, + ], + }, + }, + ], }, }, "size": 12, @@ -198,6 +222,30 @@ describe('getAll', () => { }, }, ], + "must_not": Array [ + Object { + "bool": Object { + "filter": Array [ + Object { + "term": Object { + "monitor.type": "browser", + }, + }, + Object { + "bool": Object { + "must_not": Array [ + Object { + "exists": Object { + "field": "summary", + }, + }, + ], + }, + }, + ], + }, + }, + ], }, }, "size": 12, @@ -252,6 +300,30 @@ describe('getAll', () => { }, }, ], + "must_not": Array [ + Object { + "bool": Object { + "filter": Array [ + Object { + "term": Object { + "monitor.type": "browser", + }, + }, + Object { + "bool": Object { + "must_not": Array [ + Object { + "exists": Object { + "field": "summary", + }, + }, + ], + }, + }, + ], + }, + }, + ], }, }, "size": 25, @@ -311,6 +383,30 @@ describe('getAll', () => { }, }, ], + "must_not": Array [ + Object { + "bool": Object { + "filter": Array [ + Object { + "term": Object { + "monitor.type": "browser", + }, + }, + Object { + "bool": Object { + "must_not": Array [ + Object { + "exists": Object { + "field": "summary", + }, + }, + ], + }, + }, + ], + }, + }, + ], }, }, "size": 25, @@ -370,6 +466,30 @@ describe('getAll', () => { }, }, ], + "must_not": Array [ + Object { + "bool": Object { + "filter": Array [ + Object { + "term": Object { + "monitor.type": "browser", + }, + }, + Object { + "bool": Object { + "must_not": Array [ + Object { + "exists": Object { + "field": "summary", + }, + }, + ], + }, + }, + ], + }, + }, + ], }, }, "size": 25, diff --git a/x-pack/plugins/uptime/server/lib/requests/get_journey_screenshot.ts b/x-pack/plugins/uptime/server/lib/requests/get_journey_screenshot.ts new file mode 100644 index 0000000000000..f726ef47915b8 --- /dev/null +++ b/x-pack/plugins/uptime/server/lib/requests/get_journey_screenshot.ts @@ -0,0 +1,50 @@ +/* + * 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 { UMElasticsearchQueryFn } from '../adapters/framework'; + +interface GetJourneyScreenshotParams { + checkGroup: string; + stepIndex: number; +} + +export const getJourneyScreenshot: UMElasticsearchQueryFn< + GetJourneyScreenshotParams, + any +> = async ({ callES, dynamicSettings, checkGroup, stepIndex }) => { + const params: any = { + index: dynamicSettings.heartbeatIndices, + body: { + query: { + bool: { + filter: [ + { + term: { + 'monitor.check_group': checkGroup, + }, + }, + { + term: { + 'synthetics.type': 'step/screenshot', + }, + }, + { + term: { + 'synthetics.step.index': stepIndex, + }, + }, + ], + }, + }, + _source: ['synthetics.blob'], + }, + }; + const result = await callES('search', params); + if (!Array.isArray(result?.hits?.hits) || result.hits.hits.length < 1) { + return null; + } + return result.hits.hits.map(({ _source }: any) => _source?.synthetics?.blob ?? null)[0]; +}; diff --git a/x-pack/plugins/uptime/server/lib/requests/get_journey_steps.ts b/x-pack/plugins/uptime/server/lib/requests/get_journey_steps.ts new file mode 100644 index 0000000000000..e4392480f5b72 --- /dev/null +++ b/x-pack/plugins/uptime/server/lib/requests/get_journey_steps.ts @@ -0,0 +1,61 @@ +/* + * 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 { UMElasticsearchQueryFn } from '../adapters/framework'; +import { Ping } from '../../../common/runtime_types'; + +interface GetJourneyStepsParams { + checkGroup: string; +} + +export const getJourneySteps: UMElasticsearchQueryFn = async ({ + callES, + dynamicSettings, + checkGroup, +}) => { + const params: any = { + index: dynamicSettings.heartbeatIndices, + body: { + query: { + bool: { + filter: [ + { + terms: { + 'synthetics.type': ['step/end', 'stderr', 'cmd/status', 'step/screenshot'], + }, + }, + { + term: { + 'monitor.check_group': checkGroup, + }, + }, + ], + }, + }, + _source: { + excludes: ['synthetics.blob'], + }, + }, + size: 500, + }; + const result = await callES('search', params); + const screenshotIndexes: number[] = result.hits.hits + .filter((h: any) => h?._source?.synthetics?.type === 'step/screenshot') + .map((h: any) => h?._source?.synthetics?.step?.index); + return result.hits.hits + .filter((h: any) => h?._source?.synthetics?.type !== 'step/screenshot') + .map( + ({ _id, _source, _source: { synthetics } }: any): Ping => ({ + ..._source, + timestamp: _source['@timestamp'], + docId: _id, + synthetics: { + ...synthetics, + screenshotExists: screenshotIndexes.some((i) => i === synthetics?.step?.index), + }, + }) + ); +}; diff --git a/x-pack/plugins/uptime/server/lib/requests/get_pings.ts b/x-pack/plugins/uptime/server/lib/requests/get_pings.ts index a6a0e3c3d6542..03ec2d7343c9a 100644 --- a/x-pack/plugins/uptime/server/lib/requests/get_pings.ts +++ b/x-pack/plugins/uptime/server/lib/requests/get_pings.ts @@ -14,6 +14,43 @@ import { const DEFAULT_PAGE_SIZE = 25; +/** + * This branch of filtering is used for monitors of type `browser`. This monitor + * type represents an unbounded set of steps, with each `check_group` representing + * a distinct journey. The document containing the `summary` field is indexed last, and + * contains the data necessary for querying a journey. + * + * Because of this, when querying for "pings", it is important that we treat `browser` summary + * checks as the "ping" we want. Without this filtering, we will receive >= N pings for a journey + * of N steps, because an individual step may also contain multiple documents. + */ +const REMOVE_NON_SUMMARY_BROWSER_CHECKS = { + must_not: [ + { + bool: { + filter: [ + { + term: { + 'monitor.type': 'browser', + }, + }, + { + bool: { + must_not: [ + { + exists: { + field: 'summary', + }, + }, + ], + }, + }, + ], + }, + }, + ], +}; + export const getPings: UMElasticsearchQueryFn = async ({ callES, dynamicSettings, @@ -39,7 +76,12 @@ export const getPings: UMElasticsearchQueryFn = a if (location) { postFilterClause = { post_filter: { term: { 'observer.geo.name': location } } }; } - const queryContext = { bool: { filter } }; + const queryContext = { + bool: { + filter, + ...REMOVE_NON_SUMMARY_BROWSER_CHECKS, + }, + }; const params: any = { index: dynamicSettings.heartbeatIndices, body: { diff --git a/x-pack/plugins/uptime/server/lib/requests/index.ts b/x-pack/plugins/uptime/server/lib/requests/index.ts index 8fa4561268e8f..1806495d14cc4 100644 --- a/x-pack/plugins/uptime/server/lib/requests/index.ts +++ b/x-pack/plugins/uptime/server/lib/requests/index.ts @@ -18,6 +18,8 @@ import { getPings } from './get_pings'; import { getPingHistogram } from './get_ping_histogram'; import { getSnapshotCount } from './get_snapshot_counts'; import { getIndexStatus } from './get_index_status'; +import { getJourneySteps } from './get_journey_steps'; +import { getJourneyScreenshot } from './get_journey_screenshot'; export const requests = { getCerts, @@ -34,6 +36,8 @@ export const requests = { getPingHistogram, getSnapshotCount, getIndexStatus, + getJourneySteps, + getJourneyScreenshot, }; export type UptimeRequests = typeof requests; diff --git a/x-pack/plugins/uptime/server/rest_api/index.ts b/x-pack/plugins/uptime/server/rest_api/index.ts index 2b598be284e1c..de44b2565a2f8 100644 --- a/x-pack/plugins/uptime/server/rest_api/index.ts +++ b/x-pack/plugins/uptime/server/rest_api/index.ts @@ -6,7 +6,12 @@ import { createGetCertsRoute } from './certs/certs'; import { createGetOverviewFilters } from './overview_filters'; -import { createGetPingHistogramRoute, createGetPingsRoute } from './pings'; +import { + createGetPingHistogramRoute, + createGetPingsRoute, + createJourneyRoute, + createJourneyScreenshotRoute, +} from './pings'; import { createGetDynamicSettingsRoute, createPostDynamicSettingsRoute } from './dynamic_settings'; import { createLogPageViewRoute } from './telemetry'; import { createGetSnapshotCount } from './snapshot'; @@ -40,4 +45,6 @@ export const restApiRoutes: UMRestApiRouteFactory[] = [ createLogPageViewRoute, createGetPingHistogramRoute, createGetMonitorDurationRoute, + createJourneyRoute, + createJourneyScreenshotRoute, ]; diff --git a/x-pack/plugins/uptime/server/rest_api/pings/index.ts b/x-pack/plugins/uptime/server/rest_api/pings/index.ts index a10ab435e4b0a..88fc0a84c4621 100644 --- a/x-pack/plugins/uptime/server/rest_api/pings/index.ts +++ b/x-pack/plugins/uptime/server/rest_api/pings/index.ts @@ -6,3 +6,5 @@ export { createGetPingsRoute } from './get_pings'; export { createGetPingHistogramRoute } from './get_ping_histogram'; +export { createJourneyRoute } from './journeys'; +export { createJourneyScreenshotRoute } from './journey_screenshots'; diff --git a/x-pack/plugins/uptime/server/rest_api/pings/journey_screenshots.ts b/x-pack/plugins/uptime/server/rest_api/pings/journey_screenshots.ts new file mode 100644 index 0000000000000..1fc52dd24f9d0 --- /dev/null +++ b/x-pack/plugins/uptime/server/rest_api/pings/journey_screenshots.ts @@ -0,0 +1,40 @@ +/* + * 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 { UMServerLibs } from '../../lib/lib'; +import { UMRestApiRouteFactory } from '../types'; + +export const createJourneyScreenshotRoute: UMRestApiRouteFactory = (libs: UMServerLibs) => ({ + method: 'GET', + path: '/api/uptime/journey/screenshot/{checkGroup}/{stepIndex}', + validate: { + params: schema.object({ + checkGroup: schema.string(), + stepIndex: schema.number(), + }), + }, + handler: async ({ callES, dynamicSettings }, _context, request, response) => { + const { checkGroup, stepIndex } = request.params; + const result = await libs.requests.getJourneyScreenshot({ + callES, + dynamicSettings, + checkGroup, + stepIndex, + }); + + if (result === null) { + return response.notFound(); + } + return response.ok({ + body: Buffer.from(result, 'base64'), + headers: { + 'content-type': 'image/png', + 'cache-control': 'max-age=600', + }, + }); + }, +}); diff --git a/x-pack/plugins/uptime/server/rest_api/pings/journeys.ts b/x-pack/plugins/uptime/server/rest_api/pings/journeys.ts new file mode 100644 index 0000000000000..b6e06850ad3b6 --- /dev/null +++ b/x-pack/plugins/uptime/server/rest_api/pings/journeys.ts @@ -0,0 +1,34 @@ +/* + * 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 { UMServerLibs } from '../../lib/lib'; +import { UMRestApiRouteFactory } from '../types'; + +export const createJourneyRoute: UMRestApiRouteFactory = (libs: UMServerLibs) => ({ + method: 'GET', + path: '/api/uptime/journey/{checkGroup}', + validate: { + params: schema.object({ + checkGroup: schema.string(), + }), + }, + handler: async ({ callES, dynamicSettings }, _context, request, response) => { + const { checkGroup } = request.params; + const result = await libs.requests.getJourneySteps({ + callES, + dynamicSettings, + checkGroup, + }); + + return response.ok({ + body: { + checkGroup, + steps: result, + }, + }); + }, +}); 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 f061a38b72ce6..8f1aff337a3de 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 0000000000000..a98bf5ca8bd1b --- /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 0000000000000..e24ab1c7b63f2 --- /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/services.ts b/x-pack/test/encrypted_saved_objects_api_integration/services.ts index b7398349cce5d..a8d8048462693 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 8bdc1715bf487..637bb7c202241 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'; @@ -28,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; } @@ -437,15 +438,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 +487,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 +538,121 @@ 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], + [ + 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, + ], + ]; + + 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] of savedObjectsEncryptedWithLegacyKeys.filter( + ([, , , hiddenSavedObject]) => !hiddenSavedObject + )) { + 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({ + 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', + }); + } + }); + + 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('encryption key can be properly rotated by the superuser', 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', + }); + } + }); + }); }); } diff --git a/x-pack/test/functional/es_archives/uptime/pings/data.json.gz b/x-pack/test/functional/es_archives/uptime/pings/data.json.gz index 83441218aad73..a8f7b84b7d0a8 100644 Binary files a/x-pack/test/functional/es_archives/uptime/pings/data.json.gz and b/x-pack/test/functional/es_archives/uptime/pings/data.json.gz differ diff --git a/x-pack/test/ingest_manager_api_integration/apis/fleet/agents/complete_flow.ts b/x-pack/test/ingest_manager_api_integration/apis/fleet/agents/complete_flow.ts index a59b3ff0890f7..d925d77b5b93f 100644 --- a/x-pack/test/ingest_manager_api_integration/apis/fleet/agents/complete_flow.ts +++ b/x-pack/test/ingest_manager_api_integration/apis/fleet/agents/complete_flow.ts @@ -19,7 +19,8 @@ export default function (providerContext: FtrProviderContext) { const supertestWithoutAuth = getSupertestWithoutAuth(providerContext); const esClient = getService('es'); - describe('fleet_agent_flow', () => { + // Failing: See https://github.com/elastic/kibana/issues/75241 + describe.skip('fleet_agent_flow', () => { skipIfNoDockerRegistry(providerContext); before(async () => { await esArchiver.load('empty_kibana'); diff --git a/x-pack/test/stack_functional_integration/apps/metricbeat/_metricbeat_dashboard.js b/x-pack/test/stack_functional_integration/apps/metricbeat/_metricbeat_dashboard.js index 42f707fb77854..0ce09e8f59d2d 100644 --- a/x-pack/test/stack_functional_integration/apps/metricbeat/_metricbeat_dashboard.js +++ b/x-pack/test/stack_functional_integration/apps/metricbeat/_metricbeat_dashboard.js @@ -5,7 +5,6 @@ */ import expect from '@kbn/expect'; -import { REPO_ROOT } from '@kbn/dev-utils'; export default function ({ getService, getPageObjects, updateBaselines }) { const screenshot = getService('screenshots'); @@ -15,7 +14,7 @@ export default function ({ getService, getPageObjects, updateBaselines }) { describe('check metricbeat Dashboard', function () { before(async function () { - await esArchiver.load(`${REPO_ROOT}/../integration-test/test/es_archives/metricbeat`); + await esArchiver.load('metricbeat'); // this navigateToActualURL takes the place of navigating to the dashboard landing page, // filtering on the dashboard name, selecting it, setting the timepicker, and going to full screen diff --git a/x-pack/test/stack_functional_integration/configs/config.stack_functional_integration_base.js b/x-pack/test/stack_functional_integration/configs/config.stack_functional_integration_base.js index a838b129242a1..bc5b28641c28c 100644 --- a/x-pack/test/stack_functional_integration/configs/config.stack_functional_integration_base.js +++ b/x-pack/test/stack_functional_integration/configs/config.stack_functional_integration_base.js @@ -16,10 +16,14 @@ const log = new ToolingLog({ level: 'info', writeTo: process.stdout, }); +log.info(`REPO_ROOT = ${REPO_ROOT}`); log.info(`WORKSPACE in config file ${process.env.WORKSPACE}`); -const stateFilePath = process.env.WORKSPACE - ? `${process.env.WORKSPACE}/qa/envvars.sh` - : `${REPO_ROOT}/../integration-test/qa/envvars.sh`; + +const INTEGRATION_TEST_ROOT = process.env.WORKSPACE || resolve(REPO_ROOT, '../integration-test'); +log.info(`INTEGRATION_TEST_ROOT = ${INTEGRATION_TEST_ROOT}`); + +const stateFilePath = resolve(INTEGRATION_TEST_ROOT, 'qa/envvars.sh'); +log.info(`stateFilePath = ${stateFilePath}`); const prepend = (testFile) => require.resolve(`${testsFolder}/${testFile}`); @@ -46,11 +50,11 @@ export default async ({ readConfigFile }) => { security: { disableTestUser: true }, // choose where screenshots should be saved screenshots: { - directory: resolve(`${REPO_ROOT}/../integration-test`, 'test/screenshots'), + directory: resolve(INTEGRATION_TEST_ROOT, 'test/screenshots'), }, // choose where esArchiver should load archives from esArchiver: { - directory: resolve(`${REPO_ROOT}/../integration-test`, 'test/es_archives'), + directory: resolve(INTEGRATION_TEST_ROOT, 'test/es_archives'), }, }; return settings;