ECS audit events for alerting (#84113) (#85093)

* ECS audit events for alerts plugin

* added api changes

* fixed linting and testing errors

* fix test

* Fixed linting errors after prettier update

* Revert "Allow predefined ids for encrypted saved objects (#83482)"

This reverts commit 7d929fe903.

* Added suggestions from code review

* Fixed unit tests

* Added suggestions from code review

* Changed names of alert events

* Changed naming as suggested in code review

* Added suggestions from PR

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Thom Heymann 2020-12-05 12:05:46 +00:00 committed by GitHub
parent 3519a5e1cd
commit 9ef4d22cf3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
48 changed files with 2360 additions and 554 deletions

View file

@ -9,7 +9,7 @@ Given a saved object type and id, generates the compound id that is stored in th
<b>Signature:</b>
```typescript
generateRawId(namespace: string | undefined, type: string, id?: string): string;
generateRawId(namespace: string | undefined, type: string, id: string): string;
```
## Parameters

View file

@ -0,0 +1,17 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-server](./kibana-plugin-core-server.md) &gt; [SavedObjectsUtils](./kibana-plugin-core-server.savedobjectsutils.md) &gt; [generateId](./kibana-plugin-core-server.savedobjectsutils.generateid.md)
## SavedObjectsUtils.generateId() method
Generates a random ID for a saved objects.
<b>Signature:</b>
```typescript
static generateId(): string;
```
<b>Returns:</b>
`string`

View file

@ -0,0 +1,24 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-server](./kibana-plugin-core-server.md) &gt; [SavedObjectsUtils](./kibana-plugin-core-server.savedobjectsutils.md) &gt; [isRandomId](./kibana-plugin-core-server.savedobjectsutils.israndomid.md)
## SavedObjectsUtils.isRandomId() method
Validates that a saved object ID matches UUID format.
<b>Signature:</b>
```typescript
static isRandomId(id: string | undefined): boolean;
```
## Parameters
| Parameter | Type | Description |
| --- | --- | --- |
| id | <code>string &#124; undefined</code> | |
<b>Returns:</b>
`boolean`

View file

@ -19,3 +19,10 @@ export declare class SavedObjectsUtils
| [namespaceIdToString](./kibana-plugin-core-server.savedobjectsutils.namespaceidtostring.md) | <code>static</code> | <code>(namespace?: string &#124; undefined) =&gt; string</code> | Converts a given saved object namespace ID to its string representation. All namespace IDs have an identical string representation, with the exception of the <code>undefined</code> namespace ID (which has a namespace string of <code>'default'</code>). |
| [namespaceStringToId](./kibana-plugin-core-server.savedobjectsutils.namespacestringtoid.md) | <code>static</code> | <code>(namespace: string) =&gt; string &#124; undefined</code> | Converts a given saved object namespace string to its ID representation. All namespace strings have an identical ID representation, with the exception of the <code>'default'</code> namespace string (which has a namespace ID of <code>undefined</code>). |
## Methods
| Method | Modifiers | Description |
| --- | --- | --- |
| [generateId()](./kibana-plugin-core-server.savedobjectsutils.generateid.md) | <code>static</code> | Generates a random ID for a saved objects. |
| [isRandomId(id)](./kibana-plugin-core-server.savedobjectsutils.israndomid.md) | <code>static</code> | Validates that a saved object ID matches UUID format. |

View file

@ -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

View file

@ -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');

View file

@ -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) {

View file

@ -50,7 +50,7 @@ export interface SavedObjectsRawDocSource {
*/
interface SavedObjectDoc<T = unknown> {
attributes: T;
id?: string; // NOTE: SavedObjectDoc is used for uncreated objects where `id` is optional
id: string;
type: string;
namespace?: string;
namespaces?: string[];

View file

@ -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();

View file

@ -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<SavedObject<T>> {
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',

View file

@ -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);
});
});
});

View file

