diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsserializer.generaterawid.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsserializer.generaterawid.md index e86d7cbb36435..a9dfd84cf0b42 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsserializer.generaterawid.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsserializer.generaterawid.md @@ -9,7 +9,7 @@ Given a saved object type and id, generates the compound id that is stored in th Signature: ```typescript -generateRawId(namespace: string | undefined, type: string, id?: string): string; +generateRawId(namespace: string | undefined, type: string, id: string): string; ``` ## Parameters diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsutils.generateid.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsutils.generateid.md new file mode 100644 index 0000000000000..f095184484992 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsutils.generateid.md @@ -0,0 +1,17 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsUtils](./kibana-plugin-core-server.savedobjectsutils.md) > [generateId](./kibana-plugin-core-server.savedobjectsutils.generateid.md) + +## SavedObjectsUtils.generateId() method + +Generates a random ID for a saved objects. + +Signature: + +```typescript +static generateId(): string; +``` +Returns: + +`string` + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsutils.israndomid.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsutils.israndomid.md new file mode 100644 index 0000000000000..7bfb1bcbd8cd7 --- /dev/null +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsutils.israndomid.md @@ -0,0 +1,24 @@ + + +[Home](./index.md) > [kibana-plugin-core-server](./kibana-plugin-core-server.md) > [SavedObjectsUtils](./kibana-plugin-core-server.savedobjectsutils.md) > [isRandomId](./kibana-plugin-core-server.savedobjectsutils.israndomid.md) + +## SavedObjectsUtils.isRandomId() method + +Validates that a saved object ID matches UUID format. + +Signature: + +```typescript +static isRandomId(id: string | undefined): boolean; +``` + +## Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| id | string | undefined | | + +Returns: + +`boolean` + diff --git a/docs/development/core/server/kibana-plugin-core-server.savedobjectsutils.md b/docs/development/core/server/kibana-plugin-core-server.savedobjectsutils.md index 83831f65bd41a..7b774e14b640f 100644 --- a/docs/development/core/server/kibana-plugin-core-server.savedobjectsutils.md +++ b/docs/development/core/server/kibana-plugin-core-server.savedobjectsutils.md @@ -19,3 +19,10 @@ export declare class SavedObjectsUtils | [namespaceIdToString](./kibana-plugin-core-server.savedobjectsutils.namespaceidtostring.md) | static | (namespace?: string | undefined) => string | Converts a given saved object namespace ID to its string representation. All namespace IDs have an identical string representation, with the exception of the undefined namespace ID (which has a namespace string of 'default'). | | [namespaceStringToId](./kibana-plugin-core-server.savedobjectsutils.namespacestringtoid.md) | static | (namespace: string) => string | undefined | Converts a given saved object namespace string to its ID representation. All namespace strings have an identical ID representation, with the exception of the 'default' namespace string (which has a namespace ID of undefined). | +## Methods + +| Method | Modifiers | Description | +| --- | --- | --- | +| [generateId()](./kibana-plugin-core-server.savedobjectsutils.generateid.md) | static | Generates a random ID for a saved objects. | +| [isRandomId(id)](./kibana-plugin-core-server.savedobjectsutils.israndomid.md) | static | Validates that a saved object ID matches UUID format. | + diff --git a/docs/user/security/audit-logging.asciidoc b/docs/user/security/audit-logging.asciidoc index bacd93f585adc..4b3512ae3056b 100644 --- a/docs/user/security/audit-logging.asciidoc +++ b/docs/user/security/audit-logging.asciidoc @@ -84,6 +84,14 @@ Refer to the corresponding {es} logs for potential write errors. | `unknown` | User is creating a saved object. | `failure` | User is not authorized to create a saved object. +.2+| `connector_create` +| `unknown` | User is creating a connector. +| `failure` | User is not authorized to create a connector. + +.2+| `alert_create` +| `unknown` | User is creating an alert rule. +| `failure` | User is not authorized to create an alert rule. + 3+a| ====== Type: change @@ -108,6 +116,42 @@ Refer to the corresponding {es} logs for potential write errors. | `unknown` | User is removing references to a saved object. | `failure` | User is not authorized to remove references to a saved object. +.2+| `connector_update` +| `unknown` | User is updating a connector. +| `failure` | User is not authorized to update a connector. + +.2+| `alert_update` +| `unknown` | User is updating an alert rule. +| `failure` | User is not authorized to update an alert rule. + +.2+| `alert_update_api_key` +| `unknown` | User is updating the API key of an alert rule. +| `failure` | User is not authorized to update the API key of an alert rule. + +.2+| `alert_enable` +| `unknown` | User is enabling an alert rule. +| `failure` | User is not authorized to enable an alert rule. + +.2+| `alert_disable` +| `unknown` | User is disabling an alert rule. +| `failure` | User is not authorized to disable an alert rule. + +.2+| `alert_mute` +| `unknown` | User is muting an alert rule. +| `failure` | User is not authorized to mute an alert rule. + +.2+| `alert_unmute` +| `unknown` | User is unmuting an alert rule. +| `failure` | User is not authorized to unmute an alert rule. + +.2+| `alert_instance_mute` +| `unknown` | User is muting an alert instance. +| `failure` | User is not authorized to mute an alert instance. + +.2+| `alert_instance_unmute` +| `unknown` | User is unmuting an alert instance. +| `failure` | User is not authorized to unmute an alert instance. + 3+a| ====== Type: deletion @@ -120,6 +164,14 @@ Refer to the corresponding {es} logs for potential write errors. | `unknown` | User is deleting a saved object. | `failure` | User is not authorized to delete a saved object. +.2+| `connector_delete` +| `unknown` | User is deleting a connector. +| `failure` | User is not authorized to delete a connector. + +.2+| `alert_delete` +| `unknown` | User is deleting an alert rule. +| `failure` | User is not authorized to delete an alert rule. + 3+a| ====== Type: access @@ -135,6 +187,22 @@ Refer to the corresponding {es} logs for potential write errors. | `success` | User has accessed a saved object as part of a search operation. | `failure` | User is not authorized to search for saved objects. +.2+| `connector_get` +| `success` | User has accessed a connector. +| `failure` | User is not authorized to access a connector. + +.2+| `connector_find` +| `success` | User has accessed a connector as part of a search operation. +| `failure` | User is not authorized to search for connectors. + +.2+| `alert_get` +| `success` | User has accessed an alert rule. +| `failure` | User is not authorized to access an alert rule. + +.2+| `alert_find` +| `success` | User has accessed an alert rule as part of a search operation. +| `failure` | User is not authorized to search for alert rules. + 3+a| ===== Category: web diff --git a/src/core/server/saved_objects/serialization/serializer.test.ts b/src/core/server/saved_objects/serialization/serializer.test.ts index e5f0e8abd3b71..561f9bc001e30 100644 --- a/src/core/server/saved_objects/serialization/serializer.test.ts +++ b/src/core/server/saved_objects/serialization/serializer.test.ts @@ -573,24 +573,10 @@ describe('#savedObjectToRaw', () => { }); describe('single-namespace type without a namespace', () => { - test('generates an id prefixed with type, if no id is specified', () => { - const v1 = singleNamespaceSerializer.savedObjectToRaw({ - type: 'foo', - attributes: { bar: true }, - } as any); - - const v2 = singleNamespaceSerializer.savedObjectToRaw({ - type: 'foo', - attributes: { bar: true }, - } as any); - - expect(v1._id).toMatch(/^foo\:[\w-]+$/); - expect(v1._id).not.toEqual(v2._id); - }); - test(`doesn't specify _source.namespace`, () => { const actual = singleNamespaceSerializer.savedObjectToRaw({ type: '', + id: 'mock-saved-object-id', attributes: {}, } as any); @@ -599,23 +585,6 @@ describe('#savedObjectToRaw', () => { }); describe('single-namespace type with a namespace', () => { - test('generates an id prefixed with namespace and type, if no id is specified', () => { - const v1 = singleNamespaceSerializer.savedObjectToRaw({ - type: 'foo', - namespace: 'bar', - attributes: { bar: true }, - } as any); - - const v2 = singleNamespaceSerializer.savedObjectToRaw({ - type: 'foo', - namespace: 'bar', - attributes: { bar: true }, - } as any); - - expect(v1._id).toMatch(/^bar\:foo\:[\w-]+$/); - expect(v1._id).not.toEqual(v2._id); - }); - test(`it copies namespace to _source.namespace`, () => { const actual = singleNamespaceSerializer.savedObjectToRaw({ type: 'foo', @@ -628,23 +597,6 @@ describe('#savedObjectToRaw', () => { }); describe('single-namespace type with namespaces', () => { - test('generates an id prefixed with type, if no id is specified', () => { - const v1 = namespaceAgnosticSerializer.savedObjectToRaw({ - type: 'foo', - namespaces: ['bar'], - attributes: { bar: true }, - } as any); - - const v2 = namespaceAgnosticSerializer.savedObjectToRaw({ - type: 'foo', - namespaces: ['bar'], - attributes: { bar: true }, - } as any); - - expect(v1._id).toMatch(/^foo\:[\w-]+$/); - expect(v1._id).not.toEqual(v2._id); - }); - test(`doesn't specify _source.namespaces`, () => { const actual = namespaceAgnosticSerializer.savedObjectToRaw({ type: 'foo', @@ -657,23 +609,6 @@ describe('#savedObjectToRaw', () => { }); describe('namespace-agnostic type with a namespace', () => { - test('generates an id prefixed with type, if no id is specified', () => { - const v1 = namespaceAgnosticSerializer.savedObjectToRaw({ - type: 'foo', - namespace: 'bar', - attributes: { bar: true }, - } as any); - - const v2 = namespaceAgnosticSerializer.savedObjectToRaw({ - type: 'foo', - namespace: 'bar', - attributes: { bar: true }, - } as any); - - expect(v1._id).toMatch(/^foo\:[\w-]+$/); - expect(v1._id).not.toEqual(v2._id); - }); - test(`doesn't specify _source.namespace`, () => { const actual = namespaceAgnosticSerializer.savedObjectToRaw({ type: 'foo', @@ -686,23 +621,6 @@ describe('#savedObjectToRaw', () => { }); describe('namespace-agnostic type with namespaces', () => { - test('generates an id prefixed with type, if no id is specified', () => { - const v1 = namespaceAgnosticSerializer.savedObjectToRaw({ - type: 'foo', - namespaces: ['bar'], - attributes: { bar: true }, - } as any); - - const v2 = namespaceAgnosticSerializer.savedObjectToRaw({ - type: 'foo', - namespaces: ['bar'], - attributes: { bar: true }, - } as any); - - expect(v1._id).toMatch(/^foo\:[\w-]+$/); - expect(v1._id).not.toEqual(v2._id); - }); - test(`doesn't specify _source.namespaces`, () => { const actual = namespaceAgnosticSerializer.savedObjectToRaw({ type: 'foo', @@ -715,23 +633,6 @@ describe('#savedObjectToRaw', () => { }); describe('multi-namespace type with a namespace', () => { - test('generates an id prefixed with type, if no id is specified', () => { - const v1 = multiNamespaceSerializer.savedObjectToRaw({ - type: 'foo', - namespace: 'bar', - attributes: { bar: true }, - } as any); - - const v2 = multiNamespaceSerializer.savedObjectToRaw({ - type: 'foo', - namespace: 'bar', - attributes: { bar: true }, - } as any); - - expect(v1._id).toMatch(/^foo\:[\w-]+$/); - expect(v1._id).not.toEqual(v2._id); - }); - test(`doesn't specify _source.namespace`, () => { const actual = multiNamespaceSerializer.savedObjectToRaw({ type: 'foo', @@ -744,23 +645,6 @@ describe('#savedObjectToRaw', () => { }); describe('multi-namespace type with namespaces', () => { - test('generates an id prefixed with type, if no id is specified', () => { - const v1 = multiNamespaceSerializer.savedObjectToRaw({ - type: 'foo', - namespaces: ['bar'], - attributes: { bar: true }, - } as any); - - const v2 = multiNamespaceSerializer.savedObjectToRaw({ - type: 'foo', - namespaces: ['bar'], - attributes: { bar: true }, - } as any); - - expect(v1._id).toMatch(/^foo\:[\w-]+$/); - expect(v1._id).not.toEqual(v2._id); - }); - test(`it copies namespaces to _source.namespaces`, () => { const actual = multiNamespaceSerializer.savedObjectToRaw({ type: 'foo', @@ -1064,11 +948,6 @@ describe('#isRawSavedObject', () => { describe('#generateRawId', () => { describe('single-namespace type without a namespace', () => { - test('generates an id if none is specified', () => { - const id = singleNamespaceSerializer.generateRawId('', 'goodbye'); - expect(id).toMatch(/^goodbye\:[\w-]+$/); - }); - test('uses the id that is specified', () => { const id = singleNamespaceSerializer.generateRawId('', 'hello', 'world'); expect(id).toEqual('hello:world'); @@ -1076,11 +955,6 @@ describe('#generateRawId', () => { }); describe('single-namespace type with a namespace', () => { - test('generates an id if none is specified and prefixes namespace', () => { - const id = singleNamespaceSerializer.generateRawId('foo', 'goodbye'); - expect(id).toMatch(/^foo:goodbye\:[\w-]+$/); - }); - test('uses the id that is specified and prefixes the namespace', () => { const id = singleNamespaceSerializer.generateRawId('foo', 'hello', 'world'); expect(id).toEqual('foo:hello:world'); @@ -1088,11 +962,6 @@ describe('#generateRawId', () => { }); describe('namespace-agnostic type with a namespace', () => { - test(`generates an id if none is specified and doesn't prefix namespace`, () => { - const id = namespaceAgnosticSerializer.generateRawId('foo', 'goodbye'); - expect(id).toMatch(/^goodbye\:[\w-]+$/); - }); - test(`uses the id that is specified and doesn't prefix the namespace`, () => { const id = namespaceAgnosticSerializer.generateRawId('foo', 'hello', 'world'); expect(id).toEqual('hello:world'); diff --git a/src/core/server/saved_objects/serialization/serializer.ts b/src/core/server/saved_objects/serialization/serializer.ts index 145dd286c1ca8..82999eeceb887 100644 --- a/src/core/server/saved_objects/serialization/serializer.ts +++ b/src/core/server/saved_objects/serialization/serializer.ts @@ -17,7 +17,6 @@ * under the License. */ -import uuid from 'uuid'; import { decodeVersion, encodeVersion } from '../version'; import { ISavedObjectTypeRegistry } from '../saved_objects_type_registry'; import { SavedObjectsRawDoc, SavedObjectSanitizedDoc } from './types'; @@ -127,10 +126,10 @@ export class SavedObjectsSerializer { * @param {string} type - The saved object type * @param {string} id - The id of the saved object */ - public generateRawId(namespace: string | undefined, type: string, id?: string) { + public generateRawId(namespace: string | undefined, type: string, id: string) { const namespacePrefix = namespace && this.registry.isSingleNamespace(type) ? `${namespace}:` : ''; - return `${namespacePrefix}${type}:${id || uuid.v1()}`; + return `${namespacePrefix}${type}:${id}`; } private trimIdPrefix(namespace: string | undefined, type: string, id: string) { diff --git a/src/core/server/saved_objects/serialization/types.ts b/src/core/server/saved_objects/serialization/types.ts index 8b3eebceb2c5a..e59b1a68e1ad1 100644 --- a/src/core/server/saved_objects/serialization/types.ts +++ b/src/core/server/saved_objects/serialization/types.ts @@ -50,7 +50,7 @@ export interface SavedObjectsRawDocSource { */ interface SavedObjectDoc { attributes: T; - id?: string; // NOTE: SavedObjectDoc is used for uncreated objects where `id` is optional + id: string; type: string; namespace?: string; namespaces?: string[]; diff --git a/src/core/server/saved_objects/service/lib/repository.test.js b/src/core/server/saved_objects/service/lib/repository.test.js index 6a3defb9556f5..a19b4cc01db8e 100644 --- a/src/core/server/saved_objects/service/lib/repository.test.js +++ b/src/core/server/saved_objects/service/lib/repository.test.js @@ -1831,21 +1831,16 @@ describe('SavedObjectsRepository', () => { }; describe('client calls', () => { - it(`should use the ES create action if ID is undefined and overwrite=true`, async () => { + it(`should use the ES index action if overwrite=true`, async () => { await createSuccess(type, attributes, { overwrite: true }); - expect(client.create).toHaveBeenCalled(); + expect(client.index).toHaveBeenCalled(); }); - it(`should use the ES create action if ID is undefined and overwrite=false`, async () => { + it(`should use the ES create action if overwrite=false`, async () => { await createSuccess(type, attributes); expect(client.create).toHaveBeenCalled(); }); - it(`should use the ES index action if ID is defined and overwrite=true`, async () => { - await createSuccess(type, attributes, { id, overwrite: true }); - expect(client.index).toHaveBeenCalled(); - }); - it(`should use the ES index with version if ID and version are defined and overwrite=true`, async () => { await createSuccess(type, attributes, { id, overwrite: true, version: mockVersion }); expect(client.index).toHaveBeenCalled(); diff --git a/src/core/server/saved_objects/service/lib/repository.ts b/src/core/server/saved_objects/service/lib/repository.ts index dae6a8d19dae2..587a0e51ef9b9 100644 --- a/src/core/server/saved_objects/service/lib/repository.ts +++ b/src/core/server/saved_objects/service/lib/repository.ts @@ -18,7 +18,6 @@ */ import { omit, isObject } from 'lodash'; -import uuid from 'uuid'; import { ElasticsearchClient, DeleteDocumentResponse, @@ -245,7 +244,7 @@ export class SavedObjectsRepository { options: SavedObjectsCreateOptions = {} ): Promise> { const { - id, + id = SavedObjectsUtils.generateId(), migrationVersion, overwrite = false, references = [], @@ -366,7 +365,9 @@ export class SavedObjectsRepository { const method = object.id && overwrite ? 'index' : 'create'; const requiresNamespacesCheck = object.id && this._registry.isMultiNamespace(object.type); - if (object.id == null) object.id = uuid.v1(); + if (object.id == null) { + object.id = SavedObjectsUtils.generateId(); + } return { tag: 'Right' as 'Right', diff --git a/src/core/server/saved_objects/service/lib/utils.test.ts b/src/core/server/saved_objects/service/lib/utils.test.ts index ac06ca9275783..062a68e2dca28 100644 --- a/src/core/server/saved_objects/service/lib/utils.test.ts +++ b/src/core/server/saved_objects/service/lib/utils.test.ts @@ -17,11 +17,22 @@ * under the License. */ +import uuid from 'uuid'; import { SavedObjectsFindOptions } from '../../types'; import { SavedObjectsUtils } from './utils'; +jest.mock('uuid', () => ({ + v1: jest.fn().mockReturnValue('mock-uuid'), +})); + describe('SavedObjectsUtils', () => { - const { namespaceIdToString, namespaceStringToId, createEmptyFindResponse } = SavedObjectsUtils; + const { + namespaceIdToString, + namespaceStringToId, + createEmptyFindResponse, + generateId, + isRandomId, + } = SavedObjectsUtils; describe('#namespaceIdToString', () => { it('converts `undefined` to default namespace string', () => { @@ -77,4 +88,20 @@ describe('SavedObjectsUtils', () => { expect(createEmptyFindResponse(options).per_page).toEqual(42); }); }); + + describe('#generateId', () => { + it('returns a valid uuid', () => { + expect(generateId()).toBe('mock-uuid'); + expect(uuid.v1).toHaveBeenCalled(); + }); + }); + + describe('#isRandomId', () => { + it('validates uuid correctly', () => { + expect(isRandomId('c4d82f66-3046-11eb-adc1-0242ac120002')).toBe(true); + expect(isRandomId('invalid')).toBe(false); + expect(isRandomId('')).toBe(false); + expect(isRandomId(undefined)).toBe(false); + }); + }); }); diff --git a/src/core/server/saved_objects/service/lib/utils.ts b/src/core/server/saved_objects/service/lib/utils.ts index 69abc37089218..b59829cb4978a 100644 --- a/src/core/server/saved_objects/service/lib/utils.ts +++ b/src/core/server/saved_objects/service/lib/utils.ts @@ -17,6 +17,7 @@ * under the License. */ +import uuid from 'uuid'; import { SavedObjectsFindOptions } from '../../types'; import { SavedObjectsFindResponse } from '..'; @@ -24,6 +25,7 @@ export const DEFAULT_NAMESPACE_STRING = 'default'; export const ALL_NAMESPACES_STRING = '*'; export const FIND_DEFAULT_PAGE = 1; export const FIND_DEFAULT_PER_PAGE = 20; +const UUID_REGEX = /^(?:[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}|00000000-0000-0000-0000-000000000000)$/i; /** * @public @@ -69,4 +71,21 @@ export class SavedObjectsUtils { total: 0, saved_objects: [], }); + + /** + * Generates a random ID for a saved objects. + */ + public static generateId() { + return uuid.v1(); + } + + /** + * Validates that a saved object ID has been randomly generated. + * + * @param {string} id The ID of a saved object. + * @todo Use `uuid.validate` once upgraded to v5.3+ + */ + public static isRandomId(id: string | undefined) { + return typeof id === 'string' && UUID_REGEX.test(id); + } } diff --git a/src/core/server/server.api.md b/src/core/server/server.api.md index d877fc36d114b..770048d2cff13 100644 --- a/src/core/server/server.api.md +++ b/src/core/server/server.api.md @@ -2518,7 +2518,7 @@ export interface SavedObjectsResolveImportErrorsOptions { export class SavedObjectsSerializer { // @internal constructor(registry: ISavedObjectTypeRegistry); - generateRawId(namespace: string | undefined, type: string, id?: string): string; + generateRawId(namespace: string | undefined, type: string, id: string): string; isRawSavedObject(rawDoc: SavedObjectsRawDoc): boolean; rawToSavedObject(doc: SavedObjectsRawDoc): SavedObjectSanitizedDoc; savedObjectToRaw(savedObj: SavedObjectSanitizedDoc): SavedObjectsRawDoc; @@ -2600,6 +2600,8 @@ export interface SavedObjectsUpdateResponse extends Omit({ page, perPage, }: SavedObjectsFindOptions) => SavedObjectsFindResponse; + static generateId(): string; + static isRandomId(id: string | undefined): boolean; static namespaceIdToString: (namespace?: string | undefined) => string; static namespaceStringToId: (namespace: string) => string | undefined; } diff --git a/x-pack/plugins/actions/server/actions_client.test.ts b/x-pack/plugins/actions/server/actions_client.test.ts index 171f8d4b0b1d4..8b6c25e1c3f24 100644 --- a/x-pack/plugins/actions/server/actions_client.test.ts +++ b/x-pack/plugins/actions/server/actions_client.test.ts @@ -15,6 +15,8 @@ import { actionsConfigMock } from './actions_config.mock'; import { getActionsConfigurationUtilities } from './actions_config'; import { licenseStateMock } from './lib/license_state.mock'; import { licensingMock } from '../../licensing/server/mocks'; +import { httpServerMock } from '../../../../src/core/server/mocks'; +import { auditServiceMock } from '../../security/server/audit/index.mock'; import { elasticsearchServiceMock, @@ -22,17 +24,23 @@ import { } from '../../../../src/core/server/mocks'; import { actionExecutorMock } from './lib/action_executor.mock'; import uuid from 'uuid'; -import { KibanaRequest } from 'kibana/server'; import { ActionsAuthorization } from './authorization/actions_authorization'; import { actionsAuthorizationMock } from './authorization/actions_authorization.mock'; +jest.mock('../../../../src/core/server/saved_objects/service/lib/utils', () => ({ + SavedObjectsUtils: { + generateId: () => 'mock-saved-object-id', + }, +})); + const defaultKibanaIndex = '.kibana'; const unsecuredSavedObjectsClient = savedObjectsClientMock.create(); const scopedClusterClient = elasticsearchServiceMock.createLegacyScopedClusterClient(); const actionExecutor = actionExecutorMock.create(); const authorization = actionsAuthorizationMock.create(); const executionEnqueuer = jest.fn(); -const request = {} as KibanaRequest; +const request = httpServerMock.createKibanaRequest(); +const auditLogger = auditServiceMock.create().asScoped(request); const mockTaskManager = taskManagerMock.createSetup(); @@ -68,6 +76,7 @@ beforeEach(() => { executionEnqueuer, request, authorization: (authorization as unknown) as ActionsAuthorization, + auditLogger, }); }); @@ -142,6 +151,95 @@ describe('create()', () => { }); }); + describe('auditLogger', () => { + test('logs audit event when creating a connector', async () => { + const savedObjectCreateResult = { + id: '1', + type: 'action', + attributes: { + name: 'my name', + actionTypeId: 'my-action-type', + config: {}, + }, + references: [], + }; + actionTypeRegistry.register({ + id: savedObjectCreateResult.attributes.actionTypeId, + name: 'My action type', + minimumLicenseRequired: 'basic', + executor, + }); + unsecuredSavedObjectsClient.create.mockResolvedValueOnce(savedObjectCreateResult); + + await actionsClient.create({ + action: { + ...savedObjectCreateResult.attributes, + secrets: {}, + }, + }); + + expect(auditLogger.log).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + action: 'connector_create', + outcome: 'unknown', + }), + kibana: { saved_object: { id: 'mock-saved-object-id', type: 'action' } }, + }) + ); + }); + + test('logs audit event when not authorised to create a connector', async () => { + const savedObjectCreateResult = { + id: '1', + type: 'action', + attributes: { + name: 'my name', + actionTypeId: 'my-action-type', + config: {}, + }, + references: [], + }; + actionTypeRegistry.register({ + id: savedObjectCreateResult.attributes.actionTypeId, + name: 'My action type', + minimumLicenseRequired: 'basic', + executor, + }); + unsecuredSavedObjectsClient.create.mockResolvedValueOnce(savedObjectCreateResult); + + authorization.ensureAuthorized.mockRejectedValue(new Error('Unauthorized')); + + await expect( + async () => + await actionsClient.create({ + action: { + ...savedObjectCreateResult.attributes, + secrets: {}, + }, + }) + ).rejects.toThrow(); + expect(auditLogger.log).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + action: 'connector_create', + outcome: 'failure', + }), + kibana: { + saved_object: { + id: 'mock-saved-object-id', + type: 'action', + }, + }, + error: { + code: 'Error', + message: 'Unauthorized', + }, + }) + ); + }); + }); + test('creates an action with all given properties', async () => { const savedObjectCreateResult = { id: '1', @@ -185,6 +283,9 @@ describe('create()', () => { "name": "my name", "secrets": Object {}, }, + Object { + "id": "mock-saved-object-id", + }, ] `); }); @@ -289,6 +390,9 @@ describe('create()', () => { "name": "my name", "secrets": Object {}, }, + Object { + "id": "mock-saved-object-id", + }, ] `); }); @@ -440,7 +544,7 @@ describe('get()', () => { expect(authorization.ensureAuthorized).toHaveBeenCalledWith('get'); }); - test('throws when user is not authorised to create the type of action', async () => { + test('throws when user is not authorised to get the type of action', async () => { unsecuredSavedObjectsClient.get.mockResolvedValueOnce({ id: '1', type: 'type', @@ -463,7 +567,7 @@ describe('get()', () => { expect(authorization.ensureAuthorized).toHaveBeenCalledWith('get'); }); - test('throws when user is not authorised to create preconfigured of action', async () => { + test('throws when user is not authorised to get preconfigured of action', async () => { actionsClient = new ActionsClient({ actionTypeRegistry, unsecuredSavedObjectsClient, @@ -501,6 +605,61 @@ describe('get()', () => { }); }); + describe('auditLogger', () => { + test('logs audit event when getting a connector', async () => { + unsecuredSavedObjectsClient.get.mockResolvedValueOnce({ + id: '1', + type: 'type', + attributes: { + name: 'my name', + actionTypeId: 'my-action-type', + config: {}, + }, + references: [], + }); + + await actionsClient.get({ id: '1' }); + + expect(auditLogger.log).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + action: 'connector_get', + outcome: 'success', + }), + kibana: { saved_object: { id: '1', type: 'action' } }, + }) + ); + }); + + test('logs audit event when not authorised to get a connector', async () => { + unsecuredSavedObjectsClient.get.mockResolvedValueOnce({ + id: '1', + type: 'type', + attributes: { + name: 'my name', + actionTypeId: 'my-action-type', + config: {}, + }, + references: [], + }); + + authorization.ensureAuthorized.mockRejectedValue(new Error('Unauthorized')); + + await expect(actionsClient.get({ id: '1' })).rejects.toThrow(); + + expect(auditLogger.log).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + action: 'connector_get', + outcome: 'failure', + }), + kibana: { saved_object: { id: '1', type: 'action' } }, + error: { code: 'Error', message: 'Unauthorized' }, + }) + ); + }); + }); + test('calls unsecuredSavedObjectsClient with id', async () => { unsecuredSavedObjectsClient.get.mockResolvedValueOnce({ id: '1', @@ -632,6 +791,64 @@ describe('getAll()', () => { }); }); + describe('auditLogger', () => { + test('logs audit event when searching connectors', async () => { + unsecuredSavedObjectsClient.find.mockResolvedValueOnce({ + total: 1, + per_page: 10, + page: 1, + saved_objects: [ + { + id: '1', + type: 'type', + attributes: { + name: 'test', + config: { + foo: 'bar', + }, + }, + score: 1, + references: [], + }, + ], + }); + scopedClusterClient.callAsInternalUser.mockResolvedValueOnce({ + aggregations: { + '1': { doc_count: 6 }, + testPreconfigured: { doc_count: 2 }, + }, + }); + + await actionsClient.getAll(); + + expect(auditLogger.log).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + action: 'connector_find', + outcome: 'success', + }), + kibana: { saved_object: { id: '1', type: 'action' } }, + }) + ); + }); + + test('logs audit event when not authorised to search connectors', async () => { + authorization.ensureAuthorized.mockRejectedValue(new Error('Unauthorized')); + + await expect(actionsClient.getAll()).rejects.toThrow(); + + expect(auditLogger.log).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + action: 'connector_find', + outcome: 'failure', + }), + error: { code: 'Error', message: 'Unauthorized' }, + }) + ); + }); + }); + test('calls unsecuredSavedObjectsClient with parameters', async () => { const expectedResult = { total: 1, @@ -773,6 +990,62 @@ describe('getBulk()', () => { }); }); + describe('auditLogger', () => { + test('logs audit event when bulk getting connectors', async () => { + unsecuredSavedObjectsClient.bulkGet.mockResolvedValueOnce({ + saved_objects: [ + { + id: '1', + type: 'action', + attributes: { + actionTypeId: 'test', + name: 'test', + config: { + foo: 'bar', + }, + }, + references: [], + }, + ], + }); + scopedClusterClient.callAsInternalUser.mockResolvedValueOnce({ + aggregations: { + '1': { doc_count: 6 }, + testPreconfigured: { doc_count: 2 }, + }, + }); + + await actionsClient.getBulk(['1']); + + expect(auditLogger.log).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + action: 'connector_get', + outcome: 'success', + }), + kibana: { saved_object: { id: '1', type: 'action' } }, + }) + ); + }); + + test('logs audit event when not authorised to bulk get connectors', async () => { + authorization.ensureAuthorized.mockRejectedValue(new Error('Unauthorized')); + + await expect(actionsClient.getBulk(['1'])).rejects.toThrow(); + + expect(auditLogger.log).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + action: 'connector_get', + outcome: 'failure', + }), + kibana: { saved_object: { id: '1', type: 'action' } }, + error: { code: 'Error', message: 'Unauthorized' }, + }) + ); + }); + }); + test('calls getBulk unsecuredSavedObjectsClient with parameters', async () => { unsecuredSavedObjectsClient.bulkGet.mockResolvedValueOnce({ saved_objects: [ @@ -864,6 +1137,39 @@ describe('delete()', () => { }); }); + describe('auditLogger', () => { + test('logs audit event when deleting a connector', async () => { + await actionsClient.delete({ id: '1' }); + + expect(auditLogger.log).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + action: 'connector_delete', + outcome: 'unknown', + }), + kibana: { saved_object: { id: '1', type: 'action' } }, + }) + ); + }); + + test('logs audit event when not authorised to delete a connector', async () => { + authorization.ensureAuthorized.mockRejectedValue(new Error('Unauthorized')); + + await expect(actionsClient.delete({ id: '1' })).rejects.toThrow(); + + expect(auditLogger.log).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + action: 'connector_delete', + outcome: 'failure', + }), + kibana: { saved_object: { id: '1', type: 'action' } }, + error: { code: 'Error', message: 'Unauthorized' }, + }) + ); + }); + }); + test('calls unsecuredSavedObjectsClient with id', async () => { const expectedResult = Symbol(); unsecuredSavedObjectsClient.delete.mockResolvedValueOnce(expectedResult); @@ -880,42 +1186,43 @@ describe('delete()', () => { }); describe('update()', () => { + function updateOperation(): ReturnType { + actionTypeRegistry.register({ + id: 'my-action-type', + name: 'My action type', + minimumLicenseRequired: 'basic', + executor, + }); + unsecuredSavedObjectsClient.get.mockResolvedValueOnce({ + id: '1', + type: 'action', + attributes: { + actionTypeId: 'my-action-type', + }, + references: [], + }); + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ + id: 'my-action', + type: 'action', + attributes: { + actionTypeId: 'my-action-type', + name: 'my name', + config: {}, + secrets: {}, + }, + references: [], + }); + return actionsClient.update({ + id: 'my-action', + action: { + name: 'my name', + config: {}, + secrets: {}, + }, + }); + } + describe('authorization', () => { - function updateOperation(): ReturnType { - actionTypeRegistry.register({ - id: 'my-action-type', - name: 'My action type', - minimumLicenseRequired: 'basic', - executor, - }); - unsecuredSavedObjectsClient.get.mockResolvedValueOnce({ - id: '1', - type: 'action', - attributes: { - actionTypeId: 'my-action-type', - }, - references: [], - }); - unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ - id: 'my-action', - type: 'action', - attributes: { - actionTypeId: 'my-action-type', - name: 'my name', - config: {}, - secrets: {}, - }, - references: [], - }); - return actionsClient.update({ - id: 'my-action', - action: { - name: 'my name', - config: {}, - secrets: {}, - }, - }); - } test('ensures user is authorised to update actions', async () => { await updateOperation(); expect(authorization.ensureAuthorized).toHaveBeenCalledWith('update'); @@ -934,6 +1241,39 @@ describe('update()', () => { }); }); + describe('auditLogger', () => { + test('logs audit event when updating a connector', async () => { + await updateOperation(); + + expect(auditLogger.log).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + action: 'connector_update', + outcome: 'unknown', + }), + kibana: { saved_object: { id: 'my-action', type: 'action' } }, + }) + ); + }); + + test('logs audit event when not authorised to update a connector', async () => { + authorization.ensureAuthorized.mockRejectedValue(new Error('Unauthorized')); + + await expect(updateOperation()).rejects.toThrow(); + + expect(auditLogger.log).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + action: 'connector_update', + outcome: 'failure', + }), + kibana: { saved_object: { id: 'my-action', type: 'action' } }, + error: { code: 'Error', message: 'Unauthorized' }, + }) + ); + }); + }); + test('updates an action with all given properties', async () => { actionTypeRegistry.register({ id: 'my-action-type', diff --git a/x-pack/plugins/actions/server/actions_client.ts b/x-pack/plugins/actions/server/actions_client.ts index 0d41b520501ad..ab693dc340c92 100644 --- a/x-pack/plugins/actions/server/actions_client.ts +++ b/x-pack/plugins/actions/server/actions_client.ts @@ -4,16 +4,19 @@ * you may not use this file except in compliance with the Elastic License. */ import Boom from '@hapi/boom'; + +import { i18n } from '@kbn/i18n'; +import { omitBy, isUndefined } from 'lodash'; import { ILegacyScopedClusterClient, SavedObjectsClientContract, SavedObjectAttributes, SavedObject, KibanaRequest, -} from 'src/core/server'; - -import { i18n } from '@kbn/i18n'; -import { omitBy, isUndefined } from 'lodash'; + SavedObjectsUtils, +} from '../../../../src/core/server'; +import { AuditLogger, EventOutcome } from '../../security/server'; +import { ActionType } from '../common'; import { ActionTypeRegistry } from './action_type_registry'; import { validateConfig, validateSecrets, ActionExecutorContract } from './lib'; import { @@ -30,11 +33,11 @@ import { ExecuteOptions as EnqueueExecutionOptions, } from './create_execute_function'; import { ActionsAuthorization } from './authorization/actions_authorization'; -import { ActionType } from '../common'; import { getAuthorizationModeBySource, AuthorizationMode, } from './authorization/get_authorization_mode_by_source'; +import { connectorAuditEvent, ConnectorAuditAction } from './lib/audit_events'; // We are assuming there won't be many actions. This is why we will load // all the actions in advance and assume the total count to not go over 10000. @@ -65,6 +68,7 @@ interface ConstructorOptions { executionEnqueuer: ExecutionEnqueuer; request: KibanaRequest; authorization: ActionsAuthorization; + auditLogger?: AuditLogger; } interface UpdateOptions { @@ -82,6 +86,7 @@ export class ActionsClient { private readonly request: KibanaRequest; private readonly authorization: ActionsAuthorization; private readonly executionEnqueuer: ExecutionEnqueuer; + private readonly auditLogger?: AuditLogger; constructor({ actionTypeRegistry, @@ -93,6 +98,7 @@ export class ActionsClient { executionEnqueuer, request, authorization, + auditLogger, }: ConstructorOptions) { this.actionTypeRegistry = actionTypeRegistry; this.unsecuredSavedObjectsClient = unsecuredSavedObjectsClient; @@ -103,6 +109,7 @@ export class ActionsClient { this.executionEnqueuer = executionEnqueuer; this.request = request; this.authorization = authorization; + this.auditLogger = auditLogger; } /** @@ -111,7 +118,20 @@ export class ActionsClient { public async create({ action: { actionTypeId, name, config, secrets }, }: CreateOptions): Promise { - await this.authorization.ensureAuthorized('create', actionTypeId); + const id = SavedObjectsUtils.generateId(); + + try { + await this.authorization.ensureAuthorized('create', actionTypeId); + } catch (error) { + this.auditLogger?.log( + connectorAuditEvent({ + action: ConnectorAuditAction.CREATE, + savedObject: { type: 'action', id }, + error, + }) + ); + throw error; + } const actionType = this.actionTypeRegistry.get(actionTypeId); const validatedActionTypeConfig = validateConfig(actionType, config); @@ -119,12 +139,24 @@ export class ActionsClient { this.actionTypeRegistry.ensureActionTypeEnabled(actionTypeId); - const result = await this.unsecuredSavedObjectsClient.create('action', { - actionTypeId, - name, - config: validatedActionTypeConfig as SavedObjectAttributes, - secrets: validatedActionTypeSecrets as SavedObjectAttributes, - }); + this.auditLogger?.log( + connectorAuditEvent({ + action: ConnectorAuditAction.CREATE, + savedObject: { type: 'action', id }, + outcome: EventOutcome.UNKNOWN, + }) + ); + + const result = await this.unsecuredSavedObjectsClient.create( + 'action', + { + actionTypeId, + name, + config: validatedActionTypeConfig as SavedObjectAttributes, + secrets: validatedActionTypeSecrets as SavedObjectAttributes, + }, + { id } + ); return { id: result.id, @@ -139,21 +171,32 @@ export class ActionsClient { * Update action */ public async update({ id, action }: UpdateOptions): Promise { - await this.authorization.ensureAuthorized('update'); - - if ( - this.preconfiguredActions.find((preconfiguredAction) => preconfiguredAction.id === id) !== - undefined - ) { - throw new PreconfiguredActionDisabledModificationError( - i18n.translate('xpack.actions.serverSideErrors.predefinedActionUpdateDisabled', { - defaultMessage: 'Preconfigured action {id} is not allowed to update.', - values: { - id, - }, - }), - 'update' + try { + await this.authorization.ensureAuthorized('update'); + + if ( + this.preconfiguredActions.find((preconfiguredAction) => preconfiguredAction.id === id) !== + undefined + ) { + throw new PreconfiguredActionDisabledModificationError( + i18n.translate('xpack.actions.serverSideErrors.predefinedActionUpdateDisabled', { + defaultMessage: 'Preconfigured action {id} is not allowed to update.', + values: { + id, + }, + }), + 'update' + ); + } + } catch (error) { + this.auditLogger?.log( + connectorAuditEvent({ + action: ConnectorAuditAction.UPDATE, + savedObject: { type: 'action', id }, + error, + }) ); + throw error; } const { attributes, @@ -168,6 +211,14 @@ export class ActionsClient { this.actionTypeRegistry.ensureActionTypeEnabled(actionTypeId); + this.auditLogger?.log( + connectorAuditEvent({ + action: ConnectorAuditAction.UPDATE, + savedObject: { type: 'action', id }, + outcome: EventOutcome.UNKNOWN, + }) + ); + const result = await this.unsecuredSavedObjectsClient.create( 'action', { @@ -201,12 +252,30 @@ export class ActionsClient { * Get an action */ public async get({ id }: { id: string }): Promise { - await this.authorization.ensureAuthorized('get'); + try { + await this.authorization.ensureAuthorized('get'); + } catch (error) { + this.auditLogger?.log( + connectorAuditEvent({ + action: ConnectorAuditAction.GET, + savedObject: { type: 'action', id }, + error, + }) + ); + throw error; + } const preconfiguredActionsList = this.preconfiguredActions.find( (preconfiguredAction) => preconfiguredAction.id === id ); if (preconfiguredActionsList !== undefined) { + this.auditLogger?.log( + connectorAuditEvent({ + action: ConnectorAuditAction.GET, + savedObject: { type: 'action', id }, + }) + ); + return { id, actionTypeId: preconfiguredActionsList.actionTypeId, @@ -214,8 +283,16 @@ export class ActionsClient { isPreconfigured: true, }; } + const result = await this.unsecuredSavedObjectsClient.get('action', id); + this.auditLogger?.log( + connectorAuditEvent({ + action: ConnectorAuditAction.GET, + savedObject: { type: 'action', id }, + }) + ); + return { id, actionTypeId: result.attributes.actionTypeId, @@ -229,7 +306,17 @@ export class ActionsClient { * Get all actions with preconfigured list */ public async getAll(): Promise { - await this.authorization.ensureAuthorized('get'); + try { + await this.authorization.ensureAuthorized('get'); + } catch (error) { + this.auditLogger?.log( + connectorAuditEvent({ + action: ConnectorAuditAction.FIND, + error, + }) + ); + throw error; + } const savedObjectsActions = ( await this.unsecuredSavedObjectsClient.find({ @@ -238,6 +325,15 @@ export class ActionsClient { }) ).saved_objects.map(actionFromSavedObject); + savedObjectsActions.forEach(({ id }) => + this.auditLogger?.log( + connectorAuditEvent({ + action: ConnectorAuditAction.FIND, + savedObject: { type: 'action', id }, + }) + ) + ); + const mergedResult = [ ...savedObjectsActions, ...this.preconfiguredActions.map((preconfiguredAction) => ({ @@ -258,7 +354,20 @@ export class ActionsClient { * Get bulk actions with preconfigured list */ public async getBulk(ids: string[]): Promise { - await this.authorization.ensureAuthorized('get'); + try { + await this.authorization.ensureAuthorized('get'); + } catch (error) { + ids.forEach((id) => + this.auditLogger?.log( + connectorAuditEvent({ + action: ConnectorAuditAction.GET, + savedObject: { type: 'action', id }, + error, + }) + ) + ); + throw error; + } const actionResults = new Array(); for (const actionId of ids) { @@ -283,6 +392,17 @@ export class ActionsClient { const bulkGetOpts = actionSavedObjectsIds.map((id) => ({ id, type: 'action' })); const bulkGetResult = await this.unsecuredSavedObjectsClient.bulkGet(bulkGetOpts); + bulkGetResult.saved_objects.forEach(({ id, error }) => { + if (!error && this.auditLogger) { + this.auditLogger.log( + connectorAuditEvent({ + action: ConnectorAuditAction.GET, + savedObject: { type: 'action', id }, + }) + ); + } + }); + for (const action of bulkGetResult.saved_objects) { if (action.error) { throw Boom.badRequest( @@ -298,22 +418,42 @@ export class ActionsClient { * Delete action */ public async delete({ id }: { id: string }) { - await this.authorization.ensureAuthorized('delete'); - - if ( - this.preconfiguredActions.find((preconfiguredAction) => preconfiguredAction.id === id) !== - undefined - ) { - throw new PreconfiguredActionDisabledModificationError( - i18n.translate('xpack.actions.serverSideErrors.predefinedActionDeleteDisabled', { - defaultMessage: 'Preconfigured action {id} is not allowed to delete.', - values: { - id, - }, - }), - 'delete' + try { + await this.authorization.ensureAuthorized('delete'); + + if ( + this.preconfiguredActions.find((preconfiguredAction) => preconfiguredAction.id === id) !== + undefined + ) { + throw new PreconfiguredActionDisabledModificationError( + i18n.translate('xpack.actions.serverSideErrors.predefinedActionDeleteDisabled', { + defaultMessage: 'Preconfigured action {id} is not allowed to delete.', + values: { + id, + }, + }), + 'delete' + ); + } + } catch (error) { + this.auditLogger?.log( + connectorAuditEvent({ + action: ConnectorAuditAction.DELETE, + savedObject: { type: 'action', id }, + error, + }) ); + throw error; } + + this.auditLogger?.log( + connectorAuditEvent({ + action: ConnectorAuditAction.DELETE, + outcome: EventOutcome.UNKNOWN, + savedObject: { type: 'action', id }, + }) + ); + return await this.unsecuredSavedObjectsClient.delete('action', id); } diff --git a/x-pack/plugins/actions/server/lib/audit_events.test.ts b/x-pack/plugins/actions/server/lib/audit_events.test.ts new file mode 100644 index 0000000000000..6c2fd99c2eafd --- /dev/null +++ b/x-pack/plugins/actions/server/lib/audit_events.test.ts @@ -0,0 +1,93 @@ +/* + * 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 { EventOutcome } from '../../../security/server/audit'; +import { ConnectorAuditAction, connectorAuditEvent } from './audit_events'; + +describe('#connectorAuditEvent', () => { + test('creates event with `unknown` outcome', () => { + expect( + connectorAuditEvent({ + action: ConnectorAuditAction.CREATE, + outcome: EventOutcome.UNKNOWN, + savedObject: { type: 'action', id: 'ACTION_ID' }, + }) + ).toMatchInlineSnapshot(` + Object { + "error": undefined, + "event": Object { + "action": "connector_create", + "category": "database", + "outcome": "unknown", + "type": "creation", + }, + "kibana": Object { + "saved_object": Object { + "id": "ACTION_ID", + "type": "action", + }, + }, + "message": "User is creating connector [id=ACTION_ID]", + } + `); + }); + + test('creates event with `success` outcome', () => { + expect( + connectorAuditEvent({ + action: ConnectorAuditAction.CREATE, + savedObject: { type: 'action', id: 'ACTION_ID' }, + }) + ).toMatchInlineSnapshot(` + Object { + "error": undefined, + "event": Object { + "action": "connector_create", + "category": "database", + "outcome": "success", + "type": "creation", + }, + "kibana": Object { + "saved_object": Object { + "id": "ACTION_ID", + "type": "action", + }, + }, + "message": "User has created connector [id=ACTION_ID]", + } + `); + }); + + test('creates event with `failure` outcome', () => { + expect( + connectorAuditEvent({ + action: ConnectorAuditAction.CREATE, + savedObject: { type: 'action', id: 'ACTION_ID' }, + error: new Error('ERROR_MESSAGE'), + }) + ).toMatchInlineSnapshot(` + Object { + "error": Object { + "code": "Error", + "message": "ERROR_MESSAGE", + }, + "event": Object { + "action": "connector_create", + "category": "database", + "outcome": "failure", + "type": "creation", + }, + "kibana": Object { + "saved_object": Object { + "id": "ACTION_ID", + "type": "action", + }, + }, + "message": "Failed attempt to create connector [id=ACTION_ID]", + } + `); + }); +}); diff --git a/x-pack/plugins/actions/server/lib/audit_events.ts b/x-pack/plugins/actions/server/lib/audit_events.ts new file mode 100644 index 0000000000000..7d25b5c0cd479 --- /dev/null +++ b/x-pack/plugins/actions/server/lib/audit_events.ts @@ -0,0 +1,76 @@ +/* + * 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 { AuditEvent, EventOutcome, EventCategory, EventType } from '../../../security/server'; + +export enum ConnectorAuditAction { + CREATE = 'connector_create', + GET = 'connector_get', + UPDATE = 'connector_update', + DELETE = 'connector_delete', + FIND = 'connector_find', + EXECUTE = 'connector_execute', +} + +type VerbsTuple = [string, string, string]; + +const eventVerbs: Record = { + connector_create: ['create', 'creating', 'created'], + connector_get: ['access', 'accessing', 'accessed'], + connector_update: ['update', 'updating', 'updated'], + connector_delete: ['delete', 'deleting', 'deleted'], + connector_find: ['access', 'accessing', 'accessed'], + connector_execute: ['execute', 'executing', 'executed'], +}; + +const eventTypes: Record = { + connector_create: EventType.CREATION, + connector_get: EventType.ACCESS, + connector_update: EventType.CHANGE, + connector_delete: EventType.DELETION, + connector_find: EventType.ACCESS, + connector_execute: undefined, +}; + +export interface ConnectorAuditEventParams { + action: ConnectorAuditAction; + outcome?: EventOutcome; + savedObject?: NonNullable['saved_object']; + error?: Error; +} + +export function connectorAuditEvent({ + action, + savedObject, + outcome, + error, +}: ConnectorAuditEventParams): AuditEvent { + const doc = savedObject ? `connector [id=${savedObject.id}]` : 'a connector'; + const [present, progressive, past] = eventVerbs[action]; + const message = error + ? `Failed attempt to ${present} ${doc}` + : outcome === EventOutcome.UNKNOWN + ? `User is ${progressive} ${doc}` + : `User has ${past} ${doc}`; + const type = eventTypes[action]; + + return { + message, + event: { + action, + category: EventCategory.DATABASE, + type, + outcome: outcome ?? (error ? EventOutcome.FAILURE : EventOutcome.SUCCESS), + }, + kibana: { + saved_object: savedObject, + }, + error: error && { + code: error.name, + message: error.message, + }, + }; +} diff --git a/x-pack/plugins/actions/server/plugin.ts b/x-pack/plugins/actions/server/plugin.ts index e61936321b8e0..6e37d4bd7a92a 100644 --- a/x-pack/plugins/actions/server/plugin.ts +++ b/x-pack/plugins/actions/server/plugin.ts @@ -314,6 +314,7 @@ export class ActionsPlugin implements Plugin, Plugi isESOUsingEphemeralEncryptionKey: isESOUsingEphemeralEncryptionKey!, preconfiguredActions, }), + auditLogger: this.security?.audit.asScoped(request), }); }; @@ -439,6 +440,7 @@ export class ActionsPlugin implements Plugin, Plugi preconfiguredActions, actionExecutor, instantiateAuthorization, + security, } = this; return async function actionsRouteHandlerContext(context, request) { @@ -468,6 +470,7 @@ export class ActionsPlugin implements Plugin, Plugi isESOUsingEphemeralEncryptionKey: isESOUsingEphemeralEncryptionKey!, preconfiguredActions, }), + auditLogger: security?.audit.asScoped(request), }); }, listTypes: actionTypeRegistry!.list.bind(actionTypeRegistry!), diff --git a/x-pack/plugins/alerts/server/alerts_client/alerts_client.ts b/x-pack/plugins/alerts/server/alerts_client/alerts_client.ts index c83e24c5a45f4..d697817be734b 100644 --- a/x-pack/plugins/alerts/server/alerts_client/alerts_client.ts +++ b/x-pack/plugins/alerts/server/alerts_client/alerts_client.ts @@ -13,7 +13,8 @@ import { SavedObjectReference, SavedObject, PluginInitializerContext, -} from 'src/core/server'; + SavedObjectsUtils, +} from '../../../../../src/core/server'; import { esKuery } from '../../../../../src/plugins/data/server'; import { ActionsClient, ActionsAuthorization } from '../../../actions/server'; import { @@ -44,10 +45,12 @@ import { IEventLogClient } from '../../../../plugins/event_log/server'; import { parseIsoOrRelativeDate } from '../lib/iso_or_relative_date'; import { alertInstanceSummaryFromEventLog } from '../lib/alert_instance_summary_from_event_log'; import { IEvent } from '../../../event_log/server'; +import { AuditLogger, EventOutcome } from '../../../security/server'; import { parseDuration } from '../../common/parse_duration'; import { retryIfConflicts } from '../lib/retry_if_conflicts'; import { partiallyUpdateAlert } from '../saved_objects'; import { markApiKeyForInvalidation } from '../invalidate_pending_api_keys/mark_api_key_for_invalidation'; +import { alertAuditEvent, AlertAuditAction } from './audit_events'; export interface RegistryAlertTypeWithAuth extends RegistryAlertType { authorizedConsumers: string[]; @@ -75,6 +78,7 @@ export interface ConstructorOptions { getActionsClient: () => Promise; getEventLogClient: () => Promise; kibanaVersion: PluginInitializerContext['env']['packageInfo']['version']; + auditLogger?: AuditLogger; } export interface MuteOptions extends IndexType { @@ -176,6 +180,7 @@ export class AlertsClient { private readonly getEventLogClient: () => Promise; private readonly encryptedSavedObjectsClient: EncryptedSavedObjectsClient; private readonly kibanaVersion!: PluginInitializerContext['env']['packageInfo']['version']; + private readonly auditLogger?: AuditLogger; constructor({ alertTypeRegistry, @@ -192,6 +197,7 @@ export class AlertsClient { actionsAuthorization, getEventLogClient, kibanaVersion, + auditLogger, }: ConstructorOptions) { this.logger = logger; this.getUserName = getUserName; @@ -207,14 +213,28 @@ export class AlertsClient { this.actionsAuthorization = actionsAuthorization; this.getEventLogClient = getEventLogClient; this.kibanaVersion = kibanaVersion; + this.auditLogger = auditLogger; } public async create({ data, options }: CreateOptions): Promise { - await this.authorization.ensureAuthorized( - data.alertTypeId, - data.consumer, - WriteOperations.Create - ); + const id = SavedObjectsUtils.generateId(); + + try { + await this.authorization.ensureAuthorized( + data.alertTypeId, + data.consumer, + WriteOperations.Create + ); + } catch (error) { + this.auditLogger?.log( + alertAuditEvent({ + action: AlertAuditAction.CREATE, + savedObject: { type: 'alert', id }, + error, + }) + ); + throw error; + } // Throws an error if alert type isn't registered const alertType = this.alertTypeRegistry.get(data.alertTypeId); @@ -248,6 +268,15 @@ export class AlertsClient { error: null, }, }; + + this.auditLogger?.log( + alertAuditEvent({ + action: AlertAuditAction.CREATE, + outcome: EventOutcome.UNKNOWN, + savedObject: { type: 'alert', id }, + }) + ); + let createdAlert: SavedObject; try { createdAlert = await this.unsecuredSavedObjectsClient.create( @@ -256,6 +285,7 @@ export class AlertsClient { { ...options, references, + id, } ); } catch (e) { @@ -297,10 +327,27 @@ export class AlertsClient { public async get({ id }: { id: string }): Promise { const result = await this.unsecuredSavedObjectsClient.get('alert', id); - await this.authorization.ensureAuthorized( - result.attributes.alertTypeId, - result.attributes.consumer, - ReadOperations.Get + try { + await this.authorization.ensureAuthorized( + result.attributes.alertTypeId, + result.attributes.consumer, + ReadOperations.Get + ); + } catch (error) { + this.auditLogger?.log( + alertAuditEvent({ + action: AlertAuditAction.GET, + savedObject: { type: 'alert', id }, + error, + }) + ); + throw error; + } + this.auditLogger?.log( + alertAuditEvent({ + action: AlertAuditAction.GET, + savedObject: { type: 'alert', id }, + }) ); return this.getAlertFromRaw(result.id, result.attributes, result.references); } @@ -370,11 +417,23 @@ export class AlertsClient { public async find({ options: { fields, ...options } = {}, }: { options?: FindOptions } = {}): Promise { + let authorizationTuple; + try { + authorizationTuple = await this.authorization.getFindAuthorizationFilter(); + } catch (error) { + this.auditLogger?.log( + alertAuditEvent({ + action: AlertAuditAction.FIND, + error, + }) + ); + throw error; + } const { filter: authorizationFilter, ensureAlertTypeIsAuthorized, logSuccessfulAuthorization, - } = await this.authorization.getFindAuthorizationFilter(); + } = authorizationTuple; const { page, @@ -392,7 +451,18 @@ export class AlertsClient { }); const authorizedData = data.map(({ id, attributes, references }) => { - ensureAlertTypeIsAuthorized(attributes.alertTypeId, attributes.consumer); + try { + ensureAlertTypeIsAuthorized(attributes.alertTypeId, attributes.consumer); + } catch (error) { + this.auditLogger?.log( + alertAuditEvent({ + action: AlertAuditAction.FIND, + savedObject: { type: 'alert', id }, + error, + }) + ); + throw error; + } return this.getAlertFromRaw( id, fields ? (pick(attributes, fields) as RawAlert) : attributes, @@ -400,6 +470,15 @@ export class AlertsClient { ); }); + authorizedData.forEach(({ id }) => + this.auditLogger?.log( + alertAuditEvent({ + action: AlertAuditAction.FIND, + savedObject: { type: 'alert', id }, + }) + ) + ); + logSuccessfulAuthorization(); return { @@ -473,10 +552,29 @@ export class AlertsClient { attributes = alert.attributes; } - await this.authorization.ensureAuthorized( - attributes.alertTypeId, - attributes.consumer, - WriteOperations.Delete + try { + await this.authorization.ensureAuthorized( + attributes.alertTypeId, + attributes.consumer, + WriteOperations.Delete + ); + } catch (error) { + this.auditLogger?.log( + alertAuditEvent({ + action: AlertAuditAction.DELETE, + savedObject: { type: 'alert', id }, + error, + }) + ); + throw error; + } + + this.auditLogger?.log( + alertAuditEvent({ + action: AlertAuditAction.DELETE, + outcome: EventOutcome.UNKNOWN, + savedObject: { type: 'alert', id }, + }) ); const removeResult = await this.unsecuredSavedObjectsClient.delete('alert', id); @@ -520,10 +618,30 @@ export class AlertsClient { // Still attempt to load the object using SOC alertSavedObject = await this.unsecuredSavedObjectsClient.get('alert', id); } - await this.authorization.ensureAuthorized( - alertSavedObject.attributes.alertTypeId, - alertSavedObject.attributes.consumer, - WriteOperations.Update + + try { + await this.authorization.ensureAuthorized( + alertSavedObject.attributes.alertTypeId, + alertSavedObject.attributes.consumer, + WriteOperations.Update + ); + } catch (error) { + this.auditLogger?.log( + alertAuditEvent({ + action: AlertAuditAction.UPDATE, + savedObject: { type: 'alert', id }, + error, + }) + ); + throw error; + } + + this.auditLogger?.log( + alertAuditEvent({ + action: AlertAuditAction.UPDATE, + outcome: EventOutcome.UNKNOWN, + savedObject: { type: 'alert', id }, + }) ); const updateResult = await this.updateAlert({ id, data }, alertSavedObject); @@ -658,14 +776,28 @@ export class AlertsClient { attributes = alert.attributes; version = alert.version; } - await this.authorization.ensureAuthorized( - attributes.alertTypeId, - attributes.consumer, - WriteOperations.UpdateApiKey - ); - if (attributes.actions.length && !this.authorization.shouldUseLegacyAuthorization(attributes)) { - await this.actionsAuthorization.ensureAuthorized('execute'); + try { + await this.authorization.ensureAuthorized( + attributes.alertTypeId, + attributes.consumer, + WriteOperations.UpdateApiKey + ); + if ( + attributes.actions.length && + !this.authorization.shouldUseLegacyAuthorization(attributes) + ) { + await this.actionsAuthorization.ensureAuthorized('execute'); + } + } catch (error) { + this.auditLogger?.log( + alertAuditEvent({ + action: AlertAuditAction.UPDATE_API_KEY, + savedObject: { type: 'alert', id }, + error, + }) + ); + throw error; } const username = await this.getUserName(); @@ -678,6 +810,15 @@ export class AlertsClient { updatedAt: new Date().toISOString(), updatedBy: username, }); + + this.auditLogger?.log( + alertAuditEvent({ + action: AlertAuditAction.UPDATE_API_KEY, + outcome: EventOutcome.UNKNOWN, + savedObject: { type: 'alert', id }, + }) + ); + try { await this.unsecuredSavedObjectsClient.update('alert', id, updateAttributes, { version }); } catch (e) { @@ -732,16 +873,35 @@ export class AlertsClient { version = alert.version; } - await this.authorization.ensureAuthorized( - attributes.alertTypeId, - attributes.consumer, - WriteOperations.Enable - ); + try { + await this.authorization.ensureAuthorized( + attributes.alertTypeId, + attributes.consumer, + WriteOperations.Enable + ); - if (attributes.actions.length) { - await this.actionsAuthorization.ensureAuthorized('execute'); + if (attributes.actions.length) { + await this.actionsAuthorization.ensureAuthorized('execute'); + } + } catch (error) { + this.auditLogger?.log( + alertAuditEvent({ + action: AlertAuditAction.ENABLE, + savedObject: { type: 'alert', id }, + error, + }) + ); + throw error; } + this.auditLogger?.log( + alertAuditEvent({ + action: AlertAuditAction.ENABLE, + outcome: EventOutcome.UNKNOWN, + savedObject: { type: 'alert', id }, + }) + ); + if (attributes.enabled === false) { const username = await this.getUserName(); const updateAttributes = this.updateMeta({ @@ -816,10 +976,29 @@ export class AlertsClient { version = alert.version; } - await this.authorization.ensureAuthorized( - attributes.alertTypeId, - attributes.consumer, - WriteOperations.Disable + try { + await this.authorization.ensureAuthorized( + attributes.alertTypeId, + attributes.consumer, + WriteOperations.Disable + ); + } catch (error) { + this.auditLogger?.log( + alertAuditEvent({ + action: AlertAuditAction.DISABLE, + savedObject: { type: 'alert', id }, + error, + }) + ); + throw error; + } + + this.auditLogger?.log( + alertAuditEvent({ + action: AlertAuditAction.DISABLE, + outcome: EventOutcome.UNKNOWN, + savedObject: { type: 'alert', id }, + }) ); if (attributes.enabled === true) { @@ -866,16 +1045,36 @@ export class AlertsClient { 'alert', id ); - await this.authorization.ensureAuthorized( - attributes.alertTypeId, - attributes.consumer, - WriteOperations.MuteAll - ); - if (attributes.actions.length) { - await this.actionsAuthorization.ensureAuthorized('execute'); + try { + await this.authorization.ensureAuthorized( + attributes.alertTypeId, + attributes.consumer, + WriteOperations.MuteAll + ); + + if (attributes.actions.length) { + await this.actionsAuthorization.ensureAuthorized('execute'); + } + } catch (error) { + this.auditLogger?.log( + alertAuditEvent({ + action: AlertAuditAction.MUTE, + savedObject: { type: 'alert', id }, + error, + }) + ); + throw error; } + this.auditLogger?.log( + alertAuditEvent({ + action: AlertAuditAction.MUTE, + outcome: EventOutcome.UNKNOWN, + savedObject: { type: 'alert', id }, + }) + ); + const updateAttributes = this.updateMeta({ muteAll: true, mutedInstanceIds: [], @@ -905,16 +1104,36 @@ export class AlertsClient { 'alert', id ); - await this.authorization.ensureAuthorized( - attributes.alertTypeId, - attributes.consumer, - WriteOperations.UnmuteAll - ); - if (attributes.actions.length) { - await this.actionsAuthorization.ensureAuthorized('execute'); + try { + await this.authorization.ensureAuthorized( + attributes.alertTypeId, + attributes.consumer, + WriteOperations.UnmuteAll + ); + + if (attributes.actions.length) { + await this.actionsAuthorization.ensureAuthorized('execute'); + } + } catch (error) { + this.auditLogger?.log( + alertAuditEvent({ + action: AlertAuditAction.UNMUTE, + savedObject: { type: 'alert', id }, + error, + }) + ); + throw error; } + this.auditLogger?.log( + alertAuditEvent({ + action: AlertAuditAction.UNMUTE, + outcome: EventOutcome.UNKNOWN, + savedObject: { type: 'alert', id }, + }) + ); + const updateAttributes = this.updateMeta({ muteAll: false, mutedInstanceIds: [], @@ -945,16 +1164,35 @@ export class AlertsClient { alertId ); - await this.authorization.ensureAuthorized( - attributes.alertTypeId, - attributes.consumer, - WriteOperations.MuteInstance - ); + try { + await this.authorization.ensureAuthorized( + attributes.alertTypeId, + attributes.consumer, + WriteOperations.MuteInstance + ); - if (attributes.actions.length) { - await this.actionsAuthorization.ensureAuthorized('execute'); + if (attributes.actions.length) { + await this.actionsAuthorization.ensureAuthorized('execute'); + } + } catch (error) { + this.auditLogger?.log( + alertAuditEvent({ + action: AlertAuditAction.MUTE_INSTANCE, + savedObject: { type: 'alert', id: alertId }, + error, + }) + ); + throw error; } + this.auditLogger?.log( + alertAuditEvent({ + action: AlertAuditAction.MUTE_INSTANCE, + outcome: EventOutcome.UNKNOWN, + savedObject: { type: 'alert', id: alertId }, + }) + ); + const mutedInstanceIds = attributes.mutedInstanceIds || []; if (!attributes.muteAll && !mutedInstanceIds.includes(alertInstanceId)) { mutedInstanceIds.push(alertInstanceId); @@ -991,15 +1229,34 @@ export class AlertsClient { alertId ); - await this.authorization.ensureAuthorized( - attributes.alertTypeId, - attributes.consumer, - WriteOperations.UnmuteInstance - ); - if (attributes.actions.length) { - await this.actionsAuthorization.ensureAuthorized('execute'); + try { + await this.authorization.ensureAuthorized( + attributes.alertTypeId, + attributes.consumer, + WriteOperations.UnmuteInstance + ); + if (attributes.actions.length) { + await this.actionsAuthorization.ensureAuthorized('execute'); + } + } catch (error) { + this.auditLogger?.log( + alertAuditEvent({ + action: AlertAuditAction.UNMUTE_INSTANCE, + savedObject: { type: 'alert', id: alertId }, + error, + }) + ); + throw error; } + this.auditLogger?.log( + alertAuditEvent({ + action: AlertAuditAction.UNMUTE_INSTANCE, + outcome: EventOutcome.UNKNOWN, + savedObject: { type: 'alert', id: alertId }, + }) + ); + const mutedInstanceIds = attributes.mutedInstanceIds || []; if (!attributes.muteAll && mutedInstanceIds.includes(alertInstanceId)) { await this.unsecuredSavedObjectsClient.update( diff --git a/x-pack/plugins/alerts/server/alerts_client/audit_events.test.ts b/x-pack/plugins/alerts/server/alerts_client/audit_events.test.ts new file mode 100644 index 0000000000000..9cd48248320c0 --- /dev/null +++ b/x-pack/plugins/alerts/server/alerts_client/audit_events.test.ts @@ -0,0 +1,93 @@ +/* + * 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 { EventOutcome } from '../../../security/server/audit'; +import { AlertAuditAction, alertAuditEvent } from './audit_events'; + +describe('#alertAuditEvent', () => { + test('creates event with `unknown` outcome', () => { + expect( + alertAuditEvent({ + action: AlertAuditAction.CREATE, + outcome: EventOutcome.UNKNOWN, + savedObject: { type: 'alert', id: 'ALERT_ID' }, + }) + ).toMatchInlineSnapshot(` + Object { + "error": undefined, + "event": Object { + "action": "alert_create", + "category": "database", + "outcome": "unknown", + "type": "creation", + }, + "kibana": Object { + "saved_object": Object { + "id": "ALERT_ID", + "type": "alert", + }, + }, + "message": "User is creating alert [id=ALERT_ID]", + } + `); + }); + + test('creates event with `success` outcome', () => { + expect( + alertAuditEvent({ + action: AlertAuditAction.CREATE, + savedObject: { type: 'alert', id: 'ALERT_ID' }, + }) + ).toMatchInlineSnapshot(` + Object { + "error": undefined, + "event": Object { + "action": "alert_create", + "category": "database", + "outcome": "success", + "type": "creation", + }, + "kibana": Object { + "saved_object": Object { + "id": "ALERT_ID", + "type": "alert", + }, + }, + "message": "User has created alert [id=ALERT_ID]", + } + `); + }); + + test('creates event with `failure` outcome', () => { + expect( + alertAuditEvent({ + action: AlertAuditAction.CREATE, + savedObject: { type: 'alert', id: 'ALERT_ID' }, + error: new Error('ERROR_MESSAGE'), + }) + ).toMatchInlineSnapshot(` + Object { + "error": Object { + "code": "Error", + "message": "ERROR_MESSAGE", + }, + "event": Object { + "action": "alert_create", + "category": "database", + "outcome": "failure", + "type": "creation", + }, + "kibana": Object { + "saved_object": Object { + "id": "ALERT_ID", + "type": "alert", + }, + }, + "message": "Failed attempt to create alert [id=ALERT_ID]", + } + `); + }); +}); diff --git a/x-pack/plugins/alerts/server/alerts_client/audit_events.ts b/x-pack/plugins/alerts/server/alerts_client/audit_events.ts new file mode 100644 index 0000000000000..f3e3959824084 --- /dev/null +++ b/x-pack/plugins/alerts/server/alerts_client/audit_events.ts @@ -0,0 +1,94 @@ +/* + * 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 { AuditEvent, EventOutcome, EventCategory, EventType } from '../../../security/server'; + +export enum AlertAuditAction { + CREATE = 'alert_create', + GET = 'alert_get', + UPDATE = 'alert_update', + UPDATE_API_KEY = 'alert_update_api_key', + ENABLE = 'alert_enable', + DISABLE = 'alert_disable', + DELETE = 'alert_delete', + FIND = 'alert_find', + MUTE = 'alert_mute', + UNMUTE = 'alert_unmute', + MUTE_INSTANCE = 'alert_instance_mute', + UNMUTE_INSTANCE = 'alert_instance_unmute', +} + +type VerbsTuple = [string, string, string]; + +const eventVerbs: Record = { + alert_create: ['create', 'creating', 'created'], + alert_get: ['access', 'accessing', 'accessed'], + alert_update: ['update', 'updating', 'updated'], + alert_update_api_key: ['update API key of', 'updating API key of', 'updated API key of'], + alert_enable: ['enable', 'enabling', 'enabled'], + alert_disable: ['disable', 'disabling', 'disabled'], + alert_delete: ['delete', 'deleting', 'deleted'], + alert_find: ['access', 'accessing', 'accessed'], + alert_mute: ['mute', 'muting', 'muted'], + alert_unmute: ['unmute', 'unmuting', 'unmuted'], + alert_instance_mute: ['mute instance of', 'muting instance of', 'muted instance of'], + alert_instance_unmute: ['unmute instance of', 'unmuting instance of', 'unmuted instance of'], +}; + +const eventTypes: Record = { + alert_create: EventType.CREATION, + alert_get: EventType.ACCESS, + alert_update: EventType.CHANGE, + alert_update_api_key: EventType.CHANGE, + alert_enable: EventType.CHANGE, + alert_disable: EventType.CHANGE, + alert_delete: EventType.DELETION, + alert_find: EventType.ACCESS, + alert_mute: EventType.CHANGE, + alert_unmute: EventType.CHANGE, + alert_instance_mute: EventType.CHANGE, + alert_instance_unmute: EventType.CHANGE, +}; + +export interface AlertAuditEventParams { + action: AlertAuditAction; + outcome?: EventOutcome; + savedObject?: NonNullable['saved_object']; + error?: Error; +} + +export function alertAuditEvent({ + action, + savedObject, + outcome, + error, +}: AlertAuditEventParams): AuditEvent { + const doc = savedObject ? `alert [id=${savedObject.id}]` : 'an alert'; + const [present, progressive, past] = eventVerbs[action]; + const message = error + ? `Failed attempt to ${present} ${doc}` + : outcome === EventOutcome.UNKNOWN + ? `User is ${progressive} ${doc}` + : `User has ${past} ${doc}`; + const type = eventTypes[action]; + + return { + message, + event: { + action, + category: EventCategory.DATABASE, + type, + outcome: outcome ?? (error ? EventOutcome.FAILURE : EventOutcome.SUCCESS), + }, + kibana: { + saved_object: savedObject, + }, + error: error && { + code: error.name, + message: error.message, + }, + }; +} diff --git a/x-pack/plugins/alerts/server/alerts_client/tests/create.test.ts b/x-pack/plugins/alerts/server/alerts_client/tests/create.test.ts index dcbb33d849405..b943a21ba9bb6 100644 --- a/x-pack/plugins/alerts/server/alerts_client/tests/create.test.ts +++ b/x-pack/plugins/alerts/server/alerts_client/tests/create.test.ts @@ -14,15 +14,24 @@ import { actionsAuthorizationMock } from '../../../../actions/server/mocks'; import { AlertsAuthorization } from '../../authorization/alerts_authorization'; import { ActionsAuthorization, ActionsClient } from '../../../../actions/server'; import { TaskStatus } from '../../../../task_manager/server'; +import { auditServiceMock } from '../../../../security/server/audit/index.mock'; +import { httpServerMock } from '../../../../../../src/core/server/mocks'; import { getBeforeSetup, setGlobalDate } from './lib'; import { RecoveredActionGroup } from '../../../common'; +jest.mock('../../../../../../src/core/server/saved_objects/service/lib/utils', () => ({ + SavedObjectsUtils: { + generateId: () => 'mock-saved-object-id', + }, +})); + const taskManager = taskManagerMock.createStart(); const alertTypeRegistry = alertTypeRegistryMock.create(); const unsecuredSavedObjectsClient = savedObjectsClientMock.create(); const encryptedSavedObjects = encryptedSavedObjectsMock.createClient(); const authorization = alertsAuthorizationMock.create(); const actionsAuthorization = actionsAuthorizationMock.create(); +const auditLogger = auditServiceMock.create().asScoped(httpServerMock.createKibanaRequest()); const kibanaVersion = 'v7.10.0'; const alertsClientParams: jest.Mocked = { @@ -40,10 +49,12 @@ const alertsClientParams: jest.Mocked = { getActionsClient: jest.fn(), getEventLogClient: jest.fn(), kibanaVersion, + auditLogger, }; beforeEach(() => { getBeforeSetup(alertsClientParams, taskManager, alertTypeRegistry); + (auditLogger.log as jest.Mock).mockClear(); }); setGlobalDate(); @@ -185,6 +196,62 @@ describe('create()', () => { }); }); + describe('auditLogger', () => { + test('logs audit event when creating an alert', async () => { + const data = getMockData({ + enabled: false, + actions: [], + }); + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ + id: '1', + type: 'alert', + attributes: data, + references: [], + }); + await alertsClient.create({ data }); + expect(auditLogger.log).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + action: 'alert_create', + outcome: 'unknown', + }), + kibana: { saved_object: { id: 'mock-saved-object-id', type: 'alert' } }, + }) + ); + }); + + test('logs audit event when not authorised to create an alert', async () => { + authorization.ensureAuthorized.mockRejectedValue(new Error('Unauthorized')); + + await expect( + alertsClient.create({ + data: getMockData({ + enabled: false, + actions: [], + }), + }) + ).rejects.toThrow(); + expect(auditLogger.log).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + action: 'alert_create', + outcome: 'failure', + }), + kibana: { + saved_object: { + id: 'mock-saved-object-id', + type: 'alert', + }, + }, + error: { + code: 'Error', + message: 'Unauthorized', + }, + }) + ); + }); + }); + test('creates an alert', async () => { const data = getMockData(); const createdAttributes = { @@ -337,16 +404,17 @@ describe('create()', () => { } `); expect(unsecuredSavedObjectsClient.create.mock.calls[0][2]).toMatchInlineSnapshot(` - Object { - "references": Array [ - Object { - "id": "1", - "name": "action_0", - "type": "action", - }, - ], - } - `); + Object { + "id": "mock-saved-object-id", + "references": Array [ + Object { + "id": "1", + "name": "action_0", + "type": "action", + }, + ], + } + `); expect(taskManager.schedule).toHaveBeenCalledTimes(1); expect(taskManager.schedule.mock.calls[0]).toMatchInlineSnapshot(` Array [ @@ -991,6 +1059,7 @@ describe('create()', () => { }, }, { + id: 'mock-saved-object-id', references: [ { id: '1', @@ -1113,6 +1182,7 @@ describe('create()', () => { }, }, { + id: 'mock-saved-object-id', references: [ { id: '1', diff --git a/x-pack/plugins/alerts/server/alerts_client/tests/delete.test.ts b/x-pack/plugins/alerts/server/alerts_client/tests/delete.test.ts index e7b975aec8eb0..a7ef008eaa2ee 100644 --- a/x-pack/plugins/alerts/server/alerts_client/tests/delete.test.ts +++ b/x-pack/plugins/alerts/server/alerts_client/tests/delete.test.ts @@ -12,6 +12,8 @@ import { encryptedSavedObjectsMock } from '../../../../encrypted_saved_objects/s import { actionsAuthorizationMock } from '../../../../actions/server/mocks'; import { AlertsAuthorization } from '../../authorization/alerts_authorization'; import { ActionsAuthorization } from '../../../../actions/server'; +import { httpServerMock } from '../../../../../../src/core/server/mocks'; +import { auditServiceMock } from '../../../../security/server/audit/index.mock'; import { getBeforeSetup } from './lib'; const taskManager = taskManagerMock.createStart(); @@ -20,6 +22,7 @@ const unsecuredSavedObjectsClient = savedObjectsClientMock.create(); const encryptedSavedObjects = encryptedSavedObjectsMock.createClient(); const authorization = alertsAuthorizationMock.create(); const actionsAuthorization = actionsAuthorizationMock.create(); +const auditLogger = auditServiceMock.create().asScoped(httpServerMock.createKibanaRequest()); const kibanaVersion = 'v7.10.0'; const alertsClientParams: jest.Mocked = { @@ -37,10 +40,12 @@ const alertsClientParams: jest.Mocked = { getActionsClient: jest.fn(), getEventLogClient: jest.fn(), kibanaVersion, + auditLogger, }; beforeEach(() => { getBeforeSetup(alertsClientParams, taskManager, alertTypeRegistry); + (auditLogger.log as jest.Mock).mockClear(); }); describe('delete()', () => { @@ -239,4 +244,43 @@ describe('delete()', () => { expect(authorization.ensureAuthorized).toHaveBeenCalledWith('myType', 'myApp', 'delete'); }); }); + + describe('auditLogger', () => { + test('logs audit event when deleting an alert', async () => { + await alertsClient.delete({ id: '1' }); + expect(auditLogger.log).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + action: 'alert_delete', + outcome: 'unknown', + }), + kibana: { saved_object: { id: '1', type: 'alert' } }, + }) + ); + }); + + test('logs audit event when not authorised to delete an alert', async () => { + authorization.ensureAuthorized.mockRejectedValue(new Error('Unauthorized')); + + await expect(alertsClient.delete({ id: '1' })).rejects.toThrow(); + expect(auditLogger.log).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + action: 'alert_delete', + outcome: 'failure', + }), + kibana: { + saved_object: { + id: '1', + type: 'alert', + }, + }, + error: { + code: 'Error', + message: 'Unauthorized', + }, + }) + ); + }); + }); }); diff --git a/x-pack/plugins/alerts/server/alerts_client/tests/disable.test.ts b/x-pack/plugins/alerts/server/alerts_client/tests/disable.test.ts index 8c9ab9494a50a..ce0688a5ab2ff 100644 --- a/x-pack/plugins/alerts/server/alerts_client/tests/disable.test.ts +++ b/x-pack/plugins/alerts/server/alerts_client/tests/disable.test.ts @@ -12,16 +12,18 @@ import { encryptedSavedObjectsMock } from '../../../../encrypted_saved_objects/s import { actionsAuthorizationMock } from '../../../../actions/server/mocks'; import { AlertsAuthorization } from '../../authorization/alerts_authorization'; import { ActionsAuthorization } from '../../../../actions/server'; -import { getBeforeSetup, setGlobalDate } from './lib'; import { InvalidatePendingApiKey } from '../../types'; +import { httpServerMock } from '../../../../../../src/core/server/mocks'; +import { auditServiceMock } from '../../../../security/server/audit/index.mock'; +import { getBeforeSetup, setGlobalDate } from './lib'; const taskManager = taskManagerMock.createStart(); const alertTypeRegistry = alertTypeRegistryMock.create(); const unsecuredSavedObjectsClient = savedObjectsClientMock.create(); - const encryptedSavedObjects = encryptedSavedObjectsMock.createClient(); const authorization = alertsAuthorizationMock.create(); const actionsAuthorization = actionsAuthorizationMock.create(); +const auditLogger = auditServiceMock.create().asScoped(httpServerMock.createKibanaRequest()); const kibanaVersion = 'v7.10.0'; const alertsClientParams: jest.Mocked = { @@ -39,10 +41,12 @@ const alertsClientParams: jest.Mocked = { getActionsClient: jest.fn(), getEventLogClient: jest.fn(), kibanaVersion, + auditLogger, }; beforeEach(() => { getBeforeSetup(alertsClientParams, taskManager, alertTypeRegistry); + (auditLogger.log as jest.Mock).mockClear(); }); setGlobalDate(); @@ -109,6 +113,45 @@ describe('disable()', () => { }); }); + describe('auditLogger', () => { + test('logs audit event when disabling an alert', async () => { + await alertsClient.disable({ id: '1' }); + expect(auditLogger.log).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + action: 'alert_disable', + outcome: 'unknown', + }), + kibana: { saved_object: { id: '1', type: 'alert' } }, + }) + ); + }); + + test('logs audit event when not authorised to disable an alert', async () => { + authorization.ensureAuthorized.mockRejectedValue(new Error('Unauthorized')); + + await expect(alertsClient.disable({ id: '1' })).rejects.toThrow(); + expect(auditLogger.log).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + action: 'alert_disable', + outcome: 'failure', + }), + kibana: { + saved_object: { + id: '1', + type: 'alert', + }, + }, + error: { + code: 'Error', + message: 'Unauthorized', + }, + }) + ); + }); + }); + test('disables an alert', async () => { unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ id: '1', diff --git a/x-pack/plugins/alerts/server/alerts_client/tests/enable.test.ts b/x-pack/plugins/alerts/server/alerts_client/tests/enable.test.ts index feec1d1b9334a..daac6689a183b 100644 --- a/x-pack/plugins/alerts/server/alerts_client/tests/enable.test.ts +++ b/x-pack/plugins/alerts/server/alerts_client/tests/enable.test.ts @@ -13,16 +13,18 @@ import { actionsAuthorizationMock } from '../../../../actions/server/mocks'; import { AlertsAuthorization } from '../../authorization/alerts_authorization'; import { ActionsAuthorization } from '../../../../actions/server'; import { TaskStatus } from '../../../../task_manager/server'; -import { getBeforeSetup, setGlobalDate } from './lib'; +import { httpServerMock } from '../../../../../../src/core/server/mocks'; +import { auditServiceMock } from '../../../../security/server/audit/index.mock'; import { InvalidatePendingApiKey } from '../../types'; +import { getBeforeSetup, setGlobalDate } from './lib'; const taskManager = taskManagerMock.createStart(); const alertTypeRegistry = alertTypeRegistryMock.create(); const unsecuredSavedObjectsClient = savedObjectsClientMock.create(); - const encryptedSavedObjects = encryptedSavedObjectsMock.createClient(); const authorization = alertsAuthorizationMock.create(); const actionsAuthorization = actionsAuthorizationMock.create(); +const auditLogger = auditServiceMock.create().asScoped(httpServerMock.createKibanaRequest()); const kibanaVersion = 'v7.10.0'; const alertsClientParams: jest.Mocked = { @@ -40,10 +42,12 @@ const alertsClientParams: jest.Mocked = { getActionsClient: jest.fn(), getEventLogClient: jest.fn(), kibanaVersion, + auditLogger, }; beforeEach(() => { getBeforeSetup(alertsClientParams, taskManager, alertTypeRegistry); + (auditLogger.log as jest.Mock).mockClear(); }); setGlobalDate(); @@ -148,6 +152,45 @@ describe('enable()', () => { }); }); + describe('auditLogger', () => { + test('logs audit event when enabling an alert', async () => { + await alertsClient.enable({ id: '1' }); + expect(auditLogger.log).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + action: 'alert_enable', + outcome: 'unknown', + }), + kibana: { saved_object: { id: '1', type: 'alert' } }, + }) + ); + }); + + test('logs audit event when not authorised to enable an alert', async () => { + authorization.ensureAuthorized.mockRejectedValue(new Error('Unauthorized')); + + await expect(alertsClient.enable({ id: '1' })).rejects.toThrow(); + expect(auditLogger.log).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + action: 'alert_enable', + outcome: 'failure', + }), + kibana: { + saved_object: { + id: '1', + type: 'alert', + }, + }, + error: { + code: 'Error', + message: 'Unauthorized', + }, + }) + ); + }); + }); + test('enables an alert', async () => { const createdAt = new Date().toISOString(); unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ diff --git a/x-pack/plugins/alerts/server/alerts_client/tests/find.test.ts b/x-pack/plugins/alerts/server/alerts_client/tests/find.test.ts index 336cb536d702b..232d48e258256 100644 --- a/x-pack/plugins/alerts/server/alerts_client/tests/find.test.ts +++ b/x-pack/plugins/alerts/server/alerts_client/tests/find.test.ts @@ -14,16 +14,18 @@ import { encryptedSavedObjectsMock } from '../../../../encrypted_saved_objects/s import { actionsAuthorizationMock } from '../../../../actions/server/mocks'; import { AlertsAuthorization } from '../../authorization/alerts_authorization'; import { ActionsAuthorization } from '../../../../actions/server'; +import { httpServerMock } from '../../../../../../src/core/server/mocks'; +import { auditServiceMock } from '../../../../security/server/audit/index.mock'; import { getBeforeSetup, setGlobalDate } from './lib'; import { RecoveredActionGroup } from '../../../common'; const taskManager = taskManagerMock.createStart(); const alertTypeRegistry = alertTypeRegistryMock.create(); const unsecuredSavedObjectsClient = savedObjectsClientMock.create(); - const encryptedSavedObjects = encryptedSavedObjectsMock.createClient(); const authorization = alertsAuthorizationMock.create(); const actionsAuthorization = actionsAuthorizationMock.create(); +const auditLogger = auditServiceMock.create().asScoped(httpServerMock.createKibanaRequest()); const kibanaVersion = 'v7.10.0'; const alertsClientParams: jest.Mocked = { @@ -45,6 +47,7 @@ const alertsClientParams: jest.Mocked = { beforeEach(() => { getBeforeSetup(alertsClientParams, taskManager, alertTypeRegistry); + (auditLogger.log as jest.Mock).mockClear(); }); setGlobalDate(); @@ -251,4 +254,64 @@ describe('find()', () => { expect(logSuccessfulAuthorization).toHaveBeenCalled(); }); }); + + describe('auditLogger', () => { + test('logs audit event when searching alerts', async () => { + const alertsClient = new AlertsClient({ ...alertsClientParams, auditLogger }); + await alertsClient.find(); + expect(auditLogger.log).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + action: 'alert_find', + outcome: 'success', + }), + kibana: { saved_object: { id: '1', type: 'alert' } }, + }) + ); + }); + + test('logs audit event when not authorised to search alerts', async () => { + const alertsClient = new AlertsClient({ ...alertsClientParams, auditLogger }); + authorization.getFindAuthorizationFilter.mockRejectedValue(new Error('Unauthorized')); + + await expect(alertsClient.find()).rejects.toThrow(); + expect(auditLogger.log).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + action: 'alert_find', + outcome: 'failure', + }), + error: { + code: 'Error', + message: 'Unauthorized', + }, + }) + ); + }); + + test('logs audit event when not authorised to search alert type', async () => { + const alertsClient = new AlertsClient({ ...alertsClientParams, auditLogger }); + authorization.getFindAuthorizationFilter.mockResolvedValue({ + ensureAlertTypeIsAuthorized: jest.fn(() => { + throw new Error('Unauthorized'); + }), + logSuccessfulAuthorization: jest.fn(), + }); + + await expect(async () => await alertsClient.find()).rejects.toThrow(); + expect(auditLogger.log).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + action: 'alert_find', + outcome: 'failure', + }), + kibana: { saved_object: { id: '1', type: 'alert' } }, + error: { + code: 'Error', + message: 'Unauthorized', + }, + }) + ); + }); + }); }); diff --git a/x-pack/plugins/alerts/server/alerts_client/tests/get.test.ts b/x-pack/plugins/alerts/server/alerts_client/tests/get.test.ts index 3f0c783f424d1..32ac57459795e 100644 --- a/x-pack/plugins/alerts/server/alerts_client/tests/get.test.ts +++ b/x-pack/plugins/alerts/server/alerts_client/tests/get.test.ts @@ -12,15 +12,17 @@ import { encryptedSavedObjectsMock } from '../../../../encrypted_saved_objects/s import { actionsAuthorizationMock } from '../../../../actions/server/mocks'; import { AlertsAuthorization } from '../../authorization/alerts_authorization'; import { ActionsAuthorization } from '../../../../actions/server'; +import { httpServerMock } from '../../../../../../src/core/server/mocks'; +import { auditServiceMock } from '../../../../security/server/audit/index.mock'; import { getBeforeSetup, setGlobalDate } from './lib'; const taskManager = taskManagerMock.createStart(); const alertTypeRegistry = alertTypeRegistryMock.create(); const unsecuredSavedObjectsClient = savedObjectsClientMock.create(); - const encryptedSavedObjects = encryptedSavedObjectsMock.createClient(); const authorization = alertsAuthorizationMock.create(); const actionsAuthorization = actionsAuthorizationMock.create(); +const auditLogger = auditServiceMock.create().asScoped(httpServerMock.createKibanaRequest()); const kibanaVersion = 'v7.10.0'; const alertsClientParams: jest.Mocked = { @@ -42,6 +44,7 @@ const alertsClientParams: jest.Mocked = { beforeEach(() => { getBeforeSetup(alertsClientParams, taskManager, alertTypeRegistry); + (auditLogger.log as jest.Mock).mockClear(); }); setGlobalDate(); @@ -191,4 +194,61 @@ describe('get()', () => { expect(authorization.ensureAuthorized).toHaveBeenCalledWith('myType', 'myApp', 'get'); }); }); + + describe('auditLogger', () => { + beforeEach(() => { + unsecuredSavedObjectsClient.get.mockResolvedValueOnce({ + id: '1', + type: 'alert', + attributes: { + alertTypeId: '123', + schedule: { interval: '10s' }, + params: { + bar: true, + }, + actions: [], + }, + references: [], + }); + }); + + test('logs audit event when getting an alert', async () => { + const alertsClient = new AlertsClient({ ...alertsClientParams, auditLogger }); + await alertsClient.get({ id: '1' }); + expect(auditLogger.log).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + action: 'alert_get', + outcome: 'success', + }), + kibana: { saved_object: { id: '1', type: 'alert' } }, + }) + ); + }); + + test('logs audit event when not authorised to get an alert', async () => { + const alertsClient = new AlertsClient({ ...alertsClientParams, auditLogger }); + authorization.ensureAuthorized.mockRejectedValue(new Error('Unauthorized')); + + await expect(alertsClient.get({ id: '1' })).rejects.toThrow(); + expect(auditLogger.log).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + action: 'alert_get', + outcome: 'failure', + }), + kibana: { + saved_object: { + id: '1', + type: 'alert', + }, + }, + error: { + code: 'Error', + message: 'Unauthorized', + }, + }) + ); + }); + }); }); diff --git a/x-pack/plugins/alerts/server/alerts_client/tests/mute_all.test.ts b/x-pack/plugins/alerts/server/alerts_client/tests/mute_all.test.ts index 14ebca2135587..b3c3e1bdd2ede 100644 --- a/x-pack/plugins/alerts/server/alerts_client/tests/mute_all.test.ts +++ b/x-pack/plugins/alerts/server/alerts_client/tests/mute_all.test.ts @@ -12,6 +12,8 @@ import { encryptedSavedObjectsMock } from '../../../../encrypted_saved_objects/s import { actionsAuthorizationMock } from '../../../../actions/server/mocks'; import { AlertsAuthorization } from '../../authorization/alerts_authorization'; import { ActionsAuthorization } from '../../../../actions/server'; +import { httpServerMock } from '../../../../../../src/core/server/mocks'; +import { auditServiceMock } from '../../../../security/server/audit/index.mock'; import { getBeforeSetup, setGlobalDate } from './lib'; const taskManager = taskManagerMock.createStart(); @@ -20,6 +22,7 @@ const unsecuredSavedObjectsClient = savedObjectsClientMock.create(); const encryptedSavedObjects = encryptedSavedObjectsMock.createClient(); const authorization = alertsAuthorizationMock.create(); const actionsAuthorization = actionsAuthorizationMock.create(); +const auditLogger = auditServiceMock.create().asScoped(httpServerMock.createKibanaRequest()); const kibanaVersion = 'v7.10.0'; const alertsClientParams: jest.Mocked = { @@ -41,6 +44,7 @@ const alertsClientParams: jest.Mocked = { beforeEach(() => { getBeforeSetup(alertsClientParams, taskManager, alertTypeRegistry); + (auditLogger.log as jest.Mock).mockClear(); }); setGlobalDate(); @@ -137,4 +141,85 @@ describe('muteAll()', () => { expect(authorization.ensureAuthorized).toHaveBeenCalledWith('myType', 'myApp', 'muteAll'); }); }); + + describe('auditLogger', () => { + test('logs audit event when muting an alert', async () => { + const alertsClient = new AlertsClient({ ...alertsClientParams, auditLogger }); + unsecuredSavedObjectsClient.get.mockResolvedValueOnce({ + id: '1', + type: 'alert', + attributes: { + actions: [ + { + group: 'default', + id: '1', + actionTypeId: '1', + actionRef: '1', + params: { + foo: true, + }, + }, + ], + muteAll: false, + }, + references: [], + version: '123', + }); + await alertsClient.muteAll({ id: '1' }); + expect(auditLogger.log).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + action: 'alert_mute', + outcome: 'unknown', + }), + kibana: { saved_object: { id: '1', type: 'alert' } }, + }) + ); + }); + + test('logs audit event when not authorised to mute an alert', async () => { + const alertsClient = new AlertsClient({ ...alertsClientParams, auditLogger }); + unsecuredSavedObjectsClient.get.mockResolvedValueOnce({ + id: '1', + type: 'alert', + attributes: { + actions: [ + { + group: 'default', + id: '1', + actionTypeId: '1', + actionRef: '1', + params: { + foo: true, + }, + }, + ], + muteAll: false, + }, + references: [], + version: '123', + }); + authorization.ensureAuthorized.mockRejectedValue(new Error('Unauthorized')); + + await expect(alertsClient.muteAll({ id: '1' })).rejects.toThrow(); + expect(auditLogger.log).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + action: 'alert_mute', + outcome: 'failure', + }), + kibana: { + saved_object: { + id: '1', + type: 'alert', + }, + }, + error: { + code: 'Error', + message: 'Unauthorized', + }, + }) + ); + }); + }); }); diff --git a/x-pack/plugins/alerts/server/alerts_client/tests/mute_instance.test.ts b/x-pack/plugins/alerts/server/alerts_client/tests/mute_instance.test.ts index c2188f128cb4d..ec69dbdeac55f 100644 --- a/x-pack/plugins/alerts/server/alerts_client/tests/mute_instance.test.ts +++ b/x-pack/plugins/alerts/server/alerts_client/tests/mute_instance.test.ts @@ -12,15 +12,17 @@ import { encryptedSavedObjectsMock } from '../../../../encrypted_saved_objects/s import { actionsAuthorizationMock } from '../../../../actions/server/mocks'; import { AlertsAuthorization } from '../../authorization/alerts_authorization'; import { ActionsAuthorization } from '../../../../actions/server'; +import { httpServerMock } from '../../../../../../src/core/server/mocks'; +import { auditServiceMock } from '../../../../security/server/audit/index.mock'; import { getBeforeSetup, setGlobalDate } from './lib'; const taskManager = taskManagerMock.createStart(); const alertTypeRegistry = alertTypeRegistryMock.create(); const unsecuredSavedObjectsClient = savedObjectsClientMock.create(); - const encryptedSavedObjects = encryptedSavedObjectsMock.createClient(); const authorization = alertsAuthorizationMock.create(); const actionsAuthorization = actionsAuthorizationMock.create(); +const auditLogger = auditServiceMock.create().asScoped(httpServerMock.createKibanaRequest()); const kibanaVersion = 'v7.10.0'; const alertsClientParams: jest.Mocked = { @@ -42,6 +44,7 @@ const alertsClientParams: jest.Mocked = { beforeEach(() => { getBeforeSetup(alertsClientParams, taskManager, alertTypeRegistry); + (auditLogger.log as jest.Mock).mockClear(); }); setGlobalDate(); @@ -180,4 +183,75 @@ describe('muteInstance()', () => { ); }); }); + + describe('auditLogger', () => { + test('logs audit event when muting an alert instance', async () => { + const alertsClient = new AlertsClient({ ...alertsClientParams, auditLogger }); + unsecuredSavedObjectsClient.get.mockResolvedValueOnce({ + id: '1', + type: 'alert', + attributes: { + actions: [], + schedule: { interval: '10s' }, + alertTypeId: '2', + enabled: true, + scheduledTaskId: 'task-123', + mutedInstanceIds: [], + }, + version: '123', + references: [], + }); + await alertsClient.muteInstance({ alertId: '1', alertInstanceId: '2' }); + expect(auditLogger.log).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + action: 'alert_instance_mute', + outcome: 'unknown', + }), + kibana: { saved_object: { id: '1', type: 'alert' } }, + }) + ); + }); + + test('logs audit event when not authorised to mute an alert instance', async () => { + const alertsClient = new AlertsClient({ ...alertsClientParams, auditLogger }); + unsecuredSavedObjectsClient.get.mockResolvedValueOnce({ + id: '1', + type: 'alert', + attributes: { + actions: [], + schedule: { interval: '10s' }, + alertTypeId: '2', + enabled: true, + scheduledTaskId: 'task-123', + mutedInstanceIds: [], + }, + version: '123', + references: [], + }); + authorization.ensureAuthorized.mockRejectedValue(new Error('Unauthorized')); + + await expect( + alertsClient.muteInstance({ alertId: '1', alertInstanceId: '2' }) + ).rejects.toThrow(); + expect(auditLogger.log).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + action: 'alert_instance_mute', + outcome: 'failure', + }), + kibana: { + saved_object: { + id: '1', + type: 'alert', + }, + }, + error: { + code: 'Error', + message: 'Unauthorized', + }, + }) + ); + }); + }); }); diff --git a/x-pack/plugins/alerts/server/alerts_client/tests/unmute_all.test.ts b/x-pack/plugins/alerts/server/alerts_client/tests/unmute_all.test.ts index d92304ab873be..fd0157091e3a5 100644 --- a/x-pack/plugins/alerts/server/alerts_client/tests/unmute_all.test.ts +++ b/x-pack/plugins/alerts/server/alerts_client/tests/unmute_all.test.ts @@ -12,15 +12,17 @@ import { encryptedSavedObjectsMock } from '../../../../encrypted_saved_objects/s import { actionsAuthorizationMock } from '../../../../actions/server/mocks'; import { AlertsAuthorization } from '../../authorization/alerts_authorization'; import { ActionsAuthorization } from '../../../../actions/server'; +import { httpServerMock } from '../../../../../../src/core/server/mocks'; +import { auditServiceMock } from '../../../../security/server/audit/index.mock'; import { getBeforeSetup, setGlobalDate } from './lib'; const taskManager = taskManagerMock.createStart(); const alertTypeRegistry = alertTypeRegistryMock.create(); const unsecuredSavedObjectsClient = savedObjectsClientMock.create(); - const encryptedSavedObjects = encryptedSavedObjectsMock.createClient(); const authorization = alertsAuthorizationMock.create(); const actionsAuthorization = actionsAuthorizationMock.create(); +const auditLogger = auditServiceMock.create().asScoped(httpServerMock.createKibanaRequest()); const kibanaVersion = 'v7.10.0'; const alertsClientParams: jest.Mocked = { @@ -42,6 +44,7 @@ const alertsClientParams: jest.Mocked = { beforeEach(() => { getBeforeSetup(alertsClientParams, taskManager, alertTypeRegistry); + (auditLogger.log as jest.Mock).mockClear(); }); setGlobalDate(); @@ -138,4 +141,85 @@ describe('unmuteAll()', () => { expect(authorization.ensureAuthorized).toHaveBeenCalledWith('myType', 'myApp', 'unmuteAll'); }); }); + + describe('auditLogger', () => { + test('logs audit event when unmuting an alert', async () => { + const alertsClient = new AlertsClient({ ...alertsClientParams, auditLogger }); + unsecuredSavedObjectsClient.get.mockResolvedValueOnce({ + id: '1', + type: 'alert', + attributes: { + actions: [ + { + group: 'default', + id: '1', + actionTypeId: '1', + actionRef: '1', + params: { + foo: true, + }, + }, + ], + muteAll: false, + }, + references: [], + version: '123', + }); + await alertsClient.unmuteAll({ id: '1' }); + expect(auditLogger.log).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + action: 'alert_unmute', + outcome: 'unknown', + }), + kibana: { saved_object: { id: '1', type: 'alert' } }, + }) + ); + }); + + test('logs audit event when not authorised to unmute an alert', async () => { + const alertsClient = new AlertsClient({ ...alertsClientParams, auditLogger }); + unsecuredSavedObjectsClient.get.mockResolvedValueOnce({ + id: '1', + type: 'alert', + attributes: { + actions: [ + { + group: 'default', + id: '1', + actionTypeId: '1', + actionRef: '1', + params: { + foo: true, + }, + }, + ], + muteAll: false, + }, + references: [], + version: '123', + }); + authorization.ensureAuthorized.mockRejectedValue(new Error('Unauthorized')); + + await expect(alertsClient.unmuteAll({ id: '1' })).rejects.toThrow(); + expect(auditLogger.log).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + action: 'alert_unmute', + outcome: 'failure', + }), + kibana: { + saved_object: { + id: '1', + type: 'alert', + }, + }, + error: { + code: 'Error', + message: 'Unauthorized', + }, + }) + ); + }); + }); }); diff --git a/x-pack/plugins/alerts/server/alerts_client/tests/unmute_instance.test.ts b/x-pack/plugins/alerts/server/alerts_client/tests/unmute_instance.test.ts index 3486df98f2f05..c7d084a01a2a0 100644 --- a/x-pack/plugins/alerts/server/alerts_client/tests/unmute_instance.test.ts +++ b/x-pack/plugins/alerts/server/alerts_client/tests/unmute_instance.test.ts @@ -12,15 +12,17 @@ import { encryptedSavedObjectsMock } from '../../../../encrypted_saved_objects/s import { actionsAuthorizationMock } from '../../../../actions/server/mocks'; import { AlertsAuthorization } from '../../authorization/alerts_authorization'; import { ActionsAuthorization } from '../../../../actions/server'; +import { httpServerMock } from '../../../../../../src/core/server/mocks'; +import { auditServiceMock } from '../../../../security/server/audit/index.mock'; import { getBeforeSetup, setGlobalDate } from './lib'; const taskManager = taskManagerMock.createStart(); const alertTypeRegistry = alertTypeRegistryMock.create(); const unsecuredSavedObjectsClient = savedObjectsClientMock.create(); - const encryptedSavedObjects = encryptedSavedObjectsMock.createClient(); const authorization = alertsAuthorizationMock.create(); const actionsAuthorization = actionsAuthorizationMock.create(); +const auditLogger = auditServiceMock.create().asScoped(httpServerMock.createKibanaRequest()); const kibanaVersion = 'v7.10.0'; const alertsClientParams: jest.Mocked = { @@ -42,6 +44,7 @@ const alertsClientParams: jest.Mocked = { beforeEach(() => { getBeforeSetup(alertsClientParams, taskManager, alertTypeRegistry); + (auditLogger.log as jest.Mock).mockClear(); }); setGlobalDate(); @@ -178,4 +181,75 @@ describe('unmuteInstance()', () => { ); }); }); + + describe('auditLogger', () => { + test('logs audit event when unmuting an alert instance', async () => { + const alertsClient = new AlertsClient({ ...alertsClientParams, auditLogger }); + unsecuredSavedObjectsClient.get.mockResolvedValueOnce({ + id: '1', + type: 'alert', + attributes: { + actions: [], + schedule: { interval: '10s' }, + alertTypeId: '2', + enabled: true, + scheduledTaskId: 'task-123', + mutedInstanceIds: [], + }, + version: '123', + references: [], + }); + await alertsClient.unmuteInstance({ alertId: '1', alertInstanceId: '2' }); + expect(auditLogger.log).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + action: 'alert_instance_unmute', + outcome: 'unknown', + }), + kibana: { saved_object: { id: '1', type: 'alert' } }, + }) + ); + }); + + test('logs audit event when not authorised to unmute an alert instance', async () => { + const alertsClient = new AlertsClient({ ...alertsClientParams, auditLogger }); + unsecuredSavedObjectsClient.get.mockResolvedValueOnce({ + id: '1', + type: 'alert', + attributes: { + actions: [], + schedule: { interval: '10s' }, + alertTypeId: '2', + enabled: true, + scheduledTaskId: 'task-123', + mutedInstanceIds: [], + }, + version: '123', + references: [], + }); + authorization.ensureAuthorized.mockRejectedValue(new Error('Unauthorized')); + + await expect( + alertsClient.unmuteInstance({ alertId: '1', alertInstanceId: '2' }) + ).rejects.toThrow(); + expect(auditLogger.log).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + action: 'alert_instance_unmute', + outcome: 'failure', + }), + kibana: { + saved_object: { + id: '1', + type: 'alert', + }, + }, + error: { + code: 'Error', + message: 'Unauthorized', + }, + }) + ); + }); + }); }); diff --git a/x-pack/plugins/alerts/server/alerts_client/tests/update.test.ts b/x-pack/plugins/alerts/server/alerts_client/tests/update.test.ts index b42ee096777fe..15fb1e2ec0092 100644 --- a/x-pack/plugins/alerts/server/alerts_client/tests/update.test.ts +++ b/x-pack/plugins/alerts/server/alerts_client/tests/update.test.ts @@ -18,15 +18,17 @@ import { AlertsAuthorization } from '../../authorization/alerts_authorization'; import { resolvable } from '../../test_utils'; import { ActionsAuthorization, ActionsClient } from '../../../../actions/server'; import { TaskStatus } from '../../../../task_manager/server'; +import { httpServerMock } from '../../../../../../src/core/server/mocks'; +import { auditServiceMock } from '../../../../security/server/audit/index.mock'; import { getBeforeSetup, setGlobalDate } from './lib'; const taskManager = taskManagerMock.createStart(); const alertTypeRegistry = alertTypeRegistryMock.create(); const unsecuredSavedObjectsClient = savedObjectsClientMock.create(); - const encryptedSavedObjects = encryptedSavedObjectsMock.createClient(); const authorization = alertsAuthorizationMock.create(); const actionsAuthorization = actionsAuthorizationMock.create(); +const auditLogger = auditServiceMock.create().asScoped(httpServerMock.createKibanaRequest()); const kibanaVersion = 'v7.10.0'; const alertsClientParams: jest.Mocked = { @@ -44,10 +46,12 @@ const alertsClientParams: jest.Mocked = { getActionsClient: jest.fn(), getEventLogClient: jest.fn(), kibanaVersion, + auditLogger, }; beforeEach(() => { getBeforeSetup(alertsClientParams, taskManager, alertTypeRegistry); + (auditLogger.log as jest.Mock).mockClear(); }); setGlobalDate(); @@ -1302,4 +1306,89 @@ describe('update()', () => { expect(authorization.ensureAuthorized).toHaveBeenCalledWith('myType', 'myApp', 'update'); }); }); + + describe('auditLogger', () => { + beforeEach(() => { + unsecuredSavedObjectsClient.create.mockResolvedValueOnce({ + id: '1', + type: 'alert', + attributes: { + enabled: true, + schedule: { interval: '10s' }, + params: { + bar: true, + }, + actions: [], + scheduledTaskId: 'task-123', + createdAt: new Date().toISOString(), + }, + updated_at: new Date().toISOString(), + references: [], + }); + }); + + test('logs audit event when updating an alert', async () => { + await alertsClient.update({ + id: '1', + data: { + schedule: { interval: '10s' }, + name: 'abc', + tags: ['foo'], + params: { + bar: true, + }, + throttle: null, + actions: [], + }, + }); + + expect(auditLogger.log).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + action: 'alert_update', + outcome: 'unknown', + }), + kibana: { saved_object: { id: '1', type: 'alert' } }, + }) + ); + }); + + test('logs audit event when not authorised to update an alert', async () => { + authorization.ensureAuthorized.mockRejectedValue(new Error('Unauthorized')); + + await expect( + alertsClient.update({ + id: '1', + data: { + schedule: { interval: '10s' }, + name: 'abc', + tags: ['foo'], + params: { + bar: true, + }, + throttle: null, + actions: [], + }, + }) + ).rejects.toThrow(); + expect(auditLogger.log).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + outcome: 'failure', + action: 'alert_update', + }), + kibana: { + saved_object: { + id: '1', + type: 'alert', + }, + }, + error: { + code: 'Error', + message: 'Unauthorized', + }, + }) + ); + }); + }); }); diff --git a/x-pack/plugins/alerts/server/alerts_client/tests/update_api_key.test.ts b/x-pack/plugins/alerts/server/alerts_client/tests/update_api_key.test.ts index ca5f44078f513..bf21256bb8413 100644 --- a/x-pack/plugins/alerts/server/alerts_client/tests/update_api_key.test.ts +++ b/x-pack/plugins/alerts/server/alerts_client/tests/update_api_key.test.ts @@ -12,8 +12,10 @@ import { encryptedSavedObjectsMock } from '../../../../encrypted_saved_objects/s import { actionsAuthorizationMock } from '../../../../actions/server/mocks'; import { AlertsAuthorization } from '../../authorization/alerts_authorization'; import { ActionsAuthorization } from '../../../../actions/server'; -import { getBeforeSetup, setGlobalDate } from './lib'; +import { httpServerMock } from '../../../../../../src/core/server/mocks'; +import { auditServiceMock } from '../../../../security/server/audit/index.mock'; import { InvalidatePendingApiKey } from '../../types'; +import { getBeforeSetup, setGlobalDate } from './lib'; const taskManager = taskManagerMock.createStart(); const alertTypeRegistry = alertTypeRegistryMock.create(); @@ -21,6 +23,7 @@ const unsecuredSavedObjectsClient = savedObjectsClientMock.create(); const encryptedSavedObjects = encryptedSavedObjectsMock.createClient(); const authorization = alertsAuthorizationMock.create(); const actionsAuthorization = actionsAuthorizationMock.create(); +const auditLogger = auditServiceMock.create().asScoped(httpServerMock.createKibanaRequest()); const kibanaVersion = 'v7.10.0'; const alertsClientParams: jest.Mocked = { @@ -38,10 +41,12 @@ const alertsClientParams: jest.Mocked = { getActionsClient: jest.fn(), getEventLogClient: jest.fn(), kibanaVersion, + auditLogger, }; beforeEach(() => { getBeforeSetup(alertsClientParams, taskManager, alertTypeRegistry); + (auditLogger.log as jest.Mock).mockClear(); }); setGlobalDate(); @@ -269,4 +274,44 @@ describe('updateApiKey()', () => { ); }); }); + + describe('auditLogger', () => { + test('logs audit event when updating the API key of an alert', async () => { + await alertsClient.updateApiKey({ id: '1' }); + + expect(auditLogger.log).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + action: 'alert_update_api_key', + outcome: 'unknown', + }), + kibana: { saved_object: { id: '1', type: 'alert' } }, + }) + ); + }); + + test('logs audit event when not authorised to update the API key of an alert', async () => { + authorization.ensureAuthorized.mockRejectedValue(new Error('Unauthorized')); + + await expect(alertsClient.updateApiKey({ id: '1' })).rejects.toThrow(); + expect(auditLogger.log).toHaveBeenCalledWith( + expect.objectContaining({ + event: expect.objectContaining({ + outcome: 'failure', + action: 'alert_update_api_key', + }), + kibana: { + saved_object: { + id: '1', + type: 'alert', + }, + }, + error: { + code: 'Error', + message: 'Unauthorized', + }, + }) + ); + }); + }); }); diff --git a/x-pack/plugins/alerts/server/alerts_client_factory.ts b/x-pack/plugins/alerts/server/alerts_client_factory.ts index 069703be72f8a..9d71b5f817b2c 100644 --- a/x-pack/plugins/alerts/server/alerts_client_factory.ts +++ b/x-pack/plugins/alerts/server/alerts_client_factory.ts @@ -100,6 +100,7 @@ export class AlertsClientFactory { actionsAuthorization: actions.getActionsAuthorizationWithRequest(request), namespace: this.spaceIdToNamespace(spaceId), encryptedSavedObjectsClient: this.encryptedSavedObjectsClient, + auditLogger: securityPluginSetup?.audit.asScoped(request), async getUserName() { if (!securityPluginSetup) { return null; diff --git a/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_object_type_definition.test.ts b/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_object_type_definition.test.ts index f1e06a0cec03d..f528843cf9ea3 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_object_type_definition.test.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_object_type_definition.test.ts @@ -113,18 +113,3 @@ it('correctly determines attribute properties', () => { } } }); - -it('it correctly sets allowPredefinedID', () => { - const defaultTypeDefinition = new EncryptedSavedObjectAttributesDefinition({ - type: 'so-type', - attributesToEncrypt: new Set(['attr#1', 'attr#2']), - }); - expect(defaultTypeDefinition.allowPredefinedID).toBe(false); - - const typeDefinitionWithPredefinedIDAllowed = new EncryptedSavedObjectAttributesDefinition({ - type: 'so-type', - attributesToEncrypt: new Set(['attr#1', 'attr#2']), - allowPredefinedID: true, - }); - expect(typeDefinitionWithPredefinedIDAllowed.allowPredefinedID).toBe(true); -}); diff --git a/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_object_type_definition.ts b/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_object_type_definition.ts index 398a64585411a..849a2888b6e1a 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_object_type_definition.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_object_type_definition.ts @@ -15,7 +15,6 @@ export class EncryptedSavedObjectAttributesDefinition { public readonly attributesToEncrypt: ReadonlySet; private readonly attributesToExcludeFromAAD: ReadonlySet | undefined; private readonly attributesToStrip: ReadonlySet; - public readonly allowPredefinedID: boolean; constructor(typeRegistration: EncryptedSavedObjectTypeRegistration) { const attributesToEncrypt = new Set(); @@ -35,7 +34,6 @@ export class EncryptedSavedObjectAttributesDefinition { this.attributesToEncrypt = attributesToEncrypt; this.attributesToStrip = attributesToStrip; this.attributesToExcludeFromAAD = typeRegistration.attributesToExcludeFromAAD; - this.allowPredefinedID = !!typeRegistration.allowPredefinedID; } /** diff --git a/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.mocks.ts b/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.mocks.ts index 0138e929ca1ca..c692d8698771f 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.mocks.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/crypto/encrypted_saved_objects_service.mocks.ts @@ -13,7 +13,6 @@ import { function createEncryptedSavedObjectsServiceMock() { return ({ isRegistered: jest.fn(), - canSpecifyID: jest.fn(), stripOrDecryptAttributes: jest.fn(), encryptAttributes: jest.fn(), decryptAttributes: jest.fn(), @@ -53,12 +52,6 @@ export const encryptedSavedObjectsServiceMock = { mock.isRegistered.mockImplementation( (type) => registrations.findIndex((r) => r.type === type) >= 0 ); - mock.canSpecifyID.mockImplementation((type, version, overwrite) => { - const registration = registrations.find((r) => r.type === type); - return ( - registration === undefined || registration.allowPredefinedID || !!(version && overwrite) - ); - }); mock.encryptAttributes.mockImplementation(async (descriptor, attrs) => processAttributes( descriptor, 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 6bc4a392064e4..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 @@ -89,45 +89,6 @@ describe('#isRegistered', () => { }); }); -describe('#canSpecifyID', () => { - it('returns true for unknown types', () => { - expect(service.canSpecifyID('unknown-type')).toBe(true); - }); - - it('returns true for types registered setting allowPredefinedID to true', () => { - service.registerType({ - type: 'known-type-1', - attributesToEncrypt: new Set(['attr-1']), - allowPredefinedID: true, - }); - expect(service.canSpecifyID('known-type-1')).toBe(true); - }); - - it('returns true when overwriting a saved object with a version specified even when allowPredefinedID is not set', () => { - service.registerType({ - type: 'known-type-1', - attributesToEncrypt: new Set(['attr-1']), - }); - expect(service.canSpecifyID('known-type-1', '2', true)).toBe(true); - expect(service.canSpecifyID('known-type-1', '2', false)).toBe(false); - expect(service.canSpecifyID('known-type-1', undefined, true)).toBe(false); - }); - - it('returns false for types registered without setting allowPredefinedID', () => { - service.registerType({ type: 'known-type-1', attributesToEncrypt: new Set(['attr-1']) }); - expect(service.canSpecifyID('known-type-1')).toBe(false); - }); - - it('returns false for types registered setting allowPredefinedID to false', () => { - service.registerType({ - type: 'known-type-1', - attributesToEncrypt: new Set(['attr-1']), - allowPredefinedID: false, - }); - expect(service.canSpecifyID('known-type-1')).toBe(false); - }); -}); - describe('#stripOrDecryptAttributes', () => { it('does not strip attributes from unknown types', async () => { const attributes = { attrOne: 'one', attrTwo: 'two', attrThree: 'three' }; 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 8d2ebb575c35e..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 @@ -31,7 +31,6 @@ export interface EncryptedSavedObjectTypeRegistration { readonly type: string; readonly attributesToEncrypt: ReadonlySet; readonly attributesToExcludeFromAAD?: ReadonlySet; - readonly allowPredefinedID?: boolean; } /** @@ -145,25 +144,6 @@ export class EncryptedSavedObjectsService { return this.typeDefinitions.has(type); } - /** - * Checks whether ID can be specified for the provided saved object. - * - * If the type isn't registered as an encrypted saved object, or when overwriting an existing - * saved object with a version specified, this will return "true". - * - * @param type Saved object type. - * @param version Saved object version number which changes on each successful write operation. - * Can be used in conjunction with `overwrite` for implementing optimistic concurrency - * control. - * @param overwrite Overwrite existing documents. - */ - public canSpecifyID(type: string, version?: string, overwrite?: boolean) { - const typeDefinition = this.typeDefinitions.get(type); - return ( - typeDefinition === undefined || typeDefinition.allowPredefinedID || !!(version && overwrite) - ); - } - /** * Takes saved object attributes for the specified type and, depending on the type definition, * either decrypts or strips encrypted attributes (e.g. in case AAD or encryption key has changed diff --git a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.test.ts b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.test.ts index 3c722ccfabae2..85ec08fb7388d 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.test.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.test.ts @@ -13,7 +13,18 @@ import { savedObjectsClientMock, savedObjectsTypeRegistryMock } from 'src/core/s import { mockAuthenticatedUser } from '../../../security/common/model/authenticated_user.mock'; import { encryptedSavedObjectsServiceMock } from '../crypto/index.mock'; -jest.mock('uuid', () => ({ v4: jest.fn().mockReturnValue('uuid-v4-id') })); +jest.mock('../../../../../src/core/server/saved_objects/service/lib/utils', () => { + const { SavedObjectsUtils } = jest.requireActual( + '../../../../../src/core/server/saved_objects/service/lib/utils' + ); + return { + SavedObjectsUtils: { + namespaceStringToId: SavedObjectsUtils.namespaceStringToId, + isRandomId: SavedObjectsUtils.isRandomId, + generateId: () => 'mock-saved-object-id', + }, + }; +}); let wrapper: EncryptedSavedObjectsClientWrapper; let mockBaseClient: jest.Mocked; @@ -30,11 +41,6 @@ beforeEach(() => { { key: 'attrNotSoSecret', dangerouslyExposeValue: true }, ]), }, - { - type: 'known-type-predefined-id', - attributesToEncrypt: new Set(['attrSecret']), - allowPredefinedID: true, - }, ]); wrapper = new EncryptedSavedObjectsClientWrapper({ @@ -77,36 +83,16 @@ describe('#create', () => { expect(mockBaseClient.create).toHaveBeenCalledWith('unknown-type', attributes, options); }); - it('fails if type is registered without allowPredefinedID and ID is specified', async () => { + it('fails if type is registered and ID is specified', async () => { const attributes = { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' }; await expect(wrapper.create('known-type', attributes, { id: 'some-id' })).rejects.toThrowError( - 'Predefined IDs are not allowed for encrypted saved objects of type "known-type".' + 'Predefined IDs are not allowed for saved objects with encrypted attributes, unless the ID has been generated using `SavedObjectsUtils.generateId`.' ); expect(mockBaseClient.create).not.toHaveBeenCalled(); }); - it('succeeds if type is registered with allowPredefinedID and ID is specified', async () => { - const attributes = { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' }; - const mockedResponse = { - id: 'some-id', - type: 'known-type-predefined-id', - attributes: { attrOne: 'one', attrSecret: '*secret*', attrThree: 'three' }, - references: [], - }; - - mockBaseClient.create.mockResolvedValue(mockedResponse); - await expect( - wrapper.create('known-type-predefined-id', attributes, { id: 'some-id' }) - ).resolves.toEqual({ - ...mockedResponse, - attributes: { attrOne: 'one', attrThree: 'three' }, - }); - - expect(mockBaseClient.create).toHaveBeenCalled(); - }); - it('allows a specified ID when overwriting an existing object', async () => { const attributes = { attrOne: 'one', @@ -168,7 +154,7 @@ describe('#create', () => { }; const options = { overwrite: true }; const mockedResponse = { - id: 'uuid-v4-id', + id: 'mock-saved-object-id', type: 'known-type', attributes: { attrOne: 'one', @@ -188,7 +174,7 @@ describe('#create', () => { expect(encryptedSavedObjectsServiceMockInstance.encryptAttributes).toHaveBeenCalledTimes(1); expect(encryptedSavedObjectsServiceMockInstance.encryptAttributes).toHaveBeenCalledWith( - { type: 'known-type', id: 'uuid-v4-id' }, + { type: 'known-type', id: 'mock-saved-object-id' }, { attrOne: 'one', attrSecret: 'secret', @@ -207,7 +193,7 @@ describe('#create', () => { attrNotSoSecret: '*not-so-secret*', attrThree: 'three', }, - { id: 'uuid-v4-id', overwrite: true } + { id: 'mock-saved-object-id', overwrite: true } ); }); @@ -216,7 +202,7 @@ describe('#create', () => { const attributes = { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' }; const options = { overwrite: true, namespace }; const mockedResponse = { - id: 'uuid-v4-id', + id: 'mock-saved-object-id', type: 'known-type', attributes: { attrOne: 'one', attrSecret: '*secret*', attrThree: 'three' }, references: [], @@ -233,7 +219,7 @@ describe('#create', () => { expect(encryptedSavedObjectsServiceMockInstance.encryptAttributes).toHaveBeenCalledWith( { type: 'known-type', - id: 'uuid-v4-id', + id: 'mock-saved-object-id', namespace: expectNamespaceInDescriptor ? namespace : undefined, }, { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' }, @@ -244,7 +230,7 @@ describe('#create', () => { expect(mockBaseClient.create).toHaveBeenCalledWith( 'known-type', { attrOne: 'one', attrSecret: '*secret*', attrThree: 'three' }, - { id: 'uuid-v4-id', overwrite: true, namespace } + { id: 'mock-saved-object-id', overwrite: true, namespace } ); }; @@ -270,7 +256,7 @@ describe('#create', () => { expect(mockBaseClient.create).toHaveBeenCalledWith( 'known-type', { attrOne: 'one', attrSecret: '*secret*', attrThree: 'three' }, - { id: 'uuid-v4-id' } + { id: 'mock-saved-object-id' } ); }); }); @@ -282,7 +268,7 @@ describe('#bulkCreate', () => { const mockedResponse = { saved_objects: [ { - id: 'uuid-v4-id', + id: 'mock-saved-object-id', type: 'known-type', attributes, references: [], @@ -315,7 +301,7 @@ describe('#bulkCreate', () => { [ { ...bulkCreateParams[0], - id: 'uuid-v4-id', + id: 'mock-saved-object-id', attributes: { attrOne: 'one', attrSecret: '*secret*', attrThree: 'three' }, }, bulkCreateParams[1], @@ -324,7 +310,7 @@ describe('#bulkCreate', () => { ); }); - it('fails if ID is specified for registered type without allowPredefinedID', async () => { + it('fails if ID is specified for registered type', async () => { const attributes = { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' }; const bulkCreateParams = [ @@ -333,48 +319,12 @@ describe('#bulkCreate', () => { ]; await expect(wrapper.bulkCreate(bulkCreateParams)).rejects.toThrowError( - 'Predefined IDs are not allowed for encrypted saved objects of type "known-type".' + 'Predefined IDs are not allowed for saved objects with encrypted attributes, unless the ID has been generated using `SavedObjectsUtils.generateId`.' ); expect(mockBaseClient.bulkCreate).not.toHaveBeenCalled(); }); - it('succeeds if ID is specified for registered type with allowPredefinedID', async () => { - const attributes = { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' }; - const options = { namespace: 'some-namespace' }; - const mockedResponse = { - saved_objects: [ - { - id: 'some-id', - type: 'known-type-predefined-id', - attributes, - references: [], - }, - { - id: 'some-id', - type: 'unknown-type', - attributes, - references: [], - }, - ], - }; - mockBaseClient.bulkCreate.mockResolvedValue(mockedResponse); - - const bulkCreateParams = [ - { id: 'some-id', type: 'known-type-predefined-id', attributes }, - { type: 'unknown-type', attributes }, - ]; - - await expect(wrapper.bulkCreate(bulkCreateParams, options)).resolves.toEqual({ - saved_objects: [ - { ...mockedResponse.saved_objects[0], attributes: { attrOne: 'one', attrThree: 'three' } }, - mockedResponse.saved_objects[1], - ], - }); - - expect(mockBaseClient.bulkCreate).toHaveBeenCalled(); - }); - it('allows a specified ID when overwriting an existing object', async () => { const attributes = { attrOne: 'one', @@ -456,7 +406,7 @@ describe('#bulkCreate', () => { const mockedResponse = { saved_objects: [ { - id: 'uuid-v4-id', + id: 'mock-saved-object-id', type: 'known-type', attributes: { ...attributes, attrSecret: '*secret*', attrNotSoSecret: '*not-so-secret*' }, references: [], @@ -489,7 +439,7 @@ describe('#bulkCreate', () => { expect(encryptedSavedObjectsServiceMockInstance.encryptAttributes).toHaveBeenCalledTimes(1); expect(encryptedSavedObjectsServiceMockInstance.encryptAttributes).toHaveBeenCalledWith( - { type: 'known-type', id: 'uuid-v4-id' }, + { type: 'known-type', id: 'mock-saved-object-id' }, { attrOne: 'one', attrSecret: 'secret', @@ -504,7 +454,7 @@ describe('#bulkCreate', () => { [ { ...bulkCreateParams[0], - id: 'uuid-v4-id', + id: 'mock-saved-object-id', attributes: { attrOne: 'one', attrSecret: '*secret*', @@ -523,7 +473,9 @@ describe('#bulkCreate', () => { const attributes = { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' }; const options = { namespace }; const mockedResponse = { - saved_objects: [{ id: 'uuid-v4-id', type: 'known-type', attributes, references: [] }], + saved_objects: [ + { id: 'mock-saved-object-id', type: 'known-type', attributes, references: [] }, + ], }; mockBaseClient.bulkCreate.mockResolvedValue(mockedResponse); @@ -542,7 +494,7 @@ describe('#bulkCreate', () => { expect(encryptedSavedObjectsServiceMockInstance.encryptAttributes).toHaveBeenCalledWith( { type: 'known-type', - id: 'uuid-v4-id', + id: 'mock-saved-object-id', namespace: expectNamespaceInDescriptor ? namespace : undefined, }, { attrOne: 'one', attrSecret: 'secret', attrThree: 'three' }, @@ -554,7 +506,7 @@ describe('#bulkCreate', () => { [ { ...bulkCreateParams[0], - id: 'uuid-v4-id', + id: 'mock-saved-object-id', attributes: { attrOne: 'one', attrSecret: '*secret*', attrThree: 'three' }, }, ], @@ -590,7 +542,7 @@ describe('#bulkCreate', () => { [ { type: 'known-type', - id: 'uuid-v4-id', + id: 'mock-saved-object-id', attributes: { attrOne: 'one', attrSecret: '*secret*', attrThree: 'three' }, }, ], diff --git a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.ts b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.ts index ddef9f477433c..313e7c7da9eba 100644 --- a/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.ts +++ b/x-pack/plugins/encrypted_saved_objects/server/saved_objects/encrypted_saved_objects_client_wrapper.ts @@ -4,7 +4,6 @@ * you may not use this file except in compliance with the Elastic License. */ -import uuid from 'uuid'; import { SavedObject, SavedObjectsBaseOptions, @@ -25,7 +24,8 @@ import { SavedObjectsRemoveReferencesToOptions, ISavedObjectTypeRegistry, SavedObjectsRemoveReferencesToResponse, -} from 'src/core/server'; + SavedObjectsUtils, +} from '../../../../../src/core/server'; import { AuthenticatedUser } from '../../../security/common/model'; import { EncryptedSavedObjectsService } from '../crypto'; import { getDescriptorNamespace } from './get_descriptor_namespace'; @@ -37,14 +37,6 @@ interface EncryptedSavedObjectsClientOptions { getCurrentUser: () => AuthenticatedUser | undefined; } -/** - * Generates UUIDv4 ID for the any newly created saved object that is supposed to contain - * encrypted attributes. - */ -function generateID() { - return uuid.v4(); -} - export class EncryptedSavedObjectsClientWrapper implements SavedObjectsClientContract { constructor( private readonly options: EncryptedSavedObjectsClientOptions, @@ -67,19 +59,7 @@ export class EncryptedSavedObjectsClientWrapper implements SavedObjectsClientCon return await this.options.baseClient.create(type, attributes, options); } - // Saved objects with encrypted attributes should have IDs that are hard to guess especially - // since IDs are part of the AAD used during encryption. Types can opt-out of this restriction, - // when necessary, but it's much safer for this wrapper to generate them. - if ( - options.id && - !this.options.service.canSpecifyID(type, options.version, options.overwrite) - ) { - throw new Error( - `Predefined IDs are not allowed for encrypted saved objects of type "${type}".` - ); - } - - const id = options.id ?? generateID(); + const id = getValidId(options.id, options.version, options.overwrite); const namespace = getDescriptorNamespace( this.options.baseTypeRegistry, type, @@ -113,19 +93,7 @@ export class EncryptedSavedObjectsClientWrapper implements SavedObjectsClientCon return object; } - // Saved objects with encrypted attributes should have IDs that are hard to guess especially - // since IDs are part of the AAD used during encryption, that's why we control them within this - // wrapper and don't allow consumers to specify their own IDs directly unless overwriting the original document. - if ( - object.id && - !this.options.service.canSpecifyID(object.type, object.version, options?.overwrite) - ) { - throw new Error( - `Predefined IDs are not allowed for encrypted saved objects of type "${object.type}".` - ); - } - - const id = object.id ?? generateID(); + const id = getValidId(object.id, object.version, options?.overwrite); const namespace = getDescriptorNamespace( this.options.baseTypeRegistry, object.type, @@ -327,3 +295,26 @@ export class EncryptedSavedObjectsClientWrapper implements SavedObjectsClientCon return response; } } + +// Saved objects with encrypted attributes should have IDs that are hard to guess especially +// since IDs are part of the AAD used during encryption, that's why we control them within this +// wrapper and don't allow consumers to specify their own IDs directly unless overwriting the original document. +function getValidId( + id: string | undefined, + version: string | undefined, + overwrite: boolean | undefined +) { + if (id) { + // only allow a specified ID if we're overwriting an existing ESO with a Version + // this helps us ensure that the document really was previously created using ESO + // and not being used to get around the specified ID limitation + const canSpecifyID = (overwrite && version) || SavedObjectsUtils.isRandomId(id); + if (!canSpecifyID) { + throw new Error( + 'Predefined IDs are not allowed for saved objects with encrypted attributes, unless the ID has been generated using `SavedObjectsUtils.generateId`.' + ); + } + return id; + } + return SavedObjectsUtils.generateId(); +} diff --git a/x-pack/plugins/lens/server/__snapshots__/migrations.test.ts.snap b/x-pack/plugins/lens/server/__snapshots__/migrations.test.ts.snap index 4979438dbd3d0..819fbd9c970ce 100644 --- a/x-pack/plugins/lens/server/__snapshots__/migrations.test.ts.snap +++ b/x-pack/plugins/lens/server/__snapshots__/migrations.test.ts.snap @@ -156,6 +156,7 @@ Object { "title": "mylens", "visualizationType": "lnsXY", }, + "id": "mock-saved-object-id", "references": Array [ Object { "id": "ff959d40-b880-11e8-a6d9-e546fe2bba5f", diff --git a/x-pack/plugins/lens/server/migrations.test.ts b/x-pack/plugins/lens/server/migrations.test.ts index 957da5cbb3743..9764926fc03fc 100644 --- a/x-pack/plugins/lens/server/migrations.test.ts +++ b/x-pack/plugins/lens/server/migrations.test.ts @@ -13,6 +13,7 @@ describe('Lens migrations', () => { const example = { type: 'lens', + id: 'mock-saved-object-id', attributes: { expression: 'kibana\n| kibana_context query="{\\"language\\":\\"kuery\\",\\"query\\":\\"\\"}" \n| lens_merge_tables layerIds="c61a8afb-a185-4fae-a064-fb3846f6c451" \n tables={esaggs index="logstash-*" metricsAtAllLevels=false partialRows=false includeFormatHints=true aggConfigs="[{\\"id\\":\\"2cd09808-3915-49f4-b3b0-82767eba23f7\\",\\"enabled\\":true,\\"type\\":\\"max\\",\\"schema\\":\\"metric\\",\\"params\\":{\\"field\\":\\"bytes\\"}}]" | lens_rename_columns idMap="{\\"col-0-2cd09808-3915-49f4-b3b0-82767eba23f7\\":\\"2cd09808-3915-49f4-b3b0-82767eba23f7\\"}"}\n| lens_metric_chart title="Maximum of bytes" accessor="2cd09808-3915-49f4-b3b0-82767eba23f7"', @@ -164,6 +165,7 @@ describe('Lens migrations', () => { const example = { type: 'lens', + id: 'mock-saved-object-id', attributes: { expression: `kibana | kibana_context query="{\\"query\\":\\"\\",\\"language\\":\\"kuery\\"}" filters="[]" @@ -265,6 +267,7 @@ describe('Lens migrations', () => { it('should handle pre-migrated expression', () => { const input = { type: 'lens', + id: 'mock-saved-object-id', attributes: { ...example.attributes, expression: `kibana @@ -283,6 +286,7 @@ describe('Lens migrations', () => { const context = {} as SavedObjectMigrationContext; const example = { + id: 'mock-saved-object-id', attributes: { description: '', expression: @@ -513,6 +517,7 @@ describe('Lens migrations', () => { const example = { type: 'lens', + id: 'mock-saved-object-id', attributes: { state: { datasourceStates: { diff --git a/x-pack/plugins/security/server/audit/audit_events.ts b/x-pack/plugins/security/server/audit/audit_events.ts index 6aba78c936071..2e003b1d55eac 100644 --- a/x-pack/plugins/security/server/audit/audit_events.ts +++ b/x-pack/plugins/security/server/audit/audit_events.ts @@ -45,7 +45,7 @@ export interface AuditEvent { */ saved_object?: { type: string; - id?: string; + id: string; }; /** * Any additional event specific fields. @@ -178,7 +178,9 @@ export enum SavedObjectAction { REMOVE_REFERENCES = 'saved_object_remove_references', } -const eventVerbs = { +type VerbsTuple = [string, string, string]; + +const eventVerbs: Record = { saved_object_create: ['create', 'creating', 'created'], saved_object_get: ['access', 'accessing', 'accessed'], saved_object_update: ['update', 'updating', 'updated'], @@ -193,7 +195,7 @@ const eventVerbs = { ], }; -const eventTypes = { +const eventTypes: Record = { saved_object_create: EventType.CREATION, saved_object_get: EventType.ACCESS, saved_object_update: EventType.CHANGE, @@ -204,10 +206,10 @@ const eventTypes = { saved_object_remove_references: EventType.CHANGE, }; -export interface SavedObjectParams { +export interface SavedObjectEventParams { action: SavedObjectAction; outcome?: EventOutcome; - savedObject?: Required['kibana']>['saved_object']; + savedObject?: NonNullable['saved_object']; addToSpaces?: readonly string[]; deleteFromSpaces?: readonly string[]; error?: Error; @@ -220,12 +222,12 @@ export function savedObjectEvent({ deleteFromSpaces, outcome, error, -}: SavedObjectParams): AuditEvent | undefined { +}: SavedObjectEventParams): AuditEvent | undefined { const doc = savedObject ? `${savedObject.type} [id=${savedObject.id}]` : 'saved objects'; const [present, progressive, past] = eventVerbs[action]; const message = error ? `Failed attempt to ${present} ${doc}` - : outcome === 'unknown' + : outcome === EventOutcome.UNKNOWN ? `User is ${progressive} ${doc}` : `User has ${past} ${doc}`; const type = eventTypes[action]; diff --git a/x-pack/plugins/security/server/index.ts b/x-pack/plugins/security/server/index.ts index 04db65f88cda0..d99fbc702a078 100644 --- a/x-pack/plugins/security/server/index.ts +++ b/x-pack/plugins/security/server/index.ts @@ -27,7 +27,14 @@ export { SAMLLogin, OIDCLogin, } from './authentication'; -export { LegacyAuditLogger } from './audit'; +export { + LegacyAuditLogger, + AuditLogger, + AuditEvent, + EventCategory, + EventType, + EventOutcome, +} from './audit'; export { SecurityPluginSetup }; export { AuthenticatedUser } from '../common/model'; diff --git a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts index c6f4ca6dd8afe..15ca8bac89bd6 100644 --- a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts +++ b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.test.ts @@ -12,6 +12,18 @@ import { SavedObjectsClientContract } from 'kibana/server'; import { SavedObjectActions } from '../authorization/actions/saved_object'; import { AuditEvent, EventOutcome } from '../audit'; +jest.mock('../../../../../src/core/server/saved_objects/service/lib/utils', () => { + const { SavedObjectsUtils } = jest.requireActual( + '../../../../../src/core/server/saved_objects/service/lib/utils' + ); + return { + SavedObjectsUtils: { + createEmptyFindResponse: SavedObjectsUtils.createEmptyFindResponse, + generateId: () => 'mock-saved-object-id', + }, + }; +}); + let clientOpts: ReturnType; let client: SecureSavedObjectsClientWrapper; const USERNAME = Symbol(); @@ -551,7 +563,7 @@ describe('#bulkGet', () => { }); test(`adds audit event when successful`, async () => { - const apiCallReturnValue = { saved_objects: [], foo: 'bar' }; + const apiCallReturnValue = { saved_objects: [obj1, obj2], foo: 'bar' }; clientOpts.baseClient.bulkGet.mockReturnValue(apiCallReturnValue as any); const objects = [obj1, obj2]; const options = { namespace }; @@ -686,7 +698,7 @@ describe('#create', () => { }); test(`throws decorated ForbiddenError when unauthorized`, async () => { - const options = { namespace }; + const options = { id: 'mock-saved-object-id', namespace }; await expectForbiddenError(client.create, { type, attributes, options }); }); @@ -694,8 +706,12 @@ describe('#create', () => { const apiCallReturnValue = Symbol(); clientOpts.baseClient.create.mockResolvedValue(apiCallReturnValue as any); - const options = { namespace }; - const result = await expectSuccess(client.create, { type, attributes, options }); + const options = { id: 'mock-saved-object-id', namespace }; + const result = await expectSuccess(client.create, { + type, + attributes, + options, + }); expect(result).toBe(apiCallReturnValue); }); @@ -721,17 +737,17 @@ describe('#create', () => { test(`adds audit event when successful`, async () => { const apiCallReturnValue = Symbol(); clientOpts.baseClient.create.mockResolvedValue(apiCallReturnValue as any); - const options = { namespace }; + const options = { id: 'mock-saved-object-id', namespace }; await expectSuccess(client.create, { type, attributes, options }); expect(clientOpts.auditLogger.log).toHaveBeenCalledTimes(1); - expectAuditEvent('saved_object_create', EventOutcome.UNKNOWN, { type }); + expectAuditEvent('saved_object_create', EventOutcome.UNKNOWN, { type, id: expect.any(String) }); }); test(`adds audit event when not successful`, async () => { clientOpts.checkSavedObjectsPrivilegesAsCurrentUser.mockRejectedValue(new Error()); await expect(() => client.create(type, attributes, { namespace })).rejects.toThrow(); expect(clientOpts.auditLogger.log).toHaveBeenCalledTimes(1); - expectAuditEvent('saved_object_create', EventOutcome.FAILURE, { type }); + expectAuditEvent('saved_object_create', EventOutcome.FAILURE, { type, id: expect.any(String) }); }); }); diff --git a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.ts b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.ts index e6e34de4ac9ab..765274a839efa 100644 --- a/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.ts +++ b/x-pack/plugins/security/server/saved_objects/secure_saved_objects_client_wrapper.ts @@ -96,15 +96,16 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra attributes: T = {} as T, options: SavedObjectsCreateOptions = {} ) { - const namespaces = [options.namespace, ...(options.initialNamespaces || [])]; + const optionsWithId = { ...options, id: options.id ?? SavedObjectsUtils.generateId() }; + const namespaces = [optionsWithId.namespace, ...(optionsWithId.initialNamespaces || [])]; try { - const args = { type, attributes, options }; + const args = { type, attributes, options: optionsWithId }; await this.ensureAuthorized(type, 'create', namespaces, { args }); } catch (error) { this.auditLogger.log( savedObjectEvent({ action: SavedObjectAction.CREATE, - savedObject: { type, id: options.id }, + savedObject: { type, id: optionsWithId.id }, error, }) ); @@ -114,11 +115,11 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra savedObjectEvent({ action: SavedObjectAction.CREATE, outcome: EventOutcome.UNKNOWN, - savedObject: { type, id: options.id }, + savedObject: { type, id: optionsWithId.id }, }) ); - const savedObject = await this.baseClient.create(type, attributes, options); + const savedObject = await this.baseClient.create(type, attributes, optionsWithId); return await this.redactSavedObjectNamespaces(savedObject, namespaces); } @@ -141,17 +142,26 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra objects: Array>, options: SavedObjectsBaseOptions = {} ) { - const namespaces = objects.reduce( + const objectsWithId = objects.map((obj) => ({ + ...obj, + id: obj.id ?? SavedObjectsUtils.generateId(), + })); + const namespaces = objectsWithId.reduce( (acc, { initialNamespaces = [] }) => acc.concat(initialNamespaces), [options.namespace] ); try { - const args = { objects, options }; - await this.ensureAuthorized(this.getUniqueObjectTypes(objects), 'bulk_create', namespaces, { - args, - }); + const args = { objects: objectsWithId, options }; + await this.ensureAuthorized( + this.getUniqueObjectTypes(objectsWithId), + 'bulk_create', + namespaces, + { + args, + } + ); } catch (error) { - objects.forEach(({ type, id }) => + objectsWithId.forEach(({ type, id }) => this.auditLogger.log( savedObjectEvent({ action: SavedObjectAction.CREATE, @@ -162,7 +172,7 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra ); throw error; } - objects.forEach(({ type, id }) => + objectsWithId.forEach(({ type, id }) => this.auditLogger.log( savedObjectEvent({ action: SavedObjectAction.CREATE, @@ -172,7 +182,7 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra ) ); - const response = await this.baseClient.bulkCreate(objects, options); + const response = await this.baseClient.bulkCreate(objectsWithId, options); return await this.redactSavedObjectsNamespaces(response, namespaces); } @@ -284,14 +294,16 @@ export class SecureSavedObjectsClientWrapper implements SavedObjectsClientContra const response = await this.baseClient.bulkGet(objects, options); - objects.forEach(({ type, id }) => - this.auditLogger.log( - savedObjectEvent({ - action: SavedObjectAction.GET, - savedObject: { type, id }, - }) - ) - ); + response.saved_objects.forEach(({ error, type, id }) => { + if (!error) { + this.auditLogger.log( + savedObjectEvent({ + action: SavedObjectAction.GET, + savedObject: { type, id }, + }) + ); + } + }); return await this.redactSavedObjectsNamespaces(response, [options.namespace]); } diff --git a/x-pack/plugins/security_solution/common/endpoint/policy/migrations/to_v7_11.0.test.ts b/x-pack/plugins/security_solution/common/endpoint/policy/migrations/to_v7_11.0.test.ts index b516f7c57a96d..1b70a13935b7d 100644 --- a/x-pack/plugins/security_solution/common/endpoint/policy/migrations/to_v7_11.0.test.ts +++ b/x-pack/plugins/security_solution/common/endpoint/policy/migrations/to_v7_11.0.test.ts @@ -12,6 +12,7 @@ describe('7.11.0 Endpoint Package Policy migration', () => { const migration = migratePackagePolicyToV7110; it('adds malware notification checkbox and optional message and adds AV registration config', () => { const doc: SavedObjectUnsanitizedDoc = { + id: 'mock-saved-object-id', attributes: { name: 'Some Policy Name', package: { @@ -100,11 +101,13 @@ describe('7.11.0 Endpoint Package Policy migration', () => { ], }, type: ' nested', + id: 'mock-saved-object-id', }); }); it('does not modify non-endpoint package policies', () => { const doc: SavedObjectUnsanitizedDoc = { + id: 'mock-saved-object-id', attributes: { name: 'Some Policy Name', package: { @@ -164,6 +167,7 @@ describe('7.11.0 Endpoint Package Policy migration', () => { ], }, type: ' nested', + id: 'mock-saved-object-id', }); }); });