@ -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);
}
}

View file

@ -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<T = unknown> extends Omit<SavedObjec
// @public (undocumented)
export class SavedObjectsUtils {
static createEmptyFindResponse: <T>({ page, perPage, }: SavedObjectsFindOptions) => SavedObjectsFindResponse<T>;
static generateId(): string;
static isRandomId(id: string | undefined): boolean;
static namespaceIdToString: (namespace?: string | undefined) => string;
static namespaceStringToId: (namespace: string) => string | undefined;
}

View file

@ -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<ActionsClient['update']> {
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<ActionsClient['update']> {
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',

View file

@ -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<ActionResult> {
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<ActionResult> {
await this.authorization.ensureAuthorized('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'
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<RawAction>(
'action',
{
@ -201,12 +252,30 @@ export class ActionsClient {
* Get an action
*/
public async get({ id }: { id: string }): Promise<ActionResult> {
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<RawAction>('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<FindActionResult[]> {
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<RawAction>({
@ -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<ActionResult[]> {
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<ActionResult>();
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<RawAction>(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');
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'
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);
}

View file

@ -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]",
}
`);
});
});

View file

@ -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<ConnectorAuditAction, VerbsTuple> = {
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<ConnectorAuditAction, EventType | undefined> = {
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<AuditEvent['kibana']>['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,
},
};
}

View file

@ -314,6 +314,7 @@ export class ActionsPlugin implements Plugin<Promise<PluginSetupContract>, Plugi
isESOUsingEphemeralEncryptionKey: isESOUsingEphemeralEncryptionKey!,
preconfiguredActions,
}),
auditLogger: this.security?.audit.asScoped(request),
});
};
@ -439,6 +440,7 @@ export class ActionsPlugin implements Plugin<Promise<PluginSetupContract>, Plugi
preconfiguredActions,
actionExecutor,
instantiateAuthorization,
security,
} = this;
return async function actionsRouteHandlerContext(context, request) {
@ -468,6 +470,7 @@ export class ActionsPlugin implements Plugin<Promise<PluginSetupContract>, Plugi
isESOUsingEphemeralEncryptionKey: isESOUsingEphemeralEncryptionKey!,
preconfiguredActions,
}),
auditLogger: security?.audit.asScoped(request),
});
},
listTypes: actionTypeRegistry!.list.bind(actionTypeRegistry!),

View file

@ -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<ActionsClient>;
getEventLogClient: () => Promise<IEventLogClient>;
kibanaVersion: PluginInitializerContext['env']['packageInfo']['version'];
auditLogger?: AuditLogger;
}
export interface MuteOptions extends IndexType {
@ -176,6 +180,7 @@ export class AlertsClient {
private readonly getEventLogClient: () => Promise<IEventLogClient>;
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<Alert> {
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<RawAlert>;
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<SanitizedAlert> {
const result = await this.unsecuredSavedObjectsClient.get<RawAlert>('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<FindResult> {
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<RawAlert>('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<RawAlert>(

View file

@ -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]",
}
`);
});
});

View file

@ -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<AlertAuditAction, VerbsTuple> = {
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<AlertAuditAction, EventType> = {
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<AuditEvent['kibana']>['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,
},
};
}

View file

@ -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<ConstructorOptions> = {
@ -40,10 +49,12 @@ const alertsClientParams: jest.Mocked<ConstructorOptions> = {
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',

View file

@ -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<ConstructorOptions> = {
@ -37,10 +40,12 @@ const alertsClientParams: jest.Mocked<ConstructorOptions> = {
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',
},
})
);
});
});
});

View file

@ -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<ConstructorOptions> = {
@ -39,10 +41,12 @@ const alertsClientParams: jest.Mocked<ConstructorOptions> = {
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',

View file

@ -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<ConstructorOptions> = {
@ -40,10 +42,12 @@ const alertsClientParams: jest.Mocked<ConstructorOptions> = {
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({

View file

@ -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<ConstructorOptions> = {
@ -45,6 +47,7 @@ const alertsClientParams: jest.Mocked<ConstructorOptions> = {
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',
},
})
);
});
});
});

View file

@ -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<ConstructorOptions> = {
@ -42,6 +44,7 @@ const alertsClientParams: jest.Mocked<ConstructorOptions> = {
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',
},
})
);
});
});
});

View file

@ -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<ConstructorOptions> = {
@ -41,6 +44,7 @@ const alertsClientParams: jest.Mocked<ConstructorOptions> = {
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',
},
})
);
});
});
});

View file

@ -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<ConstructorOptions> = {
@ -42,6 +44,7 @@ const alertsClientParams: jest.Mocked<ConstructorOptions> = {
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',
},
})
);
});
});
});

View file

@ -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<ConstructorOptions> = {
@ -42,6 +44,7 @@ const alertsClientParams: jest.Mocked<ConstructorOptions> = {
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',
},
})
);
});
});
});

View file

@ -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<ConstructorOptions> = {
@ -42,6 +44,7 @@ const alertsClientParams: jest.Mocked<ConstructorOptions> = {
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',
},
})
);
});
});
});

View file

@ -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<ConstructorOptions> = {
@ -44,10 +46,12 @@ const alertsClientParams: jest.Mocked<ConstructorOptions> = {
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',
},
})
);
});
});
});

View file

@ -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<ConstructorOptions> = {
@ -38,10 +41,12 @@ const alertsClientParams: jest.Mocked<ConstructorOptions> = {
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',
},
})
);
});
});
});

View file

@ -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;

View file

@ -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);
});

View file

@ -15,7 +15,6 @@ export class EncryptedSavedObjectAttributesDefinition {
public readonly attributesToEncrypt: ReadonlySet<string>;
private readonly attributesToExcludeFromAAD: ReadonlySet<string> | undefined;
private readonly attributesToStrip: ReadonlySet<string>;
public readonly allowPredefinedID: boolean;
constructor(typeRegistration: EncryptedSavedObjectTypeRegistration) {
const attributesToEncrypt = new Set<string>();
@ -35,7 +34,6 @@ export class EncryptedSavedObjectAttributesDefinition {
this.attributesToEncrypt = attributesToEncrypt;
this.attributesToStrip = attributesToStrip;
this.attributesToExcludeFromAAD = typeRegistration.attributesToExcludeFromAAD;
this.allowPredefinedID = !!typeRegistration.allowPredefinedID;
}
/**

View file

@ -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,

View file

@ -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' };

View file

@ -31,7 +31,6 @@ export interface EncryptedSavedObjectTypeRegistration {
readonly type: string;
readonly attributesToEncrypt: ReadonlySet<string | AttributeToEncrypt>;
readonly attributesToExcludeFromAAD?: ReadonlySet<string>;
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

View file

@ -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<SavedObjectsClientContract>;
@ -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' },
},
],

View file

@ -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();
}

View file

@ -156,6 +156,7 @@ Object {
"title": "mylens",
"visualizationType": "lnsXY",
},
"id": "mock-saved-object-id",
"references": Array [
Object {
"id": "ff959d40-b880-11e8-a6d9-e546fe2bba5f",

View file

@ -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: {

View file

@ -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<SavedObjectAction, VerbsTuple> = {
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<SavedObjectAction, EventType> = {
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<Required<AuditEvent>['kibana']>['saved_object'];
savedObject?: NonNullable<AuditEvent['kibana']>['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];

View file

@ -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';

View file

@ -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<typeof createSecureSavedObjectsClientWrapperOptions>;
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) });
});
});

View file

@ -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<SavedObjectsBulkCreateObject<T>>,
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<T>(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]);
}

View file

@ -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<PackagePolicy> = {
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<PackagePolicy> = {
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',
});
});
});